diff options
| author | David Schlachter <t480-debian-git@schlachter.ca> | 2026-01-08 10:44:50 -0500 |
|---|---|---|
| committer | David Schlachter <t480-debian-git@schlachter.ca> | 2026-01-08 10:44:50 -0500 |
| commit | 10f8e8c5ea5d3dd191d7e51682efc237d34cded4 (patch) | |
| tree | 09762e398b785a95f2691f3718d5c804d5107b49 | |
| parent | f0909c5ce9d3c8c525955422b64a653d54e92dec (diff) | |
Move all the UI stuff to a new file
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | main.go | 155 | ||||
| -rw-r--r-- | ui.go | 160 |
3 files changed, 163 insertions, 156 deletions
@@ -1,3 +1,3 @@ raw-wiktextract-data.jsonl -raw-wiktextract-data.sqlite3 -raw-wiktextract-data.sqlite3-journal +dictionary.sqlite3 +dictionary.sqlite3-journal
\ No newline at end of file @@ -3,176 +3,23 @@ package main import ( - "database/sql" - "errors" - "fmt" "log" "net/http" - "regexp" - "strings" "time" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" _ "github.com/mattn/go-sqlite3" - "github.com/microcosm-cc/bluemonday" - "github.com/muesli/reflow/wordwrap" ) const ( rawDictionary = "/home/david/work/french-wiktionary-flashcards/raw-wiktextract-data.jsonl" - dictionary = "/home/david/work/french-wiktionary-flashcards/raw-wiktextract-data.sqlite3" + dictionary = "dictionary.sqlite3" apiURL = "http://localhost:8765" deckName = "Français" modelName = "Basic-830ae" ) -type model struct { - wordInput textinput.Model - err error - currentWord string - currentDefinition string - db *sql.DB - c *http.Client - wordAddStatus string - p bluemonday.Policy - vp viewport.Model -} - -type ( - errMsg error - definitionMsg string - wordAddedMsg string -) - -func initialModel(c *http.Client, db *sql.DB) model { - ti := textinput.New() - ti.Placeholder = "" - ti.Focus() - ti.CharLimit = 156 - ti.Width = 36 - vp := viewport.New(80, 30) - - return model{ - wordInput: ti, - err: nil, - db: db, - c: c, - wordAddStatus: "", - p: *bluemonday.StrictPolicy(), - vp: vp, - } -} - -func (m model) Init() tea.Cmd { - return textinput.Blink -} - -func lookupWord(db *sql.DB, word string) tea.Cmd { - return func() tea.Msg { - var definition string - row := db.QueryRow(`select definition from words where word = ? limit 1`, word) - err := row.Scan(&definition) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return definitionMsg("") - } - return errMsg(fmt.Errorf("looking up '%s': %s", word, err)) - } - return definitionMsg(definition) - } -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - var textPickerLongerCmds []tea.Cmd - - switch msg := msg.(type) { - case definitionMsg: - m.currentDefinition = string(msg) - m.err = nil - m.vp.SetContent(formatDefinitionForDisplay(m.p, m.currentDefinition, m.vp.Width)) - return m, nil - case wordAddedMsg: - m.wordAddStatus = fmt.Sprintf("✅ Added '%s' to Anki", string(msg)) - m.currentWord = "" - m.currentDefinition = "" - m.wordInput.SetValue("") - m.vp.SetContent("") - m.err = nil - case tea.WindowSizeMsg: - // headerHeight is the height of everything above the definition window. - headerHeight := 11 - m.vp.Width = msg.Width - m.vp.Height = msg.Height - headerHeight - m.vp.SetContent(formatDefinitionForDisplay(m.p, m.currentDefinition, m.vp.Width)) - case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC: - return m, tea.Quit - case tea.KeyEsc: - m.currentWord = "" - m.currentDefinition = "" - m.wordInput.SetValue("") - m.vp.SetContent("") - case tea.KeyEnter: - return m, addCard(m.c, m.currentWord, m.currentDefinition) - } - - case errMsg: - m.err = msg - return m, nil - } - - if m.wordInput.Value() != m.currentWord { - m.currentWord = m.wordInput.Value() - textPickerLongerCmds = append(textPickerLongerCmds, lookupWord(m.db, m.currentWord)) - } - - var vpCmd tea.Cmd - m.vp, vpCmd = m.vp.Update(msg) - textPickerLongerCmds = append(textPickerLongerCmds, vpCmd) - - m.wordInput, cmd = m.wordInput.Update(msg) - textPickerLongerCmds = append(textPickerLongerCmds, cmd) - return m, tea.Batch(textPickerLongerCmds...) -} - -var whitespaceTrimmerRe = regexp.MustCompile(`^[ \t]*$`) - -func (m model) View() string { - return fmt.Sprintf( - "\x1b[1;30;42mLook up a word:\x1b[0m\n\n%s\n\n\x1b[1;30;42mStatus:\x1b[0m %s\n\n%s\n\n%s\n%s", - m.wordInput.View(), - formatStatus(m.err, m.wordAddStatus), - "(ctrl-c to quit, esc to clear, enter to add to Anki)", - "\x1b[1;30;42mCurrent definition:\x1b[0m\n", - m.vp.View(), - ) + "\n" -} - -func formatDefinitionForDisplay(policy bluemonday.Policy, definition string, maxWidth int) string { - str := strings.ReplaceAll(definition, "<li class=sense>", "<li class=sense>- ") - str = strings.ReplaceAll(str, "\t<ul><li><i>", "\n\t<ul><li><i>\x1b[3;39;49m") - str = strings.ReplaceAll(str, "</i></li></ul></li>", "</i></li></ul></li>\x1b[0m") - str = policy.Sanitize(str) - str = strings.ReplaceAll(str, "\t- ", "\x1b[0;33;49m•\x1b[0m ") - - width := min(maxWidth, 80) - - return wordwrap.String(str, width) -} - -func formatStatus(lastError error, lastSuccess string) string { - if lastError == nil { - return lastSuccess - } - return fmt.Sprintf("\x1b[0;31;49m%s\x1b[0m", lastError.Error()) -} - func main() { db, err := setupDatabase() if err != nil { @@ -0,0 +1,160 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/microcosm-cc/bluemonday" + "github.com/muesli/reflow/wordwrap" +) + +type model struct { + wordInput textinput.Model + err error + currentWord string + currentDefinition string + db *sql.DB + c *http.Client + wordAddStatus string + p bluemonday.Policy + vp viewport.Model +} + +type ( + errMsg error + definitionMsg string + wordAddedMsg string +) + +func initialModel(c *http.Client, db *sql.DB) model { + ti := textinput.New() + ti.Placeholder = "" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 36 + vp := viewport.New(80, 30) + + return model{ + wordInput: ti, + err: nil, + db: db, + c: c, + wordAddStatus: "", + p: *bluemonday.StrictPolicy(), + vp: vp, + } +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func lookupWord(db *sql.DB, word string) tea.Cmd { + return func() tea.Msg { + var definition string + row := db.QueryRow(`select definition from words where word = ? limit 1`, word) + err := row.Scan(&definition) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return definitionMsg("") + } + return errMsg(fmt.Errorf("looking up '%s': %s", word, err)) + } + return definitionMsg(definition) + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + var textPickerLongerCmds []tea.Cmd + + switch msg := msg.(type) { + case definitionMsg: + m.currentDefinition = string(msg) + m.err = nil + m.vp.SetContent(formatDefinitionForDisplay(m.p, m.currentDefinition, m.vp.Width)) + return m, nil + case wordAddedMsg: + m.wordAddStatus = fmt.Sprintf("✅ Added '%s' to Anki", string(msg)) + m.currentWord = "" + m.currentDefinition = "" + m.wordInput.SetValue("") + m.vp.SetContent("") + m.err = nil + case tea.WindowSizeMsg: + // headerHeight is the height of everything above the definition window. + headerHeight := 11 + m.vp.Width = msg.Width + m.vp.Height = msg.Height - headerHeight + m.vp.SetContent(formatDefinitionForDisplay(m.p, m.currentDefinition, m.vp.Width)) + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEsc: + m.currentWord = "" + m.currentDefinition = "" + m.wordInput.SetValue("") + m.vp.SetContent("") + case tea.KeyEnter: + return m, addCard(m.c, m.currentWord, m.currentDefinition) + } + + case errMsg: + m.err = msg + return m, nil + } + + if m.wordInput.Value() != m.currentWord { + m.currentWord = m.wordInput.Value() + textPickerLongerCmds = append(textPickerLongerCmds, lookupWord(m.db, m.currentWord)) + } + + var vpCmd tea.Cmd + m.vp, vpCmd = m.vp.Update(msg) + textPickerLongerCmds = append(textPickerLongerCmds, vpCmd) + + m.wordInput, cmd = m.wordInput.Update(msg) + textPickerLongerCmds = append(textPickerLongerCmds, cmd) + return m, tea.Batch(textPickerLongerCmds...) +} + +var whitespaceTrimmerRe = regexp.MustCompile(`^[ \t]*$`) + +func (m model) View() string { + return fmt.Sprintf( + "\x1b[1;30;42mLook up a word:\x1b[0m\n\n%s\n\n\x1b[1;30;42mStatus:\x1b[0m %s\n\n%s\n\n%s\n%s", + m.wordInput.View(), + formatStatus(m.err, m.wordAddStatus), + "(ctrl-c to quit, esc to clear, enter to add to Anki)", + "\x1b[1;30;42mCurrent definition:\x1b[0m\n", + m.vp.View(), + ) + "\n" +} + +func formatDefinitionForDisplay(policy bluemonday.Policy, definition string, maxWidth int) string { + str := strings.ReplaceAll(definition, "<li class=sense>", "<li class=sense>- ") + str = strings.ReplaceAll(str, "\t<ul><li><i>", "\n\t<ul><li><i>\x1b[3;39;49m") + str = strings.ReplaceAll(str, "</i></li></ul></li>", "</i></li></ul></li>\x1b[0m") + str = policy.Sanitize(str) + str = strings.ReplaceAll(str, "\t- ", "\x1b[0;33;49m•\x1b[0m ") + + width := min(maxWidth, 80) + + return wordwrap.String(str, width) +} + +func formatStatus(lastError error, lastSuccess string) string { + if lastError == nil { + return lastSuccess + } + return fmt.Sprintf("\x1b[0;31;49m%s\x1b[0m", lastError.Error()) +} |
