Complete account and tag names in the rule builder
Both fields are free text, and a slug or tag that is slightly wrong produces a rule that silently catches nothing — or a second, near-identical tag. They now complete against what already exists: accounts 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. The completion is ghosted after the cursor and never committed until it is accepted, so inventing a new tag still works. tab takes it and moves focus only when there is nothing left to complete; ctrl+n/ctrl+p cycle an ambiguous prefix, and the hint under the box says how many candidates are left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+144
-17
@@ -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,28 +448,107 @@ 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
|
||||
}
|
||||
}
|
||||
// Fall back to the index, so accounts work even when the caller passed no
|
||||
// config (as the tests do).
|
||||
accounts, err := m.db.Accounts()
|
||||
if err != nil {
|
||||
return false
|
||||
configured = append(configured, a.Slug)
|
||||
}
|
||||
var imported []string
|
||||
if accounts, err := m.db.Accounts(); err == nil {
|
||||
for _, a := range accounts {
|
||||
if a.Slug == slug {
|
||||
return true
|
||||
imported = append(imported, a.Slug)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
in.SetValue(s)
|
||||
in.CursorEnd()
|
||||
in.SetSuggestions(in.AvailableSuggestions())
|
||||
return true
|
||||
}
|
||||
|
||||
// reloadRuleList counts, for every rule, how many transactions it actually
|
||||
// claims. A rule can match nothing because no description fits it, or because
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user