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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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`. | |||
| 13 | 13 | ||
| 14 | 14 | - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`. | |
| 15 | 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 | 19 | ### Install `xterm-direct` | |
| 19 | 20 | ||
| @@ -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 ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 11 | 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 | 75 | // clientToastLines renders the toast box as one string per row, exactly | |
| 42 | 76 | // bounds.Width columns wide. | |
| 43 | 77 | func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |
| @@ -60,7 +94,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||
| 60 | 94 | if border { | |
| 61 | 95 | b.WriteString(borderSGR) | |
| 62 | 96 | } else { | |
| 63 | - | b.WriteString("\x1b[39m") | |
| 97 | + | b.WriteString(toastBorderReset(borderSGR)) | |
| 64 | 98 | } | |
| 65 | 99 | colored = border | |
| 66 | 100 | } | |
| @@ -76,7 +110,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string { | |||
| 76 | 110 | b.WriteRune(cell.Rune) | |
| 77 | 111 | } | |
| 78 | 112 | if colored { | |
| 79 | - | b.WriteString("\x1b[39m") | |
| 113 | + | b.WriteString(toastBorderReset(borderSGR)) | |
| 80 | 114 | } | |
| 81 | 115 | lines[y] = b.String() | |
| 82 | 116 | } | |
| @@ -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, exactly | 75 | // 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 = border | 99 | 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 { | |||
| 498 | 498 | } | |
| 499 | 499 | buffer := bytes.NewBuffer(blanks) | |
| 500 | 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 | 503 | if buffer.Len() == 0 { | |
| 504 | 504 | 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 nil | 504 | 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 { | |||
| 260 | 260 | if applied.Generation == 0 { | |
| 261 | 261 | // An unattached client has no terminal report yet. Reuse the static | |
| 262 | 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 | 265 | return applied | |
| 266 | 266 | } | |
| @@ -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 static | 261 | // 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 applied | 265 | return applied | ||
| 266 | } | 266 | } | ||
M internal/usecase/daemon/config.go +7 -2
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/command" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -344,10 +345,14 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |||
| 344 | 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 | 352 | config := d.currentThemeConfig() | |
| 349 | 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 | 358 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | renderer "github.com/bnema/vev-vt" | |
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 13 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -163,7 +164,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||
| 163 | 164 | t.Run(tt.name, func(t *testing.T) { | |
| 164 | 165 | d := newTestDaemon(t, nil, stubClock{}) | |
| 165 | 166 | d.ApplyConfig(tt.config) | |
| 166 | - | applied := d.resolveAppliedTheme(tt.raw) | |
| 167 | + | applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{}) | |
| 167 | 168 | model := palette.New(palette.CommandResults([]command.Command{ | |
| 168 | 169 | {Code: "ONE", Name: "One", Desc: "first description"}, | |
| 169 | 170 | {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 ( | |||
| 6 | 6 | ||
| 7 | 7 | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 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 | 498 | 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 | 517 | func resolveStyles(styles []themeui.Styles) themeui.Styles { | |
| 500 | 518 | if len(styles) > 0 { | |
| 501 | 519 | 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}).Styles | 498 | 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 ... | |||
| 157 | 157 | titles[pane.id] = pane.titleGeneration | |
| 158 | 158 | } | |
| 159 | 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 | 164 | for _, d := range pane.damage { | |
| 163 | 165 | if d.Kind != renderer.DamageFullRedraw { | |
| @@ -157,7 +157,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 157 | titles[pane.id] = pane.titleGeneration | 157 | 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 { | |||
| 33 | 33 | // punching a default-background hole in a themed bar. On the invisible beat | |
| 34 | 34 | // it returns (base, false) unchanged. | |
| 35 | 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 | 41 | if !pulseVisible(frame) { | |
| 37 | 42 | return base, false | |
| 38 | 43 | } | |
| 44 | + | if reduced { | |
| 45 | + | return themeui.PulseAttribute(base), true | |
| 46 | + | } | |
| 39 | 47 | f := frame % pulseFrameCount | |
| 40 | 48 | peak := pulseFrameCount / 2 | |
| 41 | 49 | distance := f - peak | |
| @@ -63,7 +71,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 63 | 71 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| 64 | 72 | if w.attention { | |
| 65 | 73 | writeStatusText(row, &x, " ", baseStyle) | |
| 66 | - | writeBell(row, &x, frame, baseStyle) | |
| 74 | + | writeBell(row, &x, frame, baseStyle, styles.Reduced) | |
| 67 | 75 | } | |
| 68 | 76 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 69 | 77 | writeStatusText(row, &x, " ", baseStyle) | |
| @@ -82,13 +90,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl | |||
| 82 | 90 | if sess.selected { | |
| 83 | 91 | 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 | 95 | } else { | |
| 88 | 96 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 89 | 97 | for i, sess := range fittedMRU { | |
| 90 | 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 | 102 | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | |
| @@ -103,23 +111,23 @@ type rankedRecent struct { | |||
| 103 | 111 | 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 | 115 | prefixStyle := style | |
| 108 | 116 | prefixStyle.Bold = true | |
| 109 | 117 | writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle) | |
| 110 | 118 | writeStatusText(row, x, sess.name, style) | |
| 111 | 119 | if sess.attention { | |
| 112 | 120 | writeStatusText(row, x, " ", style) | |
| 113 | - | writeBell(row, x, attentionFrame, style) | |
| 121 | + | writeBell(row, x, attentionFrame, style, reduced) | |
| 114 | 122 | } | |
| 115 | 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 | 127 | writeStatusText(row, x, " "+entry.name, style) | |
| 120 | 128 | if entry.attention { | |
| 121 | 129 | writeStatusText(row, x, " ", style) | |
| 122 | - | writeBell(row, x, attentionFrame, style) | |
| 130 | + | writeBell(row, x, attentionFrame, style, reduced) | |
| 123 | 131 | } | |
| 124 | 132 | writeStatusText(row, x, " ", style) | |
| 125 | 133 | } | |
| @@ -490,8 +498,8 @@ func mruFutureRightReserve(rowLen int) int { | |||
| 490 | 498 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | |
| 491 | 499 | // base's style on its blank beat, so the bell never leaves a bare | |
| 492 | 500 | // 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) | |
| 495 | 503 | if !visible { | |
| 496 | 504 | writeStatusText(row, x, " ", base) | |
| 497 | 505 | return | |
| @@ -33,9 +33,17 @@ func pulseVisible(frame int) bool { | |||||
| 33 | // punching a default-background hole in a themed bar. On the invisible beat | 33 | // 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, false | 42 | return base, false | ||
| 38 | } | 43 | } | ||
| 44 | + | if reduced { | |||
| 45 | + | return themeui.PulseAttribute(base), true | |||
| 46 | + | } | |||
| 39 | f := frame % pulseFrameCount | 47 | f := frame % pulseFrameCount | ||
| 40 | peak := pulseFrameCount / 2 | 48 | peak := pulseFrameCount / 2 | ||
| 41 | distance := f - peak | 49 | 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.SurfaceActive | 91 | 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 bool | 111 | 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 := style | 115 | prefixStyle := style | ||
| 108 | prefixStyle.Bold = true | 116 | 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 in | 498 | // 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 bare | 499 | // 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 | return | 505 | return | ||
M internal/usecase/daemon/theme_integration_test.go +2 -1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | 13 | ) | |
| @@ -174,7 +175,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) { | |||
| 174 | 175 | d.ApplyConfig(tt.config) | |
| 175 | 176 | got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{ | |
| 176 | 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 | 179 | require.Equal(t, []byte(tt.want), got) | |
| 179 | 180 | }) | |
| 180 | 181 | } | |
| @@ -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 @@ | |||
| 1 | 1 | 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 | 8 | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 6 | 9 | if ac != nil { | |
| @@ -16,7 +19,11 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||
| 16 | 19 | // Resolve while applying, never while composing. The theme mutex only | |
| 17 | 20 | // publishes the completed value and is not held across session/tab/pane | |
| 18 | 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 | 27 | t = applied.Raw | |
| 21 | 28 | sess.mu.Lock() | |
| 22 | 29 | if ac != nil { | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | 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 only | 19 | // Resolve while applying, never while composing. The theme mutex only | ||
| 17 | // publishes the completed value and is not held across session/tab/pane | 20 | // 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.Raw | 27 | 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 { | |||
| 47 | 47 | 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 | 58 | // MRUFade fades an MRU style by position, reaching 60 percent of the terminal | |
| 51 | 59 | // background color at the oldest entry. | |
| 52 | 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 | 70 | // Styles is the complete semantic set of terminal chrome styles. The legacy | |
| 63 | 71 | // aliases remain until daemon renderers consume the semantic fields directly. | |
| 64 | 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 | 77 | // mruStyles are all position/count combinations that status composition | |
| 66 | 78 | // can display. They keep OKLab interpolation out of render paths. | |
| 67 | 79 | mruStyles [9][9]renderer.Style | |
| @@ -47,6 +47,14 @@ func PulseColor(base renderer.Style, intensity float64) renderer.Style { | |||||
| 47 | return style | 47 | 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 terminal | 58 | // 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 legacy | 70 | // 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 composition | 77 | // 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.Style | 79 | mruStyles [9][9]renderer.Style | ||
M internal/usecase/theme/theme.go +10 -0
| @@ -23,6 +23,10 @@ type Theme struct { | |||
| 23 | 23 | SchemeKnown bool | |
| 24 | 24 | Light bool | |
| 25 | 25 | 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 | 32 | // PaletteColor returns a palette color only when palette inheritance is | |
| @@ -414,6 +418,7 @@ const defaultDimmingPercent = 35 | |||
| 414 | 418 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | |
| 415 | 419 | // it once and reuse it while rendering cells with the same theme and policy. | |
| 416 | 420 | type Dimmer struct { | |
| 421 | + | attribute bool | |
| 417 | 422 | theme Theme | |
| 418 | 423 | backgroundPercent int | |
| 419 | 424 | foregroundPercent int | |
| @@ -445,6 +450,7 @@ func WithForegroundDimming(percent int) DimmerOption { | |||
| 445 | 450 | // NewDimmer returns a reusable dimmer with optional per-channel overrides. | |
| 446 | 451 | func NewDimmer(t Theme, opts ...DimmerOption) Dimmer { | |
| 447 | 452 | d := Dimmer{ | |
| 453 | + | attribute: t.DimByAttribute, | |
| 448 | 454 | theme: t, | |
| 449 | 455 | backgroundPercent: defaultDimmingPercent, | |
| 450 | 456 | foregroundPercent: defaultDimmingPercent, | |
| @@ -465,6 +471,10 @@ func NewDimmer(t Theme, opts ...DimmerOption) Dimmer { | |||
| 465 | 471 | // Dim fades a style's resolved background toward the terminal background, | |
| 466 | 472 | // then fades its foreground and custom underline toward that dimmed background. | |
| 467 | 473 | func (d Dimmer) Dim(style renderer.Style) renderer.Style { | |
| 474 | + | if d.attribute { | |
| 475 | + | style.Attrs |= renderer.AttrDim | |
| 476 | + | return style | |
| 477 | + | } | |
| 468 | 478 | if !usable(d.theme) { | |
| 469 | 479 | return style | |
| 470 | 480 | } | |
| @@ -23,6 +23,10 @@ type Theme struct { | |||||
| 23 | SchemeKnown bool | 23 | SchemeKnown bool | ||
| 24 | Light bool | 24 | Light bool | ||
| 25 | UsePalette bool | 25 | 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 is | 32 | // 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. Construct | 418 | // 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 Theme | 422 | theme Theme | ||
| 418 | backgroundPercent int | 423 | backgroundPercent int | ||
| 419 | foregroundPercent int | 424 | 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 style | 479 | return style | ||
| 470 | } | 480 | } | ||