Merge pull request #104 from bnema/feat/palette-theme
feat(theme): inherit terminal palette accents
30 files · +2188 -306
M docs/configuration.md +7 -1
| @@ -3,8 +3,10 @@ | |||
| 3 | 3 | vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed. | |
| 4 | 4 | ||
| 5 | 5 | ```text | |
| 6 | - | # Theme: auto follows the client; dark/light force built-in palettes. | |
| 6 | + | # Theme: auto follows the client; dark/light use neutral built-in defaults. | |
| 7 | 7 | theme = auto | |
| 8 | + | # In auto mode, inherit the terminal's ANSI blue for chrome accents. | |
| 9 | + | theme.palette = on | |
| 8 | 10 | ||
| 9 | 11 | # Palette placement: auto, center, top-left, top, top-right, left, right, | |
| 10 | 12 | # bottom-left, bottom, or bottom-right. | |
| @@ -66,6 +68,10 @@ code.detach = DET | |||
| 66 | 68 | ||
| 67 | 69 | Invalid values log a warning and keep the last valid value. | |
| 68 | 70 | ||
| 71 | + | ## Theme | |
| 72 | + | ||
| 73 | + | With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only. | |
| 74 | + | ||
| 69 | 75 | ## Bindings | |
| 70 | 76 | ||
| 71 | 77 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | |
| @@ -3,8 +3,10 @@ | |||||
| 3 | vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed. | 3 | vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed. | ||
| 4 | 4 | ||||
| 5 | ```text | 5 | ```text | ||
| 6 | - | # Theme: auto follows the client; dark/light force built-in palettes. | 6 | + | # Theme: auto follows the client; dark/light use neutral built-in defaults. |
| 7 | theme = auto | 7 | theme = auto | ||
| 8 | + | # In auto mode, inherit the terminal's ANSI blue for chrome accents. | |||
| 9 | + | theme.palette = on | |||
| 8 | 10 | ||||
| 9 | # Palette placement: auto, center, top-left, top, top-right, left, right, | 11 | # Palette placement: auto, center, top-left, top, top-right, left, right, | ||
| 10 | # bottom-left, bottom, or bottom-right. | 12 | # bottom-left, bottom, or bottom-right. | ||
| @@ -66,6 +68,10 @@ code.detach = DET | |||||
| 66 | 68 | ||||
| 67 | Invalid values log a warning and keep the last valid value. | 69 | Invalid values log a warning and keep the last valid value. | ||
| 68 | 70 | ||||
| 71 | + | ## Theme | |||
| 72 | + | ||||
| 73 | + | With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only. | |||
| 74 | + | ||||
| 69 | ## Bindings | 75 | ## Bindings | ||
| 70 | 76 | ||||
| 71 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | 77 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | ||
M internal/adapters/config/config.go +7 -0
| @@ -68,6 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 68 | 68 | continue | |
| 69 | 69 | } | |
| 70 | 70 | cfg.Theme = mode | |
| 71 | + | case key == "theme.palette": | |
| 72 | + | on, ok := parseOnOff(value) | |
| 73 | + | if !ok { | |
| 74 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)}) | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | cfg.ThemePalette = on | |
| 71 | 78 | case key == "bar.top-right": | |
| 72 | 79 | command, ok := parseBarCommand(value) | |
| 73 | 80 | if !ok { | |
| @@ -68,6 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 68 | continue | 68 | continue | ||
| 69 | } | 69 | } | ||
| 70 | cfg.Theme = mode | 70 | cfg.Theme = mode | ||
| 71 | + | case key == "theme.palette": | |||
| 72 | + | on, ok := parseOnOff(value) | |||
| 73 | + | if !ok { | |||
| 74 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)}) | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | cfg.ThemePalette = on | |||
| 71 | case key == "bar.top-right": | 78 | case key == "bar.top-right": | ||
| 72 | command, ok := parseBarCommand(value) | 79 | command, ok := parseBarCommand(value) | ||
| 73 | if !ok { | 80 | if !ok { | ||
M internal/adapters/config/config_test.go +55 -0
| @@ -332,6 +332,9 @@ func TestParse(t *testing.T) { | |||
| 332 | 332 | t.Run(tt.name, func(t *testing.T) { | |
| 333 | 333 | t.Parallel() | |
| 334 | 334 | ||
| 335 | + | if !tt.want.ThemePalette { | |
| 336 | + | tt.want.ThemePalette = domain.Defaults().ThemePalette | |
| 337 | + | } | |
| 335 | 338 | if tt.want.Bar.Interval == 0 { | |
| 336 | 339 | tt.want.Bar = domain.Defaults().Bar | |
| 337 | 340 | } | |
| @@ -364,6 +367,58 @@ func TestParse(t *testing.T) { | |||
| 364 | 367 | } | |
| 365 | 368 | } | |
| 366 | 369 | ||
| 370 | + | func TestParseThemePalette(t *testing.T) { | |
| 371 | + | t.Parallel() | |
| 372 | + | ||
| 373 | + | tests := []struct { | |
| 374 | + | name string | |
| 375 | + | input string | |
| 376 | + | want bool | |
| 377 | + | wantWarnings []domain.Warning | |
| 378 | + | }{ | |
| 379 | + | { | |
| 380 | + | name: "on enables terminal palette inheritance", | |
| 381 | + | input: "theme.palette = on\n", | |
| 382 | + | want: true, | |
| 383 | + | }, | |
| 384 | + | { | |
| 385 | + | name: "off disables terminal palette inheritance", | |
| 386 | + | input: "theme.palette = off\n", | |
| 387 | + | want: false, | |
| 388 | + | }, | |
| 389 | + | { | |
| 390 | + | name: "invalid value warns once and keeps default", | |
| 391 | + | input: "theme.palette = maybe\n", | |
| 392 | + | want: true, | |
| 393 | + | wantWarnings: []domain.Warning{ | |
| 394 | + | {Line: 1, Msg: "invalid theme.palette \"maybe\""}, | |
| 395 | + | }, | |
| 396 | + | }, | |
| 397 | + | { | |
| 398 | + | name: "absent key keeps default", | |
| 399 | + | input: "theme = dark\n", | |
| 400 | + | want: true, | |
| 401 | + | }, | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | for _, tt := range tests { | |
| 405 | + | t.Run(tt.name, func(t *testing.T) { | |
| 406 | + | t.Parallel() | |
| 407 | + | ||
| 408 | + | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 409 | + | require.NoError(t, err) | |
| 410 | + | require.Equal(t, tt.want, got.ThemePalette) | |
| 411 | + | require.Equal(t, tt.wantWarnings, warnings) | |
| 412 | + | }) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestDefaultsThemePalette(t *testing.T) { | |
| 417 | + | t.Parallel() | |
| 418 | + | ||
| 419 | + | require.True(t, domain.Defaults().ThemePalette) | |
| 420 | + | } | |
| 421 | + | ||
| 367 | 422 | func TestParseTabsTerminalTitle(t *testing.T) { | |
| 368 | 423 | t.Parallel() | |
| 369 | 424 | ||
| @@ -332,6 +332,9 @@ func TestParse(t *testing.T) { | |||||
| 332 | t.Run(tt.name, func(t *testing.T) { | 332 | t.Run(tt.name, func(t *testing.T) { | ||
| 333 | t.Parallel() | 333 | t.Parallel() | ||
| 334 | 334 | ||||
| 335 | + | if !tt.want.ThemePalette { | |||
| 336 | + | tt.want.ThemePalette = domain.Defaults().ThemePalette | |||
| 337 | + | } | |||
| 335 | if tt.want.Bar.Interval == 0 { | 338 | if tt.want.Bar.Interval == 0 { | ||
| 336 | tt.want.Bar = domain.Defaults().Bar | 339 | tt.want.Bar = domain.Defaults().Bar | ||
| 337 | } | 340 | } | ||
| @@ -364,6 +367,58 @@ func TestParse(t *testing.T) { | |||||
| 364 | } | 367 | } | ||
| 365 | } | 368 | } | ||
| 366 | 369 | ||||
| 370 | + | func TestParseThemePalette(t *testing.T) { | |||
| 371 | + | t.Parallel() | |||
| 372 | + | ||||
| 373 | + | tests := []struct { | |||
| 374 | + | name string | |||
| 375 | + | input string | |||
| 376 | + | want bool | |||
| 377 | + | wantWarnings []domain.Warning | |||
| 378 | + | }{ | |||
| 379 | + | { | |||
| 380 | + | name: "on enables terminal palette inheritance", | |||
| 381 | + | input: "theme.palette = on\n", | |||
| 382 | + | want: true, | |||
| 383 | + | }, | |||
| 384 | + | { | |||
| 385 | + | name: "off disables terminal palette inheritance", | |||
| 386 | + | input: "theme.palette = off\n", | |||
| 387 | + | want: false, | |||
| 388 | + | }, | |||
| 389 | + | { | |||
| 390 | + | name: "invalid value warns once and keeps default", | |||
| 391 | + | input: "theme.palette = maybe\n", | |||
| 392 | + | want: true, | |||
| 393 | + | wantWarnings: []domain.Warning{ | |||
| 394 | + | {Line: 1, Msg: "invalid theme.palette \"maybe\""}, | |||
| 395 | + | }, | |||
| 396 | + | }, | |||
| 397 | + | { | |||
| 398 | + | name: "absent key keeps default", | |||
| 399 | + | input: "theme = dark\n", | |||
| 400 | + | want: true, | |||
| 401 | + | }, | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | for _, tt := range tests { | |||
| 405 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 406 | + | t.Parallel() | |||
| 407 | + | ||||
| 408 | + | got, warnings, err := Parse(strings.NewReader(tt.input)) | |||
| 409 | + | require.NoError(t, err) | |||
| 410 | + | require.Equal(t, tt.want, got.ThemePalette) | |||
| 411 | + | require.Equal(t, tt.wantWarnings, warnings) | |||
| 412 | + | }) | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func TestDefaultsThemePalette(t *testing.T) { | |||
| 417 | + | t.Parallel() | |||
| 418 | + | ||||
| 419 | + | require.True(t, domain.Defaults().ThemePalette) | |||
| 420 | + | } | |||
| 421 | + | ||||
| 367 | func TestParseTabsTerminalTitle(t *testing.T) { | 422 | func TestParseTabsTerminalTitle(t *testing.T) { | ||
| 368 | t.Parallel() | 423 | t.Parallel() | ||
| 369 | 424 | ||||
M internal/adapters/term/terminal.go +5 -2
| @@ -31,7 +31,9 @@ const ( | |||
| 31 | 31 | bracketedPasteDisable = "\x1b[?2004l" | |
| 32 | 32 | colorSchemeEnable = "\x1b[?2031h" | |
| 33 | 33 | colorSchemeDisable = "\x1b[?2031l" | |
| 34 | - | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | |
| 34 | + | oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | |
| 35 | + | oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" | |
| 36 | + | oscColorQuery = oscDefaultColorQuery + oscPaletteQuery | |
| 35 | 37 | ) | |
| 36 | 38 | ||
| 37 | 39 | // bufSize is the batched writer's buffer capacity. | |
| @@ -235,7 +237,8 @@ func (t *Terminal) stopResizeLocked() { | |||
| 235 | 237 | t.resizeWG.Wait() | |
| 236 | 238 | } | |
| 237 | 239 | ||
| 238 | - | // QueryColors re-emits OSC 10/11 queries for default foreground/background. | |
| 240 | + | // QueryColors re-emits OSC 10/11 queries for default foreground/background | |
| 241 | + | // followed by one batched OSC 4 query for palette slots 0 through 15. | |
| 239 | 242 | func (t *Terminal) QueryColors() error { | |
| 240 | 243 | t.mu.Lock() | |
| 241 | 244 | defer t.mu.Unlock() | |
| @@ -31,7 +31,9 @@ const ( | |||||
| 31 | bracketedPasteDisable = "\x1b[?2004l" | 31 | bracketedPasteDisable = "\x1b[?2004l" | ||
| 32 | colorSchemeEnable = "\x1b[?2031h" | 32 | colorSchemeEnable = "\x1b[?2031h" | ||
| 33 | colorSchemeDisable = "\x1b[?2031l" | 33 | colorSchemeDisable = "\x1b[?2031l" | ||
| 34 | - | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | 34 | + | oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" |
| 35 | + | oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" | |||
| 36 | + | oscColorQuery = oscDefaultColorQuery + oscPaletteQuery | |||
| 35 | ) | 37 | ) | ||
| 36 | 38 | ||||
| 37 | // bufSize is the batched writer's buffer capacity. | 39 | // bufSize is the batched writer's buffer capacity. | ||
| @@ -235,7 +237,8 @@ func (t *Terminal) stopResizeLocked() { | |||||
| 235 | t.resizeWG.Wait() | 237 | t.resizeWG.Wait() | ||
| 236 | } | 238 | } | ||
| 237 | 239 | ||||
| 238 | - | // QueryColors re-emits OSC 10/11 queries for default foreground/background. | 240 | + | // QueryColors re-emits OSC 10/11 queries for default foreground/background |
| 241 | + | // followed by one batched OSC 4 query for palette slots 0 through 15. | |||
| 239 | func (t *Terminal) QueryColors() error { | 242 | func (t *Terminal) QueryColors() error { | ||
| 240 | t.mu.Lock() | 243 | t.mu.Lock() | ||
| 241 | defer t.mu.Unlock() | 244 | defer t.mu.Unlock() | ||
M internal/adapters/term/terminal_test.go +6 -4
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" | |
| 14 | + | ||
| 13 | 15 | // pipePair returns an os.Pipe and a goroutine that continuously copies | |
| 14 | 16 | // everything written to the write end into captured, closing done once | |
| 15 | 17 | // the write end is closed (EOF). | |
| @@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 60 | 62 | _ = outW.Close() | |
| 61 | 63 | <-done | |
| 62 | 64 | ||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 65 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 64 | 66 | if got := captured.String(); got != want { | |
| 65 | 67 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 66 | 68 | } | |
| @@ -101,7 +103,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 101 | 103 | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | |
| 102 | 104 | // cursor/alt-screen exits exactly once, regardless of how many times | |
| 103 | 105 | // EnterRaw/restore were called. | |
| 104 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 106 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 105 | 107 | if got := captured.String(); got != want { | |
| 106 | 108 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 107 | 109 | } | |
| @@ -124,8 +126,8 @@ func TestTerminal_QueryColors(t *testing.T) { | |||
| 124 | 126 | } | |
| 125 | 127 | _ = outW.Close() | |
| 126 | 128 | <-done | |
| 127 | - | if got := captured.String(); got != oscColorQuery { | |
| 128 | - | t.Fatalf("captured = %q, want %q", got, oscColorQuery) | |
| 129 | + | if got := captured.String(); got != wantColorQueries { | |
| 130 | + | t.Fatalf("captured = %q, want %q", got, wantColorQueries) | |
| 129 | 131 | } | |
| 130 | 132 | } | |
| 131 | 133 | ||
| @@ -10,6 +10,8 @@ import ( | |||||
| 10 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" | |||
| 14 | + | ||||
| 13 | // pipePair returns an os.Pipe and a goroutine that continuously copies | 15 | // pipePair returns an os.Pipe and a goroutine that continuously copies | ||
| 14 | // everything written to the write end into captured, closing done once | 16 | // everything written to the write end into captured, closing done once | ||
| 15 | // the write end is closed (EOF). | 17 | // the write end is closed (EOF). | ||
| @@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 60 | _ = outW.Close() | 62 | _ = outW.Close() | ||
| 61 | <-done | 63 | <-done | ||
| 62 | 64 | ||||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 65 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit |
| 64 | if got := captured.String(); got != want { | 66 | if got := captured.String(); got != want { | ||
| 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | 67 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 66 | } | 68 | } | ||
| @@ -101,7 +103,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||||
| 101 | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | 103 | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | ||
| 102 | // cursor/alt-screen exits exactly once, regardless of how many times | 104 | // cursor/alt-screen exits exactly once, regardless of how many times | ||
| 103 | // EnterRaw/restore were called. | 105 | // EnterRaw/restore were called. | ||
| 104 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 106 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit |
| 105 | if got := captured.String(); got != want { | 107 | if got := captured.String(); got != want { | ||
| 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | 108 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 107 | } | 109 | } | ||
| @@ -124,8 +126,8 @@ func TestTerminal_QueryColors(t *testing.T) { | |||||
| 124 | } | 126 | } | ||
| 125 | _ = outW.Close() | 127 | _ = outW.Close() | ||
| 126 | <-done | 128 | <-done | ||
| 127 | - | if got := captured.String(); got != oscColorQuery { | 129 | + | if got := captured.String(); got != wantColorQueries { |
| 128 | - | t.Fatalf("captured = %q, want %q", got, oscColorQuery) | 130 | + | t.Fatalf("captured = %q, want %q", got, wantColorQueries) |
| 129 | } | 131 | } | ||
| 130 | } | 132 | } | ||
| 131 | 133 | ||||
M internal/domain/config.go +3 -1
| @@ -81,6 +81,7 @@ type TabsConfig struct { | |||
| 81 | 81 | // layer can decide which actions it understands. | |
| 82 | 82 | type Config struct { | |
| 83 | 83 | Theme ThemeMode | |
| 84 | + | ThemePalette bool | |
| 84 | 85 | Bar BarConfig | |
| 85 | 86 | BindingEntries []ConfigEntry | |
| 86 | 87 | Codes map[string]string | |
| @@ -101,7 +102,8 @@ type Warning struct { | |||
| 101 | 102 | // Defaults returns vev's default configuration. | |
| 102 | 103 | func Defaults() Config { | |
| 103 | 104 | return Config{ | |
| 104 | - | Theme: ThemeAuto, | |
| 105 | + | Theme: ThemeAuto, | |
| 106 | + | ThemePalette: true, | |
| 105 | 107 | Bar: BarConfig{ | |
| 106 | 108 | TopRight: "vev-bar-top-right", | |
| 107 | 109 | BottomRight: "vev-bar-bottom-right", | |
| @@ -81,6 +81,7 @@ type TabsConfig struct { | |||||
| 81 | // layer can decide which actions it understands. | 81 | // layer can decide which actions it understands. | ||
| 82 | type Config struct { | 82 | type Config struct { | ||
| 83 | Theme ThemeMode | 83 | Theme ThemeMode | ||
| 84 | + | ThemePalette bool | |||
| 84 | Bar BarConfig | 85 | Bar BarConfig | ||
| 85 | BindingEntries []ConfigEntry | 86 | BindingEntries []ConfigEntry | ||
| 86 | Codes map[string]string | 87 | Codes map[string]string | ||
| @@ -101,7 +102,8 @@ type Warning struct { | |||||
| 101 | // Defaults returns vev's default configuration. | 102 | // Defaults returns vev's default configuration. | ||
| 102 | func Defaults() Config { | 103 | func Defaults() Config { | ||
| 103 | return Config{ | 104 | return Config{ | ||
| 104 | - | Theme: ThemeAuto, | 105 | + | Theme: ThemeAuto, |
| 106 | + | ThemePalette: true, | |||
| 105 | Bar: BarConfig{ | 107 | Bar: BarConfig{ | ||
| 106 | TopRight: "vev-bar-top-right", | 108 | TopRight: "vev-bar-top-right", | ||
| 107 | BottomRight: "vev-bar-bottom-right", | 109 | BottomRight: "vev-bar-bottom-right", | ||
M internal/ports/frame.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 15 | |
| 4 | + | const ProtocolVersion uint16 = 16 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 15 | 4 | + | const ProtocolVersion uint16 = 16 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 15 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 16 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 15 { | 6 | + | if ProtocolVersion != 16 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
M internal/ports/wire.go +42 -11
| @@ -85,8 +85,8 @@ type Resize struct { | |||
| 85 | 85 | Size domain.Size | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | // Theme reports the client's terminal foreground/background colors and | |
| 89 | - | // whether the client terminal supports truecolor. | |
| 88 | + | // Theme reports the client's terminal foreground/background colors, ANSI | |
| 89 | + | // palette entries, and whether the client terminal supports truecolor. | |
| 90 | 90 | type Theme struct { | |
| 91 | 91 | HasForeground bool | |
| 92 | 92 | Foreground renderer.RGB | |
| @@ -95,6 +95,8 @@ type Theme struct { | |||
| 95 | 95 | TrueColor bool | |
| 96 | 96 | SchemeKnown bool | |
| 97 | 97 | Light bool | |
| 98 | + | PaletteKnown uint16 | |
| 99 | + | Palette [16]renderer.RGB | |
| 98 | 100 | } | |
| 99 | 101 | ||
| 100 | 102 | // ImagePush carries a clipboard image from a remote client, to be written to | |
| @@ -464,7 +466,7 @@ func MarshalResize(m Resize) []byte { | |||
| 464 | 466 | return w.b | |
| 465 | 467 | } | |
| 466 | 468 | ||
| 467 | - | // MarshalTheme encodes m into a fixed-width Theme message payload. | |
| 469 | + | // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload. | |
| 468 | 470 | func MarshalTheme(m Theme) []byte { | |
| 469 | 471 | var flags uint8 | |
| 470 | 472 | if m.HasForeground { | |
| @@ -482,14 +484,25 @@ func MarshalTheme(m Theme) []byte { | |||
| 482 | 484 | if m.Light { | |
| 483 | 485 | flags |= 0x10 | |
| 484 | 486 | } | |
| 485 | - | return []byte{ | |
| 486 | - | flags, | |
| 487 | - | m.Foreground.R, m.Foreground.G, m.Foreground.B, | |
| 488 | - | m.Background.R, m.Background.G, m.Background.B, | |
| 487 | + | ||
| 488 | + | w := payloadWriter{b: make([]byte, 0, 57)} | |
| 489 | + | w.putUint8(flags) | |
| 490 | + | w.putUint8(m.Foreground.R) | |
| 491 | + | w.putUint8(m.Foreground.G) | |
| 492 | + | w.putUint8(m.Foreground.B) | |
| 493 | + | w.putUint8(m.Background.R) | |
| 494 | + | w.putUint8(m.Background.G) | |
| 495 | + | w.putUint8(m.Background.B) | |
| 496 | + | w.putUint16(m.PaletteKnown) | |
| 497 | + | for _, color := range m.Palette { | |
| 498 | + | w.putUint8(color.R) | |
| 499 | + | w.putUint8(color.G) | |
| 500 | + | w.putUint8(color.B) | |
| 489 | 501 | } | |
| 502 | + | return w.b | |
| 490 | 503 | } | |
| 491 | 504 | ||
| 492 | - | // UnmarshalTheme decodes a fixed-width Theme message payload. | |
| 505 | + | // UnmarshalTheme decodes a 57-byte fixed-width Theme message payload. | |
| 493 | 506 | func UnmarshalTheme(b []byte) (Theme, error) { | |
| 494 | 507 | r := payloadReader{b: b} | |
| 495 | 508 | flags, err := r.getUint8() | |
| @@ -520,10 +533,12 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||
| 520 | 533 | if err != nil { | |
| 521 | 534 | return Theme{}, err | |
| 522 | 535 | } | |
| 523 | - | if err := r.done(); err != nil { | |
| 536 | + | paletteKnown, err := r.getUint16() | |
| 537 | + | if err != nil { | |
| 524 | 538 | return Theme{}, err | |
| 525 | 539 | } | |
| 526 | - | return Theme{ | |
| 540 | + | ||
| 541 | + | m := Theme{ | |
| 527 | 542 | HasForeground: flags&0x01 != 0, | |
| 528 | 543 | Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB}, | |
| 529 | 544 | HasBackground: flags&0x02 != 0, | |
| @@ -531,7 +546,23 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||
| 531 | 546 | TrueColor: flags&0x04 != 0, | |
| 532 | 547 | SchemeKnown: flags&0x08 != 0, | |
| 533 | 548 | Light: flags&0x10 != 0, | |
| 534 | - | }, nil | |
| 549 | + | PaletteKnown: paletteKnown, | |
| 550 | + | } | |
| 551 | + | for i := range m.Palette { | |
| 552 | + | if m.Palette[i].R, err = r.getUint8(); err != nil { | |
| 553 | + | return Theme{}, err | |
| 554 | + | } | |
| 555 | + | if m.Palette[i].G, err = r.getUint8(); err != nil { | |
| 556 | + | return Theme{}, err | |
| 557 | + | } | |
| 558 | + | if m.Palette[i].B, err = r.getUint8(); err != nil { | |
| 559 | + | return Theme{}, err | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | if err := r.done(); err != nil { | |
| 563 | + | return Theme{}, err | |
| 564 | + | } | |
| 565 | + | return m, nil | |
| 535 | 566 | } | |
| 536 | 567 | ||
| 537 | 568 | // UnmarshalResize decodes a Resize message payload. | |
| @@ -85,8 +85,8 @@ type Resize struct { | |||||
| 85 | Size domain.Size | 85 | Size domain.Size | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | // Theme reports the client's terminal foreground/background colors and | 88 | + | // Theme reports the client's terminal foreground/background colors, ANSI |
| 89 | - | // whether the client terminal supports truecolor. | 89 | + | // palette entries, and whether the client terminal supports truecolor. |
| 90 | type Theme struct { | 90 | type Theme struct { | ||
| 91 | HasForeground bool | 91 | HasForeground bool | ||
| 92 | Foreground renderer.RGB | 92 | Foreground renderer.RGB | ||
| @@ -95,6 +95,8 @@ type Theme struct { | |||||
| 95 | TrueColor bool | 95 | TrueColor bool | ||
| 96 | SchemeKnown bool | 96 | SchemeKnown bool | ||
| 97 | Light bool | 97 | Light bool | ||
| 98 | + | PaletteKnown uint16 | |||
| 99 | + | Palette [16]renderer.RGB | |||
| 98 | } | 100 | } | ||
| 99 | 101 | ||||
| 100 | // ImagePush carries a clipboard image from a remote client, to be written to | 102 | // ImagePush carries a clipboard image from a remote client, to be written to | ||
| @@ -464,7 +466,7 @@ func MarshalResize(m Resize) []byte { | |||||
| 464 | return w.b | 466 | return w.b | ||
| 465 | } | 467 | } | ||
| 466 | 468 | ||||
| 467 | - | // MarshalTheme encodes m into a fixed-width Theme message payload. | 469 | + | // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload. |
| 468 | func MarshalTheme(m Theme) []byte { | 470 | func MarshalTheme(m Theme) []byte { | ||
| 469 | var flags uint8 | 471 | var flags uint8 | ||
| 470 | if m.HasForeground { | 472 | if m.HasForeground { | ||
| @@ -482,14 +484,25 @@ func MarshalTheme(m Theme) []byte { | |||||
| 482 | if m.Light { | 484 | if m.Light { | ||
| 483 | flags |= 0x10 | 485 | flags |= 0x10 | ||
| 484 | } | 486 | } | ||
| 485 | - | return []byte{ | 487 | + | |
| 486 | - | flags, | 488 | + | w := payloadWriter{b: make([]byte, 0, 57)} |
| 487 | - | m.Foreground.R, m.Foreground.G, m.Foreground.B, | 489 | + | w.putUint8(flags) |
| 488 | - | m.Background.R, m.Background.G, m.Background.B, | 490 | + | w.putUint8(m.Foreground.R) |
| 491 | + | w.putUint8(m.Foreground.G) | |||
| 492 | + | w.putUint8(m.Foreground.B) | |||
| 493 | + | w.putUint8(m.Background.R) | |||
| 494 | + | w.putUint8(m.Background.G) | |||
| 495 | + | w.putUint8(m.Background.B) | |||
| 496 | + | w.putUint16(m.PaletteKnown) | |||
| 497 | + | for _, color := range m.Palette { | |||
| 498 | + | w.putUint8(color.R) | |||
| 499 | + | w.putUint8(color.G) | |||
| 500 | + | w.putUint8(color.B) | |||
| 489 | } | 501 | } | ||
| 502 | + | return w.b | |||
| 490 | } | 503 | } | ||
| 491 | 504 | ||||
| 492 | - | // UnmarshalTheme decodes a fixed-width Theme message payload. | 505 | + | // UnmarshalTheme decodes a 57-byte fixed-width Theme message payload. |
| 493 | func UnmarshalTheme(b []byte) (Theme, error) { | 506 | func UnmarshalTheme(b []byte) (Theme, error) { | ||
| 494 | r := payloadReader{b: b} | 507 | r := payloadReader{b: b} | ||
| 495 | flags, err := r.getUint8() | 508 | flags, err := r.getUint8() | ||
| @@ -520,10 +533,12 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||||
| 520 | if err != nil { | 533 | if err != nil { | ||
| 521 | return Theme{}, err | 534 | return Theme{}, err | ||
| 522 | } | 535 | } | ||
| 523 | - | if err := r.done(); err != nil { | 536 | + | paletteKnown, err := r.getUint16() |
| 537 | + | if err != nil { | |||
| 524 | return Theme{}, err | 538 | return Theme{}, err | ||
| 525 | } | 539 | } | ||
| 526 | - | return Theme{ | 540 | + | |
| 541 | + | m := Theme{ | |||
| 527 | HasForeground: flags&0x01 != 0, | 542 | HasForeground: flags&0x01 != 0, | ||
| 528 | Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB}, | 543 | Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB}, | ||
| 529 | HasBackground: flags&0x02 != 0, | 544 | HasBackground: flags&0x02 != 0, | ||
| @@ -531,7 +546,23 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||||
| 531 | TrueColor: flags&0x04 != 0, | 546 | TrueColor: flags&0x04 != 0, | ||
| 532 | SchemeKnown: flags&0x08 != 0, | 547 | SchemeKnown: flags&0x08 != 0, | ||
| 533 | Light: flags&0x10 != 0, | 548 | Light: flags&0x10 != 0, | ||
| 534 | - | }, nil | 549 | + | PaletteKnown: paletteKnown, |
| 550 | + | } | |||
| 551 | + | for i := range m.Palette { | |||
| 552 | + | if m.Palette[i].R, err = r.getUint8(); err != nil { | |||
| 553 | + | return Theme{}, err | |||
| 554 | + | } | |||
| 555 | + | if m.Palette[i].G, err = r.getUint8(); err != nil { | |||
| 556 | + | return Theme{}, err | |||
| 557 | + | } | |||
| 558 | + | if m.Palette[i].B, err = r.getUint8(); err != nil { | |||
| 559 | + | return Theme{}, err | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | if err := r.done(); err != nil { | |||
| 563 | + | return Theme{}, err | |||
| 564 | + | } | |||
| 565 | + | return m, nil | |||
| 535 | } | 566 | } | ||
| 536 | 567 | ||||
| 537 | // UnmarshalResize decodes a Resize message payload. | 568 | // UnmarshalResize decodes a Resize message payload. | ||
M internal/ports/wire_test.go +45 -29
| @@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 121 | 121 | Env: []string{"A=B", "XY=123"}, | |
| 122 | 122 | }) | |
| 123 | 123 | want := []byte{ | |
| 124 | - | 0x00, 0x0f, 0x00, // version, intent | |
| 124 | + | 0x00, 0x10, 0x00, // version, intent | |
| 125 | 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 126 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 127 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -244,63 +244,79 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | 246 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |
| 247 | + | palette := [16]renderer.RGB{ | |
| 248 | + | {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15}, | |
| 249 | + | {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b}, | |
| 250 | + | {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21}, | |
| 251 | + | {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27}, | |
| 252 | + | {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d}, | |
| 253 | + | {R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33}, | |
| 254 | + | {R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39}, | |
| 255 | + | {R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f}, | |
| 256 | + | } | |
| 257 | + | zeroes := make([]byte, 57) | |
| 247 | 258 | tests := []struct { | |
| 248 | 259 | name string | |
| 249 | 260 | msg Theme | |
| 250 | 261 | want []byte | |
| 251 | 262 | }{ | |
| 252 | 263 | { | |
| 253 | - | name: "foreground background truecolor dark scheme", | |
| 264 | + | name: "empty", | |
| 265 | + | msg: Theme{}, | |
| 266 | + | want: zeroes, | |
| 267 | + | }, | |
| 268 | + | { | |
| 269 | + | name: "foreground only", | |
| 254 | 270 | msg: Theme{ | |
| 255 | 271 | HasForeground: true, | |
| 256 | - | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 257 | - | HasBackground: true, | |
| 258 | - | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 259 | - | TrueColor: true, | |
| 260 | - | SchemeKnown: true, | |
| 272 | + | Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 273 | + | Background: renderer.RGB{R: 40, G: 50, B: 60}, | |
| 261 | 274 | }, | |
| 262 | - | want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, | |
| 275 | + | want: append([]byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, zeroes[7:]...), | |
| 263 | 276 | }, | |
| 264 | 277 | { | |
| 265 | - | name: "light scheme bit without known is preserved", | |
| 278 | + | name: "light flag without known scheme", | |
| 266 | 279 | msg: Theme{ | |
| 267 | 280 | Light: true, | |
| 268 | 281 | }, | |
| 269 | - | want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 282 | + | want: append([]byte{0x10}, zeroes[1:]...), | |
| 270 | 283 | }, | |
| 271 | 284 | { | |
| 272 | - | name: "foreground only no truecolor", | |
| 285 | + | name: "full palette", | |
| 273 | 286 | msg: Theme{ | |
| 274 | 287 | HasForeground: true, | |
| 275 | - | Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 276 | - | Background: renderer.RGB{R: 40, G: 50, B: 60}, | |
| 288 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 289 | + | HasBackground: true, | |
| 290 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 291 | + | TrueColor: true, | |
| 292 | + | SchemeKnown: true, | |
| 293 | + | PaletteKnown: 0x8001, | |
| 294 | + | Palette: palette, | |
| 295 | + | }, | |
| 296 | + | want: []byte{ | |
| 297 | + | 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01, | |
| 298 | + | 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, | |
| 299 | + | 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, | |
| 300 | + | 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, | |
| 301 | + | 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, | |
| 302 | + | 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, | |
| 303 | + | 0x3d, 0x3e, 0x3f, | |
| 277 | 304 | }, | |
| 278 | - | want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, | |
| 279 | - | }, | |
| 280 | - | { | |
| 281 | - | name: "empty", | |
| 282 | - | msg: Theme{}, | |
| 283 | - | want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 284 | 305 | }, | |
| 285 | 306 | } | |
| 286 | 307 | ||
| 287 | 308 | for _, tt := range tests { | |
| 288 | 309 | t.Run(tt.name, func(t *testing.T) { | |
| 289 | 310 | got := MarshalTheme(tt.msg) | |
| 290 | - | if !bytes.Equal(got, tt.want) { | |
| 291 | - | t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want) | |
| 292 | - | } | |
| 311 | + | require.Len(t, got, 57) | |
| 312 | + | require.Equal(t, tt.want, got) | |
| 293 | 313 | back, err := UnmarshalTheme(got) | |
| 294 | - | if err != nil { | |
| 295 | - | t.Fatalf("UnmarshalTheme() error = %v", err) | |
| 296 | - | } | |
| 297 | - | if !reflect.DeepEqual(back, tt.msg) { | |
| 298 | - | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |
| 299 | - | } | |
| 314 | + | require.NoError(t, err) | |
| 315 | + | require.Equal(t, tt.msg, back) | |
| 300 | 316 | }) | |
| 301 | 317 | } | |
| 302 | 318 | ||
| 303 | - | full := MarshalTheme(tests[0].msg) | |
| 319 | + | full := MarshalTheme(tests[len(tests)-1].msg) | |
| 304 | 320 | assertAllPrefixesFail(t, full, UnmarshalTheme) | |
| 305 | 321 | assertTrailingGarbageFails(t, full, UnmarshalTheme) | |
| 306 | 322 | } | |
| @@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 121 | Env: []string{"A=B", "XY=123"}, | 121 | Env: []string{"A=B", "XY=123"}, | ||
| 122 | }) | 122 | }) | ||
| 123 | want := []byte{ | 123 | want := []byte{ | ||
| 124 | - | 0x00, 0x0f, 0x00, // version, intent | 124 | + | 0x00, 0x10, 0x00, // version, intent |
| 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -244,63 +244,79 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||||
| 244 | } | 244 | } | ||
| 245 | 245 | ||||
| 246 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | 246 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | ||
| 247 | + | palette := [16]renderer.RGB{ | |||
| 248 | + | {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15}, | |||
| 249 | + | {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b}, | |||
| 250 | + | {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21}, | |||
| 251 | + | {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27}, | |||
| 252 | + | {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d}, | |||
| 253 | + | {R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33}, | |||
| 254 | + | {R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39}, | |||
| 255 | + | {R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f}, | |||
| 256 | + | } | |||
| 257 | + | zeroes := make([]byte, 57) | |||
| 247 | tests := []struct { | 258 | tests := []struct { | ||
| 248 | name string | 259 | name string | ||
| 249 | msg Theme | 260 | msg Theme | ||
| 250 | want []byte | 261 | want []byte | ||
| 251 | }{ | 262 | }{ | ||
| 252 | { | 263 | { | ||
| 253 | - | name: "foreground background truecolor dark scheme", | 264 | + | name: "empty", |
| 265 | + | msg: Theme{}, | |||
| 266 | + | want: zeroes, | |||
| 267 | + | }, | |||
| 268 | + | { | |||
| 269 | + | name: "foreground only", | |||
| 254 | msg: Theme{ | 270 | msg: Theme{ | ||
| 255 | HasForeground: true, | 271 | HasForeground: true, | ||
| 256 | - | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 272 | + | Foreground: renderer.RGB{R: 10, G: 20, B: 30}, |
| 257 | - | HasBackground: true, | 273 | + | Background: renderer.RGB{R: 40, G: 50, B: 60}, |
| 258 | - | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 259 | - | TrueColor: true, | |||
| 260 | - | SchemeKnown: true, | |||
| 261 | }, | 274 | }, | ||
| 262 | - | want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, | 275 | + | want: append([]byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, zeroes[7:]...), |
| 263 | }, | 276 | }, | ||
| 264 | { | 277 | { | ||
| 265 | - | name: "light scheme bit without known is preserved", | 278 | + | name: "light flag without known scheme", |
| 266 | msg: Theme{ | 279 | msg: Theme{ | ||
| 267 | Light: true, | 280 | Light: true, | ||
| 268 | }, | 281 | }, | ||
| 269 | - | want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | 282 | + | want: append([]byte{0x10}, zeroes[1:]...), |
| 270 | }, | 283 | }, | ||
| 271 | { | 284 | { | ||
| 272 | - | name: "foreground only no truecolor", | 285 | + | name: "full palette", |
| 273 | msg: Theme{ | 286 | msg: Theme{ | ||
| 274 | HasForeground: true, | 287 | HasForeground: true, | ||
| 275 | - | Foreground: renderer.RGB{R: 10, G: 20, B: 30}, | 288 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, |
| 276 | - | Background: renderer.RGB{R: 40, G: 50, B: 60}, | 289 | + | HasBackground: true, |
| 290 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 291 | + | TrueColor: true, | |||
| 292 | + | SchemeKnown: true, | |||
| 293 | + | PaletteKnown: 0x8001, | |||
| 294 | + | Palette: palette, | |||
| 295 | + | }, | |||
| 296 | + | want: []byte{ | |||
| 297 | + | 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01, | |||
| 298 | + | 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, | |||
| 299 | + | 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, | |||
| 300 | + | 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, | |||
| 301 | + | 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, | |||
| 302 | + | 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, | |||
| 303 | + | 0x3d, 0x3e, 0x3f, | |||
| 277 | }, | 304 | }, | ||
| 278 | - | want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c}, | |||
| 279 | - | }, | |||
| 280 | - | { | |||
| 281 | - | name: "empty", | |||
| 282 | - | msg: Theme{}, | |||
| 283 | - | want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |||
| 284 | }, | 305 | }, | ||
| 285 | } | 306 | } | ||
| 286 | 307 | ||||
| 287 | for _, tt := range tests { | 308 | for _, tt := range tests { | ||
| 288 | t.Run(tt.name, func(t *testing.T) { | 309 | t.Run(tt.name, func(t *testing.T) { | ||
| 289 | got := MarshalTheme(tt.msg) | 310 | got := MarshalTheme(tt.msg) | ||
| 290 | - | if !bytes.Equal(got, tt.want) { | 311 | + | require.Len(t, got, 57) |
| 291 | - | t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want) | 312 | + | require.Equal(t, tt.want, got) |
| 292 | - | } | |||
| 293 | back, err := UnmarshalTheme(got) | 313 | back, err := UnmarshalTheme(got) | ||
| 294 | - | if err != nil { | 314 | + | require.NoError(t, err) |
| 295 | - | t.Fatalf("UnmarshalTheme() error = %v", err) | 315 | + | require.Equal(t, tt.msg, back) |
| 296 | - | } | |||
| 297 | - | if !reflect.DeepEqual(back, tt.msg) { | |||
| 298 | - | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |||
| 299 | - | } | |||
| 300 | }) | 316 | }) | ||
| 301 | } | 317 | } | ||
| 302 | 318 | ||||
| 303 | - | full := MarshalTheme(tests[0].msg) | 319 | + | full := MarshalTheme(tests[len(tests)-1].msg) |
| 304 | assertAllPrefixesFail(t, full, UnmarshalTheme) | 320 | assertAllPrefixesFail(t, full, UnmarshalTheme) | ||
| 305 | assertTrailingGarbageFails(t, full, UnmarshalTheme) | 321 | assertTrailingGarbageFails(t, full, UnmarshalTheme) | ||
| 306 | } | 322 | } | ||
M internal/usecase/client/client.go +203 -23
| @@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme { | |||
| 71 | 71 | return s.theme | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |
| 74 | + | func (s *terminalThemeState) snapshot() ports.Theme { | |
| 75 | 75 | s.mu.Lock() | |
| 76 | 76 | defer s.mu.Unlock() | |
| 77 | - | theme := s.theme | |
| 78 | - | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown | |
| 77 | + | return s.theme | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // clearPalette invalidates entries from a previous terminal color query while | |
| 81 | + | // retaining independently reported foreground, background, and scheme data. | |
| 82 | + | func (s *terminalThemeState) clearPalette() ports.Theme { | |
| 83 | + | return s.update(func(theme *ports.Theme) { | |
| 84 | + | theme.PaletteKnown = 0 | |
| 85 | + | theme.Palette = [16]renderer.RGB{} | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |
| 90 | + | theme := s.snapshot() | |
| 91 | + | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0 | |
| 79 | 92 | } | |
| 80 | 93 | ||
| 81 | 94 | // milestones tracks lifecycle progress so a failure can report which stages | |
| @@ -588,16 +601,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 588 | 601 | ackQueue := newCumulativeAckQueue() | |
| 589 | 602 | sendErrCh := make(chan error, 1) | |
| 590 | 603 | ||
| 591 | - | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | |
| 592 | - | // the main loop, which is the only goroutine allowed to touch term.Out()/ | |
| 593 | - | // term.Flush(); QueryColors writes through the same batched writer, so | |
| 594 | - | // calling it directly from the stdin pump would race the main loop's | |
| 595 | - | // output writes. | |
| 596 | - | colorQueryCh := make(chan struct{}, 1) | |
| 597 | - | requestColors := func() { | |
| 604 | + | // Palette re-queries use a private-mode query as an ordered boundary. The | |
| 605 | + | // stdin pump drains terminal replies through its mode-specific response | |
| 606 | + | // before it permits the main loop to publish the cleared scheme and issue | |
| 607 | + | // QueryColors. This separates bytes already queued (including | |
| 608 | + | // Scanner.pending) from replacement replies without allowing a second | |
| 609 | + | // goroutine to write to the terminal. | |
| 610 | + | colorQueryCh := make(chan colorQueryRequest) | |
| 611 | + | paletteReadyCh := make(chan paletteQueryReady) | |
| 612 | + | requestColors := func() (colorQueryRequest, bool) { | |
| 613 | + | request := colorQueryRequest{boundaryWritten: make(chan struct{})} | |
| 598 | 614 | select { | |
| 599 | - | case colorQueryCh <- struct{}{}: | |
| 600 | - | default: | |
| 615 | + | case colorQueryCh <- request: | |
| 616 | + | case <-loopCtx.Done(): | |
| 617 | + | return colorQueryRequest{}, false | |
| 618 | + | } | |
| 619 | + | select { | |
| 620 | + | case <-request.boundaryWritten: | |
| 621 | + | return request, true | |
| 622 | + | case <-loopCtx.Done(): | |
| 623 | + | return colorQueryRequest{}, false | |
| 601 | 624 | } | |
| 602 | 625 | } | |
| 603 | 626 | ||
| @@ -606,7 +629,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 606 | 629 | clip = clipboard | |
| 607 | 630 | } | |
| 608 | 631 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | |
| 609 | - | go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors}).run() | |
| 632 | + | go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run() | |
| 610 | 633 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 611 | 634 | ||
| 612 | 635 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -645,10 +668,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 645 | 668 | // token. Run closes this transport on the way out. | |
| 646 | 669 | return welcomedResult(errLinkOffline) | |
| 647 | 670 | } | |
| 648 | - | case <-colorQueryCh: | |
| 671 | + | case request := <-colorQueryCh: | |
| 672 | + | if _, err := term.Out().Write(paletteBoundaryQuery); err != nil { | |
| 673 | + | return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err)) | |
| 674 | + | } | |
| 675 | + | if err := term.Flush(); err != nil { | |
| 676 | + | return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err)) | |
| 677 | + | } | |
| 678 | + | close(request.boundaryWritten) | |
| 679 | + | case ready := <-paletteReadyCh: | |
| 680 | + | // The private-mode response proves all earlier replies have been | |
| 681 | + | // discarded by stdin. Publish the one cleared scheme snapshot before | |
| 682 | + | // emitting the query; stdin remains paused until QueryColors has | |
| 683 | + | // completed below. | |
| 684 | + | clearedTheme := themeState.snapshot() | |
| 685 | + | select { | |
| 686 | + | case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}: | |
| 687 | + | case <-loopCtx.Done(): | |
| 688 | + | return welcomedResult(nil) | |
| 689 | + | } | |
| 649 | 690 | if err := term.QueryColors(); err != nil { | |
| 650 | 691 | log.Warn("querying terminal colors", "err", err) | |
| 651 | 692 | } | |
| 693 | + | close(ready.queryIssued) | |
| 652 | 694 | case r := <-recvCh: | |
| 653 | 695 | if r.err != nil { | |
| 654 | 696 | if errors.Is(r.err, io.EOF) { | |
| @@ -806,6 +848,91 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 806 | 848 | // exits. That is harmless here — Run has already returned and restored the | |
| 807 | 849 | // terminal — and matches the standard pattern for stdin pumps; a | |
| 808 | 850 | // closable stdin duplicate could lift it later if ever needed. | |
| 851 | + | type colorQueryRequest struct { | |
| 852 | + | boundaryWritten chan struct{} | |
| 853 | + | } | |
| 854 | + | ||
| 855 | + | type paletteQueryReady struct { | |
| 856 | + | queryIssued chan struct{} | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | type paletteDrain struct { | |
| 860 | + | pending []byte | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | type palettePhase uint8 | |
| 864 | + | ||
| 865 | + | const ( | |
| 866 | + | paletteAccepting palettePhase = iota | |
| 867 | + | paletteBoundaryRequested | |
| 868 | + | paletteDraining | |
| 869 | + | paletteAwaitingQuery | |
| 870 | + | ) | |
| 871 | + | ||
| 872 | + | var paletteBoundaryQuery = []byte("\x1b[?2031$p") | |
| 873 | + | ||
| 874 | + | // paletteBoundaryResponses accepts valid DECRQM status values for private | |
| 875 | + | // mode 2031. The private mode number correlates this reply with the query | |
| 876 | + | // that starts a replacement palette generation. | |
| 877 | + | var paletteBoundaryResponses = [][]byte{ | |
| 878 | + | []byte("\x1b[?2031;0$y"), | |
| 879 | + | []byte("\x1b[?2031;1$y"), | |
| 880 | + | []byte("\x1b[?2031;2$y"), | |
| 881 | + | []byte("\x1b[?2031;3$y"), | |
| 882 | + | []byte("\x1b[?2031;4$y"), | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | // scan consumes the private-mode boundary response while forwarding every | |
| 886 | + | // other byte in order. It retains only a possible response prefix, so split | |
| 887 | + | // replies are consumed without dropping a user's partial escape sequence on | |
| 888 | + | // cancellation. | |
| 889 | + | func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) { | |
| 890 | + | for _, b := range data { | |
| 891 | + | d.pending = append(d.pending, b) | |
| 892 | + | for len(d.pending) > 0 { | |
| 893 | + | matched := false | |
| 894 | + | possible := false | |
| 895 | + | for _, response := range paletteBoundaryResponses { | |
| 896 | + | if len(d.pending) > len(response) { | |
| 897 | + | continue | |
| 898 | + | } | |
| 899 | + | match := true | |
| 900 | + | for i := range d.pending { | |
| 901 | + | if d.pending[i] != response[i] { | |
| 902 | + | match = false | |
| 903 | + | break | |
| 904 | + | } | |
| 905 | + | } | |
| 906 | + | if !match { | |
| 907 | + | continue | |
| 908 | + | } | |
| 909 | + | if len(d.pending) == len(response) { | |
| 910 | + | matched = true | |
| 911 | + | } else { | |
| 912 | + | possible = true | |
| 913 | + | } | |
| 914 | + | } | |
| 915 | + | if matched { | |
| 916 | + | d.pending = nil | |
| 917 | + | onBoundary() | |
| 918 | + | break | |
| 919 | + | } | |
| 920 | + | if possible { | |
| 921 | + | break | |
| 922 | + | } | |
| 923 | + | onBytes(d.pending[:1]) | |
| 924 | + | d.pending = d.pending[1:] | |
| 925 | + | } | |
| 926 | + | } | |
| 927 | + | } | |
| 928 | + | ||
| 929 | + | func (d *paletteDrain) flush(onBytes func([]byte)) { | |
| 930 | + | if len(d.pending) != 0 { | |
| 931 | + | onBytes(d.pending) | |
| 932 | + | d.pending = nil | |
| 933 | + | } | |
| 934 | + | } | |
| 935 | + | ||
| 809 | 936 | type stdinPump struct { | |
| 810 | 937 | ctx context.Context | |
| 811 | 938 | cancel context.CancelFunc | |
| @@ -815,7 +942,8 @@ type stdinPump struct { | |||
| 815 | 942 | themeState *terminalThemeState | |
| 816 | 943 | clipboard ports.ClipboardReader | |
| 817 | 944 | logger *slog.Logger | |
| 818 | - | requestColors func() | |
| 945 | + | requestColors func() (colorQueryRequest, bool) | |
| 946 | + | paletteReady chan<- paletteQueryReady | |
| 819 | 947 | } | |
| 820 | 948 | ||
| 821 | 949 | func (p *stdinPump) run() { | |
| @@ -873,14 +1001,38 @@ func (p *stdinPump) run() { | |||
| 873 | 1001 | sink = ci.Scan | |
| 874 | 1002 | } | |
| 875 | 1003 | ||
| 1004 | + | phase := paletteAccepting | |
| 1005 | + | var drain paletteDrain | |
| 876 | 1006 | for { | |
| 877 | 1007 | n, rerr := in.Read(buf) | |
| 878 | 1008 | if n > 0 { | |
| 879 | - | sendTheme := func(current ports.Theme) { | |
| 880 | - | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 1009 | + | themeChanged := false | |
| 1010 | + | queryNeeded := false | |
| 1011 | + | onBytes := func(data []byte) { | |
| 1012 | + | if phase != paletteDraining { | |
| 1013 | + | sink(data) | |
| 1014 | + | return | |
| 1015 | + | } | |
| 1016 | + | drain.scan(data, sink, func() { | |
| 1017 | + | phase = paletteAwaitingQuery | |
| 1018 | + | ready := paletteQueryReady{queryIssued: make(chan struct{})} | |
| 1019 | + | select { | |
| 1020 | + | case p.paletteReady <- ready: | |
| 1021 | + | case <-ctx.Done(): | |
| 1022 | + | return | |
| 1023 | + | } | |
| 1024 | + | select { | |
| 1025 | + | case <-ready.queryIssued: | |
| 1026 | + | phase = paletteAccepting | |
| 1027 | + | case <-ctx.Done(): | |
| 1028 | + | } | |
| 1029 | + | }) | |
| 881 | 1030 | } | |
| 882 | 1031 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 883 | - | current := themeState.update(func(current *ports.Theme) { | |
| 1032 | + | if phase != paletteAccepting { | |
| 1033 | + | return | |
| 1034 | + | } | |
| 1035 | + | themeState.update(func(current *ports.Theme) { | |
| 884 | 1036 | switch kind { | |
| 885 | 1037 | case 10: | |
| 886 | 1038 | current.HasForeground = true | |
| @@ -890,22 +1042,50 @@ func (p *stdinPump) run() { | |||
| 890 | 1042 | current.Background = rgb | |
| 891 | 1043 | } | |
| 892 | 1044 | }) | |
| 893 | - | sendTheme(current) | |
| 1045 | + | themeChanged = true | |
| 1046 | + | }, func(slot int, rgb renderer.RGB) { | |
| 1047 | + | if phase != paletteAccepting { | |
| 1048 | + | return | |
| 1049 | + | } | |
| 1050 | + | themeState.update(func(current *ports.Theme) { | |
| 1051 | + | current.PaletteKnown |= uint16(1) << slot | |
| 1052 | + | current.Palette[slot] = rgb | |
| 1053 | + | }) | |
| 1054 | + | themeChanged = true | |
| 894 | 1055 | }, func(light bool) { | |
| 895 | - | current := themeState.update(func(current *ports.Theme) { | |
| 1056 | + | themeState.update(func(current *ports.Theme) { | |
| 896 | 1057 | current.SchemeKnown = true | |
| 897 | 1058 | current.Light = light | |
| 898 | 1059 | }) | |
| 1060 | + | // The scheme itself begins a new palette generation. Suppress every | |
| 1061 | + | // color reply after it, including those remaining in this read, and | |
| 1062 | + | // defer invalidation until Scan completes so no partial snapshot is | |
| 1063 | + | // observable mid-chunk. | |
| 1064 | + | themeChanged = false | |
| 1065 | + | if phase == paletteAccepting { | |
| 1066 | + | phase = paletteBoundaryRequested | |
| 1067 | + | queryNeeded = true | |
| 1068 | + | } | |
| 1069 | + | }, onBytes) | |
| 1070 | + | if queryNeeded { | |
| 1071 | + | themeState.clearPalette() | |
| 899 | 1072 | if requestColors != nil { | |
| 900 | - | requestColors() | |
| 1073 | + | if _, ok := requestColors(); !ok { | |
| 1074 | + | return | |
| 1075 | + | } | |
| 1076 | + | phase = paletteDraining | |
| 901 | 1077 | } | |
| 902 | - | sendTheme(current) | |
| 903 | - | }, sink) | |
| 1078 | + | } else if themeChanged { | |
| 1079 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())}) | |
| 1080 | + | } | |
| 904 | 1081 | if !sendOK.Load() { | |
| 905 | 1082 | return | |
| 906 | 1083 | } | |
| 907 | 1084 | } | |
| 908 | 1085 | if rerr != nil { | |
| 1086 | + | if phase == paletteDraining { | |
| 1087 | + | drain.flush(sink) | |
| 1088 | + | } | |
| 909 | 1089 | // Best-effort detach notification, then unwind. | |
| 910 | 1090 | select { | |
| 911 | 1091 | case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}: | |
| @@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme { | |||||
| 71 | return s.theme | 71 | return s.theme | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | - | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | 74 | + | func (s *terminalThemeState) snapshot() ports.Theme { |
| 75 | s.mu.Lock() | 75 | s.mu.Lock() | ||
| 76 | defer s.mu.Unlock() | 76 | defer s.mu.Unlock() | ||
| 77 | - | theme := s.theme | 77 | + | return s.theme |
| 78 | - | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown | 78 | + | } |
| 79 | + | ||||
| 80 | + | // clearPalette invalidates entries from a previous terminal color query while | |||
| 81 | + | // retaining independently reported foreground, background, and scheme data. | |||
| 82 | + | func (s *terminalThemeState) clearPalette() ports.Theme { | |||
| 83 | + | return s.update(func(theme *ports.Theme) { | |||
| 84 | + | theme.PaletteKnown = 0 | |||
| 85 | + | theme.Palette = [16]renderer.RGB{} | |||
| 86 | + | }) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |||
| 90 | + | theme := s.snapshot() | |||
| 91 | + | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0 | |||
| 79 | } | 92 | } | ||
| 80 | 93 | ||||
| 81 | // milestones tracks lifecycle progress so a failure can report which stages | 94 | // milestones tracks lifecycle progress so a failure can report which stages | ||
| @@ -588,16 +601,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 588 | ackQueue := newCumulativeAckQueue() | 601 | ackQueue := newCumulativeAckQueue() | ||
| 589 | sendErrCh := make(chan error, 1) | 602 | sendErrCh := make(chan error, 1) | ||
| 590 | 603 | ||||
| 591 | - | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | 604 | + | // Palette re-queries use a private-mode query as an ordered boundary. The |
| 592 | - | // the main loop, which is the only goroutine allowed to touch term.Out()/ | 605 | + | // stdin pump drains terminal replies through its mode-specific response |
| 593 | - | // term.Flush(); QueryColors writes through the same batched writer, so | 606 | + | // before it permits the main loop to publish the cleared scheme and issue |
| 594 | - | // calling it directly from the stdin pump would race the main loop's | 607 | + | // QueryColors. This separates bytes already queued (including |
| 595 | - | // output writes. | 608 | + | // Scanner.pending) from replacement replies without allowing a second |
| 596 | - | colorQueryCh := make(chan struct{}, 1) | 609 | + | // goroutine to write to the terminal. |
| 597 | - | requestColors := func() { | 610 | + | colorQueryCh := make(chan colorQueryRequest) |
| 611 | + | paletteReadyCh := make(chan paletteQueryReady) | |||
| 612 | + | requestColors := func() (colorQueryRequest, bool) { | |||
| 613 | + | request := colorQueryRequest{boundaryWritten: make(chan struct{})} | |||
| 598 | select { | 614 | select { | ||
| 599 | - | case colorQueryCh <- struct{}{}: | 615 | + | case colorQueryCh <- request: |
| 600 | - | default: | 616 | + | case <-loopCtx.Done(): |
| 617 | + | return colorQueryRequest{}, false | |||
| 618 | + | } | |||
| 619 | + | select { | |||
| 620 | + | case <-request.boundaryWritten: | |||
| 621 | + | return request, true | |||
| 622 | + | case <-loopCtx.Done(): | |||
| 623 | + | return colorQueryRequest{}, false | |||
| 601 | } | 624 | } | ||
| 602 | } | 625 | } | ||
| 603 | 626 | ||||
| @@ -606,7 +629,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 606 | clip = clipboard | 629 | clip = clipboard | ||
| 607 | } | 630 | } | ||
| 608 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | 631 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | ||
| 609 | - | go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors}).run() | 632 | + | go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run() |
| 610 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | 633 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | ||
| 611 | 634 | ||||
| 612 | // 5. Output/main loop: the only goroutine that touches the terminal. | 635 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -645,10 +668,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 645 | // token. Run closes this transport on the way out. | 668 | // token. Run closes this transport on the way out. | ||
| 646 | return welcomedResult(errLinkOffline) | 669 | return welcomedResult(errLinkOffline) | ||
| 647 | } | 670 | } | ||
| 648 | - | case <-colorQueryCh: | 671 | + | case request := <-colorQueryCh: |
| 672 | + | if _, err := term.Out().Write(paletteBoundaryQuery); err != nil { | |||
| 673 | + | return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err)) | |||
| 674 | + | } | |||
| 675 | + | if err := term.Flush(); err != nil { | |||
| 676 | + | return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err)) | |||
| 677 | + | } | |||
| 678 | + | close(request.boundaryWritten) | |||
| 679 | + | case ready := <-paletteReadyCh: | |||
| 680 | + | // The private-mode response proves all earlier replies have been | |||
| 681 | + | // discarded by stdin. Publish the one cleared scheme snapshot before | |||
| 682 | + | // emitting the query; stdin remains paused until QueryColors has | |||
| 683 | + | // completed below. | |||
| 684 | + | clearedTheme := themeState.snapshot() | |||
| 685 | + | select { | |||
| 686 | + | case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}: | |||
| 687 | + | case <-loopCtx.Done(): | |||
| 688 | + | return welcomedResult(nil) | |||
| 689 | + | } | |||
| 649 | if err := term.QueryColors(); err != nil { | 690 | if err := term.QueryColors(); err != nil { | ||
| 650 | log.Warn("querying terminal colors", "err", err) | 691 | log.Warn("querying terminal colors", "err", err) | ||
| 651 | } | 692 | } | ||
| 693 | + | close(ready.queryIssued) | |||
| 652 | case r := <-recvCh: | 694 | case r := <-recvCh: | ||
| 653 | if r.err != nil { | 695 | if r.err != nil { | ||
| 654 | if errors.Is(r.err, io.EOF) { | 696 | if errors.Is(r.err, io.EOF) { | ||
| @@ -806,6 +848,91 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 806 | // exits. That is harmless here — Run has already returned and restored the | 848 | // exits. That is harmless here — Run has already returned and restored the | ||
| 807 | // terminal — and matches the standard pattern for stdin pumps; a | 849 | // terminal — and matches the standard pattern for stdin pumps; a | ||
| 808 | // closable stdin duplicate could lift it later if ever needed. | 850 | // closable stdin duplicate could lift it later if ever needed. | ||
| 851 | + | type colorQueryRequest struct { | |||
| 852 | + | boundaryWritten chan struct{} | |||
| 853 | + | } | |||
| 854 | + | ||||
| 855 | + | type paletteQueryReady struct { | |||
| 856 | + | queryIssued chan struct{} | |||
| 857 | + | } | |||
| 858 | + | ||||
| 859 | + | type paletteDrain struct { | |||
| 860 | + | pending []byte | |||
| 861 | + | } | |||
| 862 | + | ||||
| 863 | + | type palettePhase uint8 | |||
| 864 | + | ||||
| 865 | + | const ( | |||
| 866 | + | paletteAccepting palettePhase = iota | |||
| 867 | + | paletteBoundaryRequested | |||
| 868 | + | paletteDraining | |||
| 869 | + | paletteAwaitingQuery | |||
| 870 | + | ) | |||
| 871 | + | ||||
| 872 | + | var paletteBoundaryQuery = []byte("\x1b[?2031$p") | |||
| 873 | + | ||||
| 874 | + | // paletteBoundaryResponses accepts valid DECRQM status values for private | |||
| 875 | + | // mode 2031. The private mode number correlates this reply with the query | |||
| 876 | + | // that starts a replacement palette generation. | |||
| 877 | + | var paletteBoundaryResponses = [][]byte{ | |||
| 878 | + | []byte("\x1b[?2031;0$y"), | |||
| 879 | + | []byte("\x1b[?2031;1$y"), | |||
| 880 | + | []byte("\x1b[?2031;2$y"), | |||
| 881 | + | []byte("\x1b[?2031;3$y"), | |||
| 882 | + | []byte("\x1b[?2031;4$y"), | |||
| 883 | + | } | |||
| 884 | + | ||||
| 885 | + | // scan consumes the private-mode boundary response while forwarding every | |||
| 886 | + | // other byte in order. It retains only a possible response prefix, so split | |||
| 887 | + | // replies are consumed without dropping a user's partial escape sequence on | |||
| 888 | + | // cancellation. | |||
| 889 | + | func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) { | |||
| 890 | + | for _, b := range data { | |||
| 891 | + | d.pending = append(d.pending, b) | |||
| 892 | + | for len(d.pending) > 0 { | |||
| 893 | + | matched := false | |||
| 894 | + | possible := false | |||
| 895 | + | for _, response := range paletteBoundaryResponses { | |||
| 896 | + | if len(d.pending) > len(response) { | |||
| 897 | + | continue | |||
| 898 | + | } | |||
| 899 | + | match := true | |||
| 900 | + | for i := range d.pending { | |||
| 901 | + | if d.pending[i] != response[i] { | |||
| 902 | + | match = false | |||
| 903 | + | break | |||
| 904 | + | } | |||
| 905 | + | } | |||
| 906 | + | if !match { | |||
| 907 | + | continue | |||
| 908 | + | } | |||
| 909 | + | if len(d.pending) == len(response) { | |||
| 910 | + | matched = true | |||
| 911 | + | } else { | |||
| 912 | + | possible = true | |||
| 913 | + | } | |||
| 914 | + | } | |||
| 915 | + | if matched { | |||
| 916 | + | d.pending = nil | |||
| 917 | + | onBoundary() | |||
| 918 | + | break | |||
| 919 | + | } | |||
| 920 | + | if possible { | |||
| 921 | + | break | |||
| 922 | + | } | |||
| 923 | + | onBytes(d.pending[:1]) | |||
| 924 | + | d.pending = d.pending[1:] | |||
| 925 | + | } | |||
| 926 | + | } | |||
| 927 | + | } | |||
| 928 | + | ||||
| 929 | + | func (d *paletteDrain) flush(onBytes func([]byte)) { | |||
| 930 | + | if len(d.pending) != 0 { | |||
| 931 | + | onBytes(d.pending) | |||
| 932 | + | d.pending = nil | |||
| 933 | + | } | |||
| 934 | + | } | |||
| 935 | + | ||||
| 809 | type stdinPump struct { | 936 | type stdinPump struct { | ||
| 810 | ctx context.Context | 937 | ctx context.Context | ||
| 811 | cancel context.CancelFunc | 938 | cancel context.CancelFunc | ||
| @@ -815,7 +942,8 @@ type stdinPump struct { | |||||
| 815 | themeState *terminalThemeState | 942 | themeState *terminalThemeState | ||
| 816 | clipboard ports.ClipboardReader | 943 | clipboard ports.ClipboardReader | ||
| 817 | logger *slog.Logger | 944 | logger *slog.Logger | ||
| 818 | - | requestColors func() | 945 | + | requestColors func() (colorQueryRequest, bool) |
| 946 | + | paletteReady chan<- paletteQueryReady | |||
| 819 | } | 947 | } | ||
| 820 | 948 | ||||
| 821 | func (p *stdinPump) run() { | 949 | func (p *stdinPump) run() { | ||
| @@ -873,14 +1001,38 @@ func (p *stdinPump) run() { | |||||
| 873 | sink = ci.Scan | 1001 | sink = ci.Scan | ||
| 874 | } | 1002 | } | ||
| 875 | 1003 | ||||
| 1004 | + | phase := paletteAccepting | |||
| 1005 | + | var drain paletteDrain | |||
| 876 | for { | 1006 | for { | ||
| 877 | n, rerr := in.Read(buf) | 1007 | n, rerr := in.Read(buf) | ||
| 878 | if n > 0 { | 1008 | if n > 0 { | ||
| 879 | - | sendTheme := func(current ports.Theme) { | 1009 | + | themeChanged := false |
| 880 | - | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | 1010 | + | queryNeeded := false |
| 1011 | + | onBytes := func(data []byte) { | |||
| 1012 | + | if phase != paletteDraining { | |||
| 1013 | + | sink(data) | |||
| 1014 | + | return | |||
| 1015 | + | } | |||
| 1016 | + | drain.scan(data, sink, func() { | |||
| 1017 | + | phase = paletteAwaitingQuery | |||
| 1018 | + | ready := paletteQueryReady{queryIssued: make(chan struct{})} | |||
| 1019 | + | select { | |||
| 1020 | + | case p.paletteReady <- ready: | |||
| 1021 | + | case <-ctx.Done(): | |||
| 1022 | + | return | |||
| 1023 | + | } | |||
| 1024 | + | select { | |||
| 1025 | + | case <-ready.queryIssued: | |||
| 1026 | + | phase = paletteAccepting | |||
| 1027 | + | case <-ctx.Done(): | |||
| 1028 | + | } | |||
| 1029 | + | }) | |||
| 881 | } | 1030 | } | ||
| 882 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | 1031 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | ||
| 883 | - | current := themeState.update(func(current *ports.Theme) { | 1032 | + | if phase != paletteAccepting { |
| 1033 | + | return | |||
| 1034 | + | } | |||
| 1035 | + | themeState.update(func(current *ports.Theme) { | |||
| 884 | switch kind { | 1036 | switch kind { | ||
| 885 | case 10: | 1037 | case 10: | ||
| 886 | current.HasForeground = true | 1038 | current.HasForeground = true | ||
| @@ -890,22 +1042,50 @@ func (p *stdinPump) run() { | |||||
| 890 | current.Background = rgb | 1042 | current.Background = rgb | ||
| 891 | } | 1043 | } | ||
| 892 | }) | 1044 | }) | ||
| 893 | - | sendTheme(current) | 1045 | + | themeChanged = true |
| 1046 | + | }, func(slot int, rgb renderer.RGB) { | |||
| 1047 | + | if phase != paletteAccepting { | |||
| 1048 | + | return | |||
| 1049 | + | } | |||
| 1050 | + | themeState.update(func(current *ports.Theme) { | |||
| 1051 | + | current.PaletteKnown |= uint16(1) << slot | |||
| 1052 | + | current.Palette[slot] = rgb | |||
| 1053 | + | }) | |||
| 1054 | + | themeChanged = true | |||
| 894 | }, func(light bool) { | 1055 | }, func(light bool) { | ||
| 895 | - | current := themeState.update(func(current *ports.Theme) { | 1056 | + | themeState.update(func(current *ports.Theme) { |
| 896 | current.SchemeKnown = true | 1057 | current.SchemeKnown = true | ||
| 897 | current.Light = light | 1058 | current.Light = light | ||
| 898 | }) | 1059 | }) | ||
| 1060 | + | // The scheme itself begins a new palette generation. Suppress every | |||
| 1061 | + | // color reply after it, including those remaining in this read, and | |||
| 1062 | + | // defer invalidation until Scan completes so no partial snapshot is | |||
| 1063 | + | // observable mid-chunk. | |||
| 1064 | + | themeChanged = false | |||
| 1065 | + | if phase == paletteAccepting { | |||
| 1066 | + | phase = paletteBoundaryRequested | |||
| 1067 | + | queryNeeded = true | |||
| 1068 | + | } | |||
| 1069 | + | }, onBytes) | |||
| 1070 | + | if queryNeeded { | |||
| 1071 | + | themeState.clearPalette() | |||
| 899 | if requestColors != nil { | 1072 | if requestColors != nil { | ||
| 900 | - | requestColors() | 1073 | + | if _, ok := requestColors(); !ok { |
| 1074 | + | return | |||
| 1075 | + | } | |||
| 1076 | + | phase = paletteDraining | |||
| 901 | } | 1077 | } | ||
| 902 | - | sendTheme(current) | 1078 | + | } else if themeChanged { |
| 903 | - | }, sink) | 1079 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())}) |
| 1080 | + | } | |||
| 904 | if !sendOK.Load() { | 1081 | if !sendOK.Load() { | ||
| 905 | return | 1082 | return | ||
| 906 | } | 1083 | } | ||
| 907 | } | 1084 | } | ||
| 908 | if rerr != nil { | 1085 | if rerr != nil { | ||
| 1086 | + | if phase == paletteDraining { | |||
| 1087 | + | drain.flush(sink) | |||
| 1088 | + | } | |||
| 909 | // Best-effort detach notification, then unwind. | 1089 | // Best-effort detach notification, then unwind. | ||
| 910 | select { | 1090 | select { | ||
| 911 | case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}: | 1091 | case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}: | ||
M internal/usecase/client/client_test.go +148 -0
| @@ -1047,3 +1047,151 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 1047 | 1047 | require.Equal(t, int32(1), d.calls.Load()) | |
| 1048 | 1048 | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 1049 | 1049 | } | |
| 1050 | + | ||
| 1051 | + | func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) { | |
| 1052 | + | input := newChunkedBlockingReader( | |
| 1053 | + | []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"), | |
| 1054 | + | []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"), | |
| 1055 | + | []byte("2$yc\x1b]4;3;#778899\ad"), | |
| 1056 | + | ) | |
| 1057 | + | defer input.unblock() | |
| 1058 | + | ||
| 1059 | + | var out bytes.Buffer | |
| 1060 | + | var restoreCount atomic.Int32 | |
| 1061 | + | resizeCh := make(chan domain.Size) | |
| 1062 | + | tm := portsmocks.NewMockTerminal(t) | |
| 1063 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 1064 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 1065 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 1066 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 1067 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 1068 | + | tm.EXPECT().QueryColors().Return(nil).Once() | |
| 1069 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 1070 | + | ||
| 1071 | + | var inputBytes []byte | |
| 1072 | + | var inputMu sync.Mutex | |
| 1073 | + | themes := make(chan ports.Theme, 2) | |
| 1074 | + | freshTheme := make(chan struct{}) | |
| 1075 | + | var freshOnce sync.Once | |
| 1076 | + | tr := portsmocks.NewMockTransport(t) | |
| 1077 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 1078 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 1079 | + | in, err := ports.UnmarshalInput(f.Payload) | |
| 1080 | + | if err != nil { | |
| 1081 | + | return err | |
| 1082 | + | } | |
| 1083 | + | inputMu.Lock() | |
| 1084 | + | inputBytes = append(inputBytes, in.Data...) | |
| 1085 | + | inputMu.Unlock() | |
| 1086 | + | return nil | |
| 1087 | + | }).Maybe() | |
| 1088 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 1089 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |
| 1090 | + | if err != nil { | |
| 1091 | + | return err | |
| 1092 | + | } | |
| 1093 | + | themes <- theme | |
| 1094 | + | if theme.PaletteKnown == 1<<3 { | |
| 1095 | + | freshOnce.Do(func() { close(freshTheme) }) | |
| 1096 | + | } | |
| 1097 | + | return nil | |
| 1098 | + | }).Times(2) | |
| 1099 | + | var welcomed atomic.Bool | |
| 1100 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 1101 | + | if welcomed.CompareAndSwap(false, true) { | |
| 1102 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil | |
| 1103 | + | } | |
| 1104 | + | <-freshTheme | |
| 1105 | + | return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil | |
| 1106 | + | }).Maybe() | |
| 1107 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 1108 | + | ||
| 1109 | + | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |
| 1110 | + | require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write") | |
| 1111 | + | require.Equal(t, int32(1), restoreCount.Load()) | |
| 1112 | + | ||
| 1113 | + | cleared := <-themes | |
| 1114 | + | fresh := <-themes | |
| 1115 | + | require.True(t, cleared.SchemeKnown) | |
| 1116 | + | require.True(t, cleared.Light) | |
| 1117 | + | require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary") | |
| 1118 | + | require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted") | |
| 1119 | + | require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation") | |
| 1120 | + | inputMu.Lock() | |
| 1121 | + | require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive") | |
| 1122 | + | inputMu.Unlock() | |
| 1123 | + | } | |
| 1124 | + | ||
| 1125 | + | func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) { | |
| 1126 | + | var out bytes.Buffer | |
| 1127 | + | var restoreCount atomic.Int32 | |
| 1128 | + | resizeCh := make(chan domain.Size) | |
| 1129 | + | input := newChunkedBlockingReader( | |
| 1130 | + | []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"), | |
| 1131 | + | []byte("\x1b[?997;2n"), | |
| 1132 | + | []byte("\x1b[?2031;1$y"), | |
| 1133 | + | ) | |
| 1134 | + | defer input.unblock() | |
| 1135 | + | ||
| 1136 | + | tm := portsmocks.NewMockTerminal(t) | |
| 1137 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 1138 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 1139 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 1140 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 1141 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 1142 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 1143 | + | tm.EXPECT().QueryColors().Return(nil).Once() | |
| 1144 | + | ||
| 1145 | + | var themes []ports.Theme | |
| 1146 | + | var themesMu sync.Mutex | |
| 1147 | + | clearedPalette := make(chan struct{}) | |
| 1148 | + | var clearOnce sync.Once | |
| 1149 | + | tr := portsmocks.NewMockTransport(t) | |
| 1150 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 1151 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 1152 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |
| 1153 | + | if err != nil { | |
| 1154 | + | t.Errorf("decode sent theme: %v", err) | |
| 1155 | + | return err | |
| 1156 | + | } | |
| 1157 | + | themesMu.Lock() | |
| 1158 | + | themes = append(themes, theme) | |
| 1159 | + | themesMu.Unlock() | |
| 1160 | + | if theme.SchemeKnown && theme.PaletteKnown == 0 { | |
| 1161 | + | clearOnce.Do(func() { close(clearedPalette) }) | |
| 1162 | + | } | |
| 1163 | + | return nil | |
| 1164 | + | }).Times(2) | |
| 1165 | + | ||
| 1166 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 1167 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 1168 | + | closed := make(chan struct{}) | |
| 1169 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 1170 | + | select { | |
| 1171 | + | case <-closed: | |
| 1172 | + | return ports.Frame{}, io.EOF | |
| 1173 | + | default: | |
| 1174 | + | } | |
| 1175 | + | select { | |
| 1176 | + | case <-clearedPalette: | |
| 1177 | + | close(closed) | |
| 1178 | + | return detached, nil | |
| 1179 | + | default: | |
| 1180 | + | return welcome, nil | |
| 1181 | + | } | |
| 1182 | + | }).Maybe() | |
| 1183 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 1184 | + | ||
| 1185 | + | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |
| 1186 | + | require.Equal(t, int32(1), restoreCount.Load()) | |
| 1187 | + | ||
| 1188 | + | themesMu.Lock() | |
| 1189 | + | defer themesMu.Unlock() | |
| 1190 | + | require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent") | |
| 1191 | + | require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown) | |
| 1192 | + | require.True(t, themes[0].HasBackground) | |
| 1193 | + | require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries") | |
| 1194 | + | require.Equal(t, themes[0].Background, themes[1].Background) | |
| 1195 | + | require.True(t, themes[1].SchemeKnown) | |
| 1196 | + | require.True(t, themes[1].Light) | |
| 1197 | + | } | |
| @@ -1047,3 +1047,151 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||||
| 1047 | require.Equal(t, int32(1), d.calls.Load()) | 1047 | require.Equal(t, int32(1), d.calls.Load()) | ||
| 1048 | require.Equal(t, int32(1), term.restoreCount.Load()) | 1048 | require.Equal(t, int32(1), term.restoreCount.Load()) | ||
| 1049 | } | 1049 | } | ||
| 1050 | + | ||||
| 1051 | + | func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) { | |||
| 1052 | + | input := newChunkedBlockingReader( | |||
| 1053 | + | []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"), | |||
| 1054 | + | []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"), | |||
| 1055 | + | []byte("2$yc\x1b]4;3;#778899\ad"), | |||
| 1056 | + | ) | |||
| 1057 | + | defer input.unblock() | |||
| 1058 | + | ||||
| 1059 | + | var out bytes.Buffer | |||
| 1060 | + | var restoreCount atomic.Int32 | |||
| 1061 | + | resizeCh := make(chan domain.Size) | |||
| 1062 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 1063 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 1064 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |||
| 1065 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 1066 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 1067 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 1068 | + | tm.EXPECT().QueryColors().Return(nil).Once() | |||
| 1069 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 1070 | + | ||||
| 1071 | + | var inputBytes []byte | |||
| 1072 | + | var inputMu sync.Mutex | |||
| 1073 | + | themes := make(chan ports.Theme, 2) | |||
| 1074 | + | freshTheme := make(chan struct{}) | |||
| 1075 | + | var freshOnce sync.Once | |||
| 1076 | + | tr := portsmocks.NewMockTransport(t) | |||
| 1077 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 1078 | + | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |||
| 1079 | + | in, err := ports.UnmarshalInput(f.Payload) | |||
| 1080 | + | if err != nil { | |||
| 1081 | + | return err | |||
| 1082 | + | } | |||
| 1083 | + | inputMu.Lock() | |||
| 1084 | + | inputBytes = append(inputBytes, in.Data...) | |||
| 1085 | + | inputMu.Unlock() | |||
| 1086 | + | return nil | |||
| 1087 | + | }).Maybe() | |||
| 1088 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |||
| 1089 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |||
| 1090 | + | if err != nil { | |||
| 1091 | + | return err | |||
| 1092 | + | } | |||
| 1093 | + | themes <- theme | |||
| 1094 | + | if theme.PaletteKnown == 1<<3 { | |||
| 1095 | + | freshOnce.Do(func() { close(freshTheme) }) | |||
| 1096 | + | } | |||
| 1097 | + | return nil | |||
| 1098 | + | }).Times(2) | |||
| 1099 | + | var welcomed atomic.Bool | |||
| 1100 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 1101 | + | if welcomed.CompareAndSwap(false, true) { | |||
| 1102 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil | |||
| 1103 | + | } | |||
| 1104 | + | <-freshTheme | |||
| 1105 | + | return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil | |||
| 1106 | + | }).Maybe() | |||
| 1107 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 1108 | + | ||||
| 1109 | + | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |||
| 1110 | + | require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write") | |||
| 1111 | + | require.Equal(t, int32(1), restoreCount.Load()) | |||
| 1112 | + | ||||
| 1113 | + | cleared := <-themes | |||
| 1114 | + | fresh := <-themes | |||
| 1115 | + | require.True(t, cleared.SchemeKnown) | |||
| 1116 | + | require.True(t, cleared.Light) | |||
| 1117 | + | require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary") | |||
| 1118 | + | require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted") | |||
| 1119 | + | require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation") | |||
| 1120 | + | inputMu.Lock() | |||
| 1121 | + | require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive") | |||
| 1122 | + | inputMu.Unlock() | |||
| 1123 | + | } | |||
| 1124 | + | ||||
| 1125 | + | func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) { | |||
| 1126 | + | var out bytes.Buffer | |||
| 1127 | + | var restoreCount atomic.Int32 | |||
| 1128 | + | resizeCh := make(chan domain.Size) | |||
| 1129 | + | input := newChunkedBlockingReader( | |||
| 1130 | + | []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"), | |||
| 1131 | + | []byte("\x1b[?997;2n"), | |||
| 1132 | + | []byte("\x1b[?2031;1$y"), | |||
| 1133 | + | ) | |||
| 1134 | + | defer input.unblock() | |||
| 1135 | + | ||||
| 1136 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 1137 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 1138 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |||
| 1139 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 1140 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 1141 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 1142 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 1143 | + | tm.EXPECT().QueryColors().Return(nil).Once() | |||
| 1144 | + | ||||
| 1145 | + | var themes []ports.Theme | |||
| 1146 | + | var themesMu sync.Mutex | |||
| 1147 | + | clearedPalette := make(chan struct{}) | |||
| 1148 | + | var clearOnce sync.Once | |||
| 1149 | + | tr := portsmocks.NewMockTransport(t) | |||
| 1150 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 1151 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |||
| 1152 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |||
| 1153 | + | if err != nil { | |||
| 1154 | + | t.Errorf("decode sent theme: %v", err) | |||
| 1155 | + | return err | |||
| 1156 | + | } | |||
| 1157 | + | themesMu.Lock() | |||
| 1158 | + | themes = append(themes, theme) | |||
| 1159 | + | themesMu.Unlock() | |||
| 1160 | + | if theme.SchemeKnown && theme.PaletteKnown == 0 { | |||
| 1161 | + | clearOnce.Do(func() { close(clearedPalette) }) | |||
| 1162 | + | } | |||
| 1163 | + | return nil | |||
| 1164 | + | }).Times(2) | |||
| 1165 | + | ||||
| 1166 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 1167 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 1168 | + | closed := make(chan struct{}) | |||
| 1169 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 1170 | + | select { | |||
| 1171 | + | case <-closed: | |||
| 1172 | + | return ports.Frame{}, io.EOF | |||
| 1173 | + | default: | |||
| 1174 | + | } | |||
| 1175 | + | select { | |||
| 1176 | + | case <-clearedPalette: | |||
| 1177 | + | close(closed) | |||
| 1178 | + | return detached, nil | |||
| 1179 | + | default: | |||
| 1180 | + | return welcome, nil | |||
| 1181 | + | } | |||
| 1182 | + | }).Maybe() | |||
| 1183 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 1184 | + | ||||
| 1185 | + | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |||
| 1186 | + | require.Equal(t, int32(1), restoreCount.Load()) | |||
| 1187 | + | ||||
| 1188 | + | themesMu.Lock() | |||
| 1189 | + | defer themesMu.Unlock() | |||
| 1190 | + | require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent") | |||
| 1191 | + | require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown) | |||
| 1192 | + | require.True(t, themes[0].HasBackground) | |||
| 1193 | + | require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries") | |||
| 1194 | + | require.Equal(t, themes[0].Background, themes[1].Background) | |||
| 1195 | + | require.True(t, themes[1].SchemeKnown) | |||
| 1196 | + | require.True(t, themes[1].Light) | |||
| 1197 | + | } | |||
A internal/usecase/client/theme_state_test.go +286 -0
| @@ -0,0 +1,286 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) { | |
| 18 | + | state := &terminalThemeState{} | |
| 19 | + | state.update(func(theme *ports.Theme) { | |
| 20 | + | theme.HasForeground = true | |
| 21 | + | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |
| 22 | + | theme.HasBackground = true | |
| 23 | + | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |
| 24 | + | theme.SchemeKnown = true | |
| 25 | + | theme.Light = true | |
| 26 | + | theme.PaletteKnown = 1<<1 | 1<<14 | |
| 27 | + | theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9} | |
| 28 | + | theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12} | |
| 29 | + | }) | |
| 30 | + | ||
| 31 | + | got := state.clearPalette() | |
| 32 | + | require.True(t, got.HasForeground) | |
| 33 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground) | |
| 34 | + | require.True(t, got.HasBackground) | |
| 35 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background) | |
| 36 | + | require.True(t, got.SchemeKnown) | |
| 37 | + | require.True(t, got.Light) | |
| 38 | + | require.Zero(t, got.PaletteKnown) | |
| 39 | + | require.Equal(t, [16]renderer.RGB{}, got.Palette) | |
| 40 | + | ||
| 41 | + | _, reported := state.reportedTheme() | |
| 42 | + | require.True(t, reported, "foreground/background remain usable after palette invalidation") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type queryBoundaryReader struct { | |
| 46 | + | first, second []byte | |
| 47 | + | readSecond chan struct{} | |
| 48 | + | allowSecond, close <-chan struct{} | |
| 49 | + | reads int | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (r *queryBoundaryReader) Read(p []byte) (int, error) { | |
| 53 | + | switch r.reads { | |
| 54 | + | case 0: | |
| 55 | + | r.reads++ | |
| 56 | + | return copy(p, r.first), nil | |
| 57 | + | case 1: | |
| 58 | + | select { | |
| 59 | + | case r.readSecond <- struct{}{}: | |
| 60 | + | case <-r.close: | |
| 61 | + | return 0, io.EOF | |
| 62 | + | } | |
| 63 | + | select { | |
| 64 | + | case <-r.allowSecond: | |
| 65 | + | r.reads++ | |
| 66 | + | return copy(p, r.second), nil | |
| 67 | + | case <-r.close: | |
| 68 | + | return 0, io.EOF | |
| 69 | + | } | |
| 70 | + | default: | |
| 71 | + | <-r.close | |
| 72 | + | return 0, io.EOF | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) { | |
| 77 | + | state := &terminalThemeState{} | |
| 78 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 79 | + | defer cancel() | |
| 80 | + | out := make(chan ports.Frame, 3) | |
| 81 | + | pump := stdinPump{ | |
| 82 | + | ctx: ctx, | |
| 83 | + | cancel: cancel, | |
| 84 | + | in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")), | |
| 85 | + | out: out, | |
| 86 | + | themeState: state, | |
| 87 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | pump.run() | |
| 91 | + | ||
| 92 | + | first := <-out | |
| 93 | + | require.Equal(t, ports.MsgTheme, first.Type) | |
| 94 | + | theme, err := ports.UnmarshalTheme(first.Payload) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.True(t, theme.HasBackground) | |
| 97 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background) | |
| 98 | + | require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown) | |
| 99 | + | require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1]) | |
| 100 | + | require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14]) | |
| 101 | + | ||
| 102 | + | second := <-out | |
| 103 | + | require.Equal(t, ports.MsgDetach, second.Type) | |
| 104 | + | select { | |
| 105 | + | case extra := <-out: | |
| 106 | + | t.Fatalf("got extra frame after one input chunk: %v", extra.Type) | |
| 107 | + | default: | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) { | |
| 112 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 113 | + | defer cancel() | |
| 114 | + | allowSecond := make(chan struct{}) | |
| 115 | + | closeReader := make(chan struct{}) | |
| 116 | + | reader := &queryBoundaryReader{ | |
| 117 | + | first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"), | |
| 118 | + | second: []byte("\x1b]4;2;#445566\a"), | |
| 119 | + | readSecond: make(chan struct{}, 1), | |
| 120 | + | allowSecond: allowSecond, | |
| 121 | + | close: closeReader, | |
| 122 | + | } | |
| 123 | + | queryStarted := make(chan struct{}) | |
| 124 | + | allowQuery := make(chan struct{}) | |
| 125 | + | out := make(chan ports.Frame, 3) | |
| 126 | + | pump := stdinPump{ | |
| 127 | + | ctx: ctx, | |
| 128 | + | cancel: cancel, | |
| 129 | + | in: reader, | |
| 130 | + | out: out, | |
| 131 | + | themeState: &terminalThemeState{}, | |
| 132 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 133 | + | requestColors: func() (colorQueryRequest, bool) { | |
| 134 | + | close(queryStarted) | |
| 135 | + | select { | |
| 136 | + | case <-allowQuery: | |
| 137 | + | return colorQueryRequest{}, true | |
| 138 | + | case <-ctx.Done(): | |
| 139 | + | return colorQueryRequest{}, false | |
| 140 | + | } | |
| 141 | + | }, | |
| 142 | + | } | |
| 143 | + | pumpDone := make(chan struct{}) | |
| 144 | + | go func() { | |
| 145 | + | pump.run() | |
| 146 | + | close(pumpDone) | |
| 147 | + | }() | |
| 148 | + | ||
| 149 | + | select { | |
| 150 | + | case <-queryStarted: | |
| 151 | + | case <-time.After(time.Second): | |
| 152 | + | t.Fatal("palette re-query was not requested") | |
| 153 | + | } | |
| 154 | + | select { | |
| 155 | + | case <-reader.readSecond: | |
| 156 | + | t.Fatal("stdin pump started the next read before the query acknowledgement") | |
| 157 | + | default: | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | close(allowQuery) | |
| 161 | + | select { | |
| 162 | + | case <-reader.readSecond: | |
| 163 | + | case <-time.After(time.Second): | |
| 164 | + | t.Fatal("stdin pump did not resume after the query acknowledgement") | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | cancel() | |
| 168 | + | close(closeReader) | |
| 169 | + | select { | |
| 170 | + | case <-pumpDone: | |
| 171 | + | case <-time.After(time.Second): | |
| 172 | + | t.Fatal("stdin pump did not unwind after cancellation") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | type chunksReader struct { | |
| 177 | + | chunks [][]byte | |
| 178 | + | next int | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (r *chunksReader) Read(p []byte) (int, error) { | |
| 182 | + | if r.next == len(r.chunks) { | |
| 183 | + | return 0, io.EOF | |
| 184 | + | } | |
| 185 | + | chunk := r.chunks[r.next] | |
| 186 | + | r.next++ | |
| 187 | + | return copy(p, chunk), nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) { | |
| 191 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 192 | + | defer cancel() | |
| 193 | + | requests := 0 | |
| 194 | + | pump := stdinPump{ | |
| 195 | + | ctx: ctx, | |
| 196 | + | cancel: cancel, | |
| 197 | + | in: &chunksReader{chunks: [][]byte{ | |
| 198 | + | []byte("\x1b[?997;2n"), | |
| 199 | + | []byte("\x1b[?997;1n"), | |
| 200 | + | }}, | |
| 201 | + | out: make(chan ports.Frame, 3), | |
| 202 | + | themeState: &terminalThemeState{}, | |
| 203 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 204 | + | requestColors: func() (colorQueryRequest, bool) { | |
| 205 | + | requests++ | |
| 206 | + | return colorQueryRequest{}, true | |
| 207 | + | }, | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | pump.run() | |
| 211 | + | require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) { | |
| 215 | + | for _, status := range []byte{'0', '1', '2', '3', '4'} { | |
| 216 | + | t.Run(string(status), func(t *testing.T) { | |
| 217 | + | response := []byte("\x1b[?2031;" + string(status) + "$y") | |
| 218 | + | var got []byte | |
| 219 | + | boundaries := 0 | |
| 220 | + | drain := paletteDrain{} | |
| 221 | + | drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) { | |
| 222 | + | got = append(got, data...) | |
| 223 | + | }, func() { boundaries++ }) | |
| 224 | + | drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) { | |
| 225 | + | got = append(got, data...) | |
| 226 | + | }, func() { boundaries++ }) | |
| 227 | + | ||
| 228 | + | require.Equal(t, []byte("xy"), got) | |
| 229 | + | require.Equal(t, 1, boundaries) | |
| 230 | + | }) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) { | |
| 235 | + | var got []byte | |
| 236 | + | boundaries := 0 | |
| 237 | + | drain := paletteDrain{} | |
| 238 | + | emit := func(data []byte) { got = append(got, data...) } | |
| 239 | + | drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ }) | |
| 240 | + | drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ }) | |
| 241 | + | drain.flush(emit) | |
| 242 | + | ||
| 243 | + | require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got) | |
| 244 | + | require.Zero(t, boundaries) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) { | |
| 248 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 249 | + | closeReader := make(chan struct{}) | |
| 250 | + | reader := &queryBoundaryReader{ | |
| 251 | + | first: []byte("\x1b[?997;2n"), | |
| 252 | + | close: closeReader, | |
| 253 | + | } | |
| 254 | + | requestStarted := make(chan struct{}) | |
| 255 | + | pump := stdinPump{ | |
| 256 | + | ctx: ctx, | |
| 257 | + | cancel: cancel, | |
| 258 | + | in: reader, | |
| 259 | + | out: make(chan ports.Frame, 2), | |
| 260 | + | themeState: &terminalThemeState{}, | |
| 261 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 262 | + | requestColors: func() (colorQueryRequest, bool) { | |
| 263 | + | close(requestStarted) | |
| 264 | + | <-ctx.Done() | |
| 265 | + | return colorQueryRequest{}, false | |
| 266 | + | }, | |
| 267 | + | } | |
| 268 | + | pumpDone := make(chan struct{}) | |
| 269 | + | go func() { | |
| 270 | + | pump.run() | |
| 271 | + | close(pumpDone) | |
| 272 | + | }() | |
| 273 | + | ||
| 274 | + | select { | |
| 275 | + | case <-requestStarted: | |
| 276 | + | case <-time.After(time.Second): | |
| 277 | + | t.Fatal("palette re-query was not requested") | |
| 278 | + | } | |
| 279 | + | cancel() | |
| 280 | + | select { | |
| 281 | + | case <-pumpDone: | |
| 282 | + | case <-time.After(time.Second): | |
| 283 | + | t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement") | |
| 284 | + | } | |
| 285 | + | close(closeReader) | |
| 286 | + | } | |
| @@ -0,0 +1,286 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) { | |||
| 18 | + | state := &terminalThemeState{} | |||
| 19 | + | state.update(func(theme *ports.Theme) { | |||
| 20 | + | theme.HasForeground = true | |||
| 21 | + | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 22 | + | theme.HasBackground = true | |||
| 23 | + | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |||
| 24 | + | theme.SchemeKnown = true | |||
| 25 | + | theme.Light = true | |||
| 26 | + | theme.PaletteKnown = 1<<1 | 1<<14 | |||
| 27 | + | theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9} | |||
| 28 | + | theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12} | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | got := state.clearPalette() | |||
| 32 | + | require.True(t, got.HasForeground) | |||
| 33 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground) | |||
| 34 | + | require.True(t, got.HasBackground) | |||
| 35 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background) | |||
| 36 | + | require.True(t, got.SchemeKnown) | |||
| 37 | + | require.True(t, got.Light) | |||
| 38 | + | require.Zero(t, got.PaletteKnown) | |||
| 39 | + | require.Equal(t, [16]renderer.RGB{}, got.Palette) | |||
| 40 | + | ||||
| 41 | + | _, reported := state.reportedTheme() | |||
| 42 | + | require.True(t, reported, "foreground/background remain usable after palette invalidation") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | type queryBoundaryReader struct { | |||
| 46 | + | first, second []byte | |||
| 47 | + | readSecond chan struct{} | |||
| 48 | + | allowSecond, close <-chan struct{} | |||
| 49 | + | reads int | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (r *queryBoundaryReader) Read(p []byte) (int, error) { | |||
| 53 | + | switch r.reads { | |||
| 54 | + | case 0: | |||
| 55 | + | r.reads++ | |||
| 56 | + | return copy(p, r.first), nil | |||
| 57 | + | case 1: | |||
| 58 | + | select { | |||
| 59 | + | case r.readSecond <- struct{}{}: | |||
| 60 | + | case <-r.close: | |||
| 61 | + | return 0, io.EOF | |||
| 62 | + | } | |||
| 63 | + | select { | |||
| 64 | + | case <-r.allowSecond: | |||
| 65 | + | r.reads++ | |||
| 66 | + | return copy(p, r.second), nil | |||
| 67 | + | case <-r.close: | |||
| 68 | + | return 0, io.EOF | |||
| 69 | + | } | |||
| 70 | + | default: | |||
| 71 | + | <-r.close | |||
| 72 | + | return 0, io.EOF | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) { | |||
| 77 | + | state := &terminalThemeState{} | |||
| 78 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 79 | + | defer cancel() | |||
| 80 | + | out := make(chan ports.Frame, 3) | |||
| 81 | + | pump := stdinPump{ | |||
| 82 | + | ctx: ctx, | |||
| 83 | + | cancel: cancel, | |||
| 84 | + | in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")), | |||
| 85 | + | out: out, | |||
| 86 | + | themeState: state, | |||
| 87 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | pump.run() | |||
| 91 | + | ||||
| 92 | + | first := <-out | |||
| 93 | + | require.Equal(t, ports.MsgTheme, first.Type) | |||
| 94 | + | theme, err := ports.UnmarshalTheme(first.Payload) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.True(t, theme.HasBackground) | |||
| 97 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background) | |||
| 98 | + | require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown) | |||
| 99 | + | require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1]) | |||
| 100 | + | require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14]) | |||
| 101 | + | ||||
| 102 | + | second := <-out | |||
| 103 | + | require.Equal(t, ports.MsgDetach, second.Type) | |||
| 104 | + | select { | |||
| 105 | + | case extra := <-out: | |||
| 106 | + | t.Fatalf("got extra frame after one input chunk: %v", extra.Type) | |||
| 107 | + | default: | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) { | |||
| 112 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 113 | + | defer cancel() | |||
| 114 | + | allowSecond := make(chan struct{}) | |||
| 115 | + | closeReader := make(chan struct{}) | |||
| 116 | + | reader := &queryBoundaryReader{ | |||
| 117 | + | first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"), | |||
| 118 | + | second: []byte("\x1b]4;2;#445566\a"), | |||
| 119 | + | readSecond: make(chan struct{}, 1), | |||
| 120 | + | allowSecond: allowSecond, | |||
| 121 | + | close: closeReader, | |||
| 122 | + | } | |||
| 123 | + | queryStarted := make(chan struct{}) | |||
| 124 | + | allowQuery := make(chan struct{}) | |||
| 125 | + | out := make(chan ports.Frame, 3) | |||
| 126 | + | pump := stdinPump{ | |||
| 127 | + | ctx: ctx, | |||
| 128 | + | cancel: cancel, | |||
| 129 | + | in: reader, | |||
| 130 | + | out: out, | |||
| 131 | + | themeState: &terminalThemeState{}, | |||
| 132 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |||
| 133 | + | requestColors: func() (colorQueryRequest, bool) { | |||
| 134 | + | close(queryStarted) | |||
| 135 | + | select { | |||
| 136 | + | case <-allowQuery: | |||
| 137 | + | return colorQueryRequest{}, true | |||
| 138 | + | case <-ctx.Done(): | |||
| 139 | + | return colorQueryRequest{}, false | |||
| 140 | + | } | |||
| 141 | + | }, | |||
| 142 | + | } | |||
| 143 | + | pumpDone := make(chan struct{}) | |||
| 144 | + | go func() { | |||
| 145 | + | pump.run() | |||
| 146 | + | close(pumpDone) | |||
| 147 | + | }() | |||
| 148 | + | ||||
| 149 | + | select { | |||
| 150 | + | case <-queryStarted: | |||
| 151 | + | case <-time.After(time.Second): | |||
| 152 | + | t.Fatal("palette re-query was not requested") | |||
| 153 | + | } | |||
| 154 | + | select { | |||
| 155 | + | case <-reader.readSecond: | |||
| 156 | + | t.Fatal("stdin pump started the next read before the query acknowledgement") | |||
| 157 | + | default: | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | close(allowQuery) | |||
| 161 | + | select { | |||
| 162 | + | case <-reader.readSecond: | |||
| 163 | + | case <-time.After(time.Second): | |||
| 164 | + | t.Fatal("stdin pump did not resume after the query acknowledgement") | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | cancel() | |||
| 168 | + | close(closeReader) | |||
| 169 | + | select { | |||
| 170 | + | case <-pumpDone: | |||
| 171 | + | case <-time.After(time.Second): | |||
| 172 | + | t.Fatal("stdin pump did not unwind after cancellation") | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | type chunksReader struct { | |||
| 177 | + | chunks [][]byte | |||
| 178 | + | next int | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (r *chunksReader) Read(p []byte) (int, error) { | |||
| 182 | + | if r.next == len(r.chunks) { | |||
| 183 | + | return 0, io.EOF | |||
| 184 | + | } | |||
| 185 | + | chunk := r.chunks[r.next] | |||
| 186 | + | r.next++ | |||
| 187 | + | return copy(p, chunk), nil | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) { | |||
| 191 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 192 | + | defer cancel() | |||
| 193 | + | requests := 0 | |||
| 194 | + | pump := stdinPump{ | |||
| 195 | + | ctx: ctx, | |||
| 196 | + | cancel: cancel, | |||
| 197 | + | in: &chunksReader{chunks: [][]byte{ | |||
| 198 | + | []byte("\x1b[?997;2n"), | |||
| 199 | + | []byte("\x1b[?997;1n"), | |||
| 200 | + | }}, | |||
| 201 | + | out: make(chan ports.Frame, 3), | |||
| 202 | + | themeState: &terminalThemeState{}, | |||
| 203 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |||
| 204 | + | requestColors: func() (colorQueryRequest, bool) { | |||
| 205 | + | requests++ | |||
| 206 | + | return colorQueryRequest{}, true | |||
| 207 | + | }, | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | pump.run() | |||
| 211 | + | require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives") | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) { | |||
| 215 | + | for _, status := range []byte{'0', '1', '2', '3', '4'} { | |||
| 216 | + | t.Run(string(status), func(t *testing.T) { | |||
| 217 | + | response := []byte("\x1b[?2031;" + string(status) + "$y") | |||
| 218 | + | var got []byte | |||
| 219 | + | boundaries := 0 | |||
| 220 | + | drain := paletteDrain{} | |||
| 221 | + | drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) { | |||
| 222 | + | got = append(got, data...) | |||
| 223 | + | }, func() { boundaries++ }) | |||
| 224 | + | drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) { | |||
| 225 | + | got = append(got, data...) | |||
| 226 | + | }, func() { boundaries++ }) | |||
| 227 | + | ||||
| 228 | + | require.Equal(t, []byte("xy"), got) | |||
| 229 | + | require.Equal(t, 1, boundaries) | |||
| 230 | + | }) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) { | |||
| 235 | + | var got []byte | |||
| 236 | + | boundaries := 0 | |||
| 237 | + | drain := paletteDrain{} | |||
| 238 | + | emit := func(data []byte) { got = append(got, data...) } | |||
| 239 | + | drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ }) | |||
| 240 | + | drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ }) | |||
| 241 | + | drain.flush(emit) | |||
| 242 | + | ||||
| 243 | + | require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got) | |||
| 244 | + | require.Zero(t, boundaries) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) { | |||
| 248 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 249 | + | closeReader := make(chan struct{}) | |||
| 250 | + | reader := &queryBoundaryReader{ | |||
| 251 | + | first: []byte("\x1b[?997;2n"), | |||
| 252 | + | close: closeReader, | |||
| 253 | + | } | |||
| 254 | + | requestStarted := make(chan struct{}) | |||
| 255 | + | pump := stdinPump{ | |||
| 256 | + | ctx: ctx, | |||
| 257 | + | cancel: cancel, | |||
| 258 | + | in: reader, | |||
| 259 | + | out: make(chan ports.Frame, 2), | |||
| 260 | + | themeState: &terminalThemeState{}, | |||
| 261 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |||
| 262 | + | requestColors: func() (colorQueryRequest, bool) { | |||
| 263 | + | close(requestStarted) | |||
| 264 | + | <-ctx.Done() | |||
| 265 | + | return colorQueryRequest{}, false | |||
| 266 | + | }, | |||
| 267 | + | } | |||
| 268 | + | pumpDone := make(chan struct{}) | |||
| 269 | + | go func() { | |||
| 270 | + | pump.run() | |||
| 271 | + | close(pumpDone) | |||
| 272 | + | }() | |||
| 273 | + | ||||
| 274 | + | select { | |||
| 275 | + | case <-requestStarted: | |||
| 276 | + | case <-time.After(time.Second): | |||
| 277 | + | t.Fatal("palette re-query was not requested") | |||
| 278 | + | } | |||
| 279 | + | cancel() | |||
| 280 | + | select { | |||
| 281 | + | case <-pumpDone: | |||
| 282 | + | case <-time.After(time.Second): | |||
| 283 | + | t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement") | |||
| 284 | + | } | |||
| 285 | + | close(closeReader) | |||
| 286 | + | } | |||
M internal/usecase/daemon/client.go +10 -8
| @@ -698,14 +698,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 698 | 698 | ||
| 699 | 699 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 700 | 700 | clientTheme := themeui.Theme{ | |
| 701 | - | Foreground: msg.Foreground, | |
| 702 | - | Background: msg.Background, | |
| 703 | - | HasFG: msg.HasForeground, | |
| 704 | - | HasBG: msg.HasBackground, | |
| 705 | - | TrueColor: msg.TrueColor, | |
| 706 | - | Known: msg.HasForeground && msg.HasBackground, | |
| 707 | - | SchemeKnown: msg.SchemeKnown, | |
| 708 | - | Light: msg.Light, | |
| 701 | + | Foreground: msg.Foreground, | |
| 702 | + | Background: msg.Background, | |
| 703 | + | Palette: msg.Palette, | |
| 704 | + | PaletteKnown: msg.PaletteKnown, | |
| 705 | + | HasFG: msg.HasForeground, | |
| 706 | + | HasBG: msg.HasBackground, | |
| 707 | + | TrueColor: msg.TrueColor, | |
| 708 | + | Known: msg.HasForeground && msg.HasBackground, | |
| 709 | + | SchemeKnown: msg.SchemeKnown, | |
| 710 | + | Light: msg.Light, | |
| 709 | 711 | } | |
| 710 | 712 | ac.setClientTheme(clientTheme) | |
| 711 | 713 | t := d.effectiveTheme(clientTheme) | |
| @@ -698,14 +698,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||||
| 698 | 698 | ||||
| 699 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | 699 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | ||
| 700 | clientTheme := themeui.Theme{ | 700 | clientTheme := themeui.Theme{ | ||
| 701 | - | Foreground: msg.Foreground, | 701 | + | Foreground: msg.Foreground, |
| 702 | - | Background: msg.Background, | 702 | + | Background: msg.Background, |
| 703 | - | HasFG: msg.HasForeground, | 703 | + | Palette: msg.Palette, |
| 704 | - | HasBG: msg.HasBackground, | 704 | + | PaletteKnown: msg.PaletteKnown, |
| 705 | - | TrueColor: msg.TrueColor, | 705 | + | HasFG: msg.HasForeground, |
| 706 | - | Known: msg.HasForeground && msg.HasBackground, | 706 | + | HasBG: msg.HasBackground, |
| 707 | - | SchemeKnown: msg.SchemeKnown, | 707 | + | TrueColor: msg.TrueColor, |
| 708 | - | Light: msg.Light, | 708 | + | Known: msg.HasForeground && msg.HasBackground, |
| 709 | + | SchemeKnown: msg.SchemeKnown, | |||
| 710 | + | Light: msg.Light, | |||
| 709 | } | 711 | } | ||
| 710 | ac.setClientTheme(clientTheme) | 712 | ac.setClientTheme(clientTheme) | ||
| 711 | t := d.effectiveTheme(clientTheme) | 713 | t := d.effectiveTheme(clientTheme) | ||
M internal/usecase/daemon/config.go +24 -2
| @@ -16,6 +16,26 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | |
| 18 | 18 | ||
| 19 | + | // themeConfigSnapshot is published as one immutable value. Its zero value is | |
| 20 | + | // the default theme configuration: automatic mode with palette inheritance on. | |
| 21 | + | type themeConfigSnapshot struct { | |
| 22 | + | mode domain.ThemeMode | |
| 23 | + | paletteOff bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (d *Daemon) storeThemeConfig(cfg domain.Config) { | |
| 27 | + | snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette} | |
| 28 | + | d.themeConfig.Store(&snapshot) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (d *Daemon) currentThemeConfig() themeConfigSnapshot { | |
| 32 | + | snapshot := d.themeConfig.Load() | |
| 33 | + | if snapshot == nil { | |
| 34 | + | return themeConfigSnapshot{} | |
| 35 | + | } | |
| 36 | + | return *snapshot | |
| 37 | + | } | |
| 38 | + | ||
| 19 | 39 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | |
| 20 | 40 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | |
| 21 | 41 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | |
| @@ -35,7 +55,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 35 | 55 | d.paletteConfig.Store(&palette) | |
| 36 | 56 | tabs := cfg.Tabs | |
| 37 | 57 | d.tabsConfig.Store(&tabs) | |
| 38 | - | d.themeMode.Store(uint32(cfg.Theme)) | |
| 58 | + | d.storeThemeConfig(cfg) | |
| 39 | 59 | if d.barScripts != nil { | |
| 40 | 60 | d.barScripts.mu.Lock() | |
| 41 | 61 | d.barScripts.initLocked() | |
| @@ -240,12 +260,14 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | |||
| 240 | 260 | } | |
| 241 | 261 | ||
| 242 | 262 | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |
| 243 | - | switch domain.ThemeMode(d.themeMode.Load()) { | |
| 263 | + | config := d.currentThemeConfig() | |
| 264 | + | switch config.mode { | |
| 244 | 265 | case domain.ThemeDark: | |
| 245 | 266 | return theme.BuiltinDark | |
| 246 | 267 | case domain.ThemeLight: | |
| 247 | 268 | return theme.BuiltinLight | |
| 248 | 269 | default: | |
| 270 | + | clientTheme.UsePalette = !config.paletteOff | |
| 249 | 271 | return clientTheme | |
| 250 | 272 | } | |
| 251 | 273 | } | |
| @@ -16,6 +16,26 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | 17 | var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | ||
| 18 | 18 | ||||
| 19 | + | // themeConfigSnapshot is published as one immutable value. Its zero value is | |||
| 20 | + | // the default theme configuration: automatic mode with palette inheritance on. | |||
| 21 | + | type themeConfigSnapshot struct { | |||
| 22 | + | mode domain.ThemeMode | |||
| 23 | + | paletteOff bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (d *Daemon) storeThemeConfig(cfg domain.Config) { | |||
| 27 | + | snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette} | |||
| 28 | + | d.themeConfig.Store(&snapshot) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (d *Daemon) currentThemeConfig() themeConfigSnapshot { | |||
| 32 | + | snapshot := d.themeConfig.Load() | |||
| 33 | + | if snapshot == nil { | |||
| 34 | + | return themeConfigSnapshot{} | |||
| 35 | + | } | |||
| 36 | + | return *snapshot | |||
| 37 | + | } | |||
| 38 | + | ||||
| 19 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | 39 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | ||
| 20 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | 40 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | ||
| 21 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | 41 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | ||
| @@ -35,7 +55,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 35 | d.paletteConfig.Store(&palette) | 55 | d.paletteConfig.Store(&palette) | ||
| 36 | tabs := cfg.Tabs | 56 | tabs := cfg.Tabs | ||
| 37 | d.tabsConfig.Store(&tabs) | 57 | d.tabsConfig.Store(&tabs) | ||
| 38 | - | d.themeMode.Store(uint32(cfg.Theme)) | 58 | + | d.storeThemeConfig(cfg) |
| 39 | if d.barScripts != nil { | 59 | if d.barScripts != nil { | ||
| 40 | d.barScripts.mu.Lock() | 60 | d.barScripts.mu.Lock() | ||
| 41 | d.barScripts.initLocked() | 61 | d.barScripts.initLocked() | ||
| @@ -240,12 +260,14 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | |||||
| 240 | } | 260 | } | ||
| 241 | 261 | ||||
| 242 | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | 262 | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | ||
| 243 | - | switch domain.ThemeMode(d.themeMode.Load()) { | 263 | + | config := d.currentThemeConfig() |
| 264 | + | switch config.mode { | |||
| 244 | case domain.ThemeDark: | 265 | case domain.ThemeDark: | ||
| 245 | return theme.BuiltinDark | 266 | return theme.BuiltinDark | ||
| 246 | case domain.ThemeLight: | 267 | case domain.ThemeLight: | ||
| 247 | return theme.BuiltinLight | 268 | return theme.BuiltinLight | ||
| 248 | default: | 269 | default: | ||
| 270 | + | clientTheme.UsePalette = !config.paletteOff | |||
| 249 | return clientTheme | 271 | return clientTheme | ||
| 250 | } | 272 | } | ||
| 251 | } | 273 | } | ||
M internal/usecase/daemon/config_test.go +197 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/require" | |
| @@ -8,6 +9,8 @@ import ( | |||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/keys" | |
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| 13 | 16 | type captureKeyHandler struct { | |
| @@ -50,6 +53,200 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 50 | 53 | require.Equal(t, "NT", cmd.Code) | |
| 51 | 54 | } | |
| 52 | 55 | ||
| 56 | + | func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) { | |
| 57 | + | p, release := newBlockingPTY(t) | |
| 58 | + | defer release() | |
| 59 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 60 | + | ||
| 61 | + | clientPalette := [16]renderer.RGB{} | |
| 62 | + | clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3} | |
| 63 | + | clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6} | |
| 64 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 65 | + | HasForeground: true, | |
| 66 | + | Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |
| 67 | + | HasBackground: true, | |
| 68 | + | Background: renderer.RGB{R: 10, G: 11, B: 12}, | |
| 69 | + | Palette: clientPalette, | |
| 70 | + | PaletteKnown: 1<<1 | 1<<12, | |
| 71 | + | }) | |
| 72 | + | ||
| 73 | + | require.Equal(t, clientPalette, ac.getClientTheme().Palette) | |
| 74 | + | require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown) | |
| 75 | + | require.True(t, ac.getTheme().UsePalette) | |
| 76 | + | ||
| 77 | + | cfg := domain.Defaults() | |
| 78 | + | cfg.ThemePalette = false | |
| 79 | + | d.ApplyConfig(cfg) | |
| 80 | + | ||
| 81 | + | // ApplyConfig reuses the live client report and repaints without requiring | |
| 82 | + | // a reconnect; only palette inheritance changes. | |
| 83 | + | reloaded := ac.getTheme() | |
| 84 | + | require.Equal(t, clientPalette, reloaded.Palette) | |
| 85 | + | require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown) | |
| 86 | + | require.False(t, reloaded.UsePalette) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestEffectiveThemePaletteGate(t *testing.T) { | |
| 90 | + | clientPalette := [16]renderer.RGB{} | |
| 91 | + | clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3} | |
| 92 | + | clientTheme := themeui.Theme{ | |
| 93 | + | Palette: clientPalette, | |
| 94 | + | PaletteKnown: 1 << 3, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | tests := []struct { | |
| 98 | + | name string | |
| 99 | + | config domain.Config | |
| 100 | + | want themeui.Theme | |
| 101 | + | usePalette bool | |
| 102 | + | }{ | |
| 103 | + | { | |
| 104 | + | name: "auto inherits terminal palette by default", | |
| 105 | + | config: domain.Defaults(), | |
| 106 | + | want: clientTheme, | |
| 107 | + | usePalette: true, | |
| 108 | + | }, | |
| 109 | + | { | |
| 110 | + | name: "auto disables terminal palette", | |
| 111 | + | config: func() domain.Config { | |
| 112 | + | cfg := domain.Defaults() | |
| 113 | + | cfg.ThemePalette = false | |
| 114 | + | return cfg | |
| 115 | + | }(), | |
| 116 | + | want: clientTheme, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "forced dark uses palette free builtin", | |
| 120 | + | config: domain.Config{ | |
| 121 | + | Theme: domain.ThemeDark, | |
| 122 | + | ThemePalette: true, | |
| 123 | + | }, | |
| 124 | + | want: themeui.BuiltinDark, | |
| 125 | + | }, | |
| 126 | + | { | |
| 127 | + | name: "forced dark ignores disabled palette inheritance", | |
| 128 | + | config: domain.Config{ | |
| 129 | + | Theme: domain.ThemeDark, | |
| 130 | + | ThemePalette: false, | |
| 131 | + | }, | |
| 132 | + | want: themeui.BuiltinDark, | |
| 133 | + | }, | |
| 134 | + | { | |
| 135 | + | name: "forced light uses palette free builtin", | |
| 136 | + | config: domain.Config{ | |
| 137 | + | Theme: domain.ThemeLight, | |
| 138 | + | ThemePalette: true, | |
| 139 | + | }, | |
| 140 | + | want: themeui.BuiltinLight, | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | name: "forced light ignores disabled palette inheritance", | |
| 144 | + | config: domain.Config{ | |
| 145 | + | Theme: domain.ThemeLight, | |
| 146 | + | ThemePalette: false, | |
| 147 | + | }, | |
| 148 | + | want: themeui.BuiltinLight, | |
| 149 | + | }, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | for _, tt := range tests { | |
| 153 | + | t.Run(tt.name, func(t *testing.T) { | |
| 154 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 155 | + | d.ApplyConfig(tt.config) | |
| 156 | + | ||
| 157 | + | got := d.effectiveTheme(clientTheme) | |
| 158 | + | want := tt.want | |
| 159 | + | want.UsePalette = tt.usePalette | |
| 160 | + | require.Equal(t, want, got) | |
| 161 | + | }) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestThemeConfigSnapshotIsAtomic(t *testing.T) { | |
| 166 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 167 | + | ||
| 168 | + | // A nil atomic pointer is the zero/default snapshot: auto theme with | |
| 169 | + | // terminal palette inheritance enabled. | |
| 170 | + | require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig()) | |
| 171 | + | ||
| 172 | + | for _, tt := range []struct { | |
| 173 | + | name string | |
| 174 | + | mode domain.ThemeMode | |
| 175 | + | gate bool | |
| 176 | + | }{ | |
| 177 | + | {name: "auto palette enabled", mode: domain.ThemeAuto, gate: true}, | |
| 178 | + | {name: "auto palette disabled", mode: domain.ThemeAuto, gate: false}, | |
| 179 | + | {name: "dark palette enabled", mode: domain.ThemeDark, gate: true}, | |
| 180 | + | {name: "dark palette disabled", mode: domain.ThemeDark, gate: false}, | |
| 181 | + | {name: "light palette enabled", mode: domain.ThemeLight, gate: true}, | |
| 182 | + | {name: "light palette disabled", mode: domain.ThemeLight, gate: false}, | |
| 183 | + | } { | |
| 184 | + | t.Run(tt.name, func(t *testing.T) { | |
| 185 | + | d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate}) | |
| 186 | + | require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig()) | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Publish complementary configurations concurrently. Every load must be a | |
| 191 | + | // complete published snapshot, never mode from one update and gate from | |
| 192 | + | // another. Coordination establishes concurrent readers/writers without | |
| 193 | + | // timing sleeps. | |
| 194 | + | first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false} | |
| 195 | + | second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true} | |
| 196 | + | allowed := map[themeConfigSnapshot]struct{}{ | |
| 197 | + | {mode: domain.ThemeAuto, paletteOff: true}: {}, | |
| 198 | + | {mode: domain.ThemeDark, paletteOff: false}: {}, | |
| 199 | + | } | |
| 200 | + | firstPublished := make(chan struct{}) | |
| 201 | + | firstObserved := make(chan struct{}) | |
| 202 | + | secondPublished := make(chan struct{}) | |
| 203 | + | secondObserved := make(chan struct{}) | |
| 204 | + | observed := make(chan themeConfigSnapshot, 2) | |
| 205 | + | invalid := make(chan themeConfigSnapshot, 1) | |
| 206 | + | var wg sync.WaitGroup | |
| 207 | + | wg.Add(2) | |
| 208 | + | go func() { | |
| 209 | + | defer wg.Done() | |
| 210 | + | d.storeThemeConfig(first) | |
| 211 | + | close(firstPublished) | |
| 212 | + | <-firstObserved | |
| 213 | + | d.storeThemeConfig(second) | |
| 214 | + | close(secondPublished) | |
| 215 | + | <-secondObserved | |
| 216 | + | for range 10_000 { | |
| 217 | + | d.storeThemeConfig(second) | |
| 218 | + | d.storeThemeConfig(first) | |
| 219 | + | } | |
| 220 | + | }() | |
| 221 | + | go func() { | |
| 222 | + | defer wg.Done() | |
| 223 | + | <-firstPublished | |
| 224 | + | observed <- d.currentThemeConfig() | |
| 225 | + | close(firstObserved) | |
| 226 | + | <-secondPublished | |
| 227 | + | observed <- d.currentThemeConfig() | |
| 228 | + | close(secondObserved) | |
| 229 | + | for range 20_000 { | |
| 230 | + | snapshot := d.currentThemeConfig() | |
| 231 | + | if _, ok := allowed[snapshot]; !ok { | |
| 232 | + | select { | |
| 233 | + | case invalid <- snapshot: | |
| 234 | + | default: | |
| 235 | + | } | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | }() | |
| 240 | + | wg.Wait() | |
| 241 | + | require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed) | |
| 242 | + | require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed) | |
| 243 | + | select { | |
| 244 | + | case snapshot := <-invalid: | |
| 245 | + | t.Fatalf("observed mixed theme configuration: %#v", snapshot) | |
| 246 | + | default: | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 53 | 250 | func TestApplyConfigPublishesCopyConfig(t *testing.T) { | |
| 54 | 251 | tests := []struct { | |
| 55 | 252 | name string | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sync" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| @@ -8,6 +9,8 @@ import ( | |||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/usecase/keys" | 11 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 13 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 14 | ) | ||
| 12 | 15 | ||||
| 13 | type captureKeyHandler struct { | 16 | type captureKeyHandler struct { | ||
| @@ -50,6 +53,200 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||||
| 50 | require.Equal(t, "NT", cmd.Code) | 53 | require.Equal(t, "NT", cmd.Code) | ||
| 51 | } | 54 | } | ||
| 52 | 55 | ||||
| 56 | + | func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) { | |||
| 57 | + | p, release := newBlockingPTY(t) | |||
| 58 | + | defer release() | |||
| 59 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 60 | + | ||||
| 61 | + | clientPalette := [16]renderer.RGB{} | |||
| 62 | + | clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 63 | + | clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6} | |||
| 64 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 65 | + | HasForeground: true, | |||
| 66 | + | Foreground: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 67 | + | HasBackground: true, | |||
| 68 | + | Background: renderer.RGB{R: 10, G: 11, B: 12}, | |||
| 69 | + | Palette: clientPalette, | |||
| 70 | + | PaletteKnown: 1<<1 | 1<<12, | |||
| 71 | + | }) | |||
| 72 | + | ||||
| 73 | + | require.Equal(t, clientPalette, ac.getClientTheme().Palette) | |||
| 74 | + | require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown) | |||
| 75 | + | require.True(t, ac.getTheme().UsePalette) | |||
| 76 | + | ||||
| 77 | + | cfg := domain.Defaults() | |||
| 78 | + | cfg.ThemePalette = false | |||
| 79 | + | d.ApplyConfig(cfg) | |||
| 80 | + | ||||
| 81 | + | // ApplyConfig reuses the live client report and repaints without requiring | |||
| 82 | + | // a reconnect; only palette inheritance changes. | |||
| 83 | + | reloaded := ac.getTheme() | |||
| 84 | + | require.Equal(t, clientPalette, reloaded.Palette) | |||
| 85 | + | require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown) | |||
| 86 | + | require.False(t, reloaded.UsePalette) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestEffectiveThemePaletteGate(t *testing.T) { | |||
| 90 | + | clientPalette := [16]renderer.RGB{} | |||
| 91 | + | clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 92 | + | clientTheme := themeui.Theme{ | |||
| 93 | + | Palette: clientPalette, | |||
| 94 | + | PaletteKnown: 1 << 3, | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | tests := []struct { | |||
| 98 | + | name string | |||
| 99 | + | config domain.Config | |||
| 100 | + | want themeui.Theme | |||
| 101 | + | usePalette bool | |||
| 102 | + | }{ | |||
| 103 | + | { | |||
| 104 | + | name: "auto inherits terminal palette by default", | |||
| 105 | + | config: domain.Defaults(), | |||
| 106 | + | want: clientTheme, | |||
| 107 | + | usePalette: true, | |||
| 108 | + | }, | |||
| 109 | + | { | |||
| 110 | + | name: "auto disables terminal palette", | |||
| 111 | + | config: func() domain.Config { | |||
| 112 | + | cfg := domain.Defaults() | |||
| 113 | + | cfg.ThemePalette = false | |||
| 114 | + | return cfg | |||
| 115 | + | }(), | |||
| 116 | + | want: clientTheme, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "forced dark uses palette free builtin", | |||
| 120 | + | config: domain.Config{ | |||
| 121 | + | Theme: domain.ThemeDark, | |||
| 122 | + | ThemePalette: true, | |||
| 123 | + | }, | |||
| 124 | + | want: themeui.BuiltinDark, | |||
| 125 | + | }, | |||
| 126 | + | { | |||
| 127 | + | name: "forced dark ignores disabled palette inheritance", | |||
| 128 | + | config: domain.Config{ | |||
| 129 | + | Theme: domain.ThemeDark, | |||
| 130 | + | ThemePalette: false, | |||
| 131 | + | }, | |||
| 132 | + | want: themeui.BuiltinDark, | |||
| 133 | + | }, | |||
| 134 | + | { | |||
| 135 | + | name: "forced light uses palette free builtin", | |||
| 136 | + | config: domain.Config{ | |||
| 137 | + | Theme: domain.ThemeLight, | |||
| 138 | + | ThemePalette: true, | |||
| 139 | + | }, | |||
| 140 | + | want: themeui.BuiltinLight, | |||
| 141 | + | }, | |||
| 142 | + | { | |||
| 143 | + | name: "forced light ignores disabled palette inheritance", | |||
| 144 | + | config: domain.Config{ | |||
| 145 | + | Theme: domain.ThemeLight, | |||
| 146 | + | ThemePalette: false, | |||
| 147 | + | }, | |||
| 148 | + | want: themeui.BuiltinLight, | |||
| 149 | + | }, | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | for _, tt := range tests { | |||
| 153 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 154 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 155 | + | d.ApplyConfig(tt.config) | |||
| 156 | + | ||||
| 157 | + | got := d.effectiveTheme(clientTheme) | |||
| 158 | + | want := tt.want | |||
| 159 | + | want.UsePalette = tt.usePalette | |||
| 160 | + | require.Equal(t, want, got) | |||
| 161 | + | }) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestThemeConfigSnapshotIsAtomic(t *testing.T) { | |||
| 166 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 167 | + | ||||
| 168 | + | // A nil atomic pointer is the zero/default snapshot: auto theme with | |||
| 169 | + | // terminal palette inheritance enabled. | |||
| 170 | + | require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig()) | |||
| 171 | + | ||||
| 172 | + | for _, tt := range []struct { | |||
| 173 | + | name string | |||
| 174 | + | mode domain.ThemeMode | |||
| 175 | + | gate bool | |||
| 176 | + | }{ | |||
| 177 | + | {name: "auto palette enabled", mode: domain.ThemeAuto, gate: true}, | |||
| 178 | + | {name: "auto palette disabled", mode: domain.ThemeAuto, gate: false}, | |||
| 179 | + | {name: "dark palette enabled", mode: domain.ThemeDark, gate: true}, | |||
| 180 | + | {name: "dark palette disabled", mode: domain.ThemeDark, gate: false}, | |||
| 181 | + | {name: "light palette enabled", mode: domain.ThemeLight, gate: true}, | |||
| 182 | + | {name: "light palette disabled", mode: domain.ThemeLight, gate: false}, | |||
| 183 | + | } { | |||
| 184 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 185 | + | d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate}) | |||
| 186 | + | require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig()) | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // Publish complementary configurations concurrently. Every load must be a | |||
| 191 | + | // complete published snapshot, never mode from one update and gate from | |||
| 192 | + | // another. Coordination establishes concurrent readers/writers without | |||
| 193 | + | // timing sleeps. | |||
| 194 | + | first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false} | |||
| 195 | + | second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true} | |||
| 196 | + | allowed := map[themeConfigSnapshot]struct{}{ | |||
| 197 | + | {mode: domain.ThemeAuto, paletteOff: true}: {}, | |||
| 198 | + | {mode: domain.ThemeDark, paletteOff: false}: {}, | |||
| 199 | + | } | |||
| 200 | + | firstPublished := make(chan struct{}) | |||
| 201 | + | firstObserved := make(chan struct{}) | |||
| 202 | + | secondPublished := make(chan struct{}) | |||
| 203 | + | secondObserved := make(chan struct{}) | |||
| 204 | + | observed := make(chan themeConfigSnapshot, 2) | |||
| 205 | + | invalid := make(chan themeConfigSnapshot, 1) | |||
| 206 | + | var wg sync.WaitGroup | |||
| 207 | + | wg.Add(2) | |||
| 208 | + | go func() { | |||
| 209 | + | defer wg.Done() | |||
| 210 | + | d.storeThemeConfig(first) | |||
| 211 | + | close(firstPublished) | |||
| 212 | + | <-firstObserved | |||
| 213 | + | d.storeThemeConfig(second) | |||
| 214 | + | close(secondPublished) | |||
| 215 | + | <-secondObserved | |||
| 216 | + | for range 10_000 { | |||
| 217 | + | d.storeThemeConfig(second) | |||
| 218 | + | d.storeThemeConfig(first) | |||
| 219 | + | } | |||
| 220 | + | }() | |||
| 221 | + | go func() { | |||
| 222 | + | defer wg.Done() | |||
| 223 | + | <-firstPublished | |||
| 224 | + | observed <- d.currentThemeConfig() | |||
| 225 | + | close(firstObserved) | |||
| 226 | + | <-secondPublished | |||
| 227 | + | observed <- d.currentThemeConfig() | |||
| 228 | + | close(secondObserved) | |||
| 229 | + | for range 20_000 { | |||
| 230 | + | snapshot := d.currentThemeConfig() | |||
| 231 | + | if _, ok := allowed[snapshot]; !ok { | |||
| 232 | + | select { | |||
| 233 | + | case invalid <- snapshot: | |||
| 234 | + | default: | |||
| 235 | + | } | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | }() | |||
| 240 | + | wg.Wait() | |||
| 241 | + | require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed) | |||
| 242 | + | require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed) | |||
| 243 | + | select { | |||
| 244 | + | case snapshot := <-invalid: | |||
| 245 | + | t.Fatalf("observed mixed theme configuration: %#v", snapshot) | |||
| 246 | + | default: | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 53 | func TestApplyConfigPublishesCopyConfig(t *testing.T) { | 250 | func TestApplyConfigPublishesCopyConfig(t *testing.T) { | ||
| 54 | tests := []struct { | 251 | tests := []struct { | ||
| 55 | name string | 252 | name string | ||
M internal/usecase/daemon/copymode.go +3 -2
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/keys" | |
| 12 | 12 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 13 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/ui" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 15 | 16 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -415,8 +416,8 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||
| 415 | 416 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 416 | 417 | return frame, nil | |
| 417 | 418 | } | |
| 418 | - | styles := newThemeStyles(bars.theme) | |
| 419 | - | copyFrame := mode.Render(styles.copyStatus, styles.selection) | |
| 419 | + | styles := themeui.NewStyles(bars.theme) | |
| 420 | + | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) | |
| 420 | 421 | bodyRows := max(copyFrame.Height-1, 0) | |
| 421 | 422 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | |
| 422 | 423 | dstX := max(target.X, 0) | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 11 | "github.com/bnema/vev/internal/usecase/keys" | 11 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 12 | "github.com/bnema/vev/internal/usecase/mouse" | 12 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 13 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 13 | "github.com/bnema/vev/internal/usecase/ui" | 14 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 14 | "github.com/bnema/vev/internal/usecase/visualsearch" | 15 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 15 | "github.com/bnema/vev/pkg/renderer" | 16 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -415,8 +416,8 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||||
| 415 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | 416 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | ||
| 416 | return frame, nil | 417 | return frame, nil | ||
| 417 | } | 418 | } | ||
| 418 | - | styles := newThemeStyles(bars.theme) | 419 | + | styles := themeui.NewStyles(bars.theme) |
| 419 | - | copyFrame := mode.Render(styles.copyStatus, styles.selection) | 420 | + | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) |
| 420 | bodyRows := max(copyFrame.Height-1, 0) | 421 | bodyRows := max(copyFrame.Height-1, 0) | ||
| 421 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | 422 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | ||
| 422 | dstX := max(target.X, 0) | 423 | dstX := max(target.X, 0) | ||
M internal/usecase/daemon/daemon.go +1 -1
| @@ -144,7 +144,7 @@ type Daemon struct { | |||
| 144 | 144 | copyConfig atomic.Pointer[domain.CopyConfig] | |
| 145 | 145 | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 146 | 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 147 | - | themeMode atomic.Uint32 | |
| 147 | + | themeConfig atomic.Pointer[themeConfigSnapshot] | |
| 148 | 148 | barScripts *barScriptState | |
| 149 | 149 | resumeParkGrace time.Duration | |
| 150 | 150 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| @@ -144,7 +144,7 @@ type Daemon struct { | |||||
| 144 | copyConfig atomic.Pointer[domain.CopyConfig] | 144 | copyConfig atomic.Pointer[domain.CopyConfig] | ||
| 145 | paletteConfig atomic.Pointer[domain.PaletteConfig] | 145 | paletteConfig atomic.Pointer[domain.PaletteConfig] | ||
| 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | 146 | tabsConfig atomic.Pointer[domain.TabsConfig] | ||
| 147 | - | themeMode atomic.Uint32 | 147 | + | themeConfig atomic.Pointer[themeConfigSnapshot] |
| 148 | barScripts *barScriptState | 148 | barScripts *barScriptState | ||
| 149 | resumeParkGrace time.Duration | 149 | resumeParkGrace time.Duration | ||
| 150 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 150 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
M internal/usecase/daemon/floating_render.go +1 -1
| @@ -117,7 +117,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 117 | 117 | } | |
| 118 | 118 | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | |
| 119 | 119 | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | |
| 120 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border) | |
| 120 | + | drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border) | |
| 121 | 121 | if full || popupChanged { | |
| 122 | 122 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 123 | 123 | } | |
| @@ -117,7 +117,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 117 | } | 117 | } | ||
| 118 | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | 118 | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | ||
| 119 | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | 119 | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | ||
| 120 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border) | 120 | + | drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border) |
| 121 | if full || popupChanged { | 121 | if full || popupChanged { | ||
| 122 | return frame, []renderer.Damage{renderer.FullRedraw()} | 122 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 123 | } | 123 | } | ||
M internal/usecase/daemon/modal_compose.go +3 -2
| @@ -2,12 +2,13 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/vev/internal/domain" | |
| 5 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 5 | 6 | "github.com/bnema/vev/internal/usecase/ui" | |
| 6 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | - | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 10 | - | inner := modal.Composite(base, styles.border) | |
| 10 | + | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 11 | + | inner := modal.Composite(base, styles.Border) | |
| 11 | 12 | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle) | |
| 12 | 13 | for y := range min(inner.Height, modalFrame.Height) { | |
| 13 | 14 | for x := range min(inner.Width, modalFrame.Width) { | |
| @@ -2,12 +2,13 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "github.com/bnema/vev/internal/domain" | 4 | "github.com/bnema/vev/internal/domain" | ||
| 5 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 5 | "github.com/bnema/vev/internal/usecase/ui" | 6 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 6 | "github.com/bnema/vev/pkg/renderer" | 7 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | - | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) { | 10 | + | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) { |
| 10 | - | inner := modal.Composite(base, styles.border) | 11 | + | inner := modal.Composite(base, styles.Border) |
| 11 | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle) | 12 | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle) | ||
| 12 | for y := range min(inner.Height, modalFrame.Height) { | 13 | for y := range min(inner.Height, modalFrame.Height) { | ||
| 13 | for x := range min(inner.Width, modalFrame.Width) { | 14 | for x := range min(inner.Width, modalFrame.Width) { | ||
M internal/usecase/daemon/palette.go +5 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 15 | ) | |
| @@ -496,10 +497,10 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 496 | 497 | return nil | |
| 497 | 498 | } | |
| 498 | 499 | ||
| 499 | - | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 500 | - | styleSet := resolveThemeStyles(styles) | |
| 500 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { | |
| 501 | + | styleSet := resolveStyles(styles) | |
| 501 | 502 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | |
| 502 | - | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 503 | - | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance}) | |
| 503 | + | return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 504 | + | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance}) | |
| 504 | 505 | }) | |
| 505 | 506 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | "github.com/bnema/vev/internal/usecase/palette" | 10 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 11 | "github.com/bnema/vev/internal/usecase/picker" | 11 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 12 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 12 | "github.com/bnema/vev/internal/usecase/ui" | 13 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 13 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 14 | ) | 15 | ) | ||
| @@ -496,10 +497,10 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||||
| 496 | return nil | 497 | return nil | ||
| 497 | } | 498 | } | ||
| 498 | 499 | ||||
| 499 | - | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | 500 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { |
| 500 | - | styleSet := resolveThemeStyles(styles) | 501 | + | styleSet := resolveStyles(styles) |
| 501 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | 502 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | ||
| 502 | - | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | 503 | + | return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { |
| 503 | - | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance}) | 504 | + | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance}) |
| 504 | }) | 505 | }) | ||
| 505 | } | 506 | } | ||
M internal/usecase/daemon/render.go +2 -50
| @@ -357,59 +357,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 357 | 357 | d.emitFrame(sess, ac, state, composed, &marks) | |
| 358 | 358 | } | |
| 359 | 359 | ||
| 360 | - | type themeStyles struct { | |
| 361 | - | statusBar renderer.Style | |
| 362 | - | accent renderer.Style | |
| 363 | - | border renderer.Style | |
| 364 | - | selection renderer.Style | |
| 365 | - | copyStatus renderer.Style | |
| 366 | - | paletteDesc renderer.Style | |
| 367 | - | ||
| 368 | - | // Top bar tab label segments: name (bold) and parenthesized pane-title | |
| 369 | - | // (muted), one pair per base style. No-ops to statusBar/accent on | |
| 370 | - | // non-truecolor themes. | |
| 371 | - | tabName renderer.Style | |
| 372 | - | tabNameActive renderer.Style | |
| 373 | - | tabTitle renderer.Style | |
| 374 | - | tabTitleActive renderer.Style | |
| 375 | - | ||
| 376 | - | // Session picker row segments. Detail reuses paletteDesc (both are | |
| 377 | - | // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only. | |
| 378 | - | pickerName renderer.Style | |
| 379 | - | pickerSelectionName renderer.Style | |
| 380 | - | pickerSelectionMuted renderer.Style | |
| 381 | - | pickerSeparator renderer.Style | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func newThemeStyles(t themeui.Theme) themeStyles { | |
| 385 | - | statusBar := themeui.StatusBarStyle(t) | |
| 386 | - | accent := themeui.AccentStyle(t) | |
| 387 | - | selection := themeui.SelectionStyle(t) | |
| 388 | - | return themeStyles{ | |
| 389 | - | statusBar: statusBar, | |
| 390 | - | accent: accent, | |
| 391 | - | border: themeui.BorderStyle(t), | |
| 392 | - | selection: selection, | |
| 393 | - | copyStatus: selection, | |
| 394 | - | paletteDesc: themeui.MutedTextStyle(t), | |
| 395 | - | ||
| 396 | - | tabName: themeui.EmphasisStyle(statusBar, t), | |
| 397 | - | tabNameActive: themeui.EmphasisStyle(accent, t), | |
| 398 | - | tabTitle: themeui.MutedVariantStyle(statusBar, t), | |
| 399 | - | tabTitleActive: themeui.MutedVariantStyle(accent, t), | |
| 400 | - | ||
| 401 | - | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |
| 402 | - | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |
| 403 | - | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |
| 404 | - | pickerSeparator: themeui.MutedTextStyle(t), | |
| 405 | - | } | |
| 406 | - | } | |
| 407 | - | ||
| 408 | - | func resolveThemeStyles(styles []themeStyles) themeStyles { | |
| 360 | + | func resolveStyles(styles []themeui.Styles) themeui.Styles { | |
| 409 | 361 | if len(styles) > 0 { | |
| 410 | 362 | return styles[0] | |
| 411 | 363 | } | |
| 412 | - | return newThemeStyles(themeui.Theme{}) | |
| 364 | + | return themeui.NewStyles(themeui.Theme{}) | |
| 413 | 365 | } | |
| 414 | 366 | ||
| 415 | 367 | type barCache struct { | |
| @@ -357,59 +357,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 357 | d.emitFrame(sess, ac, state, composed, &marks) | 357 | d.emitFrame(sess, ac, state, composed, &marks) | ||
| 358 | } | 358 | } | ||
| 359 | 359 | ||||
| 360 | - | type themeStyles struct { | 360 | + | func resolveStyles(styles []themeui.Styles) themeui.Styles { |
| 361 | - | statusBar renderer.Style | |||
| 362 | - | accent renderer.Style | |||
| 363 | - | border renderer.Style | |||
| 364 | - | selection renderer.Style | |||
| 365 | - | copyStatus renderer.Style | |||
| 366 | - | paletteDesc renderer.Style | |||
| 367 | - | ||||
| 368 | - | // Top bar tab label segments: name (bold) and parenthesized pane-title | |||
| 369 | - | // (muted), one pair per base style. No-ops to statusBar/accent on | |||
| 370 | - | // non-truecolor themes. | |||
| 371 | - | tabName renderer.Style | |||
| 372 | - | tabNameActive renderer.Style | |||
| 373 | - | tabTitle renderer.Style | |||
| 374 | - | tabTitleActive renderer.Style | |||
| 375 | - | ||||
| 376 | - | // Session picker row segments. Detail reuses paletteDesc (both are | |||
| 377 | - | // themeui.MutedTextStyle(t)); pickerName/pickerSelection* are picker-only. | |||
| 378 | - | pickerName renderer.Style | |||
| 379 | - | pickerSelectionName renderer.Style | |||
| 380 | - | pickerSelectionMuted renderer.Style | |||
| 381 | - | pickerSeparator renderer.Style | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | func newThemeStyles(t themeui.Theme) themeStyles { | |||
| 385 | - | statusBar := themeui.StatusBarStyle(t) | |||
| 386 | - | accent := themeui.AccentStyle(t) | |||
| 387 | - | selection := themeui.SelectionStyle(t) | |||
| 388 | - | return themeStyles{ | |||
| 389 | - | statusBar: statusBar, | |||
| 390 | - | accent: accent, | |||
| 391 | - | border: themeui.BorderStyle(t), | |||
| 392 | - | selection: selection, | |||
| 393 | - | copyStatus: selection, | |||
| 394 | - | paletteDesc: themeui.MutedTextStyle(t), | |||
| 395 | - | ||||
| 396 | - | tabName: themeui.EmphasisStyle(statusBar, t), | |||
| 397 | - | tabNameActive: themeui.EmphasisStyle(accent, t), | |||
| 398 | - | tabTitle: themeui.MutedVariantStyle(statusBar, t), | |||
| 399 | - | tabTitleActive: themeui.MutedVariantStyle(accent, t), | |||
| 400 | - | ||||
| 401 | - | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |||
| 402 | - | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |||
| 403 | - | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |||
| 404 | - | pickerSeparator: themeui.MutedTextStyle(t), | |||
| 405 | - | } | |||
| 406 | - | } | |||
| 407 | - | ||||
| 408 | - | func resolveThemeStyles(styles []themeStyles) themeStyles { | |||
| 409 | if len(styles) > 0 { | 361 | if len(styles) > 0 { | ||
| 410 | return styles[0] | 362 | return styles[0] | ||
| 411 | } | 363 | } | ||
| 412 | - | return newThemeStyles(themeui.Theme{}) | 364 | + | return themeui.NewStyles(themeui.Theme{}) |
| 413 | } | 365 | } | ||
| 414 | 366 | ||||
| 415 | type barCache struct { | 367 | type barCache struct { | ||
M internal/usecase/daemon/render_pipeline.go +11 -11
| @@ -77,14 +77,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 77 | 77 | copy(frame.Row(y), in.frame.Row(y)) | |
| 78 | 78 | } | |
| 79 | 79 | } | |
| 80 | - | styles := newThemeStyles(state.theme) | |
| 80 | + | styles := themeui.NewStyles(state.theme) | |
| 81 | 81 | defaultDimmer := themeui.NewDimmer(state.theme) | |
| 82 | 82 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | |
| 83 | 83 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 84 | 84 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 85 | 85 | content := domain.Rect{Y: 1, Width: width, Height: rows} | |
| 86 | 86 | if state.layout.valid && state.layout.root != nil { | |
| 87 | - | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.border)) | |
| 87 | + | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border)) | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | 90 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible | |
| @@ -157,10 +157,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 157 | 157 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) { | |
| 161 | - | style := styles.border | |
| 160 | + | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) { | |
| 161 | + | style := styles.Border | |
| 162 | 162 | if focused { | |
| 163 | - | style = styles.statusBar | |
| 163 | + | style = styles.StatusBar | |
| 164 | 164 | } else { | |
| 165 | 165 | style = dimmer.Dim(style) | |
| 166 | 166 | } | |
| @@ -191,15 +191,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 191 | 191 | if snap.copyPane != nil { | |
| 192 | 192 | o.copyPaneID = snap.copyPane.id | |
| 193 | 193 | } | |
| 194 | - | styles := newThemeStyles(state.theme) | |
| 194 | + | styles := themeui.NewStyles(state.theme) | |
| 195 | 195 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 196 | 196 | if snap.copySearchModel != nil { | |
| 197 | 197 | o.copySearch.modal = copySearchModal | |
| 198 | - | o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection) | |
| 198 | + | o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection) | |
| 199 | 199 | } | |
| 200 | 200 | if snap.pickerActive && snap.pickerModel != nil { | |
| 201 | 201 | o.picker.modal = pickerModal | |
| 202 | - | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator} | |
| 202 | + | renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator} | |
| 203 | 203 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 204 | 204 | } | |
| 205 | 205 | if snap.paletteActive && snap.paletteModel != nil { | |
| @@ -209,11 +209,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 209 | 209 | guidance = snap.paletteHints.Feedback | |
| 210 | 210 | } | |
| 211 | 211 | o.paletteGuidance = snap.paletteFeedback | |
| 212 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback}) | |
| 212 | + | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback}) | |
| 213 | 213 | } | |
| 214 | 214 | if snap.promptActive && snap.promptModel != nil { | |
| 215 | 215 | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | |
| 216 | - | o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent) | |
| 216 | + | o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent) | |
| 217 | 217 | } | |
| 218 | 218 | state.cursor.hiddenByOverlay = o.active() | |
| 219 | 219 | } | |
| @@ -248,7 +248,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 248 | 248 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |
| 249 | 249 | continue | |
| 250 | 250 | } | |
| 251 | - | inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border) | |
| 251 | + | inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border) | |
| 252 | 252 | for y := range min(inner.Height, modal.inner.Height) { | |
| 253 | 253 | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | |
| 254 | 254 | } | |
| @@ -77,14 +77,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 77 | copy(frame.Row(y), in.frame.Row(y)) | 77 | copy(frame.Row(y), in.frame.Row(y)) | ||
| 78 | } | 78 | } | ||
| 79 | } | 79 | } | ||
| 80 | - | styles := newThemeStyles(state.theme) | 80 | + | styles := themeui.NewStyles(state.theme) |
| 81 | defaultDimmer := themeui.NewDimmer(state.theme) | 81 | defaultDimmer := themeui.NewDimmer(state.theme) | ||
| 82 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | 82 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | ||
| 83 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | 83 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | ||
| 84 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | 84 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | ||
| 85 | content := domain.Rect{Y: 1, Width: width, Height: rows} | 85 | content := domain.Rect{Y: 1, Width: width, Height: rows} | ||
| 86 | if state.layout.valid && state.layout.root != nil { | 86 | if state.layout.valid && state.layout.root != nil { | ||
| 87 | - | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.border)) | 87 | + | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border)) |
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible | 90 | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible | ||
| @@ -157,10 +157,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 157 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 157 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | - | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) { | 160 | + | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) { |
| 161 | - | style := styles.border | 161 | + | style := styles.Border |
| 162 | if focused { | 162 | if focused { | ||
| 163 | - | style = styles.statusBar | 163 | + | style = styles.StatusBar |
| 164 | } else { | 164 | } else { | ||
| 165 | style = dimmer.Dim(style) | 165 | style = dimmer.Dim(style) | ||
| 166 | } | 166 | } | ||
| @@ -191,15 +191,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 191 | if snap.copyPane != nil { | 191 | if snap.copyPane != nil { | ||
| 192 | o.copyPaneID = snap.copyPane.id | 192 | o.copyPaneID = snap.copyPane.id | ||
| 193 | } | 193 | } | ||
| 194 | - | styles := newThemeStyles(state.theme) | 194 | + | styles := themeui.NewStyles(state.theme) |
| 195 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | 195 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | ||
| 196 | if snap.copySearchModel != nil { | 196 | if snap.copySearchModel != nil { | ||
| 197 | o.copySearch.modal = copySearchModal | 197 | o.copySearch.modal = copySearchModal | ||
| 198 | - | o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection) | 198 | + | o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection) |
| 199 | } | 199 | } | ||
| 200 | if snap.pickerActive && snap.pickerModel != nil { | 200 | if snap.pickerActive && snap.pickerModel != nil { | ||
| 201 | o.picker.modal = pickerModal | 201 | o.picker.modal = pickerModal | ||
| 202 | - | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator} | 202 | + | renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator} |
| 203 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | 203 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | ||
| 204 | } | 204 | } | ||
| 205 | if snap.paletteActive && snap.paletteModel != nil { | 205 | if snap.paletteActive && snap.paletteModel != nil { | ||
| @@ -209,11 +209,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 209 | guidance = snap.paletteHints.Feedback | 209 | guidance = snap.paletteHints.Feedback | ||
| 210 | } | 210 | } | ||
| 211 | o.paletteGuidance = snap.paletteFeedback | 211 | o.paletteGuidance = snap.paletteFeedback | ||
| 212 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback}) | 212 | + | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback}) |
| 213 | } | 213 | } | ||
| 214 | if snap.promptActive && snap.promptModel != nil { | 214 | if snap.promptActive && snap.promptModel != nil { | ||
| 215 | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | 215 | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | ||
| 216 | - | o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent) | 216 | + | o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent) |
| 217 | } | 217 | } | ||
| 218 | state.cursor.hiddenByOverlay = o.active() | 218 | state.cursor.hiddenByOverlay = o.active() | ||
| 219 | } | 219 | } | ||
| @@ -248,7 +248,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||||
| 248 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | 248 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | ||
| 249 | continue | 249 | continue | ||
| 250 | } | 250 | } | ||
| 251 | - | inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border) | 251 | + | inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border) |
| 252 | for y := range min(inner.Height, modal.inner.Height) { | 252 | for y := range min(inner.Height, modal.inner.Height) { | ||
| 253 | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | 253 | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | ||
| 254 | } | 254 | } | ||
M internal/usecase/daemon/status.go +17 -30
| @@ -41,28 +41,21 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |||
| 41 | 41 | distance = -distance | |
| 42 | 42 | } | |
| 43 | 43 | intensity := 1 - float64(distance)/float64(peak) | |
| 44 | - | style := base | |
| 45 | - | style.Bold = true | |
| 46 | - | if base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 47 | - | style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |
| 48 | - | } else { | |
| 49 | - | style.Foreground = 244 + int(intensity*11) | |
| 50 | - | } | |
| 51 | - | return style, true | |
| 44 | + | return themeui.PulseColor(base, intensity), true | |
| 52 | 45 | } | |
| 53 | 46 | ||
| 54 | - | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { | |
| 47 | + | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) { | |
| 55 | 48 | clearStatusRow(row) | |
| 56 | 49 | x := 0 | |
| 57 | 50 | labels := fitTabLabels(status.tabs, len(row), topRight) | |
| 58 | 51 | for i, w := range status.tabs { | |
| 59 | - | baseStyle := styles.statusBar | |
| 60 | - | nameStyle := styles.tabName | |
| 61 | - | titleStyle := styles.tabTitle | |
| 52 | + | baseStyle := styles.StatusBar | |
| 53 | + | nameStyle := styles.TabName | |
| 54 | + | titleStyle := styles.TabTitle | |
| 62 | 55 | if w.active { | |
| 63 | - | baseStyle = styles.accent | |
| 64 | - | nameStyle = styles.tabNameActive | |
| 65 | - | titleStyle = styles.tabTitleActive | |
| 56 | + | baseStyle = styles.Accent | |
| 57 | + | nameStyle = styles.TabNameActive | |
| 58 | + | titleStyle = styles.TabTitleActive | |
| 66 | 59 | } | |
| 67 | 60 | label := labels[i] | |
| 68 | 61 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| @@ -73,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 73 | 66 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 74 | 67 | writeStatusText(row, &x, " ", baseStyle) | |
| 75 | 68 | } | |
| 76 | - | drawRightPlainText(row, topRight, x, styles.statusBar) | |
| 69 | + | drawRightPlainText(row, topRight, x, styles.StatusBar) | |
| 77 | 70 | } | |
| 78 | 71 | ||
| 79 | - | func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) { | |
| 72 | + | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | |
| 80 | 73 | clearStatusRow(row) | |
| 81 | 74 | x := 0 | |
| 82 | 75 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | |
| 83 | - | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | |
| 76 | + | writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent) | |
| 84 | 77 | if state.rankedRecent != nil { | |
| 85 | 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | |
| 86 | - | style := styles.statusBar // contextual ranks deliberately do not fade. | |
| 79 | + | style := styles.StatusBar // contextual ranks deliberately do not fade. | |
| 87 | 80 | if sess.selected { | |
| 88 | - | style = styles.accent | |
| 81 | + | style = styles.Accent | |
| 89 | 82 | } | |
| 90 | 83 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |
| 91 | 84 | } | |
| 92 | 85 | } else { | |
| 93 | 86 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 94 | 87 | for i, sess := range fittedMRU { | |
| 95 | - | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | |
| 88 | + | style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU)) | |
| 96 | 89 | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | |
| 97 | 90 | } | |
| 98 | 91 | } | |
| 99 | - | drawRightPlainText(row, rightText, x, styles.statusBar) | |
| 92 | + | drawRightPlainText(row, rightText, x, styles.StatusBar) | |
| 100 | 93 | } | |
| 101 | 94 | ||
| 102 | 95 | type rankedRecent struct { | |
| @@ -170,7 +163,7 @@ type barState struct { | |||
| 170 | 163 | attentionFrame int | |
| 171 | 164 | // theme is the client's terminal theme, if reported. Its zero value | |
| 172 | 165 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | |
| 173 | - | // the pre-theme fallback styles (see newThemeStyles / theme.usable). | |
| 166 | + | // the pre-theme fallback styles (see themeui.NewStyles / theme.usable). | |
| 174 | 167 | theme themeui.Theme | |
| 175 | 168 | } | |
| 176 | 169 | ||
| @@ -434,13 +427,7 @@ func mruFutureRightReserve(rowLen int) int { | |||
| 434 | 427 | } | |
| 435 | 428 | ||
| 436 | 429 | func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style { | |
| 437 | - | if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG { | |
| 438 | - | return base | |
| 439 | - | } | |
| 440 | - | amount := (float64(i) / float64(count-1)) * 0.6 | |
| 441 | - | base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount) | |
| 442 | - | base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount) | |
| 443 | - | return base | |
| 430 | + | return themeui.MRUFade(base, t, i, count) | |
| 444 | 431 | } | |
| 445 | 432 | ||
| 446 | 433 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | |
| @@ -41,28 +41,21 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |||||
| 41 | distance = -distance | 41 | distance = -distance | ||
| 42 | } | 42 | } | ||
| 43 | intensity := 1 - float64(distance)/float64(peak) | 43 | intensity := 1 - float64(distance)/float64(peak) | ||
| 44 | - | style := base | 44 | + | return themeui.PulseColor(base, intensity), true |
| 45 | - | style.Bold = true | |||
| 46 | - | if base.HasForegroundRGB && base.HasBackgroundRGB { | |||
| 47 | - | style.ForegroundRGB = themeui.Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |||
| 48 | - | } else { | |||
| 49 | - | style.Foreground = 244 + int(intensity*11) | |||
| 50 | - | } | |||
| 51 | - | return style, true | |||
| 52 | } | 45 | } | ||
| 53 | 46 | ||||
| 54 | - | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { | 47 | + | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) { |
| 55 | clearStatusRow(row) | 48 | clearStatusRow(row) | ||
| 56 | x := 0 | 49 | x := 0 | ||
| 57 | labels := fitTabLabels(status.tabs, len(row), topRight) | 50 | labels := fitTabLabels(status.tabs, len(row), topRight) | ||
| 58 | for i, w := range status.tabs { | 51 | for i, w := range status.tabs { | ||
| 59 | - | baseStyle := styles.statusBar | 52 | + | baseStyle := styles.StatusBar |
| 60 | - | nameStyle := styles.tabName | 53 | + | nameStyle := styles.TabName |
| 61 | - | titleStyle := styles.tabTitle | 54 | + | titleStyle := styles.TabTitle |
| 62 | if w.active { | 55 | if w.active { | ||
| 63 | - | baseStyle = styles.accent | 56 | + | baseStyle = styles.Accent |
| 64 | - | nameStyle = styles.tabNameActive | 57 | + | nameStyle = styles.TabNameActive |
| 65 | - | titleStyle = styles.tabTitleActive | 58 | + | titleStyle = styles.TabTitleActive |
| 66 | } | 59 | } | ||
| 67 | label := labels[i] | 60 | label := labels[i] | ||
| 68 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | 61 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | ||
| @@ -73,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||||
| 73 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | 66 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | ||
| 74 | writeStatusText(row, &x, " ", baseStyle) | 67 | writeStatusText(row, &x, " ", baseStyle) | ||
| 75 | } | 68 | } | ||
| 76 | - | drawRightPlainText(row, topRight, x, styles.statusBar) | 69 | + | drawRightPlainText(row, topRight, x, styles.StatusBar) |
| 77 | } | 70 | } | ||
| 78 | 71 | ||||
| 79 | - | func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) { | 72 | + | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { |
| 80 | clearStatusRow(row) | 73 | clearStatusRow(row) | ||
| 81 | x := 0 | 74 | x := 0 | ||
| 82 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | 75 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | ||
| 83 | - | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | 76 | + | writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent) |
| 84 | if state.rankedRecent != nil { | 77 | if state.rankedRecent != nil { | ||
| 85 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | ||
| 86 | - | style := styles.statusBar // contextual ranks deliberately do not fade. | 79 | + | style := styles.StatusBar // contextual ranks deliberately do not fade. |
| 87 | if sess.selected { | 80 | if sess.selected { | ||
| 88 | - | style = styles.accent | 81 | + | style = styles.Accent |
| 89 | } | 82 | } | ||
| 90 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | 83 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | ||
| 91 | } | 84 | } | ||
| 92 | } else { | 85 | } else { | ||
| 93 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | 86 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | ||
| 94 | for i, sess := range fittedMRU { | 87 | for i, sess := range fittedMRU { | ||
| 95 | - | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | 88 | + | style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU)) |
| 96 | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | 89 | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | ||
| 97 | } | 90 | } | ||
| 98 | } | 91 | } | ||
| 99 | - | drawRightPlainText(row, rightText, x, styles.statusBar) | 92 | + | drawRightPlainText(row, rightText, x, styles.StatusBar) |
| 100 | } | 93 | } | ||
| 101 | 94 | ||||
| 102 | type rankedRecent struct { | 95 | type rankedRecent struct { | ||
| @@ -170,7 +163,7 @@ type barState struct { | |||||
| 170 | attentionFrame int | 163 | attentionFrame int | ||
| 171 | // theme is the client's terminal theme, if reported. Its zero value | 164 | // theme is the client's terminal theme, if reported. Its zero value | ||
| 172 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | 165 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | ||
| 173 | - | // the pre-theme fallback styles (see newThemeStyles / theme.usable). | 166 | + | // the pre-theme fallback styles (see themeui.NewStyles / theme.usable). |
| 174 | theme themeui.Theme | 167 | theme themeui.Theme | ||
| 175 | } | 168 | } | ||
| 176 | 169 | ||||
| @@ -434,13 +427,7 @@ func mruFutureRightReserve(rowLen int) int { | |||||
| 434 | } | 427 | } | ||
| 435 | 428 | ||||
| 436 | func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style { | 429 | func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style { | ||
| 437 | - | if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG { | 430 | + | return themeui.MRUFade(base, t, i, count) |
| 438 | - | return base | |||
| 439 | - | } | |||
| 440 | - | amount := (float64(i) / float64(count-1)) * 0.6 | |||
| 441 | - | base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount) | |||
| 442 | - | base.BackgroundRGB = themeui.Blend(base.BackgroundRGB, t.Background, amount) | |||
| 443 | - | return base | |||
| 444 | } | 431 | } | ||
| 445 | 432 | ||||
| 446 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | 433 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | ||
M internal/usecase/daemon/status_test.go +42 -42
| @@ -267,9 +267,9 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 267 | 267 | Known: true, | |
| 268 | 268 | TrueColor: true, | |
| 269 | 269 | } | |
| 270 | - | styles := newThemeStyles(theme) | |
| 271 | - | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |
| 272 | - | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | |
| 270 | + | styles := themeui.NewStyles(theme) | |
| 271 | + | require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | |
| 272 | + | require.False(t, styles.TabTitleActive.Equal(styles.Accent), "sanity: muted active-title style must differ from the base accent style") | |
| 273 | 273 | ||
| 274 | 274 | labels := fitTabLabels(tabs, rowLen, "") | |
| 275 | 275 | ||
| @@ -286,17 +286,17 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 286 | 286 | nameEnd := x + 1 + labels[0].nameLen | |
| 287 | 287 | for i := x; i < nameEnd; i++ { | |
| 288 | 288 | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | |
| 289 | - | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | |
| 289 | + | require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: name segment style mismatch", i) | |
| 290 | 290 | } | |
| 291 | 291 | x = nameEnd | |
| 292 | 292 | titleLen := len(labels[0].text) - labels[0].nameLen | |
| 293 | 293 | titleEnd := x + titleLen | |
| 294 | 294 | for i := x; i < titleEnd; i++ { | |
| 295 | 295 | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | |
| 296 | - | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | |
| 296 | + | require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: title segment style mismatch", i) | |
| 297 | 297 | } | |
| 298 | 298 | x = titleEnd | |
| 299 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x) | |
| 299 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 0 trailing space must keep the base style", x) | |
| 300 | 300 | x++ | |
| 301 | 301 | ||
| 302 | 302 | // tab 1: active, attention, no title: name, attention space+bell (both | |
| @@ -304,16 +304,16 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 304 | 304 | nameEnd = x + 1 + labels[1].nameLen | |
| 305 | 305 | for i := x; i < nameEnd; i++ { | |
| 306 | 306 | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | |
| 307 | - | require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i) | |
| 307 | + | require.True(t, row[i].Style.Equal(styles.TabNameActive), "cell %d: active name segment style mismatch", i) | |
| 308 | 308 | } | |
| 309 | 309 | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | |
| 310 | 310 | x = nameEnd | |
| 311 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x) | |
| 311 | + | require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 attention leading space must keep the active base style", x) | |
| 312 | 312 | x++ | |
| 313 | 313 | tab1Bell := x | |
| 314 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) | |
| 314 | + | require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) | |
| 315 | 315 | x++ | |
| 316 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x) | |
| 316 | + | require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 trailing space must keep the active base style", x) | |
| 317 | 317 | x++ | |
| 318 | 318 | ||
| 319 | 319 | // tab 2: inactive, attention, with title: name, attention space+bell | |
| @@ -321,22 +321,22 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 321 | 321 | nameEnd = x + 1 + labels[2].nameLen | |
| 322 | 322 | for i := x; i < nameEnd; i++ { | |
| 323 | 323 | require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i) | |
| 324 | - | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i) | |
| 324 | + | require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: tab 2 name segment style mismatch", i) | |
| 325 | 325 | } | |
| 326 | 326 | x = nameEnd | |
| 327 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x) | |
| 327 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 attention leading space must keep the base style", x) | |
| 328 | 328 | x++ | |
| 329 | 329 | tab2Bell := x | |
| 330 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) | |
| 330 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) | |
| 331 | 331 | x++ | |
| 332 | 332 | titleLen = len(labels[2].text) - labels[2].nameLen | |
| 333 | 333 | titleEnd = x + titleLen | |
| 334 | 334 | for i := x; i < titleEnd; i++ { | |
| 335 | 335 | require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i) | |
| 336 | - | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i) | |
| 336 | + | require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: tab 2 title segment style mismatch", i) | |
| 337 | 337 | } | |
| 338 | 338 | x = titleEnd | |
| 339 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x) | |
| 339 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 trailing space must keep the base style", x) | |
| 340 | 340 | ||
| 341 | 341 | // On a visible pulse frame, the bell glyph itself must keep the same | |
| 342 | 342 | // background as its tab's base style. | |
| @@ -344,18 +344,18 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 344 | 344 | drawTopBarSnapshot(visibleRow, status, 1, "", styles) | |
| 345 | 345 | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune) | |
| 346 | 346 | require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB) | |
| 347 | - | require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") | |
| 347 | + | require.Equal(t, styles.Accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") | |
| 348 | 348 | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune) | |
| 349 | 349 | require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB) | |
| 350 | - | require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") | |
| 350 | + | require.Equal(t, styles.StatusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") | |
| 351 | 351 | }) | |
| 352 | 352 | ||
| 353 | 353 | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | |
| 354 | - | styles := resolveThemeStyles(nil) | |
| 355 | - | require.True(t, styles.tabName.Equal(styles.statusBar)) | |
| 356 | - | require.True(t, styles.tabTitle.Equal(styles.statusBar)) | |
| 357 | - | require.True(t, styles.tabNameActive.Equal(styles.accent)) | |
| 358 | - | require.True(t, styles.tabTitleActive.Equal(styles.accent)) | |
| 354 | + | styles := resolveStyles(nil) | |
| 355 | + | require.True(t, styles.TabName.Equal(styles.StatusBar)) | |
| 356 | + | require.True(t, styles.TabTitle.Equal(styles.StatusBar)) | |
| 357 | + | require.True(t, styles.TabNameActive.Equal(styles.Accent)) | |
| 358 | + | require.True(t, styles.TabTitleActive.Equal(styles.Accent)) | |
| 359 | 359 | ||
| 360 | 360 | row := make([]renderer.Cell, rowLen) | |
| 361 | 361 | drawTopBarSnapshot(row, status, 0, "", styles) | |
| @@ -371,15 +371,15 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||
| 371 | 371 | require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row)) | |
| 372 | 372 | ||
| 373 | 373 | for i, c := range row[:len(tab0)] { | |
| 374 | - | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | |
| 374 | + | require.True(t, c.Style.Equal(styles.StatusBar), "cell %d should use the plain base style", i) | |
| 375 | 375 | } | |
| 376 | 376 | tab1Start := len(tab0) | |
| 377 | 377 | for i := tab1Start; i < tab1Start+len(tab1); i++ { | |
| 378 | - | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i) | |
| 378 | + | require.True(t, row[i].Style.Equal(styles.Accent), "cell %d should use the plain accent style, including the blank bell cell", i) | |
| 379 | 379 | } | |
| 380 | 380 | tab2Start := tab1Start + len(tab1) | |
| 381 | 381 | for i := tab2Start; i < tab2Start+len(tab2); i++ { | |
| 382 | - | require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i) | |
| 382 | + | require.True(t, row[i].Style.Equal(styles.StatusBar), "cell %d should use the plain base style, including the blank bell cell", i) | |
| 383 | 383 | } | |
| 384 | 384 | }) | |
| 385 | 385 | } | |
| @@ -847,7 +847,7 @@ func TestTopBarRightAnchor(t *testing.T) { | |||
| 847 | 847 | t.Run(tt.name, func(t *testing.T) { | |
| 848 | 848 | row := make([]renderer.Cell, tt.width) | |
| 849 | 849 | ||
| 850 | - | drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil)) | |
| 850 | + | drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveStyles(nil)) | |
| 851 | 851 | ||
| 852 | 852 | require.Equal(t, tt.want, rowText(row)) | |
| 853 | 853 | if tt.continuationCell > 0 { | |
| @@ -1093,7 +1093,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse( | |||
| 1093 | 1093 | draw := func(state capturedRenderState) string { | |
| 1094 | 1094 | t.Helper() | |
| 1095 | 1095 | row := make([]renderer.Cell, 32) | |
| 1096 | - | drawStatusBarState(row, state.bars, resolveThemeStyles(nil)) | |
| 1096 | + | drawStatusBarState(row, state.bars, resolveStyles(nil)) | |
| 1097 | 1097 | return rowText(row) | |
| 1098 | 1098 | } | |
| 1099 | 1099 | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}} | |
| @@ -1139,7 +1139,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||
| 1139 | 1139 | } | |
| 1140 | 1140 | row := make([]renderer.Cell, 24) | |
| 1141 | 1141 | ||
| 1142 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 1142 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |
| 1143 | 1143 | ||
| 1144 | 1144 | require.Equal(t, " cur fresh tmp ", rowText(row)) | |
| 1145 | 1145 | for _, c := range row { | |
| @@ -1153,19 +1153,19 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||
| 1153 | 1153 | ||
| 1154 | 1154 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | |
| 1155 | 1155 | theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | |
| 1156 | - | styles := newThemeStyles(theme) | |
| 1156 | + | styles := themeui.NewStyles(theme) | |
| 1157 | 1157 | row := make([]renderer.Cell, 16) | |
| 1158 | 1158 | ||
| 1159 | 1159 | drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles) | |
| 1160 | 1160 | ||
| 1161 | 1161 | for _, idx := range []int{0, 1, 2, 3, 4} { | |
| 1162 | - | require.True(t, row[idx].Style.Equal(styles.accent), "cell %d should use accent style", idx) | |
| 1162 | + | require.True(t, row[idx].Style.Equal(styles.Accent), "cell %d should use accent style", idx) | |
| 1163 | 1163 | } | |
| 1164 | - | require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB) | |
| 1164 | + | require.NotEqual(t, styles.StatusBar.BackgroundRGB, styles.Accent.BackgroundRGB) | |
| 1165 | 1165 | } | |
| 1166 | 1166 | ||
| 1167 | 1167 | func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) { | |
| 1168 | - | styles := resolveThemeStyles(nil) | |
| 1168 | + | styles := resolveStyles(nil) | |
| 1169 | 1169 | row := make([]renderer.Cell, 18) | |
| 1170 | 1170 | state := barState{ | |
| 1171 | 1171 | status: statusSnapshot{session: "cur"}, | |
| @@ -1190,8 +1190,8 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||
| 1190 | 1190 | if cell.Rune == '2' { | |
| 1191 | 1191 | found = true | |
| 1192 | 1192 | require.True(t, cell.Style.Bold) | |
| 1193 | - | require.Equal(t, styles.accent.Foreground, cell.Style.Foreground) | |
| 1194 | - | require.Equal(t, styles.accent.Background, cell.Style.Background) | |
| 1193 | + | require.Equal(t, styles.Accent.Foreground, cell.Style.Foreground) | |
| 1194 | + | require.Equal(t, styles.Accent.Background, cell.Style.Background) | |
| 1195 | 1195 | break | |
| 1196 | 1196 | } | |
| 1197 | 1197 | } | |
| @@ -1200,7 +1200,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||
| 1200 | 1200 | ||
| 1201 | 1201 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |
| 1202 | 1202 | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | |
| 1203 | - | styles := newThemeStyles(theme) | |
| 1203 | + | styles := themeui.NewStyles(theme) | |
| 1204 | 1204 | state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1205 | 1205 | row := make([]renderer.Cell, 16) | |
| 1206 | 1206 | ||
| @@ -1209,14 +1209,14 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |||
| 1209 | 1209 | firstFG := row[4].Style.ForegroundRGB.R | |
| 1210 | 1210 | secondFG := row[7].Style.ForegroundRGB.R | |
| 1211 | 1211 | thirdFG := row[10].Style.ForegroundRGB.R | |
| 1212 | - | require.Equal(t, styles.statusBar.ForegroundRGB.R, firstFG) | |
| 1212 | + | require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG) | |
| 1213 | 1213 | require.Greater(t, firstFG, secondFG) | |
| 1214 | 1214 | require.Greater(t, secondFG, thirdFG) | |
| 1215 | 1215 | ||
| 1216 | 1216 | firstBG := row[4].Style.BackgroundRGB.R | |
| 1217 | 1217 | secondBG := row[7].Style.BackgroundRGB.R | |
| 1218 | 1218 | thirdBG := row[10].Style.BackgroundRGB.R | |
| 1219 | - | require.Equal(t, styles.statusBar.BackgroundRGB.R, firstBG) | |
| 1219 | + | require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG) | |
| 1220 | 1220 | require.Greater(t, firstBG, secondBG) | |
| 1221 | 1221 | require.Greater(t, secondBG, thirdBG) | |
| 1222 | 1222 | ||
| @@ -1229,7 +1229,7 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | |||
| 1229 | 1229 | state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | |
| 1230 | 1230 | row := make([]renderer.Cell, 21) | |
| 1231 | 1231 | ||
| 1232 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 1232 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |
| 1233 | 1233 | ||
| 1234 | 1234 | text := rowText(row) | |
| 1235 | 1235 | require.Contains(t, text, "fresh") | |
| @@ -1273,7 +1273,7 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |||
| 1273 | 1273 | t.Run(tt.name, func(t *testing.T) { | |
| 1274 | 1274 | row := make([]renderer.Cell, tt.cols) | |
| 1275 | 1275 | ||
| 1276 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 1276 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |
| 1277 | 1277 | ||
| 1278 | 1278 | text := rowText(row) | |
| 1279 | 1279 | for _, want := range tt.wantShown { | |
| @@ -1346,7 +1346,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1346 | 1346 | t.Run(tt.name, func(t *testing.T) { | |
| 1347 | 1347 | row := make([]renderer.Cell, tt.width) | |
| 1348 | 1348 | ||
| 1349 | - | drawStatusBarState(row, tt.state, resolveThemeStyles(nil)) | |
| 1349 | + | drawStatusBarState(row, tt.state, resolveStyles(nil)) | |
| 1350 | 1350 | ||
| 1351 | 1351 | text := rowText(row) | |
| 1352 | 1352 | if tt.want != "" { | |
| @@ -1372,7 +1372,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |||
| 1372 | 1372 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1373 | 1373 | row := make([]renderer.Cell, 20) | |
| 1374 | 1374 | ||
| 1375 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 1375 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |
| 1376 | 1376 | ||
| 1377 | 1377 | text := rowText(row) | |
| 1378 | 1378 | require.Contains(t, text, " a") | |
| @@ -1395,7 +1395,7 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | |||
| 1395 | 1395 | t.Run(tt.name, func(t *testing.T) { | |
| 1396 | 1396 | row := make([]renderer.Cell, tt.cols) | |
| 1397 | 1397 | ||
| 1398 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 1398 | + | drawStatusBarState(row, state, resolveStyles(nil)) | |
| 1399 | 1399 | ||
| 1400 | 1400 | require.Equal(t, tt.want, rowText(row)) | |
| 1401 | 1401 | }) | |
| @@ -267,9 +267,9 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 267 | Known: true, | 267 | Known: true, | ||
| 268 | TrueColor: true, | 268 | TrueColor: true, | ||
| 269 | } | 269 | } | ||
| 270 | - | styles := newThemeStyles(theme) | 270 | + | styles := themeui.NewStyles(theme) |
| 271 | - | require.False(t, styles.tabTitle.Equal(styles.statusBar), "sanity: muted title style must differ from the base style on a truecolor theme") | 271 | + | require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme") |
| 272 | - | require.False(t, styles.tabTitleActive.Equal(styles.accent), "sanity: muted active-title style must differ from the base accent style") | 272 | + | require.False(t, styles.TabTitleActive.Equal(styles.Accent), "sanity: muted active-title style must differ from the base accent style") |
| 273 | 273 | ||||
| 274 | labels := fitTabLabels(tabs, rowLen, "") | 274 | labels := fitTabLabels(tabs, rowLen, "") | ||
| 275 | 275 | ||||
| @@ -286,17 +286,17 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 286 | nameEnd := x + 1 + labels[0].nameLen | 286 | nameEnd := x + 1 + labels[0].nameLen | ||
| 287 | for i := x; i < nameEnd; i++ { | 287 | for i := x; i < nameEnd; i++ { | ||
| 288 | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | 288 | require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i) | ||
| 289 | - | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: name segment style mismatch", i) | 289 | + | require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: name segment style mismatch", i) |
| 290 | } | 290 | } | ||
| 291 | x = nameEnd | 291 | x = nameEnd | ||
| 292 | titleLen := len(labels[0].text) - labels[0].nameLen | 292 | titleLen := len(labels[0].text) - labels[0].nameLen | ||
| 293 | titleEnd := x + titleLen | 293 | titleEnd := x + titleLen | ||
| 294 | for i := x; i < titleEnd; i++ { | 294 | for i := x; i < titleEnd; i++ { | ||
| 295 | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | 295 | require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i) | ||
| 296 | - | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: title segment style mismatch", i) | 296 | + | require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: title segment style mismatch", i) |
| 297 | } | 297 | } | ||
| 298 | x = titleEnd | 298 | x = titleEnd | ||
| 299 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 0 trailing space must keep the base style", x) | 299 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 0 trailing space must keep the base style", x) |
| 300 | x++ | 300 | x++ | ||
| 301 | 301 | ||||
| 302 | // tab 1: active, attention, no title: name, attention space+bell (both | 302 | // tab 1: active, attention, no title: name, attention space+bell (both | ||
| @@ -304,16 +304,16 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 304 | nameEnd = x + 1 + labels[1].nameLen | 304 | nameEnd = x + 1 + labels[1].nameLen | ||
| 305 | for i := x; i < nameEnd; i++ { | 305 | for i := x; i < nameEnd; i++ { | ||
| 306 | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | 306 | require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i) | ||
| 307 | - | require.True(t, row[i].Style.Equal(styles.tabNameActive), "cell %d: active name segment style mismatch", i) | 307 | + | require.True(t, row[i].Style.Equal(styles.TabNameActive), "cell %d: active name segment style mismatch", i) |
| 308 | } | 308 | } | ||
| 309 | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | 309 | require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment") | ||
| 310 | x = nameEnd | 310 | x = nameEnd | ||
| 311 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 attention leading space must keep the active base style", x) | 311 | + | require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 attention leading space must keep the active base style", x) |
| 312 | x++ | 312 | x++ | ||
| 313 | tab1Bell := x | 313 | tab1Bell := x | ||
| 314 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) | 314 | + | require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x) |
| 315 | x++ | 315 | x++ | ||
| 316 | - | require.True(t, row[x].Style.Equal(styles.accent), "cell %d: tab 1 trailing space must keep the active base style", x) | 316 | + | require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 trailing space must keep the active base style", x) |
| 317 | x++ | 317 | x++ | ||
| 318 | 318 | ||||
| 319 | // tab 2: inactive, attention, with title: name, attention space+bell | 319 | // tab 2: inactive, attention, with title: name, attention space+bell | ||
| @@ -321,22 +321,22 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 321 | nameEnd = x + 1 + labels[2].nameLen | 321 | nameEnd = x + 1 + labels[2].nameLen | ||
| 322 | for i := x; i < nameEnd; i++ { | 322 | for i := x; i < nameEnd; i++ { | ||
| 323 | require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i) | 323 | require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i) | ||
| 324 | - | require.True(t, row[i].Style.Equal(styles.tabName), "cell %d: tab 2 name segment style mismatch", i) | 324 | + | require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: tab 2 name segment style mismatch", i) |
| 325 | } | 325 | } | ||
| 326 | x = nameEnd | 326 | x = nameEnd | ||
| 327 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 attention leading space must keep the base style", x) | 327 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 attention leading space must keep the base style", x) |
| 328 | x++ | 328 | x++ | ||
| 329 | tab2Bell := x | 329 | tab2Bell := x | ||
| 330 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) | 330 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x) |
| 331 | x++ | 331 | x++ | ||
| 332 | titleLen = len(labels[2].text) - labels[2].nameLen | 332 | titleLen = len(labels[2].text) - labels[2].nameLen | ||
| 333 | titleEnd = x + titleLen | 333 | titleEnd = x + titleLen | ||
| 334 | for i := x; i < titleEnd; i++ { | 334 | for i := x; i < titleEnd; i++ { | ||
| 335 | require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i) | 335 | require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i) | ||
| 336 | - | require.True(t, row[i].Style.Equal(styles.tabTitle), "cell %d: tab 2 title segment style mismatch", i) | 336 | + | require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: tab 2 title segment style mismatch", i) |
| 337 | } | 337 | } | ||
| 338 | x = titleEnd | 338 | x = titleEnd | ||
| 339 | - | require.True(t, row[x].Style.Equal(styles.statusBar), "cell %d: tab 2 trailing space must keep the base style", x) | 339 | + | require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 trailing space must keep the base style", x) |
| 340 | 340 | ||||
| 341 | // On a visible pulse frame, the bell glyph itself must keep the same | 341 | // On a visible pulse frame, the bell glyph itself must keep the same | ||
| 342 | // background as its tab's base style. | 342 | // background as its tab's base style. | ||
| @@ -344,18 +344,18 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 344 | drawTopBarSnapshot(visibleRow, status, 1, "", styles) | 344 | drawTopBarSnapshot(visibleRow, status, 1, "", styles) | ||
| 345 | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune) | 345 | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune) | ||
| 346 | require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB) | 346 | require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB) | ||
| 347 | - | require.Equal(t, styles.accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") | 347 | + | require.Equal(t, styles.Accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background") |
| 348 | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune) | 348 | require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune) | ||
| 349 | require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB) | 349 | require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB) | ||
| 350 | - | require.Equal(t, styles.statusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") | 350 | + | require.Equal(t, styles.StatusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background") |
| 351 | }) | 351 | }) | ||
| 352 | 352 | ||||
| 353 | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | 353 | t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) { | ||
| 354 | - | styles := resolveThemeStyles(nil) | 354 | + | styles := resolveStyles(nil) |
| 355 | - | require.True(t, styles.tabName.Equal(styles.statusBar)) | 355 | + | require.True(t, styles.TabName.Equal(styles.StatusBar)) |
| 356 | - | require.True(t, styles.tabTitle.Equal(styles.statusBar)) | 356 | + | require.True(t, styles.TabTitle.Equal(styles.StatusBar)) |
| 357 | - | require.True(t, styles.tabNameActive.Equal(styles.accent)) | 357 | + | require.True(t, styles.TabNameActive.Equal(styles.Accent)) |
| 358 | - | require.True(t, styles.tabTitleActive.Equal(styles.accent)) | 358 | + | require.True(t, styles.TabTitleActive.Equal(styles.Accent)) |
| 359 | 359 | ||||
| 360 | row := make([]renderer.Cell, rowLen) | 360 | row := make([]renderer.Cell, rowLen) | ||
| 361 | drawTopBarSnapshot(row, status, 0, "", styles) | 361 | drawTopBarSnapshot(row, status, 0, "", styles) | ||
| @@ -371,15 +371,15 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) { | |||||
| 371 | require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row)) | 371 | require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row)) | ||
| 372 | 372 | ||||
| 373 | for i, c := range row[:len(tab0)] { | 373 | for i, c := range row[:len(tab0)] { | ||
| 374 | - | require.True(t, c.Style.Equal(styles.statusBar), "cell %d should use the plain base style", i) | 374 | + | require.True(t, c.Style.Equal(styles.StatusBar), "cell %d should use the plain base style", i) |
| 375 | } | 375 | } | ||
| 376 | tab1Start := len(tab0) | 376 | tab1Start := len(tab0) | ||
| 377 | for i := tab1Start; i < tab1Start+len(tab1); i++ { | 377 | for i := tab1Start; i < tab1Start+len(tab1); i++ { | ||
| 378 | - | require.True(t, row[i].Style.Equal(styles.accent), "cell %d should use the plain accent style, including the blank bell cell", i) | 378 | + | require.True(t, row[i].Style.Equal(styles.Accent), "cell %d should use the plain accent style, including the blank bell cell", i) |
| 379 | } | 379 | } | ||
| 380 | tab2Start := tab1Start + len(tab1) | 380 | tab2Start := tab1Start + len(tab1) | ||
| 381 | for i := tab2Start; i < tab2Start+len(tab2); i++ { | 381 | for i := tab2Start; i < tab2Start+len(tab2); i++ { | ||
| 382 | - | require.True(t, row[i].Style.Equal(styles.statusBar), "cell %d should use the plain base style, including the blank bell cell", i) | 382 | + | require.True(t, row[i].Style.Equal(styles.StatusBar), "cell %d should use the plain base style, including the blank bell cell", i) |
| 383 | } | 383 | } | ||
| 384 | }) | 384 | }) | ||
| 385 | } | 385 | } | ||
| @@ -847,7 +847,7 @@ func TestTopBarRightAnchor(t *testing.T) { | |||||
| 847 | t.Run(tt.name, func(t *testing.T) { | 847 | t.Run(tt.name, func(t *testing.T) { | ||
| 848 | row := make([]renderer.Cell, tt.width) | 848 | row := make([]renderer.Cell, tt.width) | ||
| 849 | 849 | ||||
| 850 | - | drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil)) | 850 | + | drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveStyles(nil)) |
| 851 | 851 | ||||
| 852 | require.Equal(t, tt.want, rowText(row)) | 852 | require.Equal(t, tt.want, rowText(row)) | ||
| 853 | if tt.continuationCell > 0 { | 853 | if tt.continuationCell > 0 { | ||
| @@ -1093,7 +1093,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse( | |||||
| 1093 | draw := func(state capturedRenderState) string { | 1093 | draw := func(state capturedRenderState) string { | ||
| 1094 | t.Helper() | 1094 | t.Helper() | ||
| 1095 | row := make([]renderer.Cell, 32) | 1095 | row := make([]renderer.Cell, 32) | ||
| 1096 | - | drawStatusBarState(row, state.bars, resolveThemeStyles(nil)) | 1096 | + | drawStatusBarState(row, state.bars, resolveStyles(nil)) |
| 1097 | return rowText(row) | 1097 | return rowText(row) | ||
| 1098 | } | 1098 | } | ||
| 1099 | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}} | 1099 | normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}} | ||
| @@ -1139,7 +1139,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||||
| 1139 | } | 1139 | } | ||
| 1140 | row := make([]renderer.Cell, 24) | 1140 | row := make([]renderer.Cell, 24) | ||
| 1141 | 1141 | ||||
| 1142 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | 1142 | + | drawStatusBarState(row, state, resolveStyles(nil)) |
| 1143 | 1143 | ||||
| 1144 | require.Equal(t, " cur fresh tmp ", rowText(row)) | 1144 | require.Equal(t, " cur fresh tmp ", rowText(row)) | ||
| 1145 | for _, c := range row { | 1145 | for _, c := range row { | ||
| @@ -1153,19 +1153,19 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) { | |||||
| 1153 | 1153 | ||||
| 1154 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | 1154 | func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | ||
| 1155 | theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | 1155 | theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | ||
| 1156 | - | styles := newThemeStyles(theme) | 1156 | + | styles := themeui.NewStyles(theme) |
| 1157 | row := make([]renderer.Cell, 16) | 1157 | row := make([]renderer.Cell, 16) | ||
| 1158 | 1158 | ||||
| 1159 | drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles) | 1159 | drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles) | ||
| 1160 | 1160 | ||||
| 1161 | for _, idx := range []int{0, 1, 2, 3, 4} { | 1161 | for _, idx := range []int{0, 1, 2, 3, 4} { | ||
| 1162 | - | require.True(t, row[idx].Style.Equal(styles.accent), "cell %d should use accent style", idx) | 1162 | + | require.True(t, row[idx].Style.Equal(styles.Accent), "cell %d should use accent style", idx) |
| 1163 | } | 1163 | } | ||
| 1164 | - | require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB) | 1164 | + | require.NotEqual(t, styles.StatusBar.BackgroundRGB, styles.Accent.BackgroundRGB) |
| 1165 | } | 1165 | } | ||
| 1166 | 1166 | ||||
| 1167 | func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) { | 1167 | func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) { | ||
| 1168 | - | styles := resolveThemeStyles(nil) | 1168 | + | styles := resolveStyles(nil) |
| 1169 | row := make([]renderer.Cell, 18) | 1169 | row := make([]renderer.Cell, 18) | ||
| 1170 | state := barState{ | 1170 | state := barState{ | ||
| 1171 | status: statusSnapshot{session: "cur"}, | 1171 | status: statusSnapshot{session: "cur"}, | ||
| @@ -1190,8 +1190,8 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||||
| 1190 | if cell.Rune == '2' { | 1190 | if cell.Rune == '2' { | ||
| 1191 | found = true | 1191 | found = true | ||
| 1192 | require.True(t, cell.Style.Bold) | 1192 | require.True(t, cell.Style.Bold) | ||
| 1193 | - | require.Equal(t, styles.accent.Foreground, cell.Style.Foreground) | 1193 | + | require.Equal(t, styles.Accent.Foreground, cell.Style.Foreground) |
| 1194 | - | require.Equal(t, styles.accent.Background, cell.Style.Background) | 1194 | + | require.Equal(t, styles.Accent.Background, cell.Style.Background) |
| 1195 | break | 1195 | break | ||
| 1196 | } | 1196 | } | ||
| 1197 | } | 1197 | } | ||
| @@ -1200,7 +1200,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||||
| 1200 | 1200 | ||||
| 1201 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | 1201 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | ||
| 1202 | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | 1202 | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | ||
| 1203 | - | styles := newThemeStyles(theme) | 1203 | + | styles := themeui.NewStyles(theme) |
| 1204 | state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 1204 | state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | ||
| 1205 | row := make([]renderer.Cell, 16) | 1205 | row := make([]renderer.Cell, 16) | ||
| 1206 | 1206 | ||||
| @@ -1209,14 +1209,14 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |||||
| 1209 | firstFG := row[4].Style.ForegroundRGB.R | 1209 | firstFG := row[4].Style.ForegroundRGB.R | ||
| 1210 | secondFG := row[7].Style.ForegroundRGB.R | 1210 | secondFG := row[7].Style.ForegroundRGB.R | ||
| 1211 | thirdFG := row[10].Style.ForegroundRGB.R | 1211 | thirdFG := row[10].Style.ForegroundRGB.R | ||
| 1212 | - | require.Equal(t, styles.statusBar.ForegroundRGB.R, firstFG) | 1212 | + | require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG) |
| 1213 | require.Greater(t, firstFG, secondFG) | 1213 | require.Greater(t, firstFG, secondFG) | ||
| 1214 | require.Greater(t, secondFG, thirdFG) | 1214 | require.Greater(t, secondFG, thirdFG) | ||
| 1215 | 1215 | ||||
| 1216 | firstBG := row[4].Style.BackgroundRGB.R | 1216 | firstBG := row[4].Style.BackgroundRGB.R | ||
| 1217 | secondBG := row[7].Style.BackgroundRGB.R | 1217 | secondBG := row[7].Style.BackgroundRGB.R | ||
| 1218 | thirdBG := row[10].Style.BackgroundRGB.R | 1218 | thirdBG := row[10].Style.BackgroundRGB.R | ||
| 1219 | - | require.Equal(t, styles.statusBar.BackgroundRGB.R, firstBG) | 1219 | + | require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG) |
| 1220 | require.Greater(t, firstBG, secondBG) | 1220 | require.Greater(t, firstBG, secondBG) | ||
| 1221 | require.Greater(t, secondBG, thirdBG) | 1221 | require.Greater(t, secondBG, thirdBG) | ||
| 1222 | 1222 | ||||
| @@ -1229,7 +1229,7 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | |||||
| 1229 | state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | 1229 | state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | ||
| 1230 | row := make([]renderer.Cell, 21) | 1230 | row := make([]renderer.Cell, 21) | ||
| 1231 | 1231 | ||||
| 1232 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | 1232 | + | drawStatusBarState(row, state, resolveStyles(nil)) |
| 1233 | 1233 | ||||
| 1234 | text := rowText(row) | 1234 | text := rowText(row) | ||
| 1235 | require.Contains(t, text, "fresh") | 1235 | require.Contains(t, text, "fresh") | ||
| @@ -1273,7 +1273,7 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |||||
| 1273 | t.Run(tt.name, func(t *testing.T) { | 1273 | t.Run(tt.name, func(t *testing.T) { | ||
| 1274 | row := make([]renderer.Cell, tt.cols) | 1274 | row := make([]renderer.Cell, tt.cols) | ||
| 1275 | 1275 | ||||
| 1276 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | 1276 | + | drawStatusBarState(row, state, resolveStyles(nil)) |
| 1277 | 1277 | ||||
| 1278 | text := rowText(row) | 1278 | text := rowText(row) | ||
| 1279 | for _, want := range tt.wantShown { | 1279 | for _, want := range tt.wantShown { | ||
| @@ -1346,7 +1346,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||||
| 1346 | t.Run(tt.name, func(t *testing.T) { | 1346 | t.Run(tt.name, func(t *testing.T) { | ||
| 1347 | row := make([]renderer.Cell, tt.width) | 1347 | row := make([]renderer.Cell, tt.width) | ||
| 1348 | 1348 | ||||
| 1349 | - | drawStatusBarState(row, tt.state, resolveThemeStyles(nil)) | 1349 | + | drawStatusBarState(row, tt.state, resolveStyles(nil)) |
| 1350 | 1350 | ||||
| 1351 | text := rowText(row) | 1351 | text := rowText(row) | ||
| 1352 | if tt.want != "" { | 1352 | if tt.want != "" { | ||
| @@ -1372,7 +1372,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |||||
| 1372 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 1372 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | ||
| 1373 | row := make([]renderer.Cell, 20) | 1373 | row := make([]renderer.Cell, 20) | ||
| 1374 | 1374 | ||||
| 1375 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | 1375 | + | drawStatusBarState(row, state, resolveStyles(nil)) |
| 1376 | 1376 | ||||
| 1377 | text := rowText(row) | 1377 | text := rowText(row) | ||
| 1378 | require.Contains(t, text, " a") | 1378 | require.Contains(t, text, " a") | ||
| @@ -1395,7 +1395,7 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | |||||
| 1395 | t.Run(tt.name, func(t *testing.T) { | 1395 | t.Run(tt.name, func(t *testing.T) { | ||
| 1396 | row := make([]renderer.Cell, tt.cols) | 1396 | row := make([]renderer.Cell, tt.cols) | ||
| 1397 | 1397 | ||||
| 1398 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | 1398 | + | drawStatusBarState(row, state, resolveStyles(nil)) |
| 1399 | 1399 | ||||
| 1400 | require.Equal(t, tt.want, rowText(row)) | 1400 | require.Equal(t, tt.want, rowText(row)) | ||
| 1401 | }) | 1401 | }) | ||
A internal/usecase/theme/palette_scan_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "reflect" | |
| 6 | + | "strconv" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type paletteEvent struct { | |
| 13 | + | slot int | |
| 14 | + | rgb renderer.RGB | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestScannerExtractsPaletteResponses(t *testing.T) { | |
| 18 | + | tests := []struct { | |
| 19 | + | name string | |
| 20 | + | chunks []string | |
| 21 | + | wantPalette []paletteEvent | |
| 22 | + | wantColors []int | |
| 23 | + | wantOut string | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "BEL hash and ST rgb", | |
| 27 | + | chunks: []string{"a\x1b]4;0;#112233\x07b\x1b]4;15;rgb:4455/6677/8899\x1b\\c"}, | |
| 28 | + | wantPalette: []paletteEvent{{slot: 0, rgb: renderer.RGB{R: 0x11, G: 0x22, B: 0x33}}, {slot: 15, rgb: renderer.RGB{R: 0x44, G: 0x66, B: 0x88}}}, | |
| 29 | + | wantOut: "abc", | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "split prefix body and terminator", | |
| 33 | + | chunks: []string{"a\x1b]4;", "1;rgb:12", "34/abcd/ffff\x1b", "\\b"}, | |
| 34 | + | wantPalette: []paletteEvent{{slot: 1, rgb: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}}}, | |
| 35 | + | wantOut: "ab", | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "interleaved defaults and multiple replies", | |
| 39 | + | chunks: []string{"a\x1b]10;#010203\x07\x1b]4;2;#102030\x07\x1b]11;#040506\x07\x1b]4;2;rgb:aa/bb/cc\x07z"}, | |
| 40 | + | wantPalette: []paletteEvent{{slot: 2, rgb: renderer.RGB{R: 0x10, G: 0x20, B: 0x30}}, {slot: 2, rgb: renderer.RGB{R: 0xaa, G: 0xbb, B: 0xcc}}}, | |
| 41 | + | wantColors: []int{10, 11}, | |
| 42 | + | wantOut: "az", | |
| 43 | + | }, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | for _, tt := range tests { | |
| 47 | + | t.Run(tt.name, func(t *testing.T) { | |
| 48 | + | var s Scanner | |
| 49 | + | var palette []paletteEvent | |
| 50 | + | var colors []int | |
| 51 | + | var out bytes.Buffer | |
| 52 | + | for _, chunk := range tt.chunks { | |
| 53 | + | s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) { | |
| 54 | + | colors = append(colors, kind) | |
| 55 | + | }, func(slot int, rgb renderer.RGB) { | |
| 56 | + | palette = append(palette, paletteEvent{slot: slot, rgb: rgb}) | |
| 57 | + | }, func(bool) {}, func(b []byte) { | |
| 58 | + | out.Write(b) | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | if !reflect.DeepEqual(palette, tt.wantPalette) { | |
| 62 | + | t.Fatalf("palette=%+v want %+v", palette, tt.wantPalette) | |
| 63 | + | } | |
| 64 | + | if !reflect.DeepEqual(colors, tt.wantColors) { | |
| 65 | + | t.Fatalf("colors=%v want %v", colors, tt.wantColors) | |
| 66 | + | } | |
| 67 | + | if out.String() != tt.wantOut { | |
| 68 | + | t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut) | |
| 69 | + | } | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestScannerReportsEveryValidPaletteSlot(t *testing.T) { | |
| 75 | + | var data bytes.Buffer | |
| 76 | + | for slot := 0; slot < 16; slot++ { | |
| 77 | + | data.WriteString("\x1b]4;") | |
| 78 | + | data.WriteString(strconv.Itoa(slot)) | |
| 79 | + | data.WriteString(";#010203\x07") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | var s Scanner | |
| 83 | + | var slots []int | |
| 84 | + | var out bytes.Buffer | |
| 85 | + | s.Scan(data.Bytes(), func(int, renderer.RGB) {}, func(slot int, rgb renderer.RGB) { | |
| 86 | + | if rgb != (renderer.RGB{R: 1, G: 2, B: 3}) { | |
| 87 | + | t.Fatalf("slot %d rgb=%+v", slot, rgb) | |
| 88 | + | } | |
| 89 | + | slots = append(slots, slot) | |
| 90 | + | }, func(bool) {}, func(b []byte) { | |
| 91 | + | out.Write(b) | |
| 92 | + | }) | |
| 93 | + | ||
| 94 | + | if want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; !reflect.DeepEqual(slots, want) { | |
| 95 | + | t.Fatalf("slots=%v want %v", slots, want) | |
| 96 | + | } | |
| 97 | + | if out.Len() != 0 { | |
| 98 | + | t.Fatalf("passthrough=%q want empty", out.String()) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestScannerForwardsMalformedPaletteResponses(t *testing.T) { | |
| 103 | + | const malformed = "\x1b]4;;#010203\x07" + | |
| 104 | + | "\x1b]4;-1;#010203\x07" + | |
| 105 | + | "\x1b]4;cat;#010203\x1b\\" + | |
| 106 | + | "\x1b]4;16;#010203\x07" + | |
| 107 | + | "\x1b]4;999999999999999999;#010203\x07" + | |
| 108 | + | "\x1b]4;3;not-a-color\x07" | |
| 109 | + | ||
| 110 | + | var s Scanner | |
| 111 | + | var out bytes.Buffer | |
| 112 | + | paletteCalls := 0 | |
| 113 | + | s.Scan([]byte("before"+malformed+"after"), func(int, renderer.RGB) {}, func(int, renderer.RGB) { | |
| 114 | + | paletteCalls++ | |
| 115 | + | }, func(bool) {}, func(b []byte) { | |
| 116 | + | out.Write(b) | |
| 117 | + | }) | |
| 118 | + | ||
| 119 | + | if paletteCalls != 0 { | |
| 120 | + | t.Fatalf("palette callbacks=%d want 0", paletteCalls) | |
| 121 | + | } | |
| 122 | + | if got, want := out.String(), "before"+malformed+"after"; got != want { | |
| 123 | + | t.Fatalf("passthrough=%q want %q", got, want) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestScannerFlushesTruncatedPaletteResponseAtPendingLimit(t *testing.T) { | |
| 128 | + | var s Scanner | |
| 129 | + | var out bytes.Buffer | |
| 130 | + | partial := append([]byte("\x1b]4;15;rgb:"), bytes.Repeat([]byte("a"), maxPending)...) | |
| 131 | + | s.Scan(partial, func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) { | |
| 132 | + | out.Write(b) | |
| 133 | + | }) | |
| 134 | + | s.Scan([]byte("z"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) { | |
| 135 | + | out.Write(b) | |
| 136 | + | }) | |
| 137 | + | ||
| 138 | + | if got, want := out.String(), string(partial)+"z"; got != want { | |
| 139 | + | t.Fatalf("passthrough=%q want %q", got, want) | |
| 140 | + | } | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "reflect" | |||
| 6 | + | "strconv" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type paletteEvent struct { | |||
| 13 | + | slot int | |||
| 14 | + | rgb renderer.RGB | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestScannerExtractsPaletteResponses(t *testing.T) { | |||
| 18 | + | tests := []struct { | |||
| 19 | + | name string | |||
| 20 | + | chunks []string | |||
| 21 | + | wantPalette []paletteEvent | |||
| 22 | + | wantColors []int | |||
| 23 | + | wantOut string | |||
| 24 | + | }{ | |||
| 25 | + | { | |||
| 26 | + | name: "BEL hash and ST rgb", | |||
| 27 | + | chunks: []string{"a\x1b]4;0;#112233\x07b\x1b]4;15;rgb:4455/6677/8899\x1b\\c"}, | |||
| 28 | + | wantPalette: []paletteEvent{{slot: 0, rgb: renderer.RGB{R: 0x11, G: 0x22, B: 0x33}}, {slot: 15, rgb: renderer.RGB{R: 0x44, G: 0x66, B: 0x88}}}, | |||
| 29 | + | wantOut: "abc", | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "split prefix body and terminator", | |||
| 33 | + | chunks: []string{"a\x1b]4;", "1;rgb:12", "34/abcd/ffff\x1b", "\\b"}, | |||
| 34 | + | wantPalette: []paletteEvent{{slot: 1, rgb: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}}}, | |||
| 35 | + | wantOut: "ab", | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "interleaved defaults and multiple replies", | |||
| 39 | + | chunks: []string{"a\x1b]10;#010203\x07\x1b]4;2;#102030\x07\x1b]11;#040506\x07\x1b]4;2;rgb:aa/bb/cc\x07z"}, | |||
| 40 | + | wantPalette: []paletteEvent{{slot: 2, rgb: renderer.RGB{R: 0x10, G: 0x20, B: 0x30}}, {slot: 2, rgb: renderer.RGB{R: 0xaa, G: 0xbb, B: 0xcc}}}, | |||
| 41 | + | wantColors: []int{10, 11}, | |||
| 42 | + | wantOut: "az", | |||
| 43 | + | }, | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | for _, tt := range tests { | |||
| 47 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 48 | + | var s Scanner | |||
| 49 | + | var palette []paletteEvent | |||
| 50 | + | var colors []int | |||
| 51 | + | var out bytes.Buffer | |||
| 52 | + | for _, chunk := range tt.chunks { | |||
| 53 | + | s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) { | |||
| 54 | + | colors = append(colors, kind) | |||
| 55 | + | }, func(slot int, rgb renderer.RGB) { | |||
| 56 | + | palette = append(palette, paletteEvent{slot: slot, rgb: rgb}) | |||
| 57 | + | }, func(bool) {}, func(b []byte) { | |||
| 58 | + | out.Write(b) | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 61 | + | if !reflect.DeepEqual(palette, tt.wantPalette) { | |||
| 62 | + | t.Fatalf("palette=%+v want %+v", palette, tt.wantPalette) | |||
| 63 | + | } | |||
| 64 | + | if !reflect.DeepEqual(colors, tt.wantColors) { | |||
| 65 | + | t.Fatalf("colors=%v want %v", colors, tt.wantColors) | |||
| 66 | + | } | |||
| 67 | + | if out.String() != tt.wantOut { | |||
| 68 | + | t.Fatalf("passthrough=%q want %q", out.String(), tt.wantOut) | |||
| 69 | + | } | |||
| 70 | + | }) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestScannerReportsEveryValidPaletteSlot(t *testing.T) { | |||
| 75 | + | var data bytes.Buffer | |||
| 76 | + | for slot := 0; slot < 16; slot++ { | |||
| 77 | + | data.WriteString("\x1b]4;") | |||
| 78 | + | data.WriteString(strconv.Itoa(slot)) | |||
| 79 | + | data.WriteString(";#010203\x07") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | var s Scanner | |||
| 83 | + | var slots []int | |||
| 84 | + | var out bytes.Buffer | |||
| 85 | + | s.Scan(data.Bytes(), func(int, renderer.RGB) {}, func(slot int, rgb renderer.RGB) { | |||
| 86 | + | if rgb != (renderer.RGB{R: 1, G: 2, B: 3}) { | |||
| 87 | + | t.Fatalf("slot %d rgb=%+v", slot, rgb) | |||
| 88 | + | } | |||
| 89 | + | slots = append(slots, slot) | |||
| 90 | + | }, func(bool) {}, func(b []byte) { | |||
| 91 | + | out.Write(b) | |||
| 92 | + | }) | |||
| 93 | + | ||||
| 94 | + | if want := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; !reflect.DeepEqual(slots, want) { | |||
| 95 | + | t.Fatalf("slots=%v want %v", slots, want) | |||
| 96 | + | } | |||
| 97 | + | if out.Len() != 0 { | |||
| 98 | + | t.Fatalf("passthrough=%q want empty", out.String()) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestScannerForwardsMalformedPaletteResponses(t *testing.T) { | |||
| 103 | + | const malformed = "\x1b]4;;#010203\x07" + | |||
| 104 | + | "\x1b]4;-1;#010203\x07" + | |||
| 105 | + | "\x1b]4;cat;#010203\x1b\\" + | |||
| 106 | + | "\x1b]4;16;#010203\x07" + | |||
| 107 | + | "\x1b]4;999999999999999999;#010203\x07" + | |||
| 108 | + | "\x1b]4;3;not-a-color\x07" | |||
| 109 | + | ||||
| 110 | + | var s Scanner | |||
| 111 | + | var out bytes.Buffer | |||
| 112 | + | paletteCalls := 0 | |||
| 113 | + | s.Scan([]byte("before"+malformed+"after"), func(int, renderer.RGB) {}, func(int, renderer.RGB) { | |||
| 114 | + | paletteCalls++ | |||
| 115 | + | }, func(bool) {}, func(b []byte) { | |||
| 116 | + | out.Write(b) | |||
| 117 | + | }) | |||
| 118 | + | ||||
| 119 | + | if paletteCalls != 0 { | |||
| 120 | + | t.Fatalf("palette callbacks=%d want 0", paletteCalls) | |||
| 121 | + | } | |||
| 122 | + | if got, want := out.String(), "before"+malformed+"after"; got != want { | |||
| 123 | + | t.Fatalf("passthrough=%q want %q", got, want) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestScannerFlushesTruncatedPaletteResponseAtPendingLimit(t *testing.T) { | |||
| 128 | + | var s Scanner | |||
| 129 | + | var out bytes.Buffer | |||
| 130 | + | partial := append([]byte("\x1b]4;15;rgb:"), bytes.Repeat([]byte("a"), maxPending)...) | |||
| 131 | + | s.Scan(partial, func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) { | |||
| 132 | + | out.Write(b) | |||
| 133 | + | }) | |||
| 134 | + | s.Scan([]byte("z"), func(int, renderer.RGB) {}, func(int, renderer.RGB) {}, func(bool) {}, func(b []byte) { | |||
| 135 | + | out.Write(b) | |||
| 136 | + | }) | |||
| 137 | + | ||||
| 138 | + | if got, want := out.String(), string(partial)+"z"; got != want { | |||
| 139 | + | t.Fatalf("passthrough=%q want %q", got, want) | |||
| 140 | + | } | |||
| 141 | + | } | |||
A internal/usecase/theme/styles.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const accentContrastMin = 3.0 | |
| 10 | + | ||
| 11 | + | // RelativeLuminance returns a color's WCAG 2.x relative luminance. | |
| 12 | + | func RelativeLuminance(color renderer.RGB) float64 { | |
| 13 | + | linearize := func(channel uint8) float64 { | |
| 14 | + | srgb := float64(channel) / 255 | |
| 15 | + | if srgb <= 0.04045 { | |
| 16 | + | return srgb / 12.92 | |
| 17 | + | } | |
| 18 | + | return math.Pow((srgb+0.055)/1.055, 2.4) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | return 0.2126*linearize(color.R) + | |
| 22 | + | 0.7152*linearize(color.G) + | |
| 23 | + | 0.0722*linearize(color.B) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // ContrastRatio returns the WCAG 2.x contrast ratio between two colors. | |
| 27 | + | func ContrastRatio(a, b renderer.RGB) float64 { | |
| 28 | + | aLuminance := RelativeLuminance(a) | |
| 29 | + | bLuminance := RelativeLuminance(b) | |
| 30 | + | lighter := math.Max(aLuminance, bLuminance) | |
| 31 | + | darker := math.Min(aLuminance, bLuminance) | |
| 32 | + | return (lighter + 0.05) / (darker + 0.05) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // PulseColor derives the attention pulse color at intensity while preserving | |
| 36 | + | // the base style's remaining attributes. | |
| 37 | + | func PulseColor(base renderer.Style, intensity float64) renderer.Style { | |
| 38 | + | style := base | |
| 39 | + | style.Bold = true | |
| 40 | + | if base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 41 | + | style.ForegroundRGB = Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |
| 42 | + | } else { | |
| 43 | + | style.HasForegroundRGB = false | |
| 44 | + | style.Foreground = 244 + int(intensity*11) | |
| 45 | + | } | |
| 46 | + | return style | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // MRUFade fades an MRU style by position, reaching 60 percent of the terminal | |
| 50 | + | // background color at the oldest entry. | |
| 51 | + | func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |
| 52 | + | if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG { | |
| 53 | + | return base | |
| 54 | + | } | |
| 55 | + | amount := (float64(i) / float64(count-1)) * 0.6 | |
| 56 | + | base.ForegroundRGB = Blend(base.ForegroundRGB, t.Background, amount) | |
| 57 | + | base.BackgroundRGB = Blend(base.BackgroundRGB, t.Background, amount) | |
| 58 | + | return base | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Styles is the resolved set of terminal chrome styles for a client theme. | |
| 62 | + | type Styles struct { | |
| 63 | + | StatusBar renderer.Style | |
| 64 | + | Accent renderer.Style | |
| 65 | + | Border renderer.Style | |
| 66 | + | Selection renderer.Style | |
| 67 | + | CopyStatus renderer.Style | |
| 68 | + | PaletteDesc renderer.Style | |
| 69 | + | ||
| 70 | + | TabName renderer.Style | |
| 71 | + | TabNameActive renderer.Style | |
| 72 | + | TabTitle renderer.Style | |
| 73 | + | TabTitleActive renderer.Style | |
| 74 | + | ||
| 75 | + | PickerName renderer.Style | |
| 76 | + | PickerSelectionName renderer.Style | |
| 77 | + | PickerSelectionMuted renderer.Style | |
| 78 | + | PickerSeparator renderer.Style | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // NewStyles composes the terminal chrome styles from the semantic theme styles. | |
| 82 | + | func NewStyles(t Theme) Styles { | |
| 83 | + | statusBar := StatusBarStyle(t) | |
| 84 | + | accent := AccentStyle(t) | |
| 85 | + | selection := SelectionStyle(t) | |
| 86 | + | return Styles{ | |
| 87 | + | StatusBar: statusBar, | |
| 88 | + | Accent: accent, | |
| 89 | + | Border: BorderStyle(t), | |
| 90 | + | Selection: selection, | |
| 91 | + | CopyStatus: selection, | |
| 92 | + | PaletteDesc: MutedTextStyle(t), | |
| 93 | + | ||
| 94 | + | TabName: EmphasisStyle(statusBar, t), | |
| 95 | + | TabNameActive: EmphasisStyle(accent, t), | |
| 96 | + | TabTitle: PaletteMutedVariantStyle(statusBar, t), | |
| 97 | + | TabTitleActive: PaletteMutedVariantStyle(accent, t), | |
| 98 | + | ||
| 99 | + | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | |
| 100 | + | PickerSelectionName: EmphasisStyle(selection, t), | |
| 101 | + | PickerSelectionMuted: PaletteMutedVariantStyle(selection, t), | |
| 102 | + | PickerSeparator: MutedTextStyle(t), | |
| 103 | + | } | |
| 104 | + | } | |
| @@ -0,0 +1,104 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const accentContrastMin = 3.0 | |||
| 10 | + | ||||
| 11 | + | // RelativeLuminance returns a color's WCAG 2.x relative luminance. | |||
| 12 | + | func RelativeLuminance(color renderer.RGB) float64 { | |||
| 13 | + | linearize := func(channel uint8) float64 { | |||
| 14 | + | srgb := float64(channel) / 255 | |||
| 15 | + | if srgb <= 0.04045 { | |||
| 16 | + | return srgb / 12.92 | |||
| 17 | + | } | |||
| 18 | + | return math.Pow((srgb+0.055)/1.055, 2.4) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | return 0.2126*linearize(color.R) + | |||
| 22 | + | 0.7152*linearize(color.G) + | |||
| 23 | + | 0.0722*linearize(color.B) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // ContrastRatio returns the WCAG 2.x contrast ratio between two colors. | |||
| 27 | + | func ContrastRatio(a, b renderer.RGB) float64 { | |||
| 28 | + | aLuminance := RelativeLuminance(a) | |||
| 29 | + | bLuminance := RelativeLuminance(b) | |||
| 30 | + | lighter := math.Max(aLuminance, bLuminance) | |||
| 31 | + | darker := math.Min(aLuminance, bLuminance) | |||
| 32 | + | return (lighter + 0.05) / (darker + 0.05) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // PulseColor derives the attention pulse color at intensity while preserving | |||
| 36 | + | // the base style's remaining attributes. | |||
| 37 | + | func PulseColor(base renderer.Style, intensity float64) renderer.Style { | |||
| 38 | + | style := base | |||
| 39 | + | style.Bold = true | |||
| 40 | + | if base.HasForegroundRGB && base.HasBackgroundRGB { | |||
| 41 | + | style.ForegroundRGB = Blend(base.BackgroundRGB, base.ForegroundRGB, intensity) | |||
| 42 | + | } else { | |||
| 43 | + | style.HasForegroundRGB = false | |||
| 44 | + | style.Foreground = 244 + int(intensity*11) | |||
| 45 | + | } | |||
| 46 | + | return style | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // MRUFade fades an MRU style by position, reaching 60 percent of the terminal | |||
| 50 | + | // background color at the oldest entry. | |||
| 51 | + | func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||
| 52 | + | if count <= 1 || !base.HasForegroundRGB || !base.HasBackgroundRGB || !t.HasBG { | |||
| 53 | + | return base | |||
| 54 | + | } | |||
| 55 | + | amount := (float64(i) / float64(count-1)) * 0.6 | |||
| 56 | + | base.ForegroundRGB = Blend(base.ForegroundRGB, t.Background, amount) | |||
| 57 | + | base.BackgroundRGB = Blend(base.BackgroundRGB, t.Background, amount) | |||
| 58 | + | return base | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Styles is the resolved set of terminal chrome styles for a client theme. | |||
| 62 | + | type Styles struct { | |||
| 63 | + | StatusBar renderer.Style | |||
| 64 | + | Accent renderer.Style | |||
| 65 | + | Border renderer.Style | |||
| 66 | + | Selection renderer.Style | |||
| 67 | + | CopyStatus renderer.Style | |||
| 68 | + | PaletteDesc renderer.Style | |||
| 69 | + | ||||
| 70 | + | TabName renderer.Style | |||
| 71 | + | TabNameActive renderer.Style | |||
| 72 | + | TabTitle renderer.Style | |||
| 73 | + | TabTitleActive renderer.Style | |||
| 74 | + | ||||
| 75 | + | PickerName renderer.Style | |||
| 76 | + | PickerSelectionName renderer.Style | |||
| 77 | + | PickerSelectionMuted renderer.Style | |||
| 78 | + | PickerSeparator renderer.Style | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // NewStyles composes the terminal chrome styles from the semantic theme styles. | |||
| 82 | + | func NewStyles(t Theme) Styles { | |||
| 83 | + | statusBar := StatusBarStyle(t) | |||
| 84 | + | accent := AccentStyle(t) | |||
| 85 | + | selection := SelectionStyle(t) | |||
| 86 | + | return Styles{ | |||
| 87 | + | StatusBar: statusBar, | |||
| 88 | + | Accent: accent, | |||
| 89 | + | Border: BorderStyle(t), | |||
| 90 | + | Selection: selection, | |||
| 91 | + | CopyStatus: selection, | |||
| 92 | + | PaletteDesc: MutedTextStyle(t), | |||
| 93 | + | ||||
| 94 | + | TabName: EmphasisStyle(statusBar, t), | |||
| 95 | + | TabNameActive: EmphasisStyle(accent, t), | |||
| 96 | + | TabTitle: PaletteMutedVariantStyle(statusBar, t), | |||
| 97 | + | TabTitleActive: PaletteMutedVariantStyle(accent, t), | |||
| 98 | + | ||||
| 99 | + | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | |||
| 100 | + | PickerSelectionName: EmphasisStyle(selection, t), | |||
| 101 | + | PickerSelectionMuted: PaletteMutedVariantStyle(selection, t), | |||
| 102 | + | PickerSeparator: MutedTextStyle(t), | |||
| 103 | + | } | |||
| 104 | + | } | |||
A internal/usecase/theme/styles_test.go +579 -0
| @@ -0,0 +1,579 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRelativeLuminance(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | color renderer.RGB | |
| 14 | + | want float64 | |
| 15 | + | }{ | |
| 16 | + | {name: "black", color: renderer.RGB{}, want: 0}, | |
| 17 | + | {name: "white", color: renderer.RGB{R: 255, G: 255, B: 255}, want: 1}, | |
| 18 | + | {name: "sRGB linearization breakpoint", color: renderer.RGB{R: 10}, want: 0.2126 * (10.0 / 255.0 / 12.92)}, | |
| 19 | + | {name: "nonlinear sRGB", color: renderer.RGB{R: 128, G: 128, B: 128}, want: 0.21586050011389923}, | |
| 20 | + | {name: "red", color: renderer.RGB{R: 255}, want: 0.2126}, | |
| 21 | + | {name: "green", color: renderer.RGB{G: 255}, want: 0.7152}, | |
| 22 | + | {name: "blue", color: renderer.RGB{B: 255}, want: 0.0722}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | got := RelativeLuminance(tt.color) | |
| 28 | + | require.InDelta(t, tt.want, got, 1e-12) | |
| 29 | + | require.Equal(t, got, RelativeLuminance(tt.color)) | |
| 30 | + | }) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestContrastRatio(t *testing.T) { | |
| 35 | + | black := renderer.RGB{} | |
| 36 | + | white := renderer.RGB{R: 255, G: 255, B: 255} | |
| 37 | + | middle := renderer.RGB{R: 123, G: 45, B: 67} | |
| 38 | + | ||
| 39 | + | require.InDelta(t, 21, ContrastRatio(black, white), 1e-12) | |
| 40 | + | require.InDelta(t, 1, ContrastRatio(middle, middle), 1e-12) | |
| 41 | + | require.Equal(t, ContrastRatio(black, middle), ContrastRatio(middle, black)) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestAccentContrastMinimum(t *testing.T) { | |
| 45 | + | require.Equal(t, 3.0, accentContrastMin) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestPulseColor(t *testing.T) { | |
| 49 | + | rgbBase := renderer.Style{ | |
| 50 | + | Italic: true, | |
| 51 | + | Foreground: 7, | |
| 52 | + | Background: 8, | |
| 53 | + | HasForegroundRGB: true, | |
| 54 | + | ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50}, | |
| 55 | + | HasBackgroundRGB: true, | |
| 56 | + | BackgroundRGB: renderer.RGB{R: 20, G: 40, B: 60}, | |
| 57 | + | HasUnderlineColor: true, | |
| 58 | + | UnderlineColor: 4, | |
| 59 | + | HasUnderlineColorRGB: false, | |
| 60 | + | } | |
| 61 | + | indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true} | |
| 62 | + | mixedBase := rgbBase | |
| 63 | + | mixedBase.HasBackgroundRGB = false | |
| 64 | + | ||
| 65 | + | tests := []struct { | |
| 66 | + | name string | |
| 67 | + | base renderer.Style | |
| 68 | + | intensity float64 | |
| 69 | + | want renderer.Style | |
| 70 | + | }{ | |
| 71 | + | { | |
| 72 | + | name: "RGB starts at the background", | |
| 73 | + | base: rgbBase, | |
| 74 | + | intensity: 0, | |
| 75 | + | want: func() renderer.Style { | |
| 76 | + | style := rgbBase | |
| 77 | + | style.Bold = true | |
| 78 | + | style.ForegroundRGB = renderer.RGB{R: 20, G: 40, B: 60} | |
| 79 | + | return style | |
| 80 | + | }(), | |
| 81 | + | }, | |
| 82 | + | { | |
| 83 | + | name: "RGB blends halfway with rounding", | |
| 84 | + | base: rgbBase, | |
| 85 | + | intensity: 0.5, | |
| 86 | + | want: func() renderer.Style { | |
| 87 | + | style := rgbBase | |
| 88 | + | style.Bold = true | |
| 89 | + | style.ForegroundRGB = renderer.RGB{R: 110, G: 70, B: 55} | |
| 90 | + | return style | |
| 91 | + | }(), | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | name: "RGB reaches the foreground", | |
| 95 | + | base: rgbBase, | |
| 96 | + | intensity: 1, | |
| 97 | + | want: func() renderer.Style { | |
| 98 | + | style := rgbBase | |
| 99 | + | style.Bold = true | |
| 100 | + | return style | |
| 101 | + | }(), | |
| 102 | + | }, | |
| 103 | + | { | |
| 104 | + | name: "RGB foreground without RGB background uses indexed grayscale", | |
| 105 | + | base: mixedBase, | |
| 106 | + | intensity: 0.5, | |
| 107 | + | want: func() renderer.Style { | |
| 108 | + | style := mixedBase | |
| 109 | + | style.Bold = true | |
| 110 | + | style.HasForegroundRGB = false | |
| 111 | + | style.Foreground = 249 | |
| 112 | + | return style | |
| 113 | + | }(), | |
| 114 | + | }, | |
| 115 | + | { | |
| 116 | + | name: "indexed color uses xterm grayscale start", | |
| 117 | + | base: indexedBase, | |
| 118 | + | intensity: 0, | |
| 119 | + | want: renderer.Style{Bold: true, Inverse: true, Foreground: 244, Background: -1}, | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | name: "indexed color truncates the grayscale ramp", | |
| 123 | + | base: indexedBase, | |
| 124 | + | intensity: 0.5, | |
| 125 | + | want: renderer.Style{Bold: true, Inverse: true, Foreground: 249, Background: -1}, | |
| 126 | + | }, | |
| 127 | + | { | |
| 128 | + | name: "indexed color reaches xterm grayscale end", | |
| 129 | + | base: indexedBase, | |
| 130 | + | intensity: 1, | |
| 131 | + | want: renderer.Style{Bold: true, Inverse: true, Foreground: 255, Background: -1}, | |
| 132 | + | }, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | require.Equal(t, tt.want, PulseColor(tt.base, tt.intensity)) | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestMRUFade(t *testing.T) { | |
| 143 | + | base := renderer.Style{ | |
| 144 | + | Bold: true, | |
| 145 | + | Foreground: 7, | |
| 146 | + | Background: 8, | |
| 147 | + | HasForegroundRGB: true, | |
| 148 | + | ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50}, | |
| 149 | + | HasBackgroundRGB: true, | |
| 150 | + | BackgroundRGB: renderer.RGB{R: 40, G: 60, B: 80}, | |
| 151 | + | HasUnderlineColorRGB: true, | |
| 152 | + | UnderlineColorRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 153 | + | } | |
| 154 | + | terminalTheme := Theme{HasBG: true, Background: renderer.RGB{R: 10, G: 20, B: 30}} | |
| 155 | + | ||
| 156 | + | tests := []struct { | |
| 157 | + | name string | |
| 158 | + | base renderer.Style | |
| 159 | + | theme Theme | |
| 160 | + | i int | |
| 161 | + | count int | |
| 162 | + | want renderer.Style | |
| 163 | + | }{ | |
| 164 | + | {name: "single entry is unchanged", base: base, theme: terminalTheme, count: 1, want: base}, | |
| 165 | + | {name: "missing theme background is unchanged", base: base, theme: Theme{}, i: 1, count: 3, want: base}, | |
| 166 | + | {name: "indexed base is unchanged", base: renderer.DefaultStyle(), theme: terminalTheme, i: 1, count: 3, want: renderer.DefaultStyle()}, | |
| 167 | + | {name: "newest entry starts unfaded", base: base, theme: terminalTheme, count: 3, want: base}, | |
| 168 | + | { | |
| 169 | + | name: "middle entry fades thirty percent toward terminal background", | |
| 170 | + | base: base, | |
| 171 | + | theme: terminalTheme, | |
| 172 | + | i: 1, | |
| 173 | + | count: 3, | |
| 174 | + | want: func() renderer.Style { | |
| 175 | + | style := base | |
| 176 | + | style.ForegroundRGB = renderer.RGB{R: 143, G: 76, B: 44} | |
| 177 | + | style.BackgroundRGB = renderer.RGB{R: 31, G: 48, B: 65} | |
| 178 | + | return style | |
| 179 | + | }(), | |
| 180 | + | }, | |
| 181 | + | { | |
| 182 | + | name: "oldest entry fades sixty percent toward terminal background", | |
| 183 | + | base: base, | |
| 184 | + | theme: terminalTheme, | |
| 185 | + | i: 2, | |
| 186 | + | count: 3, | |
| 187 | + | want: func() renderer.Style { | |
| 188 | + | style := base | |
| 189 | + | style.ForegroundRGB = renderer.RGB{R: 86, G: 52, B: 38} | |
| 190 | + | style.BackgroundRGB = renderer.RGB{R: 22, G: 36, B: 50} | |
| 191 | + | return style | |
| 192 | + | }(), | |
| 193 | + | }, | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | for _, tt := range tests { | |
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |
| 198 | + | require.Equal(t, tt.want, MRUFade(tt.base, tt.theme, tt.i, tt.count)) | |
| 199 | + | }) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestThemePaletteColor(t *testing.T) { | |
| 204 | + | palette := [16]renderer.RGB{ | |
| 205 | + | 3: {R: 0x11, G: 0x22, B: 0x33}, | |
| 206 | + | 7: {R: 0xaa, G: 0xbb, B: 0xcc}, | |
| 207 | + | } | |
| 208 | + | theme := Theme{ | |
| 209 | + | UsePalette: true, | |
| 210 | + | Palette: palette, | |
| 211 | + | PaletteKnown: 1<<3 | 1<<7, | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | tests := []struct { | |
| 215 | + | name string | |
| 216 | + | slot int | |
| 217 | + | want renderer.RGB | |
| 218 | + | ok bool | |
| 219 | + | }{ | |
| 220 | + | {name: "known slot", slot: 3, want: palette[3], ok: true}, | |
| 221 | + | {name: "unknown slot", slot: 4}, | |
| 222 | + | {name: "negative slot", slot: -1}, | |
| 223 | + | {name: "slot above palette", slot: 16}, | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | for _, tt := range tests { | |
| 227 | + | t.Run(tt.name, func(t *testing.T) { | |
| 228 | + | got, ok := theme.PaletteColor(tt.slot) | |
| 229 | + | require.Equal(t, tt.ok, ok) | |
| 230 | + | require.Equal(t, tt.want, got) | |
| 231 | + | }) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | theme.UsePalette = false | |
| 235 | + | got, ok := theme.PaletteColor(3) | |
| 236 | + | require.False(t, ok) | |
| 237 | + | require.Equal(t, renderer.RGB{}, got) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestBuiltinThemesDoNotUsePalette(t *testing.T) { | |
| 241 | + | for name, theme := range map[string]Theme{ | |
| 242 | + | "dark": BuiltinDark, | |
| 243 | + | "light": BuiltinLight, | |
| 244 | + | } { | |
| 245 | + | t.Run(name, func(t *testing.T) { | |
| 246 | + | require.False(t, theme.UsePalette) | |
| 247 | + | require.Zero(t, theme.PaletteKnown) | |
| 248 | + | require.Equal(t, [16]renderer.RGB{}, theme.Palette) | |
| 249 | + | }) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestThemeIsComparable(t *testing.T) { | |
| 254 | + | themes := map[Theme]bool{BuiltinDark: true} | |
| 255 | + | require.True(t, themes[BuiltinDark]) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func TestPaletteAccentBackground(t *testing.T) { | |
| 259 | + | base := Theme{ | |
| 260 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 261 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 262 | + | HasFG: true, | |
| 263 | + | HasBG: true, | |
| 264 | + | Known: true, | |
| 265 | + | TrueColor: true, | |
| 266 | + | UsePalette: true, | |
| 267 | + | } | |
| 268 | + | neutral := Blend(base.Background, base.Foreground, accentBlend) | |
| 269 | + | ||
| 270 | + | tests := []struct { | |
| 271 | + | name string | |
| 272 | + | palette [16]renderer.RGB | |
| 273 | + | known uint16 | |
| 274 | + | usePalette bool | |
| 275 | + | want renderer.RGB | |
| 276 | + | wantContrastMin bool | |
| 277 | + | }{ | |
| 278 | + | { | |
| 279 | + | name: "slot 4 supplies a contrasting accent background", | |
| 280 | + | palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}}, | |
| 281 | + | known: 1<<4 | 1<<12, | |
| 282 | + | usePalette: true, | |
| 283 | + | want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend), | |
| 284 | + | wantContrastMin: true, | |
| 285 | + | }, | |
| 286 | + | { | |
| 287 | + | name: "slot 4 failure escalates to slot 12", | |
| 288 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}}, | |
| 289 | + | known: 1<<4 | 1<<12, | |
| 290 | + | usePalette: true, | |
| 291 | + | want: Blend(base.Background, renderer.RGB{}, accentBlend), | |
| 292 | + | wantContrastMin: true, | |
| 293 | + | }, | |
| 294 | + | { | |
| 295 | + | name: "both palette blues failing uses exact neutral blend", | |
| 296 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 297 | + | known: 1<<4 | 1<<12, | |
| 298 | + | usePalette: true, | |
| 299 | + | want: neutral, | |
| 300 | + | }, | |
| 301 | + | { | |
| 302 | + | name: "missing palette blues uses exact neutral blend", | |
| 303 | + | usePalette: true, | |
| 304 | + | want: neutral, | |
| 305 | + | }, | |
| 306 | + | { | |
| 307 | + | name: "palette disabled preserves neutral accent", | |
| 308 | + | palette: [16]renderer.RGB{4: {B: 255}}, | |
| 309 | + | known: 1 << 4, | |
| 310 | + | usePalette: false, | |
| 311 | + | want: neutral, | |
| 312 | + | }, | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | for _, tt := range tests { | |
| 316 | + | t.Run(tt.name, func(t *testing.T) { | |
| 317 | + | theme := base | |
| 318 | + | theme.Palette = tt.palette | |
| 319 | + | theme.PaletteKnown = tt.known | |
| 320 | + | theme.UsePalette = tt.usePalette | |
| 321 | + | ||
| 322 | + | got := AccentStyle(theme) | |
| 323 | + | require.True(t, got.HasBackgroundRGB) | |
| 324 | + | require.Equal(t, tt.want, got.BackgroundRGB) | |
| 325 | + | if tt.wantContrastMin { | |
| 326 | + | require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin) | |
| 327 | + | } | |
| 328 | + | }) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func TestPaletteTitleHueDerivation(t *testing.T) { | |
| 333 | + | base := Theme{ | |
| 334 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 335 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 336 | + | HasFG: true, | |
| 337 | + | HasBG: true, | |
| 338 | + | Known: true, | |
| 339 | + | TrueColor: true, | |
| 340 | + | UsePalette: true, | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | tests := []struct { | |
| 344 | + | name string | |
| 345 | + | theme Theme | |
| 346 | + | palette [16]renderer.RGB | |
| 347 | + | known uint16 | |
| 348 | + | }{ | |
| 349 | + | { | |
| 350 | + | name: "slot 4 is used when it contrasts for each title", | |
| 351 | + | theme: Theme{ | |
| 352 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 353 | + | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |
| 354 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 355 | + | }, | |
| 356 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}}, | |
| 357 | + | ||
| 358 | + | known: 1<<4 | 1<<12, | |
| 359 | + | }, | |
| 360 | + | { | |
| 361 | + | name: "each title escalates independently from slot 4 to slot 12", | |
| 362 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}}, | |
| 363 | + | known: 1<<4 | 1<<12, | |
| 364 | + | }, | |
| 365 | + | { | |
| 366 | + | name: "both title hues failing retains neutral muted styles", | |
| 367 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 368 | + | known: 1<<4 | 1<<12, | |
| 369 | + | }, | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | for _, tt := range tests { | |
| 373 | + | t.Run(tt.name, func(t *testing.T) { | |
| 374 | + | theme := base | |
| 375 | + | if tt.theme.Known { | |
| 376 | + | theme = tt.theme | |
| 377 | + | } | |
| 378 | + | theme.Palette = tt.palette | |
| 379 | + | theme.PaletteKnown = tt.known | |
| 380 | + | styles := NewStyles(theme) | |
| 381 | + | status := StatusBarStyle(theme) | |
| 382 | + | accent := AccentStyle(theme) | |
| 383 | + | ||
| 384 | + | if tt.name == "both title hues failing retains neutral muted styles" { | |
| 385 | + | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme))) | |
| 386 | + | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | require.True(t, styles.TabTitle.HasForegroundRGB) | |
| 391 | + | require.True(t, styles.TabTitleActive.HasForegroundRGB) | |
| 392 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin) | |
| 393 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin) | |
| 394 | + | ||
| 395 | + | if tt.name == "slot 4 is used when it contrasts for each title" { | |
| 396 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 397 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 398 | + | } | |
| 399 | + | if tt.name == "each title escalates independently from slot 4 to slot 12" { | |
| 400 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 401 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 402 | + | } | |
| 403 | + | }) | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) { | |
| 408 | + | base := Theme{ | |
| 409 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 410 | + | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |
| 411 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | tests := []struct { | |
| 415 | + | name string | |
| 416 | + | theme Theme | |
| 417 | + | wantForeground renderer.RGB | |
| 418 | + | wantIndexed int | |
| 419 | + | }{ | |
| 420 | + | { | |
| 421 | + | name: "RGB palette foreground", | |
| 422 | + | theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(), | |
| 423 | + | wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend), | |
| 424 | + | wantIndexed: -1, | |
| 425 | + | }, | |
| 426 | + | { | |
| 427 | + | name: "indexed foreground fallback", | |
| 428 | + | theme: func() Theme { t := base; t.SchemeKnown = true; return t }(), | |
| 429 | + | wantIndexed: ansiBrightBlue, | |
| 430 | + | }, | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | for _, tt := range tests { | |
| 434 | + | t.Run(tt.name, func(t *testing.T) { | |
| 435 | + | styles := NewStyles(tt.theme) | |
| 436 | + | ||
| 437 | + | for name, style := range map[string]renderer.Style{ | |
| 438 | + | "border": styles.Border, | |
| 439 | + | "muted text": styles.PaletteDesc, | |
| 440 | + | } { | |
| 441 | + | require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name) | |
| 442 | + | if tt.wantIndexed >= 0 { | |
| 443 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 444 | + | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |
| 445 | + | } else { | |
| 446 | + | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |
| 447 | + | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | status := StatusBarStyle(tt.theme) | |
| 452 | + | accent := AccentStyle(tt.theme) | |
| 453 | + | for name, style := range map[string]renderer.Style{ | |
| 454 | + | "title": styles.TabTitle, | |
| 455 | + | "active title": styles.TabTitleActive, | |
| 456 | + | } { | |
| 457 | + | wantBackground := status.BackgroundRGB | |
| 458 | + | if name == "active title" { | |
| 459 | + | wantBackground = accent.BackgroundRGB | |
| 460 | + | } | |
| 461 | + | require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name) | |
| 462 | + | require.Equal(t, wantBackground, style.BackgroundRGB, name) | |
| 463 | + | if tt.wantIndexed >= 0 { | |
| 464 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 465 | + | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |
| 466 | + | } else { | |
| 467 | + | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |
| 468 | + | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | }) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func TestPaletteIndexedForegroundFallback(t *testing.T) { | |
| 476 | + | base := Theme{UsePalette: true, SchemeKnown: true, Known: true} | |
| 477 | + | ||
| 478 | + | tests := []struct { | |
| 479 | + | name string | |
| 480 | + | theme Theme | |
| 481 | + | want int | |
| 482 | + | ok bool | |
| 483 | + | }{ | |
| 484 | + | {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true}, | |
| 485 | + | {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true}, | |
| 486 | + | {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}}, | |
| 487 | + | {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}}, | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | for _, tt := range tests { | |
| 491 | + | t.Run(tt.name, func(t *testing.T) { | |
| 492 | + | styles := NewStyles(tt.theme) | |
| 493 | + | baselineTheme := tt.theme | |
| 494 | + | baselineTheme.UsePalette = false | |
| 495 | + | baseline := NewStyles(baselineTheme) | |
| 496 | + | for name, style := range map[string]renderer.Style{ | |
| 497 | + | "title": styles.TabTitle, | |
| 498 | + | "active title": styles.TabTitleActive, | |
| 499 | + | "border": styles.Border, | |
| 500 | + | "muted text": styles.PaletteDesc, | |
| 501 | + | } { | |
| 502 | + | require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name) | |
| 503 | + | if tt.ok { | |
| 504 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 505 | + | require.Equal(t, tt.want, style.Foreground, name) | |
| 506 | + | } else { | |
| 507 | + | want := map[string]renderer.Style{ | |
| 508 | + | "title": baseline.TabTitle, "active title": baseline.TabTitleActive, | |
| 509 | + | "border": baseline.Border, "muted text": baseline.PaletteDesc, | |
| 510 | + | }[name] | |
| 511 | + | require.Equal(t, want, style, name) | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | }) | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) { | |
| 519 | + | theme := Theme{ | |
| 520 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 521 | + | Background: renderer.RGB{R: 30, G: 30, B: 30}, | |
| 522 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 523 | + | UsePalette: true, SchemeKnown: true, | |
| 524 | + | } | |
| 525 | + | styles := NewStyles(theme) | |
| 526 | + | for name, style := range map[string]renderer.Style{ | |
| 527 | + | "title": styles.TabTitle, "active title": styles.TabTitleActive, | |
| 528 | + | "border": styles.Border, "muted text": styles.PaletteDesc, | |
| 529 | + | } { | |
| 530 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 531 | + | require.Equal(t, ansiBrightBlue, style.Foreground, name) | |
| 532 | + | require.Equal(t, -1, style.Background, "%s must not use an indexed background", name) | |
| 533 | + | } | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) { | |
| 537 | + | palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}} | |
| 538 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 539 | + | withDisabledPalette := usableTheme | |
| 540 | + | withDisabledPalette.Palette = palette | |
| 541 | + | withDisabledPalette.PaletteKnown = 1<<4 | 1<<12 | |
| 542 | + | withDisabledPalette.UsePalette = false | |
| 543 | + | ||
| 544 | + | require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette)) | |
| 545 | + | ||
| 546 | + | forced := Theme{Known: true, SchemeKnown: true, UsePalette: false} | |
| 547 | + | require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced)) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func TestNewStylesComposesSemanticStyles(t *testing.T) { | |
| 551 | + | theme := Theme{ | |
| 552 | + | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | |
| 553 | + | Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e}, | |
| 554 | + | HasFG: true, | |
| 555 | + | HasBG: true, | |
| 556 | + | Known: true, | |
| 557 | + | TrueColor: true, | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | styles := NewStyles(theme) | |
| 561 | + | statusBar := StatusBarStyle(theme) | |
| 562 | + | accent := AccentStyle(theme) | |
| 563 | + | selection := SelectionStyle(theme) | |
| 564 | + | ||
| 565 | + | require.True(t, styles.StatusBar.Equal(statusBar)) | |
| 566 | + | require.True(t, styles.Accent.Equal(accent)) | |
| 567 | + | require.True(t, styles.Border.Equal(BorderStyle(theme))) | |
| 568 | + | require.True(t, styles.Selection.Equal(selection)) | |
| 569 | + | require.True(t, styles.CopyStatus.Equal(selection)) | |
| 570 | + | require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme))) | |
| 571 | + | require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme))) | |
| 572 | + | require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme))) | |
| 573 | + | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme))) | |
| 574 | + | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |
| 575 | + | require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme))) | |
| 576 | + | require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme))) | |
| 577 | + | require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme))) | |
| 578 | + | require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme))) | |
| 579 | + | } | |
| @@ -0,0 +1,579 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRelativeLuminance(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | color renderer.RGB | |||
| 14 | + | want float64 | |||
| 15 | + | }{ | |||
| 16 | + | {name: "black", color: renderer.RGB{}, want: 0}, | |||
| 17 | + | {name: "white", color: renderer.RGB{R: 255, G: 255, B: 255}, want: 1}, | |||
| 18 | + | {name: "sRGB linearization breakpoint", color: renderer.RGB{R: 10}, want: 0.2126 * (10.0 / 255.0 / 12.92)}, | |||
| 19 | + | {name: "nonlinear sRGB", color: renderer.RGB{R: 128, G: 128, B: 128}, want: 0.21586050011389923}, | |||
| 20 | + | {name: "red", color: renderer.RGB{R: 255}, want: 0.2126}, | |||
| 21 | + | {name: "green", color: renderer.RGB{G: 255}, want: 0.7152}, | |||
| 22 | + | {name: "blue", color: renderer.RGB{B: 255}, want: 0.0722}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | got := RelativeLuminance(tt.color) | |||
| 28 | + | require.InDelta(t, tt.want, got, 1e-12) | |||
| 29 | + | require.Equal(t, got, RelativeLuminance(tt.color)) | |||
| 30 | + | }) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestContrastRatio(t *testing.T) { | |||
| 35 | + | black := renderer.RGB{} | |||
| 36 | + | white := renderer.RGB{R: 255, G: 255, B: 255} | |||
| 37 | + | middle := renderer.RGB{R: 123, G: 45, B: 67} | |||
| 38 | + | ||||
| 39 | + | require.InDelta(t, 21, ContrastRatio(black, white), 1e-12) | |||
| 40 | + | require.InDelta(t, 1, ContrastRatio(middle, middle), 1e-12) | |||
| 41 | + | require.Equal(t, ContrastRatio(black, middle), ContrastRatio(middle, black)) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestAccentContrastMinimum(t *testing.T) { | |||
| 45 | + | require.Equal(t, 3.0, accentContrastMin) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestPulseColor(t *testing.T) { | |||
| 49 | + | rgbBase := renderer.Style{ | |||
| 50 | + | Italic: true, | |||
| 51 | + | Foreground: 7, | |||
| 52 | + | Background: 8, | |||
| 53 | + | HasForegroundRGB: true, | |||
| 54 | + | ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50}, | |||
| 55 | + | HasBackgroundRGB: true, | |||
| 56 | + | BackgroundRGB: renderer.RGB{R: 20, G: 40, B: 60}, | |||
| 57 | + | HasUnderlineColor: true, | |||
| 58 | + | UnderlineColor: 4, | |||
| 59 | + | HasUnderlineColorRGB: false, | |||
| 60 | + | } | |||
| 61 | + | indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true} | |||
| 62 | + | mixedBase := rgbBase | |||
| 63 | + | mixedBase.HasBackgroundRGB = false | |||
| 64 | + | ||||
| 65 | + | tests := []struct { | |||
| 66 | + | name string | |||
| 67 | + | base renderer.Style | |||
| 68 | + | intensity float64 | |||
| 69 | + | want renderer.Style | |||
| 70 | + | }{ | |||
| 71 | + | { | |||
| 72 | + | name: "RGB starts at the background", | |||
| 73 | + | base: rgbBase, | |||
| 74 | + | intensity: 0, | |||
| 75 | + | want: func() renderer.Style { | |||
| 76 | + | style := rgbBase | |||
| 77 | + | style.Bold = true | |||
| 78 | + | style.ForegroundRGB = renderer.RGB{R: 20, G: 40, B: 60} | |||
| 79 | + | return style | |||
| 80 | + | }(), | |||
| 81 | + | }, | |||
| 82 | + | { | |||
| 83 | + | name: "RGB blends halfway with rounding", | |||
| 84 | + | base: rgbBase, | |||
| 85 | + | intensity: 0.5, | |||
| 86 | + | want: func() renderer.Style { | |||
| 87 | + | style := rgbBase | |||
| 88 | + | style.Bold = true | |||
| 89 | + | style.ForegroundRGB = renderer.RGB{R: 110, G: 70, B: 55} | |||
| 90 | + | return style | |||
| 91 | + | }(), | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | name: "RGB reaches the foreground", | |||
| 95 | + | base: rgbBase, | |||
| 96 | + | intensity: 1, | |||
| 97 | + | want: func() renderer.Style { | |||
| 98 | + | style := rgbBase | |||
| 99 | + | style.Bold = true | |||
| 100 | + | return style | |||
| 101 | + | }(), | |||
| 102 | + | }, | |||
| 103 | + | { | |||
| 104 | + | name: "RGB foreground without RGB background uses indexed grayscale", | |||
| 105 | + | base: mixedBase, | |||
| 106 | + | intensity: 0.5, | |||
| 107 | + | want: func() renderer.Style { | |||
| 108 | + | style := mixedBase | |||
| 109 | + | style.Bold = true | |||
| 110 | + | style.HasForegroundRGB = false | |||
| 111 | + | style.Foreground = 249 | |||
| 112 | + | return style | |||
| 113 | + | }(), | |||
| 114 | + | }, | |||
| 115 | + | { | |||
| 116 | + | name: "indexed color uses xterm grayscale start", | |||
| 117 | + | base: indexedBase, | |||
| 118 | + | intensity: 0, | |||
| 119 | + | want: renderer.Style{Bold: true, Inverse: true, Foreground: 244, Background: -1}, | |||
| 120 | + | }, | |||
| 121 | + | { | |||
| 122 | + | name: "indexed color truncates the grayscale ramp", | |||
| 123 | + | base: indexedBase, | |||
| 124 | + | intensity: 0.5, | |||
| 125 | + | want: renderer.Style{Bold: true, Inverse: true, Foreground: 249, Background: -1}, | |||
| 126 | + | }, | |||
| 127 | + | { | |||
| 128 | + | name: "indexed color reaches xterm grayscale end", | |||
| 129 | + | base: indexedBase, | |||
| 130 | + | intensity: 1, | |||
| 131 | + | want: renderer.Style{Bold: true, Inverse: true, Foreground: 255, Background: -1}, | |||
| 132 | + | }, | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | require.Equal(t, tt.want, PulseColor(tt.base, tt.intensity)) | |||
| 138 | + | }) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestMRUFade(t *testing.T) { | |||
| 143 | + | base := renderer.Style{ | |||
| 144 | + | Bold: true, | |||
| 145 | + | Foreground: 7, | |||
| 146 | + | Background: 8, | |||
| 147 | + | HasForegroundRGB: true, | |||
| 148 | + | ForegroundRGB: renderer.RGB{R: 200, G: 100, B: 50}, | |||
| 149 | + | HasBackgroundRGB: true, | |||
| 150 | + | BackgroundRGB: renderer.RGB{R: 40, G: 60, B: 80}, | |||
| 151 | + | HasUnderlineColorRGB: true, | |||
| 152 | + | UnderlineColorRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 153 | + | } | |||
| 154 | + | terminalTheme := Theme{HasBG: true, Background: renderer.RGB{R: 10, G: 20, B: 30}} | |||
| 155 | + | ||||
| 156 | + | tests := []struct { | |||
| 157 | + | name string | |||
| 158 | + | base renderer.Style | |||
| 159 | + | theme Theme | |||
| 160 | + | i int | |||
| 161 | + | count int | |||
| 162 | + | want renderer.Style | |||
| 163 | + | }{ | |||
| 164 | + | {name: "single entry is unchanged", base: base, theme: terminalTheme, count: 1, want: base}, | |||
| 165 | + | {name: "missing theme background is unchanged", base: base, theme: Theme{}, i: 1, count: 3, want: base}, | |||
| 166 | + | {name: "indexed base is unchanged", base: renderer.DefaultStyle(), theme: terminalTheme, i: 1, count: 3, want: renderer.DefaultStyle()}, | |||
| 167 | + | {name: "newest entry starts unfaded", base: base, theme: terminalTheme, count: 3, want: base}, | |||
| 168 | + | { | |||
| 169 | + | name: "middle entry fades thirty percent toward terminal background", | |||
| 170 | + | base: base, | |||
| 171 | + | theme: terminalTheme, | |||
| 172 | + | i: 1, | |||
| 173 | + | count: 3, | |||
| 174 | + | want: func() renderer.Style { | |||
| 175 | + | style := base | |||
| 176 | + | style.ForegroundRGB = renderer.RGB{R: 143, G: 76, B: 44} | |||
| 177 | + | style.BackgroundRGB = renderer.RGB{R: 31, G: 48, B: 65} | |||
| 178 | + | return style | |||
| 179 | + | }(), | |||
| 180 | + | }, | |||
| 181 | + | { | |||
| 182 | + | name: "oldest entry fades sixty percent toward terminal background", | |||
| 183 | + | base: base, | |||
| 184 | + | theme: terminalTheme, | |||
| 185 | + | i: 2, | |||
| 186 | + | count: 3, | |||
| 187 | + | want: func() renderer.Style { | |||
| 188 | + | style := base | |||
| 189 | + | style.ForegroundRGB = renderer.RGB{R: 86, G: 52, B: 38} | |||
| 190 | + | style.BackgroundRGB = renderer.RGB{R: 22, G: 36, B: 50} | |||
| 191 | + | return style | |||
| 192 | + | }(), | |||
| 193 | + | }, | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | for _, tt := range tests { | |||
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 198 | + | require.Equal(t, tt.want, MRUFade(tt.base, tt.theme, tt.i, tt.count)) | |||
| 199 | + | }) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func TestThemePaletteColor(t *testing.T) { | |||
| 204 | + | palette := [16]renderer.RGB{ | |||
| 205 | + | 3: {R: 0x11, G: 0x22, B: 0x33}, | |||
| 206 | + | 7: {R: 0xaa, G: 0xbb, B: 0xcc}, | |||
| 207 | + | } | |||
| 208 | + | theme := Theme{ | |||
| 209 | + | UsePalette: true, | |||
| 210 | + | Palette: palette, | |||
| 211 | + | PaletteKnown: 1<<3 | 1<<7, | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | tests := []struct { | |||
| 215 | + | name string | |||
| 216 | + | slot int | |||
| 217 | + | want renderer.RGB | |||
| 218 | + | ok bool | |||
| 219 | + | }{ | |||
| 220 | + | {name: "known slot", slot: 3, want: palette[3], ok: true}, | |||
| 221 | + | {name: "unknown slot", slot: 4}, | |||
| 222 | + | {name: "negative slot", slot: -1}, | |||
| 223 | + | {name: "slot above palette", slot: 16}, | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | for _, tt := range tests { | |||
| 227 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 228 | + | got, ok := theme.PaletteColor(tt.slot) | |||
| 229 | + | require.Equal(t, tt.ok, ok) | |||
| 230 | + | require.Equal(t, tt.want, got) | |||
| 231 | + | }) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | theme.UsePalette = false | |||
| 235 | + | got, ok := theme.PaletteColor(3) | |||
| 236 | + | require.False(t, ok) | |||
| 237 | + | require.Equal(t, renderer.RGB{}, got) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestBuiltinThemesDoNotUsePalette(t *testing.T) { | |||
| 241 | + | for name, theme := range map[string]Theme{ | |||
| 242 | + | "dark": BuiltinDark, | |||
| 243 | + | "light": BuiltinLight, | |||
| 244 | + | } { | |||
| 245 | + | t.Run(name, func(t *testing.T) { | |||
| 246 | + | require.False(t, theme.UsePalette) | |||
| 247 | + | require.Zero(t, theme.PaletteKnown) | |||
| 248 | + | require.Equal(t, [16]renderer.RGB{}, theme.Palette) | |||
| 249 | + | }) | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestThemeIsComparable(t *testing.T) { | |||
| 254 | + | themes := map[Theme]bool{BuiltinDark: true} | |||
| 255 | + | require.True(t, themes[BuiltinDark]) | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func TestPaletteAccentBackground(t *testing.T) { | |||
| 259 | + | base := Theme{ | |||
| 260 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 261 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |||
| 262 | + | HasFG: true, | |||
| 263 | + | HasBG: true, | |||
| 264 | + | Known: true, | |||
| 265 | + | TrueColor: true, | |||
| 266 | + | UsePalette: true, | |||
| 267 | + | } | |||
| 268 | + | neutral := Blend(base.Background, base.Foreground, accentBlend) | |||
| 269 | + | ||||
| 270 | + | tests := []struct { | |||
| 271 | + | name string | |||
| 272 | + | palette [16]renderer.RGB | |||
| 273 | + | known uint16 | |||
| 274 | + | usePalette bool | |||
| 275 | + | want renderer.RGB | |||
| 276 | + | wantContrastMin bool | |||
| 277 | + | }{ | |||
| 278 | + | { | |||
| 279 | + | name: "slot 4 supplies a contrasting accent background", | |||
| 280 | + | palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}}, | |||
| 281 | + | known: 1<<4 | 1<<12, | |||
| 282 | + | usePalette: true, | |||
| 283 | + | want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend), | |||
| 284 | + | wantContrastMin: true, | |||
| 285 | + | }, | |||
| 286 | + | { | |||
| 287 | + | name: "slot 4 failure escalates to slot 12", | |||
| 288 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}}, | |||
| 289 | + | known: 1<<4 | 1<<12, | |||
| 290 | + | usePalette: true, | |||
| 291 | + | want: Blend(base.Background, renderer.RGB{}, accentBlend), | |||
| 292 | + | wantContrastMin: true, | |||
| 293 | + | }, | |||
| 294 | + | { | |||
| 295 | + | name: "both palette blues failing uses exact neutral blend", | |||
| 296 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |||
| 297 | + | known: 1<<4 | 1<<12, | |||
| 298 | + | usePalette: true, | |||
| 299 | + | want: neutral, | |||
| 300 | + | }, | |||
| 301 | + | { | |||
| 302 | + | name: "missing palette blues uses exact neutral blend", | |||
| 303 | + | usePalette: true, | |||
| 304 | + | want: neutral, | |||
| 305 | + | }, | |||
| 306 | + | { | |||
| 307 | + | name: "palette disabled preserves neutral accent", | |||
| 308 | + | palette: [16]renderer.RGB{4: {B: 255}}, | |||
| 309 | + | known: 1 << 4, | |||
| 310 | + | usePalette: false, | |||
| 311 | + | want: neutral, | |||
| 312 | + | }, | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | for _, tt := range tests { | |||
| 316 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 317 | + | theme := base | |||
| 318 | + | theme.Palette = tt.palette | |||
| 319 | + | theme.PaletteKnown = tt.known | |||
| 320 | + | theme.UsePalette = tt.usePalette | |||
| 321 | + | ||||
| 322 | + | got := AccentStyle(theme) | |||
| 323 | + | require.True(t, got.HasBackgroundRGB) | |||
| 324 | + | require.Equal(t, tt.want, got.BackgroundRGB) | |||
| 325 | + | if tt.wantContrastMin { | |||
| 326 | + | require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin) | |||
| 327 | + | } | |||
| 328 | + | }) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func TestPaletteTitleHueDerivation(t *testing.T) { | |||
| 333 | + | base := Theme{ | |||
| 334 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 335 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |||
| 336 | + | HasFG: true, | |||
| 337 | + | HasBG: true, | |||
| 338 | + | Known: true, | |||
| 339 | + | TrueColor: true, | |||
| 340 | + | UsePalette: true, | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | tests := []struct { | |||
| 344 | + | name string | |||
| 345 | + | theme Theme | |||
| 346 | + | palette [16]renderer.RGB | |||
| 347 | + | known uint16 | |||
| 348 | + | }{ | |||
| 349 | + | { | |||
| 350 | + | name: "slot 4 is used when it contrasts for each title", | |||
| 351 | + | theme: Theme{ | |||
| 352 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 353 | + | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |||
| 354 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 355 | + | }, | |||
| 356 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}}, | |||
| 357 | + | ||||
| 358 | + | known: 1<<4 | 1<<12, | |||
| 359 | + | }, | |||
| 360 | + | { | |||
| 361 | + | name: "each title escalates independently from slot 4 to slot 12", | |||
| 362 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}}, | |||
| 363 | + | known: 1<<4 | 1<<12, | |||
| 364 | + | }, | |||
| 365 | + | { | |||
| 366 | + | name: "both title hues failing retains neutral muted styles", | |||
| 367 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |||
| 368 | + | known: 1<<4 | 1<<12, | |||
| 369 | + | }, | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | for _, tt := range tests { | |||
| 373 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 374 | + | theme := base | |||
| 375 | + | if tt.theme.Known { | |||
| 376 | + | theme = tt.theme | |||
| 377 | + | } | |||
| 378 | + | theme.Palette = tt.palette | |||
| 379 | + | theme.PaletteKnown = tt.known | |||
| 380 | + | styles := NewStyles(theme) | |||
| 381 | + | status := StatusBarStyle(theme) | |||
| 382 | + | accent := AccentStyle(theme) | |||
| 383 | + | ||||
| 384 | + | if tt.name == "both title hues failing retains neutral muted styles" { | |||
| 385 | + | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme))) | |||
| 386 | + | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | require.True(t, styles.TabTitle.HasForegroundRGB) | |||
| 391 | + | require.True(t, styles.TabTitleActive.HasForegroundRGB) | |||
| 392 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin) | |||
| 393 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin) | |||
| 394 | + | ||||
| 395 | + | if tt.name == "slot 4 is used when it contrasts for each title" { | |||
| 396 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB) | |||
| 397 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |||
| 398 | + | } | |||
| 399 | + | if tt.name == "each title escalates independently from slot 4 to slot 12" { | |||
| 400 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB) | |||
| 401 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |||
| 402 | + | } | |||
| 403 | + | }) | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) { | |||
| 408 | + | base := Theme{ | |||
| 409 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 410 | + | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |||
| 411 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | tests := []struct { | |||
| 415 | + | name string | |||
| 416 | + | theme Theme | |||
| 417 | + | wantForeground renderer.RGB | |||
| 418 | + | wantIndexed int | |||
| 419 | + | }{ | |||
| 420 | + | { | |||
| 421 | + | name: "RGB palette foreground", | |||
| 422 | + | theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(), | |||
| 423 | + | wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend), | |||
| 424 | + | wantIndexed: -1, | |||
| 425 | + | }, | |||
| 426 | + | { | |||
| 427 | + | name: "indexed foreground fallback", | |||
| 428 | + | theme: func() Theme { t := base; t.SchemeKnown = true; return t }(), | |||
| 429 | + | wantIndexed: ansiBrightBlue, | |||
| 430 | + | }, | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | for _, tt := range tests { | |||
| 434 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 435 | + | styles := NewStyles(tt.theme) | |||
| 436 | + | ||||
| 437 | + | for name, style := range map[string]renderer.Style{ | |||
| 438 | + | "border": styles.Border, | |||
| 439 | + | "muted text": styles.PaletteDesc, | |||
| 440 | + | } { | |||
| 441 | + | require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name) | |||
| 442 | + | if tt.wantIndexed >= 0 { | |||
| 443 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 444 | + | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |||
| 445 | + | } else { | |||
| 446 | + | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |||
| 447 | + | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |||
| 448 | + | } | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | status := StatusBarStyle(tt.theme) | |||
| 452 | + | accent := AccentStyle(tt.theme) | |||
| 453 | + | for name, style := range map[string]renderer.Style{ | |||
| 454 | + | "title": styles.TabTitle, | |||
| 455 | + | "active title": styles.TabTitleActive, | |||
| 456 | + | } { | |||
| 457 | + | wantBackground := status.BackgroundRGB | |||
| 458 | + | if name == "active title" { | |||
| 459 | + | wantBackground = accent.BackgroundRGB | |||
| 460 | + | } | |||
| 461 | + | require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name) | |||
| 462 | + | require.Equal(t, wantBackground, style.BackgroundRGB, name) | |||
| 463 | + | if tt.wantIndexed >= 0 { | |||
| 464 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 465 | + | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |||
| 466 | + | } else { | |||
| 467 | + | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |||
| 468 | + | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | }) | |||
| 472 | + | } | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func TestPaletteIndexedForegroundFallback(t *testing.T) { | |||
| 476 | + | base := Theme{UsePalette: true, SchemeKnown: true, Known: true} | |||
| 477 | + | ||||
| 478 | + | tests := []struct { | |||
| 479 | + | name string | |||
| 480 | + | theme Theme | |||
| 481 | + | want int | |||
| 482 | + | ok bool | |||
| 483 | + | }{ | |||
| 484 | + | {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true}, | |||
| 485 | + | {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true}, | |||
| 486 | + | {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}}, | |||
| 487 | + | {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}}, | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | for _, tt := range tests { | |||
| 491 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 492 | + | styles := NewStyles(tt.theme) | |||
| 493 | + | baselineTheme := tt.theme | |||
| 494 | + | baselineTheme.UsePalette = false | |||
| 495 | + | baseline := NewStyles(baselineTheme) | |||
| 496 | + | for name, style := range map[string]renderer.Style{ | |||
| 497 | + | "title": styles.TabTitle, | |||
| 498 | + | "active title": styles.TabTitleActive, | |||
| 499 | + | "border": styles.Border, | |||
| 500 | + | "muted text": styles.PaletteDesc, | |||
| 501 | + | } { | |||
| 502 | + | require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name) | |||
| 503 | + | if tt.ok { | |||
| 504 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 505 | + | require.Equal(t, tt.want, style.Foreground, name) | |||
| 506 | + | } else { | |||
| 507 | + | want := map[string]renderer.Style{ | |||
| 508 | + | "title": baseline.TabTitle, "active title": baseline.TabTitleActive, | |||
| 509 | + | "border": baseline.Border, "muted text": baseline.PaletteDesc, | |||
| 510 | + | }[name] | |||
| 511 | + | require.Equal(t, want, style, name) | |||
| 512 | + | } | |||
| 513 | + | } | |||
| 514 | + | }) | |||
| 515 | + | } | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) { | |||
| 519 | + | theme := Theme{ | |||
| 520 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 521 | + | Background: renderer.RGB{R: 30, G: 30, B: 30}, | |||
| 522 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |||
| 523 | + | UsePalette: true, SchemeKnown: true, | |||
| 524 | + | } | |||
| 525 | + | styles := NewStyles(theme) | |||
| 526 | + | for name, style := range map[string]renderer.Style{ | |||
| 527 | + | "title": styles.TabTitle, "active title": styles.TabTitleActive, | |||
| 528 | + | "border": styles.Border, "muted text": styles.PaletteDesc, | |||
| 529 | + | } { | |||
| 530 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 531 | + | require.Equal(t, ansiBrightBlue, style.Foreground, name) | |||
| 532 | + | require.Equal(t, -1, style.Background, "%s must not use an indexed background", name) | |||
| 533 | + | } | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) { | |||
| 537 | + | palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}} | |||
| 538 | + | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |||
| 539 | + | withDisabledPalette := usableTheme | |||
| 540 | + | withDisabledPalette.Palette = palette | |||
| 541 | + | withDisabledPalette.PaletteKnown = 1<<4 | 1<<12 | |||
| 542 | + | withDisabledPalette.UsePalette = false | |||
| 543 | + | ||||
| 544 | + | require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette)) | |||
| 545 | + | ||||
| 546 | + | forced := Theme{Known: true, SchemeKnown: true, UsePalette: false} | |||
| 547 | + | require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced)) | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | func TestNewStylesComposesSemanticStyles(t *testing.T) { | |||
| 551 | + | theme := Theme{ | |||
| 552 | + | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | |||
| 553 | + | Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e}, | |||
| 554 | + | HasFG: true, | |||
| 555 | + | HasBG: true, | |||
| 556 | + | Known: true, | |||
| 557 | + | TrueColor: true, | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | styles := NewStyles(theme) | |||
| 561 | + | statusBar := StatusBarStyle(theme) | |||
| 562 | + | accent := AccentStyle(theme) | |||
| 563 | + | selection := SelectionStyle(theme) | |||
| 564 | + | ||||
| 565 | + | require.True(t, styles.StatusBar.Equal(statusBar)) | |||
| 566 | + | require.True(t, styles.Accent.Equal(accent)) | |||
| 567 | + | require.True(t, styles.Border.Equal(BorderStyle(theme))) | |||
| 568 | + | require.True(t, styles.Selection.Equal(selection)) | |||
| 569 | + | require.True(t, styles.CopyStatus.Equal(selection)) | |||
| 570 | + | require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme))) | |||
| 571 | + | require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme))) | |||
| 572 | + | require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme))) | |||
| 573 | + | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme))) | |||
| 574 | + | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |||
| 575 | + | require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme))) | |||
| 576 | + | require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme))) | |||
| 577 | + | require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme))) | |||
| 578 | + | require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme))) | |||
| 579 | + | } | |||
M internal/usecase/theme/theme.go +218 -64
| @@ -11,16 +11,28 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | const maxPending = 64 | |
| 13 | 13 | ||
| 14 | - | // Theme describes terminal default colors reported by OSC 10/11. | |
| 14 | + | // Theme describes terminal default colors and ANSI palette entries. | |
| 15 | 15 | type Theme struct { | |
| 16 | - | Foreground renderer.RGB | |
| 17 | - | Background renderer.RGB | |
| 18 | - | HasFG bool | |
| 19 | - | HasBG bool | |
| 20 | - | TrueColor bool | |
| 21 | - | Known bool | |
| 22 | - | SchemeKnown bool | |
| 23 | - | Light bool | |
| 16 | + | Foreground renderer.RGB | |
| 17 | + | Background renderer.RGB | |
| 18 | + | Palette [16]renderer.RGB | |
| 19 | + | PaletteKnown uint16 | |
| 20 | + | HasFG bool | |
| 21 | + | HasBG bool | |
| 22 | + | TrueColor bool | |
| 23 | + | Known bool | |
| 24 | + | SchemeKnown bool | |
| 25 | + | Light bool | |
| 26 | + | UsePalette bool | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // PaletteColor returns a palette color only when palette inheritance is | |
| 30 | + | // enabled and the terminal reported the requested ANSI slot. | |
| 31 | + | func (t Theme) PaletteColor(slot int) (renderer.RGB, bool) { | |
| 32 | + | if !t.UsePalette || slot < 0 || slot >= len(t.Palette) || t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 33 | + | return renderer.RGB{}, false | |
| 34 | + | } | |
| 35 | + | return t.Palette[slot], true | |
| 24 | 36 | } | |
| 25 | 37 | ||
| 26 | 38 | var ( | |
| @@ -74,32 +86,31 @@ func ParseXColor(s string) (renderer.RGB, bool) { | |||
| 74 | 86 | if len(parts) != 3 { | |
| 75 | 87 | return renderer.RGB{}, false | |
| 76 | 88 | } | |
| 77 | - | width := len(parts[0]) | |
| 78 | - | if width != 2 && width != 4 { | |
| 79 | - | return renderer.RGB{}, false | |
| 80 | - | } | |
| 81 | 89 | var out [3]uint8 | |
| 82 | 90 | for i, part := range parts { | |
| 83 | - | if len(part) != width { | |
| 84 | - | return renderer.RGB{}, false | |
| 85 | - | } | |
| 86 | - | if width == 2 { | |
| 87 | - | v, ok := parseHexByte(part) | |
| 88 | - | if !ok { | |
| 89 | - | return renderer.RGB{}, false | |
| 90 | - | } | |
| 91 | - | out[i] = v | |
| 92 | - | continue | |
| 93 | - | } | |
| 94 | - | v, err := strconv.ParseUint(part, 16, 16) | |
| 95 | - | if err != nil { | |
| 91 | + | component, ok := parseXColorComponent(part) | |
| 92 | + | if !ok { | |
| 96 | 93 | return renderer.RGB{}, false | |
| 97 | 94 | } | |
| 98 | - | out[i] = uint8(v >> 8) | |
| 95 | + | out[i] = component | |
| 99 | 96 | } | |
| 100 | 97 | return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true | |
| 101 | 98 | } | |
| 102 | 99 | ||
| 100 | + | // parseXColorComponent scales an XParseColor component of one to four hex | |
| 101 | + | // digits to an 8-bit channel. | |
| 102 | + | func parseXColorComponent(s string) (uint8, bool) { | |
| 103 | + | if len(s) < 1 || len(s) > 4 { | |
| 104 | + | return 0, false | |
| 105 | + | } | |
| 106 | + | value, err := strconv.ParseUint(s, 16, 16) | |
| 107 | + | if err != nil { | |
| 108 | + | return 0, false | |
| 109 | + | } | |
| 110 | + | maxValue := uint64((1 << (4 * len(s))) - 1) | |
| 111 | + | return uint8(value * 255 / maxValue), true | |
| 112 | + | } | |
| 113 | + | ||
| 103 | 114 | func parseHexByte(s string) (uint8, bool) { | |
| 104 | 115 | v, err := strconv.ParseUint(s, 16, 8) | |
| 105 | 116 | if err != nil { | |
| @@ -108,12 +119,13 @@ func parseHexByte(s string) (uint8, bool) { | |||
| 108 | 119 | return uint8(v), true | |
| 109 | 120 | } | |
| 110 | 121 | ||
| 111 | - | // Scanner decodes OSC 10/11 color responses while preserving ordinary bytes. | |
| 122 | + | // Scanner decodes terminal color responses while preserving ordinary bytes. | |
| 112 | 123 | type Scanner struct { | |
| 113 | 124 | pending []byte | |
| 114 | 125 | } | |
| 115 | 126 | ||
| 116 | - | // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST. | |
| 127 | + | // Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color> | |
| 128 | + | // responses terminated by BEL or ST. | |
| 117 | 129 | // All non-matching bytes are emitted through onBytes in original order, and a | |
| 118 | 130 | // contiguous run of ordinary bytes (including any ESC that does not start a | |
| 119 | 131 | // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse | |
| @@ -121,7 +133,7 @@ type Scanner struct { | |||
| 121 | 133 | // callers never see it split. Partial OSC color responses are buffered across | |
| 122 | 134 | // calls, but the buffer is bounded so an unterminated OSC cannot block | |
| 123 | 135 | // unrelated input forever. | |
| 124 | - | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) { | |
| 136 | + | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onPalette func(slot int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) { | |
| 125 | 137 | if len(s.pending) > 0 { | |
| 126 | 138 | combined := make([]byte, 0, len(s.pending)+len(data)) | |
| 127 | 139 | combined = append(combined, s.pending...) | |
| @@ -152,7 +164,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||
| 152 | 164 | return | |
| 153 | 165 | } | |
| 154 | 166 | ||
| 155 | - | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | |
| 167 | + | kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | |
| 156 | 168 | if !completePrefix { | |
| 157 | 169 | if possiblePrefix { | |
| 158 | 170 | if byteStart < i { | |
| @@ -173,20 +185,30 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||
| 173 | 185 | onBytes(data[byteStart:i]) | |
| 174 | 186 | } | |
| 175 | 187 | ||
| 176 | - | termStart, termEnd := findTerminator(data[i+5:]) | |
| 188 | + | termStart, termEnd := findTerminator(data[i+prefixLen:]) | |
| 177 | 189 | if termStart < 0 { | |
| 178 | 190 | s.bufferOrFlush(data[i:], onBytes) | |
| 179 | 191 | return | |
| 180 | 192 | } | |
| 181 | - | termStart += i + 5 | |
| 182 | - | termEnd += i + 5 | |
| 193 | + | termStart += i + prefixLen | |
| 194 | + | termEnd += i + prefixLen | |
| 183 | 195 | raw := data[i:termEnd] | |
| 184 | - | kind := int(data[i+3]-'0') + 10 | |
| 185 | - | rgb, ok := ParseXColor(string(data[i+5 : termStart])) | |
| 186 | - | if ok { | |
| 187 | - | onColor(kind, rgb) | |
| 188 | - | } else { | |
| 189 | - | onBytes(raw) | |
| 196 | + | body := string(data[i+prefixLen : termStart]) | |
| 197 | + | switch kind { | |
| 198 | + | case 4: | |
| 199 | + | slot, rgb, ok := parsePaletteResponse(body) | |
| 200 | + | if ok { | |
| 201 | + | onPalette(slot, rgb) | |
| 202 | + | } else { | |
| 203 | + | onBytes(raw) | |
| 204 | + | } | |
| 205 | + | default: | |
| 206 | + | rgb, ok := ParseXColor(body) | |
| 207 | + | if ok { | |
| 208 | + | onColor(kind, rgb) | |
| 209 | + | } else { | |
| 210 | + | onBytes(raw) | |
| 211 | + | } | |
| 190 | 212 | } | |
| 191 | 213 | i = termEnd - 1 | |
| 192 | 214 | byteStart = termEnd | |
| @@ -206,23 +228,52 @@ func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) { | |||
| 206 | 228 | s.pending = append(s.pending, partial...) | |
| 207 | 229 | } | |
| 208 | 230 | ||
| 209 | - | func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |
| 231 | + | func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) { | |
| 210 | 232 | if len(data) < 2 { | |
| 211 | - | return false, false | |
| 212 | - | } | |
| 213 | - | prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")} | |
| 214 | - | for _, prefix := range prefixes { | |
| 215 | - | if len(data) >= len(prefix) { | |
| 216 | - | if bytes.Equal(data[:len(prefix)], prefix) { | |
| 217 | - | return true, true | |
| 233 | + | return 0, 0, false, false | |
| 234 | + | } | |
| 235 | + | prefixes := []struct { | |
| 236 | + | kind int | |
| 237 | + | prefix []byte | |
| 238 | + | }{ | |
| 239 | + | {kind: 10, prefix: []byte("\x1b]10;")}, | |
| 240 | + | {kind: 11, prefix: []byte("\x1b]11;")}, | |
| 241 | + | {kind: 4, prefix: []byte("\x1b]4;")}, | |
| 242 | + | } | |
| 243 | + | for _, candidate := range prefixes { | |
| 244 | + | if len(data) >= len(candidate.prefix) { | |
| 245 | + | if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) { | |
| 246 | + | return candidate.kind, len(candidate.prefix), true, true | |
| 218 | 247 | } | |
| 219 | 248 | continue | |
| 220 | 249 | } | |
| 221 | - | if bytes.Equal(data, prefix[:len(data)]) { | |
| 222 | - | return false, true | |
| 250 | + | if bytes.Equal(data, candidate.prefix[:len(data)]) { | |
| 251 | + | return candidate.kind, len(candidate.prefix), false, true | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return 0, 0, false, false | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func parsePaletteResponse(body string) (int, renderer.RGB, bool) { | |
| 258 | + | indexEnd := strings.IndexByte(body, ';') | |
| 259 | + | if indexEnd < 1 { | |
| 260 | + | return 0, renderer.RGB{}, false | |
| 261 | + | } | |
| 262 | + | slot := 0 | |
| 263 | + | for _, b := range []byte(body[:indexEnd]) { | |
| 264 | + | if b < '0' || b > '9' { | |
| 265 | + | return 0, renderer.RGB{}, false | |
| 266 | + | } | |
| 267 | + | slot = slot*10 + int(b-'0') | |
| 268 | + | if slot > 15 { | |
| 269 | + | return 0, renderer.RGB{}, false | |
| 223 | 270 | } | |
| 224 | 271 | } | |
| 225 | - | return false, false | |
| 272 | + | rgb, ok := ParseXColor(body[indexEnd+1:]) | |
| 273 | + | if !ok { | |
| 274 | + | return 0, renderer.RGB{}, false | |
| 275 | + | } | |
| 276 | + | return slot, rgb, true | |
| 226 | 277 | } | |
| 227 | 278 | ||
| 228 | 279 | // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev | |
| @@ -305,6 +356,13 @@ func StatusBarStyle(t Theme) renderer.Style { | |||
| 305 | 356 | return style | |
| 306 | 357 | } | |
| 307 | 358 | ||
| 359 | + | const ( | |
| 360 | + | ansiBlue = 4 | |
| 361 | + | ansiBrightBlue = 12 | |
| 362 | + | accentBlend = 0.25 | |
| 363 | + | titleHueBlend = 0.2 | |
| 364 | + | ) | |
| 365 | + | ||
| 308 | 366 | func AccentStyle(t Theme) renderer.Style { | |
| 309 | 367 | if !usable(t) { | |
| 310 | 368 | return inverseStyle() | |
| @@ -313,28 +371,69 @@ func AccentStyle(t Theme) renderer.Style { | |||
| 313 | 371 | style.HasForegroundRGB = true | |
| 314 | 372 | style.ForegroundRGB = t.Foreground | |
| 315 | 373 | style.HasBackgroundRGB = true | |
| 316 | - | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25) | |
| 374 | + | style.BackgroundRGB = accentBackground(t) | |
| 317 | 375 | return style | |
| 318 | 376 | } | |
| 319 | 377 | ||
| 378 | + | // accentBackground uses a terminal's blue palette only when the resulting | |
| 379 | + | // foreground/background pair remains legible. A neutral blend is always the | |
| 380 | + | // fallback, including when palette inheritance is disabled. | |
| 381 | + | func accentBackground(t Theme) renderer.RGB { | |
| 382 | + | neutral := Blend(t.Background, t.Foreground, accentBlend) | |
| 383 | + | if !t.UsePalette { | |
| 384 | + | return neutral | |
| 385 | + | } | |
| 386 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 387 | + | blue, ok := t.PaletteColor(slot) | |
| 388 | + | if !ok { | |
| 389 | + | continue | |
| 390 | + | } | |
| 391 | + | candidate := Blend(t.Background, blue, accentBlend) | |
| 392 | + | if ContrastRatio(t.Foreground, candidate) >= accentContrastMin { | |
| 393 | + | return candidate | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | return neutral | |
| 397 | + | } | |
| 398 | + | ||
| 320 | 399 | func BorderStyle(t Theme) renderer.Style { | |
| 321 | - | if !usable(t) { | |
| 322 | - | return renderer.DefaultStyle() | |
| 400 | + | neutral := renderer.DefaultStyle() | |
| 401 | + | if usable(t) { | |
| 402 | + | neutral.HasForegroundRGB = true | |
| 403 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |
| 323 | 404 | } | |
| 324 | - | style := renderer.DefaultStyle() | |
| 325 | - | style.HasForegroundRGB = true | |
| 326 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |
| 327 | - | return style | |
| 405 | + | if !t.UsePalette { | |
| 406 | + | return neutral | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | base := renderer.DefaultStyle() | |
| 410 | + | if usable(t) { | |
| 411 | + | base.HasForegroundRGB = true | |
| 412 | + | base.ForegroundRGB = t.Foreground | |
| 413 | + | base.HasBackgroundRGB = true | |
| 414 | + | base.BackgroundRGB = t.Background | |
| 415 | + | } | |
| 416 | + | return paletteForegroundStyle(base, neutral, t) | |
| 328 | 417 | } | |
| 329 | 418 | ||
| 330 | 419 | func MutedTextStyle(t Theme) renderer.Style { | |
| 331 | - | if !usable(t) { | |
| 332 | - | return renderer.DefaultStyle() | |
| 420 | + | neutral := renderer.DefaultStyle() | |
| 421 | + | if usable(t) { | |
| 422 | + | neutral.HasForegroundRGB = true | |
| 423 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 333 | 424 | } | |
| 334 | - | style := renderer.DefaultStyle() | |
| 335 | - | style.HasForegroundRGB = true | |
| 336 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 337 | - | return style | |
| 425 | + | if !t.UsePalette { | |
| 426 | + | return neutral | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | base := renderer.DefaultStyle() | |
| 430 | + | if usable(t) { | |
| 431 | + | base.HasForegroundRGB = true | |
| 432 | + | base.ForegroundRGB = t.Foreground | |
| 433 | + | base.HasBackgroundRGB = true | |
| 434 | + | base.BackgroundRGB = t.Background | |
| 435 | + | } | |
| 436 | + | return paletteForegroundStyle(base, neutral, t) | |
| 338 | 437 | } | |
| 339 | 438 | ||
| 340 | 439 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | |
| @@ -373,6 +472,61 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 373 | 472 | return out | |
| 374 | 473 | } | |
| 375 | 474 | ||
| 475 | + | // PaletteMutedVariantStyle derives a title foreground from a terminal blue | |
| 476 | + | // palette entry. Its contrast is evaluated against the title's actual base | |
| 477 | + | // background, rather than the terminal background, because status and active | |
| 478 | + | // title backgrounds differ. | |
| 479 | + | func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |
| 480 | + | return paletteForegroundStyle(base, MutedVariantStyle(base, t), t) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | // paletteForegroundStyle uses palette blues for a foreground only. It never | |
| 484 | + | // changes a background, so palette inheritance cannot introduce indexed | |
| 485 | + | // backgrounds. If terminal RGB data is unavailable, a known color scheme can | |
| 486 | + | // still select a best-effort ANSI foreground. A reported blue that fails | |
| 487 | + | // contrast deliberately suppresses that indexed fallback. | |
| 488 | + | func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style { | |
| 489 | + | if !t.UsePalette { | |
| 490 | + | return neutral | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | knownBlue := paletteBlueKnown(t) | |
| 494 | + | if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 495 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 496 | + | blue, ok := t.PaletteColor(slot) | |
| 497 | + | if !ok { | |
| 498 | + | continue | |
| 499 | + | } | |
| 500 | + | candidate := Blend(base.ForegroundRGB, blue, titleHueBlend) | |
| 501 | + | if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin { | |
| 502 | + | out := neutral | |
| 503 | + | out.HasForegroundRGB = true | |
| 504 | + | out.ForegroundRGB = candidate | |
| 505 | + | return out | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | if !knownBlue && t.SchemeKnown { | |
| 511 | + | out := neutral | |
| 512 | + | out.HasForegroundRGB = false | |
| 513 | + | if t.Light { | |
| 514 | + | out.Foreground = ansiBlue | |
| 515 | + | } else { | |
| 516 | + | out.Foreground = ansiBrightBlue | |
| 517 | + | } | |
| 518 | + | return out | |
| 519 | + | } | |
| 520 | + | return neutral | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func paletteBlueKnown(t Theme) bool { | |
| 524 | + | if !t.UsePalette { | |
| 525 | + | return false | |
| 526 | + | } | |
| 527 | + | return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0 | |
| 528 | + | } | |
| 529 | + | ||
| 376 | 530 | const defaultDimmingPercent = 35 | |
| 377 | 531 | ||
| 378 | 532 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | |
| @@ -11,16 +11,28 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | const maxPending = 64 | 12 | const maxPending = 64 | ||
| 13 | 13 | ||||
| 14 | - | // Theme describes terminal default colors reported by OSC 10/11. | 14 | + | // Theme describes terminal default colors and ANSI palette entries. |
| 15 | type Theme struct { | 15 | type Theme struct { | ||
| 16 | - | Foreground renderer.RGB | 16 | + | Foreground renderer.RGB |
| 17 | - | Background renderer.RGB | 17 | + | Background renderer.RGB |
| 18 | - | HasFG bool | 18 | + | Palette [16]renderer.RGB |
| 19 | - | HasBG bool | 19 | + | PaletteKnown uint16 |
| 20 | - | TrueColor bool | 20 | + | HasFG bool |
| 21 | - | Known bool | 21 | + | HasBG bool |
| 22 | - | SchemeKnown bool | 22 | + | TrueColor bool |
| 23 | - | Light bool | 23 | + | Known bool |
| 24 | + | SchemeKnown bool | |||
| 25 | + | Light bool | |||
| 26 | + | UsePalette bool | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // PaletteColor returns a palette color only when palette inheritance is | |||
| 30 | + | // enabled and the terminal reported the requested ANSI slot. | |||
| 31 | + | func (t Theme) PaletteColor(slot int) (renderer.RGB, bool) { | |||
| 32 | + | if !t.UsePalette || slot < 0 || slot >= len(t.Palette) || t.PaletteKnown&(uint16(1)<<slot) == 0 { | |||
| 33 | + | return renderer.RGB{}, false | |||
| 34 | + | } | |||
| 35 | + | return t.Palette[slot], true | |||
| 24 | } | 36 | } | ||
| 25 | 37 | ||||
| 26 | var ( | 38 | var ( | ||
| @@ -74,32 +86,31 @@ func ParseXColor(s string) (renderer.RGB, bool) { | |||||
| 74 | if len(parts) != 3 { | 86 | if len(parts) != 3 { | ||
| 75 | return renderer.RGB{}, false | 87 | return renderer.RGB{}, false | ||
| 76 | } | 88 | } | ||
| 77 | - | width := len(parts[0]) | |||
| 78 | - | if width != 2 && width != 4 { | |||
| 79 | - | return renderer.RGB{}, false | |||
| 80 | - | } | |||
| 81 | var out [3]uint8 | 89 | var out [3]uint8 | ||
| 82 | for i, part := range parts { | 90 | for i, part := range parts { | ||
| 83 | - | if len(part) != width { | 91 | + | component, ok := parseXColorComponent(part) |
| 84 | - | return renderer.RGB{}, false | 92 | + | if !ok { |
| 85 | - | } | |||
| 86 | - | if width == 2 { | |||
| 87 | - | v, ok := parseHexByte(part) | |||
| 88 | - | if !ok { | |||
| 89 | - | return renderer.RGB{}, false | |||
| 90 | - | } | |||
| 91 | - | out[i] = v | |||
| 92 | - | continue | |||
| 93 | - | } | |||
| 94 | - | v, err := strconv.ParseUint(part, 16, 16) | |||
| 95 | - | if err != nil { | |||
| 96 | return renderer.RGB{}, false | 93 | return renderer.RGB{}, false | ||
| 97 | } | 94 | } | ||
| 98 | - | out[i] = uint8(v >> 8) | 95 | + | out[i] = component |
| 99 | } | 96 | } | ||
| 100 | return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true | 97 | return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true | ||
| 101 | } | 98 | } | ||
| 102 | 99 | ||||
| 100 | + | // parseXColorComponent scales an XParseColor component of one to four hex | |||
| 101 | + | // digits to an 8-bit channel. | |||
| 102 | + | func parseXColorComponent(s string) (uint8, bool) { | |||
| 103 | + | if len(s) < 1 || len(s) > 4 { | |||
| 104 | + | return 0, false | |||
| 105 | + | } | |||
| 106 | + | value, err := strconv.ParseUint(s, 16, 16) | |||
| 107 | + | if err != nil { | |||
| 108 | + | return 0, false | |||
| 109 | + | } | |||
| 110 | + | maxValue := uint64((1 << (4 * len(s))) - 1) | |||
| 111 | + | return uint8(value * 255 / maxValue), true | |||
| 112 | + | } | |||
| 113 | + | ||||
| 103 | func parseHexByte(s string) (uint8, bool) { | 114 | func parseHexByte(s string) (uint8, bool) { | ||
| 104 | v, err := strconv.ParseUint(s, 16, 8) | 115 | v, err := strconv.ParseUint(s, 16, 8) | ||
| 105 | if err != nil { | 116 | if err != nil { | ||
| @@ -108,12 +119,13 @@ func parseHexByte(s string) (uint8, bool) { | |||||
| 108 | return uint8(v), true | 119 | return uint8(v), true | ||
| 109 | } | 120 | } | ||
| 110 | 121 | ||||
| 111 | - | // Scanner decodes OSC 10/11 color responses while preserving ordinary bytes. | 122 | + | // Scanner decodes terminal color responses while preserving ordinary bytes. |
| 112 | type Scanner struct { | 123 | type Scanner struct { | ||
| 113 | pending []byte | 124 | pending []byte | ||
| 114 | } | 125 | } | ||
| 115 | 126 | ||||
| 116 | - | // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST. | 127 | + | // Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color> |
| 128 | + | // responses terminated by BEL or ST. | |||
| 117 | // All non-matching bytes are emitted through onBytes in original order, and a | 129 | // All non-matching bytes are emitted through onBytes in original order, and a | ||
| 118 | // contiguous run of ordinary bytes (including any ESC that does not start a | 130 | // contiguous run of ordinary bytes (including any ESC that does not start a | ||
| 119 | // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse | 131 | // color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse | ||
| @@ -121,7 +133,7 @@ type Scanner struct { | |||||
| 121 | // callers never see it split. Partial OSC color responses are buffered across | 133 | // callers never see it split. Partial OSC color responses are buffered across | ||
| 122 | // calls, but the buffer is bounded so an unterminated OSC cannot block | 134 | // calls, but the buffer is bounded so an unterminated OSC cannot block | ||
| 123 | // unrelated input forever. | 135 | // unrelated input forever. | ||
| 124 | - | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) { | 136 | + | func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onPalette func(slot int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) { |
| 125 | if len(s.pending) > 0 { | 137 | if len(s.pending) > 0 { | ||
| 126 | combined := make([]byte, 0, len(s.pending)+len(data)) | 138 | combined := make([]byte, 0, len(s.pending)+len(data)) | ||
| 127 | combined = append(combined, s.pending...) | 139 | combined = append(combined, s.pending...) | ||
| @@ -152,7 +164,7 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||||
| 152 | return | 164 | return | ||
| 153 | } | 165 | } | ||
| 154 | 166 | ||||
| 155 | - | completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) | 167 | + | kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:]) |
| 156 | if !completePrefix { | 168 | if !completePrefix { | ||
| 157 | if possiblePrefix { | 169 | if possiblePrefix { | ||
| 158 | if byteStart < i { | 170 | if byteStart < i { | ||
| @@ -173,20 +185,30 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on | |||||
| 173 | onBytes(data[byteStart:i]) | 185 | onBytes(data[byteStart:i]) | ||
| 174 | } | 186 | } | ||
| 175 | 187 | ||||
| 176 | - | termStart, termEnd := findTerminator(data[i+5:]) | 188 | + | termStart, termEnd := findTerminator(data[i+prefixLen:]) |
| 177 | if termStart < 0 { | 189 | if termStart < 0 { | ||
| 178 | s.bufferOrFlush(data[i:], onBytes) | 190 | s.bufferOrFlush(data[i:], onBytes) | ||
| 179 | return | 191 | return | ||
| 180 | } | 192 | } | ||
| 181 | - | termStart += i + 5 | 193 | + | termStart += i + prefixLen |
| 182 | - | termEnd += i + 5 | 194 | + | termEnd += i + prefixLen |
| 183 | raw := data[i:termEnd] | 195 | raw := data[i:termEnd] | ||
| 184 | - | kind := int(data[i+3]-'0') + 10 | 196 | + | body := string(data[i+prefixLen : termStart]) |
| 185 | - | rgb, ok := ParseXColor(string(data[i+5 : termStart])) | 197 | + | switch kind { |
| 186 | - | if ok { | 198 | + | case 4: |
| 187 | - | onColor(kind, rgb) | 199 | + | slot, rgb, ok := parsePaletteResponse(body) |
| 188 | - | } else { | 200 | + | if ok { |
| 189 | - | onBytes(raw) | 201 | + | onPalette(slot, rgb) |
| 202 | + | } else { | |||
| 203 | + | onBytes(raw) | |||
| 204 | + | } | |||
| 205 | + | default: | |||
| 206 | + | rgb, ok := ParseXColor(body) | |||
| 207 | + | if ok { | |||
| 208 | + | onColor(kind, rgb) | |||
| 209 | + | } else { | |||
| 210 | + | onBytes(raw) | |||
| 211 | + | } | |||
| 190 | } | 212 | } | ||
| 191 | i = termEnd - 1 | 213 | i = termEnd - 1 | ||
| 192 | byteStart = termEnd | 214 | byteStart = termEnd | ||
| @@ -206,23 +228,52 @@ func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) { | |||||
| 206 | s.pending = append(s.pending, partial...) | 228 | s.pending = append(s.pending, partial...) | ||
| 207 | } | 229 | } | ||
| 208 | 230 | ||||
| 209 | - | func colorOSCPrefix(data []byte) (complete bool, possible bool) { | 231 | + | func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) { |
| 210 | if len(data) < 2 { | 232 | if len(data) < 2 { | ||
| 211 | - | return false, false | 233 | + | return 0, 0, false, false |
| 212 | - | } | 234 | + | } |
| 213 | - | prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")} | 235 | + | prefixes := []struct { |
| 214 | - | for _, prefix := range prefixes { | 236 | + | kind int |
| 215 | - | if len(data) >= len(prefix) { | 237 | + | prefix []byte |
| 216 | - | if bytes.Equal(data[:len(prefix)], prefix) { | 238 | + | }{ |
| 217 | - | return true, true | 239 | + | {kind: 10, prefix: []byte("\x1b]10;")}, |
| 240 | + | {kind: 11, prefix: []byte("\x1b]11;")}, | |||
| 241 | + | {kind: 4, prefix: []byte("\x1b]4;")}, | |||
| 242 | + | } | |||
| 243 | + | for _, candidate := range prefixes { | |||
| 244 | + | if len(data) >= len(candidate.prefix) { | |||
| 245 | + | if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) { | |||
| 246 | + | return candidate.kind, len(candidate.prefix), true, true | |||
| 218 | } | 247 | } | ||
| 219 | continue | 248 | continue | ||
| 220 | } | 249 | } | ||
| 221 | - | if bytes.Equal(data, prefix[:len(data)]) { | 250 | + | if bytes.Equal(data, candidate.prefix[:len(data)]) { |
| 222 | - | return false, true | 251 | + | return candidate.kind, len(candidate.prefix), false, true |
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | return 0, 0, false, false | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func parsePaletteResponse(body string) (int, renderer.RGB, bool) { | |||
| 258 | + | indexEnd := strings.IndexByte(body, ';') | |||
| 259 | + | if indexEnd < 1 { | |||
| 260 | + | return 0, renderer.RGB{}, false | |||
| 261 | + | } | |||
| 262 | + | slot := 0 | |||
| 263 | + | for _, b := range []byte(body[:indexEnd]) { | |||
| 264 | + | if b < '0' || b > '9' { | |||
| 265 | + | return 0, renderer.RGB{}, false | |||
| 266 | + | } | |||
| 267 | + | slot = slot*10 + int(b-'0') | |||
| 268 | + | if slot > 15 { | |||
| 269 | + | return 0, renderer.RGB{}, false | |||
| 223 | } | 270 | } | ||
| 224 | } | 271 | } | ||
| 225 | - | return false, false | 272 | + | rgb, ok := ParseXColor(body[indexEnd+1:]) |
| 273 | + | if !ok { | |||
| 274 | + | return 0, renderer.RGB{}, false | |||
| 275 | + | } | |||
| 276 | + | return slot, rgb, true | |||
| 226 | } | 277 | } | ||
| 227 | 278 | ||||
| 228 | // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev | 279 | // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev | ||
| @@ -305,6 +356,13 @@ func StatusBarStyle(t Theme) renderer.Style { | |||||
| 305 | return style | 356 | return style | ||
| 306 | } | 357 | } | ||
| 307 | 358 | ||||
| 359 | + | const ( | |||
| 360 | + | ansiBlue = 4 | |||
| 361 | + | ansiBrightBlue = 12 | |||
| 362 | + | accentBlend = 0.25 | |||
| 363 | + | titleHueBlend = 0.2 | |||
| 364 | + | ) | |||
| 365 | + | ||||
| 308 | func AccentStyle(t Theme) renderer.Style { | 366 | func AccentStyle(t Theme) renderer.Style { | ||
| 309 | if !usable(t) { | 367 | if !usable(t) { | ||
| 310 | return inverseStyle() | 368 | return inverseStyle() | ||
| @@ -313,28 +371,69 @@ func AccentStyle(t Theme) renderer.Style { | |||||
| 313 | style.HasForegroundRGB = true | 371 | style.HasForegroundRGB = true | ||
| 314 | style.ForegroundRGB = t.Foreground | 372 | style.ForegroundRGB = t.Foreground | ||
| 315 | style.HasBackgroundRGB = true | 373 | style.HasBackgroundRGB = true | ||
| 316 | - | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25) | 374 | + | style.BackgroundRGB = accentBackground(t) |
| 317 | return style | 375 | return style | ||
| 318 | } | 376 | } | ||
| 319 | 377 | ||||
| 378 | + | // accentBackground uses a terminal's blue palette only when the resulting | |||
| 379 | + | // foreground/background pair remains legible. A neutral blend is always the | |||
| 380 | + | // fallback, including when palette inheritance is disabled. | |||
| 381 | + | func accentBackground(t Theme) renderer.RGB { | |||
| 382 | + | neutral := Blend(t.Background, t.Foreground, accentBlend) | |||
| 383 | + | if !t.UsePalette { | |||
| 384 | + | return neutral | |||
| 385 | + | } | |||
| 386 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |||
| 387 | + | blue, ok := t.PaletteColor(slot) | |||
| 388 | + | if !ok { | |||
| 389 | + | continue | |||
| 390 | + | } | |||
| 391 | + | candidate := Blend(t.Background, blue, accentBlend) | |||
| 392 | + | if ContrastRatio(t.Foreground, candidate) >= accentContrastMin { | |||
| 393 | + | return candidate | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | return neutral | |||
| 397 | + | } | |||
| 398 | + | ||||
| 320 | func BorderStyle(t Theme) renderer.Style { | 399 | func BorderStyle(t Theme) renderer.Style { | ||
| 321 | - | if !usable(t) { | 400 | + | neutral := renderer.DefaultStyle() |
| 322 | - | return renderer.DefaultStyle() | 401 | + | if usable(t) { |
| 402 | + | neutral.HasForegroundRGB = true | |||
| 403 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |||
| 323 | } | 404 | } | ||
| 324 | - | style := renderer.DefaultStyle() | 405 | + | if !t.UsePalette { |
| 325 | - | style.HasForegroundRGB = true | 406 | + | return neutral |
| 326 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | 407 | + | } |
| 327 | - | return style | 408 | + | |
| 409 | + | base := renderer.DefaultStyle() | |||
| 410 | + | if usable(t) { | |||
| 411 | + | base.HasForegroundRGB = true | |||
| 412 | + | base.ForegroundRGB = t.Foreground | |||
| 413 | + | base.HasBackgroundRGB = true | |||
| 414 | + | base.BackgroundRGB = t.Background | |||
| 415 | + | } | |||
| 416 | + | return paletteForegroundStyle(base, neutral, t) | |||
| 328 | } | 417 | } | ||
| 329 | 418 | ||||
| 330 | func MutedTextStyle(t Theme) renderer.Style { | 419 | func MutedTextStyle(t Theme) renderer.Style { | ||
| 331 | - | if !usable(t) { | 420 | + | neutral := renderer.DefaultStyle() |
| 332 | - | return renderer.DefaultStyle() | 421 | + | if usable(t) { |
| 422 | + | neutral.HasForegroundRGB = true | |||
| 423 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |||
| 333 | } | 424 | } | ||
| 334 | - | style := renderer.DefaultStyle() | 425 | + | if !t.UsePalette { |
| 335 | - | style.HasForegroundRGB = true | 426 | + | return neutral |
| 336 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | 427 | + | } |
| 337 | - | return style | 428 | + | |
| 429 | + | base := renderer.DefaultStyle() | |||
| 430 | + | if usable(t) { | |||
| 431 | + | base.HasForegroundRGB = true | |||
| 432 | + | base.ForegroundRGB = t.Foreground | |||
| 433 | + | base.HasBackgroundRGB = true | |||
| 434 | + | base.BackgroundRGB = t.Background | |||
| 435 | + | } | |||
| 436 | + | return paletteForegroundStyle(base, neutral, t) | |||
| 338 | } | 437 | } | ||
| 339 | 438 | ||||
| 340 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | 439 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | ||
| @@ -373,6 +472,61 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||||
| 373 | return out | 472 | return out | ||
| 374 | } | 473 | } | ||
| 375 | 474 | ||||
| 475 | + | // PaletteMutedVariantStyle derives a title foreground from a terminal blue | |||
| 476 | + | // palette entry. Its contrast is evaluated against the title's actual base | |||
| 477 | + | // background, rather than the terminal background, because status and active | |||
| 478 | + | // title backgrounds differ. | |||
| 479 | + | func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 480 | + | return paletteForegroundStyle(base, MutedVariantStyle(base, t), t) | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | // paletteForegroundStyle uses palette blues for a foreground only. It never | |||
| 484 | + | // changes a background, so palette inheritance cannot introduce indexed | |||
| 485 | + | // backgrounds. If terminal RGB data is unavailable, a known color scheme can | |||
| 486 | + | // still select a best-effort ANSI foreground. A reported blue that fails | |||
| 487 | + | // contrast deliberately suppresses that indexed fallback. | |||
| 488 | + | func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style { | |||
| 489 | + | if !t.UsePalette { | |||
| 490 | + | return neutral | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | knownBlue := paletteBlueKnown(t) | |||
| 494 | + | if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB { | |||
| 495 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |||
| 496 | + | blue, ok := t.PaletteColor(slot) | |||
| 497 | + | if !ok { | |||
| 498 | + | continue | |||
| 499 | + | } | |||
| 500 | + | candidate := Blend(base.ForegroundRGB, blue, titleHueBlend) | |||
| 501 | + | if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin { | |||
| 502 | + | out := neutral | |||
| 503 | + | out.HasForegroundRGB = true | |||
| 504 | + | out.ForegroundRGB = candidate | |||
| 505 | + | return out | |||
| 506 | + | } | |||
| 507 | + | } | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | if !knownBlue && t.SchemeKnown { | |||
| 511 | + | out := neutral | |||
| 512 | + | out.HasForegroundRGB = false | |||
| 513 | + | if t.Light { | |||
| 514 | + | out.Foreground = ansiBlue | |||
| 515 | + | } else { | |||
| 516 | + | out.Foreground = ansiBrightBlue | |||
| 517 | + | } | |||
| 518 | + | return out | |||
| 519 | + | } | |||
| 520 | + | return neutral | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | func paletteBlueKnown(t Theme) bool { | |||
| 524 | + | if !t.UsePalette { | |||
| 525 | + | return false | |||
| 526 | + | } | |||
| 527 | + | return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0 | |||
| 528 | + | } | |||
| 529 | + | ||||
| 376 | const defaultDimmingPercent = 35 | 530 | const defaultDimmingPercent = 35 | ||
| 377 | 531 | ||||
| 378 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | 532 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | ||
M internal/usecase/theme/theme_test.go +20 -15
| @@ -16,10 +16,15 @@ func TestParseXColor(t *testing.T) { | |||
| 16 | 16 | want renderer.RGB | |
| 17 | 17 | ok bool | |
| 18 | 18 | }{ | |
| 19 | - | {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | |
| 19 | + | {name: "mixed component widths scale independently", in: "rgb:1/24/86", want: renderer.RGB{R: 0x11, G: 0x24, B: 0x86}, ok: true}, | |
| 20 | + | {name: "one, three, and four digit components", in: "rgb:f/abc/1234", want: renderer.RGB{R: 0xff, G: 0xab, B: 0x12}, ok: true}, | |
| 20 | 21 | {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | |
| 21 | 22 | {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true}, | |
| 22 | - | {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false}, | |
| 23 | + | {name: "empty component rejected", in: "rgb:/24/86", ok: false}, | |
| 24 | + | {name: "too-wide component rejected", in: "rgb:12345/24/86", ok: false}, | |
| 25 | + | {name: "non-hex component rejected", in: "rgb:1/2g/86", ok: false}, | |
| 26 | + | {name: "missing component rejected", in: "rgb:1/24", ok: false}, | |
| 27 | + | {name: "extra component rejected", in: "rgb:1/24/86/ff", ok: false}, | |
| 23 | 28 | {name: "garbage rejected", in: "not-a-color", ok: false}, | |
| 24 | 29 | {name: "bad hex rejected", in: "#xyzxyz", ok: false}, | |
| 25 | 30 | } | |
| @@ -50,7 +55,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) { | |||
| 50 | 55 | kind int | |
| 51 | 56 | rgb renderer.RGB | |
| 52 | 57 | }{kind: kind, rgb: rgb}) | |
| 53 | - | }, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 58 | + | }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 54 | 59 | ||
| 55 | 60 | if out.String() != "abc" { | |
| 56 | 61 | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | |
| @@ -73,8 +78,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) { | |||
| 73 | 78 | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | |
| 74 | 79 | onBytes := func(b []byte) { out.Write(b) } | |
| 75 | 80 | ||
| 76 | - | s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes) | |
| 77 | - | s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes) | |
| 81 | + | s.Scan([]byte("x\x1b]10;#12"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes) | |
| 82 | + | s.Scan([]byte("3456\x07y"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes) | |
| 78 | 83 | ||
| 79 | 84 | if out.String() != "xy" { | |
| 80 | 85 | t.Fatalf("passthrough=%q want xy", out.String()) | |
| @@ -112,7 +117,7 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) { | |||
| 112 | 117 | var out bytes.Buffer | |
| 113 | 118 | ||
| 114 | 119 | for _, chunk := range tt.chunks { | |
| 115 | - | s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) | |
| 120 | + | s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) | |
| 116 | 121 | } | |
| 117 | 122 | ||
| 118 | 123 | if out.String() != tt.wantOut { | |
| @@ -130,7 +135,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) { | |||
| 130 | 135 | var out bytes.Buffer | |
| 131 | 136 | schemes := 0 | |
| 132 | 137 | ||
| 133 | - | s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |
| 138 | + | s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |
| 134 | 139 | ||
| 135 | 140 | if out.String() != "a\x1b[" { | |
| 136 | 141 | t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[") | |
| @@ -140,7 +145,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) { | |||
| 140 | 145 | } | |
| 141 | 146 | ||
| 142 | 147 | out.Reset() | |
| 143 | - | s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |
| 148 | + | s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |
| 144 | 149 | ||
| 145 | 150 | if out.String() != "A" { | |
| 146 | 151 | t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A") | |
| @@ -152,7 +157,7 @@ func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | |||
| 152 | 157 | var out bytes.Buffer | |
| 153 | 158 | colors := 0 | |
| 154 | 159 | ||
| 155 | - | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 160 | + | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 156 | 161 | ||
| 157 | 162 | if colors != 0 { | |
| 158 | 163 | t.Fatalf("colors=%d want 0", colors) | |
| @@ -168,7 +173,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |||
| 168 | 173 | var out bytes.Buffer | |
| 169 | 174 | colors := 0 | |
| 170 | 175 | ||
| 171 | - | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 176 | + | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 172 | 177 | ||
| 173 | 178 | if colors != 0 { | |
| 174 | 179 | t.Fatalf("colors=%d want 0", colors) | |
| @@ -184,7 +189,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) { | |||
| 184 | 189 | colors := 0 | |
| 185 | 190 | ||
| 186 | 191 | in := []byte("\x1b[<0;1;1M") | |
| 187 | - | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { | |
| 192 | + | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { | |
| 188 | 193 | chunks = append(chunks, append([]byte(nil), b...)) | |
| 189 | 194 | }) | |
| 190 | 195 | ||
| @@ -204,7 +209,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) { | |||
| 204 | 209 | var chunks [][]byte | |
| 205 | 210 | ||
| 206 | 211 | in := []byte("\x1b[A") | |
| 207 | - | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { | |
| 212 | + | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { | |
| 208 | 213 | chunks = append(chunks, append([]byte(nil), b...)) | |
| 209 | 214 | }) | |
| 210 | 215 | ||
| @@ -229,7 +234,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) { | |||
| 229 | 234 | kind int | |
| 230 | 235 | rgb renderer.RGB | |
| 231 | 236 | }{kind: kind, rgb: rgb}) | |
| 232 | - | }, func(light bool) {}, func(b []byte) { | |
| 237 | + | }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { | |
| 233 | 238 | chunks = append(chunks, append([]byte(nil), b...)) | |
| 234 | 239 | }) | |
| 235 | 240 | ||
| @@ -254,8 +259,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | |||
| 254 | 259 | var s Scanner | |
| 255 | 260 | var out bytes.Buffer | |
| 256 | 261 | ||
| 257 | - | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 258 | - | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 262 | + | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 263 | + | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | |
| 259 | 264 | ||
| 260 | 265 | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | |
| 261 | 266 | t.Fatalf("overflow passthrough=%q", out.String()) | |
| @@ -16,10 +16,15 @@ func TestParseXColor(t *testing.T) { | |||||
| 16 | want renderer.RGB | 16 | want renderer.RGB | ||
| 17 | ok bool | 17 | ok bool | ||
| 18 | }{ | 18 | }{ | ||
| 19 | - | {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | 19 | + | {name: "mixed component widths scale independently", in: "rgb:1/24/86", want: renderer.RGB{R: 0x11, G: 0x24, B: 0x86}, ok: true}, |
| 20 | + | {name: "one, three, and four digit components", in: "rgb:f/abc/1234", want: renderer.RGB{R: 0xff, G: 0xab, B: 0x12}, ok: true}, | |||
| 20 | {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | 21 | {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true}, | ||
| 21 | {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true}, | 22 | {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true}, | ||
| 22 | - | {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false}, | 23 | + | {name: "empty component rejected", in: "rgb:/24/86", ok: false}, |
| 24 | + | {name: "too-wide component rejected", in: "rgb:12345/24/86", ok: false}, | |||
| 25 | + | {name: "non-hex component rejected", in: "rgb:1/2g/86", ok: false}, | |||
| 26 | + | {name: "missing component rejected", in: "rgb:1/24", ok: false}, | |||
| 27 | + | {name: "extra component rejected", in: "rgb:1/24/86/ff", ok: false}, | |||
| 23 | {name: "garbage rejected", in: "not-a-color", ok: false}, | 28 | {name: "garbage rejected", in: "not-a-color", ok: false}, | ||
| 24 | {name: "bad hex rejected", in: "#xyzxyz", ok: false}, | 29 | {name: "bad hex rejected", in: "#xyzxyz", ok: false}, | ||
| 25 | } | 30 | } | ||
| @@ -50,7 +55,7 @@ func TestScannerExtractsBELAndSTColors(t *testing.T) { | |||||
| 50 | kind int | 55 | kind int | ||
| 51 | rgb renderer.RGB | 56 | rgb renderer.RGB | ||
| 52 | }{kind: kind, rgb: rgb}) | 57 | }{kind: kind, rgb: rgb}) | ||
| 53 | - | }, func(light bool) {}, func(b []byte) { out.Write(b) }) | 58 | + | }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 54 | 59 | ||||
| 55 | if out.String() != "abc" { | 60 | if out.String() != "abc" { | ||
| 56 | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | 61 | t.Fatalf("passthrough=%q want %q", out.String(), "abc") | ||
| @@ -73,8 +78,8 @@ func TestScannerHandlesSplitSequences(t *testing.T) { | |||||
| 73 | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | 78 | onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) } | ||
| 74 | onBytes := func(b []byte) { out.Write(b) } | 79 | onBytes := func(b []byte) { out.Write(b) } | ||
| 75 | 80 | ||||
| 76 | - | s.Scan([]byte("x\x1b]10;#12"), onColor, func(light bool) {}, onBytes) | 81 | + | s.Scan([]byte("x\x1b]10;#12"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes) |
| 77 | - | s.Scan([]byte("3456\x07y"), onColor, func(light bool) {}, onBytes) | 82 | + | s.Scan([]byte("3456\x07y"), onColor, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, onBytes) |
| 78 | 83 | ||||
| 79 | if out.String() != "xy" { | 84 | if out.String() != "xy" { | ||
| 80 | t.Fatalf("passthrough=%q want xy", out.String()) | 85 | t.Fatalf("passthrough=%q want xy", out.String()) | ||
| @@ -112,7 +117,7 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) { | |||||
| 112 | var out bytes.Buffer | 117 | var out bytes.Buffer | ||
| 113 | 118 | ||||
| 114 | for _, chunk := range tt.chunks { | 119 | for _, chunk := range tt.chunks { | ||
| 115 | - | s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) | 120 | + | s.Scan([]byte(chunk), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes = append(schemes, light) }, func(b []byte) { out.Write(b) }) |
| 116 | } | 121 | } | ||
| 117 | 122 | ||||
| 118 | if out.String() != tt.wantOut { | 123 | if out.String() != tt.wantOut { | ||
| @@ -130,7 +135,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) { | |||||
| 130 | var out bytes.Buffer | 135 | var out bytes.Buffer | ||
| 131 | schemes := 0 | 136 | schemes := 0 | ||
| 132 | 137 | ||||
| 133 | - | s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | 138 | + | s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) |
| 134 | 139 | ||||
| 135 | if out.String() != "a\x1b[" { | 140 | if out.String() != "a\x1b[" { | ||
| 136 | t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[") | 141 | t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[") | ||
| @@ -140,7 +145,7 @@ func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) { | |||||
| 140 | } | 145 | } | ||
| 141 | 146 | ||||
| 142 | out.Reset() | 147 | out.Reset() | ||
| 143 | - | s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | 148 | + | s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) |
| 144 | 149 | ||||
| 145 | if out.String() != "A" { | 150 | if out.String() != "A" { | ||
| 146 | t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A") | 151 | t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A") | ||
| @@ -152,7 +157,7 @@ func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | |||||
| 152 | var out bytes.Buffer | 157 | var out bytes.Buffer | ||
| 153 | colors := 0 | 158 | colors := 0 | ||
| 154 | 159 | ||||
| 155 | - | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) | 160 | + | s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 156 | 161 | ||||
| 157 | if colors != 0 { | 162 | if colors != 0 { | ||
| 158 | t.Fatalf("colors=%d want 0", colors) | 163 | t.Fatalf("colors=%d want 0", colors) | ||
| @@ -168,7 +173,7 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) { | |||||
| 168 | var out bytes.Buffer | 173 | var out bytes.Buffer | ||
| 169 | colors := 0 | 174 | colors := 0 | ||
| 170 | 175 | ||||
| 171 | - | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { out.Write(b) }) | 176 | + | s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 172 | 177 | ||||
| 173 | if colors != 0 { | 178 | if colors != 0 { | ||
| 174 | t.Fatalf("colors=%d want 0", colors) | 179 | t.Fatalf("colors=%d want 0", colors) | ||
| @@ -184,7 +189,7 @@ func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) { | |||||
| 184 | colors := 0 | 189 | colors := 0 | ||
| 185 | 190 | ||||
| 186 | in := []byte("\x1b[<0;1;1M") | 191 | in := []byte("\x1b[<0;1;1M") | ||
| 187 | - | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(light bool) {}, func(b []byte) { | 192 | + | s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { |
| 188 | chunks = append(chunks, append([]byte(nil), b...)) | 193 | chunks = append(chunks, append([]byte(nil), b...)) | ||
| 189 | }) | 194 | }) | ||
| 190 | 195 | ||||
| @@ -204,7 +209,7 @@ func TestScannerDoesNotSplitArrowKey(t *testing.T) { | |||||
| 204 | var chunks [][]byte | 209 | var chunks [][]byte | ||
| 205 | 210 | ||||
| 206 | in := []byte("\x1b[A") | 211 | in := []byte("\x1b[A") | ||
| 207 | - | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { | 212 | + | s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { |
| 208 | chunks = append(chunks, append([]byte(nil), b...)) | 213 | chunks = append(chunks, append([]byte(nil), b...)) | ||
| 209 | }) | 214 | }) | ||
| 210 | 215 | ||||
| @@ -229,7 +234,7 @@ func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) { | |||||
| 229 | kind int | 234 | kind int | ||
| 230 | rgb renderer.RGB | 235 | rgb renderer.RGB | ||
| 231 | }{kind: kind, rgb: rgb}) | 236 | }{kind: kind, rgb: rgb}) | ||
| 232 | - | }, func(light bool) {}, func(b []byte) { | 237 | + | }, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { |
| 233 | chunks = append(chunks, append([]byte(nil), b...)) | 238 | chunks = append(chunks, append([]byte(nil), b...)) | ||
| 234 | }) | 239 | }) | ||
| 235 | 240 | ||||
| @@ -254,8 +259,8 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) { | |||||
| 254 | var s Scanner | 259 | var s Scanner | ||
| 255 | var out bytes.Buffer | 260 | var out bytes.Buffer | ||
| 256 | 261 | ||||
| 257 | - | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | 262 | + | s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 258 | - | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) | 263 | + | s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(slot int, rgb renderer.RGB) {}, func(light bool) {}, func(b []byte) { out.Write(b) }) |
| 259 | 264 | ||||
| 260 | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | 265 | if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) { | ||
| 261 | t.Fatalf("overflow passthrough=%q", out.String()) | 266 | t.Fatalf("overflow passthrough=%q", out.String()) | ||