From 12f062286847f81e02f5398d71e1abc36014aca2 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Wed, 8 Apr 2026 15:22:36 +0200 Subject: [PATCH 1/4] feat: migrate to tuikit-go v0.5.0 components Replace manual table rendering with tuikit.Table + CellRenderer. Extract MarketPanel as standalone tuikit.Component. Wire DualPane layout and StatusBar in main.go. Replace browser.go with tuikit.OpenURL, timeAgo with tuikit.RelativeTime. Replace manual DeFi table with second tuikit.Table instance. Disable tab focus cycling to preserve tab/shift+tab sort column cycling. --- cmd/cryptstream/main.go | 57 ++++- go.mod | 2 +- go.sum | 2 + internal/ui/browser.go | 20 -- internal/ui/crypto.go | 412 ++++++++++++++++++++++++------------- internal/ui/defiview.go | 96 +-------- internal/ui/marketpanel.go | 345 +++++++++++++++++++++++++++++++ internal/ui/panel.go | 258 +---------------------- internal/ui/view.go | 82 ++------ 9 files changed, 700 insertions(+), 574 deletions(-) delete mode 100644 internal/ui/browser.go create mode 100644 internal/ui/marketpanel.go 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..19e13ea 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.0 ) require ( diff --git a/go.sum b/go.sum index 37aff97..3b95a4d 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF 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.0 h1:RE7M5/RdUEgypYsXhrpeen2g/j6LqWxsmaCuY3lUzXk= +github.com/moneycaringcoder/tuikit-go v0.5.0/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/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..4b21d0e 100644 --- a/internal/ui/crypto.go +++ b/internal/ui/crypto.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "math" "sort" "strings" @@ -106,18 +107,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 +140,137 @@ 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: 5, 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, MinWidth: 70}, + {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 + } + + // Determine flash state + t := c.tickers[symbol] + flashing := time.Now().Before(t.FlashUntil) && t.Flash != ticker.FlashNeutral + liqFlashing := time.Now().Before(c.liqFlash[symbol]) + starred := c.Watchlist.IsStarred(symbol) + corr := c.correlations[symbol] + + if flashing { + return flashStyle(s, t.Flash).Render(cell) + } + if liqFlashing { + return s.LiqFlash.Render(cell) + } + if isCursor { + switch colIdx { + case 3: // CHANGE + return s.CursorRow.Foreground(changeColor(s, t.PriceChangePercent)).Render(cell) + case 5: // βBTC + return s.CursorRow.Foreground(corrColor(s, corr)).Render(cell) + case 6: // VOLUME + if t.VolumeSpiking { + return s.CursorRow.Foreground(s.ColorVolSpike).Render(cell) + } + case 1: // SYMBOL + if starred { + runes := []rune(cell) + if len(runes) > 1 { + return s.CursorRow.Foreground(s.ColorStar).Render(string(runes[:1])) + s.CursorRow.Render(string(runes[1:])) + } + } + } + return s.CursorRow.Render(cell) + } + + // Non-cursor styling + 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 { + runes := []rune(cell) + if len(runes) > 1 { + return s.Star.Render(string(runes[:1])) + string(runes[1:]) + } + } + } + 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 + } + + c.table = tuikit.NewTable(columns, nil, tuikit.TableOpts{ + CellRenderer: cellRenderer, + 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 isCursor { + return s.CursorRow.Render(cell) + } + if colIdx == 4 { // APY + return s.Positive.Render(cell) + } + return cell + } + + c.defiTable = tuikit.NewTable(defiColumns, nil, tuikit.TableOpts{ + Sortable: true, + CellRenderer: defiCellRenderer, + }) + for _, t := range initial { c.tickers[t.Symbol] = t c.priceHistory[t.Symbol] = []float64{t.LastPrice} @@ -179,17 +305,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 +339,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 +441,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 +479,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 - } - c.cursor += half - c.clampCursor() - return c, tuikit.Consumed() - case "ctrl+u": - 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 "tab": c.sortCol = (c.sortCol + 1) % sortColCount @@ -432,35 +528,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 +562,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 +611,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"}, @@ -695,6 +743,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 +897,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 +966,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 +1026,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 +1100,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..fe63fa8 --- /dev/null +++ b/internal/ui/marketpanel.go @@ -0,0 +1,345 @@ +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 - 2 + + var lines []string + border := s.PanelBorder.Render("┃") + + // Pinned references (BTC, ETH, SOL + starred) + for _, t := range ms.Pinned { + fr := p.fundingRates[t.Symbol] + lines = append(lines, border+" "+p.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 p.fearGreed.Value > 0 { + lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + 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, 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 p.secVolSpikes.Collapsed { + arrow = "▸" + } + lines = append(lines, border+" "+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, border+" "+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, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + arrow := "▾" + if p.secFunding.Collapsed { + arrow = "▸" + } + lines = append(lines, border+" "+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, border+" "+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, 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 + 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(p.recentLiqs) > 0 { + lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + arrow := "▾" + if p.secLiqs.Collapsed { + arrow = "▸" + } + lines = append(lines, border+" "+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, border+" "+left+right) + } + } + } + + // Fill remaining height + totalNeeded := p.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 (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..84370ee 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -1,18 +1,7 @@ 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 + panelWidthRight = 30 minTermWForPanel = 100 topN = 10 ) @@ -33,7 +22,6 @@ func (c *CryptoView) tableWidth() int { 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 { @@ -42,6 +30,7 @@ func (c *CryptoView) newsHeight() int { return 6 // separator + 5 headline lines } +// tableVisibleRows returns the number of visible rows. func (c *CryptoView) tableVisibleRows() int { rows := c.height - 4 - c.newsHeight() // header + separator + footer separator + footer + news if rows < 0 { @@ -49,246 +38,3 @@ func (c *CryptoView) tableVisibleRows() int { } 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..690d63d 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,70 +15,30 @@ 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') - - 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') - } + // 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()) - filled := (limit - c.offset) + 2 + // News band sits between table and footer (status bar handles 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(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() } @@ -108,12 +68,12 @@ func (c *CryptoView) renderNewsBand(s Styles, w int) string { } 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 @@ -139,17 +99,3 @@ func (c *CryptoView) renderNewsBand(s Styles, w int) string { return sb.String() } -// 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)) - } -} From fa82efa298117e4fb9a3b4f3febb939e68a3c9b8 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Wed, 8 Apr 2026 16:28:47 +0200 Subject: [PATCH 2/4] fix: post-migration rendering fixes for tuikit DualPane layout - Remove double border from MarketPanel (DualPane provides separator) - Remove double width subtraction in tableWidth() (DualPane handles split) - Pad MarketPanel lines to full width for JoinHorizontal alignment - Move flash/liq/cursor backgrounds from CellRenderer to RowStyler - CellRenderer now returns foreground-only styling - Add explicit HeaderStyle to fix invisible headers with zero theme - Cap TREND column at 20 chars (MaxWidth) to match sparkline width - Add news-to-footer separator line - Implement CapturesInput() for search mode - Increase FilterCount default from 20 to 100 - Reduce # column width with MaxWidth: 4 --- internal/config/config.go | 2 +- internal/ui/crypto.go | 74 +++++++++++++++++++------------------- internal/ui/marketpanel.go | 55 +++++++++++++++------------- internal/ui/panel.go | 22 +++--------- internal/ui/view.go | 19 +++++----- 5 files changed, 84 insertions(+), 88 deletions(-) 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/crypto.go b/internal/ui/crypto.go index 4b21d0e..5c2a256 100644 --- a/internal/ui/crypto.go +++ b/internal/ui/crypto.go @@ -8,6 +8,7 @@ import ( "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" @@ -144,11 +145,11 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { c.Panel = NewMarketPanel(c.styles) columns := []tuikit.Column{ - {Title: "#", Width: 5, Align: tuikit.Right}, + {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, MinWidth: 70}, + {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}, } @@ -176,41 +177,11 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { return styled } - // Determine flash state + // Foreground-only styling — RowStyler owns all backgrounds t := c.tickers[symbol] - flashing := time.Now().Before(t.FlashUntil) && t.Flash != ticker.FlashNeutral - liqFlashing := time.Now().Before(c.liqFlash[symbol]) starred := c.Watchlist.IsStarred(symbol) corr := c.correlations[symbol] - if flashing { - return flashStyle(s, t.Flash).Render(cell) - } - if liqFlashing { - return s.LiqFlash.Render(cell) - } - if isCursor { - switch colIdx { - case 3: // CHANGE - return s.CursorRow.Foreground(changeColor(s, t.PriceChangePercent)).Render(cell) - case 5: // βBTC - return s.CursorRow.Foreground(corrColor(s, corr)).Render(cell) - case 6: // VOLUME - if t.VolumeSpiking { - return s.CursorRow.Foreground(s.ColorVolSpike).Render(cell) - } - case 1: // SYMBOL - if starred { - runes := []rune(cell) - if len(runes) > 1 { - return s.CursorRow.Foreground(s.ColorStar).Render(string(runes[:1])) + s.CursorRow.Render(string(runes[1:])) - } - } - } - return s.CursorRow.Render(cell) - } - - // Non-cursor styling switch colIdx { case 3: // CHANGE return changeStyle(s, t.PriceChangePercent).Render(cell) @@ -237,8 +208,31 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { 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, }) @@ -257,17 +251,24 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { } cell := row[colIdx] s := c.styles - if isCursor { - return s.CursorRow.Render(cell) - } 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, }) @@ -629,6 +630,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) { diff --git a/internal/ui/marketpanel.go b/internal/ui/marketpanel.go index fe63fa8..423a869 100644 --- a/internal/ui/marketpanel.go +++ b/internal/ui/marketpanel.go @@ -98,27 +98,26 @@ func (p *MarketPanel) View() string { s := p.styles ms := p.marketStats w := p.width - inner := w - 2 + inner := w - 1 // 1 char padding var lines []string - border := s.PanelBorder.Render("┃") // Pinned references (BTC, ETH, SOL + starred) for _, t := range ms.Pinned { fr := p.fundingRates[t.Symbol] - lines = append(lines, border+" "+p.formatRefLine(t, inner, fr)) + lines = append(lines, " "+p.formatRefLine(t, inner, fr)) } // Separator - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + 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, border+" "+line1) + 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, border+" "+line2) + lines = append(lines, " "+line2) // Market breadth bar (gainers vs losers visual) total := ms.GainerCount + ms.LoserCount @@ -130,12 +129,12 @@ func (p *MarketPanel) View() string { } redW := barW - greenW bar := s.Positive.Render(strings.Repeat("█", greenW)) + s.Negative.Render(strings.Repeat("█", redW)) - lines = append(lines, border+" "+bar) + lines = append(lines, " "+bar) } // Fear & Greed gauge if p.fearGreed.Value > 0 { - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) fg := p.fearGreed barW := inner - 1 filled := barW * fg.Value / 100 @@ -158,23 +157,23 @@ func (p *MarketPanel) View() string { 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) + lines = append(lines, " "+bar) + lines = append(lines, labelStyled) } // Vol Spikes (collapsible, key: 1) if len(ms.VolSpikes) > 0 { - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) arrow := "▾" if p.secVolSpikes.Collapsed { arrow = "▸" } - lines = append(lines, border+" "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("VOL SPIKES")) + 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, border+" "+sym+" "+ratio) + lines = append(lines, " "+sym+" "+ratio) } } } @@ -193,19 +192,19 @@ func (p *MarketPanel) View() string { } 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))) + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) arrow := "▾" if p.secFunding.Collapsed { arrow = "▸" } - lines = append(lines, border+" "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("FUNDING RATES")) + 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, border+" "+sym+" "+rate) + lines = append(lines, " "+sym+" "+rate) } for i := len(pairs) - 1; i >= 0 && i >= len(pairs)-show; i-- { fp := pairs[i] @@ -214,19 +213,19 @@ func (p *MarketPanel) View() string { } sym := padRight(fp.sym, 8) rate := s.Positive.Render(fmt.Sprintf("%+.3f%%", fp.rate)) - lines = append(lines, border+" "+sym+" "+rate) + lines = append(lines, " "+sym+" "+rate) } } } } // Separator - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) // Gainers / Losers side by side colGap := 2 colW := (inner - colGap) / 2 - lines = append(lines, border+" "+s.PanelLabel.Render(padRight("GAINERS", colW+colGap)+"LOSERS")) + lines = append(lines, " "+s.PanelLabel.Render(padRight("GAINERS", colW+colGap)+"LOSERS")) limit := 5 for i := 0; i < limit; i++ { leftPad := strings.Repeat(" ", colW+colGap) @@ -251,17 +250,17 @@ func (p *MarketPanel) View() string { } rightStr = sym + strings.Repeat(" ", gap) + s.Negative.Render(chg) } - lines = append(lines, border+" "+leftPad+rightStr) + lines = append(lines, " "+leftPad+rightStr) } // Liquidation feed (collapsible, key: 3) if len(p.recentLiqs) > 0 { - lines = append(lines, border+s.PanelBorder.Render(strings.Repeat("─", w-1))) + lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) arrow := "▾" if p.secLiqs.Collapsed { arrow = "▸" } - lines = append(lines, border+" "+s.PanelBorder.Render(arrow)+" "+s.PanelLabel.Render("LIQUIDATIONS")) + 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 { @@ -270,7 +269,7 @@ func (p *MarketPanel) View() string { if i+1 < len(p.recentLiqs) { right = p.formatLiqCell(s, p.recentLiqs[i+1], liqColW) } - lines = append(lines, border+" "+left+right) + lines = append(lines, " "+left+right) } } } @@ -278,13 +277,21 @@ func (p *MarketPanel) View() string { // Fill remaining height totalNeeded := p.height for len(lines) < totalNeeded { - lines = append(lines, border) + 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") } diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 84370ee..6a773dd 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -1,24 +1,12 @@ package ui 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 } @@ -27,12 +15,12 @@ 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 } diff --git a/internal/ui/view.go b/internal/ui/view.go index 690d63d..50894e6 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -32,11 +32,13 @@ func (c *CryptoView) renderTable(tableW int) string { c.table.SetFocused(c.focused) sb.WriteString(c.table.View()) - // News band sits between table and footer (status bar handles footer) + // News band sits between table and footer newsH := c.newsHeight() if newsH > 0 { sb.WriteString("\n") sb.WriteString(c.renderNewsBand(s, tableW)) + sb.WriteString("\n") + sb.WriteString(s.Sep.Render(strings.Repeat("─", tableW))) } return sb.String() @@ -44,15 +46,13 @@ func (c *CryptoView) renderTable(tableW int) 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")) @@ -63,7 +63,7 @@ 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] @@ -89,13 +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") } From ffc3a8190f4e8696dfad5d0645a756464bfba89c Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Wed, 8 Apr 2026 16:30:34 +0200 Subject: [PATCH 3/4] fix: color entire starred symbol name yellow instead of just the star icon --- internal/ui/crypto.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/ui/crypto.go b/internal/ui/crypto.go index 5c2a256..4294159 100644 --- a/internal/ui/crypto.go +++ b/internal/ui/crypto.go @@ -193,10 +193,7 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { } case 1: // SYMBOL if starred { - runes := []rune(cell) - if len(runes) > 1 { - return s.Star.Render(string(runes[:1])) + string(runes[1:]) - } + return s.Star.Render(cell) } } return cell From 70e79c25466170a129a35e1dd037592c4866db15 Mon Sep 17 00:00:00 2001 From: moneycaringcoder Date: Wed, 8 Apr 2026 16:37:53 +0200 Subject: [PATCH 4/4] chore: bump tuikit-go to v0.5.1 and remove local replace --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 19e13ea..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.5.0 + github.com/moneycaringcoder/tuikit-go v0.5.1 ) require ( diff --git a/go.sum b/go.sum index 3b95a4d..f770d71 100644 --- a/go.sum +++ b/go.sum @@ -32,10 +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.0 h1:RE7M5/RdUEgypYsXhrpeen2g/j6LqWxsmaCuY3lUzXk= -github.com/moneycaringcoder/tuikit-go v0.5.0/go.mod h1:NNJ8NSFnHrd4A7dqmb0DO1kA6vtk8jmdCTKYJFL+h50= +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=