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:
+61
-9
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user