detached 5014839

refactor(ui): make presentation borders canonical

commit
501483979237dd9bae9b864cd94b5dd0b8797433
author
brice · · 2026-07-27 15:26 UTC
committer
brice · · 2026-07-27 17:06 UTC
parent
1ace180
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +130 -41
M internal/usecase/ui/modal.go +51 -41
@@ -39,26 +39,14 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
3939
4040 // Inner returns the modal content rectangle after removing a one-cell border.
4141 func (m Modal) Inner(base domain.Size) domain.Rect {
42- bounds := m.Bounds(base)
43- return domain.Rect{
44- X: bounds.X + 1,
45- Y: bounds.Y + 1,
46- Width: max(0, bounds.Width-2),
47- Height: max(0, bounds.Height-2),
48- }
42+ return modalInner(m.Bounds(base))
4943 }
5044
5145 // Resolve computes the modal's responsive presentation from one preferred
5246 // bounds and inner pair.
5347 func (m Modal) Resolve(base domain.Size) Presentation {
5448 bounds := m.Bounds(base)
55- inner := domain.Rect{
56- X: bounds.X + 1,
57- Y: bounds.Y + 1,
58- Width: max(0, bounds.Width-2),
59- Height: max(0, bounds.Height-2),
60- }
61- return ResolvePresentation(base, bounds, inner)
49+ return ResolvePresentation(base, bounds, modalInner(bounds))
6250 }
6351
6452 // Composite draws the modal border and title, fills its interior, and returns
@@ -67,12 +55,7 @@ func (m Modal) Resolve(base domain.Size) Presentation {
6755 // Cells outside the modal bounds are not changed.
6856 func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
6957 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
70- inner := domain.Rect{
71- X: bounds.X + 1,
72- Y: bounds.Y + 1,
73- Width: max(0, bounds.Width-2),
74- Height: max(0, bounds.Height-2),
75- }
58+ inner := modalInner(bounds)
7659 DrawBox(f, bounds, border)
7760 FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
7861 m.drawTitle(f, bounds, border)
@@ -80,19 +63,13 @@ func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) doma
8063 }
8164
8265 // CompositePresentation draws an already-resolved modal presentation and
83-// returns its inner content rectangle. Drawers use only top chrome.
66+// returns its inner content rectangle.
8467 func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
8568 FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior})
86- if p.Mode == PresentationDrawer {
87- if p.Bounds.Width > 0 && p.Bounds.Height > 0 {
88- for x := p.Bounds.X; x < p.Bounds.X+p.Bounds.Width; x++ {
89- setCell(f, x, p.Bounds.Y, '─', border)
90- }
91- }
92- } else {
93- DrawBox(f, p.Bounds, border)
69+ drawBorderEdges(f, p.Bounds, p.Borders, border)
70+ if p.Borders&BorderTop != 0 {
71+ m.drawTitle(f, p.Bounds, border)
9472 }
95- m.drawTitle(f, p.Bounds, border)
9673 return p.Inner
9774 }
9875
@@ -106,6 +83,15 @@ func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.St
10683 DrawText(f, start, bounds.Y, right, m.Title, style)
10784 }
10885
86+func modalInner(bounds domain.Rect) domain.Rect {
87+ return domain.Rect{
88+ X: bounds.X + 1,
89+ Y: bounds.Y + 1,
90+ Width: max(0, bounds.Width-2),
91+ Height: max(0, bounds.Height-2),
92+ }
93+}
94+
10995 // FillRect fills rect with cell, clipped to the frame bounds.
11096 func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
11197 left := clamp(rect.X, 0, f.Width)
@@ -121,7 +107,11 @@ func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
121107
122108 // DrawBox draws a single-cell box clipped to the frame bounds.
123109 func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
124- if rect.Width <= 0 || rect.Height <= 0 {
110+ drawBorderEdges(f, rect, BorderAll, style)
111+}
112+
113+func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) {
114+ if rect.Width <= 0 || rect.Height <= 0 || edges == 0 {
125115 return
126116 }
127117 left := rect.X
@@ -129,18 +119,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
129119 right := rect.X + rect.Width - 1
130120 bottom := rect.Y + rect.Height - 1
131121
132- for x := left; x <= right; x++ {
133- setCell(f, x, top, '─', style)
134- setCell(f, x, bottom, '─', style)
122+ if edges&BorderTop != 0 {
123+ for x := left; x <= right; x++ {
124+ setCell(f, x, top, '─', style)
125+ }
126+ }
127+ if edges&BorderBottom != 0 {
128+ for x := left; x <= right; x++ {
129+ setCell(f, x, bottom, '─', style)
130+ }
131+ }
132+ if edges&BorderLeft != 0 {
133+ for y := top; y <= bottom; y++ {
134+ setCell(f, left, y, '│', style)
135+ }
136+ }
137+ if edges&BorderRight != 0 {
138+ for y := top; y <= bottom; y++ {
139+ setCell(f, right, y, '│', style)
140+ }
141+ }
142+ if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft {
143+ setCell(f, left, top, '┌', style)
144+ }
145+ if edges&(BorderTop|BorderRight) == BorderTop|BorderRight {
146+ setCell(f, right, top, '┐', style)
147+ }
148+ if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft {
149+ setCell(f, left, bottom, '└', style)
135150 }
136- for y := top; y <= bottom; y++ {
137- setCell(f, left, y, '│', style)
138- setCell(f, right, y, '│', style)
151+ if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight {
152+ setCell(f, right, bottom, '┘', style)
139153 }
140- setCell(f, left, top, '┌', style)
141- setCell(f, right, top, '┐', style)
142- setCell(f, left, bottom, '└', style)
143- setCell(f, right, bottom, '┘', style)
144154 }
145155
146156 // DrawText draws text starting at x,y before exclusive clipX and returns the
@@ -39,26 +39,14 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
39 39
40 // Inner returns the modal content rectangle after removing a one-cell border.40 // Inner returns the modal content rectangle after removing a one-cell border.
41 func (m Modal) Inner(base domain.Size) domain.Rect {41 func (m Modal) Inner(base domain.Size) domain.Rect {
42- bounds := m.Bounds(base)42+ return modalInner(m.Bounds(base))
43- return domain.Rect{
44- X: bounds.X + 1,
45- Y: bounds.Y + 1,
46- Width: max(0, bounds.Width-2),
47- Height: max(0, bounds.Height-2),
48- }
49 }43 }
50 44
51 // Resolve computes the modal's responsive presentation from one preferred45 // Resolve computes the modal's responsive presentation from one preferred
52 // bounds and inner pair.46 // bounds and inner pair.
53 func (m Modal) Resolve(base domain.Size) Presentation {47 func (m Modal) Resolve(base domain.Size) Presentation {
54 bounds := m.Bounds(base)48 bounds := m.Bounds(base)
55- inner := domain.Rect{49+ return ResolvePresentation(base, bounds, modalInner(bounds))
56- X: bounds.X + 1,
57- Y: bounds.Y + 1,
58- Width: max(0, bounds.Width-2),
59- Height: max(0, bounds.Height-2),
60- }
61- return ResolvePresentation(base, bounds, inner)
62 }50 }
63 51
64 // Composite draws the modal border and title, fills its interior, and returns52 // Composite draws the modal border and title, fills its interior, and returns
@@ -67,12 +55,7 @@ func (m Modal) Resolve(base domain.Size) Presentation {
67 // Cells outside the modal bounds are not changed.55 // Cells outside the modal bounds are not changed.
68 func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {56 func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
69 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})57 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
70- inner := domain.Rect{58+ inner := modalInner(bounds)
71- X: bounds.X + 1,
72- Y: bounds.Y + 1,
73- Width: max(0, bounds.Width-2),
74- Height: max(0, bounds.Height-2),
75- }
76 DrawBox(f, bounds, border)59 DrawBox(f, bounds, border)
77 FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})60 FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
78 m.drawTitle(f, bounds, border)61 m.drawTitle(f, bounds, border)
@@ -80,19 +63,13 @@ func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) doma
80 }63 }
81 64
82 // CompositePresentation draws an already-resolved modal presentation and65 // CompositePresentation draws an already-resolved modal presentation and
83-// returns its inner content rectangle. Drawers use only top chrome.66+// returns its inner content rectangle.
84 func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {67 func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
85 FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior})68 FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior})
86- if p.Mode == PresentationDrawer {69+ drawBorderEdges(f, p.Bounds, p.Borders, border)
87- if p.Bounds.Width > 0 && p.Bounds.Height > 0 {70+ if p.Borders&BorderTop != 0 {
88- for x := p.Bounds.X; x < p.Bounds.X+p.Bounds.Width; x++ {71+ m.drawTitle(f, p.Bounds, border)
89- setCell(f, x, p.Bounds.Y, '─', border)
90- }
91- }
92- } else {
93- DrawBox(f, p.Bounds, border)
94 }72 }
95- m.drawTitle(f, p.Bounds, border)
96 return p.Inner73 return p.Inner
97 }74 }
98 75
@@ -106,6 +83,15 @@ func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.St
106 DrawText(f, start, bounds.Y, right, m.Title, style)83 DrawText(f, start, bounds.Y, right, m.Title, style)
107 }84 }
108 85
86+func modalInner(bounds domain.Rect) domain.Rect {
87+ return domain.Rect{
88+ X: bounds.X + 1,
89+ Y: bounds.Y + 1,
90+ Width: max(0, bounds.Width-2),
91+ Height: max(0, bounds.Height-2),
92+ }
93+}
94+
109 // FillRect fills rect with cell, clipped to the frame bounds.95 // FillRect fills rect with cell, clipped to the frame bounds.
110 func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {96 func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
111 left := clamp(rect.X, 0, f.Width)97 left := clamp(rect.X, 0, f.Width)
@@ -121,7 +107,11 @@ func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
121 107
122 // DrawBox draws a single-cell box clipped to the frame bounds.108 // DrawBox draws a single-cell box clipped to the frame bounds.
123 func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {109 func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
124- if rect.Width <= 0 || rect.Height <= 0 {110+ drawBorderEdges(f, rect, BorderAll, style)
111+}
112+
113+func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) {
114+ if rect.Width <= 0 || rect.Height <= 0 || edges == 0 {
125 return115 return
126 }116 }
127 left := rect.X117 left := rect.X
@@ -129,18 +119,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
129 right := rect.X + rect.Width - 1119 right := rect.X + rect.Width - 1
130 bottom := rect.Y + rect.Height - 1120 bottom := rect.Y + rect.Height - 1
131 121
132- for x := left; x <= right; x++ {122+ if edges&BorderTop != 0 {
133- setCell(f, x, top, '─', style)123+ for x := left; x <= right; x++ {
134- setCell(f, x, bottom, '─', style)124+ setCell(f, x, top, '─', style)
125+ }
126+ }
127+ if edges&BorderBottom != 0 {
128+ for x := left; x <= right; x++ {
129+ setCell(f, x, bottom, '─', style)
130+ }
131+ }
132+ if edges&BorderLeft != 0 {
133+ for y := top; y <= bottom; y++ {
134+ setCell(f, left, y, '│', style)
135+ }
136+ }
137+ if edges&BorderRight != 0 {
138+ for y := top; y <= bottom; y++ {
139+ setCell(f, right, y, '│', style)
140+ }
141+ }
142+ if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft {
143+ setCell(f, left, top, '┌', style)
144+ }
145+ if edges&(BorderTop|BorderRight) == BorderTop|BorderRight {
146+ setCell(f, right, top, '┐', style)
147+ }
148+ if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft {
149+ setCell(f, left, bottom, '└', style)
135 }150 }
136- for y := top; y <= bottom; y++ {151+ if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight {
137- setCell(f, left, y, '│', style)152+ setCell(f, right, bottom, '┘', style)
138- setCell(f, right, y, '│', style)
139 }153 }
140- setCell(f, left, top, '┌', style)
141- setCell(f, right, top, '┐', style)
142- setCell(f, left, bottom, '└', style)
143- setCell(f, right, bottom, '┘', style)
144 }154 }
145 155
146 // DrawText draws text starting at x,y before exclusive clipX and returns the156 // DrawText draws text starting at x,y before exclusive clipX and returns the
M internal/usecase/ui/modal_test.go +79 -0
@@ -200,6 +200,85 @@ func TestModalResolve(t *testing.T) {
200200 }
201201 }
202202
203+func TestCompositePresentationDrawsFloatingChrome(t *testing.T) {
204+ modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
205+ presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10})
206+ frame := renderer.NewFrame(80, 10)
207+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
208+ FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior)
209+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
210+ interior := renderer.Style{Foreground: 3, Background: 4}
211+
212+ inner := modal.CompositePresentation(frame, presentation, border, interior)
213+ require.Equal(t, presentation.Inner, inner)
214+ require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune)
215+ require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune)
216+ require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
217+ require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
218+ require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune)
219+ require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior))
220+ assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior)
221+}
222+
223+func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) {
224+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
225+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
226+ bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4}
227+ inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2}
228+
229+ tests := []struct {
230+ name string
231+ presentation Presentation
232+ assert func(*testing.T, renderer.Frame)
233+ }{
234+ {
235+ name: "all edges render for drawer mode",
236+ presentation: Presentation{
237+ Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll,
238+ },
239+ assert: func(t *testing.T, frame renderer.Frame) {
240+ require.Equal(t, '┌', frame.At(1, 1).Rune)
241+ require.Equal(t, '│', frame.At(1, 2).Rune)
242+ require.Equal(t, '┘', frame.At(8, 4).Rune)
243+ require.Equal(t, 'T', frame.At(2, 1).Rune)
244+ },
245+ },
246+ {
247+ name: "top edge alone renders for floating mode",
248+ presentation: Presentation{
249+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop,
250+ },
251+ assert: func(t *testing.T, frame renderer.Frame) {
252+ require.Equal(t, '─', frame.At(1, 1).Rune)
253+ require.Equal(t, 'T', frame.At(2, 1).Rune)
254+ assertCell(t, frame, 1, 2, exterior)
255+ assertCell(t, frame, 8, 4, exterior)
256+ },
257+ },
258+ {
259+ name: "title is hidden without top edge",
260+ presentation: Presentation{
261+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft,
262+ },
263+ assert: func(t *testing.T, frame renderer.Frame) {
264+ require.Equal(t, '│', frame.At(1, 1).Rune)
265+ require.Equal(t, '│', frame.At(1, 4).Rune)
266+ assertCell(t, frame, 4, 1, exterior)
267+ assertCell(t, frame, 8, 4, exterior)
268+ },
269+ },
270+ }
271+
272+ for _, tt := range tests {
273+ t.Run(tt.name, func(t *testing.T) {
274+ frame := renderer.NewFrame(10, 6)
275+ FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior)
276+ (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle())
277+ tt.assert(t, frame)
278+ })
279+ }
280+}
281+
203282 func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {
204283 modal := Modal{FixedHeight: 4, Title: " Rename "}
205284 presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})
@@ -200,6 +200,85 @@ func TestModalResolve(t *testing.T) {
200 }200 }
201 }201 }
202 202
203+func TestCompositePresentationDrawsFloatingChrome(t *testing.T) {
204+ modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
205+ presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10})
206+ frame := renderer.NewFrame(80, 10)
207+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
208+ FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior)
209+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
210+ interior := renderer.Style{Foreground: 3, Background: 4}
211+
212+ inner := modal.CompositePresentation(frame, presentation, border, interior)
213+ require.Equal(t, presentation.Inner, inner)
214+ require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune)
215+ require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune)
216+ require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
217+ require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
218+ require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune)
219+ require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior))
220+ assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior)
221+}
222+
223+func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) {
224+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
225+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
226+ bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4}
227+ inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2}
228+
229+ tests := []struct {
230+ name string
231+ presentation Presentation
232+ assert func(*testing.T, renderer.Frame)
233+ }{
234+ {
235+ name: "all edges render for drawer mode",
236+ presentation: Presentation{
237+ Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll,
238+ },
239+ assert: func(t *testing.T, frame renderer.Frame) {
240+ require.Equal(t, '┌', frame.At(1, 1).Rune)
241+ require.Equal(t, '│', frame.At(1, 2).Rune)
242+ require.Equal(t, '┘', frame.At(8, 4).Rune)
243+ require.Equal(t, 'T', frame.At(2, 1).Rune)
244+ },
245+ },
246+ {
247+ name: "top edge alone renders for floating mode",
248+ presentation: Presentation{
249+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop,
250+ },
251+ assert: func(t *testing.T, frame renderer.Frame) {
252+ require.Equal(t, '─', frame.At(1, 1).Rune)
253+ require.Equal(t, 'T', frame.At(2, 1).Rune)
254+ assertCell(t, frame, 1, 2, exterior)
255+ assertCell(t, frame, 8, 4, exterior)
256+ },
257+ },
258+ {
259+ name: "title is hidden without top edge",
260+ presentation: Presentation{
261+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft,
262+ },
263+ assert: func(t *testing.T, frame renderer.Frame) {
264+ require.Equal(t, '│', frame.At(1, 1).Rune)
265+ require.Equal(t, '│', frame.At(1, 4).Rune)
266+ assertCell(t, frame, 4, 1, exterior)
267+ assertCell(t, frame, 8, 4, exterior)
268+ },
269+ },
270+ }
271+
272+ for _, tt := range tests {
273+ t.Run(tt.name, func(t *testing.T) {
274+ frame := renderer.NewFrame(10, 6)
275+ FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior)
276+ (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle())
277+ tt.assert(t, frame)
278+ })
279+ }
280+}
281+
203 func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {282 func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {
204 modal := Modal{FixedHeight: 4, Title: " Rename "}283 modal := Modal{FixedHeight: 4, Title: " Rename "}
205 presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})284 presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})

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