diff --git a/cmd/cryptstream/main.go b/cmd/cryptstream/main.go index 6d6728b..3fb1016 100644 --- a/cmd/cryptstream/main.go +++ b/cmd/cryptstream/main.go @@ -8,7 +8,6 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" tuikit "github.com/moneycaringcoder/tuikit-go" "github.com/moneycaringcoder/cryptstream-tui/internal/binance" "github.com/moneycaringcoder/cryptstream-tui/internal/config" @@ -37,14 +36,6 @@ func main() { commandBar := tuikit.NewCommandBar(buildCommands(cryptoView)) - detailOverlay := tuikit.NewDetailOverlay(tuikit.DetailOverlayOpts[ticker.Ticker]{ - Title: "Coin Detail", - Render: func(t ticker.Ticker, w, h int, theme tuikit.Theme) string { - return renderCoinDetail(t, cryptoView, w) - }, - }) - cryptoView.DetailOverlay = detailOverlay - statusLeft := func() string { filterLabel := "" switch cryptoView.FilterMode() { @@ -103,7 +94,6 @@ func main() { 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(), @@ -344,92 +334,6 @@ func buildConfigFields(cfg *config.Config, cv *ui.CryptoView) []tuikit.ConfigFie } } -func renderCoinDetail(t ticker.Ticker, cv *ui.CryptoView, w int) string { - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")) - posStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff88")) - negStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")) - - chgStyle := posStyle - if t.PriceChangePercent < 0 { - chgStyle = negStyle - } - - lines := []string{ - labelStyle.Render("Symbol: ") + valStyle.Render(t.DisplaySymbol()), - labelStyle.Render("Price: ") + valStyle.Render(ticker.FormatPrice(t.LastPrice)), - labelStyle.Render("Change: ") + chgStyle.Render(fmt.Sprintf("%+.2f%%", t.PriceChangePercent)), - labelStyle.Render("High 24h: ") + valStyle.Render(ticker.FormatPrice(t.HighPrice)), - labelStyle.Render("Low 24h: ") + valStyle.Render(ticker.FormatPrice(t.LowPrice)), - labelStyle.Render("Volume: ") + valStyle.Render(ticker.FormatVolume(t.QuoteVolume)), - } - - // Funding rate if available - fr := cv.FundingRate(t.Symbol) - if fr.Rate != 0 { - frStyle := posStyle - if fr.Rate > 0 { - frStyle = negStyle - } - lines = append(lines, labelStyle.Render("Funding: ")+frStyle.Render(fmt.Sprintf("%+.4f%%", fr.Rate))) - } - - // Volume spike - if t.VolumeSpiking { - lines = append(lines, labelStyle.Render("Vol Spike: ")+posStyle.Render(fmt.Sprintf("%.1fx avg", t.VolumeSpikeRatio))) - } - - // Sparkline - hist := cv.PriceHistory(t.Symbol) - if len(hist) > 1 { - lines = append(lines, "") - lines = append(lines, labelStyle.Render("Price Trend:")) - lines = append(lines, renderSparkline(hist, w-2)) - } - - return strings.Join(lines, "\n") -} - -func renderSparkline(data []float64, width int) string { - blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} - min, max := data[0], data[0] - for _, v := range data { - if v < min { - min = v - } - if v > max { - max = v - } - } - - span := max - min - if span == 0 { - span = 1 - } - - // Sample data to fit width - n := len(data) - if n > width { - step := float64(n) / float64(width) - sampled := make([]float64, width) - for i := range sampled { - sampled[i] = data[int(float64(i)*step)] - } - data = sampled - } - - var sb strings.Builder - style := lipgloss.NewStyle().Foreground(lipgloss.Color("#00ccff")) - for _, v := range data { - idx := int((v - min) / span * float64(len(blocks)-1)) - if idx >= len(blocks) { - idx = len(blocks) - 1 - } - sb.WriteRune(blocks[idx]) - } - return style.Render(sb.String()) -} - func buildCommands(cv *ui.CryptoView) []tuikit.Command { return []tuikit.Command{ { diff --git a/go.mod b/go.mod index 7fe786d..03d9db8 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.4 + github.com/moneycaringcoder/tuikit-go v0.7.0 ) require ( @@ -27,8 +27,9 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/rcarmo/go-te v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 89ce4ad..8e1ad6b 100644 --- a/go.sum +++ b/go.sum @@ -32,14 +32,16 @@ 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.5.4 h1:JVd7C7k01NnT5btty6prc6oVTx3LWYJXM1HDtLnZSeU= -github.com/moneycaringcoder/tuikit-go v0.5.4/go.mod h1:NNJ8NSFnHrd4A7dqmb0DO1kA6vtk8jmdCTKYJFL+h50= +github.com/moneycaringcoder/tuikit-go v0.7.0 h1:DEI3CGNhndFfpxYHjwDvd/TkDndQfTbFrixMy/QVj3E= +github.com/moneycaringcoder/tuikit-go v0.7.0/go.mod h1:2P2MPQGh/A+vpCcrgh5Taz+XqMlrpPgAsc2cd4W8ucg= 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= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rcarmo/go-te v0.1.0 h1:BH9Ub+A0AVBY5Q00El4QMVaWAMbycVHgMHQI2Kz8J/o= +github.com/rcarmo/go-te v0.1.0/go.mod h1:cLsrtroxCubS+OHHwH0riB6xeNESfntaHEeI1jPAedk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -50,5 +52,5 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= diff --git a/internal/config/config.go b/internal/config/config.go index fc16191..ccf1f0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -97,14 +97,14 @@ func Default() Config { MaxBackoff: Duration(30 * time.Second), Theme: ThemeConfig{ - Green: "#00ff88", - Red: "#ff4444", - Dim: "#555555", - Separator: "#333333", - Cursor: "#1a1a2e", - Footer: "#666666", - FlashGreen: "#1a3a2a", - FlashRed: "#3a1a1a", + Green: "#22c55e", + Red: "#ef4444", + Dim: "#6b7280", + Separator: "#3b3b3b", + Cursor: "#1e293b", + Footer: "#6b7280", + FlashGreen: "#1a2e1a", + FlashRed: "#2e1a1a", Star: "#ffaa00", }, } diff --git a/internal/news/news.go b/internal/news/news.go deleted file mode 100644 index 5d783a8..0000000 --- a/internal/news/news.go +++ /dev/null @@ -1,68 +0,0 @@ -package news - -import ( - "encoding/json" - "fmt" - "net/http" - "time" -) - -const url = "https://api.coingecko.com/api/v3/news?page=1" - -// Article holds a single news headline. -type Article struct { - Title string - Description string - Author string - Source string - URL string - Time time.Time -} - -type rawResponse struct { - Data []rawArticle `json:"data"` -} - -type rawArticle struct { - Title string `json:"title"` - Description string `json:"description"` - Author string `json:"author"` - NewsSite string `json:"news_site"` - URL string `json:"url"` - CreatedAt int64 `json:"created_at"` -} - -// Fetch retrieves the latest crypto news headlines. -func Fetch(limit int) ([]Article, error) { - resp, err := http.Get(url) //nolint:gosec - if err != nil { - return nil, fmt.Errorf("news fetch: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("news fetch: status %s", resp.Status) - } - - var raw rawResponse - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return nil, fmt.Errorf("news decode: %w", err) - } - - if len(raw.Data) > limit { - raw.Data = raw.Data[:limit] - } - - articles := make([]Article, len(raw.Data)) - for i, r := range raw.Data { - articles[i] = Article{ - Title: r.Title, - Description: r.Description, - Author: r.Author, - Source: r.NewsSite, - URL: r.URL, - Time: time.Unix(r.CreatedAt, 0), - } - } - return articles, nil -} diff --git a/internal/ui/crypto.go b/internal/ui/crypto.go index 4294159..794cc1e 100644 --- a/internal/ui/crypto.go +++ b/internal/ui/crypto.go @@ -15,7 +15,6 @@ import ( "github.com/moneycaringcoder/cryptstream-tui/internal/feargreed" "github.com/moneycaringcoder/cryptstream-tui/internal/funding" "github.com/moneycaringcoder/cryptstream-tui/internal/liquidation" - "github.com/moneycaringcoder/cryptstream-tui/internal/news" "github.com/moneycaringcoder/cryptstream-tui/internal/ticker" "github.com/moneycaringcoder/cryptstream-tui/internal/watchlist" ) @@ -40,9 +39,6 @@ type fngMsg feargreed.Index // defiMsg carries DeFi yield pool data. type defiMsg []defiyields.Pool -// newsMsg carries news articles. -type newsMsg []news.Article - // SortCol identifies which column is used for sorting. type SortCol int @@ -108,13 +104,9 @@ type CryptoView struct { marketStats MarketStats defiPools []defiyields.Pool showDefi bool - newsArticles []news.Article - newsOn bool - newsFlash int focused bool - DetailOverlay *tuikit.DetailOverlay[ticker.Ticker] - Panel *MarketPanel + Panel *MarketPanel table *tuikit.Table defiTable *tuikit.Table @@ -122,7 +114,6 @@ type CryptoView struct { fundingPoller *tuikit.Poller fngPoller *tuikit.Poller defiPoller *tuikit.Poller - newsPoller *tuikit.Poller } // NewCryptoView creates a CryptoView pre-populated with initial ticker data. @@ -138,9 +129,8 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { sortAsc: cfg.SortAscending, filterMode: parseFilterMode(cfg.DefaultFilter), panelOn: parsePanelOn(cfg.PanelLayout), - liqFlash: make(map[string]time.Time), - correlations: make(map[string]float64), - newsOn: true, + liqFlash: make(map[string]time.Time), + correlations: make(map[string]float64), } c.Panel = NewMarketPanel(c.styles) @@ -161,7 +151,7 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { s := c.styles symbol := "" if len(row) > 1 { - symbol = row[1] + "USDT" + symbol = strings.TrimPrefix(row[1], "★ ") + "USDT" } cell := row[colIdx] @@ -209,7 +199,7 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { if len(row) < 2 { return nil } - symbol := row[1] + "USDT" + symbol := strings.TrimPrefix(row[1], "★ ") + "USDT" t := c.tickers[symbol] if time.Now().Before(t.FlashUntil) && t.Flash != ticker.FlashNeutral { st := flashStyle(c.styles, t.Flash) @@ -226,11 +216,22 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { return nil } + detailFunc := func(row tuikit.Row, rowIdx int, width int, theme tuikit.Theme) string { + if len(row) < 2 { + return "" + } + symbol := strings.TrimPrefix(row[1], "★ ") + "USDT" + t := c.tickers[symbol] + return c.renderDetailBar(t, width) + } + c.table = tuikit.NewTable(columns, nil, tuikit.TableOpts{ HeaderStyle: c.styles.Header, CellRenderer: cellRenderer, RowStyler: rowStyler, SortFunc: sortFunc, + DetailFunc: detailFunc, + DetailHeight: 3, }) defiColumns := []tuikit.Column{ @@ -279,7 +280,6 @@ func NewCryptoView(initial []ticker.Ticker, cfg *config.Config) *CryptoView { c.fundingPoller = tuikit.NewPoller(5*time.Minute, func() tea.Cmd { return fetchFundingCmd() }) c.fngPoller = tuikit.NewPoller(30*time.Minute, func() tea.Cmd { return fetchFngCmd() }) c.defiPoller = tuikit.NewPoller(5*time.Minute, func() tea.Cmd { return fetchDefiCmd() }) - c.newsPoller = tuikit.NewPoller(5*time.Minute, func() tea.Cmd { return fetchNewsCmd() }) return c } @@ -290,7 +290,6 @@ func (c *CryptoView) Init() tea.Cmd { fetchFundingCmd(), fetchFngCmd(), fetchDefiCmd(), - fetchNewsCmd(), ) } @@ -319,17 +318,6 @@ func (c *CryptoView) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { c.rebuildDefiRows() } return c, nil - case newsMsg: - if msg != nil { - newArticles := []news.Article(msg) - if len(c.newsArticles) == 0 || (len(newArticles) > 0 && newArticles[0].Title != c.newsArticles[0].Title) { - c.newsFlash = 20 - } - c.newsArticles = newArticles - c.visibleRows = c.tableVisibleRows() - c.clampCursor() - } - return c, nil case liqMsg: l := liquidation.Liq(msg) c.recentLiqs = append([]liquidation.Liq{l}, c.recentLiqs...) @@ -351,12 +339,6 @@ func (c *CryptoView) Update(msg tea.Msg) (tuikit.Component, tea.Cmd) { } func (c *CryptoView) handleTick(msg tuikit.TickMsg) (tuikit.Component, tea.Cmd) { - // Flash countdowns - if c.newsFlash > 0 { - c.newsFlash-- - } - - // Check pollers var cmds []tea.Cmd if cmd := c.fundingPoller.Check(msg); cmd != nil { cmds = append(cmds, cmd) @@ -367,9 +349,6 @@ func (c *CryptoView) handleTick(msg tuikit.TickMsg) (tuikit.Component, tea.Cmd) if cmd := c.defiPoller.Check(msg); cmd != nil { cmds = append(cmds, cmd) } - if cmd := c.newsPoller.Check(msg); cmd != nil { - cmds = append(cmds, cmd) - } if len(cmds) > 0 { return c, tea.Batch(cmds...) } @@ -532,16 +511,6 @@ func (c *CryptoView) handleKey(msg tea.KeyMsg) (tuikit.Component, tea.Cmd) { 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 "enter": - if c.DetailOverlay != nil && c.cursor >= 0 && c.cursor < len(c.sorted) { - c.DetailOverlay.Show(c.sorted[c.cursor]) - return c, tuikit.Consumed() - } case "esc": if c.searchQuery != "" { c.searchQuery = "" @@ -575,20 +544,7 @@ func (c *CryptoView) handleMouse(msg tea.MouseMsg) (tuikit.Component, tea.Cmd) { c.cursor = c.table.CursorIndex() } case tea.MouseButtonLeft: - newsH := c.newsHeight() - 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 { - tuikit.OpenURL(url) - return nil - } - } - } - } else if c.table != nil { + if c.table != nil { c.table.Update(msg) c.cursor = c.table.CursorIndex() } @@ -609,7 +565,6 @@ 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: "n", Label: "Toggle news", Group: "DATA"}, {Key: "d", Label: "DeFi yields", Group: "DATA"}, {Key: "enter", Label: "Coin detail", Group: "DATA"}, {Key: "/", Label: "Search symbols", Group: "SEARCH"}, @@ -998,16 +953,6 @@ func fetchDefiCmd() tea.Cmd { } } -func fetchNewsCmd() tea.Cmd { - return func() tea.Msg { - articles, err := news.Fetch(20) - if err != nil { - return newsMsg(nil) - } - return newsMsg(articles) - } -} - // ConnCmd returns a Cmd that signals connection state. func ConnCmd(connected bool) tea.Cmd { return func() tea.Msg { diff --git a/internal/ui/crypto_tuitest_test.go b/internal/ui/crypto_tuitest_test.go new file mode 100644 index 0000000..84609ae --- /dev/null +++ b/internal/ui/crypto_tuitest_test.go @@ -0,0 +1,278 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + tuikit "github.com/moneycaringcoder/tuikit-go" + "github.com/moneycaringcoder/tuikit-go/tuitest" + "github.com/moneycaringcoder/cryptstream-tui/internal/config" + "github.com/moneycaringcoder/cryptstream-tui/internal/ticker" +) + +// testTickers returns synthetic ticker data for testing. +func testTickers() []ticker.Ticker { + return []ticker.Ticker{ + {Symbol: "BTCUSDT", LastPrice: 67432.10, PriceChangePercent: 2.41, QuoteVolume: 4_200_000_000}, + {Symbol: "ETHUSDT", LastPrice: 3512.50, PriceChangePercent: -1.20, QuoteVolume: 2_100_000_000}, + {Symbol: "SOLUSDT", LastPrice: 142.30, PriceChangePercent: 5.67, QuoteVolume: 800_000_000}, + {Symbol: "DOGEUSDT", LastPrice: 0.1234, PriceChangePercent: -3.45, QuoteVolume: 500_000_000}, + {Symbol: "ADAUSDT", LastPrice: 0.45, PriceChangePercent: 0.12, QuoteVolume: 300_000_000}, + } +} + +// testCryptoApp builds a tuikit.App wrapping a CryptoView with test data. +func testCryptoApp(t testing.TB) (*tuitest.TestModel, *CryptoView) { + t.Helper() + cfg := config.Default() + initial := testTickers() + cv := NewCryptoView(initial, &cfg) + + app := tuikit.NewApp( + tuikit.WithLayout(&tuikit.DualPane{ + Main: cv, + Side: cv.Panel, + SideWidth: 30, + MinMainWidth: 70, + SideRight: true, + ToggleKey: "p", + }), + tuikit.WithStatusBar( + func() string { return " test left" }, + func() string { return "test right " }, + ), + ) + + tm := tuitest.NewTestModel(t, app.Model(), 120, 40) + + // Mark connected so header renders correctly + tm.SendMsg(connMsg{connected: true}) + + return tm, cv +} + +func TestCryptoRendersTable(t *testing.T) { + tm, _ := testCryptoApp(t) + s := tm.Screen() + + tuitest.AssertContains(t, s, "cryptstream") + tuitest.AssertContains(t, s, "BTC") + tuitest.AssertContains(t, s, "ETH") + tuitest.AssertContains(t, s, "SOL") +} + +func TestCryptoRendersHeader(t *testing.T) { + tm, _ := testCryptoApp(t) + s := tm.Screen() + + tuitest.AssertContains(t, s, "cryptstream") + tuitest.AssertContains(t, s, "5 pairs") +} + +func TestCryptoCursorNavigation(t *testing.T) { + tm, cv := testCryptoApp(t) + + // Initial cursor at 0 + if cv.CursorPos() != 0 { + t.Errorf("expected cursor at 0, got %d", cv.CursorPos()) + } + + tm.SendKey("down") + if cv.CursorPos() != 1 { + t.Errorf("expected cursor at 1 after down, got %d", cv.CursorPos()) + } + + tm.SendKey("down") + tm.SendKey("down") + if cv.CursorPos() != 3 { + t.Errorf("expected cursor at 3, got %d", cv.CursorPos()) + } + + tm.SendKey("up") + if cv.CursorPos() != 2 { + t.Errorf("expected cursor at 2 after up, got %d", cv.CursorPos()) + } +} + +func TestCryptoJumpToTopBottom(t *testing.T) { + tm, cv := testCryptoApp(t) + + // Jump to bottom + tm.SendKey("G") + if cv.CursorPos() != cv.VisibleCount()-1 { + t.Errorf("expected cursor at bottom (%d), got %d", cv.VisibleCount()-1, cv.CursorPos()) + } + + // Jump to top + tm.SendKey("g") + if cv.CursorPos() != 0 { + t.Errorf("expected cursor at 0, got %d", cv.CursorPos()) + } +} + +func TestCryptoSortCycle(t *testing.T) { + tm, _ := testCryptoApp(t) + + // Tab cycles sort column + tm.SendKey("tab") + s := tm.Screen() + tuitest.AssertNotEmpty(t, s) + + tm.SendKey("tab") + s = tm.Screen() + tuitest.AssertNotEmpty(t, s) +} + +func TestCryptoFilterCycle(t *testing.T) { + tm, cv := testCryptoApp(t) + + // f cycles filter: all -> gainers -> losers -> all + tm.SendKey("f") + if cv.FilterMode() != FilterGainers { + t.Errorf("expected FilterGainers, got %d", cv.FilterMode()) + } + + tm.SendKey("f") + if cv.FilterMode() != FilterLosers { + t.Errorf("expected FilterLosers, got %d", cv.FilterMode()) + } + + tm.SendKey("f") + if cv.FilterMode() != FilterAll { + t.Errorf("expected FilterAll, got %d", cv.FilterMode()) + } +} + +func TestCryptoSearch(t *testing.T) { + tm, cv := testCryptoApp(t) + + // Enter search mode + tm.SendKey("/") + if !cv.IsSearching() { + t.Error("should be in search mode after /") + } + + // Type search query + tm.Type("btc") + if cv.SearchQuery() != "btc" { + t.Errorf("expected search query 'btc', got '%s'", cv.SearchQuery()) + } + + // Confirm search + tm.SendKey("enter") + if cv.IsSearching() { + t.Error("should exit search mode after enter") + } + + s := tm.Screen() + tuitest.AssertContains(t, s, "BTC") +} + +func TestCryptoSearchCancel(t *testing.T) { + tm, cv := testCryptoApp(t) + + tm.SendKey("/") + tm.Type("xyz") + tm.SendKey("esc") + + if cv.IsSearching() { + t.Error("should exit search mode after esc") + } + if cv.SearchQuery() != "" { + t.Error("search query should be cleared after esc") + } +} + +func TestCryptoStarToggle(t *testing.T) { + _, cv := testCryptoApp(t) + + sym := cv.sorted[0].Symbol + + // Ensure the symbol starts unstarred (real watchlist.json may have it starred) + if cv.Watchlist.IsStarred(sym) { + cv.Watchlist.Toggle(sym) + } + + // Star via key handler + cv.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + + if !cv.Watchlist.IsStarred(sym) { + t.Errorf("expected %s to be starred", sym) + } + + // Unstar + cv.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + if cv.Watchlist.IsStarred(sym) { + t.Errorf("expected %s to be unstarred", sym) + } +} + +func TestCryptoResize(t *testing.T) { + tm, _ := testCryptoApp(t) + + // Small size + tm.SendResize(80, 20) + s := tm.Screen() + tuitest.AssertNotEmpty(t, s) + tuitest.AssertContains(t, s, "cryptstream") + + // Very small + tm.SendResize(50, 15) + s = tm.Screen() + tuitest.AssertNotEmpty(t, s) + + // Large + tm.SendResize(200, 50) + s = tm.Screen() + tuitest.AssertContains(t, s, "BTC") +} + +func TestCryptoEmptyState(t *testing.T) { + cfg := config.Default() + cv := NewCryptoView(nil, &cfg) + + app := tuikit.NewApp( + tuikit.WithLayout(&tuikit.DualPane{ + Main: cv, + Side: cv.Panel, + SideWidth: 30, + MinMainWidth: 70, + SideRight: true, + }), + ) + + tm := tuitest.NewTestModel(t, app.Model(), 120, 40) + s := tm.Screen() + tuitest.AssertNotEmpty(t, s) +} + +func TestCryptoTickerUpdate(t *testing.T) { + tm, cv := testCryptoApp(t) + + // Send a price update + updated := ticker.Ticker{ + Symbol: "BTCUSDT", + LastPrice: 70000.00, + PriceChangePercent: 3.81, + QuoteVolume: 5_000_000_000, + } + tm.SendMsg(tickerMsg(updated)) + + // The ticker map should be updated + btc := cv.tickers["BTCUSDT"] + if btc.LastPrice != 70000.00 { + t.Errorf("expected BTC price 70000, got %f", btc.LastPrice) + } +} + +func TestCryptoSelectedTicker(t *testing.T) { + _, cv := testCryptoApp(t) + + tk, ok := cv.SelectedTicker() + if !ok { + t.Fatal("expected a selected ticker") + } + if tk.Symbol == "" { + t.Error("selected ticker should have a symbol") + } +} diff --git a/internal/ui/defiview.go b/internal/ui/defiview.go index 6460c6e..3e68a30 100644 --- a/internal/ui/defiview.go +++ b/internal/ui/defiview.go @@ -19,18 +19,11 @@ func (c *CryptoView) renderDefiTable(tableW int) string { sb.WriteByte('\n') // Table component handles columns, rows, cursor, scroll - defiH := c.height - c.newsHeight() - 1 // minus title line + defiH := c.height - 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("\n") left := " d close j/k scroll" diff --git a/internal/ui/marketpanel.go b/internal/ui/marketpanel.go index 423a869..8b659b0 100644 --- a/internal/ui/marketpanel.go +++ b/internal/ui/marketpanel.go @@ -163,6 +163,7 @@ func (p *MarketPanel) View() string { // Vol Spikes (collapsible, key: 1) if len(ms.VolSpikes) > 0 { + lines = append(lines, "") lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) arrow := "▾" if p.secVolSpikes.Collapsed { @@ -173,7 +174,7 @@ func (p *MarketPanel) View() string { for _, t := range ms.VolSpikes { sym := padRight(t.DisplaySymbol(), 8) ratio := s.VolSpike.Render(fmt.Sprintf("%.1fx", t.VolumeSpikeRatio)) - lines = append(lines, " "+sym+" "+ratio) + lines = append(lines, " "+sym+" "+ratio) } } } @@ -192,6 +193,7 @@ 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, "") lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) arrow := "▾" if p.secFunding.Collapsed { @@ -204,7 +206,7 @@ func (p *MarketPanel) View() string { fp := pairs[i] sym := padRight(fp.sym, 8) rate := s.Negative.Render(fmt.Sprintf("%+.3f%%", fp.rate)) - lines = append(lines, " "+sym+" "+rate) + lines = append(lines, " "+sym+" "+rate) } for i := len(pairs) - 1; i >= 0 && i >= len(pairs)-show; i-- { fp := pairs[i] @@ -213,13 +215,14 @@ func (p *MarketPanel) View() string { } sym := padRight(fp.sym, 8) rate := s.Positive.Render(fmt.Sprintf("%+.3f%%", fp.rate)) - lines = append(lines, " "+sym+" "+rate) + lines = append(lines, " "+sym+" "+rate) } } } } // Separator + lines = append(lines, "") lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) // Gainers / Losers side by side @@ -255,6 +258,7 @@ func (p *MarketPanel) View() string { // Liquidation feed (collapsible, key: 3) if len(p.recentLiqs) > 0 { + lines = append(lines, "") lines = append(lines, s.PanelBorder.Render(strings.Repeat("─", w))) arrow := "▾" if p.secLiqs.Collapsed { diff --git a/internal/ui/panel.go b/internal/ui/panel.go index 6a773dd..8135d9a 100644 --- a/internal/ui/panel.go +++ b/internal/ui/panel.go @@ -10,17 +10,9 @@ func (c *CryptoView) tableWidth() int { return c.width } -// 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 7 // top separator + 5 headline lines + bottom separator -} - // tableVisibleRows returns the number of visible rows. func (c *CryptoView) tableVisibleRows() int { - rows := c.height - 2 - c.newsHeight() // header + header separator; status bar is handled by App + rows := c.height - 4 // 2-line app header + table header + separator; status bar handled by App if rows < 0 { rows = 0 } diff --git a/internal/ui/view.go b/internal/ui/view.go index 50894e6..5bfe722 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -1,11 +1,12 @@ package ui import ( + "fmt" "strings" - "time" "github.com/charmbracelet/lipgloss" tuikit "github.com/moneycaringcoder/tuikit-go" + "github.com/moneycaringcoder/cryptstream-tui/internal/ticker" ) // View renders the full TUI frame as a string. @@ -23,78 +24,120 @@ func (c *CryptoView) View() string { // 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 - // Table component handles header, rows, cursor, scroll - tableH := c.height - c.newsHeight() + header := c.renderHeader(tableW) + sb.WriteString(header) + sb.WriteByte('\n') + + tableH := c.height - 2 // reserve 2 lines for header c.table.SetSize(tableW, tableH) c.table.SetFocused(c.focused) sb.WriteString(c.table.View()) - // 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() } -// renderNewsBand renders the news ticker band. -func (c *CryptoView) renderNewsBand(s Styles, w int) string { - articles := c.newsArticles - if len(articles) == 0 { - return "" +// renderHeader renders the 2-line header above the table. +func (c *CryptoView) renderHeader(w int) string { + s := c.styles + + // Line 1: title + connection dot + BTC price right-aligned + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#ffffff")) + title := titleStyle.Render("cryptstream") + + dot := s.DotConnected.Render("●") + if !c.connected { + dot = s.DotReconnecting.Render("●") } - lines := make([]string, 0, 6) - lines = append(lines, s.Sep.Render(strings.Repeat("─", w))) + btcStr := "" + if btcPrice := c.BtcPrice(); btcPrice > 0 { + btcStr = "BTC " + ticker.FormatPrice(btcPrice) + } - newsLines := 5 - agoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - srcStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).Bold(true) - dotStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#444444")) - titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#cccccc")) - flashStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).Background(lipgloss.Color("#2a2000")) + // right side: dot + btc price + rightPlain := " ● " + btcStr + rightStyled := dot + " " + lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")).Render(btcStr) - for i := 0; i < newsLines; i++ { - if i >= len(articles) { - lines = append(lines, "") - continue - } - a := articles[i] + leftWidth := lipgloss.Width(title) + rightWidth := len(rightPlain) + gap := w - leftWidth - rightWidth + if gap < 1 { + gap = 1 + } + line1 := title + strings.Repeat(" ", gap) + rightStyled + + // Line 2: dim stats — pair count, filter, sort + filterLabel := "all" + switch c.filterMode { + case FilterGainers: + filterLabel = "gainers" + case FilterLosers: + filterLabel = "losers" + } + + sortLabel := "vol" + switch c.sortCol { + case SortPrice: + sortLabel = "price" + case SortChange: + sortLabel = "change" + case SortSymbol: + sortLabel = "symbol" + case SortCorrelation: + sortLabel = "βbtc" + } + if c.sortAsc { + sortLabel += " ↑" + } else { + sortLabel += " ↓" + } - ago := tuikit.RelativeTime(a.Time, time.Now()) - agoPad := padLeft(ago, 7) - src := a.Source - dot := " · " + statsStr := fmt.Sprintf("%d pairs filter:%s sort:%s", len(c.tickers), filterLabel, sortLabel) + dimStyle := lipgloss.NewStyle().Foreground(s.ColorDim) + line2 := dimStyle.Render(statsStr) - usedPlain := 1 + 7 + 1 + len(src) + len(dot) - remaining := w - usedPlain - 1 - if remaining < 0 { - remaining = 0 - } - title := a.Title - titleRunes := []rune(title) - if len(titleRunes) > remaining && remaining > 1 { - title = string(titleRunes[:remaining-1]) + "…" - } else if len(titleRunes) > remaining { - title = string(titleRunes[:remaining]) - } - title = padRight(title, remaining) + return line1 + "\n" + line2 +} + +// renderDetailBar renders a compact 3-line inline detail for the selected coin. +func (c *CryptoView) renderDetailBar(t ticker.Ticker, w int) string { + s := c.styles + theme := tuikit.DefaultTheme() - if i == 0 && c.newsFlash > 0 { - plainLine := " " + agoPad + " " + src + dot + title + " " - lines = append(lines, flashStyle.Render(plainLine)) - } else { - lines = append(lines, " "+agoStyle.Render(agoPad)+" "+srcStyle.Render(src)+dotStyle.Render(dot)+titleStyle.Render(title)+" ") + divider := tuikit.Divider(w, theme) + + chgStyle := s.Positive + if t.PriceChangePercent < 0 { + chgStyle = s.Negative + } + + line1 := fmt.Sprintf(" %s %s %s %s", + tuikit.Badge(t.DisplaySymbol(), lipgloss.Color("#ffffff"), true), + tuikit.Badge(ticker.FormatPrice(t.LastPrice), lipgloss.Color("#ffffff"), false), + chgStyle.Bold(true).Render(fmt.Sprintf("%+.2f%%", t.PriceChangePercent)), + lipgloss.NewStyle().Foreground(s.ColorDim).Render("vol "+ticker.FormatVolume(t.QuoteVolume)), + ) + + // Line 2: supplementary info + dim := lipgloss.NewStyle().Foreground(s.ColorDim) + var parts []string + parts = append(parts, dim.Render(fmt.Sprintf("H %s L %s", ticker.FormatPrice(t.HighPrice), ticker.FormatPrice(t.LowPrice)))) + if fr := c.fundingRates[t.Symbol]; fr.Rate != 0 { + frStyle := s.Positive + if fr.Rate > 0 { + frStyle = s.Negative } + parts = append(parts, dim.Render("fund ")+frStyle.Render(fmt.Sprintf("%+.3f%%", fr.Rate))) } + if t.VolumeSpiking { + parts = append(parts, s.VolSpike.Render(fmt.Sprintf("%.1fx vol", t.VolumeSpikeRatio))) + } + if corr, ok := c.correlations[t.Symbol]; ok { + parts = append(parts, dim.Render(fmt.Sprintf("βBTC %.2f", corr))) + } + line2 := " " + strings.Join(parts, " ") - return strings.Join(lines, "\n") + return divider + "\n" + line1 + "\n" + line2 } -