// Package card renders the popup: what you selected with furigana and
// romaji, and on request its parts, dictionary entries and kanji. It draws
// into a plain RGBA image so every backend shows the same pixels.
package card

import (
	"image"
	"image/color"
	"math"
	"strings"
	"unicode/utf8"

	"golang.org/x/image/math/fixed"

	"chirami/internal/jp"
	"chirami/internal/platform"
)

// Seg is a run of text with the reading shown above it; Ruby is "" over kana.
type Seg struct{ Text, Ruby string }

// Part is a piece of the focused word with its meaning written under it:
// 食べ "eat", させ "causative".
type Part struct {
	Segs   []Seg
	Romaji string
	Gloss  string
	Tip    string // the longer explanation shown in place on hover
}

// Word is one clickable unit of the selection. Its romaji is written under
// it, so each sound sits under the characters that make it.
type Word struct {
	Segs   []Seg
	Romaji string
	Lookup bool   // clicking it shows its entries
	Parts  []Part // when set, the word is drawn as its parts
}

type Entry struct {
	Head     string // kanji form, or the reading for kana words
	Reading  string // "" when Head is already the reading
	Romaji   string
	Downstep int // pitch accent downstep, -1 when unknown
	Common   bool
	JLPT     string
	DictForm bool // the selection is an inflection of this word
	Same     bool // the headword is the selection itself, already shown above
	Meanings []string
	Tip      string // part of speech and usage, shown on hover
}

// Chip is a small clickable box: a kanji, a component, a word.
type Chip struct{ Text, Label, Tip string }

type WordView struct {
	Words       []Word
	Focus       int  // index into Words the entries describe, -1 for none
	Phrase      bool // the dictionary knows the selection as a whole
	WholePhrase bool // the entries describe the selection as a whole
	Expanded    bool
	Entries     []Entry // the first is shown in full, the rest as one line each
	Kanji       []Chip
	Status      string // shown in place of entries, e.g. while jisho loads
}

type KanjiView struct {
	Back      string // what the back link returns to
	Char      string
	Meta      string // "9 strokes · grade 2 · N4"
	Meanings  string
	On, Kun   string
	Parts     []Chip // what it is made of
	Words     []Chip // words that use it
	Similar   []Chip // other kanji sharing SimilarOf
	SimilarOf string
}

// Model is one card: a word view or a kanji page.
type Model struct {
	Word  *WordView
	Kanji *KanjiView
	Hover int     // region index under the pointer, -1 for none
	Tip   bool    // show the hovered region's tip
	Room  float64 // screen space above the card a tip may use, logical px
	Zoom  float64 // draws the whole card this much bigger; 0 means 1
}

type Action int

const (
	None        Action = iota // only a tip
	Expand                    // the collapsed card: show everything
	SpeakAll                  // the speaker: say the selection
	SpeakEntry                // an entry's reading; Index is the entry
	FocusWord                 // a word of the selection
	FocusPhrase               // back to the selection as a whole
	PickEntry                 // a secondary entry: make it the main one
	OpenJisho                 // the jisho link
	OpenKanji                 // a kanji chip of the word view
	Back                      // the kanji page's back link
	Close                     // the × of an expanded card
	OpenPart                  // a "made of" chip
	OpenWord                  // an "in words" chip
	OpenSimilar               // an "also in" chip
)

// Region is a clickable area in logical px relative to the image origin.
type Region struct {
	Rect   platform.Rect
	Action Action
	Index  int
	Tip    string
	chip   bool    // paint a hover chip behind it
	sound  int     // the romaji span it lights up, -1 for none
	below  float64 // a tip placed below must start under this y
}

type Result struct {
	Image   *image.RGBA
	Hit     platform.Rect // the card without its shadow
	Regions []Region
}

// Renderer draws models; it caches glyphs, so reuse one.
type Renderer struct {
	Fonts *Fonts
	Theme Theme
}

const (
	margin = 18.0 // room for the shadow around the card
	pad    = 13.0
	radius = 14.0

	minWidth  = 150.0
	maxWidth  = 360.0
	fullWidth = 316.0 // expanded cards and kanji pages

	body   = 12.5 // meanings and labels you read
	small  = 10.0 // everything secondary
	romaji = 12.0 // the romaji line
	spkW   = 28.0 // the speaker at the end of the romaji line
)

// op is one deferred drawing step in device px.
type op func(dst *image.RGBA)

type layout struct {
	r       *Renderer
	th      *Theme
	s       float64 // device px per card unit: the display scale times the zoom
	z       float64 // screen logical px per card unit
	m       *Model
	w       float64 // card width
	ops     []op
	regions []Region
}

func (l *layout) face(size float64) *face     { return l.r.Fonts.face(size*l.s, l.s/l.z < 1.5) }
func (l *layout) dev(v fixed.Int26_6) float64 { return float64(v) / 64 / l.s }
func (l *layout) ascent(size float64) float64 { return l.dev(l.face(size).ascent) }
func (l *layout) width(s string, size float64) float64 {
	return l.dev(l.face(size).width(s))
}

// text queues s with its top-left at (x, y) and returns its width.
func (l *layout) text(x, y float64, s string, size float64, c color.NRGBA) float64 {
	f := l.face(size)
	src := image.NewUniform(c)
	bx, by := fixed.Int26_6(x*l.s*64), fixed.Int26_6(y*l.s*64)+f.ascent
	l.ops = append(l.ops, func(dst *image.RGBA) { f.draw(dst, bx, by, s, src) })
	return l.width(s, size)
}

func (l *layout) rect(x, y, w, h, r float64, c color.NRGBA) {
	s := l.s
	l.ops = append(l.ops, func(dst *image.RGBA) {
		fillRRect(dst, rrect{x * s, y * s, w * s, h * s, r * s}, c)
	})
}

func (l *layout) outline(x, y, w, h, r float64, c color.NRGBA) {
	s := l.s
	l.ops = append(l.ops, func(dst *image.RGBA) {
		strokeRRect(dst, rrect{x * s, y * s, w * s, h * s, r * s}, math.Max(1, s), c)
	})
}

// region registers a clickable area. Its hover chip is queued now, so call
// it before queueing the text it sits behind.
func (l *layout) region(x, y, w, h float64, a Action, idx int, tip string, chip bool) {
	if chip && l.m.Hover == len(l.regions) {
		l.rect(x, y, w, h, 7, l.th.Hover)
	}
	l.regions = append(l.regions, Region{Rect: platform.Rect{X: x, Y: y, W: w, H: h}, Action: a, Index: idx, Tip: tip, chip: chip, sound: -1})
}

func (r *Renderer) Render(m *Model, scale float64) *Result {
	z := m.Zoom
	if z <= 0 {
		z = 1
	}
	l := &layout{r: r, th: &r.Theme, s: scale * z, z: z, m: m}
	x0, y := margin+pad, margin+pad
	switch {
	case m.Kanji != nil:
		l.w = fullWidth
		y = l.kanjiPage(x0, y, l.w-2*pad)
	case m.Word != nil:
		l.w = l.wordWidth(m.Word)
		y = l.wordView(x0, y, l.w-2*pad)
	}
	res := l.paint(l.w, y-margin+pad-2)
	// the layout is in card units; callers get screen logical px
	zoom := func(r *platform.Rect) { r.X, r.Y, r.W, r.H = r.X*z, r.Y*z, r.W*z, r.H*z }
	zoom(&res.Hit)
	for i := range res.Regions {
		zoom(&res.Regions[i].Rect)
	}
	return res
}

func (l *layout) paint(w, h float64) *Result {
	s := l.s
	hit := platform.Rect{X: margin, Y: margin, W: w, H: h}
	main := l.ops
	l.ops = nil
	// the tip may sit above the card, past the top of the image: grow the
	// image upwards by as much, leaving the card where it is
	grow := 0.0
	if l.m.Tip && l.m.Hover >= 0 && l.m.Hover < len(l.regions) {
		if top := l.tip(l.regions[l.m.Hover], hit); top < 2 {
			grow = math.Ceil(2 - top)
		}
	}
	top := int(math.Ceil(grow * s))
	img := image.NewRGBA(image.Rect(0, -top, int(math.Ceil((w+2*margin)*s)), int(math.Ceil((h+2*margin)*s))))
	card := rrect{margin * s, margin * s, w * s, h * s, radius * s}
	shadow(img, rrect{card.x, card.y + 4*s, card.w, card.h, card.r}, card, 16*s, l.th.Shadow)
	fillRRect(img, card, l.th.BG)
	strokeRRect(img, card, math.Max(1, s), l.th.Border)
	for _, o := range append(main, l.ops...) {
		o(img)
	}
	// rebase so the image starts at the origin, as backends expect
	img.Rect = img.Rect.Sub(image.Pt(0, -top))
	dy := float64(top) / s
	hit.Y += dy
	for i := range l.regions {
		l.regions[i].Rect.Y += dy
	}
	return &Result{Image: img, Hit: hit, Regions: l.regions}
}

// tip queues a region's explanation touching the region and returns its
// top edge. Parts and words take it above them, where it hides neither the
// romaji line nor what follows, even past the card's top edge as long as
// the screen has room (Model.Room); anything else, or when there is no
// room above, takes it below (below the romaji line for parts). It
// overlaps whatever is there and moves nothing.
func (l *layout) tip(r Region, card platform.Rect) float64 {
	if r.Tip == "" {
		return card.Y
	}
	maxW := math.Min(240, card.W-2*pad)
	// numbered paragraphs ("1. Indicates…") wrap with a hanging indent
	type line struct {
		text   string
		indent float64
	}
	var lines []line
	tw := 0.0
	for _, para := range strings.Split(r.Tip, "\n") {
		ind := 0.0
		if n := strings.Index(para, ". "); n > 0 && n <= 2 && para[0] >= '0' && para[0] <= '9' {
			ind = l.width(para[:n+2], small)
		}
		for i, ln := range l.wrap(para, small, maxW-14-ind) {
			li := line{ln, 0}
			if i > 0 {
				li.indent = ind
			}
			lines = append(lines, li)
			tw = math.Max(tw, li.indent+l.width(ln, small))
		}
	}
	w, h := tw+14, float64(len(lines))*14+8
	x := r.Rect.X + r.Rect.W/2 - w/2
	x = math.Max(card.X+6, math.Min(x, card.X+card.W-6-w))
	above := r.Rect.Y - 2 - h
	below := math.Max(r.Rect.Y+r.Rect.H, r.below) + 2
	var y float64
	switch {
	case r.sound >= 0 && above >= card.Y-l.m.Room/l.z:
		y = above
	case below+h <= card.Y+card.H-4 || above < card.Y-l.m.Room/l.z:
		y = below
	default:
		y = above
	}
	l.rect(x, y, w, h, 7, l.th.TipBG)
	border := l.th.Accent
	border.A = 0x55
	l.outline(x, y, w, h, 7, border)
	for i, ln := range lines {
		c := l.th.Text
		if i == 0 && strings.Contains(r.Tip, "\n") {
			c = l.th.Accent // the first line names what the rest explains
		}
		l.text(x+7+ln.indent, y+4+float64(i)*14, ln.text, small, c)
	}
	return y
}

// wordWidth hugs a collapsed card around its text; expanded cards use a
// fixed width so their layout does not depend on the content.
func (l *layout) wordWidth(v *WordView) float64 {
	hw := math.Max(l.headlineWidth(v), l.romajiWidth(v)+spkW)
	if v.Expanded {
		return math.Max(fullWidth, math.Min(maxWidth, hw+2*pad))
	}
	return math.Max(minWidth, math.Min(maxWidth, hw+2*pad))
}

func (l *layout) headlineWidth(v *WordView) float64 {
	size := headSize(v.Words)
	w := 0.0
	for _, p := range l.pieces(v, size, math.Inf(1)) {
		w += p.w + p.gap
	}
	return w
}

func (l *layout) romajiWidth(v *WordView) float64 {
	w := 0.0
	for _, s := range sounds(v) {
		if s.text != "" {
			w += l.width(s.text, romaji) + l.width(" ", romaji)
		}
	}
	return w
}

func headSize(words []Word) float64 {
	n := 0
	for _, w := range words {
		for _, s := range w.Segs {
			n += utf8.RuneCountInString(s.Text)
		}
	}
	switch {
	case n <= 6:
		return 26
	case n <= 14:
		return 21
	default:
		return 17
	}
}

func rubySize(size float64) float64 { return math.Max(9.5, size*0.45) }

// piece is the unit the headline wraps by: a word, a run of a long word,
// or a part of the focused word with its gloss.
type piece struct {
	word, part int // part is -1 for a whole word or a run of one
	segs       []Seg
	sound      int      // index of its span in the romaji line, -1 for none
	gloss      []string // lines; a long gloss wraps instead of widening its part
	w, gap     float64
}

// glossMin is how wide a part may grow to fit its gloss on one line before
// the gloss wraps.
const glossMin = 56.0

// sound is one span of the romaji line: a word's romaji, or a part's.
type sound struct {
	text  string
	space bool // a space before it: it starts a new word
}

// sounds splits the romaji line into spans matching the headline, so
// pointing at a word or part can light up its romaji.
func sounds(v *WordView) []sound {
	var ss []sound
	for _, wd := range v.Words {
		if len(wd.Parts) > 0 {
			for i, pt := range wd.Parts {
				ss = append(ss, sound{pt.Romaji, i == 0})
			}
			continue
		}
		ss = append(ss, sound{wd.Romaji, true})
	}
	return ss
}

func (l *layout) pieces(v *WordView, size, lineW float64) []piece {
	rs := rubySize(size)
	var ps []piece
	n := 0 // next sound
	for wi, wd := range v.Words {
		if len(wd.Parts) > 0 {
			for pi, pt := range wd.Parts {
				w := l.segsWidth(pt.Segs, size, rs)
				lines := l.wrap(pt.Gloss, small, math.Max(w, glossMin))
				for _, ln := range lines {
					w = math.Max(w, l.width(ln, small))
				}
				ps = append(ps, piece{wi, pi, pt.Segs, n, lines, w, 6})
				n++
			}
			ps[len(ps)-1].gap = 7
			continue
		}
		snd := n
		n++
		if wd.Romaji == "" {
			snd = -1
			if !wd.Lookup && len(ps) > 0 {
				ps[len(ps)-1].gap = 1 // punctuation hugs the word before it
			}
		}
		segs := l.groupRuby(wd.Segs, size, rs)
		if w := l.segsWidth(segs, size, rs); w <= lineW {
			ps = append(ps, piece{wi, -1, segs, snd, nil, w, 7})
			continue
		}
		// too long for a line: break it by segments, and a segment too long
		// by itself anywhere, keeping the reading over its first run
		for _, sg := range segs {
			ruby := l.ellipsize(sg.Ruby, rs, lineW)
			for t := sg.Text; t != ""; ruby = "" {
				k := l.fit(t, size, lineW)
				pw := math.Max(l.width(t[:k], size), l.width(ruby, rs))
				ps = append(ps, piece{wi, -1, []Seg{{t[:k], ruby}}, snd, nil, pw, 0})
				t = t[k:]
			}
		}
		ps[len(ps)-1].gap = 7
	}
	return ps
}

func (l *layout) segsWidth(segs []Seg, size, rs float64) float64 {
	w := 0.0
	for _, s := range segs {
		w += math.Max(l.width(s.Text, size), l.width(s.Ruby, rs))
	}
	return w
}

// groupRuby joins neighbouring kanji whose readings are wider than they
// are, so 東(とう)京(きょう) reads as one 東京(とうきょう) instead of
// spreading the kanji apart.
func (l *layout) groupRuby(segs []Seg, size, rs float64) []Seg {
	wide := func(s Seg) bool { return s.Ruby != "" && l.width(s.Ruby, rs) > l.width(s.Text, size) }
	var out []Seg
	for i, sg := range segs {
		if n := len(out); n > 0 && sg.Ruby != "" && out[n-1].Ruby != "" && (wide(sg) || wide(segs[i-1])) {
			out[n-1].Text += sg.Text
			out[n-1].Ruby += sg.Ruby
			continue
		}
		out = append(out, sg)
	}
	return out
}

func (l *layout) wordView(x0, y, inner float64) float64 {
	v := l.m.Word
	if !v.Expanded {
		// the whole card expands; the speaker, registered first, still speaks
		defer func(y0 float64) {
			l.regions = append(l.regions, Region{Rect: platform.Rect{X: margin, Y: margin, W: l.w, H: 1e4}, Action: Expand, sound: -1})
		}(y)
	}
	first := len(l.regions)
	if v.Expanded {
		l.closeButton()
	}
	y = l.headline(v, x0, y, inner)
	y = l.romajiLine(v, x0, y+1, inner)
	for i := first; i < len(l.regions); i++ {
		l.regions[i].below = y // a tip under a word goes past its romaji
	}
	if !v.Expanded {
		l.dotsHint(x0+inner/2, y+3)
		return y + 8
	}
	l.rect(x0, y+7, inner, 1, 0.5, l.th.Faint)
	y += 15
	switch {
	case len(v.Entries) > 0:
		y = l.entries(v, x0, y, inner)
	case v.Status != "":
		l.text(x0, y, v.Status, body, l.th.Muted)
		y += 20
	}
	return l.kanjiChips(v, x0, y, inner)
}

// headline lays out the selection with furigana; the focused word is
// drawn as its parts with their meanings underneath. Pointing at a word or
// part lights up its romaji in the line below.
func (l *layout) headline(v *WordView, x0, y, inner float64) float64 {
	size := headSize(v.Words)
	rs := rubySize(size)
	rows := rowHeights{ruby: rs * 1.3, text: size * 1.3, gloss: small * 1.5}

	var lines [][]piece
	var cur []piece
	cw := 0.0
	for _, p := range l.pieces(v, size, inner) {
		if cw+p.w > inner && len(cur) > 0 {
			lines, cur, cw = append(lines, cur), nil, 0
		}
		cur, cw = append(cur, p), cw+p.w+p.gap
	}
	if len(cur) > 0 {
		lines = append(lines, cur)
	}

	multi := len(v.Words) > 1
	for _, ln := range lines {
		x := x0
		glossLines := 0
		lw := -ln[len(ln)-1].gap
		for _, p := range ln {
			glossLines = max(glossLines, len(p.gloss))
			lw += p.w + p.gap
		}
		if v.Expanded && v.WholePhrase {
			// the entries describe the whole selection: one chip under all of it
			l.rect(x0-4, y-1, lw+8, rows.ruby+rows.text+2, 8, l.th.Chip)
		}
		for i := 0; i < len(ln); {
			j, ww := i, 0.0
			for j < len(ln) && ln[j].word == ln[i].word {
				ww += ln[j].w + ln[j].gap
				j++
			}
			ww -= ln[j-1].gap
			wd := v.Words[ln[i].word]
			// chips cover a word's furigana and text, never its glosses
			h := rows.ruby + rows.text
			switch {
			case !v.Expanded:
				// a collapsed card expands wherever it is clicked
				l.region(x, y, ww, h, Expand, 0, "", false)
				l.regions[len(l.regions)-1].sound = ln[i].sound
			case multi && wd.Lookup:
				// the focused word's chip spans all its parts, so they
				// read as one word of the sentence
				if ln[i].word == v.Focus {
					l.rect(x-4, y-1, ww+8, h+2, 8, l.th.Chip)
				}
				if len(wd.Parts) == 0 {
					l.region(x-4, y-1, ww+8, h+2, FocusWord, ln[i].word, "", true)
					l.regions[len(l.regions)-1].sound = ln[i].sound
				}
			}
			for _, p := range ln[i:j] {
				if p.part >= 0 {
					l.part(p, wd.Parts[p.part], x, y, size, rs, rows)
				} else {
					l.segs(p.segs, x, y, p.w, size, rs, rows.ruby)
				}
				x += p.w + p.gap
			}
			i = j
		}
		y += rows.ruby + rows.text + float64(glossLines)*rows.gloss
		if glossLines > 0 {
			y += 4
		}
	}
	return y
}

type rowHeights struct{ ruby, text, gloss float64 }

// romajiLine is the "how it sounds" row: the romaji, muted, with the span
// of whatever the pointer is on in the accent, and the speaker at its end.
func (l *layout) romajiLine(v *WordView, x0, y, inner float64) float64 {
	l.speaker(x0+inner-20, y-1)
	lit := -1
	if l.m.Hover >= 0 && l.m.Hover < len(l.regions) {
		lit = l.regions[l.m.Hover].sound
	}
	var spans []span
	for i, s := range sounds(v) {
		if s.text == "" {
			continue
		}
		if s.space && len(spans) > 0 && !strings.ContainsAny(s.text[:1], ".,!?)]:;…”") {
			spans = append(spans, span{text: " ", size: romaji, c: l.th.Muted})
		}
		c := l.th.Muted
		if i == lit {
			c = l.th.Accent
		}
		spans = append(spans, span{text: s.text, size: romaji, c: c})
	}
	if len(spans) == 0 {
		return y + 20
	}
	return math.Max(l.flow(spans, x0, y+2, inner-spkW), y+20)
}

func (l *layout) speaker(x, y float64) {
	l.region(x-2, y-2, 24, 24, SpeakAll, 0, "", true)
	l.rect(x, y, 20, 20, 10, l.th.Chip)
	s, ic := l.s, l.th.Accent
	l.ops = append(l.ops, func(dst *image.RGBA) { speaker(dst, (x+10)*s, (y+10)*s, 7*s, ic) })
}

// segs draws text with ruby centred over each segment within width w.
func (l *layout) segs(segs []Seg, x, y, w, size, rs, rubyH float64) {
	sw := l.segsWidth(segs, size, rs)
	x += (w - sw) / 2
	for _, s := range segs {
		cw := math.Max(l.width(s.Text, size), l.width(s.Ruby, rs))
		if s.Ruby != "" {
			l.text(x+(cw-l.width(s.Ruby, rs))/2, y, s.Ruby, rs, l.th.Accent)
		}
		l.text(x+(cw-l.width(s.Text, size))/2, y+rubyH, s.Text, size, l.th.Text)
		x += cw
	}
}

// part draws one piece of the focused word: underlined, its meaning below.
func (l *layout) part(p piece, pt Part, x, y, size, rs float64, rows rowHeights) {
	under := l.th.Accent
	under.A = 0x70
	tw := l.segsWidth(pt.Segs, size, rs)
	top := y + rows.ruby
	if l.m.Hover == len(l.regions) {
		// like the word chips: furigana and text, not the gloss below
		l.rect(x-3, y-1, p.w+6, rows.ruby+rows.text+2, 7, l.th.Hover)
	}
	l.region(x-3, y-1, p.w+6, rows.ruby+rows.text+float64(len(p.gloss))*rows.gloss, None, 0, pt.Tip, false)
	l.regions[len(l.regions)-1].sound = p.sound
	l.segs(pt.Segs, x, y, p.w, size, rs, rows.ruby)
	l.rect(x+(p.w-tw)/2, top+rows.text-1, tw, 1.3, 0.6, under)
	for i, ln := range p.gloss {
		gw := l.width(ln, small)
		l.text(x+(p.w-gw)/2, top+rows.text+2+float64(i)*rows.gloss, ln, small, l.th.Text)
	}
}

// dotsHint is the collapsed card's only sign that there is more.
func (l *layout) dotsHint(cx, y float64) {
	s, c := l.s, l.th.Muted
	c.A = 0x90
	l.ops = append(l.ops, func(dst *image.RGBA) {
		for i := -1; i <= 1; i++ {
			fillCircle(dst, (cx+float64(i)*5)*s, y*s, 1.1*s, c)
		}
	})
}

func (l *layout) entries(v *WordView, x0, y, inner float64) float64 {
	for i := range v.Entries {
		e := &v.Entries[i]
		if i == 0 {
			y = l.mainEntry(e, x0, y, inner)
			continue
		}
		y = l.otherEntry(i, e, x0, y+2, inner)
	}
	return y
}

// mainEntry: the dictionary form with reading, pitch, romaji and markers,
// then up to three meanings flowing as one block. What the card already
// shows at the top is not repeated: an entry for the selection itself keeps
// only its reading, and only when that carries a pitch accent. The header
// never runs past the markers: the reading moves to its own row, and the
// romaji and "dictionary form" are left out when there is no room.
func (l *layout) mainEntry(e *Entry, x0, y, inner float64) float64 {
	const hs = 15.0
	right := x0 + inner - l.markersWidth(e) - 8
	top := y + 3 // room for the pitch line
	x := x0
	if !e.Same {
		if e.Reading == "" {
			l.region(x0-3, top-5, l.width(e.Head, hs)+6, hs+9, SpeakEntry, 0, "", true)
			l.pitch(x0, top-3, e.Head, hs, e.Downstep)
		}
		x += l.text(x, top, l.ellipsize(e.Head, hs, right-x0), hs, l.th.Text) + 6
	}
	pron := e.Reading
	if e.Same && pron == "" {
		pron = e.Head
	}
	if pron != "" && (!e.Same || e.Downstep >= 0) {
		rw := l.width(pron, body)
		if x > x0 && x+rw > right {
			top, x = top+hs*1.35, x0 // no room after the headword
		}
		ry := top + l.ascent(hs) - l.ascent(body)
		pron = l.ellipsize(pron, body, right-x)
		rw = l.width(pron, body)
		l.region(x-3, ry-5, rw+6, body+9, SpeakEntry, 0, "", true)
		l.text(x, ry, pron, body, l.th.Accent)
		l.pitch(x, ry-3, pron, body, e.Downstep)
		x += rw + 6
	}
	base := top + l.ascent(hs)
	if e.Romaji != "" && !e.Same && x+l.width(e.Romaji, small) <= right {
		x += l.text(x, base-l.ascent(small), e.Romaji, small, l.th.Muted) + 6
	}
	if e.DictForm && x+l.width("dictionary form", small) <= right {
		l.text(x, base-l.ascent(small), "dictionary form", small, l.quiet())
	}
	header := x > x0
	l.markers(e, x0+inner, y+3+l.ascent(hs)-l.ascent(small))
	if e.Tip != "" && header {
		l.region(x0-3, top-5, x-x0, hs+9, None, 0, e.Tip, false)
	}
	width := inner
	if header {
		y = top + hs*1.35
	} else {
		width = right - x0 // the meanings share their first row with the markers
	}

	var spans []span
	for i, m := range e.Meanings {
		if i > 0 {
			spans = append(spans, span{text: " · ", size: body, c: l.quiet(), glue: true})
		}
		if len(e.Meanings) > 1 {
			spans = append(spans, span{text: string(rune('1'+i)) + " ", size: small, c: l.th.Accent, sticky: true})
		}
		spans = append(spans, span{text: m, size: body, c: l.th.Text})
	}
	return l.flow(spans, x0, y, width) + 4
}

// markersWidth is how much room markers takes at the right of a header.
func (l *layout) markersWidth(e *Entry) float64 {
	w := 0.0
	if e.JLPT != "" {
		w += l.width(e.JLPT, 9) + 8 + 5
	}
	if e.Common {
		w += 7
	}
	return w
}

// otherEntry is a homonym or alternative: one line, click to make it main.
func (l *layout) otherEntry(i int, e *Entry, x0, y, inner float64) float64 {
	h := body * 1.6
	l.region(x0-4, y-1, inner+8, h, PickEntry, i, e.Tip, true)
	top := y + 3
	x := x0 + l.text(x0, top, e.Head, body, l.th.Text) + 6
	if e.Reading != "" {
		x += l.text(x, top, e.Reading, body, l.th.Accent) + 6
		l.pitch(x-6-l.width(e.Reading, body), top-3, e.Reading, body, e.Downstep)
	} else {
		l.pitch(x0, top-3, e.Head, body, e.Downstep)
	}
	if len(e.Meanings) > 0 {
		l.text(x, top, l.ellipsize(e.Meanings[0], body, x0+inner-x), body, l.th.Muted)
	}
	return y + h
}

// markers draws "common" as a dot and the JLPT level as a tiny outlined tag,
// right-aligned to x; it returns where they start. Hovering names them.
func (l *layout) markers(e *Entry, x, y float64) float64 {
	end := x
	var tip []string
	if e.Common {
		tip = append(tip, "common word")
	}
	if e.JLPT != "" {
		w := l.width(e.JLPT, 9) + 8
		x -= w
		c := l.th.Accent
		c.A = 0x90
		l.outline(x, y, w, 13, 6.5, c)
		l.text(x+4, y+1.5, e.JLPT, 9, l.th.Accent)
		tip = append(tip, "JLPT "+e.JLPT)
		x -= 5
	}
	if e.Common {
		x -= 7
		s, c := l.s, l.th.Accent
		cx, cy := x+3.5, y+6.5
		l.ops = append(l.ops, func(dst *image.RGBA) { fillCircle(dst, cx*s, cy*s, 3.2*s, c) })
	}
	if len(tip) > 0 {
		l.region(x-3, y-3, end-x+6, 19, None, 0, strings.Join(tip, " · "), false)
	}
	return x
}

// pitch draws the Tokyo accent as a line over the high morae of reading
// (whose glyphs start at x) with a hook where the pitch drops.
func (l *layout) pitch(x, y float64, reading string, size float64, downstep int) {
	if downstep < 0 {
		return
	}
	moras := jp.Moras(reading)
	n := len(moras)
	high := func(i int) bool {
		switch {
		case downstep == 0:
			return i > 0 || n == 1
		case downstep == 1:
			return i == 0
		default:
			return i > 0 && i < downstep
		}
	}
	c := l.th.Accent
	c.A = 0xc0
	for i, mx := 0, x; i < n; i++ {
		w := l.width(moras[i], size)
		if high(i) {
			l.rect(mx, y, w, 1.2, 0.6, c)
			if i+1 == downstep {
				l.rect(mx+w-1.2, y, 1.2, 4, 0.6, c)
			}
		}
		mx += w
	}
}

// kanjiChips is the row of the word's kanji, each a door to its page, and
// the jisho link at the end.
func (l *layout) kanjiChips(v *WordView, x0, y, inner float64) float64 {
	const sz = 24.0
	x := x0
	for i, k := range v.Kanji {
		l.region(x, y, sz, sz, OpenKanji, i, k.Tip, false)
		bg := l.th.Chip
		if l.m.Hover == len(l.regions)-1 {
			bg = l.th.Hover
		}
		l.rect(x, y, sz, sz, 7, bg)
		cw := l.width(k.Text, 15)
		l.text(x+(sz-cw)/2, y+(sz-15*1.25)/2+1, k.Text, 15, l.th.Text)
		x += sz + 5
	}
	t := "jisho ↗"
	tw := l.width(t, small)
	lx := x0 + inner - tw
	l.region(lx-4, y+2, tw+8, 20, OpenJisho, 0, "", true)
	l.text(lx, y+6, t, small, l.th.Muted)
	if v.Phrase && !v.WholePhrase {
		// the way back to the selection's own entry after picking a word
		p := "whole phrase"
		pw := l.width(p, small)
		px := lx - 14 - pw
		l.region(px-4, y+2, pw+8, 20, FocusPhrase, 0, "", true)
		l.text(px, y+6, p, small, l.th.Muted)
	}
	return y + sz + 2
}

// span is a run of styled text for flow. A sticky span never ends a line
// (a number stays with its meaning), a glue span never starts one (a
// separator stays with what it follows).
type span struct {
	text   string
	size   float64
	c      color.NRGBA
	sticky bool
	glue   bool
}

// flow lays spans out as wrapped running text and returns the y below it.
func (l *layout) flow(spans []span, x0, y, w float64) float64 {
	x, lineH := x0, body*1.45
	for i, sp := range spans {
		for _, word := range splitWords(sp.text) {
			ww := l.width(word, sp.size)
			need := ww
			if sp.sticky && i+1 < len(spans) { // keep a number with its meaning
				if nw := splitWords(spans[i+1].text); len(nw) > 0 {
					need += l.width(nw[0], spans[i+1].size)
				}
			}
			if x+need > x0+w && x > x0 && !sp.glue {
				x, y = x0, y+lineH
				word = strings.TrimLeft(word, " ")
				ww = l.width(word, sp.size)
			}
			for ww > w { // a single word wider than the line
				n := l.fit(word, sp.size, w-(x-x0))
				l.text(x, y+(body-sp.size)*0.8, word[:n], sp.size, sp.c)
				word, x, y = word[n:], x0, y+lineH
				ww = l.width(word, sp.size)
			}
			l.text(x, y+(body-sp.size)*0.8, word, sp.size, sp.c)
			x += ww
		}
	}
	return y + lineH
}

// splitWords splits s after spaces and between wide characters, keeping
// the spaces, so flow can break anywhere a line may break.
func splitWords(s string) []string {
	var out []string
	start := 0
	prev := rune(0)
	for i, r := range s {
		if i > start && (prev == ' ' || (breakable(prev, r) && r != ' ')) {
			out = append(out, s[start:i])
			start = i
		}
		prev = r
	}
	if start < len(s) {
		out = append(out, s[start:])
	}
	return out
}

// closeButton puts the × tucked into the card's top-right corner, inside
// the padding, so nothing has to make room for it: an expanded card stays
// until it is closed.
func (l *layout) closeButton() {
	const size, inset = 18.0, 2.0 // the hover chip, from the corner
	x, y := margin+l.w-inset-size, margin+inset
	l.region(x, y, size, size, Close, 0, "", true)
	s, c := l.s, l.th.Muted
	l.ops = append(l.ops, func(dst *image.RGBA) {
		cross(dst, (x+size/2)*s, (y+size/2)*s, 3.2*s, math.Max(1.2, 1.4*s), c)
	})
}

func (l *layout) kanjiPage(x0, y, inner float64) float64 {
	k := l.m.Kanji
	l.closeButton()
	back := "‹ " + k.Back
	bw := l.width(back, small)
	l.region(x0-4, y-3, math.Min(bw, inner-20)+8, 18, Back, 0, "", true)
	l.text(x0, y, l.ellipsize(back, small, inner-20), small, l.th.Muted)
	y += 20

	const box = 60.0
	l.rect(x0, y, box, box, 12, l.th.Chip)
	gw := l.width(k.Char, 42)
	l.text(x0+(box-gw)/2, y+(box-42*1.25)/2+2, k.Char, 42, l.th.Text)
	tx, tw := x0+box+12, inner-box-12
	ty := y + 1
	ty = l.flow([]span{{text: k.Meanings, size: body + 1.5, c: l.th.Text}}, tx, ty, tw)
	for _, rd := range [][2]string{{"on", k.On}, {"kun", k.Kun}} {
		if rd[1] == "" {
			continue
		}
		lw := l.text(tx, ty+2, rd[0], small, l.th.Muted) + 6
		ty = l.flow([]span{{text: rd[1], size: body, c: l.th.Accent}}, tx+lw, ty, tw-lw)
	}
	l.text(tx, ty+2, k.Meta, small, l.th.Muted)
	y = math.Max(y+box, ty+small*1.6) + 10

	y = l.chipSection("made of", k.Parts, OpenPart, x0, y, inner)
	y = l.chipSection("in words", k.Words, OpenWord, x0, y, inner)
	if k.SimilarOf != "" {
		y = l.chipSection("also with "+k.SimilarOf, k.Similar, OpenSimilar, x0, y, inner)
	}
	return y
}

func (l *layout) chipSection(title string, chips []Chip, a Action, x0, y, inner float64) float64 {
	if len(chips) == 0 {
		return y
	}
	l.text(x0, y, title, small, l.th.Muted)
	y += 16
	x, h := x0, 24.0
	for i, c := range chips {
		label := l.ellipsize(c.Label, small, 96)
		tw, lw := l.width(c.Text, 14), l.width(label, small)
		w := tw + 14
		if label != "" {
			w += lw + 5
		}
		if x+w > x0+inner && x > x0 {
			x, y = x0, y+h+5
		}
		l.region(x, y, w, h, a, i, c.Tip, false)
		bg := l.th.Chip
		if l.m.Hover == len(l.regions)-1 {
			bg = l.th.Hover
		}
		l.rect(x, y, w, h, 8, bg)
		l.text(x+7, y+(h-14*1.25)/2+1, c.Text, 14, l.th.Text)
		if label != "" {
			l.text(x+7+tw+5, y+(h-small*1.25)/2+1, label, small, l.th.Muted)
		}
		x += w + 5
	}
	return y + h + 12
}

// quiet is the ink for separators and hints: Muted, half faded.
func (l *layout) quiet() color.NRGBA {
	c := l.th.Muted
	c.A = 0x80
	return c
}
