Files
money/internal/tui/tui.go
T
nikolaandClaude Opus 5 442684be60 Try the most specific rule first, not the topmost
File order decided precedence, so a narrow rule had to be written above the
broad one it carves an exception out of -- an ordering constraint the file
cannot show and the user has to remember. *NIKOLA* below *NIK* silently matched
nothing, and a catch-all * could only ever be the last line.

Engine.New now sorts once and MatchIndex walks that order: most literal
characters first, then fewest *, then account-scoped over unscoped. Literals
are what a rule commits to and a * is what it gives up, so a bare * is tried
last wherever it sits. The sort is stable, so equally specific rules keep file
order and the earlier one wins -- which is all position decides now, and why
AppendRule can keep appending without displacing a rule written by hand.

The two orders must not be confused: Rules(), Usage and MatchIndex still speak
in file positions, because that is what the rules screen numbers and what
DeleteRules deletes by. A shadowed rule still reports zero usage, but a zero no
longer says anything about where the rule sits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:30:04 +02:00

2043 lines
58 KiB
Go

// Package tui is the terminal interface: browse accounts, write the rules that
// tag transactions, and see where the money went.
package tui
import (
"fmt"
"slices"
"sort"
"strconv"
"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"
"git.petrovv.com/nikola/money/internal/transfers"
)
type view int
const (
viewAccounts view = iota
viewTxns
viewReport
viewRules
viewRuleList
viewTransfers
viewTransferList
viewCount = 7
)
// 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
confirmDeleteTransfer
confirmPruneTransfers
)
// input is the modal state: the transaction list is read-only until the user
// starts typing a search.
type input int
const (
inputNone input = iota
inputSearch
)
// Model is the root Bubble Tea model.
type Model struct {
root string
db *store.DB
accounts []*config.Account
engine *rules.Engine
links *transfers.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
// 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
// screen can no longer say what they were chosen out of.
ruleCandidates int
// 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
// Transfer builder: the two sides of a movement on the left, and on the
// right the pairs it would form out of what is already imported, together
// with the legs it would catch but leave unpaired.
transferFrom textinput.Model
transferFromDesc textinput.Model
transferTo textinput.Model
transferToDesc textinput.Model
transferTolerance textinput.Model
transferNote textinput.Model
transferFocus int
transferTable table.Model
transferReturn view
// allTxns is every transaction in the index, held so the preview can pair
// across accounts without going back to the database on each keystroke.
allTxns []model.Transaction
previewPairs int
previewUnmatched int
// previewFees is what the draft's pairs would lose to fees. Every leaving
// leg is on one account, so there is a single currency to render it in.
previewFees int64
previewFeeDigits int
// Transfer list: every definition with what it currently pairs.
transferListTable table.Model
transferResult transfers.Result
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)
// flatBoxStyle is the same field with its border dropped, for a form too
// tall for the terminal. The padding puts the value in the same column the
// border's own padding would, so the fields still line up beside a boxed one.
flatBoxStyle = lipgloss.NewStyle().PaddingLeft(2).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,
links *transfers.Engine) error {
m := New(root, db, accounts, engine, links)
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,
links *transfers.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,
links: links,
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},
}),
transferFrom: completing(newInput("nlb")),
transferFromDesc: newInput("*TO REVOLUT*"),
transferTo: completing(newInput("revolut")),
transferToDesc: newInput("*FROM NLB*"),
transferTolerance: newInput("0"),
transferNote: newInput("optional"),
transferTable: newTable([]table.Column{
{Title: " ", Width: 1},
{Title: "Date", Width: 10},
// Wide enough for both sides of an exchange, e.g. "500.00 → 977.90".
{Title: "Amount", Width: 20},
{Title: "Movement", Width: 22},
{Title: "Description", Width: 30},
}),
transferListTable: newTable([]table.Column{
{Title: "#", Width: 3},
{Title: " ", Width: 1},
{Title: "From", Width: 26},
{Title: "To", Width: 26},
{Title: "Pairs", Width: 6},
{Title: "Unpaired", Width: 9},
{Title: "Tol", Width: 6},
{Title: "Note", Width: 20},
}),
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 {
rows = append(rows, table.Row{
t.Date, t.AccountSlug, t.FormatAmount(), t.DisplayTag(), t.Description,
})
}
// Keep the cursor in range after the row count shrinks (e.g. a new rule
// 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
}
// The two rows the report grows below TOTAL. They are bracketed like
// report.Untagged and model.TransferTag because they are the same kind of
// thing: a label the tool supplies for something no rule named. Neither is a
// tag, and neither can be matched or completed against.
const (
transfersRow = "(transfers)"
feesRow = " ⤷ fees"
)
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),
"",
})
}
// Below the total, what ByTag held out — otherwise the report quietly
// disagrees with the account balances by the amount moved between accounts,
// and by any fee a tolerant definition swallowed along with it.
for _, x := range report.Excluded(txns) {
out = append(out, table.Row{
transfersRow, x.Currency,
model.FormatMinor(x.Out, x.Digits),
model.FormatMinor(x.In, x.Digits),
model.FormatMinor(x.In-x.Out, x.Digits),
fmt.Sprintf("%d", x.Legs),
})
if x.Fee != 0 {
out = append(out, table.Row{
feesRow, x.Currency,
model.FormatMinor(x.Fee, x.Digits), "",
model.FormatMinor(-x.Fee, x.Digits),
fmt.Sprintf("%d", x.Pairs),
})
}
}
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-filters 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.
//
// Once a glob is typed the list is exactly what the rule would claim: the
// non-matching rows go, rather than staying on as unmarked context. With the
// glob still empty there is nothing to filter by, so the list is everything
// still waiting for a rule — which is the other question this screen answers.
// Either way the count beside the form is measured against everything in view,
// so a glob that has narrowed the list to three of forty still says so.
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, m.ruleCandidates = 0, 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
}
m.ruleCandidates++
marker := " "
if pattern != "" {
if !glob.Match(pattern, model.NormalizeDescription(g.Description)) {
continue
}
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
}
if err := m.reloadConfig(); err != nil {
return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err)
}
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()
}
// reloadConfig re-reads rules.toml and rebuilds both engines from it, rather
// than patching the in-memory ones, so what runs is exactly what is now on
// disk. Both are rebuilt together because both come out of the same file.
func (m *Model) reloadConfig() error {
loaded, err := config.LoadRules(m.root)
if err != nil {
return err
}
m.engine = rules.New(loaded)
m.links = transfers.New(loaded)
return nil
}
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 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 acceptCompletion(in *textinput.Model) bool {
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
// a more specific rule already took everything it would have caught; both show
// up here as a zero. The rows stay in file order — that is the number the user
// can find in rules.toml, and precedence is not read off it any more.
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
}
if err := m.reloadConfig(); err != nil {
return fmt.Errorf("rules deleted, but re-reading rules.toml failed: %w", err)
}
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 "6":
return m, m.openTransferBuilder()
case "7":
m.openTransferList()
return m, nil
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) { m.ruleFocus = focusField(m.ruleInputs(), i) }
// focusField gives the cursor to one field of a form and takes it from the
// rest, wrapping the index so tabbing past either end comes back round.
func focusField(inputs []*textinput.Model, i int) int {
n := len(inputs)
focus := ((i % n) + n) % n
for j, in := range inputs {
if j == focus {
in.Focus()
in.CursorEnd()
continue
}
in.Blur()
}
return focus
}
// openTransferBuilder switches to the transfer builder, remembering where to
// return to and loading the transactions the preview pairs against.
func (m *Model) openTransferBuilder() tea.Cmd {
if m.view != viewTransfers {
m.transferReturn = m.view
}
m.view = viewTransfers
m.setTransferFocus(0)
slugs := m.accountSlugs()
m.transferFrom.SetSuggestions(slugs)
m.transferTo.SetSuggestions(slugs)
// The preview pairs across accounts, so it needs everything, not the
// filtered view the transaction list is showing.
txns, err := m.db.Transactions(store.Filter{})
if err != nil {
m.err = err
}
m.allTxns = txns
m.refreshTransferPreview()
return textinput.Blink
}
// transferInputs lists the form fields in tab order: the two sides in the
// order the money travels, then the tolerance and the note.
func (m *Model) transferInputs() []*textinput.Model {
return []*textinput.Model{
&m.transferFrom, &m.transferFromDesc,
&m.transferTo, &m.transferToDesc,
&m.transferTolerance, &m.transferNote,
}
}
// setTransferFocus moves the cursor between the form fields, wrapping around.
func (m *Model) setTransferFocus(i int) {
m.transferFocus = focusField(m.transferInputs(), i)
}
// draftTransfer is the definition the form currently describes. A tolerance
// that does not parse yet previews as none at all — the field is retyped a
// character at a time, and "1." must not stop the preview from updating.
// saveTransfer is where a bad value is refused.
func (m *Model) draftTransfer() config.Transfer {
pct, _ := m.draftTolerance()
return config.Transfer{
FromAccount: strings.TrimSpace(m.transferFrom.Value()),
FromDesc: strings.TrimSpace(m.transferFromDesc.Value()),
ToAccount: strings.TrimSpace(m.transferTo.Value()),
ToDesc: strings.TrimSpace(m.transferToDesc.Value()),
TolerancePct: pct,
Note: strings.TrimSpace(m.transferNote.Value()),
}
}
// draftTolerance reads the tolerance field. Empty means none, which is what a
// definition without the key means too.
func (m *Model) draftTolerance() (float64, error) {
s := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(m.transferTolerance.Value()), "%"))
if s == "" {
return 0, nil
}
pct, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, fmt.Errorf("tolerance %q is not a number", s)
}
return pct, nil
}
// refreshTransferPreview re-pairs against whatever is typed right now.
//
// The draft is analysed *after* the definitions already on disk, exactly where
// saving would put it, so the preview cannot promise pairs that an existing
// definition would claim first. A half-written definition still previews: the
// side that is filled in shows its legs as unpaired, which is the fastest way
// to see that a glob is wrong.
func (m *Model) refreshTransferPreview() {
draft := m.draftTransfer()
defs := append(append([]config.Transfer(nil), m.links.Transfers()...), draft)
res := transfers.New(&config.Rules{Transfer: defs}).Analyze(m.allTxns)
mine := len(defs) - 1
type entry struct {
date string
row table.Row
}
var entries []entry
m.previewFees, m.previewFeeDigits = 0, 0
for _, p := range res.Pairs {
if p.Def != mine {
continue
}
m.previewFees += p.Fee()
m.previewFeeDigits = p.Out.MinorDigits
// Whenever the two legs disagree, both numbers are worth seeing: across
// currencies they are the only place the rate the bank used shows up,
// and within one they are the fee a tolerance let through, which is
// exactly the thing to eyeball before saving the definition.
amount := model.FormatMinor(-p.Out.AmountMinor, p.Out.MinorDigits)
if p.In.Currency != p.Out.Currency || p.Fee() != 0 {
amount += " → " + model.FormatMinor(p.In.AmountMinor, p.In.MinorDigits)
}
entries = append(entries, entry{p.Out.Date, table.Row{
"▸", p.Out.Date, amount,
p.Out.AccountSlug + " → " + p.In.AccountSlug, p.Out.Description,
}})
}
for _, l := range res.Unmatched {
if l.Def != mine {
continue
}
amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug
if l.Out {
amount, movement = -amount, l.Txn.AccountSlug+" → ?"
}
entries = append(entries, entry{l.Txn.Date, table.Row{
"⚠", l.Txn.Date, model.FormatMinor(amount, l.Txn.MinorDigits),
movement, l.Txn.Description,
}})
}
// Newest first, like the transaction list: the movement worth checking is
// usually the one that just came in.
sort.SliceStable(entries, func(i, j int) bool { return entries[i].date > entries[j].date })
rows := make([]table.Row, 0, len(entries))
for _, e := range entries {
rows = append(rows, e.row)
}
m.previewPairs = res.Paired[mine]
m.previewUnmatched = res.Orphaned[mine]
cursor := m.transferTable.Cursor()
m.transferTable.SetRows(rows)
if cursor >= len(rows) {
cursor = len(rows) - 1
}
if cursor < 0 {
cursor = 0
}
m.transferTable.SetCursor(cursor)
}
// saveTransfer appends the composed definition to rules.toml, re-pairs, and
// leaves the two account fields filled in, since the next transfer written is
// usually the same route in the other direction.
func (m *Model) saveTransfer() error {
t := m.draftTransfer()
switch {
case t.FromAccount == "":
return fmt.Errorf("name the account the money leaves")
case t.FromDesc == "":
return fmt.Errorf("enter a glob for the leaving leg, e.g. *TO REVOLUT*")
case t.ToAccount == "":
return fmt.Errorf("name the account the money arrives in")
case t.ToDesc == "":
return fmt.Errorf("enter a glob for the arriving leg, e.g. *FROM NLB*")
}
for _, slug := range []string{t.FromAccount, t.ToAccount} {
if !m.knownAccount(slug) {
return fmt.Errorf("no account called %q", slug)
}
}
// draftTransfer swallowed this so the preview could keep up with typing;
// saving is where a value that never became a number has to be refused,
// rather than written out as a silent 0.
if _, err := m.draftTolerance(); err != nil {
return err
}
if err := config.AppendTransfer(m.root, t); err != nil {
return err
}
if err := m.reloadConfig(); err != nil {
return fmt.Errorf("transfer saved, but re-reading rules.toml failed: %w", err)
}
paired, unpaired, err := m.links.Link(m.db)
if err != nil {
return err
}
m.status = fmt.Sprintf("saved transfer %s → %s, %d matched, %d leg(s) unpaired",
t.FromAccount, t.ToAccount, paired, unpaired)
m.transferFromDesc.SetValue("")
m.transferToDesc.SetValue("")
// Cleared with the globs rather than kept with the accounts: a tolerance
// carried silently into the next definition would loosen a route that never
// asked for one, and the reverse direction rarely charges the same fee.
m.transferTolerance.SetValue("")
m.transferNote.SetValue("")
// The accounts are kept, so land on the first field that was cleared.
m.setTransferFocus(1)
if err := m.reload(); err != nil {
return err
}
txns, err := m.db.Transactions(store.Filter{})
if err != nil {
return err
}
m.allTxns = txns
m.refreshTransferPreview()
return nil
}
// openTransferList switches to the transfer list, remembering where to return.
func (m *Model) openTransferList() {
if m.view != viewTransferList {
m.transferReturn = m.view
}
m.view = viewTransferList
m.confirm = confirmNone
if err := m.reloadTransferList(); err != nil {
m.err = err
}
}
// reloadTransferList recomputes what every definition currently pairs.
//
// Two counts are shown because they mean different things: a definition with
// no pairs and no legs matches nothing at all and can go, while one with
// unpaired legs is catching money leaving that never arrives — a wrong glob on
// the other side, a statement not imported yet, or a movement that genuinely
// went missing.
func (m *Model) reloadTransferList() error {
txns, err := m.db.Transactions(store.Filter{})
if err != nil {
return err
}
defs := m.links.Transfers()
m.transferResult = m.links.Analyze(txns)
rows := make([]table.Row, 0, len(defs))
for i, t := range defs {
marker := " "
switch {
case m.transferResult.Orphaned[i] > 0:
marker = "⚠"
case m.transferResult.Paired[i] == 0:
marker = "✗"
}
rows = append(rows, table.Row{
fmt.Sprintf("%d", i+1), marker,
t.FromAccount + " " + t.FromDesc,
t.ToAccount + " " + t.ToDesc,
fmt.Sprintf("%d", m.transferResult.Paired[i]),
fmt.Sprintf("%d", m.transferResult.Orphaned[i]),
formatTolerance(t.TolerancePct),
t.Note,
})
}
cursor := m.transferListTable.Cursor()
m.transferListTable.SetRows(rows)
if cursor >= len(rows) {
cursor = len(rows) - 1
}
if cursor < 0 {
cursor = 0
}
m.transferListTable.SetCursor(cursor)
return nil
}
// formatTolerance renders a definition's tolerance for the list. The default
// is blank rather than "0%": every definition has it, so printing it down the
// whole column would bury the one or two rows where the amounts are actually
// allowed to disagree.
func formatTolerance(pct float64) string {
if pct == 0 {
return ""
}
return strconv.FormatFloat(pct, 'f', -1, 64) + "%"
}
// unusedTransfers lists the positions of every definition that catches nothing
// at all. A definition with unpaired legs is deliberately not in here: it is
// doing something, just not completing it, and deleting it would hide the
// problem instead of fixing it.
func (m *Model) unusedTransfers() []int {
var out []int
for i, n := range m.transferResult.Paired {
if n == 0 && m.transferResult.Orphaned[i] == 0 {
out = append(out, i)
}
}
return out
}
// unpairedLegs counts the legs no definition could complete.
func (m *Model) unpairedLegs() int { return len(m.transferResult.Unmatched) }
// deleteTransfers removes definitions from rules.toml, then re-pairs so the
// counts on screen reflect the new file.
func (m *Model) deleteTransfers(positions []int) error {
n, err := config.DeleteTransfers(m.root, positions)
if err != nil {
return err
}
if err := m.reloadConfig(); err != nil {
return fmt.Errorf("transfers deleted, but re-reading rules.toml failed: %w", err)
}
paired, unpaired, err := m.links.Link(m.db)
if err != nil {
return err
}
m.status = fmt.Sprintf("deleted %d transfer(s), %d matched, %d leg(s) unpaired",
n, paired, unpaired)
if err := m.reload(); err != nil {
return err
}
return m.reloadTransferList()
}
// updateTransferList drives the transfer list, including the confirmations.
func (m *Model) updateTransferList(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 confirmDeleteTransfer:
if i := m.transferListTable.Cursor(); i >= 0 && i < len(m.links.Transfers()) {
if err := m.deleteTransfers([]int{i}); err != nil {
m.err = err
}
}
case confirmPruneTransfers:
if err := m.deleteTransfers(m.unusedTransfers()); err != nil {
m.err = err
}
}
return m, nil
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "esc":
m.view = m.transferReturn
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 "5":
m.openRuleList()
return m, nil
case "6":
return m, m.openTransferBuilder()
case "d":
i := m.transferListTable.Cursor()
defs := m.links.Transfers()
if i < 0 || i >= len(defs) {
return m, nil
}
m.confirm = confirmDeleteTransfer
m.status = fmt.Sprintf("delete transfer %d (%s → %s), %d matched? y/n",
i+1, defs[i].FromAccount, defs[i].ToAccount, m.transferResult.Paired[i])
return m, nil
case "p":
unused := m.unusedTransfers()
if len(unused) == 0 {
m.status = "no transfers to prune; only ones matching nothing at all are pruned"
return m, nil
}
m.confirm = confirmPruneTransfers
m.status = fmt.Sprintf("delete all %d transfers that match nothing? y/n", len(unused))
return m, nil
case "r":
m.err = m.reloadTransferList()
m.status = "pairing refreshed"
return m, nil
}
var cmd tea.Cmd
m.transferListTable, cmd = m.transferListTable.Update(msg)
return m, cmd
}
// updateTransfers drives the transfer builder form. Like the rule builder it
// owns every printable key, so the global keymap must not reach it.
func (m *Model) updateTransfers(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.view = m.transferReturn
return m, nil
case "tab":
if acceptCompletion(m.transferInputs()[m.transferFocus]) {
m.refreshTransferPreview()
return m, nil
}
m.setTransferFocus(m.transferFocus + 1)
return m, nil
case "down":
m.setTransferFocus(m.transferFocus + 1)
return m, nil
case "shift+tab", "up":
m.setTransferFocus(m.transferFocus - 1)
return m, nil
case "right":
in := m.transferInputs()[m.transferFocus]
if in.Position() == len([]rune(in.Value())) && acceptCompletion(in) {
m.refreshTransferPreview()
return m, nil
}
case "pgdown":
m.transferTable.MoveDown(10)
return m, nil
case "pgup":
m.transferTable.MoveUp(10)
return m, nil
case "enter":
m.err = nil
if err := m.saveTransfer(); err != nil {
m.err = err
}
return m, nil
}
var cmd tea.Cmd
inputs := m.transferInputs()
*inputs[m.transferFocus], cmd = inputs[m.transferFocus].Update(msg)
m.refreshTransferPreview()
return m, cmd
}
// transfersView puts the form on the left and the live pairing on the right.
func (m *Model) transfersView() string {
return lipgloss.JoinHorizontal(lipgloss.Top, m.transferFormView(), m.transferTable.View())
}
func (m *Model) transferFormView() string {
// Six fields need more room than the rule builder's four, so the form gives
// ground in three stages: the blank lines first, then the hints on unfocused
// fields, then their borders. Labels and values never go — six anonymous
// boxes would be worse than a form that scrolls.
room := m.height - 6
spaced := m.height <= 0 || room >= 37
hints := m.height <= 0 || room >= 31
boxed := m.height <= 0 || room >= 25
inputs := m.transferInputs()
field := func(i int, label, help string) string {
name := labelStyle.Render(" " + label)
box := flatBoxStyle.Render(inputs[i].View())
if boxed {
box = boxStyle.Render(inputs[i].View())
}
if i == m.transferFocus {
name = focusedLabelStyle.Render("▸ " + label)
box = focusedBoxStyle.Render(inputs[i].View())
}
out := name + "\n" + box + "\n"
if hints || i == m.transferFocus {
out += hintStyle.Render(help) + "\n"
}
if spaced {
out += "\n"
}
return out
}
var b strings.Builder
b.WriteString(field(0, "from account", completionHint(inputs[0], m.transferFocus == 0, "money leaves here")))
b.WriteString(field(1, "from desc", "glob vs. the leaving leg"))
b.WriteString(field(2, "to account", completionHint(inputs[2], m.transferFocus == 2, "money arrives here")))
b.WriteString(field(3, "to desc", "glob vs. the arriving leg"))
b.WriteString(field(4, "tolerance %", "0 = amounts must match exactly"))
b.WriteString(field(5, "note", "why this transfer exists"))
summary := fmt.Sprintf("%d pairs · %d unpaired", m.previewPairs, m.previewUnmatched)
if m.previewPairs == 0 && m.previewUnmatched == 0 {
summary = "nothing matches yet"
}
// Fees are the reason the tolerance field exists, so the preview says what
// it is admitting rather than only how many pairs it bought.
if fee := m.previewFees; fee != 0 {
summary += fmt.Sprintf(" · %s in fees", model.FormatMinor(fee, m.previewFeeDigits))
}
b.WriteString(matchCountStyle.Render(summary))
return ruleFormStyle.Render(b.String())
}
// 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, links := m.root, m.db, m.accounts, m.engine, m.links
return func() tea.Msg {
res, err := importer.Run(root, db, accounts, engine, links, 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 builders are forms: every printable key belongs to the focused
// input, so the global single-letter keymap cannot apply.
switch m.view {
case viewRules:
return m.updateRules(msg)
case viewTransfers:
return m.updateTransfers(msg)
case viewRuleList:
return m.updateRuleList(msg)
case viewTransferList:
return m.updateTransferList(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 acceptCompletion(m.ruleInputs()[m.ruleFocus]) {
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())) && acceptCompletion(in) {
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)
if msg.res.Unpaired > 0 {
m.status += fmt.Sprintf(" · %d transfer leg(s) unpaired", msg.res.Unpaired)
}
// 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)
m.transferTable.SetHeight(h)
m.transferListTable.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)
cols = m.transferTable.Columns()
fixed := 0
for _, c := range cols[:len(cols)-1] {
fixed += c.Width + 2
}
desc = m.width - ruleFormWidth - fixed - 6
if desc < 16 {
desc = 16
}
cols[len(cols)-1].Width = desc
m.transferTable.SetColumns(cols)
}
// The free-text columns come last and get whatever is left over.
m.stretchLastColumn(&m.txnTable, 20)
m.stretchLastColumn(&m.ruleListTable, 12)
m.stretchLastColumn(&m.transferListTable, 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 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 "6":
return m, m.openTransferBuilder()
case "7":
m.openTransferList()
return m, nil
case "tab":
m.view = (m.view + 1) % viewCount
switch m.view {
case viewRules:
return m, m.openRuleBuilder()
case viewRuleList:
m.openRuleList()
case viewTransfers:
return m, m.openTransferBuilder()
case viewTransferList:
m.openTransferList()
}
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 "/":
m.view = viewTxns
m.input = inputSearch
m.text.SetValue(m.filter.Search)
m.text.CursorEnd()
m.text.Focus()
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
}
// Tags and transfers are both derived from rules.toml, so one key
// re-derives both; leaving the pairing stale would quietly change what
// the report holds out.
paired, unpaired, err := m.links.Link(m.db)
if err != nil {
m.err = err
return m, nil
}
m.status = fmt.Sprintf("rules re-applied, %d rows changed, %d transfers matched", n, paired)
if unpaired > 0 {
m.status += fmt.Sprintf(", %d leg(s) unpaired", unpaired)
}
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())
case viewTransfers:
b.WriteString(m.transfersView())
case viewTransferList:
b.WriteString(m.transferListTable.View())
}
}
b.WriteString("\n")
switch {
case m.importing:
b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel()))
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
inputs := m.ruleInputs()
b.WriteString(field(0, "glob", "vs. the description"))
b.WriteString(field(1, "account", completionHint(inputs[1], m.ruleFocus == 1, "blank = all accounts")))
b.WriteString(field(2, "tag", completionHint(inputs[2], m.ruleFocus == 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, m.ruleCandidates)
if strings.TrimSpace(m.ruleGlob.Value()) == "" {
summary = fmt.Sprintf("%d untagged descriptions", m.ruleCandidates)
}
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 completionHint(in *textinput.Model, focused bool, fallback string) string {
if !focused {
return fallback
}
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\n" +
"Every transaction here has a tag or belongs to a transfer.\n" +
"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."
case viewTransferList:
if len(m.transferListTable.Rows()) > 0 {
return ""
}
return "No transfers yet.\n\n" +
"A transfer names both legs of money moved between your own accounts,\n" +
"so the report can leave the pair out instead of counting it as spending.\n" +
"Press 6 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 viewTransfers:
return "money · transfer builder · writes to rules.toml"
case viewTransferList:
n := len(m.links.Transfers())
if legs := m.unpairedLegs(); legs > 0 {
return fmt.Sprintf("money · transfers · %d definitions · %d leg(s) unpaired", n, legs)
}
return fmt.Sprintf("money · transfers · %d definitions · every leg paired", n)
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 · 6 new transfer · 7 transfers · 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 · 7 transfers · 1 accounts · esc back · q quit"
case viewTransfers:
return "tab complete/next field · ↑↓ field · pgup/pgdn scroll pairs · enter save transfer · esc back · ctrl+c quit"
case viewTransferList:
if m.confirm != confirmNone {
return "y confirm · any other key cancels"
}
return "d delete transfer · p prune all unmatched · r refresh pairing · 6 new transfer · 5 rules · 1 accounts · esc back · q quit"
case viewReport:
return "1 accounts · 2 transactions · 4 new rule · 5 rules · 7 transfers · u untagged · a all accounts · q quit"
default:
return "4 new rule · / search · u untagged · a all · i import · r retag · 5 rules · 7 transfers · q quit"
}
}