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
|
`viewRules`, or typing `q` would quit and `i` would start an import. Any new
|
||||||
full-screen input needs the same treatment.
|
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
|
**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
|
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
|
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.
|
"*n* of *m* descriptions match" count.
|
||||||
|
|
||||||
```
|
```
|
||||||
▸ glob Untagged description N
|
glob Untagged description N
|
||||||
╭────────────────────────────╮ ACME PAYROLL JAN 1
|
╭────────────────────────────╮ ACME PAYROLL JAN 1
|
||||||
│ *LIDL* │ BOLT RIDE 1
|
│ *LIDL* │ BOLT RIDE 1
|
||||||
╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2
|
╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2
|
||||||
vs. the description ▸ LIDL VARNA 9911 1
|
vs. the description ▸ LIDL VARNA 9911 1
|
||||||
ZARA MLADOST 1
|
ZARA MLADOST 1
|
||||||
|
▸ tag
|
||||||
|
╭────────────────────────────╮
|
||||||
|
│ groceries │
|
||||||
|
╰────────────────────────────╯
|
||||||
|
tab completes · ctrl+n: 1 more
|
||||||
|
|
||||||
2 of 7 descriptions match
|
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`
|
`tab` / `↑↓` move between the glob, account and tag fields, `pgup` / `pgdn`
|
||||||
scroll the list, and `enter` appends the rule to `rules.toml` and retags
|
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.
|
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
|
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
|
narrows the preview to that account. Rules are appended, so anything already in
|
||||||
`rules.toml` keeps precedence — the preview accounts for that automatically,
|
`rules.toml` keeps precedence — the preview accounts for that automatically,
|
||||||
|
|||||||
+144
-17
@@ -4,6 +4,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -149,6 +150,14 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
|||||||
return in
|
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.New()
|
||||||
sp.Spinner = spinner.Dot
|
sp.Spinner = spinner.Dot
|
||||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
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},
|
{Title: "Description", Width: 40},
|
||||||
}),
|
}),
|
||||||
ruleGlob: newInput("*LIDL*"),
|
ruleGlob: newInput("*LIDL*"),
|
||||||
ruleAccount: newInput("blank = every account"),
|
ruleAccount: completing(newInput("blank = every account")),
|
||||||
ruleTag: newInput("groceries"),
|
ruleTag: completing(newInput("groceries")),
|
||||||
ruleTable: newTable([]table.Column{
|
ruleTable: newTable([]table.Column{
|
||||||
{Title: " ", Width: 1},
|
{Title: " ", Width: 1},
|
||||||
{Title: "Untagged description", Width: 44},
|
{Title: "Untagged description", Width: 44},
|
||||||
@@ -439,27 +448,106 @@ func (m *Model) saveRule() error {
|
|||||||
m.ruleGlob.SetValue("")
|
m.ruleGlob.SetValue("")
|
||||||
m.ruleTag.SetValue("")
|
m.ruleTag.SetValue("")
|
||||||
m.setRuleFocus(0)
|
m.setRuleFocus(0)
|
||||||
|
if err := m.reloadSuggestions(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return m.reload()
|
return m.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) knownAccount(slug string) bool {
|
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 {
|
for _, a := range m.accounts {
|
||||||
if a.Slug == slug {
|
configured = append(configured, a.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
|
|
||||||
}
|
}
|
||||||
|
var imported []string
|
||||||
|
if accounts, err := m.db.Accounts(); err == nil {
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
if a.Slug == slug {
|
imported = append(imported, a.Slug)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
return false
|
||||||
|
}
|
||||||
|
in.SetValue(s)
|
||||||
|
in.CursorEnd()
|
||||||
|
in.SetSuggestions(in.AvailableSuggestions())
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadRuleList counts, for every rule, how many transactions it actually
|
// 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.ruleAccount.SetValue(m.filter.AccountSlug)
|
||||||
}
|
}
|
||||||
m.setRuleFocus(0)
|
m.setRuleFocus(0)
|
||||||
|
if err := m.reloadSuggestions(); err != nil {
|
||||||
|
m.err = err
|
||||||
|
}
|
||||||
if err := m.reloadUntagged(); err != nil {
|
if err := m.reloadUntagged(); err != nil {
|
||||||
m.err = err
|
m.err = err
|
||||||
}
|
}
|
||||||
@@ -752,12 +843,31 @@ func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "esc":
|
case "esc":
|
||||||
m.view = m.ruleReturn
|
m.view = m.ruleReturn
|
||||||
return m, nil
|
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)
|
m.setRuleFocus(m.ruleFocus + 1)
|
||||||
return m, nil
|
return m, nil
|
||||||
case "shift+tab", "up":
|
case "shift+tab", "up":
|
||||||
m.setRuleFocus(m.ruleFocus - 1)
|
m.setRuleFocus(m.ruleFocus - 1)
|
||||||
return m, nil
|
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":
|
case "pgdown":
|
||||||
m.ruleTable.MoveDown(10)
|
m.ruleTable.MoveDown(10)
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -1141,9 +1251,9 @@ func (m *Model) ruleFormView() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(field(0, "glob", "vs. the description"))
|
b.WriteString(field(0, "glob", "vs. the description"))
|
||||||
b.WriteString("\n")
|
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("\n")
|
||||||
b.WriteString(field(2, "tag", "applied to matches"))
|
b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
// The count is the whole point of the preview: it says what the rule will
|
// 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())
|
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
|
// emptyMessage explains an empty view and names the key that fixes it, or
|
||||||
// returns "" when there is something to show.
|
// returns "" when there is something to show.
|
||||||
func (m *Model) emptyMessage() string {
|
func (m *Model) emptyMessage() string {
|
||||||
@@ -1261,7 +1388,7 @@ func (m *Model) help() string {
|
|||||||
case viewAccounts:
|
case viewAccounts:
|
||||||
return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · i import · r retag · q quit"
|
return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · i import · r retag · q quit"
|
||||||
case viewRules:
|
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:
|
case viewRuleList:
|
||||||
if m.confirm != confirmNone {
|
if m.confirm != confirmNone {
|
||||||
return "y confirm · any other key cancels"
|
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,
|
// newRuleListModel seeds a data root whose rules.toml holds one useful rule,
|
||||||
// one shadowed rule and one that matches nothing.
|
// one shadowed rule and one that matches nothing.
|
||||||
func newRuleListModel(t *testing.T) (*Model, string) {
|
func newRuleListModel(t *testing.T) (*Model, string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user