detached ed1dd0a

feat(ui): dim pane content behind overlays

commit
ed1dd0a6002fa0b71fdab91676f5c11f6e91ae41
author
brice · · 2026-07-09 23:36 UTC
committer
brice · · 2026-07-09 23:55 UTC
parent
a72295f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +201 -0
A internal/usecase/daemon/overlay_backdrop.go +50 -0
@@ -0,0 +1,50 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// overlayBackdrop controls optional dimming of pane content behind an overlay.
10+// Chrome (bars, dividers, and pane titles) is deliberately not part of the
11+// backdrop.
12+type overlayBackdrop struct {
13+ DimPaneContents bool
14+}
15+
16+// apply dims the content rectangles from a tab-content-relative layout
17+// snapshot. A failed solve follows the tab renderer's focused-pane fallback by
18+// dimming the complete content area.
19+func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) {
20+ if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 {
21+ return
22+ }
23+ if !layoutSnap.ok {
24+ dimFrameRect(frame, contentArea, theme)
25+ return
26+ }
27+ for _, placement := range layoutSnap.placements {
28+ if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
29+ continue
30+ }
31+ r := placement.Content
32+ r.X += contentArea.X
33+ r.Y += contentArea.Y
34+ dimFrameRect(frame, r, theme)
35+ }
36+}
37+
38+func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
39+ x0 := max(rect.X, 0)
40+ y0 := max(rect.Y, 0)
41+ x1 := min(rect.X+rect.Width, frame.Width)
42+ y1 := min(rect.Y+rect.Height, frame.Height)
43+ for y := y0; y < y1; y++ {
44+ for x := x0; x < x1; x++ {
45+ cell := frame.At(x, y)
46+ cell.Style = themeui.DimStyle(cell.Style, theme)
47+ frame.Set(x, y, cell)
48+ }
49+ }
50+}
@@ -0,0 +1,50 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// overlayBackdrop controls optional dimming of pane content behind an overlay.
10+// Chrome (bars, dividers, and pane titles) is deliberately not part of the
11+// backdrop.
12+type overlayBackdrop struct {
13+ DimPaneContents bool
14+}
15+
16+// apply dims the content rectangles from a tab-content-relative layout
17+// snapshot. A failed solve follows the tab renderer's focused-pane fallback by
18+// dimming the complete content area.
19+func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) {
20+ if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 {
21+ return
22+ }
23+ if !layoutSnap.ok {
24+ dimFrameRect(frame, contentArea, theme)
25+ return
26+ }
27+ for _, placement := range layoutSnap.placements {
28+ if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
29+ continue
30+ }
31+ r := placement.Content
32+ r.X += contentArea.X
33+ r.Y += contentArea.Y
34+ dimFrameRect(frame, r, theme)
35+ }
36+}
37+
38+func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
39+ x0 := max(rect.X, 0)
40+ y0 := max(rect.Y, 0)
41+ x1 := min(rect.X+rect.Width, frame.Width)
42+ y1 := min(rect.Y+rect.Height, frame.Height)
43+ for y := y0; y < y1; y++ {
44+ for x := x0; x < x1; x++ {
45+ cell := frame.At(x, y)
46+ cell.Style = themeui.DimStyle(cell.Style, theme)
47+ frame.Set(x, y, cell)
48+ }
49+ }
50+}
A internal/usecase/daemon/overlay_backdrop_test.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func backdropTheme() themeui.Theme {
15+ return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
16+}
17+
18+func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) {
19+ frame := renderer.NewFrame(8, 6)
20+ original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}
21+ frame.Set(2, 2, original)
22+ frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
23+ frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title
24+ frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar
25+ frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar
26+ frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed
27+
28+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{
29+ {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}},
30+ {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}},
31+ }}
32+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme())
33+
34+ require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin")
35+ require.True(t, frame.At(2, 2).Style.HasForegroundRGB)
36+ require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming")
37+ require.Zero(t, frame.At(3, 2).Rune)
38+ require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp")
39+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp")
40+ require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp")
41+ require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed")
42+}
43+
44+func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
45+ tests := []struct {
46+ name string
47+ content domain.Rect
48+ affected [][2]int
49+ unaffected [][2]int
50+ }{
51+ {
52+ name: "negative origin",
53+ content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3},
54+ affected: [][2]int{{0, 0}, {1, 1}},
55+ unaffected: [][2]int{{2, 2}, {3, 3}},
56+ },
57+ {
58+ name: "right and bottom overflow",
59+ content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4},
60+ affected: [][2]int{{2, 2}, {3, 3}},
61+ unaffected: [][2]int{{0, 0}, {1, 1}},
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ frame := renderer.NewFrame(4, 4)
68+ original := renderer.DefaultStyle()
69+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}}
70+
71+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
72+
73+ for _, point := range tt.affected {
74+ require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style)
75+ }
76+ for _, point := range tt.unaffected {
77+ require.Equal(t, original, frame.At(point[0], point[1]).Style)
78+ }
79+ })
80+ }
81+}
82+
83+func TestOverlayBackdropDisabledAndFallback(t *testing.T) {
84+ frame := renderer.NewFrame(4, 4)
85+ frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
86+ before := frame.Clone()
87+ area := domain.Rect{Y: 1, Width: 4, Height: 2}
88+ (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
89+ require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default")
90+
91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
92+ require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area")
93+ require.True(t, frame.At(3, 2).Style.HasForegroundRGB)
94+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB)
95+ require.False(t, frame.At(0, 3).Style.HasForegroundRGB)
96+}
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ themeui "github.com/bnema/vev/internal/usecase/theme"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func backdropTheme() themeui.Theme {
15+ return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
16+}
17+
18+func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) {
19+ frame := renderer.NewFrame(8, 6)
20+ original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}
21+ frame.Set(2, 2, original)
22+ frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
23+ frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title
24+ frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar
25+ frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar
26+ frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed
27+
28+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{
29+ {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}},
30+ {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}},
31+ }}
32+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme())
33+
34+ require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin")
35+ require.True(t, frame.At(2, 2).Style.HasForegroundRGB)
36+ require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming")
37+ require.Zero(t, frame.At(3, 2).Rune)
38+ require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp")
39+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp")
40+ require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp")
41+ require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed")
42+}
43+
44+func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
45+ tests := []struct {
46+ name string
47+ content domain.Rect
48+ affected [][2]int
49+ unaffected [][2]int
50+ }{
51+ {
52+ name: "negative origin",
53+ content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3},
54+ affected: [][2]int{{0, 0}, {1, 1}},
55+ unaffected: [][2]int{{2, 2}, {3, 3}},
56+ },
57+ {
58+ name: "right and bottom overflow",
59+ content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4},
60+ affected: [][2]int{{2, 2}, {3, 3}},
61+ unaffected: [][2]int{{0, 0}, {1, 1}},
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ frame := renderer.NewFrame(4, 4)
68+ original := renderer.DefaultStyle()
69+ snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}}
70+
71+ (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
72+
73+ for _, point := range tt.affected {
74+ require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style)
75+ }
76+ for _, point := range tt.unaffected {
77+ require.Equal(t, original, frame.At(point[0], point[1]).Style)
78+ }
79+ })
80+ }
81+}
82+
83+func TestOverlayBackdropDisabledAndFallback(t *testing.T) {
84+ frame := renderer.NewFrame(4, 4)
85+ frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
86+ before := frame.Clone()
87+ area := domain.Rect{Y: 1, Width: 4, Height: 2}
88+ (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
89+ require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default")
90+
91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
92+ require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area")
93+ require.True(t, frame.At(3, 2).Style.HasForegroundRGB)
94+ require.False(t, frame.At(0, 0).Style.HasForegroundRGB)
95+ require.False(t, frame.At(0, 3).Style.HasForegroundRGB)
96+}
M internal/usecase/daemon/render.go +4 -0
@@ -343,6 +343,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
343343 } else {
344344 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
345345 }
346+ if overlays.paletteActive {
347+ contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
348+ (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
349+ }
346350 if overlays.copyActive {
347351 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
348352 }
@@ -343,6 +343,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
343 } else {343 } else {
344 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)344 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
345 }345 }
346+ if overlays.paletteActive {
347+ contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
348+ (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
349+ }
346 if overlays.copyActive {350 if overlays.copyActive {
347 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)351 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
348 }352 }
M internal/usecase/daemon/render_test.go +51 -0
@@ -22,8 +22,59 @@ import (
2222 "github.com/bnema/vev/internal/usecase/layout"
2323 themeui "github.com/bnema/vev/internal/usecase/theme"
2424 "github.com/bnema/vev/pkg/renderer"
25+ "github.com/bnema/vev/pkg/vt"
2526 )
2627
28+func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
29+ p, release := newBlockingPTY(t)
30+ defer release()
31+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32+ ac.setTheme(backdropTheme())
33+ client := vt.NewScreen(80, 25)
34+ pane := sess.tabs[0].focusedPane()
35+ pane.screen.Write([]byte("X"))
36+
37+ d.enterPicker(sess, ac)
38+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
39+ pickerTitle := client.Frame.At(36, 2)
40+ require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
41+ undimmedPane := client.Frame.At(0, 1)
42+
43+ d.enterPalette(sess, ac)
44+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
45+ require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")
46+ require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
47+}
48+
49+func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
50+ p, release := newBlockingPTY(t)
51+ defer release()
52+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
53+ ac.setTheme(backdropTheme())
54+ client := vt.NewScreen(80, 25)
55+ pane := sess.tabs[0].focusedPane()
56+ pane.screen.Write([]byte("X"))
57+ d.paint(sess, ac, true)
58+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
59+ undimmed := client.Frame.At(0, 1)
60+ topBar := client.Frame.At(0, 0)
61+ bottomBar := client.Frame.At(0, 24)
62+
63+ d.handleInput(sess, ac, []byte("\x1b "))
64+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
65+ dimmed := client.Frame.At(0, 1)
66+ require.Equal(t, 'X', dimmed.Rune)
67+ require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style")
68+ require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
69+ require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
70+
71+ d.handleInput(sess, ac, []byte("\x1b"))
72+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
73+ require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style")
74+ require.Equal(t, topBar, client.Frame.At(0, 0))
75+ require.Equal(t, bottomBar, client.Frame.At(0, 24))
76+}
77+
2778 // --- test doubles -----------------------------------------------------------
2879
2980 type discardTransport struct{}
@@ -22,8 +22,59 @@ import (
22 "github.com/bnema/vev/internal/usecase/layout"22 "github.com/bnema/vev/internal/usecase/layout"
23 themeui "github.com/bnema/vev/internal/usecase/theme"23 themeui "github.com/bnema/vev/internal/usecase/theme"
24 "github.com/bnema/vev/pkg/renderer"24 "github.com/bnema/vev/pkg/renderer"
25+ "github.com/bnema/vev/pkg/vt"
25 )26 )
26 27
28+func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
29+ p, release := newBlockingPTY(t)
30+ defer release()
31+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32+ ac.setTheme(backdropTheme())
33+ client := vt.NewScreen(80, 25)
34+ pane := sess.tabs[0].focusedPane()
35+ pane.screen.Write([]byte("X"))
36+
37+ d.enterPicker(sess, ac)
38+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
39+ pickerTitle := client.Frame.At(36, 2)
40+ require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
41+ undimmedPane := client.Frame.At(0, 1)
42+
43+ d.enterPalette(sess, ac)
44+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
45+ require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")
46+ require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
47+}
48+
49+func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
50+ p, release := newBlockingPTY(t)
51+ defer release()
52+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
53+ ac.setTheme(backdropTheme())
54+ client := vt.NewScreen(80, 25)
55+ pane := sess.tabs[0].focusedPane()
56+ pane.screen.Write([]byte("X"))
57+ d.paint(sess, ac, true)
58+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
59+ undimmed := client.Frame.At(0, 1)
60+ topBar := client.Frame.At(0, 0)
61+ bottomBar := client.Frame.At(0, 24)
62+
63+ d.handleInput(sess, ac, []byte("\x1b "))
64+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
65+ dimmed := client.Frame.At(0, 1)
66+ require.Equal(t, 'X', dimmed.Rune)
67+ require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style")
68+ require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
69+ require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
70+
71+ d.handleInput(sess, ac, []byte("\x1b"))
72+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
73+ require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style")
74+ require.Equal(t, topBar, client.Frame.At(0, 0))
75+ require.Equal(t, bottomBar, client.Frame.At(0, 24))
76+}
77+
27 // --- test doubles -----------------------------------------------------------78 // --- test doubles -----------------------------------------------------------
28 79
29 type discardTransport struct{}80 type discardTransport struct{}

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 12

Tags 11

↑↓ moveenter openesc close