fix: address review findings on config live reload and theme propagation
- fix race between the stdin pump's OSC color re-query and the main loop's terminal writes by routing QueryColors through the main loop - fix theme scanner buffering a bare ESC [ input forever instead of forwarding it immediately - fix command code override resolution depending on slug sort order by resolving to a duplicate-free fixpoint - fix a replacement client inheriting the previous client's stale color scheme by always clearing it on attach - clear vt screen's DEC mode 2031 subscription on RIS (reset) - make keys.NewRouter's bindings pointer an explicit parameter instead of a variadic - remove the dead Daemon.commandByCode indirection seam - make domain.Config.BindingEntries the single source of truth for bindings, dropping the redundant Bindings map
- commit
- fead0479a120719d19aaa142decd1a22a8581e12
- author
- brice · · 2026-07-05 17:22 UTC
- parent
- dc993a0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +237 -111
M internal/adapters/config/config.go +3 -2
| @@ -22,6 +22,7 @@ const pollInterval = 2 * time.Second | |||
| 22 | 22 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |
| 23 | 23 | cfg := domain.Defaults() | |
| 24 | 24 | var warnings []domain.Warning | |
| 25 | + | seenBindingKeys := make(map[string]bool) | |
| 25 | 26 | ||
| 26 | 27 | scanner := bufio.NewScanner(r) | |
| 27 | 28 | lineNo := 0 | |
| @@ -67,13 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 67 | 68 | } | |
| 68 | 69 | cfg.Codes[codeKey] = value | |
| 69 | 70 | default: | |
| 70 | - | if _, exists := cfg.Bindings[key]; exists { | |
| 71 | + | if seenBindingKeys[key] { | |
| 71 | 72 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| 72 | 73 | updateBindingEntry(cfg.BindingEntries, key, value) | |
| 73 | 74 | } else { | |
| 75 | + | seenBindingKeys[key] = true | |
| 74 | 76 | cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value}) | |
| 75 | 77 | } | |
| 76 | - | cfg.Bindings[key] = value | |
| 77 | 78 | } | |
| 78 | 79 | } | |
| 79 | 80 | if err := scanner.Err(); err != nil { | |
| @@ -22,6 +22,7 @@ const pollInterval = 2 * time.Second | |||||
| 22 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | 22 | func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | ||
| 23 | cfg := domain.Defaults() | 23 | cfg := domain.Defaults() | ||
| 24 | var warnings []domain.Warning | 24 | var warnings []domain.Warning | ||
| 25 | + | seenBindingKeys := make(map[string]bool) | |||
| 25 | 26 | ||||
| 26 | scanner := bufio.NewScanner(r) | 27 | scanner := bufio.NewScanner(r) | ||
| 27 | lineNo := 0 | 28 | lineNo := 0 | ||
| @@ -67,13 +68,13 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 67 | } | 68 | } | ||
| 68 | cfg.Codes[codeKey] = value | 69 | cfg.Codes[codeKey] = value | ||
| 69 | default: | 70 | default: | ||
| 70 | - | if _, exists := cfg.Bindings[key]; exists { | 71 | + | if seenBindingKeys[key] { |
| 71 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | 72 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | ||
| 72 | updateBindingEntry(cfg.BindingEntries, key, value) | 73 | updateBindingEntry(cfg.BindingEntries, key, value) | ||
| 73 | } else { | 74 | } else { | ||
| 75 | + | seenBindingKeys[key] = true | |||
| 74 | cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value}) | 76 | cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value}) | ||
| 75 | } | 77 | } | ||
| 76 | - | cfg.Bindings[key] = value | |||
| 77 | } | 78 | } | ||
| 78 | } | 79 | } | ||
| 79 | if err := scanner.Err(); err != nil { | 80 | if err := scanner.Err(); err != nil { | ||
M internal/adapters/config/config_test.go +0 -9
| @@ -30,10 +30,6 @@ func TestParse(t *testing.T) { | |||
| 30 | 30 | input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n", | |
| 31 | 31 | want: domain.Config{ | |
| 32 | 32 | Theme: domain.ThemeAuto, | |
| 33 | - | Bindings: map[string]string{ | |
| 34 | - | "new-tab": "alt+t", | |
| 35 | - | "detach": "alt+#", | |
| 36 | - | }, | |
| 37 | 33 | BindingEntries: []domain.ConfigEntry{ | |
| 38 | 34 | {Key: "new-tab", Value: "alt+t"}, | |
| 39 | 35 | {Key: "detach", Value: "alt+#"}, | |
| @@ -46,7 +42,6 @@ func TestParse(t *testing.T) { | |||
| 46 | 42 | input: "theme = light\r\ncode.detach = dt\r\n", | |
| 47 | 43 | want: domain.Config{ | |
| 48 | 44 | Theme: domain.ThemeLight, | |
| 49 | - | Bindings: map[string]string{}, | |
| 50 | 45 | BindingEntries: []domain.ConfigEntry{}, | |
| 51 | 46 | Codes: map[string]string{"detach": "dt"}, | |
| 52 | 47 | }, | |
| @@ -56,7 +51,6 @@ func TestParse(t *testing.T) { | |||
| 56 | 51 | input: "theme = blue\n", | |
| 57 | 52 | want: domain.Config{ | |
| 58 | 53 | Theme: domain.ThemeAuto, | |
| 59 | - | Bindings: map[string]string{}, | |
| 60 | 54 | BindingEntries: []domain.ConfigEntry{}, | |
| 61 | 55 | Codes: map[string]string{}, | |
| 62 | 56 | }, | |
| @@ -67,7 +61,6 @@ func TestParse(t *testing.T) { | |||
| 67 | 61 | input: "unknown.action = alt+x\n", | |
| 68 | 62 | want: domain.Config{ | |
| 69 | 63 | Theme: domain.ThemeAuto, | |
| 70 | - | Bindings: map[string]string{"unknown.action": "alt+x"}, | |
| 71 | 64 | BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}}, | |
| 72 | 65 | Codes: map[string]string{}, | |
| 73 | 66 | }, | |
| @@ -77,7 +70,6 @@ func TestParse(t *testing.T) { | |||
| 77 | 70 | input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n", | |
| 78 | 71 | want: domain.Config{ | |
| 79 | 72 | Theme: domain.ThemeAuto, | |
| 80 | - | Bindings: map[string]string{"new-tab": "alt+n"}, | |
| 81 | 73 | BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}}, | |
| 82 | 74 | Codes: map[string]string{"detach": "DX"}, | |
| 83 | 75 | }, | |
| @@ -91,7 +83,6 @@ func TestParse(t *testing.T) { | |||
| 91 | 83 | input: "\nno equals\n = value\ntheme = dark\n", | |
| 92 | 84 | want: domain.Config{ | |
| 93 | 85 | Theme: domain.ThemeDark, | |
| 94 | - | Bindings: map[string]string{}, | |
| 95 | 86 | BindingEntries: []domain.ConfigEntry{}, | |
| 96 | 87 | Codes: map[string]string{}, | |
| 97 | 88 | }, | |
| @@ -30,10 +30,6 @@ func TestParse(t *testing.T) { | |||||
| 30 | input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n", | 30 | input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n", | ||
| 31 | want: domain.Config{ | 31 | want: domain.Config{ | ||
| 32 | Theme: domain.ThemeAuto, | 32 | Theme: domain.ThemeAuto, | ||
| 33 | - | Bindings: map[string]string{ | |||
| 34 | - | "new-tab": "alt+t", | |||
| 35 | - | "detach": "alt+#", | |||
| 36 | - | }, | |||
| 37 | BindingEntries: []domain.ConfigEntry{ | 33 | BindingEntries: []domain.ConfigEntry{ | ||
| 38 | {Key: "new-tab", Value: "alt+t"}, | 34 | {Key: "new-tab", Value: "alt+t"}, | ||
| 39 | {Key: "detach", Value: "alt+#"}, | 35 | {Key: "detach", Value: "alt+#"}, | ||
| @@ -46,7 +42,6 @@ func TestParse(t *testing.T) { | |||||
| 46 | input: "theme = light\r\ncode.detach = dt\r\n", | 42 | input: "theme = light\r\ncode.detach = dt\r\n", | ||
| 47 | want: domain.Config{ | 43 | want: domain.Config{ | ||
| 48 | Theme: domain.ThemeLight, | 44 | Theme: domain.ThemeLight, | ||
| 49 | - | Bindings: map[string]string{}, | |||
| 50 | BindingEntries: []domain.ConfigEntry{}, | 45 | BindingEntries: []domain.ConfigEntry{}, | ||
| 51 | Codes: map[string]string{"detach": "dt"}, | 46 | Codes: map[string]string{"detach": "dt"}, | ||
| 52 | }, | 47 | }, | ||
| @@ -56,7 +51,6 @@ func TestParse(t *testing.T) { | |||||
| 56 | input: "theme = blue\n", | 51 | input: "theme = blue\n", | ||
| 57 | want: domain.Config{ | 52 | want: domain.Config{ | ||
| 58 | Theme: domain.ThemeAuto, | 53 | Theme: domain.ThemeAuto, | ||
| 59 | - | Bindings: map[string]string{}, | |||
| 60 | BindingEntries: []domain.ConfigEntry{}, | 54 | BindingEntries: []domain.ConfigEntry{}, | ||
| 61 | Codes: map[string]string{}, | 55 | Codes: map[string]string{}, | ||
| 62 | }, | 56 | }, | ||
| @@ -67,7 +61,6 @@ func TestParse(t *testing.T) { | |||||
| 67 | input: "unknown.action = alt+x\n", | 61 | input: "unknown.action = alt+x\n", | ||
| 68 | want: domain.Config{ | 62 | want: domain.Config{ | ||
| 69 | Theme: domain.ThemeAuto, | 63 | Theme: domain.ThemeAuto, | ||
| 70 | - | Bindings: map[string]string{"unknown.action": "alt+x"}, | |||
| 71 | BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}}, | 64 | BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}}, | ||
| 72 | Codes: map[string]string{}, | 65 | Codes: map[string]string{}, | ||
| 73 | }, | 66 | }, | ||
| @@ -77,7 +70,6 @@ func TestParse(t *testing.T) { | |||||
| 77 | input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n", | 70 | input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n", | ||
| 78 | want: domain.Config{ | 71 | want: domain.Config{ | ||
| 79 | Theme: domain.ThemeAuto, | 72 | Theme: domain.ThemeAuto, | ||
| 80 | - | Bindings: map[string]string{"new-tab": "alt+n"}, | |||
| 81 | BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}}, | 73 | BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}}, | ||
| 82 | Codes: map[string]string{"detach": "DX"}, | 74 | Codes: map[string]string{"detach": "DX"}, | ||
| 83 | }, | 75 | }, | ||
| @@ -91,7 +83,6 @@ func TestParse(t *testing.T) { | |||||
| 91 | input: "\nno equals\n = value\ntheme = dark\n", | 83 | input: "\nno equals\n = value\ntheme = dark\n", | ||
| 92 | want: domain.Config{ | 84 | want: domain.Config{ | ||
| 93 | Theme: domain.ThemeDark, | 85 | Theme: domain.ThemeDark, | ||
| 94 | - | Bindings: map[string]string{}, | |||
| 95 | BindingEntries: []domain.ConfigEntry{}, | 86 | BindingEntries: []domain.ConfigEntry{}, | ||
| 96 | Codes: map[string]string{}, | 87 | Codes: map[string]string{}, | ||
| 97 | }, | 88 | }, | ||
M internal/domain/config.go +2 -4
| @@ -19,11 +19,10 @@ type ConfigEntry struct { | |||
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 22 | - | // keys are preserved here so the usecase layer can decide which actions it | |
| 23 | - | // understands. | |
| 22 | + | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 23 | + | // layer can decide which actions it understands. | |
| 24 | 24 | type Config struct { | |
| 25 | 25 | Theme ThemeMode | |
| 26 | - | Bindings map[string]string | |
| 27 | 26 | BindingEntries []ConfigEntry | |
| 28 | 27 | Codes map[string]string | |
| 29 | 28 | } | |
| @@ -39,7 +38,6 @@ type Warning struct { | |||
| 39 | 38 | func Defaults() Config { | |
| 40 | 39 | return Config{ | |
| 41 | 40 | Theme: ThemeAuto, | |
| 42 | - | Bindings: map[string]string{}, | |
| 43 | 41 | BindingEntries: []ConfigEntry{}, | |
| 44 | 42 | Codes: map[string]string{}, | |
| 45 | 43 | } | |
| @@ -19,11 +19,10 @@ type ConfigEntry struct { | |||||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | // Config is the user-editable vev configuration after parsing. Unknown binding | 21 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 22 | - | // keys are preserved here so the usecase layer can decide which actions it | 22 | + | // keys are preserved here (in BindingEntries, in file order) so the usecase |
| 23 | - | // understands. | 23 | + | // layer can decide which actions it understands. |
| 24 | type Config struct { | 24 | type Config struct { | ||
| 25 | Theme ThemeMode | 25 | Theme ThemeMode | ||
| 26 | - | Bindings map[string]string | |||
| 27 | BindingEntries []ConfigEntry | 26 | BindingEntries []ConfigEntry | ||
| 28 | Codes map[string]string | 27 | Codes map[string]string | ||
| 29 | } | 28 | } | ||
| @@ -39,7 +38,6 @@ type Warning struct { | |||||
| 39 | func Defaults() Config { | 38 | func Defaults() Config { | ||
| 40 | return Config{ | 39 | return Config{ | ||
| 41 | Theme: ThemeAuto, | 40 | Theme: ThemeAuto, | ||
| 42 | - | Bindings: map[string]string{}, | |||
| 43 | BindingEntries: []ConfigEntry{}, | 41 | BindingEntries: []ConfigEntry{}, | ||
| 44 | Codes: map[string]string{}, | 42 | Codes: map[string]string{}, | ||
| 45 | } | 43 | } | ||
M internal/usecase/client/client.go +21 -8
| @@ -343,12 +343,25 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 343 | 343 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 344 | 344 | sendErrCh := make(chan error, 1) | |
| 345 | 345 | ||
| 346 | + | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | |
| 347 | + | // the main loop, which is the only goroutine allowed to touch term.Out()/ | |
| 348 | + | // term.Flush(); QueryColors writes through the same batched writer, so | |
| 349 | + | // calling it directly from the stdin pump would race the main loop's | |
| 350 | + | // output writes. | |
| 351 | + | colorQueryCh := make(chan struct{}, 1) | |
| 352 | + | requestColors := func() { | |
| 353 | + | select { | |
| 354 | + | case colorQueryCh <- struct{}{}: | |
| 355 | + | default: | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 346 | 359 | var clip ports.ClipboardReader | |
| 347 | 360 | if remote { | |
| 348 | 361 | clip = clipboard | |
| 349 | 362 | } | |
| 350 | 363 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | |
| 351 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, term.QueryColors, clip, log) | |
| 364 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log) | |
| 352 | 365 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 353 | 366 | ||
| 354 | 367 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -366,6 +379,10 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 366 | 379 | default: | |
| 367 | 380 | return attachResult{resumeToken: resumeToken, welcomed: true} | |
| 368 | 381 | } | |
| 382 | + | case <-colorQueryCh: | |
| 383 | + | if err := term.QueryColors(); err != nil { | |
| 384 | + | log.Warn("querying terminal colors", "err", err) | |
| 385 | + | } | |
| 369 | 386 | case r := <-recvCh: | |
| 370 | 387 | if r.err != nil { | |
| 371 | 388 | if errors.Is(r.err, io.EOF) { | |
| @@ -472,7 +489,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 472 | 489 | // exits. That is harmless here — Attach has already returned and restored | |
| 473 | 490 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 474 | 491 | // closable stdin duplicate could lift it later if ever needed. | |
| 475 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, queryColors func() error, clipboard ports.ClipboardReader, log *slog.Logger) { | |
| 492 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) { | |
| 476 | 493 | defer log.Debug("stdin pump exited") | |
| 477 | 494 | buf := make([]byte, stdinBufSize) | |
| 478 | 495 | var scanner theme.Scanner | |
| @@ -540,12 +557,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 540 | 557 | }, func(light bool) { | |
| 541 | 558 | current.SchemeKnown = true | |
| 542 | 559 | current.Light = light | |
| 543 | - | if queryColors != nil { | |
| 544 | - | go func() { | |
| 545 | - | if err := queryColors(); err != nil { | |
| 546 | - | log.Warn("querying terminal colors", "err", err) | |
| 547 | - | } | |
| 548 | - | }() | |
| 560 | + | if requestColors != nil { | |
| 561 | + | requestColors() | |
| 549 | 562 | } | |
| 550 | 563 | sendTheme() | |
| 551 | 564 | }, sink) | |
| @@ -343,12 +343,25 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 343 | sendCh := make(chan ports.Frame, sendQueueDepth) | 343 | sendCh := make(chan ports.Frame, sendQueueDepth) | ||
| 344 | sendErrCh := make(chan error, 1) | 344 | sendErrCh := make(chan error, 1) | ||
| 345 | 345 | ||||
| 346 | + | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | |||
| 347 | + | // the main loop, which is the only goroutine allowed to touch term.Out()/ | |||
| 348 | + | // term.Flush(); QueryColors writes through the same batched writer, so | |||
| 349 | + | // calling it directly from the stdin pump would race the main loop's | |||
| 350 | + | // output writes. | |||
| 351 | + | colorQueryCh := make(chan struct{}, 1) | |||
| 352 | + | requestColors := func() { | |||
| 353 | + | select { | |||
| 354 | + | case colorQueryCh <- struct{}{}: | |||
| 355 | + | default: | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | ||||
| 346 | var clip ports.ClipboardReader | 359 | var clip ports.ClipboardReader | ||
| 347 | if remote { | 360 | if remote { | ||
| 348 | clip = clipboard | 361 | clip = clipboard | ||
| 349 | } | 362 | } | ||
| 350 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | 363 | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | ||
| 351 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, term.QueryColors, clip, log) | 364 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log) |
| 352 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | 365 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | ||
| 353 | 366 | ||||
| 354 | // 5. Output/main loop: the only goroutine that touches the terminal. | 367 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -366,6 +379,10 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 366 | default: | 379 | default: | ||
| 367 | return attachResult{resumeToken: resumeToken, welcomed: true} | 380 | return attachResult{resumeToken: resumeToken, welcomed: true} | ||
| 368 | } | 381 | } | ||
| 382 | + | case <-colorQueryCh: | |||
| 383 | + | if err := term.QueryColors(); err != nil { | |||
| 384 | + | log.Warn("querying terminal colors", "err", err) | |||
| 385 | + | } | |||
| 369 | case r := <-recvCh: | 386 | case r := <-recvCh: | ||
| 370 | if r.err != nil { | 387 | if r.err != nil { | ||
| 371 | if errors.Is(r.err, io.EOF) { | 388 | if errors.Is(r.err, io.EOF) { | ||
| @@ -472,7 +489,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 472 | // exits. That is harmless here — Attach has already returned and restored | 489 | // exits. That is harmless here — Attach has already returned and restored | ||
| 473 | // the terminal — and matches the standard pattern for stdin pumps; a | 490 | // the terminal — and matches the standard pattern for stdin pumps; a | ||
| 474 | // closable stdin duplicate could lift it later if ever needed. | 491 | // closable stdin duplicate could lift it later if ever needed. | ||
| 475 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, queryColors func() error, clipboard ports.ClipboardReader, log *slog.Logger) { | 492 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) { |
| 476 | defer log.Debug("stdin pump exited") | 493 | defer log.Debug("stdin pump exited") | ||
| 477 | buf := make([]byte, stdinBufSize) | 494 | buf := make([]byte, stdinBufSize) | ||
| 478 | var scanner theme.Scanner | 495 | var scanner theme.Scanner | ||
| @@ -540,12 +557,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 540 | }, func(light bool) { | 557 | }, func(light bool) { | ||
| 541 | current.SchemeKnown = true | 558 | current.SchemeKnown = true | ||
| 542 | current.Light = light | 559 | current.Light = light | ||
| 543 | - | if queryColors != nil { | 560 | + | if requestColors != nil { |
| 544 | - | go func() { | 561 | + | requestColors() |
| 545 | - | if err := queryColors(); err != nil { | |||
| 546 | - | log.Warn("querying terminal colors", "err", err) | |||
| 547 | - | } | |||
| 548 | - | }() | |||
| 549 | } | 562 | } | ||
| 550 | sendTheme() | 563 | sendTheme() | ||
| 551 | }, sink) | 564 | }, sink) | ||
M internal/usecase/daemon/attention_test.go +2 -2
| @@ -107,7 +107,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 107 | 107 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | |
| 108 | 108 | sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW} | |
| 109 | 109 | acW.setSession(sessW) | |
| 110 | - | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}) | |
| 110 | + | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | |
| 111 | 111 | d.sessions[sessW.id] = sessW | |
| 112 | 112 | ||
| 113 | 113 | done := make(chan struct{}) | |
| @@ -355,7 +355,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 355 | 355 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | |
| 356 | 356 | sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB} | |
| 357 | 357 | acB.setSession(sessB) | |
| 358 | - | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}) | |
| 358 | + | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | |
| 359 | 359 | d.sessions[sessB.id] = sessB | |
| 360 | 360 | ||
| 361 | 361 | d.paint(sessB, acB, true) | |
| @@ -107,7 +107,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||||
| 107 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | 107 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | ||
| 108 | sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW} | 108 | sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW} | ||
| 109 | acW.setSession(sessW) | 109 | acW.setSession(sessW) | ||
| 110 | - | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}) | 110 | + | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) |
| 111 | d.sessions[sessW.id] = sessW | 111 | d.sessions[sessW.id] = sessW | ||
| 112 | 112 | ||||
| 113 | done := make(chan struct{}) | 113 | done := make(chan struct{}) | ||
| @@ -355,7 +355,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 355 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | 355 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | ||
| 356 | sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB} | 356 | sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB} | ||
| 357 | acB.setSession(sessB) | 357 | acB.setSession(sessB) | ||
| 358 | - | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}) | 358 | + | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) |
| 359 | d.sessions[sessB.id] = sessB | 359 | d.sessions[sessB.id] = sessB | ||
| 360 | 360 | ||||
| 361 | d.paint(sessB, acB, true) | 361 | d.paint(sessB, acB, true) | ||
M internal/usecase/daemon/client.go +3 -3
| @@ -341,7 +341,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 341 | 341 | sess.mu.Unlock() | |
| 342 | 342 | d.touchMRU(sess) | |
| 343 | 343 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 344 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{})) | |
| 344 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |
| 345 | 345 | return ac, old | |
| 346 | 346 | } | |
| 347 | 347 | ||
| @@ -357,7 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 357 | 357 | // (and run its own attach-time reset), so this call must leave the tabs | |
| 358 | 358 | // alone rather than clobbering that client's freshly applied colors. | |
| 359 | 359 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |
| 360 | - | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{})) | |
| 360 | + | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | |
| 361 | 361 | } | |
| 362 | 362 | ||
| 363 | 363 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | |
| @@ -508,7 +508,7 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 508 | 508 | ac.setClientTheme(clientTheme) | |
| 509 | 509 | t := d.effectiveTheme(clientTheme) | |
| 510 | 510 | ||
| 511 | - | if !d.applyHostTheme(sess, ac, t) { | |
| 511 | + | if !d.applyHostTheme(sess, ac, t, false) { | |
| 512 | 512 | return | |
| 513 | 513 | } | |
| 514 | 514 | d.paint(sess, ac, true) | |
| @@ -341,7 +341,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 341 | sess.mu.Unlock() | 341 | sess.mu.Unlock() | ||
| 342 | d.touchMRU(sess) | 342 | d.touchMRU(sess) | ||
| 343 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | 343 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | ||
| 344 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{})) | 344 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) |
| 345 | return ac, old | 345 | return ac, old | ||
| 346 | } | 346 | } | ||
| 347 | 347 | ||||
| @@ -357,7 +357,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 357 | // (and run its own attach-time reset), so this call must leave the tabs | 357 | // (and run its own attach-time reset), so this call must leave the tabs | ||
| 358 | // alone rather than clobbering that client's freshly applied colors. | 358 | // alone rather than clobbering that client's freshly applied colors. | ||
| 359 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | 359 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | ||
| 360 | - | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{})) | 360 | + | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) |
| 361 | } | 361 | } | ||
| 362 | 362 | ||||
| 363 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | 363 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | ||
| @@ -508,7 +508,7 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||||
| 508 | ac.setClientTheme(clientTheme) | 508 | ac.setClientTheme(clientTheme) | ||
| 509 | t := d.effectiveTheme(clientTheme) | 509 | t := d.effectiveTheme(clientTheme) | ||
| 510 | 510 | ||||
| 511 | - | if !d.applyHostTheme(sess, ac, t) { | 511 | + | if !d.applyHostTheme(sess, ac, t, false) { |
| 512 | return | 512 | return | ||
| 513 | } | 513 | } | ||
| 514 | d.paint(sess, ac, true) | 514 | d.paint(sess, ac, true) | ||
M internal/usecase/daemon/config.go +58 -21
| @@ -17,9 +17,6 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | |||
| 17 | 17 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | |
| 18 | 18 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | |
| 19 | 19 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | |
| 20 | - | if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 { | |
| 21 | - | bindings, warnings = keys.BuildBindings(cfg.Bindings) | |
| 22 | - | } | |
| 23 | 20 | for _, warning := range warnings { | |
| 24 | 21 | d.logConfigWarning(warning) | |
| 25 | 22 | } | |
| @@ -52,28 +49,68 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||
| 52 | 49 | desired[slug] = code | |
| 53 | 50 | } | |
| 54 | 51 | ||
| 55 | - | used := make(map[string]string, len(commands)) | |
| 56 | - | for _, cmd := range commands { | |
| 57 | - | used[cmd.Code] = cmd.Slug | |
| 52 | + | // The effective code for a slug is its accepted override if it has one, | |
| 53 | + | // or its registry default otherwise. Resolve to a fixpoint rather than a | |
| 54 | + | // single sorted pass: dropping one slug's override to resolve a conflict | |
| 55 | + | // can free up its default code, which may in turn conflict with a | |
| 56 | + | // *different* slug's override — that must be re-checked regardless of | |
| 57 | + | // where the two slugs fall in sort order. | |
| 58 | + | accepted := make(map[string]string, len(desired)) | |
| 59 | + | for slug, code := range desired { | |
| 60 | + | accepted[slug] = code | |
| 61 | + | } | |
| 62 | + | effectiveCode := func(slug string) string { | |
| 63 | + | if code, ok := accepted[slug]; ok { | |
| 64 | + | return code | |
| 65 | + | } | |
| 66 | + | return bySlug[slug].Code | |
| 58 | 67 | } | |
| 59 | - | overrides := make(map[string]string) | |
| 60 | - | for _, slug := range sortedKeys(desired) { | |
| 61 | - | code := desired[slug] | |
| 62 | - | fallback := bySlug[slug].Code | |
| 63 | - | delete(used, fallback) | |
| 64 | - | if existing, ok := used[code]; ok && existing != slug { | |
| 65 | - | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)}) | |
| 66 | - | if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug { | |
| 67 | - | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)}) | |
| 68 | + | ||
| 69 | + | for { | |
| 70 | + | claimants := make(map[string][]string, len(commands)) | |
| 71 | + | for _, cmd := range commands { | |
| 72 | + | code := effectiveCode(cmd.Slug) | |
| 73 | + | claimants[code] = append(claimants[code], cmd.Slug) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | dropped := false | |
| 77 | + | for code, slugs := range claimants { | |
| 78 | + | if len(slugs) < 2 { | |
| 68 | 79 | continue | |
| 69 | 80 | } | |
| 70 | - | used[fallback] = slug | |
| 71 | - | continue | |
| 81 | + | sort.Strings(slugs) | |
| 82 | + | ||
| 83 | + | // If one claimant holds this code by default (no accepted | |
| 84 | + | // override), every override claimant loses to it and is | |
| 85 | + | // dropped. Otherwise every claimant is an override; keep the | |
| 86 | + | // lexicographically smallest slug and drop the rest. | |
| 87 | + | keep := "" | |
| 88 | + | for _, slug := range slugs { | |
| 89 | + | if _, isOverride := accepted[slug]; !isOverride { | |
| 90 | + | keep = slug | |
| 91 | + | break | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | if keep == "" { | |
| 95 | + | keep = slugs[0] | |
| 96 | + | } | |
| 97 | + | for _, slug := range slugs { | |
| 98 | + | if slug == keep { | |
| 99 | + | continue | |
| 100 | + | } | |
| 101 | + | if _, isOverride := accepted[slug]; !isOverride { | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)}) | |
| 105 | + | delete(accepted, slug) | |
| 106 | + | dropped = true | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | if !dropped { | |
| 110 | + | break | |
| 72 | 111 | } | |
| 73 | - | used[code] = slug | |
| 74 | - | overrides[slug] = code | |
| 75 | 112 | } | |
| 76 | - | return overrides | |
| 113 | + | return accepted | |
| 77 | 114 | } | |
| 78 | 115 | ||
| 79 | 116 | func sortedKeys(values map[string]string) []string { | |
| @@ -147,6 +184,6 @@ func (d *Daemon) reapplyThemeAllSessions() { | |||
| 147 | 184 | if ac != nil { | |
| 148 | 185 | clientTheme = ac.getClientTheme() | |
| 149 | 186 | } | |
| 150 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme)) | |
| 187 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil) | |
| 151 | 188 | } | |
| 152 | 189 | } | |
| @@ -17,9 +17,6 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | |||||
| 17 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | 17 | // ApplyConfig validates and atomically swaps daemon runtime configuration. | ||
| 18 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | 18 | func (d *Daemon) ApplyConfig(cfg domain.Config) { | ||
| 19 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | 19 | bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries) | ||
| 20 | - | if len(cfg.BindingEntries) == 0 && len(cfg.Bindings) > 0 { | |||
| 21 | - | bindings, warnings = keys.BuildBindings(cfg.Bindings) | |||
| 22 | - | } | |||
| 23 | for _, warning := range warnings { | 20 | for _, warning := range warnings { | ||
| 24 | d.logConfigWarning(warning) | 21 | d.logConfigWarning(warning) | ||
| 25 | } | 22 | } | ||
| @@ -52,28 +49,68 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||||
| 52 | desired[slug] = code | 49 | desired[slug] = code | ||
| 53 | } | 50 | } | ||
| 54 | 51 | ||||
| 55 | - | used := make(map[string]string, len(commands)) | 52 | + | // The effective code for a slug is its accepted override if it has one, |
| 56 | - | for _, cmd := range commands { | 53 | + | // or its registry default otherwise. Resolve to a fixpoint rather than a |
| 57 | - | used[cmd.Code] = cmd.Slug | 54 | + | // single sorted pass: dropping one slug's override to resolve a conflict |
| 55 | + | // can free up its default code, which may in turn conflict with a | |||
| 56 | + | // *different* slug's override — that must be re-checked regardless of | |||
| 57 | + | // where the two slugs fall in sort order. | |||
| 58 | + | accepted := make(map[string]string, len(desired)) | |||
| 59 | + | for slug, code := range desired { | |||
| 60 | + | accepted[slug] = code | |||
| 61 | + | } | |||
| 62 | + | effectiveCode := func(slug string) string { | |||
| 63 | + | if code, ok := accepted[slug]; ok { | |||
| 64 | + | return code | |||
| 65 | + | } | |||
| 66 | + | return bySlug[slug].Code | |||
| 58 | } | 67 | } | ||
| 59 | - | overrides := make(map[string]string) | 68 | + | |
| 60 | - | for _, slug := range sortedKeys(desired) { | 69 | + | for { |
| 61 | - | code := desired[slug] | 70 | + | claimants := make(map[string][]string, len(commands)) |
| 62 | - | fallback := bySlug[slug].Code | 71 | + | for _, cmd := range commands { |
| 63 | - | delete(used, fallback) | 72 | + | code := effectiveCode(cmd.Slug) |
| 64 | - | if existing, ok := used[code]; ok && existing != slug { | 73 | + | claimants[code] = append(claimants[code], cmd.Slug) |
| 65 | - | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, existing)}) | 74 | + | } |
| 66 | - | if fallbackOwner, fallbackUsed := used[fallback]; fallbackUsed && fallbackOwner != slug { | 75 | + | |
| 67 | - | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("default command code %q for %q conflicts with %q", fallback, slug, fallbackOwner)}) | 76 | + | dropped := false |
| 77 | + | for code, slugs := range claimants { | |||
| 78 | + | if len(slugs) < 2 { | |||
| 68 | continue | 79 | continue | ||
| 69 | } | 80 | } | ||
| 70 | - | used[fallback] = slug | 81 | + | sort.Strings(slugs) |
| 71 | - | continue | 82 | + | |
| 83 | + | // If one claimant holds this code by default (no accepted | |||
| 84 | + | // override), every override claimant loses to it and is | |||
| 85 | + | // dropped. Otherwise every claimant is an override; keep the | |||
| 86 | + | // lexicographically smallest slug and drop the rest. | |||
| 87 | + | keep := "" | |||
| 88 | + | for _, slug := range slugs { | |||
| 89 | + | if _, isOverride := accepted[slug]; !isOverride { | |||
| 90 | + | keep = slug | |||
| 91 | + | break | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | if keep == "" { | |||
| 95 | + | keep = slugs[0] | |||
| 96 | + | } | |||
| 97 | + | for _, slug := range slugs { | |||
| 98 | + | if slug == keep { | |||
| 99 | + | continue | |||
| 100 | + | } | |||
| 101 | + | if _, isOverride := accepted[slug]; !isOverride { | |||
| 102 | + | continue | |||
| 103 | + | } | |||
| 104 | + | d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)}) | |||
| 105 | + | delete(accepted, slug) | |||
| 106 | + | dropped = true | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | if !dropped { | |||
| 110 | + | break | |||
| 72 | } | 111 | } | ||
| 73 | - | used[code] = slug | |||
| 74 | - | overrides[slug] = code | |||
| 75 | } | 112 | } | ||
| 76 | - | return overrides | 113 | + | return accepted |
| 77 | } | 114 | } | ||
| 78 | 115 | ||||
| 79 | func sortedKeys(values map[string]string) []string { | 116 | func sortedKeys(values map[string]string) []string { | ||
| @@ -147,6 +184,6 @@ func (d *Daemon) reapplyThemeAllSessions() { | |||||
| 147 | if ac != nil { | 184 | if ac != nil { | ||
| 148 | clientTheme = ac.getClientTheme() | 185 | clientTheme = ac.getClientTheme() | ||
| 149 | } | 186 | } | ||
| 150 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme)) | 187 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil) |
| 151 | } | 188 | } | ||
| 152 | } | 189 | } | ||
M internal/usecase/daemon/config_test.go +31 -9
| @@ -29,8 +29,8 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 29 | 29 | ||
| 30 | 30 | d.ApplyConfig(domain.Config{ | |
| 31 | 31 | Theme: domain.ThemeAuto, | |
| 32 | - | Bindings: map[string]string{ | |
| 33 | - | "focus-pane-left": "alt+q", | |
| 32 | + | BindingEntries: []domain.ConfigEntry{ | |
| 33 | + | {Key: "focus-pane-left", Value: "alt+q"}, | |
| 34 | 34 | }, | |
| 35 | 35 | Codes: map[string]string{ | |
| 36 | 36 | "new-tab": "nt", | |
| @@ -43,7 +43,7 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 43 | 43 | r.Route([]byte("\x1bq")) | |
| 44 | 44 | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions) | |
| 45 | 45 | ||
| 46 | - | cmd, ok := d.commandByCode("NT") | |
| 46 | + | cmd, ok := d.commandByEffectiveCode("NT") | |
| 47 | 47 | require.True(t, ok) | |
| 48 | 48 | require.Equal(t, "new-tab", cmd.Slug) | |
| 49 | 49 | require.Equal(t, "NT", cmd.Code) | |
| @@ -63,7 +63,7 @@ func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | |||
| 63 | 63 | require.Equal(t, "new-tab", commands[0].Slug) | |
| 64 | 64 | require.Equal(t, "NN", commands[0].Code) | |
| 65 | 65 | ||
| 66 | - | cmd, ok := d.commandByCode("nn") | |
| 66 | + | cmd, ok := d.commandByEffectiveCode("nn") | |
| 67 | 67 | require.True(t, ok) | |
| 68 | 68 | require.Equal(t, "new-tab", cmd.Slug) | |
| 69 | 69 | require.Equal(t, "NN", cmd.Code) | |
| @@ -117,14 +117,36 @@ func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) { | |||
| 117 | 117 | require.Equal(t, "detach", byCode["DET"]) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | + | func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) { | |
| 121 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 122 | + | d.ApplyConfig(domain.Config{ | |
| 123 | + | Theme: domain.ThemeAuto, | |
| 124 | + | Codes: map[string]string{ | |
| 125 | + | "new-tab": "NT", | |
| 126 | + | "new-session": "CNT", | |
| 127 | + | }, | |
| 128 | + | }) | |
| 129 | + | ||
| 130 | + | commands := d.paletteCommands() | |
| 131 | + | byCode := make(map[string]string, len(commands)) | |
| 132 | + | for _, cmd := range commands { | |
| 133 | + | if existing, ok := byCode[cmd.Code]; ok { | |
| 134 | + | t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug) | |
| 135 | + | } | |
| 136 | + | byCode[cmd.Code] = cmd.Slug | |
| 137 | + | } | |
| 138 | + | require.Equal(t, "new-tab", byCode["NT"]) | |
| 139 | + | require.Equal(t, "new-session", byCode["CNT"]) | |
| 140 | + | } | |
| 141 | + | ||
| 120 | 142 | func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | |
| 121 | 143 | d := newTestDaemon(t, nil, stubClock{}) | |
| 122 | 144 | require.NotPanics(t, func() { | |
| 123 | 145 | d.ApplyConfig(domain.Config{ | |
| 124 | 146 | Theme: domain.ThemeAuto, | |
| 125 | - | Bindings: map[string]string{ | |
| 126 | - | "unknown-action": "alt+x", | |
| 127 | - | "focus-pane-left": "ctrl+x", | |
| 147 | + | BindingEntries: []domain.ConfigEntry{ | |
| 148 | + | {Key: "unknown-action", Value: "alt+x"}, | |
| 149 | + | {Key: "focus-pane-left", Value: "ctrl+x"}, | |
| 128 | 150 | }, | |
| 129 | 151 | Codes: map[string]string{ | |
| 130 | 152 | "new-tab": "toolong", | |
| @@ -134,9 +156,9 @@ func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | |||
| 134 | 156 | }) | |
| 135 | 157 | }) | |
| 136 | 158 | ||
| 137 | - | cmd, ok := d.commandByCode("CNT") | |
| 159 | + | cmd, ok := d.commandByEffectiveCode("CNT") | |
| 138 | 160 | require.True(t, ok) | |
| 139 | 161 | require.Equal(t, "new-tab", cmd.Slug) | |
| 140 | - | _, ok = d.commandByCode("TOOLONG") | |
| 162 | + | _, ok = d.commandByEffectiveCode("TOOLONG") | |
| 141 | 163 | require.False(t, ok) | |
| 142 | 164 | } | |
| @@ -29,8 +29,8 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||||
| 29 | 29 | ||||
| 30 | d.ApplyConfig(domain.Config{ | 30 | d.ApplyConfig(domain.Config{ | ||
| 31 | Theme: domain.ThemeAuto, | 31 | Theme: domain.ThemeAuto, | ||
| 32 | - | Bindings: map[string]string{ | 32 | + | BindingEntries: []domain.ConfigEntry{ |
| 33 | - | "focus-pane-left": "alt+q", | 33 | + | {Key: "focus-pane-left", Value: "alt+q"}, |
| 34 | }, | 34 | }, | ||
| 35 | Codes: map[string]string{ | 35 | Codes: map[string]string{ | ||
| 36 | "new-tab": "nt", | 36 | "new-tab": "nt", | ||
| @@ -43,7 +43,7 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||||
| 43 | r.Route([]byte("\x1bq")) | 43 | r.Route([]byte("\x1bq")) | ||
| 44 | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions) | 44 | require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions) | ||
| 45 | 45 | ||||
| 46 | - | cmd, ok := d.commandByCode("NT") | 46 | + | cmd, ok := d.commandByEffectiveCode("NT") |
| 47 | require.True(t, ok) | 47 | require.True(t, ok) | ||
| 48 | require.Equal(t, "new-tab", cmd.Slug) | 48 | require.Equal(t, "new-tab", cmd.Slug) | ||
| 49 | require.Equal(t, "NT", cmd.Code) | 49 | require.Equal(t, "NT", cmd.Code) | ||
| @@ -63,7 +63,7 @@ func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | |||||
| 63 | require.Equal(t, "new-tab", commands[0].Slug) | 63 | require.Equal(t, "new-tab", commands[0].Slug) | ||
| 64 | require.Equal(t, "NN", commands[0].Code) | 64 | require.Equal(t, "NN", commands[0].Code) | ||
| 65 | 65 | ||||
| 66 | - | cmd, ok := d.commandByCode("nn") | 66 | + | cmd, ok := d.commandByEffectiveCode("nn") |
| 67 | require.True(t, ok) | 67 | require.True(t, ok) | ||
| 68 | require.Equal(t, "new-tab", cmd.Slug) | 68 | require.Equal(t, "new-tab", cmd.Slug) | ||
| 69 | require.Equal(t, "NN", cmd.Code) | 69 | require.Equal(t, "NN", cmd.Code) | ||
| @@ -117,14 +117,36 @@ func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) { | |||||
| 117 | require.Equal(t, "detach", byCode["DET"]) | 117 | require.Equal(t, "detach", byCode["DET"]) | ||
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | + | func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) { | |||
| 121 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 122 | + | d.ApplyConfig(domain.Config{ | |||
| 123 | + | Theme: domain.ThemeAuto, | |||
| 124 | + | Codes: map[string]string{ | |||
| 125 | + | "new-tab": "NT", | |||
| 126 | + | "new-session": "CNT", | |||
| 127 | + | }, | |||
| 128 | + | }) | |||
| 129 | + | ||||
| 130 | + | commands := d.paletteCommands() | |||
| 131 | + | byCode := make(map[string]string, len(commands)) | |||
| 132 | + | for _, cmd := range commands { | |||
| 133 | + | if existing, ok := byCode[cmd.Code]; ok { | |||
| 134 | + | t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug) | |||
| 135 | + | } | |||
| 136 | + | byCode[cmd.Code] = cmd.Slug | |||
| 137 | + | } | |||
| 138 | + | require.Equal(t, "new-tab", byCode["NT"]) | |||
| 139 | + | require.Equal(t, "new-session", byCode["CNT"]) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 120 | func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | 142 | func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | ||
| 121 | d := newTestDaemon(t, nil, stubClock{}) | 143 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 122 | require.NotPanics(t, func() { | 144 | require.NotPanics(t, func() { | ||
| 123 | d.ApplyConfig(domain.Config{ | 145 | d.ApplyConfig(domain.Config{ | ||
| 124 | Theme: domain.ThemeAuto, | 146 | Theme: domain.ThemeAuto, | ||
| 125 | - | Bindings: map[string]string{ | 147 | + | BindingEntries: []domain.ConfigEntry{ |
| 126 | - | "unknown-action": "alt+x", | 148 | + | {Key: "unknown-action", Value: "alt+x"}, |
| 127 | - | "focus-pane-left": "ctrl+x", | 149 | + | {Key: "focus-pane-left", Value: "ctrl+x"}, |
| 128 | }, | 150 | }, | ||
| 129 | Codes: map[string]string{ | 151 | Codes: map[string]string{ | ||
| 130 | "new-tab": "toolong", | 152 | "new-tab": "toolong", | ||
| @@ -134,9 +156,9 @@ func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { | |||||
| 134 | }) | 156 | }) | ||
| 135 | }) | 157 | }) | ||
| 136 | 158 | ||||
| 137 | - | cmd, ok := d.commandByCode("CNT") | 159 | + | cmd, ok := d.commandByEffectiveCode("CNT") |
| 138 | require.True(t, ok) | 160 | require.True(t, ok) | ||
| 139 | require.Equal(t, "new-tab", cmd.Slug) | 161 | require.Equal(t, "new-tab", cmd.Slug) | ||
| 140 | - | _, ok = d.commandByCode("TOOLONG") | 162 | + | _, ok = d.commandByEffectiveCode("TOOLONG") |
| 141 | require.False(t, ok) | 163 | require.False(t, ok) | ||
| 142 | } | 164 | } | ||
M internal/usecase/daemon/daemon.go +0 -5
| @@ -38,7 +38,6 @@ import ( | |||
| 38 | 38 | "github.com/bnema/vev/internal/persist" | |
| 39 | 39 | "github.com/bnema/vev/internal/platform" | |
| 40 | 40 | "github.com/bnema/vev/internal/ports" | |
| 41 | - | "github.com/bnema/vev/internal/usecase/command" | |
| 42 | 41 | "github.com/bnema/vev/internal/usecase/keys" | |
| 43 | 42 | ) | |
| 44 | 43 | ||
| @@ -107,7 +106,6 @@ type Daemon struct { | |||
| 107 | 106 | persistEnabled bool | |
| 108 | 107 | procCwd func(int) (string, error) | |
| 109 | 108 | procComm func(int) (string, error) | |
| 110 | - | commandByCode func(string) (command.Command, bool) | |
| 111 | 109 | bindings atomic.Pointer[keys.Bindings] | |
| 112 | 110 | codeOverrides atomic.Pointer[map[string]string] | |
| 113 | 111 | themeMode atomic.Uint32 | |
| @@ -231,9 +229,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 231 | 229 | if d.procComm == nil { | |
| 232 | 230 | d.procComm = platform.ProcessComm | |
| 233 | 231 | } | |
| 234 | - | if d.commandByCode == nil { | |
| 235 | - | d.commandByCode = d.commandByEffectiveCode | |
| 236 | - | } | |
| 237 | 232 | if d.bindings.Load() == nil { | |
| 238 | 233 | d.bindings.Store(keys.DefaultBindings()) | |
| 239 | 234 | } | |
| @@ -38,7 +38,6 @@ import ( | |||||
| 38 | "github.com/bnema/vev/internal/persist" | 38 | "github.com/bnema/vev/internal/persist" | ||
| 39 | "github.com/bnema/vev/internal/platform" | 39 | "github.com/bnema/vev/internal/platform" | ||
| 40 | "github.com/bnema/vev/internal/ports" | 40 | "github.com/bnema/vev/internal/ports" | ||
| 41 | - | "github.com/bnema/vev/internal/usecase/command" | |||
| 42 | "github.com/bnema/vev/internal/usecase/keys" | 41 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 43 | ) | 42 | ) | ||
| 44 | 43 | ||||
| @@ -107,7 +106,6 @@ type Daemon struct { | |||||
| 107 | persistEnabled bool | 106 | persistEnabled bool | ||
| 108 | procCwd func(int) (string, error) | 107 | procCwd func(int) (string, error) | ||
| 109 | procComm func(int) (string, error) | 108 | procComm func(int) (string, error) | ||
| 110 | - | commandByCode func(string) (command.Command, bool) | |||
| 111 | bindings atomic.Pointer[keys.Bindings] | 109 | bindings atomic.Pointer[keys.Bindings] | ||
| 112 | codeOverrides atomic.Pointer[map[string]string] | 110 | codeOverrides atomic.Pointer[map[string]string] | ||
| 113 | themeMode atomic.Uint32 | 111 | themeMode atomic.Uint32 | ||
| @@ -231,9 +229,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 231 | if d.procComm == nil { | 229 | if d.procComm == nil { | ||
| 232 | d.procComm = platform.ProcessComm | 230 | d.procComm = platform.ProcessComm | ||
| 233 | } | 231 | } | ||
| 234 | - | if d.commandByCode == nil { | |||
| 235 | - | d.commandByCode = d.commandByEffectiveCode | |||
| 236 | - | } | |||
| 237 | if d.bindings.Load() == nil { | 232 | if d.bindings.Load() == nil { | ||
| 238 | d.bindings.Store(keys.DefaultBindings()) | 233 | d.bindings.Store(keys.DefaultBindings()) | ||
| 239 | } | 234 | } | ||
M internal/usecase/daemon/daemon_test.go +1 -1
| @@ -253,7 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||
| 253 | 253 | } | |
| 254 | 254 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | |
| 255 | 255 | ac.setSession(sess) | |
| 256 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | |
| 256 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil) | |
| 257 | 257 | d.sessions[sess.id] = sess | |
| 258 | 258 | t.Cleanup(cancel) | |
| 259 | 259 | return d, sess, ac, sends | |
| @@ -253,7 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||||
| 253 | } | 253 | } | ||
| 254 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | 254 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | ||
| 255 | ac.setSession(sess) | 255 | ac.setSession(sess) | ||
| 256 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | 256 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil) |
| 257 | d.sessions[sess.id] = sess | 257 | d.sessions[sess.id] = sess | ||
| 258 | t.Cleanup(cancel) | 258 | t.Cleanup(cancel) | ||
| 259 | return d, sess, ac, sends | 259 | return d, sess, ac, sends | ||
M internal/usecase/daemon/picker_test.go +4 -4
| @@ -159,8 +159,8 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 159 | 159 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | |
| 160 | 160 | ac1.setSession(sess1) | |
| 161 | 161 | ac2.setSession(sess2) | |
| 162 | - | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | |
| 163 | - | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | |
| 162 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | |
| 163 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil) | |
| 164 | 164 | d.sessions[sess1.id] = sess1 | |
| 165 | 165 | d.sessions[sess2.id] = sess2 | |
| 166 | 166 | ||
| @@ -271,8 +271,8 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||
| 271 | 271 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | |
| 272 | 272 | ac1.setSession(sess1) | |
| 273 | 273 | ac2.setSession(sess2) | |
| 274 | - | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | |
| 275 | - | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | |
| 274 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | |
| 275 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil) | |
| 276 | 276 | d.sessions[sess1.id] = sess1 | |
| 277 | 277 | d.sessions[sess2.id] = sess2 | |
| 278 | 278 | ||
| @@ -159,8 +159,8 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||||
| 159 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | 159 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | ||
| 160 | ac1.setSession(sess1) | 160 | ac1.setSession(sess1) | ||
| 161 | ac2.setSession(sess2) | 161 | ac2.setSession(sess2) | ||
| 162 | - | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | 162 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) |
| 163 | - | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | 163 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil) |
| 164 | d.sessions[sess1.id] = sess1 | 164 | d.sessions[sess1.id] = sess1 | ||
| 165 | d.sessions[sess2.id] = sess2 | 165 | d.sessions[sess2.id] = sess2 | ||
| 166 | 166 | ||||
| @@ -271,8 +271,8 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||||
| 271 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | 271 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | ||
| 272 | ac1.setSession(sess1) | 272 | ac1.setSession(sess1) | ||
| 273 | ac2.setSession(sess2) | 273 | ac2.setSession(sess2) | ||
| 274 | - | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | 274 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) |
| 275 | - | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | 275 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil) |
| 276 | d.sessions[sess1.id] = sess1 | 276 | d.sessions[sess1.id] = sess1 | ||
| 277 | d.sessions[sess2.id] = sess2 | 277 | d.sessions[sess2.id] = sess2 | ||
| 278 | 278 | ||||
M internal/usecase/daemon/status_test.go +18 -0
| @@ -175,6 +175,24 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) { | |||
| 175 | 175 | assertSessionColorSchemeUnknown(t, sess) | |
| 176 | 176 | } | |
| 177 | 177 | ||
| 178 | + | func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) { | |
| 179 | + | p, releasePTY := newBlockingPTY(t) | |
| 180 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 181 | + | defer releasePTY() | |
| 182 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto}) | |
| 183 | + | d.applyTheme(sess, ac, ports.Theme{ | |
| 184 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 185 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 186 | + | TrueColor: true, SchemeKnown: true, Light: true, | |
| 187 | + | }) | |
| 188 | + | assertSessionColorScheme(t, sess, true) | |
| 189 | + | ||
| 190 | + | tr, _ := newCapturingTransport(t) | |
| 191 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 192 | + | ||
| 193 | + | assertSessionColorSchemeUnknown(t, sess) | |
| 194 | + | } | |
| 195 | + | ||
| 178 | 196 | func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) { | |
| 179 | 197 | p, releasePTY := newBlockingPTY(t) | |
| 180 | 198 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| @@ -175,6 +175,24 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) { | |||||
| 175 | assertSessionColorSchemeUnknown(t, sess) | 175 | assertSessionColorSchemeUnknown(t, sess) | ||
| 176 | } | 176 | } | ||
| 177 | 177 | ||||
| 178 | + | func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) { | |||
| 179 | + | p, releasePTY := newBlockingPTY(t) | |||
| 180 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 181 | + | defer releasePTY() | |||
| 182 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto}) | |||
| 183 | + | d.applyTheme(sess, ac, ports.Theme{ | |||
| 184 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 185 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 186 | + | TrueColor: true, SchemeKnown: true, Light: true, | |||
| 187 | + | }) | |||
| 188 | + | assertSessionColorScheme(t, sess, true) | |||
| 189 | + | ||||
| 190 | + | tr, _ := newCapturingTransport(t) | |||
| 191 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |||
| 192 | + | ||||
| 193 | + | assertSessionColorSchemeUnknown(t, sess) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 178 | func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) { | 196 | func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) { | ||
| 179 | p, releasePTY := newBlockingPTY(t) | 197 | p, releasePTY := newBlockingPTY(t) | ||
| 180 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 198 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
M internal/usecase/daemon/theme_resolve.go +2 -2
| @@ -2,7 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import "github.com/bnema/vev/internal/usecase/theme" | |
| 4 | 4 | ||
| 5 | - | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool { | |
| 5 | + | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 6 | 6 | sess.mu.Lock() | |
| 7 | 7 | if ac != nil { | |
| 8 | 8 | if sess.client != ac { | |
| @@ -25,7 +25,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||
| 25 | 25 | tb.mu.Unlock() | |
| 26 | 26 | for _, p := range panes { | |
| 27 | 27 | p.mu.Lock() | |
| 28 | - | applyPaneThemeLocked(p, t, ac == nil) | |
| 28 | + | applyPaneThemeLocked(p, t, clearUnknownScheme) | |
| 29 | 29 | p.mu.Unlock() | |
| 30 | 30 | } | |
| 31 | 31 | } | |
| @@ -2,7 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import "github.com/bnema/vev/internal/usecase/theme" | 3 | import "github.com/bnema/vev/internal/usecase/theme" | ||
| 4 | 4 | ||||
| 5 | - | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme) bool { | 5 | + | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { |
| 6 | sess.mu.Lock() | 6 | sess.mu.Lock() | ||
| 7 | if ac != nil { | 7 | if ac != nil { | ||
| 8 | if sess.client != ac { | 8 | if sess.client != ac { | ||
| @@ -25,7 +25,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||||
| 25 | tb.mu.Unlock() | 25 | tb.mu.Unlock() | ||
| 26 | for _, p := range panes { | 26 | for _, p := range panes { | ||
| 27 | p.mu.Lock() | 27 | p.mu.Lock() | ||
| 28 | - | applyPaneThemeLocked(p, t, ac == nil) | 28 | + | applyPaneThemeLocked(p, t, clearUnknownScheme) |
| 29 | p.mu.Unlock() | 29 | p.mu.Unlock() | ||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
M internal/usecase/keys/router.go +4 -6
| @@ -62,12 +62,10 @@ type Router struct { | |||
| 62 | 62 | pendingDone chan struct{} | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | - | func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router { | |
| 66 | - | var bindingPtr *atomic.Pointer[Bindings] | |
| 67 | - | if len(bindings) > 0 { | |
| 68 | - | bindingPtr = bindings[0] | |
| 69 | - | } | |
| 70 | - | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr} | |
| 65 | + | // NewRouter constructs a Router. bindings may be nil, in which case | |
| 66 | + | // currentBindings falls back to defaultBindings. | |
| 67 | + | func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) *Router { | |
| 68 | + | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings} | |
| 71 | 69 | } | |
| 72 | 70 | ||
| 73 | 71 | // Route routes one transport read. | |
| @@ -62,12 +62,10 @@ type Router struct { | |||||
| 62 | pendingDone chan struct{} | 62 | pendingDone chan struct{} | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | - | func NewRouter(clock ports.Clock, h Handler, bindings ...*atomic.Pointer[Bindings]) *Router { | 65 | + | // NewRouter constructs a Router. bindings may be nil, in which case |
| 66 | - | var bindingPtr *atomic.Pointer[Bindings] | 66 | + | // currentBindings falls back to defaultBindings. |
| 67 | - | if len(bindings) > 0 { | 67 | + | func NewRouter(clock ports.Clock, h Handler, bindings *atomic.Pointer[Bindings]) *Router { |
| 68 | - | bindingPtr = bindings[0] | 68 | + | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindings} |
| 69 | - | } | |||
| 70 | - | return &Router{clock: clock, delay: ESCDelay, h: h, bindings: bindingPtr} | |||
| 71 | } | 69 | } | ||
| 72 | 70 | ||||
| 73 | // Route routes one transport read. | 71 | // Route routes one transport read. | ||
M internal/usecase/keys/router_test.go +26 -26
| @@ -82,7 +82,7 @@ func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) { | |||
| 82 | 82 | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | |
| 83 | 83 | clk := &fakeClock{} | |
| 84 | 84 | h := &captureHandler{} | |
| 85 | - | NewRouter(clk, h).Route([]byte{ESC, ' '}) | |
| 85 | + | NewRouter(clk, h, nil).Route([]byte{ESC, ' '}) | |
| 86 | 86 | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | |
| 87 | 87 | require.Empty(t, h.forwards) | |
| 88 | 88 | require.Empty(t, clk.timers) | |
| @@ -91,7 +91,7 @@ func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | |||
| 91 | 91 | func TestRouterInterceptsAltDigits(t *testing.T) { | |
| 92 | 92 | clk := &fakeClock{} | |
| 93 | 93 | h := &captureHandler{} | |
| 94 | - | r := NewRouter(clk, h) | |
| 94 | + | r := NewRouter(clk, h, nil) | |
| 95 | 95 | for b := byte('1'); b <= '9'; b++ { | |
| 96 | 96 | r.Route([]byte{ESC, b}) | |
| 97 | 97 | } | |
| @@ -113,7 +113,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) { | |||
| 113 | 113 | t.Run(tc.name, func(t *testing.T) { | |
| 114 | 114 | clk := &fakeClock{} | |
| 115 | 115 | h := &captureHandler{} | |
| 116 | - | r := NewRouter(clk, h) | |
| 116 | + | r := NewRouter(clk, h, nil) | |
| 117 | 117 | for _, key := range tc.keys { | |
| 118 | 118 | r.Route(append([]byte{ESC}, []byte(key)...)) | |
| 119 | 119 | } | |
| @@ -126,7 +126,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) { | |||
| 126 | 126 | func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | |
| 127 | 127 | clk := &fakeClock{} | |
| 128 | 128 | h := &captureHandler{} | |
| 129 | - | r := NewRouter(clk, h) | |
| 129 | + | r := NewRouter(clk, h, nil) | |
| 130 | 130 | ||
| 131 | 131 | r.Route([]byte{ESC, 0xc3}) | |
| 132 | 132 | require.Empty(t, h.actions) | |
| @@ -146,7 +146,7 @@ func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | |||
| 146 | 146 | func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | |
| 147 | 147 | clk := &fakeClock{} | |
| 148 | 148 | h := &captureHandler{} | |
| 149 | - | r := NewRouter(clk, h) | |
| 149 | + | r := NewRouter(clk, h, nil) | |
| 150 | 150 | ||
| 151 | 151 | r.Route([]byte{ESC}) | |
| 152 | 152 | r.Route([]byte{0xc3}) | |
| @@ -167,7 +167,7 @@ func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | |||
| 167 | 167 | func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) { | |
| 168 | 168 | clk := &fakeClock{} | |
| 169 | 169 | h := &captureHandler{} | |
| 170 | - | r := NewRouter(clk, h) | |
| 170 | + | r := NewRouter(clk, h, nil) | |
| 171 | 171 | ||
| 172 | 172 | r.Route([]byte{ESC, 0xc3}) | |
| 173 | 173 | r.Route([]byte{0xb1}) | |
| @@ -184,7 +184,7 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) { | |||
| 184 | 184 | func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) { | |
| 185 | 185 | clk := &fakeClock{} | |
| 186 | 186 | h := &captureHandler{} | |
| 187 | - | r := NewRouter(clk, h) | |
| 187 | + | r := NewRouter(clk, h, nil) | |
| 188 | 188 | ||
| 189 | 189 | r.Route([]byte{ESC, 0xc3}) | |
| 190 | 190 | r.Route([]byte("X")) | |
| @@ -240,7 +240,7 @@ func switchTabActions() []Action { | |||
| 240 | 240 | func TestRouterInterceptsAltAForJumpAttention(t *testing.T) { | |
| 241 | 241 | clk := &fakeClock{} | |
| 242 | 242 | h := &captureHandler{} | |
| 243 | - | NewRouter(clk, h).Route([]byte{ESC, 'a'}) | |
| 243 | + | NewRouter(clk, h, nil).Route([]byte{ESC, 'a'}) | |
| 244 | 244 | require.Equal(t, []Action{ActionJumpAttention}, h.actions) | |
| 245 | 245 | require.Empty(t, h.forwards) | |
| 246 | 246 | require.Empty(t, clk.timers) | |
| @@ -261,7 +261,7 @@ func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) { | |||
| 261 | 261 | t.Run(tc.name, func(t *testing.T) { | |
| 262 | 262 | clk := &fakeClock{} | |
| 263 | 263 | h := &captureHandler{} | |
| 264 | - | NewRouter(clk, h).Route([]byte{ESC, tc.key}) | |
| 264 | + | NewRouter(clk, h, nil).Route([]byte{ESC, tc.key}) | |
| 265 | 265 | require.Equal(t, []Action{tc.want}, h.actions) | |
| 266 | 266 | require.Empty(t, h.forwards) | |
| 267 | 267 | require.Empty(t, clk.timers) | |
| @@ -274,7 +274,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | |||
| 274 | 274 | t.Run(string(b), func(t *testing.T) { | |
| 275 | 275 | clk := &fakeClock{} | |
| 276 | 276 | h := &captureHandler{} | |
| 277 | - | NewRouter(clk, h).Route([]byte{ESC, b}) | |
| 277 | + | NewRouter(clk, h, nil).Route([]byte{ESC, b}) | |
| 278 | 278 | require.Empty(t, h.actions) | |
| 279 | 279 | require.Equal(t, [][]byte{{ESC, b}}, h.forwards) | |
| 280 | 280 | }) | |
| @@ -284,7 +284,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | |||
| 284 | 284 | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | |
| 285 | 285 | clk := &fakeClock{} | |
| 286 | 286 | h := &captureHandler{} | |
| 287 | - | NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'}) | |
| 287 | + | NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'}) | |
| 288 | 288 | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | |
| 289 | 289 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | |
| 290 | 290 | } | |
| @@ -294,7 +294,7 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) { | |||
| 294 | 294 | for _, in := range cases { | |
| 295 | 295 | clk := &fakeClock{} | |
| 296 | 296 | h := &captureHandler{} | |
| 297 | - | NewRouter(clk, h).Route(in) | |
| 297 | + | NewRouter(clk, h, nil).Route(in) | |
| 298 | 298 | require.Empty(t, h.actions) | |
| 299 | 299 | require.Equal(t, [][]byte{in}, h.forwards) | |
| 300 | 300 | require.Empty(t, clk.timers) | |
| @@ -321,7 +321,7 @@ func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) { | |||
| 321 | 321 | t.Run(tc.name, func(t *testing.T) { | |
| 322 | 322 | clk := &fakeClock{} | |
| 323 | 323 | h := &captureHandler{} | |
| 324 | - | NewRouter(clk, h).Route(tc.in) | |
| 324 | + | NewRouter(clk, h, nil).Route(tc.in) | |
| 325 | 325 | require.Equal(t, []Action{tc.want}, h.actions) | |
| 326 | 326 | require.Empty(t, h.forwards) | |
| 327 | 327 | require.Empty(t, clk.timers) | |
| @@ -346,7 +346,7 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) { | |||
| 346 | 346 | t.Run(tc.name, func(t *testing.T) { | |
| 347 | 347 | clk := &fakeClock{} | |
| 348 | 348 | h := &captureHandler{} | |
| 349 | - | r := NewRouter(clk, h) | |
| 349 | + | r := NewRouter(clk, h, nil) | |
| 350 | 350 | for _, read := range tc.reads { | |
| 351 | 351 | r.Route(read) | |
| 352 | 352 | } | |
| @@ -392,7 +392,7 @@ func TestRouterAltArrowCSIPrefixRouting(t *testing.T) { | |||
| 392 | 392 | t.Run(tc.name, func(t *testing.T) { | |
| 393 | 393 | clk := &fakeClock{} | |
| 394 | 394 | h := &captureHandler{} | |
| 395 | - | r := NewRouter(clk, h) | |
| 395 | + | r := NewRouter(clk, h, nil) | |
| 396 | 396 | for _, read := range tc.reads { | |
| 397 | 397 | r.Route(read) | |
| 398 | 398 | } | |
| @@ -412,7 +412,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) { | |||
| 412 | 412 | t.Run(string(in), func(t *testing.T) { | |
| 413 | 413 | clk := &fakeClock{} | |
| 414 | 414 | h := &captureHandler{} | |
| 415 | - | NewRouter(clk, h).Route(in) | |
| 415 | + | NewRouter(clk, h, nil).Route(in) | |
| 416 | 416 | require.Empty(t, h.actions) | |
| 417 | 417 | require.Equal(t, [][]byte{in}, h.forwards) | |
| 418 | 418 | require.Empty(t, clk.timers) | |
| @@ -423,7 +423,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) { | |||
| 423 | 423 | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |
| 424 | 424 | clk := &fakeClock{} | |
| 425 | 425 | h := &captureHandler{} | |
| 426 | - | r := NewRouter(clk, h) | |
| 426 | + | r := NewRouter(clk, h, nil) | |
| 427 | 427 | r.Route([]byte{ESC}) | |
| 428 | 428 | require.Len(t, clk.timers, 1) | |
| 429 | 429 | require.Equal(t, ESCDelay, clk.timers[0].d) | |
| @@ -438,7 +438,7 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||
| 438 | 438 | func TestRouterForwardsBareT(t *testing.T) { | |
| 439 | 439 | clk := &fakeClock{} | |
| 440 | 440 | h := &captureHandler{} | |
| 441 | - | NewRouter(clk, h).Route([]byte{'t'}) | |
| 441 | + | NewRouter(clk, h, nil).Route([]byte{'t'}) | |
| 442 | 442 | require.Empty(t, h.actions) | |
| 443 | 443 | require.Equal(t, [][]byte{[]byte("t")}, h.forwards) | |
| 444 | 444 | } | |
| @@ -457,7 +457,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | |||
| 457 | 457 | t.Run(tc.name, func(t *testing.T) { | |
| 458 | 458 | clk := &fakeClock{} | |
| 459 | 459 | h := &captureHandler{} | |
| 460 | - | r := NewRouter(clk, h) | |
| 460 | + | r := NewRouter(clk, h, nil) | |
| 461 | 461 | r.Route([]byte{ESC}) | |
| 462 | 462 | require.Len(t, clk.timers, 1) | |
| 463 | 463 | require.Empty(t, h.forwards) | |
| @@ -474,7 +474,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | |||
| 474 | 474 | func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |
| 475 | 475 | clk := &fakeClock{} | |
| 476 | 476 | h := &captureHandler{} | |
| 477 | - | r := NewRouter(clk, h) | |
| 477 | + | r := NewRouter(clk, h, nil) | |
| 478 | 478 | r.Route([]byte{ESC}) | |
| 479 | 479 | ||
| 480 | 480 | r.Route([]byte{'[', 'A', ESC, ' '}) | |
| @@ -487,7 +487,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |||
| 487 | 487 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |
| 488 | 488 | clk := &fakeClock{} | |
| 489 | 489 | h := &captureHandler{} | |
| 490 | - | r := NewRouter(clk, h) | |
| 490 | + | r := NewRouter(clk, h, nil) | |
| 491 | 491 | ||
| 492 | 492 | before := retainESCWaiters() | |
| 493 | 493 | r.Route([]byte{ESC}) | |
| @@ -509,7 +509,7 @@ func retainESCWaiters() int { | |||
| 509 | 509 | func TestRouterFlushesLoneESCAfterTimer(t *testing.T) { | |
| 510 | 510 | clk := &fakeClock{} | |
| 511 | 511 | h := &captureHandler{notify: make(chan struct{}, 1)} | |
| 512 | - | r := NewRouter(clk, h) | |
| 512 | + | r := NewRouter(clk, h, nil) | |
| 513 | 513 | r.Route([]byte{ESC}) | |
| 514 | 514 | clk.timers[0].fire() | |
| 515 | 515 | select { | |
| @@ -524,7 +524,7 @@ func TestRouterFlushesLoneESCAfterTimer(t *testing.T) { | |||
| 524 | 524 | func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | |
| 525 | 525 | clk := &fakeClock{} | |
| 526 | 526 | h := &captureHandler{} | |
| 527 | - | r := NewRouter(clk, h) | |
| 527 | + | r := NewRouter(clk, h, nil) | |
| 528 | 528 | r.Route([]byte{ESC}) | |
| 529 | 529 | r.Route([]byte{'z'}) | |
| 530 | 530 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | |
| @@ -538,7 +538,7 @@ var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A") | |||
| 538 | 538 | func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | |
| 539 | 539 | clk := &fakeClock{} | |
| 540 | 540 | h := &captureHandler{} | |
| 541 | - | r := NewRouter(clk, h) | |
| 541 | + | r := NewRouter(clk, h, nil) | |
| 542 | 542 | ||
| 543 | 543 | paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...) | |
| 544 | 544 | r.Route(paste) | |
| @@ -551,7 +551,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | |||
| 551 | 551 | func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) { | |
| 552 | 552 | clk := &fakeClock{} | |
| 553 | 553 | h := &captureHandler{} | |
| 554 | - | r := NewRouter(clk, h) | |
| 554 | + | r := NewRouter(clk, h, nil) | |
| 555 | 555 | ||
| 556 | 556 | r.Route(pasteBindingLookalikes) | |
| 557 | 557 | ||
| @@ -567,7 +567,7 @@ func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) { | |||
| 567 | 567 | func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) { | |
| 568 | 568 | clk := &fakeClock{} | |
| 569 | 569 | h := &captureHandler{} | |
| 570 | - | r := NewRouter(clk, h) | |
| 570 | + | r := NewRouter(clk, h, nil) | |
| 571 | 571 | ||
| 572 | 572 | // Opening marker + content in one frame, closing marker in the next: with | |
| 573 | 573 | // no closing marker in-frame the router keeps its historical per-byte | |
| @@ -82,7 +82,7 @@ func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) { | |||||
| 82 | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | 82 | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | ||
| 83 | clk := &fakeClock{} | 83 | clk := &fakeClock{} | ||
| 84 | h := &captureHandler{} | 84 | h := &captureHandler{} | ||
| 85 | - | NewRouter(clk, h).Route([]byte{ESC, ' '}) | 85 | + | NewRouter(clk, h, nil).Route([]byte{ESC, ' '}) |
| 86 | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | 86 | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | ||
| 87 | require.Empty(t, h.forwards) | 87 | require.Empty(t, h.forwards) | ||
| 88 | require.Empty(t, clk.timers) | 88 | require.Empty(t, clk.timers) | ||
| @@ -91,7 +91,7 @@ func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | |||||
| 91 | func TestRouterInterceptsAltDigits(t *testing.T) { | 91 | func TestRouterInterceptsAltDigits(t *testing.T) { | ||
| 92 | clk := &fakeClock{} | 92 | clk := &fakeClock{} | ||
| 93 | h := &captureHandler{} | 93 | h := &captureHandler{} | ||
| 94 | - | r := NewRouter(clk, h) | 94 | + | r := NewRouter(clk, h, nil) |
| 95 | for b := byte('1'); b <= '9'; b++ { | 95 | for b := byte('1'); b <= '9'; b++ { | ||
| 96 | r.Route([]byte{ESC, b}) | 96 | r.Route([]byte{ESC, b}) | ||
| 97 | } | 97 | } | ||
| @@ -113,7 +113,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) { | |||||
| 113 | t.Run(tc.name, func(t *testing.T) { | 113 | t.Run(tc.name, func(t *testing.T) { | ||
| 114 | clk := &fakeClock{} | 114 | clk := &fakeClock{} | ||
| 115 | h := &captureHandler{} | 115 | h := &captureHandler{} | ||
| 116 | - | r := NewRouter(clk, h) | 116 | + | r := NewRouter(clk, h, nil) |
| 117 | for _, key := range tc.keys { | 117 | for _, key := range tc.keys { | ||
| 118 | r.Route(append([]byte{ESC}, []byte(key)...)) | 118 | r.Route(append([]byte{ESC}, []byte(key)...)) | ||
| 119 | } | 119 | } | ||
| @@ -126,7 +126,7 @@ func TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) { | |||||
| 126 | func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | 126 | func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | ||
| 127 | clk := &fakeClock{} | 127 | clk := &fakeClock{} | ||
| 128 | h := &captureHandler{} | 128 | h := &captureHandler{} | ||
| 129 | - | r := NewRouter(clk, h) | 129 | + | r := NewRouter(clk, h, nil) |
| 130 | 130 | ||||
| 131 | r.Route([]byte{ESC, 0xc3}) | 131 | r.Route([]byte{ESC, 0xc3}) | ||
| 132 | require.Empty(t, h.actions) | 132 | require.Empty(t, h.actions) | ||
| @@ -146,7 +146,7 @@ func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | |||||
| 146 | func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | 146 | func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | ||
| 147 | clk := &fakeClock{} | 147 | clk := &fakeClock{} | ||
| 148 | h := &captureHandler{} | 148 | h := &captureHandler{} | ||
| 149 | - | r := NewRouter(clk, h) | 149 | + | r := NewRouter(clk, h, nil) |
| 150 | 150 | ||||
| 151 | r.Route([]byte{ESC}) | 151 | r.Route([]byte{ESC}) | ||
| 152 | r.Route([]byte{0xc3}) | 152 | r.Route([]byte{0xc3}) | ||
| @@ -167,7 +167,7 @@ func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) { | |||||
| 167 | func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) { | 167 | func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) { | ||
| 168 | clk := &fakeClock{} | 168 | clk := &fakeClock{} | ||
| 169 | h := &captureHandler{} | 169 | h := &captureHandler{} | ||
| 170 | - | r := NewRouter(clk, h) | 170 | + | r := NewRouter(clk, h, nil) |
| 171 | 171 | ||||
| 172 | r.Route([]byte{ESC, 0xc3}) | 172 | r.Route([]byte{ESC, 0xc3}) | ||
| 173 | r.Route([]byte{0xb1}) | 173 | r.Route([]byte{0xb1}) | ||
| @@ -184,7 +184,7 @@ func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) { | |||||
| 184 | func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) { | 184 | func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) { | ||
| 185 | clk := &fakeClock{} | 185 | clk := &fakeClock{} | ||
| 186 | h := &captureHandler{} | 186 | h := &captureHandler{} | ||
| 187 | - | r := NewRouter(clk, h) | 187 | + | r := NewRouter(clk, h, nil) |
| 188 | 188 | ||||
| 189 | r.Route([]byte{ESC, 0xc3}) | 189 | r.Route([]byte{ESC, 0xc3}) | ||
| 190 | r.Route([]byte("X")) | 190 | r.Route([]byte("X")) | ||
| @@ -240,7 +240,7 @@ func switchTabActions() []Action { | |||||
| 240 | func TestRouterInterceptsAltAForJumpAttention(t *testing.T) { | 240 | func TestRouterInterceptsAltAForJumpAttention(t *testing.T) { | ||
| 241 | clk := &fakeClock{} | 241 | clk := &fakeClock{} | ||
| 242 | h := &captureHandler{} | 242 | h := &captureHandler{} | ||
| 243 | - | NewRouter(clk, h).Route([]byte{ESC, 'a'}) | 243 | + | NewRouter(clk, h, nil).Route([]byte{ESC, 'a'}) |
| 244 | require.Equal(t, []Action{ActionJumpAttention}, h.actions) | 244 | require.Equal(t, []Action{ActionJumpAttention}, h.actions) | ||
| 245 | require.Empty(t, h.forwards) | 245 | require.Empty(t, h.forwards) | ||
| 246 | require.Empty(t, clk.timers) | 246 | require.Empty(t, clk.timers) | ||
| @@ -261,7 +261,7 @@ func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) { | |||||
| 261 | t.Run(tc.name, func(t *testing.T) { | 261 | t.Run(tc.name, func(t *testing.T) { | ||
| 262 | clk := &fakeClock{} | 262 | clk := &fakeClock{} | ||
| 263 | h := &captureHandler{} | 263 | h := &captureHandler{} | ||
| 264 | - | NewRouter(clk, h).Route([]byte{ESC, tc.key}) | 264 | + | NewRouter(clk, h, nil).Route([]byte{ESC, tc.key}) |
| 265 | require.Equal(t, []Action{tc.want}, h.actions) | 265 | require.Equal(t, []Action{tc.want}, h.actions) | ||
| 266 | require.Empty(t, h.forwards) | 266 | require.Empty(t, h.forwards) | ||
| 267 | require.Empty(t, clk.timers) | 267 | require.Empty(t, clk.timers) | ||
| @@ -274,7 +274,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | |||||
| 274 | t.Run(string(b), func(t *testing.T) { | 274 | t.Run(string(b), func(t *testing.T) { | ||
| 275 | clk := &fakeClock{} | 275 | clk := &fakeClock{} | ||
| 276 | h := &captureHandler{} | 276 | h := &captureHandler{} | ||
| 277 | - | NewRouter(clk, h).Route([]byte{ESC, b}) | 277 | + | NewRouter(clk, h, nil).Route([]byte{ESC, b}) |
| 278 | require.Empty(t, h.actions) | 278 | require.Empty(t, h.actions) | ||
| 279 | require.Equal(t, [][]byte{{ESC, b}}, h.forwards) | 279 | require.Equal(t, [][]byte{{ESC, b}}, h.forwards) | ||
| 280 | }) | 280 | }) | ||
| @@ -284,7 +284,7 @@ func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | |||||
| 284 | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | 284 | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | ||
| 285 | clk := &fakeClock{} | 285 | clk := &fakeClock{} | ||
| 286 | h := &captureHandler{} | 286 | h := &captureHandler{} | ||
| 287 | - | NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'}) | 287 | + | NewRouter(clk, h, nil).Route([]byte{'a', ESC, ' ', 'b'}) |
| 288 | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | 288 | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | ||
| 289 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | 289 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | ||
| 290 | } | 290 | } | ||
| @@ -294,7 +294,7 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) { | |||||
| 294 | for _, in := range cases { | 294 | for _, in := range cases { | ||
| 295 | clk := &fakeClock{} | 295 | clk := &fakeClock{} | ||
| 296 | h := &captureHandler{} | 296 | h := &captureHandler{} | ||
| 297 | - | NewRouter(clk, h).Route(in) | 297 | + | NewRouter(clk, h, nil).Route(in) |
| 298 | require.Empty(t, h.actions) | 298 | require.Empty(t, h.actions) | ||
| 299 | require.Equal(t, [][]byte{in}, h.forwards) | 299 | require.Equal(t, [][]byte{in}, h.forwards) | ||
| 300 | require.Empty(t, clk.timers) | 300 | require.Empty(t, clk.timers) | ||
| @@ -321,7 +321,7 @@ func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) { | |||||
| 321 | t.Run(tc.name, func(t *testing.T) { | 321 | t.Run(tc.name, func(t *testing.T) { | ||
| 322 | clk := &fakeClock{} | 322 | clk := &fakeClock{} | ||
| 323 | h := &captureHandler{} | 323 | h := &captureHandler{} | ||
| 324 | - | NewRouter(clk, h).Route(tc.in) | 324 | + | NewRouter(clk, h, nil).Route(tc.in) |
| 325 | require.Equal(t, []Action{tc.want}, h.actions) | 325 | require.Equal(t, []Action{tc.want}, h.actions) | ||
| 326 | require.Empty(t, h.forwards) | 326 | require.Empty(t, h.forwards) | ||
| 327 | require.Empty(t, clk.timers) | 327 | require.Empty(t, clk.timers) | ||
| @@ -346,7 +346,7 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) { | |||||
| 346 | t.Run(tc.name, func(t *testing.T) { | 346 | t.Run(tc.name, func(t *testing.T) { | ||
| 347 | clk := &fakeClock{} | 347 | clk := &fakeClock{} | ||
| 348 | h := &captureHandler{} | 348 | h := &captureHandler{} | ||
| 349 | - | r := NewRouter(clk, h) | 349 | + | r := NewRouter(clk, h, nil) |
| 350 | for _, read := range tc.reads { | 350 | for _, read := range tc.reads { | ||
| 351 | r.Route(read) | 351 | r.Route(read) | ||
| 352 | } | 352 | } | ||
| @@ -392,7 +392,7 @@ func TestRouterAltArrowCSIPrefixRouting(t *testing.T) { | |||||
| 392 | t.Run(tc.name, func(t *testing.T) { | 392 | t.Run(tc.name, func(t *testing.T) { | ||
| 393 | clk := &fakeClock{} | 393 | clk := &fakeClock{} | ||
| 394 | h := &captureHandler{} | 394 | h := &captureHandler{} | ||
| 395 | - | r := NewRouter(clk, h) | 395 | + | r := NewRouter(clk, h, nil) |
| 396 | for _, read := range tc.reads { | 396 | for _, read := range tc.reads { | ||
| 397 | r.Route(read) | 397 | r.Route(read) | ||
| 398 | } | 398 | } | ||
| @@ -412,7 +412,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) { | |||||
| 412 | t.Run(string(in), func(t *testing.T) { | 412 | t.Run(string(in), func(t *testing.T) { | ||
| 413 | clk := &fakeClock{} | 413 | clk := &fakeClock{} | ||
| 414 | h := &captureHandler{} | 414 | h := &captureHandler{} | ||
| 415 | - | NewRouter(clk, h).Route(in) | 415 | + | NewRouter(clk, h, nil).Route(in) |
| 416 | require.Empty(t, h.actions) | 416 | require.Empty(t, h.actions) | ||
| 417 | require.Equal(t, [][]byte{in}, h.forwards) | 417 | require.Equal(t, [][]byte{in}, h.forwards) | ||
| 418 | require.Empty(t, clk.timers) | 418 | require.Empty(t, clk.timers) | ||
| @@ -423,7 +423,7 @@ func TestRouterPassesThroughBareArrows(t *testing.T) { | |||||
| 423 | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | 423 | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | ||
| 424 | clk := &fakeClock{} | 424 | clk := &fakeClock{} | ||
| 425 | h := &captureHandler{} | 425 | h := &captureHandler{} | ||
| 426 | - | r := NewRouter(clk, h) | 426 | + | r := NewRouter(clk, h, nil) |
| 427 | r.Route([]byte{ESC}) | 427 | r.Route([]byte{ESC}) | ||
| 428 | require.Len(t, clk.timers, 1) | 428 | require.Len(t, clk.timers, 1) | ||
| 429 | require.Equal(t, ESCDelay, clk.timers[0].d) | 429 | require.Equal(t, ESCDelay, clk.timers[0].d) | ||
| @@ -438,7 +438,7 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||||
| 438 | func TestRouterForwardsBareT(t *testing.T) { | 438 | func TestRouterForwardsBareT(t *testing.T) { | ||
| 439 | clk := &fakeClock{} | 439 | clk := &fakeClock{} | ||
| 440 | h := &captureHandler{} | 440 | h := &captureHandler{} | ||
| 441 | - | NewRouter(clk, h).Route([]byte{'t'}) | 441 | + | NewRouter(clk, h, nil).Route([]byte{'t'}) |
| 442 | require.Empty(t, h.actions) | 442 | require.Empty(t, h.actions) | ||
| 443 | require.Equal(t, [][]byte{[]byte("t")}, h.forwards) | 443 | require.Equal(t, [][]byte{[]byte("t")}, h.forwards) | ||
| 444 | } | 444 | } | ||
| @@ -457,7 +457,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | |||||
| 457 | t.Run(tc.name, func(t *testing.T) { | 457 | t.Run(tc.name, func(t *testing.T) { | ||
| 458 | clk := &fakeClock{} | 458 | clk := &fakeClock{} | ||
| 459 | h := &captureHandler{} | 459 | h := &captureHandler{} | ||
| 460 | - | r := NewRouter(clk, h) | 460 | + | r := NewRouter(clk, h, nil) |
| 461 | r.Route([]byte{ESC}) | 461 | r.Route([]byte{ESC}) | ||
| 462 | require.Len(t, clk.timers, 1) | 462 | require.Len(t, clk.timers, 1) | ||
| 463 | require.Empty(t, h.forwards) | 463 | require.Empty(t, h.forwards) | ||
| @@ -474,7 +474,7 @@ func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | |||||
| 474 | func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | 474 | func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | ||
| 475 | clk := &fakeClock{} | 475 | clk := &fakeClock{} | ||
| 476 | h := &captureHandler{} | 476 | h := &captureHandler{} | ||
| 477 | - | r := NewRouter(clk, h) | 477 | + | r := NewRouter(clk, h, nil) |
| 478 | r.Route([]byte{ESC}) | 478 | r.Route([]byte{ESC}) | ||
| 479 | 479 | ||||
| 480 | r.Route([]byte{'[', 'A', ESC, ' '}) | 480 | r.Route([]byte{'[', 'A', ESC, ' '}) | ||
| @@ -487,7 +487,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |||||
| 487 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | 487 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | ||
| 488 | clk := &fakeClock{} | 488 | clk := &fakeClock{} | ||
| 489 | h := &captureHandler{} | 489 | h := &captureHandler{} | ||
| 490 | - | r := NewRouter(clk, h) | 490 | + | r := NewRouter(clk, h, nil) |
| 491 | 491 | ||||
| 492 | before := retainESCWaiters() | 492 | before := retainESCWaiters() | ||
| 493 | r.Route([]byte{ESC}) | 493 | r.Route([]byte{ESC}) | ||
| @@ -509,7 +509,7 @@ func retainESCWaiters() int { | |||||
| 509 | func TestRouterFlushesLoneESCAfterTimer(t *testing.T) { | 509 | func TestRouterFlushesLoneESCAfterTimer(t *testing.T) { | ||
| 510 | clk := &fakeClock{} | 510 | clk := &fakeClock{} | ||
| 511 | h := &captureHandler{notify: make(chan struct{}, 1)} | 511 | h := &captureHandler{notify: make(chan struct{}, 1)} | ||
| 512 | - | r := NewRouter(clk, h) | 512 | + | r := NewRouter(clk, h, nil) |
| 513 | r.Route([]byte{ESC}) | 513 | r.Route([]byte{ESC}) | ||
| 514 | clk.timers[0].fire() | 514 | clk.timers[0].fire() | ||
| 515 | select { | 515 | select { | ||
| @@ -524,7 +524,7 @@ func TestRouterFlushesLoneESCAfterTimer(t *testing.T) { | |||||
| 524 | func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | 524 | func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) { | ||
| 525 | clk := &fakeClock{} | 525 | clk := &fakeClock{} | ||
| 526 | h := &captureHandler{} | 526 | h := &captureHandler{} | ||
| 527 | - | r := NewRouter(clk, h) | 527 | + | r := NewRouter(clk, h, nil) |
| 528 | r.Route([]byte{ESC}) | 528 | r.Route([]byte{ESC}) | ||
| 529 | r.Route([]byte{'z'}) | 529 | r.Route([]byte{'z'}) | ||
| 530 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | 530 | require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards) | ||
| @@ -538,7 +538,7 @@ var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A") | |||||
| 538 | func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | 538 | func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | ||
| 539 | clk := &fakeClock{} | 539 | clk := &fakeClock{} | ||
| 540 | h := &captureHandler{} | 540 | h := &captureHandler{} | ||
| 541 | - | r := NewRouter(clk, h) | 541 | + | r := NewRouter(clk, h, nil) |
| 542 | 542 | ||||
| 543 | paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...) | 543 | paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...) | ||
| 544 | r.Route(paste) | 544 | r.Route(paste) | ||
| @@ -551,7 +551,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) { | |||||
| 551 | func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) { | 551 | func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) { | ||
| 552 | clk := &fakeClock{} | 552 | clk := &fakeClock{} | ||
| 553 | h := &captureHandler{} | 553 | h := &captureHandler{} | ||
| 554 | - | r := NewRouter(clk, h) | 554 | + | r := NewRouter(clk, h, nil) |
| 555 | 555 | ||||
| 556 | r.Route(pasteBindingLookalikes) | 556 | r.Route(pasteBindingLookalikes) | ||
| 557 | 557 | ||||
| @@ -567,7 +567,7 @@ func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) { | |||||
| 567 | func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) { | 567 | func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) { | ||
| 568 | clk := &fakeClock{} | 568 | clk := &fakeClock{} | ||
| 569 | h := &captureHandler{} | 569 | h := &captureHandler{} | ||
| 570 | - | r := NewRouter(clk, h) | 570 | + | r := NewRouter(clk, h, nil) |
| 571 | 571 | ||||
| 572 | // Opening marker + content in one frame, closing marker in the next: with | 572 | // Opening marker + content in one frame, closing marker in the next: with | ||
| 573 | // no closing marker in-frame the router keeps its historical per-byte | 573 | // no closing marker in-frame the router keeps its historical per-byte | ||
M internal/usecase/theme/theme.go +19 -9
| @@ -224,18 +224,28 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |||
| 224 | 224 | return false, false | |
| 225 | 225 | } | |
| 226 | 226 | ||
| 227 | + | // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev | |
| 228 | + | // recognizes. Package-level so schemeCSINotification (called per ESC byte in | |
| 229 | + | // the stdin hot path) doesn't allocate on every call. | |
| 230 | + | var schemeCSIPrefixes = []struct { | |
| 231 | + | seq []byte | |
| 232 | + | light bool | |
| 233 | + | }{ | |
| 234 | + | {seq: []byte("\x1b[?997;1n"), light: false}, | |
| 235 | + | {seq: []byte("\x1b[?997;2n"), light: true}, | |
| 236 | + | } | |
| 237 | + | ||
| 227 | 238 | func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) { | |
| 228 | - | if len(data) < 2 { | |
| 239 | + | // A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is | |
| 240 | + | // something a user can type; unlike OSC's "ESC ]" it must never be | |
| 241 | + | // withheld as a partial match with nothing following, or those keystrokes | |
| 242 | + | // would be buffered and never forwarded. Partial buffering only starts | |
| 243 | + | // from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this | |
| 244 | + | // guard cannot reject one. | |
| 245 | + | if len(data) < 3 { | |
| 229 | 246 | return false, false, false | |
| 230 | 247 | } | |
| 231 | - | prefixes := []struct { | |
| 232 | - | seq []byte | |
| 233 | - | light bool | |
| 234 | - | }{ | |
| 235 | - | {seq: []byte("\x1b[?997;1n"), light: false}, | |
| 236 | - | {seq: []byte("\x1b[?997;2n"), light: true}, | |
| 237 | - | } | |
| 238 | - | for _, prefix := range prefixes { | |
| 248 | + | for _, prefix := range schemeCSIPrefixes { | |
| 239 | 249 | if len(data) >= len(prefix.seq) { | |
| 240 | 250 | if bytes.Equal(data[:len(prefix.seq)], prefix.seq) { | |
| 241 | 251 | return true, true, prefix.light | |
| @@ -224,18 +224,28 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |||||
| 224 | return false, false | 224 | return false, false | ||
| 225 | } | 225 | } | ||
| 226 | 226 | ||||
| 227 | + | // schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev | |||
| 228 | + | // recognizes. Package-level so schemeCSINotification (called per ESC byte in | |||
| 229 | + | // the stdin hot path) doesn't allocate on every call. | |||
| 230 | + | var schemeCSIPrefixes = []struct { | |||
| 231 | + | seq []byte | |||
| 232 | + | light bool | |||
| 233 | + | }{ | |||
| 234 | + | {seq: []byte("\x1b[?997;1n"), light: false}, | |||
| 235 | + | {seq: []byte("\x1b[?997;2n"), light: true}, | |||
| 236 | + | } | |||
| 237 | + | ||||
| 227 | func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) { | 238 | func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) { | ||
| 228 | - | if len(data) < 2 { | 239 | + | // A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is |
| 240 | + | // something a user can type; unlike OSC's "ESC ]" it must never be | |||
| 241 | + | // withheld as a partial match with nothing following, or those keystrokes | |||
| 242 | + | // would be buffered and never forwarded. Partial buffering only starts | |||
| 243 | + | // from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this | |||
| 244 | + | // guard cannot reject one. | |||
| 245 | + | if len(data) < 3 { | |||
| 229 | return false, false, false | 246 | return false, false, false | ||
| 230 | } | 247 | } | ||
| 231 | - | prefixes := []struct { | 248 | + | for _, prefix := range schemeCSIPrefixes { |
| 232 | - | seq []byte | |||
| 233 | - | light bool | |||
| 234 | - | }{ | |||
| 235 | - | {seq: []byte("\x1b[?997;1n"), light: false}, | |||
| 236 | - | {seq: []byte("\x1b[?997;2n"), light: true}, | |||
| 237 | - | } | |||
| 238 | - | for _, prefix := range prefixes { | |||
| 239 | if len(data) >= len(prefix.seq) { | 249 | if len(data) >= len(prefix.seq) { | ||
| 240 | if bytes.Equal(data[:len(prefix.seq)], prefix.seq) { | 250 | if bytes.Equal(data[:len(prefix.seq)], prefix.seq) { | ||
| 241 | return true, true, prefix.light | 251 | return true, true, prefix.light | ||
M internal/usecase/theme/theme_test.go +22 -0
| @@ -98,6 +98,28 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) { | |||
| 98 | 98 | } | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) { | |
| 102 | + | var s Scanner | |
| 103 | + | var out bytes.Buffer | |
| 104 | + | schemes := 0 | |
| 105 | + | ||
| 106 | + | s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |
| 107 | + | ||
| 108 | + | if out.String() != "a\x1b[" { | |
| 109 | + | t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[") | |
| 110 | + | } | |
| 111 | + | if schemes != 0 { | |
| 112 | + | t.Fatalf("schemes=%d want 0", schemes) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | out.Reset() | |
| 116 | + | s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |
| 117 | + | ||
| 118 | + | if out.String() != "A" { | |
| 119 | + | t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A") | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 101 | 123 | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | |
| 102 | 124 | var s Scanner | |
| 103 | 125 | var out bytes.Buffer | |
| @@ -98,6 +98,28 @@ func TestScannerHandlesSplitSchemeNotifications(t *testing.T) { | |||||
| 98 | } | 98 | } | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | + | func TestScannerForwardsBareCSIIntroducerImmediately(t *testing.T) { | |||
| 102 | + | var s Scanner | |||
| 103 | + | var out bytes.Buffer | |||
| 104 | + | schemes := 0 | |||
| 105 | + | ||||
| 106 | + | s.Scan([]byte("a\x1b["), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |||
| 107 | + | ||||
| 108 | + | if out.String() != "a\x1b[" { | |||
| 109 | + | t.Fatalf("passthrough=%q want %q", out.String(), "a\x1b[") | |||
| 110 | + | } | |||
| 111 | + | if schemes != 0 { | |||
| 112 | + | t.Fatalf("schemes=%d want 0", schemes) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | out.Reset() | |||
| 116 | + | s.Scan([]byte("A"), func(kind int, rgb renderer.RGB) {}, func(light bool) { schemes++ }, func(b []byte) { out.Write(b) }) | |||
| 117 | + | ||||
| 118 | + | if out.String() != "A" { | |||
| 119 | + | t.Fatalf("follow-up passthrough=%q want %q (no stale state)", out.String(), "A") | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 101 | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | 123 | func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) { | ||
| 102 | var s Scanner | 124 | var s Scanner | ||
| 103 | var out bytes.Buffer | 125 | var out bytes.Buffer | ||
M pkg/vt/query_test.go +20 -0
| @@ -151,6 +151,26 @@ func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) { | |||
| 151 | 151 | } | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | + | func TestScreenResetClearsColorSchemeModeSubscription(t *testing.T) { | |
| 155 | + | s := NewScreen(80, 24) | |
| 156 | + | var got bytes.Buffer | |
| 157 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |
| 158 | + | ||
| 159 | + | s.Write([]byte("\x1b[?2031h")) | |
| 160 | + | s.Write([]byte("\x1bc")) | |
| 161 | + | ||
| 162 | + | s.SetColorScheme(true) | |
| 163 | + | if got.Len() != 0 { | |
| 164 | + | t.Fatalf("unsolicited response after reset = %q, want silence", got.String()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | got.Reset() | |
| 168 | + | s.Write([]byte("\x1b[?2031$p")) | |
| 169 | + | if got.String() != "\x1b[?2031;2$y" { | |
| 170 | + | t.Fatalf("DECRQM 2031 after reset = %q, want reset state", got.String()) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 154 | 174 | func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) { | |
| 155 | 175 | s := NewScreen(80, 24) | |
| 156 | 176 | var got bytes.Buffer | |
| @@ -151,6 +151,26 @@ func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) { | |||||
| 151 | } | 151 | } | ||
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | + | func TestScreenResetClearsColorSchemeModeSubscription(t *testing.T) { | |||
| 155 | + | s := NewScreen(80, 24) | |||
| 156 | + | var got bytes.Buffer | |||
| 157 | + | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 158 | + | ||||
| 159 | + | s.Write([]byte("\x1b[?2031h")) | |||
| 160 | + | s.Write([]byte("\x1bc")) | |||
| 161 | + | ||||
| 162 | + | s.SetColorScheme(true) | |||
| 163 | + | if got.Len() != 0 { | |||
| 164 | + | t.Fatalf("unsolicited response after reset = %q, want silence", got.String()) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | got.Reset() | |||
| 168 | + | s.Write([]byte("\x1b[?2031$p")) | |||
| 169 | + | if got.String() != "\x1b[?2031;2$y" { | |||
| 170 | + | t.Fatalf("DECRQM 2031 after reset = %q, want reset state", got.String()) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 154 | func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) { | 174 | func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) { | ||
| 155 | s := NewScreen(80, 24) | 175 | s := NewScreen(80, 24) | ||
| 156 | var got bytes.Buffer | 176 | var got bytes.Buffer | ||
M pkg/vt/screen.go +1 -0
| @@ -1037,6 +1037,7 @@ func (s *Screen) reset() { | |||
| 1037 | 1037 | s.mouseMode = 0 | |
| 1038 | 1038 | s.mouseSGR = false | |
| 1039 | 1039 | s.bracketedPaste = false | |
| 1040 | + | s.colorSchemeMode = false | |
| 1040 | 1041 | s.resetScrollRegion() | |
| 1041 | 1042 | s.fullRedraw() | |
| 1042 | 1043 | } | |
| @@ -1037,6 +1037,7 @@ func (s *Screen) reset() { | |||||
| 1037 | s.mouseMode = 0 | 1037 | s.mouseMode = 0 | ||
| 1038 | s.mouseSGR = false | 1038 | s.mouseSGR = false | ||
| 1039 | s.bracketedPaste = false | 1039 | s.bracketedPaste = false | ||
| 1040 | + | s.colorSchemeMode = false | |||
| 1040 | s.resetScrollRegion() | 1041 | s.resetScrollRegion() | ||
| 1041 | s.fullRedraw() | 1042 | s.fullRedraw() | ||
| 1042 | } | 1043 | } | ||