diff --git a/CLAUDE.md b/CLAUDE.md index 9ff09b9..be6d034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,15 @@ rule builder cannot shadow a rule the user wrote by hand. `viewRules`, or typing `q` would quit and `i` would start an import. Any new full-screen input needs the same treatment. +**In the rule builder, `tab` completes first and moves focus second.** The +account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion` +key is `tab` and whose `NextSuggestion`/`PrevSuggestion` are `up`/`down` — all +three already meant something here. So `updateRules` intercepts `tab` and calls +`acceptCompletion` before falling back to `setRuleFocus`, keeps `up`/`down` on +field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling. +`SetValue` does not re-match the suggestion list, so `acceptCompletion` re-sets +it afterwards or `ctrl+n` would offer candidates that no longer fit the value. + **A rule's usage count is how many transactions it wins, not how many its glob could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by an earlier one correctly reports zero. That is what makes the rules screen able to diff --git a/README.md b/README.md index 8d53098..7c07a8e 100644 --- a/README.md +++ b/README.md @@ -88,19 +88,36 @@ a `▸` against each one the glob currently matches and a running "*n* of *m* descriptions match" count. ``` -▸ glob Untagged description N + glob Untagged description N ╭────────────────────────────╮ ACME PAYROLL JAN 1 │ *LIDL* │ BOLT RIDE 1 ╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2 vs. the description ▸ LIDL VARNA 9911 1 ZARA MLADOST 1 +▸ tag +╭────────────────────────────╮ +│ groceries │ +╰────────────────────────────╯ + tab completes · ctrl+n: 1 more + 2 of 7 descriptions match ``` +Only `gro` was typed in the tag field; `ceries` is the ghosted completion. + `tab` / `↑↓` move between the glob, account and tag fields, `pgup` / `pgdn` scroll the list, and `enter` appends the rule to `rules.toml` and retags immediately, so the rows it caught disappear from the list. `esc` goes back. +The account and tag fields complete as you type: the rest of the match is +ghosted in grey after the cursor, and `tab` (or `→` at the end of the line) +takes it. When several candidates share the prefix, the hint under the box says +how many, and `ctrl+n` / `ctrl+p` cycle through them. Nothing is committed until +you accept it, so a new tag is still just typed out in full. Accounts come from +the folders on disk and the index; tags from every tag in use plus any named in +`rules.toml`, so a tag is completable from the moment a rule mentions it — +which is what stops `groceries` acquiring a `grocery` twin. + Leaving the account blank applies the rule everywhere; filling it in also narrows the preview to that account. Rules are appended, so anything already in `rules.toml` keeps precedence — the preview accounts for that automatically, diff --git a/internal/tui/tui.go b/internal/tui/tui.go index abdf119..24bca02 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -4,6 +4,7 @@ package tui import ( "fmt" + "slices" "sort" "strings" @@ -149,6 +150,14 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En return in } + // Account slugs and tags are typed over and over, and a typo in either + // silently produces a rule that catches nothing, so both fields complete + // against what already exists. + completing := func(in textinput.Model) textinput.Model { + in.ShowSuggestions = true + return in + } + sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62")) @@ -185,8 +194,8 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En {Title: "Description", Width: 40}, }), ruleGlob: newInput("*LIDL*"), - ruleAccount: newInput("blank = every account"), - ruleTag: newInput("groceries"), + ruleAccount: completing(newInput("blank = every account")), + ruleTag: completing(newInput("groceries")), ruleTable: newTable([]table.Column{ {Title: " ", Width: 1}, {Title: "Untagged description", Width: 44}, @@ -439,27 +448,106 @@ func (m *Model) saveRule() error { m.ruleGlob.SetValue("") m.ruleTag.SetValue("") m.setRuleFocus(0) + if err := m.reloadSuggestions(); err != nil { + return err + } return m.reload() } func (m *Model) knownAccount(slug string) bool { + return slices.Contains(m.accountSlugs(), slug) +} + +// accountSlugs lists every account slug worth completing: those configured on +// disk, plus those the index already holds so accounts still resolve when the +// caller passed no config (as the tests do). +func (m *Model) accountSlugs() []string { + configured := make([]string, 0, len(m.accounts)) for _, a := range m.accounts { - if a.Slug == slug { - return true + configured = append(configured, a.Slug) + } + var imported []string + if accounts, err := m.db.Accounts(); err == nil { + for _, a := range accounts { + imported = append(imported, a.Slug) } } - // Fall back to the index, so accounts work even when the caller passed no - // config (as the tests do). - accounts, err := m.db.Accounts() + return sortedSet(configured, imported) +} + +// knownTags lists the tags in use in the index plus those only named by a rule. +// A rule that has not caught anything yet is exactly where a tag lives before +// it appears on a transaction, and those are the ones most likely to be +// mistyped into a second, near-identical tag. +func (m *Model) knownTags() ([]string, error) { + tagged, err := m.db.Tags() if err != nil { + return nil, err + } + rs := m.engine.Rules() + fromRules := make([]string, 0, len(rs)) + for _, r := range rs { + fromRules = append(fromRules, r.Tag) + } + return sortedSet(tagged, fromRules), nil +} + +// sortedSet merges the groups into one sorted list with blanks and duplicates +// removed. Completion cycles in this order, so it has to be stable. +func sortedSet(groups ...[]string) []string { + seen := map[string]bool{} + var out []string + for _, g := range groups { + for _, v := range g { + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + } + sort.Strings(out) + return out +} + +// reloadSuggestions refreshes what the account and tag fields complete against. +// It runs whenever the builder is opened and after every save, since saving a +// rule can introduce a tag that did not exist a moment ago. +func (m *Model) reloadSuggestions() error { + m.ruleAccount.SetSuggestions(m.accountSlugs()) + tags, err := m.knownTags() + if err != nil { + return err + } + m.ruleTag.SetSuggestions(tags) + return nil +} + +// pendingCompletion returns the suggestion an input is currently offering, or +// "" when there is nothing left to accept. Matching is case-insensitive, so a +// suggestion differing only in case is not worth rewriting what was typed. +func pendingCompletion(in *textinput.Model) string { + s := in.CurrentSuggestion() + if s == "" || strings.EqualFold(s, in.Value()) { + return "" + } + return s +} + +// acceptCompletion takes the offered completion into the focused field, +// reporting whether there was one. The suggestion list is re-set afterwards +// because SetValue does not re-match it, which would otherwise leave ctrl+n +// cycling through candidates that no longer share the new prefix. +func (m *Model) acceptCompletion() bool { + in := m.ruleInputs()[m.ruleFocus] + s := pendingCompletion(in) + if s == "" { return false } - for _, a := range accounts { - if a.Slug == slug { - return true - } - } - return false + in.SetValue(s) + in.CursorEnd() + in.SetSuggestions(in.AvailableSuggestions()) + return true } // reloadRuleList counts, for every rule, how many transactions it actually @@ -657,6 +745,9 @@ func (m *Model) openRuleBuilder() tea.Cmd { m.ruleAccount.SetValue(m.filter.AccountSlug) } m.setRuleFocus(0) + if err := m.reloadSuggestions(); err != nil { + m.err = err + } if err := m.reloadUntagged(); err != nil { m.err = err } @@ -752,12 +843,31 @@ func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": m.view = m.ruleReturn return m, nil - case "tab", "down": + case "tab": + // Tab is overloaded on purpose: with a completion on offer it takes + // it, and moving on is then one more tab. Without one it does what it + // always did and moves to the next field. + if m.acceptCompletion() { + m.refreshRulePreview() + return m, nil + } + m.setRuleFocus(m.ruleFocus + 1) + return m, nil + case "down": m.setRuleFocus(m.ruleFocus + 1) return m, nil case "shift+tab", "up": m.setRuleFocus(m.ruleFocus - 1) return m, nil + case "right": + // At the end of the line → has nothing else to do, so it accepts the + // completion the way a shell does. Anywhere else it falls through and + // moves the cursor. + in := m.ruleInputs()[m.ruleFocus] + if in.Position() == len([]rune(in.Value())) && m.acceptCompletion() { + m.refreshRulePreview() + return m, nil + } case "pgdown": m.ruleTable.MoveDown(10) return m, nil @@ -1141,9 +1251,9 @@ func (m *Model) ruleFormView() string { var b strings.Builder b.WriteString(field(0, "glob", "vs. the description")) b.WriteString("\n") - b.WriteString(field(1, "account", "blank = all accounts")) + b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts"))) b.WriteString("\n") - b.WriteString(field(2, "tag", "applied to matches")) + b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches"))) b.WriteString("\n") // The count is the whole point of the preview: it says what the rule will @@ -1157,6 +1267,23 @@ func (m *Model) ruleFormView() string { return ruleFormStyle.Render(b.String()) } +// completionHint names the key that accepts the ghosted completion, replacing +// the field's usual hint while one is on offer. The hint sits directly under +// the box the ghost text appears in, which is where the question is asked. +func (m *Model) completionHint(i int, fallback string) string { + if i != m.ruleFocus { + return fallback + } + in := m.ruleInputs()[i] + if pendingCompletion(in) == "" { + return fallback + } + if n := len(in.MatchedSuggestions()); n > 1 { + return fmt.Sprintf("tab completes · ctrl+n: %d more", n-1) + } + return "tab completes" +} + // emptyMessage explains an empty view and names the key that fixes it, or // returns "" when there is something to show. func (m *Model) emptyMessage() string { @@ -1261,7 +1388,7 @@ func (m *Model) help() string { case viewAccounts: return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · i import · r retag · q quit" case viewRules: - return "tab/↑↓ field · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit" + return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit" case viewRuleList: if m.confirm != confirmNone { return "y confirm · any other key cancels" diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 2724227..57565d3 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -654,6 +654,140 @@ func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) { } } +// The account field completes against the accounts that exist, because a slug +// typed by hand only has to be slightly wrong to produce a rule that silently +// catches nothing. +func TestRuleBuilderCompletesAccount(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "4") + + m.Update(tea.KeyMsg{Type: tea.KeyTab}) // onto the account field + typeText(t, m, "sa") + + // Nothing is committed until it is accepted: the value is still what was + // typed, with the rest offered as a ghost. + if m.ruleAccount.Value() != "sa" { + t.Fatalf("account = %q, want the typed text to stand until accepted", m.ruleAccount.Value()) + } + if got := m.ruleAccount.CurrentSuggestion(); got != "savings" { + t.Fatalf("suggestion = %q, want savings", got) + } + + m.Update(tea.KeyMsg{Type: tea.KeyTab}) + if m.ruleAccount.Value() != "savings" { + t.Fatalf("account = %q after tab, want savings", m.ruleAccount.Value()) + } + // Tab took the completion rather than the focus; the next one moves on. + if m.ruleFocus != 1 { + t.Errorf("focus = %d, want the account field to keep it while completing", m.ruleFocus) + } + m.Update(tea.KeyMsg{Type: tea.KeyTab}) + if m.ruleFocus != 2 { + t.Errorf("focus = %d, want the tag field once there is nothing left to complete", m.ruleFocus) + } + + // Accepting an account narrows the preview, exactly as typing it in full does. + if all, _ := previewRows(m); len(all) != 1 || all[0] != "LIDL VARNA 9911" { + t.Errorf("preview = %v, want only the savings row", all) + } +} + +// → accepts too, at the end of the line where it has nothing else to do. +func TestRuleBuilderRightArrowAcceptsCompletion(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "4") + + m.Update(tea.KeyMsg{Type: tea.KeyTab}) + typeText(t, m, "che") + m.Update(tea.KeyMsg{Type: tea.KeyRight}) + if m.ruleAccount.Value() != "checking" { + t.Errorf("account = %q after →, want checking", m.ruleAccount.Value()) + } + + // With no completion pending it is an ordinary cursor key again. + m.ruleAccount.SetCursor(0) + m.Update(tea.KeyMsg{Type: tea.KeyRight}) + if m.ruleAccount.Position() != 1 { + t.Errorf("cursor = %d, want → to move within the text once there is nothing to accept", + m.ruleAccount.Position()) + } +} + +// Tags come from the index and from rules.toml, so a tag is completable from +// the moment it is named in a rule, not only once something matches it. +func TestRuleBuilderCompletesTag(t *testing.T) { + m, _, _ := newRuleModel(t) + m.engine = rules.New(&config.Rules{Rule: []config.Rule{ + {Match: "*NOTHING*", Tag: "groceries"}, + {Match: "*ALSO NOTHING*", Tag: "grooming"}, + }}) + key(t, m, "4") + + m.setRuleFocus(2) + typeText(t, m, "gro") + if got := m.ruleTag.CurrentSuggestion(); got != "groceries" { + t.Fatalf("suggestion = %q, want the first of the two alphabetically", got) + } + if n := len(m.ruleTag.MatchedSuggestions()); n != 2 { + t.Fatalf("%d matched suggestions, want both gro* tags", n) + } + + // An ambiguous prefix is cycled, not guessed. + m.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + if got := m.ruleTag.CurrentSuggestion(); got != "grooming" { + t.Fatalf("suggestion = %q after ctrl+n, want grooming", got) + } + m.Update(tea.KeyMsg{Type: tea.KeyTab}) + if m.ruleTag.Value() != "grooming" { + t.Errorf("tag = %q, want the cycled-to suggestion", m.ruleTag.Value()) + } + + // The hint under the box names the key that accepts, and says how many + // other candidates there are. + m.ruleTag.SetValue("gro") + m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions()) + if hint := m.completionHint(2, "applied to matches"); !strings.Contains(hint, "tab") || + !strings.Contains(hint, "1 more") { + t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint) + } +} + +// A tag invented in the builder is completable next time round. +func TestSavingARuleAddsItsTagToTheSuggestions(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "4") + + m.ruleGlob.SetValue("*LIDL*") + m.setRuleFocus(2) + m.ruleTag.SetValue("groceries") + key(t, m, "enter") + + m.setRuleFocus(2) // saving resets the form to the glob field + typeText(t, m, "gro") + if got := m.ruleTag.CurrentSuggestion(); got != "groceries" { + t.Errorf("suggestion = %q, want the tag just saved", got) + } +} + +// A prefix nothing matches must stay exactly as typed: the completion never +// invents a tag, and tab goes back to moving between fields. +func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) { + m, _, _ := newRuleModel(t) + m.engine = rules.New(&config.Rules{Rule: []config.Rule{{Match: "*X*", Tag: "groceries"}}}) + key(t, m, "4") + + m.setRuleFocus(2) + typeText(t, m, "zzz") + m.Update(tea.KeyMsg{Type: tea.KeyTab}) + + if m.ruleTag.Value() != "zzz" { + t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value()) + } + if m.ruleFocus != 0 { + t.Errorf("focus = %d, want tab to wrap round to the glob field", m.ruleFocus) + } +} + // newRuleListModel seeds a data root whose rules.toml holds one useful rule, // one shadowed rule and one that matches nothing. func newRuleListModel(t *testing.T) (*Model, string) {