diff --git a/cmd/cryptstream/main.go b/cmd/cryptstream/main.go index 2f74733..6d6728b 100644 --- a/cmd/cryptstream/main.go +++ b/cmd/cryptstream/main.go @@ -31,6 +31,7 @@ func main() { } cryptoView := ui.NewCryptoView(initial, &cfg) + marketPanel := cryptoView.Panel configEditor := tuikit.NewConfigEditor(buildConfigFields(&cfg, cryptoView)) @@ -44,12 +45,66 @@ func main() { }) cryptoView.DetailOverlay = detailOverlay + statusLeft := func() string { + filterLabel := "" + switch cryptoView.FilterMode() { + case ui.FilterGainers: + filterLabel = " • ↑ GAINERS" + case ui.FilterLosers: + filterLabel = " • ↓ LOSERS" + } + searchLabel := "" + if q := cryptoView.SearchQuery(); q != "" { + searchLabel = fmt.Sprintf(" • /%s", q) + } + if cryptoView.IsSearching() { + query := cryptoView.SearchQuery() + if query == "" { + query = "_" + } + return fmt.Sprintf(" / %s", query) + } + return fmt.Sprintf(" ? help / search p panel q quit • %d pairs%s%s", cryptoView.PairCount(), filterLabel, searchLabel) + } + + statusRight := func() string { + if cryptoView.IsSearching() { + return "esc cancel enter confirm " + } + posStr := "" + total := cryptoView.VisibleCount() + if total > 0 { + posStr = fmt.Sprintf(" %d/%d", cryptoView.CursorPos()+1, total) + } + btcPrice := cryptoView.BtcPrice() + btc := "" + if btcPrice > 0 { + btc = fmt.Sprintf("BTC %s • ", ticker.FormatPrice(btcPrice)) + } + now := time.Now().Format("15:04:05") + dot := "●" + status := "connected" + if !cryptoView.Connected() { + status = "reconnecting..." + } + return fmt.Sprintf("%s %s%s %s %s ", posStr, btc, now, dot, status) + } + app := tuikit.NewApp( - tuikit.WithComponent("crypto", cryptoView), + tuikit.WithLayout(&tuikit.DualPane{ + Main: cryptoView, + Side: marketPanel, + SideWidth: 30, + MinMainWidth: 70, + SideRight: true, + ToggleKey: "p", + }), + tuikit.WithFocusCycleKey(""), tuikit.WithHelp(), tuikit.WithOverlay("Settings", "c", configEditor), tuikit.WithOverlay("Command", ":", commandBar), tuikit.WithOverlay("Detail", "", detailOverlay), + tuikit.WithStatusBar(statusLeft, statusRight), tuikit.WithTickInterval(100*time.Millisecond), tuikit.WithMouseSupport(), tuikit.WithAutoUpdate(tuikit.UpdateConfig{ diff --git a/go.mod b/go.mod index ea6ba45..e425f21 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/gorilla/websocket v1.5.3 - github.com/moneycaringcoder/tuikit-go v0.4.0 + github.com/moneycaringcoder/tuikit-go v0.5.1 ) require ( diff --git a/go.sum b/go.sum index 37aff97..f770d71 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/moneycaringcoder/tuikit-go v0.4.0 h1:8a9zzpK1Im7liTZCei2icvRNwsVX8TdNXyJKJbi55f4= -github.com/moneycaringcoder/tuikit-go v0.4.0/go.mod h1:+bKADz++KvjW7NsOnVlDem0NiMuYEL89qh5/IAYMJOI= +github.com/moneycaringcoder/tuikit-go v0.5.1 h1:FV1lnlNJ2nKXEnBbrnPb60XWZUb1ZUVD/PvWFuGXZgU= +github.com/moneycaringcoder/tuikit-go v0.5.1/go.mod h1:NNJ8NSFnHrd4A7dqmb0DO1kA6vtk8jmdCTKYJFL+h50= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/config/config.go b/internal/config/config.go index aa7e6eb..fc16191 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,7 +83,7 @@ func Default() Config { SortAscending: false, DefaultFilter: "all", - FilterCount: 20, + FilterCount: 100, FlashThreshold: 0.0001, VolumeWindow: 50, diff --git a/internal/ui/browser.go b/internal/ui/browser.go deleted file mode 100644 index 3b8c1b0..0000000 --- a/internal/ui/browser.go +++ /dev/null @@ -1,20 +0,0 @@ -package ui - -import ( - "os/exec" - "runtime" -) - -// openBrowser opens the given URL in the default browser. -func openBrowser(url string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("cmd", "/c", "start", url) - default: - cmd = exec.Command("xdg-open", url) - } - _ = cmd.Start() -} diff --git a/internal/ui/crypto.go b/internal/ui/crypto.go index 8ce2559..4294159 100644 --- a/internal/ui/crypto.go +++ b/internal/ui/crypto.go @@ -1,12 +1,14 @@ package ui import ( + "fmt" "math" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" tuikit "github.com/moneycaringcoder/tuikit-go" "github.com/moneycaringcoder/cryptstream-tui/internal/config" "github.com/moneycaringcoder/cryptstream-tui/internal/defiyields" @@ -106,18 +108,16 @@ type CryptoView struct { marketStats MarketStats defiPools []defiyields.Pool showDefi bool - defiCursor int - defiScroll int newsArticles []news.Article newsOn bool newsFlash int focused bool DetailOverlay *tuikit.DetailOverlay[ticker.Ticker] + Panel *MarketPanel - secVolSpikes *tuikit.CollapsibleSection - secFunding *tuikit.CollapsibleSection - secLiqs *tuikit.CollapsibleSection + table *tuikit.Table + defiTable *tuikit.Table fundingPoller *tuikit.Poller fngPoller *tuikit.Poller @@ -141,10 +141,134 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { liqFlash: make(map[string]time.Time), correlations: make(map[string]float64), newsOn: true, - secVolSpikes: tuikit.NewCollapsibleSection("VOL SPIKES"), - secFunding: tuikit.NewCollapsibleSection("FUNDING RATES"), - secLiqs: tuikit.NewCollapsibleSection("LIQUIDATIONS"), } + c.Panel = NewMarketPanel(c.styles) + + columns := []tuikit.Column{ + {Title: "#", Width: 3, MaxWidth: 4, Align: tuikit.Right}, + {Title: "SYMBOL", Width: 14, Sortable: true}, + {Title: "PRICE", Width: 20, Align: tuikit.Right, Sortable: true}, + {Title: "CHANGE", Width: 10, Align: tuikit.Right, Sortable: true}, + {Title: "TREND", Width: 22, MaxWidth: 20, MinWidth: 70, NoRowStyle: true, Align: tuikit.Center}, + {Title: "βBTC", Width: 8, Align: tuikit.Right, MinWidth: 90, Sortable: true}, + {Title: "VOLUME", Width: 15, Align: tuikit.Right, Sortable: true}, + } + + cellRenderer := func(row tuikit.Row, colIdx int, isCursor bool, theme tuikit.Theme) string { + if colIdx >= len(row) { + return "" + } + s := c.styles + symbol := "" + if len(row) > 1 { + symbol = row[1] + "USDT" + } + + cell := row[colIdx] + + // TREND column: render sparkline + if colIdx == 4 { + sparkData := c.priceHistory[symbol] + styled, _ := tuikit.Sparkline(sparkData, 20, &tuikit.SparklineOpts{ + UpStyle: s.Positive, + DownStyle: s.Negative, + NeutralStyle: s.Neutral, + }) + return styled + } + + // Foreground-only styling — RowStyler owns all backgrounds + t := c.tickers[symbol] + starred := c.Watchlist.IsStarred(symbol) + corr := c.correlations[symbol] + + switch colIdx { + case 3: // CHANGE + return changeStyle(s, t.PriceChangePercent).Render(cell) + case 5: // βBTC + return corrStyle(s, corr).Render(cell) + case 6: // VOLUME + if t.VolumeSpiking { + return s.VolSpike.Render(cell) + } + case 1: // SYMBOL + if starred { + return s.Star.Render(cell) + } + } + return cell + } + + sortFunc := func(a, b tuikit.Row, sortCol int, sortAsc bool) bool { + // Sorting is handled externally by rebuildSorted, so this is a no-op + // (rows are pre-sorted before being set on the table) + return false + } + + rowStyler := func(row tuikit.Row, idx int, isCursor bool, theme tuikit.Theme) *lipgloss.Style { + if len(row) < 2 { + return nil + } + symbol := row[1] + "USDT" + t := c.tickers[symbol] + if time.Now().Before(t.FlashUntil) && t.Flash != ticker.FlashNeutral { + st := flashStyle(c.styles, t.Flash) + return &st + } + if time.Now().Before(c.liqFlash[symbol]) { + st := c.styles.LiqFlash + return &st + } + if isCursor { + st := c.styles.CursorRow + return &st + } + return nil + } + + c.table = tuikit.NewTable(columns, nil, tuikit.TableOpts{ + HeaderStyle: c.styles.Header, + CellRenderer: cellRenderer, + RowStyler: rowStyler, + SortFunc: sortFunc, + }) + + defiColumns := []tuikit.Column{ + {Title: "#", Width: 4, Align: tuikit.Right}, + {Title: "PROTOCOL", Width: 20}, + {Title: "POOL", Width: 30}, + {Title: "CHAIN", Width: 15}, + {Title: "APY", Width: 15, Align: tuikit.Right, Sortable: true}, + {Title: "TVL", Width: 15, Align: tuikit.Right, Sortable: true}, + } + + defiCellRenderer := func(row tuikit.Row, colIdx int, isCursor bool, theme tuikit.Theme) string { + if colIdx >= len(row) { + return "" + } + cell := row[colIdx] + s := c.styles + if colIdx == 4 { // APY + return s.Positive.Render(cell) + } + return cell + } + + defiRowStyler := func(row tuikit.Row, idx int, isCursor bool, theme tuikit.Theme) *lipgloss.Style { + if isCursor { + st := c.styles.CursorRow + return &st + } + return nil + } + + c.defiTable = tuikit.NewTable(defiColumns, nil, tuikit.TableOpts{ + Sortable: true, + HeaderStyle: c.styles.Header, + RowStyler: defiRowStyler, + CellRenderer: defiCellRenderer, + }) + for _, t := range initial { c.tickers[t.Symbol] = t c.priceHistory[t.Symbol] = []float64{t.LastPrice} @@ -179,17 +303,20 @@ func (c *CryptoView) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { case fundingMsg: if msg != nil { c.fundingRates = map[string]funding.Info(msg) + c.syncPanel() } return c, nil case fngMsg: idx := feargreed.Index(msg) if idx.Value > 0 { c.fearGreed = idx + c.syncPanel() } return c, nil case defiMsg: if msg != nil { c.defiPools = []defiyields.Pool(msg) + c.rebuildDefiRows() } return c, nil case newsMsg: @@ -210,6 +337,7 @@ func (c *CryptoView) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { c.recentLiqs = c.recentLiqs[:10] } c.liqFlash[l.Symbol] = time.Now().Add(2 * time.Second) + c.syncPanel() return c, nil case connMsg: c.connected = msg.connected @@ -311,18 +439,10 @@ func (c *CryptoView) handleKey(msg tea.KeyMsg) (tuikit.Component, tea.Cmd) { switch msg.String() { case "d", "esc": c.showDefi = false - case "j", "down": - c.defiCursor++ - c.clampDefiCursor() - case "k", "up": - c.defiCursor-- - c.clampDefiCursor() - case "g", "home": - c.defiCursor = 0 - c.clampDefiCursor() - case "G", "end": - c.defiCursor = len(c.defiPools) - 1 - c.clampDefiCursor() + default: + if c.defiTable != nil { + c.defiTable.Update(msg) + } } return c, tuikit.Consumed() } @@ -357,39 +477,13 @@ func (c *CryptoView) handleKey(msg tea.KeyMsg) (tuikit.Component, tea.Cmd) { return c, tuikit.Consumed() } - // Normal mode + // Normal mode — delegate navigation to table switch msg.String() { - case "j", "down": - c.cursor++ - c.clampCursor() - return c, tuikit.Consumed() - case "k", "up": - c.cursor-- - c.clampCursor() - return c, tuikit.Consumed() - case "g", "home": - c.cursor = 0 - c.clampCursor() - return c, tuikit.Consumed() - case "G", "end": - c.cursor = len(c.sorted) - 1 - c.clampCursor() - return c, tuikit.Consumed() - case "ctrl+d": - half := c.visibleRows / 2 - if half < 1 { - half = 1 + case "j", "down", "k", "up", "g", "home", "G", "end", "ctrl+d", "ctrl+u": + if c.table != nil { + c.table.Update(msg) + c.cursor = c.table.CursorIndex() } - c.cursor += half - c.clampCursor() - return c, tuikit.Consumed() - case "ctrl+u": - half := c.visibleRows / 2 - if half < 1 { - half = 1 - } - c.cursor -= half - c.clampCursor() return c, tuikit.Consumed() case "tab": c.sortCol = (c.sortCol + 1) % sortColCount @@ -432,35 +526,17 @@ func (c *CryptoView) handleKey(msg tea.KeyMsg) (tuikit.Component, tea.Cmd) { c.searching = true c.searchQuery = "" return c, tuikit.Consumed() - case "p": - c.panelOn = !c.panelOn - if c.panelOn { - c.Cfg.PanelLayout = "right" - } else { - c.Cfg.PanelLayout = "off" - } - c.visibleRows = c.tableVisibleRows() - c.clampCursor() - return c, tuikit.Consumed() case "d": c.showDefi = true - c.defiCursor = 0 - c.defiScroll = 0 + if c.defiTable != nil { + c.defiTable.SetCursor(0) + } return c, tuikit.Consumed() case "n": c.newsOn = !c.newsOn c.visibleRows = c.tableVisibleRows() c.clampCursor() return c, tuikit.Consumed() - case "1": - c.secVolSpikes.Toggle() - return c, tuikit.Consumed() - case "2": - c.secFunding.Toggle() - return c, tuikit.Consumed() - case "3": - c.secLiqs.Toggle() - return c, tuikit.Consumed() case "enter": if c.DetailOverlay != nil && c.cursor >= 0 && c.cursor < len(c.sorted) { c.DetailOverlay.Show(c.sorted[c.cursor]) @@ -484,65 +560,37 @@ func (c *CryptoView) handleMouse(msg tea.MouseMsg) (tuikit.Component, tea.Cmd) { return c, nil } - tableW := c.tableWidth() + if c.showDefi { + if c.defiTable != nil { + c.defiTable.Update(msg) + } + return c, nil + } + // Delegate to table for scrolling and row selection switch msg.Button { - case tea.MouseButtonWheelUp: - if msg.X < tableW { - if c.showDefi { - c.defiCursor-- - c.clampDefiCursor() - } else { - c.cursor-- - c.clampCursor() - } - } - case tea.MouseButtonWheelDown: - if msg.X < tableW { - if c.showDefi { - c.defiCursor++ - c.clampDefiCursor() - } else { - c.cursor++ - c.clampCursor() - } + case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown: + if c.table != nil { + c.table.Update(msg) + c.cursor = c.table.CursorIndex() } case tea.MouseButtonLeft: - x := msg.X - y := msg.Y - - if x >= tableW { - break - } - newsH := c.newsHeight() - tableEnd := 2 + c.visibleRows - newsStart := c.height - 2 - newsH - if y >= 2 && y < tableEnd { - if c.showDefi { - row := c.defiScroll + (y - 3) - if row >= 0 && row < len(c.defiPools) { - c.defiCursor = row - c.clampDefiCursor() - } - } else { - row := c.offset + (y - 2) - if row < len(c.sorted) { - c.cursor = row - c.clampCursor() - } - } - } else if newsH > 0 && y >= newsStart && y < newsStart+newsH { - lineIdx := y - newsStart - 1 + newsStart := c.height - newsH + if newsH > 0 && msg.Y >= newsStart { + lineIdx := msg.Y - newsStart - 1 if lineIdx >= 0 && lineIdx < 5 && lineIdx < len(c.newsArticles) { url := c.newsArticles[lineIdx].URL if url != "" { return c, func() tea.Msg { - openBrowser(url) + tuikit.OpenURL(url) return nil } } } + } else if c.table != nil { + c.table.Update(msg) + c.cursor = c.table.CursorIndex() } } return c, nil @@ -561,11 +609,9 @@ func (c *CryptoView) KeyBindings() []tuikit.KeyBind { {Key: "shift+tab", Label: "Sort column back", Group: "DATA"}, {Key: "s", Label: "Star/unstar symbol", Group: "DATA"}, {Key: "f", Label: "Cycle filter", Group: "DATA"}, - {Key: "p", Label: "Toggle sidebar", Group: "DATA"}, {Key: "n", Label: "Toggle news", Group: "DATA"}, {Key: "d", Label: "DeFi yields", Group: "DATA"}, {Key: "enter", Label: "Coin detail", Group: "DATA"}, - {Key: "1/2/3", Label: "Toggle panel sections", Group: "DATA"}, {Key: "/", Label: "Search symbols", Group: "SEARCH"}, {Key: "enter", Label: "Confirm search", Group: "SEARCH"}, {Key: "esc", Label: "Cancel / clear", Group: "SEARCH"}, @@ -581,6 +627,7 @@ func (c *CryptoView) SetSize(w, h int) { func (c *CryptoView) Focused() bool { return c.focused } func (c *CryptoView) SetFocused(f bool) { c.focused = f } +func (c *CryptoView) CapturesInput() bool { return c.searching } // SelectedTicker returns the currently selected ticker, if any. func (c *CryptoView) SelectedTicker() (ticker.Ticker, bool) { @@ -695,6 +742,45 @@ func (c *CryptoView) rebuildSorted() { }) c.sorted = all c.computeMarketStats() + c.syncPanel() + c.rebuildRows() +} + +func (c *CryptoView) rebuildRows() { + if c.table == nil { + return + } + rows := make([]tuikit.Row, len(c.sorted)) + for i, t := range c.sorted { + corr := c.correlations[t.Symbol] + corrStr := fmt.Sprintf("%.2f", corr) + if t.Symbol == "BTCUSDT" { + corrStr = "—" + } + price := ticker.FormatPrice(t.LastPrice) + if time.Now().Before(t.FlashUntil) && t.PriceDelta != 0 { + price += " " + fmt.Sprintf("%+.2f", t.PriceDelta) + } + vol := ticker.FormatVolume(t.QuoteVolume) + if t.VolumeSpiking { + vol += fmt.Sprintf(" %.1fx", t.VolumeSpikeRatio) + } + sym := t.DisplaySymbol() + if c.Watchlist.IsStarred(t.Symbol) { + sym = "★ " + sym + } + rows[i] = tuikit.Row{ + fmt.Sprintf("%d", i+1), + sym, + price, + formatChange(t.PriceChangePercent), + "", // TREND — rendered by CellRenderer + corrStr, + vol, + } + } + c.table.SetRows(rows) + c.table.SetCursor(c.cursor) } func (c *CryptoView) computeMarketStats() { @@ -810,6 +896,35 @@ func (c *CryptoView) computeCorrelations() { } } +func (c *CryptoView) rebuildDefiRows() { + if c.defiTable == nil { + return + } + rows := make([]tuikit.Row, len(c.defiPools)) + for i, p := range c.defiPools { + rows[i] = tuikit.Row{ + fmt.Sprintf("%d", i+1), + p.Protocol, + p.Symbol, + p.Chain, + fmt.Sprintf("%.2f%%", p.APY), + ticker.FormatVolume(p.TVL), + } + } + c.defiTable.SetRows(rows) +} + +func (c *CryptoView) syncPanel() { + if c.Panel == nil { + return + } + c.Panel.SetMarketStats(c.marketStats) + c.Panel.SetFundingRates(c.fundingRates) + c.Panel.SetFearGreed(FearGreedData{Value: c.fearGreed.Value, Label: c.fearGreed.Label}) + c.Panel.SetRecentLiqs(c.recentLiqs) + c.Panel.SetTickers(c.tickers) +} + func pearson(x, y []float64) float64 { n := float64(len(x)) var sumX, sumY, sumXY, sumX2, sumY2 float64 @@ -850,28 +965,6 @@ func (c *CryptoView) clampCursor() { } } -func (c *CryptoView) clampDefiCursor() { - if len(c.defiPools) == 0 { - c.defiCursor = 0 - c.defiScroll = 0 - return - } - if c.defiCursor < 0 { - c.defiCursor = 0 - } - if c.defiCursor >= len(c.defiPools) { - c.defiCursor = len(c.defiPools) - 1 - } - visRows := c.visibleRows - 1 - if visRows > 0 { - if c.defiCursor < c.defiScroll { - c.defiScroll = c.defiCursor - } - if c.defiCursor >= c.defiScroll+visRows { - c.defiScroll = c.defiCursor - visRows + 1 - } - } -} // Fetch commands. @@ -932,6 +1025,37 @@ func LiqMsgFrom(l liquidation.Liq) tea.Msg { return liqMsg(l) } +// Accessor methods for status bar closures. + +// FilterMode returns the current filter mode. +func (c *CryptoView) FilterMode() FilterMode { return c.filterMode } + +// SearchQuery returns the current search query. +func (c *CryptoView) SearchQuery() string { return c.searchQuery } + +// IsSearching returns whether search mode is active. +func (c *CryptoView) IsSearching() bool { return c.searching } + +// PairCount returns the total number of tracked pairs. +func (c *CryptoView) PairCount() int { return len(c.tickers) } + +// VisibleCount returns the number of visible (filtered/sorted) rows. +func (c *CryptoView) VisibleCount() int { return len(c.sorted) } + +// CursorPos returns the current cursor position. +func (c *CryptoView) CursorPos() int { return c.cursor } + +// BtcPrice returns the current BTC price. +func (c *CryptoView) BtcPrice() float64 { + if btc, ok := c.tickers["BTCUSDT"]; ok { + return btc.LastPrice + } + return 0 +} + +// Connected returns the current connection state. +func (c *CryptoView) Connected() bool { return c.connected } + // SetSort changes the sort column by name: volume, price, change, symbol, correlation. func (c *CryptoView) SetSort(col string) bool { sc := parseSortCol(col) @@ -975,6 +1099,9 @@ func (c *CryptoView) GoToSymbol(sym string) bool { // ReapplyConfig re-derives styles and state from the current config. func (c *CryptoView) ReapplyConfig() { c.styles = NewStyles(*c.Cfg) + if c.Panel != nil { + c.Panel.SetStyles(c.styles) + } c.panelOn = parsePanelOn(c.Cfg.PanelLayout) c.sortCol = parseSortCol(c.Cfg.DefaultSort) c.sortAsc = c.Cfg.SortAscending diff --git a/internal/ui/defiview.go b/internal/ui/defiview.go index 076b550..6460c6e 100644 --- a/internal/ui/defiview.go +++ b/internal/ui/defiview.go @@ -5,10 +5,9 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/moneycaringcoder/cryptstream-tui/internal/ticker" ) -// renderDefiTable renders the DeFi yields overlay in the table area. +// renderDefiTable renders the DeFi yields overlay using the tuikit.Table component. func (c *CryptoView) renderDefiTable(tableW int) string { s := c.styles var sb strings.Builder @@ -18,99 +17,24 @@ func (c *CryptoView) renderDefiTable(tableW int) string { titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).Bold(true) sb.WriteString(titleStyle.Render(padRight(title, tableW))) sb.WriteByte('\n') - sb.WriteString(RenderSeparator(s, tableW)) - sb.WriteByte('\n') - - // Column widths (proportional) - colRank := 4 - colProto := tableW * 20 / 100 - colPool := tableW * 30 / 100 - colChain := tableW * 15 / 100 - colAPY := tableW * 15 / 100 - colTVL := tableW - colRank - colProto - colPool - colChain - colAPY - if colTVL < 8 { - colTVL = 8 - } - - // Column header - hdr := s.Header.Render( - padRight("#", colRank) + - padRight("PROTOCOL", colProto) + - padRight("POOL", colPool) + - padRight("CHAIN", colChain) + - padLeft("APY", colAPY) + - padLeft("TVL", colTVL), - ) - sb.WriteString(hdr) - sb.WriteByte('\n') - - // Rows - visRows := c.visibleRows - 1 // one extra header line used by title - pools := c.defiPools - - maxScroll := len(pools) - visRows - if maxScroll < 0 { - maxScroll = 0 - } - scroll := c.defiScroll - if scroll > maxScroll { - scroll = maxScroll - } - - end := scroll + visRows - if end > len(pools) { - end = len(pools) - } - - for i := scroll; i < end; i++ { - p := pools[i] - isCursor := i == c.defiCursor - rank := fmt.Sprintf("%d", i+1) - apy := fmt.Sprintf("%.2f%%", p.APY) - tvl := ticker.FormatVolume(p.TVL) - - row := padRight(rank, colRank) + - padRight(truncateRunes(p.Protocol, colProto-2), colProto) + - padRight(truncateRunes(p.Symbol, colPool-2), colPool) + - padRight(truncateRunes(p.Chain, colChain-2), colChain) + - padLeft(apy, colAPY) + - padLeft(tvl, colTVL) - - if isCursor { - sb.WriteString(s.CursorRow.Render(row)) - } else { - // Style APY green on non-cursor rows - plainRow := padRight(rank, colRank) + - padRight(truncateRunes(p.Protocol, colProto-2), colProto) + - padRight(truncateRunes(p.Symbol, colPool-2), colPool) + - padRight(truncateRunes(p.Chain, colChain-2), colChain) - sb.WriteString(plainRow) - sb.WriteString(s.Positive.Render(padLeft(apy, colAPY))) - sb.WriteString(padLeft(tvl, colTVL)) - } - sb.WriteByte('\n') - } - // Fill remaining height - newsH := c.newsHeight() - filled := (end - scroll) + 3 // title + sep + col header - targetH := c.height - 2 - newsH // minus footer sep + footer + news - for filled < targetH { - sb.WriteByte('\n') - filled++ - } + // Table component handles columns, rows, cursor, scroll + defiH := c.height - c.newsHeight() - 1 // minus title line + c.defiTable.SetSize(tableW, defiH) + c.defiTable.SetFocused(true) + sb.WriteString(c.defiTable.View()) // News band + newsH := c.newsHeight() if newsH > 0 { + sb.WriteString("\n") sb.WriteString(c.renderNewsBand(s, tableW)) } // Footer - sb.WriteString(RenderSeparator(s, tableW)) - sb.WriteByte('\n') - + sb.WriteString("\n") left := " d close j/k scroll" - right := fmt.Sprintf(" %d pools ", len(pools)) + right := fmt.Sprintf(" %d pools ", len(c.defiPools)) gap := tableW - len(left) - len(right) if gap < 1 { gap = 1 diff --git a/internal/ui/marketpanel.go b/internal/ui/marketpanel.go new file mode 100644 index 0000000..423a869 --- /dev/null +++ b/internal/ui/marketpanel.go @@ -0,0 +1,352 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + tuikit "github.com/moneycaringcoder/tuikit-go" + "github.com/moneycaringcoder/cryptstream-tui/internal/funding" + "github.com/moneycaringcoder/cryptstream-tui/internal/liquidation" + "github.com/moneycaringcoder/cryptstream-tui/internal/ticker" +) + +// MarketPanel is the sidebar component showing market stats, funding, +// liquidations, and watchlist data. Implements tuikit.Component. +type MarketPanel struct { + width int + height int + focused bool + styles Styles + + marketStats MarketStats + fundingRates map[string]funding.Info + fearGreed FearGreedData + recentLiqs []liquidation.Liq + tickers map[string]ticker.Ticker + + secVolSpikes *tuikit.CollapsibleSection + secFunding *tuikit.CollapsibleSection + secLiqs *tuikit.CollapsibleSection +} + +// FearGreedData holds the Fear & Greed index for display. +type FearGreedData struct { + Value int + Label string +} + +// NewMarketPanel creates a MarketPanel with default state. +func NewMarketPanel(styles Styles) *MarketPanel { + return &MarketPanel{ + styles: styles, + fundingRates: make(map[string]funding.Info), + tickers: make(map[string]ticker.Ticker), + secVolSpikes: tuikit.NewCollapsibleSection("VOL SPIKES"), + secFunding: tuikit.NewCollapsibleSection("FUNDING RATES"), + secLiqs: tuikit.NewCollapsibleSection("LIQUIDATIONS"), + } +} + +// Setter methods for CryptoView to push data. + +// SetMarketStats updates aggregate market stats. +func (p *MarketPanel) SetMarketStats(ms MarketStats) { p.marketStats = ms } + +// SetFundingRates updates funding rate data. +func (p *MarketPanel) SetFundingRates(fr map[string]funding.Info) { p.fundingRates = fr } + +// SetFearGreed updates Fear & Greed index data. +func (p *MarketPanel) SetFearGreed(fg FearGreedData) { p.fearGreed = fg } + +// SetRecentLiqs updates recent liquidation events. +func (p *MarketPanel) SetRecentLiqs(liqs []liquidation.Liq) { p.recentLiqs = liqs } + +// SetTickers updates the ticker map for reference price lookups. +func (p *MarketPanel) SetTickers(t map[string]ticker.Ticker) { p.tickers = t } + +// SetStyles updates the styles (e.g. after config change). +func (p *MarketPanel) SetStyles(s Styles) { p.styles = s } + +func (p *MarketPanel) Init() tea.Cmd { return nil } + +func (p *MarketPanel) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "1": + p.secVolSpikes.Toggle() + return p, tuikit.Consumed() + case "2": + p.secFunding.Toggle() + return p, tuikit.Consumed() + case "3": + p.secLiqs.Toggle() + return p, tuikit.Consumed() + } + } + return p, nil +} + +func (p *MarketPanel) View() string { + if p.width == 0 { + return "" + } + + s := p.styles + ms := p.marketStats + w := p.width + inner := w - 1 // 1 char padding + + var lines []string + + // Pinned references (BTC, ETH, SOL + starred) + for _, t := range ms.Pinned { + fr := p.fundingRates[t.Symbol] + lines = append(lines, " "+p.formatRefLine(t, inner, fr)) + } + + // Separator + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) + + // Aggregate stats (compact 2-line layout) + line1 := s.PanelLabel.Render("Vol ") + ticker.FormatVolume(ms.TotalVolume) + " " + s.PanelLabel.Render("Avg ") + formatChange(ms.AvgChange) + lines = append(lines, " "+line1) + line2 := s.Positive.Render(fmt.Sprintf("↑%d", ms.GainerCount)) + " " + + s.Negative.Render(fmt.Sprintf("↓%d", ms.LoserCount)) + " " + + s.PanelLabel.Render("BTC ") + fmt.Sprintf("%.1f%%", ms.BtcDominance) + lines = append(lines, " "+line2) + + // Market breadth bar (gainers vs losers visual) + total := ms.GainerCount + ms.LoserCount + if total > 0 { + barW := inner - 1 + greenW := barW * ms.GainerCount / total + if greenW > barW { + greenW = barW + } + redW := barW - greenW + bar := s.Positive.Render(strings.Repeat("█", greenW)) + s.Negative.Render(strings.Repeat("█", redW)) + lines = append(lines, " "+bar) + } + + // Fear & Greed gauge + if p.fearGreed.Value > 0 { + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) + fg := p.fearGreed + barW := inner - 1 + filled := barW * fg.Value / 100 + if filled > barW { + filled = barW + } + var barColor string + switch { + case fg.Value < 25: + barColor = "#ff4444" + case fg.Value < 50: + barColor = "#ffaa00" + case fg.Value < 75: + barColor = "#aaff00" + default: + barColor = "#00ff88" + } + barStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(barColor)) + dimBlock := lipgloss.NewStyle().Foreground(s.ColorDim) + bar := barStyle.Render(strings.Repeat("█", filled)) + dimBlock.Render(strings.Repeat("░", barW-filled)) + label := fmt.Sprintf(" %s %d", fg.Label, fg.Value) + labelStyled := barStyle.Render(label) + lines = append(lines, " "+bar) + lines = append(lines, labelStyled) + } + + // Vol Spikes (collapsible, key: 1) + if len(ms.VolSpikes) > 0 { + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) + arrow := "▾" + if p.secVolSpikes.Collapsed { + arrow = "▸" + } + lines = append(lines, " "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("VOL SPIKES")) + if !p.secVolSpikes.Collapsed { + for _, t := range ms.VolSpikes { + sym := padRight(t.DisplaySymbol(), 8) + ratio := s.VolSpike.Render(fmt.Sprintf("%.1fx", t.VolumeSpikeRatio)) + lines = append(lines, " "+sym+" "+ratio) + } + } + } + + // Funding rate extremes (collapsible, key: 2) + if len(p.fundingRates) > 0 { + type fundPair struct { + sym string + rate float64 + } + var pairs []fundPair + for sym, info := range p.fundingRates { + if info.Rate != 0 { + pairs = append(pairs, fundPair{sym: strings.TrimSuffix(sym, "USDT"), rate: info.Rate}) + } + } + if len(pairs) > 0 { + sort.Slice(pairs, func(i, j int) bool { return pairs[i].rate > pairs[j].rate }) + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) + arrow := "▾" + if p.secFunding.Collapsed { + arrow = "▸" + } + lines = append(lines, " "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("FUNDING RATES")) + if !p.secFunding.Collapsed { + show := 3 + for i := 0; i < show && i < len(pairs); i++ { + fp := pairs[i] + sym := padRight(fp.sym, 8) + rate := s.Negative.Render(fmt.Sprintf("%+.3f%%", fp.rate)) + lines = append(lines, " "+sym+" "+rate) + } + for i := len(pairs) - 1; i >= 0 && i >= len(pairs)-show; i-- { + fp := pairs[i] + if fp.rate >= 0 { + continue + } + sym := padRight(fp.sym, 8) + rate := s.Positive.Render(fmt.Sprintf("%+.3f%%", fp.rate)) + lines = append(lines, " "+sym+" "+rate) + } + } + } + } + + // Separator + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) + + // Gainers / Losers side by side + colGap := 2 + colW := (inner - colGap) / 2 + lines = append(lines, " "+s.PanelLabel.Render(padRight("GAINERS", colW+colGap)+"LOSERS")) + limit := 5 + for i := 0; i < limit; i++ { + leftPad := strings.Repeat(" ", colW+colGap) + rightStr := "" + if i < len(ms.TopGainers) { + g := ms.TopGainers[i] + sym := g.DisplaySymbol() + chg := fmt.Sprintf("%+.0f%%", g.PriceChangePercent) + gap := colW - len(sym) - len(chg) + if gap < 1 { + gap = 1 + } + leftPad = sym + strings.Repeat(" ", gap) + s.Positive.Render(chg) + strings.Repeat(" ", colGap) + } + if i < len(ms.TopLosers) { + l := ms.TopLosers[i] + sym := l.DisplaySymbol() + chg := fmt.Sprintf("%.0f%%", l.PriceChangePercent) + gap := colW - len(sym) - len(chg) + if gap < 1 { + gap = 1 + } + rightStr = sym + strings.Repeat(" ", gap) + s.Negative.Render(chg) + } + lines = append(lines, " "+leftPad+rightStr) + } + + // Liquidation feed (collapsible, key: 3) + if len(p.recentLiqs) > 0 { + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) + arrow := "▾" + if p.secLiqs.Collapsed { + arrow = "▸" + } + lines = append(lines, " "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("LIQUIDATIONS")) + if !p.secLiqs.Collapsed { + liqColW := (inner - 1) / 2 + for i := 0; i < len(p.recentLiqs); i += 2 { + left := p.formatLiqCell(s, p.recentLiqs[i], liqColW) + right := "" + if i+1 < len(p.recentLiqs) { + right = p.formatLiqCell(s, p.recentLiqs[i+1], liqColW) + } + lines = append(lines, " "+left+right) + } + } + } + + // Fill remaining height + totalNeeded := p.height + for len(lines) < totalNeeded { + lines = append(lines, "") + } + + if len(lines) > totalNeeded { + lines = lines[:totalNeeded] + } + + // Pad every line to fill the full panel width so JoinHorizontal aligns correctly + for i, line := range lines { + vis := lipgloss.Width(line) + if vis < w { + lines[i] = line + strings.Repeat(" ", w-vis) + } + } + + return strings.Join(lines, "\n") +} + +// formatRefLine formats a pinned coin reference for the panel. +func (p *MarketPanel) formatRefLine(t ticker.Ticker, maxWidth int, fr funding.Info) string { + s := p.styles + if t.Symbol == "" { + return "" + } + sym := padRight(t.DisplaySymbol(), 4) + price := ticker.FormatPrice(t.LastPrice) + chg := formatChange(t.PriceChangePercent) + chgStyled := changeStyle(s, t.PriceChangePercent).Render(chg) + + fundStr := "" + if fr.Rate != 0 { + rateStr := fmt.Sprintf("%.3f%%", fr.Rate) + if fr.Rate < 0 { + fundStr = " " + s.Positive.Render(rateStr) + } else { + fundStr = " " + s.Negative.Render(rateStr) + } + } + + line := sym + " " + price + " " + chgStyled + fundStr + return line +} + +// formatLiqCell renders a single liquidation entry padded to colW. +func (p *MarketPanel) formatLiqCell(s Styles, l liquidation.Liq, colW int) string { + sym := l.DisplaySymbol() + sideStr := l.Side + side := s.Negative.Render(sideStr) + if l.Side == "SHORT" { + side = s.Positive.Render(sideStr) + } + val := l.FormatNotional() + plainLen := len(sym) + 1 + len(sideStr) + 1 + len(val) + gap := colW - plainLen + if gap < 0 { + gap = 0 + } + return sym + " " + side + " " + val + strings.Repeat(" ", gap) +} + +func (p *MarketPanel) KeyBindings() []tuikit.KeyBind { + return []tuikit.KeyBind{ + {Key: "1/2/3", Label: "Toggle panel sections", Group: "PANEL"}, + } +} + +func (p *MarketPanel) SetSize(w, h int) { + p.width = w + p.height = h +} + +func (p *MarketPanel) Focused() bool { return p.focused } +func (p *MarketPanel) SetFocused(f bool) { p.focused = f } diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 6317fa2..6a773dd 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -1,294 +1,28 @@ package ui -import ( - "fmt" - "sort" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/moneycaringcoder/cryptstream-tui/internal/funding" - "github.com/moneycaringcoder/cryptstream-tui/internal/liquidation" - "github.com/moneycaringcoder/cryptstream-tui/internal/ticker" -) - const ( - panelWidthRight = 30 - minTermWForPanel = 100 - topN = 10 + topN = 10 ) -// panelVisible returns whether the panel should be shown given current dimensions. -func (c *CryptoView) panelVisible() bool { - if !c.panelOn { - return false - } - return c.width >= minTermWForPanel -} - -// tableWidth returns the width available for the table, accounting for panel. +// tableWidth returns the width available for the table. +// DualPane handles the sidebar split, so the table uses the full component width. func (c *CryptoView) tableWidth() int { - if c.panelVisible() { - return c.width - panelWidthRight - 1 // 1 for border - } return c.width } -// tableVisibleRows returns the number of visible rows. // newsHeight returns the number of lines the news band occupies. func (c *CryptoView) newsHeight() int { if !c.newsOn || len(c.newsArticles) == 0 { return 0 } - return 6 // separator + 5 headline lines + return 7 // top separator + 5 headline lines + bottom separator } +// tableVisibleRows returns the number of visible rows. func (c *CryptoView) tableVisibleRows() int { - rows := c.height - 4 - c.newsHeight() // header + separator + footer separator + footer + news + rows := c.height - 2 - c.newsHeight() // header + header separator; status bar is handled by App if rows < 0 { rows = 0 } return rows } - -// renderPanel renders the market pulse sidebar. -func (c *CryptoView) renderPanel() string { - if !c.panelVisible() { - return "" - } - - s := c.styles - ms := c.marketStats - w := panelWidthRight - inner := w - 2 // leave space for border char + padding - - var lines []string - border := s.PanelBorder.Render("┃") - - // Pinned references (BTC, ETH, SOL + starred) - for _, t := range ms.Pinned { - fr := c.fundingRates[t.Symbol] - lines = append(lines, border+" "+c.formatRefLine(t, inner, fr)) - } - - // Separator - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) - - // Aggregate stats (compact 2-line layout) - line1 := s.PanelLabel.Render("Vol ") + ticker.FormatVolume(ms.TotalVolume) + " " + s.PanelLabel.Render("Avg ") + formatChange(ms.AvgChange) - lines = append(lines, border+" "+line1) - line2 := s.Positive.Render(fmt.Sprintf("↑%d", ms.GainerCount)) + " " + - s.Negative.Render(fmt.Sprintf("↓%d", ms.LoserCount)) + " " + - s.PanelLabel.Render("BTC ") + fmt.Sprintf("%.1f%%", ms.BtcDominance) - lines = append(lines, border+" "+line2) - - // Market breadth bar (gainers vs losers visual) - total := ms.GainerCount + ms.LoserCount - if total > 0 { - barW := inner - 1 - greenW := barW * ms.GainerCount / total - if greenW > barW { - greenW = barW - } - redW := barW - greenW - bar := s.Positive.Render(strings.Repeat("█", greenW)) + s.Negative.Render(strings.Repeat("█", redW)) - lines = append(lines, border+" "+bar) - } - - // Fear & Greed gauge - if c.fearGreed.Value > 0 { - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) - fg := c.fearGreed - barW := inner - 1 // width for the gauge bar - filled := barW * fg.Value / 100 - if filled > barW { - filled = barW - } - // Color: red (0-25), yellow (25-50), yellow-green (50-75), green (75-100) - var barColor string - switch { - case fg.Value < 25: - barColor = "#ff4444" - case fg.Value < 50: - barColor = "#ffaa00" - case fg.Value < 75: - barColor = "#aaff00" - default: - barColor = "#00ff88" - } - barStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(barColor)) - dimBlock := lipgloss.NewStyle().Foreground(s.ColorDim) - bar := barStyle.Render(strings.Repeat("█", filled)) + dimBlock.Render(strings.Repeat("░", barW-filled)) - label := fmt.Sprintf(" %s %d", fg.Label, fg.Value) - labelStyled := barStyle.Render(label) - lines = append(lines, border+" "+bar) - lines = append(lines, border+labelStyled) - } - - // Vol Spikes (collapsible, key: 1) - if len(ms.VolSpikes) > 0 { - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) - arrow := "▾" - if c.secVolSpikes.Collapsed { - arrow = "▸" - } - lines = append(lines, border+" "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("VOL SPIKES")) - if !c.secVolSpikes.Collapsed { - for _, t := range ms.VolSpikes { - sym := padRight(t.DisplaySymbol(), 8) - ratio := s.VolSpike.Render(fmt.Sprintf("%.1fx", t.VolumeSpikeRatio)) - lines = append(lines, border+" "+sym+" "+ratio) - } - } - } - - // Funding rate extremes (collapsible, key: 2) - if len(c.fundingRates) > 0 { - type fundPair struct { - sym string - rate float64 - } - var pairs []fundPair - for sym, info := range c.fundingRates { - if info.Rate != 0 { - pairs = append(pairs, fundPair{sym: strings.TrimSuffix(sym, "USDT"), rate: info.Rate}) - } - } - if len(pairs) > 0 { - sort.Slice(pairs, func(i, j int) bool { return pairs[i].rate > pairs[j].rate }) - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) - arrow := "▾" - if c.secFunding.Collapsed { - arrow = "▸" - } - lines = append(lines, border+" "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("FUNDING RATES")) - if !c.secFunding.Collapsed { - show := 3 - for i := 0; i < show && i < len(pairs); i++ { - p := pairs[i] - sym := padRight(p.sym, 8) - rate := s.Negative.Render(fmt.Sprintf("%+.3f%%", p.rate)) - lines = append(lines, border+" "+sym+" "+rate) - } - for i := len(pairs) - 1; i >= 0 && i >= len(pairs)-show; i-- { - p := pairs[i] - if p.rate >= 0 { - continue - } - sym := padRight(p.sym, 8) - rate := s.Positive.Render(fmt.Sprintf("%+.3f%%", p.rate)) - lines = append(lines, border+" "+sym+" "+rate) - } - } - } - } - - // Separator - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) - - // Gainers / Losers side by side - colGap := 2 - colW := (inner - colGap) / 2 // width available per column - lines = append(lines, border+" "+s.PanelLabel.Render(padRight("GAINERS", colW+colGap)+"LOSERS")) - limit := 5 - for i := 0; i < limit; i++ { - leftPad := strings.Repeat(" ", colW+colGap) - rightStr := "" - if i < len(ms.TopGainers) { - g := ms.TopGainers[i] - sym := g.DisplaySymbol() - chg := fmt.Sprintf("%+.0f%%", g.PriceChangePercent) - gap := colW - len(sym) - len(chg) - if gap < 1 { - gap = 1 - } - leftPad = sym + strings.Repeat(" ", gap) + s.Positive.Render(chg) + strings.Repeat(" ", colGap) - } - if i < len(ms.TopLosers) { - l := ms.TopLosers[i] - sym := l.DisplaySymbol() - chg := fmt.Sprintf("%.0f%%", l.PriceChangePercent) - gap := colW - len(sym) - len(chg) - if gap < 1 { - gap = 1 - } - rightStr = sym + strings.Repeat(" ", gap) + s.Negative.Render(chg) - } - lines = append(lines, border+" "+leftPad+rightStr) - } - - // Liquidation feed (collapsible, key: 3) - if len(c.recentLiqs) > 0 { - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) - arrow := "▾" - if c.secLiqs.Collapsed { - arrow = "▸" - } - lines = append(lines, border+" "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("LIQUIDATIONS")) - if !c.secLiqs.Collapsed { - liqColW := (inner - 1) / 2 - for i := 0; i < len(c.recentLiqs); i += 2 { - left := c.formatLiqCell(s, c.recentLiqs[i], liqColW) - right := "" - if i+1 < len(c.recentLiqs) { - right = c.formatLiqCell(s, c.recentLiqs[i+1], liqColW) - } - lines = append(lines, border+" "+left+right) - } - } - } - - // Fill remaining height - totalNeeded := c.height - for len(lines) < totalNeeded { - lines = append(lines, border) - } - - if len(lines) > totalNeeded { - lines = lines[:totalNeeded] - } - - return strings.Join(lines, "\n") -} - -// formatRefLine formats a pinned coin reference for the panel. -func (c *CryptoView) formatRefLine(t ticker.Ticker, maxWidth int, fr funding.Info) string { - s := c.styles - if t.Symbol == "" { - return "" - } - sym := padRight(t.DisplaySymbol(), 4) - price := ticker.FormatPrice(t.LastPrice) - chg := formatChange(t.PriceChangePercent) - chgStyled := changeStyle(s, t.PriceChangePercent).Render(chg) - - // Funding rate (if available) - fundStr := "" - if fr.Rate != 0 { - rateStr := fmt.Sprintf("%.3f%%", fr.Rate) - if fr.Rate < 0 { - fundStr = " " + s.Positive.Render(rateStr) - } else { - fundStr = " " + s.Negative.Render(rateStr) - } - } - - line := sym + " " + price + " " + chgStyled + fundStr - return line -} - -// formatLiqCell renders a single liquidation entry padded to colW. -func (c *CryptoView) formatLiqCell(s Styles, l liquidation.Liq, colW int) string { - sym := l.DisplaySymbol() - sideStr := l.Side - side := s.Negative.Render(sideStr) - if l.Side == "SHORT" { - side = s.Positive.Render(sideStr) - } - val := l.FormatNotional() - plainLen := len(sym) + 1 + len(sideStr) + 1 + len(val) - gap := colW - plainLen - if gap < 0 { - gap = 0 - } - return sym + " " + side + " " + val + strings.Repeat(" ", gap) -} diff --git a/internal/ui/view.go b/internal/ui/view.go index 900678f..50894e6 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -1,11 +1,11 @@ package ui import ( - "fmt" "strings" "time" "github.com/charmbracelet/lipgloss" + tuikit "github.com/moneycaringcoder/tuikit-go" ) // View renders the full TUI frame as a string. @@ -15,84 +15,44 @@ func (c *CryptoView) View() string { } tableW := c.tableWidth() - var tableStr string if c.showDefi { - tableStr = c.renderDefiTable(tableW) - } else { - tableStr = c.renderTable(tableW) + return c.renderDefiTable(tableW) } - - if !c.panelVisible() { - return tableStr - } - - panelStr := c.renderPanel() - return lipgloss.JoinHorizontal(lipgloss.Top, tableStr, panelStr) + return c.renderTable(tableW) } -// renderTable renders the main table content at the given width. +// renderTable renders the main table content using the tuikit.Table component. func (c *CryptoView) renderTable(tableW int) string { s := c.styles var sb strings.Builder - sb.WriteString(RenderHeader(s, tableW, c.sortCol, c.sortAsc)) - sb.WriteByte('\n') + // Table component handles header, rows, cursor, scroll + tableH := c.height - c.newsHeight() + c.table.SetSize(tableW, tableH) + c.table.SetFocused(c.focused) + sb.WriteString(c.table.View()) - sb.WriteString(RenderSeparator(s, tableW)) - sb.WriteByte('\n') - - visRows := c.visibleRows - limit := len(c.sorted) - if visRows > 0 && limit > c.offset+visRows { - limit = c.offset + visRows - } - - for i := c.offset; i < limit; i++ { - t := c.sorted[i] - isCursor := i == c.cursor - spark := c.priceHistory[t.Symbol] - starred := c.Watchlist.IsStarred(t.Symbol) - liqFlash := time.Now().Before(c.liqFlash[t.Symbol]) - corr := c.correlations[t.Symbol] - sb.WriteString(RenderRow(s, i+1, t, tableW, isCursor, spark, starred, liqFlash, corr)) - sb.WriteByte('\n') - } - - filled := (limit - c.offset) + 2 + // News band sits between table and footer newsH := c.newsHeight() - targetH := c.height - 2 - newsH - for filled < targetH { - sb.WriteByte('\n') - filled++ - } - if newsH > 0 { + sb.WriteString("\n") sb.WriteString(c.renderNewsBand(s, tableW)) + sb.WriteString("\n") + sb.WriteString(s.Sep.Render(strings.Repeat("─", tableW))) } - sb.WriteString(RenderSeparator(s, tableW)) - sb.WriteByte('\n') - - btcPrice := 0.0 - if btc, ok := c.tickers["BTCUSDT"]; ok { - btcPrice = btc.LastPrice - } - sb.WriteString(RenderFooter(s, len(c.tickers), c.connected, tableW, btcPrice, c.filterMode, c.searching, c.searchQuery, c.cursor, len(c.sorted))) - return sb.String() } // renderNewsBand renders the news ticker band. func (c *CryptoView) renderNewsBand(s Styles, w int) string { - var sb strings.Builder - articles := c.newsArticles if len(articles) == 0 { return "" } - sb.WriteString(s.Sep.Render(strings.Repeat("─", w))) - sb.WriteByte('\n') + lines := make([]string, 0, 6) + lines = append(lines, s.Sep.Render(strings.Repeat("─", w))) newsLines := 5 agoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) @@ -103,17 +63,17 @@ func (c *CryptoView) renderNewsBand(s Styles, w int) string { for i := 0; i < newsLines; i++ { if i >= len(articles) { - sb.WriteByte('\n') + lines = append(lines, "") continue } a := articles[i] - ago := timeAgo(a.Time) - agoPad := padLeft(ago, 3) + ago := tuikit.RelativeTime(a.Time, time.Now()) + agoPad := padLeft(ago, 7) src := a.Source dot := " · " - usedPlain := 1 + 3 + 1 + len(src) + len(dot) + usedPlain := 1 + 7 + 1 + len(src) + len(dot) remaining := w - usedPlain - 1 if remaining < 0 { remaining = 0 @@ -129,27 +89,12 @@ func (c *CryptoView) renderNewsBand(s Styles, w int) string { if i == 0 && c.newsFlash > 0 { plainLine := " " + agoPad + " " + src + dot + title + " " - sb.WriteString(flashStyle.Render(plainLine)) + lines = append(lines, flashStyle.Render(plainLine)) } else { - sb.WriteString(" " + agoStyle.Render(agoPad) + " " + srcStyle.Render(src) + dotStyle.Render(dot) + titleStyle.Render(title) + " ") + lines = append(lines, " "+agoStyle.Render(agoPad)+" "+srcStyle.Render(src)+dotStyle.Render(dot)+titleStyle.Render(title)+" ") } - sb.WriteByte('\n') } - return sb.String() + return strings.Join(lines, "\n") } -// timeAgo returns a short human-readable time difference. -func timeAgo(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Minute: - return "now" - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) - default: - return fmt.Sprintf("%dd", int(d.Hours()/24)) - } -}