All checks were successful
		
		
	
	Build & Test / Main (push) Successful in 58s
				
			* Full screen ("alt framebuffer").
* Rounded borders.
* Spinner that starts during a refresh.
* Colored table headers.
* Table header have bottom border.
		
	
		
			
				
	
	
		
			203 lines
		
	
	
		
			4.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			203 lines
		
	
	
		
			4.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package tui
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"fmt"
 | |
| 	"time"
 | |
| 
 | |
| 	"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"
 | |
| )
 | |
| 
 | |
| func New(cfg config.Data) (p *tea.Program) {
 | |
| 	p = tea.NewProgram(
 | |
| 		newModel(cfg),
 | |
| 		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)
 | |
| 		}
 | |
| 	}
 | |
| 	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{}
 | |
| 		},
 | |
| 	)
 | |
| }
 | |
| 
 | |
| 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
 | |
| 	case tea.KeyMsg:
 | |
| 		switch msg.String() {
 | |
| 		case "ctrl+c", "q", "esc":
 | |
| 			return m, tea.Quit
 | |
| 		}
 | |
| 	}
 | |
| 	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"))
 |