// 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
}