Files
money/internal/tui/tui.go
T
nikolaandClaude Opus 5 5ca6cff87b Remove the transfer flag
A transfer was a second verdict carried alongside the tag: a boolean set by
transfer = true in a rule or by x in the TUI, kept in its own pair of rule_
and manual_ columns, whose one real effect was to hold the row out of the
report. The rest of it was display -- a T column in the transaction list, in
the rules screen and in money ls.

Money moved between your own accounts is now tagged like anything else and
counts like anything else. The leg leaving checking is an outflow and the leg
arriving in savings is an inflow, so a report over the whole data root roughly
nets out while one scoped to a single account or month does not. That is the
price of one verdict per transaction instead of two.

A rule now needs a tag, and one that set only transfer = true is refused by
number on load. A leftover transfer key beside a tag is ignored, as unknown
TOML keys always were, and an index built by an older binary drops both
columns when it is opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:07:47 +02:00

1395 lines
37 KiB
Go

// Package tui is the terminal interface: browse accounts, tag transactions,
// 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
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
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: "Description", Width: 40},
}),
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},
{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: "Txns", Width: 6},
{Title: "Note", Width: 24},
}),
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 {
tag := t.Tag()
if tag != "" && t.ManualTag != "" {
tag += "*" // marks a manual override
}
rows = append(rows, table.Row{
t.Date, t.AccountSlug, t.FormatAmount(), tag, 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()),
Note: strings.TrimSpace(m.ruleNote.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.ruleNote.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 = "✗"
}
account := r.Account
if account == "" {
account = "(all)"
}
rows = append(rows, table.Row{
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag,
fmt.Sprintf("%d", m.ruleUsage[i]), r.Note,
})
}
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 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.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, &m.ruleNote}
}
// 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)
}
// 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.
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 "c":
// Drop the manual tag 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 {
// 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())
if i == m.ruleFocus {
name = focusedLabelStyle.Render("▸ " + label)
box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
}
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(field(1, "account", m.completionHint(1, "blank = all accounts")))
b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
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.
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
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 · c clear · / search · u untagged · a all · i import · r retag · 4 new rule · 5 rules · q quit"
}
}