Files
money/internal/tui/tui.go
T
nikolaandClaude Opus 5 73fefea96d 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>
2026-08-09 23:06:31 +02:00

1403 lines
36 KiB
Go

// Package tui is the terminal interface: browse accounts, tag transactions,
// mark transfers by hand, and see where the money went.
package tui
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/glob"
"git.petrovv.com/nikola/money/internal/importer"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/report"
"git.petrovv.com/nikola/money/internal/rules"
"git.petrovv.com/nikola/money/internal/store"
)
type view int
const (
viewAccounts view = iota
viewTxns
viewReport
viewRules
viewRuleList
viewCount = 5
)
// confirmation is a pending destructive action awaiting a y/n answer.
// Deleting rewrites a file the user maintains by hand, so it is never done on
// a single keypress.
type confirmation int
const (
confirmNone confirmation = iota
confirmDeleteRule
confirmPruneRules
)
// input is the modal state: the transaction list is read-only until the user
// starts typing a tag or a search.
type input int
const (
inputNone input = iota
inputTag
inputSearch
)
// Model is the root Bubble Tea model.
type Model struct {
root string
db *store.DB
accounts []*config.Account
engine *rules.Engine
view view
input input
accountTable table.Model
txnTable table.Model
reportTable table.Model
text textinput.Model
spinner spinner.Model
// importing is set while an import runs in the background, so the UI keeps
// redrawing instead of freezing on a slow PDF.
importing bool
txns []model.Transaction // rows currently shown in txnTable
// Rule builder: three inputs on the left, and a live preview on the right
// of which untagged descriptions the glob would catch.
ruleGlob textinput.Model
ruleAccount textinput.Model
ruleTag textinput.Model
ruleFocus int // which of the three inputs has the cursor
ruleTable table.Model
ruleReturn view // the view to go back to on esc
untagged []descGroup
ruleMatches int // untagged descriptions the current glob matches
// Rule list: every rule with the number of transactions it actually
// claims, so dead ones can be found and removed.
ruleListTable table.Model
ruleUsage []int
confirm confirmation
filter store.Filter
onlyUntagged bool
status string
err error
width int
height int
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1)
statusStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1)
helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
emptyStyle = lipgloss.NewStyle().Padding(1, 2)
labelStyle = lipgloss.NewStyle().Faint(true)
focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62"))
// Both boxes are pinned to the same width: a focused input renders one
// extra cell for the cursor, which would otherwise shift the border as
// the cursor moves between fields.
boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2)
focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2)
hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2)
matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2)
ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth)
)
// Run starts the interface.
func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) error {
m := New(root, db, accounts, engine)
if err := m.reload(); err != nil {
return err
}
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
}
// New builds the root model.
func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) *Model {
ti := textinput.New()
ti.Prompt = ""
ti.CharLimit = 64
newInput := func(placeholder string) textinput.Model {
in := textinput.New()
in.Prompt = ""
in.CharLimit = 80
in.Width = ruleInputWidth
in.Placeholder = placeholder
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 = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
styles := table.DefaultStyles()
styles.Header = styles.Header.Bold(true)
styles.Selected = styles.Selected.Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62"))
newTable := func(cols []table.Column) table.Model {
return table.New(table.WithColumns(cols), table.WithFocused(true),
table.WithHeight(15), table.WithStyles(styles))
}
return &Model{
root: root,
db: db,
accounts: accounts,
engine: engine,
view: viewAccounts,
text: ti,
spinner: sp,
accountTable: newTable([]table.Column{
{Title: "Account", Width: 20},
{Title: "Balance", Width: 14},
{Title: "Cur", Width: 4},
{Title: "Txns", Width: 6},
}),
txnTable: newTable([]table.Column{
{Title: "Date", Width: 10},
{Title: "Account", Width: 12},
{Title: "Amount", Width: 12},
{Title: "Tag", Width: 14},
{Title: "T", Width: 1},
{Title: "Description", Width: 40},
}),
ruleGlob: newInput("*LIDL*"),
ruleAccount: completing(newInput("blank = every account")),
ruleTag: completing(newInput("groceries")),
ruleTable: newTable([]table.Column{
{Title: " ", Width: 1},
{Title: "Untagged description", Width: 44},
{Title: "N", Width: 4},
}),
ruleListTable: newTable([]table.Column{
{Title: "#", Width: 3},
{Title: " ", Width: 1},
{Title: "Pattern", Width: 34},
{Title: "Account", Width: 12},
{Title: "Tag", Width: 14},
{Title: "T", Width: 1},
{Title: "Txns", Width: 6},
}),
reportTable: newTable([]table.Column{
{Title: "Tag", Width: 20},
{Title: "Cur", Width: 4},
{Title: "Out", Width: 14},
{Title: "In", Width: 14},
{Title: "Net", Width: 14},
{Title: "N", Width: 5},
}),
}
}
// Init implements tea.Model.
func (m *Model) Init() tea.Cmd { return nil }
// reload refreshes whichever view is active from the index.
func (m *Model) reload() error {
if err := m.reloadAccounts(); err != nil {
return err
}
return m.reloadTxns()
}
func (m *Model) reloadAccounts() error {
accounts, err := m.db.Accounts()
if err != nil {
return err
}
rows := make([]table.Row, 0, len(accounts))
for _, a := range accounts {
bal, err := m.db.Balance(a.ID)
if err != nil {
return err
}
n, err := m.db.Count(a.ID)
if err != nil {
return err
}
rows = append(rows, table.Row{
a.Name, model.FormatMinor(bal, a.MinorDigits), a.Currency, fmt.Sprintf("%d", n),
})
}
m.accountTable.SetRows(rows)
return nil
}
func (m *Model) reloadTxns() error {
f := m.filter
f.Untagged = m.onlyUntagged
txns, err := m.db.Transactions(f)
if err != nil {
return err
}
m.txns = txns
rows := make([]table.Row, 0, len(txns))
for _, t := range txns {
transfer := ""
if t.IsTransfer() {
transfer = "T"
}
tag := t.Tag()
if tag != "" && t.ManualTag != "" {
tag += "*" // marks a manual override
}
rows = append(rows, table.Row{
t.Date, t.AccountSlug, t.FormatAmount(), tag, transfer, t.Description,
})
}
// Keep the cursor in range after the row count shrinks (e.g. tagging the
// last untagged row while the untagged filter is on).
cursor := m.txnTable.Cursor()
m.txnTable.SetRows(rows)
if cursor >= len(rows) {
cursor = len(rows) - 1
}
if cursor < 0 {
cursor = 0
}
m.txnTable.SetCursor(cursor)
m.reloadReport(txns)
return nil
}
func (m *Model) reloadReport(txns []model.Transaction) {
rows := report.ByTag(txns)
out := make([]table.Row, 0, len(rows)+2)
for _, r := range rows {
out = append(out, table.Row{
r.Tag, r.Currency,
model.FormatMinor(r.Out, r.Digits),
model.FormatMinor(r.In, r.Digits),
model.FormatMinor(r.Net(), r.Digits),
fmt.Sprintf("%d", r.Count),
})
}
for _, c := range report.Totals(rows) {
out = append(out, table.Row{
"TOTAL", c.Currency,
model.FormatMinor(c.Out, c.Digits),
model.FormatMinor(c.In, c.Digits),
model.FormatMinor(c.Net(), c.Digits),
"",
})
}
m.reportTable.SetRows(out)
}
// ruleFormWidth is the width of the rule builder's left-hand form, and
// ruleInputWidth the editable area inside its boxes. The form must stay wider
// than a box plus its border, padding and the block cursor, or lipgloss wraps
// the border and the panel falls apart.
const (
ruleInputWidth = 26
ruleFormWidth = ruleInputWidth + 12
)
// descGroup is one distinct untagged description and how often it occurs.
// Grouping matters: a statement holds the same payee dozens of times, and a
// rule is written against the description, not against individual rows.
type descGroup struct {
Description string
Accounts map[string]bool
Count int
}
// reloadUntagged rebuilds the alphabetical list of untagged descriptions that
// the rule builder previews against.
func (m *Model) reloadUntagged() error {
txns, err := m.db.Transactions(store.Filter{Untagged: true})
if err != nil {
return err
}
byDesc := map[string]*descGroup{}
for _, t := range txns {
key := model.NormalizeDescription(t.Description)
g, ok := byDesc[key]
if !ok {
g = &descGroup{Description: t.Description, Accounts: map[string]bool{}}
byDesc[key] = g
}
g.Accounts[t.AccountSlug] = true
g.Count++
}
m.untagged = make([]descGroup, 0, len(byDesc))
for _, g := range byDesc {
m.untagged = append(m.untagged, *g)
}
sort.Slice(m.untagged, func(i, j int) bool {
a := model.NormalizeDescription(m.untagged[i].Description)
b := model.NormalizeDescription(m.untagged[j].Description)
if a != b {
return a < b
}
return m.untagged[i].Description < m.untagged[j].Description
})
m.refreshRulePreview()
return nil
}
// refreshRulePreview re-marks the preview against whatever is typed right now.
// It runs on every keystroke, so the glob is checked against descriptions only,
// never against the database.
func (m *Model) refreshRulePreview() {
var (
pattern = strings.TrimSpace(m.ruleGlob.Value())
account = strings.TrimSpace(m.ruleAccount.Value())
rows = make([]table.Row, 0, len(m.untagged))
)
m.ruleMatches = 0
for _, g := range m.untagged {
// An account filter narrows the preview the same way the saved rule
// will narrow its matching.
if account != "" && !g.Accounts[account] {
continue
}
marker := " "
if pattern != "" && glob.Match(pattern, model.NormalizeDescription(g.Description)) {
marker = "▸"
m.ruleMatches++
}
rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)})
}
cursor := m.ruleTable.Cursor()
m.ruleTable.SetRows(rows)
if cursor >= len(rows) {
cursor = len(rows) - 1
}
if cursor < 0 {
cursor = 0
}
m.ruleTable.SetCursor(cursor)
}
// saveRule appends the composed rule to rules.toml, reloads the engine and
// retags, so the effect is visible immediately.
func (m *Model) saveRule() error {
r := config.Rule{
Match: strings.TrimSpace(m.ruleGlob.Value()),
Account: strings.TrimSpace(m.ruleAccount.Value()),
Tag: strings.TrimSpace(m.ruleTag.Value()),
}
if r.Match == "" {
return fmt.Errorf("enter a glob first, e.g. *LIDL*")
}
if r.Tag == "" {
return fmt.Errorf("enter a tag to apply")
}
if r.Account != "" && !m.knownAccount(r.Account) {
return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account)
}
if err := config.AppendRule(m.root, r); err != nil {
return err
}
// Re-read the file rather than appending to the in-memory engine, so what
// runs is exactly what is now on disk.
loaded, err := config.LoadRules(m.root)
if err != nil {
return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err)
}
m.engine = rules.New(loaded)
n, err := m.engine.Retag(m.db)
if err != nil {
return err
}
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
m.ruleGlob.SetValue("")
m.ruleTag.SetValue("")
m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil {
return err
}
return m.reload()
}
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 {
configured = append(configured, a.Slug)
}
var imported []string
if accounts, err := m.db.Accounts(); err == nil {
for _, a := range accounts {
imported = append(imported, a.Slug)
}
}
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
}
in.SetValue(s)
in.CursorEnd()
in.SetSuggestions(in.AvailableSuggestions())
return true
}
// reloadRuleList counts, for every rule, how many transactions it actually
// claims. A rule can match nothing because no description fits it, or because
// an earlier rule already took everything it would have caught; both show up
// here as a zero.
func (m *Model) reloadRuleList() error {
txns, err := m.db.Transactions(store.Filter{})
if err != nil {
return err
}
rs := m.engine.Rules()
m.ruleUsage = m.engine.Usage(txns)
rows := make([]table.Row, 0, len(rs))
for i, r := range rs {
marker := " "
if m.ruleUsage[i] == 0 {
marker = "✗"
}
transfer := ""
if r.Transfer {
transfer = "T"
}
account := r.Account
if account == "" {
account = "(all)"
}
rows = append(rows, table.Row{
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer,
fmt.Sprintf("%d", m.ruleUsage[i]),
})
}
cursor := m.ruleListTable.Cursor()
m.ruleListTable.SetRows(rows)
if cursor >= len(rows) {
cursor = len(rows) - 1
}
if cursor < 0 {
cursor = 0
}
m.ruleListTable.SetCursor(cursor)
return nil
}
// rulePattern renders whichever patterns a rule sets, labelled so a
// counterparty or type rule is not mistaken for a description one.
func rulePattern(r config.Rule) string {
var parts []string
if r.Match != "" {
parts = append(parts, r.Match)
}
if r.Counterparty != "" {
parts = append(parts, "counterparty:"+r.Counterparty)
}
if r.Type != "" {
parts = append(parts, "type:"+r.Type)
}
return strings.Join(parts, " + ")
}
// unusedRules lists the positions of every rule claiming no transactions.
func (m *Model) unusedRules() []int {
var out []int
for i, n := range m.ruleUsage {
if n == 0 {
out = append(out, i)
}
}
return out
}
// deleteRules removes rules from rules.toml, then reloads and retags so the
// counts on screen reflect the new file.
func (m *Model) deleteRules(positions []int) error {
n, err := config.DeleteRules(m.root, positions)
if err != nil {
return err
}
loaded, err := config.LoadRules(m.root)
if err != nil {
return fmt.Errorf("rules deleted, but re-reading rules.toml failed: %w", err)
}
m.engine = rules.New(loaded)
retagged, err := m.engine.Retag(m.db)
if err != nil {
return err
}
m.status = fmt.Sprintf("deleted %d rule(s), %d transactions retagged", n, retagged)
if err := m.reload(); err != nil {
return err
}
return m.reloadRuleList()
}
// openRuleList switches to the rule list, remembering where to return to.
func (m *Model) openRuleList() {
if m.view != viewRuleList {
m.ruleReturn = m.view
}
m.view = viewRuleList
m.confirm = confirmNone
if err := m.reloadRuleList(); err != nil {
m.err = err
}
}
// updateRuleList drives the rule list, including the delete confirmations.
func (m *Model) updateRuleList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.confirm != confirmNone {
pending := m.confirm
m.confirm = confirmNone
if msg.String() != "y" {
m.status = "cancelled"
return m, nil
}
m.err = nil
switch pending {
case confirmDeleteRule:
if i := m.ruleListTable.Cursor(); i >= 0 && i < len(m.ruleUsage) {
if err := m.deleteRules([]int{i}); err != nil {
m.err = err
}
}
case confirmPruneRules:
if err := m.deleteRules(m.unusedRules()); err != nil {
m.err = err
}
}
return m, nil
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "esc":
m.view = m.ruleReturn
return m, nil
case "1":
m.view = viewAccounts
return m, nil
case "2":
m.view = viewTxns
return m, nil
case "3":
m.view = viewReport
return m, nil
case "4":
return m, m.openRuleBuilder()
case "d":
i := m.ruleListTable.Cursor()
rs := m.engine.Rules()
if i < 0 || i >= len(rs) {
return m, nil
}
m.confirm = confirmDeleteRule
m.status = fmt.Sprintf("delete rule %d (%s → %s), used by %d transactions? y/n",
i+1, rulePattern(rs[i]), rs[i].Tag, m.ruleUsage[i])
return m, nil
case "p":
unused := m.unusedRules()
if len(unused) == 0 {
m.status = "no unused rules to prune"
return m, nil
}
m.confirm = confirmPruneRules
m.status = fmt.Sprintf("delete all %d rules that match nothing? y/n", len(unused))
return m, nil
case "r":
m.err = m.reloadRuleList()
m.status = "counts refreshed"
return m, nil
}
var cmd tea.Cmd
m.ruleListTable, cmd = m.ruleListTable.Update(msg)
return m, cmd
}
// openRuleBuilder switches to the rule builder, remembering where to return to
// and seeding the account field from whatever account is being browsed.
func (m *Model) openRuleBuilder() tea.Cmd {
if m.view != viewRules {
m.ruleReturn = m.view
}
m.view = viewRules
if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" {
m.ruleAccount.SetValue(m.filter.AccountSlug)
}
m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil {
m.err = err
}
if err := m.reloadUntagged(); err != nil {
m.err = err
}
return textinput.Blink
}
// ruleInputs lists the form fields in tab order.
func (m *Model) ruleInputs() []*textinput.Model {
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag}
}
// setRuleFocus moves the cursor between the form fields, wrapping around.
func (m *Model) setRuleFocus(i int) {
inputs := m.ruleInputs()
n := len(inputs)
m.ruleFocus = ((i % n) + n) % n
for j, in := range inputs {
if j == m.ruleFocus {
in.Focus()
in.CursorEnd()
continue
}
in.Blur()
}
}
// selected returns the transaction under the cursor, if any.
func (m *Model) selected() (model.Transaction, bool) {
i := m.txnTable.Cursor()
if i < 0 || i >= len(m.txns) {
return model.Transaction{}, false
}
return m.txns[i], true
}
// importDoneMsg carries the outcome of a background import back to the model.
type importDoneMsg struct {
res importer.Result
err error
}
// importCmd runs the import off the event loop. Only values are captured, and
// the model is left untouched until the result comes back as a message.
func (m *Model) importCmd() tea.Cmd {
root, db, accounts, engine := m.root, m.db, m.accounts, m.engine
return func() tea.Msg {
res, err := importer.Run(root, db, accounts, engine, importer.Options{})
return importDoneMsg{res: res, err: err}
}
}
// Update implements tea.Model.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.resize()
return m, nil
case spinner.TickMsg:
if !m.importing {
return m, nil // a stale tick from a finished import
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case importDoneMsg:
return m.finishImport(msg)
case tea.KeyMsg:
if m.input != inputNone {
return m.updateInput(msg)
}
// The rule builder is a form: every printable key belongs to the
// focused input, so the global single-letter keymap cannot apply.
if m.view == viewRules {
return m.updateRules(msg)
}
if m.view == viewRuleList {
return m.updateRuleList(msg)
}
return m.updateNormal(msg)
}
return m, nil
}
// updateRules drives the rule builder form.
func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.view = m.ruleReturn
return m, nil
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)
return m, nil
case "shift+tab", "up":
m.setRuleFocus(m.ruleFocus - 1)
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":
m.ruleTable.MoveDown(10)
return m, nil
case "pgup":
m.ruleTable.MoveUp(10)
return m, nil
case "enter":
m.err = nil
if err := m.saveRule(); err != nil {
m.err = err
return m, nil
}
if err := m.reloadUntagged(); err != nil {
m.err = err
}
return m, nil
}
var cmd tea.Cmd
inputs := m.ruleInputs()
*inputs[m.ruleFocus], cmd = inputs[m.ruleFocus].Update(msg)
m.refreshRulePreview()
return m, cmd
}
func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) {
m.importing = false
if msg.err != nil {
m.err = msg.err
m.status = "import failed"
return m, nil
}
_, added, skipped := msg.res.Total()
m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped)
// A failed file and a warning are both worth surfacing, but the status
// line only has room for the first thing that went wrong.
if failures := msg.res.Errs(); len(failures) > 0 {
m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err)
} else if warnings := firstWarning(msg.res); warnings != "" {
m.status += " · " + warnings
}
if err := m.reload(); err != nil {
m.err = err
}
return m, nil
}
func firstWarning(res importer.Result) string {
total := 0
first := ""
for _, f := range res.Files {
for _, w := range f.Warnings {
if first == "" {
first = fmt.Sprintf("%s: %s", f.Path, w)
}
total++
}
}
if total > 1 {
return fmt.Sprintf("%s (+%d more warnings)", first, total-1)
}
return first
}
func (m *Model) resize() {
h := m.height - 6 // title, status, help, padding
if h < 3 {
h = 3
}
m.accountTable.SetHeight(h)
m.txnTable.SetHeight(h)
m.reportTable.SetHeight(h)
m.ruleTable.SetHeight(h)
m.ruleListTable.SetHeight(h)
// The preview list gets whatever the form does not use.
if m.width > 0 {
cols := m.ruleTable.Columns()
desc := m.width - ruleFormWidth - cols[0].Width - cols[2].Width - 10
if desc < 20 {
desc = 20
}
cols[1].Width = desc
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)
}
}
// updateInput handles typing into the tag or search prompt.
func (m *Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
m.input = inputNone
m.text.Blur()
m.text.SetValue("")
return m, nil
case tea.KeyEnter:
value := strings.TrimSpace(m.text.Value())
mode := m.input
m.input = inputNone
m.text.Blur()
m.text.SetValue("")
switch mode {
case inputTag:
t, ok := m.selected()
if !ok {
return m, nil
}
if err := m.db.SetManualTag(t.ID, value); err != nil {
m.err = err
return m, nil
}
if value == "" {
m.status = "cleared manual tag"
} else {
m.status = "tagged as " + value
}
case inputSearch:
m.filter.Search = value
m.status = "search: " + value
if value == "" {
m.status = "search cleared"
}
}
m.err = m.reloadTxns()
return m, nil
}
var cmd tea.Cmd
m.text, cmd = m.text.Update(msg)
return m, cmd
}
// updateNormal handles the main keymap.
func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "1":
m.view = viewAccounts
return m, nil
case "2":
m.view = viewTxns
return m, nil
case "3":
m.view = viewReport
return m, nil
case "4":
return m, m.openRuleBuilder()
case "5":
m.openRuleList()
return m, nil
case "tab":
m.view = (m.view + 1) % viewCount
switch m.view {
case viewRules:
return m, m.openRuleBuilder()
case viewRuleList:
m.openRuleList()
}
return m, nil
case "enter":
// From the account list, drill into that account's transactions.
if m.view == viewAccounts {
accounts, err := m.db.Accounts()
if err != nil {
m.err = err
return m, nil
}
i := m.accountTable.Cursor()
if i >= 0 && i < len(accounts) {
m.filter.AccountSlug = accounts[i].Slug
m.status = "account: " + accounts[i].Slug
m.view = viewTxns
m.err = m.reloadTxns()
}
}
return m, nil
case "a":
m.filter.AccountSlug = ""
m.status = "all accounts"
m.err = m.reloadTxns()
return m, nil
case "u":
m.onlyUntagged = !m.onlyUntagged
if m.onlyUntagged {
m.status = "showing untagged only"
m.view = viewTxns
} else {
m.status = "showing all transactions"
}
m.err = m.reloadTxns()
return m, nil
case "t":
if m.view != viewTxns {
return m, nil
}
t, ok := m.selected()
if !ok {
return m, nil
}
m.input = inputTag
m.text.SetValue(t.ManualTag)
m.text.CursorEnd()
m.text.Focus()
return m, nil
case "/":
m.view = viewTxns
m.input = inputSearch
m.text.SetValue(m.filter.Search)
m.text.CursorEnd()
m.text.Focus()
return m, nil
case "x":
// Toggle the manual transfer override away from whatever is in effect.
if m.view != viewTxns {
return m, nil
}
t, ok := m.selected()
if !ok {
return m, nil
}
v := !t.IsTransfer()
if err := m.db.SetManualTransfer(t.ID, &v); err != nil {
m.err = err
return m, nil
}
if v {
m.status = "marked as transfer"
} else {
m.status = "unmarked as transfer"
}
m.err = m.reloadTxns()
return m, nil
case "c":
// Drop manual overrides and fall back to what the rules say.
if m.view != viewTxns {
return m, nil
}
t, ok := m.selected()
if !ok {
return m, nil
}
if err := m.db.ClearOverrides(t.ID); err != nil {
m.err = err
return m, nil
}
m.status = "manual overrides cleared"
m.err = m.reloadTxns()
return m, nil
case "r":
if m.importing {
m.status = "import in progress…"
return m, nil
}
n, err := m.engine.Retag(m.db)
if err != nil {
m.err = err
return m, nil
}
m.status = fmt.Sprintf("rules re-applied, %d rows changed", n)
m.err = m.reload()
return m, nil
case "i":
if m.importing {
return m, nil // already running; ignore the repeat press
}
m.importing = true
m.err = nil
m.status = ""
return m, tea.Batch(m.spinner.Tick, m.importCmd())
}
var cmd tea.Cmd
switch m.view {
case viewAccounts:
m.accountTable, cmd = m.accountTable.Update(msg)
case viewTxns:
m.txnTable, cmd = m.txnTable.Update(msg)
case viewReport:
m.reportTable, cmd = m.reportTable.Update(msg)
}
return m, cmd
}
// View implements tea.Model.
func (m *Model) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render(m.title()))
b.WriteString("\n")
// An empty table is just a bare header, which says nothing about why it is
// empty or what to do next.
if empty := m.emptyMessage(); empty != "" {
b.WriteString(emptyStyle.Render(empty))
} else {
switch m.view {
case viewAccounts:
b.WriteString(m.accountTable.View())
case viewTxns:
b.WriteString(m.txnTable.View())
case viewReport:
b.WriteString(m.reportTable.View())
case viewRules:
b.WriteString(m.rulesView())
case viewRuleList:
b.WriteString(m.ruleListTable.View())
}
}
b.WriteString("\n")
switch {
case m.importing:
b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel()))
case m.input == inputTag:
b.WriteString(statusStyle.Render("tag: ") + m.text.View())
case m.input == inputSearch:
b.WriteString(statusStyle.Render("search: ") + m.text.View())
case m.err != nil:
b.WriteString(errStyle.Render("error: " + m.err.Error()))
default:
b.WriteString(statusStyle.Render(m.status))
}
b.WriteString("\n")
// Wrap the key list rather than letting the terminal cut it off: the keys
// at the end are as important as the ones at the start, and "q quit" was
// the first casualty on a narrow window.
help := helpStyle
if m.width > 0 {
help = help.Width(m.width)
}
b.WriteString(help.Render(m.help()))
return b.String()
}
// rulesView puts the form on the left and the live preview on the right.
func (m *Model) rulesView() string {
return lipgloss.JoinHorizontal(lipgloss.Top, m.ruleFormView(), m.ruleTable.View())
}
func (m *Model) ruleFormView() string {
field := func(i int, label, help string) string {
name := labelStyle.Render(" " + label)
box := boxStyle.Render(m.ruleInputs()[i].View())
if i == m.ruleFocus {
name = focusedLabelStyle.Render("▸ " + label)
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
}
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n"
}
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")
// The count is the whole point of the preview: it says what the rule will
// do before it is written to disk.
summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, len(m.ruleTable.Rows()))
if strings.TrimSpace(m.ruleGlob.Value()) == "" {
summary = fmt.Sprintf("%d untagged descriptions", len(m.ruleTable.Rows()))
}
b.WriteString(matchCountStyle.Render(summary))
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
// returns "" when there is something to show.
func (m *Model) emptyMessage() string {
switch m.view {
case viewAccounts:
if len(m.accountTable.Rows()) > 0 {
return ""
}
// An account.toml on disk is not enough: folders are only registered
// in the index by an import, so say that rather than showing nothing.
if n := len(m.accounts); n > 0 {
return fmt.Sprintf(
"No accounts imported yet.\n\n"+
"%d account folder(s) configured in %s.\n"+
"Press i to import their statements (same as running `money import`).",
n, m.root)
}
return fmt.Sprintf(
"No account folders found in %s.\n\n"+
"An account is a folder containing an account.toml.\n"+
"Create one, drop statements in, then press i to import.",
m.root)
case viewTxns:
if len(m.txns) > 0 {
return ""
}
switch {
case m.filter.Search != "":
return fmt.Sprintf("No transactions match /%s.\n\nPress / to change the search.", m.filter.Search)
case m.onlyUntagged:
return "Nothing untagged.\n\nEvery transaction here has a tag. Press u to see them all."
case m.filter.AccountSlug != "":
return fmt.Sprintf(
"No transactions in %s.\n\nPress i to import, or a to see every account.",
m.filter.AccountSlug)
}
return "No transactions yet.\n\nPress i to import (same as running `money import`)."
case viewReport:
if len(m.reportTable.Rows()) > 0 {
return ""
}
return "Nothing to report yet.\n\nPress i to import some statements first."
case viewRuleList:
if len(m.ruleListTable.Rows()) > 0 {
return ""
}
return "No rules yet.\n\nPress 4 to build one, or write rules.toml by hand."
}
return ""
}
func (m *Model) title() string {
scope := "all accounts"
if m.filter.AccountSlug != "" {
scope = m.filter.AccountSlug
}
if m.onlyUntagged {
scope += " · untagged"
}
if m.filter.Search != "" {
scope += " · /" + m.filter.Search
}
switch m.view {
case viewAccounts:
return "money · accounts"
case viewRules:
return "money · rule builder · writes to rules.toml"
case viewRuleList:
unused := len(m.unusedRules())
if unused == 0 {
return fmt.Sprintf("money · rules · %d rules, all in use", len(m.engine.Rules()))
}
return fmt.Sprintf("money · rules · %d rules · %d match nothing", len(m.engine.Rules()), unused)
case viewReport:
return "money · report · " + scope + " · transfers excluded"
default:
return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns))
}
}
// importingLabel names what the import is working through, since extracting
// text from PDFs is where the wait actually comes from.
func (m *Model) importingLabel() string {
if n := len(m.accounts); n > 0 {
return fmt.Sprintf("importing %d account(s)… parsing statements can take a while for PDFs", n)
}
return "importing…"
}
func (m *Model) help() string {
if m.importing {
return "importing… · q quit"
}
if m.input != inputNone {
return "enter confirm · esc cancel"
}
switch m.view {
case viewAccounts:
return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · i import · r retag · q quit"
case viewRules:
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:
if m.confirm != confirmNone {
return "y confirm · any other key cancels"
}
return "d delete rule · p prune all unused · r refresh counts · 4 new rule · 1 accounts · esc back · q quit"
case viewReport:
return "1 accounts · 2 transactions · 4 new rule · 5 rules · u untagged · a all accounts · q quit"
default:
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 new rule · 5 rules · q quit"
}
}