From c4dde38d23368e7d29f0b9a04aea16feda7c96b5 Mon Sep 17 00:00:00 2001 From: Sam Fredrickson Date: Fri, 22 Mar 2024 17:43:15 -0700 Subject: [PATCH] Support multiple assets simultaneously. --- README.md | 25 +++-- config/config.go | 2 +- moonmath.go | 14 +-- tui/asset/asset.go | 201 ++++++++++++++++++++++++++++++++++++++++ tui/tui.go | 222 ++++++++++++--------------------------------- 5 files changed, 288 insertions(+), 176 deletions(-) create mode 100644 tui/asset/asset.go diff --git a/README.md b/README.md index 41eb381..242d77d 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,27 @@ program. It's written in Go using the [Bubble Tea][tea] library, and uses [tea]: https://github.com/charmbracelet/bubbletea [coin]: https://www.coindesk.com +### Installation + +Go to the [Releases page](https://code.humancabbage.net/sam/moonmath/releases) +and download the archive for your operating system and architecture. (For the +uninitiated, "Darwin" means macOS.) + ### Configuration -By default, the program will use Bitcoin along with various goals and bases -of comparison. With the `--asset` flag, another asset supported by Coindesk can -be chosen. The [builtin default config](./config/default.yaml) only has special -goals for Ethereum ("ETH"). With the `--config-file` flag, however, one can -specify a YAML file that overrides these defaults and adds goals for other -assets. +By default, the program will use Bitcoin along with various goals and bases of +comparison. With the `--asset` flag, another asset supported by Coindesk can be +chosen. These can even be chained, e.g. `--asset BTC --asset ETH`, to show +projections for multiple assets simultaneously. + +The [builtin default config](./config/default.yaml) only has special +goals a handful of the most popular assets. With the `--config-file` flag, +however, one can specify a YAML file that overrides these defaults and adds +goals for other assets. + +Check out [coindesk/assets.go](./coindesk/assets.go) for a full list of +supported assets. Keep in mind these have not been exhaustively tested, and +it's likely that many will fail with the default configuration settings. ### "Theory" diff --git a/config/config.go b/config/config.go index acb067c..437f18c 100644 --- a/config/config.go +++ b/config/config.go @@ -49,7 +49,7 @@ type Data struct { RelativeBases []moon.RelativeBase `koanf:"relativeBases"` } -func (all All) GetData(asset coindesk.Asset) (data Data, err error) { +func (all All) GetData(asset coindesk.Asset) (data Data) { data, ok := all.Assets[asset] if !ok { data = all.Defaults diff --git a/moonmath.go b/moonmath.go index 4029935..98ff08b 100644 --- a/moonmath.go +++ b/moonmath.go @@ -12,8 +12,8 @@ import ( ) var CLI struct { - Asset string `short:"a" default:"BTC" help:"Asset to project."` - ConfigFile string `short:"c" help:"Path to YAML configuration file."` + Asset []string `short:"a" default:"BTC" help:"Asset(s) to project."` + ConfigFile string `short:"c" help:"Path to YAML configuration file."` } func main() { @@ -25,12 +25,12 @@ func main() { if err != nil { fail(err) } - CLI.Asset = strings.ToUpper(CLI.Asset) - cfg, err := allCfg.GetData(coindesk.Asset(CLI.Asset)) - if err != nil { - fail(err) + var assets []coindesk.Asset + for i := range CLI.Asset { + asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i])) + assets = append(assets, asset) } - p := tui.New(cfg) + p := tui.New(assets, allCfg) if _, err := p.Run(); err != nil { fail(err) } diff --git a/tui/asset/asset.go b/tui/asset/asset.go new file mode 100644 index 0000000..b8a3c1a --- /dev/null +++ b/tui/asset/asset.go @@ -0,0 +1,201 @@ +package asset + +import ( + "context" + "fmt" + "time" + + "code.humancabbage.net/sam/moonmath/coindesk" + "code.humancabbage.net/sam/moonmath/config" + "code.humancabbage.net/sam/moonmath/moon" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Model struct { + math moon.Math + + refreshing bool + indicator spinner.Model + + properties table.Model + projections table.Model +} + +type Msg struct { + Asset coindesk.Asset + inner tea.Msg +} + +func New(cfg config.Data) (m Model) { + m.math = moon.NewMath( + cfg.Asset, + cfg.Goals, + config.GetBases(&cfg), + ) + + tableStyle := table.DefaultStyles() + tableStyle.Selected = tableStyle.Cell.Copy(). + Padding(0) + tableStyle.Header = tableStyle.Header. + Bold(true). + Foreground(lipgloss.Color("214")). + Border(baseStyle.GetBorderStyle(), false, false, true, false) + + // properties table + + m.properties = table.New( + table.WithColumns([]table.Column{ + {Title: "Property", Width: 9}, + {Title: "Value", Width: 9}, + }), + table.WithHeight(2), + table.WithStyles(tableStyle), + ) + + // projections table + + labelsWidth := 0 + for _, l := range m.math.Labels { + if len(l) > labelsWidth { + labelsWidth = len(l) + } + } + projectionCols := []table.Column{ + {Title: "Labels", Width: labelsWidth}, + } + for _, c := range m.math.Columns { + projectionCols = append(projectionCols, + table.Column{ + Title: c.Base.Label(), + Width: 10, + }) + } + m.projections = table.New( + table.WithColumns(projectionCols), + table.WithHeight(len(m.math.Labels)), + table.WithStyles(tableStyle), + ) + + // indicator spinner + + m.indicator = spinner.New() + m.indicator.Spinner = spinner.Points + m.indicator.Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("69")) + + return +} + +func (m Model) Handles(a coindesk.Asset) bool { + return m.math.Asset == a +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.indicator.Tick, + func() tea.Msg { + return Msg{m.math.Asset, refresh{}} + }, + ) +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case Msg: + switch msg := msg.inner.(type) { + case refresh: + m.refreshing = true + return m, func() tea.Msg { + // TODO: log errors + _ = m.math.Refresh(context.TODO()) + return Msg{m.math.Asset, m.math} + } + case moon.Math: + m.math = msg + refillProperties(&m) + refillProjections(&m) + return m, tea.Batch( + // schedule the next refresh + tea.Tick(time.Second*30, + func(t time.Time) tea.Msg { + return Msg{m.math.Asset, refresh{}} + }), + // wait a bit to stop the indicator, so that it's more obvious + // even when the refresh completes quickly. + tea.Tick(time.Millisecond*500, + func(t time.Time) tea.Msg { + return Msg{m.math.Asset, stopIndicator{}} + }), + ) + case stopIndicator: + m.refreshing = false + return m, nil + } + case spinner.TickMsg: + var cmd tea.Cmd + m.indicator, cmd = m.indicator.Update(msg) + return m, cmd + } + return m, nil +} + +type refresh struct{} +type stopIndicator struct{} + +func refillProperties(m *Model) { + rows := []table.Row{ + {"Asset", string(m.math.Asset)}, + {"Price", fmt.Sprintf("$%0.2f", m.math.CurrentPrice)}, + } + m.properties.SetRows(rows) +} + +func refillProjections(m *Model) { + rows := []table.Row{m.math.Labels} + for i := range m.math.Columns { + rows = append(rows, m.math.Columns[i].Column()) + } + rows = transpose(rows) + m.projections.SetRows(rows) +} + +func transpose(slice []table.Row) []table.Row { + xl := len(slice[0]) + yl := len(slice) + result := make([]table.Row, xl) + for i := range result { + result[i] = make(table.Row, yl) + } + for i := 0; i < xl; i++ { + for j := 0; j < yl; j++ { + result[i][j] = slice[j][i] + } + } + return result +} + +func (m Model) View() string { + var s string + indicator := "" + if m.refreshing { + indicator = m.indicator.View() + } + right := lipgloss.JoinVertical( + lipgloss.Center, + baseStyle.Render(m.properties.View()), + indicator, + ) + s += lipgloss.JoinHorizontal( + lipgloss.Center, + right, + baseStyle.Render(m.projections.View()), + ) + return s + "\n" +} + +var baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")) diff --git a/tui/tui.go b/tui/tui.go index 2e5c98a..6d651e1 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -1,202 +1,100 @@ package tui import ( - "context" "fmt" - "time" + "code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/config" - "code.humancabbage.net/sam/moonmath/moon" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" + "code.humancabbage.net/sam/moonmath/tui/asset" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -func New(cfg config.Data) (p *tea.Program) { +type Model struct { + assets []asset.Model +} + +func New(assets []coindesk.Asset, cfg config.All) (p *tea.Program) { + model := newModel(assets, cfg) p = tea.NewProgram( - newModel(cfg), + model, tea.WithAltScreen(), tea.WithFPS(30), ) return } -type Model struct { - math moon.Math - - refreshing bool - indicator spinner.Model - - properties table.Model - projections table.Model -} - -func newModel(cfg config.Data) (m Model) { - m.math = moon.NewMath( - cfg.Asset, - cfg.Goals, - config.GetBases(&cfg), - ) - - tableStyle := table.DefaultStyles() - tableStyle.Selected = tableStyle.Cell.Copy(). - Padding(0) - tableStyle.Header = tableStyle.Header. - Bold(true). - Foreground(lipgloss.Color("214")). - Border(baseStyle.GetBorderStyle(), false, false, true, false) - - // properties table - - m.properties = table.New( - table.WithColumns([]table.Column{ - {Title: "Property", Width: 9}, - {Title: "Value", Width: 9}, - }), - table.WithHeight(2), - table.WithStyles(tableStyle), - ) - - // projections table - - labelsWidth := 0 - for _, l := range m.math.Labels { - if len(l) > labelsWidth { - labelsWidth = len(l) +func newModel(assets []coindesk.Asset, cfg config.All) (m Model) { + // construct models for each asset, but don't filter out dupes + seen := map[coindesk.Asset]struct{}{} + for _, a := range assets { + _, ok := seen[a] + if ok { + continue } + assetCfg := cfg.GetData(a) + assetModel := asset.New(assetCfg) + m.assets = append(m.assets, assetModel) + seen[a] = struct{}{} } - projectionCols := []table.Column{ - {Title: "Labels", Width: labelsWidth}, - } - for _, c := range m.math.Columns { - projectionCols = append(projectionCols, - table.Column{ - Title: c.Base.Label(), - Width: 10, - }) - } - m.projections = table.New( - table.WithColumns(projectionCols), - table.WithHeight(len(m.math.Labels)), - table.WithStyles(tableStyle), - ) - - // indicator spinner - - m.indicator = spinner.New() - m.indicator.Spinner = spinner.Points - m.indicator.Style = lipgloss.NewStyle(). - Foreground(lipgloss.Color("69")) - return } func (m Model) Init() tea.Cmd { - return tea.Batch( - m.indicator.Tick, - func() tea.Msg { - return refresh{} - }, - ) + // initialize child models, collecting their commands, + // then return them all in a batch + var inits []tea.Cmd + for i := range m.assets { + cmd := m.assets[i].Init() + inits = append(inits, cmd) + } + return tea.Batch(inits...) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case refresh: - m.refreshing = true - return m, func() tea.Msg { - // TODO: log errors - _ = m.math.Refresh(context.TODO()) - return m.math - } - case moon.Math: - m.math = msg - refillProperties(&m) - refillProjections(&m) - return m, tea.Batch( - // schedule the next refresh - tea.Tick(time.Second*30, - func(t time.Time) tea.Msg { - return refresh{} - }), - // wait a bit to stop the indicator, so that it's more obvious - // even when the refresh completes quickly. - tea.Tick(time.Millisecond*500, - func(t time.Time) tea.Msg { - return stopIndicator{} - }), - ) - case stopIndicator: - m.refreshing = false - return m, nil - case spinner.TickMsg: - var cmd tea.Cmd - m.indicator, cmd = m.indicator.Update(msg) - return m, cmd + // handle keys for quitting case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q", "esc": return m, tea.Quit } + // forward asset messages to the appropriate model + case asset.Msg: + cmd := m.forward(msg.Asset, msg) + return m, cmd + // forward any other message to each child model. + // typically, this is for animation. + default: + var commands []tea.Cmd + for i := range m.assets { + var cmd tea.Cmd + m.assets[i], cmd = m.assets[i].Update(msg) + commands = append(commands, cmd) + } + return m, tea.Batch(commands...) } return m, nil } -type refresh struct{} -type stopIndicator struct{} - -func refillProperties(m *Model) { - rows := []table.Row{ - {"Asset", string(m.math.Asset)}, - {"Price", fmt.Sprintf("$%0.2f", m.math.CurrentPrice)}, - } - m.properties.SetRows(rows) -} - -func refillProjections(m *Model) { - rows := []table.Row{m.math.Labels} - for i := range m.math.Columns { - rows = append(rows, m.math.Columns[i].Column()) - } - rows = transpose(rows) - m.projections.SetRows(rows) -} - -func transpose(slice []table.Row) []table.Row { - xl := len(slice[0]) - yl := len(slice) - result := make([]table.Row, xl) - for i := range result { - result[i] = make(table.Row, yl) - } - for i := 0; i < xl; i++ { - for j := 0; j < yl; j++ { - result[i][j] = slice[j][i] - } - } - return result -} - func (m Model) View() string { - var s string - indicator := "" - if m.refreshing { - indicator = m.indicator.View() + var ss []string + for i := range m.assets { + s := m.assets[i].View() + ss = append(ss, s) } - right := lipgloss.JoinVertical( - lipgloss.Center, - baseStyle.Render(m.properties.View()), - indicator, - ) - s += lipgloss.JoinHorizontal( - lipgloss.Center, - right, - baseStyle.Render(m.projections.View()), - ) - return s + "\n" + r := lipgloss.JoinVertical(lipgloss.Center, ss...) + return r } -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) +func (m Model) forward(a coindesk.Asset, msg tea.Msg) (cmd tea.Cmd) { + // O(n) is fine when n is small + for i := range m.assets { + if !m.assets[i].Handles(a) { + continue + } + m.assets[i], cmd = m.assets[i].Update(msg) + return + } + panic(fmt.Errorf("rogue message: %v", msg)) +}