fix(theme): preserve surfaces in ANSI-256 terminals (#280)
* fix(theme): preserve surfaces in ANSI-256 terminals * test(ui): wait for picker release before input
- commit
- 41782a9f74fadcc321899b9aa6e7f134c2241365
- author
- bnema · · 2026-09-21 21:06 UTC
- committer
- GitHub · · 2026-09-21 21:06 UTC
- parent
- 25169d5
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
12 files · +99 -49
M internal/app/ui_driver_acceptance_test.go +15 -4
| @@ -90,9 +90,20 @@ func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) { | |||
| 90 | 90 | }) | |
| 91 | 91 | paletteClose := readDriverTestEnvelope(t, decoder) | |
| 92 | 92 | require.Nil(t, paletteClose.Error) | |
| 93 | + | var paletteCloseResult struct { | |
| 94 | + | ActionID uint64 `json:"action_id"` | |
| 95 | + | } | |
| 96 | + | require.NoError(t, json.Unmarshal(paletteClose.Result, &paletteCloseResult)) | |
| 97 | + | require.NotZero(t, paletteCloseResult.ActionID) | |
| 98 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 99 | + | "version": 1, "id": 4, "op": "wait", "attachment": readyResult.Attachment, | |
| 100 | + | "after_action": paletteCloseResult.ActionID, "expect": map[string]string{"status": "attached"}, | |
| 101 | + | }) | |
| 102 | + | paletteClosed := readDriverTestEnvelope(t, decoder) | |
| 103 | + | require.Nil(t, paletteClosed.Error) | |
| 93 | 104 | ||
| 94 | 105 | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 95 | - | "version": 1, "id": 4, "op": "text", "attachment": readyResult.Attachment, | |
| 106 | + | "version": 1, "id": 5, "op": "text", "attachment": readyResult.Attachment, | |
| 96 | 107 | "generation": readyResult.Generation, "text": "printf 'HEADLESS_%s' OK", | |
| 97 | 108 | }) | |
| 98 | 109 | textAction := readDriverTestEnvelope(t, decoder) | |
| @@ -105,7 +116,7 @@ func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) { | |||
| 105 | 116 | require.Equal(t, ports.UIActionProcessed, actionResult.Status) | |
| 106 | 117 | ||
| 107 | 118 | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 108 | - | "version": 1, "id": 5, "op": "keys", "attachment": readyResult.Attachment, | |
| 119 | + | "version": 1, "id": 6, "op": "keys", "attachment": readyResult.Attachment, | |
| 109 | 120 | "generation": readyResult.Generation, "keys": []string{"Enter"}, | |
| 110 | 121 | }) | |
| 111 | 122 | enterAction := readDriverTestEnvelope(t, decoder) | |
| @@ -117,14 +128,14 @@ func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) { | |||
| 117 | 128 | require.NotZero(t, enterResult.ActionID) | |
| 118 | 129 | ||
| 119 | 130 | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 120 | - | "version": 1, "id": 6, "op": "wait", "attachment": readyResult.Attachment, | |
| 131 | + | "version": 1, "id": 7, "op": "wait", "attachment": readyResult.Attachment, | |
| 121 | 132 | "after_action": enterResult.ActionID, "expect": map[string]string{"text_contains": "HEADLESS_OK"}, | |
| 122 | 133 | }) | |
| 123 | 134 | wait := readDriverTestEnvelope(t, decoder) | |
| 124 | 135 | require.Nil(t, wait.Error) | |
| 125 | 136 | ||
| 126 | 137 | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 127 | - | "version": 1, "id": 7, "op": "capture", "attachment": readyResult.Attachment, | |
| 138 | + | "version": 1, "id": 8, "op": "capture", "attachment": readyResult.Attachment, | |
| 128 | 139 | }) | |
| 129 | 140 | capture := readDriverTestEnvelope(t, decoder) | |
| 130 | 141 | require.Nil(t, capture.Error) | |
| @@ -90,9 +90,20 @@ func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) { | |||||
| 90 | }) | 90 | }) | ||
| 91 | paletteClose := readDriverTestEnvelope(t, decoder) | 91 | paletteClose := readDriverTestEnvelope(t, decoder) | ||
| 92 | require.Nil(t, paletteClose.Error) | 92 | require.Nil(t, paletteClose.Error) | ||
| 93 | + | var paletteCloseResult struct { | |||
| 94 | + | ActionID uint64 `json:"action_id"` | |||
| 95 | + | } | |||
| 96 | + | require.NoError(t, json.Unmarshal(paletteClose.Result, &paletteCloseResult)) | |||
| 97 | + | require.NotZero(t, paletteCloseResult.ActionID) | |||
| 98 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |||
| 99 | + | "version": 1, "id": 4, "op": "wait", "attachment": readyResult.Attachment, | |||
| 100 | + | "after_action": paletteCloseResult.ActionID, "expect": map[string]string{"status": "attached"}, | |||
| 101 | + | }) | |||
| 102 | + | paletteClosed := readDriverTestEnvelope(t, decoder) | |||
| 103 | + | require.Nil(t, paletteClosed.Error) | |||
| 93 | 104 | ||||
| 94 | writeDriverTestRequest(t, clientSide, map[string]any{ | 105 | writeDriverTestRequest(t, clientSide, map[string]any{ | ||
| 95 | - | "version": 1, "id": 4, "op": "text", "attachment": readyResult.Attachment, | 106 | + | "version": 1, "id": 5, "op": "text", "attachment": readyResult.Attachment, |
| 96 | "generation": readyResult.Generation, "text": "printf 'HEADLESS_%s' OK", | 107 | "generation": readyResult.Generation, "text": "printf 'HEADLESS_%s' OK", | ||
| 97 | }) | 108 | }) | ||
| 98 | textAction := readDriverTestEnvelope(t, decoder) | 109 | textAction := readDriverTestEnvelope(t, decoder) | ||
| @@ -105,7 +116,7 @@ func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) { | |||||
| 105 | require.Equal(t, ports.UIActionProcessed, actionResult.Status) | 116 | require.Equal(t, ports.UIActionProcessed, actionResult.Status) | ||
| 106 | 117 | ||||
| 107 | writeDriverTestRequest(t, clientSide, map[string]any{ | 118 | writeDriverTestRequest(t, clientSide, map[string]any{ | ||
| 108 | - | "version": 1, "id": 5, "op": "keys", "attachment": readyResult.Attachment, | 119 | + | "version": 1, "id": 6, "op": "keys", "attachment": readyResult.Attachment, |
| 109 | "generation": readyResult.Generation, "keys": []string{"Enter"}, | 120 | "generation": readyResult.Generation, "keys": []string{"Enter"}, | ||
| 110 | }) | 121 | }) | ||
| 111 | enterAction := readDriverTestEnvelope(t, decoder) | 122 | enterAction := readDriverTestEnvelope(t, decoder) | ||
| @@ -117,14 +128,14 @@ func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) { | |||||
| 117 | require.NotZero(t, enterResult.ActionID) | 128 | require.NotZero(t, enterResult.ActionID) | ||
| 118 | 129 | ||||
| 119 | writeDriverTestRequest(t, clientSide, map[string]any{ | 130 | writeDriverTestRequest(t, clientSide, map[string]any{ | ||
| 120 | - | "version": 1, "id": 6, "op": "wait", "attachment": readyResult.Attachment, | 131 | + | "version": 1, "id": 7, "op": "wait", "attachment": readyResult.Attachment, |
| 121 | "after_action": enterResult.ActionID, "expect": map[string]string{"text_contains": "HEADLESS_OK"}, | 132 | "after_action": enterResult.ActionID, "expect": map[string]string{"text_contains": "HEADLESS_OK"}, | ||
| 122 | }) | 133 | }) | ||
| 123 | wait := readDriverTestEnvelope(t, decoder) | 134 | wait := readDriverTestEnvelope(t, decoder) | ||
| 124 | require.Nil(t, wait.Error) | 135 | require.Nil(t, wait.Error) | ||
| 125 | 136 | ||||
| 126 | writeDriverTestRequest(t, clientSide, map[string]any{ | 137 | writeDriverTestRequest(t, clientSide, map[string]any{ | ||
| 127 | - | "version": 1, "id": 7, "op": "capture", "attachment": readyResult.Attachment, | 138 | + | "version": 1, "id": 8, "op": "capture", "attachment": readyResult.Attachment, |
| 128 | }) | 139 | }) | ||
| 129 | capture := readDriverTestEnvelope(t, decoder) | 140 | capture := readDriverTestEnvelope(t, decoder) | ||
| 130 | require.Nil(t, capture.Error) | 141 | require.Nil(t, capture.Error) | ||
M internal/usecase/client/client.go +6 -1
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "time" | |
| 21 | 21 | ||
| 22 | 22 | renderer "github.com/bnema/vev-vt" | |
| 23 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 23 | 24 | "github.com/bnema/vev-vt/protocol/terminalquery" | |
| 24 | 25 | "github.com/bnema/vev/internal/domain" | |
| 25 | 26 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| @@ -1759,7 +1760,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1759 | 1760 | // client-picker interaction: acquisition barrier, local ownership, | |
| 1760 | 1761 | // and ordered release. The daemon keeps mutation authority. | |
| 1761 | 1762 | var pickerPresentation pickerLease | |
| 1762 | - | pickerRenderer := newPickerRenderer() | |
| 1763 | + | pickerColorProfile := ansirenderer.ColorProfileANSI256 | |
| 1764 | + | if trueColor { | |
| 1765 | + | pickerColorProfile = ansirenderer.ColorProfileTrueColor | |
| 1766 | + | } | |
| 1767 | + | pickerRenderer := newPickerRenderer(pickerColorProfile) | |
| 1763 | 1768 | termSize := func() domain.Size { | |
| 1764 | 1769 | geometry, err := term.Geometry() | |
| 1765 | 1770 | if err != nil { | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | "time" | 20 | "time" | ||
| 21 | 21 | ||||
| 22 | renderer "github.com/bnema/vev-vt" | 22 | renderer "github.com/bnema/vev-vt" | ||
| 23 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 23 | "github.com/bnema/vev-vt/protocol/terminalquery" | 24 | "github.com/bnema/vev-vt/protocol/terminalquery" | ||
| 24 | "github.com/bnema/vev/internal/domain" | 25 | "github.com/bnema/vev/internal/domain" | ||
| 25 | "github.com/bnema/vev/internal/domain/terminalcap" | 26 | "github.com/bnema/vev/internal/domain/terminalcap" | ||
| @@ -1759,7 +1760,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1759 | // client-picker interaction: acquisition barrier, local ownership, | 1760 | // client-picker interaction: acquisition barrier, local ownership, | ||
| 1760 | // and ordered release. The daemon keeps mutation authority. | 1761 | // and ordered release. The daemon keeps mutation authority. | ||
| 1761 | var pickerPresentation pickerLease | 1762 | var pickerPresentation pickerLease | ||
| 1762 | - | pickerRenderer := newPickerRenderer() | 1763 | + | pickerColorProfile := ansirenderer.ColorProfileANSI256 |
| 1764 | + | if trueColor { | |||
| 1765 | + | pickerColorProfile = ansirenderer.ColorProfileTrueColor | |||
| 1766 | + | } | |||
| 1767 | + | pickerRenderer := newPickerRenderer(pickerColorProfile) | |||
| 1763 | termSize := func() domain.Size { | 1768 | termSize := func() domain.Size { | ||
| 1764 | geometry, err := term.Geometry() | 1769 | geometry, err := term.Geometry() | ||
| 1765 | if err != nil { | 1770 | if err != nil { | ||
M internal/usecase/client/picker_interaction.go +8 -6
| @@ -415,8 +415,10 @@ func clampPreviewDimension(value int, maxValue uint16) uint16 { | |||
| 415 | 415 | // client-picker frame is a local presentation overlay, not daemon output, | |
| 416 | 416 | // so its bytes never enter the output ACK window. | |
| 417 | 417 | type pickerRenderer struct { | |
| 418 | - | renderer *ansirenderer.Renderer | |
| 419 | - | size domain.Size | |
| 418 | + | renderer *ansirenderer.Renderer | |
| 419 | + | profile ansirenderer.ColorProfile | |
| 420 | + | renderStyles []picker.RenderStyles | |
| 421 | + | size domain.Size | |
| 420 | 422 | // prevBounds is the box drawn last: when it moves or resizes, its previous | |
| 421 | 423 | // cells are blanked together with the new ones, and the rest of the screen | |
| 422 | 424 | // stays untouched. | |
| @@ -426,8 +428,8 @@ type pickerRenderer struct { | |||
| 426 | 428 | primed bool | |
| 427 | 429 | } | |
| 428 | 430 | ||
| 429 | - | func newPickerRenderer() *pickerRenderer { | |
| 430 | - | return &pickerRenderer{} | |
| 431 | + | func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer { | |
| 432 | + | return &pickerRenderer{profile: profile} | |
| 431 | 433 | } | |
| 432 | 434 | ||
| 433 | 435 | // render composes the loop model into terminal bytes for one display | |
| @@ -447,7 +449,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||
| 447 | 449 | return nil | |
| 448 | 450 | } | |
| 449 | 451 | if r.renderer == nil || r.size != size { | |
| 450 | - | r.renderer = ansirenderer.New(ansirenderer.Capabilities{}) | |
| 452 | + | r.renderer = ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, r.profile) | |
| 451 | 453 | r.size = size | |
| 452 | 454 | // Keep prevBounds across a resize. The new renderer is primed with an | |
| 453 | 455 | // empty shadow below, so damaging the old rectangle explicitly clears | |
| @@ -469,7 +471,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||
| 469 | 471 | border := renderer.DefaultStyle() | |
| 470 | 472 | border.Attrs |= renderer.AttrDim | |
| 471 | 473 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | |
| 472 | - | inner := loop.model.Render(picker.Size(presentation.Inner), preview) | |
| 474 | + | inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...) | |
| 473 | 475 | copyFrameRect(base, presentation.Inner, inner) | |
| 474 | 476 | ||
| 475 | 477 | // Only the box is written: the session stays on screen around it. A box | |
| @@ -415,8 +415,10 @@ func clampPreviewDimension(value int, maxValue uint16) uint16 { | |||||
| 415 | // client-picker frame is a local presentation overlay, not daemon output, | 415 | // client-picker frame is a local presentation overlay, not daemon output, | ||
| 416 | // so its bytes never enter the output ACK window. | 416 | // so its bytes never enter the output ACK window. | ||
| 417 | type pickerRenderer struct { | 417 | type pickerRenderer struct { | ||
| 418 | - | renderer *ansirenderer.Renderer | 418 | + | renderer *ansirenderer.Renderer |
| 419 | - | size domain.Size | 419 | + | profile ansirenderer.ColorProfile |
| 420 | + | renderStyles []picker.RenderStyles | |||
| 421 | + | size domain.Size | |||
| 420 | // prevBounds is the box drawn last: when it moves or resizes, its previous | 422 | // prevBounds is the box drawn last: when it moves or resizes, its previous | ||
| 421 | // cells are blanked together with the new ones, and the rest of the screen | 423 | // cells are blanked together with the new ones, and the rest of the screen | ||
| 422 | // stays untouched. | 424 | // stays untouched. | ||
| @@ -426,8 +428,8 @@ type pickerRenderer struct { | |||||
| 426 | primed bool | 428 | primed bool | ||
| 427 | } | 429 | } | ||
| 428 | 430 | ||||
| 429 | - | func newPickerRenderer() *pickerRenderer { | 431 | + | func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer { |
| 430 | - | return &pickerRenderer{} | 432 | + | return &pickerRenderer{profile: profile} |
| 431 | } | 433 | } | ||
| 432 | 434 | ||||
| 433 | // render composes the loop model into terminal bytes for one display | 435 | // render composes the loop model into terminal bytes for one display | ||
| @@ -447,7 +449,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||||
| 447 | return nil | 449 | return nil | ||
| 448 | } | 450 | } | ||
| 449 | if r.renderer == nil || r.size != size { | 451 | if r.renderer == nil || r.size != size { | ||
| 450 | - | r.renderer = ansirenderer.New(ansirenderer.Capabilities{}) | 452 | + | r.renderer = ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, r.profile) |
| 451 | r.size = size | 453 | r.size = size | ||
| 452 | // Keep prevBounds across a resize. The new renderer is primed with an | 454 | // Keep prevBounds across a resize. The new renderer is primed with an | ||
| 453 | // empty shadow below, so damaging the old rectangle explicitly clears | 455 | // empty shadow below, so damaging the old rectangle explicitly clears | ||
| @@ -469,7 +471,7 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||||
| 469 | border := renderer.DefaultStyle() | 471 | border := renderer.DefaultStyle() | ||
| 470 | border.Attrs |= renderer.AttrDim | 472 | border.Attrs |= renderer.AttrDim | ||
| 471 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | 473 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | ||
| 472 | - | inner := loop.model.Render(picker.Size(presentation.Inner), preview) | 474 | + | inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...) |
| 473 | copyFrameRect(base, presentation.Inner, inner) | 475 | copyFrameRect(base, presentation.Inner, inner) | ||
| 474 | 476 | ||||
| 475 | // Only the box is written: the session stays on screen around it. A box | 477 | // Only the box is written: the session stays on screen around it. A box | ||
M internal/usecase/client/picker_interaction_test.go +34 -2
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -14,7 +15,7 @@ import ( | |||
| 14 | 15 | ||
| 15 | 16 | func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) { | |
| 16 | 17 | loop := pickerLoopFixture(t) | |
| 17 | - | r := &pickerRenderer{} | |
| 18 | + | r := newPickerRenderer(ansirenderer.ColorProfileTrueColor) | |
| 18 | 19 | screen := renderer.NewScreen(100, 30) | |
| 19 | 20 | paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S') | |
| 20 | 21 | ||
| @@ -38,9 +39,40 @@ func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) { | |||
| 38 | 39 | require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered") | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) { | |
| 43 | + | loop := pickerLoopFixture(t) | |
| 44 | + | styles := picker.RenderStyles{ | |
| 45 | + | Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(), | |
| 46 | + | Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(), | |
| 47 | + | Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(), | |
| 48 | + | SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(), | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | indexed := newPickerRenderer(ansirenderer.ColorProfileANSI256) | |
| 52 | + | indexed.renderStyles = []picker.RenderStyles{styles} | |
| 53 | + | for _, size := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 80, Rows: 24}} { | |
| 54 | + | output := string(indexed.render(loop, size, emptyPickerPreview())) | |
| 55 | + | require.Regexp(t, `(?:38|48);5;`, output, "picker did not emit indexed color at %v", size) | |
| 56 | + | require.NotRegexp(t, `(?:38|48|58);2;`, output, "picker emitted truecolor at %v", size) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | truecolor := newPickerRenderer(ansirenderer.ColorProfileTrueColor) | |
| 60 | + | truecolor.renderStyles = []picker.RenderStyles{styles} | |
| 61 | + | require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func rgbPickerStyle() renderer.Style { | |
| 65 | + | style := renderer.DefaultStyle() | |
| 66 | + | style.HasForegroundRGB = true | |
| 67 | + | style.ForegroundRGB = renderer.RGB{R: 12, G: 120, B: 231} | |
| 68 | + | style.HasBackgroundRGB = true | |
| 69 | + | style.BackgroundRGB = renderer.RGB{R: 23, G: 45, B: 67} | |
| 70 | + | return style | |
| 71 | + | } | |
| 72 | + | ||
| 41 | 73 | func TestPickerRendererResetProtectsRepaintedSessionFromPreviousLease(t *testing.T) { | |
| 42 | 74 | loop := pickerLoopFixture(t) | |
| 43 | - | r := &pickerRenderer{} | |
| 75 | + | r := newPickerRenderer(ansirenderer.ColorProfileTrueColor) | |
| 44 | 76 | screen := renderer.NewScreen(100, 30) | |
| 45 | 77 | paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S') | |
| 46 | 78 | screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 9 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/protocol" | 11 | "github.com/bnema/vev/internal/protocol" | ||
| 11 | "github.com/bnema/vev/internal/usecase/picker" | 12 | "github.com/bnema/vev/internal/usecase/picker" | ||
| @@ -14,7 +15,7 @@ import ( | |||||
| 14 | 15 | ||||
| 15 | func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) { | 16 | func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) { | ||
| 16 | loop := pickerLoopFixture(t) | 17 | loop := pickerLoopFixture(t) | ||
| 17 | - | r := &pickerRenderer{} | 18 | + | r := newPickerRenderer(ansirenderer.ColorProfileTrueColor) |
| 18 | screen := renderer.NewScreen(100, 30) | 19 | screen := renderer.NewScreen(100, 30) | ||
| 19 | paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S') | 20 | paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S') | ||
| 20 | 21 | ||||
| @@ -38,9 +39,40 @@ func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) { | |||||
| 38 | require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered") | 39 | require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered") | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 42 | + | func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) { | |||
| 43 | + | loop := pickerLoopFixture(t) | |||
| 44 | + | styles := picker.RenderStyles{ | |||
| 45 | + | Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(), | |||
| 46 | + | Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(), | |||
| 47 | + | Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Status: rgbPickerStyle(), | |||
| 48 | + | SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(), | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | indexed := newPickerRenderer(ansirenderer.ColorProfileANSI256) | |||
| 52 | + | indexed.renderStyles = []picker.RenderStyles{styles} | |||
| 53 | + | for _, size := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 80, Rows: 24}} { | |||
| 54 | + | output := string(indexed.render(loop, size, emptyPickerPreview())) | |||
| 55 | + | require.Regexp(t, `(?:38|48);5;`, output, "picker did not emit indexed color at %v", size) | |||
| 56 | + | require.NotRegexp(t, `(?:38|48|58);2;`, output, "picker emitted truecolor at %v", size) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | truecolor := newPickerRenderer(ansirenderer.ColorProfileTrueColor) | |||
| 60 | + | truecolor.renderStyles = []picker.RenderStyles{styles} | |||
| 61 | + | require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func rgbPickerStyle() renderer.Style { | |||
| 65 | + | style := renderer.DefaultStyle() | |||
| 66 | + | style.HasForegroundRGB = true | |||
| 67 | + | style.ForegroundRGB = renderer.RGB{R: 12, G: 120, B: 231} | |||
| 68 | + | style.HasBackgroundRGB = true | |||
| 69 | + | style.BackgroundRGB = renderer.RGB{R: 23, G: 45, B: 67} | |||
| 70 | + | return style | |||
| 71 | + | } | |||
| 72 | + | ||||
| 41 | func TestPickerRendererResetProtectsRepaintedSessionFromPreviousLease(t *testing.T) { | 73 | func TestPickerRendererResetProtectsRepaintedSessionFromPreviousLease(t *testing.T) { | ||
| 42 | loop := pickerLoopFixture(t) | 74 | loop := pickerLoopFixture(t) | ||
| 43 | - | r := &pickerRenderer{} | 75 | + | r := newPickerRenderer(ansirenderer.ColorProfileTrueColor) |
| 44 | screen := renderer.NewScreen(100, 30) | 76 | screen := renderer.NewScreen(100, 30) | ||
| 45 | paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S') | 77 | paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S') | ||
| 46 | screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | 78 | screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | ||
M internal/usecase/daemon/palette_test.go +4 -8
| @@ -179,13 +179,12 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||
| 179 | 179 | forcedLight := defaults | |
| 180 | 180 | forcedLight.Theme = domain.ThemeLight | |
| 181 | 181 | tests := []struct { | |
| 182 | - | name string | |
| 183 | - | raw themeui.Theme | |
| 184 | - | config domain.Config | |
| 185 | - | indexed bool | |
| 182 | + | name string | |
| 183 | + | raw themeui.Theme | |
| 184 | + | config domain.Config | |
| 186 | 185 | }{ | |
| 187 | 186 | {name: "truecolor accent", raw: accentTheme, config: defaults}, | |
| 188 | - | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, | |
| 187 | + | {name: "ansi256", raw: indexedTheme, config: defaults}, | |
| 189 | 188 | {name: "palette off", raw: accentTheme, config: paletteOff}, | |
| 190 | 189 | {name: "forced dark", raw: accentTheme, config: forcedDark}, | |
| 191 | 190 | {name: "forced light", raw: accentTheme, config: forcedLight}, | |
| @@ -216,9 +215,6 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||
| 216 | 215 | require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB) | |
| 217 | 216 | require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB) | |
| 218 | 217 | require.False(t, inactive.HasBackgroundRGB) | |
| 219 | - | if tt.indexed { | |
| 220 | - | require.Equal(t, 2, inactive.Foreground) | |
| 221 | - | } | |
| 222 | 218 | require.True(t, state.overlays.palette.inner.At(31, 1).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background") | |
| 223 | 219 | ||
| 224 | 220 | selected := state.overlays.palette.inner.At(4, 2).Style | |
| @@ -179,13 +179,12 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||||
| 179 | forcedLight := defaults | 179 | forcedLight := defaults | ||
| 180 | forcedLight.Theme = domain.ThemeLight | 180 | forcedLight.Theme = domain.ThemeLight | ||
| 181 | tests := []struct { | 181 | tests := []struct { | ||
| 182 | - | name string | 182 | + | name string |
| 183 | - | raw themeui.Theme | 183 | + | raw themeui.Theme |
| 184 | - | config domain.Config | 184 | + | config domain.Config |
| 185 | - | indexed bool | |||
| 186 | }{ | 185 | }{ | ||
| 187 | {name: "truecolor accent", raw: accentTheme, config: defaults}, | 186 | {name: "truecolor accent", raw: accentTheme, config: defaults}, | ||
| 188 | - | {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true}, | 187 | + | {name: "ansi256", raw: indexedTheme, config: defaults}, |
| 189 | {name: "palette off", raw: accentTheme, config: paletteOff}, | 188 | {name: "palette off", raw: accentTheme, config: paletteOff}, | ||
| 190 | {name: "forced dark", raw: accentTheme, config: forcedDark}, | 189 | {name: "forced dark", raw: accentTheme, config: forcedDark}, | ||
| 191 | {name: "forced light", raw: accentTheme, config: forcedLight}, | 190 | {name: "forced light", raw: accentTheme, config: forcedLight}, | ||
| @@ -216,9 +215,6 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t | |||||
| 216 | require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB) | 215 | require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB) | ||
| 217 | require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB) | 216 | require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB) | ||
| 218 | require.False(t, inactive.HasBackgroundRGB) | 217 | require.False(t, inactive.HasBackgroundRGB) | ||
| 219 | - | if tt.indexed { | |||
| 220 | - | require.Equal(t, 2, inactive.Foreground) | |||
| 221 | - | } | |||
| 222 | require.True(t, state.overlays.palette.inner.At(31, 1).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background") | 218 | require.True(t, state.overlays.palette.inner.At(31, 1).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background") | ||
| 223 | 219 | ||||
| 224 | selected := state.overlays.palette.inner.At(4, 2).Style | 220 | selected := state.overlays.palette.inner.At(4, 2).Style | ||
M internal/usecase/palette/model_test.go +1 -5
| @@ -527,7 +527,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||
| 527 | 527 | policy domain.ThemeAccent | |
| 528 | 528 | }{ | |
| 529 | 529 | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 530 | - | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 530 | + | {name: "ansi256", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | |
| 531 | 531 | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 532 | 532 | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| 533 | 533 | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | |
| @@ -551,10 +551,6 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||
| 551 | 551 | require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background) | |
| 552 | 552 | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB) | |
| 553 | 553 | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB) | |
| 554 | - | if tt.name == "indexed only" { | |
| 555 | - | require.Equal(t, 2, inactive.Foreground) | |
| 556 | - | require.False(t, inactive.HasBackgroundRGB) | |
| 557 | - | } | |
| 558 | 554 | ||
| 559 | 555 | selected := frame.At(4, 2).Style | |
| 560 | 556 | require.True(t, selected.Equal(styles.PickerSelectionMuted)) | |
| @@ -527,7 +527,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||||
| 527 | policy domain.ThemeAccent | 527 | policy domain.ThemeAccent | ||
| 528 | }{ | 528 | }{ | ||
| 529 | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | 529 | {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | ||
| 530 | - | {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, | 530 | + | {name: "ansi256", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}}, |
| 531 | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | 531 | {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | ||
| 532 | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | 532 | {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | ||
| 533 | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | 533 | {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}}, | ||
| @@ -551,10 +551,6 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) | |||||
| 551 | require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background) | 551 | require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background) | ||
| 552 | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB) | 552 | require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB) | ||
| 553 | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB) | 553 | require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB) | ||
| 554 | - | if tt.name == "indexed only" { | |||
| 555 | - | require.Equal(t, 2, inactive.Foreground) | |||
| 556 | - | require.False(t, inactive.HasBackgroundRGB) | |||
| 557 | - | } | |||
| 558 | 554 | ||||
| 559 | selected := frame.At(4, 2).Style | 555 | selected := frame.At(4, 2).Style | ||
| 560 | require.True(t, selected.Equal(styles.PickerSelectionMuted)) | 556 | require.True(t, selected.Equal(styles.PickerSelectionMuted)) | ||
M internal/usecase/theme/accent_resolve.go +2 -2
| @@ -13,7 +13,7 @@ const ( | |||
| 13 | 13 | ||
| 14 | 14 | // Accent is the single terminal-owned color selected for chrome. IndexedOnly | |
| 15 | 15 | // means the slot may decorate foregrounds and borders, but cannot fill RGB | |
| 16 | - | // backgrounds because terminal defaults or truecolor support are unavailable. | |
| 16 | + | // backgrounds because the terminal's semantic foreground or background is unknown. | |
| 17 | 17 | type Accent struct { | |
| 18 | 18 | RGB renderer.RGB | |
| 19 | 19 | Slot uint8 | |
| @@ -267,7 +267,7 @@ func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent { | |||
| 267 | 267 | RGB: color, | |
| 268 | 268 | Slot: slot, | |
| 269 | 269 | Known: true, | |
| 270 | - | IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG, | |
| 270 | + | IndexedOnly: !t.HasFG || !t.HasBG, | |
| 271 | 271 | } | |
| 272 | 272 | } | |
| 273 | 273 | ||
| @@ -13,7 +13,7 @@ const ( | |||||
| 13 | 13 | ||||
| 14 | // Accent is the single terminal-owned color selected for chrome. IndexedOnly | 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 | 15 | // means the slot may decorate foregrounds and borders, but cannot fill RGB | ||
| 16 | - | // backgrounds because terminal defaults or truecolor support are unavailable. | 16 | + | // backgrounds because the terminal's semantic foreground or background is unknown. |
| 17 | type Accent struct { | 17 | type Accent struct { | ||
| 18 | RGB renderer.RGB | 18 | RGB renderer.RGB | ||
| 19 | Slot uint8 | 19 | Slot uint8 | ||
| @@ -267,7 +267,7 @@ func resolvedAccent(t Theme, color renderer.RGB, slot uint8) Accent { | |||||
| 267 | RGB: color, | 267 | RGB: color, | ||
| 268 | Slot: slot, | 268 | Slot: slot, | ||
| 269 | Known: true, | 269 | Known: true, | ||
| 270 | - | IndexedOnly: !t.TrueColor || !t.HasFG || !t.HasBG, | 270 | + | IndexedOnly: !t.HasFG || !t.HasBG, |
| 271 | } | 271 | } | ||
| 272 | } | 272 | } | ||
| 273 | 273 | ||||
M internal/usecase/theme/accent_resolve_test.go +4 -4
| @@ -128,13 +128,13 @@ func TestResolveAccentAuto(t *testing.T) { | |||
| 128 | 128 | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |
| 129 | 129 | }, | |
| 130 | 130 | { | |
| 131 | - | name: "non truecolor yields indexed fallback only", | |
| 131 | + | name: "known colors remain semantic without truecolor output", | |
| 132 | 132 | theme: func() Theme { | |
| 133 | 133 | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | |
| 134 | 134 | t.TrueColor = false | |
| 135 | 135 | return t | |
| 136 | 136 | }(), | |
| 137 | - | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | |
| 137 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, | |
| 138 | 138 | }, | |
| 139 | 139 | { | |
| 140 | 140 | name: "missing background cannot infer", | |
| @@ -205,10 +205,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) { | |||
| 205 | 205 | want: Accent{}, | |
| 206 | 206 | }, | |
| 207 | 207 | { | |
| 208 | - | name: "explicit known slot is indexed only without truecolor prerequisites", | |
| 208 | + | name: "explicit known slot remains semantic without truecolor output", | |
| 209 | 209 | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | |
| 210 | 210 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | |
| 211 | - | want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true}, | |
| 211 | + | want: Accent{RGB: teal, Slot: 3, Known: true}, | |
| 212 | 212 | }, | |
| 213 | 213 | { | |
| 214 | 214 | name: "palette off ignores explicit slot", | |
| @@ -128,13 +128,13 @@ func TestResolveAccentAuto(t *testing.T) { | |||||
| 128 | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | 128 | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | ||
| 129 | }, | 129 | }, | ||
| 130 | { | 130 | { | ||
| 131 | - | name: "non truecolor yields indexed fallback only", | 131 | + | name: "known colors remain semantic without truecolor output", |
| 132 | theme: func() Theme { | 132 | theme: func() Theme { | ||
| 133 | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | 133 | t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}) | ||
| 134 | t.TrueColor = false | 134 | t.TrueColor = false | ||
| 135 | return t | 135 | return t | ||
| 136 | }(), | 136 | }(), | ||
| 137 | - | want: Accent{RGB: teal, Slot: 2, Known: true, IndexedOnly: true}, | 137 | + | want: Accent{RGB: teal, Slot: 2, Known: true}, |
| 138 | }, | 138 | }, | ||
| 139 | { | 139 | { | ||
| 140 | name: "missing background cannot infer", | 140 | name: "missing background cannot infer", | ||
| @@ -205,10 +205,10 @@ func TestResolveAccentExplicitSlot(t *testing.T) { | |||||
| 205 | want: Accent{}, | 205 | want: Accent{}, | ||
| 206 | }, | 206 | }, | ||
| 207 | { | 207 | { | ||
| 208 | - | name: "explicit known slot is indexed only without truecolor prerequisites", | 208 | + | name: "explicit known slot remains semantic without truecolor output", |
| 209 | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | 209 | theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(), | ||
| 210 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | 210 | policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3}, | ||
| 211 | - | want: Accent{RGB: teal, Slot: 3, Known: true, IndexedOnly: true}, | 211 | + | want: Accent{RGB: teal, Slot: 3, Known: true}, |
| 212 | }, | 212 | }, | ||
| 213 | { | 213 | { | ||
| 214 | name: "palette off ignores explicit slot", | 214 | name: "palette off ignores explicit slot", | ||
M internal/usecase/theme/phase3_review_test.go +2 -2
| @@ -24,14 +24,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||
| 24 | 24 | want: Accent{Slot: 5, IndexedOnly: true}, | |
| 25 | 25 | }, | |
| 26 | 26 | { | |
| 27 | - | name: "explicit known slot without truecolor is indexed only", | |
| 27 | + | name: "explicit known slot without truecolor remains semantic", | |
| 28 | 28 | theme: func() Theme { | |
| 29 | 29 | t := paletteTheme(map[int]renderer.RGB{5: teal}) | |
| 30 | 30 | t.TrueColor = false | |
| 31 | 31 | return t | |
| 32 | 32 | }(), | |
| 33 | 33 | policy: explicit, | |
| 34 | - | want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true}, | |
| 34 | + | want: Accent{RGB: teal, Slot: 5, Known: true}, | |
| 35 | 35 | }, | |
| 36 | 36 | { | |
| 37 | 37 | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | |
| @@ -24,14 +24,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) { | |||||
| 24 | want: Accent{Slot: 5, IndexedOnly: true}, | 24 | want: Accent{Slot: 5, IndexedOnly: true}, | ||
| 25 | }, | 25 | }, | ||
| 26 | { | 26 | { | ||
| 27 | - | name: "explicit known slot without truecolor is indexed only", | 27 | + | name: "explicit known slot without truecolor remains semantic", |
| 28 | theme: func() Theme { | 28 | theme: func() Theme { | ||
| 29 | t := paletteTheme(map[int]renderer.RGB{5: teal}) | 29 | t := paletteTheme(map[int]renderer.RGB{5: teal}) | ||
| 30 | t.TrueColor = false | 30 | t.TrueColor = false | ||
| 31 | return t | 31 | return t | ||
| 32 | }(), | 32 | }(), | ||
| 33 | policy: explicit, | 33 | policy: explicit, | ||
| 34 | - | want: Accent{RGB: teal, Slot: 5, Known: true, IndexedOnly: true}, | 34 | + | want: Accent{RGB: teal, Slot: 5, Known: true}, |
| 35 | }, | 35 | }, | ||
| 36 | { | 36 | { | ||
| 37 | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | 37 | name: "automatic absent osc palette keeps dark scheme indexed blue fallback", | ||
M internal/usecase/theme/ramp_test.go +13 -7
| @@ -153,18 +153,24 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||
| 153 | 153 | require.True(t, resolved.Styles.PickerSelection.Bold) | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | - | func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | |
| 156 | + | func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { | |
| 157 | 157 | theme := rampTheme(false) | |
| 158 | 158 | theme.TrueColor = false | |
| 159 | 159 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 160 | 160 | theme.PaletteKnown = 1 << 2 | |
| 161 | 161 | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 162 | - | require.True(t, indexed.Accent.IndexedOnly) | |
| 163 | - | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | |
| 164 | - | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | |
| 165 | - | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | |
| 166 | - | require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground) | |
| 167 | - | require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB) | |
| 162 | + | require.False(t, indexed.Accent.IndexedOnly) | |
| 163 | + | for name, style := range map[string]renderer.Style{ | |
| 164 | + | "active tab": indexed.Styles.TabActive, | |
| 165 | + | "active title": indexed.Styles.TabActiveTitle, | |
| 166 | + | "picker selection": indexed.Styles.PickerSelection, | |
| 167 | + | "search selection": indexed.Styles.SearchSelection, | |
| 168 | + | } { | |
| 169 | + | t.Run(name, func(t *testing.T) { | |
| 170 | + | require.True(t, style.HasBackgroundRGB) | |
| 171 | + | require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB) | |
| 172 | + | }) | |
| 173 | + | } | |
| 168 | 174 | ||
| 169 | 175 | theme.TrueColor = true | |
| 170 | 176 | theme.UsePalette = false | |
| @@ -153,18 +153,24 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||||
| 153 | require.True(t, resolved.Styles.PickerSelection.Bold) | 153 | require.True(t, resolved.Styles.PickerSelection.Bold) | ||
| 154 | } | 154 | } | ||
| 155 | 155 | ||||
| 156 | - | func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | 156 | + | func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) { |
| 157 | theme := rampTheme(false) | 157 | theme := rampTheme(false) | ||
| 158 | theme.TrueColor = false | 158 | theme.TrueColor = false | ||
| 159 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | 159 | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | ||
| 160 | theme.PaletteKnown = 1 << 2 | 160 | theme.PaletteKnown = 1 << 2 | ||
| 161 | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | 161 | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | ||
| 162 | - | require.True(t, indexed.Accent.IndexedOnly) | 162 | + | require.False(t, indexed.Accent.IndexedOnly) |
| 163 | - | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | 163 | + | for name, style := range map[string]renderer.Style{ |
| 164 | - | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | 164 | + | "active tab": indexed.Styles.TabActive, |
| 165 | - | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | 165 | + | "active title": indexed.Styles.TabActiveTitle, |
| 166 | - | require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground) | 166 | + | "picker selection": indexed.Styles.PickerSelection, |
| 167 | - | require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB) | 167 | + | "search selection": indexed.Styles.SearchSelection, |
| 168 | + | } { | |||
| 169 | + | t.Run(name, func(t *testing.T) { | |||
| 170 | + | require.True(t, style.HasBackgroundRGB) | |||
| 171 | + | require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB) | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 168 | 174 | ||||
| 169 | theme.TrueColor = true | 175 | theme.TrueColor = true | ||
| 170 | theme.UsePalette = false | 176 | theme.UsePalette = false | ||
M internal/usecase/theme/theme.go +8 -6
| @@ -19,11 +19,13 @@ type Theme struct { | |||
| 19 | 19 | PaletteKnown uint16 | |
| 20 | 20 | HasFG bool | |
| 21 | 21 | HasBG bool | |
| 22 | - | TrueColor bool | |
| 23 | - | Known bool | |
| 24 | - | SchemeKnown bool | |
| 25 | - | Light bool | |
| 26 | - | UsePalette bool | |
| 22 | + | // TrueColor records output capability for transport compatibility. Semantic | |
| 23 | + | // styles remain RGB; the attachment renderer quantizes them when necessary. | |
| 24 | + | TrueColor bool | |
| 25 | + | Known bool | |
| 26 | + | SchemeKnown bool | |
| 27 | + | Light bool | |
| 28 | + | UsePalette bool | |
| 27 | 29 | } | |
| 28 | 30 | ||
| 29 | 31 | // PaletteColor returns a palette color only when palette inheritance is | |
| @@ -591,7 +593,7 @@ func SelectionStyle(t Theme) renderer.Style { | |||
| 591 | 593 | } | |
| 592 | 594 | ||
| 593 | 595 | func usable(t Theme) bool { | |
| 594 | - | return t.Known && t.TrueColor && t.HasFG && t.HasBG | |
| 596 | + | return t.Known && t.HasFG && t.HasBG | |
| 595 | 597 | } | |
| 596 | 598 | ||
| 597 | 599 | func inverseStyle() renderer.Style { | |
| @@ -19,11 +19,13 @@ type Theme struct { | |||||
| 19 | PaletteKnown uint16 | 19 | PaletteKnown uint16 | ||
| 20 | HasFG bool | 20 | HasFG bool | ||
| 21 | HasBG bool | 21 | HasBG bool | ||
| 22 | - | TrueColor bool | 22 | + | // TrueColor records output capability for transport compatibility. Semantic |
| 23 | - | Known bool | 23 | + | // styles remain RGB; the attachment renderer quantizes them when necessary. |
| 24 | - | SchemeKnown bool | 24 | + | TrueColor bool |
| 25 | - | Light bool | 25 | + | Known bool |
| 26 | - | UsePalette bool | 26 | + | SchemeKnown bool |
| 27 | + | Light bool | |||
| 28 | + | UsePalette bool | |||
| 27 | } | 29 | } | ||
| 28 | 30 | ||||
| 29 | // PaletteColor returns a palette color only when palette inheritance is | 31 | // PaletteColor returns a palette color only when palette inheritance is | ||
| @@ -591,7 +593,7 @@ func SelectionStyle(t Theme) renderer.Style { | |||||
| 591 | } | 593 | } | ||
| 592 | 594 | ||||
| 593 | func usable(t Theme) bool { | 595 | func usable(t Theme) bool { | ||
| 594 | - | return t.Known && t.TrueColor && t.HasFG && t.HasBG | 596 | + | return t.Known && t.HasFG && t.HasBG |
| 595 | } | 597 | } | ||
| 596 | 598 | ||||
| 597 | func inverseStyle() renderer.Style { | 599 | func inverseStyle() renderer.Style { | ||
M internal/usecase/theme/theme_test.go +2 -2
| @@ -630,10 +630,10 @@ func TestEmphasisStyle(t *testing.T) { | |||
| 630 | 630 | want: base, | |
| 631 | 631 | }, | |
| 632 | 632 | { | |
| 633 | - | name: "theme missing truecolor returns base unchanged", | |
| 633 | + | name: "known theme without truecolor applies bold", | |
| 634 | 634 | theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true}, | |
| 635 | 635 | base: base, | |
| 636 | - | want: base, | |
| 636 | + | want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true}, | |
| 637 | 637 | }, | |
| 638 | 638 | } | |
| 639 | 639 | for _, tt := range tests { | |
| @@ -630,10 +630,10 @@ func TestEmphasisStyle(t *testing.T) { | |||||
| 630 | want: base, | 630 | want: base, | ||
| 631 | }, | 631 | }, | ||
| 632 | { | 632 | { | ||
| 633 | - | name: "theme missing truecolor returns base unchanged", | 633 | + | name: "known theme without truecolor applies bold", |
| 634 | theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true}, | 634 | theme: Theme{Foreground: usableTheme.Foreground, Background: usableTheme.Background, HasFG: true, HasBG: true, Known: true}, | ||
| 635 | base: base, | 635 | base: base, | ||
| 636 | - | want: base, | 636 | + | want: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, Bold: true}, |
| 637 | }, | 637 | }, | ||
| 638 | } | 638 | } | ||
| 639 | for _, tt := range tests { | 639 | for _, tt := range tests { | ||