// Package app wires the selection watcher, analysis, renderer and popup
// together and decides when the card comes and goes.
package app
import (
"context"
"log"
"math"
"net/url"
"os"
"slices"
"strings"
"time"
"unicode/utf8"
"chirami/internal/analyze"
"chirami/internal/card"
"chirami/internal/dict"
"chirami/internal/jp"
"chirami/internal/platform"
)
type Config struct {
Online bool // allow jisho.org when the offline dictionary misses
Debounce time.Duration // quiet time after the selection settles
MaxRunes int // longer selections are ignored
Idle time.Duration // hide a card nobody touched, where the pointer is invisible
NoRomaji bool // leave romaji out, for once kana reads by itself
Zoom float64 // card size on 1x displays, where small text is too few pixels to read
}
var DefaultConfig = Config{Online: true, Debounce: 250 * time.Millisecond, MaxRunes: 120, Idle: 15 * time.Second, Zoom: 1.25}
// zoom is how much bigger to draw the card on a display at scale: only
// low-density displays get the configured zoom.
func (c Config) zoom(scale float64) float64 {
if scale < 1.5 && c.Zoom > 0 {
return c.Zoom
}
return 1
}
const (
tipDelay = 200 * time.Millisecond // so passing over things does not flicker
leaveGrace = 350 * time.Millisecond // so brushing past a glance card's edge does not close it
)
type Deps struct {
Analyzer *analyze.Analyzer
Dict *dict.Dict // nil without dict.bin
Renderer *card.Renderer
Speak func(string) error
Open func(string) error
Jisho func(ctx context.Context, word string) ([]dict.Entry, error)
OCR func(ctx context.Context, png []byte) (string, error) // nil: screenshots are ignored
}
type App struct {
cfg Config
Deps
OnHide func() // called whenever the card goes away
an *analyze.Analyzer
dict *dict.Dict
b platform.Backend
stack []page // the selection's view, then pages opened from it; empty when hidden
res *card.Result
anchor platform.Anchor
x, y float64 // image top-left
above bool // the card sits above the pointer and grows upwards
scale float64 // device px per logical px of the card on screen
// A glance card (not expanded) closes when the pointer leaves it, or,
// if the pointer never came, once it moves away (where the backend can
// tell) or after Config.Idle. An expanded card stays until its ×,
// Escape, or a click outside (Dismiss, where the backend sees one).
shownAt time.Time
entered bool
leaving *time.Timer
hover int // region under the pointer, -1 for none
tip bool // the hovered region's tip is showing
tipTimer *time.Timer
tick *time.Ticker
jisho chan jishoResult
jishoMemo map[string][]dict.Entry
ocr chan ocrResult
}
type ocrResult struct {
text string
err error
}
type jishoResult struct {
v *view
key string
entries []dict.Entry
err error
}
func New(cfg Config, d Deps) *App {
return &App{cfg: cfg, Deps: d, an: d.Analyzer, dict: d.Dict, hover: -1,
jisho: make(chan jishoResult, 4), jishoMemo: map[string][]dict.Entry{}, ocr: make(chan ocrResult, 1)}
}
func stopped(d time.Duration) *time.Timer {
t := time.NewTimer(d)
t.Stop()
return t
}
// Run handles selections until ctx ends. If initial is not empty it is shown
// right away, as if it had just been selected.
func (a *App) Run(ctx context.Context, b platform.Backend, initial string) error {
a.b = b
debounce := stopped(time.Hour)
a.leaving, a.tipTimer = stopped(time.Hour), stopped(time.Hour)
defer a.leaving.Stop()
defer a.tipTimer.Stop()
// the idle check only runs while a card is up, so an idle chirami sleeps
a.tick = time.NewTicker(150 * time.Millisecond)
a.tick.Stop()
defer a.tick.Stop()
selections := b.Selections()
var shots <-chan []byte
if s, ok := b.(platform.Screenshotter); ok && a.OCR != nil {
shots = s.Screenshots()
}
pending := initial
if initial != "" {
// showing one text: the live selection is not ours to follow
selections, shots = nil, nil
debounce.Reset(0)
}
for {
select {
case <-ctx.Done():
a.hide("done")
return ctx.Err()
case t := <-selections:
pending = t
debounce.Reset(a.cfg.Debounce)
case <-debounce.C:
a.selected(ctx, pending)
case <-a.leaving.C:
if !a.expanded() {
a.hide("left")
}
case png := <-shots:
go func() {
text, err := a.OCR(ctx, png)
select {
case a.ocr <- ocrResult{text, err}:
case <-ctx.Done():
}
}()
case r := <-a.ocr:
if r.err != nil {
log.Printf("ocr: %v", r.err)
break
}
debugf("ocr %q", r.text)
a.open(ctx, clip(r.text, a.cfg.MaxRunes))
case e := <-b.Events():
a.event(ctx, e)
case <-a.tipTimer.C:
a.tip = true
a.redraw()
case r := <-a.jisho:
a.jishoDone(r)
case <-a.tick.C:
a.idle()
}
}
}
func (a *App) shown() bool { return len(a.stack) > 0 }
func (a *App) top() page { return a.stack[len(a.stack)-1] }
func (a *App) selected(ctx context.Context, text string) {
debugf("selection %q", text)
a.open(ctx, text)
}
// open shows the card for text, selected or read from a screenshot.
func (a *App) open(ctx context.Context, text string) {
text = strings.TrimSpace(text)
if !jp.HasJapanese(text) || utf8.RuneCountInString(text) > a.cfg.MaxRunes {
wasShown := a.shown()
a.hide("not japanese")
if !wasShown && a.OnHide != nil {
a.OnHide() // nothing was shown, so nothing will ever hide
}
return
}
if a.shown() && a.stack[0].title() == text {
return
}
anchor, err := a.b.Locate()
if err != nil {
log.Printf("locate: %v", err)
return
}
v := a.newView(text)
a.stack = []page{v}
a.scale = anchor.Scale
a.hover, a.tip = -1, false
a.res = a.Renderer.Render(a.model(v), a.scale)
a.anchor = anchor
a.x, a.y, a.above = place(anchor, a.res, true)
a.entered, a.shownAt = false, time.Now()
a.leaving.Stop()
a.tick.Reset(150 * time.Millisecond)
a.show()
a.maybeJisho(ctx, v)
}
// clip shortens OCR text to at most n runes, at the end of a sentence when
// there is one: a whole dialogue box should not become a giant card.
func clip(text string, n int) string {
r := []rune(text)
if len(r) <= n {
return text
}
r = r[:n]
for i := len(r) - 1; i > n/3; i-- {
if strings.ContainsRune("。!?!?」", r[i]) {
return string(r[:i+1])
}
}
return string(r)
}
// place puts the card above the pointer, where it covers text already read
// rather than the line coming next, or below it when above is false or
// there is no room. The side nearest the pointer stays put as the card
// grows; when neither side has room the card is pushed back on screen,
// even over the pointer.
func place(an platform.Anchor, r *card.Result, above bool) (x, y float64, isAbove bool) {
const dx, dy, edge = 10, 14, 6
s, h := an.Screen, r.Hit
top, bottom := s.Y+edge, s.Y+s.H-edge
fitsAbove := an.Y-dy-h.H >= top
fitsBelow := an.Y+dy+6+h.H <= bottom
if above && !fitsAbove && fitsBelow || !above && !fitsBelow && fitsAbove {
above = !above
}
cy := an.Y + dy + 6 // the card's top edge
if above {
cy = an.Y - dy - h.H
}
cy = math.Max(top, math.Min(cy, bottom-h.H))
cx := an.X - 16
if cx+h.W > s.X+s.W-edge {
cx = an.X - dx - h.W
}
cx = math.Max(s.X+edge, math.Min(cx, s.X+s.W-edge-h.W))
return cx - h.X, cy - h.Y, above
}
func (a *App) show() {
err := a.b.Show(platform.Popup{Image: a.res.Image, X: a.x, Y: a.y, Hit: a.res.Hit})
if err != nil {
log.Printf("show: %v", err)
}
}
// redraw renders the current page again and places it on the same side
// of the pointer, so the edge nearest the pointer does not move.
func (a *App) redraw() {
if !a.shown() || a.res == nil {
return
}
a.res = a.Renderer.Render(a.model(a.top()), a.scale)
a.x, a.y, a.above = place(a.anchor, a.res, a.above)
a.show()
}
// expanded reports whether the card is past its glance: clicked open, or
// on a kanji page.
func (a *App) expanded() bool {
v, ok := a.top().(*view)
return !ok || v.expanded
}
func (a *App) hide(why string) {
if !a.shown() {
return
}
debugf("hide: %s", why)
a.stack, a.res = nil, nil
a.tick.Stop()
a.leaving.Stop()
a.tipTimer.Stop()
a.b.Hide()
if a.OnHide != nil {
a.OnHide()
}
}
// Render draws the card for text without a backend, for previews.
func (a *App) Render(text string, expanded bool, scale float64) *card.Result {
v := a.newView(text)
v.expanded = expanded
a.stack, a.scale = []page{v}, scale
defer func() { a.stack = nil }()
return a.Renderer.Render(a.model(v), scale)
}
func (a *App) regionAt(x, y float64) int {
if a.res == nil {
return -1
}
for i, r := range a.res.Regions {
if r.Rect.Contains(x, y) {
return i
}
}
return -1
}
func (a *App) event(ctx context.Context, e platform.Event) {
if !a.shown() {
return
}
switch e.Kind {
case platform.Motion:
a.entered = true
a.leaving.Stop()
if h := a.regionAt(e.X, e.Y); h != a.hover {
a.hover, a.tip = h, false
a.tipTimer.Stop()
if h >= 0 && a.res.Regions[h].Tip != "" {
a.tipTimer.Reset(tipDelay)
}
a.redraw()
}
case platform.Leave:
if !a.expanded() {
a.leaving.Reset(leaveGrace)
}
case platform.Dismiss:
a.hide("dismissed")
case platform.Click:
a.entered = true
a.leaving.Stop()
if i := a.regionAt(e.X, e.Y); i >= 0 {
a.click(ctx, a.res.Regions[i])
}
}
}
func (a *App) click(ctx context.Context, r card.Region) {
if r.Action == card.Close {
a.hide("closed")
return
}
switch p := a.top().(type) {
case *view:
a.clickView(ctx, p, r)
case *kanjiPage:
a.clickKanji(ctx, p, r)
}
}
func (a *App) clickView(ctx context.Context, v *view, r card.Region) {
switch r.Action {
case card.Expand:
v.expanded = true
a.redraw()
case card.SpeakAll:
a.say(v.text)
case card.SpeakEntry:
if r.Index < len(v.shown) {
e := v.shown[r.Index]
a.say(cmp(e.Reading, e.Head))
}
case card.FocusWord, card.FocusPhrase:
focus := r.Index
if r.Action == card.FocusPhrase {
focus = wholePhrase
}
if focus != v.focus {
v.focus = focus
a.lookup(v)
a.redraw()
a.maybeJisho(ctx, v)
}
case card.PickEntry:
if r.Index < len(v.entries) {
e := v.entries[r.Index]
v.entries = slices.Insert(slices.Delete(v.entries, r.Index, r.Index+1), 0, e)
a.redraw()
}
case card.OpenKanji:
if r.Index < len(v.kanji) {
a.push(a.newKanjiPage(v.kanji[r.Index]))
}
case card.OpenJisho:
q := v.text
if w, ok := v.focused(); ok {
q = cmp(w.Key, w.Surface)
}
a.openJisho(q)
}
}
func (a *App) clickKanji(ctx context.Context, k *kanjiPage, r card.Region) {
switch r.Action {
case card.Back:
a.stack = a.stack[:len(a.stack)-1]
a.hover, a.tip = -1, false
a.redraw()
case card.OpenPart:
var parts []rune
for _, p := range k.parts {
if p != k.r {
parts = append(parts, p)
}
}
if r.Index < len(parts) {
if _, ok := a.kanjiInfo(parts[r.Index]); ok {
a.push(a.newKanjiPage(parts[r.Index]))
}
}
case card.OpenSimilar:
if r.Index < len(k.similar) {
a.push(a.newKanjiPage(k.similar[r.Index]))
}
case card.OpenWord:
if r.Index < len(k.words) {
e := k.words[r.Index]
text := pickForm(e.Kanji)
for _, f := range e.Kanji {
if strings.ContainsRune(f.Text, k.r) && displayable(f) {
text = f.Text
break
}
}
v := a.newView(text)
v.expanded = true
a.push(v)
a.maybeJisho(ctx, v)
}
}
}
// push opens a page on top of the current one; its back link returns.
func (a *App) push(p page) {
a.stack = append(a.stack, p)
a.hover, a.tip = -1, false
a.tipTimer.Stop()
a.redraw()
}
func (a *App) openJisho(q string) {
if a.Open == nil {
return
}
if err := a.Open("https://jisho.org/search/" + url.PathEscape(q)); err != nil {
log.Printf("open: %v", err)
}
}
func (a *App) say(s string) {
if a.Speak == nil {
return
}
if err := a.Speak(s); err != nil {
log.Printf("speak: %v", err)
}
}
// idle hides a card the user walked away from: the pointer went far from it
// without entering, or (where the pointer cannot be seen) it sat untouched.
// idle closes a glance card the pointer never came to: once the pointer
// is well away from it where the backend can tell, else after Config.Idle.
func (a *App) idle() {
if !a.shown() || a.res == nil || a.entered || a.expanded() {
return
}
x, y, ok := a.b.Pointer()
if !ok {
if time.Since(a.shownAt) > a.cfg.Idle {
a.hide("idle")
}
return
}
h := a.res.Hit
cx := math.Max(a.x+h.X, math.Min(x, a.x+h.X+h.W))
cy := math.Max(a.y+h.Y, math.Min(y, a.y+h.Y+h.H))
if math.Hypot(x-cx, y-cy) > 180 {
a.hide("pointer went away")
}
}
func (a *App) maybeJisho(ctx context.Context, v *view) {
key, ok := a.wantsJisho(v)
if !ok || a.Jisho == nil {
return
}
if es, ok := a.jishoMemo[key]; ok {
a.applyJisho(v, es, nil)
a.redraw()
return
}
v.status = "( ˘ω˘ ) asking jisho…"
a.redraw()
go func() {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
es, err := a.Jisho(ctx, key)
a.jisho <- jishoResult{v, key, es, err}
}()
}
func (a *App) jishoDone(r jishoResult) {
if r.err == nil {
a.jishoMemo[r.key] = r.entries
}
if w, ok := r.v.focused(); !slices.Contains(a.stack, page(r.v)) || !ok || w.Key != r.key {
return // the card moved on
}
a.applyJisho(r.v, r.entries, r.err)
if a.top() == page(r.v) {
a.redraw()
}
}
func (a *App) applyJisho(v *view, es []dict.Entry, err error) {
switch {
case err != nil:
log.Printf("jisho: %v", err)
v.status = "(。•́︿•̀。) couldn't reach jisho"
case len(es) == 0:
v.status = "(・・?) jisho doesn't know it either"
default:
v.entries, v.source, v.status = es, "jisho", ""
}
}
var debug = os.Getenv("CHIRAMI_DEBUG") != ""
func debugf(format string, args ...any) {
if debug {
log.Printf(format, args...)
}
}
func cmp(s, fallback string) string {
if s != "" {
return s
}
return fallback
}