ui.go (6683B)
1 package main 2 3 import ( 4 "database/sql" 5 "fmt" 6 "html" 7 "net/http" 8 "strings" 9 10 "github.com/charmbracelet/bubbles/key" 11 "github.com/charmbracelet/bubbles/progress" 12 "github.com/charmbracelet/bubbles/textinput" 13 "github.com/charmbracelet/bubbles/viewport" 14 tea "github.com/charmbracelet/bubbletea" 15 "github.com/microcosm-cc/bluemonday" 16 "github.com/muesli/reflow/wordwrap" 17 ) 18 19 type model struct { 20 db *sql.DB 21 c *http.Client 22 p bluemonday.Policy 23 dp dictionaryPopulator 24 25 wordInput textinput.Model 26 definitionViewport viewport.Model 27 importProgress progress.Model 28 29 ankiDeck string 30 ankiModel string 31 apiURL string 32 rawDictionaryPath string 33 34 dictionaryReady bool 35 36 currentWord string 37 currentDefinition string 38 statusString string 39 importFraction float64 40 err error 41 } 42 43 type ( 44 errMsg error 45 definitionMsg string 46 wordAddedMsg string 47 isDictionaryEmptyMsg bool 48 populatingDictionaryMsg *dictionaryPopulator 49 ) 50 51 func initialModel( 52 c *http.Client, 53 db *sql.DB, 54 apiURL, ankiDeck, ankiModel, rawDictionary, firstWord string, 55 ) model { 56 input := textinput.New() 57 input.Placeholder = "" 58 input.Focus() 59 input.CharLimit = 156 60 input.Width = 36 61 62 if firstWord != "" { 63 input.SetValue(firstWord) 64 } 65 66 textbox := viewport.New(80, 30) 67 textbox.KeyMap = viewport.KeyMap{ 68 PageDown: key.NewBinding( 69 key.WithKeys("pgdown", " ", "f"), 70 key.WithHelp("f/pgdn", "page down"), 71 ), 72 PageUp: key.NewBinding( 73 key.WithKeys("pgup", "b"), 74 key.WithHelp("b/pgup", "page up"), 75 ), 76 } 77 78 dp := dictionaryPopulator{ 79 db: db, 80 rawDictionaryPath: rawDictionary, 81 langCode: "fr", 82 } 83 84 return model{ 85 db: db, 86 c: c, 87 p: *bluemonday.StrictPolicy(), 88 dp: dp, 89 90 wordInput: input, 91 definitionViewport: textbox, 92 93 ankiDeck: ankiDeck, 94 ankiModel: ankiModel, 95 apiURL: apiURL, 96 rawDictionaryPath: rawDictionary, 97 98 currentWord: firstWord, 99 } 100 } 101 102 func (m model) Init() tea.Cmd { 103 cmds := []tea.Cmd{textinput.Blink} 104 cmds = append(cmds, isDatabaseEmpty(m.db)) 105 return tea.Batch(cmds...) 106 } 107 108 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 109 var cmd tea.Cmd 110 111 var textPickerLongerCmds []tea.Cmd 112 113 switch msg := msg.(type) { 114 case isDictionaryEmptyMsg: 115 if bool(msg) { // We need to populate the dictionary 116 m.importProgress = progress.New(progress.WithDefaultGradient()) 117 return m, setupPopulator(&m.dp) 118 } else { // The dictionary is ready 119 m.dictionaryReady = true 120 m.statusString = "" 121 if m.currentWord != "" { 122 textPickerLongerCmds = append( 123 textPickerLongerCmds, 124 lookupWord(m.db, m.currentWord), 125 ) 126 } 127 } 128 case populatingDictionaryMsg: 129 m.importFraction = float64(msg.currentLine) / float64(msg.totalLines) 130 return m, populateDictionary((*dictionaryPopulator)(msg)) 131 case definitionMsg: 132 m.currentDefinition = string(msg) 133 m.err = nil 134 m.definitionViewport.SetContent( 135 formatDefinitionForDisplay( 136 m.p, 137 m.currentDefinition, 138 m.definitionViewport.Width, 139 ), 140 ) 141 return m, nil 142 case wordAddedMsg: 143 m.statusString = fmt.Sprintf("✅ Added '%s' to Anki", string(msg)) 144 m.currentWord = "" 145 m.currentDefinition = "" 146 m.wordInput.SetValue("") 147 m.definitionViewport.SetContent("") 148 m.err = nil 149 case tea.WindowSizeMsg: 150 m.importProgress.Width = msg.Width 151 m.wordInput.Width = msg.Width 152 m.definitionViewport.Width = msg.Width 153 154 // headerHeight is the height of everything above the definition window. 155 headerHeight := 11 156 157 m.definitionViewport.Height = msg.Height - headerHeight 158 m.definitionViewport.SetContent( 159 formatDefinitionForDisplay( 160 m.p, 161 m.currentDefinition, 162 m.definitionViewport.Width, 163 ), 164 ) 165 case tea.KeyMsg: 166 switch msg.Type { 167 case tea.KeyCtrlC, tea.KeyCtrlD: 168 fmt.Print("\033[H\033[2J") // clear screen 169 return m, tea.Quit 170 case tea.KeyEsc: 171 m.currentWord = "" 172 m.currentDefinition = "" 173 m.wordInput.SetValue("") 174 m.definitionViewport.SetContent("") 175 case tea.KeyEnter: 176 return m, addCard( 177 m.c, 178 m.apiURL, 179 m.ankiDeck, 180 m.ankiModel, 181 m.currentWord, 182 m.currentDefinition, 183 ) 184 } 185 186 case errMsg: 187 m.err = msg 188 return m, nil 189 } 190 191 if m.wordInput.Value() != m.currentWord { 192 m.currentWord = m.wordInput.Value() 193 textPickerLongerCmds = append( 194 textPickerLongerCmds, 195 lookupWord(m.db, m.currentWord), 196 ) 197 } 198 199 var vpCmd tea.Cmd 200 m.definitionViewport, vpCmd = m.definitionViewport.Update(msg) 201 textPickerLongerCmds = append(textPickerLongerCmds, vpCmd) 202 203 m.wordInput, cmd = m.wordInput.Update(msg) 204 textPickerLongerCmds = append(textPickerLongerCmds, cmd) 205 return m, tea.Batch(textPickerLongerCmds...) 206 } 207 208 func (m model) View() string { 209 if m.dictionaryReady { 210 return fmt.Sprintf( 211 "\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", 212 m.wordInput.View(), 213 formatStatus(m.err, m.statusString), 214 "(Ctrl-C to quit, Esc to clear, Enter to add to Anki, PgUp/Down to scroll)", 215 "\x1b[1;30;42mCurrent definition:\x1b[0m\n", 216 m.definitionViewport.View(), 217 ) + "\n" 218 } 219 if m.err != nil { 220 return fmt.Sprintf("Failed to load dictionary! Error:\n\n%s\n\nExit with Control-C.\n", m.err) 221 } 222 if m.importFraction == 0 { 223 return "\nWe'll start preparing the dictionary in a few seconds...\n\n\n\n" 224 } 225 return fmt.Sprintf("\nPreparing dictionary...\n\n%s\n\n", m.importProgress.ViewAs(m.importFraction)) 226 } 227 228 func formatDefinitionForDisplay( 229 policy bluemonday.Policy, 230 definition string, 231 maxWidth int, 232 ) string { 233 // Add a hyphen to the start of each definition 234 str := strings.ReplaceAll(definition, "<li class=sense>", "<li class=sense>- ") 235 236 // Italicize examples 237 str = strings.ReplaceAll(str, "\t<ul><li><i>", "\n\t<ul><li><i>\x1b[3;39;49m") 238 str = strings.ReplaceAll(str, "</i></li></ul></li>", "</i></li></ul></li>\x1b[0m") 239 240 // Remove all HTML tags 241 str = policy.Sanitize(str) 242 // Some Wiktionary entries have HTML entities in them. That's okay for Anki, 243 // but it's not okay for displaying the plain text in the console interface. 244 str = html.UnescapeString(str) 245 246 // Add some colour to the start of each definition 247 str = strings.ReplaceAll(str, "\t- ", "\x1b[0;33;49m•\x1b[0m ") 248 249 // Limit the width of the displayed definition to 80 characters, or the 250 // width of the viewport (whichever is smaller). (Add one character of 251 // padding too -- seems to prevent weird line breaks) 252 width := min(maxWidth-1, 80-1) 253 return wordwrap.String(str, width) 254 } 255 256 func formatStatus(lastError error, lastSuccess string) string { 257 if lastError == nil { 258 return lastSuccess 259 } 260 return fmt.Sprintf("\x1b[0;31;49m%s\x1b[0m", lastError.Error()) 261 }