Merge pull request #64 from bnema/feat/command-palette-ui
feat(palette): improve command palette UI
12 files · +377 -30
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/palette.go +17 -1
| @@ -9,8 +9,23 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | const ( | |
| 13 | + | paletteRailBreakpoint = 96 | |
| 14 | + | paletteRailWidth = 64 | |
| 15 | + | ) | |
| 16 | + | ||
| 12 | 17 | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1} | |
| 13 | 18 | ||
| 19 | + | func paletteModalFor(size domain.Size) ui.Modal { | |
| 20 | + | modal := paletteModal | |
| 21 | + | if size.Cols >= paletteRailBreakpoint { | |
| 22 | + | modal.FixedWidth = paletteRailWidth | |
| 23 | + | modal.HorizontalAnchor = ui.HorizontalAnchorRight | |
| 24 | + | modal.RightMargin = 1 | |
| 25 | + | } | |
| 26 | + | return modal | |
| 27 | + | } | |
| 28 | + | ||
| 14 | 29 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |
| 15 | 30 | d.closePalette(ac) | |
| 16 | 31 | ac.overlays.paletteMu.Lock() | |
| @@ -277,7 +292,8 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 277 | 292 | ||
| 278 | 293 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 279 | 294 | styleSet := resolveThemeStyles(styles) | |
| 280 | - | return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 295 | + | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}) | |
| 296 | + | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 281 | 297 | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | |
| 282 | 298 | }) | |
| 283 | 299 | } | |
| @@ -9,8 +9,23 @@ import ( | |||||
| 9 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | const ( | |||
| 13 | + | paletteRailBreakpoint = 96 | |||
| 14 | + | paletteRailWidth = 64 | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 12 | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1} | 17 | var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1} | ||
| 13 | 18 | ||||
| 19 | + | func paletteModalFor(size domain.Size) ui.Modal { | |||
| 20 | + | modal := paletteModal | |||
| 21 | + | if size.Cols >= paletteRailBreakpoint { | |||
| 22 | + | modal.FixedWidth = paletteRailWidth | |||
| 23 | + | modal.HorizontalAnchor = ui.HorizontalAnchorRight | |||
| 24 | + | modal.RightMargin = 1 | |||
| 25 | + | } | |||
| 26 | + | return modal | |||
| 27 | + | } | |||
| 28 | + | ||||
| 14 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | 29 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | ||
| 15 | d.closePalette(ac) | 30 | d.closePalette(ac) | ||
| 16 | ac.overlays.paletteMu.Lock() | 31 | ac.overlays.paletteMu.Lock() | ||
| @@ -277,7 +292,8 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 277 | 292 | ||||
| 278 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | 293 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | ||
| 279 | styleSet := resolveThemeStyles(styles) | 294 | styleSet := resolveThemeStyles(styles) | ||
| 280 | - | return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | 295 | + | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}) |
| 296 | + | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |||
| 281 | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | 297 | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | ||
| 282 | }) | 298 | }) | ||
| 283 | } | 299 | } | ||
M internal/usecase/daemon/palette_test.go +21 -8
| @@ -217,14 +217,27 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||
| 217 | 217 | } | |
| 218 | 218 | ||
| 219 | 219 | func TestPaletteModalGeometry(t *testing.T) { | |
| 220 | - | require.Equal(t, 100, paletteModal.WidthPct) | |
| 221 | - | require.Equal(t, 11, paletteModal.FixedHeight) | |
| 222 | - | require.Equal(t, ui.AnchorBottom, paletteModal.Anchor) | |
| 223 | - | require.Equal(t, 1, paletteModal.BottomMargin) | |
| 224 | - | require.Equal(t, 32, paletteModal.MinWidth) | |
| 225 | - | ||
| 226 | - | bounds := paletteModal.Bounds(domain.Size{Cols: 120, Rows: 40}) | |
| 227 | - | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 120, Height: 11}, bounds) | |
| 220 | + | tests := []struct { | |
| 221 | + | name string | |
| 222 | + | size domain.Size | |
| 223 | + | want domain.Rect | |
| 224 | + | }{ | |
| 225 | + | {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}}, | |
| 226 | + | {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}}, | |
| 227 | + | {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}}, | |
| 228 | + | {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}}, | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | for _, tt := range tests { | |
| 232 | + | t.Run(tt.name, func(t *testing.T) { | |
| 233 | + | modal := paletteModalFor(tt.size) | |
| 234 | + | require.Equal(t, tt.want, modal.Bounds(tt.size)) | |
| 235 | + | require.Equal(t, " Commands ", modal.Title) | |
| 236 | + | require.Equal(t, 11, modal.FixedHeight) | |
| 237 | + | require.Equal(t, ui.AnchorBottom, modal.Anchor) | |
| 238 | + | require.Equal(t, 1, modal.BottomMargin) | |
| 239 | + | }) | |
| 240 | + | } | |
| 228 | 241 | } | |
| 229 | 242 | ||
| 230 | 243 | func TestPaletteUTF8PendingCompletesFilter(t *testing.T) { | |
| @@ -217,14 +217,27 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||||
| 217 | } | 217 | } | ||
| 218 | 218 | ||||
| 219 | func TestPaletteModalGeometry(t *testing.T) { | 219 | func TestPaletteModalGeometry(t *testing.T) { | ||
| 220 | - | require.Equal(t, 100, paletteModal.WidthPct) | 220 | + | tests := []struct { |
| 221 | - | require.Equal(t, 11, paletteModal.FixedHeight) | 221 | + | name string |
| 222 | - | require.Equal(t, ui.AnchorBottom, paletteModal.Anchor) | 222 | + | size domain.Size |
| 223 | - | require.Equal(t, 1, paletteModal.BottomMargin) | 223 | + | want domain.Rect |
| 224 | - | require.Equal(t, 32, paletteModal.MinWidth) | 224 | + | }{ |
| 225 | - | 225 | + | {name: "95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}}, | |
| 226 | - | bounds := paletteModal.Bounds(domain.Size{Cols: 120, Rows: 40}) | 226 | + | {name: "96 column rail", size: domain.Size{Cols: 96, Rows: 40}, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}}, |
| 227 | - | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 120, Height: 11}, bounds) | 227 | + | {name: "120 column rail", size: domain.Size{Cols: 120, Rows: 40}, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}}, |
| 228 | + | {name: "tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}}, | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | for _, tt := range tests { | |||
| 232 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 233 | + | modal := paletteModalFor(tt.size) | |||
| 234 | + | require.Equal(t, tt.want, modal.Bounds(tt.size)) | |||
| 235 | + | require.Equal(t, " Commands ", modal.Title) | |||
| 236 | + | require.Equal(t, 11, modal.FixedHeight) | |||
| 237 | + | require.Equal(t, ui.AnchorBottom, modal.Anchor) | |||
| 238 | + | require.Equal(t, 1, modal.BottomMargin) | |||
| 239 | + | }) | |||
| 240 | + | } | |||
| 228 | } | 241 | } | ||
| 229 | 242 | ||||
| 230 | func TestPaletteUTF8PendingCompletesFilter(t *testing.T) { | 243 | func TestPaletteUTF8PendingCompletesFilter(t *testing.T) { | ||
M internal/usecase/daemon/render.go +4 -0
| @@ -346,6 +346,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 346 | 346 | if overlays.copyActive { | |
| 347 | 347 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | |
| 348 | 348 | } | |
| 349 | + | if overlays.paletteActive { | |
| 350 | + | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |
| 351 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |
| 352 | + | } | |
| 349 | 353 | if overlays.copySearchModel != nil { | |
| 350 | 354 | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | |
| 351 | 355 | } | |
| @@ -346,6 +346,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 346 | if overlays.copyActive { | 346 | if overlays.copyActive { | ||
| 347 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | 347 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | ||
| 348 | } | 348 | } | ||
| 349 | + | if overlays.paletteActive { | |||
| 350 | + | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |||
| 351 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |||
| 352 | + | } | |||
| 349 | if overlays.copySearchModel != nil { | 353 | if overlays.copySearchModel != nil { | ||
| 350 | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | 354 | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | ||
| 351 | } | 355 | } | ||
M internal/usecase/daemon/render_test.go +81 -0
| @@ -22,8 +22,89 @@ 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 TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |
| 29 | + | p, release := newBlockingPTY(t) | |
| 30 | + | defer release() | |
| 31 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 32 | + | theme := backdropTheme() | |
| 33 | + | ac.setTheme(theme) | |
| 34 | + | client := vt.NewScreen(80, 25) | |
| 35 | + | pane := sess.tabs[0].focusedPane() | |
| 36 | + | pane.screen.Write([]byte("\x1b[38;2;180;90;30mX")) | |
| 37 | + | ||
| 38 | + | d.enterCopyMode(sess, ac) | |
| 39 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 40 | + | undimmed := client.Frame.At(0, 1) | |
| 41 | + | require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content") | |
| 42 | + | copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...) | |
| 43 | + | require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar") | |
| 44 | + | ||
| 45 | + | d.enterPalette(sess, ac) | |
| 46 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 47 | + | dimmed := client.Frame.At(0, 1) | |
| 48 | + | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | |
| 49 | + | require.Equal(t, themeui.DimStyle(undimmed.Style, theme), dimmed.Style, "palette backdrop must dim the composed copy frame") | |
| 50 | + | require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp") | |
| 51 | + | paletteVisible := false | |
| 52 | + | for y := range client.Frame.Height { | |
| 53 | + | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | |
| 54 | + | } | |
| 55 | + | require.True(t, paletteVisible, "palette must remain composed above copy mode") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |
| 59 | + | p, release := newBlockingPTY(t) | |
| 60 | + | defer release() | |
| 61 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 62 | + | ac.setTheme(backdropTheme()) | |
| 63 | + | client := vt.NewScreen(80, 25) | |
| 64 | + | pane := sess.tabs[0].focusedPane() | |
| 65 | + | pane.screen.Write([]byte("X")) | |
| 66 | + | ||
| 67 | + | d.enterPicker(sess, ac) | |
| 68 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 69 | + | pickerTitle := client.Frame.At(36, 2) | |
| 70 | + | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | |
| 71 | + | undimmedPane := client.Frame.At(0, 1) | |
| 72 | + | ||
| 73 | + | d.enterPalette(sess, ac) | |
| 74 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 75 | + | require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp") | |
| 76 | + | require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |
| 80 | + | p, release := newBlockingPTY(t) | |
| 81 | + | defer release() | |
| 82 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 83 | + | ac.setTheme(backdropTheme()) | |
| 84 | + | client := vt.NewScreen(80, 25) | |
| 85 | + | pane := sess.tabs[0].focusedPane() | |
| 86 | + | pane.screen.Write([]byte("X")) | |
| 87 | + | d.paint(sess, ac, true) | |
| 88 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 89 | + | undimmed := client.Frame.At(0, 1) | |
| 90 | + | topBar := client.Frame.At(0, 0) | |
| 91 | + | bottomBar := client.Frame.At(0, 24) | |
| 92 | + | ||
| 93 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 94 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 95 | + | dimmed := client.Frame.At(0, 1) | |
| 96 | + | require.Equal(t, 'X', dimmed.Rune) | |
| 97 | + | require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style") | |
| 98 | + | require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp") | |
| 99 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | |
| 100 | + | ||
| 101 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 102 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 103 | + | require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style") | |
| 104 | + | require.Equal(t, topBar, client.Frame.At(0, 0)) | |
| 105 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24)) | |
| 106 | + | } | |
| 107 | + | ||
| 27 | 108 | // --- test doubles ----------------------------------------------------------- | |
| 28 | 109 | ||
| 29 | 110 | type discardTransport struct{} | |
| @@ -22,8 +22,89 @@ 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 TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||
| 29 | + | p, release := newBlockingPTY(t) | |||
| 30 | + | defer release() | |||
| 31 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 32 | + | theme := backdropTheme() | |||
| 33 | + | ac.setTheme(theme) | |||
| 34 | + | client := vt.NewScreen(80, 25) | |||
| 35 | + | pane := sess.tabs[0].focusedPane() | |||
| 36 | + | pane.screen.Write([]byte("\x1b[38;2;180;90;30mX")) | |||
| 37 | + | ||||
| 38 | + | d.enterCopyMode(sess, ac) | |||
| 39 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 40 | + | undimmed := client.Frame.At(0, 1) | |||
| 41 | + | require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content") | |||
| 42 | + | copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...) | |||
| 43 | + | require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar") | |||
| 44 | + | ||||
| 45 | + | d.enterPalette(sess, ac) | |||
| 46 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 47 | + | dimmed := client.Frame.At(0, 1) | |||
| 48 | + | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | |||
| 49 | + | require.Equal(t, themeui.DimStyle(undimmed.Style, theme), dimmed.Style, "palette backdrop must dim the composed copy frame") | |||
| 50 | + | require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp") | |||
| 51 | + | paletteVisible := false | |||
| 52 | + | for y := range client.Frame.Height { | |||
| 53 | + | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | |||
| 54 | + | } | |||
| 55 | + | require.True(t, paletteVisible, "palette must remain composed above copy mode") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |||
| 59 | + | p, release := newBlockingPTY(t) | |||
| 60 | + | defer release() | |||
| 61 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 62 | + | ac.setTheme(backdropTheme()) | |||
| 63 | + | client := vt.NewScreen(80, 25) | |||
| 64 | + | pane := sess.tabs[0].focusedPane() | |||
| 65 | + | pane.screen.Write([]byte("X")) | |||
| 66 | + | ||||
| 67 | + | d.enterPicker(sess, ac) | |||
| 68 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 69 | + | pickerTitle := client.Frame.At(36, 2) | |||
| 70 | + | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | |||
| 71 | + | undimmedPane := client.Frame.At(0, 1) | |||
| 72 | + | ||||
| 73 | + | d.enterPalette(sess, ac) | |||
| 74 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 75 | + | require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp") | |||
| 76 | + | require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 80 | + | p, release := newBlockingPTY(t) | |||
| 81 | + | defer release() | |||
| 82 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 83 | + | ac.setTheme(backdropTheme()) | |||
| 84 | + | client := vt.NewScreen(80, 25) | |||
| 85 | + | pane := sess.tabs[0].focusedPane() | |||
| 86 | + | pane.screen.Write([]byte("X")) | |||
| 87 | + | d.paint(sess, ac, true) | |||
| 88 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 89 | + | undimmed := client.Frame.At(0, 1) | |||
| 90 | + | topBar := client.Frame.At(0, 0) | |||
| 91 | + | bottomBar := client.Frame.At(0, 24) | |||
| 92 | + | ||||
| 93 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 94 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 95 | + | dimmed := client.Frame.At(0, 1) | |||
| 96 | + | require.Equal(t, 'X', dimmed.Rune) | |||
| 97 | + | require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style") | |||
| 98 | + | require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp") | |||
| 99 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | |||
| 100 | + | ||||
| 101 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 102 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 103 | + | require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style") | |||
| 104 | + | require.Equal(t, topBar, client.Frame.At(0, 0)) | |||
| 105 | + | require.Equal(t, bottomBar, client.Frame.At(0, 24)) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 27 | // --- test doubles ----------------------------------------------------------- | 108 | // --- test doubles ----------------------------------------------------------- | ||
| 28 | 109 | ||||
| 29 | type discardTransport struct{} | 110 | type discardTransport struct{} | ||
M internal/usecase/palette/fuzzy.go +1 -1
| @@ -63,7 +63,7 @@ func score(cmd command.Command, query string, order int) (Match, bool) { | |||
| 63 | 63 | if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok { | |
| 64 | 64 | return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |
| 65 | 65 | } | |
| 66 | - | text := strings.ToLower(cmd.Name + " " + cmd.Desc) | |
| 66 | + | text := strings.ToLower(cmd.Desc) | |
| 67 | 67 | if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok { | |
| 68 | 68 | return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |
| 69 | 69 | } | |
| @@ -63,7 +63,7 @@ func score(cmd command.Command, query string, order int) (Match, bool) { | |||||
| 63 | if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok { | 63 | if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok { | ||
| 64 | return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | 64 | return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | ||
| 65 | } | 65 | } | ||
| 66 | - | text := strings.ToLower(cmd.Name + " " + cmd.Desc) | 66 | + | text := strings.ToLower(cmd.Desc) |
| 67 | if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok { | 67 | if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok { | ||
| 68 | return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | 68 | return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | ||
| 69 | } | 69 | } | ||
M internal/usecase/palette/fuzzy_test.go +19 -3
| @@ -19,16 +19,32 @@ func codes(matches []Match) []string { | |||
| 19 | 19 | return out | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | - | func TestFuzzyOrdersScoringTiers(t *testing.T) { | |
| 22 | + | func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) { | |
| 23 | 23 | matches := Fuzzy([]command.Command{ | |
| 24 | - | cmd("ZQQ", "alpha beta", "name subsequence"), | |
| 24 | + | cmd("ZQQ", "unused", "alpha beta"), | |
| 25 | 25 | cmd("XAB", "unused", "subsequence code"), | |
| 26 | 26 | cmd("ABX", "unused", "prefix code"), | |
| 27 | 27 | cmd("AB", "unused", "exact code"), | |
| 28 | 28 | }, "ab") | |
| 29 | 29 | ||
| 30 | 30 | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | |
| 31 | - | require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions") | |
| 31 | + | require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestFuzzyDoesNotMatchCommandNames(t *testing.T) { | |
| 35 | + | matches := Fuzzy([]command.Command{ | |
| 36 | + | cmd("CPY", "Create Alpha Beta", "Enter copy mode"), | |
| 37 | + | cmd("ZQQ", "unused", "Alpha Beta tools"), | |
| 38 | + | }, "ab") | |
| 39 | + | ||
| 40 | + | require.Equal(t, []string{"ZQQ"}, codes(matches)) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) { | |
| 44 | + | matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy") | |
| 45 | + | ||
| 46 | + | require.Len(t, matches, 1) | |
| 47 | + | require.Empty(t, matches[0].Positions) | |
| 32 | 48 | } | |
| 33 | 49 | ||
| 34 | 50 | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | |
| @@ -19,16 +19,32 @@ func codes(matches []Match) []string { | |||||
| 19 | return out | 19 | return out | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | - | func TestFuzzyOrdersScoringTiers(t *testing.T) { | 22 | + | func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) { |
| 23 | matches := Fuzzy([]command.Command{ | 23 | matches := Fuzzy([]command.Command{ | ||
| 24 | - | cmd("ZQQ", "alpha beta", "name subsequence"), | 24 | + | cmd("ZQQ", "unused", "alpha beta"), |
| 25 | cmd("XAB", "unused", "subsequence code"), | 25 | cmd("XAB", "unused", "subsequence code"), | ||
| 26 | cmd("ABX", "unused", "prefix code"), | 26 | cmd("ABX", "unused", "prefix code"), | ||
| 27 | cmd("AB", "unused", "exact code"), | 27 | cmd("AB", "unused", "exact code"), | ||
| 28 | }, "ab") | 28 | }, "ab") | ||
| 29 | 29 | ||||
| 30 | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | 30 | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | ||
| 31 | - | require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions") | 31 | + | require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions") |
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestFuzzyDoesNotMatchCommandNames(t *testing.T) { | |||
| 35 | + | matches := Fuzzy([]command.Command{ | |||
| 36 | + | cmd("CPY", "Create Alpha Beta", "Enter copy mode"), | |||
| 37 | + | cmd("ZQQ", "unused", "Alpha Beta tools"), | |||
| 38 | + | }, "ab") | |||
| 39 | + | ||||
| 40 | + | require.Equal(t, []string{"ZQQ"}, codes(matches)) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) { | |||
| 44 | + | matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy") | |||
| 45 | + | ||||
| 46 | + | require.Len(t, matches, 1) | |||
| 47 | + | require.Empty(t, matches[0].Positions) | |||
| 32 | } | 48 | } | ||
| 33 | 49 | ||||
| 34 | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | 50 | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | ||
M internal/usecase/palette/model.go +0 -1
| @@ -174,7 +174,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 174 | 174 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | |
| 175 | 175 | x++ | |
| 176 | 176 | } | |
| 177 | - | x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style) | |
| 178 | 177 | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | |
| 179 | 178 | } | |
| 180 | 179 | return frame | |
| @@ -174,7 +174,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||||
| 174 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | 174 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | ||
| 175 | x++ | 175 | x++ | ||
| 176 | } | 176 | } | ||
| 177 | - | x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style) | |||
| 178 | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | 177 | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | ||
| 179 | } | 178 | } | ||
| 180 | return frame | 179 | return frame | ||
M internal/usecase/palette/model_test.go +31 -7
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package palette | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| @@ -66,7 +67,7 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) { | |||
| 66 | 67 | require.Equal(t, []int{0}, fresh[0].Positions) | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | - | func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |
| 70 | + | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |
| 70 | 71 | m := New([]command.Command{ | |
| 71 | 72 | cmd("CPY", "Copy", "Enter copy mode"), | |
| 72 | 73 | cmd("CNT", "New", "Create tab"), | |
| @@ -81,15 +82,38 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||
| 81 | 82 | require.Equal(t, 'y', frame.At(3, 0).Rune) | |
| 82 | 83 | require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query") | |
| 83 | 84 | ||
| 84 | - | require.Equal(t, 'C', frame.At(0, 1).Rune) | |
| 85 | - | require.Equal(t, 'P', frame.At(1, 1).Rune) | |
| 86 | - | require.Equal(t, 'Y', frame.At(2, 1).Rune) | |
| 85 | + | require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1)) | |
| 86 | + | require.NotContains(t, frameRow(frame, 1), "Copy") | |
| 87 | + | require.NotContains(t, frameRow(frame, 1), "—") | |
| 87 | 88 | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | |
| 88 | 89 | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | |
| 89 | 90 | require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold") | |
| 90 | 91 | require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold") | |
| 91 | 92 | require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold") | |
| 92 | - | require.True(t, frame.At(13, 1).Style.Italic, "description is italic") | |
| 93 | + | require.True(t, frame.At(4, 1).Style.Italic, "description is italic") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | |
| 97 | + | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |
| 98 | + | want := []rune("CPY Enter copy mode") | |
| 99 | + | ||
| 100 | + | for _, cols := range []int{0, 1, 3, 4, 7} { | |
| 101 | + | t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) { | |
| 102 | + | frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles()) | |
| 103 | + | require.Equal(t, cols, frame.Width) | |
| 104 | + | if cols > 0 { | |
| 105 | + | require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1))) | |
| 106 | + | } | |
| 107 | + | }) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func frameRow(frame renderer.Frame, y int) string { | |
| 112 | + | row := make([]rune, frame.Width) | |
| 113 | + | for x := range frame.Width { | |
| 114 | + | row[x] = frame.At(x, y).Rune | |
| 115 | + | } | |
| 116 | + | return string(row) | |
| 93 | 117 | } | |
| 94 | 118 | ||
| 95 | 119 | func TestRenderUsesConfiguredStyles(t *testing.T) { | |
| @@ -128,7 +152,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 128 | 152 | styles.Description = muted | |
| 129 | 153 | return styles | |
| 130 | 154 | }, | |
| 131 | - | x: 13, | |
| 155 | + | x: 4, | |
| 132 | 156 | assert: func(t *testing.T, style renderer.Style) { | |
| 133 | 157 | require.True(t, style.Italic) | |
| 134 | 158 | require.True(t, style.HasForegroundRGB) | |
| @@ -143,7 +167,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 143 | 167 | styles.Description.Italic = false | |
| 144 | 168 | return styles | |
| 145 | 169 | }, | |
| 146 | - | x: 13, | |
| 170 | + | x: 4, | |
| 147 | 171 | assert: func(t *testing.T, style renderer.Style) { | |
| 148 | 172 | require.False(t, style.Italic) | |
| 149 | 173 | }, | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package palette | 1 | package palette | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| @@ -66,7 +67,7 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) { | |||||
| 66 | require.Equal(t, []int{0}, fresh[0].Positions) | 67 | require.Equal(t, []int{0}, fresh[0].Positions) | ||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | - | func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | 70 | + | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { |
| 70 | m := New([]command.Command{ | 71 | m := New([]command.Command{ | ||
| 71 | cmd("CPY", "Copy", "Enter copy mode"), | 72 | cmd("CPY", "Copy", "Enter copy mode"), | ||
| 72 | cmd("CNT", "New", "Create tab"), | 73 | cmd("CNT", "New", "Create tab"), | ||
| @@ -81,15 +82,38 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||||
| 81 | require.Equal(t, 'y', frame.At(3, 0).Rune) | 82 | require.Equal(t, 'y', frame.At(3, 0).Rune) | ||
| 82 | require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query") | 83 | require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query") | ||
| 83 | 84 | ||||
| 84 | - | require.Equal(t, 'C', frame.At(0, 1).Rune) | 85 | + | require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1)) |
| 85 | - | require.Equal(t, 'P', frame.At(1, 1).Rune) | 86 | + | require.NotContains(t, frameRow(frame, 1), "Copy") |
| 86 | - | require.Equal(t, 'Y', frame.At(2, 1).Rune) | 87 | + | require.NotContains(t, frameRow(frame, 1), "—") |
| 87 | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | 88 | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | ||
| 88 | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | 89 | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | ||
| 89 | require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold") | 90 | require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold") | ||
| 90 | require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold") | 91 | require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold") | ||
| 91 | require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold") | 92 | require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold") | ||
| 92 | - | require.True(t, frame.At(13, 1).Style.Italic, "description is italic") | 93 | + | require.True(t, frame.At(4, 1).Style.Italic, "description is italic") |
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | |||
| 97 | + | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |||
| 98 | + | want := []rune("CPY Enter copy mode") | |||
| 99 | + | ||||
| 100 | + | for _, cols := range []int{0, 1, 3, 4, 7} { | |||
| 101 | + | t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) { | |||
| 102 | + | frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles()) | |||
| 103 | + | require.Equal(t, cols, frame.Width) | |||
| 104 | + | if cols > 0 { | |||
| 105 | + | require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1))) | |||
| 106 | + | } | |||
| 107 | + | }) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func frameRow(frame renderer.Frame, y int) string { | |||
| 112 | + | row := make([]rune, frame.Width) | |||
| 113 | + | for x := range frame.Width { | |||
| 114 | + | row[x] = frame.At(x, y).Rune | |||
| 115 | + | } | |||
| 116 | + | return string(row) | |||
| 93 | } | 117 | } | ||
| 94 | 118 | ||||
| 95 | func TestRenderUsesConfiguredStyles(t *testing.T) { | 119 | func TestRenderUsesConfiguredStyles(t *testing.T) { | ||
| @@ -128,7 +152,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 128 | styles.Description = muted | 152 | styles.Description = muted | ||
| 129 | return styles | 153 | return styles | ||
| 130 | }, | 154 | }, | ||
| 131 | - | x: 13, | 155 | + | x: 4, |
| 132 | assert: func(t *testing.T, style renderer.Style) { | 156 | assert: func(t *testing.T, style renderer.Style) { | ||
| 133 | require.True(t, style.Italic) | 157 | require.True(t, style.Italic) | ||
| 134 | require.True(t, style.HasForegroundRGB) | 158 | require.True(t, style.HasForegroundRGB) | ||
| @@ -143,7 +167,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 143 | styles.Description.Italic = false | 167 | styles.Description.Italic = false | ||
| 144 | return styles | 168 | return styles | ||
| 145 | }, | 169 | }, | ||
| 146 | - | x: 13, | 170 | + | x: 4, |
| 147 | assert: func(t *testing.T, style renderer.Style) { | 171 | assert: func(t *testing.T, style renderer.Style) { | ||
| 148 | require.False(t, style.Italic) | 172 | require.False(t, style.Italic) | ||
| 149 | }, | 173 | }, | ||
M internal/usecase/ui/modal.go +24 -9
| @@ -15,17 +15,29 @@ const ( | |||
| 15 | 15 | AnchorBottom | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | + | // HorizontalAnchor describes a modal's horizontal position within its base rectangle. | |
| 19 | + | type HorizontalAnchor int | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | // HorizontalAnchorCenter centers the modal within the base rectangle. | |
| 23 | + | HorizontalAnchorCenter HorizontalAnchor = iota | |
| 24 | + | // HorizontalAnchorRight positions the modal left of RightMargin columns. | |
| 25 | + | HorizontalAnchorRight | |
| 26 | + | ) | |
| 27 | + | ||
| 18 | 28 | // Modal describes a rectangular overlay. | |
| 19 | 29 | type Modal struct { | |
| 20 | - | WidthPct int | |
| 21 | - | HeightPct int | |
| 22 | - | MinWidth int | |
| 23 | - | MinHeight int | |
| 24 | - | Title string | |
| 25 | - | Anchor Anchor | |
| 26 | - | FixedWidth int | |
| 27 | - | FixedHeight int | |
| 28 | - | BottomMargin int | |
| 30 | + | WidthPct int | |
| 31 | + | HeightPct int | |
| 32 | + | MinWidth int | |
| 33 | + | MinHeight int | |
| 34 | + | Title string | |
| 35 | + | Anchor Anchor | |
| 36 | + | HorizontalAnchor HorizontalAnchor | |
| 37 | + | FixedWidth int | |
| 38 | + | FixedHeight int | |
| 39 | + | BottomMargin int | |
| 40 | + | RightMargin int | |
| 29 | 41 | } | |
| 30 | 42 | ||
| 31 | 43 | // Bounds returns the modal rectangle positioned within base and clamped to base. | |
| @@ -52,6 +64,9 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 52 | 64 | height = clamp(height, 0, base.Rows) | |
| 53 | 65 | ||
| 54 | 66 | x := (base.Cols - width) / 2 | |
| 67 | + | if m.HorizontalAnchor == HorizontalAnchorRight { | |
| 68 | + | x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width) | |
| 69 | + | } | |
| 55 | 70 | y := (base.Rows - height) / 2 | |
| 56 | 71 | if m.Anchor == AnchorBottom { | |
| 57 | 72 | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | |
| @@ -15,17 +15,29 @@ const ( | |||||
| 15 | AnchorBottom | 15 | AnchorBottom | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | + | // HorizontalAnchor describes a modal's horizontal position within its base rectangle. | |||
| 19 | + | type HorizontalAnchor int | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | // HorizontalAnchorCenter centers the modal within the base rectangle. | |||
| 23 | + | HorizontalAnchorCenter HorizontalAnchor = iota | |||
| 24 | + | // HorizontalAnchorRight positions the modal left of RightMargin columns. | |||
| 25 | + | HorizontalAnchorRight | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 18 | // Modal describes a rectangular overlay. | 28 | // Modal describes a rectangular overlay. | ||
| 19 | type Modal struct { | 29 | type Modal struct { | ||
| 20 | - | WidthPct int | 30 | + | WidthPct int |
| 21 | - | HeightPct int | 31 | + | HeightPct int |
| 22 | - | MinWidth int | 32 | + | MinWidth int |
| 23 | - | MinHeight int | 33 | + | MinHeight int |
| 24 | - | Title string | 34 | + | Title string |
| 25 | - | Anchor Anchor | 35 | + | Anchor Anchor |
| 26 | - | FixedWidth int | 36 | + | HorizontalAnchor HorizontalAnchor |
| 27 | - | FixedHeight int | 37 | + | FixedWidth int |
| 28 | - | BottomMargin int | 38 | + | FixedHeight int |
| 39 | + | BottomMargin int | |||
| 40 | + | RightMargin int | |||
| 29 | } | 41 | } | ||
| 30 | 42 | ||||
| 31 | // Bounds returns the modal rectangle positioned within base and clamped to base. | 43 | // Bounds returns the modal rectangle positioned within base and clamped to base. | ||
| @@ -52,6 +64,9 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||||
| 52 | height = clamp(height, 0, base.Rows) | 64 | height = clamp(height, 0, base.Rows) | ||
| 53 | 65 | ||||
| 54 | x := (base.Cols - width) / 2 | 66 | x := (base.Cols - width) / 2 | ||
| 67 | + | if m.HorizontalAnchor == HorizontalAnchorRight { | |||
| 68 | + | x = clamp(base.Cols-m.RightMargin-width, 0, base.Cols-width) | |||
| 69 | + | } | |||
| 55 | y := (base.Rows - height) / 2 | 70 | y := (base.Rows - height) / 2 | ||
| 56 | if m.Anchor == AnchorBottom { | 71 | if m.Anchor == AnchorBottom { | ||
| 57 | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | 72 | y = clamp(base.Rows-m.BottomMargin-height, 0, base.Rows-height) | ||
M internal/usecase/ui/modal_test.go +33 -0
| @@ -50,6 +50,39 @@ func TestModalBounds(t *testing.T) { | |||
| 50 | 50 | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | |
| 51 | 51 | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | |
| 52 | 52 | }, | |
| 53 | + | { | |
| 54 | + | name: "right anchor honors right margin", | |
| 55 | + | base: domain.Size{Cols: 100, Rows: 40}, | |
| 56 | + | m: Modal{ | |
| 57 | + | FixedWidth: 30, | |
| 58 | + | FixedHeight: 12, | |
| 59 | + | HorizontalAnchor: HorizontalAnchorRight, | |
| 60 | + | RightMargin: 3, | |
| 61 | + | }, | |
| 62 | + | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "right margin clamps to screen", | |
| 66 | + | base: domain.Size{Cols: 20, Rows: 10}, | |
| 67 | + | m: Modal{ | |
| 68 | + | FixedWidth: 8, | |
| 69 | + | FixedHeight: 4, | |
| 70 | + | HorizontalAnchor: HorizontalAnchorRight, | |
| 71 | + | RightMargin: 20, | |
| 72 | + | }, | |
| 73 | + | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "right anchored fixed dimensions clamp on small terminal", | |
| 77 | + | base: domain.Size{Cols: 10, Rows: 4}, | |
| 78 | + | m: Modal{ | |
| 79 | + | FixedWidth: 30, | |
| 80 | + | FixedHeight: 12, | |
| 81 | + | HorizontalAnchor: HorizontalAnchorRight, | |
| 82 | + | RightMargin: 3, | |
| 83 | + | }, | |
| 84 | + | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 85 | + | }, | |
| 53 | 86 | { | |
| 54 | 87 | name: "fixed dimensions clamp on small terminal", | |
| 55 | 88 | base: domain.Size{Cols: 10, Rows: 4}, | |
| @@ -50,6 +50,39 @@ func TestModalBounds(t *testing.T) { | |||||
| 50 | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | 50 | m: Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12}, | ||
| 51 | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | 51 | want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12}, | ||
| 52 | }, | 52 | }, | ||
| 53 | + | { | |||
| 54 | + | name: "right anchor honors right margin", | |||
| 55 | + | base: domain.Size{Cols: 100, Rows: 40}, | |||
| 56 | + | m: Modal{ | |||
| 57 | + | FixedWidth: 30, | |||
| 58 | + | FixedHeight: 12, | |||
| 59 | + | HorizontalAnchor: HorizontalAnchorRight, | |||
| 60 | + | RightMargin: 3, | |||
| 61 | + | }, | |||
| 62 | + | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | |||
| 63 | + | }, | |||
| 64 | + | { | |||
| 65 | + | name: "right margin clamps to screen", | |||
| 66 | + | base: domain.Size{Cols: 20, Rows: 10}, | |||
| 67 | + | m: Modal{ | |||
| 68 | + | FixedWidth: 8, | |||
| 69 | + | FixedHeight: 4, | |||
| 70 | + | HorizontalAnchor: HorizontalAnchorRight, | |||
| 71 | + | RightMargin: 20, | |||
| 72 | + | }, | |||
| 73 | + | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "right anchored fixed dimensions clamp on small terminal", | |||
| 77 | + | base: domain.Size{Cols: 10, Rows: 4}, | |||
| 78 | + | m: Modal{ | |||
| 79 | + | FixedWidth: 30, | |||
| 80 | + | FixedHeight: 12, | |||
| 81 | + | HorizontalAnchor: HorizontalAnchorRight, | |||
| 82 | + | RightMargin: 3, | |||
| 83 | + | }, | |||
| 84 | + | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |||
| 85 | + | }, | |||
| 53 | { | 86 | { | ||
| 54 | name: "fixed dimensions clamp on small terminal", | 87 | name: "fixed dimensions clamp on small terminal", | ||
| 55 | base: domain.Size{Cols: 10, Rows: 4}, | 88 | base: domain.Size{Cols: 10, Rows: 4}, | ||