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.Asset) (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) Init() tea.Cmd { return tea.Batch( m.indicator.Tick, func() tea.Msg { return Msg{m.math.Asset, refresh{}} }, ) } func (m Model) Handles(a coindesk.Asset) bool { return m.math.Asset == a } 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, m.scheduleRefresh(), ) case update: var cmd tea.Cmd if msg.err == nil { m.math = msg.math refillProperties(&m) refillProjections(&m) cmd = m.stopIndicator() } else { m.indicator.Style = indicatorErrorStyle } return m, cmd 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.WithTimeout( context.Background(), refreshTimeout) 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(refreshInterval, 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(stopIndicatorDelay, func(t time.Time) tea.Msg { return Msg{m.math.Asset, stopIndicator{}} }) } var refreshInterval = time.Second * 30 var refreshTimeout = time.Second * 15 var stopIndicatorDelay = time.Millisecond * 500 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) { cols := []table.Row{m.math.Labels} for i := range m.math.Columns { entries := renderEntries(m.math.Columns[i]) cols = append(cols, entries) } rows := transpose(cols) m.projections.SetRows(rows) } func renderEntries(c moon.Column) (entries []string) { entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice)) entries = append(entries, fmt.Sprintf("%.4f%%", (c.CDPR-1)*100)) never := c.CDPR <= 1 for i := range c.Projections.Dates { var cell string if never { cell = "NEVER!!!!!" } else { cell = c. Projections. Dates[i]. Format("2006-01-02") } entries = append(entries, cell) } return } 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"))