feat(ui): dim pane content behind overlays
- commit
- ed1dd0a6002fa0b71fdab91676f5c11f6e91ae41
- author
- brice · · 2026-07-09 23:36 UTC
- committer
- brice · · 2026-07-09 23:55 UTC
- parent
- a72295f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +201 -0
A internal/usecase/daemon/overlay_backdrop.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // overlayBackdrop controls optional dimming of pane content behind an overlay. | |
| 10 | + | // Chrome (bars, dividers, and pane titles) is deliberately not part of the | |
| 11 | + | // backdrop. | |
| 12 | + | type overlayBackdrop struct { | |
| 13 | + | DimPaneContents bool | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // apply dims the content rectangles from a tab-content-relative layout | |
| 17 | + | // snapshot. A failed solve follows the tab renderer's focused-pane fallback by | |
| 18 | + | // dimming the complete content area. | |
| 19 | + | func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) { | |
| 20 | + | if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 { | |
| 21 | + | return | |
| 22 | + | } | |
| 23 | + | if !layoutSnap.ok { | |
| 24 | + | dimFrameRect(frame, contentArea, theme) | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | for _, placement := range layoutSnap.placements { | |
| 28 | + | if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 { | |
| 29 | + | continue | |
| 30 | + | } | |
| 31 | + | r := placement.Content | |
| 32 | + | r.X += contentArea.X | |
| 33 | + | r.Y += contentArea.Y | |
| 34 | + | dimFrameRect(frame, r, theme) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) { | |
| 39 | + | x0 := max(rect.X, 0) | |
| 40 | + | y0 := max(rect.Y, 0) | |
| 41 | + | x1 := min(rect.X+rect.Width, frame.Width) | |
| 42 | + | y1 := min(rect.Y+rect.Height, frame.Height) | |
| 43 | + | for y := y0; y < y1; y++ { | |
| 44 | + | for x := x0; x < x1; x++ { | |
| 45 | + | cell := frame.At(x, y) | |
| 46 | + | cell.Style = themeui.DimStyle(cell.Style, theme) | |
| 47 | + | frame.Set(x, y, cell) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // overlayBackdrop controls optional dimming of pane content behind an overlay. | |||
| 10 | + | // Chrome (bars, dividers, and pane titles) is deliberately not part of the | |||
| 11 | + | // backdrop. | |||
| 12 | + | type overlayBackdrop struct { | |||
| 13 | + | DimPaneContents bool | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // apply dims the content rectangles from a tab-content-relative layout | |||
| 17 | + | // snapshot. A failed solve follows the tab renderer's focused-pane fallback by | |||
| 18 | + | // dimming the complete content area. | |||
| 19 | + | func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) { | |||
| 20 | + | if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 { | |||
| 21 | + | return | |||
| 22 | + | } | |||
| 23 | + | if !layoutSnap.ok { | |||
| 24 | + | dimFrameRect(frame, contentArea, theme) | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | for _, placement := range layoutSnap.placements { | |||
| 28 | + | if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 { | |||
| 29 | + | continue | |||
| 30 | + | } | |||
| 31 | + | r := placement.Content | |||
| 32 | + | r.X += contentArea.X | |||
| 33 | + | r.Y += contentArea.Y | |||
| 34 | + | dimFrameRect(frame, r, theme) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) { | |||
| 39 | + | x0 := max(rect.X, 0) | |||
| 40 | + | y0 := max(rect.Y, 0) | |||
| 41 | + | x1 := min(rect.X+rect.Width, frame.Width) | |||
| 42 | + | y1 := min(rect.Y+rect.Height, frame.Height) | |||
| 43 | + | for y := y0; y < y1; y++ { | |||
| 44 | + | for x := x0; x < x1; x++ { | |||
| 45 | + | cell := frame.At(x, y) | |||
| 46 | + | cell.Style = themeui.DimStyle(cell.Style, theme) | |||
| 47 | + | frame.Set(x, y, cell) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | } | |||
A internal/usecase/daemon/overlay_backdrop_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func backdropTheme() themeui.Theme { | |
| 15 | + | return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) { | |
| 19 | + | frame := renderer.NewFrame(8, 6) | |
| 20 | + | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | |
| 21 | + | frame.Set(2, 2, original) | |
| 22 | + | frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | |
| 23 | + | frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title | |
| 24 | + | frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar | |
| 25 | + | frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar | |
| 26 | + | frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed | |
| 27 | + | ||
| 28 | + | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{ | |
| 29 | + | {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}}, | |
| 30 | + | {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}}, | |
| 31 | + | }} | |
| 32 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme()) | |
| 33 | + | ||
| 34 | + | require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin") | |
| 35 | + | require.True(t, frame.At(2, 2).Style.HasForegroundRGB) | |
| 36 | + | require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming") | |
| 37 | + | require.Zero(t, frame.At(3, 2).Rune) | |
| 38 | + | require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp") | |
| 39 | + | require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp") | |
| 40 | + | require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp") | |
| 41 | + | require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed") | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) { | |
| 45 | + | tests := []struct { | |
| 46 | + | name string | |
| 47 | + | content domain.Rect | |
| 48 | + | affected [][2]int | |
| 49 | + | unaffected [][2]int | |
| 50 | + | }{ | |
| 51 | + | { | |
| 52 | + | name: "negative origin", | |
| 53 | + | content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, | |
| 54 | + | affected: [][2]int{{0, 0}, {1, 1}}, | |
| 55 | + | unaffected: [][2]int{{2, 2}, {3, 3}}, | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | name: "right and bottom overflow", | |
| 59 | + | content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, | |
| 60 | + | affected: [][2]int{{2, 2}, {3, 3}}, | |
| 61 | + | unaffected: [][2]int{{0, 0}, {1, 1}}, | |
| 62 | + | }, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | for _, tt := range tests { | |
| 66 | + | t.Run(tt.name, func(t *testing.T) { | |
| 67 | + | frame := renderer.NewFrame(4, 4) | |
| 68 | + | original := renderer.DefaultStyle() | |
| 69 | + | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}} | |
| 70 | + | ||
| 71 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme()) | |
| 72 | + | ||
| 73 | + | for _, point := range tt.affected { | |
| 74 | + | require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style) | |
| 75 | + | } | |
| 76 | + | for _, point := range tt.unaffected { | |
| 77 | + | require.Equal(t, original, frame.At(point[0], point[1]).Style) | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestOverlayBackdropDisabledAndFallback(t *testing.T) { | |
| 84 | + | frame := renderer.NewFrame(4, 4) | |
| 85 | + | frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 86 | + | before := frame.Clone() | |
| 87 | + | area := domain.Rect{Y: 1, Width: 4, Height: 2} | |
| 88 | + | (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |
| 89 | + | require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default") | |
| 90 | + | ||
| 91 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |
| 92 | + | require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area") | |
| 93 | + | require.True(t, frame.At(3, 2).Style.HasForegroundRGB) | |
| 94 | + | require.False(t, frame.At(0, 0).Style.HasForegroundRGB) | |
| 95 | + | require.False(t, frame.At(0, 3).Style.HasForegroundRGB) | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func backdropTheme() themeui.Theme { | |||
| 15 | + | return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) { | |||
| 19 | + | frame := renderer.NewFrame(8, 6) | |||
| 20 | + | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | |||
| 21 | + | frame.Set(2, 2, original) | |||
| 22 | + | frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | |||
| 23 | + | frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title | |||
| 24 | + | frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar | |||
| 25 | + | frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar | |||
| 26 | + | frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed | |||
| 27 | + | ||||
| 28 | + | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{ | |||
| 29 | + | {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}}, | |||
| 30 | + | {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}}, | |||
| 31 | + | }} | |||
| 32 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme()) | |||
| 33 | + | ||||
| 34 | + | require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin") | |||
| 35 | + | require.True(t, frame.At(2, 2).Style.HasForegroundRGB) | |||
| 36 | + | require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming") | |||
| 37 | + | require.Zero(t, frame.At(3, 2).Rune) | |||
| 38 | + | require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp") | |||
| 39 | + | require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp") | |||
| 40 | + | require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp") | |||
| 41 | + | require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed") | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) { | |||
| 45 | + | tests := []struct { | |||
| 46 | + | name string | |||
| 47 | + | content domain.Rect | |||
| 48 | + | affected [][2]int | |||
| 49 | + | unaffected [][2]int | |||
| 50 | + | }{ | |||
| 51 | + | { | |||
| 52 | + | name: "negative origin", | |||
| 53 | + | content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, | |||
| 54 | + | affected: [][2]int{{0, 0}, {1, 1}}, | |||
| 55 | + | unaffected: [][2]int{{2, 2}, {3, 3}}, | |||
| 56 | + | }, | |||
| 57 | + | { | |||
| 58 | + | name: "right and bottom overflow", | |||
| 59 | + | content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, | |||
| 60 | + | affected: [][2]int{{2, 2}, {3, 3}}, | |||
| 61 | + | unaffected: [][2]int{{0, 0}, {1, 1}}, | |||
| 62 | + | }, | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | for _, tt := range tests { | |||
| 66 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 67 | + | frame := renderer.NewFrame(4, 4) | |||
| 68 | + | original := renderer.DefaultStyle() | |||
| 69 | + | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}} | |||
| 70 | + | ||||
| 71 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme()) | |||
| 72 | + | ||||
| 73 | + | for _, point := range tt.affected { | |||
| 74 | + | require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style) | |||
| 75 | + | } | |||
| 76 | + | for _, point := range tt.unaffected { | |||
| 77 | + | require.Equal(t, original, frame.At(point[0], point[1]).Style) | |||
| 78 | + | } | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestOverlayBackdropDisabledAndFallback(t *testing.T) { | |||
| 84 | + | frame := renderer.NewFrame(4, 4) | |||
| 85 | + | frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 86 | + | before := frame.Clone() | |||
| 87 | + | area := domain.Rect{Y: 1, Width: 4, Height: 2} | |||
| 88 | + | (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |||
| 89 | + | require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default") | |||
| 90 | + | ||||
| 91 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |||
| 92 | + | require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area") | |||
| 93 | + | require.True(t, frame.At(3, 2).Style.HasForegroundRGB) | |||
| 94 | + | require.False(t, frame.At(0, 0).Style.HasForegroundRGB) | |||
| 95 | + | require.False(t, frame.At(0, 3).Style.HasForegroundRGB) | |||
| 96 | + | } | |||
M internal/usecase/daemon/render.go +4 -0
| @@ -343,6 +343,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 343 | 343 | } else { | |
| 344 | 344 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |
| 345 | 345 | } | |
| 346 | + | if overlays.paletteActive { | |
| 347 | + | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |
| 348 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |
| 349 | + | } | |
| 346 | 350 | if overlays.copyActive { | |
| 347 | 351 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | |
| 348 | 352 | } | |
| @@ -343,6 +343,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 343 | } else { | 343 | } else { | ||
| 344 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | 344 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | ||
| 345 | } | 345 | } | ||
| 346 | + | if overlays.paletteActive { | |||
| 347 | + | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |||
| 348 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |||
| 349 | + | } | |||
| 346 | if overlays.copyActive { | 350 | if overlays.copyActive { | ||
| 347 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | 351 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | ||
| 348 | } | 352 | } | ||
M internal/usecase/daemon/render_test.go +51 -0
| @@ -22,8 +22,59 @@ import ( | |||
| 22 | 22 | "github.com/bnema/vev/internal/usecase/layout" | |
| 23 | 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 24 | 24 | "github.com/bnema/vev/pkg/renderer" | |
| 25 | + | "github.com/bnema/vev/pkg/vt" | |
| 25 | 26 | ) | |
| 26 | 27 | ||
| 28 | + | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |
| 29 | + | p, release := newBlockingPTY(t) | |
| 30 | + | defer release() | |
| 31 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 32 | + | ac.setTheme(backdropTheme()) | |
| 33 | + | client := vt.NewScreen(80, 25) | |
| 34 | + | pane := sess.tabs[0].focusedPane() | |
| 35 | + | pane.screen.Write([]byte("X")) | |
| 36 | + | ||
| 37 | + | d.enterPicker(sess, ac) | |
| 38 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 39 | + | pickerTitle := client.Frame.At(36, 2) | |
| 40 | + | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | |
| 41 | + | undimmedPane := client.Frame.At(0, 1) | |
| 42 | + | ||
| 43 | + | d.enterPalette(sess, ac) | |
| 44 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 45 | + | require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp") | |
| 46 | + | require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |
| 50 | + | p, release := newBlockingPTY(t) | |
| 51 | + | defer release() | |
| 52 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 53 | + | ac.setTheme(backdropTheme()) | |
| 54 | + | client := vt.NewScreen(80, 25) | |
| 55 | + | pane := sess.tabs[0].focusedPane() | |
| 56 | + | pane.screen.Write([]byte("X")) | |
| 57 | + | d.paint(sess, ac, true) | |
| 58 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 59 | + | undimmed := client.Frame.At(0, 1) | |
| 60 | + | topBar := client.Frame.At(0, 0) | |
| 61 | + | bottomBar := client.Frame.At(0, 24) | |
| 62 | + | ||
| 63 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 64 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 65 | + | dimmed := client.Frame.At(0, 1) | |
| 66 | + | require.Equal(t, 'X', dimmed.Rune) | |
| 67 | + | require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style") | |
| 68 | + | require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp") | |
| 69 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | |
| 70 | + | ||
| 71 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 72 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 73 | + | require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style") | |
| 74 | + | require.Equal(t, topBar, client.Frame.At(0, 0)) | |
| 75 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24)) | |
| 76 | + | } | |
| 77 | + | ||
| 27 | 78 | // --- test doubles ----------------------------------------------------------- | |
| 28 | 79 | ||
| 29 | 80 | type discardTransport struct{} | |
| @@ -22,8 +22,59 @@ import ( | |||||
| 22 | "github.com/bnema/vev/internal/usecase/layout" | 22 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 24 | "github.com/bnema/vev/pkg/renderer" | 24 | "github.com/bnema/vev/pkg/renderer" | ||
| 25 | + | "github.com/bnema/vev/pkg/vt" | |||
| 25 | ) | 26 | ) | ||
| 26 | 27 | ||||
| 28 | + | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |||
| 29 | + | p, release := newBlockingPTY(t) | |||
| 30 | + | defer release() | |||
| 31 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 32 | + | ac.setTheme(backdropTheme()) | |||
| 33 | + | client := vt.NewScreen(80, 25) | |||
| 34 | + | pane := sess.tabs[0].focusedPane() | |||
| 35 | + | pane.screen.Write([]byte("X")) | |||
| 36 | + | ||||
| 37 | + | d.enterPicker(sess, ac) | |||
| 38 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 39 | + | pickerTitle := client.Frame.At(36, 2) | |||
| 40 | + | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | |||
| 41 | + | undimmedPane := client.Frame.At(0, 1) | |||
| 42 | + | ||||
| 43 | + | d.enterPalette(sess, ac) | |||
| 44 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 45 | + | require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp") | |||
| 46 | + | require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 50 | + | p, release := newBlockingPTY(t) | |||
| 51 | + | defer release() | |||
| 52 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 53 | + | ac.setTheme(backdropTheme()) | |||
| 54 | + | client := vt.NewScreen(80, 25) | |||
| 55 | + | pane := sess.tabs[0].focusedPane() | |||
| 56 | + | pane.screen.Write([]byte("X")) | |||
| 57 | + | d.paint(sess, ac, true) | |||
| 58 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 59 | + | undimmed := client.Frame.At(0, 1) | |||
| 60 | + | topBar := client.Frame.At(0, 0) | |||
| 61 | + | bottomBar := client.Frame.At(0, 24) | |||
| 62 | + | ||||
| 63 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 64 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 65 | + | dimmed := client.Frame.At(0, 1) | |||
| 66 | + | require.Equal(t, 'X', dimmed.Rune) | |||
| 67 | + | require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style") | |||
| 68 | + | require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp") | |||
| 69 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | |||
| 70 | + | ||||
| 71 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 72 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 73 | + | require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style") | |||
| 74 | + | require.Equal(t, topBar, client.Frame.At(0, 0)) | |||
| 75 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24)) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 27 | // --- test doubles ----------------------------------------------------------- | 78 | // --- test doubles ----------------------------------------------------------- | ||
| 28 | 79 | ||||
| 29 | type discardTransport struct{} | 80 | type discardTransport struct{} | ||