Sort the rule builder's preview by count

Alphabetical order finds a payee you are already looking at, which is what the
list is for once a glob is typed. With the glob empty it answers a different
question -- what to write a rule for next -- and there the name is the least
useful thing about a row: one pattern claiming forty rows is worth more than
the first of forty claiming one, and nothing on screen said which was which
until you read the whole list.

ctrl+s switches between the two. A chord rather than a letter because the
builder is a form and every printable key belongs to the field being typed in;
the invariant that keeps q from quitting there cuts this way too. Ties fall
back to the alphabetical order so the list does not reshuffle when it is
rebuilt, and the cursor returns to the top, since the row under it is not the
row that was there a moment ago.

The header marks the column the order is read from, and the help names what the
key does next rather than where the list already is. The choice is the user's,
so it outlives the builder being left and reopened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:33:46 +02:00
co-authored by Claude Opus 5
parent 6477147988
commit 656e7e1de4
4 changed files with 137 additions and 13 deletions
+3 -1
View File
@@ -146,7 +146,9 @@ hand, while a narrower one is meant to take precedence and does.
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal` `Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit
and `i` would start an import. Any new full-screen input needs the same and `i` would start an import. Any new full-screen input needs the same
treatment. treatment. It cuts the other way too: a command *inside* a builder has to be a
chord — `ctrl+s` re-sorts the preview — because every printable key belongs to
the field being typed in.
**In the rule builder, `tab` completes first and moves focus second.** The **In the rule builder, `tab` completes first and moves focus second.** The
account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion` account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion`
+12 -3
View File
@@ -123,8 +123,8 @@ nowhere else, so tagging what you are looking at means writing a rule for it on
Writing rules by hand means guessing what a glob will catch. This screen shows Writing rules by hand means guessing what a glob will catch. This screen shows
the answer as you type: the form is on the left, and on the right the the answer as you type: the form is on the left, and on the right the
still-untagged descriptions the glob currently matches, marked `▸`, grouped and still-untagged descriptions the glob currently matches, marked `▸`, grouped by
sorted alphabetically, under a running "*n* of *m* descriptions match" count. description, under a running "*n* of *m* descriptions match" count.
The list narrows as you type, so what is on screen is what the rule would The list narrows as you type, so what is on screen is what the rule would
claim — nothing else is left there to read past. The count keeps the context claim — nothing else is left there to read past. The count keeps the context
@@ -135,7 +135,7 @@ untagged description in the data — which is the other question this screen
answers, and where you go looking for the next thing to write a rule for. answers, and where you go looking for the next thing to write a rule for.
``` ```
glob Untagged description N glob Untagged description ↓ N
╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2 ╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2
│ *LIDL* │ ▸ LIDL VARNA 9911 1 │ *LIDL* │ ▸ LIDL VARNA 9911 1
╰────────────────────────────╯ ╰────────────────────────────╯
@@ -170,6 +170,15 @@ immediately, so the rows it caught disappear from the list. `esc` goes back.
On a short window the form gives up its spacing and then its hints, so all four On a short window the form gives up its spacing and then its hints, so all four
fields stay on screen. fields stay on screen.
`ctrl+s` switches the list between by name and by count, most seen first — a
chord rather than a letter, because every printable key belongs to the field you
are typing in. The `↓` in the header says which column the order is read from.
The two answer different questions: by name finds the payee you are looking at,
by count finds the rule worth writing next, since one pattern claiming forty
rows is worth more than the first of forty claiming one. Ties keep the
alphabetical order, so the list does not reshuffle under you, and the choice
lasts until you change it.
The account and tag fields complete as you type: the rest of the match is 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) 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 takes it. When several candidates share the prefix, the hint under the box says
+61 -9
View File
@@ -52,6 +52,18 @@ const (
confirmPruneTransfers confirmPruneTransfers
) )
// previewSort is the order the rule builder lists descriptions in. The two
// answer different questions, which is why both are here rather than one being
// chosen for the user: by name is how a payee you are looking at is found, and
// by count is where the rule worth writing next is — one pattern that claims
// forty rows, rather than the first of forty patterns claiming one.
type previewSort int
const (
sortByName previewSort = iota
sortByCount
)
// input is the modal state: the transaction list is read-only until the user // input is the modal state: the transaction list is read-only until the user
// starts typing a search. // starts typing a search.
type input int type input int
@@ -94,6 +106,7 @@ type Model struct {
ruleTable table.Model ruleTable table.Model
ruleReturn view // the view to go back to on esc ruleReturn view // the view to go back to on esc
previewGroups []descGroup previewGroups []descGroup
previewSort previewSort
ruleMatches int // previewed descriptions the current glob matches ruleMatches int // previewed descriptions the current glob matches
// ruleCandidates is how many were in view before the glob filtered them, // ruleCandidates is how many were in view before the glob filtered them,
// which the count needs: the preview now shows only matches, so the rows on // which the count needs: the preview now shows only matches, so the rows on
@@ -497,26 +510,46 @@ func (m *Model) reloadPreviewGroups() error {
for _, g := range byDesc { for _, g := range byDesc {
m.previewGroups = append(m.previewGroups, *g) m.previewGroups = append(m.previewGroups, *g)
} }
m.sortPreviewGroups()
return nil
}
// sortPreviewGroups puts the preview in the order the user last chose, marks
// the column it is sorted by, and re-renders it.
func (m *Model) sortPreviewGroups() {
sort.Slice(m.previewGroups, func(i, j int) bool { sort.Slice(m.previewGroups, func(i, j int) bool {
a := model.NormalizeDescription(m.previewGroups[i].Description) a, b := m.previewGroups[i], m.previewGroups[j]
b := model.NormalizeDescription(m.previewGroups[j].Description) // Count first when asked, but ties still fall back to the alphabetical
if a != b { // order rather than to map order, or the list would reshuffle under the
return a < b // cursor every time it is rebuilt.
if m.previewSort == sortByCount && a.Count != b.Count {
return a.Count > b.Count
} }
return m.previewGroups[i].Description < m.previewGroups[j].Description x, y := model.NormalizeDescription(a.Description), model.NormalizeDescription(b.Description)
if x != y {
return x < y
}
return a.Description < b.Description
}) })
// The column says what is in it, which an edit changes: the rows it claims // Each column says what is in it. The description changes with the mode —
// are on screen precisely because they are not waiting for a rule. // an edit's rows are on screen precisely because they are *not* waiting for
// a rule — and the ↓ names the column the order is read from, since two
// sorts of one list are otherwise told apart only by squinting at it.
cols := m.ruleTable.Columns() cols := m.ruleTable.Columns()
cols[1].Title = "Untagged description" cols[1].Title = "Untagged description"
if m.ruleEditing { if m.ruleEditing {
cols[1].Title = "Description" cols[1].Title = "Description"
} }
cols[2].Title = "N"
if m.previewSort == sortByCount {
cols[2].Title += " ↓"
} else {
cols[1].Title += " ↓"
}
m.ruleTable.SetColumns(cols) m.ruleTable.SetColumns(cols)
m.refreshRulePreview() m.refreshRulePreview()
return nil
} }
// refreshRulePreview re-filters the preview against whatever is typed right // refreshRulePreview re-filters the preview against whatever is typed right
@@ -1627,6 +1660,19 @@ func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "pgup": case "pgup":
m.ruleTable.MoveUp(10) m.ruleTable.MoveUp(10)
return m, nil return m, nil
case "ctrl+s":
// A form owns every printable key, so re-sorting has to be a chord:
// "s" belongs to whichever glob is being typed.
if m.previewSort == sortByName {
m.previewSort = sortByCount
} else {
m.previewSort = sortByName
}
m.sortPreviewGroups()
// The rows under the cursor are not the rows that were there a moment
// ago, so start from the top rather than somewhere arbitrary.
m.ruleTable.SetCursor(0)
return m, nil
case "enter": case "enter":
m.err = nil m.err = nil
editing := m.ruleEditing // saveRule clears it on the way through editing := m.ruleEditing // saveRule clears it on the way through
@@ -2189,8 +2235,14 @@ func (m *Model) help() string {
if m.ruleEditing { if m.ruleEditing {
save = "enter save changes" save = "enter save changes"
} }
// The key is named by what pressing it does now, not by the order the
// list is already in.
order := "ctrl+s sort by count"
if m.previewSort == sortByCount {
order = "ctrl+s sort by name"
}
return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · " + return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · " +
save + " · esc back · ctrl+c quit" order + " · " + save + " · 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"
+61
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -77,6 +78,8 @@ func key(t *testing.T, m *Model, s string) {
msg = tea.KeyMsg{Type: tea.KeyEnter} msg = tea.KeyMsg{Type: tea.KeyEnter}
case "esc": case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc} msg = tea.KeyMsg{Type: tea.KeyEsc}
case "ctrl+s":
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
default: default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
} }
@@ -2124,3 +2127,61 @@ func TestEscFromRuleListDoesNotBounceIntoTheBuilder(t *testing.T) {
t.Errorf("esc from the list went to %v, want the transactions it was opened from", m.view) t.Errorf("esc from the list went to %v, want the transactions it was opened from", m.view)
} }
} }
// Two orders, because the preview answers two questions: by name finds the
// payee you are looking at, by count finds the rule worth writing next.
func TestRuleBuilderSortsByCount(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
alphabetical := []string{
"AAA FIRST ALPHABETICALLY",
"LIDL SOFIA 4412",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if all, _ := previewRows(m); !slices.Equal(all, alphabetical) {
t.Fatalf("preview = %v, want %v by default", all, alphabetical)
}
key(t, m, "ctrl+s")
// The twice-seen description leads; the rest tie on one and keep the
// alphabetical order between them, so the list does not reshuffle.
byCount := []string{
"LIDL SOFIA 4412",
"AAA FIRST ALPHABETICALLY",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if all, _ := previewRows(m); !slices.Equal(all, byCount) {
t.Fatalf("preview = %v, want %v after sorting by count", all, byCount)
}
// The header names the column the order is read from, and the help names
// what the key does next rather than where the list already is.
if cols := m.ruleTable.Columns(); cols[2].Title != "N ↓" || strings.Contains(cols[1].Title, "↓") {
t.Errorf("headers = %q / %q, want the count column marked", cols[1].Title, cols[2].Title)
}
if !strings.Contains(m.help(), "ctrl+s sort by name") {
t.Errorf("help = %q, want the key to offer the way back", m.help())
}
key(t, m, "ctrl+s")
if all, _ := previewRows(m); !slices.Equal(all, alphabetical) {
t.Errorf("preview = %v, want %v back", all, alphabetical)
}
// The choice is the user's, so it survives a glob narrowing the list and
// the builder being left and reopened.
key(t, m, "ctrl+s")
typeText(t, m, "*LIDL*")
key(t, m, "esc")
key(t, m, "4")
if m.previewSort != sortByCount {
t.Fatal("the sort was reset by leaving the builder")
}
want := []string{"LIDL SOFIA 4412", "LIDL VARNA 9911"}
if all, _ := previewRows(m); !slices.Equal(all, want) {
t.Errorf("preview = %v, want %v: the glob still filters, in count order", all, want)
}
}