diff --git a/cmd/riptide/main.go b/cmd/riptide/main.go index 7f96256..47afb8c 100644 --- a/cmd/riptide/main.go +++ b/cmd/riptide/main.go @@ -54,8 +54,10 @@ func main() { t := theme.Get(themeName) lipgloss.SetHasDarkBackground(true) - fmt.Fprint(os.Stdout, "\x1b]11;"+t.HexBG()+"\a") - fmt.Fprint(os.Stdout, "\x1b]10;"+t.HexFG()+"\a") + if !t.IsTransparent() { + fmt.Fprint(os.Stdout, "\x1b]11;"+t.HexBG()+"\a") + fmt.Fprint(os.Stdout, "\x1b]10;"+t.HexFG()+"\a") + } defer func() { fmt.Fprint(os.Stdout, "\x1b]111\a\x1b]110\a") }() diff --git a/internal/theme/theme.go b/internal/theme/theme.go index b738f55..7bd5af7 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -95,6 +95,7 @@ var DefaultTheme = Theme{ // All is every built-in palette, default first. var All = []Theme{ DefaultTheme, + SystemTheme, oceanTheme, midnightTheme, sunsetTheme, @@ -150,16 +151,41 @@ func PaintScreen(t Theme, width, height int, content string) string { if height <= 0 { height = 24 } + + if !t.IsTransparent() { + lines := strings.Split(content, "\n") + bg := lipgloss.NewStyle().Background(t.AppBg) + for i, line := range lines { + lines[i] = bg.Render(line) + } + content = strings.Join(lines, "\n") + + return lipgloss.Place( + width, height, + lipgloss.Center, lipgloss.Center, + content, + lipgloss.WithWhitespaceBackground(t.AppBg), + ) + } return lipgloss.Place( width, height, lipgloss.Center, lipgloss.Center, content, - lipgloss.WithWhitespaceBackground(t.AppBg), ) } -// Hex returns the AppBg as a #rrggbb string for OSC sequences. +// IsTransparent returns true when the theme uses a transparent canvas so +// callers can skip background fills / OSC sequences. +func (t Theme) IsTransparent() bool { + return t.AppBg == "" +} + +// HexBG returns the AppBg as a #rrggbb string for OSC sequences. +// Returns empty string for transparent themes (caller should skip OSC). func (t Theme) HexBG() string { + if t.IsTransparent() { + return "" + } s := string(t.AppBg) if s == "" { return "#191a1b" @@ -168,14 +194,56 @@ func (t Theme) HexBG() string { } // HexFG returns a concrete foreground for OSC sequences. +// Returns empty string for transparent themes (caller should skip OSC). func (t Theme) HexFG() string { + if t.IsTransparent() { + return "" + } if t.AccentDL != "" { - // Prefer a soft off-white derived from foreground dark branch. return "#e8eaed" } return "#e8eaed" } +// SystemTheme uses the terminal's native color palette with transparent +// background. IsTransparent() identifies it by Name=="system". Accent colors +// reference ANSI palette numbers (0-15) so they inherit whatever colors the +// terminal emulator has configured. Background fills are left empty for +// transparency so the terminal canvas shows through. +var SystemTheme = Theme{ + Name: "system", + Display: "System", + Tagline: "Terminal native colors", + + AppBg: lipgloss.Color(""), + + Foreground: lipgloss.AdaptiveColor{}, + Muted: lipgloss.AdaptiveColor{Light: "8", Dark: "8"}, + Border: lipgloss.AdaptiveColor{Light: "7", Dark: "8"}, + Download: lipgloss.AdaptiveColor{Light: "6", Dark: "6"}, + Upload: lipgloss.AdaptiveColor{Light: "3", Dark: "3"}, + Latency: lipgloss.AdaptiveColor{Light: "5", Dark: "5"}, + Highlight: lipgloss.AdaptiveColor{Light: "2", Dark: "2"}, + + GraphDownBottom: lipgloss.Color("6"), + GraphDownTop: lipgloss.Color("6"), + GraphUpBottom: lipgloss.Color("3"), + GraphUpTop: lipgloss.Color("3"), + + MenuAccentFill: lipgloss.AdaptiveColor{}, + + MenuIdleFill: lipgloss.Color(""), + MenuSelectDL: lipgloss.Color(""), + MenuSelectUL: lipgloss.Color(""), + MenuSelectExit: lipgloss.Color(""), + MenuSelectSet: lipgloss.Color(""), + + AccentDL: lipgloss.Color("6"), + AccentUL: lipgloss.Color("3"), + AccentLat: lipgloss.Color("5"), + AccentHL: lipgloss.Color("2"), +} + // --- Built-in palettes --------------------------------------------------- var oceanTheme = Theme{ diff --git a/internal/ui/app.go b/internal/ui/app.go index ea74f63..5a78210 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -106,7 +106,13 @@ func (a *App) enter(s screenID) tea.Cmd { func (a *App) applyTheme(name string) { t := apptheme.Get(name) a.theme = t - fmt.Fprint(os.Stdout, "\x1b]11;"+t.HexBG()+"\a") + if t.IsTransparent() { + fmt.Fprint(os.Stdout, "\x1b]111\a\x1b]110\a") + } else { + fmt.Fprint(os.Stdout, "\x1b]11;"+t.HexBG()+"\a") + fmt.Fprint(os.Stdout, "\x1b]10;"+t.HexFG()+"\a") + } + if a.menu != nil { a.menu.applyTheme(t) } @@ -117,18 +123,26 @@ func (a *App) applyTheme(name string) { a.test.theme = t if a.test.dlGraph != nil { a.test.dlGraph.bottom, a.test.dlGraph.top = t.GraphDownBottom, t.GraphDownTop + a.test.dlGraph.bg = t.AppBg + a.test.dlGraph.grid = gridColor(t, t.GraphDownBottom) } if a.test.ulGraph != nil { a.test.ulGraph.bottom, a.test.ulGraph.top = t.GraphUpBottom, t.GraphUpTop + a.test.ulGraph.bg = t.AppBg + a.test.ulGraph.grid = gridColor(t, t.GraphUpBottom) } } if a.monitor != nil { a.monitor.theme = t if a.monitor.dlGraph != nil { a.monitor.dlGraph.bottom, a.monitor.dlGraph.top = t.GraphDownBottom, t.GraphDownTop + a.monitor.dlGraph.bg = t.AppBg + a.monitor.dlGraph.grid = gridColor(t, t.GraphDownBottom) } if a.monitor.ulGraph != nil { a.monitor.ulGraph.bottom, a.monitor.ulGraph.top = t.GraphUpBottom, t.GraphUpTop + a.monitor.ulGraph.bg = t.AppBg + a.monitor.ulGraph.grid = gridColor(t, t.GraphUpBottom) } } } diff --git a/internal/ui/card.go b/internal/ui/card.go index 85feea7..5772740 100644 --- a/internal/ui/card.go +++ b/internal/ui/card.go @@ -119,6 +119,17 @@ type cardState struct { err error } +// gridColor returns the color used for graph grid/peak lines. +// For transparent themes (terminal native palette) it returns ANSI 8 (gray) +// since hex-based dimming cannot target ANSI palette numbers. For all other +// themes it returns a faint blend of bottom blended toward the canvas. +func gridColor(theme apptheme.Theme, bottom lipgloss.Color) lipgloss.Color { + if theme.IsTransparent() { + return lipgloss.Color("8") + } + return dimColor(bottom, 0.28, theme.AppBg) +} + // newCardState builds the shared state for one run: spinner, channels, context, // and the two history graphs. func newCardState(theme apptheme.Theme, compact bool) *cardState { @@ -142,8 +153,8 @@ func newCardState(theme apptheme.Theme, compact bool) *cardState { events: make(chan tea.Msg, 64), spinner: s, phase: engine.PhaseInit, - dlGraph: newGraph(40, graphHeight, theme.GraphDownBottom, theme.GraphDownTop), - ulGraph: newGraph(40, graphHeight, theme.GraphUpBottom, theme.GraphUpTop), + dlGraph: newGraph(40, graphHeight, theme.GraphDownBottom, theme.GraphDownTop, theme.AppBg, gridColor(theme, theme.GraphDownBottom)), + ulGraph: newGraph(40, graphHeight, theme.GraphUpBottom, theme.GraphUpTop, theme.AppBg, gridColor(theme, theme.GraphUpBottom)), } } @@ -485,22 +496,33 @@ var logoStops = [4][3]uint8{ // renderHeader draws the RIPTIDE wordmark the same way flow draws FLOW: // pre-baked ANSI Shadow art + per-row 4-stop gradient + muted tagline. -func renderHeader(tagline string) string { +func renderHeader(theme apptheme.Theme, tagline string) string { n := len(logoSrc) lines := make([]string, n) for i, line := range logoSrc { - rowT := 0.0 - if n > 1 { - rowT = float64(i) / float64(n-1) + var color lipgloss.Color + if theme.IsTransparent() { + color = theme.AccentDL + } else { + rowT := 0.0 + if n > 1 { + rowT = float64(i) / float64(n-1) + } + r, g, b := logoGradient(rowT) + color = lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) } - r, g, b := logoGradient(rowT) - color := lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) lines[i] = lipgloss.NewStyle().Foreground(color).Bold(true).Render(line) } logo := lipgloss.JoinVertical(lipgloss.Left, lines...) + var tagColor lipgloss.Color + if theme.IsTransparent() { + tagColor = theme.AccentHL + } else { + tagColor = lipgloss.Color("#94a3b8") + } tag := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#94a3b8")). + Foreground(tagColor). Render(tagline) return lipgloss.JoinVertical(lipgloss.Center, logo, "", tag) @@ -532,9 +554,15 @@ func lerpU8(a, b uint8, t float64) uint8 { // renderCompactHeader draws a minimal header: just the tagline text without the // large pixel-art logo. Used when --compact mode is active or toggled with 't'. -func renderCompactHeader(tagline string) string { +func renderCompactHeader(theme apptheme.Theme, tagline string) string { + var color lipgloss.Color + if theme.IsTransparent() { + color = theme.AccentDL + } else { + color = lipgloss.Color("#56d364") + } tag := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#56d364")). + Foreground(color). Bold(true). Render(tagline) return tag diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 6bb5b60..d6149c0 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -488,9 +488,9 @@ func (m *menuModel) View() string { var header string if m.compact { - header = renderCompactHeader("Choose how you'd like to measure your connection") + header = renderCompactHeader(m.theme, "Choose how you'd like to measure your connection") } else { - header = renderHeader("Choose how you'd like to measure your connection") + header = renderHeader(m.theme, "Choose how you'd like to measure your connection") } rule := lipgloss.NewStyle().Foreground(m.theme.Border).Render(strings.Repeat("─", 36)) diff --git a/internal/ui/model.go b/internal/ui/model.go index 1e721f0..1ba625a 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -428,9 +428,9 @@ func (m *model) View() string { var header string if m.compact { - header = renderCompactHeader("Wonder how speedy your internet is?") + header = renderCompactHeader(m.theme, "Wonder how speedy your internet is?") } else { - header = renderHeader("Wonder how speedy your internet is?") + header = renderHeader(m.theme, "Wonder how speedy your internet is?") } var main string diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index d274f8d..b2996d0 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -269,9 +269,9 @@ func (m *monitorModel) View() string { var header string if m.compact { - header = renderCompactHeader("Watching your connection in real time") + header = renderCompactHeader(m.theme, "Watching your connection in real time") } else { - header = renderHeader("Watching your connection in real time") + header = renderHeader(m.theme, "Watching your connection in real time") } stack := lipgloss.JoinVertical(lipgloss.Center, header, "", card) diff --git a/internal/ui/settings.go b/internal/ui/settings.go index fe9bee1..3f8180b 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -601,7 +601,7 @@ func (m *settingsModel) View() string { } // Always compact title — full logo makes the page too tall. - header := renderCompactHeader("Settings · Tune riptide to your taste") + header := renderCompactHeader(m.theme, "Settings · Tune riptide to your taste") search := m.viewSearch(cardW) diff --git a/internal/ui/sparkline.go b/internal/ui/sparkline.go index ae6dd2d..399d7fd 100644 --- a/internal/ui/sparkline.go +++ b/internal/ui/sparkline.go @@ -22,14 +22,18 @@ type graph struct { data []float64 // most-recent-last bottom lipgloss.Color top lipgloss.Color + bg lipgloss.Color // canvas background (for dimming) + grid lipgloss.Color // muted color for grid/peak lines } -func newGraph(width, height int, bottom, top lipgloss.Color) *graph { +func newGraph(width, height int, bottom, top, bg, grid lipgloss.Color) *graph { return &graph{ width: width, height: height, bottom: bottom, top: top, + bg: bg, + grid: grid, } } @@ -132,8 +136,8 @@ func (g *graph) View() string { peakRow = g.height - 1 - fromBottom } - gridStyle := lipgloss.NewStyle().Foreground(dimColor(g.bottom, 0.28)) - peakLineStyle := lipgloss.NewStyle().Foreground(dimColor(g.top, 0.40)) + gridStyle := lipgloss.NewStyle().Foreground(g.grid) + peakLineStyle := lipgloss.NewStyle().Foreground(g.grid) rows := make([]string, g.height) for row := 0; row < g.height; row++ { @@ -153,7 +157,7 @@ func (g *graph) View() string { switch { case lv >= rowBase+8: // Solid body cell. - c := dimColor(rowColor, age) + c := dimColor(rowColor, age, g.bg) if col == peakCol && row == peakRow { c = peakSpark(g.top) } @@ -165,13 +169,13 @@ func (g *graph) View() string { if partial > 8 { partial = 8 } - c := dimColor(rowColor, age) + c := dimColor(rowColor, age, g.bg) if col == peakCol { c = peakSpark(g.top) } else { // Tips are a touch brighter than the body at this row. c = lerpColor(c, g.top, 0.35) - c = dimColor(c, age) + c = dimColor(c, age, g.bg) } b.WriteString(lipgloss.NewStyle().Foreground(c).Render(string(barRamp[partial]))) @@ -194,7 +198,7 @@ func (g *graph) View() string { // renderEmpty draws a stable blank chart with a faint grid so layout never jumps. func (g *graph) renderEmpty() string { - gridStyle := lipgloss.NewStyle().Foreground(dimColor(g.bottom, 0.22)) + gridStyle := lipgloss.NewStyle().Foreground(g.grid) gridRow := make([]bool, g.height) for _, frac := range []float64{0.25, 0.5, 0.75} { fromBottom := int(math.Round(frac * float64(g.height-1))) @@ -233,21 +237,28 @@ func (g *graph) ageFactor(col int) float64 { } // peakSpark returns a hot highlight color for the current peak tip. +// When top is empty (terminal default), returns empty (terminal default). func peakSpark(top lipgloss.Color) lipgloss.Color { + if top == "" { + return "" + } return lerpColor(top, lipgloss.Color("#f0f6fc"), 0.55) } -// dimColor blends c toward near-black by keeping `amount` of the original -// (amount 1 = full color, 0 = almost black). -func dimColor(c lipgloss.Color, amount float64) lipgloss.Color { - if amount >= 0.999 { +// dimColor blends c toward the canvas background bg by keeping `amount` of the +// original (amount 1 = full color, 0 = matches bg). When bg is empty +// (transparent theme) the color is returned unchanged. +func dimColor(c lipgloss.Color, amount float64, bg lipgloss.Color) lipgloss.Color { + if amount >= 0.999 || c == "" { return c } if amount < 0 { amount = 0 } - // Blend toward the app canvas so empty graph cells match the UI chrome. - return lerpColor(lipgloss.Color("#191a1b"), c, amount) + if bg == "" { + return c + } + return lerpColor(bg, c, amount) } // easeOutQuad pushes more of the gradient toward the bright tip. @@ -261,7 +272,9 @@ func easeOutQuad(t float64) float64 { return 1 - (1-t)*(1-t) } -// lerpColor blends from a to b by t in [0,1] in RGB space. +// lerpColor blends from a to b by t in [0,1] in Lab space. When either color +// is empty (terminal default), the non-empty one is returned (or empty if +// both are empty). func lerpColor(a, b lipgloss.Color, t float64) lipgloss.Color { if t < 0 { t = 0 @@ -269,6 +282,15 @@ func lerpColor(a, b lipgloss.Color, t float64) lipgloss.Color { if t > 1 { t = 1 } + if a == "" && b == "" { + return "" + } + if a == "" { + return b + } + if b == "" { + return a + } ca, errA := colorful.Hex(string(a)) cb, errB := colorful.Hex(string(b)) if errA != nil || errB != nil {