Let rules carry a note
A glob like *4412* says nothing about why it exists or who it catches, and six months later neither does memory. Rules get an optional note: free text that never takes part in matching, written as a TOML key rather than a comment so it survives a round trip and can be shown back. The rule builder grows a fourth field for it and the rules screen a last column. Four fields spaced out are taller than a short window has room for, so the form now drops its blank lines and then the hints on unfocused fields before anything would run off the bottom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+47
-20
@@ -83,7 +83,8 @@ type Model struct {
|
||||
ruleGlob textinput.Model
|
||||
ruleAccount textinput.Model
|
||||
ruleTag textinput.Model
|
||||
ruleFocus int // which of the three inputs has the cursor
|
||||
ruleNote textinput.Model
|
||||
ruleFocus int // which of the inputs has the cursor
|
||||
ruleTable table.Model
|
||||
ruleReturn view // the view to go back to on esc
|
||||
untagged []descGroup
|
||||
@@ -196,6 +197,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
||||
ruleGlob: newInput("*LIDL*"),
|
||||
ruleAccount: completing(newInput("blank = every account")),
|
||||
ruleTag: completing(newInput("groceries")),
|
||||
ruleNote: newInput("optional"),
|
||||
ruleTable: newTable([]table.Column{
|
||||
{Title: " ", Width: 1},
|
||||
{Title: "Untagged description", Width: 44},
|
||||
@@ -209,6 +211,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
||||
{Title: "Tag", Width: 14},
|
||||
{Title: "T", Width: 1},
|
||||
{Title: "Txns", Width: 6},
|
||||
{Title: "Note", Width: 24},
|
||||
}),
|
||||
reportTable: newTable([]table.Column{
|
||||
{Title: "Tag", Width: 20},
|
||||
@@ -416,6 +419,7 @@ func (m *Model) saveRule() error {
|
||||
Match: strings.TrimSpace(m.ruleGlob.Value()),
|
||||
Account: strings.TrimSpace(m.ruleAccount.Value()),
|
||||
Tag: strings.TrimSpace(m.ruleTag.Value()),
|
||||
Note: strings.TrimSpace(m.ruleNote.Value()),
|
||||
}
|
||||
if r.Match == "" {
|
||||
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
|
||||
@@ -447,6 +451,7 @@ func (m *Model) saveRule() error {
|
||||
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
|
||||
m.ruleGlob.SetValue("")
|
||||
m.ruleTag.SetValue("")
|
||||
m.ruleNote.SetValue("")
|
||||
m.setRuleFocus(0)
|
||||
if err := m.reloadSuggestions(); err != nil {
|
||||
return err
|
||||
@@ -578,7 +583,7 @@ func (m *Model) reloadRuleList() error {
|
||||
}
|
||||
rows = append(rows, table.Row{
|
||||
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer,
|
||||
fmt.Sprintf("%d", m.ruleUsage[i]),
|
||||
fmt.Sprintf("%d", m.ruleUsage[i]), r.Note,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -756,7 +761,7 @@ func (m *Model) openRuleBuilder() tea.Cmd {
|
||||
|
||||
// ruleInputs lists the form fields in tab order.
|
||||
func (m *Model) ruleInputs() []*textinput.Model {
|
||||
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag}
|
||||
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag, &m.ruleNote}
|
||||
}
|
||||
|
||||
// setRuleFocus moves the cursor between the form fields, wrapping around.
|
||||
@@ -957,20 +962,28 @@ func (m *Model) resize() {
|
||||
m.ruleTable.SetColumns(cols)
|
||||
}
|
||||
|
||||
// Give the description column whatever is left over.
|
||||
if m.width > 0 {
|
||||
cols := m.txnTable.Columns()
|
||||
fixed := 0
|
||||
for _, c := range cols[:len(cols)-1] {
|
||||
fixed += c.Width + 2
|
||||
}
|
||||
desc := m.width - fixed - 4
|
||||
if desc < 20 {
|
||||
desc = 20
|
||||
}
|
||||
cols[len(cols)-1].Width = desc
|
||||
m.txnTable.SetColumns(cols)
|
||||
// The free-text columns come last and get whatever is left over.
|
||||
m.stretchLastColumn(&m.txnTable, 20)
|
||||
m.stretchLastColumn(&m.ruleListTable, 12)
|
||||
}
|
||||
|
||||
// stretchLastColumn widens a table's final column to fill the window, down to
|
||||
// a floor below which truncation is worse than letting the row overflow.
|
||||
func (m *Model) stretchLastColumn(t *table.Model, min int) {
|
||||
if m.width <= 0 {
|
||||
return
|
||||
}
|
||||
cols := t.Columns()
|
||||
fixed := 0
|
||||
for _, c := range cols[:len(cols)-1] {
|
||||
fixed += c.Width + 2
|
||||
}
|
||||
last := m.width - fixed - 4
|
||||
if last < min {
|
||||
last = min
|
||||
}
|
||||
cols[len(cols)-1].Width = last
|
||||
t.SetColumns(cols)
|
||||
}
|
||||
|
||||
// updateInput handles typing into the tag or search prompt.
|
||||
@@ -1238,6 +1251,15 @@ func (m *Model) rulesView() string {
|
||||
}
|
||||
|
||||
func (m *Model) ruleFormView() string {
|
||||
// Spaced out, the four fields come to 25 lines, which is more than a short
|
||||
// window has left once the same allowance the tables get is taken off.
|
||||
// Give up the blank lines between fields first (21 lines) and the hints on
|
||||
// unfocused fields second (18), rather than letting the last field run off
|
||||
// the bottom. Below that the box borders are the floor.
|
||||
room := m.height - 6
|
||||
spaced := m.height <= 0 || room >= 25
|
||||
hints := m.height <= 0 || room >= 21
|
||||
|
||||
field := func(i int, label, help string) string {
|
||||
name := labelStyle.Render(" " + label)
|
||||
box := boxStyle.Render(m.ruleInputs()[i].View())
|
||||
@@ -1245,16 +1267,21 @@ func (m *Model) ruleFormView() string {
|
||||
name = focusedLabelStyle.Render("▸ " + label)
|
||||
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
|
||||
}
|
||||
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n"
|
||||
out := name + "\n" + box + "\n"
|
||||
if hints || i == m.ruleFocus {
|
||||
out += hintStyle.Render(help) + "\n"
|
||||
}
|
||||
if spaced {
|
||||
out += "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(field(0, "glob", "vs. the description"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts")))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(field(3, "note", "why this rule exists"))
|
||||
|
||||
// The count is the whole point of the preview: it says what the rule will
|
||||
// do before it is written to disk.
|
||||
|
||||
Reference in New Issue
Block a user