detached 86f743f

feat(picker): make preview layout responsive

commit
86f743f94082cbfffe58e0539957378e47b69c24
author
brice · · 2026-07-14 11:19 UTC
parent
c415453
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +172 -89
M internal/usecase/picker/model.go +42 -51
@@ -7,11 +7,12 @@ import (
77 )
88
99 const (
10- MinPreviewWidth = 24
11- MinListWidth = 16
12- MaxListWidth = 32
13- MinPaneHeight = 4
14- MinStackHeight = 12
10+ MinPreviewWidth = 24
11+ MinHorizontalPreviewWidth = 48
12+ MinListWidth = 16
13+ MaxListWidth = 32
14+ MinPaneHeight = 4
15+ MinStackHeight = 12
1516 )
1617
1718 type SessionView struct {
@@ -40,13 +41,16 @@ type RenderStyles struct {
4041 Name renderer.Style // non-selected name segment
4142 Detail renderer.Style // non-selected detail segment
4243 Base renderer.Style // non-selected fill + suffixes
44+ Separator renderer.Style // preview separator
4345 }
4446
4547 func defaultRenderStyles() RenderStyles {
4648 selection := renderer.DefaultStyle()
4749 selection.Inverse = true
4850 base := renderer.DefaultStyle()
49- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base}
51+ separator := renderer.DefaultStyle()
52+ separator.Attrs = renderer.AttrDim
53+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator}
5054 }
5155
5256 type Target struct {
@@ -56,15 +60,22 @@ type Target struct {
5660 Stopped bool
5761 }
5862
59-type Preview struct {
60- Rows [][]renderer.Cell
61- Width int
62- Height int
63-}
63+// Preview is a bounded view of the selected pane's visible frame.
64+type Preview = ui.FrameView
65+
66+type LayoutMode uint8
67+
68+const (
69+ LayoutListOnly LayoutMode = iota
70+ LayoutStacked
71+ LayoutHorizontal
72+)
6473
6574 type Layout struct {
66- List domain.Rect
67- Preview domain.Rect
75+ Mode LayoutMode
76+ List domain.Rect
77+ Separator domain.Rect
78+ Preview domain.Rect
6879 }
6980
7081 type Model struct {
@@ -125,13 +136,15 @@ func ChooseLayout(inner domain.Size) Layout {
125136 if inner.Cols <= 0 || inner.Rows <= 0 {
126137 return Layout{}
127138 }
128- if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth {
139+ if inner.Rows >= MinPaneHeight {
129140 listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)
130141 previewWidth := inner.Cols - listWidth - 1
131- if previewWidth >= MinPreviewWidth {
142+ if previewWidth >= MinHorizontalPreviewWidth {
132143 return Layout{
133- List: domain.Rect{Width: listWidth, Height: inner.Rows},
134- Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
144+ Mode: LayoutHorizontal,
145+ List: domain.Rect{Width: listWidth, Height: inner.Rows},
146+ Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows},
147+ Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
135148 }
136149 }
137150 }
@@ -139,11 +152,13 @@ func ChooseLayout(inner domain.Size) Layout {
139152 listHeight := max(MinPaneHeight, inner.Rows*40/100)
140153 previewHeight := inner.Rows - listHeight - 1
141154 return Layout{
142- List: domain.Rect{Width: inner.Cols, Height: listHeight},
143- Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
155+ Mode: LayoutStacked,
156+ List: domain.Rect{Width: inner.Cols, Height: listHeight},
157+ Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1},
158+ Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
144159 }
145160 }
146- return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
161+ return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
147162 }
148163
149164 func (m *Model) Up() {
@@ -182,7 +197,13 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle
182197 styleSet = styles[0]
183198 }
184199 m.renderList(frame, layout.List, styleSet)
185- blitPreview(frame, layout.Preview, preview)
200+ switch layout.Mode {
201+ case LayoutHorizontal:
202+ ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator)
203+ case LayoutStacked:
204+ ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator)
205+ }
206+ ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom)
186207 return frame
187208 }
188209
@@ -278,33 +299,3 @@ func clamp(n, low, high int) int {
278299 }
279300 return n
280301 }
281-
282-func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) {
283- if rect.Width <= 0 || rect.Height <= 0 {
284- return
285- }
286- w := min(rect.Width, frame.Width-rect.X)
287- h := min(rect.Height, frame.Height-rect.Y)
288- if w <= 0 || h <= 0 {
289- return
290- }
291- for y := range h {
292- if y >= preview.Height || y >= len(preview.Rows) {
293- continue
294- }
295- src := preview.Rows[y]
296- for x := range w {
297- if x >= preview.Width || x >= len(src) {
298- continue
299- }
300- cell := src[x]
301- if cell.Continuation && x == 0 {
302- continue
303- }
304- if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation {
305- continue
306- }
307- frame.Set(rect.X+x, rect.Y+y, cell)
308- }
309- }
310-}
@@ -7,11 +7,12 @@ import (
7 )7 )
8 8
9 const (9 const (
10- MinPreviewWidth = 2410+ MinPreviewWidth = 24
11- MinListWidth = 1611+ MinHorizontalPreviewWidth = 48
12- MaxListWidth = 3212+ MinListWidth = 16
13- MinPaneHeight = 413+ MaxListWidth = 32
14- MinStackHeight = 1214+ MinPaneHeight = 4
15+ MinStackHeight = 12
15 )16 )
16 17
17 type SessionView struct {18 type SessionView struct {
@@ -40,13 +41,16 @@ type RenderStyles struct {
40 Name renderer.Style // non-selected name segment41 Name renderer.Style // non-selected name segment
41 Detail renderer.Style // non-selected detail segment42 Detail renderer.Style // non-selected detail segment
42 Base renderer.Style // non-selected fill + suffixes43 Base renderer.Style // non-selected fill + suffixes
44+ Separator renderer.Style // preview separator
43 }45 }
44 46
45 func defaultRenderStyles() RenderStyles {47 func defaultRenderStyles() RenderStyles {
46 selection := renderer.DefaultStyle()48 selection := renderer.DefaultStyle()
47 selection.Inverse = true49 selection.Inverse = true
48 base := renderer.DefaultStyle()50 base := renderer.DefaultStyle()
49- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base}51+ separator := renderer.DefaultStyle()
52+ separator.Attrs = renderer.AttrDim
53+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator}
50 }54 }
51 55
52 type Target struct {56 type Target struct {
@@ -56,15 +60,22 @@ type Target struct {
56 Stopped bool60 Stopped bool
57 }61 }
58 62
59-type Preview struct {63+// Preview is a bounded view of the selected pane's visible frame.
60- Rows [][]renderer.Cell64+type Preview = ui.FrameView
61- Width int65+
62- Height int66+type LayoutMode uint8
63-}67+
68+const (
69+ LayoutListOnly LayoutMode = iota
70+ LayoutStacked
71+ LayoutHorizontal
72+)
64 73
65 type Layout struct {74 type Layout struct {
66- List domain.Rect75+ Mode LayoutMode
67- Preview domain.Rect76+ List domain.Rect
77+ Separator domain.Rect
78+ Preview domain.Rect
68 }79 }
69 80
70 type Model struct {81 type Model struct {
@@ -125,13 +136,15 @@ func ChooseLayout(inner domain.Size) Layout {
125 if inner.Cols <= 0 || inner.Rows <= 0 {136 if inner.Cols <= 0 || inner.Rows <= 0 {
126 return Layout{}137 return Layout{}
127 }138 }
128- if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth {139+ if inner.Rows >= MinPaneHeight {
129 listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)140 listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)
130 previewWidth := inner.Cols - listWidth - 1141 previewWidth := inner.Cols - listWidth - 1
131- if previewWidth >= MinPreviewWidth {142+ if previewWidth >= MinHorizontalPreviewWidth {
132 return Layout{143 return Layout{
133- List: domain.Rect{Width: listWidth, Height: inner.Rows},144+ Mode: LayoutHorizontal,
134- Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},145+ List: domain.Rect{Width: listWidth, Height: inner.Rows},
146+ Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows},
147+ Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
135 }148 }
136 }149 }
137 }150 }
@@ -139,11 +152,13 @@ func ChooseLayout(inner domain.Size) Layout {
139 listHeight := max(MinPaneHeight, inner.Rows*40/100)152 listHeight := max(MinPaneHeight, inner.Rows*40/100)
140 previewHeight := inner.Rows - listHeight - 1153 previewHeight := inner.Rows - listHeight - 1
141 return Layout{154 return Layout{
142- List: domain.Rect{Width: inner.Cols, Height: listHeight},155+ Mode: LayoutStacked,
143- Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},156+ List: domain.Rect{Width: inner.Cols, Height: listHeight},
157+ Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1},
158+ Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
144 }159 }
145 }160 }
146- return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}161+ return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
147 }162 }
148 163
149 func (m *Model) Up() {164 func (m *Model) Up() {
@@ -182,7 +197,13 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle
182 styleSet = styles[0]197 styleSet = styles[0]
183 }198 }
184 m.renderList(frame, layout.List, styleSet)199 m.renderList(frame, layout.List, styleSet)
185- blitPreview(frame, layout.Preview, preview)200+ switch layout.Mode {
201+ case LayoutHorizontal:
202+ ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator)
203+ case LayoutStacked:
204+ ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator)
205+ }
206+ ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom)
186 return frame207 return frame
187 }208 }
188 209
@@ -278,33 +299,3 @@ func clamp(n, low, high int) int {
278 }299 }
279 return n300 return n
280 }301 }
281-
282-func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) {
283- if rect.Width <= 0 || rect.Height <= 0 {
284- return
285- }
286- w := min(rect.Width, frame.Width-rect.X)
287- h := min(rect.Height, frame.Height-rect.Y)
288- if w <= 0 || h <= 0 {
289- return
290- }
291- for y := range h {
292- if y >= preview.Height || y >= len(preview.Rows) {
293- continue
294- }
295- src := preview.Rows[y]
296- for x := range w {
297- if x >= preview.Width || x >= len(src) {
298- continue
299- }
300- cell := src[x]
301- if cell.Continuation && x == 0 {
302- continue
303- }
304- if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation {
305- continue
306- }
307- frame.Set(rect.X+x, rect.Y+y, cell)
308- }
309- }
310-}
M internal/usecase/picker/model_test.go +130 -38
@@ -65,30 +65,122 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
6565 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")
6666 }
6767
68-func TestChooseLayoutBoundaries(t *testing.T) {
69- layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12})
70- require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List)
71- require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview)
68+func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
69+ tests := []struct {
70+ name string
71+ size domain.Size
72+ want Layout
73+ }{
74+ {
75+ name: "horizontal rows minus one is list only", size: domain.Size{Cols: 69, Rows: 3},
76+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 69, Height: 3}},
77+ },
78+ {
79+ name: "horizontal preview minus one is list only", size: domain.Size{Cols: 68, Rows: 4},
80+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 68, Height: 4}},
81+ },
82+ {
83+ name: "horizontal exact minimum preview", size: domain.Size{Cols: 69, Rows: 4},
84+ want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 20, Height: 4}, Separator: domain.Rect{X: 20, Width: 1, Height: 4}, Preview: domain.Rect{X: 21, Width: 48, Height: 4}},
85+ },
86+ {
87+ name: "horizontal preview plus one", size: domain.Size{Cols: 70, Rows: 4},
88+ want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 21, Height: 4}, Separator: domain.Rect{X: 21, Width: 1, Height: 4}, Preview: domain.Rect{X: 22, Width: 48, Height: 4}},
89+ },
90+ {
91+ name: "stacked columns minus one is list only", size: domain.Size{Cols: 23, Rows: 12},
92+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 23, Height: 12}},
93+ },
94+ {
95+ name: "stacked rows minus one is list only", size: domain.Size{Cols: 24, Rows: 11},
96+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 24, Height: 11}},
97+ },
98+ {
99+ name: "stacked exact minimum", size: domain.Size{Cols: 24, Rows: 12},
100+ want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 4}, Separator: domain.Rect{Y: 4, Width: 24, Height: 1}, Preview: domain.Rect{Y: 5, Width: 24, Height: 7}},
101+ },
102+ {
103+ name: "stacked list height plus one", size: domain.Size{Cols: 24, Rows: 13},
104+ want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 5}, Separator: domain.Rect{Y: 5, Width: 24, Height: 1}, Preview: domain.Rect{Y: 6, Width: 24, Height: 7}},
105+ },
106+ }
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ require.Equal(t, tt.want, ChooseLayout(tt.size))
110+ })
111+ }
112+}
113+
114+func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
115+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
116+ separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim}
117+ styles := RenderStyles{Separator: separator}
118+ tests := []struct {
119+ name string
120+ size domain.Size
121+ rect domain.Rect
122+ rune rune
123+ }{
124+ {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'},
125+ {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'},
126+ }
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ frame := m.Render(tt.size, Preview{}, styles)
130+ for y := tt.rect.Y; y < tt.rect.Y+tt.rect.Height; y++ {
131+ for x := tt.rect.X; x < tt.rect.X+tt.rect.Width; x++ {
132+ got := frame.At(x, y)
133+ require.Equal(t, tt.rune, got.Rune)
134+ require.True(t, got.Style.Equal(separator))
135+ }
136+ }
137+ })
138+ }
139+}
140+
141+func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
142+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
143+ frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}})
72144
73- layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4})
74- require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List)
75- require.Equal(t, domain.Rect{}, layout.Preview)
145+ require.Equal(t, ' ', frame.At(22, 10).Rune)
146+ require.NotEqual(t, 'x', frame.At(22, 10).Rune)
147+}
148+
149+func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
150+ m := New(nil, "", 0)
151+ preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}
152+
153+ frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
154+ require.Equal(t, 'c', frame.At(0, 5).Rune)
155+ require.Equal(t, 'i', frame.At(0, 11).Rune)
156+}
76157
77- layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4})
78- require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List)
79- require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview)
158+func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
159+ m := New(nil, "", 0)
160+ preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}
80161
81- layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4})
82- require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List)
83- require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview)
162+ frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
163+ require.Equal(t, ' ', frame.At(0, 5).Rune)
164+ require.Equal(t, 'z', frame.At(0, 11).Rune)
165+}
166+
167+func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {
168+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0)
169+ frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{})
84170
85- layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11})
86- require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List)
87- require.Equal(t, domain.Rect{}, layout.Preview)
171+ require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune)
172+}
88173
89- layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12})
90- require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List)
91- require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview)
174+func previewRows(width int, labels string) [][]renderer.Cell {
175+ rows := make([][]renderer.Cell, len(labels))
176+ for y, label := range labels {
177+ rows[y] = make([]renderer.Cell, width)
178+ for x := range rows[y] {
179+ rows[y][x] = cell(' ')
180+ }
181+ rows[y][0] = cell(label)
182+ }
183+ return rows
92184 }
93185
94186 func TestSelectedMapping(t *testing.T) {
@@ -115,7 +207,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
115207 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
116208 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
117209 preview := Preview{
118- Width: 24,
210+ Width: 25,
119211 Height: 1,
120212 Rows: [][]renderer.Cell{{
121213 cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),
@@ -124,12 +216,12 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
124216 }},
125217 }
126218
127- frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview)
219+ frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
128220 require.True(t, frame.At(0, 1).Style.Inverse)
129- require.Equal(t, 'a', frame.At(17, 0).Rune)
130- require.Equal(t, 'w', frame.At(39, 0).Rune)
131- require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped")
132- require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks")
221+ require.Equal(t, 'a', frame.At(0, 11).Rune)
222+ require.Equal(t, 'w', frame.At(22, 11).Rune)
223+ require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped")
224+ require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored")
133225 }
134226
135227 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
@@ -147,12 +239,12 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
147239 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
148240 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)
149241
150- frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
242+ frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
151243
152- layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
153- require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
244+ layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
245+ require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
154246 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")
155- require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width")
247+ require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
156248 }
157249
158250 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
@@ -212,16 +304,16 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
212304 {Name: "short-name", Detail: " (a very long detail text)"},
213305 }, Active: 0}}, "s1", 0)
214306
215- frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
216- layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
217- require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
307+ frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
308+ layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
309+ require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
218310
219- want := " short-name (a…"
311+ want := " short-name (a ver…"
220312 for i, r := range want {
221313 require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)
222314 }
223315 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")
224- require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width")
316+ require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
225317 }
226318
227319 func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
@@ -229,12 +321,12 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
229321 {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
230322 }, Active: 0}}, "s1", 0)
231323
232- frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
233- layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
234- require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
324+ frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
325+ layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
326+ require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
235327
236328 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
237- require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all")
329+ require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
238330 }
239331
240332 func cell(r rune) renderer.Cell {
@@ -65,30 +65,122 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
65 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")65 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")
66 }66 }
67 67
68-func TestChooseLayoutBoundaries(t *testing.T) {68+func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
69- layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12})69+ tests := []struct {
70- require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List)70+ name string
71- require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview)71+ size domain.Size
72+ want Layout
73+ }{
74+ {
75+ name: "horizontal rows minus one is list only", size: domain.Size{Cols: 69, Rows: 3},
76+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 69, Height: 3}},
77+ },
78+ {
79+ name: "horizontal preview minus one is list only", size: domain.Size{Cols: 68, Rows: 4},
80+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 68, Height: 4}},
81+ },
82+ {
83+ name: "horizontal exact minimum preview", size: domain.Size{Cols: 69, Rows: 4},
84+ want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 20, Height: 4}, Separator: domain.Rect{X: 20, Width: 1, Height: 4}, Preview: domain.Rect{X: 21, Width: 48, Height: 4}},
85+ },
86+ {
87+ name: "horizontal preview plus one", size: domain.Size{Cols: 70, Rows: 4},
88+ want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 21, Height: 4}, Separator: domain.Rect{X: 21, Width: 1, Height: 4}, Preview: domain.Rect{X: 22, Width: 48, Height: 4}},
89+ },
90+ {
91+ name: "stacked columns minus one is list only", size: domain.Size{Cols: 23, Rows: 12},
92+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 23, Height: 12}},
93+ },
94+ {
95+ name: "stacked rows minus one is list only", size: domain.Size{Cols: 24, Rows: 11},
96+ want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 24, Height: 11}},
97+ },
98+ {
99+ name: "stacked exact minimum", size: domain.Size{Cols: 24, Rows: 12},
100+ want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 4}, Separator: domain.Rect{Y: 4, Width: 24, Height: 1}, Preview: domain.Rect{Y: 5, Width: 24, Height: 7}},
101+ },
102+ {
103+ name: "stacked list height plus one", size: domain.Size{Cols: 24, Rows: 13},
104+ want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 5}, Separator: domain.Rect{Y: 5, Width: 24, Height: 1}, Preview: domain.Rect{Y: 6, Width: 24, Height: 7}},
105+ },
106+ }
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ require.Equal(t, tt.want, ChooseLayout(tt.size))
110+ })
111+ }
112+}
113+
114+func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
115+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
116+ separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim}
117+ styles := RenderStyles{Separator: separator}
118+ tests := []struct {
119+ name string
120+ size domain.Size
121+ rect domain.Rect
122+ rune rune
123+ }{
124+ {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'},
125+ {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'},
126+ }
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ frame := m.Render(tt.size, Preview{}, styles)
130+ for y := tt.rect.Y; y < tt.rect.Y+tt.rect.Height; y++ {
131+ for x := tt.rect.X; x < tt.rect.X+tt.rect.Width; x++ {
132+ got := frame.At(x, y)
133+ require.Equal(t, tt.rune, got.Rune)
134+ require.True(t, got.Style.Equal(separator))
135+ }
136+ }
137+ })
138+ }
139+}
140+
141+func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
142+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
143+ frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}})
72 144
73- layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4})145+ require.Equal(t, ' ', frame.At(22, 10).Rune)
74- require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List)146+ require.NotEqual(t, 'x', frame.At(22, 10).Rune)
75- require.Equal(t, domain.Rect{}, layout.Preview)147+}
148+
149+func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
150+ m := New(nil, "", 0)
151+ preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}
152+
153+ frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
154+ require.Equal(t, 'c', frame.At(0, 5).Rune)
155+ require.Equal(t, 'i', frame.At(0, 11).Rune)
156+}
76 157
77- layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4})158+func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
78- require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List)159+ m := New(nil, "", 0)
79- require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview)160+ preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}
80 161
81- layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4})162+ frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
82- require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List)163+ require.Equal(t, ' ', frame.At(0, 5).Rune)
83- require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview)164+ require.Equal(t, 'z', frame.At(0, 11).Rune)
165+}
166+
167+func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {
168+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0)
169+ frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{})
84 170
85- layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11})171+ require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune)
86- require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List)172+}
87- require.Equal(t, domain.Rect{}, layout.Preview)
88 173
89- layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12})174+func previewRows(width int, labels string) [][]renderer.Cell {
90- require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List)175+ rows := make([][]renderer.Cell, len(labels))
91- require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview)176+ for y, label := range labels {
177+ rows[y] = make([]renderer.Cell, width)
178+ for x := range rows[y] {
179+ rows[y][x] = cell(' ')
180+ }
181+ rows[y][0] = cell(label)
182+ }
183+ return rows
92 }184 }
93 185
94 func TestSelectedMapping(t *testing.T) {186 func TestSelectedMapping(t *testing.T) {
@@ -115,7 +207,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
115 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {207 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
116 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)208 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
117 preview := Preview{209 preview := Preview{
118- Width: 24,210+ Width: 25,
119 Height: 1,211 Height: 1,
120 Rows: [][]renderer.Cell{{212 Rows: [][]renderer.Cell{{
121 cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),213 cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),
@@ -124,12 +216,12 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
124 }},216 }},
125 }217 }
126 218
127- frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview)219+ frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
128 require.True(t, frame.At(0, 1).Style.Inverse)220 require.True(t, frame.At(0, 1).Style.Inverse)
129- require.Equal(t, 'a', frame.At(17, 0).Rune)221+ require.Equal(t, 'a', frame.At(0, 11).Rune)
130- require.Equal(t, 'w', frame.At(39, 0).Rune)222+ require.Equal(t, 'w', frame.At(22, 11).Rune)
131- require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped")223+ require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped")
132- require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks")224+ require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored")
133 }225 }
134 226
135 func TestRenderListScrollsSelectionIntoView(t *testing.T) {227 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
@@ -147,12 +239,12 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
147 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {239 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
148 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)240 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)
149 241
150- frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})242+ frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
151 243
152- layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})244+ layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
153- require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")245+ require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
154 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")246 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")
155- require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width")247+ require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
156 }248 }
157 249
158 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {250 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
@@ -212,16 +304,16 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
212 {Name: "short-name", Detail: " (a very long detail text)"},304 {Name: "short-name", Detail: " (a very long detail text)"},
213 }, Active: 0}}, "s1", 0)305 }, Active: 0}}, "s1", 0)
214 306
215- frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})307+ frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
216- layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})308+ layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
217- require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")309+ require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
218 310
219- want := " short-name (a…"311+ want := " short-name (a ver…"
220 for i, r := range want {312 for i, r := range want {
221 require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)313 require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)
222 }314 }
223 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")315 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")
224- require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width")316+ require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
225 }317 }
226 318
227 func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {319 func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
@@ -229,12 +321,12 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
229 {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},321 {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
230 }, Active: 0}}, "s1", 0)322 }, Active: 0}}, "s1", 0)
231 323
232- frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})324+ frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
233- layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})325+ layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
234- require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")326+ require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
235 327
236 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")328 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
237- require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all")329+ require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
238 }330 }
239 331
240 func cell(r rune) renderer.Cell {332 func cell(r rune) renderer.Cell {

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