moonmath/tui/asset/asset.go
Sam Fredrickson 7b445a02a2
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m3s
Refresh indicator goes red on error.
It will stay flashing red until the next refresh, at which point it goes
back to its normal color. On a successful refresh, it still stops.

Also, add a deadline of 15 seconds to the refresh command.
2024-03-24 02:29:45 -07:00

244 lines
4.9 KiB
Go

package asset
import (
"context"
"fmt"
"log/slog"
"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 = indicatorNormalStyle
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
m.indicator.Style = indicatorNormalStyle
return m, tea.Batch(
m.resumeIndicator,
m.refresh,
)
case update:
commands := []tea.Cmd{m.scheduleRefresh()}
if msg.err == nil {
m.math = msg.math
refillProperties(&m)
refillProjections(&m)
commands = append(commands, m.stopIndicator())
} else {
m.indicator.Style = indicatorErrorStyle
}
return m, tea.Batch(commands...)
case stopIndicator:
m.refreshing = false
return m, nil
}
case spinner.TickMsg:
if !m.refreshing {
return m, nil
}
var cmd tea.Cmd
m.indicator, cmd = m.indicator.Update(msg)
return m, cmd
}
return m, nil
}
type refresh struct{}
type update struct {
math moon.Math
err error
}
type stopIndicator struct{}
func (m Model) refresh() tea.Msg {
ctx, cancel := context.WithDeadline(
context.Background(),
time.Now().Add(time.Second*15))
defer cancel()
err := m.math.Refresh(ctx)
if err != nil {
slog.Error("refresh",
"asset", m.math.Asset,
"err", err,
)
}
return Msg{m.math.Asset, update{m.math, err}}
}
func (m Model) resumeIndicator() tea.Msg {
return m.indicator.Tick()
}
func (m Model) scheduleRefresh() tea.Cmd {
return tea.Tick(time.Second*30,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, refresh{}}
})
}
func (m Model) stopIndicator() tea.Cmd {
// wait a bit to stop the indicator, so that it's more obvious
// even when the refresh completes quickly.
return tea.Tick(time.Millisecond*500,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, stopIndicator{}}
})
}
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"))
var indicatorNormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
var indicatorErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("160"))