fix(picker): keep selection visible on resize
- commit
- d8255fc228b28fd396d3fef4500174b72fb51aba
- author
- brice · · 2026-07-02 21:21 UTC
- parent
- 00a245c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +64 -21
M internal/usecase/daemon/daemon.go +6 -9
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | 3 | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | 4 | // | |
| 5 | - | // Concurrency model (one tab per session for the MVP; multi-tab is M3): | |
| 5 | + | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | 6 | // | |
| 7 | 7 | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | 8 | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| @@ -487,7 +487,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 487 | 487 | return | |
| 488 | 488 | } | |
| 489 | 489 | d.firstPaint(sess, ac, h.Size) | |
| 490 | - | d.runConnLoop(sess, ac) | |
| 490 | + | d.runConnLoop(ac) | |
| 491 | 491 | _ = tr.Close() | |
| 492 | 492 | } | |
| 493 | 493 | ||
| @@ -675,9 +675,9 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 675 | 675 | ||
| 676 | 676 | // runConnLoop is the per-connection input router: it pumps client messages | |
| 677 | 677 | // until detach, EOF, or a transport error. | |
| 678 | - | func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) { | |
| 678 | + | func (d *Daemon) runConnLoop(ac *attachedClient) { | |
| 679 | 679 | for { | |
| 680 | - | sess = ac.currentSession() | |
| 680 | + | sess := ac.currentSession() | |
| 681 | 681 | if sess == nil { | |
| 682 | 682 | return | |
| 683 | 683 | } | |
| @@ -1200,11 +1200,8 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||
| 1200 | 1200 | return | |
| 1201 | 1201 | } | |
| 1202 | 1202 | if targetSess == sess { | |
| 1203 | - | if sess.switchTab(target.TabIndex) { | |
| 1204 | - | d.paint(sess, ac, true) | |
| 1205 | - | } else { | |
| 1206 | - | d.paint(sess, ac, true) | |
| 1207 | - | } | |
| 1203 | + | sess.switchTab(target.TabIndex) | |
| 1204 | + | d.paint(sess, ac, true) | |
| 1208 | 1205 | return | |
| 1209 | 1206 | } | |
| 1210 | 1207 | if !sess.detachIfCurrent(ac) { | |
| @@ -2,7 +2,7 @@ | |||||
| 2 | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | 2 | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | ||
| 3 | // and VT screen, and the per-client debounced render scheduler. | 3 | // and VT screen, and the per-client debounced render scheduler. | ||
| 4 | // | 4 | // | ||
| 5 | - | // Concurrency model (one tab per session for the MVP; multi-tab is M3): | 5 | + | // Concurrency model (sessions own one or more PTY-backed tabs): |
| 6 | // | 6 | // | ||
| 7 | // - Serve runs the accept loop. Each accepted connection is handled by its | 7 | // - Serve runs the accept loop. Each accepted connection is handled by its | ||
| 8 | // own goroutine (handleConn): it reads the first frame and routes it to a | 8 | // own goroutine (handleConn): it reads the first frame and routes it to a | ||
| @@ -487,7 +487,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 487 | return | 487 | return | ||
| 488 | } | 488 | } | ||
| 489 | d.firstPaint(sess, ac, h.Size) | 489 | d.firstPaint(sess, ac, h.Size) | ||
| 490 | - | d.runConnLoop(sess, ac) | 490 | + | d.runConnLoop(ac) |
| 491 | _ = tr.Close() | 491 | _ = tr.Close() | ||
| 492 | } | 492 | } | ||
| 493 | 493 | ||||
| @@ -675,9 +675,9 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||||
| 675 | 675 | ||||
| 676 | // runConnLoop is the per-connection input router: it pumps client messages | 676 | // runConnLoop is the per-connection input router: it pumps client messages | ||
| 677 | // until detach, EOF, or a transport error. | 677 | // until detach, EOF, or a transport error. | ||
| 678 | - | func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) { | 678 | + | func (d *Daemon) runConnLoop(ac *attachedClient) { |
| 679 | for { | 679 | for { | ||
| 680 | - | sess = ac.currentSession() | 680 | + | sess := ac.currentSession() |
| 681 | if sess == nil { | 681 | if sess == nil { | ||
| 682 | return | 682 | return | ||
| 683 | } | 683 | } | ||
| @@ -1200,11 +1200,8 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||||
| 1200 | return | 1200 | return | ||
| 1201 | } | 1201 | } | ||
| 1202 | if targetSess == sess { | 1202 | if targetSess == sess { | ||
| 1203 | - | if sess.switchTab(target.TabIndex) { | 1203 | + | sess.switchTab(target.TabIndex) |
| 1204 | - | d.paint(sess, ac, true) | 1204 | + | d.paint(sess, ac, true) |
| 1205 | - | } else { | |||
| 1206 | - | d.paint(sess, ac, true) | |||
| 1207 | - | } | |||
| 1208 | return | 1205 | return | ||
| 1209 | } | 1206 | } | ||
| 1210 | if !sess.detachIfCurrent(ac) { | 1207 | if !sess.detachIfCurrent(ac) { | ||
M internal/usecase/picker/model.go +33 -7
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | const ( | |
| 9 | 9 | MinPreviewWidth = 24 | |
| 10 | 10 | MinListWidth = 16 | |
| 11 | + | MaxListWidth = 32 | |
| 11 | 12 | MinPaneHeight = 4 | |
| 12 | 13 | MinStackHeight = 12 | |
| 13 | 14 | ) | |
| @@ -83,12 +84,14 @@ func ChooseLayout(inner domain.Size) Layout { | |||
| 83 | 84 | if inner.Cols <= 0 || inner.Rows <= 0 { | |
| 84 | 85 | return Layout{} | |
| 85 | 86 | } | |
| 86 | - | if inner.Cols >= 40 && inner.Rows >= MinPaneHeight { | |
| 87 | - | listWidth := max(MinListWidth, inner.Cols*30/100) | |
| 87 | + | if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth { | |
| 88 | + | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) | |
| 88 | 89 | previewWidth := inner.Cols - listWidth - 1 | |
| 89 | - | return Layout{ | |
| 90 | - | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | |
| 91 | - | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |
| 90 | + | if previewWidth >= MinPreviewWidth { | |
| 91 | + | return Layout{ | |
| 92 | + | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | |
| 93 | + | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |
| 94 | + | } | |
| 92 | 95 | } | |
| 93 | 96 | } | |
| 94 | 97 | if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth { | |
| @@ -158,8 +161,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||
| 158 | 161 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 159 | 162 | return | |
| 160 | 163 | } | |
| 161 | - | for y := range min(rect.Height, frame.Height-rect.Y) { | |
| 162 | - | idx := y | |
| 164 | + | visible := min(rect.Height, frame.Height-rect.Y) | |
| 165 | + | offset := m.scrollOffset(visible) | |
| 166 | + | for y := range visible { | |
| 167 | + | idx := offset + y | |
| 163 | 168 | if idx >= len(m.rows) { | |
| 164 | 169 | break | |
| 165 | 170 | } | |
| @@ -176,6 +181,17 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||
| 176 | 181 | } | |
| 177 | 182 | } | |
| 178 | 183 | ||
| 184 | + | func (m *Model) scrollOffset(visible int) int { | |
| 185 | + | if visible <= 0 || len(m.rows) <= visible || m.selected < 0 { | |
| 186 | + | return 0 | |
| 187 | + | } | |
| 188 | + | if m.selected < visible { | |
| 189 | + | return 0 | |
| 190 | + | } | |
| 191 | + | offset := m.selected - visible + 1 | |
| 192 | + | return min(offset, len(m.rows)-visible) | |
| 193 | + | } | |
| 194 | + | ||
| 179 | 195 | func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) { | |
| 180 | 196 | if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 { | |
| 181 | 197 | return | |
| @@ -198,6 +214,16 @@ func drawText(frame renderer.Frame, x, y, width int, text string, style renderer | |||
| 198 | 214 | } | |
| 199 | 215 | } | |
| 200 | 216 | ||
| 217 | + | func clamp(n, low, high int) int { | |
| 218 | + | if n < low { | |
| 219 | + | return low | |
| 220 | + | } | |
| 221 | + | if n > high { | |
| 222 | + | return high | |
| 223 | + | } | |
| 224 | + | return n | |
| 225 | + | } | |
| 226 | + | ||
| 201 | 227 | func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) { | |
| 202 | 228 | if rect.Width <= 0 || rect.Height <= 0 { | |
| 203 | 229 | return | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | const ( | 8 | const ( | ||
| 9 | MinPreviewWidth = 24 | 9 | MinPreviewWidth = 24 | ||
| 10 | MinListWidth = 16 | 10 | MinListWidth = 16 | ||
| 11 | + | MaxListWidth = 32 | |||
| 11 | MinPaneHeight = 4 | 12 | MinPaneHeight = 4 | ||
| 12 | MinStackHeight = 12 | 13 | MinStackHeight = 12 | ||
| 13 | ) | 14 | ) | ||
| @@ -83,12 +84,14 @@ func ChooseLayout(inner domain.Size) Layout { | |||||
| 83 | if inner.Cols <= 0 || inner.Rows <= 0 { | 84 | if inner.Cols <= 0 || inner.Rows <= 0 { | ||
| 84 | return Layout{} | 85 | return Layout{} | ||
| 85 | } | 86 | } | ||
| 86 | - | if inner.Cols >= 40 && inner.Rows >= MinPaneHeight { | 87 | + | if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth { |
| 87 | - | listWidth := max(MinListWidth, inner.Cols*30/100) | 88 | + | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) |
| 88 | previewWidth := inner.Cols - listWidth - 1 | 89 | previewWidth := inner.Cols - listWidth - 1 | ||
| 89 | - | return Layout{ | 90 | + | if previewWidth >= MinPreviewWidth { |
| 90 | - | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | 91 | + | return Layout{ |
| 91 | - | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | 92 | + | List: domain.Rect{Width: listWidth, Height: inner.Rows}, |
| 93 | + | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |||
| 94 | + | } | |||
| 92 | } | 95 | } | ||
| 93 | } | 96 | } | ||
| 94 | if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth { | 97 | if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth { | ||
| @@ -158,8 +161,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||||
| 158 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | 161 | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | ||
| 159 | return | 162 | return | ||
| 160 | } | 163 | } | ||
| 161 | - | for y := range min(rect.Height, frame.Height-rect.Y) { | 164 | + | visible := min(rect.Height, frame.Height-rect.Y) |
| 162 | - | idx := y | 165 | + | offset := m.scrollOffset(visible) |
| 166 | + | for y := range visible { | |||
| 167 | + | idx := offset + y | |||
| 163 | if idx >= len(m.rows) { | 168 | if idx >= len(m.rows) { | ||
| 164 | break | 169 | break | ||
| 165 | } | 170 | } | ||
| @@ -176,6 +181,17 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||||
| 176 | } | 181 | } | ||
| 177 | } | 182 | } | ||
| 178 | 183 | ||||
| 184 | + | func (m *Model) scrollOffset(visible int) int { | |||
| 185 | + | if visible <= 0 || len(m.rows) <= visible || m.selected < 0 { | |||
| 186 | + | return 0 | |||
| 187 | + | } | |||
| 188 | + | if m.selected < visible { | |||
| 189 | + | return 0 | |||
| 190 | + | } | |||
| 191 | + | offset := m.selected - visible + 1 | |||
| 192 | + | return min(offset, len(m.rows)-visible) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 179 | func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) { | 195 | func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) { | ||
| 180 | if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 { | 196 | if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 { | ||
| 181 | return | 197 | return | ||
| @@ -198,6 +214,16 @@ func drawText(frame renderer.Frame, x, y, width int, text string, style renderer | |||||
| 198 | } | 214 | } | ||
| 199 | } | 215 | } | ||
| 200 | 216 | ||||
| 217 | + | func clamp(n, low, high int) int { | |||
| 218 | + | if n < low { | |||
| 219 | + | return low | |||
| 220 | + | } | |||
| 221 | + | if n > high { | |||
| 222 | + | return high | |||
| 223 | + | } | |||
| 224 | + | return n | |||
| 225 | + | } | |||
| 226 | + | ||||
| 201 | func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) { | 227 | func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) { | ||
| 202 | if rect.Width <= 0 || rect.Height <= 0 { | 228 | if rect.Width <= 0 || rect.Height <= 0 { | ||
| 203 | return | 229 | return | ||
M internal/usecase/picker/model_test.go +25 -5
| @@ -70,8 +70,16 @@ func TestChooseLayoutBoundaries(t *testing.T) { | |||
| 70 | 70 | require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview) | |
| 71 | 71 | ||
| 72 | 72 | layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4}) | |
| 73 | + | require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List) | |
| 74 | + | require.Equal(t, domain.Rect{}, layout.Preview) | |
| 75 | + | ||
| 76 | + | layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4}) | |
| 73 | 77 | require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List) | |
| 74 | - | require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview) | |
| 78 | + | require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview) | |
| 79 | + | ||
| 80 | + | layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4}) | |
| 81 | + | require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List) | |
| 82 | + | require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview) | |
| 75 | 83 | ||
| 76 | 84 | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11}) | |
| 77 | 85 | require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List) | |
| @@ -101,18 +109,30 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||
| 101 | 109 | Rows: [][]renderer.Cell{{ | |
| 102 | 110 | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | |
| 103 | 111 | cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'), | |
| 104 | - | cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()}, | |
| 112 | + | cell('u'), cell('v'), cell('w'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()}, | |
| 105 | 113 | }}, | |
| 106 | 114 | } | |
| 107 | 115 | ||
| 108 | - | frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview) | |
| 116 | + | frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview) | |
| 109 | 117 | require.True(t, frame.At(0, 1).Style.Inverse) | |
| 110 | 118 | require.Equal(t, 'a', frame.At(17, 0).Rune) | |
| 111 | - | require.Equal(t, 'v', frame.At(38, 0).Rune) | |
| 112 | - | require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped") | |
| 119 | + | require.Equal(t, 'w', frame.At(39, 0).Rune) | |
| 120 | + | require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped") | |
| 113 | 121 | require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks") | |
| 114 | 122 | } | |
| 115 | 123 | ||
| 124 | + | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |
| 125 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0) | |
| 126 | + | for range 5 { | |
| 127 | + | m.Down() | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{}) | |
| 131 | + | require.Equal(t, ' ', frame.At(0, 3).Rune) | |
| 132 | + | require.Equal(t, 'f', frame.At(2, 3).Rune) | |
| 133 | + | require.True(t, frame.At(0, 3).Style.Inverse) | |
| 134 | + | } | |
| 135 | + | ||
| 116 | 136 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| 117 | 137 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 118 | 138 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | |
| @@ -70,8 +70,16 @@ func TestChooseLayoutBoundaries(t *testing.T) { | |||||
| 70 | require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview) | 70 | require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview) | ||
| 71 | 71 | ||||
| 72 | layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4}) | 72 | layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4}) | ||
| 73 | + | require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List) | |||
| 74 | + | require.Equal(t, domain.Rect{}, layout.Preview) | |||
| 75 | + | ||||
| 76 | + | layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4}) | |||
| 73 | require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List) | 77 | require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List) | ||
| 74 | - | require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview) | 78 | + | require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview) |
| 79 | + | ||||
| 80 | + | layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4}) | |||
| 81 | + | require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List) | |||
| 82 | + | require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview) | |||
| 75 | 83 | ||||
| 76 | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11}) | 84 | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11}) | ||
| 77 | require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List) | 85 | require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List) | ||
| @@ -101,18 +109,30 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||||
| 101 | Rows: [][]renderer.Cell{{ | 109 | Rows: [][]renderer.Cell{{ | ||
| 102 | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | 110 | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | ||
| 103 | cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'), | 111 | cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'), | ||
| 104 | - | cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()}, | 112 | + | cell('u'), cell('v'), cell('w'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()}, |
| 105 | }}, | 113 | }}, | ||
| 106 | } | 114 | } | ||
| 107 | 115 | ||||
| 108 | - | frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview) | 116 | + | frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview) |
| 109 | require.True(t, frame.At(0, 1).Style.Inverse) | 117 | require.True(t, frame.At(0, 1).Style.Inverse) | ||
| 110 | require.Equal(t, 'a', frame.At(17, 0).Rune) | 118 | require.Equal(t, 'a', frame.At(17, 0).Rune) | ||
| 111 | - | require.Equal(t, 'v', frame.At(38, 0).Rune) | 119 | + | require.Equal(t, 'w', frame.At(39, 0).Rune) |
| 112 | - | require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped") | 120 | + | require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped") |
| 113 | require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks") | 121 | require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks") | ||
| 114 | } | 122 | } | ||
| 115 | 123 | ||||
| 124 | + | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 125 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0) | |||
| 126 | + | for range 5 { | |||
| 127 | + | m.Down() | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{}) | |||
| 131 | + | require.Equal(t, ' ', frame.At(0, 3).Rune) | |||
| 132 | + | require.Equal(t, 'f', frame.At(2, 3).Rune) | |||
| 133 | + | require.True(t, frame.At(0, 3).Style.Inverse) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 116 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | 136 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | ||
| 117 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | 137 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | ||
| 118 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | 138 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | ||