// Package tui is the terminal interface: browse accounts, tag transactions, // mark transfers by hand, and see where the money went. package tui import ( "fmt" "strings" "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/importer" "git.petrovv.com/nikola/money/internal/model" "git.petrovv.com/nikola/money/internal/report" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" ) type view int const ( viewAccounts view = iota viewTxns viewReport ) // input is the modal state: the transaction list is read-only until the user // starts typing a tag or a search. type input int const ( inputNone input = iota inputTag inputSearch ) // Model is the root Bubble Tea model. type Model struct { root string db *store.DB accounts []*config.Account engine *rules.Engine view view input input accountTable table.Model txnTable table.Model reportTable table.Model text textinput.Model txns []model.Transaction // rows currently shown in txnTable 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) ) // Run starts the interface. func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) error { m := New(root, db, accounts, engine) if err := m.reload(); err != nil { return err } _, err := tea.NewProgram(m, tea.WithAltScreen()).Run() return err } // New builds the root model. func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) *Model { ti := textinput.New() ti.Prompt = "" ti.CharLimit = 64 styles := table.DefaultStyles() styles.Header = styles.Header.Bold(true) styles.Selected = styles.Selected.Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62")) newTable := func(cols []table.Column) table.Model { return table.New(table.WithColumns(cols), table.WithFocused(true), table.WithHeight(15), table.WithStyles(styles)) } return &Model{ root: root, db: db, accounts: accounts, engine: engine, view: viewAccounts, text: ti, accountTable: newTable([]table.Column{ {Title: "Account", Width: 20}, {Title: "Balance", Width: 14}, {Title: "Cur", Width: 4}, {Title: "Txns", Width: 6}, }), txnTable: newTable([]table.Column{ {Title: "Date", Width: 10}, {Title: "Account", Width: 12}, {Title: "Amount", Width: 12}, {Title: "Tag", Width: 14}, {Title: "T", Width: 1}, {Title: "Description", Width: 40}, }), reportTable: newTable([]table.Column{ {Title: "Tag", Width: 20}, {Title: "Cur", Width: 4}, {Title: "Out", Width: 14}, {Title: "In", Width: 14}, {Title: "Net", Width: 14}, {Title: "N", Width: 5}, }), } } // Init implements tea.Model. func (m *Model) Init() tea.Cmd { return nil } // reload refreshes whichever view is active from the index. func (m *Model) reload() error { if err := m.reloadAccounts(); err != nil { return err } return m.reloadTxns() } func (m *Model) reloadAccounts() error { accounts, err := m.db.Accounts() if err != nil { return err } rows := make([]table.Row, 0, len(accounts)) for _, a := range accounts { bal, err := m.db.Balance(a.ID) if err != nil { return err } n, err := m.db.Count(a.ID) if err != nil { return err } rows = append(rows, table.Row{ a.Name, model.FormatMinor(bal, a.MinorDigits), a.Currency, fmt.Sprintf("%d", n), }) } m.accountTable.SetRows(rows) return nil } func (m *Model) reloadTxns() error { f := m.filter f.Untagged = m.onlyUntagged txns, err := m.db.Transactions(f) if err != nil { return err } m.txns = txns rows := make([]table.Row, 0, len(txns)) for _, t := range txns { transfer := "" if t.IsTransfer() { transfer = "T" } tag := t.Tag() if tag != "" && t.ManualTag != "" { tag += "*" // marks a manual override } rows = append(rows, table.Row{ t.Date, t.AccountSlug, t.FormatAmount(), tag, transfer, t.Description, }) } // Keep the cursor in range after the row count shrinks (e.g. tagging the // last untagged row while the untagged filter is on). cursor := m.txnTable.Cursor() m.txnTable.SetRows(rows) if cursor >= len(rows) { cursor = len(rows) - 1 } if cursor < 0 { cursor = 0 } m.txnTable.SetCursor(cursor) m.reloadReport(txns) return nil } func (m *Model) reloadReport(txns []model.Transaction) { rows := report.ByTag(txns) out := make([]table.Row, 0, len(rows)+2) for _, r := range rows { out = append(out, table.Row{ r.Tag, r.Currency, model.FormatMinor(r.Out, r.Digits), model.FormatMinor(r.In, r.Digits), model.FormatMinor(r.Net(), r.Digits), fmt.Sprintf("%d", r.Count), }) } for _, c := range report.Totals(rows) { out = append(out, table.Row{ "TOTAL", c.Currency, model.FormatMinor(c.Out, c.Digits), model.FormatMinor(c.In, c.Digits), model.FormatMinor(c.Net(), c.Digits), "", }) } m.reportTable.SetRows(out) } // 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 } // 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 tea.KeyMsg: if m.input != inputNone { return m.updateInput(msg) } return m.updateNormal(msg) } return m, nil } 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) // Give the description column whatever is left over. if m.width > 0 { cols := m.txnTable.Columns() fixed := 0 for _, c := range cols[:len(cols)-1] { fixed += c.Width + 2 } desc := m.width - fixed - 4 if desc < 20 { desc = 20 } cols[len(cols)-1].Width = desc m.txnTable.SetColumns(cols) } } // updateInput handles typing into the tag or search prompt. func (m *Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.input = inputNone m.text.Blur() m.text.SetValue("") return m, nil case tea.KeyEnter: value := strings.TrimSpace(m.text.Value()) mode := m.input m.input = inputNone m.text.Blur() m.text.SetValue("") switch mode { case inputTag: t, ok := m.selected() if !ok { return m, nil } if err := m.db.SetManualTag(t.ID, value); err != nil { m.err = err return m, nil } if value == "" { m.status = "cleared manual tag" } else { m.status = "tagged as " + value } case inputSearch: m.filter.Search = value m.status = "search: " + value if value == "" { m.status = "search cleared" } } m.err = m.reloadTxns() return m, nil } var cmd tea.Cmd m.text, cmd = m.text.Update(msg) return m, cmd } // updateNormal handles the main keymap. func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "1": m.view = viewAccounts return m, nil case "2": m.view = viewTxns return m, nil case "3": m.view = viewReport return m, nil case "tab": m.view = (m.view + 1) % 3 return m, nil case "enter": // From the account list, drill into that account's transactions. if m.view == viewAccounts { accounts, err := m.db.Accounts() if err != nil { m.err = err return m, nil } i := m.accountTable.Cursor() if i >= 0 && i < len(accounts) { m.filter.AccountSlug = accounts[i].Slug m.status = "account: " + accounts[i].Slug m.view = viewTxns m.err = m.reloadTxns() } } return m, nil case "a": m.filter.AccountSlug = "" m.status = "all accounts" m.err = m.reloadTxns() return m, nil case "u": m.onlyUntagged = !m.onlyUntagged if m.onlyUntagged { m.status = "showing untagged only" m.view = viewTxns } else { m.status = "showing all transactions" } m.err = m.reloadTxns() return m, nil case "t": if m.view != viewTxns { return m, nil } t, ok := m.selected() if !ok { return m, nil } m.input = inputTag m.text.SetValue(t.ManualTag) m.text.CursorEnd() m.text.Focus() return m, nil case "/": m.view = viewTxns m.input = inputSearch m.text.SetValue(m.filter.Search) m.text.CursorEnd() m.text.Focus() return m, nil case "x": // Toggle the manual transfer override away from whatever is in effect. if m.view != viewTxns { return m, nil } t, ok := m.selected() if !ok { return m, nil } v := !t.IsTransfer() if err := m.db.SetManualTransfer(t.ID, &v); err != nil { m.err = err return m, nil } if v { m.status = "marked as transfer" } else { m.status = "unmarked as transfer" } m.err = m.reloadTxns() return m, nil case "c": // Drop manual overrides and fall back to what the rules say. if m.view != viewTxns { return m, nil } t, ok := m.selected() if !ok { return m, nil } if err := m.db.ClearOverrides(t.ID); err != nil { m.err = err return m, nil } m.status = "manual overrides cleared" m.err = m.reloadTxns() return m, nil case "r": n, err := m.engine.Retag(m.db) if err != nil { m.err = err return m, nil } m.status = fmt.Sprintf("rules re-applied, %d rows changed", n) m.err = m.reload() return m, nil case "i": res, err := importer.Run(m.root, m.db, m.accounts, m.engine, importer.Options{}) if err != nil { m.err = err return m, nil } _, added, skipped := res.Total() m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped) if failures := res.Errs(); len(failures) > 0 { m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err) } m.err = m.reload() return m, nil } 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()) + "\n") switch m.view { case viewAccounts: b.WriteString(m.accountTable.View()) case viewTxns: b.WriteString(m.txnTable.View()) case viewReport: b.WriteString(m.reportTable.View()) } b.WriteString("\n") switch { case m.input == inputTag: b.WriteString(statusStyle.Render("tag: ") + m.text.View()) case m.input == inputSearch: b.WriteString(statusStyle.Render("search: ") + m.text.View()) case m.err != nil: b.WriteString(errStyle.Render("error: " + m.err.Error())) default: b.WriteString(statusStyle.Render(m.status)) } b.WriteString("\n") b.WriteString(helpStyle.Render(m.help())) return b.String() } 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 viewReport: return "money · report · " + scope + " · transfers excluded" default: return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns)) } } func (m *Model) help() string { if m.input != inputNone { return "enter confirm · esc cancel" } switch m.view { case viewAccounts: return "enter open · 2 transactions · 3 report · i import · r retag · q quit" case viewReport: return "1 accounts · 2 transactions · u untagged · a all accounts · q quit" default: return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 1 accounts · 3 report · q quit" } }