package app
import (
"fmt"
"slices"
"strings"
"unicode"
"chirami/internal/analyze"
"chirami/internal/card"
"chirami/internal/dict"
"chirami/internal/jp"
)
const (
maxEntries = 3
maxMeanings = 3
maxKanji = 6
kanjiWords = 6
maxSimilar = 6
)
// page is what the card shows: a word view, or a kanji page reached from one.
type page interface{ title() string }
// view is everything the card shows for one selection.
type view struct {
text string
words []analyze.Word
phrase *analyze.Word // the whole selection, when the dictionary knows it as one
focus int // index into words, or wholePhrase
entries []dict.Entry
source string // "jmdict", "jisho", or "" for none
status string
expanded bool
// what the card shows, so clicks can find what they point at
shown []card.Entry
kanji []rune
}
func (v *view) title() string { return v.text }
// wholePhrase as a view's focus means the entries describe the selection
// as a whole.
const wholePhrase = -2
// focused is the word the entries describe.
func (v *view) focused() (analyze.Word, bool) {
switch {
case v.focus == wholePhrase:
return *v.phrase, true
case v.focus >= 0:
return v.words[v.focus], true
}
return analyze.Word{}, false
}
type kanjiPage struct {
r rune
parts []rune
words []dict.Entry
similar []rune
of rune // the part the similar kanji share
}
func (k *kanjiPage) title() string { return string(k.r) }
func (a *App) newView(text string) *view {
v := &view{text: text, words: a.an.Words(text), focus: -1}
if p, ok := a.an.Phrase(text, v.words); ok {
v.phrase, v.focus = &p, wholePhrase
a.lookup(v)
return v
}
for i, w := range v.words {
if w.Content {
v.focus = i
break
}
}
if v.focus < 0 && len(v.words) > 0 && !v.words[0].Symbol {
v.focus = 0
}
a.lookup(v)
return v
}
// lookup fills the offline entries for the focused word; it never touches
// the network.
func (a *App) lookup(v *view) {
v.entries, v.source, v.status = nil, "", ""
w, ok := v.focused()
if !ok {
return
}
if es := a.an.Entries(w); len(es) > 0 {
v.entries, v.source = es, "jmdict"
return
}
v.status = "(・・?) not in the offline dictionary"
}
// wantsJisho reports whether the focused word should be looked up online:
// only when the offline dictionary has nothing and there is kanji to look
// up. Kana never goes to the network.
func (a *App) wantsJisho(v *view) (string, bool) {
w, ok := v.focused()
if !a.cfg.Online || !ok || len(v.entries) > 0 {
return "", false
}
if !strings.ContainsFunc(w.Surface, jp.IsKanji) {
return "", false
}
return w.Key, w.Key != ""
}
func (a *App) newKanjiPage(r rune) *kanjiPage {
k := &kanjiPage{r: r}
if a.dict == nil {
return k
}
k.parts = a.dict.Components(r)
k.words = a.dict.WordsWith(r, kanjiWords)
// the part shared with the fewest other kanji says the most
best := 0
for _, p := range k.parts {
if p == r {
continue
}
with := slices.DeleteFunc(a.dict.KanjiWith(p, maxSimilar+1), func(x rune) bool { return x == r })
if len(with) >= 3 && (best == 0 || len(with) < best) {
best, k.of, k.similar = len(with), p, with[:min(len(with), maxSimilar)]
}
}
return k
}
func (a *App) model(p page) *card.Model {
m := &card.Model{Hover: a.hover, Tip: a.tip, Zoom: a.cfg.zoom(a.scale)}
if a.res != nil {
m.Room = a.y + a.res.Hit.Y - a.anchor.Screen.Y - 6
}
switch p := p.(type) {
case *view:
m.Word = a.wordModel(p)
case *kanjiPage:
m.Kanji = a.kanjiModel(p)
}
return m
}
func (a *App) wordModel(v *view) *card.WordView {
wv := &card.WordView{Focus: v.focus, Expanded: v.expanded, Status: v.status,
Phrase: v.phrase != nil, WholePhrase: v.focus == wholePhrase}
for i, w := range v.words {
cw := card.Word{Segs: segs(w.Segs), Romaji: w.Romaji, Lookup: !w.Symbol}
if a.cfg.NoRomaji || (w.Symbol && !hasAlnum(w.Romaji)) {
cw.Romaji = "" // punctuation needs no romaji under it
}
if v.expanded && i == v.focus {
cw.Parts = a.parts(w, v.entries)
}
wv.Words = append(wv.Words, cw)
}
v.shown, v.kanji = v.shown[:0], v.kanji[:0]
w, ok := v.focused()
if !ok {
return wv
}
for i := range v.entries {
if i == maxEntries {
break
}
v.shown = append(v.shown, a.entry(&v.entries[i], w))
}
wv.Entries = v.shown
for _, r := range w.Surface {
if !jp.IsKanji(r) || slices.Contains(v.kanji, r) || len(v.kanji) == maxKanji {
continue
}
v.kanji = append(v.kanji, r)
chip := card.Chip{Text: string(r)}
if k, ok := a.kanjiInfo(r); ok {
chip.Tip = strings.Join(k.Meanings[:min(len(k.Meanings), 3)], ", ")
}
wv.Kanji = append(wv.Kanji, chip)
}
return wv
}
func (a *App) kanjiInfo(r rune) (dict.Kanji, bool) {
if a.dict == nil {
return dict.Kanji{}, false
}
return a.dict.Kanji(r)
}
func hasAlnum(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
})
}
// parts splits a compound particle or inflected word for display: each
// particle or stem glossed with its meaning, each inflection with a short
// name and Yomitan's description on hover.
func (a *App) parts(w analyze.Word, entries []dict.Entry) []card.Part {
if len(w.Parts) > 0 {
var out []card.Part
for _, p := range w.Parts {
cp := card.Part{Segs: segs(p.Segs)}
if !a.cfg.NoRomaji {
cp.Romaji = p.Romaji
}
if es := a.an.Entries(p); len(es) > 0 {
cp.Gloss = shortGloss(&es[0])
cp.Tip = a.partTip(p, &es[0])
}
out = append(out, cp)
}
return out
}
pieces, ok := a.an.Inflection(w)
if !ok {
return nil
}
var out []card.Part
for _, p := range pieces {
cp := card.Part{Segs: segs(p.Segs)}
if !a.cfg.NoRomaji {
cp.Romaji = p.Romaji
}
if p.Name == "" {
if len(entries) > 0 {
cp.Gloss = shortGloss(&entries[0])
}
} else {
cp.Gloss = stepLabel(p.Name)
cp.Tip = p.Name + "\n" + describe(p.Description)
}
out = append(out, cp)
}
return out
}
// stepLabels shortens Yomitan's transform names to fit under a word part;
// the full name and its description are in the tip.
var stepLabels = map[string]string{
"-た": "past", "-て": "te-form", "-いる": "ongoing", "-たい": "want to", "-ます": "polite",
"-ば": "if", "-たら": "if/when", "-たり": "and so on", "-ず": "without", "-ちゃう": "end up",
"-しまう": "end up", "-おく": "in advance", "-ある": "state", "-そう": "looks like",
"potential or passive": "passive/potential", "imperative negative": "don't",
}
func stepLabel(name string) string {
if l, ok := stepLabels[name]; ok {
return l
}
return strings.TrimPrefix(name, "-")
}
// describe keeps the meaning lines of a Yomitan description, one per line,
// and drops its usage notes, which are about forming the word rather than
// reading it.
func describe(d string) string {
var keep []string
for _, ln := range strings.Split(d, "\n") {
if strings.HasPrefix(ln, "Usage") || strings.HasPrefix(ln, "Refers to") {
break
}
if ln = strings.TrimSpace(ln); ln != "" {
keep = append(keep, ln)
}
}
return strings.Join(keep, "\n")
}
func (a *App) partTip(w analyze.Word, e *dict.Entry) string {
ce := a.entry(e, w)
head := w.Surface
if ce.Tip != "" {
head += " · " + ce.Tip
}
var lines []string
for i, m := range ce.Meanings {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, m))
}
if len(lines) == 0 {
return head
}
return head + "\n" + strings.Join(lines, "\n")
}
// shortGloss is the first meaning cut to a label: "to eat" -> "eat",
// "indicates sentence topic" -> "topic".
func shortGloss(e *dict.Entry) string {
if len(e.Senses) == 0 || len(e.Senses[0].Gloss) == 0 {
return ""
}
g := strings.TrimPrefix(e.Senses[0].Gloss[0], "to ")
for _, pfx := range []string{"indicates sentence ", "indicates the ", "indicates a ", "indicates "} {
if after, ok := strings.CutPrefix(g, pfx); ok {
g = after
break
}
}
for _, sfx := range []string{" of a sentence", " of action"} {
g = strings.TrimSuffix(g, sfx)
}
if i := strings.IndexAny(g, "(;,"); i > 0 {
g = g[:i]
}
return strings.TrimSpace(g)
}
func segs(ss []analyze.Seg) []card.Seg {
out := make([]card.Seg, len(ss))
for i, s := range ss {
out[i] = card.Seg{Text: s.Text, Ruby: s.Ruby}
}
return out
}
// entry picks the forms of e that match the selected word and keeps the
// meanings that apply to them.
func (a *App) entry(e *dict.Entry, w analyze.Word) card.Entry {
head := pickForm(e.Kanji, w.Surface, w.Key)
reading := pickReading(e.Kana, head, w.KeyReading, w.Reading)
if jp.IsKanaOnly(w.Surface) && usuallyKana(e) {
head = "" // selected as kana and written that way: lead with the kana
}
ce := card.Entry{Head: head, Reading: reading, Downstep: -1, Common: e.Common(), JLPT: e.JLPT}
if head == "" {
ce.Head, ce.Reading = reading, ""
}
ce.DictForm = w.Key != "" && w.Key != w.Surface && (ce.Head == w.Key || ce.Reading == w.Key)
ce.Same = ce.Head == w.Surface && len(w.Parts) == 0
if !a.cfg.NoRomaji {
ce.Romaji = a.an.Romaji(cmp(head, reading), reading)
}
for _, p := range e.Pitch {
if jp.ToHiragana(p.Reading) == jp.ToHiragana(reading) && len(p.Downsteps) > 0 {
ce.Downstep = p.Downsteps[0]
break
}
}
var tags []string
for _, s := range e.Senses {
if !applies(s.AppliesToKanji, head) || !applies(s.AppliesToKana, reading) {
continue
}
if len(ce.Meanings) == 0 {
for _, c := range slices.Concat(s.POS, s.Misc) {
if l := a.label(c); !slices.Contains(tags, l) {
tags = append(tags, l)
}
}
}
if len(ce.Meanings) < maxMeanings {
ce.Meanings = append(ce.Meanings, strings.Join(s.Gloss, "; "))
}
}
ce.Tip = strings.Join(tags, " · ")
if alt := pickForm(e.Kanji); head == "" && alt != "" {
ce.Tip = strings.TrimPrefix(ce.Tip+" · also written "+alt, " · ")
}
return ce
}
func (a *App) kanjiModel(k *kanjiPage) *card.KanjiView {
kv := &card.KanjiView{Char: string(k.r)}
if len(a.stack) > 1 {
kv.Back = a.stack[len(a.stack)-2].title()
}
if info, ok := a.kanjiInfo(k.r); ok {
kv.Meanings = strings.Join(info.Meanings, ", ")
kv.On = strings.Join(info.On, " ")
kv.Kun = strings.Join(info.Kun, " ")
kv.Meta = kanjiMeta(info)
}
for _, p := range k.parts {
if p != k.r {
kv.Parts = append(kv.Parts, card.Chip{Text: string(p), Label: a.dict.ComponentName(p)})
}
}
for _, e := range k.words {
form := ""
for _, f := range e.Kanji {
if strings.ContainsRune(f.Text, k.r) && displayable(f) {
form = f.Text
break
}
}
reading := ""
if len(e.Kana) > 0 {
reading = e.Kana[0].Text
}
kv.Words = append(kv.Words, card.Chip{Text: form, Label: shortGloss(&e), Tip: reading})
}
if k.of != 0 {
kv.SimilarOf = string(k.of)
for _, s := range k.similar {
chip := card.Chip{Text: string(s)}
if info, ok := a.kanjiInfo(s); ok && len(info.Meanings) > 0 {
chip.Label = info.Meanings[0]
}
kv.Similar = append(kv.Similar, chip)
}
}
return kv
}
func kanjiMeta(k dict.Kanji) string {
meta := []string{fmt.Sprintf("%d strokes", k.Strokes)}
switch {
case k.Grade >= 1 && k.Grade <= 6:
meta = append(meta, fmt.Sprintf("grade %d", k.Grade))
case k.Grade == 8:
meta = append(meta, "jōyō")
}
if k.Freq > 0 {
meta = append(meta, fmt.Sprintf("#%d in news", k.Freq))
}
return strings.Join(meta, " · ")
}
// searchOnly marks forms JMdict keeps for lookup but that should not be shown
// as the headword.
var searchOnly = []string{"sK", "sk", "iK", "ik", "oK", "ok", "rK"}
func displayable(f dict.Form) bool {
return !slices.ContainsFunc(f.Tags, func(t string) bool { return slices.Contains(searchOnly, t) })
}
// pickForm returns the kanji form matching one of want, else the first
// displayable form, else "".
func pickForm(forms []dict.Form, want ...string) string {
for _, f := range forms {
if slices.Contains(want, f.Text) {
return f.Text
}
}
for _, f := range forms {
if displayable(f) {
return f.Text
}
}
return ""
}
func pickReading(kana []dict.Form, head string, want ...string) string {
for _, f := range kana {
if slices.Contains(want, jp.ToHiragana(f.Text)) {
return f.Text
}
}
for _, f := range kana {
if displayable(f) {
return f.Text
}
}
if len(kana) > 0 {
return kana[0].Text
}
return head
}
func usuallyKana(e *dict.Entry) bool {
return len(e.Senses) > 0 && slices.Contains(e.Senses[0].Misc, "uk")
}
func applies(only []string, form string) bool {
return len(only) == 0 || form == "" || slices.Contains(only, "*") || slices.Contains(only, form)
}