From 60feba97912d25e37854370cd5c2c769657b1f3f Mon Sep 17 00:00:00 2001 From: volodymur Date: Fri, 10 Jul 2026 19:28:19 -0700 Subject: [PATCH 1/4] feat: add macOS network monitor and 3 new themes - monitor_darwin.go: macOS bandwidth monitor using `netstat -ib` (build-constrained with //go:build darwin, zero impact on Linux/Windows) - theme.go: macOS transparency fix via runtime.GOOS guard; new themes: Mono, Signal, Ink (all true-black #000000 backgrounds) - card.go: per-theme logo gradient via LogoStops on Theme struct - menu/model/monitor: pass LogoStops to renderHeader --- internal/engine/monitor_darwin.go | 105 ++++++++++++++++++ internal/theme/theme.go | 176 ++++++++++++++++++++++++++++++ internal/ui/card.go | 46 +++----- internal/ui/menu.go | 2 +- internal/ui/model.go | 2 +- internal/ui/monitor.go | 2 +- 6 files changed, 301 insertions(+), 32 deletions(-) create mode 100644 internal/engine/monitor_darwin.go diff --git a/internal/engine/monitor_darwin.go b/internal/engine/monitor_darwin.go new file mode 100644 index 0000000..cf3f98f --- /dev/null +++ b/internal/engine/monitor_darwin.go @@ -0,0 +1,105 @@ +//go:build darwin + +package engine + +import ( + "bytes" + "context" + "os/exec" + "strconv" + "strings" + "time" +) + +func RunMonitor(ctx context.Context, p *Progress, sampleInterval time.Duration) { + p.ServerName = discoverAdaptersDarwin() + sendPhase(p, PhaseConnected) + + prev := map[string]counters{} + + ticker := time.NewTicker(sampleInterval) + defer ticker.Stop() + + var last time.Time + for { + select { + case <-ctx.Done(): + return + case t := <-ticker.C: + if last.IsZero() { + _, _ = readCountersDarwin(prev) + last = t + continue + } + elapsed := t.Sub(last).Seconds() + last = t + if elapsed <= 0 { + continue + } + + total, names := readCountersDarwin(nil) + if len(names) > 0 { + p.ServerName = strings.Join(names, ", ") + } + + var rx, tx uint64 + for iface, c := range total { + base, ok := prev[iface] + if !ok { + prev[iface] = c + continue + } + rx += safeDelta(base.rx, c.rx) + tx += safeDelta(base.tx, c.tx) + prev[iface] = c + } + + _ = sendSample(p, Sample{Phase: PhaseDownload, Rate: float64(rx) / elapsed, At: t}) + _ = sendSample(p, Sample{Phase: PhaseUpload, Rate: float64(tx) / elapsed, At: t}) + } + } +} + +func readCountersDarwin(seen map[string]counters) (map[string]counters, []string) { + total := map[string]counters{} + var names []string + + out, err := exec.Command("netstat", "-ib", "-n").Output() + if err != nil { + return total, names + } + + for _, line := range bytes.Split(out, []byte("\n")) { + s := string(bytes.TrimSpace(line)) + if s == "" || !strings.Contains(s, " 1 { t = 1 } + segment := t * 3.0 + idx := int(segment) + if idx >= 3 { idx = 2; segment = 3.0 } + u := segment - float64(idx) + a, b := stops[idx], stops[idx+1] + return lerpU8(a[0], b[0], u), lerpU8(a[1], b[1], u), lerpU8(a[2], b[2], u) } -// 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 { +// renderHeader draws the RIPTIDE wordmark with a per-theme gradient so the +// logo palette matches the selected colour scheme. +func renderHeader(tagline string, stops [4][3]uint8) string { n := len(logoSrc) lines := make([]string, n) for i, line := range logoSrc { @@ -493,7 +498,7 @@ func renderHeader(tagline string) string { if n > 1 { rowT = float64(i) / float64(n-1) } - r, g, b := logoGradient(rowT) + r, g, b := logoGradientFromStops(rowT, stops) color := lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) lines[i] = lipgloss.NewStyle().Foreground(color).Bold(true).Render(line) } @@ -506,26 +511,9 @@ func renderHeader(tagline string) string { return lipgloss.JoinVertical(lipgloss.Center, logo, "", tag) } -// logoGradient samples the 4-stop logo palette at position t in [0,1] -// (top → bottom), same approach as flow's fourStopLogoGradient. -func logoGradient(t float64) (uint8, uint8, uint8) { - if t < 0 { - t = 0 - } - if t > 1 { - t = 1 - } - segment := t * 3.0 - idx := int(segment) - if idx >= 3 { - idx = 2 - segment = 3.0 - } - u := segment - float64(idx) - a, b := logoStops[idx], logoStops[idx+1] - return lerpU8(a[0], b[0], u), lerpU8(a[1], b[1], u), lerpU8(a[2], b[2], u) -} +// lerpU8 linearly interpolates between two uint8 values. +// lerpU8 linearly interpolates between two uint8 values. func lerpU8(a, b uint8, t float64) uint8 { return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5) } diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 5f9273d..64814e7 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -394,7 +394,7 @@ func (m *menuModel) View() string { if m.compact { header = renderCompactHeader("Choose how you'd like to measure your connection") } else { - header = renderHeader("Choose how you'd like to measure your connection") + header = renderHeader("Choose how you'd like to measure your connection", m.theme.LogoStops) } rule := lipgloss.NewStyle().Foreground(m.theme.Border).Render(strings.Repeat("─", 36)) diff --git a/internal/ui/model.go b/internal/ui/model.go index 2d6cf36..089db20 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -354,7 +354,7 @@ func (m *model) View() string { if m.compact { header = renderCompactHeader("Wonder how speedy your internet is?") } else { - header = renderHeader("Wonder how speedy your internet is?") + header = renderHeader("Wonder how speedy your internet is?", m.theme.LogoStops) } var main string diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index 99a3bba..38366d7 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -217,7 +217,7 @@ func (m *monitorModel) View() string { if m.compact { header = renderCompactHeader("Watching your connection in real time") } else { - header = renderHeader("Watching your connection in real time") + header = renderHeader("Watching your connection in real time", m.theme.LogoStops) } stack := lipgloss.JoinVertical(lipgloss.Center, header, From cc03dedecad70bf94e66941f025be15d116fee4e Mon Sep 17 00:00:00 2001 From: volodymur Date: Fri, 10 Jul 2026 19:36:43 -0700 Subject: [PATCH 2/4] feat: add transparent background toggle and macOS README section - theme.go: atomic.TransparentBg flag skips background fill on macOS - settings.go: new Terminal BG panel (key 4) with enter/space toggle - DB persistence via transparent_bg setting key - README: macOS install instructions + transparency known issue note - README: updated theme count to 14, added mono/signal/ink --- README.md | 24 +++++++++++- internal/theme/theme.go | 9 ++++- internal/ui/settings.go | 81 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index afabc87..8c0e227 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A polished Go TUI with a startup menu, one-shot speed tests, a live bandwidth mo [![terminal](https://img.shields.io/badge/terminal-TUI-39d0d8?style=flat-square)](https://github.com/Foxemsx/riptide) [![Go](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev) [![Linux](https://img.shields.io/badge/Linux-supported-2ea44f?style=flat-square&logo=linux&logoColor=white)](https://github.com/Foxemsx/riptide) +[![macOS](https://img.shields.io/badge/macOS-supported-A2AAAD?style=flat-square&logo=apple&logoColor=white)](https://github.com/Foxemsx/riptide) [![Windows](https://img.shields.io/badge/Windows-supported-0078D6?style=flat-square&logo=windows&logoColor=white)](https://github.com/Foxemsx/riptide) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) @@ -22,7 +23,7 @@ A polished Go TUI with a startup menu, one-shot speed tests, a live bandwidth mo |:---|:---| | **Speed Test** | One-shot download, upload, and ping. Parallel connections, peak rates, timed phases. Auto-saves runs; compare the latest 10. | | **Bandwidth Monitor** | Live view of *real* PC traffic (OS counters only — no test load). Peaks, uptime, pause. | -| **Settings** | Searchable settings: 11 color themes, database reset, uninstall instructions. | +| **Settings** | Searchable settings: 14 color themes, database reset, uninstall instructions. | --- @@ -101,6 +102,24 @@ curl -fsSL https://raw.githubusercontent.com/Foxemsx/riptide/main/uninstall.sh | You can also open **Settings → Uninstall** inside the app for the same instructions. +### macOS + +With Go 1.23+ installed: + +```sh +go build -o /usr/local/bin/riptide github.com/Foxemsx/riptide/cmd/riptide@main +riptide +``` + +Or from source (same as the [From source](#from-source-any-os-with-go-123) section below). + +> **Known issue — transparent terminals (Ghostty, iTerm2)** +> On macOS, some terminal emulators render ~30% of the background as +> transparent due to a Go/Lipgloss interaction with ANSI reset codes. +> The core features (speed test, bandwidth monitor, themes, history) +> all work normally. Use **Settings → Terminal BG** (key `4`) to +> toggle full transparency mode as a workaround. + ### Windows The bash installer does **not** run on Windows. Pick one of: @@ -184,6 +203,9 @@ riptide --theme ocean # start with a palette (also saved as preference) | `cyber` | Neon green · hot magenta | | `ember` | Charcoal fire · molten gold | | `arctic` | Ice blue · clean slate | +| `mono` | True black · arctic white | +| `signal` | True black · rose red | +| `ink` | True black · cold blue | Theme preference is stored in the local database (overridden by `--theme` for that launch). diff --git a/internal/theme/theme.go b/internal/theme/theme.go index d8773f0..eaacc85 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -3,10 +3,16 @@ package theme import ( "runtime" "strings" + "sync/atomic" "github.com/charmbracelet/lipgloss" ) +// TransparentBg controls whether the terminal canvas background fill is +// skipped. When true the host terminal's native background (including +// transparency) shows through. Toggled via Settings → Terminal BG (key 4). +var TransparentBg atomic.Bool + // Theme holds the reskinnable palette for the whole UI. Text colors use // lipgloss.AdaptiveColor so they stay readable on light or dark terminals. type Theme struct { @@ -160,7 +166,8 @@ func PaintScreen(t Theme, width, height int, content string) string { // Wrap every line with AppBg so no cell is left without a background. // runtime.GOOS is a compile-time constant — this branch is eliminated on // Linux/Windows builds with zero binary impact. - if runtime.GOOS == "darwin" { + // Skipped when the user enables transparent mode via Settings. + if runtime.GOOS == "darwin" && !TransparentBg.Load() { lines := strings.Split(content, "\n") for i, line := range lines { lines[i] = lipgloss.NewStyle().Background(t.AppBg).Render(line) diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 4c9e330..361df24 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -19,6 +19,7 @@ const ( focusThemes focusReset focusUninstall + focusTransparent ) type settingsModel struct { @@ -40,6 +41,8 @@ type settingsModel struct { flash string flashOK bool + transparentBg bool + dbPath string runCount int } @@ -77,6 +80,8 @@ func newSettingsModel(theme apptheme.Theme, compact bool, store *db.Store) *sett m.dbPath = store.Path() n, _ := store.CountRuns() m.runCount = n + m.transparentBg = store.GetSetting("transparent_bg", "") == "true" + apptheme.TransparentBg.Store(m.transparentBg) } m.styleSearch() m.refilter() @@ -167,6 +172,10 @@ func (m *settingsModel) showUninstall() bool { return tokenMatch(m.query(), "uninstall", "remove", "install", "linux", "windows", "manual") } +func (m *settingsModel) showTransparent() bool { + return m.query() == "" || tokenMatch(m.query(), "transparent", "terminal", "bg", "background", "glass", "opacity", "trans") +} + func (m *settingsModel) themeList() []int { q := m.query() if q == "" || tokenMatch(q, "theme", "themes", "color", "palette", "look") { @@ -222,6 +231,10 @@ func (m *settingsModel) jumpFromSearch() tea.Cmd { m.focus = focusUninstall return nil } + if m.showTransparent() { + m.focus = focusTransparent + return nil + } m.search.Focus() m.focus = focusSearch return textinput.Blink @@ -309,6 +322,9 @@ func (m *settingsModel) Update(msg tea.Msg) tea.Cmd { m.confirmReset = true m.resetCursor = 0 return nil + case focusTransparent: + m.toggleTransparent() + return nil } case "1": if m.showThemes() { @@ -325,6 +341,11 @@ func (m *settingsModel) Update(msg tea.Msg) tea.Cmd { m.focus = focusUninstall } return nil + case "4": + if m.showTransparent() { + m.focus = focusTransparent + } + return nil case "/": m.focus = focusSearch m.search.Focus() @@ -438,6 +459,18 @@ func (m *settingsModel) applyThemeCmd() tea.Cmd { return func() tea.Msg { return themeChangedMsg{name: name} } } +func (m *settingsModel) toggleTransparent() { + m.transparentBg = !m.transparentBg + apptheme.TransparentBg.Store(m.transparentBg) + if m.store != nil { + if m.transparentBg { + _ = m.store.SetSetting("transparent_bg", "true") + } else { + _ = m.store.SetSetting("transparent_bg", "false") + } + } +} + func (m *settingsModel) moveTheme(delta int) { list := m.themeList() if len(list) == 0 { @@ -465,6 +498,9 @@ func (m *settingsModel) visibleSections() []settingsFocus { if m.showUninstall() { out = append(out, focusUninstall) } + if m.showTransparent() { + out = append(out, focusTransparent) + } return out } @@ -556,7 +592,7 @@ func (m *settingsModel) View() string { hl := lipgloss.NewStyle().Foreground(m.theme.Highlight).Bold(true) mt := lipgloss.NewStyle().Foreground(m.theme.Muted) hint := lipgloss.JoinHorizontal(lipgloss.Center, - hl.Render("1/2/3"), mt.Render(" panels · "), + hl.Render("1/2/3/4"), mt.Render(" panels · "), hl.Render("tab"), mt.Render(" next · "), hl.Render("enter"), mt.Render(" apply · "), hl.Render("esc"), mt.Render(" menu"), @@ -591,6 +627,7 @@ func (m *settingsModel) viewTabs(w int) string { {focusThemes, "Themes", "1", m.theme.AccentDL, m.showThemes()}, {focusReset, "Reset DB", "2", m.theme.AccentUL, m.showReset()}, {focusUninstall, "Uninstall", "3", m.theme.AccentHL, m.showUninstall()}, + {focusTransparent, "Terminal BG", "4", m.theme.AccentLat, m.showTransparent()}, } var chips []string @@ -639,6 +676,8 @@ func (m *settingsModel) viewActivePanel(w int) string { return m.viewReset(w) case m.focus == focusUninstall && m.showUninstall(): return m.viewUninstall(w) + case m.focus == focusTransparent && m.showTransparent(): + return m.viewTransparent(w) case m.focus == focusSearch: // Preview first available panel under search if m.showThemes() { @@ -905,6 +944,46 @@ func (m *settingsModel) viewUninstall(w int) string { Render(strings.Join(lines, "\n")) } +func (m *settingsModel) viewTransparent(w int) string { + var border lipgloss.TerminalColor = m.theme.Border + if m.focus == focusTransparent { + border = m.theme.AccentLat + } + bg := m.theme.MenuIdleFill + ink := lipgloss.Color("#0a0e14") + + title := lipgloss.NewStyle(). + Foreground(ink).Background(m.theme.AccentLat).Bold(true).Padding(0, 1). + Render("Terminal background") + + state := "OPAQUE ●" + stateColor := m.theme.Highlight + if m.transparentBg { + state = "TRANSPARENT ○" + stateColor = m.theme.Foreground + } + + lines := []string{ + title, + "", + lipgloss.NewStyle().Foreground(m.theme.Foreground).Background(bg).Render("Let the terminal background show through."), + lipgloss.NewStyle().Foreground(m.theme.Muted).Background(bg).Render("Works with any theme — toggle anytime with enter or space."), + lipgloss.NewStyle().Foreground(m.theme.Muted).Background(bg).Render("Theme colours remain but the canvas background is skipped."), + "", + lipgloss.NewStyle().Foreground(stateColor).Background(bg).Bold(true).Render(" " + state), + "", + lipgloss.NewStyle().Foreground(m.theme.Muted).Background(bg).Render("enter / space to toggle · saved as preference"), + } + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(border). + Background(bg). + Padding(1, 1). + Width(w). + Render(strings.Join(lines, "\n")) +} + func (m *settingsModel) viewConfirm() string { bg := m.theme.MenuIdleFill ink := lipgloss.Color("#0a0e14") From 3967320c57e4dbba0e08a2b3d35f602d5dc5cfec Mon Sep 17 00:00:00 2001 From: volodymur Date: Fri, 10 Jul 2026 19:39:48 -0700 Subject: [PATCH 3/4] fix: fully transparent background when toggled Skip lipgloss.WithWhitespaceBackground when TransparentBg is true so the outer canvas area also shows the terminal's native background. --- internal/theme/theme.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/theme/theme.go b/internal/theme/theme.go index eaacc85..a1b7205 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -174,11 +174,15 @@ func PaintScreen(t Theme, width, height int, content string) string { } content = strings.Join(lines, "\n") } + var opts []lipgloss.WhitespaceOption + if !TransparentBg.Load() { + opts = append(opts, lipgloss.WithWhitespaceBackground(t.AppBg)) + } return lipgloss.Place( width, height, lipgloss.Center, lipgloss.Center, content, - lipgloss.WithWhitespaceBackground(t.AppBg), + opts..., ) } From a552c8db69e4ded47dee74f3fe008029d56663e8 Mon Sep 17 00:00:00 2001 From: volodymur Date: Fri, 10 Jul 2026 19:46:46 -0700 Subject: [PATCH 4/4] fix: load transparent_bg setting on app startup Call apptheme.TransparentBg.Store in NewApp so the preference takes effect immediately on launch, not only after entering Settings. --- internal/ui/app.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ui/app.go b/internal/ui/app.go index 53e690a..c676c96 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -39,6 +39,7 @@ func NewApp(t apptheme.Theme, compact bool, store *db.Store) *App { menu: newMenuModel(t, compact), } a.reloadHistory() + apptheme.TransparentBg.Store(store.GetSetting("transparent_bg", "") == "true") return a }