From 3aa7938aa03331ff2beef9613f231bafc695dff0 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Thu, 24 Nov 2022 09:01:37 -0500 Subject: [PATCH] cmd/rosebud: display txs and tree for loaded budget Signed-off-by: Chris Waldon --- cmd/rosebud/main.go | 355 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 300 insertions(+), 55 deletions(-) diff --git a/cmd/rosebud/main.go b/cmd/rosebud/main.go index 8ee8e1b..04b8258 100644 --- a/cmd/rosebud/main.go +++ b/cmd/rosebud/main.go @@ -2,20 +2,29 @@ package main import ( "fmt" + "image" "io" "log" + "math/big" "os" "path/filepath" + "strings" "time" "gioui.org/app" + "gioui.org/font/gofont" "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "gioui.org/x/component" "gioui.org/x/explorer" "git.sr.ht/~gioverse/skel/bus" "git.sr.ht/~gioverse/skel/future" "git.sr.ht/~gioverse/skel/window" + "git.sr.ht/~whereswaldon/rosebud/theme" "github.com/howeyc/ledger" ) @@ -41,9 +50,11 @@ const ( func loop(w *app.Window, conn bus.Connection) error { var ops op.Ops ui := UI{ + Theme: material.NewTheme(gofont.Collection()), Explorer: explorer.NewExplorer(w), Conn: conn, } + ui.Divider.Ratio = .4 for { select { case e := <-conn.Output(): @@ -55,7 +66,7 @@ func loop(w *app.Window, conn bus.Connection) error { switch e := e.(type) { case system.DestroyEvent: if e.Err != nil { - log.Println("error: %v", e.Err) + log.Printf("error: %v", e.Err) } return e.Err case system.FrameEvent: @@ -71,6 +82,42 @@ type UI struct { Stage LoadStage Explorer *explorer.Explorer Conn bus.Connection + + Theme *material.Theme + Txs []*ledger.Transaction + Balances []*ledger.Account + OutFilePath string + CacheFilePath string + + // Layout state. + Divider component.Resize + TxList widget.List + BalanceList widget.List + AccountRegistry + Tree *AccountTreeNode +} + +type AccountState struct { + component.DiscloserState + widget.Clickable +} + +type AccountRegistry struct { + elements map[string]*AccountState +} + +func (d *AccountRegistry) Get(account ledger.Account) *AccountState { + if d.elements == nil { + d.elements = make(map[string]*AccountState) + } + if ds, ok := d.elements[account.Name]; ok { + return ds + } + ds := &AccountState{} + ds.State = component.Visible + ds.Duration = time.Millisecond * 100 + d.elements[account.Name] = ds + return ds } type ( @@ -83,68 +130,266 @@ type Namer interface { } func (ui *UI) Layout(gtx C) D { + switch ui.Stage { + case RequestingFiles: + ui.requestFiles() + ui.Stage = LoadingFiles + case LoadingFiles: + return layout.Center.Layout(gtx, material.Loader(ui.Theme).Layout) + case Ready: + return ui.layout(gtx) + } + return D{} +} + +func (ui *UI) requestFiles() { type LoadInfo struct { Txs []*ledger.Transaction + Balances []*ledger.Account + Tree *AccountTreeNode OutFilePath string CacheFilePath string } - switch ui.Stage { - case RequestingFiles: - future.Run(ui.Conn, - func() (li LoadInfo, err error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - return LoadInfo{}, fmt.Errorf("unable to resolve cache dir: %w", err) - } - confDir, err := os.UserConfigDir() - if err != nil { - return LoadInfo{}, fmt.Errorf("unable to resolve config dir: %w", err) - } - cacheDir = filepath.Join(cacheDir, "rosebud") - if err := os.MkdirAll(cacheDir, 0o755); err != nil { - return LoadInfo{}, fmt.Errorf("unable to make application cache dir: %w", err) - } - file, err := ui.Explorer.ChooseFile("txt", "ledger") - if err != nil { - return LoadInfo{}, err - } - outFilePath := filepath.Join(confDir, "rosebud", "budget.ledger") - cacheFileName := "file" - if namer, ok := file.(Namer); ok { - name := namer.Name() - outFilePath = name - cacheFileName = filepath.Base(name) - } + future.Run(ui.Conn, + func() (li LoadInfo, err error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return LoadInfo{}, fmt.Errorf("unable to resolve cache dir: %w", err) + } + confDir, err := os.UserConfigDir() + if err != nil { + return LoadInfo{}, fmt.Errorf("unable to resolve config dir: %w", err) + } + cacheDir = filepath.Join(cacheDir, "rosebud") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return LoadInfo{}, fmt.Errorf("unable to make application cache dir: %w", err) + } + file, err := ui.Explorer.ChooseFile("txt", "ledger") + if err != nil { + return LoadInfo{}, err + } + outFilePath := filepath.Join(confDir, "rosebud", "budget.ledger") + cacheFileName := "file" + if namer, ok := file.(Namer); ok { + name := namer.Name() + outFilePath = name + cacheFileName = filepath.Base(name) + } - defer file.Close() - cacheFileName = fmt.Sprintf("%s-%s.gz", time.Now().Local().Format(time.RFC3339), cacheFileName) - cacheFilePath := filepath.Join(cacheDir, cacheFileName) - backupFile, err := os.Create(cacheFilePath) - if err != nil { - return LoadInfo{}, fmt.Errorf("unable to create backup file: %w", err) + defer file.Close() + cacheFileName = fmt.Sprintf("%s-%s.gz", time.Now().Local().Format(time.RFC3339), cacheFileName) + cacheFilePath := filepath.Join(cacheDir, cacheFileName) + backupFile, err := os.Create(cacheFilePath) + if err != nil { + return LoadInfo{}, fmt.Errorf("unable to create backup file: %w", err) + } + defer func() { + closeErr := backupFile.Close() + if closeErr != nil && err == nil { + err = fmt.Errorf("failed closing backup file: %w", closeErr) } - defer func() { - closeErr := backupFile.Close() - if closeErr != nil && err == nil { - err = fmt.Errorf("failed closing backup file: %w", closeErr) + }() + tee := io.TeeReader(file, backupFile) + transactions, err := ledger.ParseLedger(tee) + if err != nil { + return LoadInfo{}, fmt.Errorf("failed parsing input file: %w", err) + } + balances := ledger.GetBalances(transactions, nil) + tree := BuildAccountTree(balances) + return LoadInfo{ + Txs: transactions, + Balances: balances, + Tree: tree, + OutFilePath: outFilePath, + CacheFilePath: cacheFilePath, + }, nil + }, + func(li LoadInfo, err error) bool { + ui.Txs = li.Txs + ui.Tree = li.Tree + ui.Balances = li.Balances + ui.OutFilePath = li.OutFilePath + ui.CacheFilePath = li.CacheFilePath + ui.Stage = Ready + return true + }) +} + +func (ui *UI) layout(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + macro := op.Record(gtx.Ops) + dims := ui.layoutTopBar(gtx) + op.Defer(gtx.Ops, macro.Stop()) + return dims + }), + layout.Flexed(1, func(gtx C) D { + return ui.Divider.Layout(gtx, + ui.layoutBalancesPanel, + ui.layoutTransactions, + func(gtx C) D { + size := image.Point{ + X: gtx.Dp(1), + Y: gtx.Constraints.Max.Y, } - }() - tee := io.TeeReader(file, backupFile) - transactions, err := ledger.ParseLedger(tee) - if err != nil { - return LoadInfo{}, fmt.Errorf("failed parsing input file: %w", err) - } - return LoadInfo{ - Txs: transactions, - OutFilePath: outFilePath, - CacheFilePath: cacheFilePath, - }, nil - }, - func(li LoadInfo, err error) bool { - log.Printf("%v, %v", li, err) - return true + rect := component.Rect{ + Color: theme.Black, + Size: size, + } + return layout.Inset{ + Right: unit.Dp(4), + }.Layout(gtx, rect.Layout) + }) + }), + ) +} + +func (u *UI) layoutBalancesPanel(gtx C) D { + u.BalanceList.Axis = layout.Vertical + list := material.List(u.Theme, &u.BalanceList) + return list.Layout(gtx, 1, func(gtx C, index int) D { + return u.layoutAccountTree(gtx, u.Tree) + }) +} + +func (u *UI) layoutAccountTree(gtx C, at *AccountTreeNode) D { + if at == nil { + return D{} + } + ds := u.AccountRegistry.Get(at.Account) + d := component.SimpleDiscloser(u.Theme, &ds.DiscloserState) + if len(at.Children) > 0 { + var children []layout.FlexChild + for i := range at.Children { + child := at.Children[i] + children = append(children, layout.Rigid(func(gtx C) D { + return u.layoutAccountTreeNode(gtx, child) + })) + } + return d.Layout(gtx, material.Body1(u.Theme, "Journal").Layout, + func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children...) }) - ui.Stage = LoadingFiles } - return D{} + return u.layoutAccountTreeNode(gtx, at) +} + +func (u *UI) layoutAccountTreeNode(gtx C, at *AccountTreeNode) D { + ds := u.AccountRegistry.Get(at.Account) + d := component.SimpleDiscloser(u.Theme, &ds.DiscloserState) + balance := at.Account + parts := strings.Split(balance.Name, ":") + balance.Name = parts[len(parts)-1] + if len(at.Children) > 0 { + var children []layout.FlexChild + for i := range at.Children { + child := at.Children[i] + children = append(children, layout.Rigid(func(gtx C) D { + return u.layoutAccountTreeNode(gtx, child) + })) + } + return d.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + if ds.Hovered() { + macro := op.Record(gtx.Ops) + layout.E.Layout(gtx, func(gtx C) D { + return component.Surface(u.Theme).Layout(gtx, func(gtx C) D { + return layout.UniformInset(unit.Dp(2)).Layout(gtx, material.Body2(u.Theme, "Residue: "+at.Residue.FloatString(2)).Layout) + }) + }) + op.Defer(gtx.Ops, macro.Stop()) + } + return ds.Clickable.Layout(gtx, theme.Account(u.Theme, balance).Layout) + }, + func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + children...) + }) + } + return d.DetailInset().Layout(gtx, theme.Account(u.Theme, balance).Layout) +} + +func (u *UI) layoutTransactions(gtx C) D { + u.TxList.Axis = layout.Vertical + list := material.List(u.Theme, &u.TxList) + return list.Layout(gtx, len(u.Txs), func(gtx C, index int) D { + tx := u.Txs[index] + return theme.Transaction(u.Theme, tx).Layout(gtx) + }) +} + +func (u *UI) layoutTopBar(gtx C) D { + surf := component.Surface(u.Theme) + surf.CornerRadius = unit.Dp(0) + return surf.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return LayoutLogo(gtx, u.Theme) + }), + layout.Flexed(1, func(gtx C) D { + return D{ + Size: image.Pt(gtx.Constraints.Max.X, 0), + } + }), + ) + }) +} + +func LayoutLogo(gtx C, th *material.Theme) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(theme.TextColor(material.H3(th, "Rose"), theme.PrimaryDark).Layout), + layout.Rigid(theme.TextColor(material.H3(th, "bud"), theme.SecondaryDark).Layout), + ) +} + +type AccountTreeNode struct { + ledger.Account + Residue *big.Rat + Children []*AccountTreeNode +} + +func BuildAccountTree(accounts []*ledger.Account) *AccountTreeNode { + var at AccountTreeNode + for _, account := range accounts { + at.Insert(account) + } + computeResidues(&at) + return &at +} + +func computeResidues(node *AccountTreeNode) { + if len(node.Children) < 1 { + node.Residue = big.NewRat(0, 1) + return + } + node.Residue = &big.Rat{} + if node.Balance != nil { + node.Residue.Set(node.Balance) + } + for _, child := range node.Children { + computeResidues(child) + if node.Balance != nil { + balance := *child.Balance + node.Residue = node.Residue.Add(node.Residue, balance.Neg(&balance)) + } + } +} + +func (a *AccountTreeNode) Insert(account *ledger.Account) bool { + if a.Name == account.Name { + return false + } + if !strings.HasPrefix(account.Name, a.Name) { + return false + } + for _, child := range a.Children { + if child.Insert(account) { + return true + } + } + a.Children = append(a.Children, &AccountTreeNode{ + Account: *account, + }) + return true } -- 2.45.2