~whereswaldon/rosebud

ac25ca3ed7a04a1dd71f207e649dbefa0c24f4a9 — Chris Waldon 9 months ago 43d4c58
deps:cmd/rosebud: switch to streaming state

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

A cmd/editor/hello.go
A cmd/rosebud/bundle.go
M cmd/rosebud/main.go
A cmd/rosebud/tx-service.go
A ds/ds.go
M go.mod
M go.sum
A cmd/editor/hello.go => cmd/editor/hello.go +52 -0
@@ 0,0 1,52 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

// A simple Gio program. See https://gioui.org for more information.

import (
	"log"
	"os"

	"gioui.org/app"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/op"

	"gioui.org/font/gofont"
	"gioui.org/widget"
	"gioui.org/widget/material"
	//"git.sr.ht/~whereswaldon/rosebud/appwidget"
	//"git.sr.ht/~whereswaldon/rosebud/appwidget/apptheme"
)

func main() {
	log.SetFlags(log.Lshortfile)
	go func() {
		w := app.NewWindow()
		if err := loop(w); err != nil {
			log.Fatal(err)
		}
		os.Exit(0)
	}()
	app.Main()
}

func loop(w *app.Window) error {
	th := material.NewTheme(gofont.Collection())
	var ops op.Ops
	//var form appwidget.TxForm
	var ed widget.Editor
	ed.SetText("abc def\nghi jkl")
	for {
		e := <-w.Events()
		switch e := e.(type) {
		case system.DestroyEvent:
			return e.Err
		case system.FrameEvent:
			gtx := layout.NewContext(&ops, e)
			material.Editor(th, &ed, "").Layout(gtx)
			e.Frame(gtx.Ops)
		}
	}
}

A cmd/rosebud/bundle.go => cmd/rosebud/bundle.go +11 -0
@@ 0,0 1,11 @@
package main

type Bundle struct {
	Tx *TxService
}

func NewBundle() Bundle {
	return Bundle{
		Tx: NewTxService(),
	}
}

M cmd/rosebud/main.go => cmd/rosebud/main.go +79 -197
@@ 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 {

A cmd/rosebud/tx-service.go => cmd/rosebud/tx-service.go +176 -0
@@ 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
}

A ds/ds.go => ds/ds.go +33 -0
@@ 0,0 1,33 @@
/*
ds provides data structure helper functions.
*/
package ds

import "golang.org/x/exp/slices"

// EnsureSize grows the provided slice to ensure it has length size and returns
// the result. It may or may not have capacity greater than size.
func EnsureSize[T any](ts []T, size int) []T {
	if needed := size - len(ts); needed > 0 {
		ts = slices.Grow(ts, needed)
	}
	return ts[:size]
}

// EnsureFilled accepts a slice of pointers and allocates a zero value for any
// element that is nil.
func EnsureFilled[T any, PT *T](ts []PT) {
	for i := range ts {
		if ts[i] == nil {
			ts[i] = new(T)
		}
	}
}

// EnsureFilledSize accepts a slice of pointers, resizes it to ensure it has
// length at least size, and allocates zero values for any nil elements.
func EnsureFilledSize[T any, PT *T](ts []PT, size int) []PT {
	ts = EnsureSize(ts, size)
	EnsureFilled(ts)
	return ts
}

M go.mod => go.mod +1 -1
@@ 5,7 5,7 @@ go 1.19
require (
	gioui.org v0.0.0-20221122135904-dee53b364560
	gioui.org/x v0.0.0-20221121204253-9da08d942944
	git.sr.ht/~gioverse/skel v0.0.0-20220916150537-2f38f089e413
	git.sr.ht/~gioverse/skel v0.0.0-20230226150210-a6ce6e3dcc0d
	git.sr.ht/~whereswaldon/ledger v0.0.0-20221201021135-9858bd9f1e74
	github.com/lithammer/fuzzysearch v1.1.5
	golang.org/x/exp v0.0.0-20221114191408-850992195362

M go.sum => go.sum +4 -4
@@ 6,12 6,12 @@ gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.0.0-20221121204253-9da08d942944 h1:1qB+TI3FwPFWzyqF6LzW4jhC3Vc+LbBnMqV7EvmJflI=
gioui.org/x v0.0.0-20221121204253-9da08d942944/go.mod h1:uhVlN625ysZfFEbEdxt+wgyCGW2sh5p6SYs9dr+w+u4=
git.sr.ht/~gioverse/skel v0.0.0-20220916150537-2f38f089e413 h1:rqEp9NqS0icb0xxSWpUe11sHf0bhwobxd+O6Wei6wGc=
git.sr.ht/~gioverse/skel v0.0.0-20220916150537-2f38f089e413/go.mod h1:DtJGzJPf/ONJskXDaxORcVjIFpKWN7mBq0R8j7c4BQM=
git.sr.ht/~gioverse/skel v0.0.0-20230224192548-66aecf7054f6 h1:ETqDhWYfbhjeGdatnqV6Epc7VPLFIXRRLvajrykP/8E=
git.sr.ht/~gioverse/skel v0.0.0-20230224192548-66aecf7054f6/go.mod h1:DtJGzJPf/ONJskXDaxORcVjIFpKWN7mBq0R8j7c4BQM=
git.sr.ht/~gioverse/skel v0.0.0-20230226150210-a6ce6e3dcc0d h1:+Dobn70kVxsgEbiCI2NScqBFBhgz/6LY5Vqa5vVNXws=
git.sr.ht/~gioverse/skel v0.0.0-20230226150210-a6ce6e3dcc0d/go.mod h1:DtJGzJPf/ONJskXDaxORcVjIFpKWN7mBq0R8j7c4BQM=
git.sr.ht/~whereswaldon/gio v0.0.0-20221130215233-799cf1866570 h1:YJwB0VWnSNrcwXsdgLmFvv81DmtLCkBnN1LcjHV7UYE=
git.sr.ht/~whereswaldon/gio v0.0.0-20221130215233-799cf1866570/go.mod h1:GN091SCcGAfHfQiSOetXx7Abdy+8nmONj0ZN63Xxf7w=
git.sr.ht/~whereswaldon/ledger v0.0.0-20221201014303-8b8bd21c19ce h1:5r//Qmi7gajkHXN/9e7zOjba+gL2hMGu2/+wYcrnJpw=
git.sr.ht/~whereswaldon/ledger v0.0.0-20221201014303-8b8bd21c19ce/go.mod h1:fgkfUSZLCQMS9ahsdW7nU2Lgyd1cOHx8gN6H16ToTDs=
git.sr.ht/~whereswaldon/ledger v0.0.0-20221201021135-9858bd9f1e74 h1:TLzqsgV55Zz2mK69oaz7GNlpju9Q7A0ErIVahW4rWaw=
git.sr.ht/~whereswaldon/ledger v0.0.0-20221201021135-9858bd9f1e74/go.mod h1:fgkfUSZLCQMS9ahsdW7nU2Lgyd1cOHx8gN6H16ToTDs=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=