detached 20dcfd4

Merge pull request #88 from bnema/feature/picker-ux

feat(picker): improve completion and responsive previews
commit
20dcfd491bd07ad4c10d9319f05294aafb6398f5
author
bnema · · 2026-07-14 13:32 UTC
committer
GitHub · · 2026-07-14 13:32 UTC
merge of
0adb096 + ac01c75 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +1066 -103
M README.md +2 -2
@@ -178,7 +178,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`;
178178
179179 ## Command palette
180180
181-Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close.
181+Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Tab to complete the selected command code, Enter to run, and Esc/Ctrl-C to close. With an empty query, Tab completes the most recently successful command; commands that take an argument complete with a trailing space and preserve an argument already entered.
182182
183183 Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open.
184184
@@ -206,7 +206,7 @@ Rename prompts are prefilled with the current session name. Empty names and name
206206
207207 ## Session picker
208208
209-Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions.
209+Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. Wide pickers place the preview beside the list with a muted divider; narrower pickers stack it below the list, then hide it when neither layout has useful space. A clipped preview stays anchored to the bottom of the target's visible terminal frame and never loads its scrollback.
210210
211211 | Key | Action |
212212 |---|---|
@@ -178,7 +178,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`;
178 178
179 ## Command palette179 ## Command palette
180 180
181-Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close.181+Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Tab to complete the selected command code, Enter to run, and Esc/Ctrl-C to close. With an empty query, Tab completes the most recently successful command; commands that take an argument complete with a trailing space and preserve an argument already entered.
182 182
183 Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open.183 Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open.
184 184
@@ -206,7 +206,7 @@ Rename prompts are prefilled with the current session name. Empty names and name
206 206
207 ## Session picker207 ## Session picker
208 208
209-Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions.209+Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. Wide pickers place the preview beside the list with a muted divider; narrower pickers stack it below the list, then hide it when neither layout has useful space. A clipped preview stays anchored to the bottom of the target's visible terminal frame and never loads its scrollback.
210 210
211 | Key | Action |211 | Key | Action |
212 |---|---|212 |---|---|
M internal/usecase/daemon/overlay.go +4 -0
@@ -9,6 +9,7 @@ type overlayEvents struct {
99 rune func(rune)
1010 backspace func()
1111 enter func()
12+ tab func()
1213 cancel func()
1314 up func()
1415 down func()
@@ -51,6 +52,9 @@ func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
5152 case '\r', '\n':
5253 call(ev.enter)
5354 i++
55+ case 0x09:
56+ call(ev.tab)
57+ i++
5458 case 0x7f, 0x08:
5559 call(ev.backspace)
5660 i++
@@ -9,6 +9,7 @@ type overlayEvents struct {
9 rune func(rune)9 rune func(rune)
10 backspace func()10 backspace func()
11 enter func()11 enter func()
12+ tab func()
12 cancel func()13 cancel func()
13 up func()14 up func()
14 down func()15 down func()
@@ -51,6 +52,9 @@ func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
51 case '\r', '\n':52 case '\r', '\n':
52 call(ev.enter)53 call(ev.enter)
53 i++54 i++
55+ case 0x09:
56+ call(ev.tab)
57+ i++
54 case 0x7f, 0x08:58 case 0x7f, 0x08:
55 call(ev.backspace)59 call(ev.backspace)
56 i++60 i++
M internal/usecase/daemon/overlay_test.go +17 -1
@@ -19,6 +19,8 @@ func TestRouteOverlayBytes(t *testing.T) {
1919 wantCancel int
2020 wantUp int
2121 wantDown int
22+ wantTab int
23+ callback bool
2224 }{
2325 {
2426 name: "text and split utf8",
@@ -76,13 +78,23 @@ func TestRouteOverlayBytes(t *testing.T) {
7678 chunks: [][]byte{{'a', 0x01, 0xff, 'b'}},
7779 wantRunes: []rune{'a', 'b'},
7880 },
81+ {
82+ name: "tab routes to callback",
83+ chunks: [][]byte{{0x09}},
84+ wantTab: 1,
85+ callback: true,
86+ },
87+ {
88+ name: "tab without callback is consumed",
89+ chunks: [][]byte{{0x09}},
90+ },
7991 }
8092
8193 for _, tt := range tests {
8294 t.Run(tt.name, func(t *testing.T) {
8395 var pending []byte
8496 var gotRunes []rune
85- var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int
97+ var gotBackspace, gotEnter, gotCancel, gotUp, gotDown, gotTab int
8698 ev := overlayEvents{
8799 rune: func(r rune) { gotRunes = append(gotRunes, r) },
88100 backspace: func() { gotBackspace++ },
@@ -91,6 +103,9 @@ func TestRouteOverlayBytes(t *testing.T) {
91103 up: func() { gotUp++ },
92104 down: func() { gotDown++ },
93105 }
106+ if tt.callback {
107+ ev.tab = func() { gotTab++ }
108+ }
94109
95110 for _, chunk := range tt.chunks {
96111 routeOverlayBytes(chunk, &pending, ev)
@@ -103,6 +118,7 @@ func TestRouteOverlayBytes(t *testing.T) {
103118 require.Equal(t, tt.wantCancel, gotCancel)
104119 require.Equal(t, tt.wantUp, gotUp)
105120 require.Equal(t, tt.wantDown, gotDown)
121+ require.Equal(t, tt.wantTab, gotTab)
106122 })
107123 }
108124 }
@@ -19,6 +19,8 @@ func TestRouteOverlayBytes(t *testing.T) {
19 wantCancel int19 wantCancel int
20 wantUp int20 wantUp int
21 wantDown int21 wantDown int
22+ wantTab int
23+ callback bool
22 }{24 }{
23 {25 {
24 name: "text and split utf8",26 name: "text and split utf8",
@@ -76,13 +78,23 @@ func TestRouteOverlayBytes(t *testing.T) {
76 chunks: [][]byte{{'a', 0x01, 0xff, 'b'}},78 chunks: [][]byte{{'a', 0x01, 0xff, 'b'}},
77 wantRunes: []rune{'a', 'b'},79 wantRunes: []rune{'a', 'b'},
78 },80 },
81+ {
82+ name: "tab routes to callback",
83+ chunks: [][]byte{{0x09}},
84+ wantTab: 1,
85+ callback: true,
86+ },
87+ {
88+ name: "tab without callback is consumed",
89+ chunks: [][]byte{{0x09}},
90+ },
79 }91 }
80 92
81 for _, tt := range tests {93 for _, tt := range tests {
82 t.Run(tt.name, func(t *testing.T) {94 t.Run(tt.name, func(t *testing.T) {
83 var pending []byte95 var pending []byte
84 var gotRunes []rune96 var gotRunes []rune
85- var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int97+ var gotBackspace, gotEnter, gotCancel, gotUp, gotDown, gotTab int
86 ev := overlayEvents{98 ev := overlayEvents{
87 rune: func(r rune) { gotRunes = append(gotRunes, r) },99 rune: func(r rune) { gotRunes = append(gotRunes, r) },
88 backspace: func() { gotBackspace++ },100 backspace: func() { gotBackspace++ },
@@ -91,6 +103,9 @@ func TestRouteOverlayBytes(t *testing.T) {
91 up: func() { gotUp++ },103 up: func() { gotUp++ },
92 down: func() { gotDown++ },104 down: func() { gotDown++ },
93 }105 }
106+ if tt.callback {
107+ ev.tab = func() { gotTab++ }
108+ }
94 109
95 for _, chunk := range tt.chunks {110 for _, chunk := range tt.chunks {
96 routeOverlayBytes(chunk, &pending, ev)111 routeOverlayBytes(chunk, &pending, ev)
@@ -103,6 +118,7 @@ func TestRouteOverlayBytes(t *testing.T) {
103 require.Equal(t, tt.wantCancel, gotCancel)118 require.Equal(t, tt.wantCancel, gotCancel)
104 require.Equal(t, tt.wantUp, gotUp)119 require.Equal(t, tt.wantUp, gotUp)
105 require.Equal(t, tt.wantDown, gotDown)120 require.Equal(t, tt.wantDown, gotDown)
121+ require.Equal(t, tt.wantTab, gotTab)
106 })122 })
107 }123 }
108 }124 }
M internal/usecase/daemon/palette.go +1 -0
@@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
125125 ac.overlays.palette.Backspace()
126126 changed = true
127127 },
128+ tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed },
128129 up: func() { ac.overlays.palette.Up(); changed = true },
129130 down: func() { ac.overlays.palette.Down(); changed = true },
130131 cancel: func() { cancel = true },
@@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
125 ac.overlays.palette.Backspace()125 ac.overlays.palette.Backspace()
126 changed = true126 changed = true
127 },127 },
128+ tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed },
128 up: func() { ac.overlays.palette.Up(); changed = true },129 up: func() { ac.overlays.palette.Up(); changed = true },
129 down: func() { ac.overlays.palette.Down(); changed = true },130 down: func() { ac.overlays.palette.Down(); changed = true },
130 cancel: func() { cancel = true },131 cancel: func() { cancel = true },
M internal/usecase/daemon/palette_test.go +94 -0
@@ -530,6 +530,100 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
530530 wg.Wait()
531531 }
532532
533+func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
534+ tests := []struct {
535+ name string
536+ config map[string]string
537+ recent string
538+ query string
539+ want string
540+ wantCompletionInvalidation bool
541+ setupInvalidations int
542+ }{
543+ {name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true},
544+ {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"},
545+ {name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true},
546+ {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true},
547+ {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
548+ {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
549+ }
550+
551+ for _, tt := range tests {
552+ t.Run(tt.name, func(t *testing.T) {
553+ writes := make(chan []byte, 1)
554+ p, release := newBlockingPTYWithWrites(t, writes)
555+ defer release()
556+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
557+ invs := installPaletteInvalidationObserver(sess)
558+ if tt.recent != "" {
559+ d.recordPaletteUse(tt.recent)
560+ }
561+ if tt.config != nil {
562+ d.ApplyConfig(domain.Config{Codes: tt.config})
563+ for range tt.setupInvalidations {
564+ awaitInvalidation(t, invs)
565+ }
566+ requireNoInvalidation(t, invs)
567+ }
568+
569+ d.handleInput(sess, ac, []byte("\x1b "))
570+ awaitInvalidation(t, invs)
571+ requireNoInvalidation(t, invs)
572+ if tt.query != "" {
573+ d.handleInput(sess, ac, []byte(tt.query))
574+ awaitInvalidation(t, invs)
575+ requireNoInvalidation(t, invs)
576+ }
577+
578+ d.handleInput(sess, ac, []byte("\t"))
579+ ac.overlays.paletteMu.Lock()
580+ got := ac.overlays.palette.Query()
581+ ac.overlays.paletteMu.Unlock()
582+ require.Equal(t, tt.want, got)
583+ if tt.wantCompletionInvalidation {
584+ awaitInvalidation(t, invs)
585+ }
586+ requireNoInvalidation(t, invs)
587+ requireNoOutputFrame(t, sends)
588+ requireNoPTYWrite(t, writes)
589+ })
590+ }
591+}
592+
593+func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) {
594+ writes := make(chan []byte, 1)
595+ p, release := newBlockingPTYWithWrites(t, writes)
596+ defer release()
597+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
598+ invs := installPaletteInvalidationObserver(sess)
599+
600+ d.handleInput(sess, ac, []byte("\x1b "))
601+ awaitInvalidation(t, invs)
602+ requireNoInvalidation(t, invs)
603+
604+ d.handleInput(sess, ac, []byte("NX\t\t"))
605+ ac.overlays.paletteMu.Lock()
606+ got := ac.overlays.palette.Query()
607+ ac.overlays.paletteMu.Unlock()
608+ require.Equal(t, "NXT", got)
609+ awaitInvalidation(t, invs)
610+ requireNoInvalidation(t, invs)
611+ requireNoOutputFrame(t, sends)
612+ requireNoPTYWrite(t, writes)
613+}
614+
615+func installPaletteInvalidationObserver(sess *session) chan renderInvalidation {
616+ invs := make(chan renderInvalidation, 8)
617+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
618+ clock: nil,
619+ wake: nil,
620+ onInvalidate: func(inv renderInvalidation) {
621+ invs <- inv
622+ },
623+ }))
624+ return invs
625+}
626+
533627 func TestPaletteExecMethods(t *testing.T) {
534628 p1, release1 := newBlockingPTY(t)
535629 p2, release2 := newBlockingPTY(t)
@@ -530,6 +530,100 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
530 wg.Wait()530 wg.Wait()
531 }531 }
532 532
533+func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
534+ tests := []struct {
535+ name string
536+ config map[string]string
537+ recent string
538+ query string
539+ want string
540+ wantCompletionInvalidation bool
541+ setupInvalidations int
542+ }{
543+ {name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true},
544+ {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"},
545+ {name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true},
546+ {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true},
547+ {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
548+ {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
549+ }
550+
551+ for _, tt := range tests {
552+ t.Run(tt.name, func(t *testing.T) {
553+ writes := make(chan []byte, 1)
554+ p, release := newBlockingPTYWithWrites(t, writes)
555+ defer release()
556+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
557+ invs := installPaletteInvalidationObserver(sess)
558+ if tt.recent != "" {
559+ d.recordPaletteUse(tt.recent)
560+ }
561+ if tt.config != nil {
562+ d.ApplyConfig(domain.Config{Codes: tt.config})
563+ for range tt.setupInvalidations {
564+ awaitInvalidation(t, invs)
565+ }
566+ requireNoInvalidation(t, invs)
567+ }
568+
569+ d.handleInput(sess, ac, []byte("\x1b "))
570+ awaitInvalidation(t, invs)
571+ requireNoInvalidation(t, invs)
572+ if tt.query != "" {
573+ d.handleInput(sess, ac, []byte(tt.query))
574+ awaitInvalidation(t, invs)
575+ requireNoInvalidation(t, invs)
576+ }
577+
578+ d.handleInput(sess, ac, []byte("\t"))
579+ ac.overlays.paletteMu.Lock()
580+ got := ac.overlays.palette.Query()
581+ ac.overlays.paletteMu.Unlock()
582+ require.Equal(t, tt.want, got)
583+ if tt.wantCompletionInvalidation {
584+ awaitInvalidation(t, invs)
585+ }
586+ requireNoInvalidation(t, invs)
587+ requireNoOutputFrame(t, sends)
588+ requireNoPTYWrite(t, writes)
589+ })
590+ }
591+}
592+
593+func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) {
594+ writes := make(chan []byte, 1)
595+ p, release := newBlockingPTYWithWrites(t, writes)
596+ defer release()
597+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
598+ invs := installPaletteInvalidationObserver(sess)
599+
600+ d.handleInput(sess, ac, []byte("\x1b "))
601+ awaitInvalidation(t, invs)
602+ requireNoInvalidation(t, invs)
603+
604+ d.handleInput(sess, ac, []byte("NX\t\t"))
605+ ac.overlays.paletteMu.Lock()
606+ got := ac.overlays.palette.Query()
607+ ac.overlays.paletteMu.Unlock()
608+ require.Equal(t, "NXT", got)
609+ awaitInvalidation(t, invs)
610+ requireNoInvalidation(t, invs)
611+ requireNoOutputFrame(t, sends)
612+ requireNoPTYWrite(t, writes)
613+}
614+
615+func installPaletteInvalidationObserver(sess *session) chan renderInvalidation {
616+ invs := make(chan renderInvalidation, 8)
617+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
618+ clock: nil,
619+ wake: nil,
620+ onInvalidate: func(inv renderInvalidation) {
621+ invs <- inv
622+ },
623+ }))
624+ return invs
625+}
626+
533 func TestPaletteExecMethods(t *testing.T) {627 func TestPaletteExecMethods(t *testing.T) {
534 p1, release1 := newBlockingPTY(t)628 p1, release1 := newBlockingPTY(t)
535 p2, release2 := newBlockingPTY(t)629 p2, release2 := newBlockingPTY(t)
M internal/usecase/daemon/picker_test.go +127 -0
@@ -15,6 +15,8 @@ import (
1515 "github.com/bnema/vev/internal/usecase/keys"
1616 "github.com/bnema/vev/internal/usecase/layout"
1717 "github.com/bnema/vev/internal/usecase/picker"
18+ themeui "github.com/bnema/vev/internal/usecase/theme"
19+ "github.com/bnema/vev/pkg/renderer"
1820 "github.com/bnema/vev/pkg/vt"
1921 )
2022
@@ -545,6 +547,131 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
545547 require.True(t, subscribed)
546548 }
547549
550+func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) {
551+ for _, tt := range []struct {
552+ name string
553+ theme themeui.Theme
554+ }{
555+ {name: "default terminal", theme: themeui.Theme{}},
556+ {name: "truecolor terminal", theme: themeui.BuiltinDark},
557+ } {
558+ t.Run(tt.name, func(t *testing.T) {
559+ state := capturedRenderState{
560+ theme: tt.theme,
561+ layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
562+ }
563+ snap := &overlayRenderSnapshot{
564+ pickerActive: true,
565+ pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0),
566+ }
567+
568+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
569+
570+ inner := state.overlays.picker.inner
571+ layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
572+ require.Equal(t, picker.LayoutHorizontal, layout.Mode)
573+ want := themeui.MutedTextStyle(tt.theme)
574+ for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
575+ for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
576+ cell := inner.At(x, y)
577+ require.Equal(t, '│', cell.Rune)
578+ require.True(t, cell.Style.Equal(want))
579+ }
580+ }
581+ })
582+ }
583+}
584+
585+func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
586+ model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
587+ state := capturedRenderState{theme: themeui.BuiltinDark}
588+ cases := []struct {
589+ name string
590+ size domain.Size
591+ marker rune
592+ wantMode picker.LayoutMode
593+ wantSep rune
594+ wantMarker bool
595+ }{
596+ {name: "horizontal", size: domain.Size{Cols: 100, Rows: 40}, marker: '◆', wantMode: picker.LayoutHorizontal, wantSep: '│', wantMarker: true},
597+ {name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, marker: '◇', wantMode: picker.LayoutStacked, wantSep: '─', wantMarker: true},
598+ {name: "list only", size: domain.Size{Cols: 24, Rows: 11}, marker: '●', wantMode: picker.LayoutListOnly},
599+ }
600+ for _, tt := range cases {
601+ t.Run(tt.name, func(t *testing.T) {
602+ state.layout.area = domain.Rect{Width: tt.size.Cols, Height: tt.size.Rows - 2}
603+ snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model, previewTab: nil}
604+ state.preview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: tt.marker, Style: renderer.DefaultStyle()}}}}
605+
606+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
607+
608+ inner := state.overlays.picker.inner
609+ layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
610+ require.Equal(t, tt.wantMode, layout.Mode)
611+ got := strings.Join(frameRows(inner), "\n")
612+ if tt.wantMarker {
613+ require.Contains(t, got, string(tt.marker))
614+ } else {
615+ require.NotContains(t, got, "◆")
616+ require.NotContains(t, got, "◇")
617+ require.NotContains(t, got, "●")
618+ }
619+ if tt.wantSep == 0 {
620+ require.NotContains(t, got, "│")
621+ require.NotContains(t, got, "─")
622+ return
623+ }
624+ if tt.wantSep == '│' {
625+ require.NotContains(t, got, "─")
626+ } else {
627+ require.NotContains(t, got, "│")
628+ }
629+ for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
630+ for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
631+ cell := inner.At(x, y)
632+ require.Equal(t, tt.wantSep, cell.Rune)
633+ require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme)))
634+ }
635+ }
636+ })
637+ }
638+}
639+
640+func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) {
641+ withScrollback := newPickerPreviewTabWithHistoryRows(10_000)
642+ require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
643+
644+ preview := snapshotPickerPreview(withScrollback)
645+ require.Equal(t, 10, preview.Width)
646+ require.Equal(t, 3, preview.Height)
647+ require.Len(t, preview.Rows, preview.Height)
648+ cells := 0
649+ for _, row := range preview.Rows {
650+ require.Len(t, row, preview.Width)
651+ cells += len(row)
652+ }
653+ require.Equal(t, preview.Width*preview.Height, cells)
654+ got := strings.Join(func() []string {
655+ rows := make([]string, len(preview.Rows))
656+ for i, row := range preview.Rows {
657+ rows[i] = rowText(row)
658+ }
659+ return rows
660+ }(), "\n")
661+ require.Contains(t, got, "NOW")
662+ require.NotContains(t, got, "history-only-marker")
663+}
664+
665+func newPickerPreviewTabWithHistoryRows(historyRows int) *tab {
666+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
667+ p := tb.focusedPane()
668+ for range historyRows {
669+ p.screen.History().Append(testRow("history-only-marker"))
670+ }
671+ p.screen.Write([]byte("NOW"))
672+ return tb
673+}
674+
548675 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
549676 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
550677 p := tb.focusedPane()
@@ -15,6 +15,8 @@ import (
15 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
16 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
17 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
18+ themeui "github.com/bnema/vev/internal/usecase/theme"
19+ "github.com/bnema/vev/pkg/renderer"
18 "github.com/bnema/vev/pkg/vt"20 "github.com/bnema/vev/pkg/vt"
19 )21 )
20 22
@@ -545,6 +547,131 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
545 require.True(t, subscribed)547 require.True(t, subscribed)
546 }548 }
547 549
550+func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) {
551+ for _, tt := range []struct {
552+ name string
553+ theme themeui.Theme
554+ }{
555+ {name: "default terminal", theme: themeui.Theme{}},
556+ {name: "truecolor terminal", theme: themeui.BuiltinDark},
557+ } {
558+ t.Run(tt.name, func(t *testing.T) {
559+ state := capturedRenderState{
560+ theme: tt.theme,
561+ layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
562+ }
563+ snap := &overlayRenderSnapshot{
564+ pickerActive: true,
565+ pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0),
566+ }
567+
568+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
569+
570+ inner := state.overlays.picker.inner
571+ layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
572+ require.Equal(t, picker.LayoutHorizontal, layout.Mode)
573+ want := themeui.MutedTextStyle(tt.theme)
574+ for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
575+ for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
576+ cell := inner.At(x, y)
577+ require.Equal(t, '│', cell.Rune)
578+ require.True(t, cell.Style.Equal(want))
579+ }
580+ }
581+ })
582+ }
583+}
584+
585+func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
586+ model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
587+ state := capturedRenderState{theme: themeui.BuiltinDark}
588+ cases := []struct {
589+ name string
590+ size domain.Size
591+ marker rune
592+ wantMode picker.LayoutMode
593+ wantSep rune
594+ wantMarker bool
595+ }{
596+ {name: "horizontal", size: domain.Size{Cols: 100, Rows: 40}, marker: '◆', wantMode: picker.LayoutHorizontal, wantSep: '│', wantMarker: true},
597+ {name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, marker: '◇', wantMode: picker.LayoutStacked, wantSep: '─', wantMarker: true},
598+ {name: "list only", size: domain.Size{Cols: 24, Rows: 11}, marker: '●', wantMode: picker.LayoutListOnly},
599+ }
600+ for _, tt := range cases {
601+ t.Run(tt.name, func(t *testing.T) {
602+ state.layout.area = domain.Rect{Width: tt.size.Cols, Height: tt.size.Rows - 2}
603+ snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model, previewTab: nil}
604+ state.preview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: tt.marker, Style: renderer.DefaultStyle()}}}}
605+
606+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
607+
608+ inner := state.overlays.picker.inner
609+ layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
610+ require.Equal(t, tt.wantMode, layout.Mode)
611+ got := strings.Join(frameRows(inner), "\n")
612+ if tt.wantMarker {
613+ require.Contains(t, got, string(tt.marker))
614+ } else {
615+ require.NotContains(t, got, "◆")
616+ require.NotContains(t, got, "◇")
617+ require.NotContains(t, got, "●")
618+ }
619+ if tt.wantSep == 0 {
620+ require.NotContains(t, got, "│")
621+ require.NotContains(t, got, "─")
622+ return
623+ }
624+ if tt.wantSep == '│' {
625+ require.NotContains(t, got, "─")
626+ } else {
627+ require.NotContains(t, got, "│")
628+ }
629+ for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
630+ for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
631+ cell := inner.At(x, y)
632+ require.Equal(t, tt.wantSep, cell.Rune)
633+ require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme)))
634+ }
635+ }
636+ })
637+ }
638+}
639+
640+func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) {
641+ withScrollback := newPickerPreviewTabWithHistoryRows(10_000)
642+ require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
643+
644+ preview := snapshotPickerPreview(withScrollback)
645+ require.Equal(t, 10, preview.Width)
646+ require.Equal(t, 3, preview.Height)
647+ require.Len(t, preview.Rows, preview.Height)
648+ cells := 0
649+ for _, row := range preview.Rows {
650+ require.Len(t, row, preview.Width)
651+ cells += len(row)
652+ }
653+ require.Equal(t, preview.Width*preview.Height, cells)
654+ got := strings.Join(func() []string {
655+ rows := make([]string, len(preview.Rows))
656+ for i, row := range preview.Rows {
657+ rows[i] = rowText(row)
658+ }
659+ return rows
660+ }(), "\n")
661+ require.Contains(t, got, "NOW")
662+ require.NotContains(t, got, "history-only-marker")
663+}
664+
665+func newPickerPreviewTabWithHistoryRows(historyRows int) *tab {
666+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
667+ p := tb.focusedPane()
668+ for range historyRows {
669+ p.screen.History().Append(testRow("history-only-marker"))
670+ }
671+ p.screen.Write([]byte("NOW"))
672+ return tb
673+}
674+
548 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {675 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
549 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})676 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
550 p := tb.focusedPane()677 p := tb.focusedPane()
M internal/usecase/daemon/render.go +2 -0
@@ -371,6 +371,7 @@ type themeStyles struct {
371371 pickerName renderer.Style
372372 pickerSelectionName renderer.Style
373373 pickerSelectionMuted renderer.Style
374+ pickerSeparator renderer.Style
374375 }
375376
376377 func newThemeStyles(t themeui.Theme) themeStyles {
@@ -393,6 +394,7 @@ func newThemeStyles(t themeui.Theme) themeStyles {
393394 pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
394395 pickerSelectionName: themeui.EmphasisStyle(selection, t),
395396 pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
397+ pickerSeparator: themeui.MutedTextStyle(t),
396398 }
397399 }
398400
@@ -371,6 +371,7 @@ type themeStyles struct {
371 pickerName renderer.Style371 pickerName renderer.Style
372 pickerSelectionName renderer.Style372 pickerSelectionName renderer.Style
373 pickerSelectionMuted renderer.Style373 pickerSelectionMuted renderer.Style
374+ pickerSeparator renderer.Style
374 }375 }
375 376
376 func newThemeStyles(t themeui.Theme) themeStyles {377 func newThemeStyles(t themeui.Theme) themeStyles {
@@ -393,6 +394,7 @@ func newThemeStyles(t themeui.Theme) themeStyles {
393 pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),394 pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t),
394 pickerSelectionName: themeui.EmphasisStyle(selection, t),395 pickerSelectionName: themeui.EmphasisStyle(selection, t),
395 pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),396 pickerSelectionMuted: themeui.MutedVariantStyle(selection, t),
397+ pickerSeparator: themeui.MutedTextStyle(t),
396 }398 }
397 }399 }
398 400
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -184,7 +184,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
184184 }
185185 if snap.pickerActive && snap.pickerModel != nil {
186186 o.picker.modal = pickerModal
187- renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()}
187+ renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator}
188188 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
189189 }
190190 if snap.paletteActive && snap.paletteModel != nil {
@@ -184,7 +184,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
184 }184 }
185 if snap.pickerActive && snap.pickerModel != nil {185 if snap.pickerActive && snap.pickerModel != nil {
186 o.picker.modal = pickerModal186 o.picker.modal = pickerModal
187- renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()}187+ renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator}
188 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)188 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
189 }189 }
190 if snap.paletteActive && snap.paletteModel != nil {190 if snap.paletteActive && snap.paletteModel != nil {
M internal/usecase/palette/model.go +78 -10
@@ -1,6 +1,8 @@
11 package palette
22
33 import (
4+ "unicode"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/usecase/command"
68 "github.com/bnema/vev/internal/usecase/ui"
@@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) {
8688 return m.matches[m.selected].Command, true
8789 }
8890
91+// CompleteSelected replaces the query with the selected command's effective
92+// code. Required-argument commands retain the typed argument bytes.
93+func (m *Model) CompleteSelected() bool {
94+ selected, ok := m.Selected()
95+ if !ok {
96+ return false
97+ }
98+
99+ query := m.Query()
100+ completed := selected.Code
101+ if selected.Arguments == command.ArgumentsRequired {
102+ token, args, hasSeparator := completionParts(query)
103+ if token == selected.Code && hasSeparator && args != "" {
104+ return false
105+ }
106+ completed += " "
107+ if args != "" {
108+ completed += args
109+ }
110+ }
111+ if completed == query {
112+ return false
113+ }
114+
115+ m.input.SetValue(completed)
116+ m.selected = 0
117+ m.scroll = 0
118+ m.refresh()
119+ return true
120+}
121+
122+// completionParts splits the first token at Unicode whitespace and returns
123+// the argument beginning at its first non-whitespace byte.
124+func completionParts(query string) (token, args string, hasSeparator bool) {
125+ for i, r := range query {
126+ if !unicode.IsSpace(r) {
127+ continue
128+ }
129+ for j, argumentRune := range query[i:] {
130+ if !unicode.IsSpace(argumentRune) {
131+ return query[:i], query[i+j:], true
132+ }
133+ }
134+ return query[:i], "", true
135+ }
136+ return query, "", false
137+}
138+
89139 // ArgumentCommand returns the exact argument-taking command being entered.
90140 func (m *Model) ArgumentCommand() (command.Command, bool) {
91141 if m == nil {
@@ -107,23 +157,41 @@ func (m *Model) Matches() []Match {
107157 }
108158
109159 func (m *Model) refresh() {
110- m.matches = Fuzzy(m.commands, m.input.Value())
160+ query := m.input.Value()
161+ m.matches = Fuzzy(m.commands, query)
111162 // Once an argument-taking token is exact, retain its row while its
112163 // arguments make ordinary fuzzy matching inapplicable.
113- if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {
114- found := false
115- for _, match := range m.matches {
116- if match.Command.Code == cmd.Code {
117- found = true
118- break
164+ if cmd, ok := ArgumentCommand(m.commands, query); ok {
165+ m.prependMatch(cmd)
166+ } else if len(m.matches) == 0 {
167+ // Arguments are not part of static command matching. When they would
168+ // otherwise clear the list, keep required-argument candidates matched by
169+ // the partial first token so Tab can complete them.
170+ token, _, hasSeparator := completionParts(query)
171+ if hasSeparator {
172+ for _, match := range Fuzzy(m.commands, token) {
173+ if match.Command.Arguments == command.ArgumentsRequired {
174+ m.matches = append(m.matches, match)
175+ }
119176 }
120177 }
121- if !found {
122- m.matches = append([]Match{{Command: cmd}}, m.matches...)
123- }
124178 }
125179 m.clamp()
126180 }
181+
182+func (m *Model) prependMatch(cmd command.Command) {
183+ for i, match := range m.matches {
184+ if match.Command.Code != cmd.Code {
185+ continue
186+ }
187+ if i > 0 {
188+ copy(m.matches[1:i+1], m.matches[:i])
189+ m.matches[0] = match
190+ }
191+ return
192+ }
193+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
194+}
127195 func (m *Model) clamp() {
128196 if len(m.matches) == 0 {
129197 m.selected = -1
@@ -1,6 +1,8 @@
1 package palette1 package palette
2 2
3 import (3 import (
4+ "unicode"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
6 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
@@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) {
86 return m.matches[m.selected].Command, true88 return m.matches[m.selected].Command, true
87 }89 }
88 90
91+// CompleteSelected replaces the query with the selected command's effective
92+// code. Required-argument commands retain the typed argument bytes.
93+func (m *Model) CompleteSelected() bool {
94+ selected, ok := m.Selected()
95+ if !ok {
96+ return false
97+ }
98+
99+ query := m.Query()
100+ completed := selected.Code
101+ if selected.Arguments == command.ArgumentsRequired {
102+ token, args, hasSeparator := completionParts(query)
103+ if token == selected.Code && hasSeparator && args != "" {
104+ return false
105+ }
106+ completed += " "
107+ if args != "" {
108+ completed += args
109+ }
110+ }
111+ if completed == query {
112+ return false
113+ }
114+
115+ m.input.SetValue(completed)
116+ m.selected = 0
117+ m.scroll = 0
118+ m.refresh()
119+ return true
120+}
121+
122+// completionParts splits the first token at Unicode whitespace and returns
123+// the argument beginning at its first non-whitespace byte.
124+func completionParts(query string) (token, args string, hasSeparator bool) {
125+ for i, r := range query {
126+ if !unicode.IsSpace(r) {
127+ continue
128+ }
129+ for j, argumentRune := range query[i:] {
130+ if !unicode.IsSpace(argumentRune) {
131+ return query[:i], query[i+j:], true
132+ }
133+ }
134+ return query[:i], "", true
135+ }
136+ return query, "", false
137+}
138+
89 // ArgumentCommand returns the exact argument-taking command being entered.139 // ArgumentCommand returns the exact argument-taking command being entered.
90 func (m *Model) ArgumentCommand() (command.Command, bool) {140 func (m *Model) ArgumentCommand() (command.Command, bool) {
91 if m == nil {141 if m == nil {
@@ -107,23 +157,41 @@ func (m *Model) Matches() []Match {
107 }157 }
108 158
109 func (m *Model) refresh() {159 func (m *Model) refresh() {
110- m.matches = Fuzzy(m.commands, m.input.Value())160+ query := m.input.Value()
161+ m.matches = Fuzzy(m.commands, query)
111 // Once an argument-taking token is exact, retain its row while its162 // Once an argument-taking token is exact, retain its row while its
112 // arguments make ordinary fuzzy matching inapplicable.163 // arguments make ordinary fuzzy matching inapplicable.
113- if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {164+ if cmd, ok := ArgumentCommand(m.commands, query); ok {
114- found := false165+ m.prependMatch(cmd)
115- for _, match := range m.matches {166+ } else if len(m.matches) == 0 {
116- if match.Command.Code == cmd.Code {167+ // Arguments are not part of static command matching. When they would
117- found = true168+ // otherwise clear the list, keep required-argument candidates matched by
118- break169+ // the partial first token so Tab can complete them.
170+ token, _, hasSeparator := completionParts(query)
171+ if hasSeparator {
172+ for _, match := range Fuzzy(m.commands, token) {
173+ if match.Command.Arguments == command.ArgumentsRequired {
174+ m.matches = append(m.matches, match)
175+ }
119 }176 }
120 }177 }
121- if !found {
122- m.matches = append([]Match{{Command: cmd}}, m.matches...)
123- }
124 }178 }
125 m.clamp()179 m.clamp()
126 }180 }
181+
182+func (m *Model) prependMatch(cmd command.Command) {
183+ for i, match := range m.matches {
184+ if match.Command.Code != cmd.Code {
185+ continue
186+ }
187+ if i > 0 {
188+ copy(m.matches[1:i+1], m.matches[:i])
189+ m.matches[0] = match
190+ }
191+ return
192+ }
193+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
194+}
127 func (m *Model) clamp() {195 func (m *Model) clamp() {
128 if len(m.matches) == 0 {196 if len(m.matches) == 0 {
129 m.selected = -1197 m.selected = -1
M internal/usecase/palette/model_test.go +133 -0
@@ -67,6 +67,77 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) {
6767 require.Equal(t, []int{0}, fresh[0].Positions)
6868 }
6969
70+func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
71+ zzz := cmd("ZZZ", "", "ZZZ 1")
72+ zzz.Arguments = command.ArgumentsRequired
73+ commands := []command.Command{
74+ cmd("AAA", "", "ZZZ 1"),
75+ zzz,
76+ }
77+ want := Fuzzy(commands, "ZZZ 1")[1]
78+ m := New(commands)
79+
80+ for _, r := range "ZZZ 1" {
81+ m.Insert(r)
82+ }
83+
84+ matches := m.Matches()
85+ require.Len(t, matches, 2)
86+ require.Equal(t, "ZZZ", matches[0].Command.Code)
87+ require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
88+ selected, ok := m.Selected()
89+ require.True(t, ok)
90+ require.Equal(t, "ZZZ", selected.Code)
91+}
92+
93+func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
94+ for _, tt := range []struct {
95+ name string
96+ commands func(zzz command.Command) []command.Command
97+ want Match
98+ wantLen int
99+ }{
100+ {
101+ name: "absent fuzzy match is prepended",
102+ commands: func(zzz command.Command) []command.Command {
103+ return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
104+ },
105+ want: Match{Command: command.Command{
106+ Code: "ZZZ",
107+ Desc: "unmatched",
108+ Arguments: command.ArgumentsRequired,
109+ }},
110+ wantLen: 2,
111+ },
112+ {
113+ name: "first fuzzy match is unchanged",
114+ commands: func(zzz command.Command) []command.Command {
115+ zzz.Desc = "ZZZ 1"
116+ return []command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}
117+ },
118+ want: func() Match {
119+ zzz := cmd("ZZZ", "", "ZZZ 1")
120+ zzz.Arguments = command.ArgumentsRequired
121+ return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0]
122+ }(),
123+ wantLen: 2,
124+ },
125+ } {
126+ t.Run(tt.name, func(t *testing.T) {
127+ zzz := cmd("ZZZ", "", "unmatched")
128+ zzz.Arguments = command.ArgumentsRequired
129+ m := New(tt.commands(zzz))
130+ for _, r := range "ZZZ 1" {
131+ m.Insert(r)
132+ }
133+
134+ matches := m.Matches()
135+ require.Len(t, matches, tt.wantLen)
136+ require.Equal(t, tt.want, matches[0])
137+ })
138+ }
139+}
140+
70141 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
71142 m := New([]command.Command{
72143 cmd("CPY", "Copy", "Enter copy mode"),
@@ -186,6 +257,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
186257 }
187258 }
188259
260+func TestModelCompleteSelected(t *testing.T) {
261+ jrs := cmd("JRS", "Jump", "Jump to recent session")
262+ jrs.Arguments = command.ArgumentsRequired
263+ registryFirst := command.Registry()[0]
264+
265+ tests := []struct {
266+ name string
267+ commands []command.Command
268+ registry bool
269+ query string
270+ down int
271+ want string
272+ wantChange bool
273+ rendered string
274+ }{
275+ {name: "nil model", want: "", wantChange: false},
276+ {name: "empty model", commands: nil, want: "", wantChange: false},
277+ {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
278+ {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
279+ {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
280+ {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
281+ {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
282+ {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
283+ {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
284+ {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
285+ {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
286+ {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
287+ {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
288+ }
289+
290+ for _, tt := range tests {
291+ t.Run(tt.name, func(t *testing.T) {
292+ var m *Model
293+ if tt.name != "nil model" {
294+ if tt.registry {
295+ m = NewRegistry()
296+ } else {
297+ m = New(tt.commands)
298+ }
299+ for _, r := range tt.query {
300+ m.Insert(r)
301+ }
302+ for range tt.down {
303+ m.Down()
304+ }
305+ }
306+
307+ changed := m.CompleteSelected()
308+ require.Equal(t, tt.wantChange, changed)
309+ if m == nil {
310+ require.Equal(t, tt.want, "")
311+ return
312+ }
313+ require.Equal(t, tt.want, m.Query())
314+ if tt.rendered != "" {
315+ frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
316+ require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
317+ }
318+ })
319+ }
320+}
321+
189322 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
190323 jrs := cmd("JRS", "Jump", "Jump to recent session")
191324 jrs.Arguments = command.ArgumentsRequired
@@ -67,6 +67,77 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) {
67 require.Equal(t, []int{0}, fresh[0].Positions)67 require.Equal(t, []int{0}, fresh[0].Positions)
68 }68 }
69 69
70+func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
71+ zzz := cmd("ZZZ", "", "ZZZ 1")
72+ zzz.Arguments = command.ArgumentsRequired
73+ commands := []command.Command{
74+ cmd("AAA", "", "ZZZ 1"),
75+ zzz,
76+ }
77+ want := Fuzzy(commands, "ZZZ 1")[1]
78+ m := New(commands)
79+
80+ for _, r := range "ZZZ 1" {
81+ m.Insert(r)
82+ }
83+
84+ matches := m.Matches()
85+ require.Len(t, matches, 2)
86+ require.Equal(t, "ZZZ", matches[0].Command.Code)
87+ require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
88+ selected, ok := m.Selected()
89+ require.True(t, ok)
90+ require.Equal(t, "ZZZ", selected.Code)
91+}
92+
93+func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
94+ for _, tt := range []struct {
95+ name string
96+ commands func(zzz command.Command) []command.Command
97+ want Match
98+ wantLen int
99+ }{
100+ {
101+ name: "absent fuzzy match is prepended",
102+ commands: func(zzz command.Command) []command.Command {
103+ return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
104+ },
105+ want: Match{Command: command.Command{
106+ Code: "ZZZ",
107+ Desc: "unmatched",
108+ Arguments: command.ArgumentsRequired,
109+ }},
110+ wantLen: 2,
111+ },
112+ {
113+ name: "first fuzzy match is unchanged",
114+ commands: func(zzz command.Command) []command.Command {
115+ zzz.Desc = "ZZZ 1"
116+ return []command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}
117+ },
118+ want: func() Match {
119+ zzz := cmd("ZZZ", "", "ZZZ 1")
120+ zzz.Arguments = command.ArgumentsRequired
121+ return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0]
122+ }(),
123+ wantLen: 2,
124+ },
125+ } {
126+ t.Run(tt.name, func(t *testing.T) {
127+ zzz := cmd("ZZZ", "", "unmatched")
128+ zzz.Arguments = command.ArgumentsRequired
129+ m := New(tt.commands(zzz))
130+ for _, r := range "ZZZ 1" {
131+ m.Insert(r)
132+ }
133+
134+ matches := m.Matches()
135+ require.Len(t, matches, tt.wantLen)
136+ require.Equal(t, tt.want, matches[0])
137+ })
138+ }
139+}
140+
70 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {141 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
71 m := New([]command.Command{142 m := New([]command.Command{
72 cmd("CPY", "Copy", "Enter copy mode"),143 cmd("CPY", "Copy", "Enter copy mode"),
@@ -186,6 +257,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
186 }257 }
187 }258 }
188 259
260+func TestModelCompleteSelected(t *testing.T) {
261+ jrs := cmd("JRS", "Jump", "Jump to recent session")
262+ jrs.Arguments = command.ArgumentsRequired
263+ registryFirst := command.Registry()[0]
264+
265+ tests := []struct {
266+ name string
267+ commands []command.Command
268+ registry bool
269+ query string
270+ down int
271+ want string
272+ wantChange bool
273+ rendered string
274+ }{
275+ {name: "nil model", want: "", wantChange: false},
276+ {name: "empty model", commands: nil, want: "", wantChange: false},
277+ {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
278+ {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
279+ {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
280+ {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
281+ {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
282+ {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
283+ {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
284+ {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
285+ {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
286+ {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
287+ {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
288+ }
289+
290+ for _, tt := range tests {
291+ t.Run(tt.name, func(t *testing.T) {
292+ var m *Model
293+ if tt.name != "nil model" {
294+ if tt.registry {
295+ m = NewRegistry()
296+ } else {
297+ m = New(tt.commands)
298+ }
299+ for _, r := range tt.query {
300+ m.Insert(r)
301+ }
302+ for range tt.down {
303+ m.Down()
304+ }
305+ }
306+
307+ changed := m.CompleteSelected()
308+ require.Equal(t, tt.wantChange, changed)
309+ if m == nil {
310+ require.Equal(t, tt.want, "")
311+ return
312+ }
313+ require.Equal(t, tt.want, m.Query())
314+ if tt.rendered != "" {
315+ frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
316+ require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
317+ }
318+ })
319+ }
320+}
321+
189 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {322 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
190 jrs := cmd("JRS", "Jump", "Jump to recent session")323 jrs := cmd("JRS", "Jump", "Jump to recent session")
191 jrs.Arguments = command.ArgumentsRequired324 jrs.Arguments = command.ArgumentsRequired
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 {
A internal/usecase/ui/frame.go +123 -0
@@ -0,0 +1,123 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// VerticalAnchor controls which end of a source is retained when its height
9+// differs from the destination rectangle. Source and destination aliasing is
10+// unsupported.
11+type VerticalAnchor uint8
12+
13+const (
14+ VerticalAnchorTop VerticalAnchor = iota
15+ VerticalAnchorBottom
16+)
17+
18+// FrameView is a bounded, row-oriented view of cells.
19+type FrameView struct {
20+ Rows [][]renderer.Cell
21+ Width, Height int
22+}
23+
24+type frameSpan struct {
25+ source, destination, length int
26+}
27+
28+// BlitFrame copies the valid intersection of src and rect into dst. Uncovered
29+// destination cells are left untouched. Invalid dimensions, views, rectangles,
30+// or anchors are ignored.
31+func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) {
32+ if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom {
33+ return
34+ }
35+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 {
36+ return
37+ }
38+ if len(src.Rows) != src.Height {
39+ return
40+ }
41+ for _, row := range src.Rows {
42+ if len(row) != src.Width {
43+ return
44+ }
45+ }
46+
47+ width := min(src.Width, rect.Width)
48+ x, ok := clippedFrameSpan(rect.X, 0, width, dst.Width)
49+ if !ok {
50+ return
51+ }
52+
53+ height := min(src.Height, rect.Height)
54+ sourceY, destinationOffset := 0, 0
55+ if anchor == VerticalAnchorBottom {
56+ if src.Height > rect.Height {
57+ sourceY = src.Height - rect.Height
58+ } else {
59+ destinationOffset = rect.Height - src.Height
60+ }
61+ }
62+ destinationY, ok := frameDestinationOrigin(rect.Y, destinationOffset, dst.Height)
63+ if !ok {
64+ return
65+ }
66+ y, ok := clippedFrameSpan(destinationY, sourceY, height, dst.Height)
67+ if !ok {
68+ return
69+ }
70+
71+ for dy := range y.length {
72+ row := src.Rows[y.source+dy]
73+ for dx := range x.length {
74+ sourceX := x.source + dx
75+ current := row[sourceX]
76+ if current.Continuation {
77+ continue
78+ }
79+ if renderer.RuneWidth(current.Rune) == 2 {
80+ next := sourceX + 1
81+ if dx+1 >= x.length || !row[next].Continuation {
82+ continue
83+ }
84+ dst.Set(x.destination+dx, y.destination+dy, current)
85+ dst.Set(x.destination+dx+1, y.destination+dy, row[next])
86+ continue
87+ }
88+ dst.Set(x.destination+dx, y.destination+dy, current)
89+ }
90+ }
91+}
92+
93+// frameDestinationOrigin adds an in-rectangle offset only after proving that
94+// a non-negative origin remains in the destination's bounded coordinate range.
95+func frameDestinationOrigin(origin, offset, limit int) (int, bool) {
96+ if origin >= 0 {
97+ if origin >= limit || offset >= limit-origin {
98+ return 0, false
99+ }
100+ return origin + offset, true
101+ }
102+ // A negative integer plus a non-negative offset cannot overflow.
103+ return origin + offset, true
104+}
105+
106+// clippedFrameSpan intersects a source span placed at origin with [0, limit).
107+// It avoids forming an unbounded endpoint before both operands are bounded.
108+func clippedFrameSpan(origin, source, length, limit int) (frameSpan, bool) {
109+ if origin < 0 {
110+ if origin <= -length {
111+ return frameSpan{}, false
112+ }
113+ trim := -origin
114+ source += trim
115+ length -= trim
116+ origin = 0
117+ }
118+ if origin >= limit {
119+ return frameSpan{}, false
120+ }
121+ length = min(length, limit-origin)
122+ return frameSpan{source: source, destination: origin, length: length}, length > 0
123+}
@@ -0,0 +1,123 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// VerticalAnchor controls which end of a source is retained when its height
9+// differs from the destination rectangle. Source and destination aliasing is
10+// unsupported.
11+type VerticalAnchor uint8
12+
13+const (
14+ VerticalAnchorTop VerticalAnchor = iota
15+ VerticalAnchorBottom
16+)
17+
18+// FrameView is a bounded, row-oriented view of cells.
19+type FrameView struct {
20+ Rows [][]renderer.Cell
21+ Width, Height int
22+}
23+
24+type frameSpan struct {
25+ source, destination, length int
26+}
27+
28+// BlitFrame copies the valid intersection of src and rect into dst. Uncovered
29+// destination cells are left untouched. Invalid dimensions, views, rectangles,
30+// or anchors are ignored.
31+func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) {
32+ if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom {
33+ return
34+ }
35+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 {
36+ return
37+ }
38+ if len(src.Rows) != src.Height {
39+ return
40+ }
41+ for _, row := range src.Rows {
42+ if len(row) != src.Width {
43+ return
44+ }
45+ }
46+
47+ width := min(src.Width, rect.Width)
48+ x, ok := clippedFrameSpan(rect.X, 0, width, dst.Width)
49+ if !ok {
50+ return
51+ }
52+
53+ height := min(src.Height, rect.Height)
54+ sourceY, destinationOffset := 0, 0
55+ if anchor == VerticalAnchorBottom {
56+ if src.Height > rect.Height {
57+ sourceY = src.Height - rect.Height
58+ } else {
59+ destinationOffset = rect.Height - src.Height
60+ }
61+ }
62+ destinationY, ok := frameDestinationOrigin(rect.Y, destinationOffset, dst.Height)
63+ if !ok {
64+ return
65+ }
66+ y, ok := clippedFrameSpan(destinationY, sourceY, height, dst.Height)
67+ if !ok {
68+ return
69+ }
70+
71+ for dy := range y.length {
72+ row := src.Rows[y.source+dy]
73+ for dx := range x.length {
74+ sourceX := x.source + dx
75+ current := row[sourceX]
76+ if current.Continuation {
77+ continue
78+ }
79+ if renderer.RuneWidth(current.Rune) == 2 {
80+ next := sourceX + 1
81+ if dx+1 >= x.length || !row[next].Continuation {
82+ continue
83+ }
84+ dst.Set(x.destination+dx, y.destination+dy, current)
85+ dst.Set(x.destination+dx+1, y.destination+dy, row[next])
86+ continue
87+ }
88+ dst.Set(x.destination+dx, y.destination+dy, current)
89+ }
90+ }
91+}
92+
93+// frameDestinationOrigin adds an in-rectangle offset only after proving that
94+// a non-negative origin remains in the destination's bounded coordinate range.
95+func frameDestinationOrigin(origin, offset, limit int) (int, bool) {
96+ if origin >= 0 {
97+ if origin >= limit || offset >= limit-origin {
98+ return 0, false
99+ }
100+ return origin + offset, true
101+ }
102+ // A negative integer plus a non-negative offset cannot overflow.
103+ return origin + offset, true
104+}
105+
106+// clippedFrameSpan intersects a source span placed at origin with [0, limit).
107+// It avoids forming an unbounded endpoint before both operands are bounded.
108+func clippedFrameSpan(origin, source, length, limit int) (frameSpan, bool) {
109+ if origin < 0 {
110+ if origin <= -length {
111+ return frameSpan{}, false
112+ }
113+ trim := -origin
114+ source += trim
115+ length -= trim
116+ origin = 0
117+ }
118+ if origin >= limit {
119+ return frameSpan{}, false
120+ }
121+ length = min(length, limit-origin)
122+ return frameSpan{source: source, destination: origin, length: length}, length > 0
123+}
A internal/usecase/ui/frame_test.go +198 -0
@@ -0,0 +1,198 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func cell(r rune, style renderer.Style, continuation bool) renderer.Cell {
11+ return renderer.Cell{Rune: r, Style: style, Continuation: continuation}
12+}
13+
14+func TestBlitFrameAnchorsAndClips(t *testing.T) {
15+ style := renderer.Style{Foreground: 1, Background: 2}
16+ tests := []struct {
17+ name string
18+ anchor VerticalAnchor
19+ srcH, srcW, rectX, rectY, rectW, rectH int
20+ want string
21+ }{
22+ {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"},
23+ {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"},
24+ {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "},
25+ {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)}
30+ for y := range src.Rows {
31+ src.Rows[y] = make([]renderer.Cell, tt.srcW)
32+ for x := range src.Rows[y] {
33+ src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, false)
34+ }
35+ }
36+ dst := renderer.NewFrame(5, 5)
37+ BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor)
38+ got := ""
39+ for y := 1; y < 3; y++ {
40+ for x := 1; x < 3; x++ {
41+ got += string(dst.At(x, y).Rune)
42+ }
43+ }
44+ if got != tt.want {
45+ t.Fatalf("got %q want %q", got, tt.want)
46+ }
47+ })
48+ }
49+}
50+
51+func TestBlitFrameNeverSplitsWidePairsAtClippedEdges(t *testing.T) {
52+ style := renderer.Style{Foreground: 7, Background: 8, Bold: true}
53+ wide := []renderer.Cell{
54+ cell('界', style, false),
55+ cell(0, style, true),
56+ cell('x', renderer.DefaultStyle(), false),
57+ }
58+ mark := cell('!', renderer.Style{Foreground: 3}, false)
59+ tests := []struct {
60+ name string
61+ rect domain.Rect
62+ dst renderer.Frame
63+ want []renderer.Cell
64+ }{
65+ {
66+ name: "left clipped continuation remains untouched",
67+ rect: domain.Rect{X: -1, Y: 0, Width: 3, Height: 1},
68+ dst: markedFrame(3, 1, mark),
69+ want: []renderer.Cell{mark, wide[2], mark},
70+ },
71+ {
72+ name: "right clipped leader remains untouched",
73+ rect: domain.Rect{X: 0, Y: 0, Width: 3, Height: 1},
74+ dst: markedFrame(1, 1, mark),
75+ want: []renderer.Cell{mark},
76+ },
77+ {
78+ name: "complete pair retains exact cells",
79+ rect: domain.Rect{X: 1, Y: 0, Width: 3, Height: 1},
80+ dst: markedFrame(4, 1, mark),
81+ want: []renderer.Cell{mark, wide[0], wide[1], wide[2]},
82+ },
83+ }
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ BlitFrame(tt.dst, tt.rect, FrameView{Rows: [][]renderer.Cell{wide}, Width: len(wide), Height: 1}, VerticalAnchorTop)
87+ assertRowEqual(t, tt.dst, 0, tt.want)
88+ })
89+ }
90+}
91+
92+func TestBlitFrameBoundsExtremeRectanglesToVisibleIntersection(t *testing.T) {
93+ maxInt := int(^uint(0) >> 1)
94+ minInt := -maxInt - 1
95+ style := renderer.Style{Foreground: 9}
96+ src := FrameView{Rows: [][]renderer.Cell{{cell('a', style, false)}}, Width: 1, Height: 1}
97+ mark := cell('!', renderer.Style{Background: 4}, false)
98+ tests := []struct {
99+ name string
100+ rect domain.Rect
101+ want []renderer.Cell
102+ }{
103+ {"huge visible width copies only source span", domain.Rect{X: 0, Y: 0, Width: maxInt, Height: maxInt}, []renderer.Cell{src.Rows[0][0], mark}},
104+ {"max coordinate overflow is outside frame", domain.Rect{X: maxInt - 1, Y: maxInt - 1, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
105+ {"min coordinate overflow is outside frame", domain.Rect{X: minInt, Y: minInt, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
106+ }
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ dst := markedFrame(2, 1, mark)
110+ BlitFrame(dst, tt.rect, src, VerticalAnchorTop)
111+ assertRowEqual(t, dst, 0, tt.want)
112+ })
113+ }
114+}
115+
116+func TestBlitFrameUsesLogicalDestinationRowsAndPreservesClippedSentinels(t *testing.T) {
117+ mark := cell('!', renderer.Style{Foreground: 3}, false)
118+ dst := renderer.NewFrame(3, 3)
119+ dst.ScrollUp(0, 2, 1)
120+ for y := range dst.Height {
121+ for x := range dst.Width {
122+ dst.Set(x, y, mark)
123+ }
124+ }
125+ src := FrameView{Rows: [][]renderer.Cell{
126+ {cell('a', renderer.Style{Foreground: 1}, false), cell('b', renderer.Style{Foreground: 2}, false)},
127+ {cell('c', renderer.Style{Foreground: 4}, false), cell('d', renderer.Style{Foreground: 5}, false)},
128+ }, Width: 2, Height: 2}
129+
130+ BlitFrame(dst, domain.Rect{X: -1, Y: 1, Width: 3, Height: 2}, src, VerticalAnchorTop)
131+
132+ assertRowEqual(t, dst, 0, []renderer.Cell{mark, mark, mark})
133+ assertRowEqual(t, dst, 1, []renderer.Cell{src.Rows[0][1], mark, mark})
134+ assertRowEqual(t, dst, 2, []renderer.Cell{src.Rows[1][1], mark, mark})
135+}
136+
137+func TestBlitFrameClipsEveryEdgeAndPreservesUntouchedSentinels(t *testing.T) {
138+ mark := cell('!', renderer.Style{Foreground: 3}, false)
139+ src := FrameView{Rows: [][]renderer.Cell{
140+ {cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), false), cell('c', renderer.DefaultStyle(), false)},
141+ {cell('d', renderer.DefaultStyle(), false), cell('e', renderer.DefaultStyle(), false), cell('f', renderer.DefaultStyle(), false)},
142+ {cell('g', renderer.DefaultStyle(), false), cell('h', renderer.DefaultStyle(), false), cell('i', renderer.DefaultStyle(), false)},
143+ }, Width: 3, Height: 3}
144+
145+ leftTop := markedFrame(3, 3, mark)
146+ BlitFrame(leftTop, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)
147+ assertRowEqual(t, leftTop, 0, []renderer.Cell{src.Rows[1][1], src.Rows[1][2], mark})
148+ assertRowEqual(t, leftTop, 1, []renderer.Cell{src.Rows[2][1], src.Rows[2][2], mark})
149+ assertRowEqual(t, leftTop, 2, []renderer.Cell{mark, mark, mark})
150+
151+ rightBottom := markedFrame(3, 3, mark)
152+ BlitFrame(rightBottom, domain.Rect{X: 2, Y: 2, Width: 3, Height: 3}, src, VerticalAnchorTop)
153+ assertRowEqual(t, rightBottom, 0, []renderer.Cell{mark, mark, mark})
154+ assertRowEqual(t, rightBottom, 1, []renderer.Cell{mark, mark, mark})
155+ assertRowEqual(t, rightBottom, 2, []renderer.Cell{mark, mark, src.Rows[0][0]})
156+}
157+
158+func TestBlitFrameInvalidNoOp(t *testing.T) {
159+ dst := renderer.NewFrame(2, 2)
160+ before := dst.Clone()
161+ cases := []struct {
162+ r domain.Rect
163+ s FrameView
164+ a VerticalAnchor
165+ }{
166+ {domain.Rect{}, FrameView{}, VerticalAnchorTop},
167+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},
168+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)},
169+ }
170+ for _, tc := range cases {
171+ BlitFrame(dst, tc.r, tc.s, tc.a)
172+ }
173+ if !framesEqual(dst, before) {
174+ t.Fatal("invalid blit changed destination")
175+ }
176+}
177+
178+func markedFrame(width, height int, mark renderer.Cell) renderer.Frame {
179+ frame := renderer.NewFrame(width, height)
180+ for y := range height {
181+ for x := range width {
182+ frame.Set(x, y, mark)
183+ }
184+ }
185+ return frame
186+}
187+
188+func assertRowEqual(t *testing.T, frame renderer.Frame, y int, want []renderer.Cell) {
189+ t.Helper()
190+ if len(want) != frame.Width {
191+ t.Fatalf("want row length %d, frame width %d", len(want), frame.Width)
192+ }
193+ for x := range want {
194+ if got := frame.At(x, y); !got.Equal(want[x]) {
195+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, got, want[x])
196+ }
197+ }
198+}
@@ -0,0 +1,198 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func cell(r rune, style renderer.Style, continuation bool) renderer.Cell {
11+ return renderer.Cell{Rune: r, Style: style, Continuation: continuation}
12+}
13+
14+func TestBlitFrameAnchorsAndClips(t *testing.T) {
15+ style := renderer.Style{Foreground: 1, Background: 2}
16+ tests := []struct {
17+ name string
18+ anchor VerticalAnchor
19+ srcH, srcW, rectX, rectY, rectW, rectH int
20+ want string
21+ }{
22+ {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"},
23+ {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"},
24+ {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "},
25+ {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)}
30+ for y := range src.Rows {
31+ src.Rows[y] = make([]renderer.Cell, tt.srcW)
32+ for x := range src.Rows[y] {
33+ src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, false)
34+ }
35+ }
36+ dst := renderer.NewFrame(5, 5)
37+ BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor)
38+ got := ""
39+ for y := 1; y < 3; y++ {
40+ for x := 1; x < 3; x++ {
41+ got += string(dst.At(x, y).Rune)
42+ }
43+ }
44+ if got != tt.want {
45+ t.Fatalf("got %q want %q", got, tt.want)
46+ }
47+ })
48+ }
49+}
50+
51+func TestBlitFrameNeverSplitsWidePairsAtClippedEdges(t *testing.T) {
52+ style := renderer.Style{Foreground: 7, Background: 8, Bold: true}
53+ wide := []renderer.Cell{
54+ cell('界', style, false),
55+ cell(0, style, true),
56+ cell('x', renderer.DefaultStyle(), false),
57+ }
58+ mark := cell('!', renderer.Style{Foreground: 3}, false)
59+ tests := []struct {
60+ name string
61+ rect domain.Rect
62+ dst renderer.Frame
63+ want []renderer.Cell
64+ }{
65+ {
66+ name: "left clipped continuation remains untouched",
67+ rect: domain.Rect{X: -1, Y: 0, Width: 3, Height: 1},
68+ dst: markedFrame(3, 1, mark),
69+ want: []renderer.Cell{mark, wide[2], mark},
70+ },
71+ {
72+ name: "right clipped leader remains untouched",
73+ rect: domain.Rect{X: 0, Y: 0, Width: 3, Height: 1},
74+ dst: markedFrame(1, 1, mark),
75+ want: []renderer.Cell{mark},
76+ },
77+ {
78+ name: "complete pair retains exact cells",
79+ rect: domain.Rect{X: 1, Y: 0, Width: 3, Height: 1},
80+ dst: markedFrame(4, 1, mark),
81+ want: []renderer.Cell{mark, wide[0], wide[1], wide[2]},
82+ },
83+ }
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ BlitFrame(tt.dst, tt.rect, FrameView{Rows: [][]renderer.Cell{wide}, Width: len(wide), Height: 1}, VerticalAnchorTop)
87+ assertRowEqual(t, tt.dst, 0, tt.want)
88+ })
89+ }
90+}
91+
92+func TestBlitFrameBoundsExtremeRectanglesToVisibleIntersection(t *testing.T) {
93+ maxInt := int(^uint(0) >> 1)
94+ minInt := -maxInt - 1
95+ style := renderer.Style{Foreground: 9}
96+ src := FrameView{Rows: [][]renderer.Cell{{cell('a', style, false)}}, Width: 1, Height: 1}
97+ mark := cell('!', renderer.Style{Background: 4}, false)
98+ tests := []struct {
99+ name string
100+ rect domain.Rect
101+ want []renderer.Cell
102+ }{
103+ {"huge visible width copies only source span", domain.Rect{X: 0, Y: 0, Width: maxInt, Height: maxInt}, []renderer.Cell{src.Rows[0][0], mark}},
104+ {"max coordinate overflow is outside frame", domain.Rect{X: maxInt - 1, Y: maxInt - 1, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
105+ {"min coordinate overflow is outside frame", domain.Rect{X: minInt, Y: minInt, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}},
106+ }
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ dst := markedFrame(2, 1, mark)
110+ BlitFrame(dst, tt.rect, src, VerticalAnchorTop)
111+ assertRowEqual(t, dst, 0, tt.want)
112+ })
113+ }
114+}
115+
116+func TestBlitFrameUsesLogicalDestinationRowsAndPreservesClippedSentinels(t *testing.T) {
117+ mark := cell('!', renderer.Style{Foreground: 3}, false)
118+ dst := renderer.NewFrame(3, 3)
119+ dst.ScrollUp(0, 2, 1)
120+ for y := range dst.Height {
121+ for x := range dst.Width {
122+ dst.Set(x, y, mark)
123+ }
124+ }
125+ src := FrameView{Rows: [][]renderer.Cell{
126+ {cell('a', renderer.Style{Foreground: 1}, false), cell('b', renderer.Style{Foreground: 2}, false)},
127+ {cell('c', renderer.Style{Foreground: 4}, false), cell('d', renderer.Style{Foreground: 5}, false)},
128+ }, Width: 2, Height: 2}
129+
130+ BlitFrame(dst, domain.Rect{X: -1, Y: 1, Width: 3, Height: 2}, src, VerticalAnchorTop)
131+
132+ assertRowEqual(t, dst, 0, []renderer.Cell{mark, mark, mark})
133+ assertRowEqual(t, dst, 1, []renderer.Cell{src.Rows[0][1], mark, mark})
134+ assertRowEqual(t, dst, 2, []renderer.Cell{src.Rows[1][1], mark, mark})
135+}
136+
137+func TestBlitFrameClipsEveryEdgeAndPreservesUntouchedSentinels(t *testing.T) {
138+ mark := cell('!', renderer.Style{Foreground: 3}, false)
139+ src := FrameView{Rows: [][]renderer.Cell{
140+ {cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), false), cell('c', renderer.DefaultStyle(), false)},
141+ {cell('d', renderer.DefaultStyle(), false), cell('e', renderer.DefaultStyle(), false), cell('f', renderer.DefaultStyle(), false)},
142+ {cell('g', renderer.DefaultStyle(), false), cell('h', renderer.DefaultStyle(), false), cell('i', renderer.DefaultStyle(), false)},
143+ }, Width: 3, Height: 3}
144+
145+ leftTop := markedFrame(3, 3, mark)
146+ BlitFrame(leftTop, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)
147+ assertRowEqual(t, leftTop, 0, []renderer.Cell{src.Rows[1][1], src.Rows[1][2], mark})
148+ assertRowEqual(t, leftTop, 1, []renderer.Cell{src.Rows[2][1], src.Rows[2][2], mark})
149+ assertRowEqual(t, leftTop, 2, []renderer.Cell{mark, mark, mark})
150+
151+ rightBottom := markedFrame(3, 3, mark)
152+ BlitFrame(rightBottom, domain.Rect{X: 2, Y: 2, Width: 3, Height: 3}, src, VerticalAnchorTop)
153+ assertRowEqual(t, rightBottom, 0, []renderer.Cell{mark, mark, mark})
154+ assertRowEqual(t, rightBottom, 1, []renderer.Cell{mark, mark, mark})
155+ assertRowEqual(t, rightBottom, 2, []renderer.Cell{mark, mark, src.Rows[0][0]})
156+}
157+
158+func TestBlitFrameInvalidNoOp(t *testing.T) {
159+ dst := renderer.NewFrame(2, 2)
160+ before := dst.Clone()
161+ cases := []struct {
162+ r domain.Rect
163+ s FrameView
164+ a VerticalAnchor
165+ }{
166+ {domain.Rect{}, FrameView{}, VerticalAnchorTop},
167+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},
168+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)},
169+ }
170+ for _, tc := range cases {
171+ BlitFrame(dst, tc.r, tc.s, tc.a)
172+ }
173+ if !framesEqual(dst, before) {
174+ t.Fatal("invalid blit changed destination")
175+ }
176+}
177+
178+func markedFrame(width, height int, mark renderer.Cell) renderer.Frame {
179+ frame := renderer.NewFrame(width, height)
180+ for y := range height {
181+ for x := range width {
182+ frame.Set(x, y, mark)
183+ }
184+ }
185+ return frame
186+}
187+
188+func assertRowEqual(t *testing.T, frame renderer.Frame, y int, want []renderer.Cell) {
189+ t.Helper()
190+ if len(want) != frame.Width {
191+ t.Fatalf("want row length %d, frame width %d", len(want), frame.Width)
192+ }
193+ for x := range want {
194+ if got := frame.At(x, y); !got.Equal(want[x]) {
195+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, got, want[x])
196+ }
197+ }
198+}
A internal/usecase/ui/separator.go +31 -0
@@ -0,0 +1,31 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// SeparatorOrientation selects the axis of a separator slice.
9+type SeparatorOrientation uint8
10+
11+const (
12+ SeparatorHorizontal SeparatorOrientation = iota
13+ SeparatorVertical
14+)
15+
16+// DrawSeparator draws a styled, clipped separator slice. It contains no layout
17+// or theme policy; callers provide both the rectangle and style.
18+func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) {
19+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 {
20+ return
21+ }
22+ var cell renderer.Cell
23+ switch orientation {
24+ case SeparatorHorizontal:
25+ cell = renderer.Cell{Rune: '─', Style: style}
26+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell)
27+ case SeparatorVertical:
28+ cell = renderer.Cell{Rune: '│', Style: style}
29+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell)
30+ }
31+}
@@ -0,0 +1,31 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// SeparatorOrientation selects the axis of a separator slice.
9+type SeparatorOrientation uint8
10+
11+const (
12+ SeparatorHorizontal SeparatorOrientation = iota
13+ SeparatorVertical
14+)
15+
16+// DrawSeparator draws a styled, clipped separator slice. It contains no layout
17+// or theme policy; callers provide both the rectangle and style.
18+func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) {
19+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 {
20+ return
21+ }
22+ var cell renderer.Cell
23+ switch orientation {
24+ case SeparatorHorizontal:
25+ cell = renderer.Cell{Rune: '─', Style: style}
26+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell)
27+ case SeparatorVertical:
28+ cell = renderer.Cell{Rune: '│', Style: style}
29+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell)
30+ }
31+}
A internal/usecase/ui/separator_test.go +83 -0
@@ -0,0 +1,83 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestDrawSeparator(t *testing.T) {
11+ style := renderer.DefaultStyle()
12+ style.Attrs = renderer.AttrDim
13+ cases := []struct {
14+ name string
15+ orientation SeparatorOrientation
16+ rune rune
17+ rect domain.Rect
18+ }{
19+ {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}},
20+ {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}},
21+ }
22+ for _, tc := range cases {
23+ t.Run(tc.name, func(t *testing.T) {
24+ f := renderer.NewFrame(6, 6)
25+ DrawSeparator(f, tc.rect, tc.orientation, style)
26+ for y := 0; y < 6; y++ {
27+ for x := 0; x < 6; x++ {
28+ want := renderer.BlankCell()
29+ if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) ||
30+ (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) {
31+ want = renderer.Cell{Rune: tc.rune, Style: style}
32+ }
33+ if !f.At(x, y).Equal(want) {
34+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want)
35+ }
36+ }
37+ }
38+ })
39+ }
40+}
41+
42+func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
43+ style := renderer.DefaultStyle()
44+ f := renderer.NewFrame(3, 3)
45+ for y := range f.Height {
46+ for x := range f.Width {
47+ f.Set(x, y, renderer.Cell{Rune: 'x', Style: style})
48+ }
49+ }
50+ DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style)
51+ for x := 0; x < 3; x++ {
52+ if f.At(x, 1).Rune != '─' {
53+ t.Fatalf("clipping failed at %d", x)
54+ }
55+ }
56+ before := f.Clone()
57+ for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} {
58+ DrawSeparator(f, rect, SeparatorHorizontal, style)
59+ }
60+ DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style)
61+ if !framesEqual(f, before) {
62+ t.Fatal("invalid rectangles/orientation changed frame")
63+ }
64+}
65+
66+func TestDrawSeparatorInvalidFrame(t *testing.T) {
67+ f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)}
68+ DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle())
69+}
70+
71+func framesEqual(a, b renderer.Frame) bool {
72+ if a.Width != b.Width || a.Height != b.Height {
73+ return false
74+ }
75+ for y := 0; y < a.Height; y++ {
76+ for x := 0; x < a.Width; x++ {
77+ if !a.At(x, y).Equal(b.At(x, y)) {
78+ return false
79+ }
80+ }
81+ }
82+ return true
83+}
@@ -0,0 +1,83 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestDrawSeparator(t *testing.T) {
11+ style := renderer.DefaultStyle()
12+ style.Attrs = renderer.AttrDim
13+ cases := []struct {
14+ name string
15+ orientation SeparatorOrientation
16+ rune rune
17+ rect domain.Rect
18+ }{
19+ {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}},
20+ {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}},
21+ }
22+ for _, tc := range cases {
23+ t.Run(tc.name, func(t *testing.T) {
24+ f := renderer.NewFrame(6, 6)
25+ DrawSeparator(f, tc.rect, tc.orientation, style)
26+ for y := 0; y < 6; y++ {
27+ for x := 0; x < 6; x++ {
28+ want := renderer.BlankCell()
29+ if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) ||
30+ (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) {
31+ want = renderer.Cell{Rune: tc.rune, Style: style}
32+ }
33+ if !f.At(x, y).Equal(want) {
34+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want)
35+ }
36+ }
37+ }
38+ })
39+ }
40+}
41+
42+func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
43+ style := renderer.DefaultStyle()
44+ f := renderer.NewFrame(3, 3)
45+ for y := range f.Height {
46+ for x := range f.Width {
47+ f.Set(x, y, renderer.Cell{Rune: 'x', Style: style})
48+ }
49+ }
50+ DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style)
51+ for x := 0; x < 3; x++ {
52+ if f.At(x, 1).Rune != '─' {
53+ t.Fatalf("clipping failed at %d", x)
54+ }
55+ }
56+ before := f.Clone()
57+ for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} {
58+ DrawSeparator(f, rect, SeparatorHorizontal, style)
59+ }
60+ DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style)
61+ if !framesEqual(f, before) {
62+ t.Fatal("invalid rectangles/orientation changed frame")
63+ }
64+}
65+
66+func TestDrawSeparatorInvalidFrame(t *testing.T) {
67+ f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)}
68+ DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle())
69+}
70+
71+func framesEqual(a, b renderer.Frame) bool {
72+ if a.Width != b.Width || a.Height != b.Height {
73+ return false
74+ }
75+ for y := 0; y < a.Height; y++ {
76+ for x := 0; x < a.Width; x++ {
77+ if !a.At(x, y).Equal(b.At(x, y)) {
78+ return false
79+ }
80+ }
81+ }
82+ return true
83+}

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