Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Prerequisites:
RunE: func(cmd *cobra.Command, args []string) error {
debug := viper.GetBool("logging.debug")
logFile := viper.GetString("logging.file")
timeout := viper.GetInt("timeout")

if debug {
fmt.Println("Debug logging is enabled")
Expand All @@ -64,7 +65,7 @@ Prerequisites:
ff, err := firefly.NewApi(firefly.ApiConfig{
ApiKey: apiKey,
ApiUrl: apiUrl,
TimeoutSeconds: 10,
TimeoutSeconds: timeout,
})
if err != nil {
return fmt.Errorf("failed to connect to Firefly III: %w", err)
Expand Down Expand Up @@ -120,6 +121,7 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/ffiii-tui/config)")
rootCmd.PersistentFlags().StringP("firefly.api_key", "k", "your_firefly_api_key_here", "Firefly III API key")
rootCmd.PersistentFlags().StringP("firefly.api_url", "u", "https://your-firefly-iii-instance.com/api/v1", "Firefly III API URL")
rootCmd.PersistentFlags().IntP("timeout", "t", 10, "Connection timeout")
rootCmd.Flags().BoolP("logging.debug", "d", false, "Enable debug logging")
rootCmd.Flags().StringP("logging.file", "l", "messages.log", "Log file path (if empty, logs to stdout)")

Expand Down
4 changes: 4 additions & 0 deletions internal/firefly/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,7 @@ func (a *Account) GetBalance(api *Api) float64 {
func (a *Account) IsEmpty() bool {
return *a == Account{}
}

func (a Account) GetName() string {
return a.Name
}
192 changes: 192 additions & 0 deletions internal/ui/account_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/*
Copyright © 2025-2026 Artur Taranchiev <artur.taranchiev@gmail.com>
SPDX-License-Identifier: Apache-2.0
*/
package ui

import (
"reflect"

"ffiii-tui/internal/firefly"
"ffiii-tui/internal/ui/notify"

"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)

// AccountListModel is a generic model for account/category list views
type AccountListModel[T ListEntity] struct {
list list.Model
api any // Specific API interface
focus bool
sorted bool
config *AccountListConfig[T]
styles Styles
keymap AccountKeyMap
}

// NewAccountListModel creates a new generic account list model
func NewAccountListModel[T ListEntity](api any, config *AccountListConfig[T]) AccountListModel[T] {
items := config.GetItems(api, false)

m := AccountListModel[T]{
list: list.New(items, list.NewDefaultDelegate(), 0, 0),
api: api,
config: config,
styles: DefaultStyles(),
keymap: DefaultAccountKeyMap(),
}
m.list.Title = config.Title
m.list.SetShowStatusBar(false)
m.list.SetFilteringEnabled(false)
m.list.SetShowHelp(false)
m.list.DisableQuitKeybindings()

return m
}

func (m AccountListModel[T]) Init() tea.Cmd {
return nil
}

func (m AccountListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd

if matchMsgType(msg, m.config.RefreshMsgType) {
return m, func() tea.Msg {
err := m.config.RefreshItems(m.api, m.config.AccountType)
if err != nil {
return notify.NotifyWarn(err.Error())()
}
return m.config.UpdateMsgType
}
}

if matchMsgType(msg, m.config.UpdateMsgType) {
return m, m.updateItemsCmd()
}

if msg, ok := msg.(UpdatePositions); ok {
if msg.layout != nil {
h, v := m.styles.Base.GetFrameSize()
var height int
if m.config.HasSummary {
height = msg.layout.Height - v - msg.layout.TopSize - msg.layout.SummarySize
} else {
height = msg.layout.Height - v - msg.layout.TopSize
}
m.list.SetSize(msg.layout.Width-h, height)
}
return m, nil
}

if !m.focus {
return m, nil
}

// Common keys
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keymap.Quit):
return m, SetView(transactionsView)
case key.Matches(msg, m.keymap.ViewTransactions):
return m, SetView(transactionsView)
case key.Matches(msg, m.keymap.ViewAssets):
return m, SetView(assetsView)
case key.Matches(msg, m.keymap.ViewCategories):
return m, SetView(categoriesView)
case key.Matches(msg, m.keymap.ViewExpenses):
return m, SetView(expensesView)
case key.Matches(msg, m.keymap.ViewRevenues):
return m, SetView(revenuesView)
case key.Matches(msg, m.keymap.ViewLiabilities):
return m, SetView(liabilitiesView)
case key.Matches(msg, m.keymap.Refresh):
return m, Cmd(m.config.RefreshMsgType)
case key.Matches(msg, m.keymap.ResetFilter):
return m, Cmd(FilterMsg{Reset: true})
case key.Matches(msg, m.keymap.Sort):
if m.config.HasSort {
m.sorted = !m.sorted
return m, Cmd(m.config.UpdateMsgType)
}
case key.Matches(msg, m.keymap.New):
return m, m.config.PromptNewFunc()
case key.Matches(msg, m.keymap.Filter):
i, ok := m.list.SelectedItem().(accountListItem[T])
if ok {
if m.config.HasTotalRow && i.Entity.GetName() == "Total" {
return m, nil
}
return m, m.config.FilterFunc(i)
}
return m, nil
case key.Matches(msg, m.keymap.Select):
i, ok := m.list.SelectedItem().(accountListItem[T])
if ok {
if m.config.HasTotalRow && i.Entity.GetName() == "Total" {
return m, nil
}
return m, m.config.SelectFunc(i)
}
return m, nil
}
}

m.list, cmd = m.list.Update(msg)
return m, cmd
}

func (m AccountListModel[T]) View() string {
return m.styles.LeftPanel.Render(m.list.View())
}

func (m *AccountListModel[T]) Focus() {
m.list.FilterInput.Focus()
m.focus = true
}

func (m *AccountListModel[T]) Blur() {
m.list.FilterInput.Blur()
m.focus = false
}

func (m AccountListModel[T]) createTotalEntity(primary float64) list.Item {
var entity T

acc := firefly.Account{Name: "Total", CurrencyCode: ""}
if api, ok := m.api.(interface{ PrimaryCurrency() firefly.Currency }); ok {
acc.CurrencyCode = api.PrimaryCurrency().Code
}
entity = any(acc).(T)
return newAccountListItem(entity, "Total", primary)
}

func (m *AccountListModel[T]) updateItemsCmd() tea.Cmd {
items := m.config.GetItems(m.api, m.sorted)

if m.config.HasTotalRow && m.config.GetTotalFunc != nil {
primary := m.config.GetTotalFunc(m.api)
totalEntity := m.createTotalEntity(primary)

cmds := []tea.Cmd{
m.list.SetItems(items),
m.list.InsertItem(0, totalEntity),
}

cmds = append(cmds, Cmd(DataLoadCompletedMsg{DataType: m.config.AccountType}))

return tea.Sequence(cmds...)
}

return tea.Batch(
m.list.SetItems(items),
Cmd(DataLoadCompletedMsg{DataType: m.config.AccountType}),
)
}

func matchMsgType(msg, ty tea.Msg) bool {
return reflect.TypeOf(msg) == reflect.TypeOf(ty)
}
38 changes: 38 additions & 0 deletions internal/ui/account_list_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
Copyright © 2025-2026 Artur Taranchiev <artur.taranchiev@gmail.com>
SPDX-License-Identifier: Apache-2.0
*/
package ui

import (
"ffiii-tui/internal/firefly"

"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)

type AccountListConfig[T firefly.Account] struct {
// Data specific
AccountType string

// Display
Title string

// Data functions
GetItems func(api any, sorted bool) []list.Item
RefreshItems func(api any, accountType string) error

// Messages
RefreshMsgType any
UpdateMsgType any

// UI Behavior
PromptNewFunc func() tea.Cmd
HasSort bool
HasTotalRow bool
HasSummary bool
GetTotalFunc func(api any) float64 // for totals

FilterFunc func(item list.Item) tea.Cmd
SelectFunc func(item list.Item) tea.Cmd
}
67 changes: 67 additions & 0 deletions internal/ui/account_list_items.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
Copyright © 2025-2026 Artur Taranchiev <artur.taranchiev@gmail.com>
SPDX-License-Identifier: Apache-2.0
*/
package ui

import (
"fmt"

"ffiii-tui/internal/firefly"
)

// ListEntity is a constraint for types that can be displayed in account lists
type ListEntity interface {
firefly.Account
GetName() string
}

// accountListItem is a generic list item for accounts and categories
type accountListItem[T ListEntity] struct {
Entity T
PrimaryVal float64
primaryLabel string
}

// Accessors for backward compatibility with tests
func (i accountListItem[T]) GetPrimaryVal() float64 {
return i.PrimaryVal
}

func (i accountListItem[T]) Title() string {
var name string
switch entity := any(i.Entity).(type) {
case firefly.Account:
name = entity.Name
}
return name
}

func (i accountListItem[T]) Description() string {
var currencyCode string
switch entity := any(i.Entity).(type) {
case firefly.Account:
currencyCode = entity.CurrencyCode
}

desc := fmt.Sprintf("%s: %.2f %s", i.primaryLabel, i.PrimaryVal, currencyCode)
return desc
}

func (i accountListItem[T]) FilterValue() string {
var name string
switch entity := any(i.Entity).(type) {
case firefly.Account:
name = entity.Name
}
return name
}

// newAccountListItem creates a new account list item with a single value
func newAccountListItem[T ListEntity](entity T, primaryLabel string, primaryVal float64) accountListItem[T] {
return accountListItem[T]{
Entity: entity,
PrimaryVal: primaryVal,
primaryLabel: primaryLabel,
}
}
Loading