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/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, 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")