feat(client): track palette responses with coalesced theme sends
- commit
- 2274b2669bfb0e6eb3aedd3f5d93040271efef39
- author
- brice · · 2026-07-17 08:27 UTC
- parent
- 75e21f8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +196 -16
M internal/usecase/client/client.go +47 -16
| @@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme { | |||
| 71 | 71 | return s.theme | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |
| 74 | + | func (s *terminalThemeState) snapshot() ports.Theme { | |
| 75 | 75 | s.mu.Lock() | |
| 76 | 76 | defer s.mu.Unlock() | |
| 77 | - | theme := s.theme | |
| 78 | - | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown | |
| 77 | + | return s.theme | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // clearPalette invalidates entries from a previous terminal color query while | |
| 81 | + | // retaining independently reported foreground, background, and scheme data. | |
| 82 | + | func (s *terminalThemeState) clearPalette() ports.Theme { | |
| 83 | + | return s.update(func(theme *ports.Theme) { | |
| 84 | + | theme.PaletteKnown = 0 | |
| 85 | + | theme.Palette = [16]renderer.RGB{} | |
| 86 | + | }) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |
| 90 | + | theme := s.snapshot() | |
| 91 | + | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0 | |
| 79 | 92 | } | |
| 80 | 93 | ||
| 81 | 94 | // milestones tracks lifecycle progress so a failure can report which stages | |
| @@ -646,6 +659,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 646 | 659 | return welcomedResult(errLinkOffline) | |
| 647 | 660 | } | |
| 648 | 661 | case <-colorQueryCh: | |
| 662 | + | // A fresh query supersedes every prior OSC 4 response. Queue the | |
| 663 | + | // cleared snapshot before writing the query so the daemon never | |
| 664 | + | // applies stale palette entries while it awaits the new responses. | |
| 665 | + | clearedTheme := themeState.clearPalette() | |
| 666 | + | select { | |
| 667 | + | case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}: | |
| 668 | + | case <-loopCtx.Done(): | |
| 669 | + | return welcomedResult(nil) | |
| 670 | + | } | |
| 649 | 671 | if err := term.QueryColors(); err != nil { | |
| 650 | 672 | log.Warn("querying terminal colors", "err", err) | |
| 651 | 673 | } | |
| @@ -876,11 +898,10 @@ func (p *stdinPump) run() { | |||
| 876 | 898 | for { | |
| 877 | 899 | n, rerr := in.Read(buf) | |
| 878 | 900 | if n > 0 { | |
| 879 | - | sendTheme := func(current ports.Theme) { | |
| 880 | - | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 881 | - | } | |
| 901 | + | themeChanged := false | |
| 902 | + | queryColors := false | |
| 882 | 903 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 883 | - | current := themeState.update(func(current *ports.Theme) { | |
| 904 | + | themeState.update(func(current *ports.Theme) { | |
| 884 | 905 | switch kind { | |
| 885 | 906 | case 10: | |
| 886 | 907 | current.HasForeground = true | |
| @@ -890,20 +911,30 @@ func (p *stdinPump) run() { | |||
| 890 | 911 | current.Background = rgb | |
| 891 | 912 | } | |
| 892 | 913 | }) | |
| 893 | - | sendTheme(current) | |
| 894 | - | }, func(int, renderer.RGB) { | |
| 895 | - | // Palette inheritance is wired by the theme update path; retain a | |
| 896 | - | // no-op callback here until that path owns palette updates. | |
| 914 | + | themeChanged = true | |
| 915 | + | }, func(slot int, rgb renderer.RGB) { | |
| 916 | + | themeState.update(func(current *ports.Theme) { | |
| 917 | + | current.PaletteKnown |= uint16(1) << slot | |
| 918 | + | current.Palette[slot] = rgb | |
| 919 | + | }) | |
| 920 | + | themeChanged = true | |
| 897 | 921 | }, func(light bool) { | |
| 898 | - | current := themeState.update(func(current *ports.Theme) { | |
| 922 | + | themeState.update(func(current *ports.Theme) { | |
| 899 | 923 | current.SchemeKnown = true | |
| 900 | 924 | current.Light = light | |
| 901 | 925 | }) | |
| 902 | - | if requestColors != nil { | |
| 903 | - | requestColors() | |
| 904 | - | } | |
| 905 | - | sendTheme(current) | |
| 926 | + | themeChanged = true | |
| 927 | + | queryColors = true | |
| 906 | 928 | }, sink) | |
| 929 | + | if themeChanged { | |
| 930 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())}) | |
| 931 | + | } | |
| 932 | + | // Defer the re-query until this read's complete snapshot is queued; | |
| 933 | + | // otherwise the main loop could invalidate palette state midway | |
| 934 | + | // through a chunk containing multiple terminal responses. | |
| 935 | + | if queryColors && requestColors != nil { | |
| 936 | + | requestColors() | |
| 937 | + | } | |
| 907 | 938 | if !sendOK.Load() { | |
| 908 | 939 | return | |
| 909 | 940 | } | |
| @@ -71,11 +71,24 @@ func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme { | |||||
| 71 | return s.theme | 71 | return s.theme | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | - | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | 74 | + | func (s *terminalThemeState) snapshot() ports.Theme { |
| 75 | s.mu.Lock() | 75 | s.mu.Lock() | ||
| 76 | defer s.mu.Unlock() | 76 | defer s.mu.Unlock() | ||
| 77 | - | theme := s.theme | 77 | + | return s.theme |
| 78 | - | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown | 78 | + | } |
| 79 | + | ||||
| 80 | + | // clearPalette invalidates entries from a previous terminal color query while | |||
| 81 | + | // retaining independently reported foreground, background, and scheme data. | |||
| 82 | + | func (s *terminalThemeState) clearPalette() ports.Theme { | |||
| 83 | + | return s.update(func(theme *ports.Theme) { | |||
| 84 | + | theme.PaletteKnown = 0 | |||
| 85 | + | theme.Palette = [16]renderer.RGB{} | |||
| 86 | + | }) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |||
| 90 | + | theme := s.snapshot() | |||
| 91 | + | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown || theme.PaletteKnown != 0 | |||
| 79 | } | 92 | } | ||
| 80 | 93 | ||||
| 81 | // milestones tracks lifecycle progress so a failure can report which stages | 94 | // milestones tracks lifecycle progress so a failure can report which stages | ||
| @@ -646,6 +659,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 646 | return welcomedResult(errLinkOffline) | 659 | return welcomedResult(errLinkOffline) | ||
| 647 | } | 660 | } | ||
| 648 | case <-colorQueryCh: | 661 | case <-colorQueryCh: | ||
| 662 | + | // A fresh query supersedes every prior OSC 4 response. Queue the | |||
| 663 | + | // cleared snapshot before writing the query so the daemon never | |||
| 664 | + | // applies stale palette entries while it awaits the new responses. | |||
| 665 | + | clearedTheme := themeState.clearPalette() | |||
| 666 | + | select { | |||
| 667 | + | case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}: | |||
| 668 | + | case <-loopCtx.Done(): | |||
| 669 | + | return welcomedResult(nil) | |||
| 670 | + | } | |||
| 649 | if err := term.QueryColors(); err != nil { | 671 | if err := term.QueryColors(); err != nil { | ||
| 650 | log.Warn("querying terminal colors", "err", err) | 672 | log.Warn("querying terminal colors", "err", err) | ||
| 651 | } | 673 | } | ||
| @@ -876,11 +898,10 @@ func (p *stdinPump) run() { | |||||
| 876 | for { | 898 | for { | ||
| 877 | n, rerr := in.Read(buf) | 899 | n, rerr := in.Read(buf) | ||
| 878 | if n > 0 { | 900 | if n > 0 { | ||
| 879 | - | sendTheme := func(current ports.Theme) { | 901 | + | themeChanged := false |
| 880 | - | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | 902 | + | queryColors := false |
| 881 | - | } | |||
| 882 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | 903 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | ||
| 883 | - | current := themeState.update(func(current *ports.Theme) { | 904 | + | themeState.update(func(current *ports.Theme) { |
| 884 | switch kind { | 905 | switch kind { | ||
| 885 | case 10: | 906 | case 10: | ||
| 886 | current.HasForeground = true | 907 | current.HasForeground = true | ||
| @@ -890,20 +911,30 @@ func (p *stdinPump) run() { | |||||
| 890 | current.Background = rgb | 911 | current.Background = rgb | ||
| 891 | } | 912 | } | ||
| 892 | }) | 913 | }) | ||
| 893 | - | sendTheme(current) | 914 | + | themeChanged = true |
| 894 | - | }, func(int, renderer.RGB) { | 915 | + | }, func(slot int, rgb renderer.RGB) { |
| 895 | - | // Palette inheritance is wired by the theme update path; retain a | 916 | + | themeState.update(func(current *ports.Theme) { |
| 896 | - | // no-op callback here until that path owns palette updates. | 917 | + | current.PaletteKnown |= uint16(1) << slot |
| 918 | + | current.Palette[slot] = rgb | |||
| 919 | + | }) | |||
| 920 | + | themeChanged = true | |||
| 897 | }, func(light bool) { | 921 | }, func(light bool) { | ||
| 898 | - | current := themeState.update(func(current *ports.Theme) { | 922 | + | themeState.update(func(current *ports.Theme) { |
| 899 | current.SchemeKnown = true | 923 | current.SchemeKnown = true | ||
| 900 | current.Light = light | 924 | current.Light = light | ||
| 901 | }) | 925 | }) | ||
| 902 | - | if requestColors != nil { | 926 | + | themeChanged = true |
| 903 | - | requestColors() | 927 | + | queryColors = true |
| 904 | - | } | |||
| 905 | - | sendTheme(current) | |||
| 906 | }, sink) | 928 | }, sink) | ||
| 929 | + | if themeChanged { | |||
| 930 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())}) | |||
| 931 | + | } | |||
| 932 | + | // Defer the re-query until this read's complete snapshot is queued; | |||
| 933 | + | // otherwise the main loop could invalidate palette state midway | |||
| 934 | + | // through a chunk containing multiple terminal responses. | |||
| 935 | + | if queryColors && requestColors != nil { | |||
| 936 | + | requestColors() | |||
| 937 | + | } | |||
| 907 | if !sendOK.Load() { | 938 | if !sendOK.Load() { | ||
| 908 | return | 939 | return | ||
| 909 | } | 940 | } | ||
M internal/usecase/client/client_test.go +72 -0
| @@ -1047,3 +1047,75 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 1047 | 1047 | require.Equal(t, int32(1), d.calls.Load()) | |
| 1048 | 1048 | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 1049 | 1049 | } | |
| 1050 | + | ||
| 1051 | + | func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) { | |
| 1052 | + | var out bytes.Buffer | |
| 1053 | + | var restoreCount atomic.Int32 | |
| 1054 | + | resizeCh := make(chan domain.Size) | |
| 1055 | + | input := newChunkedBlockingReader( | |
| 1056 | + | []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"), | |
| 1057 | + | []byte("\x1b[?997;2n"), | |
| 1058 | + | ) | |
| 1059 | + | defer input.unblock() | |
| 1060 | + | ||
| 1061 | + | tm := portsmocks.NewMockTerminal(t) | |
| 1062 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 1063 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |
| 1064 | + | tm.EXPECT().In().Return(input).Maybe() | |
| 1065 | + | tm.EXPECT().Out().Return(&out).Maybe() | |
| 1066 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 1067 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 1068 | + | tm.EXPECT().QueryColors().Return(nil).Once() | |
| 1069 | + | ||
| 1070 | + | var themes []ports.Theme | |
| 1071 | + | var themesMu sync.Mutex | |
| 1072 | + | clearedPalette := make(chan struct{}) | |
| 1073 | + | var clearOnce sync.Once | |
| 1074 | + | tr := portsmocks.NewMockTransport(t) | |
| 1075 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 1076 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |
| 1077 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |
| 1078 | + | require.NoError(t, err) | |
| 1079 | + | themesMu.Lock() | |
| 1080 | + | themes = append(themes, theme) | |
| 1081 | + | themesMu.Unlock() | |
| 1082 | + | if theme.SchemeKnown && theme.PaletteKnown == 0 { | |
| 1083 | + | clearOnce.Do(func() { close(clearedPalette) }) | |
| 1084 | + | } | |
| 1085 | + | return nil | |
| 1086 | + | }).Times(3) | |
| 1087 | + | ||
| 1088 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 1089 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 1090 | + | closed := make(chan struct{}) | |
| 1091 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 1092 | + | select { | |
| 1093 | + | case <-closed: | |
| 1094 | + | return ports.Frame{}, io.EOF | |
| 1095 | + | default: | |
| 1096 | + | } | |
| 1097 | + | select { | |
| 1098 | + | case <-clearedPalette: | |
| 1099 | + | close(closed) | |
| 1100 | + | return detached, nil | |
| 1101 | + | default: | |
| 1102 | + | return welcome, nil | |
| 1103 | + | } | |
| 1104 | + | }).Maybe() | |
| 1105 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 1106 | + | ||
| 1107 | + | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |
| 1108 | + | require.Equal(t, int32(1), restoreCount.Load()) | |
| 1109 | + | ||
| 1110 | + | themesMu.Lock() | |
| 1111 | + | defer themesMu.Unlock() | |
| 1112 | + | require.Len(t, themes, 3, "one complete snapshot per input chunk plus the re-query invalidation") | |
| 1113 | + | require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown) | |
| 1114 | + | require.True(t, themes[0].HasBackground) | |
| 1115 | + | require.Equal(t, uint16(1<<1|1<<14), themes[1].PaletteKnown, "scheme update retains palette until re-query") | |
| 1116 | + | require.True(t, themes[1].SchemeKnown) | |
| 1117 | + | require.Zero(t, themes[2].PaletteKnown, "re-query must invalidate stale palette entries") | |
| 1118 | + | require.Equal(t, themes[0].Background, themes[2].Background) | |
| 1119 | + | require.True(t, themes[2].SchemeKnown) | |
| 1120 | + | require.True(t, themes[2].Light) | |
| 1121 | + | } | |
| @@ -1047,3 +1047,75 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||||
| 1047 | require.Equal(t, int32(1), d.calls.Load()) | 1047 | require.Equal(t, int32(1), d.calls.Load()) | ||
| 1048 | require.Equal(t, int32(1), term.restoreCount.Load()) | 1048 | require.Equal(t, int32(1), term.restoreCount.Load()) | ||
| 1049 | } | 1049 | } | ||
| 1050 | + | ||||
| 1051 | + | func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) { | |||
| 1052 | + | var out bytes.Buffer | |||
| 1053 | + | var restoreCount atomic.Int32 | |||
| 1054 | + | resizeCh := make(chan domain.Size) | |||
| 1055 | + | input := newChunkedBlockingReader( | |||
| 1056 | + | []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"), | |||
| 1057 | + | []byte("\x1b[?997;2n"), | |||
| 1058 | + | ) | |||
| 1059 | + | defer input.unblock() | |||
| 1060 | + | ||||
| 1061 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 1062 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 1063 | + | tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once() | |||
| 1064 | + | tm.EXPECT().In().Return(input).Maybe() | |||
| 1065 | + | tm.EXPECT().Out().Return(&out).Maybe() | |||
| 1066 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 1067 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 1068 | + | tm.EXPECT().QueryColors().Return(nil).Once() | |||
| 1069 | + | ||||
| 1070 | + | var themes []ports.Theme | |||
| 1071 | + | var themesMu sync.Mutex | |||
| 1072 | + | clearedPalette := make(chan struct{}) | |||
| 1073 | + | var clearOnce sync.Once | |||
| 1074 | + | tr := portsmocks.NewMockTransport(t) | |||
| 1075 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 1076 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error { | |||
| 1077 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |||
| 1078 | + | require.NoError(t, err) | |||
| 1079 | + | themesMu.Lock() | |||
| 1080 | + | themes = append(themes, theme) | |||
| 1081 | + | themesMu.Unlock() | |||
| 1082 | + | if theme.SchemeKnown && theme.PaletteKnown == 0 { | |||
| 1083 | + | clearOnce.Do(func() { close(clearedPalette) }) | |||
| 1084 | + | } | |||
| 1085 | + | return nil | |||
| 1086 | + | }).Times(3) | |||
| 1087 | + | ||||
| 1088 | + | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |||
| 1089 | + | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |||
| 1090 | + | closed := make(chan struct{}) | |||
| 1091 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 1092 | + | select { | |||
| 1093 | + | case <-closed: | |||
| 1094 | + | return ports.Frame{}, io.EOF | |||
| 1095 | + | default: | |||
| 1096 | + | } | |||
| 1097 | + | select { | |||
| 1098 | + | case <-clearedPalette: | |||
| 1099 | + | close(closed) | |||
| 1100 | + | return detached, nil | |||
| 1101 | + | default: | |||
| 1102 | + | return welcome, nil | |||
| 1103 | + | } | |||
| 1104 | + | }).Maybe() | |||
| 1105 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 1106 | + | ||||
| 1107 | + | require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral})) | |||
| 1108 | + | require.Equal(t, int32(1), restoreCount.Load()) | |||
| 1109 | + | ||||
| 1110 | + | themesMu.Lock() | |||
| 1111 | + | defer themesMu.Unlock() | |||
| 1112 | + | require.Len(t, themes, 3, "one complete snapshot per input chunk plus the re-query invalidation") | |||
| 1113 | + | require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown) | |||
| 1114 | + | require.True(t, themes[0].HasBackground) | |||
| 1115 | + | require.Equal(t, uint16(1<<1|1<<14), themes[1].PaletteKnown, "scheme update retains palette until re-query") | |||
| 1116 | + | require.True(t, themes[1].SchemeKnown) | |||
| 1117 | + | require.Zero(t, themes[2].PaletteKnown, "re-query must invalidate stale palette entries") | |||
| 1118 | + | require.Equal(t, themes[0].Background, themes[2].Background) | |||
| 1119 | + | require.True(t, themes[2].SchemeKnown) | |||
| 1120 | + | require.True(t, themes[2].Light) | |||
| 1121 | + | } | |||
A internal/usecase/client/theme_state_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/pkg/renderer" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) { | |
| 17 | + | state := &terminalThemeState{} | |
| 18 | + | state.update(func(theme *ports.Theme) { | |
| 19 | + | theme.HasForeground = true | |
| 20 | + | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |
| 21 | + | theme.HasBackground = true | |
| 22 | + | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |
| 23 | + | theme.SchemeKnown = true | |
| 24 | + | theme.Light = true | |
| 25 | + | theme.PaletteKnown = 1<<1 | 1<<14 | |
| 26 | + | theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9} | |
| 27 | + | theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12} | |
| 28 | + | }) | |
| 29 | + | ||
| 30 | + | got := state.clearPalette() | |
| 31 | + | require.True(t, got.HasForeground) | |
| 32 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground) | |
| 33 | + | require.True(t, got.HasBackground) | |
| 34 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background) | |
| 35 | + | require.True(t, got.SchemeKnown) | |
| 36 | + | require.True(t, got.Light) | |
| 37 | + | require.Zero(t, got.PaletteKnown) | |
| 38 | + | require.Equal(t, [16]renderer.RGB{}, got.Palette) | |
| 39 | + | ||
| 40 | + | _, reported := state.reportedTheme() | |
| 41 | + | require.True(t, reported, "foreground/background remain usable after palette invalidation") | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) { | |
| 45 | + | state := &terminalThemeState{} | |
| 46 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 47 | + | defer cancel() | |
| 48 | + | out := make(chan ports.Frame, 3) | |
| 49 | + | pump := stdinPump{ | |
| 50 | + | ctx: ctx, | |
| 51 | + | cancel: cancel, | |
| 52 | + | in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")), | |
| 53 | + | out: out, | |
| 54 | + | themeState: state, | |
| 55 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | pump.run() | |
| 59 | + | ||
| 60 | + | first := <-out | |
| 61 | + | require.Equal(t, ports.MsgTheme, first.Type) | |
| 62 | + | theme, err := ports.UnmarshalTheme(first.Payload) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.True(t, theme.HasBackground) | |
| 65 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background) | |
| 66 | + | require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown) | |
| 67 | + | require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1]) | |
| 68 | + | require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14]) | |
| 69 | + | ||
| 70 | + | second := <-out | |
| 71 | + | require.Equal(t, ports.MsgDetach, second.Type) | |
| 72 | + | select { | |
| 73 | + | case extra := <-out: | |
| 74 | + | t.Fatalf("got extra frame after one input chunk: %v", extra.Type) | |
| 75 | + | default: | |
| 76 | + | } | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) { | |||
| 17 | + | state := &terminalThemeState{} | |||
| 18 | + | state.update(func(theme *ports.Theme) { | |||
| 19 | + | theme.HasForeground = true | |||
| 20 | + | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 21 | + | theme.HasBackground = true | |||
| 22 | + | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |||
| 23 | + | theme.SchemeKnown = true | |||
| 24 | + | theme.Light = true | |||
| 25 | + | theme.PaletteKnown = 1<<1 | 1<<14 | |||
| 26 | + | theme.Palette[1] = renderer.RGB{R: 7, G: 8, B: 9} | |||
| 27 | + | theme.Palette[14] = renderer.RGB{R: 10, G: 11, B: 12} | |||
| 28 | + | }) | |||
| 29 | + | ||||
| 30 | + | got := state.clearPalette() | |||
| 31 | + | require.True(t, got.HasForeground) | |||
| 32 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, got.Foreground) | |||
| 33 | + | require.True(t, got.HasBackground) | |||
| 34 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, got.Background) | |||
| 35 | + | require.True(t, got.SchemeKnown) | |||
| 36 | + | require.True(t, got.Light) | |||
| 37 | + | require.Zero(t, got.PaletteKnown) | |||
| 38 | + | require.Equal(t, [16]renderer.RGB{}, got.Palette) | |||
| 39 | + | ||||
| 40 | + | _, reported := state.reportedTheme() | |||
| 41 | + | require.True(t, reported, "foreground/background remain usable after palette invalidation") | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) { | |||
| 45 | + | state := &terminalThemeState{} | |||
| 46 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 47 | + | defer cancel() | |||
| 48 | + | out := make(chan ports.Frame, 3) | |||
| 49 | + | pump := stdinPump{ | |||
| 50 | + | ctx: ctx, | |||
| 51 | + | cancel: cancel, | |||
| 52 | + | in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")), | |||
| 53 | + | out: out, | |||
| 54 | + | themeState: state, | |||
| 55 | + | logger: slog.New(slog.NewTextHandler(io.Discard, nil)), | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | pump.run() | |||
| 59 | + | ||||
| 60 | + | first := <-out | |||
| 61 | + | require.Equal(t, ports.MsgTheme, first.Type) | |||
| 62 | + | theme, err := ports.UnmarshalTheme(first.Payload) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.True(t, theme.HasBackground) | |||
| 65 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background) | |||
| 66 | + | require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown) | |||
| 67 | + | require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1]) | |||
| 68 | + | require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14]) | |||
| 69 | + | ||||
| 70 | + | second := <-out | |||
| 71 | + | require.Equal(t, ports.MsgDetach, second.Type) | |||
| 72 | + | select { | |||
| 73 | + | case extra := <-out: | |||
| 74 | + | t.Fatalf("got extra frame after one input chunk: %v", extra.Type) | |||
| 75 | + | default: | |||
| 76 | + | } | |||
| 77 | + | } | |||