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() +}