An account folder is only registered in the index by an import, so a freshly configured account.toml produced an empty accounts table with nothing to say why or what to do about it. Each view now explains its own emptiness and names the key that resolves it, distinguishing configured-but-not-imported from no-folders-at-all, and telling apart a filter that matched nothing from having no data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
598 lines
14 KiB
Go
598 lines
14 KiB
Go
// Package tui is the terminal interface: browse accounts, tag transactions,
|
|
// mark transfers by hand, and see where the money went.
|
|
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"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)
|
|
emptyStyle = lipgloss.NewStyle().Padding(1, 2)
|
|
)
|
|
|
|
// 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()))
|
|
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())
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
|
|
// emptyMessage explains an empty view and names the key that fixes it, or
|
|
// returns "" when there is something to show.
|
|
func (m *Model) emptyMessage() string {
|
|
switch m.view {
|
|
case viewAccounts:
|
|
if len(m.accountTable.Rows()) > 0 {
|
|
return ""
|
|
}
|
|
// An account.toml on disk is not enough: folders are only registered
|
|
// in the index by an import, so say that rather than showing nothing.
|
|
if n := len(m.accounts); n > 0 {
|
|
return fmt.Sprintf(
|
|
"No accounts imported yet.\n\n"+
|
|
"%d account folder(s) configured in %s.\n"+
|
|
"Press i to import their statements (same as running `money import`).",
|
|
n, m.root)
|
|
}
|
|
return fmt.Sprintf(
|
|
"No account folders found in %s.\n\n"+
|
|
"An account is a folder containing an account.toml.\n"+
|
|
"Create one, drop statements in, then press i to import.",
|
|
m.root)
|
|
|
|
case viewTxns:
|
|
if len(m.txns) > 0 {
|
|
return ""
|
|
}
|
|
switch {
|
|
case m.filter.Search != "":
|
|
return fmt.Sprintf("No transactions match /%s.\n\nPress / to change the search.", m.filter.Search)
|
|
case m.onlyUntagged:
|
|
return "Nothing untagged.\n\nEvery transaction here has a tag. Press u to see them all."
|
|
case m.filter.AccountSlug != "":
|
|
return fmt.Sprintf(
|
|
"No transactions in %s.\n\nPress i to import, or a to see every account.",
|
|
m.filter.AccountSlug)
|
|
}
|
|
return "No transactions yet.\n\nPress i to import (same as running `money import`)."
|
|
|
|
case viewReport:
|
|
if len(m.reportTable.Rows()) > 0 {
|
|
return ""
|
|
}
|
|
return "Nothing to report yet.\n\nPress i to import some statements first."
|
|
}
|
|
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 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"
|
|
}
|
|
}
|