feat(picker): make preview layout responsive
- commit
- 86f743f94082cbfffe58e0539957378e47b69c24
- author
- brice · · 2026-07-14 11:19 UTC
- parent
- c415453
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +172 -89
M internal/usecase/picker/model.go +42 -51
| @@ -7,11 +7,12 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | 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 | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | type SessionView struct { | |
| @@ -40,13 +41,16 @@ type RenderStyles struct { | |||
| 40 | 41 | Name renderer.Style // non-selected name segment | |
| 41 | 42 | Detail renderer.Style // non-selected detail segment | |
| 42 | 43 | Base renderer.Style // non-selected fill + suffixes | |
| 44 | + | Separator renderer.Style // preview separator | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | func defaultRenderStyles() RenderStyles { | |
| 46 | 48 | selection := renderer.DefaultStyle() | |
| 47 | 49 | selection.Inverse = true | |
| 48 | 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 | 56 | type Target struct { | |
| @@ -56,15 +60,22 @@ type Target struct { | |||
| 56 | 60 | Stopped bool | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 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 | + | ) | |
| 64 | 73 | ||
| 65 | 74 | 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 | |
| 68 | 79 | } | |
| 69 | 80 | ||
| 70 | 81 | type Model struct { | |
| @@ -125,13 +136,15 @@ func ChooseLayout(inner domain.Size) Layout { | |||
| 125 | 136 | if inner.Cols <= 0 || inner.Rows <= 0 { | |
| 126 | 137 | return Layout{} | |
| 127 | 138 | } | |
| 128 | - | if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth { | |
| 139 | + | if inner.Rows >= MinPaneHeight { | |
| 129 | 140 | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) | |
| 130 | 141 | previewWidth := inner.Cols - listWidth - 1 | |
| 131 | - | if previewWidth >= MinPreviewWidth { | |
| 142 | + | if previewWidth >= MinHorizontalPreviewWidth { | |
| 132 | 143 | 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}, | |
| 135 | 148 | } | |
| 136 | 149 | } | |
| 137 | 150 | } | |
| @@ -139,11 +152,13 @@ func ChooseLayout(inner domain.Size) Layout { | |||
| 139 | 152 | listHeight := max(MinPaneHeight, inner.Rows*40/100) | |
| 140 | 153 | previewHeight := inner.Rows - listHeight - 1 | |
| 141 | 154 | 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}, | |
| 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 | 164 | func (m *Model) Up() { | |
| @@ -182,7 +197,13 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle | |||
| 182 | 197 | styleSet = styles[0] | |
| 183 | 198 | } | |
| 184 | 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 | 207 | return frame | |
| 187 | 208 | } | |
| 188 | 209 | ||
| @@ -278,33 +299,3 @@ func clamp(n, low, high int) int { | |||
| 278 | 299 | } | |
| 279 | 300 | 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 | - | } | |
| @@ -7,11 +7,12 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | const ( | 9 | const ( | ||
| 10 | - | MinPreviewWidth = 24 | 10 | + | MinPreviewWidth = 24 |
| 11 | - | MinListWidth = 16 | 11 | + | MinHorizontalPreviewWidth = 48 |
| 12 | - | MaxListWidth = 32 | 12 | + | MinListWidth = 16 |
| 13 | - | MinPaneHeight = 4 | 13 | + | MaxListWidth = 32 |
| 14 | - | MinStackHeight = 12 | 14 | + | 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 segment | 41 | Name renderer.Style // non-selected name segment | ||
| 41 | Detail renderer.Style // non-selected detail segment | 42 | Detail renderer.Style // non-selected detail segment | ||
| 42 | Base renderer.Style // non-selected fill + suffixes | 43 | 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 = true | 49 | 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 bool | 60 | 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.Cell | 64 | + | type Preview = ui.FrameView |
| 61 | - | Width int | 65 | + | |
| 62 | - | Height int | 66 | + | 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.Rect | 75 | + | Mode LayoutMode |
| 67 | - | Preview domain.Rect | 76 | + | 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 - 1 | 141 | 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 - 1 | 153 | 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 frame | 207 | return frame | ||
| 187 | } | 208 | } | ||
| 188 | 209 | ||||
| @@ -278,33 +299,3 @@ func clamp(n, low, high int) int { | |||||
| 278 | } | 299 | } | ||
| 279 | return n | 300 | 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) { | |||
| 65 | 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) { | |
| 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}}) | |
| 72 | 144 | ||
| 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 | + | } | |
| 76 | 157 | ||
| 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")} | |
| 80 | 161 | ||
| 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{}) | |
| 84 | 170 | ||
| 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 | + | } | |
| 88 | 173 | ||
| 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 | |
| 92 | 184 | } | |
| 93 | 185 | ||
| 94 | 186 | func TestSelectedMapping(t *testing.T) { | |
| @@ -115,7 +207,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||
| 115 | 207 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 116 | 208 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 117 | 209 | preview := Preview{ | |
| 118 | - | Width: 24, | |
| 210 | + | Width: 25, | |
| 119 | 211 | Height: 1, | |
| 120 | 212 | Rows: [][]renderer.Cell{{ | |
| 121 | 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 | 220 | 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") | |
| 133 | 225 | } | |
| 134 | 226 | ||
| 135 | 227 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |
| @@ -147,12 +239,12 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 147 | 239 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |
| 148 | 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}) | |
| 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") | |
| 154 | 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 | 250 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| @@ -212,16 +304,16 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||
| 212 | 304 | {Name: "short-name", Detail: " (a very long detail text)"}, | |
| 213 | 305 | }, Active: 0}}, "s1", 0) | |
| 214 | 306 | ||
| 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") | |
| 218 | 310 | ||
| 219 | - | want := " short-name (a…" | |
| 311 | + | want := " short-name (a ver…" | |
| 220 | 312 | for i, r := range want { | |
| 221 | 313 | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | |
| 222 | 314 | } | |
| 223 | 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 | 319 | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |
| @@ -229,12 +321,12 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||
| 229 | 321 | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | |
| 230 | 322 | }, Active: 0}}, "s1", 0) | |
| 231 | 323 | ||
| 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") | |
| 235 | 327 | ||
| 236 | 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 | 332 | 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 { | ||