Support multiple assets simultaneously.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m1s

This commit is contained in:
Sam Fredrickson 2024-03-22 17:43:15 -07:00
parent e14f0488c5
commit c4dde38d23
5 changed files with 288 additions and 176 deletions

View File

@ -12,14 +12,27 @@ program. It's written in Go using the [Bubble Tea][tea] library, and uses
[tea]: https://github.com/charmbracelet/bubbletea [tea]: https://github.com/charmbracelet/bubbletea
[coin]: https://www.coindesk.com [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 ### Configuration
By default, the program will use Bitcoin along with various goals and bases By default, the program will use Bitcoin along with various goals and bases of
of comparison. With the `--asset` flag, another asset supported by Coindesk can comparison. With the `--asset` flag, another asset supported by Coindesk can be
be chosen. The [builtin default config](./config/default.yaml) only has special chosen. These can even be chained, e.g. `--asset BTC --asset ETH`, to show
goals for Ethereum ("ETH"). With the `--config-file` flag, however, one can projections for multiple assets simultaneously.
specify a YAML file that overrides these defaults and adds goals for other
assets. 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" ### "Theory"

View File

@ -49,7 +49,7 @@ type Data struct {
RelativeBases []moon.RelativeBase `koanf:"relativeBases"` 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] data, ok := all.Assets[asset]
if !ok { if !ok {
data = all.Defaults data = all.Defaults

View File

@ -12,7 +12,7 @@ import (
) )
var CLI struct { var CLI struct {
Asset string `short:"a" default:"BTC" help:"Asset to project."` Asset []string `short:"a" default:"BTC" help:"Asset(s) to project."`
ConfigFile string `short:"c" help:"Path to YAML configuration file."` ConfigFile string `short:"c" help:"Path to YAML configuration file."`
} }
@ -25,12 +25,12 @@ func main() {
if err != nil { if err != nil {
fail(err) fail(err)
} }
CLI.Asset = strings.ToUpper(CLI.Asset) var assets []coindesk.Asset
cfg, err := allCfg.GetData(coindesk.Asset(CLI.Asset)) for i := range CLI.Asset {
if err != nil { asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i]))
fail(err) assets = append(assets, asset)
} }
p := tui.New(cfg) p := tui.New(assets, allCfg)
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fail(err) fail(err)
} }

201
tui/asset/asset.go Normal file
View File

@ -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"))

View File

@ -1,202 +1,100 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"time"
"code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config" "code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/moon" "code.humancabbage.net/sam/moonmath/tui/asset"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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( p = tea.NewProgram(
newModel(cfg), model,
tea.WithAltScreen(), tea.WithAltScreen(),
tea.WithFPS(30), tea.WithFPS(30),
) )
return return
} }
type Model struct { func newModel(assets []coindesk.Asset, cfg config.All) (m Model) {
math moon.Math // construct models for each asset, but don't filter out dupes
seen := map[coindesk.Asset]struct{}{}
refreshing bool for _, a := range assets {
indicator spinner.Model _, ok := seen[a]
if ok {
properties table.Model continue
projections table.Model
} }
assetCfg := cfg.GetData(a)
func newModel(cfg config.Data) (m Model) { assetModel := asset.New(assetCfg)
m.math = moon.NewMath( m.assets = append(m.assets, assetModel)
cfg.Asset, seen[a] = struct{}{}
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 return
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch( // initialize child models, collecting their commands,
m.indicator.Tick, // then return them all in a batch
func() tea.Msg { var inits []tea.Cmd
return refresh{} 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) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case refresh: // handle keys for quitting
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
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "q", "esc": case "ctrl+c", "q", "esc":
return m, tea.Quit 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 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 { func (m Model) View() string {
var s string var ss []string
indicator := "" for i := range m.assets {
if m.refreshing { s := m.assets[i].View()
indicator = m.indicator.View() ss = append(ss, s)
} }
right := lipgloss.JoinVertical( r := lipgloss.JoinVertical(lipgloss.Center, ss...)
lipgloss.Center, return r
baseStyle.Render(m.properties.View()),
indicator,
)
s += lipgloss.JoinHorizontal(
lipgloss.Center,
right,
baseStyle.Render(m.projections.View()),
)
return s + "\n"
} }
var baseStyle = lipgloss.NewStyle(). func (m Model) forward(a coindesk.Asset, msg tea.Msg) (cmd tea.Cmd) {
BorderStyle(lipgloss.RoundedBorder()). // O(n) is fine when n is small
BorderForeground(lipgloss.Color("240")) 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))
}