feat(palette): style command entries
- commit
- 32173a365aeae4187497efc86614a11e39f90b02
- author
- brice · · 2026-07-07 11:23 UTC
- parent
- 0323a27
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +123 -25
M internal/usecase/daemon/palette.go +4 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 4 | 5 | "github.com/bnema/vev/internal/usecase/command" | |
| 5 | 6 | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -262,5 +263,7 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 262 | 263 | ||
| 263 | 264 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 264 | 265 | styleSet := resolveThemeStyles(styles) | |
| 265 | - | return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, model.Render) | |
| 266 | + | return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 267 | + | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | |
| 268 | + | }) | |
| 266 | 269 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 4 | "github.com/bnema/vev/internal/usecase/command" | 5 | "github.com/bnema/vev/internal/usecase/command" | ||
| 5 | "github.com/bnema/vev/internal/usecase/layout" | 6 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 6 | "github.com/bnema/vev/internal/usecase/palette" | 7 | "github.com/bnema/vev/internal/usecase/palette" | ||
| @@ -262,5 +263,7 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 262 | 263 | ||||
| 263 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | 264 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | ||
| 264 | styleSet := resolveThemeStyles(styles) | 265 | styleSet := resolveThemeStyles(styles) | ||
| 265 | - | return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, model.Render) | 266 | + | return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { |
| 267 | + | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | |||
| 268 | + | }) | |||
| 266 | } | 269 | } | ||
M internal/usecase/daemon/render.go +12 -10
| @@ -440,20 +440,22 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||
| 440 | 440 | } | |
| 441 | 441 | ||
| 442 | 442 | type themeStyles struct { | |
| 443 | - | statusBar renderer.Style | |
| 444 | - | accent renderer.Style | |
| 445 | - | border renderer.Style | |
| 446 | - | selection renderer.Style | |
| 447 | - | copyStatus renderer.Style | |
| 443 | + | statusBar renderer.Style | |
| 444 | + | accent renderer.Style | |
| 445 | + | border renderer.Style | |
| 446 | + | selection renderer.Style | |
| 447 | + | copyStatus renderer.Style | |
| 448 | + | paletteDesc renderer.Style | |
| 448 | 449 | } | |
| 449 | 450 | ||
| 450 | 451 | func newThemeStyles(t themeui.Theme) themeStyles { | |
| 451 | 452 | return themeStyles{ | |
| 452 | - | statusBar: themeui.StatusBarStyle(t), | |
| 453 | - | accent: themeui.AccentStyle(t), | |
| 454 | - | border: themeui.BorderStyle(t), | |
| 455 | - | selection: themeui.SelectionStyle(t), | |
| 456 | - | copyStatus: themeui.SelectionStyle(t), | |
| 453 | + | statusBar: themeui.StatusBarStyle(t), | |
| 454 | + | accent: themeui.AccentStyle(t), | |
| 455 | + | border: themeui.BorderStyle(t), | |
| 456 | + | selection: themeui.SelectionStyle(t), | |
| 457 | + | copyStatus: themeui.SelectionStyle(t), | |
| 458 | + | paletteDesc: themeui.MutedTextStyle(t), | |
| 457 | 459 | } | |
| 458 | 460 | } | |
| 459 | 461 | ||
| @@ -440,20 +440,22 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||||
| 440 | } | 440 | } | ||
| 441 | 441 | ||||
| 442 | type themeStyles struct { | 442 | type themeStyles struct { | ||
| 443 | - | statusBar renderer.Style | 443 | + | statusBar renderer.Style |
| 444 | - | accent renderer.Style | 444 | + | accent renderer.Style |
| 445 | - | border renderer.Style | 445 | + | border renderer.Style |
| 446 | - | selection renderer.Style | 446 | + | selection renderer.Style |
| 447 | - | copyStatus renderer.Style | 447 | + | copyStatus renderer.Style |
| 448 | + | paletteDesc renderer.Style | |||
| 448 | } | 449 | } | ||
| 449 | 450 | ||||
| 450 | func newThemeStyles(t themeui.Theme) themeStyles { | 451 | func newThemeStyles(t themeui.Theme) themeStyles { | ||
| 451 | return themeStyles{ | 452 | return themeStyles{ | ||
| 452 | - | statusBar: themeui.StatusBarStyle(t), | 453 | + | statusBar: themeui.StatusBarStyle(t), |
| 453 | - | accent: themeui.AccentStyle(t), | 454 | + | accent: themeui.AccentStyle(t), |
| 454 | - | border: themeui.BorderStyle(t), | 455 | + | border: themeui.BorderStyle(t), |
| 455 | - | selection: themeui.SelectionStyle(t), | 456 | + | selection: themeui.SelectionStyle(t), |
| 456 | - | copyStatus: themeui.SelectionStyle(t), | 457 | + | copyStatus: themeui.SelectionStyle(t), |
| 458 | + | paletteDesc: themeui.MutedTextStyle(t), | |||
| 457 | } | 459 | } | ||
| 458 | } | 460 | } | ||
| 459 | 461 | ||||
M internal/usecase/palette/model.go +34 -7
| @@ -7,6 +7,11 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | type RenderStyles struct { | |
| 11 | + | Selection renderer.Style | |
| 12 | + | Description renderer.Style | |
| 13 | + | } | |
| 14 | + | ||
| 10 | 15 | type Model struct { | |
| 11 | 16 | commands []command.Command | |
| 12 | 17 | input ui.TextInput | |
| @@ -23,6 +28,14 @@ func New(commands []command.Command) *Model { | |||
| 23 | 28 | ||
| 24 | 29 | func NewRegistry() *Model { return New(command.Registry()) } | |
| 25 | 30 | ||
| 31 | + | func DefaultRenderStyles() RenderStyles { | |
| 32 | + | selection := renderer.DefaultStyle() | |
| 33 | + | selection.Inverse = true | |
| 34 | + | description := renderer.DefaultStyle() | |
| 35 | + | description.Italic = true | |
| 36 | + | return RenderStyles{Selection: selection, Description: description} | |
| 37 | + | } | |
| 38 | + | ||
| 26 | 39 | func (m *Model) Insert(r rune) { | |
| 27 | 40 | if m != nil { | |
| 28 | 41 | m.input.Insert(r) | |
| @@ -96,17 +109,15 @@ func (m *Model) clamp() { | |||
| 96 | 109 | } | |
| 97 | 110 | } | |
| 98 | 111 | ||
| 99 | - | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | |
| 112 | + | func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |
| 100 | 113 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 101 | 114 | if frame.Width == 0 || frame.Height == 0 { | |
| 102 | 115 | return frame | |
| 103 | 116 | } | |
| 104 | 117 | base := renderer.DefaultStyle() | |
| 105 | - | selection := base | |
| 106 | - | selection.Inverse = true | |
| 107 | - | if len(selectedStyle) > 0 { | |
| 108 | - | selection = selectedStyle[0] | |
| 109 | - | } | |
| 118 | + | selection := styles.Selection | |
| 119 | + | desc := styles.Description | |
| 120 | + | desc.Italic = true | |
| 110 | 121 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 111 | 122 | if m == nil { | |
| 112 | 123 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| @@ -150,6 +161,7 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende | |||
| 150 | 161 | } | |
| 151 | 162 | for i, r := range []rune(match.Command.Code) { | |
| 152 | 163 | cellStyle := style | |
| 164 | + | cellStyle.Bold = true | |
| 153 | 165 | if highlight[i] { | |
| 154 | 166 | cellStyle = selection | |
| 155 | 167 | cellStyle.Bold = true | |
| @@ -163,7 +175,22 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende | |||
| 163 | 175 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | |
| 164 | 176 | x++ | |
| 165 | 177 | } | |
| 166 | - | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — "+match.Command.Desc, style) | |
| 178 | + | x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style) | |
| 179 | + | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | |
| 167 | 180 | } | |
| 168 | 181 | return frame | |
| 169 | 182 | } | |
| 183 | + | ||
| 184 | + | func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style { | |
| 185 | + | out := line | |
| 186 | + | out.Italic = true | |
| 187 | + | if desc.HasForegroundRGB { | |
| 188 | + | out.HasForegroundRGB = true | |
| 189 | + | out.ForegroundRGB = desc.ForegroundRGB | |
| 190 | + | out.Foreground = desc.Foreground | |
| 191 | + | } else if desc.Foreground >= 0 { | |
| 192 | + | out.HasForegroundRGB = false | |
| 193 | + | out.Foreground = desc.Foreground | |
| 194 | + | } | |
| 195 | + | return out | |
| 196 | + | } | |
| @@ -7,6 +7,11 @@ import ( | |||||
| 7 | "github.com/bnema/vev/pkg/renderer" | 7 | "github.com/bnema/vev/pkg/renderer" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | type RenderStyles struct { | |||
| 11 | + | Selection renderer.Style | |||
| 12 | + | Description renderer.Style | |||
| 13 | + | } | |||
| 14 | + | ||||
| 10 | type Model struct { | 15 | type Model struct { | ||
| 11 | commands []command.Command | 16 | commands []command.Command | ||
| 12 | input ui.TextInput | 17 | input ui.TextInput | ||
| @@ -23,6 +28,14 @@ func New(commands []command.Command) *Model { | |||||
| 23 | 28 | ||||
| 24 | func NewRegistry() *Model { return New(command.Registry()) } | 29 | func NewRegistry() *Model { return New(command.Registry()) } | ||
| 25 | 30 | ||||
| 31 | + | func DefaultRenderStyles() RenderStyles { | |||
| 32 | + | selection := renderer.DefaultStyle() | |||
| 33 | + | selection.Inverse = true | |||
| 34 | + | description := renderer.DefaultStyle() | |||
| 35 | + | description.Italic = true | |||
| 36 | + | return RenderStyles{Selection: selection, Description: description} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 26 | func (m *Model) Insert(r rune) { | 39 | func (m *Model) Insert(r rune) { | ||
| 27 | if m != nil { | 40 | if m != nil { | ||
| 28 | m.input.Insert(r) | 41 | m.input.Insert(r) | ||
| @@ -96,17 +109,15 @@ func (m *Model) clamp() { | |||||
| 96 | } | 109 | } | ||
| 97 | } | 110 | } | ||
| 98 | 111 | ||||
| 99 | - | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | 112 | + | func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { |
| 100 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 113 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 101 | if frame.Width == 0 || frame.Height == 0 { | 114 | if frame.Width == 0 || frame.Height == 0 { | ||
| 102 | return frame | 115 | return frame | ||
| 103 | } | 116 | } | ||
| 104 | base := renderer.DefaultStyle() | 117 | base := renderer.DefaultStyle() | ||
| 105 | - | selection := base | 118 | + | selection := styles.Selection |
| 106 | - | selection.Inverse = true | 119 | + | desc := styles.Description |
| 107 | - | if len(selectedStyle) > 0 { | 120 | + | desc.Italic = true |
| 108 | - | selection = selectedStyle[0] | |||
| 109 | - | } | |||
| 110 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 121 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 111 | if m == nil { | 122 | if m == nil { | ||
| 112 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | 123 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | ||
| @@ -150,6 +161,7 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende | |||||
| 150 | } | 161 | } | ||
| 151 | for i, r := range []rune(match.Command.Code) { | 162 | for i, r := range []rune(match.Command.Code) { | ||
| 152 | cellStyle := style | 163 | cellStyle := style | ||
| 164 | + | cellStyle.Bold = true | |||
| 153 | if highlight[i] { | 165 | if highlight[i] { | ||
| 154 | cellStyle = selection | 166 | cellStyle = selection | ||
| 155 | cellStyle.Bold = true | 167 | cellStyle.Bold = true | ||
| @@ -163,7 +175,22 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende | |||||
| 163 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | 175 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | ||
| 164 | x++ | 176 | x++ | ||
| 165 | } | 177 | } | ||
| 166 | - | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — "+match.Command.Desc, style) | 178 | + | x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style) |
| 179 | + | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | |||
| 167 | } | 180 | } | ||
| 168 | return frame | 181 | return frame | ||
| 169 | } | 182 | } | ||
| 183 | + | ||||
| 184 | + | func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style { | |||
| 185 | + | out := line | |||
| 186 | + | out.Italic = true | |||
| 187 | + | if desc.HasForegroundRGB { | |||
| 188 | + | out.HasForegroundRGB = true | |||
| 189 | + | out.ForegroundRGB = desc.ForegroundRGB | |||
| 190 | + | out.Foreground = desc.Foreground | |||
| 191 | + | } else if desc.Foreground >= 0 { | |||
| 192 | + | out.HasForegroundRGB = false | |||
| 193 | + | out.Foreground = desc.Foreground | |||
| 194 | + | } | |||
| 195 | + | return out | |||
| 196 | + | } | |||
M internal/usecase/palette/model_test.go +24 -5
| @@ -73,7 +73,7 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||
| 73 | 73 | }) | |
| 74 | 74 | m.Insert('c') | |
| 75 | 75 | m.Insert('y') | |
| 76 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}) | |
| 76 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, DefaultRenderStyles()) | |
| 77 | 77 | ||
| 78 | 78 | require.Equal(t, '>', frame.At(0, 0).Rune) | |
| 79 | 79 | require.Equal(t, ' ', frame.At(1, 0).Rune) | |
| @@ -86,9 +86,10 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||
| 86 | 86 | require.Equal(t, 'Y', frame.At(2, 1).Rune) | |
| 87 | 87 | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | |
| 88 | 88 | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | |
| 89 | - | require.True(t, frame.At(0, 1).Style.Bold, "matched C is bold") | |
| 90 | - | require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted") | |
| 91 | - | require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold") | |
| 89 | + | require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold") | |
| 90 | + | require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold") | |
| 91 | + | require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold") | |
| 92 | + | require.True(t, frame.At(13, 1).Style.Italic, "description is italic") | |
| 92 | 93 | } | |
| 93 | 94 | ||
| 94 | 95 | func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | |
| @@ -99,7 +100,9 @@ func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | |||
| 99 | 100 | accent.HasBackgroundRGB = true | |
| 100 | 101 | accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |
| 101 | 102 | ||
| 102 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, accent) | |
| 103 | + | styles := DefaultRenderStyles() | |
| 104 | + | styles.Selection = accent | |
| 105 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, styles) | |
| 103 | 106 | ||
| 104 | 107 | matched := frame.At(0, 1).Style | |
| 105 | 108 | require.True(t, matched.Bold) | |
| @@ -107,3 +110,19 @@ func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | |||
| 107 | 110 | require.True(t, matched.HasBackgroundRGB) | |
| 108 | 111 | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB) | |
| 109 | 112 | } | |
| 113 | + | ||
| 114 | + | func TestRenderUsesMutedItalicDescriptionStyle(t *testing.T) { | |
| 115 | + | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |
| 116 | + | muted := renderer.DefaultStyle() | |
| 117 | + | muted.HasForegroundRGB = true | |
| 118 | + | muted.ForegroundRGB = renderer.RGB{R: 10, G: 20, B: 30} | |
| 119 | + | ||
| 120 | + | styles := DefaultRenderStyles() | |
| 121 | + | styles.Description = muted | |
| 122 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, styles) | |
| 123 | + | ||
| 124 | + | desc := frame.At(13, 1).Style | |
| 125 | + | require.True(t, desc.Italic) | |
| 126 | + | require.True(t, desc.HasForegroundRGB) | |
| 127 | + | require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, desc.ForegroundRGB) | |
| 128 | + | } | |
| @@ -73,7 +73,7 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||||
| 73 | }) | 73 | }) | ||
| 74 | m.Insert('c') | 74 | m.Insert('c') | ||
| 75 | m.Insert('y') | 75 | m.Insert('y') | ||
| 76 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}) | 76 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, DefaultRenderStyles()) |
| 77 | 77 | ||||
| 78 | require.Equal(t, '>', frame.At(0, 0).Rune) | 78 | require.Equal(t, '>', frame.At(0, 0).Rune) | ||
| 79 | require.Equal(t, ' ', frame.At(1, 0).Rune) | 79 | require.Equal(t, ' ', frame.At(1, 0).Rune) | ||
| @@ -86,9 +86,10 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||||
| 86 | require.Equal(t, 'Y', frame.At(2, 1).Rune) | 86 | require.Equal(t, 'Y', frame.At(2, 1).Rune) | ||
| 87 | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | 87 | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | ||
| 88 | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | 88 | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | ||
| 89 | - | require.True(t, frame.At(0, 1).Style.Bold, "matched C is bold") | 89 | + | require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold") |
| 90 | - | require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted") | 90 | + | require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold") |
| 91 | - | require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold") | 91 | + | require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold") |
| 92 | + | require.True(t, frame.At(13, 1).Style.Italic, "description is italic") | |||
| 92 | } | 93 | } | ||
| 93 | 94 | ||||
| 94 | func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | 95 | func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | ||
| @@ -99,7 +100,9 @@ func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | |||||
| 99 | accent.HasBackgroundRGB = true | 100 | accent.HasBackgroundRGB = true | ||
| 100 | accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | 101 | accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | ||
| 101 | 102 | ||||
| 102 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, accent) | 103 | + | styles := DefaultRenderStyles() |
| 104 | + | styles.Selection = accent | |||
| 105 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, styles) | |||
| 103 | 106 | ||||
| 104 | matched := frame.At(0, 1).Style | 107 | matched := frame.At(0, 1).Style | ||
| 105 | require.True(t, matched.Bold) | 108 | require.True(t, matched.Bold) | ||
| @@ -107,3 +110,19 @@ func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) { | |||||
| 107 | require.True(t, matched.HasBackgroundRGB) | 110 | require.True(t, matched.HasBackgroundRGB) | ||
| 108 | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB) | 111 | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB) | ||
| 109 | } | 112 | } | ||
| 113 | + | ||||
| 114 | + | func TestRenderUsesMutedItalicDescriptionStyle(t *testing.T) { | |||
| 115 | + | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |||
| 116 | + | muted := renderer.DefaultStyle() | |||
| 117 | + | muted.HasForegroundRGB = true | |||
| 118 | + | muted.ForegroundRGB = renderer.RGB{R: 10, G: 20, B: 30} | |||
| 119 | + | ||||
| 120 | + | styles := DefaultRenderStyles() | |||
| 121 | + | styles.Description = muted | |||
| 122 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, styles) | |||
| 123 | + | ||||
| 124 | + | desc := frame.At(13, 1).Style | |||
| 125 | + | require.True(t, desc.Italic) | |||
| 126 | + | require.True(t, desc.HasForegroundRGB) | |||
| 127 | + | require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, desc.ForegroundRGB) | |||
| 128 | + | } | |||
M internal/usecase/snapshot/codec.go +4 -1
| @@ -751,6 +751,9 @@ func writeStyle(w *payloadWriter, s renderer.Style) { | |||
| 751 | 751 | if s.HasBackgroundRGB { | |
| 752 | 752 | flags |= 8 | |
| 753 | 753 | } | |
| 754 | + | if s.Italic { | |
| 755 | + | flags |= 16 | |
| 756 | + | } | |
| 754 | 757 | w.putUint8(flags) | |
| 755 | 758 | w.putUint32(uint32(int32(s.Foreground))) | |
| 756 | 759 | w.putUint32(uint32(int32(s.Background))) | |
| @@ -799,7 +802,7 @@ func readStyle(r *payloadReader) (renderer.Style, error) { | |||
| 799 | 802 | if err != nil { | |
| 800 | 803 | return renderer.Style{}, err | |
| 801 | 804 | } | |
| 802 | - | return renderer.Style{Bold: flags&1 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil | |
| 805 | + | return renderer.Style{Bold: flags&1 != 0, Italic: flags&16 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil | |
| 803 | 806 | } | |
| 804 | 807 | ||
| 805 | 808 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | |
| @@ -751,6 +751,9 @@ func writeStyle(w *payloadWriter, s renderer.Style) { | |||||
| 751 | if s.HasBackgroundRGB { | 751 | if s.HasBackgroundRGB { | ||
| 752 | flags |= 8 | 752 | flags |= 8 | ||
| 753 | } | 753 | } | ||
| 754 | + | if s.Italic { | |||
| 755 | + | flags |= 16 | |||
| 756 | + | } | |||
| 754 | w.putUint8(flags) | 757 | w.putUint8(flags) | ||
| 755 | w.putUint32(uint32(int32(s.Foreground))) | 758 | w.putUint32(uint32(int32(s.Foreground))) | ||
| 756 | w.putUint32(uint32(int32(s.Background))) | 759 | w.putUint32(uint32(int32(s.Background))) | ||
| @@ -799,7 +802,7 @@ func readStyle(r *payloadReader) (renderer.Style, error) { | |||||
| 799 | if err != nil { | 802 | if err != nil { | ||
| 800 | return renderer.Style{}, err | 803 | return renderer.Style{}, err | ||
| 801 | } | 804 | } | ||
| 802 | - | return renderer.Style{Bold: flags&1 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil | 805 | + | return renderer.Style{Bold: flags&1 != 0, Italic: flags&16 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil |
| 803 | } | 806 | } | ||
| 804 | 807 | ||||
| 805 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | 808 | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | ||
M internal/usecase/snapshot/codec_test.go +1 -0
| @@ -66,6 +66,7 @@ func TestRoundTripSessions(t *testing.T) { | |||
| 66 | 66 | indexed.Foreground = 2 | |
| 67 | 67 | indexed.Background = 4 | |
| 68 | 68 | indexed.Inverse = true | |
| 69 | + | indexed.Italic = true | |
| 69 | 70 | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 70 | 71 | layout.NewLeaf("1"), | |
| 71 | 72 | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | |
| @@ -66,6 +66,7 @@ func TestRoundTripSessions(t *testing.T) { | |||||
| 66 | indexed.Foreground = 2 | 66 | indexed.Foreground = 2 | ||
| 67 | indexed.Background = 4 | 67 | indexed.Background = 4 | ||
| 68 | indexed.Inverse = true | 68 | indexed.Inverse = true | ||
| 69 | + | indexed.Italic = true | |||
| 69 | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | 70 | deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | ||
| 70 | layout.NewLeaf("1"), | 71 | layout.NewLeaf("1"), | ||
| 71 | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | 72 | &layout.Node{Kind: layout.Stack, Expanded: "2", Children: []*layout.Node{layout.NewLeaf("2"), layout.NewLeaf("3")}}, | ||
M internal/usecase/theme/theme.go +10 -0
| @@ -327,6 +327,16 @@ func BorderStyle(t Theme) renderer.Style { | |||
| 327 | 327 | return style | |
| 328 | 328 | } | |
| 329 | 329 | ||
| 330 | + | func MutedTextStyle(t Theme) renderer.Style { | |
| 331 | + | if !usable(t) { | |
| 332 | + | return renderer.DefaultStyle() | |
| 333 | + | } | |
| 334 | + | style := renderer.DefaultStyle() | |
| 335 | + | style.HasForegroundRGB = true | |
| 336 | + | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 337 | + | return style | |
| 338 | + | } | |
| 339 | + | ||
| 330 | 340 | func DimStyle(style renderer.Style, t Theme) renderer.Style { | |
| 331 | 341 | if !usable(t) { | |
| 332 | 342 | return style | |
| @@ -327,6 +327,16 @@ func BorderStyle(t Theme) renderer.Style { | |||||
| 327 | return style | 327 | return style | ||
| 328 | } | 328 | } | ||
| 329 | 329 | ||||
| 330 | + | func MutedTextStyle(t Theme) renderer.Style { | |||
| 331 | + | if !usable(t) { | |||
| 332 | + | return renderer.DefaultStyle() | |||
| 333 | + | } | |||
| 334 | + | style := renderer.DefaultStyle() | |||
| 335 | + | style.HasForegroundRGB = true | |||
| 336 | + | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |||
| 337 | + | return style | |||
| 338 | + | } | |||
| 339 | + | ||||
| 330 | func DimStyle(style renderer.Style, t Theme) renderer.Style { | 340 | func DimStyle(style renderer.Style, t Theme) renderer.Style { | ||
| 331 | if !usable(t) { | 341 | if !usable(t) { | ||
| 332 | return style | 342 | return style | ||
M pkg/renderer/renderer.go +3 -0
| @@ -319,6 +319,9 @@ func writeStyle(out *bytes.Buffer, style Style) { | |||
| 319 | 319 | if style.Bold { | |
| 320 | 320 | out.WriteString(";1") | |
| 321 | 321 | } | |
| 322 | + | if style.Italic { | |
| 323 | + | out.WriteString(";3") | |
| 324 | + | } | |
| 322 | 325 | if style.Inverse { | |
| 323 | 326 | out.WriteString(";7") | |
| 324 | 327 | } | |
| @@ -319,6 +319,9 @@ func writeStyle(out *bytes.Buffer, style Style) { | |||||
| 319 | if style.Bold { | 319 | if style.Bold { | ||
| 320 | out.WriteString(";1") | 320 | out.WriteString(";1") | ||
| 321 | } | 321 | } | ||
| 322 | + | if style.Italic { | |||
| 323 | + | out.WriteString(";3") | |||
| 324 | + | } | |||
| 322 | if style.Inverse { | 325 | if style.Inverse { | ||
| 323 | out.WriteString(";7") | 326 | out.WriteString(";7") | ||
| 324 | } | 327 | } | ||
M pkg/renderer/renderer_test.go +10 -0
| @@ -272,6 +272,16 @@ func TestRendererEmitsSGR(t *testing.T) { | |||
| 272 | 272 | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}, | |
| 273 | 273 | wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"}, | |
| 274 | 274 | }, | |
| 275 | + | { | |
| 276 | + | name: "bold italic inverse", | |
| 277 | + | width: 1, | |
| 278 | + | height: 1, | |
| 279 | + | setup: func(frame Frame) { | |
| 280 | + | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}}) | |
| 281 | + | }, | |
| 282 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |
| 283 | + | wantAll: []string{"\x1b[0;1;3;7m", "X"}, | |
| 284 | + | }, | |
| 275 | 285 | } | |
| 276 | 286 | ||
| 277 | 287 | for _, tt := range tests { | |
| @@ -272,6 +272,16 @@ func TestRendererEmitsSGR(t *testing.T) { | |||||
| 272 | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}, | 272 | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}, | ||
| 273 | wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"}, | 273 | wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"}, | ||
| 274 | }, | 274 | }, | ||
| 275 | + | { | |||
| 276 | + | name: "bold italic inverse", | |||
| 277 | + | width: 1, | |||
| 278 | + | height: 1, | |||
| 279 | + | setup: func(frame Frame) { | |||
| 280 | + | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}}) | |||
| 281 | + | }, | |||
| 282 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |||
| 283 | + | wantAll: []string{"\x1b[0;1;3;7m", "X"}, | |||
| 284 | + | }, | |||
| 275 | } | 285 | } | ||
| 276 | 286 | ||||
| 277 | for _, tt := range tests { | 287 | for _, tt := range tests { | ||
M pkg/renderer/style.go +2 -1
| @@ -8,6 +8,7 @@ type RGB struct { | |||
| 8 | 8 | ||
| 9 | 9 | type Style struct { | |
| 10 | 10 | Bold bool | |
| 11 | + | Italic bool | |
| 11 | 12 | Inverse bool | |
| 12 | 13 | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | |
| 13 | 14 | Background int // -1 means unset; ignored when HasBackgroundRGB is true | |
| @@ -21,7 +22,7 @@ type Style struct { | |||
| 21 | 22 | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | |
| 22 | 23 | ||
| 23 | 24 | func (s Style) Equal(other Style) bool { | |
| 24 | - | if s.Bold != other.Bold || s.Inverse != other.Inverse { | |
| 25 | + | if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse { | |
| 25 | 26 | return false | |
| 26 | 27 | } | |
| 27 | 28 | if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB { | |
| @@ -8,6 +8,7 @@ type RGB struct { | |||||
| 8 | 8 | ||||
| 9 | type Style struct { | 9 | type Style struct { | ||
| 10 | Bold bool | 10 | Bold bool | ||
| 11 | + | Italic bool | |||
| 11 | Inverse bool | 12 | Inverse bool | ||
| 12 | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | 13 | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | ||
| 13 | Background int // -1 means unset; ignored when HasBackgroundRGB is true | 14 | Background int // -1 means unset; ignored when HasBackgroundRGB is true | ||
| @@ -21,7 +22,7 @@ type Style struct { | |||||
| 21 | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | 22 | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | ||
| 22 | 23 | ||||
| 23 | func (s Style) Equal(other Style) bool { | 24 | func (s Style) Equal(other Style) bool { | ||
| 24 | - | if s.Bold != other.Bold || s.Inverse != other.Inverse { | 25 | + | if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse { |
| 25 | return false | 26 | return false | ||
| 26 | } | 27 | } | ||
| 27 | if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB { | 28 | if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB { | ||
M pkg/vt/screen.go +4 -0
| @@ -959,10 +959,14 @@ func (s *Screen) applySGR(params string) { | |||
| 959 | 959 | s.Style = renderer.DefaultStyle() | |
| 960 | 960 | case 1: | |
| 961 | 961 | s.Style.Bold = true | |
| 962 | + | case 3: | |
| 963 | + | s.Style.Italic = true | |
| 962 | 964 | case 7: | |
| 963 | 965 | s.Style.Inverse = true | |
| 964 | 966 | case 22: | |
| 965 | 967 | s.Style.Bold = false | |
| 968 | + | case 23: | |
| 969 | + | s.Style.Italic = false | |
| 966 | 970 | case 27: | |
| 967 | 971 | s.Style.Inverse = false | |
| 968 | 972 | case 30, 31, 32, 33, 34, 35, 36, 37: | |
| @@ -959,10 +959,14 @@ func (s *Screen) applySGR(params string) { | |||||
| 959 | s.Style = renderer.DefaultStyle() | 959 | s.Style = renderer.DefaultStyle() | ||
| 960 | case 1: | 960 | case 1: | ||
| 961 | s.Style.Bold = true | 961 | s.Style.Bold = true | ||
| 962 | + | case 3: | |||
| 963 | + | s.Style.Italic = true | |||
| 962 | case 7: | 964 | case 7: | ||
| 963 | s.Style.Inverse = true | 965 | s.Style.Inverse = true | ||
| 964 | case 22: | 966 | case 22: | ||
| 965 | s.Style.Bold = false | 967 | s.Style.Bold = false | ||
| 968 | + | case 23: | |||
| 969 | + | s.Style.Italic = false | |||
| 966 | case 27: | 970 | case 27: | ||
| 967 | s.Style.Inverse = false | 971 | s.Style.Inverse = false | ||
| 968 | case 30, 31, 32, 33, 34, 35, 36, 37: | 972 | case 30, 31, 32, 33, 34, 35, 36, 37: | ||
M pkg/vt/screen_test.go +15 -0
| @@ -699,6 +699,21 @@ func TestSGR(t *testing.T) { | |||
| 699 | 699 | } | |
| 700 | 700 | }, | |
| 701 | 701 | }, | |
| 702 | + | { | |
| 703 | + | name: "SGR 3 sets italic on style and cell, SGR 23 clears it", | |
| 704 | + | seq: "\x1b[3mX\x1b[23mY", | |
| 705 | + | check: func(t *testing.T, s *Screen) { | |
| 706 | + | if !cellAt(s, 0, 0).Style.Italic { | |
| 707 | + | t.Errorf("first cell italic = false, want true") | |
| 708 | + | } | |
| 709 | + | if cellAt(s, 1, 0).Style.Italic { | |
| 710 | + | t.Errorf("second cell italic = true, want false after SGR 23") | |
| 711 | + | } | |
| 712 | + | if s.Style.Italic { | |
| 713 | + | t.Errorf("current style italic = true, want false after SGR 23") | |
| 714 | + | } | |
| 715 | + | }, | |
| 716 | + | }, | |
| 702 | 717 | { | |
| 703 | 718 | name: "empty SGR params reset style", | |
| 704 | 719 | seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset | |
| @@ -699,6 +699,21 @@ func TestSGR(t *testing.T) { | |||||
| 699 | } | 699 | } | ||
| 700 | }, | 700 | }, | ||
| 701 | }, | 701 | }, | ||
| 702 | + | { | |||
| 703 | + | name: "SGR 3 sets italic on style and cell, SGR 23 clears it", | |||
| 704 | + | seq: "\x1b[3mX\x1b[23mY", | |||
| 705 | + | check: func(t *testing.T, s *Screen) { | |||
| 706 | + | if !cellAt(s, 0, 0).Style.Italic { | |||
| 707 | + | t.Errorf("first cell italic = false, want true") | |||
| 708 | + | } | |||
| 709 | + | if cellAt(s, 1, 0).Style.Italic { | |||
| 710 | + | t.Errorf("second cell italic = true, want false after SGR 23") | |||
| 711 | + | } | |||
| 712 | + | if s.Style.Italic { | |||
| 713 | + | t.Errorf("current style italic = true, want false after SGR 23") | |||
| 714 | + | } | |||
| 715 | + | }, | |||
| 716 | + | }, | |||
| 702 | { | 717 | { | ||
| 703 | name: "empty SGR params reset style", | 718 | name: "empty SGR params reset style", | ||
| 704 | seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset | 719 | seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset | ||