Merge pull request #108 from bnema/feat/accent-camaieu
feat(theme): add terminal accent camaïeu
75 files · +5833 -1968
M cmd/vev-perf-harness/launcher.go +6 -3
| @@ -628,9 +628,6 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte { | |||
| 628 | 628 | func (p *cliProcess) Close() error { | |
| 629 | 629 | var result error | |
| 630 | 630 | p.closed.Do(func() { | |
| 631 | - | if p.done != nil { | |
| 632 | - | close(p.done) | |
| 633 | - | } | |
| 634 | 631 | // A public terminal client owns a shell session. Ask that shell to exit | |
| 635 | 632 | // before closing the PTY so its transport can finish its receive span; | |
| 636 | 633 | // this avoids turning ordinary harness teardown into an aborted trace. | |
| @@ -654,6 +651,12 @@ func (p *cliProcess) Close() error { | |||
| 654 | 651 | case <-p.closeDeadline(): | |
| 655 | 652 | } | |
| 656 | 653 | } | |
| 654 | + | // Keep draining terminal output while the client exits gracefully. If the | |
| 655 | + | // drain stops first, restoration output can fill the PTY and block the | |
| 656 | + | // client before it closes its observed transport receive operation. | |
| 657 | + | if p.done != nil { | |
| 658 | + | close(p.done) | |
| 659 | + | } | |
| 657 | 660 | if p.pty != nil { | |
| 658 | 661 | _ = p.pty.Close() | |
| 659 | 662 | } | |
M cmd/vev-perf-harness/launcher_test.go +49 -0
| @@ -11,12 +11,61 @@ import ( | |||
| 11 | 11 | "os/exec" | |
| 12 | 12 | "path/filepath" | |
| 13 | 13 | "strings" | |
| 14 | + | "sync" | |
| 14 | 15 | "testing" | |
| 15 | 16 | "time" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/vev/pkg/safedir" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 21 | + | func TestCLIProcessCloseKeepsTerminalDrainActiveUntilClientExit(t *testing.T) { | |
| 22 | + | pty, err := os.CreateTemp(t.TempDir(), "pty") | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | waiting := make(chan struct{}) | |
| 27 | + | deadline := make(chan time.Time) | |
| 28 | + | waitErr := make(chan error) | |
| 29 | + | done := make(chan struct{}) | |
| 30 | + | closed := make(chan error, 1) | |
| 31 | + | var waitingOnce sync.Once | |
| 32 | + | p := &cliProcess{ | |
| 33 | + | pty: pty, | |
| 34 | + | done: done, | |
| 35 | + | waitErr: waitErr, | |
| 36 | + | waitTimeout: func() <-chan time.Time { | |
| 37 | + | waitingOnce.Do(func() { close(waiting) }) | |
| 38 | + | return deadline | |
| 39 | + | }, | |
| 40 | + | } | |
| 41 | + | go func() { closed <- p.Close() }() | |
| 42 | + | ||
| 43 | + | select { | |
| 44 | + | case <-waiting: | |
| 45 | + | case <-time.After(time.Second): | |
| 46 | + | t.Fatal("Close did not begin waiting for graceful client exit") | |
| 47 | + | } | |
| 48 | + | select { | |
| 49 | + | case <-done: | |
| 50 | + | t.Fatal("terminal drain stopped before the client exited") | |
| 51 | + | default: | |
| 52 | + | } | |
| 53 | + | waitErr <- nil | |
| 54 | + | select { | |
| 55 | + | case err := <-closed: | |
| 56 | + | if err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | case <-time.After(time.Second): | |
| 60 | + | t.Fatal("Close did not finish after the client exited") | |
| 61 | + | } | |
| 62 | + | select { | |
| 63 | + | case <-done: | |
| 64 | + | default: | |
| 65 | + | t.Fatal("terminal drain remained active after client exit") | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 20 | 69 | func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) { | |
| 21 | 70 | deadline := make(chan time.Time) | |
| 22 | 71 | close(deadline) | |
M docs/configuration.md +11 -3
| @@ -5,8 +5,10 @@ vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means d | |||
| 5 | 5 | ```text | |
| 6 | 6 | # Theme: auto follows the client; dark/light use neutral built-in defaults. | |
| 7 | 7 | theme = auto | |
| 8 | - | # In auto mode, inherit the terminal's ANSI blue for chrome accents. | |
| 8 | + | # In auto mode with palette inheritance enabled, infer a terminal accent. | |
| 9 | 9 | theme.palette = on | |
| 10 | + | # Accent policy: auto or one exact ANSI slot number from 0 through 15. | |
| 11 | + | theme.accent = auto | |
| 10 | 12 | ||
| 11 | 13 | # Palette placement: auto, center, top-left, top, top-right, left, right, | |
| 12 | 14 | # bottom-left, bottom, or bottom-right. | |
| @@ -66,11 +68,17 @@ code.rename-tab = RNT | |||
| 66 | 68 | code.detach = DET | |
| 67 | 69 | ``` | |
| 68 | 70 | ||
| 69 | - | Invalid values log a warning and keep the last valid value. | |
| 71 | + | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | |
| 70 | 72 | ||
| 71 | 73 | ## Theme | |
| 72 | 74 | ||
| 73 | - | With `theme = auto`, vev follows the terminal's foreground, background, light/dark scheme, and ANSI palette as the terminal reports changes. `theme.palette = on` is the default and uses the terminal's blue for chrome accents when available; vev falls back to neutral foreground/background blends when the color is unavailable or lacks contrast. Set it to `off` for blend-only styling. Forced `theme = dark` or `theme = light` modes are always blend-only. | |
| 75 | + | With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; repeated terminal colors are preferred, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`. | |
| 76 | + | ||
| 77 | + | When the terminal provides truecolor defaults and a usable RGB palette slot, vev uses the resolved accent for active chrome and derives softer bar, inactive, recent-session, and border colors from it. It preserves readability by reducing a surface intensity when needed. Terminal application/pane colors are not recolored. Without truecolor default colors or a usable RGB resolved accent, chrome backgrounds remain neutral; an available selected ANSI slot can still decorate foregrounds or borders. An explicit unavailable slot is never replaced with another slot. | |
| 78 | + | ||
| 79 | + | Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning because conventional neutral slots may provide little or no accent separation. An invalid accent value logs a warning and falls back to `auto`, including on hot reload. | |
| 80 | + | ||
| 81 | + | `theme.palette = off` is authoritative: it keeps exact neutral foreground/background rendering and ignores `theme.accent`. Forced `theme = dark` or `theme = light` is also neutral and ignores both palette and accent policy. Configuration reload immediately reapplies the current terminal snapshot; vev updates a live palette when the terminal reports a light/dark scheme change, but cannot detect palette changes a terminal does not report. | |
| 74 | 82 | ||
| 75 | 83 | ## Bindings | |
| 76 | 84 | ||
M internal/adapters/config/config.go +6 -0
| @@ -75,6 +75,12 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 75 | 75 | continue | |
| 76 | 76 | } | |
| 77 | 77 | cfg.ThemePalette = on | |
| 78 | + | case key == "theme.accent": | |
| 79 | + | accent, warning := parseThemeAccent(value) | |
| 80 | + | cfg.ThemeAccent = accent | |
| 81 | + | if warning != "" { | |
| 82 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning}) | |
| 83 | + | } | |
| 78 | 84 | case key == "bar.top-right": | |
| 79 | 85 | command, ok := parseBarCommand(value) | |
| 80 | 86 | if !ok { | |
M internal/adapters/config/config_test.go +56 -98
| @@ -7,12 +7,11 @@ import ( | |||
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "reflect" | |
| 9 | 9 | "strings" | |
| 10 | - | "sync" | |
| 11 | 10 | "testing" | |
| 12 | 11 | "time" | |
| 13 | 12 | ||
| 14 | 13 | "github.com/bnema/vev/internal/domain" | |
| 15 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 15 | "github.com/stretchr/testify/require" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| @@ -593,7 +592,7 @@ func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) { | |||
| 593 | 592 | t.Fatal(err) | |
| 594 | 593 | } | |
| 595 | 594 | ||
| 596 | - | clk := newFakeClock(time.Unix(0, 0)) | |
| 595 | + | clk := newWatchClockMock(t) | |
| 597 | 596 | ctx, cancel := context.WithCancel(context.Background()) | |
| 598 | 597 | defer cancel() | |
| 599 | 598 | ||
| @@ -604,16 +603,16 @@ func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) { | |||
| 604 | 603 | changes := make(chan change, 2) | |
| 605 | 604 | done := make(chan error, 1) | |
| 606 | 605 | go func() { | |
| 607 | - | done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 606 | + | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 608 | 607 | changes <- change{cfg: cfg, warnings: warnings} | |
| 609 | 608 | }) | |
| 610 | 609 | }() | |
| 611 | 610 | ||
| 612 | - | clk.waitForTimers(t, 1) | |
| 611 | + | clk.waitForTimer() | |
| 613 | 612 | if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil { | |
| 614 | 613 | t.Fatal(err) | |
| 615 | 614 | } | |
| 616 | - | clk.advance(2 * time.Second) | |
| 615 | + | clk.fire() | |
| 617 | 616 | ||
| 618 | 617 | var got change | |
| 619 | 618 | select { | |
| @@ -642,7 +641,7 @@ func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) { | |||
| 642 | 641 | t.Fatal(err) | |
| 643 | 642 | } | |
| 644 | 643 | ||
| 645 | - | clk := newFakeClock(time.Unix(0, 0)) | |
| 644 | + | clk := newWatchClockMock(t) | |
| 646 | 645 | ctx, cancel := context.WithCancel(context.Background()) | |
| 647 | 646 | defer cancel() | |
| 648 | 647 | ||
| @@ -653,17 +652,17 @@ func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) { | |||
| 653 | 652 | changes := make(chan change, 2) | |
| 654 | 653 | done := make(chan error, 1) | |
| 655 | 654 | go func() { | |
| 656 | - | done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 655 | + | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 657 | 656 | changes <- change{cfg: cfg, warnings: warnings} | |
| 658 | 657 | }) | |
| 659 | 658 | }() | |
| 660 | 659 | ||
| 661 | - | clk.waitForTimers(t, 1) | |
| 660 | + | clk.waitForTimer() | |
| 662 | 661 | tooLong := strings.Repeat("x", 70*1024) | |
| 663 | 662 | if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil { | |
| 664 | 663 | t.Fatal(err) | |
| 665 | 664 | } | |
| 666 | - | clk.advance(2 * time.Second) | |
| 665 | + | clk.fire() | |
| 667 | 666 | ||
| 668 | 667 | got := <-changes | |
| 669 | 668 | require.Equal(t, domain.Defaults(), got.cfg) | |
| @@ -679,23 +678,23 @@ func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) { | |||
| 679 | 678 | t.Parallel() | |
| 680 | 679 | ||
| 681 | 680 | path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000)) | |
| 682 | - | clk := newFakeClock(time.Unix(0, 0)) | |
| 681 | + | clk := newWatchClockMock(t) | |
| 683 | 682 | ctx, cancel := context.WithCancel(context.Background()) | |
| 684 | 683 | defer cancel() | |
| 685 | 684 | ||
| 686 | 685 | changes := make(chan []domain.Warning, 4) | |
| 687 | 686 | done := make(chan error, 1) | |
| 688 | 687 | go func() { | |
| 689 | - | done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) { | |
| 688 | + | done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) { | |
| 690 | 689 | changes <- warnings | |
| 691 | 690 | }) | |
| 692 | 691 | }() | |
| 693 | 692 | ||
| 694 | 693 | first := <-changes | |
| 695 | 694 | require.Len(t, first, 1) | |
| 696 | - | clk.waitForTimers(t, 1) | |
| 697 | - | clk.advance(2 * time.Second) | |
| 698 | - | clk.advance(2 * time.Second) | |
| 695 | + | clk.waitForTimer() | |
| 696 | + | clk.fire() | |
| 697 | + | clk.fire() | |
| 699 | 698 | ||
| 700 | 699 | select { | |
| 701 | 700 | case extra := <-changes: | |
| @@ -709,96 +708,55 @@ func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) { | |||
| 709 | 708 | } | |
| 710 | 709 | } | |
| 711 | 710 | ||
| 712 | - | var _ ports.Clock = (*fakeClock)(nil) | |
| 713 | - | var _ ports.Timer = (*fakeTimer)(nil) | |
| 714 | - | ||
| 715 | - | type fakeClock struct { | |
| 716 | - | mu sync.Mutex | |
| 717 | - | now time.Time | |
| 718 | - | timers []*fakeTimer | |
| 719 | - | notify chan struct{} | |
| 720 | - | } | |
| 721 | - | ||
| 722 | - | func newFakeClock(now time.Time) *fakeClock { | |
| 723 | - | return &fakeClock{now: now, notify: make(chan struct{}, 16)} | |
| 724 | - | } | |
| 725 | - | ||
| 726 | - | func (c *fakeClock) Now() time.Time { | |
| 727 | - | c.mu.Lock() | |
| 728 | - | defer c.mu.Unlock() | |
| 729 | - | return c.now | |
| 730 | - | } | |
| 731 | - | ||
| 732 | - | func (c *fakeClock) NewTimer(d time.Duration) ports.Timer { | |
| 733 | - | c.mu.Lock() | |
| 734 | - | defer c.mu.Unlock() | |
| 735 | - | ||
| 736 | - | t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true} | |
| 737 | - | c.timers = append(c.timers, t) | |
| 738 | - | c.notify <- struct{}{} | |
| 739 | - | return t | |
| 711 | + | type watchClockMock struct { | |
| 712 | + | t *testing.T | |
| 713 | + | clock *portsmocks.MockClock | |
| 714 | + | ticks chan time.Time | |
| 715 | + | ready chan struct{} | |
| 716 | + | resets chan struct{} | |
| 740 | 717 | } | |
| 741 | 718 | ||
| 742 | - | func (c *fakeClock) waitForTimers(t *testing.T, n int) { | |
| 719 | + | func newWatchClockMock(t *testing.T) *watchClockMock { | |
| 743 | 720 | t.Helper() | |
| 744 | - | deadline := time.After(2 * time.Second) | |
| 745 | - | for c.timerCount() < n { | |
| 746 | - | select { | |
| 747 | - | case <-c.notify: | |
| 748 | - | case <-deadline: | |
| 749 | - | t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount()) | |
| 750 | - | } | |
| 721 | + | timer := portsmocks.NewMockTimer(t) | |
| 722 | + | clock := portsmocks.NewMockClock(t) | |
| 723 | + | m := &watchClockMock{ | |
| 724 | + | t: t, | |
| 725 | + | clock: clock, | |
| 726 | + | ticks: make(chan time.Time), | |
| 727 | + | ready: make(chan struct{}), | |
| 728 | + | resets: make(chan struct{}), | |
| 751 | 729 | } | |
| 730 | + | timer.EXPECT().C().Return(m.ticks) | |
| 731 | + | timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) { | |
| 732 | + | m.resets <- struct{}{} | |
| 733 | + | }).Return(false) | |
| 734 | + | timer.EXPECT().Stop().Return(true) | |
| 735 | + | clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) { | |
| 736 | + | close(m.ready) | |
| 737 | + | }).Return(timer).Once() | |
| 738 | + | return m | |
| 752 | 739 | } | |
| 753 | 740 | ||
| 754 | - | func (c *fakeClock) timerCount() int { | |
| 755 | - | c.mu.Lock() | |
| 756 | - | defer c.mu.Unlock() | |
| 757 | - | return len(c.timers) | |
| 758 | - | } | |
| 759 | - | ||
| 760 | - | func (c *fakeClock) advance(d time.Duration) { | |
| 761 | - | c.mu.Lock() | |
| 762 | - | defer c.mu.Unlock() | |
| 763 | - | ||
| 764 | - | c.now = c.now.Add(d) | |
| 765 | - | for _, timer := range c.timers { | |
| 766 | - | timer.mu.Lock() | |
| 767 | - | if timer.active && !timer.next.After(c.now) { | |
| 768 | - | timer.ch <- c.now | |
| 769 | - | timer.active = false | |
| 770 | - | } | |
| 771 | - | timer.mu.Unlock() | |
| 741 | + | func (m *watchClockMock) waitForTimer() { | |
| 742 | + | m.t.Helper() | |
| 743 | + | select { | |
| 744 | + | case <-m.ready: | |
| 745 | + | case <-time.After(time.Second): | |
| 746 | + | m.t.Fatal("timed out waiting for watcher timer creation") | |
| 772 | 747 | } | |
| 773 | 748 | } | |
| 774 | 749 | ||
| 775 | - | type fakeTimer struct { | |
| 776 | - | clock *fakeClock | |
| 777 | - | mu sync.Mutex | |
| 778 | - | ch chan time.Time | |
| 779 | - | next time.Time | |
| 780 | - | active bool | |
| 781 | - | } | |
| 782 | - | ||
| 783 | - | func (t *fakeTimer) C() <-chan time.Time { return t.ch } | |
| 784 | - | ||
| 785 | - | func (t *fakeTimer) Reset(d time.Duration) bool { | |
| 786 | - | t.clock.mu.Lock() | |
| 787 | - | defer t.clock.mu.Unlock() | |
| 788 | - | t.mu.Lock() | |
| 789 | - | defer t.mu.Unlock() | |
| 790 | - | ||
| 791 | - | wasActive := t.active | |
| 792 | - | t.active = true | |
| 793 | - | t.next = t.clock.now.Add(d) | |
| 794 | - | return wasActive | |
| 795 | - | } | |
| 796 | - | ||
| 797 | - | func (t *fakeTimer) Stop() bool { | |
| 798 | - | t.mu.Lock() | |
| 799 | - | defer t.mu.Unlock() | |
| 800 | - | ||
| 801 | - | wasActive := t.active | |
| 802 | - | t.active = false | |
| 803 | - | return wasActive | |
| 750 | + | func (m *watchClockMock) fire() { | |
| 751 | + | m.t.Helper() | |
| 752 | + | select { | |
| 753 | + | case m.ticks <- time.Now(): | |
| 754 | + | case <-time.After(time.Second): | |
| 755 | + | m.t.Fatal("timed out firing watcher timer") | |
| 756 | + | } | |
| 757 | + | select { | |
| 758 | + | case <-m.resets: | |
| 759 | + | case <-time.After(time.Second): | |
| 760 | + | m.t.Fatal("timed out waiting for watcher timer reset") | |
| 761 | + | } | |
| 804 | 762 | } | |
M internal/adapters/config/palette_anchor_test.go +7 -7
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "testing" | |
| 9 | - | "time" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/vev/internal/domain" | |
| 12 | 11 | "github.com/stretchr/testify/require" | |
| @@ -50,21 +49,22 @@ func TestLoadMissingPaletteAnchorDefaultsToCenter(t *testing.T) { | |||
| 50 | 49 | func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) { | |
| 51 | 50 | path := filepath.Join(t.TempDir(), "config") | |
| 52 | 51 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600)) | |
| 53 | - | clk := newFakeClock(time.Unix(0, 0)) | |
| 52 | + | clk := newWatchClockMock(t) | |
| 54 | 53 | ctx, cancel := context.WithCancel(context.Background()) | |
| 55 | 54 | defer cancel() | |
| 56 | 55 | changes := make(chan domain.Config, 2) | |
| 57 | 56 | done := make(chan error, 1) | |
| 58 | - | go func() { done <- Watch(ctx, clk, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) }() | |
| 59 | - | clk.waitForTimers(t, 1) | |
| 57 | + | go func() { | |
| 58 | + | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) | |
| 59 | + | }() | |
| 60 | + | clk.waitForTimer() | |
| 60 | 61 | ||
| 61 | 62 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600)) | |
| 62 | - | clk.advance(2 * time.Second) | |
| 63 | + | clk.fire() | |
| 63 | 64 | require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor) | |
| 64 | 65 | ||
| 65 | 66 | require.NoError(t, os.Remove(path)) | |
| 66 | - | clk.waitForTimers(t, 1) | |
| 67 | - | clk.advance(2 * time.Second) | |
| 67 | + | clk.fire() | |
| 68 | 68 | got := <-changes | |
| 69 | 69 | require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.Palette) | |
| 70 | 70 | cancel() | |
A internal/adapters/config/theme_accent.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strconv" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func parseThemeAccent(value string) (domain.ThemeAccent, string) { | |
| 11 | + | if value == "auto" { | |
| 12 | + | return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, "" | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | slot, err := strconv.Atoi(value) | |
| 16 | + | if err != nil || slot < 0 || slot > 15 || strconv.Itoa(slot) != value { | |
| 17 | + | return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, fmt.Sprintf("invalid theme.accent %q", value) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | accent := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: uint8(slot)} | |
| 21 | + | switch slot { | |
| 22 | + | case 0, 7, 8, 15: | |
| 23 | + | return accent, fmt.Sprintf("theme.accent slot %d is conventionally neutral", slot) | |
| 24 | + | default: | |
| 25 | + | return accent, "" | |
| 26 | + | } | |
| 27 | + | } | |
A internal/adapters/config/theme_accent_test.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestParseThemeAccent(t *testing.T) { | |
| 17 | + | t.Parallel() | |
| 18 | + | ||
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | input string | |
| 22 | + | want domain.ThemeAccent | |
| 23 | + | warnings []domain.Warning | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "auto", | |
| 27 | + | input: "theme.accent = auto\n", | |
| 28 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "slot zero is valid but warns", | |
| 32 | + | input: "theme.accent = 0\n", | |
| 33 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0}, | |
| 34 | + | warnings: []domain.Warning{ | |
| 35 | + | {Line: 1, Msg: "theme.accent slot 0 is conventionally neutral"}, | |
| 36 | + | }, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "slot fifteen is valid but warns", | |
| 40 | + | input: "theme.accent = 15\n", | |
| 41 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15}, | |
| 42 | + | warnings: []domain.Warning{ | |
| 43 | + | {Line: 1, Msg: "theme.accent slot 15 is conventionally neutral"}, | |
| 44 | + | }, | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "chromatic slot", | |
| 48 | + | input: "theme.accent = 2\n", | |
| 49 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | name: "negative slot falls back to auto", | |
| 53 | + | input: "theme.accent = -1\n", | |
| 54 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 55 | + | warnings: []domain.Warning{ | |
| 56 | + | {Line: 1, Msg: "invalid theme.accent \"-1\""}, | |
| 57 | + | }, | |
| 58 | + | }, | |
| 59 | + | { | |
| 60 | + | name: "out of range slot falls back to auto", | |
| 61 | + | input: "theme.accent = 16\n", | |
| 62 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 63 | + | warnings: []domain.Warning{ | |
| 64 | + | {Line: 1, Msg: "invalid theme.accent \"16\""}, | |
| 65 | + | }, | |
| 66 | + | }, | |
| 67 | + | { | |
| 68 | + | name: "non numeric value falls back to auto", | |
| 69 | + | input: "theme.accent = teal\n", | |
| 70 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 71 | + | warnings: []domain.Warning{ | |
| 72 | + | {Line: 1, Msg: "invalid theme.accent \"teal\""}, | |
| 73 | + | }, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "non canonical slot falls back to auto", | |
| 77 | + | input: "theme.accent = 02\n", | |
| 78 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 79 | + | warnings: []domain.Warning{ | |
| 80 | + | {Line: 1, Msg: "invalid theme.accent \"02\""}, | |
| 81 | + | }, | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "case sensitive auto falls back to auto", | |
| 85 | + | input: "theme.accent = AUTO\n", | |
| 86 | + | want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 87 | + | warnings: []domain.Warning{ | |
| 88 | + | {Line: 1, Msg: "invalid theme.accent \"AUTO\""}, | |
| 89 | + | }, | |
| 90 | + | }, | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | for _, tt := range tests { | |
| 94 | + | t.Run(tt.name, func(t *testing.T) { | |
| 95 | + | t.Parallel() | |
| 96 | + | ||
| 97 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | require.Equal(t, tt.want, cfg.ThemeAccent) | |
| 100 | + | require.Equal(t, tt.warnings, warnings) | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestDefaultsThemeAccent(t *testing.T) { | |
| 106 | + | t.Parallel() | |
| 107 | + | ||
| 108 | + | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, domain.Defaults().ThemeAccent) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) { | |
| 112 | + | t.Parallel() | |
| 113 | + | ||
| 114 | + | path := filepath.Join(t.TempDir(), "config") | |
| 115 | + | require.NoError(t, os.WriteFile(path, []byte("theme.accent = 2\n"), 0o600)) | |
| 116 | + | initial, warnings, err := Load(path) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Empty(t, warnings) | |
| 119 | + | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent) | |
| 120 | + | ||
| 121 | + | clk := newWatchClockMock(t) | |
| 122 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 123 | + | defer cancel() | |
| 124 | + | type change struct { | |
| 125 | + | cfg domain.Config | |
| 126 | + | warnings []domain.Warning | |
| 127 | + | } | |
| 128 | + | changes := make(chan change, 1) | |
| 129 | + | done := make(chan error, 1) | |
| 130 | + | go func() { | |
| 131 | + | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 132 | + | changes <- change{cfg: cfg, warnings: warnings} | |
| 133 | + | }) | |
| 134 | + | }() | |
| 135 | + | ||
| 136 | + | clk.waitForTimer() | |
| 137 | + | require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600)) | |
| 138 | + | clk.fire() | |
| 139 | + | ||
| 140 | + | select { | |
| 141 | + | case got := <-changes: | |
| 142 | + | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent) | |
| 143 | + | require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings) | |
| 144 | + | case <-time.After(2 * time.Second): | |
| 145 | + | t.Fatal("timed out waiting for config reload") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | cancel() | |
| 149 | + | select { | |
| 150 | + | case err := <-done: | |
| 151 | + | require.True(t, errors.Is(err, context.Canceled)) | |
| 152 | + | case <-time.After(2 * time.Second): | |
| 153 | + | t.Fatal("timed out waiting for config watcher shutdown") | |
| 154 | + | } | |
| 155 | + | } | |
M internal/adapters/term/pty_integration_test.go +1 -1
| @@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 89 | 89 | ||
| 90 | 90 | select { | |
| 91 | 91 | case got := <-captured: | |
| 92 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 92 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 93 | 93 | if string(got) != want { | |
| 94 | 94 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 95 | 95 | } | |
M internal/adapters/term/terminal.go +1 -18
| @@ -31,9 +31,6 @@ const ( | |||
| 31 | 31 | bracketedPasteDisable = "\x1b[?2004l" | |
| 32 | 32 | colorSchemeEnable = "\x1b[?2031h" | |
| 33 | 33 | colorSchemeDisable = "\x1b[?2031l" | |
| 34 | - | oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | |
| 35 | - | oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" | |
| 36 | - | oscColorQuery = oscDefaultColorQuery + oscPaletteQuery | |
| 37 | 34 | ) | |
| 38 | 35 | ||
| 39 | 36 | // bufSize is the batched writer's buffer capacity. | |
| @@ -110,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 110 | 107 | } | |
| 111 | 108 | t.orig = old | |
| 112 | 109 | ||
| 113 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil { | |
| 110 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | |
| 114 | 111 | _ = t.restoreRawLocked() | |
| 115 | 112 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 116 | 113 | } | |
| @@ -237,20 +234,6 @@ func (t *Terminal) stopResizeLocked() { | |||
| 237 | 234 | t.resizeWG.Wait() | |
| 238 | 235 | } | |
| 239 | 236 | ||
| 240 | - | // QueryColors re-emits OSC 10/11 queries for default foreground/background | |
| 241 | - | // followed by one batched OSC 4 query for palette slots 0 through 15. | |
| 242 | - | func (t *Terminal) QueryColors() error { | |
| 243 | - | t.mu.Lock() | |
| 244 | - | defer t.mu.Unlock() | |
| 245 | - | if _, err := t.bw.WriteString(oscColorQuery); err != nil { | |
| 246 | - | return fmt.Errorf("term: query colors: %w", err) | |
| 247 | - | } | |
| 248 | - | if err := t.bw.Flush(); err != nil { | |
| 249 | - | return fmt.Errorf("term: query colors: %w", err) | |
| 250 | - | } | |
| 251 | - | return nil | |
| 252 | - | } | |
| 253 | - | ||
| 254 | 237 | // In returns the reader for the controlling terminal's input. | |
| 255 | 238 | func (t *Terminal) In() io.Reader { | |
| 256 | 239 | return t.in | |
M internal/adapters/term/terminal_test.go +7 -29
| @@ -10,8 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" | |
| 14 | - | ||
| 15 | 13 | // pipePair returns an os.Pipe and a goroutine that continuously copies | |
| 16 | 14 | // everything written to the write end into captured, closing done once | |
| 17 | 15 | // the write end is closed (EOF). | |
| @@ -62,10 +60,13 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 62 | 60 | _ = outW.Close() | |
| 63 | 61 | <-done | |
| 64 | 62 | ||
| 65 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 63 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 66 | 64 | if got := captured.String(); got != want { | |
| 67 | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 68 | 66 | } | |
| 67 | + | if got := captured.String(); bytes.Contains([]byte(got), []byte("\x1b]10;?")) || bytes.Contains([]byte(got), []byte("\x1b]4;")) { | |
| 68 | + | t.Fatalf("EnterRaw emitted a color query: %q", got) | |
| 69 | + | } | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |
| @@ -100,37 +101,14 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 100 | 101 | _ = outW.Close() | |
| 101 | 102 | <-done | |
| 102 | 103 | ||
| 103 | - | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | |
| 104 | - | // cursor/alt-screen exits exactly once, regardless of how many times | |
| 105 | - | // EnterRaw/restore were called. | |
| 106 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 104 | + | // Alt-screen/cursor escapes must appear exactly once for enter and exits | |
| 105 | + | // exactly once, regardless of how many times EnterRaw/restore were called. | |
| 106 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 107 | 107 | if got := captured.String(); got != want { | |
| 108 | 108 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 109 | 109 | } | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | - | func TestTerminal_QueryColors(t *testing.T) { | |
| 113 | - | inR, inW, err := os.Pipe() | |
| 114 | - | if err != nil { | |
| 115 | - | t.Fatalf("os.Pipe(in): %v", err) | |
| 116 | - | } | |
| 117 | - | defer func() { _ = inW.Close() }() | |
| 118 | - | defer func() { _ = inR.Close() }() | |
| 119 | - | ||
| 120 | - | outR, outW, captured, done := pipeCapture(t) | |
| 121 | - | defer func() { _ = outR.Close() }() | |
| 122 | - | ||
| 123 | - | tm := NewWithFiles(inR, outW) | |
| 124 | - | if err := tm.QueryColors(); err != nil { | |
| 125 | - | t.Fatalf("QueryColors: %v", err) | |
| 126 | - | } | |
| 127 | - | _ = outW.Close() | |
| 128 | - | <-done | |
| 129 | - | if got := captured.String(); got != wantColorQueries { | |
| 130 | - | t.Fatalf("captured = %q, want %q", got, wantColorQueries) | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | ||
| 134 | 112 | func TestTerminal_InOut(t *testing.T) { | |
| 135 | 113 | inR, inW, err := os.Pipe() | |
| 136 | 114 | if err != nil { | |
M internal/domain/config.go +2 -0
| @@ -82,6 +82,7 @@ type TabsConfig struct { | |||
| 82 | 82 | type Config struct { | |
| 83 | 83 | Theme ThemeMode | |
| 84 | 84 | ThemePalette bool | |
| 85 | + | ThemeAccent ThemeAccent | |
| 85 | 86 | Bar BarConfig | |
| 86 | 87 | BindingEntries []ConfigEntry | |
| 87 | 88 | Codes map[string]string | |
| @@ -104,6 +105,7 @@ func Defaults() Config { | |||
| 104 | 105 | return Config{ | |
| 105 | 106 | Theme: ThemeAuto, | |
| 106 | 107 | ThemePalette: true, | |
| 108 | + | ThemeAccent: ThemeAccent{Mode: ThemeAccentAuto}, | |
| 107 | 109 | Bar: BarConfig{ | |
| 108 | 110 | Interval: 5 * time.Second, | |
| 109 | 111 | }, | |
A internal/domain/theme_accent.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | // ThemeAccentMode selects whether vev infers a terminal accent or uses one | |
| 4 | + | // explicit ANSI slot. | |
| 5 | + | type ThemeAccentMode uint8 | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | // ThemeAccentAuto infers an accent from the terminal palette. | |
| 9 | + | ThemeAccentAuto ThemeAccentMode = iota | |
| 10 | + | // ThemeAccentSlot selects one explicit ANSI palette slot. | |
| 11 | + | ThemeAccentSlot | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ThemeAccent is the parsed terminal accent policy. Slot is meaningful only | |
| 15 | + | // when Mode is ThemeAccentSlot. | |
| 16 | + | type ThemeAccent struct { | |
| 17 | + | Mode ThemeAccentMode | |
| 18 | + | Slot uint8 | |
| 19 | + | } | |
M internal/ports/mocks/terminal_mock.go +0 -44
| @@ -229,50 +229,6 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina | |||
| 229 | 229 | return _c | |
| 230 | 230 | } | |
| 231 | 231 | ||
| 232 | - | // QueryColors provides a mock function for the type MockTerminal | |
| 233 | - | func (_mock *MockTerminal) QueryColors() error { | |
| 234 | - | ret := _mock.Called() | |
| 235 | - | ||
| 236 | - | if len(ret) == 0 { | |
| 237 | - | panic("no return value specified for QueryColors") | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | var r0 error | |
| 241 | - | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 242 | - | r0 = returnFunc() | |
| 243 | - | } else { | |
| 244 | - | r0 = ret.Error(0) | |
| 245 | - | } | |
| 246 | - | return r0 | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | // MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors' | |
| 250 | - | type MockTerminal_QueryColors_Call struct { | |
| 251 | - | *mock.Call | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | // QueryColors is a helper method to define mock.On call | |
| 255 | - | func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call { | |
| 256 | - | return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")} | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call { | |
| 260 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 261 | - | run() | |
| 262 | - | }) | |
| 263 | - | return _c | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call { | |
| 267 | - | _c.Call.Return(err) | |
| 268 | - | return _c | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call { | |
| 272 | - | _c.Call.Return(run) | |
| 273 | - | return _c | |
| 274 | - | } | |
| 275 | - | ||
| 276 | 232 | // ResizeEvents provides a mock function for the type MockTerminal | |
| 277 | 233 | func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size { | |
| 278 | 234 | ret := _mock.Called() | |
M internal/ports/terminal.go +0 -1
| @@ -11,7 +11,6 @@ type Terminal interface { | |||
| 11 | 11 | EnterRaw() (restore func() error, err error) | |
| 12 | 12 | Size() (domain.Size, error) | |
| 13 | 13 | ResizeEvents() <-chan domain.Size | |
| 14 | - | QueryColors() error | |
| 15 | 14 | In() io.Reader | |
| 16 | 15 | Out() io.Writer | |
| 17 | 16 | Flush() error | |
M internal/ports/wire_test.go +17 -0
| @@ -321,6 +321,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||
| 321 | 321 | assertTrailingGarbageFails(t, full, UnmarshalTheme) | |
| 322 | 322 | } | |
| 323 | 323 | ||
| 324 | + | func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) { | |
| 325 | + | // A generation clear retains defaults/capabilities but has no palette bits. | |
| 326 | + | // This literal payload locks the existing 57-byte Theme layout while the | |
| 327 | + | // full definitive-palette golden above locks the final publication. | |
| 328 | + | cleared := Theme{ | |
| 329 | + | HasForeground: true, | |
| 330 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 331 | + | HasBackground: true, | |
| 332 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 333 | + | TrueColor: true, | |
| 334 | + | SchemeKnown: true, | |
| 335 | + | } | |
| 336 | + | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 337 | + | require.Equal(t, want, MarshalTheme(cleared)) | |
| 338 | + | require.Equal(t, uint16(16), ProtocolVersion) | |
| 339 | + | } | |
| 340 | + | ||
| 324 | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| 325 | 342 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | |
| 326 | 343 | want := []byte{0x00, 0x64, 0x00, 0x28} | |
M internal/usecase/client/client.go +691 -260
| @@ -77,20 +77,6 @@ func (s *terminalThemeState) snapshot() ports.Theme { | |||
| 77 | 77 | return s.theme | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | // clearPalette invalidates entries from a previous terminal color query while | |
| 81 | - | // retaining independently reported foreground, background, and scheme data. | |
| 82 | - | func (s *terminalThemeState) clearPalette() ports.Theme { | |
| 83 | - | return s.update(func(theme *ports.Theme) { | |
| 84 | - | theme.PaletteKnown = 0 | |
| 85 | - | theme.Palette = [16]renderer.RGB{} | |
| 86 | - | }) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |
| 90 | - | theme := s.snapshot() | |
| 91 | - | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0 | |
| 92 | - | } | |
| 93 | - | ||
| 94 | 80 | // milestones tracks lifecycle progress so a failure can report which stages | |
| 95 | 81 | // were never reached — the key diagnostic when an attach dies early. | |
| 96 | 82 | type milestones struct { | |
| @@ -181,6 +167,9 @@ type Runner struct { | |||
| 181 | 167 | clipboard ports.ClipboardReader | |
| 182 | 168 | logger *slog.Logger | |
| 183 | 169 | runtimeObserver ports.SerializedRuntimeObserver | |
| 170 | + | ||
| 171 | + | inputMu sync.Mutex | |
| 172 | + | input *terminalInputPump | |
| 184 | 173 | } | |
| 185 | 174 | ||
| 186 | 175 | // NewRunner constructs a client runner. A nil logger uses the process default. | |
| @@ -199,6 +188,21 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 199 | 188 | } | |
| 200 | 189 | } | |
| 201 | 190 | ||
| 191 | + | // terminalInput returns the Runner-owned sole reader. It survives sequential | |
| 192 | + | // Run calls because a caller-owned reader may remain blocked after an attach | |
| 193 | + | // exits and therefore cannot safely be replaced by another reader. | |
| 194 | + | func (r *Runner) terminalInput() *terminalInputPump { | |
| 195 | + | r.inputMu.Lock() | |
| 196 | + | defer r.inputMu.Unlock() | |
| 197 | + | ||
| 198 | + | if r.input == nil || r.input.hasExited() || r.input.isClosed() { | |
| 199 | + | r.input = newTerminalInputPump(r.term.In()) | |
| 200 | + | r.input.start() | |
| 201 | + | } | |
| 202 | + | r.input.resume() | |
| 203 | + | return r.input | |
| 204 | + | } | |
| 205 | + | ||
| 202 | 206 | // Run connects and runs the attach client. It owns the terminal lifecycle | |
| 203 | 207 | // above attach attempts so raw mode remains active while a live client process | |
| 204 | 208 | // redials a lost link. | |
| @@ -244,6 +248,22 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 244 | 248 | rawEntered: &rawEntered, | |
| 245 | 249 | stage: reconnectStageOfflineRetrying, | |
| 246 | 250 | } | |
| 251 | + | var input *terminalInputPump | |
| 252 | + | defer func() { | |
| 253 | + | if input != nil { | |
| 254 | + | input.suspend() | |
| 255 | + | } | |
| 256 | + | }() | |
| 257 | + | originalEnterRaw := enterRaw | |
| 258 | + | enterRaw = func() error { | |
| 259 | + | if err := originalEnterRaw(); err != nil { | |
| 260 | + | return err | |
| 261 | + | } | |
| 262 | + | if input == nil { | |
| 263 | + | input = r.terminalInput() | |
| 264 | + | } | |
| 265 | + | return nil | |
| 266 | + | } | |
| 247 | 267 | ||
| 248 | 268 | for { | |
| 249 | 269 | transport, err := r.dialer.Dial(ctx) | |
| @@ -279,6 +299,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 279 | 299 | enterRaw: enterRaw, | |
| 280 | 300 | reconnect: reconnect, | |
| 281 | 301 | linkEvents: linkEvents, | |
| 302 | + | terminalInput: func() *terminalInputPump { | |
| 303 | + | return input | |
| 304 | + | }, | |
| 282 | 305 | }).run(ctx) | |
| 283 | 306 | if result.welcomed { | |
| 284 | 307 | backoff = defaultReconnectBackoff.initial | |
| @@ -418,16 +441,17 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time | |||
| 418 | 441 | } | |
| 419 | 442 | ||
| 420 | 443 | type attachAttempt struct { | |
| 421 | - | runner *Runner | |
| 422 | - | transport ports.Transport | |
| 423 | - | request AttachRequest | |
| 424 | - | resumeToken uint64 | |
| 425 | - | clientID [16]byte | |
| 426 | - | milestones *milestones | |
| 427 | - | themeState *terminalThemeState | |
| 428 | - | enterRaw func() error | |
| 429 | - | reconnect *reconnectUI | |
| 430 | - | linkEvents <-chan ports.LinkEvent | |
| 444 | + | runner *Runner | |
| 445 | + | transport ports.Transport | |
| 446 | + | request AttachRequest | |
| 447 | + | resumeToken uint64 | |
| 448 | + | clientID [16]byte | |
| 449 | + | milestones *milestones | |
| 450 | + | themeState *terminalThemeState | |
| 451 | + | enterRaw func() error | |
| 452 | + | reconnect *reconnectUI | |
| 453 | + | linkEvents <-chan ports.LinkEvent | |
| 454 | + | terminalInput func() *terminalInputPump | |
| 431 | 455 | } | |
| 432 | 456 | ||
| 433 | 457 | type attachResult struct { | |
| @@ -479,6 +503,16 @@ func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Tra | |||
| 479 | 503 | } | |
| 480 | 504 | } | |
| 481 | 505 | ||
| 506 | + | // paletteSlot validates the scanner's signed slot before narrowing it for the | |
| 507 | + | // wire accumulator. Keep this boundary even though the in-tree Scanner also | |
| 508 | + | // validates slots: callbacks are an interface boundary. | |
| 509 | + | func paletteSlot(slot int) (uint8, bool) { | |
| 510 | + | if slot < 0 || slot > 15 { | |
| 511 | + | return 0, false | |
| 512 | + | } | |
| 513 | + | return uint8(slot), true | |
| 514 | + | } | |
| 515 | + | ||
| 482 | 516 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | |
| 483 | 517 | func DetectTrueColor(termEnv, colorTerm string) bool { | |
| 484 | 518 | switch strings.ToLower(strings.TrimSpace(colorTerm)) { | |
| @@ -586,50 +620,160 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 586 | 620 | return welcomedResult(err) | |
| 587 | 621 | } | |
| 588 | 622 | reconnect.clear() | |
| 589 | - | reportedTheme, restoreTheme := themeState.reportedTheme() | |
| 590 | 623 | ||
| 591 | 624 | // 4. Derive a cancellable context so the pumps always unwind when the | |
| 592 | - | // loop returns. cancel runs first (LIFO): it signals the pumps; the | |
| 593 | - | // deferred Close above then unblocks any pump parked in a syscall. | |
| 625 | + | // loop returns. The attach loop is the sole terminal writer and palette | |
| 626 | + | // coordinator owner. | |
| 594 | 627 | loopCtx, cancel := context.WithCancel(ctx) | |
| 595 | 628 | defer cancel() | |
| 629 | + | input := (*terminalInputPump)(nil) | |
| 630 | + | if a.terminalInput != nil { | |
| 631 | + | input = a.terminalInput() | |
| 632 | + | } | |
| 633 | + | ownedInput := input == nil | |
| 634 | + | if ownedInput { | |
| 635 | + | input = newTerminalInputPump(term.In()) | |
| 636 | + | input.start() | |
| 637 | + | defer input.stop() | |
| 638 | + | } | |
| 639 | + | inputConsumer := input.claim() | |
| 640 | + | claimActive := true | |
| 641 | + | revokeInputClaim := func() { | |
| 642 | + | if claimActive { | |
| 643 | + | input.revoke(inputConsumer) | |
| 644 | + | claimActive = false | |
| 645 | + | } | |
| 646 | + | } | |
| 647 | + | // Register revocation before any palette publication or terminal I/O: both | |
| 648 | + | // can fail before the stdin scanner is started, and a reconnect must still | |
| 649 | + | // be able to claim this lifecycle-owned reader. | |
| 650 | + | defer revokeInputClaim() | |
| 596 | 651 | ||
| 597 | 652 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 598 | - | if restoreTheme { | |
| 599 | - | sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)} | |
| 600 | - | } | |
| 601 | 653 | ackQueue := newCumulativeAckQueue() | |
| 602 | 654 | sendErrCh := make(chan error, 1) | |
| 603 | - | ||
| 604 | - | // Palette re-queries use a private-mode query as an ordered boundary. The | |
| 605 | - | // stdin pump drains terminal replies through its mode-specific response | |
| 606 | - | // before it permits the main loop to publish the cleared scheme and issue | |
| 607 | - | // QueryColors. This separates bytes already queued (including | |
| 608 | - | // Scanner.pending) from replacement replies without allowing a second | |
| 609 | - | // goroutine to write to the terminal. | |
| 610 | - | colorQueryCh := make(chan colorQueryRequest) | |
| 611 | - | paletteReadyCh := make(chan paletteQueryReady) | |
| 612 | - | requestColors := func() (colorQueryRequest, bool) { | |
| 613 | - | request := colorQueryRequest{boundaryWritten: make(chan struct{})} | |
| 614 | - | select { | |
| 615 | - | case colorQueryCh <- request: | |
| 616 | - | case <-loopCtx.Done(): | |
| 617 | - | return colorQueryRequest{}, false | |
| 655 | + | senderStarted := false | |
| 656 | + | paletteEvents := make(chan paletteGenerationEvent, 32) | |
| 657 | + | var activeGeneration atomic.Uint64 | |
| 658 | + | coordinator := newPaletteGenerationCoordinator() | |
| 659 | + | type paletteTimer struct { | |
| 660 | + | timer ports.Timer | |
| 661 | + | cancel chan struct{} | |
| 662 | + | } | |
| 663 | + | drainTimers := map[paletteGenerationID]paletteTimer{} | |
| 664 | + | completionTimers := map[paletteGenerationID]paletteTimer{} | |
| 665 | + | cancelTimer := func(timers map[paletteGenerationID]paletteTimer, id paletteGenerationID) { | |
| 666 | + | if timer, ok := timers[id]; ok { | |
| 667 | + | timer.timer.Stop() | |
| 668 | + | close(timer.cancel) | |
| 669 | + | delete(timers, id) | |
| 670 | + | } | |
| 671 | + | } | |
| 672 | + | publish := func(snapshot ports.Theme) error { | |
| 673 | + | frame := ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)} | |
| 674 | + | // The initial cleared snapshot is sent before the sender exists. This | |
| 675 | + | // keeps the generation publication ordered before the first query and | |
| 676 | + | // avoids leaving a queued Theme behind an immediate detach. | |
| 677 | + | if !senderStarted { | |
| 678 | + | if err := transport.Send(frame); err != nil { | |
| 679 | + | return fmt.Errorf("sending initial theme: %w", err) | |
| 680 | + | } | |
| 681 | + | return nil | |
| 618 | 682 | } | |
| 619 | 683 | select { | |
| 620 | - | case <-request.boundaryWritten: | |
| 621 | - | return request, true | |
| 684 | + | case sendCh <- frame: | |
| 685 | + | return nil | |
| 622 | 686 | case <-loopCtx.Done(): | |
| 623 | - | return colorQueryRequest{}, false | |
| 687 | + | return context.Canceled | |
| 624 | 688 | } | |
| 625 | 689 | } | |
| 690 | + | processPaletteActions := func(actions []paletteGenerationAction) error { | |
| 691 | + | for _, action := range actions { | |
| 692 | + | switch action.kind { | |
| 693 | + | case paletteActionPublishCleared: | |
| 694 | + | if err := publish(action.theme); err != nil { | |
| 695 | + | return fmt.Errorf("publishing theme: %w", err) | |
| 696 | + | } | |
| 697 | + | case paletteActionPublishFinal: | |
| 698 | + | themeState.update(func(current *ports.Theme) { *current = action.theme }) | |
| 699 | + | if err := publish(action.theme); err != nil { | |
| 700 | + | return fmt.Errorf("publishing theme: %w", err) | |
| 701 | + | } | |
| 702 | + | case paletteActionWriteDrain, paletteActionWriteBatch: | |
| 703 | + | if _, err := term.Out().Write([]byte(action.bytes)); err != nil { | |
| 704 | + | return fmt.Errorf("writing palette query: %w", err) | |
| 705 | + | } | |
| 706 | + | if err := term.Flush(); err != nil { | |
| 707 | + | return fmt.Errorf("flushing palette query: %w", err) | |
| 708 | + | } | |
| 709 | + | case paletteActionArmDrainDeadline, paletteActionArmCompletionDeadline: | |
| 710 | + | timer := clk.NewTimer(action.deadline) | |
| 711 | + | entry := paletteTimer{timer: timer, cancel: make(chan struct{})} | |
| 712 | + | timers := drainTimers | |
| 713 | + | kind := paletteEventDrainDeadline | |
| 714 | + | if action.kind == paletteActionArmCompletionDeadline { | |
| 715 | + | timers = completionTimers | |
| 716 | + | kind = paletteEventCompletionDeadline | |
| 717 | + | } | |
| 718 | + | timers[action.id] = entry | |
| 719 | + | go func(id paletteGenerationID, eventKind paletteGenerationEventKind, timer paletteTimer) { | |
| 720 | + | select { | |
| 721 | + | case <-timer.timer.C(): | |
| 722 | + | select { | |
| 723 | + | case paletteEvents <- paletteGenerationEvent{id: id, kind: eventKind}: | |
| 724 | + | case <-loopCtx.Done(): | |
| 725 | + | } | |
| 726 | + | case <-timer.cancel: | |
| 727 | + | case <-loopCtx.Done(): | |
| 728 | + | } | |
| 729 | + | }(action.id, kind, entry) | |
| 730 | + | case paletteActionCancelDrainDeadline: | |
| 731 | + | cancelTimer(drainTimers, action.id) | |
| 732 | + | case paletteActionCancelCompletionDeadline: | |
| 733 | + | cancelTimer(completionTimers, action.id) | |
| 734 | + | } | |
| 735 | + | } | |
| 736 | + | return nil | |
| 737 | + | } | |
| 738 | + | defer func() { | |
| 739 | + | for id := range drainTimers { | |
| 740 | + | cancelTimer(drainTimers, id) | |
| 741 | + | } | |
| 742 | + | for id := range completionTimers { | |
| 743 | + | cancelTimer(completionTimers, id) | |
| 744 | + | } | |
| 745 | + | }() | |
| 746 | + | ||
| 747 | + | // Initial acquisition is a generation too. A reconnect publishes its one | |
| 748 | + | // cleared snapshot from retained definitive colors, never a restore frame. | |
| 749 | + | retained := themeState.snapshot() | |
| 750 | + | initialActions := coordinator.start(retained, false) | |
| 751 | + | activeGeneration.Store(uint64(coordinator.current.id)) | |
| 752 | + | if err := processPaletteActions(initialActions); err != nil { | |
| 753 | + | return welcomedResult(err) | |
| 754 | + | } | |
| 626 | 755 | ||
| 627 | 756 | var clip ports.ClipboardReader | |
| 628 | 757 | if remote { | |
| 629 | 758 | clip = clipboard | |
| 630 | 759 | } | |
| 760 | + | senderStarted = true | |
| 631 | 761 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | |
| 632 | - | go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run() | |
| 762 | + | stdinDone := make(chan struct{}) | |
| 763 | + | go func() { | |
| 764 | + | defer close(stdinDone) | |
| 765 | + | (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run() | |
| 766 | + | }() | |
| 767 | + | // Scanner cancellation can leave an undecided marker suffix (including a | |
| 768 | + | // standalone Escape) that it must hand back to the lifecycle-owned reader. | |
| 769 | + | // Wait for that handoff before revoking the claim: otherwise a replacement | |
| 770 | + | // scanner could claim the reader between revocation and preservation and | |
| 771 | + | // lose the suffix. | |
| 772 | + | defer func() { | |
| 773 | + | cancel() | |
| 774 | + | <-stdinDone | |
| 775 | + | revokeInputClaim() | |
| 776 | + | }() | |
| 633 | 777 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 634 | 778 | ||
| 635 | 779 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -668,29 +812,22 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 668 | 812 | // token. Run closes this transport on the way out. | |
| 669 | 813 | return welcomedResult(errLinkOffline) | |
| 670 | 814 | } | |
| 671 | - | case request := <-colorQueryCh: | |
| 672 | - | if _, err := term.Out().Write(paletteBoundaryQuery); err != nil { | |
| 673 | - | return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err)) | |
| 674 | - | } | |
| 675 | - | if err := term.Flush(); err != nil { | |
| 676 | - | return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err)) | |
| 677 | - | } | |
| 678 | - | close(request.boundaryWritten) | |
| 679 | - | case ready := <-paletteReadyCh: | |
| 680 | - | // The private-mode response proves all earlier replies have been | |
| 681 | - | // discarded by stdin. Publish the one cleared scheme snapshot before | |
| 682 | - | // emitting the query; stdin remains paused until QueryColors has | |
| 683 | - | // completed below. | |
| 684 | - | clearedTheme := themeState.snapshot() | |
| 685 | - | select { | |
| 686 | - | case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}: | |
| 687 | - | case <-loopCtx.Done(): | |
| 688 | - | return welcomedResult(nil) | |
| 815 | + | case event := <-paletteEvents: | |
| 816 | + | if event.kind == paletteEventScheme { | |
| 817 | + | retained := themeState.update(func(current *ports.Theme) { | |
| 818 | + | current.SchemeKnown = true | |
| 819 | + | current.Light = event.light | |
| 820 | + | }) | |
| 821 | + | actions := coordinator.start(retained, true) | |
| 822 | + | activeGeneration.Store(uint64(coordinator.current.id)) | |
| 823 | + | if err := processPaletteActions(actions); err != nil { | |
| 824 | + | return welcomedResult(err) | |
| 825 | + | } | |
| 826 | + | continue | |
| 689 | 827 | } | |
| 690 | - | if err := term.QueryColors(); err != nil { | |
| 691 | - | log.Warn("querying terminal colors", "err", err) | |
| 828 | + | if err := processPaletteActions(coordinator.handle(event)); err != nil { | |
| 829 | + | return welcomedResult(err) | |
| 692 | 830 | } | |
| 693 | - | close(ready.queryIssued) | |
| 694 | 831 | case r := <-recvCh: | |
| 695 | 832 | if r.err != nil { | |
| 696 | 833 | if errors.Is(r.err, io.EOF) { | |
| @@ -838,160 +975,397 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 838 | 975 | } | |
| 839 | 976 | } | |
| 840 | 977 | ||
| 841 | - | // stdinPump pumps terminal input to the daemon as Input frames. A read error | |
| 842 | - | // or EOF is treated as a detach: it best-effort sends a Detach frame and | |
| 843 | - | // cancels the loop. | |
| 844 | - | // | |
| 845 | - | // Known MVP limitation: a bare io.Reader cannot be unblocked from outside, | |
| 846 | - | // so on shutdown initiated elsewhere (daemon detach, transport loss) this | |
| 847 | - | // goroutine stays parked in Read until the next byte arrives or the process | |
| 848 | - | // exits. That is harmless here — Run has already returned and restored the | |
| 849 | - | // terminal — and matches the standard pattern for stdin pumps; a | |
| 850 | - | // closable stdin duplicate could lift it later if ever needed. | |
| 851 | - | type colorQueryRequest struct { | |
| 852 | - | boundaryWritten chan struct{} | |
| 978 | + | // terminalReadResult is copied by terminalInputPump before the next Read so | |
| 979 | + | // terminal input remains owned by one lifecycle reader across reconnects. | |
| 980 | + | type terminalReadResult struct { | |
| 981 | + | data []byte | |
| 982 | + | err error | |
| 853 | 983 | } | |
| 854 | 984 | ||
| 855 | - | type paletteQueryReady struct { | |
| 856 | - | queryIssued chan struct{} | |
| 985 | + | // terminalInputPump is the sole consumer of the caller-owned terminal reader. | |
| 986 | + | // It is started once after raw mode is entered and is reused by each attach | |
| 987 | + | // scanner. A bare io.Reader cannot be interrupted, so a Read may stay blocked | |
| 988 | + | // after Run exits; suspend drops any result received while inactive and never | |
| 989 | + | // closes caller-owned stdin. Reconnects and later runs never add another reader. | |
| 990 | + | type terminalInputPump struct { | |
| 991 | + | in io.Reader | |
| 992 | + | done chan struct{} | |
| 993 | + | ||
| 994 | + | mu sync.Mutex | |
| 995 | + | // residual holds scanner bytes that were read but not delivered when an | |
| 996 | + | // attempt is cancelled. It is consumed before the next terminal Read so a | |
| 997 | + | // replacement scanner neither loses a partial marker nor replays bytes | |
| 998 | + | // whose callbacks already ran. | |
| 999 | + | residual []byte | |
| 1000 | + | pending *terminalReadResult | |
| 1001 | + | consumer uint64 | |
| 1002 | + | delivering uint64 | |
| 1003 | + | deliveringResidual bool | |
| 1004 | + | nextID uint64 | |
| 1005 | + | activation uint64 | |
| 1006 | + | closed bool | |
| 1007 | + | active bool | |
| 1008 | + | ready chan struct{} | |
| 1009 | + | space chan struct{} | |
| 1010 | + | state chan struct{} | |
| 1011 | + | exited chan struct{} | |
| 1012 | + | afterRevoke func() // test synchronization hook | |
| 1013 | + | startMu sync.Once | |
| 1014 | + | stopMu sync.Once | |
| 857 | 1015 | } | |
| 858 | 1016 | ||
| 859 | - | type paletteDrain struct { | |
| 860 | - | pending []byte | |
| 1017 | + | func newTerminalInputPump(in io.Reader) *terminalInputPump { | |
| 1018 | + | space := make(chan struct{}, 1) | |
| 1019 | + | space <- struct{}{} | |
| 1020 | + | return &terminalInputPump{ | |
| 1021 | + | in: in, | |
| 1022 | + | done: make(chan struct{}), | |
| 1023 | + | activation: 1, | |
| 1024 | + | active: true, | |
| 1025 | + | ready: make(chan struct{}, 1), | |
| 1026 | + | space: space, | |
| 1027 | + | state: make(chan struct{}, 1), | |
| 1028 | + | exited: make(chan struct{}), | |
| 1029 | + | } | |
| 861 | 1030 | } | |
| 862 | 1031 | ||
| 863 | - | type palettePhase uint8 | |
| 1032 | + | // claim grants one attach scanner exclusive permission to dequeue terminal | |
| 1033 | + | // input. The reader itself remains lifecycle-owned across reconnects. | |
| 1034 | + | func (p *terminalInputPump) claim() uint64 { | |
| 1035 | + | p.mu.Lock() | |
| 1036 | + | defer p.mu.Unlock() | |
| 1037 | + | if p.consumer != 0 { | |
| 1038 | + | panic("terminal input consumer already claimed") | |
| 1039 | + | } | |
| 1040 | + | p.nextID++ | |
| 1041 | + | p.consumer = p.nextID | |
| 1042 | + | return p.consumer | |
| 1043 | + | } | |
| 864 | 1044 | ||
| 865 | - | const ( | |
| 866 | - | paletteAccepting palettePhase = iota | |
| 867 | - | paletteBoundaryRequested | |
| 868 | - | paletteDraining | |
| 869 | - | paletteAwaitingQuery | |
| 870 | - | ) | |
| 1045 | + | // revoke invalidates an attempt before its replacement is allowed to claim | |
| 1046 | + | // input. Pending bytes are deliberately retained for that replacement. | |
| 1047 | + | func (p *terminalInputPump) revoke(consumer uint64) { | |
| 1048 | + | p.mu.Lock() | |
| 1049 | + | if p.consumer == consumer { | |
| 1050 | + | p.consumer = 0 | |
| 1051 | + | } | |
| 1052 | + | // An unacknowledged read was never delivered by this scanner. Leave it | |
| 1053 | + | // pending and make it available to its replacement. | |
| 1054 | + | if p.delivering == consumer { | |
| 1055 | + | p.delivering = 0 | |
| 1056 | + | p.deliveringResidual = false | |
| 1057 | + | } | |
| 1058 | + | p.mu.Unlock() | |
| 1059 | + | p.signalReady() | |
| 1060 | + | if p.afterRevoke != nil { | |
| 1061 | + | p.afterRevoke() | |
| 1062 | + | } | |
| 1063 | + | } | |
| 871 | 1064 | ||
| 872 | - | var paletteBoundaryQuery = []byte("\x1b[?2031$p") | |
| 873 | - | ||
| 874 | - | // paletteBoundaryResponses accepts valid DECRQM status values for private | |
| 875 | - | // mode 2031. The private mode number correlates this reply with the query | |
| 876 | - | // that starts a replacement palette generation. | |
| 877 | - | var paletteBoundaryResponses = [][]byte{ | |
| 878 | - | []byte("\x1b[?2031;0$y"), | |
| 879 | - | []byte("\x1b[?2031;1$y"), | |
| 880 | - | []byte("\x1b[?2031;2$y"), | |
| 881 | - | []byte("\x1b[?2031;3$y"), | |
| 882 | - | []byte("\x1b[?2031;4$y"), | |
| 1065 | + | func (p *terminalInputPump) signalReady() { | |
| 1066 | + | select { | |
| 1067 | + | case p.ready <- struct{}{}: | |
| 1068 | + | default: | |
| 1069 | + | } | |
| 883 | 1070 | } | |
| 884 | 1071 | ||
| 885 | - | // scan consumes the private-mode boundary response while forwarding every | |
| 886 | - | // other byte in order. It retains only a possible response prefix, so split | |
| 887 | - | // replies are consumed without dropping a user's partial escape sequence on | |
| 888 | - | // cancellation. | |
| 889 | - | func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) { | |
| 890 | - | for _, b := range data { | |
| 891 | - | d.pending = append(d.pending, b) | |
| 892 | - | for len(d.pending) > 0 { | |
| 893 | - | matched := false | |
| 894 | - | possible := false | |
| 895 | - | for _, response := range paletteBoundaryResponses { | |
| 896 | - | if len(d.pending) > len(response) { | |
| 897 | - | continue | |
| 1072 | + | // enqueue retains at most one completed terminal read. The reader blocks | |
| 1073 | + | // before publishing another result, keeping handoff buffering bounded. | |
| 1074 | + | func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64) bool { | |
| 1075 | + | select { | |
| 1076 | + | case <-p.done: | |
| 1077 | + | return false | |
| 1078 | + | case <-p.space: | |
| 1079 | + | } | |
| 1080 | + | p.mu.Lock() | |
| 1081 | + | if p.closed || !p.active || p.activation != activation { | |
| 1082 | + | p.mu.Unlock() | |
| 1083 | + | p.space <- struct{}{} | |
| 1084 | + | return false | |
| 1085 | + | } | |
| 1086 | + | p.pending = &result | |
| 1087 | + | p.mu.Unlock() | |
| 1088 | + | p.signalReady() | |
| 1089 | + | return true | |
| 1090 | + | } | |
| 1091 | + | ||
| 1092 | + | // take leases a ready result only to the current, non-cancelled consumer. | |
| 1093 | + | // The raw read remains pending until ack, so revocation or cancellation before | |
| 1094 | + | // scanner delivery leaves the exact bytes available to the next attempt. | |
| 1095 | + | func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) { | |
| 1096 | + | p.mu.Lock() | |
| 1097 | + | if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 { | |
| 1098 | + | ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0 | |
| 1099 | + | p.mu.Unlock() | |
| 1100 | + | if ready { | |
| 1101 | + | p.signalReady() | |
| 1102 | + | } | |
| 1103 | + | return terminalReadResult{}, false | |
| 1104 | + | } | |
| 1105 | + | if len(p.residual) != 0 { | |
| 1106 | + | result := terminalReadResult{data: append([]byte(nil), p.residual...)} | |
| 1107 | + | p.delivering = consumer | |
| 1108 | + | p.deliveringResidual = true | |
| 1109 | + | p.mu.Unlock() | |
| 1110 | + | return result, true | |
| 1111 | + | } | |
| 1112 | + | if p.pending == nil { | |
| 1113 | + | p.mu.Unlock() | |
| 1114 | + | return terminalReadResult{}, false | |
| 1115 | + | } | |
| 1116 | + | result := *p.pending | |
| 1117 | + | p.delivering = consumer | |
| 1118 | + | p.deliveringResidual = false | |
| 1119 | + | p.mu.Unlock() | |
| 1120 | + | return result, true | |
| 1121 | + | } | |
| 1122 | + | ||
| 1123 | + | // preserveResidual records the undecided marker prefix after callbacks for the | |
| 1124 | + | // rest of its read have completed. It is intentionally bounded by the marker | |
| 1125 | + | // scanner's fixed response prefixes and is replayed before later terminal | |
| 1126 | + | // reads on a replacement attempt. | |
| 1127 | + | func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) { | |
| 1128 | + | if len(data) == 0 { | |
| 1129 | + | return | |
| 1130 | + | } | |
| 1131 | + | p.mu.Lock() | |
| 1132 | + | defer p.mu.Unlock() | |
| 1133 | + | if p.consumer != consumer { | |
| 1134 | + | return | |
| 1135 | + | } | |
| 1136 | + | p.residual = append(p.residual[:0], data...) | |
| 1137 | + | } | |
| 1138 | + | ||
| 1139 | + | // ack commits scanner delivery of a leased read and lets the lifecycle reader | |
| 1140 | + | // accept the next result. Only the consumer that holds the lease can ack it. | |
| 1141 | + | func (p *terminalInputPump) ack(consumer uint64) { | |
| 1142 | + | p.mu.Lock() | |
| 1143 | + | if p.consumer != consumer || p.delivering != consumer { | |
| 1144 | + | p.mu.Unlock() | |
| 1145 | + | return | |
| 1146 | + | } | |
| 1147 | + | wasResidual := p.deliveringResidual | |
| 1148 | + | if wasResidual { | |
| 1149 | + | p.residual = nil | |
| 1150 | + | } else { | |
| 1151 | + | p.pending = nil | |
| 1152 | + | } | |
| 1153 | + | p.delivering = 0 | |
| 1154 | + | p.deliveringResidual = false | |
| 1155 | + | more := len(p.residual) != 0 || p.pending != nil | |
| 1156 | + | p.mu.Unlock() | |
| 1157 | + | if wasResidual { | |
| 1158 | + | if more { | |
| 1159 | + | p.signalReady() | |
| 1160 | + | } | |
| 1161 | + | return | |
| 1162 | + | } | |
| 1163 | + | p.space <- struct{}{} | |
| 1164 | + | } | |
| 1165 | + | ||
| 1166 | + | func (p *terminalInputPump) finish() { | |
| 1167 | + | p.mu.Lock() | |
| 1168 | + | p.closed = true | |
| 1169 | + | p.mu.Unlock() | |
| 1170 | + | p.signalReady() | |
| 1171 | + | p.signalState() | |
| 1172 | + | } | |
| 1173 | + | ||
| 1174 | + | func (p *terminalInputPump) signalState() { | |
| 1175 | + | select { | |
| 1176 | + | case p.state <- struct{}{}: | |
| 1177 | + | default: | |
| 1178 | + | } | |
| 1179 | + | } | |
| 1180 | + | ||
| 1181 | + | func (p *terminalInputPump) waitForActive() (uint64, bool) { | |
| 1182 | + | for { | |
| 1183 | + | p.mu.Lock() | |
| 1184 | + | active, closed, activation := p.active, p.closed, p.activation | |
| 1185 | + | p.mu.Unlock() | |
| 1186 | + | if closed { | |
| 1187 | + | return 0, false | |
| 1188 | + | } | |
| 1189 | + | if active { | |
| 1190 | + | return activation, true | |
| 1191 | + | } | |
| 1192 | + | select { | |
| 1193 | + | case <-p.done: | |
| 1194 | + | return 0, false | |
| 1195 | + | case <-p.state: | |
| 1196 | + | } | |
| 1197 | + | } | |
| 1198 | + | } | |
| 1199 | + | ||
| 1200 | + | func (p *terminalInputPump) start() { | |
| 1201 | + | p.startMu.Do(func() { | |
| 1202 | + | go func() { | |
| 1203 | + | defer close(p.exited) | |
| 1204 | + | buf := make([]byte, stdinBufSize) | |
| 1205 | + | for { | |
| 1206 | + | activation, ok := p.waitForActive() | |
| 1207 | + | if !ok { | |
| 1208 | + | return | |
| 898 | 1209 | } | |
| 899 | - | match := true | |
| 900 | - | for i := range d.pending { | |
| 901 | - | if d.pending[i] != response[i] { | |
| 902 | - | match = false | |
| 903 | - | break | |
| 904 | - | } | |
| 1210 | + | n, err := p.in.Read(buf) | |
| 1211 | + | result := terminalReadResult{err: err} | |
| 1212 | + | if n > 0 { | |
| 1213 | + | result.data = append([]byte(nil), buf[:n]...) | |
| 905 | 1214 | } | |
| 906 | - | if !match { | |
| 1215 | + | if !p.enqueue(result, activation) { | |
| 1216 | + | if p.isClosed() { | |
| 1217 | + | return | |
| 1218 | + | } | |
| 907 | 1219 | continue | |
| 908 | 1220 | } | |
| 909 | - | if len(d.pending) == len(response) { | |
| 910 | - | matched = true | |
| 911 | - | } else { | |
| 912 | - | possible = true | |
| 1221 | + | if err != nil { | |
| 1222 | + | p.finish() | |
| 1223 | + | return | |
| 913 | 1224 | } | |
| 914 | 1225 | } | |
| 915 | - | if matched { | |
| 916 | - | d.pending = nil | |
| 917 | - | onBoundary() | |
| 918 | - | break | |
| 919 | - | } | |
| 920 | - | if possible { | |
| 921 | - | break | |
| 922 | - | } | |
| 923 | - | onBytes(d.pending[:1]) | |
| 924 | - | d.pending = d.pending[1:] | |
| 1226 | + | }() | |
| 1227 | + | }) | |
| 1228 | + | } | |
| 1229 | + | ||
| 1230 | + | func (p *terminalInputPump) hasExited() bool { | |
| 1231 | + | select { | |
| 1232 | + | case <-p.exited: | |
| 1233 | + | return true | |
| 1234 | + | default: | |
| 1235 | + | return false | |
| 1236 | + | } | |
| 1237 | + | } | |
| 1238 | + | ||
| 1239 | + | func (p *terminalInputPump) isClosed() bool { | |
| 1240 | + | p.mu.Lock() | |
| 1241 | + | defer p.mu.Unlock() | |
| 1242 | + | return p.closed | |
| 1243 | + | } | |
| 1244 | + | ||
| 1245 | + | // suspend drops input completed after an attach ends and parks the lifecycle | |
| 1246 | + | // reader before its next Read. A later Run resumes this same reader, avoiding | |
| 1247 | + | // both input replay between runs and a competing read on caller-owned stdin. | |
| 1248 | + | func (p *terminalInputPump) suspend() { | |
| 1249 | + | p.mu.Lock() | |
| 1250 | + | p.active = false | |
| 1251 | + | discarded := p.pending != nil | |
| 1252 | + | p.residual = nil | |
| 1253 | + | p.pending = nil | |
| 1254 | + | p.delivering = 0 | |
| 1255 | + | p.deliveringResidual = false | |
| 1256 | + | p.mu.Unlock() | |
| 1257 | + | if discarded { | |
| 1258 | + | select { | |
| 1259 | + | case p.space <- struct{}{}: | |
| 1260 | + | default: | |
| 925 | 1261 | } | |
| 926 | 1262 | } | |
| 1263 | + | p.signalState() | |
| 1264 | + | p.signalReady() | |
| 927 | 1265 | } | |
| 928 | 1266 | ||
| 929 | - | func (d *paletteDrain) flush(onBytes func([]byte)) { | |
| 930 | - | if len(d.pending) != 0 { | |
| 931 | - | onBytes(d.pending) | |
| 932 | - | d.pending = nil | |
| 1267 | + | func (p *terminalInputPump) resume() { | |
| 1268 | + | p.mu.Lock() | |
| 1269 | + | if !p.closed && !p.active { | |
| 1270 | + | p.activation++ | |
| 1271 | + | p.active = true | |
| 933 | 1272 | } | |
| 1273 | + | p.mu.Unlock() | |
| 1274 | + | p.signalState() | |
| 934 | 1275 | } | |
| 935 | 1276 | ||
| 1277 | + | func (p *terminalInputPump) stop() { | |
| 1278 | + | p.stopMu.Do(func() { | |
| 1279 | + | // Mark closure while holding the same mutex that publishes a completed | |
| 1280 | + | // Read. A Read which returns after stop therefore cannot publish even | |
| 1281 | + | // when both done and space are selectable in enqueue. | |
| 1282 | + | p.mu.Lock() | |
| 1283 | + | p.closed = true | |
| 1284 | + | p.mu.Unlock() | |
| 1285 | + | close(p.done) | |
| 1286 | + | p.signalReady() | |
| 1287 | + | p.signalState() | |
| 1288 | + | }) | |
| 1289 | + | } | |
| 1290 | + | ||
| 1291 | + | // stdinPump parses terminal color reports into generation-tagged events while | |
| 1292 | + | // preserving all ordinary terminal input byte-for-byte. It never reads the | |
| 1293 | + | // terminal directly, publishes a Theme, or writes terminal output; | |
| 1294 | + | // attachAttempt owns those operations. | |
| 936 | 1295 | type stdinPump struct { | |
| 937 | - | ctx context.Context | |
| 938 | - | cancel context.CancelFunc | |
| 939 | - | in io.Reader | |
| 940 | - | out chan<- ports.Frame | |
| 941 | - | clock ports.Clock | |
| 942 | - | themeState *terminalThemeState | |
| 943 | - | clipboard ports.ClipboardReader | |
| 944 | - | logger *slog.Logger | |
| 945 | - | requestColors func() (colorQueryRequest, bool) | |
| 946 | - | paletteReady chan<- paletteQueryReady | |
| 1296 | + | ctx context.Context | |
| 1297 | + | cancel context.CancelFunc | |
| 1298 | + | in io.Reader // compatibility for isolated scanner tests | |
| 1299 | + | input *terminalInputPump | |
| 1300 | + | consumer uint64 | |
| 1301 | + | out chan<- ports.Frame | |
| 1302 | + | clock ports.Clock | |
| 1303 | + | clipboard ports.ClipboardReader | |
| 1304 | + | logger *slog.Logger | |
| 1305 | + | paletteEvents chan<- paletteGenerationEvent | |
| 1306 | + | activeGeneration *atomic.Uint64 | |
| 1307 | + | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | |
| 1308 | + | afterInputTake func() // test synchronization hook | |
| 1309 | + | afterInputDelivered func() // test synchronization hook | |
| 947 | 1310 | } | |
| 948 | 1311 | ||
| 949 | 1312 | func (p *stdinPump) run() { | |
| 950 | - | ctx := p.ctx | |
| 951 | - | cancel := p.cancel | |
| 952 | - | in := p.in | |
| 953 | - | out := p.out | |
| 954 | - | clk := p.clock | |
| 955 | - | themeState := p.themeState | |
| 956 | - | requestColors := p.requestColors | |
| 957 | - | clipboard := p.clipboard | |
| 958 | - | log := p.logger | |
| 959 | - | defer log.Debug("stdin pump exited") | |
| 960 | - | buf := make([]byte, stdinBufSize) | |
| 1313 | + | defer p.logger.Debug("stdin pump exited") | |
| 961 | 1314 | var scanner theme.Scanner | |
| 1315 | + | var markers paletteMarkerScanner | |
| 962 | 1316 | var inputSeq uint64 | |
| 963 | 1317 | var sendOK atomic.Bool | |
| 1318 | + | var undeliveredMu sync.Mutex | |
| 1319 | + | var undelivered []byte | |
| 964 | 1320 | sendOK.Store(true) | |
| 965 | - | send := func(frame ports.Frame) { | |
| 1321 | + | send := func(frame ports.Frame) bool { | |
| 1322 | + | if !sendOK.Load() { | |
| 1323 | + | return false | |
| 1324 | + | } | |
| 966 | 1325 | select { | |
| 967 | - | case out <- frame: | |
| 968 | - | case <-ctx.Done(): | |
| 1326 | + | case p.out <- frame: | |
| 1327 | + | if p.afterInputDelivered != nil { | |
| 1328 | + | p.afterInputDelivered() | |
| 1329 | + | } | |
| 1330 | + | return true | |
| 1331 | + | case <-p.ctx.Done(): | |
| 969 | 1332 | sendOK.Store(false) | |
| 1333 | + | return false | |
| 970 | 1334 | } | |
| 971 | 1335 | } | |
| 972 | - | // The coalescer reframes a bracketed paste split across reads into one | |
| 973 | - | // MsgInput frame, so a marker boundary can never leave a lone ESC on the | |
| 974 | - | // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract. | |
| 975 | - | coalescer := newPasteCoalescer(clk, func(data []byte) { | |
| 1336 | + | sendEvent := func(event paletteGenerationEvent) bool { | |
| 1337 | + | if !sendOK.Load() { | |
| 1338 | + | return false | |
| 1339 | + | } | |
| 1340 | + | select { | |
| 1341 | + | case p.paletteEvents <- event: | |
| 1342 | + | if p.afterPaletteEvent != nil { | |
| 1343 | + | p.afterPaletteEvent(event) | |
| 1344 | + | } | |
| 1345 | + | return true | |
| 1346 | + | case <-p.ctx.Done(): | |
| 1347 | + | sendOK.Store(false) | |
| 1348 | + | return false | |
| 1349 | + | } | |
| 1350 | + | } | |
| 1351 | + | coalescer := newPasteCoalescer(p.clock, func(data []byte) { | |
| 976 | 1352 | if len(data) == 0 { | |
| 977 | 1353 | return | |
| 978 | 1354 | } | |
| 979 | - | copyData := append([]byte(nil), data...) | |
| 980 | 1355 | inputSeq++ | |
| 981 | - | send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |
| 1356 | + | if !send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})}) { | |
| 1357 | + | undeliveredMu.Lock() | |
| 1358 | + | undelivered = append(undelivered, data...) | |
| 1359 | + | undeliveredMu.Unlock() | |
| 1360 | + | } | |
| 982 | 1361 | }) | |
| 983 | 1362 | defer coalescer.Close() | |
| 984 | - | ||
| 985 | - | // On a remote attach with a ClipboardReader configured, splice in the | |
| 986 | - | // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16) | |
| 987 | - | // becomes a clipboard image push instead of reaching the coalescer (and | |
| 988 | - | // from there the remote pane) as an ordinary keystroke. | |
| 989 | 1363 | sink := coalescer.Scan | |
| 990 | - | if clipboard != nil { | |
| 1364 | + | if p.clipboard != nil { | |
| 991 | 1365 | ci := &clipboardIntercept{ | |
| 992 | 1366 | coalescer: coalescer, | |
| 993 | - | reader: clipboard, | |
| 994 | - | log: log, | |
| 1367 | + | reader: p.clipboard, | |
| 1368 | + | log: p.logger, | |
| 995 | 1369 | sendImage: func(mime string, data []byte) { | |
| 996 | 1370 | inputSeq++ | |
| 997 | 1371 | send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})}) | |
| @@ -1000,99 +1374,156 @@ func (p *stdinPump) run() { | |||
| 1000 | 1374 | } | |
| 1001 | 1375 | sink = ci.Scan | |
| 1002 | 1376 | } | |
| 1377 | + | input := p.input | |
| 1378 | + | ownedInput := input == nil | |
| 1379 | + | if ownedInput { | |
| 1380 | + | input = newTerminalInputPump(p.in) | |
| 1381 | + | input.start() | |
| 1382 | + | defer input.stop() | |
| 1383 | + | } | |
| 1384 | + | consumer := p.consumer | |
| 1385 | + | if consumer == 0 { | |
| 1386 | + | consumer = input.claim() | |
| 1387 | + | defer input.revoke(consumer) | |
| 1388 | + | } | |
| 1389 | + | // A marker prefix is ordinary terminal input until it becomes a complete | |
| 1390 | + | // DECRQM reply. If cancellation wins while it is withheld, preserve just | |
| 1391 | + | // that undecided suffix; the rest of its source read has already been | |
| 1392 | + | // delivered and must not be replayed by the next attempt. | |
| 1393 | + | defer func() { | |
| 1394 | + | if p.ctx.Err() != nil { | |
| 1395 | + | // Closing flushes any bracketed-paste prefix or buffer through the | |
| 1396 | + | // coalescer's emit callback. Failed ordinary-input deliveries are | |
| 1397 | + | // retained ahead of a trailing undecided marker prefix, matching their | |
| 1398 | + | // original order within the raw read. | |
| 1399 | + | held := coalescer.CloseAndTakeHeld() | |
| 1400 | + | undeliveredMu.Lock() | |
| 1401 | + | residual := append([]byte(nil), undelivered...) | |
| 1402 | + | undeliveredMu.Unlock() | |
| 1403 | + | residual = append(residual, held...) | |
| 1404 | + | residual = append(residual, markers.takePending()...) | |
| 1405 | + | input.preserveResidual(consumer, residual) | |
| 1406 | + | } | |
| 1407 | + | }() | |
| 1408 | + | ||
| 1409 | + | var markerTimer ports.Timer | |
| 1410 | + | var markerTimerC <-chan time.Time | |
| 1411 | + | flushMarkerPrefix := func() { | |
| 1412 | + | markers.flush(sink) | |
| 1413 | + | markerTimer = nil | |
| 1414 | + | markerTimerC = nil | |
| 1415 | + | } | |
| 1416 | + | // Disarm before processing a subsequent read. A deadline already ready at | |
| 1417 | + | // that boundary wins, so bytes cannot be retroactively consumed as a | |
| 1418 | + | // marker after they were due to be forwarded as ordinary input. | |
| 1419 | + | disarmMarkerDeadline := func() { | |
| 1420 | + | if markerTimer == nil { | |
| 1421 | + | return | |
| 1422 | + | } | |
| 1423 | + | select { | |
| 1424 | + | case <-markerTimerC: | |
| 1425 | + | flushMarkerPrefix() | |
| 1426 | + | return | |
| 1427 | + | default: | |
| 1428 | + | } | |
| 1429 | + | if !markerTimer.Stop() { | |
| 1430 | + | // Stop reports false only after expiry or a prior stop. This timer is | |
| 1431 | + | // owned only here, so expiry wins even if delivery to C races this | |
| 1432 | + | // select; do not let a late value consume a newly arrived suffix. | |
| 1433 | + | flushMarkerPrefix() | |
| 1434 | + | return | |
| 1435 | + | } | |
| 1436 | + | markerTimer = nil | |
| 1437 | + | markerTimerC = nil | |
| 1438 | + | } | |
| 1439 | + | armMarkerDeadline := func() { | |
| 1440 | + | disarmMarkerDeadline() | |
| 1441 | + | markerTimer = p.clock.NewTimer(paletteMarkerAmbiguityDeadline) | |
| 1442 | + | markerTimerC = markerTimer.C() | |
| 1443 | + | } | |
| 1444 | + | defer disarmMarkerDeadline() | |
| 1003 | 1445 | ||
| 1004 | - | phase := paletteAccepting | |
| 1005 | - | var drain paletteDrain | |
| 1006 | 1446 | for { | |
| 1007 | - | n, rerr := in.Read(buf) | |
| 1008 | - | if n > 0 { | |
| 1009 | - | themeChanged := false | |
| 1010 | - | queryNeeded := false | |
| 1011 | - | onBytes := func(data []byte) { | |
| 1012 | - | if phase != paletteDraining { | |
| 1013 | - | sink(data) | |
| 1447 | + | select { | |
| 1448 | + | case <-p.ctx.Done(): | |
| 1449 | + | return | |
| 1450 | + | case <-markerTimerC: | |
| 1451 | + | flushMarkerPrefix() | |
| 1452 | + | case <-input.ready: | |
| 1453 | + | result, ok := input.take(p.ctx, consumer) | |
| 1454 | + | if !ok { | |
| 1455 | + | if p.ctx.Err() != nil { | |
| 1014 | 1456 | return | |
| 1015 | 1457 | } | |
| 1016 | - | drain.scan(data, sink, func() { | |
| 1017 | - | phase = paletteAwaitingQuery | |
| 1018 | - | ready := paletteQueryReady{queryIssued: make(chan struct{})} | |
| 1019 | - | select { | |
| 1020 | - | case p.paletteReady <- ready: | |
| 1021 | - | case <-ctx.Done(): | |
| 1022 | - | return | |
| 1023 | - | } | |
| 1024 | - | select { | |
| 1025 | - | case <-ready.queryIssued: | |
| 1026 | - | phase = paletteAccepting | |
| 1027 | - | case <-ctx.Done(): | |
| 1028 | - | } | |
| 1029 | - | }) | |
| 1458 | + | continue | |
| 1030 | 1459 | } | |
| 1031 | - | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 1032 | - | if phase != paletteAccepting { | |
| 1033 | - | return | |
| 1034 | - | } | |
| 1035 | - | themeState.update(func(current *ports.Theme) { | |
| 1036 | - | switch kind { | |
| 1037 | - | case 10: | |
| 1038 | - | current.HasForeground = true | |
| 1039 | - | current.Foreground = rgb | |
| 1040 | - | case 11: | |
| 1041 | - | current.HasBackground = true | |
| 1042 | - | current.Background = rgb | |
| 1460 | + | if p.afterInputTake != nil { | |
| 1461 | + | p.afterInputTake() | |
| 1462 | + | } | |
| 1463 | + | // The lease is not acknowledged until scanner delivery completes. | |
| 1464 | + | // This second check closes the cancellation window after take. | |
| 1465 | + | if p.ctx.Err() != nil { | |
| 1466 | + | return | |
| 1467 | + | } | |
| 1468 | + | disarmMarkerDeadline() | |
| 1469 | + | // All scanner callbacks from this Read must retain this one generation. | |
| 1470 | + | // A scheme notification can start its replacement before a later byte in | |
| 1471 | + | // the same read is scanned; reloading there would misclassify an old | |
| 1472 | + | // completion marker as the replacement's drain response. | |
| 1473 | + | readGeneration := paletteGenerationID(p.activeGeneration.Load()) | |
| 1474 | + | if len(result.data) > 0 { | |
| 1475 | + | scanner.Scan(result.data, func(kind int, rgb renderer.RGB) { | |
| 1476 | + | kindEvent := paletteEventForeground | |
| 1477 | + | if kind == 11 { | |
| 1478 | + | kindEvent = paletteEventBackground | |
| 1043 | 1479 | } | |
| 1044 | - | }) | |
| 1045 | - | themeChanged = true | |
| 1046 | - | }, func(slot int, rgb renderer.RGB) { | |
| 1047 | - | if phase != paletteAccepting { | |
| 1048 | - | return | |
| 1049 | - | } | |
| 1050 | - | themeState.update(func(current *ports.Theme) { | |
| 1051 | - | current.PaletteKnown |= uint16(1) << slot | |
| 1052 | - | current.Palette[slot] = rgb | |
| 1053 | - | }) | |
| 1054 | - | themeChanged = true | |
| 1055 | - | }, func(light bool) { | |
| 1056 | - | themeState.update(func(current *ports.Theme) { | |
| 1057 | - | current.SchemeKnown = true | |
| 1058 | - | current.Light = light | |
| 1059 | - | }) | |
| 1060 | - | // The scheme itself begins a new palette generation. Suppress every | |
| 1061 | - | // color reply after it, including those remaining in this read, and | |
| 1062 | - | // defer invalidation until Scan completes so no partial snapshot is | |
| 1063 | - | // observable mid-chunk. | |
| 1064 | - | themeChanged = false | |
| 1065 | - | if phase == paletteAccepting { | |
| 1066 | - | phase = paletteBoundaryRequested | |
| 1067 | - | queryNeeded = true | |
| 1068 | - | } | |
| 1069 | - | }, onBytes) | |
| 1070 | - | if queryNeeded { | |
| 1071 | - | themeState.clearPalette() | |
| 1072 | - | if requestColors != nil { | |
| 1073 | - | if _, ok := requestColors(); !ok { | |
| 1480 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb}) | |
| 1481 | + | }, func(slot int, rgb renderer.RGB) { | |
| 1482 | + | // Scanner implementations are independently testable; retain the | |
| 1483 | + | // protocol boundary here too so a malformed implementation cannot | |
| 1484 | + | // wrap a negative or oversized int into an ANSI palette slot. | |
| 1485 | + | slot8, ok := paletteSlot(slot) | |
| 1486 | + | if !ok { | |
| 1074 | 1487 | return | |
| 1075 | 1488 | } | |
| 1076 | - | phase = paletteDraining | |
| 1489 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb}) | |
| 1490 | + | }, func(light bool) { | |
| 1491 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light}) | |
| 1492 | + | }, func(data []byte) { | |
| 1493 | + | markers.scan(data, sink, func() { | |
| 1494 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) | |
| 1495 | + | }) | |
| 1496 | + | }) | |
| 1497 | + | if !sendOK.Load() { | |
| 1498 | + | // Commit the source read so callbacks accepted before cancellation | |
| 1499 | + | // are never replayed. The deferred handoff retains only ordinary | |
| 1500 | + | // bytes whose callback could not be delivered, plus any undecided | |
| 1501 | + | // trailing marker prefix. | |
| 1502 | + | input.ack(consumer) | |
| 1503 | + | return | |
| 1077 | 1504 | } | |
| 1078 | - | } else if themeChanged { | |
| 1079 | - | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())}) | |
| 1080 | 1505 | } | |
| 1081 | - | if !sendOK.Load() { | |
| 1082 | - | return | |
| 1506 | + | if markers.hasPendingPrefix() { | |
| 1507 | + | armMarkerDeadline() | |
| 1083 | 1508 | } | |
| 1084 | - | } | |
| 1085 | - | if rerr != nil { | |
| 1086 | - | if phase == paletteDraining { | |
| 1087 | - | drain.flush(sink) | |
| 1509 | + | if result.err != nil { | |
| 1510 | + | disarmMarkerDeadline() | |
| 1511 | + | markers.flush(sink) | |
| 1512 | + | select { | |
| 1513 | + | case p.out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}: | |
| 1514 | + | case <-p.ctx.Done(): | |
| 1515 | + | } | |
| 1516 | + | p.cancel() | |
| 1517 | + | return | |
| 1088 | 1518 | } | |
| 1089 | - | // Best-effort detach notification, then unwind. | |
| 1090 | - | select { | |
| 1091 | - | case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}: | |
| 1092 | - | case <-ctx.Done(): | |
| 1519 | + | // Commit every callback from this raw read before observing a | |
| 1520 | + | // concurrent cancellation. Any remaining marker prefix is retained | |
| 1521 | + | // by the deferred handoff above, so reconnects never duplicate the | |
| 1522 | + | // delivered bytes or lose a standalone Escape. | |
| 1523 | + | input.ack(consumer) | |
| 1524 | + | if p.ctx.Err() != nil { | |
| 1525 | + | return | |
| 1093 | 1526 | } | |
| 1094 | - | cancel() | |
| 1095 | - | return | |
| 1096 | 1527 | } | |
| 1097 | 1528 | } | |
| 1098 | 1529 | } | |
M internal/usecase/client/client_test.go +24 -365
| @@ -292,6 +292,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) { | |||
| 292 | 292 | case <-time.After(time.Second): | |
| 293 | 293 | t.Fatal("pre-Welcome operation leaked after Run returned") | |
| 294 | 294 | } | |
| 295 | + | tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme)) | |
| 295 | 296 | }) | |
| 296 | 297 | } | |
| 297 | 298 | } | |
| @@ -308,6 +309,8 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) { | |||
| 308 | 309 | ||
| 309 | 310 | gotHello := make(chan ports.Hello, 1) | |
| 310 | 311 | tr := portsmocks.NewMockTransport(t) | |
| 312 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 313 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 311 | 314 | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error { | |
| 312 | 315 | hello, err := ports.UnmarshalHello(f.Payload) | |
| 313 | 316 | require.NoError(t, err) | |
| @@ -345,6 +348,8 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) { | |||
| 345 | 348 | ||
| 346 | 349 | gotHello := make(chan ports.Hello, 1) | |
| 347 | 350 | tr := portsmocks.NewMockTransport(t) | |
| 351 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 352 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 348 | 353 | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error { | |
| 349 | 354 | hello, err := ports.UnmarshalHello(f.Payload) | |
| 350 | 355 | if err != nil { | |
| @@ -372,6 +377,8 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) { | |||
| 372 | 377 | defer in.unblock() | |
| 373 | 378 | ||
| 374 | 379 | tr := portsmocks.NewMockTransport(t) | |
| 380 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 381 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 375 | 382 | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error { | |
| 376 | 383 | hello, err := ports.UnmarshalHello(f.Payload) | |
| 377 | 384 | require.NoError(t, err) | |
| @@ -396,6 +403,8 @@ func TestAttachHappyPath(t *testing.T) { | |||
| 396 | 403 | defer in.unblock() | |
| 397 | 404 | ||
| 398 | 405 | tr := portsmocks.NewMockTransport(t) | |
| 406 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 407 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 399 | 408 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 400 | 409 | unblock := scriptRecv(tr, | |
| 401 | 410 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))}, | |
| @@ -407,7 +416,7 @@ func TestAttachHappyPath(t *testing.T) { | |||
| 407 | 416 | ||
| 408 | 417 | err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""}) | |
| 409 | 418 | require.NoError(t, err) | |
| 410 | - | require.Equal(t, "hello world", out.String()) | |
| 419 | + | require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$phello world", out.String()) | |
| 411 | 420 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | |
| 412 | 421 | } | |
| 413 | 422 | ||
| @@ -429,6 +438,7 @@ func TestAttachVersionMismatch(t *testing.T) { | |||
| 429 | 438 | var pe *client.ProtocolError | |
| 430 | 439 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| 431 | 440 | require.Equal(t, ports.ErrVersionMismatch, pe.Code) | |
| 441 | + | tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme)) | |
| 432 | 442 | } | |
| 433 | 443 | ||
| 434 | 444 | func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |
| @@ -439,6 +449,8 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |||
| 439 | 449 | defer in.unblock() | |
| 440 | 450 | ||
| 441 | 451 | tr := portsmocks.NewMockTransport(t) | |
| 452 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 453 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 442 | 454 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 443 | 455 | boom := errors.New("connection reset") | |
| 444 | 456 | unblock := scriptRecv(tr, | |
| @@ -461,6 +473,8 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||
| 461 | 473 | defer in.unblock() | |
| 462 | 474 | ||
| 463 | 475 | tr := portsmocks.NewMockTransport(t) | |
| 476 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 477 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 464 | 478 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 465 | 479 | unblock := scriptRecv(tr, | |
| 466 | 480 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |
| @@ -475,221 +489,6 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||
| 475 | 489 | require.Equal(t, int32(1), restoreCount.Load()) | |
| 476 | 490 | } | |
| 477 | 491 | ||
| 478 | - | func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |
| 479 | - | t.Setenv("COLORTERM", "truecolor") | |
| 480 | - | ||
| 481 | - | var out bytes.Buffer | |
| 482 | - | var restoreCount atomic.Int32 | |
| 483 | - | resizeCh := make(chan domain.Size) | |
| 484 | - | input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b")) | |
| 485 | - | defer input.unblock() | |
| 486 | - | ||
| 487 | - | tm := portsmocks.NewMockTerminal(t) | |
| 488 | - | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 489 | - | tm.EXPECT().EnterRaw().Return(func() error { | |
| 490 | - | restoreCount.Add(1) | |
| 491 | - | return nil | |
| 492 | - | }, nil).Once() | |
| 493 | - | tm.EXPECT().In().Return(input).Maybe() | |
| 494 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 495 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 496 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 497 | - | ||
| 498 | - | gotTheme := make(chan ports.Theme, 1) | |
| 499 | - | gotInput := make(chan []byte, 2) | |
| 500 | - | allowDetach := make(chan struct{}) | |
| 501 | - | ||
| 502 | - | tr := portsmocks.NewMockTransport(t) | |
| 503 | - | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 504 | - | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 505 | - | in, err := ports.UnmarshalInput(f.Payload) | |
| 506 | - | require.NoError(t, err) | |
| 507 | - | require.NotContains(t, string(in.Data), "\x1b]11;") | |
| 508 | - | gotInput <- append([]byte(nil), in.Data...) | |
| 509 | - | if bytes.Contains(in.Data, []byte("b")) { | |
| 510 | - | close(allowDetach) | |
| 511 | - | } | |
| 512 | - | return nil | |
| 513 | - | }).Maybe() | |
| 514 | - | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 515 | - | th, err := ports.UnmarshalTheme(f.Payload) | |
| 516 | - | require.NoError(t, err) | |
| 517 | - | gotTheme <- th | |
| 518 | - | return nil | |
| 519 | - | }).Once() | |
| 520 | - | ||
| 521 | - | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 522 | - | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 523 | - | recvCh := make(chan recvItem, 1) | |
| 524 | - | recvCh <- recvItem{f: welcome} | |
| 525 | - | closed := make(chan struct{}) | |
| 526 | - | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 527 | - | select { | |
| 528 | - | case it := <-recvCh: | |
| 529 | - | return it.f, it.err | |
| 530 | - | case <-allowDetach: | |
| 531 | - | select { | |
| 532 | - | case <-closed: | |
| 533 | - | return ports.Frame{}, io.EOF | |
| 534 | - | default: | |
| 535 | - | close(closed) | |
| 536 | - | return detached, nil | |
| 537 | - | } | |
| 538 | - | case <-closed: | |
| 539 | - | return ports.Frame{}, io.EOF | |
| 540 | - | } | |
| 541 | - | }).Maybe() | |
| 542 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 543 | - | ||
| 544 | - | err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""}) | |
| 545 | - | require.NoError(t, err) | |
| 546 | - | require.Equal(t, int32(1), restoreCount.Load()) | |
| 547 | - | ||
| 548 | - | select { | |
| 549 | - | case th := <-gotTheme: | |
| 550 | - | require.True(t, th.HasBackground) | |
| 551 | - | require.Equal(t, uint8(1), th.Background.R) | |
| 552 | - | require.Equal(t, uint8(2), th.Background.G) | |
| 553 | - | require.Equal(t, uint8(3), th.Background.B) | |
| 554 | - | require.True(t, th.TrueColor) | |
| 555 | - | case <-time.After(2 * time.Second): | |
| 556 | - | t.Fatal("theme frame was not sent") | |
| 557 | - | } | |
| 558 | - | ||
| 559 | - | var inputBytes []byte | |
| 560 | - | for { | |
| 561 | - | select { | |
| 562 | - | case b := <-gotInput: | |
| 563 | - | inputBytes = append(inputBytes, b...) | |
| 564 | - | default: | |
| 565 | - | require.Equal(t, []byte("ab"), inputBytes) | |
| 566 | - | return | |
| 567 | - | } | |
| 568 | - | } | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |
| 572 | - | t.Setenv("TERM", "xterm-256color") | |
| 573 | - | t.Setenv("COLORTERM", "") | |
| 574 | - | ||
| 575 | - | var out bytes.Buffer | |
| 576 | - | var restoreCount atomic.Int32 | |
| 577 | - | resizeCh := make(chan domain.Size) | |
| 578 | - | input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07")) | |
| 579 | - | defer input.unblock() | |
| 580 | - | ||
| 581 | - | tm := portsmocks.NewMockTerminal(t) | |
| 582 | - | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 583 | - | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 584 | - | tm.EXPECT().In().Return(input).Maybe() | |
| 585 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 586 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 587 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 588 | - | ||
| 589 | - | gotTheme := make(chan ports.Theme, 1) | |
| 590 | - | allowDetach := make(chan struct{}) | |
| 591 | - | tr := portsmocks.NewMockTransport(t) | |
| 592 | - | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 593 | - | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 594 | - | th, err := ports.UnmarshalTheme(f.Payload) | |
| 595 | - | require.NoError(t, err) | |
| 596 | - | gotTheme <- th | |
| 597 | - | close(allowDetach) | |
| 598 | - | return nil | |
| 599 | - | }).Once() | |
| 600 | - | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 601 | - | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 602 | - | recvCh := make(chan recvItem, 1) | |
| 603 | - | recvCh <- recvItem{f: welcome} | |
| 604 | - | closed := make(chan struct{}) | |
| 605 | - | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 606 | - | select { | |
| 607 | - | case it := <-recvCh: | |
| 608 | - | return it.f, it.err | |
| 609 | - | case <-allowDetach: | |
| 610 | - | select { | |
| 611 | - | case <-closed: | |
| 612 | - | return ports.Frame{}, io.EOF | |
| 613 | - | default: | |
| 614 | - | close(closed) | |
| 615 | - | return detached, nil | |
| 616 | - | } | |
| 617 | - | case <-closed: | |
| 618 | - | return ports.Frame{}, io.EOF | |
| 619 | - | } | |
| 620 | - | }).Maybe() | |
| 621 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 622 | - | ||
| 623 | - | err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""}) | |
| 624 | - | require.NoError(t, err) | |
| 625 | - | select { | |
| 626 | - | case th := <-gotTheme: | |
| 627 | - | require.True(t, th.HasForeground) | |
| 628 | - | require.False(t, th.TrueColor) | |
| 629 | - | case <-time.After(2 * time.Second): | |
| 630 | - | t.Fatal("theme frame was not sent") | |
| 631 | - | } | |
| 632 | - | } | |
| 633 | - | ||
| 634 | - | func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |
| 635 | - | var out bytes.Buffer | |
| 636 | - | var restoreCount atomic.Int32 | |
| 637 | - | resizeCh := make(chan domain.Size) | |
| 638 | - | input := newOneShotBlockingReader([]byte("\x1b")) | |
| 639 | - | defer input.unblock() | |
| 640 | - | ||
| 641 | - | tm := portsmocks.NewMockTerminal(t) | |
| 642 | - | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 643 | - | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 644 | - | tm.EXPECT().In().Return(input).Maybe() | |
| 645 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 646 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 647 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 648 | - | ||
| 649 | - | gotInput := make(chan []byte, 1) | |
| 650 | - | allowDetach := make(chan struct{}) | |
| 651 | - | tr := portsmocks.NewMockTransport(t) | |
| 652 | - | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 653 | - | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 654 | - | in, err := ports.UnmarshalInput(f.Payload) | |
| 655 | - | require.NoError(t, err) | |
| 656 | - | gotInput <- in.Data | |
| 657 | - | close(allowDetach) | |
| 658 | - | return nil | |
| 659 | - | }).Once() | |
| 660 | - | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 661 | - | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 662 | - | recvCh := make(chan recvItem, 1) | |
| 663 | - | recvCh <- recvItem{f: welcome} | |
| 664 | - | closed := make(chan struct{}) | |
| 665 | - | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 666 | - | select { | |
| 667 | - | case it := <-recvCh: | |
| 668 | - | return it.f, it.err | |
| 669 | - | case <-allowDetach: | |
| 670 | - | select { | |
| 671 | - | case <-closed: | |
| 672 | - | return ports.Frame{}, io.EOF | |
| 673 | - | default: | |
| 674 | - | close(closed) | |
| 675 | - | return detached, nil | |
| 676 | - | } | |
| 677 | - | case <-closed: | |
| 678 | - | return ports.Frame{}, io.EOF | |
| 679 | - | } | |
| 680 | - | }).Maybe() | |
| 681 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 682 | - | ||
| 683 | - | err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""}) | |
| 684 | - | require.NoError(t, err) | |
| 685 | - | select { | |
| 686 | - | case got := <-gotInput: | |
| 687 | - | require.Equal(t, []byte("\x1b"), got) | |
| 688 | - | case <-time.After(2 * time.Second): | |
| 689 | - | t.Fatal("standalone escape input was not sent") | |
| 690 | - | } | |
| 691 | - | } | |
| 692 | - | ||
| 693 | 492 | func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |
| 694 | 493 | var out bytes.Buffer | |
| 695 | 494 | var restoreCount atomic.Int32 | |
| @@ -708,6 +507,8 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |||
| 708 | 507 | gotInput := make(chan []byte, 2) | |
| 709 | 508 | allowDetach := make(chan struct{}) | |
| 710 | 509 | tr := portsmocks.NewMockTransport(t) | |
| 510 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 511 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 711 | 512 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 712 | 513 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 713 | 514 | in, err := ports.UnmarshalInput(f.Payload) | |
| @@ -768,6 +569,8 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) { | |||
| 768 | 569 | gotInput := make(chan []byte, 1) | |
| 769 | 570 | allowDetach := make(chan struct{}) | |
| 770 | 571 | tr := portsmocks.NewMockTransport(t) | |
| 572 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 573 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 771 | 574 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 772 | 575 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 773 | 576 | in, err := ports.UnmarshalInput(f.Payload) | |
| @@ -821,6 +624,8 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 821 | 624 | var firstRecvOnce sync.Once | |
| 822 | 625 | ||
| 823 | 626 | tr := portsmocks.NewMockTransport(t) | |
| 627 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 628 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 824 | 629 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 825 | 630 | // The resize frame is forwarded via the sender goroutine. | |
| 826 | 631 | gotResize := make(chan ports.Resize, 1) | |
| @@ -880,6 +685,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 880 | 685 | defer in.unblock() | |
| 881 | 686 | ||
| 882 | 687 | tr := portsmocks.NewMockTransport(t) | |
| 688 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 689 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 883 | 690 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 884 | 691 | boom := errors.New("connection reset") | |
| 885 | 692 | unblock := scriptRecv(tr, | |
| @@ -915,6 +722,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||
| 915 | 722 | require.Error(t, err) | |
| 916 | 723 | var pe *client.ProtocolError | |
| 917 | 724 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| 725 | + | tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme)) | |
| 918 | 726 | } | |
| 919 | 727 | ||
| 920 | 728 | func TestRunPhaseASingleAttempt(t *testing.T) { | |
| @@ -987,7 +795,6 @@ func (t *runTerminal) EnterRaw() (func() error, error) { | |||
| 987 | 795 | } | |
| 988 | 796 | func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil } | |
| 989 | 797 | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | |
| 990 | - | func (t *runTerminal) QueryColors() error { return nil } | |
| 991 | 798 | func (t *runTerminal) In() io.Reader { return t.in } | |
| 992 | 799 | func (t *runTerminal) Out() io.Writer { return &t.out } | |
| 993 | 800 | func (t *runTerminal) Flush() error { return nil } | |
| @@ -1047,151 +854,3 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 1047 | 854 | require.Equal(t, int32(1), d.calls.Load()) | |
| 1048 | 855 | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 1049 | 856 | } | |
| 1050 | - | ||
| 1051 | - | func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) { | |
| 1052 | - | input := newChunkedBlockingReader( | |
| 1053 | - | []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"), | |
| 1054 | - | []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"), | |
| 1055 | - | []byte("2$yc\x1b]4;3;#778899\ad"), | |
| 1056 | - | ) | |
| 1057 | - | defer input.unblock() | |
| 1058 | - | ||
| 1059 | - | var out bytes.Buffer | |
| 1060 | - | var restoreCount atomic.Int32 | |
| 1061 | - | resizeCh := make(chan domain.Size) | |
| 1062 | - | tm := portsmocks.NewMockTerminal(t) | |
| 1063 | - | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 1064 | - | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 1065 | - | tm.EXPECT().In().Return(input).Maybe() | |
| 1066 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 1067 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 1068 | - | tm.EXPECT().QueryColors().Return(nil).Once() | |
| 1069 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 1070 | - | ||
| 1071 | - | var inputBytes []byte | |
| 1072 | - | var inputMu sync.Mutex | |
| 1073 | - | themes := make(chan ports.Theme, 2) | |
| 1074 | - | freshTheme := make(chan struct{}) | |
| 1075 | - | var freshOnce sync.Once | |
| 1076 | - | tr := portsmocks.NewMockTransport(t) | |
| 1077 | - | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 1078 | - | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 1079 | - | in, err := ports.UnmarshalInput(f.Payload) | |
| 1080 | - | if err != nil { | |
| 1081 | - | return err | |
| 1082 | - | } | |
| 1083 | - | inputMu.Lock() | |
| 1084 | - | inputBytes = append(inputBytes, in.Data...) | |
| 1085 | - | inputMu.Unlock() | |
| 1086 | - | return nil | |
| 1087 | - | }).Maybe() | |
| 1088 | - | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 1089 | - | theme, err := ports.UnmarshalTheme(f.Payload) | |
| 1090 | - | if err != nil { | |
| 1091 | - | return err | |
| 1092 | - | } | |
| 1093 | - | themes <- theme | |
| 1094 | - | if theme.PaletteKnown == 1<<3 { | |
| 1095 | - | freshOnce.Do(func() { close(freshTheme) }) | |
| 1096 | - | } | |
| 1097 | - | return nil | |
| 1098 | - | }).Times(2) | |
| 1099 | - | var welcomed atomic.Bool | |
| 1100 | - | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 1101 | - | if welcomed.CompareAndSwap(false, true) { | |
| 1102 | - | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil | |
| 1103 | - | } | |
| 1104 | - | <-freshTheme | |
| 1105 | - | return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil | |
| 1106 | - | }).Maybe() | |
| 1107 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 1108 | - | ||
| 1109 | - | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |
| 1110 | - | require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write") | |
| 1111 | - | require.Equal(t, int32(1), restoreCount.Load()) | |
| 1112 | - | ||
| 1113 | - | cleared := <-themes | |
| 1114 | - | fresh := <-themes | |
| 1115 | - | require.True(t, cleared.SchemeKnown) | |
| 1116 | - | require.True(t, cleared.Light) | |
| 1117 | - | require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary") | |
| 1118 | - | require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted") | |
| 1119 | - | require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation") | |
| 1120 | - | inputMu.Lock() | |
| 1121 | - | require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive") | |
| 1122 | - | inputMu.Unlock() | |
| 1123 | - | } | |
| 1124 | - | ||
| 1125 | - | func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) { | |
| 1126 | - | var out bytes.Buffer | |
| 1127 | - | var restoreCount atomic.Int32 | |
| 1128 | - | resizeCh := make(chan domain.Size) | |
| 1129 | - | input := newChunkedBlockingReader( | |
| 1130 | - | []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"), | |
| 1131 | - | []byte("\x1b[?997;2n"), | |
| 1132 | - | []byte("\x1b[?2031;1$y"), | |
| 1133 | - | ) | |
| 1134 | - | defer input.unblock() | |
| 1135 | - | ||
| 1136 | - | tm := portsmocks.NewMockTerminal(t) | |
| 1137 | - | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 1138 | - | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 1139 | - | tm.EXPECT().In().Return(input).Maybe() | |
| 1140 | - | tm.EXPECT().Out().Return(&out).Maybe() | |
| 1141 | - | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 1142 | - | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 1143 | - | tm.EXPECT().QueryColors().Return(nil).Once() | |
| 1144 | - | ||
| 1145 | - | var themes []ports.Theme | |
| 1146 | - | var themesMu sync.Mutex | |
| 1147 | - | clearedPalette := make(chan struct{}) | |
| 1148 | - | var clearOnce sync.Once | |
| 1149 | - | tr := portsmocks.NewMockTransport(t) | |
| 1150 | - | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 1151 | - | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 1152 | - | theme, err := ports.UnmarshalTheme(f.Payload) | |
| 1153 | - | if err != nil { | |
| 1154 | - | t.Errorf("decode sent theme: %v", err) | |
| 1155 | - | return err | |
| 1156 | - | } | |
| 1157 | - | themesMu.Lock() | |
| 1158 | - | themes = append(themes, theme) | |
| 1159 | - | themesMu.Unlock() | |
| 1160 | - | if theme.SchemeKnown && theme.PaletteKnown == 0 { | |
| 1161 | - | clearOnce.Do(func() { close(clearedPalette) }) | |
| 1162 | - | } | |
| 1163 | - | return nil | |
| 1164 | - | }).Times(2) | |
| 1165 | - | ||
| 1166 | - | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 1167 | - | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 1168 | - | closed := make(chan struct{}) | |
| 1169 | - | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 1170 | - | select { | |
| 1171 | - | case <-closed: | |
| 1172 | - | return ports.Frame{}, io.EOF | |
| 1173 | - | default: | |
| 1174 | - | } | |
| 1175 | - | select { | |
| 1176 | - | case <-clearedPalette: | |
| 1177 | - | close(closed) | |
| 1178 | - | return detached, nil | |
| 1179 | - | default: | |
| 1180 | - | return welcome, nil | |
| 1181 | - | } | |
| 1182 | - | }).Maybe() | |
| 1183 | - | tr.EXPECT().Close().Return(nil).Once() | |
| 1184 | - | ||
| 1185 | - | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |
| 1186 | - | require.Equal(t, int32(1), restoreCount.Load()) | |
| 1187 | - | ||
| 1188 | - | themesMu.Lock() | |
| 1189 | - | defer themesMu.Unlock() | |
| 1190 | - | require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent") | |
| 1191 | - | require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown) | |
| 1192 | - | require.True(t, themes[0].HasBackground) | |
| 1193 | - | require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries") | |
| 1194 | - | require.Equal(t, themes[0].Background, themes[1].Background) | |
| 1195 | - | require.True(t, themes[1].SchemeKnown) | |
| 1196 | - | require.True(t, themes[1].Light) | |
| 1197 | - | } | |
M internal/usecase/client/clipboard_intercept_test.go +1 -0
| @@ -42,6 +42,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||
| 42 | 42 | allowDetach := make(chan struct{}) | |
| 43 | 43 | ||
| 44 | 44 | tr := portsmocks.NewMockTransport(t) | |
| 45 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 45 | 46 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 46 | 47 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 47 | 48 | in, err := ports.UnmarshalInput(f.Payload) | |
A internal/usecase/client/palette_attach_generation_test.go +542 -0
| @@ -0,0 +1,542 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/ports" | |
| 18 | + | "github.com/bnema/vev/pkg/renderer" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // attachPaletteClock deliberately exposes each independently-created timer. | |
| 22 | + | // Tests fire only the deadline they intend to exercise; no wall clock is used. | |
| 23 | + | type attachPaletteClock struct{ timers chan *attachPaletteTimer } | |
| 24 | + | ||
| 25 | + | type attachPaletteTimer struct { | |
| 26 | + | ch chan time.Time | |
| 27 | + | stopped chan struct{} | |
| 28 | + | stopOnce sync.Once | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newAttachPaletteClock() *attachPaletteClock { | |
| 32 | + | return &attachPaletteClock{timers: make(chan *attachPaletteTimer, 16)} | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (*attachPaletteClock) Now() time.Time { return time.Time{} } | |
| 36 | + | func (c *attachPaletteClock) NewTimer(time.Duration) ports.Timer { | |
| 37 | + | timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{})} | |
| 38 | + | c.timers <- timer | |
| 39 | + | return timer | |
| 40 | + | } | |
| 41 | + | func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch } | |
| 42 | + | func (*attachPaletteTimer) Reset(time.Duration) bool { return false } | |
| 43 | + | func (t *attachPaletteTimer) Stop() bool { | |
| 44 | + | t.stopOnce.Do(func() { close(t.stopped) }) | |
| 45 | + | return true | |
| 46 | + | } | |
| 47 | + | func (t *attachPaletteTimer) fire() { t.ch <- time.Time{} } | |
| 48 | + | ||
| 49 | + | type attachPaletteReader struct{ chunks chan []byte } | |
| 50 | + | ||
| 51 | + | func newAttachPaletteReader() *attachPaletteReader { | |
| 52 | + | return &attachPaletteReader{chunks: make(chan []byte, 16)} | |
| 53 | + | } | |
| 54 | + | func (r *attachPaletteReader) Read(p []byte) (int, error) { | |
| 55 | + | data := <-r.chunks | |
| 56 | + | return copy(p, data), nil | |
| 57 | + | } | |
| 58 | + | func (r *attachPaletteReader) send(data string) { r.chunks <- []byte(data) } | |
| 59 | + | ||
| 60 | + | type attachPaletteWriter struct { | |
| 61 | + | mu sync.Mutex | |
| 62 | + | writes []string | |
| 63 | + | wrote chan string | |
| 64 | + | err error | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func newAttachPaletteWriter() *attachPaletteWriter { | |
| 68 | + | return &attachPaletteWriter{wrote: make(chan string, 16)} | |
| 69 | + | } | |
| 70 | + | func (w *attachPaletteWriter) Write(data []byte) (int, error) { | |
| 71 | + | copyData := string(data) | |
| 72 | + | w.mu.Lock() | |
| 73 | + | w.writes = append(w.writes, copyData) | |
| 74 | + | err := w.err | |
| 75 | + | w.mu.Unlock() | |
| 76 | + | if err != nil { | |
| 77 | + | return 0, err | |
| 78 | + | } | |
| 79 | + | w.wrote <- copyData | |
| 80 | + | return len(data), nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | type attachPaletteTerminalHarness struct { | |
| 84 | + | in io.Reader | |
| 85 | + | out *attachPaletteWriter | |
| 86 | + | resize chan domain.Size | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) { | |
| 90 | + | return func() error { return nil }, nil | |
| 91 | + | } | |
| 92 | + | func (*attachPaletteTerminalHarness) Size() (domain.Size, error) { | |
| 93 | + | return domain.Size{Cols: 80, Rows: 24}, nil | |
| 94 | + | } | |
| 95 | + | func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize } | |
| 96 | + | func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in } | |
| 97 | + | func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out } | |
| 98 | + | func (*attachPaletteTerminalHarness) Flush() error { return nil } | |
| 99 | + | ||
| 100 | + | type attachPaletteTransport struct { | |
| 101 | + | mu sync.Mutex | |
| 102 | + | frames []ports.Frame | |
| 103 | + | themes []ports.Theme | |
| 104 | + | themeCh chan ports.Theme | |
| 105 | + | inputCh chan ports.Input | |
| 106 | + | detached chan ports.Frame | |
| 107 | + | themeSendErr error | |
| 108 | + | welcomed bool | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func newAttachPaletteTransport() *attachPaletteTransport { | |
| 112 | + | return &attachPaletteTransport{ | |
| 113 | + | themeCh: make(chan ports.Theme, 16), | |
| 114 | + | inputCh: make(chan ports.Input, 16), | |
| 115 | + | detached: make(chan ports.Frame, 1), | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | func (t *attachPaletteTransport) Send(frame ports.Frame) error { | |
| 119 | + | t.mu.Lock() | |
| 120 | + | t.frames = append(t.frames, frame) | |
| 121 | + | themeSendErr := t.themeSendErr | |
| 122 | + | t.mu.Unlock() | |
| 123 | + | switch frame.Type { | |
| 124 | + | case ports.MsgTheme: | |
| 125 | + | if themeSendErr != nil { | |
| 126 | + | return themeSendErr | |
| 127 | + | } | |
| 128 | + | got, err := ports.UnmarshalTheme(frame.Payload) | |
| 129 | + | if err != nil { | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | t.mu.Lock() | |
| 133 | + | t.themes = append(t.themes, got) | |
| 134 | + | t.mu.Unlock() | |
| 135 | + | t.themeCh <- got | |
| 136 | + | case ports.MsgInput: | |
| 137 | + | got, err := ports.UnmarshalInput(frame.Payload) | |
| 138 | + | if err != nil { | |
| 139 | + | return err | |
| 140 | + | } | |
| 141 | + | t.inputCh <- got | |
| 142 | + | } | |
| 143 | + | return nil | |
| 144 | + | } | |
| 145 | + | func (t *attachPaletteTransport) Recv() (ports.Frame, error) { | |
| 146 | + | t.mu.Lock() | |
| 147 | + | if !t.welcomed { | |
| 148 | + | t.welcomed = true | |
| 149 | + | t.mu.Unlock() | |
| 150 | + | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil | |
| 151 | + | } | |
| 152 | + | t.mu.Unlock() | |
| 153 | + | return <-t.detached, nil | |
| 154 | + | } | |
| 155 | + | func (*attachPaletteTransport) Close() error { return nil } | |
| 156 | + | func (t *attachPaletteTransport) snapshots() []ports.Theme { | |
| 157 | + | t.mu.Lock() | |
| 158 | + | defer t.mu.Unlock() | |
| 159 | + | return append([]ports.Theme(nil), t.themes...) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | type attachPaletteHarness struct { | |
| 163 | + | clock *attachPaletteClock | |
| 164 | + | reader *attachPaletteReader | |
| 165 | + | writer *attachPaletteWriter | |
| 166 | + | transport *attachPaletteTransport | |
| 167 | + | done chan attachResult | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness { | |
| 171 | + | return startAttachPaletteHarnessWithTransport(state, newAttachPaletteTransport()) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func startAttachPaletteHarnessWithTransport(state *terminalThemeState, transport *attachPaletteTransport) *attachPaletteHarness { | |
| 175 | + | return startAttachPaletteHarnessWithInput(state, transport, nil) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func startAttachPaletteHarnessWithInput(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump) *attachPaletteHarness { | |
| 179 | + | return startAttachPaletteHarnessWithInputAndWriteError(state, transport, input, nil) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState, transport *attachPaletteTransport, input *terminalInputPump, writeErr error) *attachPaletteHarness { | |
| 183 | + | clock := newAttachPaletteClock() | |
| 184 | + | reader := newAttachPaletteReader() | |
| 185 | + | writer := newAttachPaletteWriter() | |
| 186 | + | writer.err = writeErr | |
| 187 | + | term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)} | |
| 188 | + | ms := &milestones{} | |
| 189 | + | runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)} | |
| 190 | + | attempt := &attachAttempt{ | |
| 191 | + | runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach}, | |
| 192 | + | milestones: ms, themeState: state, | |
| 193 | + | enterRaw: func() error { ms.rawEntered = true; return nil }, | |
| 194 | + | reconnect: &reconnectUI{term: term, rawEntered: new(bool)}, | |
| 195 | + | terminalInput: func() *terminalInputPump { | |
| 196 | + | return input | |
| 197 | + | }, | |
| 198 | + | } | |
| 199 | + | h := &attachPaletteHarness{clock: clock, reader: reader, writer: writer, transport: transport, done: make(chan attachResult, 1)} | |
| 200 | + | go func() { h.done <- attempt.run(context.Background()) }() | |
| 201 | + | // The handshake's two bounded-pre-welcome timers are unrelated to palette | |
| 202 | + | // acquisition. Remove them in this harness so every exposed timer is a | |
| 203 | + | // generation deadline (or a timer deliberately created by input handling). | |
| 204 | + | <-clock.timers | |
| 205 | + | <-clock.timers | |
| 206 | + | return h | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (h *attachPaletteHarness) nextWrite(t *testing.T) string { | |
| 210 | + | t.Helper() | |
| 211 | + | return <-h.writer.wrote | |
| 212 | + | } | |
| 213 | + | func (h *attachPaletteHarness) nextTheme(t *testing.T) ports.Theme { | |
| 214 | + | t.Helper() | |
| 215 | + | return <-h.transport.themeCh | |
| 216 | + | } | |
| 217 | + | func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer { | |
| 218 | + | t.Helper() | |
| 219 | + | return <-h.clock.timers | |
| 220 | + | } | |
| 221 | + | func (h *attachPaletteHarness) detach(t *testing.T) { | |
| 222 | + | t.Helper() | |
| 223 | + | h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})} | |
| 224 | + | require.NoError(t, (<-h.done).err) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func paletteReply(foreground, background string, slot uint8, color string) string { | |
| 228 | + | return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y" | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) { | |
| 232 | + | t.Setenv("COLORTERM", "truecolor") | |
| 233 | + | state := &terminalThemeState{} | |
| 234 | + | first := startAttachPaletteHarness(state) | |
| 235 | + | require.Equal(t, paletteColorBatch, first.nextWrite(t)) | |
| 236 | + | first.reader.send(paletteReply("#010203", "#040506", 2, "#102030")) | |
| 237 | + | _ = first.nextTheme(t) // initial cleared snapshot | |
| 238 | + | final := first.nextTheme(t) | |
| 239 | + | require.True(t, final.HasForeground) | |
| 240 | + | first.detach(t) | |
| 241 | + | ||
| 242 | + | reconnected := startAttachPaletteHarness(state) | |
| 243 | + | require.Equal(t, paletteColorBatch, reconnected.nextWrite(t)) | |
| 244 | + | cleared := reconnected.nextTheme(t) | |
| 245 | + | require.True(t, cleared.HasForeground) | |
| 246 | + | require.Equal(t, final.Foreground, cleared.Foreground) | |
| 247 | + | require.True(t, cleared.HasBackground) | |
| 248 | + | require.Equal(t, final.Background, cleared.Background) | |
| 249 | + | require.Zero(t, cleared.PaletteKnown) | |
| 250 | + | require.Equal(t, [16]renderer.RGB{}, cleared.Palette) | |
| 251 | + | ||
| 252 | + | reconnected.reader.send(paletteReply("#111213", "#141516", 2, "#202122")) | |
| 253 | + | _ = reconnected.nextTheme(t) | |
| 254 | + | reconnected.detach(t) | |
| 255 | + | require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication") | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func TestAttachReconnectPreservesStandaloneEscapeBeforeClaimRevocation(t *testing.T) { | |
| 259 | + | t.Setenv("COLORTERM", "truecolor") | |
| 260 | + | reader := newAttachPaletteReader() | |
| 261 | + | input := newTerminalInputPump(reader) | |
| 262 | + | input.start() | |
| 263 | + | t.Cleanup(input.stop) | |
| 264 | + | ||
| 265 | + | first := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input) | |
| 266 | + | require.Equal(t, paletteColorBatch, first.nextWrite(t)) | |
| 267 | + | _ = first.nextTheme(t) | |
| 268 | + | _ = first.nextTimer(t) // initial palette completion deadline | |
| 269 | + | ||
| 270 | + | // A standalone Escape is withheld as a possible DECRQM marker prefix. Its | |
| 271 | + | // raw read is committed, leaving only the scanner-local residual to hand | |
| 272 | + | // over when this attach is replaced. | |
| 273 | + | reader.send("\x1b") | |
| 274 | + | _ = first.nextTimer(t) // standalone Escape ambiguity deadline | |
| 275 | + | ||
| 276 | + | revoked := make(chan []byte, 1) | |
| 277 | + | allowRevocationToReturn := make(chan struct{}) | |
| 278 | + | var releaseRevocation sync.Once | |
| 279 | + | release := func() { releaseRevocation.Do(func() { close(allowRevocationToReturn) }) } | |
| 280 | + | defer release() | |
| 281 | + | input.afterRevoke = func() { | |
| 282 | + | input.mu.Lock() | |
| 283 | + | residual := append([]byte(nil), input.residual...) | |
| 284 | + | input.mu.Unlock() | |
| 285 | + | revoked <- residual | |
| 286 | + | <-allowRevocationToReturn | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // Detach starts reconnect teardown. The residual must already be owned by | |
| 290 | + | // the lifecycle reader at the precise point the old claim is revoked. | |
| 291 | + | first.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})} | |
| 292 | + | require.Equal(t, []byte("\x1b"), <-revoked) | |
| 293 | + | release() | |
| 294 | + | require.NoError(t, (<-first.done).err) | |
| 295 | + | input.afterRevoke = nil | |
| 296 | + | ||
| 297 | + | // The replacement attach receives the retained byte exactly once. | |
| 298 | + | second := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input) | |
| 299 | + | require.Equal(t, paletteColorBatch, second.nextWrite(t)) | |
| 300 | + | _ = second.nextTheme(t) | |
| 301 | + | _ = second.nextTimer(t) // initial palette completion deadline | |
| 302 | + | second.nextTimer(t).fire() | |
| 303 | + | second.nextTimer(t).fire() | |
| 304 | + | got := <-second.transport.inputCh | |
| 305 | + | require.Equal(t, []byte("\x1b"), got.Data) | |
| 306 | + | select { | |
| 307 | + | case duplicate := <-second.transport.inputCh: | |
| 308 | + | require.Failf(t, "duplicate input", "unexpected duplicate %q", duplicate.Data) | |
| 309 | + | default: | |
| 310 | + | } | |
| 311 | + | second.detach(t) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) { | |
| 315 | + | t.Setenv("COLORTERM", "truecolor") | |
| 316 | + | h := startAttachPaletteHarness(&terminalThemeState{}) | |
| 317 | + | require.Equal(t, paletteColorBatch, h.nextWrite(t)) | |
| 318 | + | initialDeadline := h.nextTimer(t) | |
| 319 | + | h.reader.send(paletteReply("#010203", "#040506", 2, "#102030")) | |
| 320 | + | _ = h.nextTheme(t) | |
| 321 | + | _ = h.nextTheme(t) | |
| 322 | + | // The initial completion timer was cancelled by the marker; it is distinct | |
| 323 | + | // from both replacement deadlines. | |
| 324 | + | _ = initialDeadline | |
| 325 | + | ||
| 326 | + | h.reader.send("\x1b[?997;2n") | |
| 327 | + | cleared := h.nextTheme(t) | |
| 328 | + | require.True(t, cleared.SchemeKnown) | |
| 329 | + | require.True(t, cleared.Light) | |
| 330 | + | require.Zero(t, cleared.PaletteKnown) | |
| 331 | + | require.Equal(t, paletteBoundaryQuery, h.nextWrite(t)) | |
| 332 | + | drainDeadline := h.nextTimer(t) | |
| 333 | + | ||
| 334 | + | // stdin is now blocked in Read. Firing the independently-created drain | |
| 335 | + | // deadline still starts the color batch, and its completion deadline still | |
| 336 | + | // finalizes the partial generation. | |
| 337 | + | drainDeadline.fire() | |
| 338 | + | require.Equal(t, paletteColorBatch, h.nextWrite(t)) | |
| 339 | + | completionDeadline := h.nextTimer(t) | |
| 340 | + | completionDeadline.fire() | |
| 341 | + | final := h.nextTheme(t) | |
| 342 | + | require.True(t, final.SchemeKnown) | |
| 343 | + | require.True(t, final.Light) | |
| 344 | + | require.False(t, final.HasForeground) | |
| 345 | + | require.False(t, final.HasBackground) | |
| 346 | + | h.detach(t) | |
| 347 | + | require.Len(t, h.transport.snapshots(), 4, "initial and replacement generations each publish only cleared then definitive Theme snapshots") | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | func TestAttachInitialPaletteFailureRevokesInputClaim(t *testing.T) { | |
| 351 | + | t.Setenv("COLORTERM", "truecolor") | |
| 352 | + | for _, tc := range []struct { | |
| 353 | + | name string | |
| 354 | + | start func(*terminalInputPump) *attachPaletteHarness | |
| 355 | + | contains string | |
| 356 | + | }{ | |
| 357 | + | { | |
| 358 | + | name: "publish", | |
| 359 | + | start: func(input *terminalInputPump) *attachPaletteHarness { | |
| 360 | + | want := errors.New("initial theme send failed") | |
| 361 | + | transport := newAttachPaletteTransport() | |
| 362 | + | transport.themeSendErr = want | |
| 363 | + | return startAttachPaletteHarnessWithInput(&terminalThemeState{}, transport, input) | |
| 364 | + | }, | |
| 365 | + | contains: "publishing theme", | |
| 366 | + | }, | |
| 367 | + | { | |
| 368 | + | name: "write", | |
| 369 | + | start: func(input *terminalInputPump) *attachPaletteHarness { | |
| 370 | + | return startAttachPaletteHarnessWithInputAndWriteError(&terminalThemeState{}, newAttachPaletteTransport(), input, errors.New("palette write failed")) | |
| 371 | + | }, | |
| 372 | + | contains: "writing palette query", | |
| 373 | + | }, | |
| 374 | + | } { | |
| 375 | + | t.Run(tc.name, func(t *testing.T) { | |
| 376 | + | input := newTerminalInputPump(nil) | |
| 377 | + | failed := tc.start(input) | |
| 378 | + | err := (<-failed.done).err | |
| 379 | + | require.ErrorContains(t, err, tc.contains) | |
| 380 | + | ||
| 381 | + | // The next attach must be able to claim the lifecycle-owned reader. | |
| 382 | + | // Before revocation was registered at claim time this panicked above. | |
| 383 | + | reconnected := startAttachPaletteHarnessWithInput(&terminalThemeState{}, newAttachPaletteTransport(), input) | |
| 384 | + | require.Equal(t, paletteColorBatch, reconnected.nextWrite(t)) | |
| 385 | + | reconnected.detach(t) | |
| 386 | + | }) | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestStdinPumpSameReadSchemeAndOldMarkerCannotAdvanceReplacement(t *testing.T) { | |
| 391 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 392 | + | defer cancel() | |
| 393 | + | ||
| 394 | + | coordinator := newPaletteGenerationCoordinator() | |
| 395 | + | coordinator.start(ports.Theme{}, false) | |
| 396 | + | var activeGeneration atomic.Uint64 | |
| 397 | + | activeGeneration.Store(uint64(coordinator.current.id)) | |
| 398 | + | ||
| 399 | + | // An unbuffered event channel forces the attach loop to process the scheme | |
| 400 | + | // notification and publish its replacement generation before the scanner | |
| 401 | + | // can deliver the old marker from the same terminal Read. | |
| 402 | + | events := make(chan paletteGenerationEvent) | |
| 403 | + | out := make(chan ports.Frame, 1) | |
| 404 | + | schemeHandled := make(chan struct{}) | |
| 405 | + | pump := &stdinPump{ | |
| 406 | + | ctx: ctx, | |
| 407 | + | cancel: cancel, | |
| 408 | + | in: strings.NewReader("\x1b[?997;2n\x1b[?2031;1$y"), | |
| 409 | + | out: out, | |
| 410 | + | clock: newAttachPaletteClock(), | |
| 411 | + | logger: slog.New(slog.DiscardHandler), | |
| 412 | + | paletteEvents: events, | |
| 413 | + | activeGeneration: &activeGeneration, | |
| 414 | + | afterPaletteEvent: func(event paletteGenerationEvent) { | |
| 415 | + | if event.kind == paletteEventScheme { | |
| 416 | + | <-schemeHandled | |
| 417 | + | } | |
| 418 | + | }, | |
| 419 | + | } | |
| 420 | + | pumpDone := make(chan struct{}) | |
| 421 | + | go func() { | |
| 422 | + | pump.run() | |
| 423 | + | close(pumpDone) | |
| 424 | + | }() | |
| 425 | + | ||
| 426 | + | scheme := <-events | |
| 427 | + | require.Equal(t, paletteEventScheme, scheme.kind) | |
| 428 | + | oldID := scheme.id | |
| 429 | + | ||
| 430 | + | // This models attachAttempt handling the scheme event. The replacement is | |
| 431 | + | // draining, so a marker tagged with its ID would incorrectly start its | |
| 432 | + | // color batch before the real drain boundary arrives. | |
| 433 | + | coordinator.start(ports.Theme{SchemeKnown: true, Light: scheme.light}, true) | |
| 434 | + | newID := coordinator.current.id | |
| 435 | + | activeGeneration.Store(uint64(newID)) | |
| 436 | + | close(schemeHandled) | |
| 437 | + | require.NotEqual(t, oldID, newID) | |
| 438 | + | require.Equal(t, generationDraining, coordinator.current.phase) | |
| 439 | + | ||
| 440 | + | oldMarker := <-events | |
| 441 | + | require.Equal(t, paletteEventMarker, oldMarker.kind) | |
| 442 | + | require.Equal(t, oldID, oldMarker.id, "one read must retain its generation tag") | |
| 443 | + | require.Empty(t, coordinator.handle(oldMarker)) | |
| 444 | + | require.True(t, coordinator.active) | |
| 445 | + | require.Equal(t, generationDraining, coordinator.current.phase) | |
| 446 | + | require.Equal(t, newID, coordinator.current.id) | |
| 447 | + | ||
| 448 | + | <-pumpDone | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) { | |
| 452 | + | t.Setenv("COLORTERM", "truecolor") | |
| 453 | + | h := startAttachPaletteHarness(&terminalThemeState{}) | |
| 454 | + | require.Equal(t, paletteColorBatch, h.nextWrite(t)) | |
| 455 | + | _ = h.nextTimer(t) | |
| 456 | + | h.reader.send(paletteReply("#010203", "#040506", 2, "#102030")) | |
| 457 | + | _ = h.nextTheme(t) | |
| 458 | + | _ = h.nextTheme(t) | |
| 459 | + | ||
| 460 | + | h.reader.send("\x1b[?997;1n") | |
| 461 | + | _ = h.nextTheme(t) | |
| 462 | + | require.Equal(t, paletteBoundaryQuery, h.nextWrite(t)) | |
| 463 | + | drainDeadline := h.nextTimer(t) | |
| 464 | + | drainDeadline.fire() | |
| 465 | + | require.Equal(t, paletteColorBatch, h.nextWrite(t)) | |
| 466 | + | completionDeadline := h.nextTimer(t) | |
| 467 | + | ||
| 468 | + | // The drain's stale reports arrive before its late boundary and must not | |
| 469 | + | // contaminate the replacement accumulator. Reports after that boundary are | |
| 470 | + | // the batch's reports and are collected through the second marker. | |
| 471 | + | h.reader.send("\x1b]10;#a1a2a3\a\x1b]11;#a4a5a6\a\x1b]4;2;#a7a8a9\a\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y") | |
| 472 | + | final := h.nextTheme(t) | |
| 473 | + | require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground) | |
| 474 | + | require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background) | |
| 475 | + | require.Equal(t, uint16(1<<3), final.PaletteKnown) | |
| 476 | + | // The marker finalization cancels, rather than fires, the independently | |
| 477 | + | // armed completion deadline. | |
| 478 | + | _ = completionDeadline | |
| 479 | + | h.detach(t) | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | func TestAttachForwardsStandaloneEscapeAfterAmbiguityDeadline(t *testing.T) { | |
| 483 | + | t.Setenv("COLORTERM", "truecolor") | |
| 484 | + | h := startAttachPaletteHarness(&terminalThemeState{}) | |
| 485 | + | require.Equal(t, paletteColorBatch, h.nextWrite(t)) | |
| 486 | + | _ = h.nextTimer(t) // initial palette completion deadline | |
| 487 | + | _ = h.nextTheme(t) // initial cleared publication | |
| 488 | + | ||
| 489 | + | // A bare Escape is ambiguous with the prefix of the DECRQM completion | |
| 490 | + | // response. It must not require another Read or EOF before reaching the | |
| 491 | + | // daemon. | |
| 492 | + | h.reader.send("\x1b") | |
| 493 | + | h.nextTimer(t).fire() // DECRQM ambiguity deadline | |
| 494 | + | h.nextTimer(t).fire() // bracketed-paste ambiguity deadline | |
| 495 | + | input := <-h.transport.inputCh | |
| 496 | + | require.Equal(t, []byte("\x1b"), input.Data) | |
| 497 | + | h.detach(t) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func TestAttachConsumesSplitMarkerAndCancelsAmbiguityDeadline(t *testing.T) { | |
| 501 | + | t.Setenv("COLORTERM", "truecolor") | |
| 502 | + | h := startAttachPaletteHarness(&terminalThemeState{}) | |
| 503 | + | require.Equal(t, paletteColorBatch, h.nextWrite(t)) | |
| 504 | + | _ = h.nextTimer(t) // initial palette completion deadline | |
| 505 | + | _ = h.nextTheme(t) // initial cleared publication | |
| 506 | + | ||
| 507 | + | h.reader.send("before\x1b[?2031;") | |
| 508 | + | markerDeadline := h.nextTimer(t) | |
| 509 | + | h.reader.send("1$yafter") | |
| 510 | + | ||
| 511 | + | final := h.nextTheme(t) | |
| 512 | + | require.False(t, final.HasForeground) | |
| 513 | + | var got []byte | |
| 514 | + | for len(got) < len("beforeafter") { | |
| 515 | + | got = append(got, (<-h.transport.inputCh).Data...) | |
| 516 | + | } | |
| 517 | + | require.Equal(t, []byte("beforeafter"), got) | |
| 518 | + | <-markerDeadline.stopped | |
| 519 | + | h.detach(t) | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func TestAttachForwardsInputOnceAndPartialOSCDoesNotPublishTheme(t *testing.T) { | |
| 523 | + | t.Setenv("COLORTERM", "truecolor") | |
| 524 | + | h := startAttachPaletteHarness(&terminalThemeState{}) | |
| 525 | + | require.Equal(t, paletteColorBatch, h.nextWrite(t)) | |
| 526 | + | _ = h.nextTimer(t) | |
| 527 | + | cleared := h.nextTheme(t) | |
| 528 | + | require.Zero(t, cleared.PaletteKnown) | |
| 529 | + | ||
| 530 | + | h.reader.send("\x1b]10;#0102") | |
| 531 | + | h.reader.send("03\az") | |
| 532 | + | input := <-h.transport.inputCh | |
| 533 | + | require.Equal(t, []byte("z"), input.Data) | |
| 534 | + | require.Len(t, h.transport.snapshots(), 1, "partial and incomplete OSC replies must not publish Theme") | |
| 535 | + | ||
| 536 | + | h.reader.send("\x1b[?2031;1$y") | |
| 537 | + | final := h.nextTheme(t) | |
| 538 | + | require.True(t, final.HasForeground) | |
| 539 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground) | |
| 540 | + | require.Len(t, h.transport.snapshots(), 2) | |
| 541 | + | h.detach(t) | |
| 542 | + | } | |
A internal/usecase/client/palette_attach_test.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/pkg/renderer" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type paletteAttachClock struct{} | |
| 20 | + | ||
| 21 | + | func (paletteAttachClock) Now() time.Time { return time.Time{} } | |
| 22 | + | func (paletteAttachClock) NewTimer(time.Duration) ports.Timer { | |
| 23 | + | return paletteAttachTimer{ch: make(chan time.Time)} | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type paletteAttachTimer struct{ ch chan time.Time } | |
| 27 | + | ||
| 28 | + | func (t paletteAttachTimer) C() <-chan time.Time { return t.ch } | |
| 29 | + | func (paletteAttachTimer) Reset(time.Duration) bool { return false } | |
| 30 | + | func (paletteAttachTimer) Stop() bool { return true } | |
| 31 | + | ||
| 32 | + | type paletteAttachReader struct { | |
| 33 | + | mu sync.Mutex | |
| 34 | + | data []byte | |
| 35 | + | done chan struct{} | |
| 36 | + | once sync.Once | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func newPaletteAttachReader(data []byte) *paletteAttachReader { | |
| 40 | + | return &paletteAttachReader{data: append([]byte(nil), data...), done: make(chan struct{})} | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Read retains unread bytes after a short read and blocks only until close. | |
| 44 | + | // This keeps the test terminal reader faithful to io.Reader and makes a | |
| 45 | + | // failed test teardown deterministic instead of leaving a goroutine parked. | |
| 46 | + | func (r *paletteAttachReader) Read(p []byte) (int, error) { | |
| 47 | + | r.mu.Lock() | |
| 48 | + | if len(r.data) != 0 { | |
| 49 | + | n := copy(p, r.data) | |
| 50 | + | r.data = r.data[n:] | |
| 51 | + | r.mu.Unlock() | |
| 52 | + | return n, nil | |
| 53 | + | } | |
| 54 | + | r.mu.Unlock() | |
| 55 | + | <-r.done | |
| 56 | + | return 0, io.EOF | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) } | |
| 60 | + | ||
| 61 | + | type paletteAttachTerminal struct { | |
| 62 | + | in io.Reader | |
| 63 | + | out bytes.Buffer | |
| 64 | + | resize chan domain.Size | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (t *paletteAttachTerminal) EnterRaw() (func() error, error) { | |
| 68 | + | return func() error { return nil }, nil | |
| 69 | + | } | |
| 70 | + | func (t *paletteAttachTerminal) Size() (domain.Size, error) { | |
| 71 | + | return domain.Size{Cols: 80, Rows: 24}, nil | |
| 72 | + | } | |
| 73 | + | func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize } | |
| 74 | + | func (t *paletteAttachTerminal) In() io.Reader { return t.in } | |
| 75 | + | func (t *paletteAttachTerminal) Out() io.Writer { return &t.out } | |
| 76 | + | func (t *paletteAttachTerminal) Flush() error { return nil } | |
| 77 | + | ||
| 78 | + | type paletteAttachTransport struct { | |
| 79 | + | mu sync.Mutex | |
| 80 | + | sent []ports.Frame | |
| 81 | + | themes []ports.Theme | |
| 82 | + | finals int | |
| 83 | + | finalSet chan struct{} | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (t *paletteAttachTransport) Send(frame ports.Frame) error { | |
| 87 | + | t.mu.Lock() | |
| 88 | + | defer t.mu.Unlock() | |
| 89 | + | t.sent = append(t.sent, frame) | |
| 90 | + | if frame.Type == ports.MsgTheme { | |
| 91 | + | theme, err := ports.UnmarshalTheme(frame.Payload) | |
| 92 | + | if err != nil { | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | t.themes = append(t.themes, theme) | |
| 96 | + | if theme.HasForeground && theme.HasBackground { | |
| 97 | + | t.finals++ | |
| 98 | + | if t.finals == 1 { | |
| 99 | + | close(t.finalSet) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (t *paletteAttachTransport) Recv() (ports.Frame, error) { | |
| 107 | + | t.mu.Lock() | |
| 108 | + | first := len(t.sent) == 1 | |
| 109 | + | t.mu.Unlock() | |
| 110 | + | if first { | |
| 111 | + | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil | |
| 112 | + | } | |
| 113 | + | <-t.finalSet | |
| 114 | + | return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}, nil | |
| 115 | + | } | |
| 116 | + | func (*paletteAttachTransport) Close() error { return nil } | |
| 117 | + | ||
| 118 | + | func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) { | |
| 119 | + | reader := newPaletteAttachReader([]byte("abcdef")) | |
| 120 | + | first := make([]byte, 2) | |
| 121 | + | n, err := reader.Read(first) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | require.Equal(t, "ab", string(first[:n])) | |
| 124 | + | ||
| 125 | + | rest := make([]byte, 8) | |
| 126 | + | n, err = reader.Read(rest) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | require.Equal(t, "cdef", string(rest[:n])) | |
| 129 | + | ||
| 130 | + | reader.close() | |
| 131 | + | n, err = reader.Read(rest) | |
| 132 | + | require.Zero(t, n) | |
| 133 | + | require.ErrorIs(t, err, io.EOF) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) { | |
| 137 | + | input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y")) | |
| 138 | + | t.Cleanup(input.close) | |
| 139 | + | term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)} | |
| 140 | + | transport := &paletteAttachTransport{finalSet: make(chan struct{})} | |
| 141 | + | ms := &milestones{} | |
| 142 | + | runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)} | |
| 143 | + | attempt := &attachAttempt{ | |
| 144 | + | runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach}, | |
| 145 | + | milestones: ms, themeState: &terminalThemeState{}, | |
| 146 | + | enterRaw: func() error { ms.rawEntered = true; return nil }, | |
| 147 | + | reconnect: &reconnectUI{term: term, rawEntered: new(bool)}, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | result := attempt.run(context.Background()) | |
| 151 | + | require.NoError(t, result.err) | |
| 152 | + | require.Equal(t, paletteColorBatch, term.out.String()) | |
| 153 | + | ||
| 154 | + | transport.mu.Lock() | |
| 155 | + | defer transport.mu.Unlock() | |
| 156 | + | require.Len(t, transport.sent, 3) | |
| 157 | + | require.Equal(t, []ports.MsgType{ports.MsgHello, ports.MsgTheme, ports.MsgTheme}, []ports.MsgType{ | |
| 158 | + | transport.sent[0].Type, transport.sent[1].Type, transport.sent[2].Type, | |
| 159 | + | }, "the protocol requires Hello before the two palette publications") | |
| 160 | + | require.Len(t, transport.themes, 2) | |
| 161 | + | cleared, definitive := transport.themes[0], transport.themes[1] | |
| 162 | + | require.Zero(t, cleared.PaletteKnown) | |
| 163 | + | require.False(t, cleared.HasForeground) | |
| 164 | + | require.True(t, definitive.HasForeground) | |
| 165 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, definitive.Foreground) | |
| 166 | + | require.True(t, definitive.HasBackground) | |
| 167 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, definitive.Background) | |
| 168 | + | require.Equal(t, uint16(1<<2), definitive.PaletteKnown) | |
| 169 | + | } | |
A internal/usecase/client/palette_generation.go +320 -0
| @@ -0,0 +1,320 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Palette queries always have a DECRQM boundary. The first marker drains | |
| 11 | + | // replies from a replaced generation; the second completes the color batch. | |
| 12 | + | const paletteBoundaryQuery = "\x1b[?2031$p" | |
| 13 | + | ||
| 14 | + | const paletteColorBatch = "\x1b]10;?\x07\x1b]11;?\x07" + | |
| 15 | + | "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" + | |
| 16 | + | paletteBoundaryQuery | |
| 17 | + | ||
| 18 | + | // paletteGenerationDeadline is a local terminal capability fallback, not a | |
| 19 | + | // user-facing timeout. Timer ownership belongs to the attach loop. | |
| 20 | + | const paletteGenerationDeadline = 200 * time.Millisecond | |
| 21 | + | ||
| 22 | + | // paletteMarkerAmbiguityDeadline bounds how long an Escape byte may remain | |
| 23 | + | // withheld while it could still become a DECRQM completion response. Match | |
| 24 | + | // the existing local input-prefix bound so a keypress remains prompt. | |
| 25 | + | const paletteMarkerAmbiguityDeadline = pasteFlushDelay | |
| 26 | + | ||
| 27 | + | type paletteGenerationID uint64 | |
| 28 | + | ||
| 29 | + | type paletteGenerationPhase uint8 | |
| 30 | + | ||
| 31 | + | const ( | |
| 32 | + | generationIdle paletteGenerationPhase = iota | |
| 33 | + | generationDraining | |
| 34 | + | generationCollecting | |
| 35 | + | generationFinalized | |
| 36 | + | ) | |
| 37 | + | ||
| 38 | + | type paletteGeneration struct { | |
| 39 | + | id paletteGenerationID | |
| 40 | + | phase paletteGenerationPhase | |
| 41 | + | theme ports.Theme | |
| 42 | + | expectedMarkers int | |
| 43 | + | awaitingLateDrainBoundary bool | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | type paletteGenerationEventKind uint8 | |
| 47 | + | ||
| 48 | + | const ( | |
| 49 | + | paletteEventMarker paletteGenerationEventKind = iota | |
| 50 | + | paletteEventDrainDeadline | |
| 51 | + | paletteEventCompletionDeadline | |
| 52 | + | paletteEventForeground | |
| 53 | + | paletteEventBackground | |
| 54 | + | paletteEventPalette | |
| 55 | + | paletteEventScheme | |
| 56 | + | ) | |
| 57 | + | ||
| 58 | + | type paletteGenerationEvent struct { | |
| 59 | + | id paletteGenerationID | |
| 60 | + | kind paletteGenerationEventKind | |
| 61 | + | rgb renderer.RGB | |
| 62 | + | slot uint8 | |
| 63 | + | light bool | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // paletteGenerationAction is deliberately declarative. The attach loop is | |
| 67 | + | // the sole terminal writer and protocol sender; timer bridges only enqueue a | |
| 68 | + | // generation-tagged event back to that loop. | |
| 69 | + | type paletteGenerationActionKind uint8 | |
| 70 | + | ||
| 71 | + | const ( | |
| 72 | + | paletteActionPublishCleared paletteGenerationActionKind = iota | |
| 73 | + | paletteActionWriteDrain | |
| 74 | + | paletteActionWriteBatch | |
| 75 | + | paletteActionArmDrainDeadline | |
| 76 | + | paletteActionArmCompletionDeadline | |
| 77 | + | paletteActionCancelDrainDeadline | |
| 78 | + | paletteActionCancelCompletionDeadline | |
| 79 | + | paletteActionPublishFinal | |
| 80 | + | ) | |
| 81 | + | ||
| 82 | + | type paletteGenerationAction struct { | |
| 83 | + | kind paletteGenerationActionKind | |
| 84 | + | id paletteGenerationID | |
| 85 | + | bytes string | |
| 86 | + | theme ports.Theme | |
| 87 | + | deadline time.Duration | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // paletteGenerationCoordinator has no I/O, clocks, channels, or goroutines. | |
| 91 | + | // All state is scoped to one monotonically increasing generation. | |
| 92 | + | type paletteGenerationCoordinator struct { | |
| 93 | + | next paletteGenerationID | |
| 94 | + | current paletteGeneration | |
| 95 | + | active bool | |
| 96 | + | finalized ports.Theme | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func newPaletteGenerationCoordinator() *paletteGenerationCoordinator { | |
| 100 | + | return &paletteGenerationCoordinator{} | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // start clears the last definitive palette before issuing either the initial | |
| 104 | + | // batch or a replacement drain. The retained foreground/background and | |
| 105 | + | // capability avoid a full neutral flash, while the accumulator begins with no | |
| 106 | + | // defaults or palette values so a definitive result never mixes generations. | |
| 107 | + | func (c *paletteGenerationCoordinator) start(retained ports.Theme, replacement bool) []paletteGenerationAction { | |
| 108 | + | actions := c.cancelCurrent() | |
| 109 | + | c.next++ | |
| 110 | + | id := c.next | |
| 111 | + | ||
| 112 | + | cleared := retained | |
| 113 | + | cleared.PaletteKnown = 0 | |
| 114 | + | cleared.Palette = [16]renderer.RGB{} | |
| 115 | + | c.current = paletteGeneration{ | |
| 116 | + | id: id, | |
| 117 | + | phase: generationCollecting, | |
| 118 | + | theme: ports.Theme{ | |
| 119 | + | TrueColor: retained.TrueColor, | |
| 120 | + | SchemeKnown: retained.SchemeKnown, | |
| 121 | + | Light: retained.Light, | |
| 122 | + | }, | |
| 123 | + | } | |
| 124 | + | c.active = true | |
| 125 | + | actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared}) | |
| 126 | + | if replacement { | |
| 127 | + | c.current.phase = generationDraining | |
| 128 | + | actions = append(actions, | |
| 129 | + | paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery}, | |
| 130 | + | paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline}, | |
| 131 | + | ) | |
| 132 | + | return actions | |
| 133 | + | } | |
| 134 | + | return append(actions, | |
| 135 | + | paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch}, | |
| 136 | + | paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline}, | |
| 137 | + | ) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction { | |
| 141 | + | if !c.active { | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | id := c.current.id | |
| 145 | + | c.active = false | |
| 146 | + | c.current.phase = generationFinalized | |
| 147 | + | return []paletteGenerationAction{ | |
| 148 | + | {kind: paletteActionCancelDrainDeadline, id: id}, | |
| 149 | + | {kind: paletteActionCancelCompletionDeadline, id: id}, | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction { | |
| 154 | + | if !c.active || event.id != c.current.id { | |
| 155 | + | return nil | |
| 156 | + | } | |
| 157 | + | switch event.kind { | |
| 158 | + | case paletteEventMarker: | |
| 159 | + | return c.onMarker() | |
| 160 | + | case paletteEventDrainDeadline: | |
| 161 | + | if c.current.phase != generationDraining { | |
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | c.current.phase = generationCollecting | |
| 165 | + | // A late drain response may still arrive after the batch marker. Exclude | |
| 166 | + | // OSC reports until its boundary is consumed: otherwise stale drain-era | |
| 167 | + | // reports could become part of this generation's definitive snapshot. | |
| 168 | + | c.current.expectedMarkers = 2 | |
| 169 | + | c.current.awaitingLateDrainBoundary = true | |
| 170 | + | return []paletteGenerationAction{ | |
| 171 | + | {kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch}, | |
| 172 | + | {kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline}, | |
| 173 | + | } | |
| 174 | + | case paletteEventCompletionDeadline: | |
| 175 | + | if c.current.phase != generationCollecting { | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | return c.finalize() | |
| 179 | + | case paletteEventForeground: | |
| 180 | + | if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary { | |
| 181 | + | c.current.theme.HasForeground = true | |
| 182 | + | c.current.theme.Foreground = event.rgb | |
| 183 | + | } | |
| 184 | + | case paletteEventBackground: | |
| 185 | + | if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary { | |
| 186 | + | c.current.theme.HasBackground = true | |
| 187 | + | c.current.theme.Background = event.rgb | |
| 188 | + | } | |
| 189 | + | case paletteEventPalette: | |
| 190 | + | if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary && event.slot < 16 { | |
| 191 | + | c.current.theme.PaletteKnown |= uint16(1) << event.slot | |
| 192 | + | c.current.theme.Palette[event.slot] = event.rgb | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | return nil | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction { | |
| 199 | + | switch c.current.phase { | |
| 200 | + | case generationDraining: | |
| 201 | + | c.current.phase = generationCollecting | |
| 202 | + | c.current.expectedMarkers = 1 | |
| 203 | + | return []paletteGenerationAction{ | |
| 204 | + | {kind: paletteActionCancelDrainDeadline, id: c.current.id}, | |
| 205 | + | {kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch}, | |
| 206 | + | {kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline}, | |
| 207 | + | } | |
| 208 | + | case generationCollecting: | |
| 209 | + | if c.current.expectedMarkers == 0 { | |
| 210 | + | c.current.expectedMarkers = 1 | |
| 211 | + | } | |
| 212 | + | if c.current.awaitingLateDrainBoundary { | |
| 213 | + | c.current.awaitingLateDrainBoundary = false | |
| 214 | + | } | |
| 215 | + | c.current.expectedMarkers-- | |
| 216 | + | if c.current.expectedMarkers == 0 { | |
| 217 | + | return c.finalize() | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | return nil | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction { | |
| 224 | + | if !c.active || c.current.phase != generationCollecting { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | c.current.phase = generationFinalized | |
| 228 | + | c.active = false | |
| 229 | + | c.finalized = c.current.theme | |
| 230 | + | return []paletteGenerationAction{ | |
| 231 | + | {kind: paletteActionCancelDrainDeadline, id: c.current.id}, | |
| 232 | + | {kind: paletteActionCancelCompletionDeadline, id: c.current.id}, | |
| 233 | + | {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized}, | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction { | |
| 238 | + | return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker}) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) { | |
| 242 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb}) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) { | |
| 246 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb}) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) { | |
| 250 | + | c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb}) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (c *paletteGenerationCoordinator) finalizedTheme() ports.Theme { return c.finalized } | |
| 254 | + | ||
| 255 | + | // paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains | |
| 256 | + | // possible prefixes across reads, forwarding all ordinary input exactly once. | |
| 257 | + | type paletteMarkerScanner struct{ pending []byte } | |
| 258 | + | ||
| 259 | + | var paletteMarkerResponses = [][]byte{ | |
| 260 | + | []byte("\x1b[?2031;0$y"), | |
| 261 | + | []byte("\x1b[?2031;1$y"), | |
| 262 | + | []byte("\x1b[?2031;2$y"), | |
| 263 | + | []byte("\x1b[?2031;3$y"), | |
| 264 | + | []byte("\x1b[?2031;4$y"), | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) { | |
| 268 | + | for i, b := range data { | |
| 269 | + | s.pending = append(s.pending, b) | |
| 270 | + | matched, possible := false, false | |
| 271 | + | for _, response := range paletteMarkerResponses { | |
| 272 | + | if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) { | |
| 273 | + | continue | |
| 274 | + | } | |
| 275 | + | if len(s.pending) == len(response) { | |
| 276 | + | matched = true | |
| 277 | + | } else { | |
| 278 | + | possible = true | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | if matched { | |
| 282 | + | s.pending = nil | |
| 283 | + | onMarker() | |
| 284 | + | continue | |
| 285 | + | } | |
| 286 | + | if possible { | |
| 287 | + | continue | |
| 288 | + | } | |
| 289 | + | if s.pending[0] != '\x1b' { | |
| 290 | + | onBytes(s.pending[:1]) | |
| 291 | + | s.pending = s.pending[1:] | |
| 292 | + | continue | |
| 293 | + | } | |
| 294 | + | // This is not a marker. Forward the entire remaining read as one | |
| 295 | + | // contiguous input run so CSI mouse reports and escape sequences keep | |
| 296 | + | // their framing. | |
| 297 | + | s.pending = append(s.pending, data[i+1:]...) | |
| 298 | + | onBytes(s.pending) | |
| 299 | + | s.pending = nil | |
| 300 | + | return | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | func (s *paletteMarkerScanner) flush(onBytes func([]byte)) { | |
| 304 | + | if len(s.pending) == 0 { | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | onBytes(s.pending) | |
| 308 | + | s.pending = nil | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // takePending transfers only an undecided DECRQM prefix to a replacement | |
| 312 | + | // scanner. Callers use it during cancellation after all other callbacks from | |
| 313 | + | // the source read have been committed, preventing both loss and replay. | |
| 314 | + | func (s *paletteMarkerScanner) takePending() []byte { | |
| 315 | + | pending := append([]byte(nil), s.pending...) | |
| 316 | + | s.pending = nil | |
| 317 | + | return pending | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 } | |
A internal/usecase/client/palette_generation_test.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // manualPaletteClock applies only declarative coordinator timer actions. Its | |
| 14 | + | // callbacks re-enter the coordinator through generation-tagged events; they | |
| 15 | + | // never perform terminal I/O or protocol sends. | |
| 16 | + | type manualPaletteClock struct { | |
| 17 | + | drain, completion map[paletteGenerationID]bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func newManualPaletteClock() *manualPaletteClock { | |
| 21 | + | return &manualPaletteClock{drain: map[paletteGenerationID]bool{}, completion: map[paletteGenerationID]bool{}} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (c *manualPaletteClock) apply(actions []paletteGenerationAction) { | |
| 25 | + | for _, action := range actions { | |
| 26 | + | switch action.kind { | |
| 27 | + | case paletteActionArmDrainDeadline: | |
| 28 | + | c.drain[action.id] = true | |
| 29 | + | case paletteActionArmCompletionDeadline: | |
| 30 | + | c.completion[action.id] = true | |
| 31 | + | case paletteActionCancelDrainDeadline: | |
| 32 | + | delete(c.drain, action.id) | |
| 33 | + | case paletteActionCancelCompletionDeadline: | |
| 34 | + | delete(c.completion, action.id) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (c *manualPaletteClock) fireDrain(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction { | |
| 40 | + | if !c.drain[id] { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | delete(c.drain, id) | |
| 44 | + | return g.handle(paletteGenerationEvent{id: id, kind: paletteEventDrainDeadline}) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction { | |
| 48 | + | if !c.completion[id] { | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | delete(c.completion, id) | |
| 52 | + | return g.handle(paletteGenerationEvent{id: id, kind: paletteEventCompletionDeadline}) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func generationTheme() ports.Theme { | |
| 56 | + | return ports.Theme{ | |
| 57 | + | TrueColor: true, | |
| 58 | + | HasForeground: true, | |
| 59 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 60 | + | HasBackground: true, | |
| 61 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 62 | + | SchemeKnown: true, | |
| 63 | + | Light: true, | |
| 64 | + | PaletteKnown: 1 << 2, | |
| 65 | + | Palette: [16]renderer.RGB{2: {R: 7, G: 8, B: 9}}, | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func actionKinds(actions []paletteGenerationAction) []paletteGenerationActionKind { | |
| 70 | + | out := make([]paletteGenerationActionKind, len(actions)) | |
| 71 | + | for i := range actions { | |
| 72 | + | out[i] = actions[i].kind | |
| 73 | + | } | |
| 74 | + | return out | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func findAction(t *testing.T, actions []paletteGenerationAction, kind paletteGenerationActionKind) paletteGenerationAction { | |
| 78 | + | t.Helper() | |
| 79 | + | for _, action := range actions { | |
| 80 | + | if action.kind == kind { | |
| 81 | + | return action | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | t.Fatalf("missing action %v in %#v", kind, actions) | |
| 85 | + | return paletteGenerationAction{} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) { | |
| 89 | + | g := newPaletteGenerationCoordinator() | |
| 90 | + | clock := newManualPaletteClock() | |
| 91 | + | start := g.start(generationTheme(), false) | |
| 92 | + | clock.apply(start) | |
| 93 | + | ||
| 94 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 95 | + | paletteActionPublishCleared, | |
| 96 | + | paletteActionWriteBatch, | |
| 97 | + | paletteActionArmCompletionDeadline, | |
| 98 | + | }, actionKinds(start)) | |
| 99 | + | cleared := findAction(t, start, paletteActionPublishCleared).theme | |
| 100 | + | require.True(t, cleared.HasForeground) | |
| 101 | + | require.Equal(t, generationTheme().Foreground, cleared.Foreground) | |
| 102 | + | require.Zero(t, cleared.PaletteKnown) | |
| 103 | + | require.Zero(t, cleared.Palette) | |
| 104 | + | ||
| 105 | + | batch := findAction(t, start, paletteActionWriteBatch) | |
| 106 | + | require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07"+ | |
| 107 | + | "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"+ | |
| 108 | + | "\x1b[?2031$p", batch.bytes) | |
| 109 | + | id := batch.id | |
| 110 | + | g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12}) | |
| 111 | + | g.background(id, renderer.RGB{R: 13, G: 14, B: 15}) | |
| 112 | + | g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18}) | |
| 113 | + | final := g.marker(id) | |
| 114 | + | clock.apply(final) | |
| 115 | + | ||
| 116 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 117 | + | paletteActionCancelDrainDeadline, | |
| 118 | + | paletteActionCancelCompletionDeadline, | |
| 119 | + | paletteActionPublishFinal, | |
| 120 | + | }, actionKinds(final)) | |
| 121 | + | got := findAction(t, final, paletteActionPublishFinal).theme | |
| 122 | + | require.True(t, got.TrueColor) | |
| 123 | + | require.True(t, got.HasForeground) | |
| 124 | + | require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground) | |
| 125 | + | require.True(t, got.HasBackground) | |
| 126 | + | require.Equal(t, uint16(1<<3), got.PaletteKnown) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestPaletteGenerationReplacementWaitsForDrain(t *testing.T) { | |
| 130 | + | g := newPaletteGenerationCoordinator() | |
| 131 | + | clock := newManualPaletteClock() | |
| 132 | + | start := g.start(generationTheme(), true) | |
| 133 | + | clock.apply(start) | |
| 134 | + | ||
| 135 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 136 | + | paletteActionPublishCleared, | |
| 137 | + | paletteActionWriteDrain, | |
| 138 | + | paletteActionArmDrainDeadline, | |
| 139 | + | }, actionKinds(start)) | |
| 140 | + | drain := findAction(t, start, paletteActionWriteDrain) | |
| 141 | + | require.Equal(t, "\x1b[?2031$p", drain.bytes) | |
| 142 | + | id := drain.id | |
| 143 | + | batch := g.marker(id) | |
| 144 | + | clock.apply(batch) | |
| 145 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 146 | + | paletteActionCancelDrainDeadline, | |
| 147 | + | paletteActionWriteBatch, | |
| 148 | + | paletteActionArmCompletionDeadline, | |
| 149 | + | }, actionKinds(batch)) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestPaletteGenerationLateDrainBoundaryExcludesStaleOSC(t *testing.T) { | |
| 153 | + | g := newPaletteGenerationCoordinator() | |
| 154 | + | clock := newManualPaletteClock() | |
| 155 | + | start := g.start(generationTheme(), true) | |
| 156 | + | clock.apply(start) | |
| 157 | + | id := findAction(t, start, paletteActionWriteDrain).id | |
| 158 | + | ||
| 159 | + | timedOut := clock.fireDrain(g, id) | |
| 160 | + | clock.apply(timedOut) | |
| 161 | + | require.Equal(t, []paletteGenerationActionKind{ | |
| 162 | + | paletteActionWriteBatch, | |
| 163 | + | paletteActionArmCompletionDeadline, | |
| 164 | + | }, actionKinds(timedOut)) | |
| 165 | + | ||
| 166 | + | // The drain reply may be late, so reports received before its boundary are | |
| 167 | + | // conservatively excluded from this generation's accumulator. | |
| 168 | + | g.foreground(id, renderer.RGB{R: 1, G: 2, B: 3}) | |
| 169 | + | g.background(id, renderer.RGB{R: 4, G: 5, B: 6}) | |
| 170 | + | g.palette(id, 2, renderer.RGB{R: 7, G: 8, B: 9}) | |
| 171 | + | ||
| 172 | + | lateDrain := g.marker(id) | |
| 173 | + | require.Empty(t, lateDrain, "the late drain response must not finalize the batch") | |
| 174 | + | g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12}) | |
| 175 | + | g.background(id, renderer.RGB{R: 13, G: 14, B: 15}) | |
| 176 | + | g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18}) | |
| 177 | + | complete := g.marker(id) | |
| 178 | + | clock.apply(complete) | |
| 179 | + | final := findAction(t, complete, paletteActionPublishFinal).theme | |
| 180 | + | require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, final.Foreground) | |
| 181 | + | require.Equal(t, renderer.RGB{R: 13, G: 14, B: 15}, final.Background) | |
| 182 | + | require.Equal(t, uint16(1<<3), final.PaletteKnown) | |
| 183 | + | require.Equal(t, renderer.RGB{R: 16, G: 17, B: 18}, final.Palette[3]) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestPaletteGenerationCompletionDeadlineFinalizesAfterMissingLateDrain(t *testing.T) { | |
| 187 | + | g := newPaletteGenerationCoordinator() | |
| 188 | + | clock := newManualPaletteClock() | |
| 189 | + | start := g.start(generationTheme(), true) | |
| 190 | + | clock.apply(start) | |
| 191 | + | id := findAction(t, start, paletteActionWriteDrain).id | |
| 192 | + | clock.apply(clock.fireDrain(g, id)) | |
| 193 | + | ||
| 194 | + | // One marker may be the completion marker while the timed-out drain reply | |
| 195 | + | // never arrives, so it cannot finalize by marker accounting alone. | |
| 196 | + | require.Empty(t, g.marker(id)) | |
| 197 | + | final := clock.fireCompletion(g, id) | |
| 198 | + | clock.apply(final) | |
| 199 | + | require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestPaletteGenerationStaleTimerAndMarkerAreNoOps(t *testing.T) { | |
| 203 | + | g := newPaletteGenerationCoordinator() | |
| 204 | + | clock := newManualPaletteClock() | |
| 205 | + | first := g.start(generationTheme(), true) | |
| 206 | + | clock.apply(first) | |
| 207 | + | oldID := findAction(t, first, paletteActionWriteDrain).id | |
| 208 | + | second := g.start(generationTheme(), false) | |
| 209 | + | clock.apply(second) | |
| 210 | + | newID := findAction(t, second, paletteActionWriteBatch).id | |
| 211 | + | ||
| 212 | + | require.Empty(t, g.marker(oldID)) | |
| 213 | + | require.Empty(t, clock.fireDrain(g, oldID)) | |
| 214 | + | require.Empty(t, clock.fireCompletion(g, oldID)) | |
| 215 | + | final := g.marker(newID) | |
| 216 | + | require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestPaletteMarkerScannerConsumesSplitMarkerAndPreservesInput(t *testing.T) { | |
| 220 | + | scanner := paletteMarkerScanner{} | |
| 221 | + | var got []byte | |
| 222 | + | markers := 0 | |
| 223 | + | emit := func(data []byte) { got = append(got, data...) } | |
| 224 | + | marker := func() { markers++ } | |
| 225 | + | ||
| 226 | + | scanner.scan([]byte("a\x1b[?2031;"), emit, marker) | |
| 227 | + | scanner.scan([]byte("2$yb"), emit, marker) | |
| 228 | + | scanner.flush(emit) | |
| 229 | + | ||
| 230 | + | require.Equal(t, []byte("ab"), got) | |
| 231 | + | require.Equal(t, 1, markers) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T) { | |
| 235 | + | g := newPaletteGenerationCoordinator() | |
| 236 | + | start := g.start(generationTheme(), false) | |
| 237 | + | id := findAction(t, start, paletteActionWriteBatch).id | |
| 238 | + | g.palette(id, 2, renderer.RGB{R: 1, G: 2, B: 3}) | |
| 239 | + | final := g.marker(id) | |
| 240 | + | want := findAction(t, final, paletteActionPublishFinal).theme | |
| 241 | + | ||
| 242 | + | g.palette(id, 2, renderer.RGB{R: 9, G: 9, B: 9}) | |
| 243 | + | require.Equal(t, want, g.finalizedTheme()) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) { | |
| 247 | + | require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline) | |
| 248 | + | } | |
M internal/usecase/client/paste_coalescer.go +15 -3
| @@ -158,15 +158,27 @@ func (c *pasteCoalescer) Buffering() bool { | |||
| 158 | 158 | return c.buffering || len(c.pending) > 0 | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | - | // Close stops any pending timers and their goroutines. Held bytes are dropped: | |
| 162 | - | // Close runs only as the stdin pump unwinds on detach, when there is nowhere | |
| 163 | - | // left to deliver them. | |
| 161 | + | // Close stops any pending timers and their goroutines. Callers which hand input | |
| 162 | + | // to a replacement must first use CloseAndTakeHeld so undecided bytes survive. | |
| 164 | 163 | func (c *pasteCoalescer) Close() { | |
| 164 | + | _ = c.CloseAndTakeHeld() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while | |
| 168 | + | // deciding whether an opening marker or complete bracketed paste would arrive. | |
| 169 | + | // The returned bytes retain their original order and are removed from c. | |
| 170 | + | func (c *pasteCoalescer) CloseAndTakeHeld() []byte { | |
| 165 | 171 | c.mu.Lock() | |
| 166 | 172 | defer c.mu.Unlock() | |
| 167 | 173 | c.closed = true | |
| 168 | 174 | c.stopFlushTimer() | |
| 169 | 175 | c.stopSafetyTimer() | |
| 176 | + | held := append([]byte(nil), c.pending...) | |
| 177 | + | held = append(held, c.buf...) | |
| 178 | + | c.pending = nil | |
| 179 | + | c.buf = nil | |
| 180 | + | c.buffering = false | |
| 181 | + | return held | |
| 170 | 182 | } | |
| 171 | 183 | ||
| 172 | 184 | func (c *pasteCoalescer) startFlushTimer() { | |
M internal/usecase/client/reconnect_toast_test.go +64 -183
| @@ -43,7 +43,6 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne | |||
| 43 | 43 | h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe() | |
| 44 | 44 | h.term.EXPECT().Size().Return(h.size, nil).Maybe() | |
| 45 | 45 | h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe() | |
| 46 | - | h.term.EXPECT().QueryColors().Return(nil).Maybe() | |
| 47 | 46 | h.term.EXPECT().In().Return(h.in).Maybe() | |
| 48 | 47 | h.term.EXPECT().Out().Return(&h.out).Maybe() | |
| 49 | 48 | h.term.EXPECT().Flush().Return(nil).Maybe() | |
| @@ -63,7 +62,6 @@ func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *re | |||
| 63 | 62 | h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe() | |
| 64 | 63 | h.term.EXPECT().Size().Return(h.size, nil).Maybe() | |
| 65 | 64 | h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe() | |
| 66 | - | h.term.EXPECT().QueryColors().Return(nil).Maybe() | |
| 67 | 65 | h.term.EXPECT().In().Return(h.in).Maybe() | |
| 68 | 66 | h.term.EXPECT().Out().Return(out).Maybe() | |
| 69 | 67 | h.term.EXPECT().Flush().Return(nil).Maybe() | |
| @@ -112,18 +110,6 @@ func reconnectToastDetach(reason uint8) ports.Frame { | |||
| 112 | 110 | return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})} | |
| 113 | 111 | } | |
| 114 | 112 | ||
| 115 | - | func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport { | |
| 116 | - | t.Helper() | |
| 117 | - | tr := portsmocks.NewMockTransport(t) | |
| 118 | - | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 119 | - | for _, recv := range recvs { | |
| 120 | - | tr.EXPECT().Recv().Return(recv.frame, recv.err).Once() | |
| 121 | - | } | |
| 122 | - | tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe() | |
| 123 | - | tr.EXPECT().Close().Return(nil).Maybe() | |
| 124 | - | return tr | |
| 125 | - | } | |
| 126 | - | ||
| 127 | 113 | type reconnectToastRecv struct { | |
| 128 | 114 | frame ports.Frame | |
| 129 | 115 | err error | |
| @@ -144,11 +130,16 @@ func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, e | |||
| 144 | 130 | } | |
| 145 | 131 | ||
| 146 | 132 | type reconnectToastRecordingTransport struct { | |
| 133 | + | mu sync.Mutex | |
| 147 | 134 | recvs []reconnectToastRecv | |
| 148 | 135 | sends []ports.Frame | |
| 149 | 136 | closed bool | |
| 150 | 137 | } | |
| 151 | 138 | ||
| 139 | + | func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnectToastRecordingTransport { | |
| 140 | + | return &reconnectToastRecordingTransport{recvs: append([]reconnectToastRecv(nil), recvs...)} | |
| 141 | + | } | |
| 142 | + | ||
| 152 | 143 | type reconnectToastLinkTransport struct { | |
| 153 | 144 | recvCh chan reconnectToastRecv | |
| 154 | 145 | sends []ports.Frame | |
| @@ -181,11 +172,15 @@ func (t *reconnectToastLinkTransport) LinkEvents() <-chan ports.LinkEvent { retu | |||
| 181 | 172 | func (t *reconnectToastLinkTransport) LinkState() ports.LinkState { return t.state } | |
| 182 | 173 | ||
| 183 | 174 | func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error { | |
| 175 | + | t.mu.Lock() | |
| 176 | + | defer t.mu.Unlock() | |
| 184 | 177 | t.sends = append(t.sends, f) | |
| 185 | 178 | return nil | |
| 186 | 179 | } | |
| 187 | 180 | ||
| 188 | 181 | func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) { | |
| 182 | + | t.mu.Lock() | |
| 183 | + | defer t.mu.Unlock() | |
| 189 | 184 | if len(t.recvs) == 0 { | |
| 190 | 185 | return ports.Frame{}, io.EOF | |
| 191 | 186 | } | |
| @@ -195,15 +190,47 @@ func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) { | |||
| 195 | 190 | } | |
| 196 | 191 | ||
| 197 | 192 | func (t *reconnectToastRecordingTransport) Close() error { | |
| 193 | + | t.mu.Lock() | |
| 194 | + | defer t.mu.Unlock() | |
| 198 | 195 | t.closed = true | |
| 199 | 196 | return nil | |
| 200 | 197 | } | |
| 201 | 198 | ||
| 199 | + | func (t *reconnectToastRecordingTransport) sentFrames() []ports.Frame { | |
| 200 | + | t.mu.Lock() | |
| 201 | + | defer t.mu.Unlock() | |
| 202 | + | return append([]ports.Frame(nil), t.sends...) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (t *reconnectToastRecordingTransport) wasClosed() bool { | |
| 206 | + | t.mu.Lock() | |
| 207 | + | defer t.mu.Unlock() | |
| 208 | + | return t.closed | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // assertReconnectToastAttemptPublishesOnlyClearedTheme verifies the precise | |
| 212 | + | // handshake publication for these no-OSC-response reconnect paths. A final | |
| 213 | + | // Theme would be wrong here: no terminal palette response was supplied. | |
| 214 | + | func assertReconnectToastAttemptPublishesOnlyClearedTheme(t *testing.T, tr *reconnectToastRecordingTransport) { | |
| 215 | + | t.Helper() | |
| 216 | + | frames := tr.sentFrames() | |
| 217 | + | require.Len(t, frames, 2) | |
| 218 | + | require.Equal(t, ports.MsgHello, frames[0].Type) | |
| 219 | + | require.Equal(t, ports.MsgTheme, frames[1].Type) | |
| 220 | + | theme, err := ports.UnmarshalTheme(frames[1].Payload) | |
| 221 | + | require.NoError(t, err) | |
| 222 | + | require.False(t, theme.HasForeground) | |
| 223 | + | require.False(t, theme.HasBackground) | |
| 224 | + | require.Zero(t, theme.PaletteKnown) | |
| 225 | + | require.True(t, tr.wasClosed()) | |
| 226 | + | } | |
| 227 | + | ||
| 202 | 228 | func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello { | |
| 203 | 229 | t.Helper() | |
| 204 | - | require.NotEmpty(t, tr.sends) | |
| 205 | - | require.Equal(t, ports.MsgHello, tr.sends[0].Type) | |
| 206 | - | hello, err := ports.UnmarshalHello(tr.sends[0].Payload) | |
| 230 | + | frames := tr.sentFrames() | |
| 231 | + | require.NotEmpty(t, frames) | |
| 232 | + | require.Equal(t, ports.MsgHello, frames[0].Type) | |
| 233 | + | hello, err := ports.UnmarshalHello(frames[0].Payload) | |
| 207 | 234 | require.NoError(t, err) | |
| 208 | 235 | return hello | |
| 209 | 236 | } | |
| @@ -217,6 +244,8 @@ func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock { | |||
| 217 | 244 | timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe() | |
| 218 | 245 | timer.EXPECT().Stop().Return(true).Maybe() | |
| 219 | 246 | clk.EXPECT().NewTimer(preWelcomeTimeout).Return(timer).Maybe() | |
| 247 | + | // Every successful attach starts an initial palette-generation deadline. | |
| 248 | + | clk.EXPECT().NewTimer(paletteGenerationDeadline).Return(timer).Maybe() | |
| 220 | 249 | return clk | |
| 221 | 250 | } | |
| 222 | 251 | ||
| @@ -493,8 +522,8 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 493 | 522 | ||
| 494 | 523 | err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}) | |
| 495 | 524 | require.NoError(t, err) | |
| 496 | - | require.True(t, tr1.closed) | |
| 497 | - | require.True(t, tr2.closed) | |
| 525 | + | require.True(t, tr1.wasClosed()) | |
| 526 | + | require.True(t, tr2.wasClosed()) | |
| 498 | 527 | require.Equal(t, 2, dialer.calls) | |
| 499 | 528 | ||
| 500 | 529 | firstHello := reconnectToastHelloFromSend(t, tr1) | |
| @@ -510,160 +539,6 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 510 | 539 | require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width)) | |
| 511 | 540 | } | |
| 512 | 541 | ||
| 513 | - | type reconnectThemeTransport struct { | |
| 514 | - | resumeToken uint64 | |
| 515 | - | disconnectAfterTheme bool | |
| 516 | - | recvCount int | |
| 517 | - | fullTheme chan ports.Theme | |
| 518 | - | observedTheme ports.Theme | |
| 519 | - | } | |
| 520 | - | ||
| 521 | - | func newReconnectThemeTransport(token uint64, disconnectAfterTheme bool) *reconnectThemeTransport { | |
| 522 | - | return &reconnectThemeTransport{ | |
| 523 | - | resumeToken: token, | |
| 524 | - | disconnectAfterTheme: disconnectAfterTheme, | |
| 525 | - | fullTheme: make(chan ports.Theme, 1), | |
| 526 | - | } | |
| 527 | - | } | |
| 528 | - | ||
| 529 | - | func (t *reconnectThemeTransport) Send(f ports.Frame) error { | |
| 530 | - | if f.Type != ports.MsgTheme { | |
| 531 | - | return nil | |
| 532 | - | } | |
| 533 | - | theme, err := ports.UnmarshalTheme(f.Payload) | |
| 534 | - | if err != nil { | |
| 535 | - | return err | |
| 536 | - | } | |
| 537 | - | if theme.HasForeground && theme.HasBackground { | |
| 538 | - | select { | |
| 539 | - | case t.fullTheme <- theme: | |
| 540 | - | default: | |
| 541 | - | } | |
| 542 | - | } | |
| 543 | - | return nil | |
| 544 | - | } | |
| 545 | - | ||
| 546 | - | func (t *reconnectThemeTransport) Recv() (ports.Frame, error) { | |
| 547 | - | t.recvCount++ | |
| 548 | - | if t.recvCount == 1 { | |
| 549 | - | return reconnectToastWelcome(t.resumeToken), nil | |
| 550 | - | } | |
| 551 | - | t.observedTheme = <-t.fullTheme | |
| 552 | - | if t.disconnectAfterTheme { | |
| 553 | - | return ports.Frame{}, io.EOF | |
| 554 | - | } | |
| 555 | - | return reconnectToastDetach(ports.ReasonDetach), nil | |
| 556 | - | } | |
| 557 | - | ||
| 558 | - | func (t *reconnectThemeTransport) Close() error { return nil } | |
| 559 | - | ||
| 560 | - | type blockingThemeRestoreTransport struct { | |
| 561 | - | started chan struct{} | |
| 562 | - | closed chan struct{} | |
| 563 | - | recvs int | |
| 564 | - | } | |
| 565 | - | ||
| 566 | - | func newBlockingThemeRestoreTransport() *blockingThemeRestoreTransport { | |
| 567 | - | return &blockingThemeRestoreTransport{started: make(chan struct{}), closed: make(chan struct{})} | |
| 568 | - | } | |
| 569 | - | ||
| 570 | - | func (t *blockingThemeRestoreTransport) Send(f ports.Frame) error { | |
| 571 | - | if f.Type != ports.MsgTheme { | |
| 572 | - | return nil | |
| 573 | - | } | |
| 574 | - | close(t.started) | |
| 575 | - | <-t.closed | |
| 576 | - | return io.ErrClosedPipe | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | func (t *blockingThemeRestoreTransport) Recv() (ports.Frame, error) { | |
| 580 | - | t.recvs++ | |
| 581 | - | if t.recvs == 1 { | |
| 582 | - | return reconnectToastWelcome(44), nil | |
| 583 | - | } | |
| 584 | - | <-t.closed | |
| 585 | - | return ports.Frame{}, io.EOF | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | func (t *blockingThemeRestoreTransport) Close() error { | |
| 589 | - | select { | |
| 590 | - | case <-t.closed: | |
| 591 | - | default: | |
| 592 | - | close(t.closed) | |
| 593 | - | } | |
| 594 | - | return nil | |
| 595 | - | } | |
| 596 | - | ||
| 597 | - | func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) { | |
| 598 | - | term := newReconnectToastTerminalHarness(t) | |
| 599 | - | defer term.closeInput() | |
| 600 | - | themeState := &terminalThemeState{} | |
| 601 | - | themeState.setTrueColor(true) | |
| 602 | - | themeState.update(func(theme *ports.Theme) { | |
| 603 | - | theme.HasForeground = true | |
| 604 | - | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |
| 605 | - | theme.HasBackground = true | |
| 606 | - | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |
| 607 | - | }) | |
| 608 | - | transport := newBlockingThemeRestoreTransport() | |
| 609 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 610 | - | resultCh := make(chan attachResult, 1) | |
| 611 | - | ms := milestones{} | |
| 612 | - | attempt := newReconnectAttachAttempt(term.term, transport, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentResume, SessionName: "main", Remote: true}, 44, themeState, nil, &ms) | |
| 613 | - | go func() { resultCh <- attempt.run(ctx) }() | |
| 614 | - | ||
| 615 | - | select { | |
| 616 | - | case <-transport.started: | |
| 617 | - | case <-time.After(time.Second): | |
| 618 | - | cancel() | |
| 619 | - | _ = transport.Close() | |
| 620 | - | t.Fatal("cached theme restoration did not start") | |
| 621 | - | } | |
| 622 | - | cancel() | |
| 623 | - | select { | |
| 624 | - | case result := <-resultCh: | |
| 625 | - | require.True(t, result.welcomed) | |
| 626 | - | require.NoError(t, result.err) | |
| 627 | - | _ = transport.Close() | |
| 628 | - | case <-time.After(100 * time.Millisecond): | |
| 629 | - | _ = transport.Close() | |
| 630 | - | <-resultCh | |
| 631 | - | t.Fatal("cached theme send blocked attach cancellation") | |
| 632 | - | } | |
| 633 | - | } | |
| 634 | - | ||
| 635 | - | func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) { | |
| 636 | - | t.Setenv("TERM", "xterm-256color") | |
| 637 | - | t.Setenv("COLORTERM", "truecolor") | |
| 638 | - | oldSleep := reconnectSleep | |
| 639 | - | oldSleepWithResize := reconnectSleepWithResize | |
| 640 | - | reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true } | |
| 641 | - | reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool { | |
| 642 | - | return true | |
| 643 | - | } | |
| 644 | - | defer func() { | |
| 645 | - | reconnectSleep = oldSleep | |
| 646 | - | reconnectSleepWithResize = oldSleepWithResize | |
| 647 | - | }() | |
| 648 | - | ||
| 649 | - | term := newReconnectToastTerminalHarness(t) | |
| 650 | - | defer term.closeInput() | |
| 651 | - | go func() { | |
| 652 | - | _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07")) | |
| 653 | - | }() | |
| 654 | - | tr1 := newReconnectThemeTransport(44, true) | |
| 655 | - | tr2 := newReconnectThemeTransport(55, false) | |
| 656 | - | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | |
| 657 | - | ||
| 658 | - | err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}) | |
| 659 | - | require.NoError(t, err) | |
| 660 | - | require.True(t, tr2.observedTheme.HasForeground) | |
| 661 | - | require.True(t, tr2.observedTheme.HasBackground) | |
| 662 | - | require.True(t, tr2.observedTheme.TrueColor) | |
| 663 | - | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground) | |
| 664 | - | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background) | |
| 665 | - | } | |
| 666 | - | ||
| 667 | 542 | func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |
| 668 | 543 | linkDead := errors.New("remote link dead") | |
| 669 | 544 | oldSleep := reconnectSleep | |
| @@ -708,18 +583,19 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |||
| 708 | 583 | func TestRemoteReconnectToastLifecycle(t *testing.T) { | |
| 709 | 584 | tests := []struct { | |
| 710 | 585 | name string | |
| 711 | - | configure func(t *testing.T, dialer *portsmocks.MockDialer) | |
| 586 | + | configure func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport | |
| 712 | 587 | sleep func(cancel context.CancelFunc) bool | |
| 713 | 588 | wantErr func(t *testing.T, err error) | |
| 714 | 589 | wantBlank bool | |
| 715 | 590 | }{ | |
| 716 | 591 | { | |
| 717 | 592 | name: "clears on successful reconnect", | |
| 718 | - | configure: func(t *testing.T, dialer *portsmocks.MockDialer) { | |
| 719 | - | tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF}) | |
| 720 | - | tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}) | |
| 593 | + | configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport { | |
| 594 | + | tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF}) | |
| 595 | + | tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}) | |
| 721 | 596 | dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once() | |
| 722 | 597 | dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once() | |
| 598 | + | return []*reconnectToastRecordingTransport{tr1, tr2} | |
| 723 | 599 | }, | |
| 724 | 600 | sleep: func(context.CancelFunc) bool { return true }, | |
| 725 | 601 | wantErr: func(t *testing.T, err error) { require.NoError(t, err) }, | |
| @@ -727,9 +603,10 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||
| 727 | 603 | }, | |
| 728 | 604 | { | |
| 729 | 605 | name: "clears on cancellation", | |
| 730 | - | configure: func(t *testing.T, dialer *portsmocks.MockDialer) { | |
| 731 | - | tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF}) | |
| 606 | + | configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport { | |
| 607 | + | tr := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF}) | |
| 732 | 608 | dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 609 | + | return []*reconnectToastRecordingTransport{tr} | |
| 733 | 610 | }, | |
| 734 | 611 | sleep: func(cancel context.CancelFunc) bool { | |
| 735 | 612 | cancel() | |
| @@ -740,11 +617,12 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||
| 740 | 617 | }, | |
| 741 | 618 | { | |
| 742 | 619 | name: "clears on final exit", | |
| 743 | - | configure: func(t *testing.T, dialer *portsmocks.MockDialer) { | |
| 744 | - | tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF}) | |
| 745 | - | tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)}) | |
| 620 | + | configure: func(t *testing.T, dialer *portsmocks.MockDialer) []*reconnectToastRecordingTransport { | |
| 621 | + | tr1 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF}) | |
| 622 | + | tr2 := newReconnectToastRecordingTransport(reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)}) | |
| 746 | 623 | dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once() | |
| 747 | 624 | dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once() | |
| 625 | + | return []*reconnectToastRecordingTransport{tr1, tr2} | |
| 748 | 626 | }, | |
| 749 | 627 | sleep: func(context.CancelFunc) bool { return true }, | |
| 750 | 628 | wantErr: func(t *testing.T, err error) { | |
| @@ -772,10 +650,13 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||
| 772 | 650 | term := newReconnectToastTerminalHarness(t) | |
| 773 | 651 | defer term.closeInput() | |
| 774 | 652 | dialer := portsmocks.NewMockDialer(t) | |
| 775 | - | tt.configure(t, dialer) | |
| 653 | + | transports := tt.configure(t, dialer) | |
| 776 | 654 | ||
| 777 | 655 | err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}) | |
| 778 | 656 | tt.wantErr(t, err) | |
| 657 | + | for _, transport := range transports { | |
| 658 | + | assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport) | |
| 659 | + | } | |
| 779 | 660 | out := term.out.String() | |
| 780 | 661 | require.Contains(t, out, reconnectToastMessage) | |
| 781 | 662 | if tt.wantBlank { | |
A internal/usecase/client/terminal_input_pump_test.go +498 -0
| @@ -0,0 +1,498 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "log/slog" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type lifecycleReader struct { | |
| 19 | + | chunks chan []byte | |
| 20 | + | started chan struct{} | |
| 21 | + | ||
| 22 | + | mu sync.Mutex | |
| 23 | + | active int | |
| 24 | + | max int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func newLifecycleReader() *lifecycleReader { | |
| 28 | + | return &lifecycleReader{chunks: make(chan []byte), started: make(chan struct{}, 4)} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (r *lifecycleReader) Read(p []byte) (int, error) { | |
| 32 | + | r.mu.Lock() | |
| 33 | + | r.active++ | |
| 34 | + | if r.active > r.max { | |
| 35 | + | r.max = r.active | |
| 36 | + | } | |
| 37 | + | r.mu.Unlock() | |
| 38 | + | r.started <- struct{}{} | |
| 39 | + | data, ok := <-r.chunks | |
| 40 | + | r.mu.Lock() | |
| 41 | + | r.active-- | |
| 42 | + | r.mu.Unlock() | |
| 43 | + | if !ok { | |
| 44 | + | return 0, io.EOF | |
| 45 | + | } | |
| 46 | + | return copy(p, data), nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (r *lifecycleReader) maxActive() int { | |
| 50 | + | r.mu.Lock() | |
| 51 | + | defer r.mu.Unlock() | |
| 52 | + | return r.max | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type lateStopReader struct { | |
| 56 | + | started chan struct{} | |
| 57 | + | release chan terminalReadResult | |
| 58 | + | reads atomic.Int32 | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func newLateStopReader() *lateStopReader { | |
| 62 | + | return &lateStopReader{ | |
| 63 | + | started: make(chan struct{}, 2), | |
| 64 | + | release: make(chan terminalReadResult, 1), | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (r *lateStopReader) Read(p []byte) (int, error) { | |
| 69 | + | r.reads.Add(1) | |
| 70 | + | r.started <- struct{}{} | |
| 71 | + | result := <-r.release | |
| 72 | + | return copy(p, result.data), result.err | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func requireSignal(t *testing.T, ch <-chan struct{}, message string) { | |
| 76 | + | t.Helper() | |
| 77 | + | select { | |
| 78 | + | case <-ch: | |
| 79 | + | case <-time.After(time.Second): | |
| 80 | + | t.Fatal(message) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func requireFrame(t *testing.T, ch <-chan ports.Frame, message string) ports.Frame { | |
| 85 | + | t.Helper() | |
| 86 | + | select { | |
| 87 | + | case frame := <-ch: | |
| 88 | + | return frame | |
| 89 | + | case <-time.After(time.Second): | |
| 90 | + | t.Fatal(message) | |
| 91 | + | return ports.Frame{} | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func requireTimer(t *testing.T, ch <-chan *attachPaletteTimer, message string) *attachPaletteTimer { | |
| 96 | + | t.Helper() | |
| 97 | + | select { | |
| 98 | + | case timer := <-ch: | |
| 99 | + | return timer | |
| 100 | + | case <-time.After(time.Second): | |
| 101 | + | t.Fatal(message) | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestPaletteSlotRejectsOutOfRangeIntBeforeNarrowing(t *testing.T) { | |
| 107 | + | for _, tc := range []struct { | |
| 108 | + | slot int | |
| 109 | + | want uint8 | |
| 110 | + | ok bool | |
| 111 | + | }{ | |
| 112 | + | {slot: -1}, | |
| 113 | + | {slot: 0, want: 0, ok: true}, | |
| 114 | + | {slot: 15, want: 15, ok: true}, | |
| 115 | + | {slot: 16}, | |
| 116 | + | {slot: 256}, | |
| 117 | + | } { | |
| 118 | + | got, ok := paletteSlot(tc.slot) | |
| 119 | + | require.Equal(t, tc.ok, ok, "slot %d", tc.slot) | |
| 120 | + | if ok { | |
| 121 | + | require.Equal(t, tc.want, got, "slot %d", tc.slot) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T) { | |
| 127 | + | reader := newLateStopReader() | |
| 128 | + | input := newTerminalInputPump(reader) | |
| 129 | + | input.start() | |
| 130 | + | <-reader.started // The sole lifecycle read is blocked in the caller-owned reader. | |
| 131 | + | ||
| 132 | + | input.stop() | |
| 133 | + | reader.release <- terminalReadResult{data: []byte("late")} | |
| 134 | + | ||
| 135 | + | // The late read must be discarded rather than published. Both possible | |
| 136 | + | // outcomes are channel-synchronized: a correct pump exits; the historical | |
| 137 | + | // race published and immediately issued a second Read. | |
| 138 | + | select { | |
| 139 | + | case <-input.exited: | |
| 140 | + | case <-reader.started: | |
| 141 | + | require.Fail(t, "pump issued another Read after stop") | |
| 142 | + | } | |
| 143 | + | require.Equal(t, int32(1), reader.reads.Load()) | |
| 144 | + | ||
| 145 | + | consumer := input.claim() | |
| 146 | + | _, ok := input.take(context.Background(), consumer) | |
| 147 | + | require.False(t, ok, "late read must not be available after stop") | |
| 148 | + | input.revoke(consumer) | |
| 149 | + | input.stop() // stop is deliberately idempotent. | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestRunnerReusesBlockedTerminalInputPumpAcrossRuns(t *testing.T) { | |
| 153 | + | reader := newLateStopReader() | |
| 154 | + | runner := &Runner{term: &attachPaletteTerminalHarness{ | |
| 155 | + | in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Size), | |
| 156 | + | }} | |
| 157 | + | ||
| 158 | + | first := runner.terminalInput() | |
| 159 | + | <-reader.started | |
| 160 | + | first.suspend() | |
| 161 | + | second := runner.terminalInput() | |
| 162 | + | require.Same(t, first, second, "a later Run must reuse a blocked caller-owned reader") | |
| 163 | + | require.Equal(t, int32(1), reader.reads.Load()) | |
| 164 | + | ||
| 165 | + | first.stop() | |
| 166 | + | reader.release <- terminalReadResult{} | |
| 167 | + | <-first.exited | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T) { | |
| 171 | + | input := newTerminalInputPump(nil) | |
| 172 | + | // Both cancellation and a terminal read are ready before the old scanner | |
| 173 | + | // tries to dequeue. It must surrender the read to its replacement. | |
| 174 | + | want := []byte("first\x00second\xff") | |
| 175 | + | input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1) | |
| 176 | + | ||
| 177 | + | var activeGeneration atomic.Uint64 | |
| 178 | + | cancelledCtx, cancelCancelled := context.WithCancel(context.Background()) | |
| 179 | + | cancelCancelled() | |
| 180 | + | cancelledDone := make(chan struct{}) | |
| 181 | + | go func() { | |
| 182 | + | (&stdinPump{ | |
| 183 | + | ctx: cancelledCtx, cancel: cancelCancelled, input: input, | |
| 184 | + | out: make(chan ports.Frame, 1), clock: newAttachPaletteClock(), | |
| 185 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 186 | + | activeGeneration: &activeGeneration, | |
| 187 | + | }).run() | |
| 188 | + | close(cancelledDone) | |
| 189 | + | }() | |
| 190 | + | <-cancelledDone | |
| 191 | + | ||
| 192 | + | replacementCtx, cancelReplacement := context.WithCancel(context.Background()) | |
| 193 | + | defer cancelReplacement() | |
| 194 | + | out := make(chan ports.Frame, len(want)) | |
| 195 | + | replacementDone := make(chan struct{}) | |
| 196 | + | go func() { | |
| 197 | + | (&stdinPump{ | |
| 198 | + | ctx: replacementCtx, cancel: cancelReplacement, input: input, | |
| 199 | + | out: out, clock: newAttachPaletteClock(), | |
| 200 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 201 | + | activeGeneration: &activeGeneration, | |
| 202 | + | }).run() | |
| 203 | + | close(replacementDone) | |
| 204 | + | }() | |
| 205 | + | ||
| 206 | + | var got []byte | |
| 207 | + | for len(got) < len(want) { | |
| 208 | + | frame := <-out | |
| 209 | + | input, err := ports.UnmarshalInput(frame.Payload) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | got = append(got, input.Data...) | |
| 212 | + | } | |
| 213 | + | require.Equal(t, want, got, "handoff must preserve every queued byte in order") | |
| 214 | + | ||
| 215 | + | cancelReplacement() | |
| 216 | + | <-replacementDone | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) { | |
| 220 | + | input := newTerminalInputPump(nil) | |
| 221 | + | want := []byte("first\x00second\xff") | |
| 222 | + | input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1) | |
| 223 | + | ||
| 224 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 225 | + | consumer := input.claim() | |
| 226 | + | taken := make(chan struct{}) | |
| 227 | + | release := make(chan struct{}) | |
| 228 | + | done := make(chan struct{}) | |
| 229 | + | go func() { | |
| 230 | + | (&stdinPump{ | |
| 231 | + | ctx: ctx, cancel: cancel, input: input, consumer: consumer, | |
| 232 | + | out: make(chan ports.Frame, 1), clock: newAttachPaletteClock(), | |
| 233 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 234 | + | afterInputTake: func() { | |
| 235 | + | close(taken) | |
| 236 | + | <-release | |
| 237 | + | }, | |
| 238 | + | }).run() | |
| 239 | + | close(done) | |
| 240 | + | }() | |
| 241 | + | ||
| 242 | + | <-taken | |
| 243 | + | cancel() | |
| 244 | + | close(release) | |
| 245 | + | <-done | |
| 246 | + | input.revoke(consumer) | |
| 247 | + | ||
| 248 | + | replacement := input.claim() | |
| 249 | + | got, ok := input.take(context.Background(), replacement) | |
| 250 | + | require.True(t, ok, "cancelled delivery must leave the read for the replacement scanner") | |
| 251 | + | require.Equal(t, want, got.data) | |
| 252 | + | input.ack(replacement) | |
| 253 | + | input.revoke(replacement) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t *testing.T) { | |
| 257 | + | input := newTerminalInputPump(nil) | |
| 258 | + | input.enqueue(terminalReadResult{data: []byte("\x1b")}, 1) | |
| 259 | + | var activeGeneration atomic.Uint64 | |
| 260 | + | ||
| 261 | + | oldCtx, cancelOld := context.WithCancel(context.Background()) | |
| 262 | + | oldClock := newAttachPaletteClock() | |
| 263 | + | oldDone := make(chan struct{}) | |
| 264 | + | go func() { | |
| 265 | + | (&stdinPump{ | |
| 266 | + | ctx: oldCtx, cancel: cancelOld, input: input, out: make(chan ports.Frame, 1), | |
| 267 | + | clock: oldClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 268 | + | activeGeneration: &activeGeneration, | |
| 269 | + | }).run() | |
| 270 | + | close(oldDone) | |
| 271 | + | }() | |
| 272 | + | // The ambiguity timer proves the ESC was scanned and its raw read was | |
| 273 | + | // committed; only the marker scanner's undecided suffix remains. | |
| 274 | + | <-oldClock.timers | |
| 275 | + | cancelOld() | |
| 276 | + | <-oldDone | |
| 277 | + | ||
| 278 | + | newCtx, cancelNew := context.WithCancel(context.Background()) | |
| 279 | + | defer cancelNew() | |
| 280 | + | newClock := newAttachPaletteClock() | |
| 281 | + | out := make(chan ports.Frame, 1) | |
| 282 | + | newDone := make(chan struct{}) | |
| 283 | + | go func() { | |
| 284 | + | (&stdinPump{ | |
| 285 | + | ctx: newCtx, cancel: cancelNew, input: input, out: out, | |
| 286 | + | clock: newClock, logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 287 | + | activeGeneration: &activeGeneration, | |
| 288 | + | }).run() | |
| 289 | + | close(newDone) | |
| 290 | + | }() | |
| 291 | + | (<-newClock.timers).fire() // marker ambiguity deadline | |
| 292 | + | (<-newClock.timers).fire() // paste coalescer's lone-ESC deadline | |
| 293 | + | frame := <-out | |
| 294 | + | got, err := ports.UnmarshalInput(frame.Payload) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | require.Equal(t, []byte("\x1b"), got.Data) | |
| 297 | + | ||
| 298 | + | cancelNew() | |
| 299 | + | <-newDone | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func TestTerminalInputPumpCancellationAfterInputBeforeOSCPreservesOnlyUndeliveredSuffix(t *testing.T) { | |
| 303 | + | input := newTerminalInputPump(nil) | |
| 304 | + | input.enqueue(terminalReadResult{data: []byte("x\x1b]10;rgb:ffff/0000/0000\x07y")}, 1) | |
| 305 | + | var activeGeneration atomic.Uint64 | |
| 306 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 307 | + | delivered := make(chan struct{}) | |
| 308 | + | done := make(chan struct{}) | |
| 309 | + | out := make(chan ports.Frame, 1) | |
| 310 | + | go func() { | |
| 311 | + | (&stdinPump{ | |
| 312 | + | ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(), | |
| 313 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 314 | + | activeGeneration: &activeGeneration, | |
| 315 | + | afterInputDelivered: func() { | |
| 316 | + | cancel() | |
| 317 | + | close(delivered) | |
| 318 | + | }, | |
| 319 | + | }).run() | |
| 320 | + | close(done) | |
| 321 | + | }() | |
| 322 | + | frame := requireFrame(t, out, "timed out waiting for delivered input prefix") | |
| 323 | + | got, err := ports.UnmarshalInput(frame.Payload) | |
| 324 | + | require.NoError(t, err) | |
| 325 | + | require.Equal(t, []byte("x"), got.Data) | |
| 326 | + | requireSignal(t, delivered, "timed out waiting for cancellation after input delivery") | |
| 327 | + | requireSignal(t, done, "timed out waiting for cancelled stdin pump") | |
| 328 | + | ||
| 329 | + | replacementCtx, cancelReplacement := context.WithCancel(context.Background()) | |
| 330 | + | replacementOut := make(chan ports.Frame, 1) | |
| 331 | + | replacementDone := make(chan struct{}) | |
| 332 | + | go func() { | |
| 333 | + | (&stdinPump{ | |
| 334 | + | ctx: replacementCtx, cancel: cancelReplacement, input: input, | |
| 335 | + | out: replacementOut, clock: newAttachPaletteClock(), | |
| 336 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 337 | + | activeGeneration: &activeGeneration, | |
| 338 | + | }).run() | |
| 339 | + | close(replacementDone) | |
| 340 | + | }() | |
| 341 | + | frame = requireFrame(t, replacementOut, "timed out waiting for undelivered input suffix") | |
| 342 | + | got, err = ports.UnmarshalInput(frame.Payload) | |
| 343 | + | require.NoError(t, err) | |
| 344 | + | require.Equal(t, []byte("y"), got.Data, "replacement must receive only the undelivered ordinary suffix") | |
| 345 | + | ||
| 346 | + | cancelReplacement() | |
| 347 | + | requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown") | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | func TestTerminalInputPumpCancellationPreservesCoalescerHeldSuffix(t *testing.T) { | |
| 351 | + | input := newTerminalInputPump(nil) | |
| 352 | + | held := ports.BracketedPasteOpenMarker[:3] | |
| 353 | + | raw := append([]byte("x\x1b]10;rgb:ffff/0000/0000\x07"), held...) | |
| 354 | + | input.enqueue(terminalReadResult{data: raw}, 1) | |
| 355 | + | var activeGeneration atomic.Uint64 | |
| 356 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 357 | + | out := make(chan ports.Frame, 1) | |
| 358 | + | done := make(chan struct{}) | |
| 359 | + | go func() { | |
| 360 | + | (&stdinPump{ | |
| 361 | + | ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(), | |
| 362 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 363 | + | activeGeneration: &activeGeneration, | |
| 364 | + | afterInputDelivered: cancel, | |
| 365 | + | }).run() | |
| 366 | + | close(done) | |
| 367 | + | }() | |
| 368 | + | frame := requireFrame(t, out, "timed out waiting for delivered input prefix") | |
| 369 | + | got, err := ports.UnmarshalInput(frame.Payload) | |
| 370 | + | require.NoError(t, err) | |
| 371 | + | require.Equal(t, []byte("x"), got.Data) | |
| 372 | + | requireSignal(t, done, "timed out waiting for cancelled stdin pump") | |
| 373 | + | ||
| 374 | + | replacementCtx, cancelReplacement := context.WithCancel(context.Background()) | |
| 375 | + | replacementClock := newAttachPaletteClock() | |
| 376 | + | replacementOut := make(chan ports.Frame, 1) | |
| 377 | + | replacementDone := make(chan struct{}) | |
| 378 | + | go func() { | |
| 379 | + | (&stdinPump{ | |
| 380 | + | ctx: replacementCtx, cancel: cancelReplacement, input: input, | |
| 381 | + | out: replacementOut, clock: replacementClock, | |
| 382 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 383 | + | activeGeneration: &activeGeneration, | |
| 384 | + | }).run() | |
| 385 | + | close(replacementDone) | |
| 386 | + | }() | |
| 387 | + | requireTimer(t, replacementClock.timers, "timed out waiting for paste-prefix ambiguity timer").fire() | |
| 388 | + | frame = requireFrame(t, replacementOut, "timed out waiting for coalescer-held suffix") | |
| 389 | + | got, err = ports.UnmarshalInput(frame.Payload) | |
| 390 | + | require.NoError(t, err) | |
| 391 | + | require.Equal(t, held, got.Data, "replacement must receive the coalescer-held suffix exactly once") | |
| 392 | + | ||
| 393 | + | cancelReplacement() | |
| 394 | + | requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown") | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | func TestTerminalInputPumpRejectsReadFromPriorActivation(t *testing.T) { | |
| 398 | + | reader := newLateStopReader() | |
| 399 | + | input := newTerminalInputPump(reader) | |
| 400 | + | input.start() | |
| 401 | + | requireSignal(t, reader.started, "timed out waiting for initial activation Read") | |
| 402 | + | ||
| 403 | + | input.suspend() | |
| 404 | + | input.resume() | |
| 405 | + | reader.release <- terminalReadResult{data: []byte("stale")} | |
| 406 | + | ||
| 407 | + | // The old read must be discarded after resume, then the reader may begin a | |
| 408 | + | // new Read for the current activation. | |
| 409 | + | requireSignal(t, reader.started, "timed out waiting for current activation Read") | |
| 410 | + | consumer := input.claim() | |
| 411 | + | _, ok := input.take(context.Background(), consumer) | |
| 412 | + | require.False(t, ok, "a Read authorized before suspend/resume must not reach the new activation") | |
| 413 | + | input.revoke(consumer) | |
| 414 | + | ||
| 415 | + | input.stop() | |
| 416 | + | reader.release <- terminalReadResult{} | |
| 417 | + | requireSignal(t, input.exited, "timed out waiting for terminal input pump shutdown") | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func TestTerminalInputPumpCancellationAfterDeliveredCallbackDoesNotReplay(t *testing.T) { | |
| 421 | + | input := newTerminalInputPump(nil) | |
| 422 | + | input.enqueue(terminalReadResult{data: []byte("x")}, 1) | |
| 423 | + | var activeGeneration atomic.Uint64 | |
| 424 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 425 | + | delivered := make(chan struct{}) | |
| 426 | + | done := make(chan struct{}) | |
| 427 | + | out := make(chan ports.Frame, 1) | |
| 428 | + | go func() { | |
| 429 | + | (&stdinPump{ | |
| 430 | + | ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(), | |
| 431 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 432 | + | activeGeneration: &activeGeneration, | |
| 433 | + | afterInputDelivered: func() { | |
| 434 | + | cancel() | |
| 435 | + | close(delivered) | |
| 436 | + | }, | |
| 437 | + | }).run() | |
| 438 | + | close(done) | |
| 439 | + | }() | |
| 440 | + | frame := <-out | |
| 441 | + | got, err := ports.UnmarshalInput(frame.Payload) | |
| 442 | + | require.NoError(t, err) | |
| 443 | + | require.Equal(t, []byte("x"), got.Data) | |
| 444 | + | <-delivered | |
| 445 | + | <-done | |
| 446 | + | ||
| 447 | + | consumer := input.claim() | |
| 448 | + | _, ok := input.take(context.Background(), consumer) | |
| 449 | + | require.False(t, ok, "a callback already delivered to the sender must not replay after reconnect") | |
| 450 | + | input.revoke(consumer) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func TestTerminalInputPumpReusesOneReaderAcrossCancelledAttempts(t *testing.T) { | |
| 454 | + | reader := newLifecycleReader() | |
| 455 | + | input := newTerminalInputPump(reader) | |
| 456 | + | input.start() | |
| 457 | + | t.Cleanup(input.stop) | |
| 458 | + | <-reader.started // The lifecycle pump owns the sole blocking Read. | |
| 459 | + | ||
| 460 | + | var activeGeneration atomic.Uint64 | |
| 461 | + | firstCtx, cancelFirst := context.WithCancel(context.Background()) | |
| 462 | + | firstDone := make(chan struct{}) | |
| 463 | + | go func() { | |
| 464 | + | (&stdinPump{ | |
| 465 | + | ctx: firstCtx, cancel: cancelFirst, input: input, | |
| 466 | + | out: make(chan ports.Frame), clock: newAttachPaletteClock(), | |
| 467 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 468 | + | activeGeneration: &activeGeneration, | |
| 469 | + | }).run() | |
| 470 | + | close(firstDone) | |
| 471 | + | }() | |
| 472 | + | cancelFirst() | |
| 473 | + | <-firstDone | |
| 474 | + | ||
| 475 | + | secondCtx, cancelSecond := context.WithCancel(context.Background()) | |
| 476 | + | defer cancelSecond() | |
| 477 | + | out := make(chan ports.Frame, 1) | |
| 478 | + | secondDone := make(chan struct{}) | |
| 479 | + | go func() { | |
| 480 | + | (&stdinPump{ | |
| 481 | + | ctx: secondCtx, cancel: cancelSecond, input: input, | |
| 482 | + | out: out, clock: newAttachPaletteClock(), | |
| 483 | + | logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent), | |
| 484 | + | activeGeneration: &activeGeneration, | |
| 485 | + | }).run() | |
| 486 | + | close(secondDone) | |
| 487 | + | }() | |
| 488 | + | ||
| 489 | + | reader.chunks <- []byte("x") | |
| 490 | + | frame := <-out | |
| 491 | + | got, err := ports.UnmarshalInput(frame.Payload) | |
| 492 | + | require.NoError(t, err) | |
| 493 | + | require.Equal(t, []byte("x"), got.Data) | |
| 494 | + | require.Equal(t, 1, reader.maxActive(), "cancelled attempts must not leave competing reads") | |
| 495 | + | ||
| 496 | + | cancelSecond() | |
| 497 | + | <-secondDone | |
| 498 | + | } | |
D internal/usecase/client/theme_state_test.go +0 -286
| @@ -1,286 +0,0 @@ | |||
| 1 | - | package client | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "io" | |
| 7 | - | "log/slog" | |
| 8 | - | "testing" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/stretchr/testify/require" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) { | |
| 18 | - | state := &terminalThemeState{} | |
| 19 | - | state.update(func(theme *ports.Theme) { | |
| 20 | - | theme.HasForeground = true | |
| 21 | - | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |
| 22 | - | theme.HasBackground = true | |
| 23 | - | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |
| 24 | - | theme.SchemeKnown = true | |
| 25 | - | theme.Light = true | |
| 26 | - | theme.PaletteKnown = 1<<1 | 1<<14 | |
| 27 | - | theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9} | |
| 28 | - | theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12} | |
| 29 | - | }) | |
| 30 | - | ||
| 31 | - | got := state.clearPalette() | |
| 32 | - | require.True(t, got.HasForeground) | |
| 33 | - | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground) | |
| 34 | - | require.True(t, got.HasBackground) | |
| 35 | - | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background) | |
| 36 | - | require.True(t, got.SchemeKnown) | |
| 37 | - | require.True(t, got.Light) | |
| 38 | - | require.Zero(t, got.PaletteKnown) | |
| 39 | - | require.Equal(t, [16]renderer.RGB{}, got.Palette) | |
| 40 | - | ||
| 41 | - | _, reported := state.reportedTheme() | |
| 42 | - | require.True(t, reported, "foreground/background remain usable after palette invalidation") | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | type queryBoundaryReader struct { | |
| 46 | - | first, second []byte | |
| 47 | - | readSecond chan struct{} | |
| 48 | - | allowSecond, close <-chan struct{} | |
| 49 | - | reads int | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | func (r *queryBoundaryReader) Read(p []byte) (int, error) { | |
| 53 | - | switch r.reads { | |
| 54 | - | case 0: | |
| 55 | - | r.reads++ | |
| 56 | - | return copy(p, r.first), nil | |
| 57 | - | case 1: | |
| 58 | - | select { | |
| 59 | - | case r.readSecond <- struct{}{}: | |
| 60 | - | case <-r.close: | |
| 61 | - | return 0, io.EOF | |
| 62 | - | } | |
| 63 | - | select { | |
| 64 | - | case <-r.allowSecond: | |
| 65 | - | r.reads++ | |
| 66 | - | return copy(p, r.second), nil | |
| 67 | - | case <-r.close: | |
| 68 | - | return 0, io.EOF | |
| 69 | - | } | |
| 70 | - | default: | |
| 71 | - | <-r.close | |
| 72 | - | return 0, io.EOF | |
| 73 | - | } | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) { | |
| 77 | - | state := &terminalThemeState{} | |
| 78 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 79 | - | defer cancel() | |
| 80 | - | out := make(chan ports.Frame, 3) | |
| 81 | - | pump := stdinPump{ | |
| 82 | - | ctx: ctx, | |
| 83 | - | cancel: cancel, | |
| 84 | - | in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")), | |
| 85 | - | out: out, | |
| 86 | - | themeState: state, | |
| 87 | - | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | pump.run() | |
| 91 | - | ||
| 92 | - | first := <-out | |
| 93 | - | require.Equal(t, ports.MsgTheme, first.Type) | |
| 94 | - | theme, err := ports.UnmarshalTheme(first.Payload) | |
| 95 | - | require.NoError(t, err) | |
| 96 | - | require.True(t, theme.HasBackground) | |
| 97 | - | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background) | |
| 98 | - | require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown) | |
| 99 | - | require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1]) | |
| 100 | - | require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14]) | |
| 101 | - | ||
| 102 | - | second := <-out | |
| 103 | - | require.Equal(t, ports.MsgDetach, second.Type) | |
| 104 | - | select { | |
| 105 | - | case extra := <-out: | |
| 106 | - | t.Fatalf("got extra frame after one input chunk: %v", extra.Type) | |
| 107 | - | default: | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) { | |
| 112 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 113 | - | defer cancel() | |
| 114 | - | allowSecond := make(chan struct{}) | |
| 115 | - | closeReader := make(chan struct{}) | |
| 116 | - | reader := &queryBoundaryReader{ | |
| 117 | - | first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"), | |
| 118 | - | second: []byte("\x1b]4;2;#445566\a"), | |
| 119 | - | readSecond: make(chan struct{}, 1), | |
| 120 | - | allowSecond: allowSecond, | |
| 121 | - | close: closeReader, | |
| 122 | - | } | |
| 123 | - | queryStarted := make(chan struct{}) | |
| 124 | - | allowQuery := make(chan struct{}) | |
| 125 | - | out := make(chan ports.Frame, 3) | |
| 126 | - | pump := stdinPump{ | |
| 127 | - | ctx: ctx, | |
| 128 | - | cancel: cancel, | |
| 129 | - | in: reader, | |
| 130 | - | out: out, | |
| 131 | - | themeState: &terminalThemeState{}, | |
| 132 | - | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 133 | - | requestColors: func() (colorQueryRequest, bool) { | |
| 134 | - | close(queryStarted) | |
| 135 | - | select { | |
| 136 | - | case <-allowQuery: | |
| 137 | - | return colorQueryRequest{}, true | |
| 138 | - | case <-ctx.Done(): | |
| 139 | - | return colorQueryRequest{}, false | |
| 140 | - | } | |
| 141 | - | }, | |
| 142 | - | } | |
| 143 | - | pumpDone := make(chan struct{}) | |
| 144 | - | go func() { | |
| 145 | - | pump.run() | |
| 146 | - | close(pumpDone) | |
| 147 | - | }() | |
| 148 | - | ||
| 149 | - | select { | |
| 150 | - | case <-queryStarted: | |
| 151 | - | case <-time.After(time.Second): | |
| 152 | - | t.Fatal("palette re-query was not requested") | |
| 153 | - | } | |
| 154 | - | select { | |
| 155 | - | case <-reader.readSecond: | |
| 156 | - | t.Fatal("stdin pump started the next read before the query acknowledgement") | |
| 157 | - | default: | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | close(allowQuery) | |
| 161 | - | select { | |
| 162 | - | case <-reader.readSecond: | |
| 163 | - | case <-time.After(time.Second): | |
| 164 | - | t.Fatal("stdin pump did not resume after the query acknowledgement") | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | cancel() | |
| 168 | - | close(closeReader) | |
| 169 | - | select { | |
| 170 | - | case <-pumpDone: | |
| 171 | - | case <-time.After(time.Second): | |
| 172 | - | t.Fatal("stdin pump did not unwind after cancellation") | |
| 173 | - | } | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | type chunksReader struct { | |
| 177 | - | chunks [][]byte | |
| 178 | - | next int | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | func (r *chunksReader) Read(p []byte) (int, error) { | |
| 182 | - | if r.next == len(r.chunks) { | |
| 183 | - | return 0, io.EOF | |
| 184 | - | } | |
| 185 | - | chunk := r.chunks[r.next] | |
| 186 | - | r.next++ | |
| 187 | - | return copy(p, chunk), nil | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) { | |
| 191 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 192 | - | defer cancel() | |
| 193 | - | requests := 0 | |
| 194 | - | pump := stdinPump{ | |
| 195 | - | ctx: ctx, | |
| 196 | - | cancel: cancel, | |
| 197 | - | in: &chunksReader{chunks: [][]byte{ | |
| 198 | - | []byte("\x1b[?997;2n"), | |
| 199 | - | []byte("\x1b[?997;1n"), | |
| 200 | - | }}, | |
| 201 | - | out: make(chan ports.Frame, 3), | |
| 202 | - | themeState: &terminalThemeState{}, | |
| 203 | - | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 204 | - | requestColors: func() (colorQueryRequest, bool) { | |
| 205 | - | requests++ | |
| 206 | - | return colorQueryRequest{}, true | |
| 207 | - | }, | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | pump.run() | |
| 211 | - | require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives") | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) { | |
| 215 | - | for _, status := range []byte{'0', '1', '2', '3', '4'} { | |
| 216 | - | t.Run(string(status), func(t *testing.T) { | |
| 217 | - | response := []byte("\x1b[?2031;" + string(status) + "$y") | |
| 218 | - | var got []byte | |
| 219 | - | boundaries := 0 | |
| 220 | - | drain := paletteDrain{} | |
| 221 | - | drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) { | |
| 222 | - | got = append(got, data...) | |
| 223 | - | }, func() { boundaries++ }) | |
| 224 | - | drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) { | |
| 225 | - | got = append(got, data...) | |
| 226 | - | }, func() { boundaries++ }) | |
| 227 | - | ||
| 228 | - | require.Equal(t, []byte("xy"), got) | |
| 229 | - | require.Equal(t, 1, boundaries) | |
| 230 | - | }) | |
| 231 | - | } | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) { | |
| 235 | - | var got []byte | |
| 236 | - | boundaries := 0 | |
| 237 | - | drain := paletteDrain{} | |
| 238 | - | emit := func(data []byte) { got = append(got, data...) } | |
| 239 | - | drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ }) | |
| 240 | - | drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ }) | |
| 241 | - | drain.flush(emit) | |
| 242 | - | ||
| 243 | - | require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got) | |
| 244 | - | require.Zero(t, boundaries) | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) { | |
| 248 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 249 | - | closeReader := make(chan struct{}) | |
| 250 | - | reader := &queryBoundaryReader{ | |
| 251 | - | first: []byte("\x1b[?997;2n"), | |
| 252 | - | close: closeReader, | |
| 253 | - | } | |
| 254 | - | requestStarted := make(chan struct{}) | |
| 255 | - | pump := stdinPump{ | |
| 256 | - | ctx: ctx, | |
| 257 | - | cancel: cancel, | |
| 258 | - | in: reader, | |
| 259 | - | out: make(chan ports.Frame, 2), | |
| 260 | - | themeState: &terminalThemeState{}, | |
| 261 | - | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 262 | - | requestColors: func() (colorQueryRequest, bool) { | |
| 263 | - | close(requestStarted) | |
| 264 | - | <-ctx.Done() | |
| 265 | - | return colorQueryRequest{}, false | |
| 266 | - | }, | |
| 267 | - | } | |
| 268 | - | pumpDone := make(chan struct{}) | |
| 269 | - | go func() { | |
| 270 | - | pump.run() | |
| 271 | - | close(pumpDone) | |
| 272 | - | }() | |
| 273 | - | ||
| 274 | - | select { | |
| 275 | - | case <-requestStarted: | |
| 276 | - | case <-time.After(time.Second): | |
| 277 | - | t.Fatal("palette re-query was not requested") | |
| 278 | - | } | |
| 279 | - | cancel() | |
| 280 | - | select { | |
| 281 | - | case <-pumpDone: | |
| 282 | - | case <-time.After(time.Second): | |
| 283 | - | t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement") | |
| 284 | - | } | |
| 285 | - | close(closeReader) | |
| 286 | - | } | |
M internal/usecase/client/transport_observability_test.go +5 -3
| @@ -37,7 +37,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||
| 37 | 37 | defer in.unblock() | |
| 38 | 38 | term.EXPECT().In().Return(in).Maybe() | |
| 39 | 39 | term.EXPECT().Out().Return(&out).Maybe() | |
| 40 | - | term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once() | |
| 40 | + | term.EXPECT().Flush().Run(func() { closeOnce(flushed) }).Return(nil).Maybe() | |
| 41 | 41 | term.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 42 | 42 | ||
| 43 | 43 | observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})} | |
| @@ -46,6 +46,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||
| 46 | 46 | acked := make(chan struct{}) | |
| 47 | 47 | transport := portsmocks.NewMockTransport(t) | |
| 48 | 48 | transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 49 | + | transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 49 | 50 | transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once() | |
| 50 | 51 | unblock := scriptRecv(transport, | |
| 51 | 52 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |
| @@ -70,7 +71,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||
| 70 | 71 | case <-time.After(time.Second): | |
| 71 | 72 | t.Fatal("Attach did not complete after observer release") | |
| 72 | 73 | } | |
| 73 | - | require.Equal(t, "flush-before-observe", out.String()) | |
| 74 | + | require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$pflush-before-observe", out.String()) | |
| 74 | 75 | require.Equal(t, int32(1), restores.Load()) | |
| 75 | 76 | } | |
| 76 | 77 | ||
| @@ -86,6 +87,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) { | |||
| 86 | 87 | defer reporter.Close() | |
| 87 | 88 | transport := portsmocks.NewMockTransport(t) | |
| 88 | 89 | transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 90 | + | transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 89 | 91 | unblock := scriptRecv(transport, | |
| 90 | 92 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |
| 91 | 93 | recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))}, | |
| @@ -98,7 +100,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) { | |||
| 98 | 100 | err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral}) | |
| 99 | 101 | require.NoError(t, err) | |
| 100 | 102 | reporter.Flush() | |
| 101 | - | require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes") | |
| 103 | + | require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$punchanged-by-observer", out.String(), "observer must not alter terminal bytes") | |
| 102 | 104 | require.Equal(t, int32(1), restores.Load()) | |
| 103 | 105 | // Carriage spans belong only to concrete adapters. The client owns the | |
| 104 | 106 | // post-successful-flush terminal boundary, before its ACK is queued. | |
M internal/usecase/copy/mode.go +1 -1
| @@ -421,7 +421,7 @@ func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) { | |||
| 421 | 421 | func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s } | |
| 422 | 422 | func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) { | |
| 423 | 423 | for i := range row { | |
| 424 | - | row[i] = renderer.BlankCell() | |
| 424 | + | row[i] = renderer.Cell{Rune: ' ', Style: style} | |
| 425 | 425 | } | |
| 426 | 426 | text := " [SCROLL] " | |
| 427 | 427 | if m.selection.Enabled { | |
M internal/usecase/copy/mode_test.go +9 -0
| @@ -256,3 +256,12 @@ func TestOSC52LimitsAndBase64(t *testing.T) { | |||
| 256 | 256 | encoded := base64.StdEncoding.EncodeToString([]byte("hello")) | |
| 257 | 257 | require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded)) | |
| 258 | 258 | } | |
| 259 | + | ||
| 260 | + | func TestCopyModeRenderStylesStatusFiller(t *testing.T) { | |
| 261 | + | m := modeFor([]string{"alpha"}, 5) | |
| 262 | + | status := renderer.Style{Foreground: 1, Background: 2} | |
| 263 | + | selection := renderer.Style{Foreground: 3, Background: 4} | |
| 264 | + | frame := m.Render(status, selection) | |
| 265 | + | ||
| 266 | + | require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface") | |
| 267 | + | } | |
M internal/usecase/daemon/client.go +31 -9
| @@ -23,6 +23,15 @@ type transportSnapshot struct { | |||
| 23 | 23 | incarnation uint64 | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | // appliedTheme is the single immutable chrome snapshot consumed by rendering. | |
| 27 | + | // Raw retains terminal defaults for panes; Resolved belongs exclusively to vev | |
| 28 | + | // chrome. Generation changes whenever either terminal input or policy changes. | |
| 29 | + | type appliedTheme struct { | |
| 30 | + | Raw themeui.Theme | |
| 31 | + | Resolved themeui.ResolvedTheme | |
| 32 | + | Generation uint64 | |
| 33 | + | } | |
| 34 | + | ||
| 26 | 35 | type attachedClient struct { | |
| 27 | 36 | tr ports.Transport | |
| 28 | 37 | transportIncarnation uint64 | |
| @@ -48,8 +57,8 @@ type attachedClient struct { | |||
| 48 | 57 | sess Guarded[*session] | |
| 49 | 58 | mouseScan mouse.Scanner | |
| 50 | 59 | themeMu sync.Mutex | |
| 51 | - | theme themeui.Theme | |
| 52 | 60 | clientTheme themeui.Theme | |
| 61 | + | appliedTheme appliedTheme | |
| 53 | 62 | lastCursor cursorOut | |
| 54 | 63 | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 55 | 64 | // previousSession is guarded independently. It is retained through temporary | |
| @@ -137,10 +146,16 @@ func (ac *attachedClient) clearCaptureFrames() { | |||
| 137 | 146 | ac.sendMu.Unlock() | |
| 138 | 147 | } | |
| 139 | 148 | ||
| 140 | - | func (ac *attachedClient) getTheme() themeui.Theme { | |
| 149 | + | func (ac *attachedClient) getAppliedTheme() appliedTheme { | |
| 141 | 150 | ac.themeMu.Lock() | |
| 142 | 151 | defer ac.themeMu.Unlock() | |
| 143 | - | return ac.theme | |
| 152 | + | applied := ac.appliedTheme | |
| 153 | + | if applied.Generation == 0 { | |
| 154 | + | // An unattached client has no terminal report yet. Reuse the static | |
| 155 | + | // neutral cache rather than resolving from a render path. | |
| 156 | + | applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles} | |
| 157 | + | } | |
| 158 | + | return applied | |
| 144 | 159 | } | |
| 145 | 160 | ||
| 146 | 161 | func (ac *attachedClient) getClientTheme() themeui.Theme { | |
| @@ -149,9 +164,17 @@ func (ac *attachedClient) getClientTheme() themeui.Theme { | |||
| 149 | 164 | return ac.clientTheme | |
| 150 | 165 | } | |
| 151 | 166 | ||
| 152 | - | func (ac *attachedClient) setTheme(t themeui.Theme) { | |
| 167 | + | // setThemeForTest publishes a complete applied snapshot for tests that do | |
| 168 | + | // not exercise daemon configuration. Production theme changes use | |
| 169 | + | // applyHostThemeLocked, which supplies the active policy. | |
| 170 | + | func (ac *attachedClient) setThemeForTest(t themeui.Theme) { | |
| 171 | + | ac.setAppliedTheme(appliedTheme{Raw: t, Resolved: themeui.Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (ac *attachedClient) setAppliedTheme(next appliedTheme) { | |
| 153 | 175 | ac.themeMu.Lock() | |
| 154 | - | ac.theme = t | |
| 176 | + | next.Generation = ac.appliedTheme.Generation + 1 | |
| 177 | + | ac.appliedTheme = next | |
| 155 | 178 | ac.themeMu.Unlock() | |
| 156 | 179 | } | |
| 157 | 180 | ||
| @@ -432,7 +455,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||
| 432 | 455 | sess.mu.Unlock() | |
| 433 | 456 | d.touchMRU(sess) | |
| 434 | 457 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 435 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |
| 458 | + | d.applyHostTheme(sess, ac, themeui.Theme{}, true) | |
| 436 | 459 | return ac, old, cleanup | |
| 437 | 460 | } | |
| 438 | 461 | ||
| @@ -526,7 +549,7 @@ func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attached | |||
| 526 | 549 | // (and run its own attach-time reset), so this call must leave the tabs | |
| 527 | 550 | // alone rather than clobbering that client's freshly applied colors. | |
| 528 | 551 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |
| 529 | - | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | |
| 552 | + | d.applyHostTheme(sess, nil, themeui.Theme{}, true) | |
| 530 | 553 | } | |
| 531 | 554 | ||
| 532 | 555 | // retireReplacedClient owns old-link retirement and obsolete render-worker | |
| @@ -710,9 +733,8 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 710 | 733 | Light: msg.Light, | |
| 711 | 734 | } | |
| 712 | 735 | ac.setClientTheme(clientTheme) | |
| 713 | - | t := d.effectiveTheme(clientTheme) | |
| 714 | 736 | ||
| 715 | - | if !d.applyHostTheme(sess, ac, t, false) { | |
| 737 | + | if !d.applyHostTheme(sess, ac, clientTheme, false) { | |
| 716 | 738 | return | |
| 717 | 739 | } | |
| 718 | 740 | d.invalidateRender(sess, ac, true, "client.go") | |
M internal/usecase/daemon/config.go +15 -5
| @@ -21,10 +21,11 @@ var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`) | |||
| 21 | 21 | type themeConfigSnapshot struct { | |
| 22 | 22 | mode domain.ThemeMode | |
| 23 | 23 | paletteOff bool | |
| 24 | + | accent domain.ThemeAccent | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | func (d *Daemon) storeThemeConfig(cfg domain.Config) { | |
| 27 | - | snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette} | |
| 28 | + | snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent} | |
| 28 | 29 | d.themeConfig.Store(&snapshot) | |
| 29 | 30 | } | |
| 30 | 31 | ||
| @@ -259,8 +260,7 @@ func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | |||
| 259 | 260 | return command.Command{}, false | |
| 260 | 261 | } | |
| 261 | 262 | ||
| 262 | - | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |
| 263 | - | config := d.currentThemeConfig() | |
| 263 | + | func effectiveThemeForConfig(clientTheme theme.Theme, config themeConfigSnapshot) theme.Theme { | |
| 264 | 264 | switch config.mode { | |
| 265 | 265 | case domain.ThemeDark: | |
| 266 | 266 | return theme.BuiltinDark | |
| @@ -272,6 +272,16 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |||
| 272 | 272 | } | |
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | + | func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme { | |
| 276 | + | return effectiveThemeForConfig(clientTheme, d.currentThemeConfig()) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme { | |
| 280 | + | config := d.currentThemeConfig() | |
| 281 | + | effective := effectiveThemeForConfig(raw, config) | |
| 282 | + | return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)} | |
| 283 | + | } | |
| 284 | + | ||
| 275 | 285 | func (d *Daemon) reapplyThemeAllSessions() { | |
| 276 | 286 | d.mu.Lock() | |
| 277 | 287 | sessions := make([]*session, 0, len(d.sessions)) | |
| @@ -290,12 +300,12 @@ func (d *Daemon) reapplyThemeSession(sess *session) { | |||
| 290 | 300 | ac := sess.client | |
| 291 | 301 | sess.mu.Unlock() | |
| 292 | 302 | if ac == nil { | |
| 293 | - | d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true) | |
| 303 | + | d.applyHostTheme(sess, nil, theme.Theme{}, true) | |
| 294 | 304 | return | |
| 295 | 305 | } | |
| 296 | 306 | ac.sendMu.Lock() | |
| 297 | 307 | defer ac.sendMu.Unlock() | |
| 298 | 308 | sess.themeMu.Lock() | |
| 299 | 309 | defer sess.themeMu.Unlock() | |
| 300 | - | d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false) | |
| 310 | + | d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false) | |
| 301 | 311 | } | |
M internal/usecase/daemon/config_test.go +2 -2
| @@ -72,7 +72,7 @@ func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) { | |||
| 72 | 72 | ||
| 73 | 73 | require.Equal(t, clientPalette, ac.getClientTheme().Palette) | |
| 74 | 74 | require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown) | |
| 75 | - | require.True(t, ac.getTheme().UsePalette) | |
| 75 | + | require.True(t, ac.getAppliedTheme().Raw.UsePalette) | |
| 76 | 76 | ||
| 77 | 77 | cfg := domain.Defaults() | |
| 78 | 78 | cfg.ThemePalette = false | |
| @@ -80,7 +80,7 @@ func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) { | |||
| 80 | 80 | ||
| 81 | 81 | // ApplyConfig reuses the live client report and repaints without requiring | |
| 82 | 82 | // a reconnect; only palette inheritance changes. | |
| 83 | - | reloaded := ac.getTheme() | |
| 83 | + | reloaded := ac.getAppliedTheme().Raw | |
| 84 | 84 | require.Equal(t, clientPalette, reloaded.Palette) | |
| 85 | 85 | require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown) | |
| 86 | 86 | require.False(t, reloaded.UsePalette) | |
M internal/usecase/daemon/copymode.go +2 -3
| @@ -412,11 +412,10 @@ func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) { | |||
| 412 | 412 | func isCopyEscapePrefix(data []byte) bool { | |
| 413 | 413 | return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |
| 414 | 414 | } | |
| 415 | - | func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 415 | + | func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, styles themeui.Styles) (renderer.Frame, []renderer.Damage) { | |
| 416 | 416 | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 417 | 417 | return frame, nil | |
| 418 | 418 | } | |
| 419 | - | styles := themeui.NewStyles(bars.theme) | |
| 420 | 419 | copyFrame := mode.Render(styles.CopyStatus, styles.Selection) | |
| 421 | 420 | bodyRows := max(copyFrame.Height-1, 0) | |
| 422 | 421 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | |
| @@ -430,7 +429,7 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer | |||
| 430 | 429 | } | |
| 431 | 430 | statusY := frame.Height - 1 | |
| 432 | 431 | for x := range frame.Row(statusY) { | |
| 433 | - | frame.Row(statusY)[x] = renderer.BlankCell() | |
| 432 | + | frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar} | |
| 434 | 433 | } | |
| 435 | 434 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | |
| 436 | 435 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
M internal/usecase/daemon/copymode_test.go +36 -3
| @@ -115,7 +115,6 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 115 | 115 | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | - | bars := barState{status: sess.statusSegments(true)} | |
| 119 | 118 | ||
| 120 | 119 | base := renderer.NewFrame(80, 25) | |
| 121 | 120 | target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23} | |
| @@ -133,7 +132,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 133 | 132 | go func() { | |
| 134 | 133 | defer wg.Done() | |
| 135 | 134 | for range 200 { | |
| 136 | - | _, _ = composeCopyClientFrame(mode, target, base, bars) | |
| 135 | + | _, _ = composeCopyClientFrame(mode, target, base, resolveStyles(nil)) | |
| 137 | 136 | } | |
| 138 | 137 | }() | |
| 139 | 138 | wg.Wait() | |
| @@ -160,7 +159,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 160 | 159 | }}, | |
| 161 | 160 | bars: bars, | |
| 162 | 161 | }, composeCacheInput{}).frame | |
| 163 | - | frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 162 | + | frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, resolveStyles(nil)) | |
| 164 | 163 | ||
| 165 | 164 | require.Equal(t, 80, frame.Width) | |
| 166 | 165 | require.Equal(t, 25, frame.Height) | |
| @@ -881,3 +880,37 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 881 | 880 | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07" | |
| 882 | 881 | require.Equal(t, want, string(msg.Data)) | |
| 883 | 882 | } | |
| 883 | + | ||
| 884 | + | func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |
| 885 | + | for _, tt := range []struct { | |
| 886 | + | name string | |
| 887 | + | selectMode bool | |
| 888 | + | want string | |
| 889 | + | }{ | |
| 890 | + | {name: "scroll", want: "[SCROLL]"}, | |
| 891 | + | {name: "selection", selectMode: true, want: "[SELECT]"}, | |
| 892 | + | } { | |
| 893 | + | t.Run(tt.name, func(t *testing.T) { | |
| 894 | + | base := renderer.NewFrame(20, 8) | |
| 895 | + | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 896 | + | pane.screen.Write([]byte("copy")) | |
| 897 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | |
| 898 | + | if tt.selectMode { | |
| 899 | + | mode.ToggleLineSelection() | |
| 900 | + | } | |
| 901 | + | styles := resolveStyles(nil) | |
| 902 | + | styles.SurfaceBar = renderer.Style{Foreground: 7, Background: 3} | |
| 903 | + | styles.CopyStatus = renderer.Style{Foreground: 5, Background: 4} | |
| 904 | + | ||
| 905 | + | frame, _ := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 2}, base, styles) | |
| 906 | + | status := frame.Row(frame.Height - 1) | |
| 907 | + | for x := range 12 { | |
| 908 | + | require.Truef(t, status[x].Style.Equal(styles.CopyStatus), "status content cell %d must use CopyStatus", x) | |
| 909 | + | } | |
| 910 | + | for x := 12; x < len(status); x++ { | |
| 911 | + | require.Truef(t, status[x].Style.Equal(styles.SurfaceBar), "surrounding status cell %d must use SurfaceBar", x) | |
| 912 | + | } | |
| 913 | + | require.Contains(t, rowText(status), tt.want) | |
| 914 | + | }) | |
| 915 | + | } | |
| 916 | + | } | |
M internal/usecase/daemon/floating_render.go +18 -10
| @@ -86,14 +86,16 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | 88 | type floatingComposeInput struct { | |
| 89 | - | baseFrame renderer.Frame | |
| 90 | - | baseDamage []renderer.Damage | |
| 91 | - | floating capturedFloatingRenderState | |
| 92 | - | content domain.Rect | |
| 93 | - | layout capturedTabLayout | |
| 94 | - | theme themeui.Theme | |
| 95 | - | cache composeCacheInput | |
| 96 | - | full bool | |
| 89 | + | baseFrame renderer.Frame | |
| 90 | + | baseDamage []renderer.Damage | |
| 91 | + | floating capturedFloatingRenderState | |
| 92 | + | content domain.Rect | |
| 93 | + | layout capturedTabLayout | |
| 94 | + | theme themeui.Theme | |
| 95 | + | borderMuted renderer.Style | |
| 96 | + | borderActive renderer.Style | |
| 97 | + | cache composeCacheInput | |
| 98 | + | full bool | |
| 97 | 99 | } | |
| 98 | 100 | ||
| 99 | 101 | func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) { | |
| @@ -103,6 +105,8 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 103 | 105 | content := input.content | |
| 104 | 106 | layoutSnap := input.layout | |
| 105 | 107 | theme := input.theme | |
| 108 | + | borderMuted := input.borderMuted | |
| 109 | + | borderActive := input.borderActive | |
| 106 | 110 | cache := input.cache | |
| 107 | 111 | full := input.full | |
| 108 | 112 | ||
| @@ -115,9 +119,13 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 115 | 119 | for _, d := range floating.pane.damage { | |
| 116 | 120 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| 117 | 121 | } | |
| 118 | - | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | |
| 122 | + | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused | |
| 119 | 123 | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | |
| 120 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, themeui.NewStyles(theme).Border) | |
| 124 | + | border := borderMuted | |
| 125 | + | if floating.focused { | |
| 126 | + | border = borderActive | |
| 127 | + | } | |
| 128 | + | drawFloatingBorder(frame, geometry.Bounds, floating.title, border) | |
| 121 | 129 | if full || popupChanged { | |
| 122 | 130 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 123 | 131 | } | |
M internal/usecase/daemon/floating_render_test.go +157 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 14 | 15 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 16 | ) | |
| @@ -177,6 +178,77 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi | |||
| 177 | 178 | require.Equal(t, 1, damage[0].Height) | |
| 178 | 179 | } | |
| 179 | 180 | ||
| 181 | + | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | |
| 182 | + | base := renderer.NewFrame(20, 9) | |
| 183 | + | content := domain.Rect{Y: 1, Width: 20, Height: 7} | |
| 184 | + | geometry := floatingGeometry{ | |
| 185 | + | Bounds: domain.Rect{X: 3, Y: 1, Width: 8, Height: 5}, | |
| 186 | + | Inner: domain.Rect{X: 4, Y: 2, Width: 6, Height: 3}, | |
| 187 | + | } | |
| 188 | + | active := renderer.Style{Foreground: 2, Background: 3} | |
| 189 | + | muted := renderer.Style{Foreground: 4, Background: 5} | |
| 190 | + | pane := renderer.NewFrame(6, 3) | |
| 191 | + | for y := range pane.Height { | |
| 192 | + | for x := range pane.Width { | |
| 193 | + | pane.Set(x, y, renderer.Cell{Rune: rune('a' + y*pane.Width + x), Style: renderer.Style{Foreground: x + y*10, Background: x + y*10 + 1}}) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | frame, _ := composeCapturedFloatingFrame(floatingComposeInput{ | |
| 198 | + | baseFrame: base, | |
| 199 | + | floating: capturedFloatingRenderState{ | |
| 200 | + | visible: true, focused: true, pane: capturedPaneRenderState{frame: pane}, geometry: geometry, title: "float", generation: 1, | |
| 201 | + | }, | |
| 202 | + | content: content, | |
| 203 | + | borderActive: active, | |
| 204 | + | borderMuted: muted, | |
| 205 | + | full: true, | |
| 206 | + | }) | |
| 207 | + | bounds := geometry.translate(content.X, content.Y) | |
| 208 | + | require.Equal(t, active, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style, "focused floating border must use BorderActive") | |
| 209 | + | require.NotEqual(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style) | |
| 210 | + | for y := range bounds.Inner.Height { | |
| 211 | + | for x := range bounds.Inner.Width { | |
| 212 | + | require.Equal(t, pane.At(x, y), frame.At(bounds.Inner.X+x, bounds.Inner.Y+y), "pane cell (%d,%d) must remain byte-identical", x, y) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestComposeCapturedFloatingFrameUsesMutedBorderWhenUnfocused(t *testing.T) { | |
| 218 | + | base := renderer.NewFrame(12, 7) | |
| 219 | + | content := domain.Rect{Y: 1, Width: 12, Height: 5} | |
| 220 | + | geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}} | |
| 221 | + | active, muted := renderer.Style{Foreground: 2}, renderer.Style{Foreground: 4} | |
| 222 | + | ||
| 223 | + | frame, _ := composeCapturedFloatingFrame(floatingComposeInput{ | |
| 224 | + | baseFrame: base, | |
| 225 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, title: "float", generation: 1}, | |
| 226 | + | content: content, | |
| 227 | + | borderActive: active, | |
| 228 | + | borderMuted: muted, | |
| 229 | + | full: true, | |
| 230 | + | }) | |
| 231 | + | bounds := geometry.translate(content.X, content.Y) | |
| 232 | + | require.Equal(t, muted, frame.At(bounds.Bounds.X, bounds.Bounds.Y).Style) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestComposeCapturedFloatingFrameFocusChangeInvalidatesCache(t *testing.T) { | |
| 236 | + | content := domain.Rect{Y: 1, Width: 12, Height: 5} | |
| 237 | + | geometry := floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}} | |
| 238 | + | frame, damage := composeCapturedFloatingFrame(floatingComposeInput{ | |
| 239 | + | baseFrame: renderer.NewFrame(12, 7), | |
| 240 | + | floating: capturedFloatingRenderState{ | |
| 241 | + | visible: true, focused: true, pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, geometry: geometry, generation: 1, | |
| 242 | + | }, | |
| 243 | + | content: content, | |
| 244 | + | cache: composeCacheInput{ | |
| 245 | + | valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), | |
| 246 | + | }, | |
| 247 | + | }) | |
| 248 | + | require.NotNil(t, frame) | |
| 249 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage, "a focused border role change must redraw the popup") | |
| 250 | + | } | |
| 251 | + | ||
| 180 | 252 | func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) { | |
| 181 | 253 | initial := floatingGeometry{ | |
| 182 | 254 | Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5}, | |
| @@ -356,6 +428,91 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi | |||
| 356 | 428 | require.LessOrEqual(t, allocs, float64(2)) | |
| 357 | 429 | } | |
| 358 | 430 | ||
| 431 | + | var ( | |
| 432 | + | benchmarkComposeSink composedRenderFrame | |
| 433 | + | benchmarkOutputSink []byte | |
| 434 | + | ) | |
| 435 | + | ||
| 436 | + | // benchmarkCapturedRenderState is a stable, post-capture production input: one | |
| 437 | + | // 80x24 pane, both chrome bars, and no pending pane damage. The primed cache | |
| 438 | + | // below makes each benchmark operation exercise the cached compose path. | |
| 439 | + | func benchmarkCapturedRenderState() capturedRenderState { | |
| 440 | + | pane := renderer.NewFrame(80, 24) | |
| 441 | + | for y := range pane.Height { | |
| 442 | + | for x := range pane.Width { | |
| 443 | + | pane.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26)}) | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | terminalTheme := themeui.Theme{ | |
| 447 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, | |
| 448 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 449 | + | Palette: [16]renderer.RGB{ | |
| 450 | + | 2: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 451 | + | 4: {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 452 | + | 10: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 453 | + | 12: {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 454 | + | 14: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 455 | + | }, | |
| 456 | + | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | |
| 457 | + | HasFG: true, | |
| 458 | + | HasBG: true, | |
| 459 | + | TrueColor: true, | |
| 460 | + | Known: true, | |
| 461 | + | SchemeKnown: true, | |
| 462 | + | UsePalette: true, | |
| 463 | + | } | |
| 464 | + | placement := layout.Placement{ID: "benchmark-pane", Content: domain.Rect{Width: 80, Height: 24}} | |
| 465 | + | return capturedRenderState{ | |
| 466 | + | layout: capturedTabLayout{ | |
| 467 | + | area: domain.Rect{Width: 80, Height: 24}, | |
| 468 | + | focus: placement.ID, | |
| 469 | + | placements: []layout.Placement{placement}, | |
| 470 | + | fingerprint: "benchmark-one-pane", | |
| 471 | + | valid: true, | |
| 472 | + | }, | |
| 473 | + | panes: []capturedPaneRenderState{{id: placement.ID, frame: pane, placement: placement, focused: true}}, | |
| 474 | + | bars: barState{ | |
| 475 | + | status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}}, | |
| 476 | + | topRight: "vev", | |
| 477 | + | bottomRight: "main", | |
| 478 | + | mru: []recentSession{{name: "work"}, {name: "logs"}}, | |
| 479 | + | theme: terminalTheme, | |
| 480 | + | }, | |
| 481 | + | theme: terminalTheme, | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func benchmarkComposeCacheInput(state capturedRenderState) composeCacheInput { | |
| 486 | + | priming := state | |
| 487 | + | priming.reset = true | |
| 488 | + | priming.panes = append([]capturedPaneRenderState(nil), state.panes...) | |
| 489 | + | priming.panes[0].damage = []renderer.Damage{renderer.FullRedraw()} | |
| 490 | + | return composeFrame(priming, composeCacheInput{}).cache | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | func BenchmarkComposeCapturedFrame(b *testing.B) { | |
| 494 | + | state := benchmarkCapturedRenderState() | |
| 495 | + | cache := benchmarkComposeCacheInput(state) | |
| 496 | + | scratch := composeCacheInput{} | |
| 497 | + | draw := renderer.New(renderer.Capabilities{}) | |
| 498 | + | var totalBytes int64 | |
| 499 | + | ||
| 500 | + | b.ReportAllocs() | |
| 501 | + | b.ResetTimer() | |
| 502 | + | for b.Loop() { | |
| 503 | + | composed := composeFrame(state, cache, scratch) | |
| 504 | + | scratch = composed.cache | |
| 505 | + | draw.Reset() | |
| 506 | + | out, err := draw.Draw(composed.frame, composed.damage) | |
| 507 | + | if err != nil { | |
| 508 | + | b.Fatal(err) | |
| 509 | + | } | |
| 510 | + | totalBytes += int64(len(out)) | |
| 511 | + | benchmarkComposeSink, benchmarkOutputSink = composed, out | |
| 512 | + | } | |
| 513 | + | b.ReportMetric(float64(totalBytes)/float64(b.N), "output-bytes/op") | |
| 514 | + | } | |
| 515 | + | ||
| 359 | 516 | func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) { | |
| 360 | 517 | base := renderer.NewFrame(80, 24) | |
| 361 | 518 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
M internal/usecase/daemon/modal_compose.go +3 -3
| @@ -7,9 +7,9 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 11 | - | inner := modal.Composite(base, styles.Border) | |
| 12 | - | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle) | |
| 10 | + | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 11 | + | inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase) | |
| 12 | + | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 13 | 13 | for y := range min(inner.Height, modalFrame.Height) { | |
| 14 | 14 | for x := range min(inner.Width, modalFrame.Width) { | |
| 15 | 15 | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
M internal/usecase/daemon/palette.go +2 -2
| @@ -500,7 +500,7 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 500 | 500 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) { | |
| 501 | 501 | styleSet := resolveStyles(styles) | |
| 502 | 502 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | |
| 503 | - | return composeModalClientFrame(base, modal, styleSet, styleSet.Selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 504 | - | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.Selection, Description: styleSet.PaletteDesc}, Guidance: guidance}) | |
| 503 | + | return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame { | |
| 504 | + | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription}, Guidance: guidance}) | |
| 505 | 505 | }) | |
| 506 | 506 | } | |
M internal/usecase/daemon/palette_test.go +68 -0
| @@ -13,10 +13,78 @@ import ( | |||
| 13 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 14 | "github.com/bnema/vev/internal/usecase/command" | |
| 15 | 15 | "github.com/bnema/vev/internal/usecase/palette" | |
| 16 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | 18 | "github.com/bnema/vev/pkg/renderer" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 21 | + | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | |
| 22 | + | paletteColors := [16]renderer.RGB{} | |
| 23 | + | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 24 | + | paletteColors[10] = paletteColors[2] | |
| 25 | + | accentTheme := themeui.Theme{ | |
| 26 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 27 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 28 | + | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |
| 29 | + | } | |
| 30 | + | indexedTheme := accentTheme | |
| 31 | + | indexedTheme.TrueColor = false | |
| 32 | + | neutralTheme := accentTheme | |
| 33 | + | neutralTheme.PaletteKnown = 0 | |
| 34 | + | neutralTheme.SchemeKnown = false | |
| 35 | + | ||
| 36 | + | defaults := domain.Defaults() | |
| 37 | + | paletteOff := defaults | |
| 38 | + | paletteOff.ThemePalette = false | |
| 39 | + | forcedDark := defaults | |
| 40 | + | forcedDark.Theme = domain.ThemeDark | |
| 41 | + | forcedLight := defaults | |
| 42 | + | forcedLight.Theme = domain.ThemeLight | |
| 43 | + | tests := []struct { | |
| 44 | + | name string | |
| 45 | + | raw themeui.Theme | |
| 46 | + | config domain.Config | |
| 47 | + | indexed bool | |
| 48 | + | }{ | |
| 49 | + | {name: "truecolor accent", raw: accentTheme, config: defaults}, | |
| 50 | + | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, | |
| 51 | + | {name: "palette off", raw: accentTheme, config: paletteOff}, | |
| 52 | + | {name: "forced dark", raw: accentTheme, config: forcedDark}, | |
| 53 | + | {name: "forced light", raw: accentTheme, config: forcedLight}, | |
| 54 | + | {name: "neutral fallback", raw: neutralTheme, config: defaults}, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | for _, tt := range tests { | |
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |
| 59 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 60 | + | d.ApplyConfig(tt.config) | |
| 61 | + | applied := d.resolveAppliedTheme(tt.raw) | |
| 62 | + | model := palette.New(palette.CommandResults([]command.Command{ | |
| 63 | + | {Code: "ONE", Name: "One", Desc: "first description"}, | |
| 64 | + | {Code: "TWO", Name: "Two", Desc: "second description"}, | |
| 65 | + | })) | |
| 66 | + | model.Down() | |
| 67 | + | state := capturedRenderState{ | |
| 68 | + | theme: applied.Raw, | |
| 69 | + | styles: applied.Resolved.Styles, | |
| 70 | + | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |
| 71 | + | } | |
| 72 | + | snap := &overlayRenderSnapshot{paletteActive: true, paletteModel: model} | |
| 73 | + | ||
| 74 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 75 | + | ||
| 76 | + | description := state.overlays.palette.inner.At(4, 1).Style | |
| 77 | + | require.Equal(t, state.styles.SurfaceInactive.Background, description.Background) | |
| 78 | + | require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB) | |
| 79 | + | require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB) | |
| 80 | + | if tt.indexed { | |
| 81 | + | require.Equal(t, 2, description.Foreground) | |
| 82 | + | require.False(t, description.HasBackgroundRGB) | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 20 | 88 | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |
| 21 | 89 | p1, release1 := newBlockingPTY(t) | |
| 22 | 90 | p2, release2 := newBlockingPTY(t) | |
M internal/usecase/daemon/picker_test.go +62 -13
| @@ -642,35 +642,84 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||
| 642 | 642 | require.True(t, subscribed) | |
| 643 | 643 | } | |
| 644 | 644 | ||
| 645 | - | func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) { | |
| 646 | - | for _, tt := range []struct { | |
| 647 | - | name string | |
| 648 | - | theme themeui.Theme | |
| 645 | + | func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) { | |
| 646 | + | palette := [16]renderer.RGB{} | |
| 647 | + | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 648 | + | palette[10] = palette[2] | |
| 649 | + | accentTheme := themeui.Theme{ | |
| 650 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 651 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 652 | + | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |
| 653 | + | } | |
| 654 | + | indexedTheme := accentTheme | |
| 655 | + | indexedTheme.TrueColor = false | |
| 656 | + | neutralTheme := accentTheme | |
| 657 | + | neutralTheme.PaletteKnown = 0 | |
| 658 | + | neutralTheme.SchemeKnown = false | |
| 659 | + | ||
| 660 | + | defaults := domain.Defaults() | |
| 661 | + | paletteOff := defaults | |
| 662 | + | paletteOff.ThemePalette = false | |
| 663 | + | forcedDark := defaults | |
| 664 | + | forcedDark.Theme = domain.ThemeDark | |
| 665 | + | forcedLight := defaults | |
| 666 | + | forcedLight.Theme = domain.ThemeLight | |
| 667 | + | tests := []struct { | |
| 668 | + | name string | |
| 669 | + | raw themeui.Theme | |
| 670 | + | config domain.Config | |
| 671 | + | indexed bool | |
| 649 | 672 | }{ | |
| 650 | - | {name: "default terminal", theme: themeui.Theme{}}, | |
| 651 | - | {name: "truecolor terminal", theme: themeui.BuiltinDark}, | |
| 652 | - | } { | |
| 673 | + | {name: "truecolor accent", raw: accentTheme, config: defaults}, | |
| 674 | + | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, | |
| 675 | + | {name: "palette off", raw: accentTheme, config: paletteOff}, | |
| 676 | + | {name: "forced dark", raw: accentTheme, config: forcedDark}, | |
| 677 | + | {name: "forced light", raw: accentTheme, config: forcedLight}, | |
| 678 | + | {name: "neutral fallback", raw: neutralTheme, config: defaults}, | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | for _, tt := range tests { | |
| 653 | 682 | t.Run(tt.name, func(t *testing.T) { | |
| 683 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 684 | + | d.ApplyConfig(tt.config) | |
| 685 | + | applied := d.resolveAppliedTheme(tt.raw) | |
| 654 | 686 | state := capturedRenderState{ | |
| 655 | - | theme: tt.theme, | |
| 687 | + | theme: applied.Raw, | |
| 688 | + | styles: applied.Resolved.Styles, | |
| 656 | 689 | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |
| 657 | 690 | } | |
| 658 | 691 | snap := &overlayRenderSnapshot{ | |
| 659 | 692 | pickerActive: true, | |
| 660 | - | pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0), | |
| 693 | + | pickerModel: picker.New([]picker.SessionView{ | |
| 694 | + | {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0}, | |
| 695 | + | {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 696 | + | }, "selected", 0), | |
| 661 | 697 | } | |
| 662 | 698 | ||
| 663 | 699 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 664 | 700 | ||
| 701 | + | require.Equal(t, state.styles.SurfaceInactive.Background, state.styles.PickerDescription.Background) | |
| 702 | + | require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, state.styles.PickerDescription.HasBackgroundRGB) | |
| 703 | + | require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, state.styles.PickerDescription.BackgroundRGB) | |
| 704 | + | require.Equal(t, state.styles.SurfaceBar.Background, state.styles.PickerSeparator.Background) | |
| 705 | + | require.Equal(t, state.styles.SurfaceBar.HasBackgroundRGB, state.styles.PickerSeparator.HasBackgroundRGB) | |
| 706 | + | require.Equal(t, state.styles.SurfaceBar.BackgroundRGB, state.styles.PickerSeparator.BackgroundRGB) | |
| 707 | + | if tt.indexed { | |
| 708 | + | require.Equal(t, 2, state.styles.PickerDescription.Foreground) | |
| 709 | + | require.Equal(t, 2, state.styles.PickerSeparator.Foreground) | |
| 710 | + | require.False(t, state.styles.PickerDescription.HasBackgroundRGB) | |
| 711 | + | require.False(t, state.styles.PickerSeparator.HasBackgroundRGB) | |
| 712 | + | } | |
| 713 | + | ||
| 665 | 714 | inner := state.overlays.picker.inner | |
| 666 | 715 | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 667 | 716 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | |
| 668 | - | want := themeui.MutedTextStyle(tt.theme) | |
| 717 | + | require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text retains the inactive row surface") | |
| 669 | 718 | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | |
| 670 | 719 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |
| 671 | 720 | cell := inner.At(x, y) | |
| 672 | 721 | require.Equal(t, '│', cell.Rune) | |
| 673 | - | require.True(t, cell.Style.Equal(want)) | |
| 722 | + | require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator retains the bar surface") | |
| 674 | 723 | } | |
| 675 | 724 | } | |
| 676 | 725 | }) | |
| @@ -679,7 +728,7 @@ func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T | |||
| 679 | 728 | ||
| 680 | 729 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | |
| 681 | 730 | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 682 | - | state := capturedRenderState{theme: themeui.BuiltinDark} | |
| 731 | + | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | |
| 683 | 732 | cases := []struct { | |
| 684 | 733 | name string | |
| 685 | 734 | size domain.Size | |
| @@ -725,7 +774,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin | |||
| 725 | 774 | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |
| 726 | 775 | cell := inner.At(x, y) | |
| 727 | 776 | require.Equal(t, tt.wantSep, cell.Rune) | |
| 728 | - | require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme))) | |
| 777 | + | require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator must use the cached PickerSeparator role") | |
| 729 | 778 | } | |
| 730 | 779 | } | |
| 731 | 780 | }) | |
M internal/usecase/daemon/render.go +13 -8
| @@ -295,7 +295,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 295 | 295 | // Contextual ranks are completely captured under paletteMu. Compose them | |
| 296 | 296 | // without reading the live MRU, whose order may have changed mid-interaction. | |
| 297 | 297 | bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 298 | - | bars.theme = ac.getTheme() | |
| 298 | + | applied := ac.getAppliedTheme() | |
| 299 | + | bars.theme = applied.Raw | |
| 299 | 300 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 300 | 301 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 301 | 302 | ||
| @@ -326,12 +327,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 326 | 327 | } | |
| 327 | 328 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 328 | 329 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | |
| 329 | - | bars: bars, | |
| 330 | - | overlays: capturedOverlays, | |
| 331 | - | preview: preview, | |
| 332 | - | floatingCfg: floatingCfg, | |
| 333 | - | reset: reset, | |
| 334 | - | lease: lease, | |
| 330 | + | bars: bars, | |
| 331 | + | overlays: capturedOverlays, | |
| 332 | + | preview: preview, | |
| 333 | + | floatingCfg: floatingCfg, | |
| 334 | + | styles: applied.Resolved.Styles, | |
| 335 | + | styleGeneration: applied.Generation, | |
| 336 | + | reset: reset, | |
| 337 | + | lease: lease, | |
| 335 | 338 | }) | |
| 336 | 339 | endCapture(0, ok) | |
| 337 | 340 | if !ok { | |
| @@ -357,11 +360,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 357 | 360 | d.emitFrame(sess, ac, state, composed, &marks) | |
| 358 | 361 | } | |
| 359 | 362 | ||
| 363 | + | var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 364 | + | ||
| 360 | 365 | func resolveStyles(styles []themeui.Styles) themeui.Styles { | |
| 361 | 366 | if len(styles) > 0 { | |
| 362 | 367 | return styles[0] | |
| 363 | 368 | } | |
| 364 | - | return themeui.NewStyles(themeui.Theme{}) | |
| 369 | + | return fallbackChromeStyles | |
| 365 | 370 | } | |
| 366 | 371 | ||
| 367 | 372 | type barCache struct { | |
M internal/usecase/daemon/render_capture.go +18 -9
| @@ -43,6 +43,8 @@ type capturedRenderState struct { | |||
| 43 | 43 | floating capturedFloatingRenderState | |
| 44 | 44 | bars barState | |
| 45 | 45 | theme themeui.Theme | |
| 46 | + | styles themeui.Styles | |
| 47 | + | styleGeneration uint64 | |
| 46 | 48 | overlays capturedOverlayRenderState | |
| 47 | 49 | preview picker.Preview | |
| 48 | 50 | cursor capturedCursorInputs | |
| @@ -74,6 +76,7 @@ type capturedPaneRenderState struct { | |||
| 74 | 76 | ||
| 75 | 77 | type capturedFloatingRenderState struct { | |
| 76 | 78 | visible bool | |
| 79 | + | focused bool | |
| 77 | 80 | pane capturedPaneRenderState | |
| 78 | 81 | geometry floatingGeometry | |
| 79 | 82 | title string | |
| @@ -91,8 +94,9 @@ type capturedOverlayRenderState struct { | |||
| 91 | 94 | } | |
| 92 | 95 | ||
| 93 | 96 | type capturedModal struct { | |
| 94 | - | modal ui.Modal | |
| 95 | - | inner renderer.Frame | |
| 97 | + | modal ui.Modal | |
| 98 | + | inner renderer.Frame | |
| 99 | + | focused bool | |
| 96 | 100 | } | |
| 97 | 101 | ||
| 98 | 102 | func (o capturedOverlayRenderState) active() bool { | |
| @@ -166,12 +170,14 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |||
| 166 | 170 | } | |
| 167 | 171 | ||
| 168 | 172 | type primaryCaptureRequest struct { | |
| 169 | - | bars barState | |
| 170 | - | overlays capturedOverlayRenderState | |
| 171 | - | preview picker.Preview | |
| 172 | - | floatingCfg domain.FloatingConfig | |
| 173 | - | reset bool | |
| 174 | - | lease *attachmentLease | |
| 173 | + | bars barState | |
| 174 | + | overlays capturedOverlayRenderState | |
| 175 | + | preview picker.Preview | |
| 176 | + | floatingCfg domain.FloatingConfig | |
| 177 | + | styles themeui.Styles | |
| 178 | + | styleGeneration uint64 | |
| 179 | + | reset bool | |
| 180 | + | lease *attachmentLease | |
| 175 | 181 | } | |
| 176 | 182 | ||
| 177 | 183 | // capturePrimaryRenderState is the ownership boundary for a primary render | |
| @@ -227,6 +233,7 @@ func capturePrimaryRenderState( | |||
| 227 | 233 | state := &scratch.state | |
| 228 | 234 | *state = capturedRenderState{ | |
| 229 | 235 | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | |
| 236 | + | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 230 | 237 | overlays: overlays, preview: preview, | |
| 231 | 238 | layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 232 | 239 | floatingGeneration: tb.floating.generation, | |
| @@ -279,7 +286,9 @@ func capturePrimaryRenderState( | |||
| 279 | 286 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | |
| 280 | 287 | } | |
| 281 | 288 | ac.captureFrames[p] = captured | |
| 282 | - | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 289 | + | // A visible floating pane is the terminal input target, so its structural | |
| 290 | + | // border carries the focused semantic role independently of its content. | |
| 291 | + | state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 283 | 292 | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| 284 | 293 | p.mu.Unlock() | |
| 285 | 294 | } | |
M internal/usecase/daemon/render_pipeline.go +48 -26
| @@ -9,8 +9,10 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/prompt" | |
| 12 | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 14 | 16 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| @@ -36,11 +38,13 @@ type composeCacheInput struct { | |||
| 36 | 38 | titleGenerations map[layout.PaneID]uint64 | |
| 37 | 39 | damage []renderer.Damage | |
| 38 | 40 | floatingVisible bool | |
| 41 | + | floatingFocused bool | |
| 39 | 42 | floatingGeneration uint64 | |
| 40 | 43 | floatingGeometry floatingGeometry | |
| 41 | 44 | floatingTitleGeneration uint64 | |
| 42 | 45 | bars barCache | |
| 43 | 46 | theme themeui.Theme | |
| 47 | + | styleGeneration uint64 | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | type composedRenderFrame struct { | |
| @@ -77,17 +81,24 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 77 | 81 | copy(frame.Row(y), in.frame.Row(y)) | |
| 78 | 82 | } | |
| 79 | 83 | } | |
| 80 | - | styles := themeui.NewStyles(state.theme) | |
| 84 | + | styles := state.styles | |
| 85 | + | if styles == (themeui.Styles{}) { | |
| 86 | + | // Direct/test-only composition has no applied snapshot; production | |
| 87 | + | // capture always supplies immutable styles. | |
| 88 | + | styles = fallbackChromeStyles | |
| 89 | + | } | |
| 90 | + | state.styles = styles | |
| 81 | 91 | defaultDimmer := themeui.NewDimmer(state.theme) | |
| 92 | + | neutralBorder := styles.NeutralBorder | |
| 82 | 93 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | |
| 83 | 94 | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 84 | 95 | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 85 | 96 | content := domain.Rect{Y: 1, Width: width, Height: rows} | |
| 86 | 97 | if state.layout.valid && state.layout.root != nil { | |
| 87 | - | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.Border)) | |
| 98 | + | drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(neutralBorder)) | |
| 88 | 99 | } | |
| 89 | 100 | ||
| 90 | - | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible | |
| 101 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible | |
| 91 | 102 | titles := scratch.titleGenerations | |
| 92 | 103 | if titles == nil { | |
| 93 | 104 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | |
| @@ -97,7 +108,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 97 | 108 | for _, pane := range state.panes { | |
| 98 | 109 | pl := offsetPlacement(pane.placement, 0, 1) | |
| 99 | 110 | if pl.TitleBar.Height > 0 { | |
| 100 | - | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer) | |
| 111 | + | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer) | |
| 101 | 112 | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | |
| 102 | 113 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height}) | |
| 103 | 114 | } | |
| @@ -119,14 +130,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 119 | 130 | if state.floating.visible { | |
| 120 | 131 | var floatingDamage []renderer.Damage | |
| 121 | 132 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | |
| 122 | - | baseFrame: baseFrame, | |
| 123 | - | baseDamage: damage, | |
| 124 | - | floating: state.floating, | |
| 125 | - | content: content, | |
| 126 | - | layout: state.layout, | |
| 127 | - | theme: state.theme, | |
| 128 | - | cache: in, | |
| 129 | - | full: full || state.overlays.active(), | |
| 133 | + | baseFrame: baseFrame, | |
| 134 | + | baseDamage: damage, | |
| 135 | + | floating: state.floating, | |
| 136 | + | content: content, | |
| 137 | + | layout: state.layout, | |
| 138 | + | theme: state.theme, | |
| 139 | + | borderMuted: styles.BorderMuted, | |
| 140 | + | borderActive: styles.BorderActive, | |
| 141 | + | cache: in, | |
| 142 | + | full: full || state.overlays.active(), | |
| 130 | 143 | }) | |
| 131 | 144 | damage = floatingDamage | |
| 132 | 145 | } | |
| @@ -152,17 +165,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 152 | 165 | cursorInputs := state.cursor | |
| 153 | 166 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active() | |
| 154 | 167 | cursor := desiredCapturedCursor(cursorInputs) | |
| 155 | - | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 168 | + | outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 156 | 169 | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 157 | 170 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 158 | 171 | } | |
| 159 | 172 | ||
| 160 | - | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, dimmer themeui.Dimmer) { | |
| 161 | - | style := styles.Border | |
| 162 | - | if focused { | |
| 163 | - | style = styles.StatusBar | |
| 164 | - | } else { | |
| 165 | - | style = dimmer.Dim(style) | |
| 173 | + | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | |
| 174 | + | style := styles.StatusBar | |
| 175 | + | if !focused { | |
| 176 | + | style = dimmer.Dim(neutralBorder) | |
| 166 | 177 | } | |
| 167 | 178 | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |
| 168 | 179 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |
| @@ -191,29 +202,36 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 191 | 202 | if snap.copyPane != nil { | |
| 192 | 203 | o.copyPaneID = snap.copyPane.id | |
| 193 | 204 | } | |
| 194 | - | styles := themeui.NewStyles(state.theme) | |
| 205 | + | styles := state.styles | |
| 206 | + | if styles == (themeui.Styles{}) { | |
| 207 | + | styles = fallbackChromeStyles | |
| 208 | + | } | |
| 195 | 209 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 196 | 210 | if snap.copySearchModel != nil { | |
| 197 | 211 | o.copySearch.modal = copySearchModal | |
| 198 | - | o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.Selection) | |
| 212 | + | o.copySearch.focused = true | |
| 213 | + | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | |
| 199 | 214 | } | |
| 200 | 215 | if snap.pickerActive && snap.pickerModel != nil { | |
| 201 | 216 | o.picker.modal = pickerModal | |
| 202 | - | renderStyles := picker.RenderStyles{Selection: styles.Selection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PaletteDesc, Base: renderer.DefaultStyle(), Separator: styles.PickerSeparator} | |
| 217 | + | o.picker.focused = true | |
| 218 | + | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator} | |
| 203 | 219 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 204 | 220 | } | |
| 205 | 221 | if snap.paletteActive && snap.paletteModel != nil { | |
| 206 | 222 | o.palette.modal = paletteModalFor(size, paletteCfg) | |
| 223 | + | o.palette.focused = true | |
| 207 | 224 | guidance := "" | |
| 208 | 225 | if snap.paletteHints != nil { | |
| 209 | 226 | guidance = snap.paletteHints.Feedback | |
| 210 | 227 | } | |
| 211 | 228 | o.paletteGuidance = snap.paletteFeedback | |
| 212 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.Selection, Description: styles.PaletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback}) | |
| 229 | + | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription}, Guidance: guidance, Feedback: snap.paletteFeedback}) | |
| 213 | 230 | } | |
| 214 | 231 | if snap.promptActive && snap.promptModel != nil { | |
| 215 | 232 | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | |
| 216 | - | o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.Accent) | |
| 233 | + | o.prompt.focused = true | |
| 234 | + | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | |
| 217 | 235 | } | |
| 218 | 236 | state.cursor.hiddenByOverlay = o.active() | |
| 219 | 237 | } | |
| @@ -238,7 +256,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 238 | 256 | } | |
| 239 | 257 | } | |
| 240 | 258 | } | |
| 241 | - | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars) | |
| 259 | + | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |
| 242 | 260 | } | |
| 243 | 261 | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |
| 244 | 262 | if o.paletteActive && !state.floating.visible { | |
| @@ -248,7 +266,11 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 248 | 266 | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |
| 249 | 267 | continue | |
| 250 | 268 | } | |
| 251 | - | inner := modal.modal.Composite(frame, themeui.NewStyles(state.theme).Border) | |
| 269 | + | border := state.styles.BorderMuted | |
| 270 | + | if modal.focused { | |
| 271 | + | border = state.styles.BorderActive | |
| 272 | + | } | |
| 273 | + | inner := modal.modal.Composite(frame, border, state.styles.PickerBase) | |
| 252 | 274 | for y := range min(inner.Height, modal.inner.Height) { | |
| 253 | 275 | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | |
| 254 | 276 | } | |
M internal/usecase/daemon/render_pipeline_cache_test.go +29 -1
| @@ -125,6 +125,30 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes | |||
| 125 | 125 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | |
| 129 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}} | |
| 130 | + | neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}} | |
| 131 | + | expected := themeui.NewDimmer(theme).Dim(neutralBorder) | |
| 132 | + | ||
| 133 | + | t.Run("divider", func(t *testing.T) { | |
| 134 | + | state := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme) | |
| 135 | + | state.styles.NeutralBorder = neutralBorder | |
| 136 | + | ||
| 137 | + | out := composeFrame(state, composeCacheInput{}) | |
| 138 | + | require.Equal(t, expected, out.frame.At(20, 1).Style) | |
| 139 | + | }) | |
| 140 | + | ||
| 141 | + | t.Run("unfocused title", func(t *testing.T) { | |
| 142 | + | state := cachedStackTitleState("collapsed", 1, true) | |
| 143 | + | state.theme = theme | |
| 144 | + | state.styles = themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 145 | + | state.styles.NeutralBorder = neutralBorder | |
| 146 | + | ||
| 147 | + | out := composeFrame(state, composeCacheInput{}) | |
| 148 | + | require.Equal(t, expected, out.frame.At(0, 1).Style) | |
| 149 | + | }) | |
| 150 | + | } | |
| 151 | + | ||
| 128 | 152 | func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |
| 129 | 153 | state := cachedStackTitleState("collapsed", 1, true) | |
| 130 | 154 | state.theme = themeui.Theme{ | |
| @@ -135,6 +159,7 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||
| 135 | 159 | Foreground: renderer.RGB{R: 220, G: 210, B: 200}, | |
| 136 | 160 | Background: renderer.RGB{R: 20, G: 30, B: 40}, | |
| 137 | 161 | } | |
| 162 | + | state.styles = themeui.Resolve(state.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 138 | 163 | before := cloneCapturedStackState(state) | |
| 139 | 164 | ||
| 140 | 165 | out := composeFrame(state, composeCacheInput{}) | |
| @@ -208,6 +233,7 @@ func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout. | |||
| 208 | 233 | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true}, | |
| 209 | 234 | panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 210 | 235 | theme: theme, | |
| 236 | + | styles: themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | |
| 211 | 237 | } | |
| 212 | 238 | } | |
| 213 | 239 | ||
| @@ -221,6 +247,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured | |||
| 221 | 247 | reset: reset, | |
| 222 | 248 | layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true}, | |
| 223 | 249 | panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 250 | + | styles: resolveStyles(nil), | |
| 224 | 251 | } | |
| 225 | 252 | } | |
| 226 | 253 | ||
| @@ -241,7 +268,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState { | |||
| 241 | 268 | func cacheBarState(bottom string, reset bool) capturedRenderState { | |
| 242 | 269 | pane := cachePaneFrame(6, 1, 'P') | |
| 243 | 270 | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}} | |
| 244 | - | return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}} | |
| 271 | + | return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}, styles: resolveStyles(nil)} | |
| 245 | 272 | } | |
| 246 | 273 | ||
| 247 | 274 | func cachePaneFrame(width, height int, r rune) renderer.Frame { | |
| @@ -263,6 +290,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||
| 263 | 290 | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 264 | 291 | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 265 | 292 | bars: barState{topRight: title, bottomRight: title}, | |
| 293 | + | styles: resolveStyles(nil), | |
| 266 | 294 | } | |
| 267 | 295 | } | |
| 268 | 296 | ||
M internal/usecase/daemon/render_pipeline_test.go +29 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | 15 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/ui" | |
| 18 | 19 | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 20 | "github.com/bnema/vev/pkg/vt" | |
| @@ -223,6 +224,7 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||
| 223 | 224 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | |
| 224 | 225 | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}}, | |
| 225 | 226 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | |
| 227 | + | styles: resolveStyles(nil), | |
| 226 | 228 | } | |
| 227 | 229 | composed := composeFrame(state, composeCacheInput{}) | |
| 228 | 230 | require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame)) | |
| @@ -290,3 +292,30 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) { | |||
| 290 | 292 | }) | |
| 291 | 293 | } | |
| 292 | 294 | } | |
| 295 | + | ||
| 296 | + | func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |
| 297 | + | muted := renderer.Style{Foreground: 1, Background: 2} | |
| 298 | + | active := renderer.Style{Foreground: 3, Background: 4} | |
| 299 | + | interior := renderer.Style{Foreground: 5, Background: 6} | |
| 300 | + | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | |
| 301 | + | ||
| 302 | + | for _, tt := range []struct { | |
| 303 | + | name string | |
| 304 | + | focused bool | |
| 305 | + | border renderer.Style | |
| 306 | + | }{ | |
| 307 | + | {name: "unfocused", focused: false, border: muted}, | |
| 308 | + | {name: "focused", focused: true, border: active}, | |
| 309 | + | } { | |
| 310 | + | t.Run(tt.name, func(t *testing.T) { | |
| 311 | + | state := capturedRenderState{ | |
| 312 | + | styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active}, | |
| 313 | + | overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}}, | |
| 314 | + | } | |
| 315 | + | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{}) | |
| 316 | + | bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8}) | |
| 317 | + | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | |
| 318 | + | require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role") | |
| 319 | + | }) | |
| 320 | + | } | |
| 321 | + | } | |
M internal/usecase/daemon/render_test.go +4 -4
| @@ -30,7 +30,7 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||
| 30 | 30 | defer release() | |
| 31 | 31 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 32 | 32 | theme := backdropTheme() | |
| 33 | - | ac.setTheme(theme) | |
| 33 | + | ac.setThemeForTest(theme) | |
| 34 | 34 | client := vt.NewScreen(80, 25) | |
| 35 | 35 | pane := sess.tabs[0].focusedPane() | |
| 36 | 36 | pane.screen.Write([]byte("\x1b[38;2;180;90;30mX")) | |
| @@ -96,7 +96,7 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |||
| 96 | 96 | p, release := newBlockingPTY(t) | |
| 97 | 97 | defer release() | |
| 98 | 98 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 99 | - | ac.setTheme(backdropTheme()) | |
| 99 | + | ac.setThemeForTest(backdropTheme()) | |
| 100 | 100 | client := vt.NewScreen(80, 25) | |
| 101 | 101 | pane := sess.tabs[0].focusedPane() | |
| 102 | 102 | pane.screen.Write([]byte("X")) | |
| @@ -117,7 +117,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 117 | 117 | p, release := newBlockingPTY(t) | |
| 118 | 118 | defer release() | |
| 119 | 119 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 120 | - | ac.setTheme(backdropTheme()) | |
| 120 | + | ac.setThemeForTest(backdropTheme()) | |
| 121 | 121 | client := vt.NewScreen(80, 25) | |
| 122 | 122 | pane := sess.tabs[0].focusedPane() | |
| 123 | 123 | pane.screen.Write([]byte("X")) | |
| @@ -1178,7 +1178,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1178 | 1178 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1179 | 1179 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1180 | 1180 | ||
| 1181 | - | frame, damage := composeCopyClientFrame(mode, target, base, barState{}) | |
| 1181 | + | frame, damage := composeCopyClientFrame(mode, target, base, resolveStyles(nil)) | |
| 1182 | 1182 | ||
| 1183 | 1183 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 1184 | 1184 | require.Equal(t, "##ab ", rowText(frame.Row(3))) | |
M internal/usecase/daemon/status.go +17 -20
| @@ -45,17 +45,17 @@ func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) { | |||
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) { | |
| 48 | - | clearStatusRow(row) | |
| 48 | + | clearStatusRow(row, styles.SurfaceBar) | |
| 49 | 49 | x := 0 | |
| 50 | 50 | labels := fitTabLabels(status.tabs, len(row), topRight) | |
| 51 | 51 | for i, w := range status.tabs { | |
| 52 | - | baseStyle := styles.StatusBar | |
| 53 | - | nameStyle := styles.TabName | |
| 54 | - | titleStyle := styles.TabTitle | |
| 52 | + | baseStyle := styles.SurfaceInactive | |
| 53 | + | nameStyle := styles.TabInactive | |
| 54 | + | titleStyle := styles.TabInactiveTitle | |
| 55 | 55 | if w.active { | |
| 56 | - | baseStyle = styles.Accent | |
| 57 | - | nameStyle = styles.TabNameActive | |
| 58 | - | titleStyle = styles.TabTitleActive | |
| 56 | + | baseStyle = styles.SurfaceActive | |
| 57 | + | nameStyle = styles.TabActive | |
| 58 | + | titleStyle = styles.TabActiveTitle | |
| 59 | 59 | } | |
| 60 | 60 | label := labels[i] | |
| 61 | 61 | writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle) | |
| @@ -66,30 +66,30 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t | |||
| 66 | 66 | writeStatusText(row, &x, label.text[label.nameLen:], titleStyle) | |
| 67 | 67 | writeStatusText(row, &x, " ", baseStyle) | |
| 68 | 68 | } | |
| 69 | - | drawRightPlainText(row, topRight, x, styles.StatusBar) | |
| 69 | + | drawRightPlainText(row, topRight, x, styles.SurfaceBar) | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) { | |
| 73 | - | clearStatusRow(row) | |
| 73 | + | clearStatusRow(row, styles.SurfaceBar) | |
| 74 | 74 | x := 0 | |
| 75 | 75 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | |
| 76 | - | writeStatusText(row, &x, " "+state.status.session+" ", styles.Accent) | |
| 76 | + | writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive) | |
| 77 | 77 | if state.rankedRecent != nil { | |
| 78 | 78 | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | |
| 79 | - | style := styles.StatusBar // contextual ranks deliberately do not fade. | |
| 79 | + | style := styles.SurfaceInactive // contextual ranks deliberately do not fade. | |
| 80 | 80 | if sess.selected { | |
| 81 | - | style = styles.Accent | |
| 81 | + | style = styles.SurfaceActive | |
| 82 | 82 | } | |
| 83 | 83 | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |
| 84 | 84 | } | |
| 85 | 85 | } else { | |
| 86 | 86 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 87 | 87 | for i, sess := range fittedMRU { | |
| 88 | - | style := mruStyle(styles.StatusBar, state.theme, i, len(fittedMRU)) | |
| 88 | + | style := styles.MRUStyle(i, len(fittedMRU)) | |
| 89 | 89 | drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame) | |
| 90 | 90 | } | |
| 91 | 91 | } | |
| 92 | - | drawRightPlainText(row, rightText, x, styles.StatusBar) | |
| 92 | + | drawRightPlainText(row, rightText, x, styles.SurfaceBar) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | type rankedRecent struct { | |
| @@ -147,9 +147,10 @@ func statusTextWidth(text string) int { | |||
| 147 | 147 | return width | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | - | func clearStatusRow(row []renderer.Cell) { | |
| 150 | + | func clearStatusRow(row []renderer.Cell, style renderer.Style) { | |
| 151 | + | blank := renderer.Cell{Rune: ' ', Style: style} | |
| 151 | 152 | for i := range row { | |
| 152 | - | row[i] = renderer.BlankCell() | |
| 153 | + | row[i] = blank | |
| 153 | 154 | } | |
| 154 | 155 | } | |
| 155 | 156 | ||
| @@ -426,10 +427,6 @@ func mruFutureRightReserve(rowLen int) int { | |||
| 426 | 427 | return reserve | |
| 427 | 428 | } | |
| 428 | 429 | ||
| 429 | - | func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style { | |
| 430 | - | return themeui.MRUFade(base, t, i, count) | |
| 431 | - | } | |
| 432 | - | ||
| 433 | 430 | // writeBell draws the attention glyph on its visible beat, or a plain cell in | |
| 434 | 431 | // base's style on its blank beat, so the bell never leaves a bare | |
| 435 | 432 | // default-background hole in a themed bar. | |
M internal/usecase/daemon/status_test.go +117 -27
| @@ -408,7 +408,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 408 | 408 | win := sess.activeTab() | |
| 409 | 409 | win.focusedPane().screen.Resize(12, 2) | |
| 410 | 410 | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 411 | - | ac.setTheme(themeui.Theme{ | |
| 411 | + | ac.setThemeForTest(themeui.Theme{ | |
| 412 | 412 | Foreground: renderer.RGB{R: 220, G: 220, B: 220}, | |
| 413 | 413 | Background: renderer.RGB{R: 10, G: 20, B: 30}, | |
| 414 | 414 | HasFG: true, | |
| @@ -417,9 +417,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 417 | 417 | Known: true, | |
| 418 | 418 | }) | |
| 419 | 419 | ||
| 420 | - | bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()} | |
| 420 | + | applied := ac.getAppliedTheme() | |
| 421 | + | bars := barState{status: sess.statusSegments(true), theme: applied.Raw} | |
| 421 | 422 | composed := composeFrame(capturedRenderState{ | |
| 422 | - | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme, | |
| 423 | + | reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme, styles: applied.Resolved.Styles, styleGeneration: applied.Generation, | |
| 423 | 424 | }, composeCacheInput{}) | |
| 424 | 425 | out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage) | |
| 425 | 426 | ||
| @@ -437,9 +438,9 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) { | |||
| 437 | 438 | ||
| 438 | 439 | d.applyTheme(sess, ac, msg) | |
| 439 | 440 | ||
| 440 | - | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground) | |
| 441 | - | require.True(t, ac.getTheme().SchemeKnown) | |
| 442 | - | require.True(t, ac.getTheme().Light) | |
| 441 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getAppliedTheme().Raw.Foreground) | |
| 442 | + | require.True(t, ac.getAppliedTheme().Raw.SchemeKnown) | |
| 443 | + | require.True(t, ac.getAppliedTheme().Raw.Light) | |
| 443 | 444 | assertSessionDefaultColors(t, sess, renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}) | |
| 444 | 445 | assertSessionColorScheme(t, sess, true) | |
| 445 | 446 | } | |
| @@ -493,7 +494,7 @@ func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) { | |||
| 493 | 494 | TrueColor: true, SchemeKnown: true, Light: !tc.want.Light, | |
| 494 | 495 | }) | |
| 495 | 496 | ||
| 496 | - | require.Equal(t, tc.want, ac.getTheme()) | |
| 497 | + | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | |
| 497 | 498 | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| 498 | 499 | assertSessionColorScheme(t, sess, tc.want.Light) | |
| 499 | 500 | }) | |
| @@ -529,7 +530,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) { | |||
| 529 | 530 | ||
| 530 | 531 | ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 531 | 532 | ||
| 532 | - | require.Equal(t, tc.want, ac.getTheme()) | |
| 533 | + | require.Equal(t, tc.want, ac.getAppliedTheme().Raw) | |
| 533 | 534 | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| 534 | 535 | assertSessionColorScheme(t, sess, tc.want.Light) | |
| 535 | 536 | }) | |
| @@ -1199,28 +1200,22 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi | |||
| 1199 | 1200 | } | |
| 1200 | 1201 | ||
| 1201 | 1202 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |
| 1202 | - | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | |
| 1203 | - | styles := themeui.NewStyles(theme) | |
| 1204 | - | state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1203 | + | theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 1204 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 1205 | + | theme.Palette[10] = theme.Palette[2] | |
| 1206 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 1207 | + | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 1208 | + | state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 1205 | 1209 | row := make([]renderer.Cell, 16) | |
| 1206 | 1210 | ||
| 1207 | - | drawStatusBarState(row, state, styles) | |
| 1211 | + | drawStatusBarState(row, state, resolved.Styles) | |
| 1212 | + | ||
| 1213 | + | for index, cell := range []int{4, 7, 10} { | |
| 1214 | + | require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3))) | |
| 1215 | + | } | |
| 1216 | + | require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar") | |
| 1208 | 1217 | ||
| 1209 | - | firstFG := row[4].Style.ForegroundRGB.R | |
| 1210 | - | secondFG := row[7].Style.ForegroundRGB.R | |
| 1211 | - | thirdFG := row[10].Style.ForegroundRGB.R | |
| 1212 | - | require.Equal(t, styles.StatusBar.ForegroundRGB.R, firstFG) | |
| 1213 | - | require.Greater(t, firstFG, secondFG) | |
| 1214 | - | require.Greater(t, secondFG, thirdFG) | |
| 1215 | - | ||
| 1216 | - | firstBG := row[4].Style.BackgroundRGB.R | |
| 1217 | - | secondBG := row[7].Style.BackgroundRGB.R | |
| 1218 | - | thirdBG := row[10].Style.BackgroundRGB.R | |
| 1219 | - | require.Equal(t, styles.StatusBar.BackgroundRGB.R, firstBG) | |
| 1220 | - | require.Greater(t, firstBG, secondBG) | |
| 1221 | - | require.Greater(t, secondBG, thirdBG) | |
| 1222 | - | ||
| 1223 | - | plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3) | |
| 1218 | + | plain := resolveStyles(nil).MRUStyle(1, 3) | |
| 1224 | 1219 | require.False(t, plain.HasForegroundRGB) | |
| 1225 | 1220 | require.False(t, plain.HasBackgroundRGB) | |
| 1226 | 1221 | } | |
| @@ -1402,6 +1397,101 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) { | |||
| 1402 | 1397 | } | |
| 1403 | 1398 | } | |
| 1404 | 1399 | ||
| 1400 | + | func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |
| 1401 | + | theme := themeui.Theme{ | |
| 1402 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, | |
| 1403 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 1404 | + | HasFG: true, | |
| 1405 | + | HasBG: true, | |
| 1406 | + | TrueColor: true, | |
| 1407 | + | Known: true, | |
| 1408 | + | UsePalette: true, | |
| 1409 | + | } | |
| 1410 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 1411 | + | theme.Palette[10] = theme.Palette[2] | |
| 1412 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 1413 | + | resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 1414 | + | styles := resolved.Styles | |
| 1415 | + | ||
| 1416 | + | t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) { | |
| 1417 | + | row := make([]renderer.Cell, 48) | |
| 1418 | + | status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}} | |
| 1419 | + | drawTopBarSnapshot(row, status, 0, "clock", styles) | |
| 1420 | + | ||
| 1421 | + | for _, cell := range row[28:40] { | |
| 1422 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar") | |
| 1423 | + | } | |
| 1424 | + | require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive") | |
| 1425 | + | require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text") | |
| 1426 | + | selected := strings.Index(rowText(row), "selected") | |
| 1427 | + | require.GreaterOrEqual(t, selected, 0) | |
| 1428 | + | require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling") | |
| 1429 | + | require.True(t, row[selected].Style.Bold, "active tab stays bold") | |
| 1430 | + | }) | |
| 1431 | + | ||
| 1432 | + | t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) { | |
| 1433 | + | row := make([]renderer.Cell, 48) | |
| 1434 | + | state := barState{ | |
| 1435 | + | status: statusSnapshot{session: "current"}, | |
| 1436 | + | bottomRight: "script ok", | |
| 1437 | + | mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}}, | |
| 1438 | + | } | |
| 1439 | + | drawStatusBarState(row, state, styles) | |
| 1440 | + | for i := range len(" current ") { | |
| 1441 | + | require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive") | |
| 1442 | + | } | |
| 1443 | + | for index, name := range []string{"new", "middle", "old"} { | |
| 1444 | + | at := strings.Index(rowText(row), name) | |
| 1445 | + | require.GreaterOrEqual(t, at, 0) | |
| 1446 | + | require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name) | |
| 1447 | + | } | |
| 1448 | + | for _, cell := range row[30:38] { | |
| 1449 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar") | |
| 1450 | + | } | |
| 1451 | + | at := strings.Index(rowText(row), "script ok") | |
| 1452 | + | require.GreaterOrEqual(t, at, 0) | |
| 1453 | + | require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar") | |
| 1454 | + | ||
| 1455 | + | ranked := make([]renderer.Cell, 48) | |
| 1456 | + | drawStatusBarState(ranked, barState{ | |
| 1457 | + | status: statusSnapshot{session: "current"}, | |
| 1458 | + | rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}}, | |
| 1459 | + | }, styles) | |
| 1460 | + | inactive := strings.Index(rowText(ranked), "inactive") | |
| 1461 | + | chosen := strings.Index(rowText(ranked), "chosen") | |
| 1462 | + | require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive") | |
| 1463 | + | require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling") | |
| 1464 | + | }) | |
| 1465 | + | ||
| 1466 | + | t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) { | |
| 1467 | + | empty := make([]renderer.Cell, 20) | |
| 1468 | + | drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles) | |
| 1469 | + | for _, cell := range empty[len(" current "):] { | |
| 1470 | + | require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar") | |
| 1471 | + | } | |
| 1472 | + | ||
| 1473 | + | single := make([]renderer.Cell, 20) | |
| 1474 | + | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles) | |
| 1475 | + | only := strings.Index(rowText(single), "only") | |
| 1476 | + | require.GreaterOrEqual(t, only, 0) | |
| 1477 | + | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | |
| 1478 | + | }) | |
| 1479 | + | ||
| 1480 | + | t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) { | |
| 1481 | + | row := make([]renderer.Cell, 32) | |
| 1482 | + | drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles) | |
| 1483 | + | bells := make([]int, 0, 2) | |
| 1484 | + | for i, cell := range row { | |
| 1485 | + | if cell.Rune == ui.AttentionGlyph { | |
| 1486 | + | bells = append(bells, i) | |
| 1487 | + | } | |
| 1488 | + | } | |
| 1489 | + | require.Len(t, bells, 2) | |
| 1490 | + | require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention") | |
| 1491 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface") | |
| 1492 | + | }) | |
| 1493 | + | } | |
| 1494 | + | ||
| 1405 | 1495 | func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |
| 1406 | 1496 | p1, releasePTY1 := newBlockingPTY(t) | |
| 1407 | 1497 | p2, releasePTY2 := newBlockingPTY(t) | |
A internal/usecase/daemon/theme_cache_test.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func cacheTheme() themeui.Theme { | |
| 16 | + | palette := [16]renderer.RGB{} | |
| 17 | + | palette[2] = renderer.RGB{R: 30, G: 190, B: 150} | |
| 18 | + | palette[10] = palette[2] | |
| 19 | + | return themeui.Theme{ | |
| 20 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 21 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 22 | + | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) { | |
| 27 | + | d, sess, ac, _ := newManualSessionWithPTYs(t) | |
| 28 | + | raw := cacheTheme() | |
| 29 | + | d.applyTheme(sess, ac, ports.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}) | |
| 30 | + | before := ac.getAppliedTheme() | |
| 31 | + | ||
| 32 | + | cfg := domain.Defaults() | |
| 33 | + | cfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2} | |
| 34 | + | d.ApplyConfig(cfg) | |
| 35 | + | after := ac.getAppliedTheme() | |
| 36 | + | ||
| 37 | + | require.Equal(t, before.Raw, after.Raw, "an accent-only reload retains the definitive client palette") | |
| 38 | + | require.Greater(t, after.Generation, before.Generation) | |
| 39 | + | require.Equal(t, themeui.Resolve(after.Raw, cfg.ThemeAccent), after.Resolved) | |
| 40 | + | require.Equal(t, uint8(2), after.Resolved.Accent.Slot) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestComposeCacheInvalidatesOnStyleGenerationWithoutPaneRecoloring(t *testing.T) { | |
| 44 | + | raw := cacheTheme() | |
| 45 | + | first := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 46 | + | second := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 47 | + | pane := renderer.NewFrame(4, 1) | |
| 48 | + | pane.Set(0, 0, renderer.Cell{Rune: 'P', Style: renderer.Style{Foreground: 196}}) | |
| 49 | + | state := capturedRenderState{ | |
| 50 | + | reset: false, theme: raw, styles: first.Styles, styleGeneration: 1, | |
| 51 | + | layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "p", valid: true, fingerprint: "same", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}}}, | |
| 52 | + | panes: []capturedPaneRenderState{{id: "p", frame: pane, focused: true, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 53 | + | } | |
| 54 | + | committed := composeFrame(state, composeCacheInput{}) | |
| 55 | + | state.reset, state.styles, state.styleGeneration, state.panes[0].damage = false, second.Styles, 2, nil | |
| 56 | + | out := composeFrame(state, committed.cache, composeCacheInput{}) | |
| 57 | + | ||
| 58 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage) | |
| 59 | + | require.Equal(t, pane.At(0, 0), out.frame.At(0, 1), "captured application cells must not receive chrome styles") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) { | |
| 63 | + | raw := cacheTheme() | |
| 64 | + | resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 65 | + | cell := renderer.Cell{Rune: 'X', Style: renderer.Style{Foreground: 196}} | |
| 66 | + | focused, inactive := renderer.NewFrame(2, 1), renderer.NewFrame(2, 1) | |
| 67 | + | focused.Set(0, 0, cell) | |
| 68 | + | inactive.Set(0, 0, cell) | |
| 69 | + | placements := []layout.Placement{{ID: "focused", Content: domain.Rect{Width: 2, Height: 1}}, {ID: "inactive", Content: domain.Rect{X: 2, Width: 2, Height: 1}}} | |
| 70 | + | state := capturedRenderState{ | |
| 71 | + | reset: true, theme: raw, styles: resolved.Styles, styleGeneration: 1, | |
| 72 | + | layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, focus: "focused", valid: true, placements: placements}, | |
| 73 | + | panes: []capturedPaneRenderState{{id: "focused", frame: focused, focused: true, placement: placements[0], damage: []renderer.Damage{renderer.FullRedraw()}}, {id: "inactive", frame: inactive, placement: placements[1], damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 74 | + | } | |
| 75 | + | out := composeFrame(state, composeCacheInput{}) | |
| 76 | + | require.Equal(t, cell, out.frame.At(0, 1)) | |
| 77 | + | require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |
| 81 | + | raw := cacheTheme() | |
| 82 | + | resolved := themeui.Resolve(raw, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 83 | + | neutral := renderer.DefaultStyle() | |
| 84 | + | neutral.HasForegroundRGB = true | |
| 85 | + | neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40) | |
| 86 | + | want := themeui.NewDimmer(raw).Dim(neutral) | |
| 87 | + | accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted) | |
| 88 | + | ||
| 89 | + | split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw) | |
| 90 | + | split.styles = resolved.Styles | |
| 91 | + | splitOut := composeFrame(split, composeCacheInput{}) | |
| 92 | + | require.Equal(t, 'L', splitOut.frame.At(0, 1).Rune, "split geometry and pane content must remain unchanged") | |
| 93 | + | require.Equal(t, 'R', splitOut.frame.At(21, 1).Rune, "split geometry and pane content must remain unchanged") | |
| 94 | + | require.Equal(t, want, splitOut.frame.At(20, 1).Style, "divider must dim a neutral input") | |
| 95 | + | require.NotEqual(t, accentDerived, splitOut.frame.At(20, 1).Style, "divider must exclude accent-ramp styles from Dimmer") | |
| 96 | + | ||
| 97 | + | titles := cachedStackTitleState("inactive", 1, true) | |
| 98 | + | titles.theme, titles.styles = raw, resolved.Styles | |
| 99 | + | titleOut := composeFrame(titles, composeCacheInput{}) | |
| 100 | + | require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged") | |
| 101 | + | require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input") | |
| 102 | + | require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer") | |
| 103 | + | require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged") | |
| 104 | + | } | |
A internal/usecase/daemon/theme_integration_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme { | |
| 15 | + | out := ports.Theme{ | |
| 16 | + | Foreground: foreground, Background: background, | |
| 17 | + | HasForeground: true, HasBackground: true, TrueColor: true, | |
| 18 | + | SchemeKnown: true, | |
| 19 | + | } | |
| 20 | + | for slot, color := range palette { | |
| 21 | + | out.Palette[slot] = color | |
| 22 | + | out.PaletteKnown |= 1 << slot | |
| 23 | + | } | |
| 24 | + | return out | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func chromeStyleSnapshot(t *testing.T, styles themeui.Styles) []byte { | |
| 28 | + | t.Helper() | |
| 29 | + | frame := renderer.NewFrame(6, 1) | |
| 30 | + | for x, style := range []renderer.Style{ | |
| 31 | + | styles.SurfaceBar, styles.SurfaceInactive, styles.SurfaceRecent, | |
| 32 | + | styles.SurfaceActive, styles.BorderMuted, styles.BorderActive, | |
| 33 | + | } { | |
| 34 | + | frame.Set(x, 0, renderer.Cell{Rune: rune('A' + x), Style: style}) | |
| 35 | + | } | |
| 36 | + | out, err := renderer.New(renderer.Capabilities{}).Draw(frame, []renderer.Damage{renderer.FullRedraw()}) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | return out | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestAccentLifecycleGenerationDaemonResolutionAndReload(t *testing.T) { | |
| 42 | + | p, release := newBlockingPTY(t) | |
| 43 | + | defer release() | |
| 44 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 45 | + | ||
| 46 | + | oldForeground, oldBackground := renderer.RGB{R: 230, G: 230, B: 230}, renderer.RGB{R: 8, G: 9, B: 10} | |
| 47 | + | old := lifecycleTheme(oldForeground, oldBackground, map[uint8]renderer.RGB{ | |
| 48 | + | 4: {R: 108, G: 155, B: 217}, 12: {R: 108, G: 155, B: 217}, | |
| 49 | + | }) | |
| 50 | + | d.applyTheme(sess, ac, old) | |
| 51 | + | beforeClear := ac.getAppliedTheme() | |
| 52 | + | require.True(t, beforeClear.Resolved.Accent.Known) | |
| 53 | + | require.Equal(t, uint8(4), beforeClear.Resolved.Accent.Slot) | |
| 54 | + | ||
| 55 | + | // A replacement generation's first publication retains pane defaults but | |
| 56 | + | // clears every palette bit before daemon style resolution. | |
| 57 | + | cleared := old | |
| 58 | + | cleared.PaletteKnown = 0 | |
| 59 | + | cleared.Palette = [16]renderer.RGB{} | |
| 60 | + | cleared.Light = true | |
| 61 | + | d.applyTheme(sess, ac, cleared) | |
| 62 | + | clearedApplied := ac.getAppliedTheme() | |
| 63 | + | require.Equal(t, oldForeground, clearedApplied.Raw.Foreground) | |
| 64 | + | require.Equal(t, oldBackground, clearedApplied.Raw.Background) | |
| 65 | + | require.True(t, clearedApplied.Raw.Light) | |
| 66 | + | require.Zero(t, clearedApplied.Raw.PaletteKnown) | |
| 67 | + | require.False(t, clearedApplied.Resolved.Accent.Known) | |
| 68 | + | require.Equal(t, beforeClear.Generation+1, clearedApplied.Generation) | |
| 69 | + | assertSessionDefaultColors(t, sess, oldForeground, oldBackground) | |
| 70 | + | ||
| 71 | + | newForeground, newBackground := renderer.RGB{R: 240, G: 240, B: 240}, renderer.RGB{R: 12, G: 13, B: 14} | |
| 72 | + | teal := renderer.RGB{R: 125, G: 181, B: 181} | |
| 73 | + | definitive := lifecycleTheme(newForeground, newBackground, map[uint8]renderer.RGB{ | |
| 74 | + | 2: teal, 10: teal, 14: teal, | |
| 75 | + | 4: {R: 108, G: 155, B: 217}, 12: {R: 108, G: 155, B: 217}, | |
| 76 | + | }) | |
| 77 | + | d.applyTheme(sess, ac, definitive) | |
| 78 | + | resolved := ac.getAppliedTheme() | |
| 79 | + | require.Equal(t, clearedApplied.Generation+1, resolved.Generation) | |
| 80 | + | require.True(t, resolved.Resolved.Accent.Known) | |
| 81 | + | require.Equal(t, uint8(2), resolved.Resolved.Accent.Slot) | |
| 82 | + | require.Equal(t, teal, resolved.Resolved.Accent.RGB) | |
| 83 | + | assertSessionDefaultColors(t, sess, newForeground, newBackground) | |
| 84 | + | ||
| 85 | + | // Policy-only reloads resolve the retained definitive snapshot: no client | |
| 86 | + | // query or intermediate Theme publication is involved. | |
| 87 | + | explicit := domain.Defaults() | |
| 88 | + | explicit.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 4} | |
| 89 | + | d.ApplyConfig(explicit) | |
| 90 | + | reloaded := ac.getAppliedTheme() | |
| 91 | + | require.Equal(t, resolved.Raw, reloaded.Raw) | |
| 92 | + | require.Greater(t, reloaded.Generation, resolved.Generation) | |
| 93 | + | require.Equal(t, uint8(4), reloaded.Resolved.Accent.Slot) | |
| 94 | + | require.Equal(t, definitive.Palette, ac.getClientTheme().Palette) | |
| 95 | + | ||
| 96 | + | unknown := explicit | |
| 97 | + | unknown.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 6} | |
| 98 | + | d.ApplyConfig(unknown) | |
| 99 | + | require.False(t, ac.getAppliedTheme().Resolved.Accent.Known, "an explicit unknown slot must not substitute another slot") | |
| 100 | + | ||
| 101 | + | paletteOff := explicit | |
| 102 | + | paletteOff.ThemePalette = false | |
| 103 | + | d.ApplyConfig(paletteOff) | |
| 104 | + | off := ac.getAppliedTheme() | |
| 105 | + | require.False(t, off.Raw.UsePalette) | |
| 106 | + | require.False(t, off.Resolved.Accent.Known) | |
| 107 | + | assertSessionDefaultColors(t, sess, newForeground, newBackground) | |
| 108 | + | ||
| 109 | + | for _, tc := range []struct { | |
| 110 | + | name string | |
| 111 | + | mode domain.ThemeMode | |
| 112 | + | want themeui.Theme | |
| 113 | + | }{ | |
| 114 | + | {name: "forced dark", mode: domain.ThemeDark, want: themeui.BuiltinDark}, | |
| 115 | + | {name: "forced light", mode: domain.ThemeLight, want: themeui.BuiltinLight}, | |
| 116 | + | } { | |
| 117 | + | t.Run(tc.name, func(t *testing.T) { | |
| 118 | + | cfg := domain.Defaults() | |
| 119 | + | cfg.Theme = tc.mode | |
| 120 | + | d.ApplyConfig(cfg) | |
| 121 | + | applied := ac.getAppliedTheme() | |
| 122 | + | require.Equal(t, tc.want, applied.Raw) | |
| 123 | + | require.False(t, applied.Resolved.Accent.Known) | |
| 124 | + | assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background) | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // A timeout-finalized partial report is still one definitive daemon apply; | |
| 129 | + | // an unavailable explicit slot remains neutral. | |
| 130 | + | partialCfg := domain.Defaults() | |
| 131 | + | partialCfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 6} | |
| 132 | + | d.ApplyConfig(partialCfg) | |
| 133 | + | beforePartial := ac.getAppliedTheme() | |
| 134 | + | partial := lifecycleTheme(newForeground, newBackground, map[uint8]renderer.RGB{2: teal}) | |
| 135 | + | d.applyTheme(sess, ac, partial) | |
| 136 | + | partialApplied := ac.getAppliedTheme() | |
| 137 | + | require.Equal(t, beforePartial.Generation+1, partialApplied.Generation) | |
| 138 | + | require.Equal(t, uint16(1<<2), ac.getClientTheme().PaletteKnown) | |
| 139 | + | require.False(t, partialApplied.Resolved.Accent.Known) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) { | |
| 143 | + | raw := lifecycleTheme( | |
| 144 | + | renderer.RGB{R: 230, G: 230, B: 230}, renderer.RGB{R: 8, G: 9, B: 10}, | |
| 145 | + | map[uint8]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}}, | |
| 146 | + | ) | |
| 147 | + | tests := []struct { | |
| 148 | + | name string | |
| 149 | + | config domain.Config | |
| 150 | + | want string | |
| 151 | + | }{ | |
| 152 | + | { | |
| 153 | + | name: "palette off", | |
| 154 | + | config: func() domain.Config { | |
| 155 | + | cfg := domain.Defaults() | |
| 156 | + | cfg.ThemePalette = false | |
| 157 | + | return cfg | |
| 158 | + | }(), | |
| 159 | + | want: "\x1b[1;1HABC\x1b[0;7mD\x1b[0mEF\x1b[0m", | |
| 160 | + | }, | |
| 161 | + | { | |
| 162 | + | name: "forced dark", | |
| 163 | + | config: domain.Config{Theme: domain.ThemeDark}, | |
| 164 | + | want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;216;216;216;48;2;72;72;72mD\x1b[0;38;2;139;139;139mEF\x1b[0m", | |
| 165 | + | }, | |
| 166 | + | { | |
| 167 | + | name: "forced light", | |
| 168 | + | config: domain.Config{Theme: domain.ThemeLight}, | |
| 169 | + | want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;32;32;32;48;2;194;194;194mD\x1b[0;38;2;118;118;118mEF\x1b[0m", | |
| 170 | + | }, | |
| 171 | + | } | |
| 172 | + | for _, tt := range tests { | |
| 173 | + | t.Run(tt.name, func(t *testing.T) { | |
| 174 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 175 | + | d.ApplyConfig(tt.config) | |
| 176 | + | got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{ | |
| 177 | + | Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown, | |
| 178 | + | HasFG: true, HasBG: true, TrueColor: true, | |
| 179 | + | }).Resolved.Styles) | |
| 180 | + | require.Equal(t, []byte(tt.want), got) | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | } | |
M internal/usecase/daemon/theme_resolve.go +6 -1
| @@ -13,6 +13,11 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | 15 | func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 16 | + | // Resolve while applying, never while composing. The theme mutex only | |
| 17 | + | // publishes the completed value and is not held across session/tab/pane | |
| 18 | + | // locks, preserving the daemon lock order. | |
| 19 | + | applied := d.resolveAppliedTheme(t) | |
| 20 | + | t = applied.Raw | |
| 16 | 21 | sess.mu.Lock() | |
| 17 | 22 | if ac != nil { | |
| 18 | 23 | if sess.client != ac { | |
| @@ -27,7 +32,7 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||
| 27 | 32 | sess.mu.Unlock() | |
| 28 | 33 | ||
| 29 | 34 | if ac != nil { | |
| 30 | - | ac.setTheme(t) | |
| 35 | + | ac.setAppliedTheme(applied) | |
| 31 | 36 | } | |
| 32 | 37 | for _, tb := range tabs { | |
| 33 | 38 | tb.mu.Lock() | |
M internal/usecase/palette/model.go +11 -3
| @@ -11,6 +11,10 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | type RenderStyles struct { | |
| 14 | + | // Base fills unused modal interior cells; Row is the ordinary inactive | |
| 15 | + | // result surface. They are separate to retain the chrome hierarchy. | |
| 16 | + | Base renderer.Style | |
| 17 | + | Row renderer.Style | |
| 14 | 18 | Selection renderer.Style | |
| 15 | 19 | Description renderer.Style | |
| 16 | 20 | } | |
| @@ -53,7 +57,8 @@ func DefaultRenderStyles() RenderStyles { | |||
| 53 | 57 | selection.Inverse = true | |
| 54 | 58 | description := renderer.DefaultStyle() | |
| 55 | 59 | description.Italic = true | |
| 56 | - | return RenderStyles{Selection: selection, Description: description} | |
| 60 | + | base := renderer.DefaultStyle() | |
| 61 | + | return RenderStyles{Base: base, Row: base, Selection: selection, Description: description} | |
| 57 | 62 | } | |
| 58 | 63 | ||
| 59 | 64 | func (m *Model) Insert(r rune) { | |
| @@ -235,11 +240,14 @@ func (m *Model) clamp() { | |||
| 235 | 240 | ||
| 236 | 241 | func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |
| 237 | 242 | styles := opts.Styles | |
| 243 | + | if styles == (RenderStyles{}) { | |
| 244 | + | styles = DefaultRenderStyles() | |
| 245 | + | } | |
| 238 | 246 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 239 | 247 | if frame.Width == 0 || frame.Height == 0 { | |
| 240 | 248 | return frame | |
| 241 | 249 | } | |
| 242 | - | base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description | |
| 250 | + | base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description | |
| 243 | 251 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 244 | 252 | if m == nil { | |
| 245 | 253 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| @@ -269,7 +277,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 269 | 277 | break | |
| 270 | 278 | } | |
| 271 | 279 | match := m.matches[idx] | |
| 272 | - | style := base | |
| 280 | + | style := row | |
| 273 | 281 | if idx == m.selected { | |
| 274 | 282 | style = selection | |
| 275 | 283 | } | |
M internal/usecase/palette/model_test.go +66 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 10 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| 12 | 13 | ) | |
| @@ -270,6 +271,59 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 270 | 271 | } | |
| 271 | 272 | } | |
| 272 | 273 | ||
| 274 | + | func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) { | |
| 275 | + | paletteColors := [16]renderer.RGB{} | |
| 276 | + | paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 277 | + | paletteColors[10] = paletteColors[2] | |
| 278 | + | accentTheme := themeui.Theme{ | |
| 279 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 280 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 281 | + | Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10, | |
| 282 | + | } | |
| 283 | + | indexedTheme := accentTheme | |
| 284 | + | indexedTheme.TrueColor = false | |
| 285 | + | paletteOffTheme := accentTheme | |
| 286 | + | paletteOffTheme.UsePalette = false | |
| 287 | + | neutralTheme := paletteOffTheme | |
| 288 | + | neutralTheme.PaletteKnown = 0 | |
| 289 | + | ||
| 290 | + | tests := []struct { | |
| 291 | + | name string | |
| 292 | + | theme themeui.Theme | |
| 293 | + | policy domain.ThemeAccent | |
| 294 | + | }{ | |
| 295 | + | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 296 | + | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 297 | + | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 298 | + | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 299 | + | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 300 | + | {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | for _, tt := range tests { | |
| 304 | + | t.Run(tt.name, func(t *testing.T) { | |
| 305 | + | styles := themeui.Resolve(tt.theme, tt.policy).Styles | |
| 306 | + | model := New(CommandResults([]command.Command{ | |
| 307 | + | cmd("ONE", "One", "first description"), | |
| 308 | + | cmd("TWO", "Two", "second description"), | |
| 309 | + | })) | |
| 310 | + | model.Down() | |
| 311 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{ | |
| 312 | + | Base: styles.PickerBase, Row: styles.SurfaceInactive, | |
| 313 | + | Selection: styles.PickerSelection, Description: styles.PickerDescription, | |
| 314 | + | }}) | |
| 315 | + | description := frame.At(4, 1).Style | |
| 316 | + | require.Equal(t, styles.SurfaceInactive.Background, description.Background) | |
| 317 | + | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB) | |
| 318 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB) | |
| 319 | + | if tt.name == "indexed only" { | |
| 320 | + | require.Equal(t, 2, description.Foreground) | |
| 321 | + | require.False(t, description.HasBackgroundRGB) | |
| 322 | + | } | |
| 323 | + | }) | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 273 | 327 | func TestModelCompleteSelected(t *testing.T) { | |
| 274 | 328 | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 275 | 329 | jrs.Arguments = command.ArgumentsRequired | |
| @@ -400,3 +454,15 @@ func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |||
| 400 | 454 | t.Fatalf("command row = %q, want contextual guidance without a feedback row", got) | |
| 401 | 455 | } | |
| 402 | 456 | } | |
| 457 | + | ||
| 458 | + | func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) { | |
| 459 | + | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) | |
| 460 | + | base := renderer.Style{Foreground: 1, Background: 2} | |
| 461 | + | row := renderer.Style{Foreground: 3, Background: 4} | |
| 462 | + | selection := renderer.Style{Foreground: 5, Background: 6} | |
| 463 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}}) | |
| 464 | + | ||
| 465 | + | require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base") | |
| 466 | + | require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface") | |
| 467 | + | require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface") | |
| 468 | + | } | |
M internal/usecase/picker/model.go +7 -3
| @@ -42,8 +42,11 @@ type RenderStyles struct { | |||
| 42 | 42 | SelectionMuted renderer.Style // selected row detail segment | |
| 43 | 43 | Name renderer.Style // non-selected name segment | |
| 44 | 44 | Detail renderer.Style // non-selected detail segment | |
| 45 | - | Base renderer.Style // non-selected fill + suffixes | |
| 46 | - | Separator renderer.Style // preview separator | |
| 45 | + | // Background fills otherwise unused interior cells. Base owns ordinary | |
| 46 | + | // rows, preserving a distinct inactive surface from modal chrome. | |
| 47 | + | Background renderer.Style | |
| 48 | + | Base renderer.Style // non-selected row fill + suffixes | |
| 49 | + | Separator renderer.Style // preview separator | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | func defaultRenderStyles() RenderStyles { | |
| @@ -52,7 +55,7 @@ func defaultRenderStyles() RenderStyles { | |||
| 52 | 55 | base := renderer.DefaultStyle() | |
| 53 | 56 | separator := renderer.DefaultStyle() | |
| 54 | 57 | separator.Attrs = renderer.AttrDim | |
| 55 | - | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator} | |
| 58 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator} | |
| 56 | 59 | } | |
| 57 | 60 | ||
| 58 | 61 | type Target struct { | |
| @@ -203,6 +206,7 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle | |||
| 203 | 206 | if len(styles) > 0 { | |
| 204 | 207 | styleSet = styles[0] | |
| 205 | 208 | } | |
| 209 | + | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background}) | |
| 206 | 210 | m.renderList(frame, layout.List, styleSet) | |
| 207 | 211 | switch layout.Mode { | |
| 208 | 212 | case LayoutHorizontal: | |
M internal/usecase/picker/model_test.go +79 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -332,3 +333,81 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||
| 332 | 333 | func cell(r rune) renderer.Cell { | |
| 333 | 334 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 334 | 335 | } | |
| 336 | + | ||
| 337 | + | func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |
| 338 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 339 | + | background := renderer.Style{Foreground: 1, Background: 2} | |
| 340 | + | base := renderer.Style{Foreground: 3, Background: 4} | |
| 341 | + | selection := renderer.Style{Foreground: 5, Background: 6} | |
| 342 | + | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{ | |
| 343 | + | Background: background, Base: base, Name: base, Detail: base, | |
| 344 | + | Selection: selection, SelectionName: selection, SelectionMuted: selection, | |
| 345 | + | Separator: base, | |
| 346 | + | }) | |
| 347 | + | ||
| 348 | + | require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base") | |
| 349 | + | require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface") | |
| 350 | + | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func TestPickerDetailKeepsInactiveSurfaceAcrossAccentFallbacks(t *testing.T) { | |
| 354 | + | palette := [16]renderer.RGB{} | |
| 355 | + | palette[2] = renderer.RGB{R: 10, G: 230, B: 120} | |
| 356 | + | palette[10] = palette[2] | |
| 357 | + | accentTheme := themeui.Theme{ | |
| 358 | + | Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 359 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 360 | + | Palette: palette, PaletteKnown: 1<<2 | 1<<10, | |
| 361 | + | } | |
| 362 | + | indexedTheme := accentTheme | |
| 363 | + | indexedTheme.TrueColor = false | |
| 364 | + | paletteOffTheme := accentTheme | |
| 365 | + | paletteOffTheme.UsePalette = false | |
| 366 | + | neutralTheme := accentTheme | |
| 367 | + | neutralTheme.UsePalette = false | |
| 368 | + | neutralTheme.PaletteKnown = 0 | |
| 369 | + | ||
| 370 | + | tests := []struct { | |
| 371 | + | name string | |
| 372 | + | theme themeui.Theme | |
| 373 | + | policy domain.ThemeAccent | |
| 374 | + | }{ | |
| 375 | + | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 376 | + | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 377 | + | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 378 | + | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 379 | + | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 380 | + | {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | model := New([]SessionView{ | |
| 384 | + | {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0}, | |
| 385 | + | {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0}, | |
| 386 | + | }, "selected", 0) | |
| 387 | + | for _, tt := range tests { | |
| 388 | + | t.Run(tt.name, func(t *testing.T) { | |
| 389 | + | styles := themeui.Resolve(tt.theme, tt.policy).Styles | |
| 390 | + | require.Equal(t, styles.SurfaceInactive.Background, styles.PickerDescription.Background) | |
| 391 | + | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, styles.PickerDescription.HasBackgroundRGB) | |
| 392 | + | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, styles.PickerDescription.BackgroundRGB) | |
| 393 | + | require.Equal(t, styles.SurfaceBar.Background, styles.PickerSeparator.Background) | |
| 394 | + | require.Equal(t, styles.SurfaceBar.HasBackgroundRGB, styles.PickerSeparator.HasBackgroundRGB) | |
| 395 | + | require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.PickerSeparator.BackgroundRGB) | |
| 396 | + | if tt.name == "indexed only" { | |
| 397 | + | require.Equal(t, 2, styles.PickerDescription.Foreground) | |
| 398 | + | require.Equal(t, 2, styles.PickerSeparator.Foreground) | |
| 399 | + | require.False(t, styles.PickerDescription.HasBackgroundRGB) | |
| 400 | + | require.False(t, styles.PickerSeparator.HasBackgroundRGB) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{ | |
| 404 | + | Background: styles.PickerBase, Base: styles.SurfaceInactive, Name: styles.SurfaceInactive, | |
| 405 | + | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | |
| 406 | + | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | |
| 407 | + | Separator: styles.PickerSeparator, | |
| 408 | + | }) | |
| 409 | + | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text must retain its contrast-derived foreground and inactive background") | |
| 410 | + | require.True(t, frame.At(31, 3).Style.Equal(styles.SurfaceInactive), "unused row cells must retain the inactive surface") | |
| 411 | + | }) | |
| 412 | + | } | |
| 413 | + | } | |
M internal/usecase/prompt/model.go +22 -9
| @@ -6,6 +6,14 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | // RenderStyles contains daemon-supplied semantic chrome roles. The zero value | |
| 10 | + | // is intentionally not used by RenderStyled; isolated Render callers retain | |
| 11 | + | // the historic neutral defaults. | |
| 12 | + | type RenderStyles struct { | |
| 13 | + | Base renderer.Style | |
| 14 | + | Selection renderer.Style | |
| 15 | + | } | |
| 16 | + | ||
| 9 | 17 | type Model struct { | |
| 10 | 18 | title string | |
| 11 | 19 | text ui.TextInput | |
| @@ -55,20 +63,25 @@ func (m *Model) SetError(msg string) { | |||
| 55 | 63 | } | |
| 56 | 64 | ||
| 57 | 65 | func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame { | |
| 66 | + | base := renderer.DefaultStyle() | |
| 67 | + | selection := base | |
| 68 | + | selection.Inverse = true | |
| 69 | + | if len(accentStyle) > 0 { | |
| 70 | + | selection = accentStyle[0] | |
| 71 | + | } | |
| 72 | + | return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection}) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // RenderStyled renders with explicit chrome roles captured by the daemon. | |
| 76 | + | func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame { | |
| 58 | 77 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 59 | 78 | if frame.Width == 0 || frame.Height == 0 { | |
| 60 | 79 | return frame | |
| 61 | 80 | } | |
| 62 | - | base := renderer.DefaultStyle() | |
| 63 | - | accent := base | |
| 64 | - | accent.Inverse = true | |
| 65 | - | if len(accentStyle) > 0 { | |
| 66 | - | accent = accentStyle[0] | |
| 67 | - | } | |
| 68 | - | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 69 | - | ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent) | |
| 81 | + | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base}) | |
| 82 | + | ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection) | |
| 70 | 83 | if frame.Height > 1 && m != nil && m.errMsg != "" { | |
| 71 | - | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent) | |
| 84 | + | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection) | |
| 72 | 85 | } | |
| 73 | 86 | return frame | |
| 74 | 87 | } | |
M internal/usecase/prompt/model_test.go +10 -0
| @@ -57,3 +57,13 @@ func TestModelRenderHandlesTinySize(t *testing.T) { | |||
| 57 | 57 | require.Equal(t, 0, frame.Width) | |
| 58 | 58 | require.Equal(t, 0, frame.Height) | |
| 59 | 59 | } | |
| 60 | + | ||
| 61 | + | func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) { | |
| 62 | + | m := New(" Rename ", "x") | |
| 63 | + | base := renderer.Style{Foreground: 1, Background: 2} | |
| 64 | + | selection := renderer.Style{Foreground: 3, Background: 4} | |
| 65 | + | frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection}) | |
| 66 | + | ||
| 67 | + | require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface") | |
| 68 | + | require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface") | |
| 69 | + | } | |
A internal/usecase/theme/accent_resolve.go +269 -0
| @@ -0,0 +1,269 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | accentMinChroma = 0.025 | |
| 10 | + | accentMinBackgroundDistance = 0.08 | |
| 11 | + | accentClusterDistance = 0.04 | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Accent is the single terminal-owned color selected for chrome. IndexedOnly | |
| 15 | + | // means the slot may decorate foregrounds and borders, but cannot fill RGB | |
| 16 | + | // backgrounds because terminal defaults or truecolor support are unavailable. | |
| 17 | + | type Accent struct { | |
| 18 | + | RGB renderer.RGB | |
| 19 | + | Slot uint8 | |
| 20 | + | Known bool | |
| 21 | + | IndexedOnly bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // The resolver only ever considers the chromatic ANSI slots in automatic | |
| 25 | + | // mode. This immutable mapping keeps resolution bounded and independent of | |
| 26 | + | // terminal response order without mutable package-level state. | |
| 27 | + | const accentCandidateCount = 12 | |
| 28 | + | ||
| 29 | + | func accentCandidateSlot(index int) uint8 { | |
| 30 | + | if index < 6 { | |
| 31 | + | return uint8(index + 1) | |
| 32 | + | } | |
| 33 | + | return uint8(index + 3) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | type accentGroup struct { | |
| 37 | + | members uint16 | |
| 38 | + | size uint8 | |
| 39 | + | pairs uint8 | |
| 40 | + | chromaSum float64 | |
| 41 | + | rep uint8 | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // ResolveAccent deterministically selects a terminal-owned accent. It uses | |
| 45 | + | // only fixed arrays and bitsets so this pure hot-reload operation makes no | |
| 46 | + | // per-call heap allocations. | |
| 47 | + | func ResolveAccent(t Theme, policy domain.ThemeAccent) Accent { | |
| 48 | + | if !t.UsePalette { | |
| 49 | + | return Accent{} | |
| 50 | + | } | |
| 51 | + | if policy.Mode == domain.ThemeAccentSlot { | |
| 52 | + | return resolveExplicitAccent(t, policy.Slot) | |
| 53 | + | } | |
| 54 | + | return resolveAutoAccent(t) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func resolveExplicitAccent(t Theme, slot uint8) Accent { | |
| 58 | + | if slot >= uint8(len(t.Palette)) { | |
| 59 | + | return Accent{} | |
| 60 | + | } | |
| 61 | + | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 62 | + | // An explicit policy is a strict override. Even without OSC RGB for its | |
| 63 | + | // slot, it may still safely decorate foregrounds and borders by index. | |
| 64 | + | return indexedAccent(slot) | |
| 65 | + | } | |
| 66 | + | return resolvedAccent(t, t.Palette[slot], slot) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func resolveAutoAccent(t Theme) Accent { | |
| 70 | + | // Background is necessary to reject colors that merely alias the terminal | |
| 71 | + | // surface. Without it, retain the pre-accent scheme-aware ANSI blue | |
| 72 | + | // foreground fallback rather than inventing an RGB background. | |
| 73 | + | if !t.HasBG { | |
| 74 | + | return schemeIndexedBlue(t) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | var colors [accentCandidateCount]oklab | |
| 78 | + | var eligible uint16 | |
| 79 | + | background := rgbToOKLab(t.Background) | |
| 80 | + | for i := range accentCandidateCount { | |
| 81 | + | slot := accentCandidateSlot(i) | |
| 82 | + | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | colors[i] = rgbToOKLab(t.Palette[slot]) | |
| 86 | + | if accentEligible(colors[i], background) { | |
| 87 | + | eligible |= uint16(1) << i | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | if eligible == 0 { | |
| 91 | + | return fallbackAutoAccent(t, background) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | var adjacent [accentCandidateCount]uint16 | |
| 95 | + | for i := range accentCandidateCount { | |
| 96 | + | if eligible&(uint16(1)<<i) == 0 { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | adjacent[i] = uint16(1) << i | |
| 100 | + | for j := i + 1; j < accentCandidateCount; j++ { | |
| 101 | + | if eligible&(uint16(1)<<j) == 0 || !accentConnected(colors[i], colors[j]) { | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | adjacent[i] |= uint16(1) << j | |
| 105 | + | adjacent[j] |= uint16(1) << i | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | var groups [accentCandidateCount]accentGroup | |
| 110 | + | var visited uint16 | |
| 111 | + | groupCount := 0 | |
| 112 | + | for start := range accentCandidateCount { | |
| 113 | + | startBit := uint16(1) << start | |
| 114 | + | if eligible&startBit == 0 || visited&startBit != 0 { | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | var queue [accentCandidateCount]uint8 | |
| 118 | + | head, tail := 0, 1 | |
| 119 | + | queue[0] = uint8(start) | |
| 120 | + | visited |= startBit | |
| 121 | + | for head < tail { | |
| 122 | + | i := queue[head] | |
| 123 | + | head++ | |
| 124 | + | groups[groupCount].members |= uint16(1) << i | |
| 125 | + | neighbors := adjacent[i] &^ visited | |
| 126 | + | for j := range accentCandidateCount { | |
| 127 | + | bit := uint16(1) << j | |
| 128 | + | if neighbors&bit == 0 { | |
| 129 | + | continue | |
| 130 | + | } | |
| 131 | + | visited |= bit | |
| 132 | + | queue[tail] = uint8(j) | |
| 133 | + | tail++ | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | finalizeAccentGroup(&groups[groupCount], colors) | |
| 137 | + | groupCount++ | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | best, runner := -1, -1 | |
| 141 | + | for i := 0; i < groupCount; i++ { | |
| 142 | + | if best < 0 || betterAccentGroup(groups[i], groups[best]) { | |
| 143 | + | runner = best | |
| 144 | + | best = i | |
| 145 | + | } else if runner < 0 || betterAccentGroup(groups[i], groups[runner]) { | |
| 146 | + | runner = i | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | winner := groups[best] | |
| 150 | + | runnerSize, runnerPairs := uint8(0), uint8(0) | |
| 151 | + | if runner >= 0 { | |
| 152 | + | runnerSize, runnerPairs = groups[runner].size, groups[runner].pairs | |
| 153 | + | } | |
| 154 | + | if winner.size >= 2 && (winner.size > runnerSize || winner.pairs > runnerPairs) { | |
| 155 | + | slot := accentCandidateSlot(int(winner.rep)) | |
| 156 | + | return resolvedAccent(t, t.Palette[slot], slot) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | return fallbackAutoAccent(t, background) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func fallbackAutoAccent(t Theme, background oklab) Accent { | |
| 163 | + | for _, slot := range [...]uint8{4, 12} { | |
| 164 | + | if t.PaletteKnown&(uint16(1)<<slot) == 0 { | |
| 165 | + | continue | |
| 166 | + | } | |
| 167 | + | color := rgbToOKLab(t.Palette[slot]) | |
| 168 | + | if accentEligible(color, background) { | |
| 169 | + | return resolvedAccent(t, t.Palette[slot], slot) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | if t.PaletteKnown&(uint16(1)<<4|uint16(1)<<12) != 0 { | |
| 173 | + | // A terminal-reported blue that fails eligibility deliberately does not | |
| 174 | + | // become an unvalidated indexed fallback. | |
| 175 | + | return Accent{} | |
| 176 | + | } | |
| 177 | + | return schemeIndexedBlue(t) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func schemeIndexedBlue(t Theme) Accent { | |
| 181 | + | if !t.SchemeKnown { | |
| 182 | + | return Accent{} | |
| 183 | + | } | |
| 184 | + | if t.Light { | |
| 185 | + | return indexedAccent(4) | |
| 186 | + | } | |
| 187 | + | return indexedAccent(12) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func indexedAccent(slot uint8) Accent { | |
| 191 | + | return Accent{Slot: slot, IndexedOnly: true} | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func finalizeAccentGroup(group *accentGroup, colors [accentCandidateCount]oklab) { | |
| 195 | + | var representative uint8 | |
| 196 | + | var representativeDistance float64 | |
| 197 | + | first := true | |
| 198 | + | for i := range accentCandidateCount { | |
| 199 | + | slot := accentCandidateSlot(i) | |
| 200 | + | if group.members&(uint16(1)<<i) == 0 { | |
| 201 | + | continue | |
| 202 | + | } | |
| 203 | + | group.size++ | |
| 204 | + | group.chromaSum += okLabChroma(colors[i]) | |
| 205 | + | var totalDistance float64 | |
| 206 | + | for j := range accentCandidateCount { | |
| 207 | + | if group.members&(uint16(1)<<j) != 0 { | |
| 208 | + | totalDistance += okLabDistance(colors[i], colors[j]) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | if first || totalDistance < representativeDistance || (totalDistance == representativeDistance && slot < accentCandidateSlot(int(representative))) { | |
| 212 | + | first = false | |
| 213 | + | representative = uint8(i) | |
| 214 | + | representativeDistance = totalDistance | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | group.rep = representative | |
| 218 | + | for slot := uint8(1); slot <= 6; slot++ { | |
| 219 | + | if accentGroupHasSlot(*group, slot) && accentGroupHasSlot(*group, slot+8) { | |
| 220 | + | group.pairs++ | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func accentGroupHasSlot(group accentGroup, slot uint8) bool { | |
| 226 | + | for i := range accentCandidateCount { | |
| 227 | + | if accentCandidateSlot(i) == slot { | |
| 228 | + | return group.members&(uint16(1)<<i) != 0 | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | return false | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func betterAccentGroup(a, b accentGroup) bool { | |
| 235 | + | if a.size != b.size { | |
| 236 | + | return a.size > b.size | |
| 237 | + | } | |
| 238 | + | if a.pairs != b.pairs { | |
| 239 | + | return a.pairs > b.pairs | |
| 240 | + | } | |
| 241 | + | averageA := a.chromaSum / float64(a.size) | |
| 242 | + | averageB := b.chromaSum / float64(b.size) | |
| 243 | + | if averageA != averageB { | |
| 244 | + | return averageA > averageB | |
| 245 | + | } | |
| 246 | + | aBlue := accentGroupHasSlot(a, 4) || accentGroupHasSlot(a, 12) | |
| 247 | + | bBlue := accentGroupHasSlot(b, 4) || accentGroupHasSlot(b, 12) | |
| 248 | + | if aBlue != bBlue { | |
| 249 | + | return aBlue | |
| 250 | + | } | |
| 251 | + | return accentCandidateSlot(int(a.rep)) < accentCandidateSlot(int(b.rep)) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent { | |
| 255 | + | return Accent{ | |
| 256 | + | RGB: color, | |
| 257 | + | Slot: slot, | |
| 258 | + | Known: true, | |
| 259 | + | IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG, | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func accentEligible(candidate, background oklab) bool { | |
| 264 | + | return okLabChroma(candidate) >= accentMinChroma && okLabDistance(candidate, background) >= accentMinBackgroundDistance | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func accentConnected(a, b oklab) bool { | |
| 268 | + | return okLabDistance(a, b) <= accentClusterDistance | |
| 269 | + | } | |
A internal/usecase/theme/accent_resolve_test.go +257 -0
| @@ -0,0 +1,257 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math/rand" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestAccentCandidateSlotsAreFixedAndAscending(t *testing.T) { | |
| 13 | + | want := [...]uint8{1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14} | |
| 14 | + | for i, slot := range want { | |
| 15 | + | require.Equalf(t, slot, accentCandidateSlot(i), "candidate index %d", i) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestResolveAccentThresholds(t *testing.T) { | |
| 20 | + | tests := []struct { | |
| 21 | + | name string | |
| 22 | + | color oklab | |
| 23 | + | background oklab | |
| 24 | + | want bool | |
| 25 | + | }{ | |
| 26 | + | {name: "chroma below", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma - 0.000001}}, | |
| 27 | + | {name: "chroma at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, want: true}, | |
| 28 | + | {name: "chroma above", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma + 0.000001}, want: true}, | |
| 29 | + | {name: "background distance below", color: oklab{L: accentMinBackgroundDistance - 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}}, | |
| 30 | + | {name: "background distance at", color: oklab{L: accentMinBackgroundDistance, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | |
| 31 | + | {name: "background distance above", color: oklab{L: accentMinBackgroundDistance + 0.000001, A: accentMinChroma}, background: oklab{A: accentMinChroma}, want: true}, | |
| 32 | + | } | |
| 33 | + | for _, tt := range tests { | |
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |
| 35 | + | require.Equal(t, tt.want, accentEligible(tt.color, tt.background)) | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | a := oklab{} | |
| 40 | + | require.True(t, accentConnected(a, oklab{L: accentClusterDistance - 0.000001})) | |
| 41 | + | require.True(t, accentConnected(a, oklab{L: accentClusterDistance})) | |
| 42 | + | require.False(t, accentConnected(a, oklab{L: accentClusterDistance + 0.000001})) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestResolveAccentAuto(t *testing.T) { | |
| 46 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 47 | + | blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9} | |
| 48 | + | tests := []struct { | |
| 49 | + | name string | |
| 50 | + | theme Theme | |
| 51 | + | want Accent | |
| 52 | + | }{ | |
| 53 | + | { | |
| 54 | + | name: "repeated teal beats repeated blue", | |
| 55 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 14: teal, 4: blue, 12: blue}), | |
| 56 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "singleton falls back to slot four", | |
| 60 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 4: blue}), | |
| 61 | + | want: Accent{RGB: blue, Slot: 4, Known: true}, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "singleton falls back to slot twelve", | |
| 65 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 12: blue}), | |
| 66 | + | want: Accent{RGB: blue, Slot: 12, Known: true}, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "tied repeated groups fall back to blue", | |
| 70 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 12: blue}), | |
| 71 | + | want: Accent{RGB: blue, Slot: 4, Known: true}, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "regular bright pair beats equal size runner up", | |
| 75 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue, 13: blue}), | |
| 76 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "neutral slots excluded from auto", | |
| 80 | + | theme: paletteTheme(map[int]renderer.RGB{0: teal, 7: teal, 8: teal, 15: teal}), | |
| 81 | + | want: Accent{}, | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "low chroma is rejected", | |
| 85 | + | theme: paletteTheme(map[int]renderer.RGB{ | |
| 86 | + | 1: {R: 60, G: 61, B: 60}, 9: {R: 60, G: 61, B: 60}, | |
| 87 | + | }), | |
| 88 | + | want: Accent{}, | |
| 89 | + | }, | |
| 90 | + | { | |
| 91 | + | name: "near background is rejected", | |
| 92 | + | theme: paletteTheme(map[int]renderer.RGB{ | |
| 93 | + | 1: {R: 9, G: 8, B: 12}, 9: {R: 9, G: 8, B: 12}, | |
| 94 | + | }), | |
| 95 | + | want: Accent{}, | |
| 96 | + | }, | |
| 97 | + | { | |
| 98 | + | name: "missing foreground yields indexed fallback only", | |
| 99 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasFG = false; return t }(), | |
| 100 | + | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |
| 101 | + | }, | |
| 102 | + | { | |
| 103 | + | name: "non truecolor yields indexed fallback only", | |
| 104 | + | theme: func() Theme { | |
| 105 | + | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |
| 106 | + | t.TrueColor = false | |
| 107 | + | return t | |
| 108 | + | }(), | |
| 109 | + | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |
| 110 | + | }, | |
| 111 | + | { | |
| 112 | + | name: "missing background cannot infer", | |
| 113 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}); t.HasBG = false; return t }(), | |
| 114 | + | want: Accent{}, | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | + | name: "palette disabled ignores accent policy", | |
| 118 | + | theme: func() Theme { | |
| 119 | + | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |
| 120 | + | t.UsePalette = false | |
| 121 | + | return t | |
| 122 | + | }(), | |
| 123 | + | want: Accent{}, | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | for _, tt := range tests { | |
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |
| 129 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})) | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestResolveAccentExplicitSlot(t *testing.T) { | |
| 135 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 136 | + | blue := renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9} | |
| 137 | + | tests := []struct { | |
| 138 | + | name string | |
| 139 | + | theme Theme | |
| 140 | + | policy domain.ThemeAccent | |
| 141 | + | want Accent | |
| 142 | + | }{ | |
| 143 | + | { | |
| 144 | + | name: "known explicit neutral slot bypasses auto exclusions", | |
| 145 | + | theme: paletteTheme(map[int]renderer.RGB{0: teal, 2: blue, 10: blue}), | |
| 146 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0}, | |
| 147 | + | want: Accent{RGB: teal, Slot: 0, Known: true}, | |
| 148 | + | }, | |
| 149 | + | { | |
| 150 | + | name: "known explicit slot bypasses eligibility", | |
| 151 | + | theme: paletteTheme(map[int]renderer.RGB{3: {R: 9, G: 8, B: 12}, 2: teal, 10: teal}), | |
| 152 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 153 | + | want: Accent{RGB: renderer.RGB{R: 9, G: 8, B: 12}, Slot: 3, Known: true}, | |
| 154 | + | }, | |
| 155 | + | { | |
| 156 | + | name: "unknown explicit slot keeps its exact indexed decoration", | |
| 157 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | |
| 158 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}, | |
| 159 | + | want: Accent{Slot: 5, IndexedOnly: true}, | |
| 160 | + | }, | |
| 161 | + | { | |
| 162 | + | name: "unknown final palette slot keeps its indexed decoration", | |
| 163 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | |
| 164 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15}, | |
| 165 | + | want: Accent{Slot: 15, IndexedOnly: true}, | |
| 166 | + | }, | |
| 167 | + | { | |
| 168 | + | name: "first slot outside palette is rejected", | |
| 169 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | |
| 170 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 16}, | |
| 171 | + | want: Accent{}, | |
| 172 | + | }, | |
| 173 | + | { | |
| 174 | + | name: "maximum uint8 slot outside palette is rejected", | |
| 175 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal, 4: blue}), | |
| 176 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 255}, | |
| 177 | + | want: Accent{}, | |
| 178 | + | }, | |
| 179 | + | { | |
| 180 | + | name: "explicit known slot is indexed only without truecolor prerequisites", | |
| 181 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | |
| 182 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 183 | + | want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true}, | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | name: "palette off ignores explicit slot", | |
| 187 | + | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.UsePalette = false; return t }(), | |
| 188 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 189 | + | want: Accent{}, | |
| 190 | + | }, | |
| 191 | + | } | |
| 192 | + | for _, tt := range tests { | |
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |
| 194 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy)) | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestResolveAccentDeterministicAcrossShuffledPaletteConstruction(t *testing.T) { | |
| 200 | + | entries := []struct { | |
| 201 | + | slot int | |
| 202 | + | color renderer.RGB | |
| 203 | + | }{ | |
| 204 | + | {2, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {10, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, {14, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, | |
| 205 | + | {4, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, {12, renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}}, | |
| 206 | + | } | |
| 207 | + | want := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true} | |
| 208 | + | rng := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic test shuffle. | |
| 209 | + | for i := 0; i < 50; i++ { | |
| 210 | + | rng.Shuffle(len(entries), func(a, b int) { entries[a], entries[b] = entries[b], entries[a] }) | |
| 211 | + | palette := make(map[int]renderer.RGB, len(entries)) | |
| 212 | + | for _, entry := range entries { | |
| 213 | + | palette[entry.slot] = entry.color | |
| 214 | + | } | |
| 215 | + | require.Equalf(t, want, ResolveAccent(paletteTheme(palette), domain.ThemeAccent{}), "shuffle %d", i) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestResolveAccentMedoidAndTieBreak(t *testing.T) { | |
| 220 | + | group := accentGroup{members: 1<<0 | 1<<1 | 1<<2} | |
| 221 | + | colors := [accentCandidateCount]oklab{ | |
| 222 | + | {L: 0.10, A: 0.10}, {L: 0.11, A: 0.10}, {L: 0.12, A: 0.10}, | |
| 223 | + | } | |
| 224 | + | finalizeAccentGroup(&group, colors) | |
| 225 | + | require.Equal(t, uint8(1), group.rep, "central member is the medoid") | |
| 226 | + | ||
| 227 | + | color := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 228 | + | got := ResolveAccent(paletteTheme(map[int]renderer.RGB{3: color, 11: color}), domain.ThemeAccent{}) | |
| 229 | + | require.Equal(t, Accent{RGB: color, Slot: 3, Known: true}, got) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestResolveAccentAllocs(t *testing.T) { | |
| 233 | + | theme := paletteTheme(map[int]renderer.RGB{ | |
| 234 | + | 2: {R: 0x7d, G: 0xb5, B: 0xb5}, 10: {R: 0x7d, G: 0xb5, B: 0xb5}, 14: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 235 | + | 4: {R: 0x6c, G: 0x9b, B: 0xd9}, 12: {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 236 | + | }) | |
| 237 | + | require.Zero(t, testing.AllocsPerRun(1000, func() { | |
| 238 | + | _ = ResolveAccent(theme, domain.ThemeAccent{}) | |
| 239 | + | })) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func paletteTheme(colors map[int]renderer.RGB) Theme { | |
| 243 | + | theme := Theme{ | |
| 244 | + | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | |
| 245 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 246 | + | HasFG: true, | |
| 247 | + | HasBG: true, | |
| 248 | + | TrueColor: true, | |
| 249 | + | Known: true, | |
| 250 | + | UsePalette: true, | |
| 251 | + | } | |
| 252 | + | for slot, color := range colors { | |
| 253 | + | theme.Palette[slot] = color | |
| 254 | + | theme.PaletteKnown |= 1 << slot | |
| 255 | + | } | |
| 256 | + | return theme | |
| 257 | + | } | |
A internal/usecase/theme/benchmark_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var ( | |
| 11 | + | benchmarkStylesSink Styles | |
| 12 | + | benchmarkResolvedSink ResolvedTheme | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func benchmarkPaletteTheme() Theme { | |
| 16 | + | return Theme{ | |
| 17 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, | |
| 18 | + | Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, | |
| 19 | + | Palette: [16]renderer.RGB{ | |
| 20 | + | 2: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 21 | + | 4: {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 22 | + | 10: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 23 | + | 12: {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 24 | + | 14: {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 25 | + | }, | |
| 26 | + | PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14, | |
| 27 | + | HasFG: true, | |
| 28 | + | HasBG: true, | |
| 29 | + | TrueColor: true, | |
| 30 | + | Known: true, | |
| 31 | + | SchemeKnown: true, | |
| 32 | + | UsePalette: true, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestNewStylesAllocations(t *testing.T) { | |
| 37 | + | theme := benchmarkPaletteTheme() | |
| 38 | + | allocs := testing.AllocsPerRun(1000, func() { | |
| 39 | + | benchmarkStylesSink = NewStyles(theme) | |
| 40 | + | }) | |
| 41 | + | if allocs != 0 { | |
| 42 | + | t.Fatalf("NewStyles allocations/op = %v, want 0", allocs) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func BenchmarkNewStyles(b *testing.B) { | |
| 47 | + | theme := benchmarkPaletteTheme() | |
| 48 | + | b.ReportAllocs() | |
| 49 | + | for b.Loop() { | |
| 50 | + | benchmarkStylesSink = NewStyles(theme) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestResolveAllocations(t *testing.T) { | |
| 55 | + | theme := benchmarkPaletteTheme() | |
| 56 | + | allocs := testing.AllocsPerRun(1000, func() { | |
| 57 | + | benchmarkResolvedSink = Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 58 | + | }) | |
| 59 | + | if allocs != 0 { | |
| 60 | + | t.Fatalf("Resolve allocations/op = %v, want 0", allocs) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func BenchmarkResolve(b *testing.B) { | |
| 65 | + | theme := benchmarkPaletteTheme() | |
| 66 | + | policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto} | |
| 67 | + | b.ReportAllocs() | |
| 68 | + | for b.Loop() { | |
| 69 | + | benchmarkResolvedSink = Resolve(theme, policy) | |
| 70 | + | } | |
| 71 | + | } | |
A internal/usecase/theme/color_oklab.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // oklab is the perceptually uniform OKLab representation of an sRGB color. | |
| 10 | + | type oklab struct { | |
| 11 | + | L float64 | |
| 12 | + | A float64 | |
| 13 | + | B float64 | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // rgbToOKLab converts an 8-bit sRGB color to OKLab via linear sRGB and LMS. | |
| 17 | + | func rgbToOKLab(color renderer.RGB) oklab { | |
| 18 | + | r := srgbToLinear(color.R) | |
| 19 | + | g := srgbToLinear(color.G) | |
| 20 | + | b := srgbToLinear(color.B) | |
| 21 | + | ||
| 22 | + | l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b) | |
| 23 | + | m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b) | |
| 24 | + | s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b) | |
| 25 | + | ||
| 26 | + | return oklab{ | |
| 27 | + | L: 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, | |
| 28 | + | A: 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, | |
| 29 | + | B: 0.0259040371*l + 0.7827717662*m - 0.8086757660*s, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // okLabToRGB converts OKLab to sRGB. Linear channels are clamped before | |
| 34 | + | // transfer encoding so out-of-gamut intermediate values are deterministic. | |
| 35 | + | func okLabToRGB(color oklab) renderer.RGB { | |
| 36 | + | l := cube(color.L + 0.3963377774*color.A + 0.2158037573*color.B) | |
| 37 | + | m := cube(color.L - 0.1055613458*color.A - 0.0638541728*color.B) | |
| 38 | + | s := cube(color.L - 0.0894841775*color.A - 1.2914855480*color.B) | |
| 39 | + | ||
| 40 | + | return renderer.RGB{ | |
| 41 | + | R: linearToSRGB(4.0767416621*l - 3.3077115913*m + 0.2309699292*s), | |
| 42 | + | G: linearToSRGB(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s), | |
| 43 | + | B: linearToSRGB(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s), | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func okLabDistance(a, b oklab) float64 { | |
| 48 | + | return math.Sqrt(square(a.L-b.L) + square(a.A-b.A) + square(a.B-b.B)) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func okLabChroma(color oklab) float64 { | |
| 52 | + | return math.Hypot(color.A, color.B) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid | |
| 56 | + | // round-trip quantization changing terminal-provided colors. | |
| 57 | + | func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB { | |
| 58 | + | weight = clampUnit(weight) | |
| 59 | + | if weight == 0 { | |
| 60 | + | return a | |
| 61 | + | } | |
| 62 | + | if weight == 1 { | |
| 63 | + | return b | |
| 64 | + | } | |
| 65 | + | start := rgbToOKLab(a) | |
| 66 | + | end := rgbToOKLab(b) | |
| 67 | + | return okLabToRGB(oklab{ | |
| 68 | + | L: start.L + (end.L-start.L)*weight, | |
| 69 | + | A: start.A + (end.A-start.A)*weight, | |
| 70 | + | B: start.B + (end.B-start.B)*weight, | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func srgbToLinear(channel uint8) float64 { | |
| 75 | + | value := float64(channel) / 255 | |
| 76 | + | if value <= 0.04045 { | |
| 77 | + | return value / 12.92 | |
| 78 | + | } | |
| 79 | + | return math.Pow((value+0.055)/1.055, 2.4) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func linearToSRGB(value float64) uint8 { | |
| 83 | + | value = clampUnit(value) | |
| 84 | + | if value <= 0.0031308 { | |
| 85 | + | value *= 12.92 | |
| 86 | + | } else { | |
| 87 | + | value = 1.055*math.Pow(value, 1/2.4) - 0.055 | |
| 88 | + | } | |
| 89 | + | return uint8(value*255 + 0.5) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func clampUnit(value float64) float64 { | |
| 93 | + | if math.IsNaN(value) || value <= 0 { | |
| 94 | + | return 0 | |
| 95 | + | } | |
| 96 | + | if value >= 1 { | |
| 97 | + | return 1 | |
| 98 | + | } | |
| 99 | + | return value | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func square(value float64) float64 { | |
| 103 | + | return value * value | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func cube(value float64) float64 { | |
| 107 | + | return value * value * value | |
| 108 | + | } | |
A internal/usecase/theme/color_oklab_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRGBToOKLabKnownValues(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | rgb renderer.RGB | |
| 14 | + | want oklab | |
| 15 | + | }{ | |
| 16 | + | {name: "black", rgb: renderer.RGB{}, want: oklab{}}, | |
| 17 | + | {name: "white", rgb: renderer.RGB{R: 255, G: 255, B: 255}, want: oklab{L: 1}}, | |
| 18 | + | {name: "red", rgb: renderer.RGB{R: 255}, want: oklab{L: 0.627955, A: 0.224863, B: 0.125846}}, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | for _, tt := range tests { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | got := rgbToOKLab(tt.rgb) | |
| 24 | + | if math.Abs(got.L-tt.want.L) > 0.000001 || math.Abs(got.A-tt.want.A) > 0.000001 || math.Abs(got.B-tt.want.B) > 0.000001 { | |
| 25 | + | t.Fatalf("rgbToOKLab(%+v) = %+v, want %+v", tt.rgb, got, tt.want) | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestOKLabRGBRoundTrip(t *testing.T) { | |
| 32 | + | colors := []renderer.RGB{ | |
| 33 | + | {}, | |
| 34 | + | {R: 255, G: 255, B: 255}, | |
| 35 | + | {R: 255}, | |
| 36 | + | {G: 255}, | |
| 37 | + | {B: 255}, | |
| 38 | + | {R: 0x7d, G: 0xb5, B: 0xb5}, | |
| 39 | + | {R: 0x6c, G: 0x9b, B: 0xd9}, | |
| 40 | + | } | |
| 41 | + | for _, color := range colors { | |
| 42 | + | t.Run("round_trip", func(t *testing.T) { | |
| 43 | + | got := okLabToRGB(rgbToOKLab(color)) | |
| 44 | + | if channelDelta(got.R, color.R) > 1 || channelDelta(got.G, color.G) > 1 || channelDelta(got.B, color.B) > 1 { | |
| 45 | + | t.Fatalf("round trip %v = %v", color, got) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestOKLabDistance(t *testing.T) { | |
| 52 | + | a := rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}) | |
| 53 | + | b := rgbToOKLab(renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}) | |
| 54 | + | if got := okLabDistance(a, a); got != 0 { | |
| 55 | + | t.Fatalf("self distance = %v, want 0", got) | |
| 56 | + | } | |
| 57 | + | if got, want := okLabDistance(a, b), okLabDistance(b, a); math.Abs(got-want) > 1e-15 || got <= 0 { | |
| 58 | + | t.Fatalf("distance symmetry: %v != %v", got, want) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestOKLabLerpEndpointsAndMonotonicity(t *testing.T) { | |
| 63 | + | background := renderer.RGB{R: 0x08, G: 0x09, B: 0x0a} | |
| 64 | + | accent := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 65 | + | ||
| 66 | + | if got := okLabLerp(background, accent, 0); got != background { | |
| 67 | + | t.Fatalf("weight 0 = %v, want %v", got, background) | |
| 68 | + | } | |
| 69 | + | if got := okLabLerp(background, accent, 1); got != accent { | |
| 70 | + | t.Fatalf("weight 1 = %v, want %v", got, accent) | |
| 71 | + | } | |
| 72 | + | if got := okLabLerp(background, accent, -1); got != background { | |
| 73 | + | t.Fatalf("clamped low weight = %v, want %v", got, background) | |
| 74 | + | } | |
| 75 | + | if got := okLabLerp(background, accent, 2); got != accent { | |
| 76 | + | t.Fatalf("clamped high weight = %v, want %v", got, accent) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | previous := background | |
| 80 | + | for _, weight := range []float64{0.25, 0.5, 0.75, 1} { | |
| 81 | + | got := okLabLerp(background, accent, weight) | |
| 82 | + | if got.R < previous.R || got.G < previous.G || got.B < previous.B { | |
| 83 | + | t.Fatalf("weight %v = %v is not monotonic after %v", weight, got, previous) | |
| 84 | + | } | |
| 85 | + | previous = got | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) { | |
| 90 | + | got := okLabToRGB(oklab{L: 0.7, A: 0.5, B: 0.5}) | |
| 91 | + | if got.R != 255 || got.G != 0 || got.B != 0 { | |
| 92 | + | t.Fatalf("out-of-gamut color = %v, want deterministic per-channel clamp to {255 0 0}", got) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func channelDelta(a, b uint8) uint8 { | |
| 97 | + | if a > b { | |
| 98 | + | return a - b | |
| 99 | + | } | |
| 100 | + | return b - a | |
| 101 | + | } | |
A internal/usecase/theme/phase3_review_test.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |
| 12 | + | teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 13 | + | explicit := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5} | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | theme Theme | |
| 17 | + | policy domain.ThemeAccent | |
| 18 | + | want Accent | |
| 19 | + | }{ | |
| 20 | + | { | |
| 21 | + | name: "explicit unknown slot decorates only with its exact index", | |
| 22 | + | theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}), | |
| 23 | + | policy: explicit, | |
| 24 | + | want: Accent{Slot: 5, IndexedOnly: true}, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "explicit known slot without truecolor is indexed only", | |
| 28 | + | theme: func() Theme { | |
| 29 | + | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |
| 30 | + | t.TrueColor = false | |
| 31 | + | return t | |
| 32 | + | }(), | |
| 33 | + | policy: explicit, | |
| 34 | + | want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true}, | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | |
| 38 | + | theme: Theme{ | |
| 39 | + | Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10}, | |
| 40 | + | HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true, | |
| 41 | + | }, | |
| 42 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 43 | + | want: Accent{Slot: 12, IndexedOnly: true}, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "automatic absent defaults keeps light scheme indexed blue fallback", | |
| 47 | + | theme: Theme{ | |
| 48 | + | TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true, | |
| 49 | + | }, | |
| 50 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 51 | + | want: Accent{Slot: 4, IndexedOnly: true}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "automatic absent palette and unknown scheme stays neutral", | |
| 55 | + | theme: Theme{TrueColor: true, Known: true, UsePalette: true}, | |
| 56 | + | policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, | |
| 57 | + | want: Accent{}, | |
| 58 | + | }, | |
| 59 | + | { | |
| 60 | + | name: "palette off suppresses explicit indexed decoration", | |
| 61 | + | theme: func() Theme { | |
| 62 | + | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |
| 63 | + | t.UsePalette = false | |
| 64 | + | return t | |
| 65 | + | }(), | |
| 66 | + | policy: explicit, | |
| 67 | + | want: Accent{}, | |
| 68 | + | }, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | for _, tt := range tests { | |
| 72 | + | t.Run(tt.name, func(t *testing.T) { | |
| 73 | + | require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy)) | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestResolveIndexedAccentNeverFillsAnIndexedBackground(t *testing.T) { | |
| 79 | + | theme := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}}) | |
| 80 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}) | |
| 81 | + | require.Equal(t, Accent{Slot: 5, IndexedOnly: true}, resolved.Accent) | |
| 82 | + | ||
| 83 | + | for name, style := range legacyAndSemanticStyles(resolved.Styles) { | |
| 84 | + | require.NotEqualf(t, 5, style.Background, "%s must not use an indexed accent background", name) | |
| 85 | + | } | |
| 86 | + | require.Equal(t, 5, resolved.Styles.BorderMuted.Foreground) | |
| 87 | + | require.Equal(t, 5, resolved.Styles.PickerSeparator.Foreground) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestPickerSeparatorUsesSecondaryTextContrast(t *testing.T) { | |
| 91 | + | theme := rampTheme(false) | |
| 92 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 93 | + | theme.Palette[10] = theme.Palette[2] | |
| 94 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 95 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 96 | + | ||
| 97 | + | want, ok := secondaryText(resolved.Styles.PickerBase.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB) | |
| 98 | + | require.True(t, ok) | |
| 99 | + | require.Equal(t, want, resolved.Styles.PickerSeparator.ForegroundRGB) | |
| 100 | + | require.GreaterOrEqual(t, ContrastRatio(resolved.Styles.PickerSeparator.ForegroundRGB, resolved.Styles.PickerBase.BackgroundRGB), normalTextContrast) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) { | |
| 104 | + | paletteOff := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}}) | |
| 105 | + | paletteOff.UsePalette = false | |
| 106 | + | tests := []struct { | |
| 107 | + | name string | |
| 108 | + | theme Theme | |
| 109 | + | }{ | |
| 110 | + | {name: "palette off", theme: paletteOff}, | |
| 111 | + | {name: "forced dark", theme: BuiltinDark}, | |
| 112 | + | {name: "forced light", theme: BuiltinLight}, | |
| 113 | + | } | |
| 114 | + | for _, tt := range tests { | |
| 115 | + | t.Run(tt.name, func(t *testing.T) { | |
| 116 | + | got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles | |
| 117 | + | want := prePhase3LegacyAliases(tt.theme) | |
| 118 | + | // PickerSeparator is now a semantic role with the SurfaceBar | |
| 119 | + | // background; it was never a compatibility alias. | |
| 120 | + | delete(want, "PickerSeparator") | |
| 121 | + | for name, style := range want { | |
| 122 | + | require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name) | |
| 123 | + | } | |
| 124 | + | }) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func prePhase3LegacyAliases(t Theme) map[string]renderer.Style { | |
| 129 | + | status := renderer.DefaultStyle() | |
| 130 | + | accent := inverseStyle() | |
| 131 | + | border := renderer.DefaultStyle() | |
| 132 | + | muted := renderer.DefaultStyle() | |
| 133 | + | if usable(t) { | |
| 134 | + | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |
| 135 | + | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |
| 136 | + | border = foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |
| 137 | + | muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |
| 138 | + | } | |
| 139 | + | selection := accent | |
| 140 | + | return map[string]renderer.Style{ | |
| 141 | + | "StatusBar": status, | |
| 142 | + | "Accent": accent, | |
| 143 | + | "Border": border, | |
| 144 | + | "Selection": selection, | |
| 145 | + | "CopyStatus": selection, | |
| 146 | + | "PaletteDesc": muted, | |
| 147 | + | "TabName": EmphasisStyle(status, t), | |
| 148 | + | "TabNameActive": EmphasisStyle(accent, t), | |
| 149 | + | "TabTitle": MutedVariantStyle(status, t), | |
| 150 | + | "TabTitleActive": MutedVariantStyle(accent, t), | |
| 151 | + | "PickerName": EmphasisStyle(renderer.DefaultStyle(), t), | |
| 152 | + | "PickerSelectionName": EmphasisStyle(selection, t), | |
| 153 | + | "PickerSelectionMuted": MutedVariantStyle(selection, t), | |
| 154 | + | "PickerSeparator": muted, | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style { | |
| 159 | + | return map[string]renderer.Style{ | |
| 160 | + | "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive, | |
| 161 | + | "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive, | |
| 162 | + | "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive, | |
| 163 | + | "NeutralBorder": styles.NeutralBorder, "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar, | |
| 164 | + | "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection, | |
| 165 | + | "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc, | |
| 166 | + | "TabName": styles.TabName, "TabNameActive": styles.TabNameActive, | |
| 167 | + | "TabTitle": styles.TabTitle, "TabTitleActive": styles.TabTitleActive, | |
| 168 | + | "PickerName": styles.PickerName, "PickerSelectionName": styles.PickerSelectionName, | |
| 169 | + | "PickerSelectionMuted": styles.PickerSelectionMuted, | |
| 170 | + | } | |
| 171 | + | } | |
A internal/usecase/theme/ramp.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | normalTextContrast = 4.5 | |
| 7 | + | borderContrast = 3.0 | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Ramp is the contrast-safe set of semantic terminal chrome colors. | |
| 11 | + | type Ramp struct { | |
| 12 | + | SurfaceBar renderer.Style | |
| 13 | + | SurfaceInactive renderer.Style | |
| 14 | + | SurfaceRecent renderer.Style | |
| 15 | + | SurfaceActive renderer.Style | |
| 16 | + | BorderMuted renderer.Style | |
| 17 | + | BorderActive renderer.Style | |
| 18 | + | ||
| 19 | + | background renderer.RGB | |
| 20 | + | accent renderer.RGB | |
| 21 | + | rgb bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // BuildRamp derives every RGB surface from the terminal background and one | |
| 25 | + | // resolved accent. It deliberately never uses indexed colors for backgrounds. | |
| 26 | + | func BuildRamp(t Theme, accent Accent) Ramp { | |
| 27 | + | if !accent.Known || accent.IndexedOnly || !usable(t) { | |
| 28 | + | return neutralRamp(t) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | bar, ok := surfaceAtOrBelow(t, accent.RGB, 8) | |
| 32 | + | if !ok { | |
| 33 | + | return neutralRamp(t) | |
| 34 | + | } | |
| 35 | + | inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14) | |
| 36 | + | if !ok { | |
| 37 | + | return neutralRamp(t) | |
| 38 | + | } | |
| 39 | + | recent, ok := surfaceAtOrBelow(t, accent.RGB, 22) | |
| 40 | + | if !ok { | |
| 41 | + | return neutralRamp(t) | |
| 42 | + | } | |
| 43 | + | active, ok := surfaceAtOrBelow(t, accent.RGB, 100) | |
| 44 | + | if !ok { | |
| 45 | + | return neutralRamp(t) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | return Ramp{ | |
| 49 | + | SurfaceBar: bar, | |
| 50 | + | SurfaceInactive: inactive, | |
| 51 | + | SurfaceRecent: recent, | |
| 52 | + | SurfaceActive: active, | |
| 53 | + | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 54 | + | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 55 | + | background: t.Background, | |
| 56 | + | accent: accent.RGB, | |
| 57 | + | rgb: true, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // surfaceAtOrBelow searches from the requested intensity to neutral. The | |
| 62 | + | // integer sequence is intentional: it makes contrast adaptation stable. | |
| 63 | + | func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) { | |
| 64 | + | for weight := target; weight >= 0; weight-- { | |
| 65 | + | background := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 66 | + | foreground, ok := primaryText(t, background) | |
| 67 | + | if !ok { | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | if _, ok := secondaryText(foreground, background); !ok { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | return rgbSurface(foreground, background), true | |
| 74 | + | } | |
| 75 | + | return renderer.Style{}, false | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func rgbSurface(foreground, background renderer.RGB) renderer.Style { | |
| 79 | + | style := renderer.DefaultStyle() | |
| 80 | + | style.HasForegroundRGB = true | |
| 81 | + | style.ForegroundRGB = foreground | |
| 82 | + | style.HasBackgroundRGB = true | |
| 83 | + | style.BackgroundRGB = background | |
| 84 | + | return style | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // primaryText chooses only the reported default foreground or background. | |
| 88 | + | func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) { | |
| 89 | + | best := t.Foreground | |
| 90 | + | bestContrast := ContrastRatio(t.Foreground, surface) | |
| 91 | + | backgroundContrast := ContrastRatio(t.Background, surface) | |
| 92 | + | if backgroundContrast > bestContrast { | |
| 93 | + | best, bestContrast = t.Background, backgroundContrast | |
| 94 | + | } | |
| 95 | + | return best, bestContrast >= normalTextContrast | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // secondaryText mutes toward the actual surface while retaining normal-text | |
| 99 | + | // contrast. Searching down from 40 selects the greatest permitted mute. | |
| 100 | + | func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) { | |
| 101 | + | for weight := 40; weight >= 0; weight-- { | |
| 102 | + | candidate := okLabLerp(primary, surface, float64(weight)/100) | |
| 103 | + | if ContrastRatio(candidate, surface) >= normalTextContrast { | |
| 104 | + | return candidate, true | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return renderer.RGB{}, false | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // mutedBorder starts at 60 percent and only moves toward the accent, choosing | |
| 111 | + | // the lowest weight that separates from its adjacent surface. | |
| 112 | + | func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |
| 113 | + | for weight := 60; weight <= 100; weight++ { | |
| 114 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 115 | + | if ContrastRatio(candidate, adjacent) >= borderContrast { | |
| 116 | + | return foregroundStyle(candidate) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return neutralBorder(t) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // activeBorder starts at the accent endpoint and reduces only when needed. | |
| 123 | + | func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |
| 124 | + | for weight := 100; weight >= 0; weight-- { | |
| 125 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 126 | + | if ContrastRatio(candidate, adjacent) >= borderContrast { | |
| 127 | + | return foregroundStyle(candidate) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return neutralBorder(t) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func foregroundStyle(color renderer.RGB) renderer.Style { | |
| 134 | + | style := renderer.DefaultStyle() | |
| 135 | + | style.HasForegroundRGB = true | |
| 136 | + | style.ForegroundRGB = color | |
| 137 | + | return style | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func neutralRamp(t Theme) Ramp { | |
| 141 | + | styles := neutralStyles(t) | |
| 142 | + | return Ramp{ | |
| 143 | + | SurfaceBar: styles.SurfaceBar, | |
| 144 | + | SurfaceInactive: styles.SurfaceInactive, | |
| 145 | + | SurfaceRecent: styles.SurfaceRecent, | |
| 146 | + | SurfaceActive: styles.SurfaceActive, | |
| 147 | + | BorderMuted: styles.BorderMuted, | |
| 148 | + | BorderActive: styles.BorderActive, | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for | |
| 153 | + | // the oldest entry. A singleton is therefore the full recent surface. | |
| 154 | + | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |
| 155 | + | if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB { | |
| 156 | + | return ramp.SurfaceRecent | |
| 157 | + | } | |
| 158 | + | if index < 0 { | |
| 159 | + | index = 0 | |
| 160 | + | } | |
| 161 | + | if index >= count { | |
| 162 | + | index = count - 1 | |
| 163 | + | } | |
| 164 | + | if !ramp.rgb { | |
| 165 | + | return ramp.SurfaceRecent | |
| 166 | + | } | |
| 167 | + | weight := 22.0 - 11.0*float64(index)/float64(count-1) | |
| 168 | + | background := okLabLerp(ramp.background, ramp.accent, weight/100) | |
| 169 | + | foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background) | |
| 170 | + | if !ok { | |
| 171 | + | return ramp.SurfaceRecent | |
| 172 | + | } | |
| 173 | + | return rgbSurface(foreground, background) | |
| 174 | + | } | |
A internal/usecase/theme/ramp_test.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBuildRampUsesExactSemanticWeights(t *testing.T) { | |
| 12 | + | theme := rampTheme(false) | |
| 13 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true} | |
| 14 | + | ramp := BuildRamp(theme, accent) | |
| 15 | + | ||
| 16 | + | for name, got := range map[string]renderer.Style{ | |
| 17 | + | "bar": ramp.SurfaceBar, | |
| 18 | + | "inactive": ramp.SurfaceInactive, | |
| 19 | + | "recent": ramp.SurfaceRecent, | |
| 20 | + | } { | |
| 21 | + | require.True(t, got.HasBackgroundRGB, name) | |
| 22 | + | } | |
| 23 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB) | |
| 24 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB) | |
| 25 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB) | |
| 26 | + | require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB) | |
| 27 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB) | |
| 28 | + | require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB) | |
| 29 | + | require.False(t, ramp.BorderMuted.HasBackgroundRGB) | |
| 30 | + | require.False(t, ramp.BorderActive.HasBackgroundRGB) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) { | |
| 34 | + | for _, light := range []bool{false, true} { | |
| 35 | + | t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) { | |
| 36 | + | theme := rampTheme(light) | |
| 37 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | |
| 38 | + | ramp := BuildRamp(theme, accent) | |
| 39 | + | for name, style := range map[string]renderer.Style{ | |
| 40 | + | "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive, | |
| 41 | + | "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive, | |
| 42 | + | } { | |
| 43 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name) | |
| 44 | + | } | |
| 45 | + | for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} { | |
| 46 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name) | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |
| 53 | + | theme := Theme{ | |
| 54 | + | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | |
| 55 | + | Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59}, | |
| 56 | + | HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true, | |
| 57 | + | } | |
| 58 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | |
| 59 | + | ramp := BuildRamp(theme, accent) | |
| 60 | + | ||
| 61 | + | wantWeight := -1 | |
| 62 | + | for weight := 100; weight >= 0; weight-- { | |
| 63 | + | surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100) | |
| 64 | + | primary, ok := primaryText(theme, surface) | |
| 65 | + | if !ok { | |
| 66 | + | continue | |
| 67 | + | } | |
| 68 | + | if _, ok := secondaryText(primary, surface); ok { | |
| 69 | + | wantWeight = weight | |
| 70 | + | break | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | require.NotEqual(t, 100, wantWeight) | |
| 74 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | |
| 78 | + | theme := rampTheme(false) | |
| 79 | + | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | |
| 80 | + | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1)) | |
| 81 | + | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | |
| 82 | + | require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB) | |
| 83 | + | require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |
| 87 | + | theme := rampTheme(false) | |
| 88 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 89 | + | theme.Palette[10] = theme.Palette[2] | |
| 90 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 91 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 92 | + | ||
| 93 | + | require.True(t, resolved.Accent.Known) | |
| 94 | + | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | |
| 95 | + | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | |
| 96 | + | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | |
| 97 | + | require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder) | |
| 98 | + | require.True(t, resolved.Styles.TabActive.Bold) | |
| 99 | + | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | |
| 100 | + | require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase) | |
| 101 | + | require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB) | |
| 102 | + | require.True(t, resolved.Styles.PickerSelection.Bold) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | |
| 106 | + | theme := rampTheme(false) | |
| 107 | + | theme.TrueColor = false | |
| 108 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 109 | + | theme.PaletteKnown = 1 << 2 | |
| 110 | + | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 111 | + | require.True(t, indexed.Accent.IndexedOnly) | |
| 112 | + | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | |
| 113 | + | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | |
| 114 | + | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | |
| 115 | + | ||
| 116 | + | theme.TrueColor = true | |
| 117 | + | theme.UsePalette = false | |
| 118 | + | off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 119 | + | require.False(t, off.Accent.Known) | |
| 120 | + | require.Equal(t, neutralStyles(theme), off.Styles) | |
| 121 | + | ||
| 122 | + | insufficient := rampTheme(false) | |
| 123 | + | insufficient.Foreground = insufficient.Background | |
| 124 | + | insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 125 | + | insufficient.PaletteKnown = 1 << 2 | |
| 126 | + | neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 127 | + | require.Equal(t, neutralStyles(insufficient), neutral.Styles) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func rampTheme(light bool) Theme { | |
| 131 | + | if light { | |
| 132 | + | return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 133 | + | } | |
| 134 | + | return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 135 | + | } | |
A internal/usecase/theme/resolved_styles.go +201 -0
| @@ -0,0 +1,201 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const neutralAccentBlend = 0.25 | |
| 9 | + | ||
| 10 | + | // ResolvedTheme is the immutable, complete chrome style snapshot produced from | |
| 11 | + | // a terminal theme and typed accent policy. | |
| 12 | + | type ResolvedTheme struct { | |
| 13 | + | Theme Theme | |
| 14 | + | Accent Accent | |
| 15 | + | Ramp Ramp | |
| 16 | + | Styles Styles | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Resolve is the single entry point for terminal-owned accent inference and | |
| 20 | + | // semantic chrome construction. | |
| 21 | + | func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme { | |
| 22 | + | accent := ResolveAccent(t, policy) | |
| 23 | + | ramp := BuildRamp(t, accent) | |
| 24 | + | styles := stylesFromRamp(t, accent, ramp) | |
| 25 | + | return ResolvedTheme{Theme: t, Accent: accent, Ramp: ramp, Styles: styles} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |
| 29 | + | if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb { | |
| 30 | + | styles := neutralStyles(t) | |
| 31 | + | if accent.IndexedOnly { | |
| 32 | + | styles = indexedStyles(styles, accent.Slot) | |
| 33 | + | } | |
| 34 | + | return styles | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | inactiveTitle := secondarySurface(ramp.SurfaceInactive) | |
| 38 | + | activeTitle := secondarySurface(ramp.SurfaceActive) | |
| 39 | + | inactiveDescription := secondarySurface(ramp.SurfaceInactive) | |
| 40 | + | barDescription := secondarySurface(ramp.SurfaceBar) | |
| 41 | + | styles := Styles{ | |
| 42 | + | SurfaceBar: ramp.SurfaceBar, | |
| 43 | + | SurfaceInactive: ramp.SurfaceInactive, | |
| 44 | + | SurfaceRecent: ramp.SurfaceRecent, | |
| 45 | + | SurfaceActive: ramp.SurfaceActive, | |
| 46 | + | BorderMuted: ramp.BorderMuted, | |
| 47 | + | BorderActive: ramp.BorderActive, | |
| 48 | + | NeutralBorder: neutralBorder(t), | |
| 49 | + | ||
| 50 | + | TabInactive: ramp.SurfaceInactive, | |
| 51 | + | TabInactiveTitle: inactiveTitle, | |
| 52 | + | TabActive: EmphasisStyle(ramp.SurfaceActive, t), | |
| 53 | + | TabActiveTitle: activeTitle, | |
| 54 | + | MRURecent: ramp.SurfaceRecent, | |
| 55 | + | ||
| 56 | + | PickerBase: ramp.SurfaceBar, | |
| 57 | + | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 58 | + | PickerDescription: inactiveDescription, | |
| 59 | + | // Separators are secondary text on the picker base surface, not | |
| 60 | + | // non-text borders; they therefore require the normal 4.5:1 contrast. | |
| 61 | + | PickerSeparator: barDescription, | |
| 62 | + | PromptBase: ramp.SurfaceBar, | |
| 63 | + | CopyStatus: ramp.SurfaceBar, | |
| 64 | + | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 65 | + | } | |
| 66 | + | return withMRUStyles(withLegacyAliases(styles, t), ramp) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func secondarySurface(base renderer.Style) renderer.Style { | |
| 70 | + | if !base.HasForegroundRGB || !base.HasBackgroundRGB { | |
| 71 | + | return base | |
| 72 | + | } | |
| 73 | + | foreground, ok := secondaryText(base.ForegroundRGB, base.BackgroundRGB) | |
| 74 | + | if !ok { | |
| 75 | + | return base | |
| 76 | + | } | |
| 77 | + | base.ForegroundRGB = foreground | |
| 78 | + | return base | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func indexedStyles(styles Styles, slot uint8) Styles { | |
| 82 | + | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | |
| 83 | + | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | |
| 84 | + | styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot) | |
| 85 | + | styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot) | |
| 86 | + | styles.PickerDescription = indexedForeground(styles.PickerDescription, slot) | |
| 87 | + | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | |
| 88 | + | ||
| 89 | + | // Compatibility aliases historically received the scheme-aware indexed | |
| 90 | + | // blue fallback too. Keep the strict explicit index on each foreground | |
| 91 | + | // decoration without changing any neutral surface background. | |
| 92 | + | styles.Border = styles.BorderMuted | |
| 93 | + | styles.PaletteDesc = indexedForeground(styles.PaletteDesc, slot) | |
| 94 | + | styles.TabTitle = styles.TabInactiveTitle | |
| 95 | + | styles.TabTitleActive = styles.TabActiveTitle | |
| 96 | + | styles.PickerSelectionMuted = indexedForeground(styles.PickerSelectionMuted, slot) | |
| 97 | + | return styles | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | |
| 101 | + | style.HasForegroundRGB = false | |
| 102 | + | style.Foreground = int(slot) | |
| 103 | + | style.Inverse = false | |
| 104 | + | return style | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // neutralStyles deliberately preserves the pre-accent neutral hierarchy. It | |
| 108 | + | // is used for palette-off, forced themes, incomplete defaults, and RGB-less | |
| 109 | + | // indexed fallbacks; it never performs a palette slot lookup. | |
| 110 | + | func neutralStyles(t Theme) Styles { | |
| 111 | + | status := renderer.DefaultStyle() | |
| 112 | + | accent := inverseStyle() | |
| 113 | + | border := renderer.DefaultStyle() | |
| 114 | + | inactiveDescription := status | |
| 115 | + | barDescription := status | |
| 116 | + | if usable(t) { | |
| 117 | + | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |
| 118 | + | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |
| 119 | + | border = neutralBorder(t) | |
| 120 | + | // Preserve the legacy neutral muted foreground while retaining the | |
| 121 | + | // semantic row/bar background beneath description and separator cells. | |
| 122 | + | inactiveDescription = status | |
| 123 | + | inactiveDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 124 | + | barDescription = status | |
| 125 | + | barDescription.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 126 | + | } | |
| 127 | + | styles := Styles{ | |
| 128 | + | SurfaceBar: status, | |
| 129 | + | SurfaceInactive: status, | |
| 130 | + | SurfaceRecent: status, | |
| 131 | + | SurfaceActive: accent, | |
| 132 | + | BorderMuted: border, | |
| 133 | + | BorderActive: border, | |
| 134 | + | NeutralBorder: border, | |
| 135 | + | ||
| 136 | + | TabInactive: EmphasisStyle(status, t), | |
| 137 | + | TabInactiveTitle: MutedVariantStyle(status, t), | |
| 138 | + | TabActive: EmphasisStyle(accent, t), | |
| 139 | + | TabActiveTitle: MutedVariantStyle(accent, t), | |
| 140 | + | MRURecent: status, | |
| 141 | + | ||
| 142 | + | PickerBase: status, | |
| 143 | + | PickerSelection: EmphasisStyle(accent, t), | |
| 144 | + | PickerDescription: inactiveDescription, | |
| 145 | + | PickerSeparator: barDescription, | |
| 146 | + | PromptBase: status, | |
| 147 | + | CopyStatus: accent, | |
| 148 | + | SearchSelection: accent, | |
| 149 | + | } | |
| 150 | + | styles = withLegacyAliases(styles, t) | |
| 151 | + | // PaletteDesc is a Phase 3 compatibility alias. It retains its legacy | |
| 152 | + | // foreground-only bytes; rendered palette descriptions use PickerDescription. | |
| 153 | + | styles.PaletteDesc = legacyMutedText(t) | |
| 154 | + | // Neutral and indexed fallbacks retain their existing non-RGB hierarchy; | |
| 155 | + | // MRU entries deliberately share the neutral recent surface. | |
| 156 | + | return withMRUStyles(styles, Ramp{SurfaceBar: styles.SurfaceBar, SurfaceRecent: styles.SurfaceRecent}) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func withMRUStyles(styles Styles, ramp Ramp) Styles { | |
| 160 | + | for count := 1; count <= len(styles.mruStyles); count++ { | |
| 161 | + | for index := 0; index < count; index++ { | |
| 162 | + | styles.mruStyles[count-1][index] = MRUStyle(ramp, index, count) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | return styles | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // neutralBorder is the existing non-accent structural border input for pane | |
| 169 | + | // chrome. It intentionally does not consume a resolved accent and is called | |
| 170 | + | // only while resolving an immutable style snapshot. | |
| 171 | + | func neutralBorder(t Theme) renderer.Style { | |
| 172 | + | if !usable(t) { | |
| 173 | + | return renderer.DefaultStyle() | |
| 174 | + | } | |
| 175 | + | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func legacyMutedText(t Theme) renderer.Style { | |
| 179 | + | if !usable(t) { | |
| 180 | + | return renderer.DefaultStyle() | |
| 181 | + | } | |
| 182 | + | return foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func withLegacyAliases(styles Styles, t Theme) Styles { | |
| 186 | + | styles.StatusBar = styles.SurfaceBar | |
| 187 | + | styles.Accent = styles.SurfaceActive | |
| 188 | + | styles.Border = styles.BorderMuted | |
| 189 | + | styles.Selection = styles.SurfaceActive | |
| 190 | + | styles.PaletteDesc = styles.PickerDescription | |
| 191 | + | styles.TabName = styles.TabInactive | |
| 192 | + | styles.TabNameActive = styles.TabActive | |
| 193 | + | styles.TabTitle = styles.TabInactiveTitle | |
| 194 | + | styles.TabTitleActive = styles.TabActiveTitle | |
| 195 | + | // These aliases retain their pre-semantic-role contracts for render paths | |
| 196 | + | // that have not migrated yet. | |
| 197 | + | styles.PickerName = EmphasisStyle(renderer.DefaultStyle(), t) | |
| 198 | + | styles.PickerSelectionName = EmphasisStyle(styles.Selection, t) | |
| 199 | + | styles.PickerSelectionMuted = MutedVariantStyle(styles.Selection, t) | |
| 200 | + | return styles | |
| 201 | + | } | |
M internal/usecase/theme/styles.go +57 -32
| @@ -3,6 +3,7 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -58,47 +59,71 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||
| 58 | 59 | return base | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | - | // Styles is the resolved set of terminal chrome styles for a client theme. | |
| 62 | + | // Styles is the complete semantic set of terminal chrome styles. The legacy | |
| 63 | + | // aliases remain until daemon renderers consume the semantic fields directly. | |
| 62 | 64 | type Styles struct { | |
| 63 | - | StatusBar renderer.Style | |
| 64 | - | Accent renderer.Style | |
| 65 | - | Border renderer.Style | |
| 66 | - | Selection renderer.Style | |
| 67 | - | CopyStatus renderer.Style | |
| 68 | - | PaletteDesc renderer.Style | |
| 65 | + | // mruStyles are all position/count combinations that status composition | |
| 66 | + | // can display. They keep OKLab interpolation out of render paths. | |
| 67 | + | mruStyles [9][9]renderer.Style | |
| 69 | 68 | ||
| 70 | - | TabName renderer.Style | |
| 71 | - | TabNameActive renderer.Style | |
| 72 | - | TabTitle renderer.Style | |
| 73 | - | TabTitleActive renderer.Style | |
| 69 | + | SurfaceBar renderer.Style | |
| 70 | + | SurfaceInactive renderer.Style | |
| 71 | + | SurfaceRecent renderer.Style | |
| 72 | + | SurfaceActive renderer.Style | |
| 73 | + | BorderMuted renderer.Style | |
| 74 | + | BorderActive renderer.Style | |
| 75 | + | // NeutralBorder is the non-accent structural border for pane dividers and | |
| 76 | + | // unfocused pane title bars. It is resolved with the rest of the immutable | |
| 77 | + | // chrome snapshot so composition never derives theme colors. | |
| 78 | + | NeutralBorder renderer.Style | |
| 74 | 79 | ||
| 80 | + | TabInactive renderer.Style | |
| 81 | + | TabInactiveTitle renderer.Style | |
| 82 | + | TabActive renderer.Style | |
| 83 | + | TabActiveTitle renderer.Style | |
| 84 | + | MRURecent renderer.Style | |
| 85 | + | ||
| 86 | + | PickerBase renderer.Style | |
| 87 | + | PickerSelection renderer.Style | |
| 88 | + | PickerDescription renderer.Style | |
| 89 | + | PickerSeparator renderer.Style | |
| 90 | + | PromptBase renderer.Style | |
| 91 | + | CopyStatus renderer.Style | |
| 92 | + | SearchSelection renderer.Style | |
| 93 | + | ||
| 94 | + | // Compatibility aliases for pre-cache render paths. | |
| 95 | + | StatusBar renderer.Style | |
| 96 | + | Accent renderer.Style | |
| 97 | + | Border renderer.Style | |
| 98 | + | Selection renderer.Style | |
| 99 | + | PaletteDesc renderer.Style | |
| 100 | + | TabName renderer.Style | |
| 101 | + | TabNameActive renderer.Style | |
| 102 | + | TabTitle renderer.Style | |
| 103 | + | TabTitleActive renderer.Style | |
| 75 | 104 | PickerName renderer.Style | |
| 76 | 105 | PickerSelectionName renderer.Style | |
| 77 | 106 | PickerSelectionMuted renderer.Style | |
| 78 | - | PickerSeparator renderer.Style | |
| 79 | 107 | } | |
| 80 | 108 | ||
| 81 | - | // NewStyles composes the terminal chrome styles from the semantic theme styles. | |
| 109 | + | // NewStyles applies the default automatic policy. New callers that own a | |
| 110 | + | // policy should use Resolve and retain its complete immutable result. | |
| 82 | 111 | func NewStyles(t Theme) Styles { | |
| 83 | - | statusBar := StatusBarStyle(t) | |
| 84 | - | accent := AccentStyle(t) | |
| 85 | - | selection := SelectionStyle(t) | |
| 86 | - | return Styles{ | |
| 87 | - | StatusBar: statusBar, | |
| 88 | - | Accent: accent, | |
| 89 | - | Border: BorderStyle(t), | |
| 90 | - | Selection: selection, | |
| 91 | - | CopyStatus: selection, | |
| 92 | - | PaletteDesc: MutedTextStyle(t), | |
| 93 | - | ||
| 94 | - | TabName: EmphasisStyle(statusBar, t), | |
| 95 | - | TabNameActive: EmphasisStyle(accent, t), | |
| 96 | - | TabTitle: PaletteMutedVariantStyle(statusBar, t), | |
| 97 | - | TabTitleActive: PaletteMutedVariantStyle(accent, t), | |
| 112 | + | return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 113 | + | } | |
| 98 | 114 | ||
| 99 | - | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | |
| 100 | - | PickerSelectionName: EmphasisStyle(selection, t), | |
| 101 | - | PickerSelectionMuted: PaletteMutedVariantStyle(selection, t), | |
| 102 | - | PickerSeparator: MutedTextStyle(t), | |
| 115 | + | // MRUStyle returns the position-dependent recent-session surface. Color | |
| 116 | + | // interpolation stays owned by theme; renderers consume only this cached | |
| 117 | + | // semantic style snapshot. | |
| 118 | + | func (s Styles) MRUStyle(index, count int) renderer.Style { | |
| 119 | + | if count <= 0 || count > len(s.mruStyles) { | |
| 120 | + | return s.MRURecent | |
| 121 | + | } | |
| 122 | + | if index < 0 { | |
| 123 | + | index = 0 | |
| 124 | + | } | |
| 125 | + | if index >= count { | |
| 126 | + | index = count - 1 | |
| 103 | 127 | } | |
| 128 | + | return s.mruStyles[count-1][index] | |
| 104 | 129 | } | |
M internal/usecase/theme/styles_test.go +15 -323
| @@ -139,6 +139,21 @@ func TestPulseColor(t *testing.T) { | |||
| 139 | 139 | } | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | func TestIndexedForegroundClearsInverseForNonTrueColorFallback(t *testing.T) { | |
| 143 | + | fallback := indexedForeground(renderer.Style{ | |
| 144 | + | Bold: true, | |
| 145 | + | Inverse: true, | |
| 146 | + | Foreground: -1, | |
| 147 | + | HasForegroundRGB: true, | |
| 148 | + | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 149 | + | }, 2) | |
| 150 | + | ||
| 151 | + | require.False(t, fallback.Inverse, "an indexed foreground must not become an effective background") | |
| 152 | + | require.False(t, fallback.HasForegroundRGB) | |
| 153 | + | require.Equal(t, 2, fallback.Foreground) | |
| 154 | + | require.True(t, fallback.Bold) | |
| 155 | + | } | |
| 156 | + | ||
| 142 | 157 | func TestMRUFade(t *testing.T) { | |
| 143 | 158 | base := renderer.Style{ | |
| 144 | 159 | Bold: true, | |
| @@ -254,326 +269,3 @@ func TestThemeIsComparable(t *testing.T) { | |||
| 254 | 269 | themes := map[Theme]bool{BuiltinDark: true} | |
| 255 | 270 | require.True(t, themes[BuiltinDark]) | |
| 256 | 271 | } | |
| 257 | - | ||
| 258 | - | func TestPaletteAccentBackground(t *testing.T) { | |
| 259 | - | base := Theme{ | |
| 260 | - | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 261 | - | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 262 | - | HasFG: true, | |
| 263 | - | HasBG: true, | |
| 264 | - | Known: true, | |
| 265 | - | TrueColor: true, | |
| 266 | - | UsePalette: true, | |
| 267 | - | } | |
| 268 | - | neutral := Blend(base.Background, base.Foreground, accentBlend) | |
| 269 | - | ||
| 270 | - | tests := []struct { | |
| 271 | - | name string | |
| 272 | - | palette [16]renderer.RGB | |
| 273 | - | known uint16 | |
| 274 | - | usePalette bool | |
| 275 | - | want renderer.RGB | |
| 276 | - | wantContrastMin bool | |
| 277 | - | }{ | |
| 278 | - | { | |
| 279 | - | name: "slot 4 supplies a contrasting accent background", | |
| 280 | - | palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}}, | |
| 281 | - | known: 1<<4 | 1<<12, | |
| 282 | - | usePalette: true, | |
| 283 | - | want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend), | |
| 284 | - | wantContrastMin: true, | |
| 285 | - | }, | |
| 286 | - | { | |
| 287 | - | name: "slot 4 failure escalates to slot 12", | |
| 288 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}}, | |
| 289 | - | known: 1<<4 | 1<<12, | |
| 290 | - | usePalette: true, | |
| 291 | - | want: Blend(base.Background, renderer.RGB{}, accentBlend), | |
| 292 | - | wantContrastMin: true, | |
| 293 | - | }, | |
| 294 | - | { | |
| 295 | - | name: "both palette blues failing uses exact neutral blend", | |
| 296 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 297 | - | known: 1<<4 | 1<<12, | |
| 298 | - | usePalette: true, | |
| 299 | - | want: neutral, | |
| 300 | - | }, | |
| 301 | - | { | |
| 302 | - | name: "missing palette blues uses exact neutral blend", | |
| 303 | - | usePalette: true, | |
| 304 | - | want: neutral, | |
| 305 | - | }, | |
| 306 | - | { | |
| 307 | - | name: "palette disabled preserves neutral accent", | |
| 308 | - | palette: [16]renderer.RGB{4: {B: 255}}, | |
| 309 | - | known: 1 << 4, | |
| 310 | - | usePalette: false, | |
| 311 | - | want: neutral, | |
| 312 | - | }, | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | for _, tt := range tests { | |
| 316 | - | t.Run(tt.name, func(t *testing.T) { | |
| 317 | - | theme := base | |
| 318 | - | theme.Palette = tt.palette | |
| 319 | - | theme.PaletteKnown = tt.known | |
| 320 | - | theme.UsePalette = tt.usePalette | |
| 321 | - | ||
| 322 | - | got := AccentStyle(theme) | |
| 323 | - | require.True(t, got.HasBackgroundRGB) | |
| 324 | - | require.Equal(t, tt.want, got.BackgroundRGB) | |
| 325 | - | if tt.wantContrastMin { | |
| 326 | - | require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin) | |
| 327 | - | } | |
| 328 | - | }) | |
| 329 | - | } | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | func TestPaletteTitleHueDerivation(t *testing.T) { | |
| 333 | - | base := Theme{ | |
| 334 | - | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 335 | - | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 336 | - | HasFG: true, | |
| 337 | - | HasBG: true, | |
| 338 | - | Known: true, | |
| 339 | - | TrueColor: true, | |
| 340 | - | UsePalette: true, | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | tests := []struct { | |
| 344 | - | name string | |
| 345 | - | theme Theme | |
| 346 | - | palette [16]renderer.RGB | |
| 347 | - | known uint16 | |
| 348 | - | }{ | |
| 349 | - | { | |
| 350 | - | name: "slot 4 is used when it contrasts for each title", | |
| 351 | - | theme: Theme{ | |
| 352 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 353 | - | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |
| 354 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 355 | - | }, | |
| 356 | - | palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}}, | |
| 357 | - | ||
| 358 | - | known: 1<<4 | 1<<12, | |
| 359 | - | }, | |
| 360 | - | { | |
| 361 | - | name: "each title escalates independently from slot 4 to slot 12", | |
| 362 | - | palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}}, | |
| 363 | - | known: 1<<4 | 1<<12, | |
| 364 | - | }, | |
| 365 | - | { | |
| 366 | - | name: "both title hues failing retains neutral muted styles", | |
| 367 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 368 | - | known: 1<<4 | 1<<12, | |
| 369 | - | }, | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | for _, tt := range tests { | |
| 373 | - | t.Run(tt.name, func(t *testing.T) { | |
| 374 | - | theme := base | |
| 375 | - | if tt.theme.Known { | |
| 376 | - | theme = tt.theme | |
| 377 | - | } | |
| 378 | - | theme.Palette = tt.palette | |
| 379 | - | theme.PaletteKnown = tt.known | |
| 380 | - | styles := NewStyles(theme) | |
| 381 | - | status := StatusBarStyle(theme) | |
| 382 | - | accent := AccentStyle(theme) | |
| 383 | - | ||
| 384 | - | if tt.name == "both title hues failing retains neutral muted styles" { | |
| 385 | - | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme))) | |
| 386 | - | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |
| 387 | - | return | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | require.True(t, styles.TabTitle.HasForegroundRGB) | |
| 391 | - | require.True(t, styles.TabTitleActive.HasForegroundRGB) | |
| 392 | - | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin) | |
| 393 | - | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin) | |
| 394 | - | ||
| 395 | - | if tt.name == "slot 4 is used when it contrasts for each title" { | |
| 396 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 397 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 398 | - | } | |
| 399 | - | if tt.name == "each title escalates independently from slot 4 to slot 12" { | |
| 400 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 401 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 402 | - | } | |
| 403 | - | }) | |
| 404 | - | } | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) { | |
| 408 | - | base := Theme{ | |
| 409 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 410 | - | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |
| 411 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | tests := []struct { | |
| 415 | - | name string | |
| 416 | - | theme Theme | |
| 417 | - | wantForeground renderer.RGB | |
| 418 | - | wantIndexed int | |
| 419 | - | }{ | |
| 420 | - | { | |
| 421 | - | name: "RGB palette foreground", | |
| 422 | - | theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(), | |
| 423 | - | wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend), | |
| 424 | - | wantIndexed: -1, | |
| 425 | - | }, | |
| 426 | - | { | |
| 427 | - | name: "indexed foreground fallback", | |
| 428 | - | theme: func() Theme { t := base; t.SchemeKnown = true; return t }(), | |
| 429 | - | wantIndexed: ansiBrightBlue, | |
| 430 | - | }, | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | for _, tt := range tests { | |
| 434 | - | t.Run(tt.name, func(t *testing.T) { | |
| 435 | - | styles := NewStyles(tt.theme) | |
| 436 | - | ||
| 437 | - | for name, style := range map[string]renderer.Style{ | |
| 438 | - | "border": styles.Border, | |
| 439 | - | "muted text": styles.PaletteDesc, | |
| 440 | - | } { | |
| 441 | - | require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name) | |
| 442 | - | if tt.wantIndexed >= 0 { | |
| 443 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 444 | - | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |
| 445 | - | } else { | |
| 446 | - | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |
| 447 | - | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |
| 448 | - | } | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | status := StatusBarStyle(tt.theme) | |
| 452 | - | accent := AccentStyle(tt.theme) | |
| 453 | - | for name, style := range map[string]renderer.Style{ | |
| 454 | - | "title": styles.TabTitle, | |
| 455 | - | "active title": styles.TabTitleActive, | |
| 456 | - | } { | |
| 457 | - | wantBackground := status.BackgroundRGB | |
| 458 | - | if name == "active title" { | |
| 459 | - | wantBackground = accent.BackgroundRGB | |
| 460 | - | } | |
| 461 | - | require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name) | |
| 462 | - | require.Equal(t, wantBackground, style.BackgroundRGB, name) | |
| 463 | - | if tt.wantIndexed >= 0 { | |
| 464 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 465 | - | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |
| 466 | - | } else { | |
| 467 | - | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |
| 468 | - | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |
| 469 | - | } | |
| 470 | - | } | |
| 471 | - | }) | |
| 472 | - | } | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | func TestPaletteIndexedForegroundFallback(t *testing.T) { | |
| 476 | - | base := Theme{UsePalette: true, SchemeKnown: true, Known: true} | |
| 477 | - | ||
| 478 | - | tests := []struct { | |
| 479 | - | name string | |
| 480 | - | theme Theme | |
| 481 | - | want int | |
| 482 | - | ok bool | |
| 483 | - | }{ | |
| 484 | - | {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true}, | |
| 485 | - | {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true}, | |
| 486 | - | {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}}, | |
| 487 | - | {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}}, | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | for _, tt := range tests { | |
| 491 | - | t.Run(tt.name, func(t *testing.T) { | |
| 492 | - | styles := NewStyles(tt.theme) | |
| 493 | - | baselineTheme := tt.theme | |
| 494 | - | baselineTheme.UsePalette = false | |
| 495 | - | baseline := NewStyles(baselineTheme) | |
| 496 | - | for name, style := range map[string]renderer.Style{ | |
| 497 | - | "title": styles.TabTitle, | |
| 498 | - | "active title": styles.TabTitleActive, | |
| 499 | - | "border": styles.Border, | |
| 500 | - | "muted text": styles.PaletteDesc, | |
| 501 | - | } { | |
| 502 | - | require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name) | |
| 503 | - | if tt.ok { | |
| 504 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 505 | - | require.Equal(t, tt.want, style.Foreground, name) | |
| 506 | - | } else { | |
| 507 | - | want := map[string]renderer.Style{ | |
| 508 | - | "title": baseline.TabTitle, "active title": baseline.TabTitleActive, | |
| 509 | - | "border": baseline.Border, "muted text": baseline.PaletteDesc, | |
| 510 | - | }[name] | |
| 511 | - | require.Equal(t, want, style, name) | |
| 512 | - | } | |
| 513 | - | } | |
| 514 | - | }) | |
| 515 | - | } | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) { | |
| 519 | - | theme := Theme{ | |
| 520 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 521 | - | Background: renderer.RGB{R: 30, G: 30, B: 30}, | |
| 522 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 523 | - | UsePalette: true, SchemeKnown: true, | |
| 524 | - | } | |
| 525 | - | styles := NewStyles(theme) | |
| 526 | - | for name, style := range map[string]renderer.Style{ | |
| 527 | - | "title": styles.TabTitle, "active title": styles.TabTitleActive, | |
| 528 | - | "border": styles.Border, "muted text": styles.PaletteDesc, | |
| 529 | - | } { | |
| 530 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 531 | - | require.Equal(t, ansiBrightBlue, style.Foreground, name) | |
| 532 | - | require.Equal(t, -1, style.Background, "%s must not use an indexed background", name) | |
| 533 | - | } | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) { | |
| 537 | - | palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}} | |
| 538 | - | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 539 | - | withDisabledPalette := usableTheme | |
| 540 | - | withDisabledPalette.Palette = palette | |
| 541 | - | withDisabledPalette.PaletteKnown = 1<<4 | 1<<12 | |
| 542 | - | withDisabledPalette.UsePalette = false | |
| 543 | - | ||
| 544 | - | require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette)) | |
| 545 | - | ||
| 546 | - | forced := Theme{Known: true, SchemeKnown: true, UsePalette: false} | |
| 547 | - | require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced)) | |
| 548 | - | } | |
| 549 | - | ||
| 550 | - | func TestNewStylesComposesSemanticStyles(t *testing.T) { | |
| 551 | - | theme := Theme{ | |
| 552 | - | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | |
| 553 | - | Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e}, | |
| 554 | - | HasFG: true, | |
| 555 | - | HasBG: true, | |
| 556 | - | Known: true, | |
| 557 | - | TrueColor: true, | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | styles := NewStyles(theme) | |
| 561 | - | statusBar := StatusBarStyle(theme) | |
| 562 | - | accent := AccentStyle(theme) | |
| 563 | - | selection := SelectionStyle(theme) | |
| 564 | - | ||
| 565 | - | require.True(t, styles.StatusBar.Equal(statusBar)) | |
| 566 | - | require.True(t, styles.Accent.Equal(accent)) | |
| 567 | - | require.True(t, styles.Border.Equal(BorderStyle(theme))) | |
| 568 | - | require.True(t, styles.Selection.Equal(selection)) | |
| 569 | - | require.True(t, styles.CopyStatus.Equal(selection)) | |
| 570 | - | require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme))) | |
| 571 | - | require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme))) | |
| 572 | - | require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme))) | |
| 573 | - | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme))) | |
| 574 | - | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |
| 575 | - | require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme))) | |
| 576 | - | require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme))) | |
| 577 | - | require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme))) | |
| 578 | - | require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme))) | |
| 579 | - | } | |
M internal/usecase/theme/theme.go +10 -135
| @@ -356,89 +356,22 @@ func StatusBarStyle(t Theme) renderer.Style { | |||
| 356 | 356 | return style | |
| 357 | 357 | } | |
| 358 | 358 | ||
| 359 | - | const ( | |
| 360 | - | ansiBlue = 4 | |
| 361 | - | ansiBrightBlue = 12 | |
| 362 | - | accentBlend = 0.25 | |
| 363 | - | titleHueBlend = 0.2 | |
| 364 | - | ) | |
| 365 | - | ||
| 359 | + | // The compatibility helpers below retain the neutral styles for isolated | |
| 360 | + | // callers. Accent-aware construction goes exclusively through Resolve. | |
| 366 | 361 | func AccentStyle(t Theme) renderer.Style { | |
| 367 | - | if !usable(t) { | |
| 368 | - | return inverseStyle() | |
| 369 | - | } | |
| 370 | - | style := renderer.DefaultStyle() | |
| 371 | - | style.HasForegroundRGB = true | |
| 372 | - | style.ForegroundRGB = t.Foreground | |
| 373 | - | style.HasBackgroundRGB = true | |
| 374 | - | style.BackgroundRGB = accentBackground(t) | |
| 375 | - | return style | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | // accentBackground uses a terminal's blue palette only when the resulting | |
| 379 | - | // foreground/background pair remains legible. A neutral blend is always the | |
| 380 | - | // fallback, including when palette inheritance is disabled. | |
| 381 | - | func accentBackground(t Theme) renderer.RGB { | |
| 382 | - | neutral := Blend(t.Background, t.Foreground, accentBlend) | |
| 383 | - | if !t.UsePalette { | |
| 384 | - | return neutral | |
| 385 | - | } | |
| 386 | - | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 387 | - | blue, ok := t.PaletteColor(slot) | |
| 388 | - | if !ok { | |
| 389 | - | continue | |
| 390 | - | } | |
| 391 | - | candidate := Blend(t.Background, blue, accentBlend) | |
| 392 | - | if ContrastRatio(t.Foreground, candidate) >= accentContrastMin { | |
| 393 | - | return candidate | |
| 394 | - | } | |
| 395 | - | } | |
| 396 | - | return neutral | |
| 362 | + | return neutralStyles(t).SurfaceActive | |
| 397 | 363 | } | |
| 398 | 364 | ||
| 399 | 365 | func BorderStyle(t Theme) renderer.Style { | |
| 400 | - | neutral := renderer.DefaultStyle() | |
| 401 | - | if usable(t) { | |
| 402 | - | neutral.HasForegroundRGB = true | |
| 403 | - | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |
| 404 | - | } | |
| 405 | - | if !t.UsePalette { | |
| 406 | - | return neutral | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | base := renderer.DefaultStyle() | |
| 410 | - | if usable(t) { | |
| 411 | - | base.HasForegroundRGB = true | |
| 412 | - | base.ForegroundRGB = t.Foreground | |
| 413 | - | base.HasBackgroundRGB = true | |
| 414 | - | base.BackgroundRGB = t.Background | |
| 415 | - | } | |
| 416 | - | return paletteForegroundStyle(base, neutral, t) | |
| 366 | + | return neutralStyles(t).BorderMuted | |
| 417 | 367 | } | |
| 418 | 368 | ||
| 419 | 369 | func MutedTextStyle(t Theme) renderer.Style { | |
| 420 | - | neutral := renderer.DefaultStyle() | |
| 421 | - | if usable(t) { | |
| 422 | - | neutral.HasForegroundRGB = true | |
| 423 | - | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 424 | - | } | |
| 425 | - | if !t.UsePalette { | |
| 426 | - | return neutral | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | base := renderer.DefaultStyle() | |
| 430 | - | if usable(t) { | |
| 431 | - | base.HasForegroundRGB = true | |
| 432 | - | base.ForegroundRGB = t.Foreground | |
| 433 | - | base.HasBackgroundRGB = true | |
| 434 | - | base.BackgroundRGB = t.Background | |
| 435 | - | } | |
| 436 | - | return paletteForegroundStyle(base, neutral, t) | |
| 370 | + | return neutralStyles(t).PickerDescription | |
| 437 | 371 | } | |
| 438 | 372 | ||
| 439 | 373 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | |
| 440 | - | // foreground toward its own background, subtler than MutedTextStyle's 0.45 | |
| 441 | - | // (which blends the theme's global fg/bg instead of a style's own colors). | |
| 374 | + | // foreground toward its own background. | |
| 442 | 375 | const mutedVariantBlend = 0.4 | |
| 443 | 376 | ||
| 444 | 377 | // EmphasisStyle returns base with bold applied when the client theme supports | |
| @@ -452,81 +385,23 @@ func EmphasisStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 452 | 385 | return out | |
| 453 | 386 | } | |
| 454 | 387 | ||
| 455 | - | // MutedVariantStyle fades base's foreground toward its background when both | |
| 456 | - | // are RGB and the client theme supports styled output; otherwise base is | |
| 457 | - | // returned unchanged. When base has an RGB foreground but no RGB background, | |
| 458 | - | // it fades toward t.Background instead. | |
| 388 | + | // MutedVariantStyle fades base's foreground toward its own background. | |
| 459 | 389 | func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |
| 460 | 390 | if !usable(t) || !base.HasForegroundRGB { | |
| 461 | 391 | return base | |
| 462 | 392 | } | |
| 463 | - | bg := base.BackgroundRGB | |
| 393 | + | background := base.BackgroundRGB | |
| 464 | 394 | if !base.HasBackgroundRGB { | |
| 465 | 395 | if !t.HasBG { | |
| 466 | 396 | return base | |
| 467 | 397 | } | |
| 468 | - | bg = t.Background | |
| 398 | + | background = t.Background | |
| 469 | 399 | } | |
| 470 | 400 | out := base | |
| 471 | - | out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend) | |
| 401 | + | out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend) | |
| 472 | 402 | return out | |
| 473 | 403 | } | |
| 474 | 404 | ||
| 475 | - | // PaletteMutedVariantStyle derives a title foreground from a terminal blue | |
| 476 | - | // palette entry. Its contrast is evaluated against the title's actual base | |
| 477 | - | // background, rather than the terminal background, because status and active | |
| 478 | - | // title backgrounds differ. | |
| 479 | - | func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |
| 480 | - | return paletteForegroundStyle(base, MutedVariantStyle(base, t), t) | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | // paletteForegroundStyle uses palette blues for a foreground only. It never | |
| 484 | - | // changes a background, so palette inheritance cannot introduce indexed | |
| 485 | - | // backgrounds. If terminal RGB data is unavailable, a known color scheme can | |
| 486 | - | // still select a best-effort ANSI foreground. A reported blue that fails | |
| 487 | - | // contrast deliberately suppresses that indexed fallback. | |
| 488 | - | func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style { | |
| 489 | - | if !t.UsePalette { | |
| 490 | - | return neutral | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | knownBlue := paletteBlueKnown(t) | |
| 494 | - | if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 495 | - | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 496 | - | blue, ok := t.PaletteColor(slot) | |
| 497 | - | if !ok { | |
| 498 | - | continue | |
| 499 | - | } | |
| 500 | - | candidate := Blend(base.ForegroundRGB, blue, titleHueBlend) | |
| 501 | - | if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin { | |
| 502 | - | out := neutral | |
| 503 | - | out.HasForegroundRGB = true | |
| 504 | - | out.ForegroundRGB = candidate | |
| 505 | - | return out | |
| 506 | - | } | |
| 507 | - | } | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | if !knownBlue && t.SchemeKnown { | |
| 511 | - | out := neutral | |
| 512 | - | out.HasForegroundRGB = false | |
| 513 | - | if t.Light { | |
| 514 | - | out.Foreground = ansiBlue | |
| 515 | - | } else { | |
| 516 | - | out.Foreground = ansiBrightBlue | |
| 517 | - | } | |
| 518 | - | return out | |
| 519 | - | } | |
| 520 | - | return neutral | |
| 521 | - | } | |
| 522 | - | ||
| 523 | - | func paletteBlueKnown(t Theme) bool { | |
| 524 | - | if !t.UsePalette { | |
| 525 | - | return false | |
| 526 | - | } | |
| 527 | - | return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0 | |
| 528 | - | } | |
| 529 | - | ||
| 530 | 405 | const defaultDimmingPercent = 35 | |
| 531 | 406 | ||
| 532 | 407 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | |
M internal/usecase/ui/modal.go +6 -4
| @@ -48,9 +48,11 @@ func (m Modal) Inner(base domain.Size) domain.Rect { | |||
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | - | // Composite draws the modal border and title, clears the interior, and returns | |
| 52 | - | // the inner content rectangle. Cells outside the modal bounds are not changed. | |
| 53 | - | func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | |
| 51 | + | // Composite draws the modal border and title, fills its interior, and returns | |
| 52 | + | // the inner content rectangle. Border and interior styles are deliberately | |
| 53 | + | // independent so unfocused structure and chrome surfaces retain their roles. | |
| 54 | + | // Cells outside the modal bounds are not changed. | |
| 55 | + | func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect { | |
| 54 | 56 | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |
| 55 | 57 | inner := domain.Rect{ | |
| 56 | 58 | X: bounds.X + 1, | |
| @@ -59,7 +61,7 @@ func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | |||
| 59 | 61 | Height: max(0, bounds.Height-2), | |
| 60 | 62 | } | |
| 61 | 63 | DrawBox(f, bounds, border) | |
| 62 | - | FillRect(f, inner, renderer.BlankCell()) | |
| 64 | + | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | |
| 63 | 65 | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |
| 64 | 66 | left := bounds.X + 1 | |
| 65 | 67 | right := bounds.X + bounds.Width - 1 | |
M internal/usecase/ui/modal_test.go +34 -4
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | func TestModalBounds(t *testing.T) { | |
| @@ -130,9 +131,10 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |||
| 130 | 131 | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 131 | 132 | FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior) | |
| 132 | 133 | ||
| 133 | - | style := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 134 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 135 | + | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 134 | 136 | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | |
| 135 | - | inner := m.Composite(f, style) | |
| 137 | + | inner := m.Composite(f, border, interior) | |
| 136 | 138 | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | |
| 137 | 139 | if inner != wantInner { | |
| 138 | 140 | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | |
| @@ -149,11 +151,15 @@ func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |||
| 149 | 151 | ||
| 150 | 152 | for y := inner.Y; y < inner.Y+inner.Height; y++ { | |
| 151 | 153 | for x := inner.X; x < inner.X+inner.Width; x++ { | |
| 152 | - | if got := f.At(x, y); !got.Equal(renderer.BlankCell()) { | |
| 153 | - | t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got) | |
| 154 | + | want := renderer.Cell{Rune: ' ', Style: interior} | |
| 155 | + | if got := f.At(x, y); !got.Equal(want) { | |
| 156 | + | t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want) | |
| 154 | 157 | } | |
| 155 | 158 | } | |
| 156 | 159 | } | |
| 160 | + | if got := f.At(2, 1).Style; !got.Equal(border) { | |
| 161 | + | t.Fatalf("border style = %+v, want %+v", got, border) | |
| 162 | + | } | |
| 157 | 163 | assertCell(t, f, 0, 0, exterior) | |
| 158 | 164 | assertCell(t, f, 9, 5, exterior) | |
| 159 | 165 | } | |
| @@ -214,3 +220,27 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) { | |||
| 214 | 220 | t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want) | |
| 215 | 221 | } | |
| 216 | 222 | } | |
| 223 | + | ||
| 224 | + | func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) { | |
| 225 | + | border := renderer.Style{Foreground: 1, Background: 2} | |
| 226 | + | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 227 | + | for _, tt := range []struct { | |
| 228 | + | name string | |
| 229 | + | size domain.Size | |
| 230 | + | want domain.Rect | |
| 231 | + | }{ | |
| 232 | + | {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}}, | |
| 233 | + | {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}}, | |
| 234 | + | } { | |
| 235 | + | t.Run(tt.name, func(t *testing.T) { | |
| 236 | + | frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows) | |
| 237 | + | got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior) | |
| 238 | + | require.Equal(t, tt.want, got) | |
| 239 | + | for y := range frame.Height { | |
| 240 | + | for x := range frame.Width { | |
| 241 | + | require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds") | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | }) | |
| 245 | + | } | |
| 246 | + | } | |
M internal/usecase/visualsearch/model.go +16 -4
| @@ -133,6 +133,12 @@ func (m *Model) clamp() { | |||
| 133 | 133 | ||
| 134 | 134 | const defaultVisibleRows = 10 | |
| 135 | 135 | ||
| 136 | + | // RenderStyles contains the base chrome surface and active selection role. | |
| 137 | + | type RenderStyles struct { | |
| 138 | + | Base renderer.Style | |
| 139 | + | Selection renderer.Style | |
| 140 | + | } | |
| 141 | + | ||
| 136 | 142 | func (m *Model) ensureVisible(visible int) { | |
| 137 | 143 | if m == nil || visible <= 0 || len(m.matches) == 0 { | |
| 138 | 144 | return | |
| @@ -146,16 +152,22 @@ func (m *Model) ensureVisible(visible int) { | |||
| 146 | 152 | } | |
| 147 | 153 | ||
| 148 | 154 | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | |
| 149 | - | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 150 | - | if frame.Width == 0 || frame.Height == 0 { | |
| 151 | - | return frame | |
| 152 | - | } | |
| 153 | 155 | base := renderer.DefaultStyle() | |
| 154 | 156 | selection := base | |
| 155 | 157 | selection.Inverse = true | |
| 156 | 158 | if len(selectedStyle) > 0 { | |
| 157 | 159 | selection = selectedStyle[0] | |
| 158 | 160 | } | |
| 161 | + | return m.RenderStyled(inner, RenderStyles{Base: base, Selection: selection}) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // RenderStyled renders with explicit chrome roles captured by the daemon. | |
| 165 | + | func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Frame { | |
| 166 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 167 | + | if frame.Width == 0 || frame.Height == 0 { | |
| 168 | + | return frame | |
| 169 | + | } | |
| 170 | + | base, selection := styles.Base, styles.Selection | |
| 159 | 171 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 160 | 172 | ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection) | |
| 161 | 173 | visible := frame.Height - 1 | |
M internal/usecase/visualsearch/model_test.go +21 -0
| @@ -186,3 +186,24 @@ func rowText(row []renderer.Cell) string { | |||
| 186 | 186 | } | |
| 187 | 187 | return b.String() | |
| 188 | 188 | } | |
| 189 | + | ||
| 190 | + | func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) { | |
| 191 | + | base := renderer.Style{Foreground: 1, Background: 2} | |
| 192 | + | selection := renderer.Style{Foreground: 3, Background: 4} | |
| 193 | + | for _, tt := range []struct { | |
| 194 | + | name string | |
| 195 | + | x, y int | |
| 196 | + | want renderer.Style | |
| 197 | + | why string | |
| 198 | + | }{ | |
| 199 | + | {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"}, | |
| 200 | + | {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"}, | |
| 201 | + | } { | |
| 202 | + | t.Run(tt.name, func(t *testing.T) { | |
| 203 | + | m := New(testSnapshot("alpha")) | |
| 204 | + | m.Insert('a') | |
| 205 | + | frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection}) | |
| 206 | + | require.True(t, frame.At(tt.x, tt.y).Style.Equal(tt.want), tt.why) | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | } | |
M transport_replay_integration_test.go +77 -0
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package main_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "io" | |
| 4 | 5 | "net" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 10 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 9 | 11 | "github.com/bnema/vev/internal/ports" | |
| 10 | 12 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 11 | 13 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -46,6 +48,81 @@ func TestTransportReplayIntegration(t *testing.T) { | |||
| 46 | 48 | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame)) | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 51 | + | // TestThemeGenerationTransportSequences proves the generation's cleared then | |
| 52 | + | // definitive wire sequence is unchanged across the local socket and remote | |
| 53 | + | // SSH-stdio transports. The transports only carry frames; neither can infer | |
| 54 | + | // or alter the palette lifecycle. | |
| 55 | + | func TestThemeGenerationTransportSequences(t *testing.T) { | |
| 56 | + | cleared := ports.Theme{ | |
| 57 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 58 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 59 | + | TrueColor: true, SchemeKnown: true, | |
| 60 | + | } | |
| 61 | + | palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}} | |
| 62 | + | definitive := cleared | |
| 63 | + | definitive.Palette = palette | |
| 64 | + | definitive.PaletteKnown = 1<<2 | 1<<10 | |
| 65 | + | sequence := []ports.Theme{cleared, definitive} | |
| 66 | + | ||
| 67 | + | tests := []struct { | |
| 68 | + | name string | |
| 69 | + | pair func(*testing.T) (ports.Transport, ports.Transport) | |
| 70 | + | }{ | |
| 71 | + | { | |
| 72 | + | name: "local ipc socket", | |
| 73 | + | pair: func(t *testing.T) (ports.Transport, ports.Transport) { | |
| 74 | + | left, right := net.Pipe() | |
| 75 | + | t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) | |
| 76 | + | return ipc.NewTransport(left), ipc.NewTransport(right) | |
| 77 | + | }, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "remote ssh stdio", | |
| 81 | + | pair: func(t *testing.T) (ports.Transport, ports.Transport) { | |
| 82 | + | clientRead, serverWrite := io.Pipe() | |
| 83 | + | serverRead, clientWrite := io.Pipe() | |
| 84 | + | closeClient := func() error { | |
| 85 | + | _ = clientRead.Close() | |
| 86 | + | return clientWrite.Close() | |
| 87 | + | } | |
| 88 | + | closeServer := func() error { | |
| 89 | + | _ = serverRead.Close() | |
| 90 | + | return serverWrite.Close() | |
| 91 | + | } | |
| 92 | + | t.Cleanup(func() { _ = closeClient(); _ = closeServer() }) | |
| 93 | + | return sshstdio.NewTransport(clientRead, clientWrite, closeClient), sshstdio.NewTransport(serverRead, serverWrite, closeServer) | |
| 94 | + | }, | |
| 95 | + | }, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | for _, tt := range tests { | |
| 99 | + | t.Run(tt.name, func(t *testing.T) { | |
| 100 | + | sender, receiver := tt.pair(t) | |
| 101 | + | done := make(chan error, 1) | |
| 102 | + | go func() { | |
| 103 | + | for _, snapshot := range sequence { | |
| 104 | + | if err := sender.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}); err != nil { | |
| 105 | + | done <- err | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | done <- nil | |
| 110 | + | }() | |
| 111 | + | ||
| 112 | + | for _, want := range sequence { | |
| 113 | + | frame, err := receiver.Recv() | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | require.Equal(t, ports.MsgTheme, frame.Type) | |
| 116 | + | require.Equal(t, ports.MarshalTheme(want), frame.Payload, "theme payload must be preserved byte-for-byte before decode") | |
| 117 | + | got, err := ports.UnmarshalTheme(frame.Payload) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | require.Equal(t, want, got) | |
| 120 | + | } | |
| 121 | + | require.NoError(t, <-done) | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 49 | 126 | func integrationFrameRows(frame renderer.Frame) []string { | |
| 50 | 127 | rows := make([]string, frame.Height) | |
| 51 | 128 | for y := range rows { | |