~whereswaldon/rosebud

3aa7938aa03331ff2beef9613f231bafc695dff0 — Chris Waldon 1 year, 3 months ago 9c30c96
cmd/rosebud: display txs and tree for loaded budget

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
1 files changed, 300 insertions(+), 55 deletions(-)

M cmd/rosebud/main.go
M cmd/rosebud/main.go => cmd/rosebud/main.go +300 -55
@@ 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
}