detached a50b4a9

feat(picker): style responsive session previews

commit
a50b4a9706c093e379e4dc992f52d3b1c9e1c9ad
author
brice · · 2026-07-14 11:23 UTC
parent
86f743f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +123 -1
M internal/usecase/daemon/picker_test.go +120 -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,124 @@ 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 TestPickerPreviewDoesNotCloneScrollback(t *testing.T) {
641+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
642+ p := tb.focusedPane()
643+ p.screen.Write([]byte(strings.Repeat("history-only-marker\n", 10_000)))
644+ p.screen.Write([]byte("\rNOW"))
645+
646+ preview := snapshotPickerPreview(tb)
647+
648+ require.Equal(t, 10, preview.Width)
649+ require.Equal(t, 3, preview.Height)
650+ require.Len(t, preview.Rows, preview.Height)
651+ cells := 0
652+ for _, row := range preview.Rows {
653+ require.Len(t, row, preview.Width)
654+ cells += len(row)
655+ }
656+ require.Equal(t, preview.Width*preview.Height, cells)
657+ got := strings.Join(func() []string {
658+ rows := make([]string, len(preview.Rows))
659+ for i, row := range preview.Rows {
660+ rows[i] = rowText(row)
661+ }
662+ return rows
663+ }(), "\n")
664+ require.Contains(t, got, "NOW")
665+ require.NotContains(t, got, "history-only-marker")
666+}
667+
548668 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
549669 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
550670 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,124 @@ 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 TestPickerPreviewDoesNotCloneScrollback(t *testing.T) {
641+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
642+ p := tb.focusedPane()
643+ p.screen.Write([]byte(strings.Repeat("history-only-marker\n", 10_000)))
644+ p.screen.Write([]byte("\rNOW"))
645+
646+ preview := snapshotPickerPreview(tb)
647+
648+ require.Equal(t, 10, preview.Width)
649+ require.Equal(t, 3, preview.Height)
650+ require.Len(t, preview.Rows, preview.Height)
651+ cells := 0
652+ for _, row := range preview.Rows {
653+ require.Len(t, row, preview.Width)
654+ cells += len(row)
655+ }
656+ require.Equal(t, preview.Width*preview.Height, cells)
657+ got := strings.Join(func() []string {
658+ rows := make([]string, len(preview.Rows))
659+ for i, row := range preview.Rows {
660+ rows[i] = rowText(row)
661+ }
662+ return rows
663+ }(), "\n")
664+ require.Contains(t, got, "NOW")
665+ require.NotContains(t, got, "history-only-marker")
666+}
667+
548 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {668 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
549 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})669 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
550 p := tb.focusedPane()670 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 {

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