@@ 3,12 3,9 @@ package main
import (
"bytes"
"context"
- "fmt"
"image"
- "io"
"log"
"os"
- "path/filepath"
"sort"
"strings"
"time"
@@ 23,9 20,7 @@ import (
"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/~gioverse/skel/stream"
"git.sr.ht/~whereswaldon/ledger"
"git.sr.ht/~whereswaldon/ledger/decimal"
"git.sr.ht/~whereswaldon/rosebud/appwidget"
@@ 35,39 30,31 @@ import (
"golang.org/x/exp/maps"
)
+type (
+ C = layout.Context
+ D = layout.Dimensions
+)
+
func main() {
go func() {
- bus := bus.New()
- windower := window.NewWindower(bus)
- window.NewWindowForBus(bus, loop, app.Title("Rosebud - tend to your finances"))
- windower.Run()
+ w := app.NewWindow(app.Title("Rosebud - tend to your finances"))
+ if err := loop(w); err != nil {
+ log.Fatal(err)
+ }
os.Exit(0)
}()
app.Main()
}
-type LoadStage byte
-
-const (
- RequestingFiles LoadStage = iota
- LoadingFiles
- Ready
-)
-
-func loop(w *app.Window, conn bus.Connection) error {
+func loop(w *app.Window) error {
var ops op.Ops
- ui := UI{
- Theme: apptheme.NewTheme(gofont.Collection()),
- Explorer: explorer.NewExplorer(w),
- Conn: conn,
- }
- ui.Divider.Ratio = .4
+ windowCtx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ controller := stream.NewController(windowCtx, w.Invalidate)
+ ui := NewUI(controller, NewBundle(), explorer.NewExplorer(w))
+ ui.requestFiles()
for {
select {
- case e := <-conn.Output():
- if future.TryResults(e) {
- w.Invalidate()
- }
case e := <-w.Events():
ui.Explorer.ListenEvents(e)
switch e := e.(type) {
@@ 85,30 72,68 @@ func loop(w *app.Window, conn bus.Connection) error {
}
}
+// TxState is a snapshot of the transaction state displayed by the application.
+// This type is used to reload all relevant state via the same stream.
+type TxState struct {
+ Loaded bool
+ Txs []*ledger.Transaction
+ Filtered []int
+ Balances []*ledger.Account
+ Tree *AccountTreeNode
+}
+
type UI struct {
- Stage LoadStage
+ Bundle
Explorer *explorer.Explorer
- Conn bus.Connection
Theme *apptheme.Theme
- Txs []*ledger.Transaction
- // Filtered is a slice of indices into Txs that have been filtered and
- // sorted by user interactions.
- Filtered []int
- Balances []*ledger.Account
- OutFilePath string
- CacheFilePath string
- reload future.Single
+
+ txStateStream *stream.Stream[TxState]
+ TxState
// Layout state.
Divider component.Resize
TxList widget.List
BalanceList widget.List
AccountRegistry
- Tree *AccountTreeNode
TxEditor appwidget.TxForm
}
+func NewUI(controller *stream.Controller, bundle Bundle, expl *explorer.Explorer) UI {
+ ui := UI{
+ Bundle: bundle,
+ Explorer: expl,
+ Theme: apptheme.NewTheme(gofont.Collection()),
+ txStateStream: stream.New(controller, func(ctx context.Context) <-chan stream.Result[TxState] {
+ rawTxs := bundle.Tx.TxStream(ctx)
+ asStates := stream.Transform(rawTxs, func(rawTxs []*ledger.Transaction, err error) (TxState, error) {
+ filtered := make([]int, len(rawTxs))
+ for i := range filtered {
+ filtered[i] = i
+ }
+ // sort the elements of filtered by the dates of the transactions they refer to.
+ sort.Slice(filtered, func(i, j int) bool {
+ return rawTxs[filtered[i]].Date.After(rawTxs[filtered[j]].Date)
+ })
+ balances := ledger.GetBalances(rawTxs, nil)
+ tree := BuildAccountTree(balances)
+ return TxState{
+ Txs: rawTxs,
+ Filtered: filtered,
+ Balances: balances,
+ Tree: tree,
+ Loaded: true,
+ }, err
+ })
+ return asStates
+ }),
+ }
+ ui.Divider.Ratio = .4
+ ui.TxList.List.Axis = layout.Vertical
+ ui.BalanceList.List.Axis = layout.Vertical
+ return ui
+}
+
type AccountState struct {
component.DiscloserState
widget.Clickable
@@ 131,115 156,25 @@ func (d *AccountRegistry) Get(account ledger.Account) *AccountState {
d.elements[account.Name] = ds
return ds
}
-
-type (
- C = layout.Context
- D = layout.Dimensions
-)
-
-type Namer interface {
- Name() string
-}
-
func (ui *UI) Layout(gtx C) D {
- switch ui.Stage {
- case RequestingFiles:
- ui.requestFiles()
- ui.Stage = LoadingFiles
- case LoadingFiles:
+ if err := ui.txStateStream.ReadInto(gtx, &ui.TxState, TxState{}); err != nil {
+ log.Printf("failed streaming transactions: %v", err)
+ }
+ if !ui.Loaded {
return layout.Center.Layout(gtx, material.Loader(ui.Theme.Th).Layout)
- case Ready:
- return ui.layout(gtx)
}
- return D{}
+ return ui.layout(gtx)
}
func (ui *UI) requestFiles() {
- type LoadInfo struct {
- Txs []*ledger.Transaction
- Filtered []int
- Balances []*ledger.Account
- Tree *AccountTreeNode
- OutFilePath string
- CacheFilePath string
- }
- 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()
- 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 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.ParseNamedLedger(outFilePath, tee)
- if err != nil {
- return LoadInfo{}, fmt.Errorf("failed parsing input file: %w", err)
- }
- filtered := make([]int, len(transactions))
- for i := range filtered {
- filtered[i] = i
- }
- // sort the elements of filtered by the dates of the transactions they refer to.
- sort.Slice(filtered, func(i, j int) bool {
- return transactions[filtered[i]].Date.After(transactions[filtered[j]].Date)
- })
- balances := ledger.GetBalances(transactions, nil)
- tree := BuildAccountTree(balances)
- return LoadInfo{
- Txs: transactions,
- Filtered: filtered,
- Balances: balances,
- Tree: tree,
- OutFilePath: outFilePath,
- CacheFilePath: cacheFilePath,
- }, nil
- },
- func(li LoadInfo, err error) bool {
- if err != nil {
- log.Printf("failed loading: %v", err)
- } else {
- ui.Txs = li.Txs
- ui.Filtered = li.Filtered
- ui.Tree = li.Tree
- ui.Balances = li.Balances
- ui.OutFilePath = li.OutFilePath
- ui.CacheFilePath = li.CacheFilePath
- ui.Stage = Ready
- }
- return true
- })
+ go func() {
+ file, err := ui.Explorer.ChooseFile()
+ if err != nil {
+ log.Printf("failed requesting files: %v", err)
+ return
+ }
+ ui.Tx.OpenLedger(file)
+ }()
}
func (ui *UI) rewriteAndLoad() {
@@ 247,60 182,7 @@ func (ui *UI) rewriteAndLoad() {
for i := range txCopies {
txCopies[i] = &(*ui.Txs[i])
}
- filterCopies := make([]int, len(ui.Filtered))
- copy(filterCopies, ui.Filtered)
- targetFile := ui.OutFilePath
- type reloadInfo struct {
- Txs []*ledger.Transaction
- Filtered []int
- Balances []*ledger.Account
- Tree *AccountTreeNode
- OutFilePath string
- CacheFilePath string
- }
- ui.Stage = LoadingFiles
- future.RunSingle(&ui.reload, ui.Conn,
- func(ctx context.Context) (li reloadInfo, err error) {
- var b bytes.Buffer
- for _, tx := range txCopies {
- b.Write(toTxBytes(*tx))
- }
- if err := os.WriteFile(targetFile, b.Bytes(), 0o644); err != nil {
- return reloadInfo{}, fmt.Errorf("failed rewriting input file: %w", err)
- }
- transactions, err := ledger.ParseLedgerFile(targetFile)
- if err != nil {
- return reloadInfo{}, fmt.Errorf("failed parsing input file: %w", err)
- }
- filtered := make([]int, len(transactions))
- for i := range filtered {
- filtered[i] = i
- }
- // sort the elements of filtered by the dates of the transactions they refer to.
- sort.Slice(filtered, func(i, j int) bool {
- return transactions[filtered[i]].Date.After(transactions[filtered[j]].Date)
- })
- balances := ledger.GetBalances(transactions, nil)
- tree := BuildAccountTree(balances)
- return reloadInfo{
- Txs: transactions,
- Filtered: filtered,
- Balances: balances,
- Tree: tree,
- }, nil
- },
- func(ctx context.Context, li reloadInfo, err error) bool {
- if err != nil {
- log.Printf("failed loading: %v", err)
- } else {
- ui.Txs = li.Txs
- ui.Filtered = li.Filtered
- ui.Tree = li.Tree
- ui.Balances = li.Balances
- ui.Stage = Ready
- }
- return true
- })
+ go ui.Tx.UpdateLedger(txCopies)
}
func (ui *UI) layout(gtx C) D {
@@ 0,0 1,176 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "git.sr.ht/~gioverse/skel/stream"
+ "git.sr.ht/~whereswaldon/ledger"
+)
+
+type TxService struct {
+ txLock sync.Mutex
+ loaded bool
+ transactions []*ledger.Transaction
+ loadErr error
+ txFile string
+
+ broadcast *sync.Cond
+}
+
+func NewTxService() *TxService {
+ tx := &TxService{}
+ tx.broadcast = sync.NewCond(&tx.txLock)
+ return tx
+}
+
+// OpenLedger reconfigures the service using the contents of the provided
+func (t *TxService) OpenLedger(contents io.ReadCloser) {
+ var err error
+ defer func() {
+ if err != nil {
+ t.txLock.Lock()
+ t.loadErr = err
+ t.txLock.Unlock()
+ }
+ }()
+ defer func() {
+ err := contents.Close()
+ if err != nil {
+ log.Printf("failed closing ledger file: %v", err)
+ }
+ }()
+ cacheDir, err := os.UserCacheDir()
+ if err != nil {
+ err = fmt.Errorf("unable to resolve cache dir: %w", err)
+ return
+ }
+ confDir, err := os.UserConfigDir()
+ if err != nil {
+ err = fmt.Errorf("unable to resolve config dir: %w", err)
+ }
+ cacheDir = filepath.Join(cacheDir, "rosebud")
+ if err = os.MkdirAll(cacheDir, 0o755); err != nil {
+ err = fmt.Errorf("unable to make application cache dir: %w", err)
+ return
+ }
+ outFilePath := filepath.Join(confDir, "rosebud", "budget.ledger")
+ cacheFileName := "file"
+ type Namer interface {
+ Name() string
+ }
+
+ if namer, ok := contents.(Namer); ok {
+ name := namer.Name()
+ outFilePath = name
+ cacheFileName = filepath.Base(name)
+ }
+
+ 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 {
+ err = fmt.Errorf("unable to create backup file: %w", err)
+ return
+ }
+ defer func() {
+ closeErr := backupFile.Close()
+ if closeErr != nil && err == nil {
+ err = fmt.Errorf("failed closing backup file: %w", closeErr)
+ }
+ }()
+ tee := io.TeeReader(contents, backupFile)
+ transactions, err := ledger.ParseNamedLedger(outFilePath, tee)
+ if err != nil {
+ err = fmt.Errorf("failed parsing input file: %w", err)
+ return
+ }
+ t.txLock.Lock()
+ defer t.txLock.Unlock()
+ t.transactions = transactions
+ t.txFile = outFilePath
+ t.loadErr = nil
+ t.broadcast.Broadcast()
+ t.loaded = true
+}
+
+func (t *TxService) UpdateLedger(txs []*ledger.Transaction) {
+ t.txLock.Lock()
+ defer t.txLock.Unlock()
+ var b bytes.Buffer
+ for _, tx := range txs {
+ b.Write(toTxBytes(*tx))
+ }
+ if err := os.WriteFile(t.txFile, b.Bytes(), 0o644); err != nil {
+ t.loadErr = fmt.Errorf("failed rewriting input file: %w", err)
+ return
+ }
+ transactions, err := ledger.ParseLedgerFile(t.txFile)
+ if err != nil {
+ t.loadErr = fmt.Errorf("failed parsing input file: %w", err)
+ return
+ }
+ t.transactions = transactions
+ t.broadcast.Broadcast()
+}
+
+func (t *TxService) TxStream(ctx context.Context) <-chan stream.Result[[]*ledger.Transaction] {
+ out := make(chan stream.Result[[]*ledger.Transaction])
+ broadcasted := make(chan struct{})
+ newValue := func() (stream.Result[[]*ledger.Transaction], bool) {
+ t.txLock.Lock()
+ txs := make([]*ledger.Transaction, len(t.transactions))
+ for i, tx := range t.transactions {
+ txCopy := *tx
+ txs[i] = &(txCopy)
+ }
+ loadErr := t.loadErr
+ loaded := t.loaded
+ t.txLock.Unlock()
+ return stream.ResultFrom(txs, loadErr), loaded
+ }
+ go func() {
+ defer close(broadcasted)
+ for {
+ t.txLock.Lock()
+ t.broadcast.Wait()
+ select {
+ case <-ctx.Done():
+ t.txLock.Unlock()
+ return
+ case broadcasted <- struct{}{}:
+ t.txLock.Unlock()
+ }
+ }
+ }()
+ go func() {
+ defer close(out)
+ emit := out
+ emitVal, shouldEmit := newValue()
+ if !shouldEmit {
+ emit = nil
+ }
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case emit <- emitVal:
+ emit = nil
+ case <-broadcasted:
+ emit = out
+ emitVal, shouldEmit = newValue()
+ if !shouldEmit {
+ emit = nil
+ }
+ }
+ }
+ }()
+ return out
+}