From 63683d1c8971e9798cc7b81bcf229007bdb2fef0 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 27 Mar 2026 15:50:33 +0000 Subject: [PATCH] Restore simplified color detection But to avoid leaking unread ANSI sequences to the terminal, don't try to switch back to cooked mode before launching TUI. Instead, stay in raw mode until exit. Also, don't use a partially-detected palette. It should be all or nothing. --- internal/ui/app.go | 21 +- internal/ui/dashboard_panel.go | 4 +- internal/ui/dashboard_panel_test.go | 8 +- internal/ui/palette.go | 161 ++++++++------- internal/ui/palette_detect.go | 302 +++++++++++----------------- internal/ui/palette_detect_test.go | 242 +++++++++++----------- internal/ui/palette_test.go | 242 ++++++++++++++-------- internal/ui/styles.go | 2 +- internal/ui/terminal.go | 29 +++ 9 files changed, 548 insertions(+), 463 deletions(-) create mode 100644 internal/ui/terminal.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 468bae5..1b8e556 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -268,18 +268,21 @@ func Run(ns *docker.Namespace, installImageRef string) error { slog.Info("Starting ONCE UI", "version", version.Version) defer func() { slog.Info("Stopping ONCE UI") }() - detected := DetectTerminalColors(100 * time.Millisecond) - ApplyPalette(NewPalette(detected)) + return WithRawTerminal(func() error { + palette := defaultPalette() + palette.Detect(100 * time.Millisecond) + ApplyPalette(palette) - app := NewApp(ns, installImageRef) + app := NewApp(ns, installImageRef) - var opts []tea.ProgramOption - if detected.SupportsTrueColor() { - opts = append(opts, tea.WithColorProfile(colorprofile.TrueColor)) - } + var opts []tea.ProgramOption + if palette.SupportsTrueColor() { + opts = append(opts, tea.WithColorProfile(colorprofile.TrueColor)) + } - _, err := tea.NewProgram(app, opts...).Run() - return err + _, err := tea.NewProgram(app, opts...).Run() + return err + }) } // Private diff --git a/internal/ui/dashboard_panel.go b/internal/ui/dashboard_panel.go index cdeacda..c9b1f8d 100644 --- a/internal/ui/dashboard_panel.go +++ b/internal/ui/dashboard_panel.go @@ -214,7 +214,7 @@ func (p DashboardPanel) renderVisitsCard(width int) string { } func (p DashboardPanel) renderTopTransition(selected bool, width int) string { - if !selected { + if !selected || Colors.BackgroundTint == nil { return strings.Repeat(" ", width) } indicatorChar := lipgloss.NewStyle().Foreground(Colors.Focused).Render("▗") @@ -223,7 +223,7 @@ func (p DashboardPanel) renderTopTransition(selected bool, width int) string { } func (p DashboardPanel) renderBottomTransition(selected bool, width int) string { - if !selected { + if !selected || Colors.BackgroundTint == nil { return strings.Repeat(" ", width) } indicatorChar := lipgloss.NewStyle().Foreground(Colors.Focused).Render("▝") diff --git a/internal/ui/dashboard_panel_test.go b/internal/ui/dashboard_panel_test.go index 2d70c5c..1d59469 100644 --- a/internal/ui/dashboard_panel_test.go +++ b/internal/ui/dashboard_panel_test.go @@ -51,10 +51,12 @@ func TestDashboardPanelSelectedHasIndicator(t *testing.T) { panel := testPanel(true) view := panel.View(true, false, true, 80, DashboardScales{}) - // Selected panels have the indicator character + // Selected panels have the side indicator assert.Contains(t, view, "▐") - assert.Contains(t, view, "▗") - assert.Contains(t, view, "▝") + + // Transition corners only appear with true-color BackgroundTint + assert.NotContains(t, view, "▗") + assert.NotContains(t, view, "▝") } func TestDashboardPanelNotSelectedNoIndicator(t *testing.T) { diff --git a/internal/ui/palette.go b/internal/ui/palette.go index 39c7f16..8f35cb4 100644 --- a/internal/ui/palette.go +++ b/internal/ui/palette.go @@ -2,7 +2,10 @@ package ui import ( "image/color" + "log/slog" "math" + "os" + "time" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -10,14 +13,15 @@ import ( ) // Palette holds all colors used by the UI. ANSI color fields always contain -// BasicColor values so the terminal applies its own theme. Synthesized -// colors (FocusOrange, BackgroundTint, LightText) are true-color RGB. +// BasicColor values so the terminal applies its own theme. The synthesized +// colors (FocusOrange, BackgroundTint, LightText) are true-color RGB when +// the terminal supports it, or ANSI fallbacks otherwise. type Palette struct { // ANSI 16 — always BasicColor values for rendering Black, Red, Green, Yellow, Blue, Magenta, Cyan, White color.Color BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite color.Color - // Synthesized (always true-color RGB) + // Synthesized (true-color RGB when supported, ANSI fallbacks otherwise) FocusOrange color.Color BackgroundTint color.Color LightText color.Color @@ -30,19 +34,29 @@ type Palette struct { Error color.Color // = Red Success color.Color // = Green Warning color.Color // = FocusOrange - // Private: detected RGB samples for calculations - samples [sampleCount]colorful.Color - detected [sampleCount]bool - isDark bool + + isDark bool + trueColor bool + gradientGreen colorful.Color + gradientOrange colorful.Color } // Gradient interpolates between green and FocusOrange in OKLCH. -// t=0 returns green, t=1 returns orange. +// t=0 returns green, t=1 returns orange. When true color is not +// supported, the gradient is clamped to ANSI green/yellow/red. func (p *Palette) Gradient(t float64) color.Color { t = max(0, min(1, t)) - greenSample := p.samples[int(ansi.Green)] - orangeSample, _ := colorful.MakeColor(p.FocusOrange) - return greenSample.BlendOkLch(orangeSample, t) + if !p.trueColor { + switch { + case t < 0.33: + return p.Green + case t < 0.67: + return p.Yellow + default: + return p.Red + } + } + return p.gradientGreen.BlendOkLch(p.gradientOrange, t) } // HealthColor returns the palette color for the given health state. @@ -57,9 +71,31 @@ func (p *Palette) HealthColor(h HealthState) color.Color { } } -// DefaultPalette returns a palette with ANSI BasicColor values and -// fallback-derived synthesized colors. This is the package-init value. -func DefaultPalette() *Palette { +// SupportsTrueColor reports whether the terminal is likely to support +// 24-bit color output. +func (p *Palette) SupportsTrueColor() bool { + return p.trueColor +} + +// Detect queries the terminal for colors and updates the palette +// accordingly. If detection succeeds (all 18 colors received), +// synthesized colors are computed from the detected RGB values. +// If COLORTERM indicates true-color support, synthesized colors +// are computed from fallback samples. Otherwise the ANSI defaults +// from defaultPalette are kept. +func (p *Palette) Detect(timeout time.Duration) { + colors, ok := DetectTerminalColors(timeout) + p.apply(colors, ok) + + if !p.SupportsTrueColor() { + slog.Info("True color output is not enabled") + } +} + +// defaultPalette returns a palette with ANSI BasicColor values for all +// color fields. This is the starting point; Detect may upgrade the +// synthesized colors to true-color RGB. +func defaultPalette() *Palette { p := &Palette{ Black: lipgloss.Black, Red: lipgloss.Red, @@ -77,41 +113,14 @@ func DefaultPalette() *Palette { BrightMagenta: lipgloss.BrightMagenta, BrightCyan: lipgloss.BrightCyan, BrightWhite: lipgloss.BrightWhite, - isDark: true, - } - - p.samples = defaultSamples() - p.synthesize() - return p -} - -// NewPalette creates a palette from detected terminal colors. -func NewPalette(detected DetectedColors) *Palette { - p := DefaultPalette() - - p.detected = detected.Detected - defaults := defaultSamples() - - for i := range sampleCount { - if detected.Detected[i] { - p.samples[i] = detected.Colors[i] - } else { - p.samples[i] = defaults[i] - } - } - - if detected.Detected[sampleBackground] { - l, _, _ := detected.Colors[sampleBackground].OkLch() - p.isDark = l < 0.5 - } - p.Primary = pickPrimary(p) - p.synthesize() + FocusOrange: lipgloss.Red, + LightText: lipgloss.BrightBlack, + Primary: lipgloss.BrightBlue, - if !detected.SupportsTrueColor() { - p.BackgroundTint = nil + isDark: true, } - + p.setAliases() return p } @@ -124,21 +133,42 @@ func ApplyPalette(p *Palette) { // Private -func (p *Palette) synthesize() { - p.FocusOrange = synthesizeOrange(p.samples[int(ansi.Blue)]) - p.BackgroundTint = synthesizeTint(p.samples[sampleBackground]) +func (p *Palette) apply(colors detectedColors, ok bool) { + colorterm := os.Getenv("COLORTERM") + p.trueColor = ok || colorterm == "truecolor" || colorterm == "24bit" + + if !p.trueColor { + return + } + + var samples [sampleCount]colorful.Color + if ok { + samples = colors.Colors + l, _, _ := samples[sampleBackground].OkLch() + p.isDark = l < 0.5 + p.Primary = pickPrimary(samples) + } else { + samples = defaultSamples() + } + + p.FocusOrange = synthesizeOrange(samples[int(ansi.Blue)]) + p.BackgroundTint = synthesizeTint(samples[sampleBackground]) p.LightText = synthesizeLightText( - p.samples[sampleBackground], - p.samples[sampleForeground], - p.samples[int(ansi.Blue)], + samples[sampleBackground], + samples[sampleForeground], + samples[int(ansi.Blue)], ) + p.gradientGreen = samples[int(ansi.Green)] + p.gradientOrange, _ = colorful.MakeColor(p.FocusOrange) + + p.setAliases() +} + +func (p *Palette) setAliases() { p.Border = p.LightText p.Muted = p.LightText p.Focused = p.FocusOrange - if p.Primary == nil { - p.Primary = lipgloss.BrightBlue - } p.Error = p.Red p.Success = p.Green p.Warning = p.FocusOrange @@ -183,22 +213,11 @@ func synthesizeLightText(bg, fg, blue colorful.Color) color.Color { } // pickPrimary chooses the better of Blue and BrightBlue for contrast -// against the background. Falls back to BrightBlue when detection is -// incomplete. -func pickPrimary(p *Palette) color.Color { - bothDetected := p.detected[int(ansi.Blue)] && - p.detected[int(ansi.BrightBlue)] && - p.detected[sampleBackground] - - if !bothDetected { - return lipgloss.BrightBlue - } - - bg := p.samples[sampleBackground] - bgL, _, _ := bg.OkLch() - - blueL, _, _ := p.samples[int(ansi.Blue)].OkLch() - brightL, _, _ := p.samples[int(ansi.BrightBlue)].OkLch() +// against the background. +func pickPrimary(samples [sampleCount]colorful.Color) color.Color { + bgL, _, _ := samples[sampleBackground].OkLch() + blueL, _, _ := samples[int(ansi.Blue)].OkLch() + brightL, _, _ := samples[int(ansi.BrightBlue)].OkLch() if math.Abs(brightL-bgL) >= math.Abs(blueL-bgL) { return lipgloss.BrightBlue diff --git a/internal/ui/palette_detect.go b/internal/ui/palette_detect.go index 683f513..1905df5 100644 --- a/internal/ui/palette_detect.go +++ b/internal/ui/palette_detect.go @@ -1,15 +1,16 @@ package ui import ( + "bufio" + "errors" "fmt" "io" + "log/slog" "os" - "slices" "strconv" "strings" "time" - "github.com/charmbracelet/x/term" "github.com/lucasb-eyer/go-colorful" ) @@ -19,234 +20,167 @@ const ( sampleCount = 18 ) -// DetectedColors holds optional RGB values detected from the terminal. -type DetectedColors struct { +// detectedColors holds optional RGB values detected from the terminal. +type detectedColors struct { Colors [sampleCount]colorful.Color Detected [sampleCount]bool } -// SupportsTrueColor returns true if the terminal responded with RGB color -// values, implying it can also render them. -// SupportsTrueColor reports whether the terminal is likely to support -// 24-bit color output. COLORTERM is the authoritative signal; when it is -// absent (common over SSH), we infer support from successful OSC palette -// detection. -func (d DetectedColors) SupportsTrueColor() bool { - colorterm := os.Getenv("COLORTERM") - if colorterm == "truecolor" || colorterm == "24bit" { - return true +func (d detectedColors) complete() bool { + for _, ok := range d.Detected { + if !ok { + return false + } } - - return slices.Contains(d.Detected[:], true) -} - -type colorResult struct { - index int - color colorful.Color + return true } // DetectTerminalColors queries the terminal for foreground, background, // and all 16 ANSI palette colors via OSC sequences. A DA1 request is -// appended as a sentinel. The function returns after all responses arrive, -// the DA1 sentinel is received, or the timeout expires. -func DetectTerminalColors(timeout time.Duration) DetectedColors { +// appended as a sentinel so the reader knows when the terminal has +// finished responding. Returns ok=true only when all 18 colors were +// successfully detected. +// +// The terminal must already be in raw mode. DetectTerminalColors opens +// its own fd for I/O, so the caller's terminal fd is unaffected. +func DetectTerminalColors(timeout time.Duration) (detectedColors, bool) { tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) if err != nil { - return DetectedColors{} - } - - fd := tty.Fd() - oldState, err := term.MakeRaw(fd) - if err != nil { - tty.Close() - return DetectedColors{} + slog.Error("Failed to open /dev/tty for color detection", "error", err) + return detectedColors{}, false } - defer func() { - term.Restore(fd, oldState) - tty.Close() - }() - - return detectFrom(tty, timeout) + time.AfterFunc(timeout, func() { tty.Close() }) + return detectFrom(tty) } // detectFrom runs the OSC query/response cycle over any ReadWriter. -// It spawns a reader goroutine that blocks on rw.Read; the caller must -// close rw after detectFrom returns to unblock the goroutine. -// Split out from DetectTerminalColors for testability. -func detectFrom(rw io.ReadWriter, timeout time.Duration) DetectedColors { +// It reads responses until DA1 is seen or a read error occurs. The +// caller can close the reader to interrupt. The bool reports whether +// the DA1 sentinel was received (i.e. detection completed). +func detectFrom(rw io.ReadWriter) (detectedColors, bool) { var query strings.Builder - query.WriteString("\x1b]10;?\x07") // foreground - query.WriteString("\x1b]11;?\x07") // background + oscQuery := func(code string) { fmt.Fprintf(&query, "\x1b]%s;?\x07", code) } + csiQuery := func(code string) { fmt.Fprintf(&query, "\x1b[%s", code) } + + oscQuery("10") // foreground + oscQuery("11") // background for i := range 16 { - fmt.Fprintf(&query, "\x1b]4;%d;?\x07", i) + oscQuery(fmt.Sprintf("4;%d", i)) } - query.WriteString("\x1b[c") // DA1 sentinel + csiQuery("c") // DA1 sentinel if _, err := rw.Write([]byte(query.String())); err != nil { - return DetectedColors{} - } - - ch := make(chan colorResult, sampleCount) - done := make(chan struct{}) - - go readOSCResponses(rw, ch, done) - - var detected DetectedColors - timer := time.After(timeout) - received := 0 - - collect := func(r colorResult) { - if r.index >= 0 && r.index < sampleCount { - detected.Colors[r.index] = r.color - detected.Detected[r.index] = true - received++ - } + return detectedColors{}, false } + d := detector{reader: bufio.NewReader(rw)} for { - select { - case r := <-ch: - collect(r) - if received >= sampleCount { - return detected - } - case <-done: - // Drain any remaining buffered results - for { - select { - case r := <-ch: - collect(r) - default: - return detected - } - } - case <-timer: - // Drain any results already buffered - for { - select { - case r := <-ch: - collect(r) - default: - return detected - } + da1, err := d.readNext() + if da1 { + return d.colors, d.colors.complete() + } + if err != nil { + if errors.Is(err, os.ErrClosed) { + slog.Info("Color detection timed out without receiving a response") + } else { + slog.Error("Failed to complete color detection", "error", err) } + return d.colors, false } } } -// readOSCResponses reads from the reader, parses OSC color responses, -// and sends them to ch. Closes done when DA1 is seen or a read error occurs. -func readOSCResponses(r io.Reader, ch chan<- colorResult, done chan<- struct{}) { - defer close(done) +// Private - buf := make([]byte, 4096) - var acc []byte +type detector struct { + colors detectedColors + reader *bufio.Reader +} +// readNext scans for the next escape sequence and dispatches to the +// appropriate reader. Returns true when DA1 is seen. +func (d *detector) readNext() (da1 bool, err error) { for { - n, err := r.Read(buf) - if n > 0 { - acc = append(acc, buf[:n]...) - var da1 bool - acc, da1 = processBuffer(acc, ch) - if da1 { - return - } - } + b, err := d.reader.ReadByte() if err != nil { - return + return false, err } - } -} - -// processBuffer extracts complete OSC responses and DA1 from the -// accumulated buffer. Returns the unprocessed remainder and whether -// DA1 was seen. -func processBuffer(buf []byte, ch chan<- colorResult) ([]byte, bool) { - for { - // Look for DA1 response: CSI ... c - if i := findDA1(buf); i >= 0 { - processOSCSequences(buf[:i], ch) - return nil, true + if b != 0x1b { + continue } - // Look for a complete OSC response terminated by BEL or ST - end := findOSCEnd(buf) - if end < 0 { - return buf, false + b, err = d.reader.ReadByte() + if err != nil { + return false, err } - seq := buf[:end] - buf = buf[end:] - parseOSCColor(seq, ch) - } -} - -// findDA1 finds a DA1 response (ESC [ ... c) in the buffer. -func findDA1(buf []byte) int { - for i := range len(buf) - 2 { - if buf[i] == 0x1b && buf[i+1] == '[' { - for j := i + 2; j < len(buf); j++ { - if buf[j] == 'c' { - return i - } - if buf[j] >= 0x40 && buf[j] <= 0x7e { - break // different CSI final byte - } + switch b { + case ']': + if err := d.readOSC(); err != nil { + return false, err } + return false, nil + case '[': + return d.readCSI() } } - return -1 } -// findOSCEnd finds the end of the first OSC sequence in buf. -// Returns the index after the terminator, or -1 if incomplete. -func findOSCEnd(buf []byte) int { - for i := range len(buf) { - if buf[i] == 0x07 { // BEL - return i + 1 +// readOSC reads an OSC sequence body (after ESC ]) until the BEL or +// ST terminator, then stores any color result. +func (d *detector) readOSC() error { + var body []byte + for { + b, err := d.reader.ReadByte() + if err != nil { + return err } - if buf[i] == 0x1b && i+1 < len(buf) && buf[i+1] == '\\' { // ST - return i + 2 + switch b { + case 0x07: // BEL + d.collectColor(body) + return nil + case 0x1b: // possible ST + next, err := d.reader.ReadByte() + if err != nil { + return err + } + if next == '\\' { + d.collectColor(body) + return nil + } + body = append(body, b, next) + default: + body = append(body, b) } } - return -1 } -func processOSCSequences(buf []byte, ch chan<- colorResult) { - for len(buf) > 0 { - end := findOSCEnd(buf) - if end < 0 { - return +// readCSI reads a CSI sequence (after ESC [) until a final byte. +// Returns true if the final byte is 'c' (DA1). +func (d *detector) readCSI() (da1 bool, err error) { + for { + b, err := d.reader.ReadByte() + if err != nil { + return false, err + } + if b >= 0x40 && b <= 0x7e { + return b == 'c', nil } - parseOSCColor(buf[:end], ch) - buf = buf[end:] } } -// parseOSCColor parses an OSC color response and sends the result. -// Formats: -// -// ESC ] 10 ; rgb:RRRR/GGGG/BBBB BEL (foreground) -// ESC ] 11 ; rgb:RRRR/GGGG/BBBB BEL (background) -// ESC ] 4 ; N ; rgb:RRRR/GGGG/BBBB BEL (ANSI color N) -func parseOSCColor(seq []byte, ch chan<- colorResult) { - s := string(seq) - - if !strings.HasPrefix(s, "\x1b]") { - return - } - s = s[2:] - - // Strip terminator - s = strings.TrimRight(s, "\x07") - s = strings.TrimSuffix(s, "\x1b\\") +// collectColor parses an OSC color response body and stores the result. +// The body is the content between ESC ] and the terminator, e.g. +// "10;rgb:c0c0/caca/f5f5" or "4;2;rgb:5050/fafa/7b7b". +func (d *detector) collectColor(body []byte) { + s := string(body) rgbIdx := strings.Index(s, "rgb:") if rgbIdx < 0 { return } - prefix := s[:rgbIdx] + prefix := strings.TrimRight(s[:rgbIdx], ";") rgbStr := s[rgbIdx:] clr, ok := parseRGB(rgbStr) @@ -254,23 +188,29 @@ func parseOSCColor(seq []byte, ch chan<- colorResult) { return } - prefix = strings.TrimRight(prefix, ";") + index := -1 switch { case prefix == "10": - ch <- colorResult{sampleForeground, clr} + index = sampleForeground case prefix == "11": - ch <- colorResult{sampleBackground, clr} + index = sampleBackground case strings.HasPrefix(prefix, "4;"): - numStr := strings.TrimPrefix(prefix, "4;") - n, err := strconv.Atoi(numStr) + n, err := strconv.Atoi(strings.TrimPrefix(prefix, "4;")) if err == nil && n >= 0 && n < 16 { - ch <- colorResult{n, clr} + index = n } } + + if index >= 0 { + d.colors.Colors[index] = clr + d.colors.Detected[index] = true + } } +// Helpers + // parseRGB parses "rgb:RRRR/GGGG/BBBB" into a colorful.Color. -// Handles both 4-digit (16-bit) and 2-digit (8-bit) hex components. +// Handles 1 to 4 hex digits per component (XParseColor format). func parseRGB(s string) (colorful.Color, bool) { s = strings.TrimPrefix(s, "rgb:") parts := strings.Split(s, "/") @@ -278,8 +218,6 @@ func parseRGB(s string) (colorful.Color, bool) { return colorful.Color{}, false } - // XParseColor allows 1-4 hex digits per component: h, hh, hhh, hhhh. - // Each is scaled to its own range: h/0xF, hh/0xFF, hhh/0xFFF, hhhh/0xFFFF. parse := func(hex string) (float64, bool) { if len(hex) < 1 || len(hex) > 4 { return 0, false diff --git a/internal/ui/palette_detect_test.go b/internal/ui/palette_detect_test.go index 6b16b5a..d9d456b 100644 --- a/internal/ui/palette_detect_test.go +++ b/internal/ui/palette_detect_test.go @@ -1,8 +1,10 @@ package ui import ( + "bufio" "bytes" "io" + "strings" "testing" "time" @@ -54,99 +56,73 @@ func TestParseRGBInvalid(t *testing.T) { assert.False(t, ok) } -func TestParseOSCForeground(t *testing.T) { - ch := make(chan colorResult, 1) - parseOSCColor([]byte("\x1b]10;rgb:c0c0/caca/f5f5\x07"), ch) - require.Len(t, ch, 1) - r := <-ch - assert.Equal(t, sampleForeground, r.index) - assert.InDelta(t, 0.753, r.color.R, 0.01) +func newTestDetector(data string) *detector { + return &detector{reader: bufio.NewReader(strings.NewReader(data))} } -func TestParseOSCBackground(t *testing.T) { - ch := make(chan colorResult, 1) - parseOSCColor([]byte("\x1b]11;rgb:1a1a/1b1b/2626\x07"), ch) - require.Len(t, ch, 1) - r := <-ch - assert.Equal(t, sampleBackground, r.index) - assert.InDelta(t, 0.102, r.color.R, 0.01) -} - -func TestParseOSCANSIColor(t *testing.T) { - ch := make(chan colorResult, 1) - parseOSCColor([]byte("\x1b]4;4;rgb:7a7a/a2a2/f7f7\x07"), ch) - require.Len(t, ch, 1) - r := <-ch - assert.Equal(t, 4, r.index) // blue - assert.InDelta(t, 0.478, r.color.R, 0.01) +func TestReadForegroundColor(t *testing.T) { + d := newTestDetector("\x1b]10;rgb:c0c0/caca/f5f5\x07") + da1, err := d.readNext() + require.NoError(t, err) + assert.False(t, da1) + assert.True(t, d.colors.Detected[sampleForeground]) + assert.InDelta(t, 0.753, d.colors.Colors[sampleForeground].R, 0.01) } -func TestParseOSCWithST(t *testing.T) { - ch := make(chan colorResult, 1) - parseOSCColor([]byte("\x1b]10;rgb:ffff/ffff/ffff\x1b\\"), ch) - require.Len(t, ch, 1) - r := <-ch - assert.Equal(t, sampleForeground, r.index) - assert.InDelta(t, 1.0, r.color.R, 0.001) +func TestReadBackgroundColor(t *testing.T) { + d := newTestDetector("\x1b]11;rgb:1a1a/1b1b/2626\x07") + da1, err := d.readNext() + require.NoError(t, err) + assert.False(t, da1) + assert.True(t, d.colors.Detected[sampleBackground]) + assert.InDelta(t, 0.102, d.colors.Colors[sampleBackground].R, 0.01) } -func TestProcessBufferDA1(t *testing.T) { - ch := make(chan colorResult, sampleCount) - - // Buffer with one OSC response followed by DA1 - buf := []byte("\x1b]10;rgb:ffff/ffff/ffff\x07\x1b[?62;c") - remainder, da1 := processBuffer(buf, ch) - assert.True(t, da1) - assert.Nil(t, remainder) - assert.Len(t, ch, 1) +func TestReadANSIColor(t *testing.T) { + d := newTestDetector("\x1b]4;4;rgb:7a7a/a2a2/f7f7\x07") + da1, err := d.readNext() + require.NoError(t, err) + assert.False(t, da1) + assert.True(t, d.colors.Detected[4]) // blue + assert.InDelta(t, 0.478, d.colors.Colors[4].R, 0.01) } -func TestProcessBufferNoDA1(t *testing.T) { - ch := make(chan colorResult, sampleCount) - - buf := []byte("\x1b]10;rgb:ffff/ffff/ffff\x07") - remainder, da1 := processBuffer(buf, ch) +func TestReadColorWithSTTerminator(t *testing.T) { + d := newTestDetector("\x1b]10;rgb:ffff/ffff/ffff\x1b\\") + da1, err := d.readNext() + require.NoError(t, err) assert.False(t, da1) - assert.Empty(t, remainder) - assert.Len(t, ch, 1) + assert.True(t, d.colors.Detected[sampleForeground]) + assert.InDelta(t, 1.0, d.colors.Colors[sampleForeground].R, 0.001) } -func TestProcessBufferMultipleResponses(t *testing.T) { - ch := make(chan colorResult, sampleCount) +func TestReadDA1(t *testing.T) { + d := newTestDetector("\x1b[?62;c") + da1, err := d.readNext() + require.NoError(t, err) + assert.True(t, da1) +} - buf := []byte( +func TestReadMultipleResponses(t *testing.T) { + d := newTestDetector( "\x1b]10;rgb:c0c0/caca/f5f5\x07" + "\x1b]11;rgb:1a1a/1b1b/2626\x07" + "\x1b]4;2;rgb:5050/fafa/7b7b\x07", ) - remainder, da1 := processBuffer(buf, ch) - assert.False(t, da1) - assert.Empty(t, remainder) - assert.Len(t, ch, 3) - - r := <-ch - assert.Equal(t, sampleForeground, r.index) - r = <-ch - assert.Equal(t, sampleBackground, r.index) - r = <-ch - assert.Equal(t, 2, r.index) -} -func TestFindDA1(t *testing.T) { - assert.Equal(t, 5, findDA1([]byte("hello\x1b[?62;22c"))) - assert.Equal(t, -1, findDA1([]byte("hello\x1b[?62;22m"))) // wrong final byte - assert.Equal(t, 0, findDA1([]byte("\x1b[c"))) - assert.Equal(t, -1, findDA1([]byte("no da1 here"))) -} + for range 3 { + da1, err := d.readNext() + require.NoError(t, err) + assert.False(t, da1) + } -func TestFindOSCEnd(t *testing.T) { - assert.Equal(t, 5, findOSCEnd([]byte("test\x07rest"))) - assert.Equal(t, 6, findOSCEnd([]byte("test\x1b\\rest"))) - assert.Equal(t, -1, findOSCEnd([]byte("incomplete"))) + assert.True(t, d.colors.Detected[sampleForeground]) + assert.True(t, d.colors.Detected[sampleBackground]) + assert.True(t, d.colors.Detected[2]) } func TestDetectedColorsDefaultEmpty(t *testing.T) { - d := DetectedColors{} + d := detectedColors{} for i := range sampleCount { assert.False(t, d.Detected[i]) assert.Equal(t, colorful.Color{}, d.Colors[i]) @@ -170,25 +146,62 @@ func newMockTTY(response []byte) *mockTTY { func (m *mockTTY) Read(p []byte) (int, error) { return m.Reader.Read(p) } func (m *mockTTY) Write(p []byte) (int, error) { return m.Writer.Write(p) } -func TestDetectFromDarkTheme(t *testing.T) { - // Simulate a Tokyo Night-like dark terminal responding with - // foreground, background, blue, green, bright blue, then DA1. - response := "" + +// fullDarkResponse returns a terminal response with all 18 colors +// (Tokyo Night-like dark theme) followed by a DA1 sentinel. +func fullDarkResponse() string { + return "" + "\x1b]10;rgb:c0c0/caca/f5f5\x07" + // foreground "\x1b]11;rgb:1a1a/1b1b/2626\x07" + // background - "\x1b]4;4;rgb:7a7a/a2a2/f7f7\x07" + // blue + "\x1b]4;0;rgb:1515/1616/1e1e\x07" + // black + "\x1b]4;1;rgb:f7f7/7676/8e8e\x07" + // red "\x1b]4;2;rgb:9e9e/cece/6a6a\x07" + // green + "\x1b]4;3;rgb:e0e0/afaf/6868\x07" + // yellow + "\x1b]4;4;rgb:7a7a/a2a2/f7f7\x07" + // blue + "\x1b]4;5;rgb:bbbb/9a9a/f7f7\x07" + // magenta + "\x1b]4;6;rgb:7d7d/cfcf/ffff\x07" + // cyan + "\x1b]4;7;rgb:a9a9/b1b1/d6d6\x07" + // white + "\x1b]4;8;rgb:4141/4444/6868\x07" + // bright black + "\x1b]4;9;rgb:ffff/0000/7c7c\x07" + // bright red + "\x1b]4;10;rgb:7373/daca/a3a3\x07" + // bright green + "\x1b]4;11;rgb:ffff/9e9e/6464\x07" + // bright yellow "\x1b]4;12;rgb:7d7d/cfcf/ffff\x07" + // bright blue + "\x1b]4;13;rgb:bbbb/9a9a/f7f7\x07" + // bright magenta + "\x1b]4;14;rgb:0d0d/b9b9/d7d7\x07" + // bright cyan + "\x1b]4;15;rgb:c0c0/caca/f5f5\x07" + // bright white "\x1b[?62;22c" // DA1 sentinel +} - mock := newMockTTY([]byte(response)) - d := detectFrom(mock, time.Second) +// fullLightResponse returns a terminal response with all 18 colors +// (light theme) followed by a DA1 sentinel. +func fullLightResponse() string { + return "" + + "\x1b]10;rgb:3434/3b3b/5858\x07" + // dark foreground + "\x1b]11;rgb:d5d5/d6d6/dbdb\x07" + // light background + "\x1b]4;0;rgb:0f0f/0f0f/1414\x07" + // black + "\x1b]4;1;rgb:f5f5/2a2a/6565\x07" + // red + "\x1b]4;2;rgb:5858/7c7c/0c0c\x07" + // green + "\x1b]4;3;rgb:8c8c/6c6c/3e3e\x07" + // yellow + "\x1b]4;4;rgb:2e2e/7d7d/e9e9\x07" + // blue + "\x1b]4;5;rgb:9854/f1f1/4343\x07" + // magenta + "\x1b]4;6;rgb:0707/8787/8787\x07" + // cyan + "\x1b]4;7;rgb:6060/6060/7070\x07" + // white + "\x1b]4;8;rgb:a1a1/a6a6/c5c5\x07" + // bright black + "\x1b]4;9;rgb:f5f5/2a2a/6565\x07" + // bright red + "\x1b]4;10;rgb:5858/7c7c/0c0c\x07" + // bright green + "\x1b]4;11;rgb:8c8c/6c6c/3e3e\x07" + // bright yellow + "\x1b]4;12;rgb:2e2e/7d7d/e9e9\x07" + // bright blue + "\x1b]4;13;rgb:9854/f1f1/4343\x07" + // bright magenta + "\x1b]4;14;rgb:0707/8787/8787\x07" + // bright cyan + "\x1b]4;15;rgb:c0c0/caca/f5f5\x07" + // bright white + "\x1b[?62;c" // DA1 sentinel +} - assert.True(t, d.Detected[sampleForeground]) - assert.True(t, d.Detected[sampleBackground]) - assert.True(t, d.Detected[4]) // blue - assert.True(t, d.Detected[2]) // green - assert.True(t, d.Detected[12]) // bright blue +func TestDetectFromDarkTheme(t *testing.T) { + mock := newMockTTY([]byte(fullDarkResponse())) + d, ok := detectFrom(mock) + + assert.True(t, ok) + assert.True(t, d.complete()) assert.InDelta(t, 0.753, d.Colors[sampleForeground].R, 0.01) assert.InDelta(t, 0.102, d.Colors[sampleBackground].R, 0.01) @@ -196,18 +209,11 @@ func TestDetectFromDarkTheme(t *testing.T) { } func TestDetectFromLightTheme(t *testing.T) { - response := "" + - "\x1b]10;rgb:3434/3b3b/5858\x07" + // dark foreground - "\x1b]11;rgb:d5d5/d6d6/dbdb\x07" + // light background - "\x1b]4;4;rgb:2e2e/7d7d/e9e9\x07" + // blue - "\x1b[?62;c" // DA1 + mock := newMockTTY([]byte(fullLightResponse())) + d, ok := detectFrom(mock) - mock := newMockTTY([]byte(response)) - d := detectFrom(mock, time.Second) - - assert.True(t, d.Detected[sampleForeground]) - assert.True(t, d.Detected[sampleBackground]) - assert.True(t, d.Detected[4]) + assert.True(t, ok) + assert.True(t, d.complete()) // Light background should have high lightness bgL, _, _ := d.Colors[sampleBackground].OkLch() @@ -218,44 +224,54 @@ func TestDetectFromLightTheme(t *testing.T) { assert.Less(t, fgL, 0.4) } -func TestDetectFromPartialResponse(t *testing.T) { - // Only background responds before DA1 +func TestDetectFromPartialWithSentinel(t *testing.T) { response := "" + "\x1b]11;rgb:1a1a/1b1b/2626\x07" + "\x1b[?62;c" mock := newMockTTY([]byte(response)) - d := detectFrom(mock, time.Second) + _, ok := detectFrom(mock) - assert.True(t, d.Detected[sampleBackground]) - assert.False(t, d.Detected[sampleForeground]) - assert.False(t, d.Detected[4]) // blue not detected + assert.False(t, ok) +} + +func TestDetectFromPartialNoSentinel(t *testing.T) { + response := "" + + "\x1b]10;rgb:c0c0/caca/f5f5\x07" + + "\x1b]11;rgb:1a1a/1b1b/2626\x07" + + "\x1b]4;4;rgb:7a7a/a2a2/f7f7\x07" + + mock := newMockTTY([]byte(response)) + _, ok := detectFrom(mock) + + assert.False(t, ok) +} + +func TestDetectFromSentinelOnly(t *testing.T) { + mock := newMockTTY([]byte("\x1b[?62;c")) + _, ok := detectFrom(mock) + + assert.False(t, ok) } func TestDetectFromNoOSCSupport(t *testing.T) { - // Terminal that doesn't support OSC queries — reader returns EOF - // immediately (no response data at all). mock := newMockTTY([]byte{}) - d := detectFrom(mock, 50*time.Millisecond) + _, ok := detectFrom(mock) - for i := range sampleCount { - assert.False(t, d.Detected[i]) - } + assert.False(t, ok) } func TestDetectFromTimeout(t *testing.T) { - // Simulate a terminal that hangs — use a pipe that never writes. pr, pw := io.Pipe() defer pw.Close() rw := &mockTTY{Reader: pr, Writer: io.Discard} start := time.Now() - d := detectFrom(rw, 50*time.Millisecond) + time.AfterFunc(50*time.Millisecond, func() { pr.Close() }) + _, ok := detectFrom(rw) elapsed := time.Since(start) - for i := range sampleCount { - assert.False(t, d.Detected[i]) - } - assert.Less(t, elapsed, 200*time.Millisecond, "should respect timeout") + assert.False(t, ok) + assert.Less(t, elapsed, 200*time.Millisecond) } diff --git a/internal/ui/palette_test.go b/internal/ui/palette_test.go index 02c7520..f5dd375 100644 --- a/internal/ui/palette_test.go +++ b/internal/ui/palette_test.go @@ -12,17 +12,22 @@ import ( ) func TestDefaultPaletteHasANSICodes(t *testing.T) { - p := DefaultPalette() + p := defaultPalette() assert.Equal(t, color.Color(lipgloss.Red), p.Red) assert.Equal(t, color.Color(lipgloss.Green), p.Green) assert.Equal(t, color.Color(lipgloss.Blue), p.Blue) assert.Equal(t, color.Color(lipgloss.White), p.White) assert.Equal(t, color.Color(lipgloss.BrightBlack), p.BrightBlack) + + assert.Equal(t, color.Color(lipgloss.Red), p.FocusOrange) + assert.Nil(t, p.BackgroundTint) + assert.Equal(t, color.Color(lipgloss.BrightBlack), p.LightText) + assert.Equal(t, color.Color(lipgloss.BrightBlue), p.Primary) } func TestDefaultPaletteSemanticAliases(t *testing.T) { - p := DefaultPalette() + p := defaultPalette() assert.Equal(t, p.LightText, p.Border) assert.Equal(t, p.LightText, p.Muted) @@ -32,6 +37,22 @@ func TestDefaultPaletteSemanticAliases(t *testing.T) { assert.Equal(t, p.Green, p.Success) } +func TestDefaultPaletteNoNilColors(t *testing.T) { + p := defaultPalette() + + allColors := []color.Color{ + p.Black, p.Red, p.Green, p.Yellow, p.Blue, p.Magenta, p.Cyan, p.White, + p.BrightBlack, p.BrightRed, p.BrightGreen, p.BrightYellow, + p.BrightBlue, p.BrightMagenta, p.BrightCyan, p.BrightWhite, + p.FocusOrange, p.LightText, + p.Border, p.Muted, p.Focused, p.Primary, p.Error, p.Success, p.Warning, + } + for i, c := range allColors { + assert.NotNil(t, c, "color at index %d should not be nil", i) + } + assert.Nil(t, p.BackgroundTint) +} + func TestSynthesizeOrangeDarkTheme(t *testing.T) { // Tokyo Night blue blue, _ := colorful.Hex("#7AA2F7") @@ -136,95 +157,161 @@ func TestSynthesizeLightTextLightTheme(t *testing.T) { assert.Greater(t, c, 0.005, "should have slight blue tint") } -func TestGradientEndpoints(t *testing.T) { - p := DefaultPalette() +func TestApplyNoDetection(t *testing.T) { + t.Setenv("COLORTERM", "") - // t=0 should be close to green sample - g0, ok := colorful.MakeColor(p.Gradient(0)) - require.True(t, ok) - greenSample := p.samples[int(ansi.Green)] - assert.InDelta(t, greenSample.R, g0.R, 0.01) - - // t=1 should be close to orange - g1, ok := colorful.MakeColor(p.Gradient(1)) - require.True(t, ok) - orangeSample, _ := colorful.MakeColor(p.FocusOrange) - assert.InDelta(t, orangeSample.R, g1.R, 0.01) -} + p := defaultPalette() + p.apply(detectedColors{}, false) -func TestGradientMidpoint(t *testing.T) { - p := DefaultPalette() - mid, ok := colorful.MakeColor(p.Gradient(0.5)) - require.True(t, ok) - assert.True(t, mid.IsValid()) + assert.False(t, p.SupportsTrueColor()) + assert.Equal(t, color.Color(lipgloss.Red), p.FocusOrange) + assert.Nil(t, p.BackgroundTint) + assert.Equal(t, color.Color(lipgloss.BrightBlack), p.LightText) + assert.Equal(t, color.Color(lipgloss.BrightBlue), p.Primary) } -func TestNewPaletteWithDetectedColors(t *testing.T) { - var d DetectedColors +func TestApplyCompleteDetection(t *testing.T) { + t.Setenv("COLORTERM", "") - // Simulate a dark theme detection (Tokyo Night) - d.Colors[sampleBackground], _ = colorful.Hex("#1a1b26") - d.Detected[sampleBackground] = true - d.Colors[sampleForeground], _ = colorful.Hex("#c0caf5") - d.Detected[sampleForeground] = true - d.Colors[int(ansi.Blue)], _ = colorful.Hex("#7AA2F7") - d.Detected[int(ansi.Blue)] = true - d.Colors[int(ansi.Green)], _ = colorful.Hex("#9ece6a") - d.Detected[int(ansi.Green)] = true - d.Colors[int(ansi.BrightBlue)], _ = colorful.Hex("#7dcfff") - d.Detected[int(ansi.BrightBlue)] = true + p := defaultPalette() + colors, ok := detectFrom(newMockTTY([]byte(fullDarkResponse()))) + require.True(t, ok) - p := NewPalette(d) + p.apply(colors, true) - // ANSI colors should still be BasicColor for rendering - assert.Equal(t, color.Color(lipgloss.Red), p.Red) - assert.Equal(t, color.Color(lipgloss.Green), p.Green) + assert.True(t, p.SupportsTrueColor()) + assert.True(t, p.isDark) - // Synthesized colors should be true-color RGB + // Synthesized colors should be true-color RGB, not ANSI orangeCf, ok := colorful.MakeColor(p.FocusOrange) require.True(t, ok) _, _, h := orangeCf.OkLch() assert.GreaterOrEqual(t, h, 30.0) assert.LessOrEqual(t, h, 80.0) - assert.True(t, p.isDark) + assert.NotNil(t, p.BackgroundTint) + assert.NotNil(t, p.LightText) +} + +func TestApplyCOLORTERMOverride(t *testing.T) { + t.Setenv("COLORTERM", "truecolor") + + p := defaultPalette() + p.apply(detectedColors{}, false) + + assert.True(t, p.SupportsTrueColor()) + + // Should synthesize from default samples + assert.NotNil(t, p.FocusOrange) + assert.NotNil(t, p.BackgroundTint) + assert.NotNil(t, p.LightText) + + // FocusOrange should be RGB, not the ANSI fallback + _, isBasic := p.FocusOrange.(ansi.BasicColor) + assert.False(t, isBasic, "FocusOrange should be RGB when truecolor is supported") +} + +func TestApplyCOLORTERM24Bit(t *testing.T) { + t.Setenv("COLORTERM", "24bit") + + p := defaultPalette() + p.apply(detectedColors{}, false) + + assert.True(t, p.SupportsTrueColor()) } -func TestNewPaletteWithLightTheme(t *testing.T) { - var d DetectedColors +func TestApplyLightTheme(t *testing.T) { + t.Setenv("COLORTERM", "") - d.Colors[sampleBackground], _ = colorful.Hex("#d5d6db") - d.Detected[sampleBackground] = true - d.Colors[sampleForeground], _ = colorful.Hex("#343b58") - d.Detected[sampleForeground] = true - d.Colors[int(ansi.Blue)], _ = colorful.Hex("#2e7de9") - d.Detected[int(ansi.Blue)] = true + p := defaultPalette() + colors, ok := detectFrom(newMockTTY([]byte(fullLightResponse()))) + require.True(t, ok) - p := NewPalette(d) + p.apply(colors, true) + + assert.True(t, p.SupportsTrueColor()) assert.False(t, p.isDark) +} - // LightText should be different for light theme - ltDark := DefaultPalette().LightText - ltLight := p.LightText - assert.NotEqual(t, ltDark, ltLight, "LightText should differ between dark and light themes") +func TestApplyNoNilColors(t *testing.T) { + t.Setenv("COLORTERM", "") + + checkAllColors := func(t *testing.T, p *Palette) { + t.Helper() + allColors := []color.Color{ + p.Black, p.Red, p.Green, p.Yellow, p.Blue, p.Magenta, p.Cyan, p.White, + p.BrightBlack, p.BrightRed, p.BrightGreen, p.BrightYellow, + p.BrightBlue, p.BrightMagenta, p.BrightCyan, p.BrightWhite, + p.FocusOrange, p.BackgroundTint, p.LightText, + p.Border, p.Muted, p.Focused, p.Primary, p.Error, p.Success, p.Warning, + } + for i, c := range allColors { + assert.NotNil(t, c, "color at index %d should not be nil", i) + } + } + + t.Run("no detection", func(t *testing.T) { + p := defaultPalette() + p.apply(detectedColors{}, false) + assert.Nil(t, p.BackgroundTint) + }) + + t.Run("complete detection", func(t *testing.T) { + p := defaultPalette() + colors, ok := detectFrom(newMockTTY([]byte(fullDarkResponse()))) + require.True(t, ok) + p.apply(colors, true) + checkAllColors(t, p) + }) + + t.Run("COLORTERM override", func(t *testing.T) { + t.Setenv("COLORTERM", "truecolor") + p := defaultPalette() + p.apply(detectedColors{}, false) + checkAllColors(t, p) + }) } -func TestNewPaletteNoDetection(t *testing.T) { - t.Setenv("COLORTERM", "truecolor") - d := DetectedColors{} // nothing detected - p := NewPalette(d) +func TestGradientTrueColor(t *testing.T) { + t.Setenv("COLORTERM", "") - // Should still produce valid colors - assert.NotNil(t, p.FocusOrange) - assert.NotNil(t, p.BackgroundTint) - assert.NotNil(t, p.LightText) + p := defaultPalette() + colors, ok := detectFrom(newMockTTY([]byte(fullDarkResponse()))) + require.True(t, ok) + p.apply(colors, true) - // ANSI codes should be intact - assert.Equal(t, color.Color(lipgloss.Red), p.Red) + // t=0 should be close to green sample + g0, ok := colorful.MakeColor(p.Gradient(0)) + require.True(t, ok) + assert.True(t, g0.IsValid()) + + // t=1 should be close to orange + g1, ok := colorful.MakeColor(p.Gradient(1)) + require.True(t, ok) + orangeSample, _ := colorful.MakeColor(p.FocusOrange) + assert.InDelta(t, orangeSample.R, g1.R, 0.01) + + // Midpoint should be valid + mid, ok := colorful.MakeColor(p.Gradient(0.5)) + require.True(t, ok) + assert.True(t, mid.IsValid()) +} + +func TestGradientANSIFallback(t *testing.T) { + t.Setenv("COLORTERM", "") + + p := defaultPalette() + p.apply(detectedColors{}, false) + + assert.Equal(t, p.Green, p.Gradient(0)) + assert.Equal(t, p.Green, p.Gradient(0.1)) + assert.Equal(t, p.Yellow, p.Gradient(0.5)) + assert.Equal(t, p.Red, p.Gradient(0.9)) + assert.Equal(t, p.Red, p.Gradient(1.0)) } func TestANSISlotsNeverEmitTruecolor(t *testing.T) { - p := DefaultPalette() + p := defaultPalette() // All 16 ANSI slots should be BasicColor, not RGB ansiColors := []color.Color{ @@ -243,40 +330,31 @@ func TestApplyPaletteRebuildStyles(t *testing.T) { original := Colors defer func() { ApplyPalette(original) }() - var d DetectedColors - d.Colors[sampleBackground], _ = colorful.Hex("#d5d6db") - d.Detected[sampleBackground] = true - - p := NewPalette(d) + p := defaultPalette() ApplyPalette(p) assert.Equal(t, p, Colors) } func TestPickPrimaryPrefersBrightBlue(t *testing.T) { - p := DefaultPalette() - // Without detection, default should be BrightBlue + p := defaultPalette() assert.Equal(t, color.Color(lipgloss.BrightBlue), p.Primary) } func TestPickPrimaryWithDetection(t *testing.T) { - var d DetectedColors + var samples [sampleCount]colorful.Color // Dark bg, dim Blue, bright BrightBlue - d.Colors[sampleBackground], _ = colorful.Hex("#1a1b26") - d.Detected[sampleBackground] = true - d.Colors[int(ansi.Blue)], _ = colorful.Hex("#2222aa") // dim blue - d.Detected[int(ansi.Blue)] = true - d.Colors[int(ansi.BrightBlue)], _ = colorful.Hex("#7dcfff") // bright blue - d.Detected[int(ansi.BrightBlue)] = true - - p := NewPalette(d) + samples[sampleBackground], _ = colorful.Hex("#1a1b26") + samples[int(ansi.Blue)], _ = colorful.Hex("#2222aa") // dim blue + samples[int(ansi.BrightBlue)], _ = colorful.Hex("#7dcfff") // bright blue + // BrightBlue has better contrast against dark bg - assert.Equal(t, color.Color(lipgloss.BrightBlue), p.Primary) + assert.Equal(t, color.Color(lipgloss.BrightBlue), pickPrimary(samples)) } func TestHealthStateColors(t *testing.T) { - p := DefaultPalette() + p := defaultPalette() assert.Equal(t, p.Success, healthNormal.Color()) assert.Equal(t, p.Warning, healthWarning.Color()) diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 31fda54..8fb00f6 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -11,7 +11,7 @@ import ( // Colors is the active palette. It is initialized with a default palette // at package init and may be replaced by ApplyPalette after terminal // color detection. -var Colors = DefaultPalette() +var Colors = defaultPalette() type styles struct { Title lipgloss.Style diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go new file mode 100644 index 0000000..97c2a05 --- /dev/null +++ b/internal/ui/terminal.go @@ -0,0 +1,29 @@ +package ui + +import ( + "os" + + "github.com/charmbracelet/x/term" +) + +// WithRawTerminal switches /dev/tty to raw mode, calls fn, and +// restores the terminal to its original mode when fn returns. +func WithRawTerminal(fn func() error) error { + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return err + } + + saved, err := term.MakeRaw(tty.Fd()) + if err != nil { + tty.Close() + return err + } + + defer func() { + term.Restore(tty.Fd(), saved) + tty.Close() + }() + + return fn() +}