// 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 // 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 } 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" } }