detached 46e9f79

feat(theme): keep vev UI legible in ANSI-16 and monochrome

commit
46e9f7916e21af4f27e9061fc07a3697123c580b
author
brice · · 2026-10-03 13:31 UTC
committer
brice · · 2026-10-03 14:14 UTC
parent
b375212
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +543 -21
M docs/terminal.md +2 -1
@@ -13,7 +13,8 @@ Both set `COLORTERM=truecolor` and `TERM_PROGRAM=vev`.
1313
1414 - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`.
1515 - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors.
16-- If your outer terminal does not support truecolor, vev converts colors to 256 and shows a one-time warning.
16+- If your outer terminal does not support truecolor, vev converts colors to the terminal's palette (256, 16, or none) and shows a one-time notice unless the mode was forced with `terminal.colors` or `VEV_COLORS`.
17+- On 16-color terminals vev's own UI (bars, borders, pickers, overlays) drops tinted backgrounds and uses reverse video, bold, faint, and the terminal's own palette slots for its accent. On monochrome terminals it uses only bold, reverse, and faint. Pane content is never recolored by vev's UI.
1718
1819 ### Install `xterm-direct`
1920
@@ -13,7 +13,8 @@ Both set `COLORTERM=truecolor` and `TERM_PROGRAM=vev`.
13 13
14 - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`.14 - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`.
15 - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors.15 - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors.
16-- If your outer terminal does not support truecolor, vev converts colors to 256 and shows a one-time warning.16+- If your outer terminal does not support truecolor, vev converts colors to the terminal's palette (256, 16, or none) and shows a one-time notice unless the mode was forced with `terminal.colors` or `VEV_COLORS`.
17+- On 16-color terminals vev's own UI (bars, borders, pickers, overlays) drops tinted backgrounds and uses reverse video, bold, faint, and the terminal's own palette slots for its accent. On monochrome terminals it uses only bold, reverse, and faint. Pane content is never recolored by vev's UI.
17 18
18 ### Install `xterm-direct`19 ### Install `xterm-direct`
19 20
M internal/usecase/client/client_toast.go +36 -2
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 renderer "github.com/bnema/vev-vt"
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
910 "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/usecase/picker"
1112 "github.com/bnema/vev/internal/usecase/ui"
@@ -38,6 +39,39 @@ func toastBorderSGR(severity domain.NoticeSeverity) string {
3839 }
3940 }
4041
42+// toastBorderSGRFor is toastBorderSGR for one renderer color profile. The
43+// toast bypasses the renderer, so it must follow the profile itself: 16-color
44+// terminals get basic red/yellow, monochrome terminals get bold for problems.
45+// TrueColor and 256-color output is unchanged.
46+func toastBorderSGRFor(severity domain.NoticeSeverity, profile ansirenderer.ColorProfile) string {
47+ switch profile {
48+ case ansirenderer.ColorProfileANSI16:
49+ switch severity {
50+ case domain.NoticeError:
51+ return "\x1b[31m"
52+ case domain.NoticeWarn:
53+ return "\x1b[33m"
54+ }
55+ return ""
56+ case ansirenderer.ColorProfileMonochrome:
57+ if severity == domain.NoticeError || severity == domain.NoticeWarn {
58+ return "\x1b[1m"
59+ }
60+ return ""
61+ default:
62+ return toastBorderSGR(severity)
63+ }
64+}
65+
66+// toastBorderReset undoes toastBorderSGR's attribute: default foreground for
67+// colors, normal intensity for the monochrome bold border.
68+func toastBorderReset(borderSGR string) string {
69+ if borderSGR == "\x1b[1m" {
70+ return "\x1b[22m"
71+ }
72+ return "\x1b[39m"
73+}
74+
4175 // clientToastLines renders the toast box as one string per row, exactly
4276 // bounds.Width columns wide.
4377 func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
@@ -60,7 +94,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
6094 if border {
6195 b.WriteString(borderSGR)
6296 } else {
63- b.WriteString("\x1b[39m")
97+ b.WriteString(toastBorderReset(borderSGR))
6498 }
6599 colored = border
66100 }
@@ -76,7 +110,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
76110 b.WriteRune(cell.Rune)
77111 }
78112 if colored {
79- b.WriteString("\x1b[39m")
113+ b.WriteString(toastBorderReset(borderSGR))
80114 }
81115 lines[y] = b.String()
82116 }
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
11 "github.com/bnema/vev/internal/usecase/ui"12 "github.com/bnema/vev/internal/usecase/ui"
@@ -38,6 +39,39 @@ func toastBorderSGR(severity domain.NoticeSeverity) string {
38 }39 }
39 }40 }
40 41
42+// toastBorderSGRFor is toastBorderSGR for one renderer color profile. The
43+// toast bypasses the renderer, so it must follow the profile itself: 16-color
44+// terminals get basic red/yellow, monochrome terminals get bold for problems.
45+// TrueColor and 256-color output is unchanged.
46+func toastBorderSGRFor(severity domain.NoticeSeverity, profile ansirenderer.ColorProfile) string {
47+ switch profile {
48+ case ansirenderer.ColorProfileANSI16:
49+ switch severity {
50+ case domain.NoticeError:
51+ return "\x1b[31m"
52+ case domain.NoticeWarn:
53+ return "\x1b[33m"
54+ }
55+ return ""
56+ case ansirenderer.ColorProfileMonochrome:
57+ if severity == domain.NoticeError || severity == domain.NoticeWarn {
58+ return "\x1b[1m"
59+ }
60+ return ""
61+ default:
62+ return toastBorderSGR(severity)
63+ }
64+}
65+
66+// toastBorderReset undoes toastBorderSGR's attribute: default foreground for
67+// colors, normal intensity for the monochrome bold border.
68+func toastBorderReset(borderSGR string) string {
69+ if borderSGR == "\x1b[1m" {
70+ return "\x1b[22m"
71+ }
72+ return "\x1b[39m"
73+}
74+
41 // clientToastLines renders the toast box as one string per row, exactly75 // clientToastLines renders the toast box as one string per row, exactly
42 // bounds.Width columns wide.76 // bounds.Width columns wide.
43 func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {77 func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
@@ -60,7 +94,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
60 if border {94 if border {
61 b.WriteString(borderSGR)95 b.WriteString(borderSGR)
62 } else {96 } else {
63- b.WriteString("\x1b[39m")97+ b.WriteString(toastBorderReset(borderSGR))
64 }98 }
65 colored = border99 colored = border
66 }100 }
@@ -76,7 +110,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
76 b.WriteRune(cell.Rune)110 b.WriteRune(cell.Rune)
77 }111 }
78 if colored {112 if colored {
79- b.WriteString("\x1b[39m")113+ b.WriteString(toastBorderReset(borderSGR))
80 }114 }
81 lines[y] = b.String()115 lines[y] = b.String()
82 }116 }
M internal/usecase/client/picker_controller.go +1 -1
@@ -498,7 +498,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
498498 }
499499 buffer := bytes.NewBuffer(blanks)
500500 for i, rect := range p.noticeBounds {
501- _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity)))
501+ _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGRFor(toasts[i].Severity, p.renderer.profile)))
502502 }
503503 if buffer.Len() == 0 {
504504 return nil
@@ -498,7 +498,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
498 }498 }
499 buffer := bytes.NewBuffer(blanks)499 buffer := bytes.NewBuffer(blanks)
500 for i, rect := range p.noticeBounds {500 for i, rect := range p.noticeBounds {
501- _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity)))501+ _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGRFor(toasts[i].Severity, p.renderer.profile)))
502 }502 }
503 if buffer.Len() == 0 {503 if buffer.Len() == 0 {
504 return nil504 return nil
A internal/usecase/client/reduced_color_test.go +53 -0
@@ -0,0 +1,53 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ ansirenderer "github.com/bnema/vev-vt/ansi"
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestPickerStaysLegibleInReducedColor(t *testing.T) {
13+ loop := pickerLoopFixture(t)
14+ size := domain.Size{Cols: 100, Rows: 30}
15+
16+ ansi16 := string(newPickerRenderer(ansirenderer.ColorProfileANSI16).render(loop, size, emptyPickerPreview()))
17+ require.NotRegexp(t, `(?:38|48|58);[25];`, ansi16, "ansi16 picker emitted extended colors")
18+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, ansi16, "the selected row must stay reverse video")
19+
20+ mono := string(newPickerRenderer(ansirenderer.ColorProfileMonochrome).render(loop, size, emptyPickerPreview()))
21+ require.NotRegexp(t, `\x1b\[[0-9;:]*(?:38|48|58|3[0-7]|4[0-7]|9[0-7]|10[0-7])[;:m]`, mono, "monochrome picker emitted a color")
22+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, mono, "the selected row must stay reverse video")
23+ require.Contains(t, mono, "work")
24+}
25+
26+func TestToastBorderFollowsColorProfile(t *testing.T) {
27+ tests := []struct {
28+ name string
29+ profile ansirenderer.ColorProfile
30+ severity domain.NoticeSeverity
31+ want string
32+ }{
33+ {name: "truecolor error", profile: ansirenderer.ColorProfileTrueColor, severity: domain.NoticeError, want: "\x1b[38;5;167m"},
34+ {name: "256 warn", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeWarn, want: "\x1b[38;5;179m"},
35+ {name: "256 info", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeInfo},
36+ {name: "16 error", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeError, want: "\x1b[31m"},
37+ {name: "16 warn", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeWarn, want: "\x1b[33m"},
38+ {name: "16 info", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeInfo},
39+ {name: "mono error", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeError, want: "\x1b[1m"},
40+ {name: "mono warn", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeWarn, want: "\x1b[1m"},
41+ {name: "mono info", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeInfo},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ require.Equal(t, tt.want, toastBorderSGRFor(tt.severity, tt.profile))
46+ })
47+ }
48+ // Monochrome borders are closed with normal intensity, not default color.
49+ bounds := domain.Rect{Width: 12, Height: 3}
50+ lines := clientToastLines(bounds, "hi", toastBorderSGRFor(domain.NoticeError, ansirenderer.ColorProfileMonochrome))
51+ require.Contains(t, lines[0], "\x1b[22m")
52+ require.NotContains(t, lines[0], "\x1b[39m")
53+}
@@ -0,0 +1,53 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ ansirenderer "github.com/bnema/vev-vt/ansi"
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestPickerStaysLegibleInReducedColor(t *testing.T) {
13+ loop := pickerLoopFixture(t)
14+ size := domain.Size{Cols: 100, Rows: 30}
15+
16+ ansi16 := string(newPickerRenderer(ansirenderer.ColorProfileANSI16).render(loop, size, emptyPickerPreview()))
17+ require.NotRegexp(t, `(?:38|48|58);[25];`, ansi16, "ansi16 picker emitted extended colors")
18+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, ansi16, "the selected row must stay reverse video")
19+
20+ mono := string(newPickerRenderer(ansirenderer.ColorProfileMonochrome).render(loop, size, emptyPickerPreview()))
21+ require.NotRegexp(t, `\x1b\[[0-9;:]*(?:38|48|58|3[0-7]|4[0-7]|9[0-7]|10[0-7])[;:m]`, mono, "monochrome picker emitted a color")
22+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, mono, "the selected row must stay reverse video")
23+ require.Contains(t, mono, "work")
24+}
25+
26+func TestToastBorderFollowsColorProfile(t *testing.T) {
27+ tests := []struct {
28+ name string
29+ profile ansirenderer.ColorProfile
30+ severity domain.NoticeSeverity
31+ want string
32+ }{
33+ {name: "truecolor error", profile: ansirenderer.ColorProfileTrueColor, severity: domain.NoticeError, want: "\x1b[38;5;167m"},
34+ {name: "256 warn", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeWarn, want: "\x1b[38;5;179m"},
35+ {name: "256 info", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeInfo},
36+ {name: "16 error", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeError, want: "\x1b[31m"},
37+ {name: "16 warn", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeWarn, want: "\x1b[33m"},
38+ {name: "16 info", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeInfo},
39+ {name: "mono error", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeError, want: "\x1b[1m"},
40+ {name: "mono warn", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeWarn, want: "\x1b[1m"},
41+ {name: "mono info", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeInfo},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ require.Equal(t, tt.want, toastBorderSGRFor(tt.severity, tt.profile))
46+ })
47+ }
48+ // Monochrome borders are closed with normal intensity, not default color.
49+ bounds := domain.Rect{Width: 12, Height: 3}
50+ lines := clientToastLines(bounds, "hi", toastBorderSGRFor(domain.NoticeError, ansirenderer.ColorProfileMonochrome))
51+ require.Contains(t, lines[0], "\x1b[22m")
52+ require.NotContains(t, lines[0], "\x1b[39m")
53+}
M internal/usecase/daemon/client.go +1 -1
@@ -260,7 +260,7 @@ func (ac *attachedClient) getAppliedTheme() appliedTheme {
260260 if applied.Generation == 0 {
261261 // An unattached client has no terminal report yet. Reuse the static
262262 // neutral cache rather than resolving from a render path.
263- applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles}
263+ applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)}
264264 }
265265 return applied
266266 }
@@ -260,7 +260,7 @@ func (ac *attachedClient) getAppliedTheme() appliedTheme {
260 if applied.Generation == 0 {260 if applied.Generation == 0 {
261 // An unattached client has no terminal report yet. Reuse the static261 // An unattached client has no terminal report yet. Reuse the static
262 // neutral cache rather than resolving from a render path.262 // neutral cache rather than resolving from a render path.
263- applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles}263+ applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)}
264 }264 }
265 return applied265 return applied
266 }266 }
M internal/usecase/daemon/config.go +7 -2
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
1314 "github.com/bnema/vev/internal/usecase/command"
1415 "github.com/bnema/vev/internal/usecase/keys"
1516 "github.com/bnema/vev/internal/usecase/theme"
@@ -344,10 +345,14 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
344345 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
345346 }
346347
347-func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
348+// resolveAppliedTheme resolves chrome for one attachment's color capability;
349+// ANSI16 and monochrome attachments get attribute-based chrome, so the result
350+// is never shared between attachments of different modes.
351+func (d *Daemon) resolveAppliedTheme(raw theme.Theme, color terminalcap.ColorCapabilities) appliedTheme {
348352 config := d.currentThemeConfig()
349353 effective := effectiveThemeForConfig(raw, config)
350- return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}
354+ resolved := theme.ResolveForColor(effective, config.accent, color)
355+ return appliedTheme{Raw: resolved.Theme, Resolved: resolved}
351356 }
352357
353358 func (d *Daemon) reapplyThemeAllSessions() {
@@ -10,6 +10,7 @@ import (
10 "time"10 "time"
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
13 "github.com/bnema/vev/internal/usecase/command"14 "github.com/bnema/vev/internal/usecase/command"
14 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
15 "github.com/bnema/vev/internal/usecase/theme"16 "github.com/bnema/vev/internal/usecase/theme"
@@ -344,10 +345,14 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
344 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())345 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
345 }346 }
346 347
347-func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {348+// resolveAppliedTheme resolves chrome for one attachment's color capability;
349+// ANSI16 and monochrome attachments get attribute-based chrome, so the result
350+// is never shared between attachments of different modes.
351+func (d *Daemon) resolveAppliedTheme(raw theme.Theme, color terminalcap.ColorCapabilities) appliedTheme {
348 config := d.currentThemeConfig()352 config := d.currentThemeConfig()
349 effective := effectiveThemeForConfig(raw, config)353 effective := effectiveThemeForConfig(raw, config)
350- return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}354+ resolved := theme.ResolveForColor(effective, config.accent, color)
355+ return appliedTheme{Raw: resolved.Theme, Resolved: resolved}
351 }356 }
352 357
353 func (d *Daemon) reapplyThemeAllSessions() {358 func (d *Daemon) reapplyThemeAllSessions() {
M internal/usecase/daemon/palette_test.go +2 -1
@@ -10,6 +10,7 @@ import (
1010
1111 renderer "github.com/bnema/vev-vt"
1212 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
1314 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1415 "github.com/bnema/vev/internal/protocol"
1516 "github.com/bnema/vev/internal/usecase/command"
@@ -163,7 +164,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
163164 t.Run(tt.name, func(t *testing.T) {
164165 d := newTestDaemon(t, nil, stubClock{})
165166 d.ApplyConfig(tt.config)
166- applied := d.resolveAppliedTheme(tt.raw)
167+ applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{})
167168 model := palette.New(palette.CommandResults([]command.Command{
168169 {Code: "ONE", Name: "One", Desc: "first description"},
169170 {Code: "TWO", Name: "Two", Desc: "second description"},
@@ -10,6 +10,7 @@ import (
10 10
11 renderer "github.com/bnema/vev-vt"11 renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/usecase/command"16 "github.com/bnema/vev/internal/usecase/command"
@@ -163,7 +164,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
163 t.Run(tt.name, func(t *testing.T) {164 t.Run(tt.name, func(t *testing.T) {
164 d := newTestDaemon(t, nil, stubClock{})165 d := newTestDaemon(t, nil, stubClock{})
165 d.ApplyConfig(tt.config)166 d.ApplyConfig(tt.config)
166- applied := d.resolveAppliedTheme(tt.raw)167+ applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{})
167 model := palette.New(palette.CommandResults([]command.Command{168 model := palette.New(palette.CommandResults([]command.Command{
168 {Code: "ONE", Name: "One", Desc: "first description"},169 {Code: "ONE", Name: "One", Desc: "first description"},
169 {Code: "TWO", Name: "Two", Desc: "second description"},170 {Code: "TWO", Name: "Two", Desc: "second description"},
A internal/usecase/daemon/reduced_color_test.go +242 -0
@@ -0,0 +1,242 @@
1+package daemon
2+
3+import (
4+ "regexp"
5+ "strconv"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ renderer "github.com/bnema/vev-vt"
12+ ansirenderer "github.com/bnema/vev-vt/ansi"
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/terminalcap"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/usecase/colorprofile"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+ themeui "github.com/bnema/vev/internal/usecase/theme"
19+ "github.com/bnema/vev/internal/usecase/ui"
20+)
21+
22+var sgrSequence = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
23+
24+// colorParams returns every color-bearing SGR parameter group in out, e.g.
25+// "31", "48;2;1;2;3", "58;5;9". Attribute parameters (0, 1, 2, 3, 4, 7...) are
26+// not reported.
27+func colorParams(out []byte) []string {
28+ var found []string
29+ for _, m := range sgrSequence.FindAllStringSubmatch(string(out), -1) {
30+ params := strings.Split(m[1], ";")
31+ for i := 0; i < len(params); i++ {
32+ n, err := strconv.Atoi(params[i])
33+ if err != nil {
34+ continue
35+ }
36+ switch {
37+ case n == 38 || n == 48 || n == 58:
38+ end := len(params)
39+ if i+1 < len(params) && params[i+1] == "2" {
40+ end = min(i+5, len(params))
41+ } else if i+1 < len(params) {
42+ end = min(i+3, len(params))
43+ }
44+ found = append(found, strings.Join(params[i:end], ";"))
45+ i = end - 1
46+ case n >= 30 && n <= 37, n >= 40 && n <= 47, n >= 90 && n <= 97, n >= 100 && n <= 107:
47+ found = append(found, params[i])
48+ }
49+ }
50+ }
51+ return found
52+}
53+
54+func reducedTestTheme() themeui.Theme {
55+ palette := [16]renderer.RGB{}
56+ palette[4] = renderer.RGB{R: 60, G: 120, B: 220}
57+ palette[12] = palette[4]
58+ return themeui.Theme{
59+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
60+ HasFG: true, HasBG: true, Known: true, UsePalette: true, SchemeKnown: true,
61+ Palette: palette, PaletteKnown: 1<<4 | 1<<12,
62+ }
63+}
64+
65+// reducedChromeFrame composes a representative frame: two tabs (second
66+// active) with an attention bell, a split with the left pane focused, a
67+// bottom bar with MRU entries, a toast, and a focused palette modal with a
68+// selected row. rawTheme is what the attachment reported.
69+func reducedChromeFrame(t *testing.T, color terminalcap.ColorCapabilities) (composedRenderFrame, themeui.ResolvedTheme) {
70+ t.Helper()
71+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
72+ state := cachedSplitState("reduced", "left", layout.Horizontal, resolved.Theme)
73+ state.styles = resolved.Styles
74+ state.bars = barState{
75+ status: statusSnapshot{session: "work", tabs: []statusTab{{name: "one", paneTitle: "sh"}, {name: "two", paneTitle: "vim", active: true, attention: true}}},
76+ attentionFrame: pulseFrameCount / 2,
77+ mru: []recentRouteDisplay{{name: "alp"}, {name: "beta"}},
78+ theme: resolved.Theme,
79+ }
80+ state.panes[0].title, state.panes[1].title = "left", "right"
81+ state.overlays.notices = []domain.Notification{{Code: domain.NoticeUser, Severity: domain.NoticeWarn, Message: "careful", Count: 1}}
82+ inner := renderer.NewFrame(18, 3)
83+ for x, r := range "> sel" {
84+ inner.Set(x, 0, renderer.Cell{Rune: r, Style: resolved.Styles.PickerSelection})
85+ }
86+ state.overlays.paletteActive = true
87+ state.overlays.palette = capturedModal{
88+ active: true, title: "Palette", focused: true, inner: inner,
89+ presentation: ui.Presentation{Bounds: domain.Rect{X: 10, Y: 2, Width: 20, Height: 5}, Inner: domain.Rect{X: 11, Y: 3, Width: 18, Height: 3}, Borders: ui.BorderAll},
90+ }
91+ return composeFrame(state, composeCacheInput{}), resolved
92+}
93+
94+// rowIndex returns the x of the first cell of text on row y.
95+func rowIndex(frame renderer.Frame, y int, text string) int {
96+ var row strings.Builder
97+ for x := range frame.Width {
98+ r := frame.At(x, y).Rune
99+ if r == 0 {
100+ r = ' '
101+ }
102+ row.WriteRune(r)
103+ }
104+ return strings.Index(row.String(), text)
105+}
106+
107+func drawProfile(t *testing.T, frame renderer.Frame, color terminalcap.ColorCapabilities) []byte {
108+ t.Helper()
109+ out, err := ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, colorprofile.Profile(color)).Draw(frame, []renderer.Damage{renderer.FullRedraw()})
110+ require.NoError(t, err)
111+ return out
112+}
113+
114+func TestReducedColorChromeIsLegible(t *testing.T) {
115+ ansi16 := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}
116+ mono := terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}
117+
118+ t.Run("ansi16 has no RGB, indexed or underline colors outside slots 0-15", func(t *testing.T) {
119+ composed, resolved := reducedChromeFrame(t, ansi16)
120+ out := drawProfile(t, composed.frame, ansi16)
121+ for _, p := range colorParams(out) {
122+ require.NotRegexp(t, `^(38|48|58)`, p, "ansi16 chrome emitted extended color %q", p)
123+ }
124+ // The accent is a palette slot, never RGB: the border and bell use it.
125+ require.True(t, resolved.Accent.Known)
126+ require.Contains(t, colorParams(out), "34", "accent slot 4 renders as the terminal's own blue")
127+ // Chrome surfaces are the terminal background: no RGB backgrounds.
128+ for x := range composed.frame.Width {
129+ for _, y := range []int{0, composed.frame.Height - 1} {
130+ require.False(t, composed.frame.At(x, y).Style.HasBackgroundRGB)
131+ }
132+ }
133+ })
134+
135+ t.Run("monochrome has no color sequences at all", func(t *testing.T) {
136+ composed, _ := reducedChromeFrame(t, mono)
137+ out := drawProfile(t, composed.frame, mono)
138+ require.Empty(t, colorParams(out), "%q", out)
139+ require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)`, string(out))
140+ })
141+
142+ for name, color := range map[string]terminalcap.ColorCapabilities{"ansi16": ansi16, "monochrome": mono} {
143+ t.Run(name+" distinguishes active, selected and focused elements", func(t *testing.T) {
144+ composed, resolved := reducedChromeFrame(t, color)
145+ frame := composed.frame
146+ // Top bar: " one" (inactive) vs " two" (active).
147+ inactive, active := frame.At(rowIndex(frame, 0, "one"), 0).Style, frame.At(rowIndex(frame, 0, "two"), 0).Style
148+ require.True(t, active.Inverse && active.Bold, "active tab must be bold reverse")
149+ require.False(t, inactive.Inverse)
150+ require.NotEqual(t, inactive.Canonical(), active.Canonical())
151+ // The attention bell is bold on its visible beat.
152+ bell := frame.At(rowIndex(frame, 0, string(ui.AttentionGlyph)), 0).Style
153+ require.True(t, bell.Bold && bell.Inverse)
154+ // Bottom bar: the session name is the active surface.
155+ require.True(t, frame.At(rowIndex(frame, frame.Height-1, "work"), frame.Height-1).Style.Inverse)
156+ require.False(t, frame.At(rowIndex(frame, frame.Height-1, "alp"), frame.Height-1).Style.Inverse)
157+ // Focused pane title bar is plain reverse (no stack title bars in
158+ // this fixture, so assert the role the title bar draws with).
159+ require.True(t, resolved.Styles.StatusBar.Inverse)
160+ // Palette selection row and focused modal border.
161+ require.True(t, frame.At(11, 3).Style.Inverse && frame.At(11, 3).Style.Bold, "selected row must be bold reverse")
162+ border := frame.At(10, 2).Style
163+ require.True(t, border.Bold, "focused modal border must be bold")
164+ require.Zero(t, border.Attrs&ansirenderer.AttrDim)
165+ // The same border, unfocused, is faint: focus is attribute-visible.
166+ require.NotZero(t, resolved.Styles.BorderMuted.Attrs&ansirenderer.AttrDim)
167+ // Copy selection is plain reverse.
168+ require.True(t, resolved.Styles.Selection.Inverse)
169+ require.True(t, resolved.Styles.CopyStatus.Inverse)
170+ // Toast box: warn border is visible without color.
171+ require.True(t, resolved.Styles.BorderWarn.Bold)
172+ })
173+ }
174+}
175+
176+// TestReducedColorLeavesPaneContentAlone guards that panes are never recolored
177+// or faded: vev-vt quantizes them, vev chrome must not touch them.
178+func TestReducedColorLeavesPaneContentAlone(t *testing.T) {
179+ for _, mode := range []terminalcap.ColorMode{terminalcap.ANSI16, terminalcap.Monochrome} {
180+ color := terminalcap.ColorCapabilities{Mode: mode}
181+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
182+ state := cachedSplitState("pane", "left", layout.Horizontal, resolved.Theme)
183+ state.styles = resolved.Styles
184+ pane := state.panes[1].frame // unfocused
185+ cell := pane.At(0, 0)
186+ cell.Style.Foreground = 196
187+ pane.Set(0, 0, cell)
188+ composed := composeFrame(state, composeCacheInput{})
189+ x := state.panes[1].placement.Content.X
190+ y := state.panes[1].placement.Content.Y + 1
191+ require.Equal(t, cell.Style.Canonical(), composed.frame.At(x, y).Style.Canonical())
192+ }
193+}
194+
195+// TestResolveForColorKeepsRGBModesIdentical proves TrueColor and ANSI256
196+// attachments resolve exactly as before this policy existed, so their output
197+// bytes cannot change.
198+func TestResolveForColorKeepsRGBModesIdentical(t *testing.T) {
199+ raw := reducedTestTheme()
200+ policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
201+ want := themeui.Resolve(raw, policy)
202+ for _, color := range []terminalcap.ColorCapabilities{{}, {Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}, {Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}} {
203+ require.Equal(t, want, themeui.ResolveForColor(raw, policy, color))
204+ }
205+ for _, color := range []terminalcap.ColorCapabilities{{Mode: terminalcap.TrueColor}, {Mode: terminalcap.ANSI256}} {
206+ composed, _ := reducedChromeFrame(t, color)
207+ state := cachedSplitState("reference", "left", layout.Horizontal, raw)
208+ _ = state
209+ out := drawProfile(t, composed.frame, color)
210+ require.Regexp(t, `(38|48);[25];`, string(out), "RGB-capable modes keep tinted surfaces")
211+ }
212+}
213+
214+// TestAppliedThemeIsPerAttachment confirms two attachments of one session
215+// resolve independently.
216+func TestAppliedThemeIsPerAttachment(t *testing.T) {
217+ p, release := newBlockingPTY(t)
218+ defer release()
219+ d, sess, ac16, _ := newManualSessionWithPTYs(t, p)
220+ ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}
221+ tr, _ := newCapturingTransport(t)
222+ acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
223+ acTrue.output.attachment = acTrue
224+ acTrue.initOverlays()
225+ sess.mu.Lock()
226+ sess.attachments[acTrue] = struct{}{}
227+ sess.mu.Unlock()
228+ acTrue.setSession(sess)
229+
230+ raw := reducedTestTheme()
231+ msg := protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}
232+ d.applyTheme(sess, ac16, msg)
233+ d.applyTheme(sess, acTrue, msg)
234+ d.reapplyThemeSession(sess) // a config reload must keep each mode
235+
236+ reduced, rgb := ac16.getAppliedTheme(), acTrue.getAppliedTheme()
237+ require.True(t, reduced.Raw.DimByAttribute)
238+ require.False(t, rgb.Raw.DimByAttribute)
239+ require.False(t, reduced.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
240+ require.True(t, rgb.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
241+ require.Equal(t, themeui.Resolve(rgb.Raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, rgb.Resolved.Styles)
242+}
@@ -0,0 +1,242 @@
1+package daemon
2+
3+import (
4+ "regexp"
5+ "strconv"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ renderer "github.com/bnema/vev-vt"
12+ ansirenderer "github.com/bnema/vev-vt/ansi"
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/terminalcap"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/usecase/colorprofile"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+ themeui "github.com/bnema/vev/internal/usecase/theme"
19+ "github.com/bnema/vev/internal/usecase/ui"
20+)
21+
22+var sgrSequence = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
23+
24+// colorParams returns every color-bearing SGR parameter group in out, e.g.
25+// "31", "48;2;1;2;3", "58;5;9". Attribute parameters (0, 1, 2, 3, 4, 7...) are
26+// not reported.
27+func colorParams(out []byte) []string {
28+ var found []string
29+ for _, m := range sgrSequence.FindAllStringSubmatch(string(out), -1) {
30+ params := strings.Split(m[1], ";")
31+ for i := 0; i < len(params); i++ {
32+ n, err := strconv.Atoi(params[i])
33+ if err != nil {
34+ continue
35+ }
36+ switch {
37+ case n == 38 || n == 48 || n == 58:
38+ end := len(params)
39+ if i+1 < len(params) && params[i+1] == "2" {
40+ end = min(i+5, len(params))
41+ } else if i+1 < len(params) {
42+ end = min(i+3, len(params))
43+ }
44+ found = append(found, strings.Join(params[i:end], ";"))
45+ i = end - 1
46+ case n >= 30 && n <= 37, n >= 40 && n <= 47, n >= 90 && n <= 97, n >= 100 && n <= 107:
47+ found = append(found, params[i])
48+ }
49+ }
50+ }
51+ return found
52+}
53+
54+func reducedTestTheme() themeui.Theme {
55+ palette := [16]renderer.RGB{}
56+ palette[4] = renderer.RGB{R: 60, G: 120, B: 220}
57+ palette[12] = palette[4]
58+ return themeui.Theme{
59+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
60+ HasFG: true, HasBG: true, Known: true, UsePalette: true, SchemeKnown: true,
61+ Palette: palette, PaletteKnown: 1<<4 | 1<<12,
62+ }
63+}
64+
65+// reducedChromeFrame composes a representative frame: two tabs (second
66+// active) with an attention bell, a split with the left pane focused, a
67+// bottom bar with MRU entries, a toast, and a focused palette modal with a
68+// selected row. rawTheme is what the attachment reported.
69+func reducedChromeFrame(t *testing.T, color terminalcap.ColorCapabilities) (composedRenderFrame, themeui.ResolvedTheme) {
70+ t.Helper()
71+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
72+ state := cachedSplitState("reduced", "left", layout.Horizontal, resolved.Theme)
73+ state.styles = resolved.Styles
74+ state.bars = barState{
75+ status: statusSnapshot{session: "work", tabs: []statusTab{{name: "one", paneTitle: "sh"}, {name: "two", paneTitle: "vim", active: true, attention: true}}},
76+ attentionFrame: pulseFrameCount / 2,
77+ mru: []recentRouteDisplay{{name: "alp"}, {name: "beta"}},
78+ theme: resolved.Theme,
79+ }
80+ state.panes[0].title, state.panes[1].title = "left", "right"
81+ state.overlays.notices = []domain.Notification{{Code: domain.NoticeUser, Severity: domain.NoticeWarn, Message: "careful", Count: 1}}
82+ inner := renderer.NewFrame(18, 3)
83+ for x, r := range "> sel" {
84+ inner.Set(x, 0, renderer.Cell{Rune: r, Style: resolved.Styles.PickerSelection})
85+ }
86+ state.overlays.paletteActive = true
87+ state.overlays.palette = capturedModal{
88+ active: true, title: "Palette", focused: true, inner: inner,
89+ presentation: ui.Presentation{Bounds: domain.Rect{X: 10, Y: 2, Width: 20, Height: 5}, Inner: domain.Rect{X: 11, Y: 3, Width: 18, Height: 3}, Borders: ui.BorderAll},
90+ }
91+ return composeFrame(state, composeCacheInput{}), resolved
92+}
93+
94+// rowIndex returns the x of the first cell of text on row y.
95+func rowIndex(frame renderer.Frame, y int, text string) int {
96+ var row strings.Builder
97+ for x := range frame.Width {
98+ r := frame.At(x, y).Rune
99+ if r == 0 {
100+ r = ' '
101+ }
102+ row.WriteRune(r)
103+ }
104+ return strings.Index(row.String(), text)
105+}
106+
107+func drawProfile(t *testing.T, frame renderer.Frame, color terminalcap.ColorCapabilities) []byte {
108+ t.Helper()
109+ out, err := ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, colorprofile.Profile(color)).Draw(frame, []renderer.Damage{renderer.FullRedraw()})
110+ require.NoError(t, err)
111+ return out
112+}
113+
114+func TestReducedColorChromeIsLegible(t *testing.T) {
115+ ansi16 := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}
116+ mono := terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}
117+
118+ t.Run("ansi16 has no RGB, indexed or underline colors outside slots 0-15", func(t *testing.T) {
119+ composed, resolved := reducedChromeFrame(t, ansi16)
120+ out := drawProfile(t, composed.frame, ansi16)
121+ for _, p := range colorParams(out) {
122+ require.NotRegexp(t, `^(38|48|58)`, p, "ansi16 chrome emitted extended color %q", p)
123+ }
124+ // The accent is a palette slot, never RGB: the border and bell use it.
125+ require.True(t, resolved.Accent.Known)
126+ require.Contains(t, colorParams(out), "34", "accent slot 4 renders as the terminal's own blue")
127+ // Chrome surfaces are the terminal background: no RGB backgrounds.
128+ for x := range composed.frame.Width {
129+ for _, y := range []int{0, composed.frame.Height - 1} {
130+ require.False(t, composed.frame.At(x, y).Style.HasBackgroundRGB)
131+ }
132+ }
133+ })
134+
135+ t.Run("monochrome has no color sequences at all", func(t *testing.T) {
136+ composed, _ := reducedChromeFrame(t, mono)
137+ out := drawProfile(t, composed.frame, mono)
138+ require.Empty(t, colorParams(out), "%q", out)
139+ require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)`, string(out))
140+ })
141+
142+ for name, color := range map[string]terminalcap.ColorCapabilities{"ansi16": ansi16, "monochrome": mono} {
143+ t.Run(name+" distinguishes active, selected and focused elements", func(t *testing.T) {
144+ composed, resolved := reducedChromeFrame(t, color)
145+ frame := composed.frame
146+ // Top bar: " one" (inactive) vs " two" (active).
147+ inactive, active := frame.At(rowIndex(frame, 0, "one"), 0).Style, frame.At(rowIndex(frame, 0, "two"), 0).Style
148+ require.True(t, active.Inverse && active.Bold, "active tab must be bold reverse")
149+ require.False(t, inactive.Inverse)
150+ require.NotEqual(t, inactive.Canonical(), active.Canonical())
151+ // The attention bell is bold on its visible beat.
152+ bell := frame.At(rowIndex(frame, 0, string(ui.AttentionGlyph)), 0).Style
153+ require.True(t, bell.Bold && bell.Inverse)
154+ // Bottom bar: the session name is the active surface.
155+ require.True(t, frame.At(rowIndex(frame, frame.Height-1, "work"), frame.Height-1).Style.Inverse)
156+ require.False(t, frame.At(rowIndex(frame, frame.Height-1, "alp"), frame.Height-1).Style.Inverse)
157+ // Focused pane title bar is plain reverse (no stack title bars in
158+ // this fixture, so assert the role the title bar draws with).
159+ require.True(t, resolved.Styles.StatusBar.Inverse)
160+ // Palette selection row and focused modal border.
161+ require.True(t, frame.At(11, 3).Style.Inverse && frame.At(11, 3).Style.Bold, "selected row must be bold reverse")
162+ border := frame.At(10, 2).Style
163+ require.True(t, border.Bold, "focused modal border must be bold")
164+ require.Zero(t, border.Attrs&ansirenderer.AttrDim)
165+ // The same border, unfocused, is faint: focus is attribute-visible.
166+ require.NotZero(t, resolved.Styles.BorderMuted.Attrs&ansirenderer.AttrDim)
167+ // Copy selection is plain reverse.
168+ require.True(t, resolved.Styles.Selection.Inverse)
169+ require.True(t, resolved.Styles.CopyStatus.Inverse)
170+ // Toast box: warn border is visible without color.
171+ require.True(t, resolved.Styles.BorderWarn.Bold)
172+ })
173+ }
174+}
175+
176+// TestReducedColorLeavesPaneContentAlone guards that panes are never recolored
177+// or faded: vev-vt quantizes them, vev chrome must not touch them.
178+func TestReducedColorLeavesPaneContentAlone(t *testing.T) {
179+ for _, mode := range []terminalcap.ColorMode{terminalcap.ANSI16, terminalcap.Monochrome} {
180+ color := terminalcap.ColorCapabilities{Mode: mode}
181+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
182+ state := cachedSplitState("pane", "left", layout.Horizontal, resolved.Theme)
183+ state.styles = resolved.Styles
184+ pane := state.panes[1].frame // unfocused
185+ cell := pane.At(0, 0)
186+ cell.Style.Foreground = 196
187+ pane.Set(0, 0, cell)
188+ composed := composeFrame(state, composeCacheInput{})
189+ x := state.panes[1].placement.Content.X
190+ y := state.panes[1].placement.Content.Y + 1
191+ require.Equal(t, cell.Style.Canonical(), composed.frame.At(x, y).Style.Canonical())
192+ }
193+}
194+
195+// TestResolveForColorKeepsRGBModesIdentical proves TrueColor and ANSI256
196+// attachments resolve exactly as before this policy existed, so their output
197+// bytes cannot change.
198+func TestResolveForColorKeepsRGBModesIdentical(t *testing.T) {
199+ raw := reducedTestTheme()
200+ policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
201+ want := themeui.Resolve(raw, policy)
202+ for _, color := range []terminalcap.ColorCapabilities{{}, {Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}, {Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}} {
203+ require.Equal(t, want, themeui.ResolveForColor(raw, policy, color))
204+ }
205+ for _, color := range []terminalcap.ColorCapabilities{{Mode: terminalcap.TrueColor}, {Mode: terminalcap.ANSI256}} {
206+ composed, _ := reducedChromeFrame(t, color)
207+ state := cachedSplitState("reference", "left", layout.Horizontal, raw)
208+ _ = state
209+ out := drawProfile(t, composed.frame, color)
210+ require.Regexp(t, `(38|48);[25];`, string(out), "RGB-capable modes keep tinted surfaces")
211+ }
212+}
213+
214+// TestAppliedThemeIsPerAttachment confirms two attachments of one session
215+// resolve independently.
216+func TestAppliedThemeIsPerAttachment(t *testing.T) {
217+ p, release := newBlockingPTY(t)
218+ defer release()
219+ d, sess, ac16, _ := newManualSessionWithPTYs(t, p)
220+ ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}
221+ tr, _ := newCapturingTransport(t)
222+ acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
223+ acTrue.output.attachment = acTrue
224+ acTrue.initOverlays()
225+ sess.mu.Lock()
226+ sess.attachments[acTrue] = struct{}{}
227+ sess.mu.Unlock()
228+ acTrue.setSession(sess)
229+
230+ raw := reducedTestTheme()
231+ msg := protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}
232+ d.applyTheme(sess, ac16, msg)
233+ d.applyTheme(sess, acTrue, msg)
234+ d.reapplyThemeSession(sess) // a config reload must keep each mode
235+
236+ reduced, rgb := ac16.getAppliedTheme(), acTrue.getAppliedTheme()
237+ require.True(t, reduced.Raw.DimByAttribute)
238+ require.False(t, rgb.Raw.DimByAttribute)
239+ require.False(t, reduced.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
240+ require.True(t, rgb.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
241+ require.Equal(t, themeui.Resolve(rgb.Raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, rgb.Resolved.Styles)
242+}
M internal/usecase/daemon/render.go +18 -0
@@ -6,6 +6,7 @@ import (
66
77 renderer "github.com/bnema/vev-vt/ansi"
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/layout"
1112 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -496,6 +497,23 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
496497
497498 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
498499
500+var (
501+ fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles
502+ fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles
503+)
504+
505+// fallbackStylesFor is the static pre-theme chrome for one attachment's mode.
506+func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles {
507+ switch color.Mode {
508+ case terminalcap.ANSI16:
509+ return fallbackANSI16Styles
510+ case terminalcap.Monochrome:
511+ return fallbackMonoStyles
512+ default:
513+ return fallbackChromeStyles
514+ }
515+}
516+
499517 func resolveStyles(styles []themeui.Styles) themeui.Styles {
500518 if len(styles) > 0 {
501519 return styles[0]
@@ -6,6 +6,7 @@ import (
6 6
7 renderer "github.com/bnema/vev-vt/ansi"7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
11 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -496,6 +497,23 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
496 497
497 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles498 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
498 499
500+var (
501+ fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles
502+ fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles
503+)
504+
505+// fallbackStylesFor is the static pre-theme chrome for one attachment's mode.
506+func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles {
507+ switch color.Mode {
508+ case terminalcap.ANSI16:
509+ return fallbackANSI16Styles
510+ case terminalcap.Monochrome:
511+ return fallbackMonoStyles
512+ default:
513+ return fallbackChromeStyles
514+ }
515+}
516+
499 func resolveStyles(styles []themeui.Styles) themeui.Styles {517 func resolveStyles(styles []themeui.Styles) themeui.Styles {
500 if len(styles) > 0 {518 if len(styles) > 0 {
501 return styles[0]519 return styles[0]
M internal/usecase/daemon/render_pipeline.go +3 -1
@@ -157,7 +157,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157157 titles[pane.id] = pane.titleGeneration
158158 }
159159 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
160- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
160+ // Reduced-color chrome never recolors or fades pane content; focus is
161+ // shown by the title bar and dividers.
162+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer)
161163 }
162164 for _, d := range pane.damage {
163165 if d.Kind != renderer.DamageFullRedraw {
@@ -157,7 +157,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157 titles[pane.id] = pane.titleGeneration157 titles[pane.id] = pane.titleGeneration
158 }158 }
159 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {159 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
160- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)160+ // Reduced-color chrome never recolors or fades pane content; focus is
161+ // shown by the title bar and dividers.
162+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer)
161 }163 }
162 for _, d := range pane.damage {164 for _, d := range pane.damage {
163 if d.Kind != renderer.DamageFullRedraw {165 if d.Kind != renderer.DamageFullRedraw {
M internal/usecase/daemon/status.go +17 -9
@@ -33,9 +33,17 @@ func pulseVisible(frame int) bool {
3333 // punching a default-background hole in a themed bar. On the invisible beat
3434 // it returns (base, false) unchanged.
3535 func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
36+ return pulseStyleFor(frame, base, false)
37+}
38+
39+// pulseStyleFor is pulseStyle for chrome built by the reduced-color policy.
40+func pulseStyleFor(frame int, base renderer.Style, reduced bool) (renderer.Style, bool) {
3641 if !pulseVisible(frame) {
3742 return base, false
3843 }
44+ if reduced {
45+ return themeui.PulseAttribute(base), true
46+ }
3947 f := frame % pulseFrameCount
4048 peak := pulseFrameCount / 2
4149 distance := f - peak
@@ -63,7 +71,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
6371 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
6472 if w.attention {
6573 writeStatusText(row, &x, " ", baseStyle)
66- writeBell(row, &x, frame, baseStyle)
74+ writeBell(row, &x, frame, baseStyle, styles.Reduced)
6775 }
6876 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
6977 writeStatusText(row, &x, " ", baseStyle)
@@ -82,13 +90,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
8290 if sess.selected {
8391 style = styles.SurfaceActive
8492 }
85- drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
93+ drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
8694 }
8795 } else {
8896 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
8997 for i, sess := range fittedMRU {
9098 style := styles.MRUStyle(i, len(fittedMRU))
91- drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
99+ drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
92100 }
93101 }
94102 drawRightPlainText(row, rightText, x, styles.SurfaceBar)
@@ -103,23 +111,23 @@ type rankedRecent struct {
103111 selected bool
104112 }
105113
106-func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) {
114+func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int, reduced bool) {
107115 prefixStyle := style
108116 prefixStyle.Bold = true
109117 writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
110118 writeStatusText(row, x, sess.name, style)
111119 if sess.attention {
112120 writeStatusText(row, x, " ", style)
113- writeBell(row, x, attentionFrame, style)
121+ writeBell(row, x, attentionFrame, style, reduced)
114122 }
115123 writeStatusText(row, x, " ", style)
116124 }
117125
118-func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) {
126+func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int, reduced bool) {
119127 writeStatusText(row, x, " "+entry.name, style)
120128 if entry.attention {
121129 writeStatusText(row, x, " ", style)
122- writeBell(row, x, attentionFrame, style)
130+ writeBell(row, x, attentionFrame, style, reduced)
123131 }
124132 writeStatusText(row, x, " ", style)
125133 }
@@ -490,8 +498,8 @@ func mruFutureRightReserve(rowLen int) int {
490498 // writeBell draws the attention glyph on its visible beat, or a plain cell in
491499 // base's style on its blank beat, so the bell never leaves a bare
492500 // default-background hole in a themed bar.
493-func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
494- style, visible := pulseStyle(frame, base)
501+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style, reduced bool) {
502+ style, visible := pulseStyleFor(frame, base, reduced)
495503 if !visible {
496504 writeStatusText(row, x, " ", base)
497505 return
@@ -33,9 +33,17 @@ func pulseVisible(frame int) bool {
33 // punching a default-background hole in a themed bar. On the invisible beat33 // punching a default-background hole in a themed bar. On the invisible beat
34 // it returns (base, false) unchanged.34 // it returns (base, false) unchanged.
35 func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {35 func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
36+ return pulseStyleFor(frame, base, false)
37+}
38+
39+// pulseStyleFor is pulseStyle for chrome built by the reduced-color policy.
40+func pulseStyleFor(frame int, base renderer.Style, reduced bool) (renderer.Style, bool) {
36 if !pulseVisible(frame) {41 if !pulseVisible(frame) {
37 return base, false42 return base, false
38 }43 }
44+ if reduced {
45+ return themeui.PulseAttribute(base), true
46+ }
39 f := frame % pulseFrameCount47 f := frame % pulseFrameCount
40 peak := pulseFrameCount / 248 peak := pulseFrameCount / 2
41 distance := f - peak49 distance := f - peak
@@ -63,7 +71,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
63 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)71 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
64 if w.attention {72 if w.attention {
65 writeStatusText(row, &x, " ", baseStyle)73 writeStatusText(row, &x, " ", baseStyle)
66- writeBell(row, &x, frame, baseStyle)74+ writeBell(row, &x, frame, baseStyle, styles.Reduced)
67 }75 }
68 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)76 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
69 writeStatusText(row, &x, " ", baseStyle)77 writeStatusText(row, &x, " ", baseStyle)
@@ -82,13 +90,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
82 if sess.selected {90 if sess.selected {
83 style = styles.SurfaceActive91 style = styles.SurfaceActive
84 }92 }
85- drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)93+ drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
86 }94 }
87 } else {95 } else {
88 fittedMRU := fitMRU(state.mru, len(row), x, rightText)96 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
89 for i, sess := range fittedMRU {97 for i, sess := range fittedMRU {
90 style := styles.MRUStyle(i, len(fittedMRU))98 style := styles.MRUStyle(i, len(fittedMRU))
91- drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame)99+ drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
92 }100 }
93 }101 }
94 drawRightPlainText(row, rightText, x, styles.SurfaceBar)102 drawRightPlainText(row, rightText, x, styles.SurfaceBar)
@@ -103,23 +111,23 @@ type rankedRecent struct {
103 selected bool111 selected bool
104 }112 }
105 113
106-func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) {114+func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int, reduced bool) {
107 prefixStyle := style115 prefixStyle := style
108 prefixStyle.Bold = true116 prefixStyle.Bold = true
109 writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)117 writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
110 writeStatusText(row, x, sess.name, style)118 writeStatusText(row, x, sess.name, style)
111 if sess.attention {119 if sess.attention {
112 writeStatusText(row, x, " ", style)120 writeStatusText(row, x, " ", style)
113- writeBell(row, x, attentionFrame, style)121+ writeBell(row, x, attentionFrame, style, reduced)
114 }122 }
115 writeStatusText(row, x, " ", style)123 writeStatusText(row, x, " ", style)
116 }124 }
117 125
118-func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) {126+func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int, reduced bool) {
119 writeStatusText(row, x, " "+entry.name, style)127 writeStatusText(row, x, " "+entry.name, style)
120 if entry.attention {128 if entry.attention {
121 writeStatusText(row, x, " ", style)129 writeStatusText(row, x, " ", style)
122- writeBell(row, x, attentionFrame, style)130+ writeBell(row, x, attentionFrame, style, reduced)
123 }131 }
124 writeStatusText(row, x, " ", style)132 writeStatusText(row, x, " ", style)
125 }133 }
@@ -490,8 +498,8 @@ func mruFutureRightReserve(rowLen int) int {
490 // writeBell draws the attention glyph on its visible beat, or a plain cell in498 // writeBell draws the attention glyph on its visible beat, or a plain cell in
491 // base's style on its blank beat, so the bell never leaves a bare499 // base's style on its blank beat, so the bell never leaves a bare
492 // default-background hole in a themed bar.500 // default-background hole in a themed bar.
493-func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {501+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style, reduced bool) {
494- style, visible := pulseStyle(frame, base)502+ style, visible := pulseStyleFor(frame, base, reduced)
495 if !visible {503 if !visible {
496 writeStatusText(row, x, " ", base)504 writeStatusText(row, x, " ", base)
497 return505 return
M internal/usecase/daemon/theme_integration_test.go +2 -1
@@ -7,6 +7,7 @@ import (
77
88 renderer "github.com/bnema/vev-vt/ansi"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
1011 "github.com/bnema/vev/internal/protocol"
1112 themeui "github.com/bnema/vev/internal/usecase/theme"
1213 )
@@ -174,7 +175,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
174175 d.ApplyConfig(tt.config)
175176 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
176177 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
177- HasFG: true, HasBG: true}).Resolved.Styles)
178+ HasFG: true, HasBG: true}, terminalcap.ColorCapabilities{}).Resolved.Styles)
178179 require.Equal(t, []byte(tt.want), got)
179180 })
180181 }
@@ -7,6 +7,7 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt/ansi"8 renderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
11 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
12 )13 )
@@ -174,7 +175,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
174 d.ApplyConfig(tt.config)175 d.ApplyConfig(tt.config)
175 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{176 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
176 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,177 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
177- HasFG: true, HasBG: true}).Resolved.Styles)178+ HasFG: true, HasBG: true}, terminalcap.ColorCapabilities{}).Resolved.Styles)
178 require.Equal(t, []byte(tt.want), got)179 require.Equal(t, []byte(tt.want), got)
179 })180 })
180 }181 }
M internal/usecase/daemon/theme_resolve.go +9 -2
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/usecase/theme"
3+import (
4+ "github.com/bnema/vev/internal/domain/terminalcap"
5+ "github.com/bnema/vev/internal/usecase/theme"
6+)
47
58 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
69 if ac != nil {
@@ -16,7 +19,11 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
1619 // Resolve while applying, never while composing. The theme mutex only
1720 // publishes the completed value and is not held across session/tab/pane
1821 // locks, preserving the daemon lock order.
19- applied := d.resolveAppliedTheme(t)
22+ var color terminalcap.ColorCapabilities
23+ if ac != nil {
24+ color = ac.terminalCapabilities.Color
25+ }
26+ applied := d.resolveAppliedTheme(t, color)
2027 t = applied.Raw
2128 sess.mu.Lock()
2229 if ac != nil {
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/usecase/theme"3+import (
4+ "github.com/bnema/vev/internal/domain/terminalcap"
5+ "github.com/bnema/vev/internal/usecase/theme"
6+)
4 7
5 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {8 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
6 if ac != nil {9 if ac != nil {
@@ -16,7 +19,11 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
16 // Resolve while applying, never while composing. The theme mutex only19 // Resolve while applying, never while composing. The theme mutex only
17 // publishes the completed value and is not held across session/tab/pane20 // publishes the completed value and is not held across session/tab/pane
18 // locks, preserving the daemon lock order.21 // locks, preserving the daemon lock order.
19- applied := d.resolveAppliedTheme(t)22+ var color terminalcap.ColorCapabilities
23+ if ac != nil {
24+ color = ac.terminalCapabilities.Color
25+ }
26+ applied := d.resolveAppliedTheme(t, color)
20 t = applied.Raw27 t = applied.Raw
21 sess.mu.Lock()28 sess.mu.Lock()
22 if ac != nil {29 if ac != nil {
A internal/usecase/theme/color_policy.go +128 -0
@@ -0,0 +1,128 @@
1+package theme
2+
3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
8+
9+// ReducedColor reports whether chrome for c must be built from attributes and
10+// terminal palette slots instead of RGB surfaces: 16-color and monochrome
11+// attachments.
12+func ReducedColor(c terminalcap.ColorCapabilities) bool {
13+ return c.Mode == terminalcap.ANSI16 || c.Mode == terminalcap.Monochrome
14+}
15+
16+// ResolveForColor is Resolve for one attachment's color capability. TrueColor
17+// and ANSI256 return exactly what Resolve returns. ANSI16 and Monochrome
18+// replace the chrome styles with the reduced-color policy (see reducedStyles)
19+// and mark the returned Theme so dimming uses the faint attribute instead of
20+// RGB blends. Color is per attachment, so callers resolve once per attachment.
21+func ResolveForColor(t Theme, policy domain.ThemeAccent, color terminalcap.ColorCapabilities) ResolvedTheme {
22+ if !ReducedColor(color) {
23+ return Resolve(t, policy)
24+ }
25+ t.DimByAttribute = true
26+ resolved := Resolve(t, policy) // resolved.Theme carries DimByAttribute
27+ accent := resolved.Accent
28+ slot := -1
29+ if (accent.Known || accent.IndexedOnly) && accent.Slot < 16 {
30+ slot = int(accent.Slot)
31+ }
32+ resolved.Styles = reducedStyles(slot, color.Mode == terminalcap.Monochrome)
33+ return resolved
34+}
35+
36+// reducedStyles builds the chrome for terminals that cannot show tinted RGB
37+// surfaces. Surfaces keep the terminal's default background; hierarchy comes
38+// from attributes, and the accent (when known) is only ever a palette slot
39+// 0-15 so the terminal's own palette renders it:
40+//
41+// - active tab, session name, selected rows, hint keys, search selection:
42+// bold reverse, so contrast is always the terminal's own fg/bg pair;
43+// - copy status, copy selection and the focused pane title: plain reverse
44+// (a selection keeps the text's own colors);
45+// - inactive tabs and bars: plain; secondary text, muted borders and
46+// unfocused titles: faint;
47+// - focused borders: bold, in the accent palette slot when there is one;
48+// warn borders: bold yellow (slot 3); errors share the focused border.
49+//
50+// Monochrome uses the same attributes with no color at all. The accent never
51+// fills a background: a palette slot is not guaranteed to contrast with the
52+// terminal background that reverse video would put on top of it.
53+func reducedStyles(accentSlot int, mono bool) Styles {
54+ plain := renderer.DefaultStyle()
55+ withFG := func(s renderer.Style, slot int) renderer.Style {
56+ if !mono && slot >= 0 {
57+ s.Foreground = slot
58+ }
59+ return s
60+ }
61+ with := func(base renderer.Style, bold, italic, inverse, faint bool) renderer.Style {
62+ base.Bold, base.Italic, base.Inverse = bold, italic, inverse
63+ if faint {
64+ base.Attrs |= renderer.AttrDim
65+ }
66+ return base
67+ }
68+ active := with(plain, true, false, true, false)
69+ activeTitle := with(plain, false, false, true, false)
70+ selectionMuted := with(plain, false, true, true, false)
71+ faint := with(plain, false, false, false, true)
72+ description := with(plain, false, true, false, true)
73+ focusedBorder := with(withFG(plain, accentSlot), true, false, false, false)
74+ warn := with(plain, true, true, false, false)
75+ if !mono {
76+ warn = with(withFG(plain, 3), true, false, false, false)
77+ }
78+ copySelection := with(plain, false, false, true, false)
79+
80+ s := Styles{
81+ Reduced: true,
82+
83+ SurfaceBar: plain,
84+ SurfaceInactive: plain,
85+ SurfaceRecent: plain,
86+ SurfaceActive: active,
87+ BorderMuted: faint,
88+ BorderActive: focusedBorder,
89+ BorderWarn: warn,
90+ NeutralBorder: faint,
91+
92+ TabInactive: plain,
93+ TabInactiveTitle: faint,
94+ TabActive: active,
95+ TabActiveTitle: activeTitle,
96+ MRURecent: plain,
97+
98+ PickerBase: plain,
99+ PickerSelection: active,
100+ HintKey: active,
101+ PickerDescription: description,
102+ PickerSeparator: faint,
103+ PromptBase: plain,
104+ CopyStatus: with(plain, false, false, true, false),
105+ SearchSelection: active,
106+ }
107+ // Aliases consumed by render paths that have not migrated yet. Selection
108+ // is the copy-mode selection role and StatusBar the focused pane title
109+ // bar; both are plain reverse.
110+ s.StatusBar = copySelection
111+ s.Accent = s.SurfaceActive
112+ s.Border = s.BorderMuted
113+ s.Selection = copySelection
114+ s.PaletteDesc = description
115+ s.TabName = s.TabInactive
116+ s.TabNameActive = s.TabActive
117+ s.TabTitle = s.TabInactiveTitle
118+ s.TabTitleActive = s.TabActiveTitle
119+ s.PickerName = plain
120+ s.PickerSelectionName = active
121+ s.PickerSelectionMuted = selectionMuted
122+ for count := range s.mruStyles {
123+ for index := 0; index <= count; index++ {
124+ s.mruStyles[count][index] = plain
125+ }
126+ }
127+ return s
128+}
@@ -0,0 +1,128 @@
1+package theme
2+
3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
8+
9+// ReducedColor reports whether chrome for c must be built from attributes and
10+// terminal palette slots instead of RGB surfaces: 16-color and monochrome
11+// attachments.
12+func ReducedColor(c terminalcap.ColorCapabilities) bool {
13+ return c.Mode == terminalcap.ANSI16 || c.Mode == terminalcap.Monochrome
14+}
15+
16+// ResolveForColor is Resolve for one attachment's color capability. TrueColor
17+// and ANSI256 return exactly what Resolve returns. ANSI16 and Monochrome
18+// replace the chrome styles with the reduced-color policy (see reducedStyles)
19+// and mark the returned Theme so dimming uses the faint attribute instead of
20+// RGB blends. Color is per attachment, so callers resolve once per attachment.
21+func ResolveForColor(t Theme, policy domain.ThemeAccent, color terminalcap.ColorCapabilities) ResolvedTheme {
22+ if !ReducedColor(color) {
23+ return Resolve(t, policy)
24+ }
25+ t.DimByAttribute = true
26+ resolved := Resolve(t, policy) // resolved.Theme carries DimByAttribute
27+ accent := resolved.Accent
28+ slot := -1
29+ if (accent.Known || accent.IndexedOnly) && accent.Slot < 16 {
30+ slot = int(accent.Slot)
31+ }
32+ resolved.Styles = reducedStyles(slot, color.Mode == terminalcap.Monochrome)
33+ return resolved
34+}
35+
36+// reducedStyles builds the chrome for terminals that cannot show tinted RGB
37+// surfaces. Surfaces keep the terminal's default background; hierarchy comes
38+// from attributes, and the accent (when known) is only ever a palette slot
39+// 0-15 so the terminal's own palette renders it:
40+//
41+// - active tab, session name, selected rows, hint keys, search selection:
42+// bold reverse, so contrast is always the terminal's own fg/bg pair;
43+// - copy status, copy selection and the focused pane title: plain reverse
44+// (a selection keeps the text's own colors);
45+// - inactive tabs and bars: plain; secondary text, muted borders and
46+// unfocused titles: faint;
47+// - focused borders: bold, in the accent palette slot when there is one;
48+// warn borders: bold yellow (slot 3); errors share the focused border.
49+//
50+// Monochrome uses the same attributes with no color at all. The accent never
51+// fills a background: a palette slot is not guaranteed to contrast with the
52+// terminal background that reverse video would put on top of it.
53+func reducedStyles(accentSlot int, mono bool) Styles {
54+ plain := renderer.DefaultStyle()
55+ withFG := func(s renderer.Style, slot int) renderer.Style {
56+ if !mono && slot >= 0 {
57+ s.Foreground = slot
58+ }
59+ return s
60+ }
61+ with := func(base renderer.Style, bold, italic, inverse, faint bool) renderer.Style {
62+ base.Bold, base.Italic, base.Inverse = bold, italic, inverse
63+ if faint {
64+ base.Attrs |= renderer.AttrDim
65+ }
66+ return base
67+ }
68+ active := with(plain, true, false, true, false)
69+ activeTitle := with(plain, false, false, true, false)
70+ selectionMuted := with(plain, false, true, true, false)
71+ faint := with(plain, false, false, false, true)
72+ description := with(plain, false, true, false, true)
73+ focusedBorder := with(withFG(plain, accentSlot), true, false, false, false)
74+ warn := with(plain, true, true, false, false)
75+ if !mono {
76+ warn = with(withFG(plain, 3), true, false, false, false)
77+ }
78+ copySelection := with(plain, false, false, true, false)
79+
80+ s := Styles{
81+ Reduced: true,
82+
83+ SurfaceBar: plain,
84+ SurfaceInactive: plain,
85+ SurfaceRecent: plain,
86+ SurfaceActive: active,
87+ BorderMuted: faint,
88+ BorderActive: focusedBorder,
89+ BorderWarn: warn,
90+ NeutralBorder: faint,
91+
92+ TabInactive: plain,
93+ TabInactiveTitle: faint,
94+ TabActive: active,
95+ TabActiveTitle: activeTitle,
96+ MRURecent: plain,
97+
98+ PickerBase: plain,
99+ PickerSelection: active,
100+ HintKey: active,
101+ PickerDescription: description,
102+ PickerSeparator: faint,
103+ PromptBase: plain,
104+ CopyStatus: with(plain, false, false, true, false),
105+ SearchSelection: active,
106+ }
107+ // Aliases consumed by render paths that have not migrated yet. Selection
108+ // is the copy-mode selection role and StatusBar the focused pane title
109+ // bar; both are plain reverse.
110+ s.StatusBar = copySelection
111+ s.Accent = s.SurfaceActive
112+ s.Border = s.BorderMuted
113+ s.Selection = copySelection
114+ s.PaletteDesc = description
115+ s.TabName = s.TabInactive
116+ s.TabNameActive = s.TabActive
117+ s.TabTitle = s.TabInactiveTitle
118+ s.TabTitleActive = s.TabActiveTitle
119+ s.PickerName = plain
120+ s.PickerSelectionName = active
121+ s.PickerSelectionMuted = selectionMuted
122+ for count := range s.mruStyles {
123+ for index := 0; index <= count; index++ {
124+ s.mruStyles[count][index] = plain
125+ }
126+ }
127+ return s
128+}
M internal/usecase/theme/styles.go +12 -0
@@ -47,6 +47,14 @@ func PulseColor(base renderer.Style, intensity float64) renderer.Style {
4747 return style
4848 }
4949
50+// PulseAttribute is PulseColor for reduced-color attachments: the glyph's
51+// on/off beat already pulses, so the visible beat is just bold. Grey indexes
52+// would quantize to arbitrary slots or vanish.
53+func PulseAttribute(base renderer.Style) renderer.Style {
54+ base.Bold = true
55+ return base
56+}
57+
5058 // MRUFade fades an MRU style by position, reaching 60 percent of the terminal
5159 // background color at the oldest entry.
5260 func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
@@ -62,6 +70,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
6270 // Styles is the complete semantic set of terminal chrome styles. The legacy
6371 // aliases remain until daemon renderers consume the semantic fields directly.
6472 type Styles struct {
73+ // Reduced marks the ANSI16/monochrome policy: no RGB anywhere, and the
74+ // attention pulse uses bold instead of a grey ramp (see ResolveForColor).
75+ Reduced bool
76+
6577 // mruStyles are all position/count combinations that status composition
6678 // can display. They keep OKLab interpolation out of render paths.
6779 mruStyles [9][9]renderer.Style
@@ -47,6 +47,14 @@ func PulseColor(base renderer.Style, intensity float64) renderer.Style {
47 return style47 return style
48 }48 }
49 49
50+// PulseAttribute is PulseColor for reduced-color attachments: the glyph's
51+// on/off beat already pulses, so the visible beat is just bold. Grey indexes
52+// would quantize to arbitrary slots or vanish.
53+func PulseAttribute(base renderer.Style) renderer.Style {
54+ base.Bold = true
55+ return base
56+}
57+
50 // MRUFade fades an MRU style by position, reaching 60 percent of the terminal58 // MRUFade fades an MRU style by position, reaching 60 percent of the terminal
51 // background color at the oldest entry.59 // background color at the oldest entry.
52 func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {60 func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
@@ -62,6 +70,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
62 // Styles is the complete semantic set of terminal chrome styles. The legacy70 // Styles is the complete semantic set of terminal chrome styles. The legacy
63 // aliases remain until daemon renderers consume the semantic fields directly.71 // aliases remain until daemon renderers consume the semantic fields directly.
64 type Styles struct {72 type Styles struct {
73+ // Reduced marks the ANSI16/monochrome policy: no RGB anywhere, and the
74+ // attention pulse uses bold instead of a grey ramp (see ResolveForColor).
75+ Reduced bool
76+
65 // mruStyles are all position/count combinations that status composition77 // mruStyles are all position/count combinations that status composition
66 // can display. They keep OKLab interpolation out of render paths.78 // can display. They keep OKLab interpolation out of render paths.
67 mruStyles [9][9]renderer.Style79 mruStyles [9][9]renderer.Style
M internal/usecase/theme/theme.go +10 -0
@@ -23,6 +23,10 @@ type Theme struct {
2323 SchemeKnown bool
2424 Light bool
2525 UsePalette bool
26+ // DimByAttribute makes Dimmer use the faint attribute instead of blending
27+ // colors. The daemon sets it per attachment for ANSI16 and monochrome
28+ // output, where RGB blends would quantize badly or vanish.
29+ DimByAttribute bool
2630 }
2731
2832 // PaletteColor returns a palette color only when palette inheritance is
@@ -414,6 +418,7 @@ const defaultDimmingPercent = 35
414418 // Dimmer transforms resolved terminal colors for subdued UI states. Construct
415419 // it once and reuse it while rendering cells with the same theme and policy.
416420 type Dimmer struct {
421+ attribute bool
417422 theme Theme
418423 backgroundPercent int
419424 foregroundPercent int
@@ -445,6 +450,7 @@ func WithForegroundDimming(percent int) DimmerOption {
445450 // NewDimmer returns a reusable dimmer with optional per-channel overrides.
446451 func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
447452 d := Dimmer{
453+ attribute: t.DimByAttribute,
448454 theme: t,
449455 backgroundPercent: defaultDimmingPercent,
450456 foregroundPercent: defaultDimmingPercent,
@@ -465,6 +471,10 @@ func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
465471 // Dim fades a style's resolved background toward the terminal background,
466472 // then fades its foreground and custom underline toward that dimmed background.
467473 func (d Dimmer) Dim(style renderer.Style) renderer.Style {
474+ if d.attribute {
475+ style.Attrs |= renderer.AttrDim
476+ return style
477+ }
468478 if !usable(d.theme) {
469479 return style
470480 }
@@ -23,6 +23,10 @@ type Theme struct {
23 SchemeKnown bool23 SchemeKnown bool
24 Light bool24 Light bool
25 UsePalette bool25 UsePalette bool
26+ // DimByAttribute makes Dimmer use the faint attribute instead of blending
27+ // colors. The daemon sets it per attachment for ANSI16 and monochrome
28+ // output, where RGB blends would quantize badly or vanish.
29+ DimByAttribute bool
26 }30 }
27 31
28 // PaletteColor returns a palette color only when palette inheritance is32 // PaletteColor returns a palette color only when palette inheritance is
@@ -414,6 +418,7 @@ const defaultDimmingPercent = 35
414 // Dimmer transforms resolved terminal colors for subdued UI states. Construct418 // Dimmer transforms resolved terminal colors for subdued UI states. Construct
415 // it once and reuse it while rendering cells with the same theme and policy.419 // it once and reuse it while rendering cells with the same theme and policy.
416 type Dimmer struct {420 type Dimmer struct {
421+ attribute bool
417 theme Theme422 theme Theme
418 backgroundPercent int423 backgroundPercent int
419 foregroundPercent int424 foregroundPercent int
@@ -445,6 +450,7 @@ func WithForegroundDimming(percent int) DimmerOption {
445 // NewDimmer returns a reusable dimmer with optional per-channel overrides.450 // NewDimmer returns a reusable dimmer with optional per-channel overrides.
446 func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {451 func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
447 d := Dimmer{452 d := Dimmer{
453+ attribute: t.DimByAttribute,
448 theme: t,454 theme: t,
449 backgroundPercent: defaultDimmingPercent,455 backgroundPercent: defaultDimmingPercent,
450 foregroundPercent: defaultDimmingPercent,456 foregroundPercent: defaultDimmingPercent,
@@ -465,6 +471,10 @@ func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
465 // Dim fades a style's resolved background toward the terminal background,471 // Dim fades a style's resolved background toward the terminal background,
466 // then fades its foreground and custom underline toward that dimmed background.472 // then fades its foreground and custom underline toward that dimmed background.
467 func (d Dimmer) Dim(style renderer.Style) renderer.Style {473 func (d Dimmer) Dim(style renderer.Style) renderer.Style {
474+ if d.attribute {
475+ style.Attrs |= renderer.AttrDim
476+ return style
477+ }
468 if !usable(d.theme) {478 if !usable(d.theme) {
469 return style479 return style
470 }480 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close