3 Commits

Author SHA1 Message Date
7b445a02a2 Refresh indicator goes red on error.
All checks were successful
Build & Test / Main (push) Successful in 1m0s
Release / Release (push) Successful in 1m3s
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
97f4793ec3 Write errors to log file.
All checks were successful
Build & Test / Main (push) Successful in 1m1s
2024-03-22 22:55:41 -07:00
270534c0d5 Pause spinner ticks when not refreshing.
All checks were successful
Build & Test / Main (push) Successful in 59s
Also, add a quick-and-dirty model for displaying basic performance
stats, currently just the number of calls to the root Update() and
View() methods.
2024-03-22 21:14:34 -07:00
4 changed files with 161 additions and 35 deletions

View File

@@ -2,21 +2,30 @@ package main
import ( import (
"fmt" "fmt"
"io"
"log/slog"
"os" "os"
"path/filepath"
"strings" "strings"
"code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config" "code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/tui" "code.humancabbage.net/sam/moonmath/tui"
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
tea "github.com/charmbracelet/bubbletea"
) )
var CLI struct { var CLI struct {
Asset []string `short:"a" default:"BTC" help:"Asset(s) 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."`
Perf bool `help:"Display internal performance stats."`
} }
func main() { func main() {
logFile := setupLogging()
defer func() {
_ = logFile.Close()
}()
ctx := kong.Parse(&CLI) ctx := kong.Parse(&CLI)
if ctx.Error != nil { if ctx.Error != nil {
fail(ctx.Error) fail(ctx.Error)
@@ -30,12 +39,45 @@ func main() {
asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i])) asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i]))
assets = append(assets, asset) assets = append(assets, asset)
} }
p := tui.New(assets, allCfg) m := tui.New(assets, allCfg, CLI.Perf)
p := tea.NewProgram(m,
tea.WithAltScreen(),
tea.WithFPS(30),
)
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fail(err) fail(err)
} }
} }
func setupLogging() io.Closer {
homePath, err := os.UserHomeDir()
if err != nil {
panic(err)
}
programConfigPath := filepath.Join(homePath, ".moonmath")
err = os.MkdirAll(programConfigPath, 0755)
if err != nil {
panic(err)
}
errLogPath := filepath.Join(programConfigPath, "errors.log")
errLogFile, err := os.OpenFile(
errLogPath,
os.O_CREATE|os.O_APPEND|os.O_WRONLY,
0600,
)
if err != nil {
panic(err)
}
slog.SetDefault(slog.New(
slog.NewTextHandler(errLogFile, &slog.HandlerOptions{
Level: slog.LevelError,
})))
return errLogFile
}
func fail(err error) { func fail(err error) {
fmt.Printf("program error: %v\n", err) fmt.Printf("program error: %v\n", err)
os.Exit(1) os.Exit(1)

View File

@@ -3,6 +3,7 @@ package asset
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"time" "time"
"code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/coindesk"
@@ -83,8 +84,7 @@ func New(cfg config.Data) (m Model) {
m.indicator = spinner.New() m.indicator = spinner.New()
m.indicator.Spinner = spinner.Points m.indicator.Spinner = spinner.Points
m.indicator.Style = lipgloss.NewStyle(). m.indicator.Style = indicatorNormalStyle
Foreground(lipgloss.Color("69"))
return return
} }
@@ -108,33 +108,30 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.inner.(type) { switch msg := msg.inner.(type) {
case refresh: case refresh:
m.refreshing = true m.refreshing = true
return m, func() tea.Msg { m.indicator.Style = indicatorNormalStyle
// 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( return m, tea.Batch(
// schedule the next refresh m.resumeIndicator,
tea.Tick(time.Second*30, m.refresh,
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 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: case stopIndicator:
m.refreshing = false m.refreshing = false
return m, nil return m, nil
} }
case spinner.TickMsg: case spinner.TickMsg:
if !m.refreshing {
return m, nil
}
var cmd tea.Cmd var cmd tea.Cmd
m.indicator, cmd = m.indicator.Update(msg) m.indicator, cmd = m.indicator.Update(msg)
return m, cmd return m, cmd
@@ -143,8 +140,47 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
type refresh struct{} type refresh struct{}
type update struct {
math moon.Math
err error
}
type stopIndicator struct{} 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) { func refillProperties(m *Model) {
rows := []table.Row{ rows := []table.Row{
{"Asset", string(m.math.Asset)}, {"Asset", string(m.math.Asset)},
@@ -199,3 +235,9 @@ func (m Model) View() string {
var baseStyle = lipgloss.NewStyle(). var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")) BorderForeground(lipgloss.Color("240"))
var indicatorNormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
var indicatorErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("160"))

42
tui/perf/perf.go Normal file
View File

@@ -0,0 +1,42 @@
package perf
import (
"fmt"
"sync/atomic"
tea "github.com/charmbracelet/bubbletea"
)
type Model struct {
updates *atomic.Int64
views *atomic.Int64
}
func New() (m Model) {
m.updates = new(atomic.Int64)
m.views = new(atomic.Int64)
return
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
func (m Model) View() string {
updates := m.updates.Load()
views := m.views.Load()
s := fmt.Sprintf("updates: %d\tviews: %d", updates, views)
return s
}
func (m Model) AddUpdate() {
m.updates.Add(1)
}
func (m Model) AddView() {
m.views.Add(1)
}

View File

@@ -6,25 +6,20 @@ import (
"code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config" "code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/tui/asset" "code.humancabbage.net/sam/moonmath/tui/asset"
"code.humancabbage.net/sam/moonmath/tui/perf"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type Model struct { type Model struct {
assets []asset.Model assets []asset.Model
stats perf.Model
displayStats bool
} }
func New(assets []coindesk.Asset, cfg config.All) (p *tea.Program) { func New(assets []coindesk.Asset, cfg config.All, displayStats bool) (m Model) {
model := newModel(assets, cfg) m.stats = perf.New()
p = tea.NewProgram( m.displayStats = displayStats
model,
tea.WithAltScreen(),
tea.WithFPS(30),
)
return
}
func newModel(assets []coindesk.Asset, cfg config.All) (m Model) {
// construct models for each asset, but don't filter out dupes // construct models for each asset, but don't filter out dupes
seen := map[coindesk.Asset]struct{}{} seen := map[coindesk.Asset]struct{}{}
for _, a := range assets { for _, a := range assets {
@@ -52,6 +47,7 @@ func (m Model) Init() tea.Cmd {
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stats.AddUpdate()
switch msg := msg.(type) { switch msg := msg.(type) {
// handle keys for quitting // handle keys for quitting
case tea.KeyMsg: case tea.KeyMsg:
@@ -78,11 +74,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m Model) View() string { func (m Model) View() string {
m.stats.AddView()
var ss []string var ss []string
for i := range m.assets { for i := range m.assets {
s := m.assets[i].View() s := m.assets[i].View()
ss = append(ss, s) ss = append(ss, s)
} }
if m.displayStats {
ss = append(ss, m.stats.View())
}
r := lipgloss.JoinVertical(lipgloss.Center, ss...) r := lipgloss.JoinVertical(lipgloss.Center, ss...)
return r return r
} }