diff --git a/CLAUDE.md b/CLAUDE.md index bc58a9c..42fb057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,9 @@ hand, while a narrower one is meant to take precedence and does. `Update` routes to `updateRules` / `updateTransfers` before `updateNormal` 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 -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 account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion` diff --git a/README.md b/README.md index c1cf222..bd90029 100644 --- a/README.md +++ b/README.md @@ -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 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 -sorted alphabetically, under a running "*n* of *m* descriptions match" count. +still-untagged descriptions the glob currently matches, marked `▸`, grouped by +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 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. ``` - glob Untagged description N + glob Untagged description ↓ N ╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2 │ *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 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 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 diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d2ed2cf..32bfbab 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -52,6 +52,18 @@ const ( 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 // starts typing a search. type input int @@ -94,6 +106,7 @@ type Model struct { ruleTable table.Model ruleReturn view // the view to go back to on esc previewGroups []descGroup + previewSort previewSort ruleMatches int // previewed descriptions the current glob matches // 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 @@ -497,26 +510,46 @@ func (m *Model) reloadPreviewGroups() error { for _, g := range byDesc { 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 { - a := model.NormalizeDescription(m.previewGroups[i].Description) - b := model.NormalizeDescription(m.previewGroups[j].Description) - if a != b { - return a < b + a, b := m.previewGroups[i], m.previewGroups[j] + // Count first when asked, but ties still fall back to the alphabetical + // order rather than to map order, or the list would reshuffle under the + // 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 - // are on screen precisely because they are not waiting for a rule. + // Each column says what is in it. The description changes with the mode — + // 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[1].Title = "Untagged description" if m.ruleEditing { cols[1].Title = "Description" } + cols[2].Title = "N" + if m.previewSort == sortByCount { + cols[2].Title += " ↓" + } else { + cols[1].Title += " ↓" + } m.ruleTable.SetColumns(cols) m.refreshRulePreview() - return nil } // 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": m.ruleTable.MoveUp(10) 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": m.err = nil editing := m.ruleEditing // saveRule clears it on the way through @@ -2189,8 +2235,14 @@ func (m *Model) help() string { if m.ruleEditing { 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 · " + - save + " · esc back · ctrl+c quit" + order + " · " + save + " · 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 a661e96..b133bdf 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -77,6 +78,8 @@ func key(t *testing.T, m *Model, s string) { msg = tea.KeyMsg{Type: tea.KeyEnter} case "esc": msg = tea.KeyMsg{Type: tea.KeyEsc} + case "ctrl+s": + msg = tea.KeyMsg{Type: tea.KeyCtrlS} default: 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) } } + +// 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) + } +}