detached 32173a3

feat(palette): style command entries

commit
32173a365aeae4187497efc86614a11e39f90b02
author
brice · · 2026-07-07 11:23 UTC
parent
0323a27
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +123 -25
M internal/usecase/daemon/palette.go +4 -1
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "github.com/bnema/vev/internal/domain"
45 "github.com/bnema/vev/internal/usecase/command"
56 "github.com/bnema/vev/internal/usecase/layout"
67 "github.com/bnema/vev/internal/usecase/palette"
@@ -262,5 +263,7 @@ func (e paletteExec) OpenSessionPicker() error {
262263
263264 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
264265 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+ })
266269 }
@@ -1,6 +1,7 @@
1 package daemon1 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
440440 }
441441
442442 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
448449 }
449450
450451 func newThemeStyles(t themeui.Theme) themeStyles {
451452 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),
457459 }
458460 }
459461
@@ -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.Style443+ statusBar renderer.Style
444- accent renderer.Style444+ accent renderer.Style
445- border renderer.Style445+ border renderer.Style
446- selection renderer.Style446+ selection renderer.Style
447- copyStatus renderer.Style447+ 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 (
77 "github.com/bnema/vev/pkg/renderer"
88 )
99
10+type RenderStyles struct {
11+ Selection renderer.Style
12+ Description renderer.Style
13+}
14+
1015 type Model struct {
1116 commands []command.Command
1217 input ui.TextInput
@@ -23,6 +28,14 @@ func New(commands []command.Command) *Model {
2328
2429 func NewRegistry() *Model { return New(command.Registry()) }
2530
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+
2639 func (m *Model) Insert(r rune) {
2740 if m != nil {
2841 m.input.Insert(r)
@@ -96,17 +109,15 @@ func (m *Model) clamp() {
96109 }
97110 }
98111
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 {
100113 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
101114 if frame.Width == 0 || frame.Height == 0 {
102115 return frame
103116 }
104117 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
110121 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
111122 if m == nil {
112123 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
@@ -150,6 +161,7 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende
150161 }
151162 for i, r := range []rune(match.Command.Code) {
152163 cellStyle := style
164+ cellStyle.Bold = true
153165 if highlight[i] {
154166 cellStyle = selection
155167 cellStyle.Bold = true
@@ -163,7 +175,22 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende
163175 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
164176 x++
165177 }
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))
167180 }
168181 return frame
169182 }
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.Command16 commands []command.Command
12 input ui.TextInput17 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 frame115 return frame
103 }116 }
104 base := renderer.DefaultStyle()117 base := renderer.DefaultStyle()
105- selection := base118+ selection := styles.Selection
106- selection.Inverse = true119+ 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 := style163 cellStyle := style
164+ cellStyle.Bold = true
153 if highlight[i] {165 if highlight[i] {
154 cellStyle = selection166 cellStyle = selection
155 cellStyle.Bold = true167 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 frame181 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) {
7373 })
7474 m.Insert('c')
7575 m.Insert('y')
76- frame := m.Render(domain.Size{Cols: 28, Rows: 3})
76+ frame := m.Render(domain.Size{Cols: 28, Rows: 3}, DefaultRenderStyles())
7777
7878 require.Equal(t, '>', frame.At(0, 0).Rune)
7979 require.Equal(t, ' ', frame.At(1, 0).Rune)
@@ -86,9 +86,10 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
8686 require.Equal(t, 'Y', frame.At(2, 1).Rune)
8787 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
8888 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")
9293 }
9394
9495 func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) {
@@ -99,7 +100,9 @@ func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) {
99100 accent.HasBackgroundRGB = true
100101 accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
101102
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)
103106
104107 matched := frame.At(0, 1).Style
105108 require.True(t, matched.Bold)
@@ -107,3 +110,19 @@ func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) {
107110 require.True(t, matched.HasBackgroundRGB)
108111 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB)
109112 }
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 = true100 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).Style107 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) {
751751 if s.HasBackgroundRGB {
752752 flags |= 8
753753 }
754+ if s.Italic {
755+ flags |= 16
756+ }
754757 w.putUint8(flags)
755758 w.putUint32(uint32(int32(s.Foreground)))
756759 w.putUint32(uint32(int32(s.Background)))
@@ -799,7 +802,7 @@ func readStyle(r *payloadReader) (renderer.Style, error) {
799802 if err != nil {
800803 return renderer.Style{}, err
801804 }
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
803806 }
804807
805808 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 |= 8752 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{}, err803 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}}, nil805+ 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) {
6666 indexed.Foreground = 2
6767 indexed.Background = 4
6868 indexed.Inverse = true
69+ indexed.Italic = true
6970 deepTree := &layout.Tree{Focus: "2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
7071 layout.NewLeaf("1"),
7172 &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 = 266 indexed.Foreground = 2
67 indexed.Background = 467 indexed.Background = 4
68 indexed.Inverse = true68 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 {
327327 return style
328328 }
329329
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+
330340 func DimStyle(style renderer.Style, t Theme) renderer.Style {
331341 if !usable(t) {
332342 return style
@@ -327,6 +327,16 @@ func BorderStyle(t Theme) renderer.Style {
327 return style327 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 style342 return style
M pkg/renderer/renderer.go +3 -0
@@ -319,6 +319,9 @@ func writeStyle(out *bytes.Buffer, style Style) {
319319 if style.Bold {
320320 out.WriteString(";1")
321321 }
322+ if style.Italic {
323+ out.WriteString(";3")
324+ }
322325 if style.Inverse {
323326 out.WriteString(";7")
324327 }
@@ -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) {
272272 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}},
273273 wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"},
274274 },
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+ },
275285 }
276286
277287 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 {
88
99 type Style struct {
1010 Bold bool
11+ Italic bool
1112 Inverse bool
1213 Foreground int // -1 means unset; ignored when HasForegroundRGB is true
1314 Background int // -1 means unset; ignored when HasBackgroundRGB is true
@@ -21,7 +22,7 @@ type Style struct {
2122 func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }
2223
2324 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 {
2526 return false
2627 }
2728 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 bool10 Bold bool
11+ Italic bool
11 Inverse bool12 Inverse bool
12 Foreground int // -1 means unset; ignored when HasForegroundRGB is true13 Foreground int // -1 means unset; ignored when HasForegroundRGB is true
13 Background int // -1 means unset; ignored when HasBackgroundRGB is true14 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 false26 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) {
959959 s.Style = renderer.DefaultStyle()
960960 case 1:
961961 s.Style.Bold = true
962+ case 3:
963+ s.Style.Italic = true
962964 case 7:
963965 s.Style.Inverse = true
964966 case 22:
965967 s.Style.Bold = false
968+ case 23:
969+ s.Style.Italic = false
966970 case 27:
967971 s.Style.Inverse = false
968972 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 = true961 s.Style.Bold = true
962+ case 3:
963+ s.Style.Italic = true
962 case 7:964 case 7:
963 s.Style.Inverse = true965 s.Style.Inverse = true
964 case 22:966 case 22:
965 s.Style.Bold = false967 s.Style.Bold = false
968+ case 23:
969+ s.Style.Italic = false
966 case 27:970 case 27:
967 s.Style.Inverse = false971 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) {
699699 }
700700 },
701701 },
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+ },
702717 {
703718 name: "empty SGR params reset style",
704719 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 → reset719 seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close