feat(picker): style responsive session previews
- commit
- a50b4a9706c093e379e4dc992f52d3b1c9e1c9ad
- author
- brice · · 2026-07-14 11:23 UTC
- parent
- 86f743f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 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 | 20 | "github.com/bnema/vev/pkg/vt" | |
| 19 | 21 | ) | |
| 20 | 22 | ||
| @@ -545,6 +547,124 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||
| 545 | 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 | 668 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | |
| 549 | 669 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 550 | 670 | 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 { | |||
| 371 | 371 | pickerName renderer.Style | |
| 372 | 372 | pickerSelectionName renderer.Style | |
| 373 | 373 | pickerSelectionMuted renderer.Style | |
| 374 | + | pickerSeparator renderer.Style | |
| 374 | 375 | } | |
| 375 | 376 | ||
| 376 | 377 | func newThemeStyles(t themeui.Theme) themeStyles { | |
| @@ -393,6 +394,7 @@ func newThemeStyles(t themeui.Theme) themeStyles { | |||
| 393 | 394 | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |
| 394 | 395 | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |
| 395 | 396 | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |
| 397 | + | pickerSeparator: themeui.MutedTextStyle(t), | |
| 396 | 398 | } | |
| 397 | 399 | } | |
| 398 | 400 | ||
| @@ -371,6 +371,7 @@ type themeStyles struct { | |||||
| 371 | pickerName renderer.Style | 371 | pickerName renderer.Style | ||
| 372 | pickerSelectionName renderer.Style | 372 | pickerSelectionName renderer.Style | ||
| 373 | pickerSelectionMuted renderer.Style | 373 | 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 | |||
| 184 | 184 | } | |
| 185 | 185 | if snap.pickerActive && snap.pickerModel != nil { | |
| 186 | 186 | 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 | 188 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 189 | 189 | } | |
| 190 | 190 | 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 = pickerModal | 186 | 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 { | ||