// TODO document TUI lifecycle // TODO viewport separation // // BLOCKERS: header/footer height func // // TODO header/footer height func package pingo import ( "fmt" "slices" "time" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/NimbleMarkets/ntcharts/linechart/streamlinechart" ) // Style Defintions var ( // A style for chart headers headerStyle = lipgloss.NewStyle(). Bold(true). Italic(true) // A style for info text infoStyle = lipgloss.NewStyle(). Italic(true). Faint(true) // A style for the secondary colour secondaryColor = lipgloss.NewStyle(). Foreground(lipgloss.Color("#7b2d26")) // A style for the primary colour // primaryColor = lipgloss.NewStyle(). // Foreground(lipgloss.Color("#f0f3f5")) // A style for handling center-aligning blockStyle = lipgloss.NewStyle(). Align(lipgloss.Center) // borderStyle = lipgloss.NewStyle(). // BorderForeground(lipgloss.Color("8")). // // Padding(1, 2). // BorderStyle(lipgloss.NormalBorder()) // footer styles titleStyle = lipgloss.NewStyle(). Align(lipgloss.Center). // implies consumer functions will apply a width Italic(true). Faint(true) // footer style footerStyle = lipgloss.NewStyle(). Align(lipgloss.Center). // implies consumer functions will apply a width Italic(true). Faint(true) ) type ( // tea.Msg signatures tickMsg time.Time pollResultMsg struct { results []float64 index int err error } ) // Bubbletea model type Model struct { width int Addresses []Address // as defined in internal/tui/types.go viewport viewport.Model UpdateSpeed time.Duration ChartHeight int ModelHeight int ModelWidth int } func InitialModel(addresses []string, speed time.Duration, chartHeight int) Model { var model Model model.viewport.MouseWheelEnabled = true model.UpdateSpeed = speed model.ChartHeight = chartHeight for _, address := range addresses { var addr Address addr.max_results = 80 addr.Address = address model.Addresses = append(model.Addresses, addr) } return model } func (m Model) Init() tea.Cmd { return m.Tick() } func (m Model) Tick() tea.Cmd { return tea.Tick(time.Millisecond*m.UpdateSpeed, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { // if case is KeyMsg (keypress) case tea.WindowSizeMsg: if m.ModelWidth == 0 && m.ModelHeight == 0 { m.viewport = viewport.New( viewport.WithHeight(10), viewport.WithWidth(msg.Width), ) } m.ModelWidth = msg.Width m.ModelHeight = msg.Height for i, address := range m.Addresses { address.max_results = m.ModelWidth m.Addresses[i] = address } m.viewport.SetHeight(m.ModelHeight - m.getVerticalMargin()) m.viewport.SetWidth(m.ModelWidth) m.viewport.YPosition = 1 case tea.KeyPressMsg: if k := msg.String(); k == "j" { // scroll down m.viewport.ScrollDown(1) } else if k == "k" { // scroll up m.viewport.ScrollUp(1) } else { if k == "ctrl+c" { cmds = append(cmds, tea.Quit) } } case tickMsg: cmds = append(cmds, m.Tick(), m.Poll()) case pollResultMsg: m.Addresses[msg.index].results = msg.results } m.viewport.SetContent(m.Render()) m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) // cmds = append(cmds, m.Poll) return m, tea.Batch(cmds...) } func (m Model) View() tea.View { content := fmt.Sprintf("%s%s\n%s", m.header(), m.viewport.View(), m.footer()) var v tea.View v.SetContent(content) v.AltScreen = true return v } func (m Model) Render() string { var output string for _, address := range m.Addresses { if len(address.results) == 0 { output = output + fmt.Sprintf("\n%s\tloading...", headerStyle.Render(address.Address)) } else if m.ModelWidth != 0 && m.ModelHeight != 0 { if slices.Contains(address.results, -1) { output = output + blockStyle.Width(m.ModelWidth).Render(headerStyle.Render( fmt.Sprintf("\n%s\t%s", secondaryColor.Render(address.Address), infoStyle.Render("(connection unstable)"), ), )) } else { output = output + fmt.Sprintf("\n%s", blockStyle.Width(m.ModelWidth).Render(headerStyle.Render(address.Address))) } // Linechart // set chartHeight - vertical margin chartHeight := m.ModelHeight - m.getVerticalMargin() var slc streamlinechart.Model if m.ChartHeight == 0 && len(m.Addresses) == 1 { // catch user specified fullscreen // render chart at fullscreen slc = streamlinechart.New(m.ModelWidth, chartHeight) } else if m.ChartHeight == 0 && len(m.Addresses) > 1 { // catch user specified fullscreen // render chart at fullscreen minus a few lines to hint at scrolling slc = streamlinechart.New(m.ModelWidth, chartHeight-5) } else { slc = streamlinechart.New(m.ModelWidth, m.ChartHeight) } for _, v := range address.results { slc.Push(v) } slc.Draw() output = output + fmt.Sprintf("\n%s", slc.View()) } } return output } func (m Model) header() string { return titleStyle.Width(m.ModelWidth).Render("pingo v0") } func (m Model) footer() string { return footerStyle.Width(m.ModelWidth).Render("j/k: down/up\t|\tq/ctrl-c/esc: quit") } func (m Model) getVerticalMargin() int { return lipgloss.Height(m.header() + m.footer()) } // Returns a batched set of tea.Cmd functions for each address. func (m Model) Poll() tea.Cmd { var cmds []tea.Cmd for i, element := range m.Addresses { cmds = append(cmds, func() tea.Msg { results, err := element.Poll() return pollResultMsg{results: results, err: err, index: i} }) } return tea.Batch(cmds...) }