fix(client): mark picker problems without color in monochrome
Monochrome drops the fixed problem dot colors, leaving error, warning and healthy rows indistinguishable. Draw error and warning dots bold and dim the tree lines instead of relying on the grey foreground. Truecolor, 256-color and ANSI-16 bytes are unchanged.
- commit
- da6f075a9575f6845133c4d39275ace0855de57f
- author
- brice · · 2026-10-03 13:36 UTC
- committer
- brice · · 2026-10-03 14:14 UTC
- parent
- 1a70faf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +76 -4
M internal/usecase/client/picker_interaction.go +5 -1
| @@ -379,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||
| 379 | 379 | border := renderer.DefaultStyle() | |
| 380 | 380 | border.Attrs |= renderer.AttrDim | |
| 381 | 381 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | |
| 382 | - | inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...) | |
| 382 | + | styles := r.renderStyles | |
| 383 | + | if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome { | |
| 384 | + | styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)} | |
| 385 | + | } | |
| 386 | + | inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...) | |
| 383 | 387 | copyFrameRect(base, presentation.Inner, inner) | |
| 384 | 388 | ||
| 385 | 389 | // Only the box is written: the session stays on screen around it. A box | |
| @@ -379,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick | |||||
| 379 | border := renderer.DefaultStyle() | 379 | border := renderer.DefaultStyle() | ||
| 380 | border.Attrs |= renderer.AttrDim | 380 | border.Attrs |= renderer.AttrDim | ||
| 381 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | 381 | picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle()) | ||
| 382 | - | inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...) | 382 | + | styles := r.renderStyles |
| 383 | + | if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome { | |||
| 384 | + | styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)} | |||
| 385 | + | } | |||
| 386 | + | inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...) | |||
| 383 | copyFrameRect(base, presentation.Inner, inner) | 387 | copyFrameRect(base, presentation.Inner, inner) | ||
| 384 | 388 | ||||
| 385 | // Only the box is written: the session stays on screen around it. A box | 389 | // Only the box is written: the session stays on screen around it. A box | ||
M internal/usecase/client/reduced_color_test.go +50 -0
| @@ -1,12 +1,15 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "regexp" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/require" | |
| 7 | 8 | ||
| 8 | 9 | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | 13 | ) | |
| 11 | 14 | ||
| 12 | 15 | func TestPickerStaysLegibleInReducedColor(t *testing.T) { | |
| @@ -51,3 +54,50 @@ func TestToastBorderFollowsColorProfile(t *testing.T) { | |||
| 51 | 54 | require.Contains(t, lines[0], "\x1b[22m") | |
| 52 | 55 | require.NotContains(t, lines[0], "\x1b[39m") | |
| 53 | 56 | } | |
| 57 | + | ||
| 58 | + | // TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes | |
| 59 | + | // the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is | |
| 60 | + | // quantized by the renderer, and monochrome marks error and warning dots bold | |
| 61 | + | // since no color survives. Informational statuses never gain weight. | |
| 62 | + | func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) { | |
| 63 | + | snapshot := pickerSnapshotFixture() | |
| 64 | + | snapshot.Lines = []protocol.PickerLine{ | |
| 65 | + | {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError}, | |
| 66 | + | {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale}, | |
| 67 | + | {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion}, | |
| 68 | + | } | |
| 69 | + | snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"} | |
| 70 | + | // dot returns the SGR group in effect for the dot on the row labelled label. | |
| 71 | + | dot := func(out, label string) string { | |
| 72 | + | row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`) | |
| 73 | + | m := row.FindStringSubmatch(out) | |
| 74 | + | require.NotNil(t, m, "no problem dot after %q in %q", label, out) | |
| 75 | + | return m[1] | |
| 76 | + | } | |
| 77 | + | tests := []struct { | |
| 78 | + | name string | |
| 79 | + | profile ansirenderer.ColorProfile | |
| 80 | + | want map[string]string // label -> exact SGR before the dot | |
| 81 | + | }{ | |
| 82 | + | {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |
| 83 | + | {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |
| 84 | + | {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}}, | |
| 85 | + | {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}}, | |
| 86 | + | } | |
| 87 | + | for _, tt := range tests { | |
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |
| 89 | + | loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent) | |
| 90 | + | out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |
| 91 | + | for label, want := range tt.want { | |
| 92 | + | got := dot(out, label) | |
| 93 | + | if want == "" { | |
| 94 | + | // ANSI-16 colors are chosen by the renderer's quantizer: only | |
| 95 | + | // require a basic or bright palette slot, never extended color. | |
| 96 | + | require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label) | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | require.Equal(t, want, got, label) | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
| @@ -1,12 +1,15 @@ | |||||
| 1 | package client | 1 | package client | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "regexp" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 7 | 8 | ||||
| 8 | ansirenderer "github.com/bnema/vev-vt/ansi" | 9 | ansirenderer "github.com/bnema/vev-vt/ansi" | ||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| 12 | func TestPickerStaysLegibleInReducedColor(t *testing.T) { | 15 | func TestPickerStaysLegibleInReducedColor(t *testing.T) { | ||
| @@ -51,3 +54,50 @@ func TestToastBorderFollowsColorProfile(t *testing.T) { | |||||
| 51 | require.Contains(t, lines[0], "\x1b[22m") | 54 | require.Contains(t, lines[0], "\x1b[22m") | ||
| 52 | require.NotContains(t, lines[0], "\x1b[39m") | 55 | require.NotContains(t, lines[0], "\x1b[39m") | ||
| 53 | } | 56 | } | ||
| 57 | + | ||||
| 58 | + | // TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes | |||
| 59 | + | // the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is | |||
| 60 | + | // quantized by the renderer, and monochrome marks error and warning dots bold | |||
| 61 | + | // since no color survives. Informational statuses never gain weight. | |||
| 62 | + | func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) { | |||
| 63 | + | snapshot := pickerSnapshotFixture() | |||
| 64 | + | snapshot.Lines = []protocol.PickerLine{ | |||
| 65 | + | {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError}, | |||
| 66 | + | {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale}, | |||
| 67 | + | {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion}, | |||
| 68 | + | } | |||
| 69 | + | snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"} | |||
| 70 | + | // dot returns the SGR group in effect for the dot on the row labelled label. | |||
| 71 | + | dot := func(out, label string) string { | |||
| 72 | + | row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`) | |||
| 73 | + | m := row.FindStringSubmatch(out) | |||
| 74 | + | require.NotNil(t, m, "no problem dot after %q in %q", label, out) | |||
| 75 | + | return m[1] | |||
| 76 | + | } | |||
| 77 | + | tests := []struct { | |||
| 78 | + | name string | |||
| 79 | + | profile ansirenderer.ColorProfile | |||
| 80 | + | want map[string]string // label -> exact SGR before the dot | |||
| 81 | + | }{ | |||
| 82 | + | {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |||
| 83 | + | {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}}, | |||
| 84 | + | {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}}, | |||
| 85 | + | {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}}, | |||
| 86 | + | } | |||
| 87 | + | for _, tt := range tests { | |||
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 89 | + | loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent) | |||
| 90 | + | out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())) | |||
| 91 | + | for label, want := range tt.want { | |||
| 92 | + | got := dot(out, label) | |||
| 93 | + | if want == "" { | |||
| 94 | + | // ANSI-16 colors are chosen by the renderer's quantizer: only | |||
| 95 | + | // require a basic or bright palette slot, never extended color. | |||
| 96 | + | require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label) | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | require.Equal(t, want, got, label) | |||
| 100 | + | } | |||
| 101 | + | }) | |||
| 102 | + | } | |||
| 103 | + | } | |||
M internal/usecase/picker/model.go +21 -3
| @@ -61,6 +61,21 @@ type RenderStyles struct { | |||
| 61 | 61 | Status renderer.Style // one-row picker-local contextual help | |
| 62 | 62 | SearchMatch renderer.Style // matched runes on ordinary rows | |
| 63 | 63 | SelectionMatch renderer.Style // matched runes on the selected row | |
| 64 | + | // ProblemBold draws error and warning dots bold. A monochrome terminal | |
| 65 | + | // drops the fixed problem colors, so weight is what sets them apart. | |
| 66 | + | ProblemBold bool | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // DefaultRenderStyles returns the picker's built-in styles. monochrome selects | |
| 70 | + | // the attribute-only variant: tree lines are dimmed instead of grey and | |
| 71 | + | // problem dots are bold, since no color survives on such a terminal. | |
| 72 | + | func DefaultRenderStyles(monochrome bool) RenderStyles { | |
| 73 | + | styles := defaultRenderStyles() | |
| 74 | + | if monochrome { | |
| 75 | + | styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style | |
| 76 | + | styles.ProblemBold = true | |
| 77 | + | } | |
| 78 | + | return styles | |
| 64 | 79 | } | |
| 65 | 80 | ||
| 66 | 81 | func defaultRenderStyles() RenderStyles { | |
| @@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int { | |||
| 585 | 600 | ||
| 586 | 601 | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | |
| 587 | 602 | // its own color even on the inverse selected row. | |
| 588 | - | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) { | |
| 603 | + | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) { | |
| 589 | 604 | if badge == "" { | |
| 590 | 605 | return | |
| 591 | 606 | } | |
| @@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge | |||
| 593 | 608 | style.Inverse = false | |
| 594 | 609 | style.Foreground = lineStatusColor(status) | |
| 595 | 610 | style.HasForegroundRGB = false | |
| 611 | + | if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) { | |
| 612 | + | style.Bold = true | |
| 613 | + | } | |
| 596 | 614 | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | |
| 597 | 615 | } | |
| 598 | 616 | ||
| @@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 681 | 699 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | |
| 682 | 700 | ||
| 683 | 701 | if r.section() { | |
| 684 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | |
| 702 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) | |
| 685 | 703 | continue | |
| 686 | 704 | } | |
| 687 | 705 | if r.rendersAsHeader() { | |
| @@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 697 | 715 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |
| 698 | 716 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| 699 | 717 | } | |
| 700 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | |
| 718 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) | |
| 701 | 719 | continue | |
| 702 | 720 | } | |
| 703 | 721 | ||
| @@ -61,6 +61,21 @@ type RenderStyles struct { | |||||
| 61 | Status renderer.Style // one-row picker-local contextual help | 61 | Status renderer.Style // one-row picker-local contextual help | ||
| 62 | SearchMatch renderer.Style // matched runes on ordinary rows | 62 | SearchMatch renderer.Style // matched runes on ordinary rows | ||
| 63 | SelectionMatch renderer.Style // matched runes on the selected row | 63 | SelectionMatch renderer.Style // matched runes on the selected row | ||
| 64 | + | // ProblemBold draws error and warning dots bold. A monochrome terminal | |||
| 65 | + | // drops the fixed problem colors, so weight is what sets them apart. | |||
| 66 | + | ProblemBold bool | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // DefaultRenderStyles returns the picker's built-in styles. monochrome selects | |||
| 70 | + | // the attribute-only variant: tree lines are dimmed instead of grey and | |||
| 71 | + | // problem dots are bold, since no color survives on such a terminal. | |||
| 72 | + | func DefaultRenderStyles(monochrome bool) RenderStyles { | |||
| 73 | + | styles := defaultRenderStyles() | |||
| 74 | + | if monochrome { | |||
| 75 | + | styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style | |||
| 76 | + | styles.ProblemBold = true | |||
| 77 | + | } | |||
| 78 | + | return styles | |||
| 64 | } | 79 | } | ||
| 65 | 80 | ||||
| 66 | func defaultRenderStyles() RenderStyles { | 81 | func defaultRenderStyles() RenderStyles { | ||
| @@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int { | |||||
| 585 | 600 | ||||
| 586 | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | 601 | // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps | ||
| 587 | // its own color even on the inverse selected row. | 602 | // its own color even on the inverse selected row. | ||
| 588 | - | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) { | 603 | + | func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) { |
| 589 | if badge == "" { | 604 | if badge == "" { | ||
| 590 | return | 605 | return | ||
| 591 | } | 606 | } | ||
| @@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge | |||||
| 593 | style.Inverse = false | 608 | style.Inverse = false | ||
| 594 | style.Foreground = lineStatusColor(status) | 609 | style.Foreground = lineStatusColor(status) | ||
| 595 | style.HasForegroundRGB = false | 610 | style.HasForegroundRGB = false | ||
| 611 | + | if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) { | |||
| 612 | + | style.Bold = true | |||
| 613 | + | } | |||
| 596 | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | 614 | ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style) | ||
| 597 | } | 615 | } | ||
| 598 | 616 | ||||
| @@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 681 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | 699 | x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | ||
| 682 | 700 | ||||
| 683 | if r.section() { | 701 | if r.section() { | ||
| 684 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | 702 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) |
| 685 | continue | 703 | continue | ||
| 686 | } | 704 | } | ||
| 687 | if r.rendersAsHeader() { | 705 | if r.rendersAsHeader() { | ||
| @@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 697 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | 715 | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | ||
| 698 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | 716 | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | ||
| 699 | } | 717 | } | ||
| 700 | - | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status) | 718 | + | drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold) |
| 701 | continue | 719 | continue | ||
| 702 | } | 720 | } | ||
| 703 | 721 | ||||