fix(client): render pickers with the terminal color profile
The session picker and the move picker hardcoded a truecolor renderer, so an ANSI-256 terminal received RGB SGR from picker boxes (the #280 regression). Both now take the profile from the detected terminal capability carried by AttachmentEnvironment.TrueColor.
- commit
- 25dcf71b8b0d73b2d46fbba561cc485c5b48e831
- author
- brice · · 2026-09-22 19:51 UTC
- parent
- 0f340fa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +71 -19
M internal/app/broker_client.go +1 -1
| @@ -218,7 +218,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||
| 218 | 218 | if sessionEnv.Cwd != "" { | |
| 219 | 219 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 220 | 220 | } | |
| 221 | - | picker := client.NewPicker(clk, 0) | |
| 221 | + | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | |
| 222 | 222 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState} | |
| 223 | 223 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 224 | 224 | Connector: cfg.Connector, | |
| @@ -218,7 +218,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||||
| 218 | if sessionEnv.Cwd != "" { | 218 | if sessionEnv.Cwd != "" { | ||
| 219 | attachmentEnv.Cwd = sessionEnv.Cwd | 219 | attachmentEnv.Cwd = sessionEnv.Cwd | ||
| 220 | } | 220 | } | ||
| 221 | - | picker := client.NewPicker(clk, 0) | 221 | + | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) |
| 222 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState} | 222 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState} | ||
| 223 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | 223 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | ||
| 224 | Connector: cfg.Connector, | 224 | Connector: cfg.Connector, | ||
M internal/app/broker_offline_client_render_test.go +1 -1
| @@ -104,7 +104,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) { | |||
| 104 | 104 | // daemon with a single session, so Render produces a real frame at any size. | |
| 105 | 105 | func offlineRenderPicker(t *testing.T) *client.Picker { | |
| 106 | 106 | t.Helper() | |
| 107 | - | picker := client.NewPicker(nil, 0) | |
| 107 | + | picker := client.NewPicker(nil, 0, true) | |
| 108 | 108 | picker.ApplySnapshot(ports.BrokerSnapshot{ | |
| 109 | 109 | Epoch: 1, | |
| 110 | 110 | Revision: 1, | |
| @@ -104,7 +104,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) { | |||||
| 104 | // daemon with a single session, so Render produces a real frame at any size. | 104 | // daemon with a single session, so Render produces a real frame at any size. | ||
| 105 | func offlineRenderPicker(t *testing.T) *client.Picker { | 105 | func offlineRenderPicker(t *testing.T) *client.Picker { | ||
| 106 | t.Helper() | 106 | t.Helper() | ||
| 107 | - | picker := client.NewPicker(nil, 0) | 107 | + | picker := client.NewPicker(nil, 0, true) |
| 108 | picker.ApplySnapshot(ports.BrokerSnapshot{ | 108 | picker.ApplySnapshot(ports.BrokerSnapshot{ | ||
| 109 | Epoch: 1, | 109 | Epoch: 1, | ||
| 110 | Revision: 1, | 110 | Revision: 1, | ||
M internal/usecase/client/attachment_session.go +1 -1
| @@ -285,7 +285,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 285 | 285 | if overlay != nil { | |
| 286 | 286 | repaint = overlay.overlayRepaint() | |
| 287 | 287 | } | |
| 288 | - | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()} | |
| 288 | + | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | |
| 289 | 289 | defer picker.stopEscape() | |
| 290 | 290 | var inputSeq uint64 | |
| 291 | 291 | for { | |
| @@ -285,7 +285,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 285 | if overlay != nil { | 285 | if overlay != nil { | ||
| 286 | repaint = overlay.overlayRepaint() | 286 | repaint = overlay.overlayRepaint() | ||
| 287 | } | 287 | } | ||
| 288 | - | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()} | 288 | + | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} |
| 289 | defer picker.stopEscape() | 289 | defer picker.stopEscape() | ||
| 290 | var inputSeq uint64 | 290 | var inputSeq uint64 | ||
| 291 | for { | 291 | for { | ||
M internal/usecase/client/move_picker_overlay.go +2 -3
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | - | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 8 | "github.com/bnema/vev/internal/domain" | |
| 10 | 9 | "github.com/bnema/vev/internal/ports" | |
| 11 | 10 | "github.com/bnema/vev/internal/protocol" | |
| @@ -39,8 +38,8 @@ type movePickerOverlay struct { | |||
| 39 | 38 | presenting bool | |
| 40 | 39 | } | |
| 41 | 40 | ||
| 42 | - | func newMovePickerOverlay() *movePickerOverlay { | |
| 43 | - | return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)} | |
| 41 | + | func newMovePickerOverlay(trueColor bool) *movePickerOverlay { | |
| 42 | + | return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))} | |
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | 45 | // offer opens the namespace a move offer names. A superseding offer retires | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | - | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/protocol" | 10 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -39,8 +38,8 @@ type movePickerOverlay struct { | |||||
| 39 | presenting bool | 38 | presenting bool | ||
| 40 | } | 39 | } | ||
| 41 | 40 | ||||
| 42 | - | func newMovePickerOverlay() *movePickerOverlay { | 41 | + | func newMovePickerOverlay(trueColor bool) *movePickerOverlay { |
| 43 | - | return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)} | 42 | + | return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))} |
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| 46 | // offer opens the namespace a move offer names. A superseding offer retires | 45 | // offer opens the namespace a move offer names. A superseding offer retires | ||
M internal/usecase/client/picker_composition.go +5 -3
| @@ -48,9 +48,11 @@ func PickerPresentation(state State) bool { | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | // NewPicker composes one client-owned picker. A nil clock uses the system | |
| 51 | - | // clock, and a zero freshness uses the catalogue default. | |
| 52 | - | func NewPicker(clock ports.Clock, freshness time.Duration) *Picker { | |
| 53 | - | return &Picker{controller: newPickerController(clock, freshness)} | |
| 51 | + | // clock, and a zero freshness uses the catalogue default. trueColor is the | |
| 52 | + | // terminal's detected color capability (AttachmentEnvironment.TrueColor); the | |
| 53 | + | // picker renders indexed colors without it. | |
| 54 | + | func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker { | |
| 55 | + | return &Picker{controller: newPickerController(clock, freshness, trueColor)} | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | 58 | // ApplySnapshot forwards a broker publication to the supervisor-owned picker. | |
| @@ -48,9 +48,11 @@ func PickerPresentation(state State) bool { | |||||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | // NewPicker composes one client-owned picker. A nil clock uses the system | 50 | // NewPicker composes one client-owned picker. A nil clock uses the system | ||
| 51 | - | // clock, and a zero freshness uses the catalogue default. | 51 | + | // clock, and a zero freshness uses the catalogue default. trueColor is the |
| 52 | - | func NewPicker(clock ports.Clock, freshness time.Duration) *Picker { | 52 | + | // terminal's detected color capability (AttachmentEnvironment.TrueColor); the |
| 53 | - | return &Picker{controller: newPickerController(clock, freshness)} | 53 | + | // picker renders indexed colors without it. |
| 54 | + | func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker { | |||
| 55 | + | return &Picker{controller: newPickerController(clock, freshness, trueColor)} | |||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
| 56 | // ApplySnapshot forwards a broker publication to the supervisor-owned picker. | 58 | // ApplySnapshot forwards a broker publication to the supervisor-owned picker. | ||
M internal/usecase/client/picker_composition_external_test.go +1 -1
| @@ -19,7 +19,7 @@ import ( | |||
| 19 | 19 | // so it could never resolve a selection into an attachment. | |
| 20 | 20 | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | |
| 21 | 21 | geometry := domain.Size{Cols: 80, Rows: 24} | |
| 22 | - | picker := client.NewPicker(nil, 0) | |
| 22 | + | picker := client.NewPicker(nil, 0, true) | |
| 23 | 23 | require.NotNil(t, picker) | |
| 24 | 24 | ||
| 25 | 25 | // The exact assignment an app composition performs. | |
| @@ -19,7 +19,7 @@ import ( | |||||
| 19 | // so it could never resolve a selection into an attachment. | 19 | // so it could never resolve a selection into an attachment. | ||
| 20 | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | 20 | func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) { | ||
| 21 | geometry := domain.Size{Cols: 80, Rows: 24} | 21 | geometry := domain.Size{Cols: 80, Rows: 24} | ||
| 22 | - | picker := client.NewPicker(nil, 0) | 22 | + | picker := client.NewPicker(nil, 0, true) |
| 23 | require.NotNil(t, picker) | 23 | require.NotNil(t, picker) | ||
| 24 | 24 | ||||
| 25 | // The exact assignment an app composition performs. | 25 | // The exact assignment an app composition performs. | ||
M internal/usecase/client/picker_controller.go +2 -3
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | - | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 8 | "github.com/bnema/vev/internal/domain" | |
| 10 | 9 | "github.com/bnema/vev/internal/ports" | |
| 11 | 10 | "github.com/bnema/vev/internal/protocol" | |
| @@ -112,14 +111,14 @@ type pickerController struct { | |||
| 112 | 111 | // newPickerController builds a picker over an empty catalogue. The picker owns | |
| 113 | 112 | // input from the start: P5.2b presents it for the whole picker presentation and | |
| 114 | 113 | // has no session pipeline to defer to. | |
| 115 | - | func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController { | |
| 114 | + | func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController { | |
| 116 | 115 | if supervisorNil(clock) { | |
| 117 | 116 | clock = systemClock{} | |
| 118 | 117 | } | |
| 119 | 118 | controller := &pickerController{ | |
| 120 | 119 | clock: clock, | |
| 121 | 120 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | |
| 122 | - | renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor), | |
| 121 | + | renderer: newPickerRenderer(pickerColorProfile(trueColor)), | |
| 123 | 122 | ownsInput: true, | |
| 124 | 123 | opsReady: make(chan struct{}, 1), | |
| 125 | 124 | } | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "sync" | 5 | "sync" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | - | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/protocol" | 10 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -112,14 +111,14 @@ type pickerController struct { | |||||
| 112 | // newPickerController builds a picker over an empty catalogue. The picker owns | 111 | // newPickerController builds a picker over an empty catalogue. The picker owns | ||
| 113 | // input from the start: P5.2b presents it for the whole picker presentation and | 112 | // input from the start: P5.2b presents it for the whole picker presentation and | ||
| 114 | // has no session pipeline to defer to. | 113 | // has no session pipeline to defer to. | ||
| 115 | - | func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController { | 114 | + | func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController { |
| 116 | if supervisorNil(clock) { | 115 | if supervisorNil(clock) { | ||
| 117 | clock = systemClock{} | 116 | clock = systemClock{} | ||
| 118 | } | 117 | } | ||
| 119 | controller := &pickerController{ | 118 | controller := &pickerController{ | ||
| 120 | clock: clock, | 119 | clock: clock, | ||
| 121 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | 120 | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | ||
| 122 | - | renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor), | 121 | + | renderer: newPickerRenderer(pickerColorProfile(trueColor)), |
| 123 | ownsInput: true, | 122 | ownsInput: true, | ||
| 124 | opsReady: make(chan struct{}, 1), | 123 | opsReady: make(chan struct{}, 1), | ||
| 125 | } | 124 | } | ||
M internal/usecase/client/picker_controller_test.go +3 -3
| @@ -115,7 +115,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, | |||
| 115 | 115 | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | |
| 116 | 116 | t.Helper() | |
| 117 | 117 | clock := newSupervisorTestClock() | |
| 118 | - | return newPickerController(clock, pickerTestFreshness), clock | |
| 118 | + | return newPickerController(clock, pickerTestFreshness, true), clock | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | |
| @@ -423,7 +423,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) { | |||
| 423 | 423 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 424 | 424 | return service, nil | |
| 425 | 425 | }) | |
| 426 | - | controller := newPickerController(clock, pickerTestFreshness) | |
| 426 | + | controller := newPickerController(clock, pickerTestFreshness, true) | |
| 427 | 427 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |
| 428 | 428 | ||
| 429 | 429 | runErr := make(chan error, 1) | |
| @@ -468,7 +468,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) { | |||
| 468 | 468 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 469 | 469 | return service, nil | |
| 470 | 470 | }) | |
| 471 | - | controller := newPickerController(clock, pickerTestFreshness) | |
| 471 | + | controller := newPickerController(clock, pickerTestFreshness, true) | |
| 472 | 472 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |
| 473 | 473 | ||
| 474 | 474 | runErr := make(chan error, 1) | |
| @@ -115,7 +115,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, | |||||
| 115 | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | 115 | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | ||
| 116 | t.Helper() | 116 | t.Helper() | ||
| 117 | clock := newSupervisorTestClock() | 117 | clock := newSupervisorTestClock() | ||
| 118 | - | return newPickerController(clock, pickerTestFreshness), clock | 118 | + | return newPickerController(clock, pickerTestFreshness, true), clock |
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | 121 | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | ||
| @@ -423,7 +423,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) { | |||||
| 423 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | 423 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | ||
| 424 | return service, nil | 424 | return service, nil | ||
| 425 | }) | 425 | }) | ||
| 426 | - | controller := newPickerController(clock, pickerTestFreshness) | 426 | + | controller := newPickerController(clock, pickerTestFreshness, true) |
| 427 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | 427 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | ||
| 428 | 428 | ||||
| 429 | runErr := make(chan error, 1) | 429 | runErr := make(chan error, 1) | ||
| @@ -468,7 +468,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) { | |||||
| 468 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | 468 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | ||
| 469 | return service, nil | 469 | return service, nil | ||
| 470 | }) | 470 | }) | ||
| 471 | - | controller := newPickerController(clock, pickerTestFreshness) | 471 | + | controller := newPickerController(clock, pickerTestFreshness, true) |
| 472 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | 472 | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | ||
| 473 | 473 | ||||
| 474 | runErr := make(chan error, 1) | 474 | runErr := make(chan error, 1) | ||
M internal/usecase/client/picker_interaction.go +10 -0
| @@ -432,6 +432,16 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer { | |||
| 432 | 432 | return &pickerRenderer{profile: profile} | |
| 433 | 433 | } | |
| 434 | 434 | ||
| 435 | + | // pickerColorProfile maps the terminal's detected color capability to the | |
| 436 | + | // picker renderer profile. A terminal without truecolor gets indexed colors, | |
| 437 | + | // so RGB surfaces are quantized instead of dropped (#280). | |
| 438 | + | func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile { | |
| 439 | + | if trueColor { | |
| 440 | + | return ansirenderer.ColorProfileTrueColor | |
| 441 | + | } | |
| 442 | + | return ansirenderer.ColorProfileANSI256 | |
| 443 | + | } | |
| 444 | + | ||
| 435 | 445 | // render composes the loop model into terminal bytes for one display | |
| 436 | 446 | // refresh. Previews arrive as source data (picker_preview.go) and are attached | |
| 437 | 447 | // to the model by the caller; a full redraw every refresh keeps the shadow | |
| @@ -432,6 +432,16 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer { | |||||
| 432 | return &pickerRenderer{profile: profile} | 432 | return &pickerRenderer{profile: profile} | ||
| 433 | } | 433 | } | ||
| 434 | 434 | ||||
| 435 | + | // pickerColorProfile maps the terminal's detected color capability to the | |||
| 436 | + | // picker renderer profile. A terminal without truecolor gets indexed colors, | |||
| 437 | + | // so RGB surfaces are quantized instead of dropped (#280). | |||
| 438 | + | func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile { | |||
| 439 | + | if trueColor { | |||
| 440 | + | return ansirenderer.ColorProfileTrueColor | |||
| 441 | + | } | |||
| 442 | + | return ansirenderer.ColorProfileANSI256 | |||
| 443 | + | } | |||
| 444 | + | ||||
| 435 | // render composes the loop model into terminal bytes for one display | 445 | // render composes the loop model into terminal bytes for one display | ||
| 436 | // refresh. Previews arrive as source data (picker_preview.go) and are attached | 446 | // refresh. Previews arrive as source data (picker_preview.go) and are attached | ||
| 437 | // to the model by the caller; a full redraw every refresh keeps the shadow | 447 | // to the model by the caller; a full redraw every refresh keeps the shadow | ||
M internal/usecase/client/picker_interaction_test.go +42 -0
| @@ -61,6 +61,48 @@ func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) { | |||
| 61 | 61 | require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))) | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | // TestPickerRenderersFollowTerminalColorProfile guards #280 on the broker | |
| 65 | + | // client: both the session picker and the move picker render with the | |
| 66 | + | // terminal's detected color profile, so an ANSI-256 terminal never receives | |
| 67 | + | // RGB SGR sequences from a picker box. | |
| 68 | + | func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) { | |
| 69 | + | loop := pickerLoopFixture(t) | |
| 70 | + | styles := picker.RenderStyles{ | |
| 71 | + | Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(), | |
| 72 | + | Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(), | |
| 73 | + | Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(), | |
| 74 | + | SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(), | |
| 75 | + | } | |
| 76 | + | renderers := map[string]func(trueColor bool) *pickerRenderer{ | |
| 77 | + | "session picker": func(trueColor bool) *pickerRenderer { | |
| 78 | + | return NewPicker(nil, 0, trueColor).controller.renderer | |
| 79 | + | }, | |
| 80 | + | "move picker": func(trueColor bool) *pickerRenderer { | |
| 81 | + | return newMovePickerOverlay(trueColor).renderer | |
| 82 | + | }, | |
| 83 | + | } | |
| 84 | + | for name, build := range renderers { | |
| 85 | + | for _, tc := range []struct { | |
| 86 | + | trueColor bool | |
| 87 | + | profile ansirenderer.ColorProfile | |
| 88 | + | want string | |
| 89 | + | forbidden string | |
| 90 | + | }{ | |
| 91 | + | {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`}, | |
| 92 | + | {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`}, | |
| 93 | + | } { | |
| 94 | + | t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) { | |
| 95 | + | r := build(tc.trueColor) | |
| 96 | + | require.Equal(t, tc.profile, r.profile) | |
| 97 | + | r.renderStyles = []picker.RenderStyles{styles} | |
| 98 | + | output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |
| 99 | + | require.Regexp(t, tc.want, output) | |
| 100 | + | require.NotRegexp(t, tc.forbidden, output) | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 64 | 106 | func rgbPickerStyle() renderer.Style { | |
| 65 | 107 | style := renderer.DefaultStyle() | |
| 66 | 108 | style.HasForegroundRGB = true | |
| @@ -61,6 +61,48 @@ func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) { | |||||
| 61 | require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))) | 61 | require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))) | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | + | // TestPickerRenderersFollowTerminalColorProfile guards #280 on the broker | |||
| 65 | + | // client: both the session picker and the move picker render with the | |||
| 66 | + | // terminal's detected color profile, so an ANSI-256 terminal never receives | |||
| 67 | + | // RGB SGR sequences from a picker box. | |||
| 68 | + | func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) { | |||
| 69 | + | loop := pickerLoopFixture(t) | |||
| 70 | + | styles := picker.RenderStyles{ | |||
| 71 | + | Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(), | |||
| 72 | + | Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(), | |||
| 73 | + | Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(), | |||
| 74 | + | SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(), | |||
| 75 | + | } | |||
| 76 | + | renderers := map[string]func(trueColor bool) *pickerRenderer{ | |||
| 77 | + | "session picker": func(trueColor bool) *pickerRenderer { | |||
| 78 | + | return NewPicker(nil, 0, trueColor).controller.renderer | |||
| 79 | + | }, | |||
| 80 | + | "move picker": func(trueColor bool) *pickerRenderer { | |||
| 81 | + | return newMovePickerOverlay(trueColor).renderer | |||
| 82 | + | }, | |||
| 83 | + | } | |||
| 84 | + | for name, build := range renderers { | |||
| 85 | + | for _, tc := range []struct { | |||
| 86 | + | trueColor bool | |||
| 87 | + | profile ansirenderer.ColorProfile | |||
| 88 | + | want string | |||
| 89 | + | forbidden string | |||
| 90 | + | }{ | |||
| 91 | + | {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`}, | |||
| 92 | + | {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`}, | |||
| 93 | + | } { | |||
| 94 | + | t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) { | |||
| 95 | + | r := build(tc.trueColor) | |||
| 96 | + | require.Equal(t, tc.profile, r.profile) | |||
| 97 | + | r.renderStyles = []picker.RenderStyles{styles} | |||
| 98 | + | output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |||
| 99 | + | require.Regexp(t, tc.want, output) | |||
| 100 | + | require.NotRegexp(t, tc.forbidden, output) | |||
| 101 | + | }) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 64 | func rgbPickerStyle() renderer.Style { | 106 | func rgbPickerStyle() renderer.Style { | ||
| 65 | style := renderer.DefaultStyle() | 107 | style := renderer.DefaultStyle() | ||
| 66 | style.HasForegroundRGB = true | 108 | style.HasForegroundRGB = true | ||
M internal/usecase/client/supervisor_attach_test.go +1 -1
| @@ -1305,7 +1305,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) { | |||
| 1305 | 1305 | return admitted, nil | |
| 1306 | 1306 | }) | |
| 1307 | 1307 | ||
| 1308 | - | controller := newPickerController(clock, pickerTestFreshness) | |
| 1308 | + | controller := newPickerController(clock, pickerTestFreshness, true) | |
| 1309 | 1309 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }) | |
| 1310 | 1310 | sup := mustSupervisor(t, SupervisorConfig{ | |
| 1311 | 1311 | Connector: connector, | |
| @@ -1305,7 +1305,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) { | |||||
| 1305 | return admitted, nil | 1305 | return admitted, nil | ||
| 1306 | }) | 1306 | }) | ||
| 1307 | 1307 | ||||
| 1308 | - | controller := newPickerController(clock, pickerTestFreshness) | 1308 | + | controller := newPickerController(clock, pickerTestFreshness, true) |
| 1309 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }) | 1309 | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }) | ||
| 1310 | sup := mustSupervisor(t, SupervisorConfig{ | 1310 | sup := mustSupervisor(t, SupervisorConfig{ | ||
| 1311 | Connector: connector, | 1311 | Connector: connector, | ||
M internal/usecase/client/supervisor_test.go +2 -2
| @@ -687,7 +687,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) { | |||
| 687 | 687 | }), | |
| 688 | 688 | Terminal: newSupervisorTestTerminal(reader), | |
| 689 | 689 | Clock: clock, | |
| 690 | - | Picker: newPickerController(clock, pickerTestFreshness), | |
| 690 | + | Picker: newPickerController(clock, pickerTestFreshness, true), | |
| 691 | 691 | Render: func(state State) { | |
| 692 | 692 | renders <- state | |
| 693 | 693 | }, | |
| @@ -1466,7 +1466,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) { | |||
| 1466 | 1466 | }) | |
| 1467 | 1467 | s := mustSupervisor(t, SupervisorConfig{ | |
| 1468 | 1468 | Connector: connector, Terminal: terminal, Clock: clock, | |
| 1469 | - | Picker: NewPicker(clock, 0), | |
| 1469 | + | Picker: NewPicker(clock, 0, true), | |
| 1470 | 1470 | Notify: func(State, error) { reached <- struct{}{} }, | |
| 1471 | 1471 | }) | |
| 1472 | 1472 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -687,7 +687,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) { | |||||
| 687 | }), | 687 | }), | ||
| 688 | Terminal: newSupervisorTestTerminal(reader), | 688 | Terminal: newSupervisorTestTerminal(reader), | ||
| 689 | Clock: clock, | 689 | Clock: clock, | ||
| 690 | - | Picker: newPickerController(clock, pickerTestFreshness), | 690 | + | Picker: newPickerController(clock, pickerTestFreshness, true), |
| 691 | Render: func(state State) { | 691 | Render: func(state State) { | ||
| 692 | renders <- state | 692 | renders <- state | ||
| 693 | }, | 693 | }, | ||
| @@ -1466,7 +1466,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) { | |||||
| 1466 | }) | 1466 | }) | ||
| 1467 | s := mustSupervisor(t, SupervisorConfig{ | 1467 | s := mustSupervisor(t, SupervisorConfig{ | ||
| 1468 | Connector: connector, Terminal: terminal, Clock: clock, | 1468 | Connector: connector, Terminal: terminal, Clock: clock, | ||
| 1469 | - | Picker: NewPicker(clock, 0), | 1469 | + | Picker: NewPicker(clock, 0, true), |
| 1470 | Notify: func(State, error) { reached <- struct{}{} }, | 1470 | Notify: func(State, error) { reached <- struct{}{} }, | ||
| 1471 | }) | 1471 | }) | ||
| 1472 | ctx, cancel := context.WithCancel(context.Background()) | 1472 | ctx, cancel := context.WithCancel(context.Background()) | ||