Merge pull request #142 from bnema/feat/responsive-overlay-drawers
feat(ui): add responsive overlay drawers
26 files · +1712 -369
M docs/configuration.md +8 -2
| @@ -129,13 +129,19 @@ copy.word-separators = " -_@" | |||
| 129 | 129 | ||
| 130 | 130 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | |
| 131 | 131 | ||
| 132 | + | ## Responsive overlays | |
| 133 | + | ||
| 134 | + | On complete frames below 80 columns, interactive overlays use full-width bottom drawers immediately above the bottom bar. This applies to the floating terminal, command palette, session picker, notification history, prompts, and copy search. A drawer keeps its overlay's preferred outer height, capped at the frame height minus four rows, so frame rows 0–2 remain visible. Drawers use only a top border. | |
| 135 | + | ||
| 136 | + | Opening an interactive overlay dims the complete underlying frame, including panes, both bars, notices, and any lower-priority overlay. Notice toasts remain compact rather than becoming drawers. Copy mode remains full-screen; only its search prompt uses the responsive drawer. | |
| 137 | + | ||
| 132 | 138 | ## Palette anchor | |
| 133 | 139 | ||
| 134 | - | The command palette is centered by default. Set `palette.anchor` to another anchor to reposition it, or use `auto` for a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query. | |
| 140 | + | The command palette is centered by default. With `palette.anchor = auto`, it uses a full-width bottom shelf from 80 through 95 columns and a 64-column bottom-right rail from 96 columns up. Set an explicit anchor to position the shelf or rail. Below 80 columns, the palette is always a bottom drawer, so an explicit anchor does not override the responsive layout. Reload moves an open palette without losing your query. | |
| 135 | 141 | ||
| 136 | 142 | ## Floating terminal | |
| 137 | 143 | ||
| 138 | - | The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Floating state is not restored across daemon restarts. | |
| 144 | + | The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Below 80 columns, `floating.height` continues to determine the drawer's outer height, capped to preserve the first three frame rows and the bottom bar; `floating.width` is replaced by the full frame width. Floating state is not restored across daemon restarts. | |
| 139 | 145 | ||
| 140 | 146 | ## Bar anchors | |
| 141 | 147 | ||
| @@ -129,13 +129,19 @@ copy.word-separators = " -_@" | |||||
| 129 | 129 | ||||
| 130 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | 130 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | ||
| 131 | 131 | ||||
| 132 | + | ## Responsive overlays | |||
| 133 | + | ||||
| 134 | + | On complete frames below 80 columns, interactive overlays use full-width bottom drawers immediately above the bottom bar. This applies to the floating terminal, command palette, session picker, notification history, prompts, and copy search. A drawer keeps its overlay's preferred outer height, capped at the frame height minus four rows, so frame rows 0–2 remain visible. Drawers use only a top border. | |||
| 135 | + | ||||
| 136 | + | Opening an interactive overlay dims the complete underlying frame, including panes, both bars, notices, and any lower-priority overlay. Notice toasts remain compact rather than becoming drawers. Copy mode remains full-screen; only its search prompt uses the responsive drawer. | |||
| 137 | + | ||||
| 132 | ## Palette anchor | 138 | ## Palette anchor | ||
| 133 | 139 | ||||
| 134 | - | The command palette is centered by default. Set `palette.anchor` to another anchor to reposition it, or use `auto` for a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query. | 140 | + | The command palette is centered by default. With `palette.anchor = auto`, it uses a full-width bottom shelf from 80 through 95 columns and a 64-column bottom-right rail from 96 columns up. Set an explicit anchor to position the shelf or rail. Below 80 columns, the palette is always a bottom drawer, so an explicit anchor does not override the responsive layout. Reload moves an open palette without losing your query. |
| 135 | 141 | ||||
| 136 | ## Floating terminal | 142 | ## Floating terminal | ||
| 137 | 143 | ||||
| 138 | - | The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Floating state is not restored across daemon restarts. | 144 | + | The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Below 80 columns, `floating.height` continues to determine the drawer's outer height, capped to preserve the first three frame rows and the bottom bar; `floating.width` is replaced by the full frame width. Floating state is not restored across daemon restarts. |
| 139 | 145 | ||||
| 140 | ## Bar anchors | 146 | ## Bar anchors | ||
| 141 | 147 | ||||
M internal/usecase/daemon/copymode_test.go +1 -1
| @@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil } | |||
| 100 | 100 | func TestCopySearchModalGeometry(t *testing.T) { | |
| 101 | 101 | base := domain.Size{Cols: 100, Rows: 40} | |
| 102 | 102 | ||
| 103 | - | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base)) | |
| 103 | + | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds) | |
| 104 | 104 | require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor) | |
| 105 | 105 | require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins) | |
| 106 | 106 | } | |
| @@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil } | |||||
| 100 | func TestCopySearchModalGeometry(t *testing.T) { | 100 | func TestCopySearchModalGeometry(t *testing.T) { | ||
| 101 | base := domain.Size{Cols: 100, Rows: 40} | 101 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 102 | 102 | ||||
| 103 | - | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base)) | 103 | + | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds) |
| 104 | require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor) | 104 | require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor) | ||
| 105 | require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins) | 105 | require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins) | ||
| 106 | } | 106 | } | ||
M internal/usecase/daemon/floating.go +3 -7
| @@ -136,7 +136,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, | |||
| 136 | 136 | p.mu.Lock() | |
| 137 | 137 | geometry := p.committedFloatingGeometryLocked(desired) | |
| 138 | 138 | p.mu.Unlock() | |
| 139 | - | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |
| 139 | + | if !geometry.committable() { | |
| 140 | 140 | return nil, floatingGeometry{}, false | |
| 141 | 141 | } | |
| 142 | 142 | return p, geometry, true | |
| @@ -316,11 +316,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 316 | 316 | sess.mu.Unlock() | |
| 317 | 317 | tb.mu.Lock() | |
| 318 | 318 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | |
| 319 | - | size := rectSize(geometry.Inner) | |
| 320 | - | if !size.Valid() { | |
| 321 | - | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | |
| 322 | - | size = rectSize(geometry.Inner) | |
| 323 | - | } | |
| 319 | + | size := rectSize(geometry.ptyRect()) | |
| 324 | 320 | focused := tb.focusedPane() | |
| 325 | 321 | tabStableID, tabCtx := tb.stableID, tb.ctx | |
| 326 | 322 | tb.mu.Unlock() | |
| @@ -385,7 +381,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 385 | 381 | return | |
| 386 | 382 | } | |
| 387 | 383 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | |
| 388 | - | p.rect = spec.geometry.Inner | |
| 384 | + | p.rect = spec.geometry.ptyRect() | |
| 389 | 385 | p.popupGeometry = spec.geometry | |
| 390 | 386 | p.title.displayFallback = spec.fallback | |
| 391 | 387 | // CommandContext owns the child through openCtx, so retain both this context | |
| @@ -136,7 +136,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, | |||||
| 136 | p.mu.Lock() | 136 | p.mu.Lock() | ||
| 137 | geometry := p.committedFloatingGeometryLocked(desired) | 137 | geometry := p.committedFloatingGeometryLocked(desired) | ||
| 138 | p.mu.Unlock() | 138 | p.mu.Unlock() | ||
| 139 | - | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | 139 | + | if !geometry.committable() { |
| 140 | return nil, floatingGeometry{}, false | 140 | return nil, floatingGeometry{}, false | ||
| 141 | } | 141 | } | ||
| 142 | return p, geometry, true | 142 | return p, geometry, true | ||
| @@ -316,11 +316,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 316 | sess.mu.Unlock() | 316 | sess.mu.Unlock() | ||
| 317 | tb.mu.Lock() | 317 | tb.mu.Lock() | ||
| 318 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | 318 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | ||
| 319 | - | size := rectSize(geometry.Inner) | 319 | + | size := rectSize(geometry.ptyRect()) |
| 320 | - | if !size.Valid() { | |||
| 321 | - | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | |||
| 322 | - | size = rectSize(geometry.Inner) | |||
| 323 | - | } | |||
| 324 | focused := tb.focusedPane() | 320 | focused := tb.focusedPane() | ||
| 325 | tabStableID, tabCtx := tb.stableID, tb.ctx | 321 | tabStableID, tabCtx := tb.stableID, tb.ctx | ||
| 326 | tb.mu.Unlock() | 322 | tb.mu.Unlock() | ||
| @@ -385,7 +381,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 385 | return | 381 | return | ||
| 386 | } | 382 | } | ||
| 387 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | 383 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | ||
| 388 | - | p.rect = spec.geometry.Inner | 384 | + | p.rect = spec.geometry.ptyRect() |
| 389 | p.popupGeometry = spec.geometry | 385 | p.popupGeometry = spec.geometry | ||
| 390 | p.title.displayFallback = spec.fallback | 386 | p.title.displayFallback = spec.fallback | ||
| 391 | // CommandContext owns the child through openCtx, so retain both this context | 387 | // CommandContext owns the child through openCtx, so retain both this context | ||
M internal/usecase/daemon/floating_render.go +191 -27
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | |
| 14 | 14 | // must not translate a composed or cached value again. | |
| 15 | 15 | type floatingGeometry struct { | |
| 16 | + | Mode ui.PresentationMode | |
| 16 | 17 | Bounds domain.Rect | |
| 17 | 18 | Inner domain.Rect | |
| 18 | 19 | } | |
| @@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool { | |||
| 21 | 22 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 25 | + | // committable accepts a drawer with no terminal content. Its presentation must | |
| 26 | + | // remain canonical even though the PTY and VT screen use a 1x1 fallback. | |
| 27 | + | func (g floatingGeometry) committable() bool { | |
| 28 | + | if g.Mode == ui.PresentationDrawer { | |
| 29 | + | return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0 | |
| 30 | + | } | |
| 31 | + | return g.valid() | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (g floatingGeometry) ptyRect() domain.Rect { | |
| 35 | + | if g.Inner.Width > 0 && g.Inner.Height > 0 { | |
| 36 | + | return g.Inner | |
| 37 | + | } | |
| 38 | + | return domain.Rect{Width: 1, Height: 1} | |
| 39 | + | } | |
| 40 | + | ||
| 24 | 41 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |
| 25 | 42 | g.Bounds.X += dx | |
| 26 | 43 | g.Bounds.Y += dy | |
| @@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |||
| 33 | 50 | // last committed. Newly constructed test panes fall back to the requested | |
| 34 | 51 | // geometry until their first resize. The caller holds p.mu. | |
| 35 | 52 | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | |
| 36 | - | if p.popupGeometry.valid() { | |
| 53 | + | if p.popupGeometry.committable() { | |
| 37 | 54 | return p.popupGeometry | |
| 38 | 55 | } | |
| 39 | 56 | return fallback | |
| @@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||
| 58 | 75 | return axis | |
| 59 | 76 | } | |
| 60 | 77 | ||
| 61 | - | // calculateContentFloatingGeometry returns origin-zero, tab-content-relative | |
| 62 | - | // coordinates for a percentage-sized popup. Launch sizing derives from Inner | |
| 63 | - | // too, so rendering and PTY geometry share the same per-axis percentage and | |
| 64 | - | // tiny-border rules. | |
| 65 | - | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 78 | + | // calculatePreferredFloatingGeometry retains the canonical percentage and | |
| 79 | + | // tiny-axis rules used by wide floating terminals. | |
| 80 | + | func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 66 | 81 | if !content.Valid() { | |
| 67 | 82 | return floatingGeometry{} | |
| 68 | 83 | } | |
| @@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||
| 75 | 90 | Height: y.BoundsSize, | |
| 76 | 91 | } | |
| 77 | 92 | return floatingGeometry{ | |
| 93 | + | Mode: ui.PresentationFloating, | |
| 78 | 94 | Bounds: bounds, | |
| 79 | 95 | Inner: domain.Rect{ | |
| 80 | 96 | X: bounds.X + x.BorderOffset, | |
| @@ -85,12 +101,33 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||
| 85 | 101 | } | |
| 86 | 102 | } | |
| 87 | 103 | ||
| 104 | + | // calculateContentFloatingGeometry returns canonical tab-content-relative | |
| 105 | + | // coordinates. Presentation resolution consumes complete-frame coordinates, | |
| 106 | + | // so the preferred geometry crosses the top-bar boundary exactly once in each | |
| 107 | + | // direction. | |
| 108 | + | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 109 | + | preferred := calculatePreferredFloatingGeometry(content, cfg) | |
| 110 | + | if !preferred.valid() { | |
| 111 | + | return floatingGeometry{} | |
| 112 | + | } | |
| 113 | + | framePreferred := preferred.translate(0, 1) | |
| 114 | + | presentation := ui.ResolvePresentation( | |
| 115 | + | domain.Size{Cols: content.Cols, Rows: content.Rows + 2}, | |
| 116 | + | framePreferred.Bounds, | |
| 117 | + | framePreferred.Inner, | |
| 118 | + | ) | |
| 119 | + | return floatingGeometry{ | |
| 120 | + | Mode: presentation.Mode, | |
| 121 | + | Bounds: presentation.Bounds, | |
| 122 | + | Inner: presentation.Inner, | |
| 123 | + | }.translate(0, -1) | |
| 124 | + | } | |
| 125 | + | ||
| 88 | 126 | type floatingComposeInput struct { | |
| 89 | 127 | baseFrame renderer.Frame | |
| 90 | 128 | baseDamage []renderer.Damage | |
| 91 | 129 | floating capturedFloatingRenderState | |
| 92 | 130 | content domain.Rect | |
| 93 | - | layout capturedTabLayout | |
| 94 | 131 | theme themeui.Theme | |
| 95 | 132 | borderMuted renderer.Style | |
| 96 | 133 | borderActive renderer.Style | |
| @@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 103 | 140 | baseDamage := input.baseDamage | |
| 104 | 141 | floating := input.floating | |
| 105 | 142 | content := input.content | |
| 106 | - | layoutSnap := input.layout | |
| 107 | 143 | theme := input.theme | |
| 108 | 144 | borderMuted := input.borderMuted | |
| 109 | 145 | borderActive := input.borderActive | |
| @@ -111,10 +147,9 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 111 | 147 | full := input.full | |
| 112 | 148 | ||
| 113 | 149 | frame := base.Clone() | |
| 114 | - | layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | |
| 115 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme) | |
| 150 | + | applyOverlayBackdrop(frame, theme) | |
| 116 | 151 | geometry := floating.geometry.translate(content.X, content.Y) | |
| 117 | - | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme)) | |
| 152 | + | blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame) | |
| 118 | 153 | damage := append([]renderer.Damage(nil), baseDamage...) | |
| 119 | 154 | for _, d := range floating.pane.damage { | |
| 120 | 155 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| @@ -125,39 +160,168 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 125 | 160 | if floating.focused { | |
| 126 | 161 | border = borderActive | |
| 127 | 162 | } | |
| 128 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, border) | |
| 163 | + | drawFloatingBorder(frame, geometry, floating.title, border) | |
| 129 | 164 | if full || popupChanged { | |
| 130 | 165 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 131 | 166 | } | |
| 132 | - | if titleChanged && geometry.Bounds.Height >= 3 { | |
| 133 | - | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | |
| 167 | + | hasTitleBorder := geometry.Bounds.Height >= 3 | |
| 168 | + | if geometry.Mode == ui.PresentationDrawer { | |
| 169 | + | hasTitleBorder = geometry.Bounds.Height > 0 | |
| 170 | + | } | |
| 171 | + | if titleChanged && hasTitleBorder { | |
| 172 | + | if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok { | |
| 173 | + | damage = append(damage, titleDamage) | |
| 174 | + | } | |
| 134 | 175 | } | |
| 135 | 176 | return frame, damage | |
| 136 | 177 | } | |
| 137 | 178 | ||
| 138 | - | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | |
| 179 | + | type floatingSpan struct { | |
| 180 | + | source, destination, length int | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // clippedFloatingSpan intersects a source span placed at origin with the | |
| 184 | + | // destination interval [0, limit). The source offset preserves pane cell | |
| 185 | + | // mapping when the retained committed geometry starts above or left of frame. | |
| 186 | + | func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) { | |
| 187 | + | if length <= 0 || limit <= 0 { | |
| 188 | + | return floatingSpan{}, false | |
| 189 | + | } | |
| 190 | + | if origin < 0 { | |
| 191 | + | if origin <= -length { | |
| 192 | + | return floatingSpan{}, false | |
| 193 | + | } | |
| 194 | + | trim := -origin | |
| 195 | + | length -= trim | |
| 196 | + | origin = 0 | |
| 197 | + | if length <= 0 { | |
| 198 | + | return floatingSpan{}, false | |
| 199 | + | } | |
| 200 | + | return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true | |
| 201 | + | } | |
| 202 | + | if origin >= limit { | |
| 203 | + | return floatingSpan{}, false | |
| 204 | + | } | |
| 205 | + | length = min(length, limit-origin) | |
| 206 | + | return floatingSpan{destination: origin, length: length}, length > 0 | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) { | |
| 210 | + | x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width) | |
| 211 | + | if !ok { | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height) | |
| 215 | + | if !ok { | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | for dy := range y.length { | |
| 219 | + | for dx := range x.length { | |
| 220 | + | dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy)) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) { | |
| 226 | + | if bounds.Y < 0 || bounds.Y >= frame.Height { | |
| 227 | + | return renderer.Damage{}, false | |
| 228 | + | } | |
| 229 | + | x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width) | |
| 230 | + | if !ok { | |
| 231 | + | return renderer.Damage{}, false | |
| 232 | + | } | |
| 233 | + | return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) { | |
| 237 | + | bounds := geometry.Bounds | |
| 139 | 238 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 140 | 239 | return | |
| 141 | 240 | } | |
| 241 | + | if geometry.Mode == ui.PresentationDrawer { | |
| 242 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style) | |
| 243 | + | drawFloatingTitle(frame, bounds, title, style) | |
| 244 | + | return | |
| 245 | + | } | |
| 142 | 246 | // Each axis omits its borders independently for tiny popups, matching | |
| 143 | 247 | // floatingInnerSize and leaving every cell available to the terminal. | |
| 248 | + | right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width) | |
| 249 | + | bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height) | |
| 144 | 250 | if bounds.Height >= 3 { | |
| 145 | - | for x := bounds.X; x < bounds.X+bounds.Width; x++ { | |
| 146 | - | frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style}) | |
| 147 | - | frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style}) | |
| 251 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style) | |
| 252 | + | if hasBottom { | |
| 253 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style) | |
| 148 | 254 | } | |
| 149 | 255 | } | |
| 150 | 256 | if bounds.Width >= 3 { | |
| 151 | - | for y := bounds.Y; y < bounds.Y+bounds.Height; y++ { | |
| 152 | - | frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style}) | |
| 153 | - | frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style}) | |
| 257 | + | drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style) | |
| 258 | + | if hasRight { | |
| 259 | + | drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style) | |
| 154 | 260 | } | |
| 155 | 261 | } | |
| 156 | - | if bounds.Width >= 3 && bounds.Height >= 3 { | |
| 157 | - | frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style}) | |
| 158 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style}) | |
| 159 | - | frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style}) | |
| 160 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style}) | |
| 161 | - | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | |
| 262 | + | if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom { | |
| 263 | + | setFloatingCell(frame, bounds.X, bounds.Y, '┌', style) | |
| 264 | + | setFloatingCell(frame, right, bounds.Y, '┐', style) | |
| 265 | + | setFloatingCell(frame, bounds.X, bottom, '└', style) | |
| 266 | + | setFloatingCell(frame, right, bottom, '┘', style) | |
| 267 | + | drawFloatingTitle(frame, bounds, title, style) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) { | |
| 272 | + | if y < 0 || y >= frame.Height { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | span, ok := clippedFloatingSpan(x, width, frame.Width) | |
| 276 | + | if !ok { | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | for dx := range span.length { | |
| 280 | + | frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style}) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) { | |
| 285 | + | if x < 0 || x >= frame.Width { | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | span, ok := clippedFloatingSpan(y, height, frame.Height) | |
| 289 | + | if !ok { | |
| 290 | + | return | |
| 291 | + | } | |
| 292 | + | for dy := range span.length { | |
| 293 | + | frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style}) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) { | |
| 298 | + | if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height { | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func floatingLastCoordinate(origin, size int) (int, bool) { | |
| 305 | + | if size <= 0 { | |
| 306 | + | return 0, false | |
| 307 | + | } | |
| 308 | + | last := origin + size - 1 | |
| 309 | + | if last < origin { | |
| 310 | + | return 0, false | |
| 311 | + | } | |
| 312 | + | return last, true | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | |
| 316 | + | const inset = 2 | |
| 317 | + | titleWidth := bounds.Width - 2*inset | |
| 318 | + | if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset { | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | titleX := bounds.X + inset | |
| 322 | + | span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width) | |
| 323 | + | if !ok { | |
| 324 | + | return | |
| 162 | 325 | } | |
| 326 | + | ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style) | |
| 163 | 327 | } | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | ||
| 14 | // must not translate a composed or cached value again. | 14 | // must not translate a composed or cached value again. | ||
| 15 | type floatingGeometry struct { | 15 | type floatingGeometry struct { | ||
| 16 | + | Mode ui.PresentationMode | |||
| 16 | Bounds domain.Rect | 17 | Bounds domain.Rect | ||
| 17 | Inner domain.Rect | 18 | Inner domain.Rect | ||
| 18 | } | 19 | } | ||
| @@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool { | |||||
| 21 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | 22 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 25 | + | // committable accepts a drawer with no terminal content. Its presentation must | |||
| 26 | + | // remain canonical even though the PTY and VT screen use a 1x1 fallback. | |||
| 27 | + | func (g floatingGeometry) committable() bool { | |||
| 28 | + | if g.Mode == ui.PresentationDrawer { | |||
| 29 | + | return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0 | |||
| 30 | + | } | |||
| 31 | + | return g.valid() | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (g floatingGeometry) ptyRect() domain.Rect { | |||
| 35 | + | if g.Inner.Width > 0 && g.Inner.Height > 0 { | |||
| 36 | + | return g.Inner | |||
| 37 | + | } | |||
| 38 | + | return domain.Rect{Width: 1, Height: 1} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 24 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | 41 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | ||
| 25 | g.Bounds.X += dx | 42 | g.Bounds.X += dx | ||
| 26 | g.Bounds.Y += dy | 43 | g.Bounds.Y += dy | ||
| @@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |||||
| 33 | // last committed. Newly constructed test panes fall back to the requested | 50 | // last committed. Newly constructed test panes fall back to the requested | ||
| 34 | // geometry until their first resize. The caller holds p.mu. | 51 | // geometry until their first resize. The caller holds p.mu. | ||
| 35 | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | 52 | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | ||
| 36 | - | if p.popupGeometry.valid() { | 53 | + | if p.popupGeometry.committable() { |
| 37 | return p.popupGeometry | 54 | return p.popupGeometry | ||
| 38 | } | 55 | } | ||
| 39 | return fallback | 56 | return fallback | ||
| @@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||||
| 58 | return axis | 75 | return axis | ||
| 59 | } | 76 | } | ||
| 60 | 77 | ||||
| 61 | - | // calculateContentFloatingGeometry returns origin-zero, tab-content-relative | 78 | + | // calculatePreferredFloatingGeometry retains the canonical percentage and |
| 62 | - | // coordinates for a percentage-sized popup. Launch sizing derives from Inner | 79 | + | // tiny-axis rules used by wide floating terminals. |
| 63 | - | // too, so rendering and PTY geometry share the same per-axis percentage and | 80 | + | func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { |
| 64 | - | // tiny-border rules. | |||
| 65 | - | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |||
| 66 | if !content.Valid() { | 81 | if !content.Valid() { | ||
| 67 | return floatingGeometry{} | 82 | return floatingGeometry{} | ||
| 68 | } | 83 | } | ||
| @@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||||
| 75 | Height: y.BoundsSize, | 90 | Height: y.BoundsSize, | ||
| 76 | } | 91 | } | ||
| 77 | return floatingGeometry{ | 92 | return floatingGeometry{ | ||
| 93 | + | Mode: ui.PresentationFloating, | |||
| 78 | Bounds: bounds, | 94 | Bounds: bounds, | ||
| 79 | Inner: domain.Rect{ | 95 | Inner: domain.Rect{ | ||
| 80 | X: bounds.X + x.BorderOffset, | 96 | X: bounds.X + x.BorderOffset, | ||
| @@ -85,12 +101,33 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||||
| 85 | } | 101 | } | ||
| 86 | } | 102 | } | ||
| 87 | 103 | ||||
| 104 | + | // calculateContentFloatingGeometry returns canonical tab-content-relative | |||
| 105 | + | // coordinates. Presentation resolution consumes complete-frame coordinates, | |||
| 106 | + | // so the preferred geometry crosses the top-bar boundary exactly once in each | |||
| 107 | + | // direction. | |||
| 108 | + | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |||
| 109 | + | preferred := calculatePreferredFloatingGeometry(content, cfg) | |||
| 110 | + | if !preferred.valid() { | |||
| 111 | + | return floatingGeometry{} | |||
| 112 | + | } | |||
| 113 | + | framePreferred := preferred.translate(0, 1) | |||
| 114 | + | presentation := ui.ResolvePresentation( | |||
| 115 | + | domain.Size{Cols: content.Cols, Rows: content.Rows + 2}, | |||
| 116 | + | framePreferred.Bounds, | |||
| 117 | + | framePreferred.Inner, | |||
| 118 | + | ) | |||
| 119 | + | return floatingGeometry{ | |||
| 120 | + | Mode: presentation.Mode, | |||
| 121 | + | Bounds: presentation.Bounds, | |||
| 122 | + | Inner: presentation.Inner, | |||
| 123 | + | }.translate(0, -1) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 88 | type floatingComposeInput struct { | 126 | type floatingComposeInput struct { | ||
| 89 | baseFrame renderer.Frame | 127 | baseFrame renderer.Frame | ||
| 90 | baseDamage []renderer.Damage | 128 | baseDamage []renderer.Damage | ||
| 91 | floating capturedFloatingRenderState | 129 | floating capturedFloatingRenderState | ||
| 92 | content domain.Rect | 130 | content domain.Rect | ||
| 93 | - | layout capturedTabLayout | |||
| 94 | theme themeui.Theme | 131 | theme themeui.Theme | ||
| 95 | borderMuted renderer.Style | 132 | borderMuted renderer.Style | ||
| 96 | borderActive renderer.Style | 133 | borderActive renderer.Style | ||
| @@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 103 | baseDamage := input.baseDamage | 140 | baseDamage := input.baseDamage | ||
| 104 | floating := input.floating | 141 | floating := input.floating | ||
| 105 | content := input.content | 142 | content := input.content | ||
| 106 | - | layoutSnap := input.layout | |||
| 107 | theme := input.theme | 143 | theme := input.theme | ||
| 108 | borderMuted := input.borderMuted | 144 | borderMuted := input.borderMuted | ||
| 109 | borderActive := input.borderActive | 145 | borderActive := input.borderActive | ||
| @@ -111,10 +147,9 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 111 | full := input.full | 147 | full := input.full | ||
| 112 | 148 | ||||
| 113 | frame := base.Clone() | 149 | frame := base.Clone() | ||
| 114 | - | layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | 150 | + | applyOverlayBackdrop(frame, theme) |
| 115 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme) | |||
| 116 | geometry := floating.geometry.translate(content.X, content.Y) | 151 | geometry := floating.geometry.translate(content.X, content.Y) | ||
| 117 | - | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme)) | 152 | + | blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame) |
| 118 | damage := append([]renderer.Damage(nil), baseDamage...) | 153 | damage := append([]renderer.Damage(nil), baseDamage...) | ||
| 119 | for _, d := range floating.pane.damage { | 154 | for _, d := range floating.pane.damage { | ||
| 120 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | 155 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | ||
| @@ -125,39 +160,168 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 125 | if floating.focused { | 160 | if floating.focused { | ||
| 126 | border = borderActive | 161 | border = borderActive | ||
| 127 | } | 162 | } | ||
| 128 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, border) | 163 | + | drawFloatingBorder(frame, geometry, floating.title, border) |
| 129 | if full || popupChanged { | 164 | if full || popupChanged { | ||
| 130 | return frame, []renderer.Damage{renderer.FullRedraw()} | 165 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 131 | } | 166 | } | ||
| 132 | - | if titleChanged && geometry.Bounds.Height >= 3 { | 167 | + | hasTitleBorder := geometry.Bounds.Height >= 3 |
| 133 | - | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | 168 | + | if geometry.Mode == ui.PresentationDrawer { |
| 169 | + | hasTitleBorder = geometry.Bounds.Height > 0 | |||
| 170 | + | } | |||
| 171 | + | if titleChanged && hasTitleBorder { | |||
| 172 | + | if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok { | |||
| 173 | + | damage = append(damage, titleDamage) | |||
| 174 | + | } | |||
| 134 | } | 175 | } | ||
| 135 | return frame, damage | 176 | return frame, damage | ||
| 136 | } | 177 | } | ||
| 137 | 178 | ||||
| 138 | - | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | 179 | + | type floatingSpan struct { |
| 180 | + | source, destination, length int | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // clippedFloatingSpan intersects a source span placed at origin with the | |||
| 184 | + | // destination interval [0, limit). The source offset preserves pane cell | |||
| 185 | + | // mapping when the retained committed geometry starts above or left of frame. | |||
| 186 | + | func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) { | |||
| 187 | + | if length <= 0 || limit <= 0 { | |||
| 188 | + | return floatingSpan{}, false | |||
| 189 | + | } | |||
| 190 | + | if origin < 0 { | |||
| 191 | + | if origin <= -length { | |||
| 192 | + | return floatingSpan{}, false | |||
| 193 | + | } | |||
| 194 | + | trim := -origin | |||
| 195 | + | length -= trim | |||
| 196 | + | origin = 0 | |||
| 197 | + | if length <= 0 { | |||
| 198 | + | return floatingSpan{}, false | |||
| 199 | + | } | |||
| 200 | + | return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true | |||
| 201 | + | } | |||
| 202 | + | if origin >= limit { | |||
| 203 | + | return floatingSpan{}, false | |||
| 204 | + | } | |||
| 205 | + | length = min(length, limit-origin) | |||
| 206 | + | return floatingSpan{destination: origin, length: length}, length > 0 | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) { | |||
| 210 | + | x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width) | |||
| 211 | + | if !ok { | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height) | |||
| 215 | + | if !ok { | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | for dy := range y.length { | |||
| 219 | + | for dx := range x.length { | |||
| 220 | + | dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy)) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) { | |||
| 226 | + | if bounds.Y < 0 || bounds.Y >= frame.Height { | |||
| 227 | + | return renderer.Damage{}, false | |||
| 228 | + | } | |||
| 229 | + | x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width) | |||
| 230 | + | if !ok { | |||
| 231 | + | return renderer.Damage{}, false | |||
| 232 | + | } | |||
| 233 | + | return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) { | |||
| 237 | + | bounds := geometry.Bounds | |||
| 139 | if bounds.Width <= 0 || bounds.Height <= 0 { | 238 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 140 | return | 239 | return | ||
| 141 | } | 240 | } | ||
| 241 | + | if geometry.Mode == ui.PresentationDrawer { | |||
| 242 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style) | |||
| 243 | + | drawFloatingTitle(frame, bounds, title, style) | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 142 | // Each axis omits its borders independently for tiny popups, matching | 246 | // Each axis omits its borders independently for tiny popups, matching | ||
| 143 | // floatingInnerSize and leaving every cell available to the terminal. | 247 | // floatingInnerSize and leaving every cell available to the terminal. | ||
| 248 | + | right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width) | |||
| 249 | + | bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height) | |||
| 144 | if bounds.Height >= 3 { | 250 | if bounds.Height >= 3 { | ||
| 145 | - | for x := bounds.X; x < bounds.X+bounds.Width; x++ { | 251 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style) |
| 146 | - | frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style}) | 252 | + | if hasBottom { |
| 147 | - | frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style}) | 253 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style) |
| 148 | } | 254 | } | ||
| 149 | } | 255 | } | ||
| 150 | if bounds.Width >= 3 { | 256 | if bounds.Width >= 3 { | ||
| 151 | - | for y := bounds.Y; y < bounds.Y+bounds.Height; y++ { | 257 | + | drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style) |
| 152 | - | frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style}) | 258 | + | if hasRight { |
| 153 | - | frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style}) | 259 | + | drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style) |
| 154 | } | 260 | } | ||
| 155 | } | 261 | } | ||
| 156 | - | if bounds.Width >= 3 && bounds.Height >= 3 { | 262 | + | if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom { |
| 157 | - | frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style}) | 263 | + | setFloatingCell(frame, bounds.X, bounds.Y, '┌', style) |
| 158 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style}) | 264 | + | setFloatingCell(frame, right, bounds.Y, '┐', style) |
| 159 | - | frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style}) | 265 | + | setFloatingCell(frame, bounds.X, bottom, '└', style) |
| 160 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style}) | 266 | + | setFloatingCell(frame, right, bottom, '┘', style) |
| 161 | - | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | 267 | + | drawFloatingTitle(frame, bounds, title, style) |
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) { | |||
| 272 | + | if y < 0 || y >= frame.Height { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | span, ok := clippedFloatingSpan(x, width, frame.Width) | |||
| 276 | + | if !ok { | |||
| 277 | + | return | |||
| 278 | + | } | |||
| 279 | + | for dx := range span.length { | |||
| 280 | + | frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style}) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) { | |||
| 285 | + | if x < 0 || x >= frame.Width { | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | span, ok := clippedFloatingSpan(y, height, frame.Height) | |||
| 289 | + | if !ok { | |||
| 290 | + | return | |||
| 291 | + | } | |||
| 292 | + | for dy := range span.length { | |||
| 293 | + | frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style}) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) { | |||
| 298 | + | if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height { | |||
| 299 | + | return | |||
| 300 | + | } | |||
| 301 | + | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func floatingLastCoordinate(origin, size int) (int, bool) { | |||
| 305 | + | if size <= 0 { | |||
| 306 | + | return 0, false | |||
| 307 | + | } | |||
| 308 | + | last := origin + size - 1 | |||
| 309 | + | if last < origin { | |||
| 310 | + | return 0, false | |||
| 311 | + | } | |||
| 312 | + | return last, true | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | |||
| 316 | + | const inset = 2 | |||
| 317 | + | titleWidth := bounds.Width - 2*inset | |||
| 318 | + | if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset { | |||
| 319 | + | return | |||
| 320 | + | } | |||
| 321 | + | titleX := bounds.X + inset | |||
| 322 | + | span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width) | |||
| 323 | + | if !ok { | |||
| 324 | + | return | |||
| 162 | } | 325 | } | ||
| 326 | + | ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style) | |||
| 163 | } | 327 | } | ||
M internal/usecase/daemon/floating_render_test.go +284 -13
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | 16 | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -39,21 +40,66 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr | |||
| 39 | 40 | ||
| 40 | 41 | func TestFloatingGeometryTranslate(t *testing.T) { | |
| 41 | 42 | geometry := floatingGeometry{ | |
| 43 | + | Mode: ui.PresentationDrawer, | |
| 42 | 44 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 43 | 45 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | 48 | translated := geometry.translate(11, 13) | |
| 47 | 49 | require.Equal(t, floatingGeometry{ | |
| 50 | + | Mode: ui.PresentationDrawer, | |
| 48 | 51 | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | |
| 49 | 52 | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | |
| 50 | 53 | }, translated) | |
| 51 | 54 | require.Equal(t, floatingGeometry{ | |
| 55 | + | Mode: ui.PresentationDrawer, | |
| 52 | 56 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 53 | 57 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 54 | 58 | }, geometry) | |
| 55 | 59 | } | |
| 56 | 60 | ||
| 61 | + | func TestResponsiveFloatingGeometry(t *testing.T) { | |
| 62 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | content domain.Size | |
| 66 | + | want floatingGeometry | |
| 67 | + | }{ | |
| 68 | + | { | |
| 69 | + | name: "narrow full height becomes content-relative drawer", | |
| 70 | + | content: domain.Size{Cols: 79, Rows: 22}, | |
| 71 | + | want: floatingGeometry{ | |
| 72 | + | Mode: ui.PresentationDrawer, | |
| 73 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 20}, | |
| 74 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79, Height: 19}, | |
| 75 | + | }, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "breakpoint preserves centered floating geometry", | |
| 79 | + | content: domain.Size{Cols: 80, Rows: 22}, | |
| 80 | + | want: floatingGeometry{ | |
| 81 | + | Mode: ui.PresentationFloating, | |
| 82 | + | Bounds: domain.Rect{Width: 80, Height: 22}, | |
| 83 | + | Inner: domain.Rect{X: 1, Y: 1, Width: 78, Height: 20}, | |
| 84 | + | }, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "tiny narrow frame retains zero-content drawer", | |
| 88 | + | content: domain.Size{Cols: 79, Rows: 2}, | |
| 89 | + | want: floatingGeometry{ | |
| 90 | + | Mode: ui.PresentationDrawer, | |
| 91 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |
| 92 | + | Inner: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |
| 93 | + | }, | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | for _, tt := range tests { | |
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |
| 98 | + | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, cfg)) | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 57 | 103 | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |
| 58 | 104 | const frameX, frameY = 7, 3 | |
| 59 | 105 | tests := []struct { | |
| @@ -93,26 +139,77 @@ func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |||
| 93 | 139 | } | |
| 94 | 140 | } | |
| 95 | 141 | ||
| 96 | - | func TestCalculateFloatingGeometry(t *testing.T) { | |
| 142 | + | func TestCalculatePreferredFloatingGeometry(t *testing.T) { | |
| 97 | 143 | tests := []struct { | |
| 98 | 144 | name string | |
| 99 | 145 | content domain.Size | |
| 100 | 146 | cfg domain.FloatingConfig | |
| 101 | 147 | want floatingGeometry | |
| 102 | 148 | }{ | |
| 103 | - | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, | |
| 104 | - | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, | |
| 105 | - | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, | |
| 106 | - | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, | |
| 107 | - | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, | |
| 149 | + | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, | |
| 150 | + | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, | |
| 151 | + | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, | |
| 152 | + | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, | |
| 153 | + | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, | |
| 108 | 154 | } | |
| 109 | 155 | for _, tt := range tests { | |
| 110 | 156 | t.Run(tt.name, func(t *testing.T) { | |
| 111 | - | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg)) | |
| 157 | + | require.Equal(t, tt.want, calculatePreferredFloatingGeometry(tt.content, tt.cfg)) | |
| 112 | 158 | }) | |
| 113 | 159 | } | |
| 114 | 160 | } | |
| 115 | 161 | ||
| 162 | + | func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testing.T) { | |
| 163 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 164 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |
| 165 | + | require.False(t, geometry.valid()) | |
| 166 | + | require.True(t, geometry.committable()) | |
| 167 | + | require.Equal(t, domain.Rect{Width: 1, Height: 1}, geometry.ptyRect()) | |
| 168 | + | ||
| 169 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 170 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 171 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |
| 172 | + | sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()} | |
| 173 | + | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation") | |
| 176 | + | require.Equal(t, domain.Size{Cols: 1, Rows: 1}, spec.size) | |
| 177 | + | ||
| 178 | + | floating := newPane("floating", nil, spec.size) | |
| 179 | + | floating.popupGeometry = spec.geometry | |
| 180 | + | installTestFloating(tb, floating, true) | |
| 181 | + | tb.mu.Lock() | |
| 182 | + | gotPane, gotGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 183 | + | require.Same(t, floating, tb.terminalTargetLocked()) | |
| 184 | + | tb.mu.Unlock() | |
| 185 | + | require.True(t, visible) | |
| 186 | + | require.Same(t, floating, gotPane) | |
| 187 | + | require.Equal(t, geometry, gotGeometry) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) { | |
| 191 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 192 | + | initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |
| 193 | + | requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |
| 194 | + | pty := &resizePTY{} | |
| 195 | + | floating := newPane("floating", pty, rectSize(initial.Inner)) | |
| 196 | + | floating.rect = initial.Inner | |
| 197 | + | floating.popupGeometry = initial | |
| 198 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |
| 199 | + | installTestFloating(tb, floating, true) | |
| 200 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 201 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 202 | + | ||
| 203 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 204 | + | require.True(t, ok) | |
| 205 | + | require.Empty(t, failed) | |
| 206 | + | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | |
| 207 | + | require.Equal(t, requested, floating.popupGeometry) | |
| 208 | + | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") | |
| 209 | + | require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width) | |
| 210 | + | require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height) | |
| 211 | + | } | |
| 212 | + | ||
| 116 | 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |
| 117 | 214 | tests := []struct { | |
| 118 | 215 | name string | |
| @@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |||
| 133 | 230 | } | |
| 134 | 231 | ||
| 135 | 232 | func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |
| 136 | - | base := renderer.NewFrame(40, 12) | |
| 233 | + | base := renderer.NewFrame(80, 12) | |
| 137 | 234 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | |
| 138 | - | content := domain.Rect{Y: 1, Width: 40, Height: 10} | |
| 235 | + | content := domain.Rect{Y: 1, Width: 80, Height: 10} | |
| 139 | 236 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | |
| 140 | 237 | paneFrame := renderer.NewFrame(6, 3) | |
| 141 | 238 | paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |
| @@ -181,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi | |||
| 181 | 278 | require.Equal(t, 1, damage[0].Height) | |
| 182 | 279 | } | |
| 183 | 280 | ||
| 281 | + | func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) { | |
| 282 | + | base := renderer.NewFrame(15, 7) | |
| 283 | + | for y := range base.Height { | |
| 284 | + | for x := range base.Width { | |
| 285 | + | base.Set(x, y, renderer.Cell{Rune: '·'}) | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | content := domain.Rect{Y: 1, Width: 15, Height: 5} | |
| 289 | + | retained := calculateContentFloatingGeometry( | |
| 290 | + | domain.Size{Cols: 100, Rows: 30}, | |
| 291 | + | domain.FloatingConfig{Width: 80, Height: 80}, | |
| 292 | + | ) | |
| 293 | + | pty := &resizePTY{err: errors.New("resize failed")} | |
| 294 | + | floatingPane := newPane("floating", pty, rectSize(retained.Inner)) | |
| 295 | + | floatingPane.rect = retained.Inner | |
| 296 | + | floatingPane.popupGeometry = retained | |
| 297 | + | for y := range floatingPane.screen.Frame.Height { | |
| 298 | + | for x := range floatingPane.screen.Frame.Width { | |
| 299 | + | floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)}) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80}) | |
| 303 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 304 | + | require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested)) | |
| 305 | + | require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry") | |
| 306 | + | floatingPane.mu.Lock() | |
| 307 | + | captured := capturePaneRenderStateLocked(floatingPane, retained.Inner) | |
| 308 | + | floatingPane.mu.Unlock() | |
| 309 | + | ||
| 310 | + | var frame renderer.Frame | |
| 311 | + | require.NotPanics(t, func() { | |
| 312 | + | frame, _ = composeCapturedFloatingFrame(floatingComposeInput{ | |
| 313 | + | baseFrame: base, | |
| 314 | + | floating: capturedFloatingRenderState{ | |
| 315 | + | visible: true, | |
| 316 | + | pane: captured, | |
| 317 | + | geometry: retained, | |
| 318 | + | generation: 1, | |
| 319 | + | }, | |
| 320 | + | content: content, | |
| 321 | + | full: true, | |
| 322 | + | }) | |
| 323 | + | }) | |
| 324 | + | ||
| 325 | + | for y := range 4 { | |
| 326 | + | require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y) | |
| 327 | + | } | |
| 328 | + | require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted") | |
| 329 | + | require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates") | |
| 330 | + | require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source") | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) { | |
| 334 | + | base := renderer.NewFrame(4, 4) | |
| 335 | + | pane := renderer.NewFrame(5, 5) | |
| 336 | + | for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} { | |
| 337 | + | for x, r := range text { | |
| 338 | + | pane.Set(x, y, renderer.Cell{Rune: r}) | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | geometry := floatingGeometry{ | |
| 342 | + | Mode: ui.PresentationFloating, | |
| 343 | + | Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7}, | |
| 344 | + | Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5}, | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | frame, _ := composeCapturedFloatingFrame(floatingComposeInput{ | |
| 348 | + | baseFrame: base, | |
| 349 | + | floating: capturedFloatingRenderState{ | |
| 350 | + | visible: true, | |
| 351 | + | pane: capturedPaneRenderState{frame: pane}, | |
| 352 | + | geometry: geometry, | |
| 353 | + | generation: 1, | |
| 354 | + | }, | |
| 355 | + | full: true, | |
| 356 | + | }) | |
| 357 | + | ||
| 358 | + | require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{ | |
| 359 | + | rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)), | |
| 360 | + | }) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) { | |
| 364 | + | tests := []struct { | |
| 365 | + | name string | |
| 366 | + | geometry floatingGeometry | |
| 367 | + | want []string | |
| 368 | + | }{ | |
| 369 | + | { | |
| 370 | + | name: "negative top and left retain bottom and right edges", | |
| 371 | + | geometry: floatingGeometry{ | |
| 372 | + | Mode: ui.PresentationFloating, | |
| 373 | + | Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5}, | |
| 374 | + | }, | |
| 375 | + | want: []string{"···│", "···│", "···│", "───┘"}, | |
| 376 | + | }, | |
| 377 | + | { | |
| 378 | + | name: "overflowing bottom and right retain top and left edges", | |
| 379 | + | geometry: floatingGeometry{ | |
| 380 | + | Mode: ui.PresentationFloating, | |
| 381 | + | Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4}, | |
| 382 | + | }, | |
| 383 | + | want: []string{"····", "····", "··┌─", "··│·"}, | |
| 384 | + | }, | |
| 385 | + | { | |
| 386 | + | name: "drawer clips its separator", | |
| 387 | + | geometry: floatingGeometry{ | |
| 388 | + | Mode: ui.PresentationDrawer, | |
| 389 | + | Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3}, | |
| 390 | + | }, | |
| 391 | + | want: []string{"····", "····", "xy──", "····"}, | |
| 392 | + | }, | |
| 393 | + | } | |
| 394 | + | for _, tt := range tests { | |
| 395 | + | t.Run(tt.name, func(t *testing.T) { | |
| 396 | + | frame := renderer.NewFrame(4, 4) | |
| 397 | + | for y := range frame.Height { | |
| 398 | + | for x := range frame.Width { | |
| 399 | + | frame.Set(x, y, renderer.Cell{Rune: '·'}) | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | require.NotPanics(t, func() { | |
| 404 | + | drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{}) | |
| 405 | + | }) | |
| 406 | + | for y, want := range tt.want { | |
| 407 | + | require.Equal(t, want, rowText(frame.Row(y)), "row %d", y) | |
| 408 | + | } | |
| 409 | + | }) | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | ||
| 184 | 413 | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | |
| 185 | 414 | base := renderer.NewFrame(20, 9) | |
| 186 | 415 | content := domain.Rect{Y: 1, Width: 20, Height: 7} | |
| @@ -401,12 +630,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 401 | 630 | ||
| 402 | 631 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |
| 403 | 632 | frame := renderer.NewFrame(2, 1) | |
| 404 | - | drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{}) | |
| 633 | + | drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{}) | |
| 405 | 634 | for _, cell := range frame.Cells { | |
| 406 | 635 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | |
| 407 | 636 | } | |
| 408 | 637 | } | |
| 409 | 638 | ||
| 639 | + | func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) { | |
| 640 | + | frame := renderer.NewFrame(12, 6) | |
| 641 | + | geometry := floatingGeometry{ | |
| 642 | + | Mode: ui.PresentationDrawer, | |
| 643 | + | Bounds: domain.Rect{Y: 2, Width: 12, Height: 4}, | |
| 644 | + | Inner: domain.Rect{Y: 3, Width: 12, Height: 3}, | |
| 645 | + | } | |
| 646 | + | drawFloatingBorder(frame, geometry, "float", renderer.Style{}) | |
| 647 | + | ||
| 648 | + | require.Equal(t, "──float─────", rowText(frame.Row(2))) | |
| 649 | + | for y := 3; y < frame.Height; y++ { | |
| 650 | + | require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders") | |
| 651 | + | } | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) { | |
| 655 | + | content := domain.Rect{Y: 1, Width: 12, Height: 5} | |
| 656 | + | bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3} | |
| 657 | + | inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1} | |
| 658 | + | floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner} | |
| 659 | + | _, damage := composeCapturedFloatingFrame(floatingComposeInput{ | |
| 660 | + | baseFrame: renderer.NewFrame(12, 7), | |
| 661 | + | floating: capturedFloatingRenderState{ | |
| 662 | + | visible: true, | |
| 663 | + | pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, | |
| 664 | + | geometry: floating, | |
| 665 | + | generation: 1, | |
| 666 | + | }, | |
| 667 | + | content: content, | |
| 668 | + | cache: composeCacheInput{ | |
| 669 | + | valid: true, | |
| 670 | + | floatingGeneration: 1, | |
| 671 | + | floatingGeometry: floatingGeometry{ | |
| 672 | + | Mode: ui.PresentationFloating, | |
| 673 | + | Bounds: bounds, | |
| 674 | + | Inner: inner, | |
| 675 | + | }.translate(content.X, content.Y), | |
| 676 | + | }, | |
| 677 | + | }) | |
| 678 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 679 | + | } | |
| 680 | + | ||
| 410 | 681 | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | |
| 411 | 682 | base := renderer.NewFrame(80, 24) | |
| 412 | 683 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| @@ -574,13 +845,13 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 574 | 845 | require.Empty(t, pty.sizes()) | |
| 575 | 846 | ||
| 576 | 847 | // Showing attempts the current size before paint. A failed resize retains | |
| 577 | - | // the old screen, but commits the new popup rect so capture clips/pads it. | |
| 848 | + | // the complete previously committed render and input geometry. | |
| 578 | 849 | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 579 | 850 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | |
| 580 | - | require.Equal(t, current, floating.rect) | |
| 851 | + | require.Equal(t, initial, floating.rect) | |
| 581 | 852 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 582 | 853 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | |
| 583 | - | require.Equal(t, currentGeometry, floating.popupGeometry) | |
| 854 | + | require.Equal(t, initialGeometry, floating.popupGeometry) | |
| 584 | 855 | ||
| 585 | 856 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 586 | 857 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/usecase/layout" | 13 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 15 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 15 | "github.com/bnema/vev/pkg/renderer" | 16 | "github.com/bnema/vev/pkg/renderer" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -39,21 +40,66 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr | |||||
| 39 | 40 | ||||
| 40 | func TestFloatingGeometryTranslate(t *testing.T) { | 41 | func TestFloatingGeometryTranslate(t *testing.T) { | ||
| 41 | geometry := floatingGeometry{ | 42 | geometry := floatingGeometry{ | ||
| 43 | + | Mode: ui.PresentationDrawer, | |||
| 42 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | 44 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | ||
| 43 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | 45 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 46 | translated := geometry.translate(11, 13) | 48 | translated := geometry.translate(11, 13) | ||
| 47 | require.Equal(t, floatingGeometry{ | 49 | require.Equal(t, floatingGeometry{ | ||
| 50 | + | Mode: ui.PresentationDrawer, | |||
| 48 | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | 51 | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | ||
| 49 | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | 52 | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | ||
| 50 | }, translated) | 53 | }, translated) | ||
| 51 | require.Equal(t, floatingGeometry{ | 54 | require.Equal(t, floatingGeometry{ | ||
| 55 | + | Mode: ui.PresentationDrawer, | |||
| 52 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | 56 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | ||
| 53 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | 57 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | ||
| 54 | }, geometry) | 58 | }, geometry) | ||
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 61 | + | func TestResponsiveFloatingGeometry(t *testing.T) { | |||
| 62 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 63 | + | tests := []struct { | |||
| 64 | + | name string | |||
| 65 | + | content domain.Size | |||
| 66 | + | want floatingGeometry | |||
| 67 | + | }{ | |||
| 68 | + | { | |||
| 69 | + | name: "narrow full height becomes content-relative drawer", | |||
| 70 | + | content: domain.Size{Cols: 79, Rows: 22}, | |||
| 71 | + | want: floatingGeometry{ | |||
| 72 | + | Mode: ui.PresentationDrawer, | |||
| 73 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 20}, | |||
| 74 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79, Height: 19}, | |||
| 75 | + | }, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "breakpoint preserves centered floating geometry", | |||
| 79 | + | content: domain.Size{Cols: 80, Rows: 22}, | |||
| 80 | + | want: floatingGeometry{ | |||
| 81 | + | Mode: ui.PresentationFloating, | |||
| 82 | + | Bounds: domain.Rect{Width: 80, Height: 22}, | |||
| 83 | + | Inner: domain.Rect{X: 1, Y: 1, Width: 78, Height: 20}, | |||
| 84 | + | }, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "tiny narrow frame retains zero-content drawer", | |||
| 88 | + | content: domain.Size{Cols: 79, Rows: 2}, | |||
| 89 | + | want: floatingGeometry{ | |||
| 90 | + | Mode: ui.PresentationDrawer, | |||
| 91 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |||
| 92 | + | Inner: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |||
| 93 | + | }, | |||
| 94 | + | }, | |||
| 95 | + | } | |||
| 96 | + | for _, tt := range tests { | |||
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 98 | + | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, cfg)) | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 57 | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | 103 | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | ||
| 58 | const frameX, frameY = 7, 3 | 104 | const frameX, frameY = 7, 3 | ||
| 59 | tests := []struct { | 105 | tests := []struct { | ||
| @@ -93,26 +139,77 @@ func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |||||
| 93 | } | 139 | } | ||
| 94 | } | 140 | } | ||
| 95 | 141 | ||||
| 96 | - | func TestCalculateFloatingGeometry(t *testing.T) { | 142 | + | func TestCalculatePreferredFloatingGeometry(t *testing.T) { |
| 97 | tests := []struct { | 143 | tests := []struct { | ||
| 98 | name string | 144 | name string | ||
| 99 | content domain.Size | 145 | content domain.Size | ||
| 100 | cfg domain.FloatingConfig | 146 | cfg domain.FloatingConfig | ||
| 101 | want floatingGeometry | 147 | want floatingGeometry | ||
| 102 | }{ | 148 | }{ | ||
| 103 | - | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, | 149 | + | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, |
| 104 | - | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, | 150 | + | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, |
| 105 | - | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, | 151 | + | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, |
| 106 | - | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, | 152 | + | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, |
| 107 | - | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, | 153 | + | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, |
| 108 | } | 154 | } | ||
| 109 | for _, tt := range tests { | 155 | for _, tt := range tests { | ||
| 110 | t.Run(tt.name, func(t *testing.T) { | 156 | t.Run(tt.name, func(t *testing.T) { | ||
| 111 | - | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg)) | 157 | + | require.Equal(t, tt.want, calculatePreferredFloatingGeometry(tt.content, tt.cfg)) |
| 112 | }) | 158 | }) | ||
| 113 | } | 159 | } | ||
| 114 | } | 160 | } | ||
| 115 | 161 | ||||
| 162 | + | func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testing.T) { | |||
| 163 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 164 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |||
| 165 | + | require.False(t, geometry.valid()) | |||
| 166 | + | require.True(t, geometry.committable()) | |||
| 167 | + | require.Equal(t, domain.Rect{Width: 1, Height: 1}, geometry.ptyRect()) | |||
| 168 | + | ||||
| 169 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 170 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 171 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 172 | + | sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 173 | + | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true) | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation") | |||
| 176 | + | require.Equal(t, domain.Size{Cols: 1, Rows: 1}, spec.size) | |||
| 177 | + | ||||
| 178 | + | floating := newPane("floating", nil, spec.size) | |||
| 179 | + | floating.popupGeometry = spec.geometry | |||
| 180 | + | installTestFloating(tb, floating, true) | |||
| 181 | + | tb.mu.Lock() | |||
| 182 | + | gotPane, gotGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |||
| 183 | + | require.Same(t, floating, tb.terminalTargetLocked()) | |||
| 184 | + | tb.mu.Unlock() | |||
| 185 | + | require.True(t, visible) | |||
| 186 | + | require.Same(t, floating, gotPane) | |||
| 187 | + | require.Equal(t, geometry, gotGeometry) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) { | |||
| 191 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 192 | + | initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |||
| 193 | + | requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |||
| 194 | + | pty := &resizePTY{} | |||
| 195 | + | floating := newPane("floating", pty, rectSize(initial.Inner)) | |||
| 196 | + | floating.rect = initial.Inner | |||
| 197 | + | floating.popupGeometry = initial | |||
| 198 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 199 | + | installTestFloating(tb, floating, true) | |||
| 200 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 201 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 202 | + | ||||
| 203 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |||
| 204 | + | require.True(t, ok) | |||
| 205 | + | require.Empty(t, failed) | |||
| 206 | + | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | |||
| 207 | + | require.Equal(t, requested, floating.popupGeometry) | |||
| 208 | + | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") | |||
| 209 | + | require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width) | |||
| 210 | + | require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 116 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | ||
| 117 | tests := []struct { | 214 | tests := []struct { | ||
| 118 | name string | 215 | name string | ||
| @@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |||||
| 133 | } | 230 | } | ||
| 134 | 231 | ||||
| 135 | func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | 232 | func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | ||
| 136 | - | base := renderer.NewFrame(40, 12) | 233 | + | base := renderer.NewFrame(80, 12) |
| 137 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | 234 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | ||
| 138 | - | content := domain.Rect{Y: 1, Width: 40, Height: 10} | 235 | + | content := domain.Rect{Y: 1, Width: 80, Height: 10} |
| 139 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | 236 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | ||
| 140 | paneFrame := renderer.NewFrame(6, 3) | 237 | paneFrame := renderer.NewFrame(6, 3) | ||
| 141 | paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'}) | 238 | paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'}) | ||
| @@ -181,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi | |||||
| 181 | require.Equal(t, 1, damage[0].Height) | 278 | require.Equal(t, 1, damage[0].Height) | ||
| 182 | } | 279 | } | ||
| 183 | 280 | ||||
| 281 | + | func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) { | |||
| 282 | + | base := renderer.NewFrame(15, 7) | |||
| 283 | + | for y := range base.Height { | |||
| 284 | + | for x := range base.Width { | |||
| 285 | + | base.Set(x, y, renderer.Cell{Rune: '·'}) | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | content := domain.Rect{Y: 1, Width: 15, Height: 5} | |||
| 289 | + | retained := calculateContentFloatingGeometry( | |||
| 290 | + | domain.Size{Cols: 100, Rows: 30}, | |||
| 291 | + | domain.FloatingConfig{Width: 80, Height: 80}, | |||
| 292 | + | ) | |||
| 293 | + | pty := &resizePTY{err: errors.New("resize failed")} | |||
| 294 | + | floatingPane := newPane("floating", pty, rectSize(retained.Inner)) | |||
| 295 | + | floatingPane.rect = retained.Inner | |||
| 296 | + | floatingPane.popupGeometry = retained | |||
| 297 | + | for y := range floatingPane.screen.Frame.Height { | |||
| 298 | + | for x := range floatingPane.screen.Frame.Width { | |||
| 299 | + | floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)}) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80}) | |||
| 303 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 304 | + | require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested)) | |||
| 305 | + | require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry") | |||
| 306 | + | floatingPane.mu.Lock() | |||
| 307 | + | captured := capturePaneRenderStateLocked(floatingPane, retained.Inner) | |||
| 308 | + | floatingPane.mu.Unlock() | |||
| 309 | + | ||||
| 310 | + | var frame renderer.Frame | |||
| 311 | + | require.NotPanics(t, func() { | |||
| 312 | + | frame, _ = composeCapturedFloatingFrame(floatingComposeInput{ | |||
| 313 | + | baseFrame: base, | |||
| 314 | + | floating: capturedFloatingRenderState{ | |||
| 315 | + | visible: true, | |||
| 316 | + | pane: captured, | |||
| 317 | + | geometry: retained, | |||
| 318 | + | generation: 1, | |||
| 319 | + | }, | |||
| 320 | + | content: content, | |||
| 321 | + | full: true, | |||
| 322 | + | }) | |||
| 323 | + | }) | |||
| 324 | + | ||||
| 325 | + | for y := range 4 { | |||
| 326 | + | require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y) | |||
| 327 | + | } | |||
| 328 | + | require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted") | |||
| 329 | + | require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates") | |||
| 330 | + | require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source") | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) { | |||
| 334 | + | base := renderer.NewFrame(4, 4) | |||
| 335 | + | pane := renderer.NewFrame(5, 5) | |||
| 336 | + | for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} { | |||
| 337 | + | for x, r := range text { | |||
| 338 | + | pane.Set(x, y, renderer.Cell{Rune: r}) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | geometry := floatingGeometry{ | |||
| 342 | + | Mode: ui.PresentationFloating, | |||
| 343 | + | Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7}, | |||
| 344 | + | Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5}, | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | frame, _ := composeCapturedFloatingFrame(floatingComposeInput{ | |||
| 348 | + | baseFrame: base, | |||
| 349 | + | floating: capturedFloatingRenderState{ | |||
| 350 | + | visible: true, | |||
| 351 | + | pane: capturedPaneRenderState{frame: pane}, | |||
| 352 | + | geometry: geometry, | |||
| 353 | + | generation: 1, | |||
| 354 | + | }, | |||
| 355 | + | full: true, | |||
| 356 | + | }) | |||
| 357 | + | ||||
| 358 | + | require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{ | |||
| 359 | + | rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)), | |||
| 360 | + | }) | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) { | |||
| 364 | + | tests := []struct { | |||
| 365 | + | name string | |||
| 366 | + | geometry floatingGeometry | |||
| 367 | + | want []string | |||
| 368 | + | }{ | |||
| 369 | + | { | |||
| 370 | + | name: "negative top and left retain bottom and right edges", | |||
| 371 | + | geometry: floatingGeometry{ | |||
| 372 | + | Mode: ui.PresentationFloating, | |||
| 373 | + | Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5}, | |||
| 374 | + | }, | |||
| 375 | + | want: []string{"···│", "···│", "···│", "───┘"}, | |||
| 376 | + | }, | |||
| 377 | + | { | |||
| 378 | + | name: "overflowing bottom and right retain top and left edges", | |||
| 379 | + | geometry: floatingGeometry{ | |||
| 380 | + | Mode: ui.PresentationFloating, | |||
| 381 | + | Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4}, | |||
| 382 | + | }, | |||
| 383 | + | want: []string{"····", "····", "··┌─", "··│·"}, | |||
| 384 | + | }, | |||
| 385 | + | { | |||
| 386 | + | name: "drawer clips its separator", | |||
| 387 | + | geometry: floatingGeometry{ | |||
| 388 | + | Mode: ui.PresentationDrawer, | |||
| 389 | + | Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3}, | |||
| 390 | + | }, | |||
| 391 | + | want: []string{"····", "····", "xy──", "····"}, | |||
| 392 | + | }, | |||
| 393 | + | } | |||
| 394 | + | for _, tt := range tests { | |||
| 395 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 396 | + | frame := renderer.NewFrame(4, 4) | |||
| 397 | + | for y := range frame.Height { | |||
| 398 | + | for x := range frame.Width { | |||
| 399 | + | frame.Set(x, y, renderer.Cell{Rune: '·'}) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | require.NotPanics(t, func() { | |||
| 404 | + | drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{}) | |||
| 405 | + | }) | |||
| 406 | + | for y, want := range tt.want { | |||
| 407 | + | require.Equal(t, want, rowText(frame.Row(y)), "row %d", y) | |||
| 408 | + | } | |||
| 409 | + | }) | |||
| 410 | + | } | |||
| 411 | + | } | |||
| 412 | + | ||||
| 184 | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | 413 | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | ||
| 185 | base := renderer.NewFrame(20, 9) | 414 | base := renderer.NewFrame(20, 9) | ||
| 186 | content := domain.Rect{Y: 1, Width: 20, Height: 7} | 415 | content := domain.Rect{Y: 1, Width: 20, Height: 7} | ||
| @@ -401,12 +630,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||||
| 401 | 630 | ||||
| 402 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | 631 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | ||
| 403 | frame := renderer.NewFrame(2, 1) | 632 | frame := renderer.NewFrame(2, 1) | ||
| 404 | - | drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{}) | 633 | + | drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{}) |
| 405 | for _, cell := range frame.Cells { | 634 | for _, cell := range frame.Cells { | ||
| 406 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | 635 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | ||
| 407 | } | 636 | } | ||
| 408 | } | 637 | } | ||
| 409 | 638 | ||||
| 639 | + | func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) { | |||
| 640 | + | frame := renderer.NewFrame(12, 6) | |||
| 641 | + | geometry := floatingGeometry{ | |||
| 642 | + | Mode: ui.PresentationDrawer, | |||
| 643 | + | Bounds: domain.Rect{Y: 2, Width: 12, Height: 4}, | |||
| 644 | + | Inner: domain.Rect{Y: 3, Width: 12, Height: 3}, | |||
| 645 | + | } | |||
| 646 | + | drawFloatingBorder(frame, geometry, "float", renderer.Style{}) | |||
| 647 | + | ||||
| 648 | + | require.Equal(t, "──float─────", rowText(frame.Row(2))) | |||
| 649 | + | for y := 3; y < frame.Height; y++ { | |||
| 650 | + | require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders") | |||
| 651 | + | } | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) { | |||
| 655 | + | content := domain.Rect{Y: 1, Width: 12, Height: 5} | |||
| 656 | + | bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3} | |||
| 657 | + | inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1} | |||
| 658 | + | floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner} | |||
| 659 | + | _, damage := composeCapturedFloatingFrame(floatingComposeInput{ | |||
| 660 | + | baseFrame: renderer.NewFrame(12, 7), | |||
| 661 | + | floating: capturedFloatingRenderState{ | |||
| 662 | + | visible: true, | |||
| 663 | + | pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, | |||
| 664 | + | geometry: floating, | |||
| 665 | + | generation: 1, | |||
| 666 | + | }, | |||
| 667 | + | content: content, | |||
| 668 | + | cache: composeCacheInput{ | |||
| 669 | + | valid: true, | |||
| 670 | + | floatingGeneration: 1, | |||
| 671 | + | floatingGeometry: floatingGeometry{ | |||
| 672 | + | Mode: ui.PresentationFloating, | |||
| 673 | + | Bounds: bounds, | |||
| 674 | + | Inner: inner, | |||
| 675 | + | }.translate(content.X, content.Y), | |||
| 676 | + | }, | |||
| 677 | + | }) | |||
| 678 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 679 | + | } | |||
| 680 | + | ||||
| 410 | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | 681 | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | ||
| 411 | base := renderer.NewFrame(80, 24) | 682 | base := renderer.NewFrame(80, 24) | ||
| 412 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | 683 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | ||
| @@ -574,13 +845,13 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 574 | require.Empty(t, pty.sizes()) | 845 | require.Empty(t, pty.sizes()) | ||
| 575 | 846 | ||||
| 576 | // Showing attempts the current size before paint. A failed resize retains | 847 | // Showing attempts the current size before paint. A failed resize retains | ||
| 577 | - | // the old screen, but commits the new popup rect so capture clips/pads it. | 848 | + | // the complete previously committed render and input geometry. |
| 578 | require.NoError(t, d.toggleFloating(sess, nil)) | 849 | require.NoError(t, d.toggleFloating(sess, nil)) | ||
| 579 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | 850 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | ||
| 580 | - | require.Equal(t, current, floating.rect) | 851 | + | require.Equal(t, initial, floating.rect) |
| 581 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | 852 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | ||
| 582 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | 853 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | ||
| 583 | - | require.Equal(t, currentGeometry, floating.popupGeometry) | 854 | + | require.Equal(t, initialGeometry, floating.popupGeometry) |
| 584 | 855 | ||||
| 585 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | 856 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | ||
| 586 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | 857 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | ||
M internal/usecase/daemon/floating_test.go +65 -0
| @@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||
| 184 | 184 | d.sessWg.Wait() | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |
| 188 | + | initial := domain.Size{Cols: 80, Rows: 25} | |
| 189 | + | latest := domain.Size{Cols: 79, Rows: 25} | |
| 190 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 191 | + | latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg) | |
| 192 | + | ||
| 193 | + | readerRelease := make(chan struct{}) | |
| 194 | + | readerStarted := make(chan struct{}) | |
| 195 | + | resized := make(chan struct{}) | |
| 196 | + | floatingPTY := portsmocks.NewMockPTY(t) | |
| 197 | + | floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |
| 198 | + | close(readerStarted) | |
| 199 | + | <-readerRelease | |
| 200 | + | return 0, io.EOF | |
| 201 | + | }).Once() | |
| 202 | + | floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once() | |
| 203 | + | floatingPTY.EXPECT().Close().RunAndReturn(func() error { | |
| 204 | + | select { | |
| 205 | + | case <-readerRelease: | |
| 206 | + | default: | |
| 207 | + | close(readerRelease) | |
| 208 | + | } | |
| 209 | + | return nil | |
| 210 | + | }).Once() | |
| 211 | + | factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil) | |
| 212 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 213 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 214 | + | tabCtx, cancelTab := context.WithCancel(t.Context()) | |
| 215 | + | t.Cleanup(cancelTab) | |
| 216 | + | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | |
| 217 | + | tb.ctx, tb.cancel = tabCtx, cancelTab | |
| 218 | + | sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |
| 219 | + | ||
| 220 | + | d.startFloating(sess, tb, true) | |
| 221 | + | openCall := <-opened | |
| 222 | + | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | |
| 223 | + | require.True(t, d.requestTransactionalResize(sess, nil, latest, true)) | |
| 224 | + | allowOpen() | |
| 225 | + | select { | |
| 226 | + | case <-resized: | |
| 227 | + | case <-time.After(time.Second): | |
| 228 | + | t.Fatal("floating install did not reconcile the resize committed while Open was blocked") | |
| 229 | + | } | |
| 230 | + | select { | |
| 231 | + | case <-readerStarted: | |
| 232 | + | case <-time.After(time.Second): | |
| 233 | + | t.Fatal("floating reader did not start") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | require.Eventually(t, func() bool { | |
| 237 | + | tb.mu.Lock() | |
| 238 | + | p := tb.floating.pane | |
| 239 | + | visible := tb.floating.state == floatingVisible | |
| 240 | + | tb.mu.Unlock() | |
| 241 | + | if !visible || p == nil { | |
| 242 | + | return false | |
| 243 | + | } | |
| 244 | + | p.mu.Lock() | |
| 245 | + | defer p.mu.Unlock() | |
| 246 | + | return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner | |
| 247 | + | }, time.Second, time.Millisecond, "floating install retained the stale Open geometry") | |
| 248 | + | d.teardownFloating(tb, nil) | |
| 249 | + | d.sessWg.Wait() | |
| 250 | + | } | |
| 251 | + | ||
| 187 | 252 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | |
| 188 | 253 | floatingPTY := portsmocks.NewMockPTY(t) | |
| 189 | 254 | readerStarted := make(chan struct{}) | |
| @@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||||
| 184 | d.sessWg.Wait() | 184 | d.sessWg.Wait() | ||
| 185 | } | 185 | } | ||
| 186 | 186 | ||||
| 187 | + | func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |||
| 188 | + | initial := domain.Size{Cols: 80, Rows: 25} | |||
| 189 | + | latest := domain.Size{Cols: 79, Rows: 25} | |||
| 190 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 191 | + | latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg) | |||
| 192 | + | ||||
| 193 | + | readerRelease := make(chan struct{}) | |||
| 194 | + | readerStarted := make(chan struct{}) | |||
| 195 | + | resized := make(chan struct{}) | |||
| 196 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 197 | + | floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |||
| 198 | + | close(readerStarted) | |||
| 199 | + | <-readerRelease | |||
| 200 | + | return 0, io.EOF | |||
| 201 | + | }).Once() | |||
| 202 | + | floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once() | |||
| 203 | + | floatingPTY.EXPECT().Close().RunAndReturn(func() error { | |||
| 204 | + | select { | |||
| 205 | + | case <-readerRelease: | |||
| 206 | + | default: | |||
| 207 | + | close(readerRelease) | |||
| 208 | + | } | |||
| 209 | + | return nil | |||
| 210 | + | }).Once() | |||
| 211 | + | factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil) | |||
| 212 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 213 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 214 | + | tabCtx, cancelTab := context.WithCancel(t.Context()) | |||
| 215 | + | t.Cleanup(cancelTab) | |||
| 216 | + | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | |||
| 217 | + | tb.ctx, tb.cancel = tabCtx, cancelTab | |||
| 218 | + | sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 219 | + | ||||
| 220 | + | d.startFloating(sess, tb, true) | |||
| 221 | + | openCall := <-opened | |||
| 222 | + | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | |||
| 223 | + | require.True(t, d.requestTransactionalResize(sess, nil, latest, true)) | |||
| 224 | + | allowOpen() | |||
| 225 | + | select { | |||
| 226 | + | case <-resized: | |||
| 227 | + | case <-time.After(time.Second): | |||
| 228 | + | t.Fatal("floating install did not reconcile the resize committed while Open was blocked") | |||
| 229 | + | } | |||
| 230 | + | select { | |||
| 231 | + | case <-readerStarted: | |||
| 232 | + | case <-time.After(time.Second): | |||
| 233 | + | t.Fatal("floating reader did not start") | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | require.Eventually(t, func() bool { | |||
| 237 | + | tb.mu.Lock() | |||
| 238 | + | p := tb.floating.pane | |||
| 239 | + | visible := tb.floating.state == floatingVisible | |||
| 240 | + | tb.mu.Unlock() | |||
| 241 | + | if !visible || p == nil { | |||
| 242 | + | return false | |||
| 243 | + | } | |||
| 244 | + | p.mu.Lock() | |||
| 245 | + | defer p.mu.Unlock() | |||
| 246 | + | return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner | |||
| 247 | + | }, time.Second, time.Millisecond, "floating install retained the stale Open geometry") | |||
| 248 | + | d.teardownFloating(tb, nil) | |||
| 249 | + | d.sessWg.Wait() | |||
| 250 | + | } | |||
| 251 | + | ||||
| 187 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | 252 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | ||
| 188 | floatingPTY := portsmocks.NewMockPTY(t) | 253 | floatingPTY := portsmocks.NewMockPTY(t) | ||
| 189 | readerStarted := make(chan struct{}) | 254 | readerStarted := make(chan struct{}) | ||
M internal/usecase/daemon/input.go +2 -2
| @@ -61,7 +61,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | 63 | tb.mu.Lock() | |
| 64 | - | contentRow := ev.Row - 1 | |
| 64 | + | contentRow := ev.Row - clientTopBarRows | |
| 65 | 65 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 66 | 66 | if floatingVisible { | |
| 67 | 67 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |
| @@ -170,7 +170,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 170 | 170 | if translated { | |
| 171 | 171 | d.writeToPane(sess, p, ev.Raw) | |
| 172 | 172 | } else { | |
| 173 | - | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1)) | |
| 173 | + | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) | |
| 174 | 174 | } | |
| 175 | 175 | return | |
| 176 | 176 | } | |
| @@ -61,7 +61,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | tb.mu.Lock() | 63 | tb.mu.Lock() | ||
| 64 | - | contentRow := ev.Row - 1 | 64 | + | contentRow := ev.Row - clientTopBarRows |
| 65 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | 65 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | ||
| 66 | if floatingVisible { | 66 | if floatingVisible { | ||
| 67 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | 67 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | ||
| @@ -170,7 +170,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 170 | if translated { | 170 | if translated { | ||
| 171 | d.writeToPane(sess, p, ev.Raw) | 171 | d.writeToPane(sess, p, ev.Raw) | ||
| 172 | } else { | 172 | } else { | ||
| 173 | - | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1)) | 173 | + | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) |
| 174 | } | 174 | } | ||
| 175 | return | 175 | return | ||
| 176 | } | 176 | } | ||
M internal/usecase/daemon/input_test.go +75 -2
| @@ -21,6 +21,7 @@ import ( | |||
| 21 | 21 | "github.com/bnema/vev/internal/usecase/layout" | |
| 22 | 22 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 23 | 23 | "github.com/bnema/vev/internal/usecase/picker" | |
| 24 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 24 | 25 | "github.com/bnema/vev/pkg/vt" | |
| 25 | 26 | ) | |
| 26 | 27 | ||
| @@ -704,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||
| 704 | 705 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 705 | 706 | installTestFloating(tb, floating, true) | |
| 706 | 707 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | |
| 707 | - | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | |
| 708 | + | raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |
| 708 | 709 | ||
| 709 | 710 | d.handleInput(sess, ac, raw) | |
| 710 | 711 | } | |
| @@ -722,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||
| 722 | 723 | installTestFloating(tb, floating, true) | |
| 723 | 724 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | |
| 724 | 725 | ||
| 725 | - | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | |
| 726 | + | border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1) | |
| 726 | 727 | outside := []byte("\x1b[<0;1;2M") | |
| 727 | 728 | d.handleInput(sess, ac, border) | |
| 728 | 729 | d.handleInput(sess, ac, outside) | |
| 729 | 730 | } | |
| 730 | 731 | ||
| 732 | + | func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) { | |
| 733 | + | normal := portsmocks.NewMockPTY(t) | |
| 734 | + | floatingPTY := portsmocks.NewMockPTY(t) | |
| 735 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |
| 736 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 737 | + | tb := sess.activeTab() | |
| 738 | + | complete := domain.Size{Cols: 79, Rows: 25} | |
| 739 | + | content := tabSize(complete) | |
| 740 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |
| 741 | + | require.Equal(t, ui.PresentationDrawer, geometry.Mode) | |
| 742 | + | tb.mu.Lock() | |
| 743 | + | tb.size = content | |
| 744 | + | tb.mu.Unlock() | |
| 745 | + | floating := newPane("floating", floatingPTY, rectSize(geometry.Inner)) | |
| 746 | + | floating.popupGeometry = geometry | |
| 747 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 748 | + | installTestFloating(tb, floating, true) | |
| 749 | + | ||
| 750 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |
| 751 | + | d.handleInput(sess, ac, firstInner) | |
| 752 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |
| 753 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |
| 754 | + | } | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) { | |
| 758 | + | p, release := newBlockingPTY(t) | |
| 759 | + | defer release() | |
| 760 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 761 | + | tb := sess.activeTab() | |
| 762 | + | complete := domain.Size{Cols: 79, Rows: 25} | |
| 763 | + | content := tabSize(complete) | |
| 764 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |
| 765 | + | tb.mu.Lock() | |
| 766 | + | tb.size = content | |
| 767 | + | tb.mu.Unlock() | |
| 768 | + | floating := newPane("floating", nil, rectSize(geometry.Inner)) | |
| 769 | + | floating.popupGeometry = geometry | |
| 770 | + | floating.screen.Write([]byte("drawer row zero\r\nsecond row")) | |
| 771 | + | installTestFloating(tb, floating, true) | |
| 772 | + | ||
| 773 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |
| 774 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |
| 775 | + | ac.overlays.copyMu.Lock() | |
| 776 | + | require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow) | |
| 777 | + | ac.overlays.copyMu.Unlock() | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |
| 781 | + | d.handleInput(sess, ac, firstInner) | |
| 782 | + | ac.overlays.copyMu.Lock() | |
| 783 | + | pointer := ac.overlays.copyPointer | |
| 784 | + | ac.overlays.copyMu.Unlock() | |
| 785 | + | require.True(t, pointer.valid) | |
| 786 | + | require.Same(t, floating, pointer.pane) | |
| 787 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press) | |
| 788 | + | require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content) | |
| 789 | + | ||
| 790 | + | // Motion onto the protected top row clamps against the press-owned drawer | |
| 791 | + | // geometry instead of retargeting the underlying tiled terminal. | |
| 792 | + | d.handleInput(sess, ac, []byte("\x1b[<32;79;1M")) | |
| 793 | + | ac.overlays.copyMu.Lock() | |
| 794 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 795 | + | require.Same(t, floating, ac.overlays.copyPane) | |
| 796 | + | require.Same(t, floating, ac.overlays.copyPointer.pane) | |
| 797 | + | require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry) | |
| 798 | + | selection := ac.overlays.copyMode.Selection() | |
| 799 | + | ac.overlays.copyMu.Unlock() | |
| 800 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |
| 801 | + | require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row") | |
| 802 | + | } | |
| 803 | + | ||
| 731 | 804 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |
| 732 | 805 | writes := make(chan []byte, 1) | |
| 733 | 806 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| @@ -21,6 +21,7 @@ import ( | |||||
| 21 | "github.com/bnema/vev/internal/usecase/layout" | 21 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 22 | "github.com/bnema/vev/internal/usecase/mouse" | 22 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 23 | "github.com/bnema/vev/internal/usecase/picker" | 23 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 24 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 24 | "github.com/bnema/vev/pkg/vt" | 25 | "github.com/bnema/vev/pkg/vt" | ||
| 25 | ) | 26 | ) | ||
| 26 | 27 | ||||
| @@ -704,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||||
| 704 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | 705 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | ||
| 705 | installTestFloating(tb, floating, true) | 706 | installTestFloating(tb, floating, true) | ||
| 706 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | 707 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | ||
| 707 | - | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | 708 | + | raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) |
| 708 | 709 | ||||
| 709 | d.handleInput(sess, ac, raw) | 710 | d.handleInput(sess, ac, raw) | ||
| 710 | } | 711 | } | ||
| @@ -722,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||||
| 722 | installTestFloating(tb, floating, true) | 723 | installTestFloating(tb, floating, true) | ||
| 723 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | 724 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | ||
| 724 | 725 | ||||
| 725 | - | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | 726 | + | border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1) |
| 726 | outside := []byte("\x1b[<0;1;2M") | 727 | outside := []byte("\x1b[<0;1;2M") | ||
| 727 | d.handleInput(sess, ac, border) | 728 | d.handleInput(sess, ac, border) | ||
| 728 | d.handleInput(sess, ac, outside) | 729 | d.handleInput(sess, ac, outside) | ||
| 729 | } | 730 | } | ||
| 730 | 731 | ||||
| 732 | + | func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) { | |||
| 733 | + | normal := portsmocks.NewMockPTY(t) | |||
| 734 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 735 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |||
| 736 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 737 | + | tb := sess.activeTab() | |||
| 738 | + | complete := domain.Size{Cols: 79, Rows: 25} | |||
| 739 | + | content := tabSize(complete) | |||
| 740 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |||
| 741 | + | require.Equal(t, ui.PresentationDrawer, geometry.Mode) | |||
| 742 | + | tb.mu.Lock() | |||
| 743 | + | tb.size = content | |||
| 744 | + | tb.mu.Unlock() | |||
| 745 | + | floating := newPane("floating", floatingPTY, rectSize(geometry.Inner)) | |||
| 746 | + | floating.popupGeometry = geometry | |||
| 747 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 748 | + | installTestFloating(tb, floating, true) | |||
| 749 | + | ||||
| 750 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |||
| 751 | + | d.handleInput(sess, ac, firstInner) | |||
| 752 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |||
| 753 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |||
| 754 | + | } | |||
| 755 | + | } | |||
| 756 | + | ||||
| 757 | + | func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) { | |||
| 758 | + | p, release := newBlockingPTY(t) | |||
| 759 | + | defer release() | |||
| 760 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 761 | + | tb := sess.activeTab() | |||
| 762 | + | complete := domain.Size{Cols: 79, Rows: 25} | |||
| 763 | + | content := tabSize(complete) | |||
| 764 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |||
| 765 | + | tb.mu.Lock() | |||
| 766 | + | tb.size = content | |||
| 767 | + | tb.mu.Unlock() | |||
| 768 | + | floating := newPane("floating", nil, rectSize(geometry.Inner)) | |||
| 769 | + | floating.popupGeometry = geometry | |||
| 770 | + | floating.screen.Write([]byte("drawer row zero\r\nsecond row")) | |||
| 771 | + | installTestFloating(tb, floating, true) | |||
| 772 | + | ||||
| 773 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |||
| 774 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |||
| 775 | + | ac.overlays.copyMu.Lock() | |||
| 776 | + | require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow) | |||
| 777 | + | ac.overlays.copyMu.Unlock() | |||
| 778 | + | } | |||
| 779 | + | ||||
| 780 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |||
| 781 | + | d.handleInput(sess, ac, firstInner) | |||
| 782 | + | ac.overlays.copyMu.Lock() | |||
| 783 | + | pointer := ac.overlays.copyPointer | |||
| 784 | + | ac.overlays.copyMu.Unlock() | |||
| 785 | + | require.True(t, pointer.valid) | |||
| 786 | + | require.Same(t, floating, pointer.pane) | |||
| 787 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press) | |||
| 788 | + | require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content) | |||
| 789 | + | ||||
| 790 | + | // Motion onto the protected top row clamps against the press-owned drawer | |||
| 791 | + | // geometry instead of retargeting the underlying tiled terminal. | |||
| 792 | + | d.handleInput(sess, ac, []byte("\x1b[<32;79;1M")) | |||
| 793 | + | ac.overlays.copyMu.Lock() | |||
| 794 | + | require.NotNil(t, ac.overlays.copyMode) | |||
| 795 | + | require.Same(t, floating, ac.overlays.copyPane) | |||
| 796 | + | require.Same(t, floating, ac.overlays.copyPointer.pane) | |||
| 797 | + | require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry) | |||
| 798 | + | selection := ac.overlays.copyMode.Selection() | |||
| 799 | + | ac.overlays.copyMu.Unlock() | |||
| 800 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |||
| 801 | + | require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row") | |||
| 802 | + | } | |||
| 803 | + | ||||
| 731 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | 804 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | ||
| 732 | writes := make(chan []byte, 1) | 805 | writes := make(chan []byte, 1) | ||
| 733 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 806 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
M internal/usecase/daemon/modal_compose.go +4 -7
| @@ -8,12 +8,9 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 11 | - | inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase) | |
| 12 | - | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 13 | - | for y := range min(inner.Height, modalFrame.Height) { | |
| 14 | - | for x := range min(inner.Width, modalFrame.Width) { | |
| 15 | - | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
| 16 | - | } | |
| 17 | - | } | |
| 11 | + | presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height}) | |
| 12 | + | modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase) | |
| 13 | + | modalFrame := renderInner(rectSize(presentation.Inner)) | |
| 14 | + | copyModalInner(base, presentation.Inner, modalFrame) | |
| 18 | 15 | return base, []renderer.Damage{renderer.FullRedraw()} | |
| 19 | 16 | } | |
| @@ -8,12 +8,9 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | 10 | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | ||
| 11 | - | inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase) | 11 | + | presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height}) |
| 12 | - | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}) | 12 | + | modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase) |
| 13 | - | for y := range min(inner.Height, modalFrame.Height) { | 13 | + | modalFrame := renderInner(rectSize(presentation.Inner)) |
| 14 | - | for x := range min(inner.Width, modalFrame.Width) { | 14 | + | copyModalInner(base, presentation.Inner, modalFrame) |
| 15 | - | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |||
| 16 | - | } | |||
| 17 | - | } | |||
| 18 | return base, []renderer.Damage{renderer.FullRedraw()} | 15 | return base, []renderer.Damage{renderer.FullRedraw()} | ||
| 19 | } | 16 | } | ||
M internal/usecase/daemon/mouse_panes.go +1 -1
| @@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |||
| 187 | 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | |
| 188 | 188 | ev.Col -= colOffset | |
| 189 | 189 | ev.Row -= contentYOffset | |
| 190 | - | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | |
| 190 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows) | |
| 191 | 191 | return ev | |
| 192 | 192 | } | |
| 193 | 193 | ||
| @@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |||||
| 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | ||
| 188 | ev.Col -= colOffset | 188 | ev.Col -= colOffset | ||
| 189 | ev.Row -= contentYOffset | 189 | ev.Row -= contentYOffset | ||
| 190 | - | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | 190 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows) |
| 191 | return ev | 191 | return ev | ||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
M internal/usecase/daemon/overlay_backdrop.go +5 -27
| @@ -6,33 +6,11 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 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 | - | } | |
| 9 | + | // applyOverlayBackdrop dims every cell already composed into the complete | |
| 10 | + | // renderer frame. Modal composition calls it immediately before painting each | |
| 11 | + | // active layer so bars, panes, toasts, and lower overlays share one backdrop. | |
| 12 | + | func applyOverlayBackdrop(frame renderer.Frame, theme themeui.Theme) { | |
| 13 | + | dimFrameRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, theme) | |
| 36 | 14 | } | |
| 37 | 15 | ||
| 38 | 16 | func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) { | |
| @@ -6,33 +6,11 @@ import ( | |||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | // overlayBackdrop controls optional dimming of pane content behind an overlay. | 9 | + | // applyOverlayBackdrop dims every cell already composed into the complete |
| 10 | - | // Chrome (bars, dividers, and pane titles) is deliberately not part of the | 10 | + | // renderer frame. Modal composition calls it immediately before painting each |
| 11 | - | // backdrop. | 11 | + | // active layer so bars, panes, toasts, and lower overlays share one backdrop. |
| 12 | - | type overlayBackdrop struct { | 12 | + | func applyOverlayBackdrop(frame renderer.Frame, theme themeui.Theme) { |
| 13 | - | DimPaneContents bool | 13 | + | dimFrameRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, theme) |
| 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 | } | 14 | } | ||
| 37 | 15 | ||||
| 38 | func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) { | 16 | func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) { | ||
M internal/usecase/daemon/overlay_backdrop_test.go +27 -71
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 11 | ) | |
| @@ -15,82 +14,39 @@ func backdropTheme() themeui.Theme { | |||
| 15 | 14 | 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 | 15 | } | |
| 17 | 16 | ||
| 18 | - | func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) { | |
| 19 | - | frame := renderer.NewFrame(8, 6) | |
| 17 | + | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | |
| 18 | + | frame := renderer.NewFrame(4, 3) | |
| 20 | 19 | 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 | - | }, | |
| 20 | + | for y := range frame.Height { | |
| 21 | + | for x := range frame.Width { | |
| 22 | + | frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()}) | |
| 23 | + | } | |
| 63 | 24 | } | |
| 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.NewDimmer(backdropTheme()).Dim(original), 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 | - | }) | |
| 25 | + | frame.Set(1, 1, original) | |
| 26 | + | frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | |
| 27 | + | ||
| 28 | + | applyOverlayBackdrop(frame, backdropTheme()) | |
| 29 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 30 | + | for y := range frame.Height { | |
| 31 | + | for x := range frame.Width { | |
| 32 | + | require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y) | |
| 33 | + | } | |
| 80 | 34 | } | |
| 35 | + | require.Equal(t, '界', frame.At(1, 1).Rune) | |
| 36 | + | require.True(t, frame.At(2, 1).Continuation) | |
| 37 | + | require.Zero(t, frame.At(2, 1).Rune) | |
| 81 | 38 | } | |
| 82 | 39 | ||
| 83 | - | func TestOverlayBackdropDisabledAndFallback(t *testing.T) { | |
| 40 | + | func TestDimFrameRectClipsToFrame(t *testing.T) { | |
| 84 | 41 | frame := renderer.NewFrame(4, 4) | |
| 85 | - | frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 86 | 42 | 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") | |
| 43 | + | dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme()) | |
| 44 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 90 | 45 | ||
| 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) | |
| 46 | + | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { | |
| 47 | + | require.Equal(t, dimmed, frame.At(point[0], point[1]).Style) | |
| 48 | + | } | |
| 49 | + | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { | |
| 50 | + | require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style) | |
| 51 | + | } | |
| 96 | 52 | } | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 11 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 12 | ) | 11 | ) | ||
| @@ -15,82 +14,39 @@ 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}} | 14 | 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 | } | 15 | } | ||
| 17 | 16 | ||||
| 18 | - | func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) { | 17 | + | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { |
| 19 | - | frame := renderer.NewFrame(8, 6) | 18 | + | frame := renderer.NewFrame(4, 3) |
| 20 | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | 19 | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | ||
| 21 | - | frame.Set(2, 2, original) | 20 | + | for y := range frame.Height { |
| 22 | - | frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | 21 | + | for x := range frame.Width { |
| 23 | - | frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title | 22 | + | frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()}) |
| 24 | - | frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar | 23 | + | } |
| 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 | } | 24 | } | ||
| 64 | - | 25 | + | frame.Set(1, 1, original) | |
| 65 | - | for _, tt := range tests { | 26 | + | frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) |
| 66 | - | t.Run(tt.name, func(t *testing.T) { | 27 | + | |
| 67 | - | frame := renderer.NewFrame(4, 4) | 28 | + | applyOverlayBackdrop(frame, backdropTheme()) |
| 68 | - | original := renderer.DefaultStyle() | 29 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) |
| 69 | - | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}} | 30 | + | for y := range frame.Height { |
| 70 | - | 31 | + | for x := range frame.Width { | |
| 71 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme()) | 32 | + | require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y) |
| 72 | - | 33 | + | } | |
| 73 | - | for _, point := range tt.affected { | |||
| 74 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), 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 | } | 34 | } | ||
| 35 | + | require.Equal(t, '界', frame.At(1, 1).Rune) | |||
| 36 | + | require.True(t, frame.At(2, 1).Continuation) | |||
| 37 | + | require.Zero(t, frame.At(2, 1).Rune) | |||
| 81 | } | 38 | } | ||
| 82 | 39 | ||||
| 83 | - | func TestOverlayBackdropDisabledAndFallback(t *testing.T) { | 40 | + | func TestDimFrameRectClipsToFrame(t *testing.T) { |
| 84 | frame := renderer.NewFrame(4, 4) | 41 | frame := renderer.NewFrame(4, 4) | ||
| 85 | - | frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 86 | before := frame.Clone() | 42 | before := frame.Clone() | ||
| 87 | - | area := domain.Rect{Y: 1, Width: 4, Height: 2} | 43 | + | dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme()) |
| 88 | - | (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | 44 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) |
| 89 | - | require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default") | |||
| 90 | 45 | ||||
| 91 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | 46 | + | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { |
| 92 | - | require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area") | 47 | + | require.Equal(t, dimmed, frame.At(point[0], point[1]).Style) |
| 93 | - | require.True(t, frame.At(3, 2).Style.HasForegroundRGB) | 48 | + | } |
| 94 | - | require.False(t, frame.At(0, 0).Style.HasForegroundRGB) | 49 | + | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { |
| 95 | - | require.False(t, frame.At(0, 3).Style.HasForegroundRGB) | 50 | + | require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style) |
| 51 | + | } | |||
| 96 | } | 52 | } | ||
M internal/usecase/daemon/palette_test.go +33 -3
| @@ -743,10 +743,12 @@ func TestPaletteModalGeometry(t *testing.T) { | |||
| 743 | 743 | ||
| 744 | 744 | auto := domain.PaletteConfig{} | |
| 745 | 745 | tests := []testCase{ | |
| 746 | + | {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}}, | |
| 747 | + | {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}}, | |
| 746 | 748 | {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}}, | |
| 747 | 749 | {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}}, | |
| 748 | 750 | {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}}, | |
| 749 | - | {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}}, | |
| 751 | + | {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}}, | |
| 750 | 752 | } | |
| 751 | 753 | for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} { | |
| 752 | 754 | wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor] | |
| @@ -760,7 +762,15 @@ func TestPaletteModalGeometry(t *testing.T) { | |||
| 760 | 762 | for _, tt := range tests { | |
| 761 | 763 | t.Run(tt.name, func(t *testing.T) { | |
| 762 | 764 | modal := paletteModalFor(tt.size, tt.cfg) | |
| 763 | - | require.Equal(t, tt.want, modal.Bounds(tt.size)) | |
| 765 | + | presentation := modal.Resolve(tt.size) | |
| 766 | + | require.Equal(t, tt.want, presentation.Bounds) | |
| 767 | + | if tt.size.Cols < ui.ResponsiveDrawerBreakpoint { | |
| 768 | + | require.Equal(t, ui.PresentationDrawer, presentation.Mode) | |
| 769 | + | require.Equal(t, ui.BorderTop, presentation.Borders) | |
| 770 | + | } else { | |
| 771 | + | require.Equal(t, ui.PresentationFloating, presentation.Mode) | |
| 772 | + | require.Equal(t, ui.BorderAll, presentation.Borders) | |
| 773 | + | } | |
| 764 | 774 | require.Equal(t, " Commands ", modal.Title) | |
| 765 | 775 | require.Equal(t, 11, modal.FixedHeight) | |
| 766 | 776 | require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins) | |
| @@ -768,6 +778,26 @@ func TestPaletteModalGeometry(t *testing.T) { | |||
| 768 | 778 | } | |
| 769 | 779 | } | |
| 770 | 780 | ||
| 781 | + | func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) { | |
| 782 | + | model := palette.New(nil) | |
| 783 | + | for _, tt := range []struct { | |
| 784 | + | name string | |
| 785 | + | width int | |
| 786 | + | topLeft rune | |
| 787 | + | innerLeft rune | |
| 788 | + | }{ | |
| 789 | + | {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'}, | |
| 790 | + | {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'}, | |
| 791 | + | } { | |
| 792 | + | t.Run(tt.name, func(t *testing.T) { | |
| 793 | + | frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "") | |
| 794 | + | ||
| 795 | + | require.Equal(t, tt.topLeft, frame.At(0, 28).Rune) | |
| 796 | + | require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune) | |
| 797 | + | }) | |
| 798 | + | } | |
| 799 | + | } | |
| 800 | + | ||
| 771 | 801 | func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | |
| 772 | 802 | model := palette.New(nil) | |
| 773 | 803 | base := renderer.NewFrame(120, 40) | |
| @@ -793,7 +823,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) { | |||
| 793 | 823 | styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 794 | 824 | ||
| 795 | 825 | frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles) | |
| 796 | - | inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size) | |
| 826 | + | inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner | |
| 797 | 827 | ||
| 798 | 828 | require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted)) | |
| 799 | 829 | } | |
| @@ -743,10 +743,12 @@ func TestPaletteModalGeometry(t *testing.T) { | |||||
| 743 | 743 | ||||
| 744 | auto := domain.PaletteConfig{} | 744 | auto := domain.PaletteConfig{} | ||
| 745 | tests := []testCase{ | 745 | tests := []testCase{ | ||
| 746 | + | {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}}, | |||
| 747 | + | {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}}, | |||
| 746 | {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}}, | 748 | {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}}, | ||
| 747 | {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}}, | 749 | {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}}, | ||
| 748 | {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}}, | 750 | {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}}, | ||
| 749 | - | {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}}, | 751 | + | {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}}, |
| 750 | } | 752 | } | ||
| 751 | for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} { | 753 | for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} { | ||
| 752 | wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor] | 754 | wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor] | ||
| @@ -760,7 +762,15 @@ func TestPaletteModalGeometry(t *testing.T) { | |||||
| 760 | for _, tt := range tests { | 762 | for _, tt := range tests { | ||
| 761 | t.Run(tt.name, func(t *testing.T) { | 763 | t.Run(tt.name, func(t *testing.T) { | ||
| 762 | modal := paletteModalFor(tt.size, tt.cfg) | 764 | modal := paletteModalFor(tt.size, tt.cfg) | ||
| 763 | - | require.Equal(t, tt.want, modal.Bounds(tt.size)) | 765 | + | presentation := modal.Resolve(tt.size) |
| 766 | + | require.Equal(t, tt.want, presentation.Bounds) | |||
| 767 | + | if tt.size.Cols < ui.ResponsiveDrawerBreakpoint { | |||
| 768 | + | require.Equal(t, ui.PresentationDrawer, presentation.Mode) | |||
| 769 | + | require.Equal(t, ui.BorderTop, presentation.Borders) | |||
| 770 | + | } else { | |||
| 771 | + | require.Equal(t, ui.PresentationFloating, presentation.Mode) | |||
| 772 | + | require.Equal(t, ui.BorderAll, presentation.Borders) | |||
| 773 | + | } | |||
| 764 | require.Equal(t, " Commands ", modal.Title) | 774 | require.Equal(t, " Commands ", modal.Title) | ||
| 765 | require.Equal(t, 11, modal.FixedHeight) | 775 | require.Equal(t, 11, modal.FixedHeight) | ||
| 766 | require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins) | 776 | require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins) | ||
| @@ -768,6 +778,26 @@ func TestPaletteModalGeometry(t *testing.T) { | |||||
| 768 | } | 778 | } | ||
| 769 | } | 779 | } | ||
| 770 | 780 | ||||
| 781 | + | func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) { | |||
| 782 | + | model := palette.New(nil) | |||
| 783 | + | for _, tt := range []struct { | |||
| 784 | + | name string | |||
| 785 | + | width int | |||
| 786 | + | topLeft rune | |||
| 787 | + | innerLeft rune | |||
| 788 | + | }{ | |||
| 789 | + | {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'}, | |||
| 790 | + | {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'}, | |||
| 791 | + | } { | |||
| 792 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 793 | + | frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "") | |||
| 794 | + | ||||
| 795 | + | require.Equal(t, tt.topLeft, frame.At(0, 28).Rune) | |||
| 796 | + | require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune) | |||
| 797 | + | }) | |||
| 798 | + | } | |||
| 799 | + | } | |||
| 800 | + | ||||
| 771 | func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | 801 | func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | ||
| 772 | model := palette.New(nil) | 802 | model := palette.New(nil) | ||
| 773 | base := renderer.NewFrame(120, 40) | 803 | base := renderer.NewFrame(120, 40) | ||
| @@ -793,7 +823,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) { | |||||
| 793 | styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | 823 | styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | ||
| 794 | 824 | ||||
| 795 | frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles) | 825 | frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles) | ||
| 796 | - | inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size) | 826 | + | inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner |
| 797 | 827 | ||||
| 798 | require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted)) | 828 | require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted)) | ||
| 799 | } | 829 | } | ||
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -1016,7 +1016,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||
| 1016 | 1016 | func TestPickerModalGeometry(t *testing.T) { | |
| 1017 | 1017 | base := domain.Size{Cols: 100, Rows: 40} | |
| 1018 | 1018 | ||
| 1019 | - | require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base)) | |
| 1019 | + | require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds) | |
| 1020 | 1020 | require.Equal(t, domain.AnchorCenter, pickerModal.Anchor) | |
| 1021 | 1021 | } | |
| 1022 | 1022 | ||
| @@ -1016,7 +1016,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||||
| 1016 | func TestPickerModalGeometry(t *testing.T) { | 1016 | func TestPickerModalGeometry(t *testing.T) { | ||
| 1017 | base := domain.Size{Cols: 100, Rows: 40} | 1017 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 1018 | 1018 | ||||
| 1019 | - | require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base)) | 1019 | + | require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds) |
| 1020 | require.Equal(t, domain.AnchorCenter, pickerModal.Anchor) | 1020 | require.Equal(t, domain.AnchorCenter, pickerModal.Anchor) | ||
| 1021 | } | 1021 | } | ||
| 1022 | 1022 | ||||
M internal/usecase/daemon/prompt_test.go +1 -1
| @@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str | |||
| 20 | 20 | func TestPromptModalGeometry(t *testing.T) { | |
| 21 | 21 | base := domain.Size{Cols: 100, Rows: 40} | |
| 22 | 22 | ||
| 23 | - | require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base)) | |
| 23 | + | require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds) | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | |
| @@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str | |||||
| 20 | func TestPromptModalGeometry(t *testing.T) { | 20 | func TestPromptModalGeometry(t *testing.T) { | ||
| 21 | base := domain.Size{Cols: 100, Rows: 40} | 21 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 22 | 22 | ||||
| 23 | - | require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base)) | 23 | + | require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds) |
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | 26 | func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | ||
M internal/usecase/daemon/render_capture.go +5 -3
| @@ -102,9 +102,11 @@ type capturedOverlayRenderState struct { | |||
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | type capturedModal struct { | |
| 105 | - | modal ui.Modal | |
| 106 | - | inner renderer.Frame | |
| 107 | - | focused bool | |
| 105 | + | active bool | |
| 106 | + | title string | |
| 107 | + | presentation ui.Presentation | |
| 108 | + | inner renderer.Frame | |
| 109 | + | focused bool | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | func (o capturedOverlayRenderState) active() bool { | |
| @@ -102,9 +102,11 @@ type capturedOverlayRenderState struct { | |||||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | type capturedModal struct { | 104 | type capturedModal struct { | ||
| 105 | - | modal ui.Modal | 105 | + | active bool |
| 106 | - | inner renderer.Frame | 106 | + | title string |
| 107 | - | focused bool | 107 | + | presentation ui.Presentation |
| 108 | + | inner renderer.Frame | |||
| 109 | + | focused bool | |||
| 108 | } | 110 | } | ||
| 109 | 111 | ||||
| 110 | func (o capturedOverlayRenderState) active() bool { | 112 | func (o capturedOverlayRenderState) active() bool { | ||
M internal/usecase/daemon/render_pipeline.go +91 -69
| @@ -126,30 +126,43 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 126 | 126 | damage = append(damage, d) | |
| 127 | 127 | } | |
| 128 | 128 | } | |
| 129 | - | // Keep the committed cache unadorned: floating composition clones this | |
| 130 | - | // base, so closing or moving a popup cannot retain dimmed/bordered cells. | |
| 129 | + | // Keep the committed cache unadorned. Toasts are composed first so floating | |
| 130 | + | // terminals and scoped modals dim them with the rest of the complete frame. | |
| 131 | 131 | baseFrame := frame | |
| 132 | + | overlaysActive := state.overlays.active() | |
| 133 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |
| 134 | + | var toastFootprints []domain.Rect | |
| 135 | + | if toastsVisible { | |
| 136 | + | frame = baseFrame.Clone() | |
| 137 | + | toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles) | |
| 138 | + | } | |
| 132 | 139 | if state.floating.visible { | |
| 133 | 140 | var floatingDamage []renderer.Damage | |
| 134 | 141 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | |
| 135 | - | baseFrame: baseFrame, | |
| 142 | + | baseFrame: frame, | |
| 136 | 143 | baseDamage: damage, | |
| 137 | 144 | floating: state.floating, | |
| 138 | 145 | content: content, | |
| 139 | - | layout: state.layout, | |
| 140 | 146 | theme: state.theme, | |
| 141 | 147 | borderMuted: styles.BorderMuted, | |
| 142 | 148 | borderActive: styles.BorderActive, | |
| 143 | 149 | cache: in, | |
| 144 | - | full: full || state.overlays.active(), | |
| 150 | + | full: full || overlaysActive, | |
| 145 | 151 | }) | |
| 146 | 152 | damage = floatingDamage | |
| 147 | 153 | } | |
| 154 | + | if overlaysActive { | |
| 155 | + | if !toastsVisible && !state.floating.visible { | |
| 156 | + | frame = baseFrame.Clone() | |
| 157 | + | } | |
| 158 | + | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| 159 | + | frame, damage = composeCapturedOverlays(state, frame, damage) | |
| 160 | + | } | |
| 148 | 161 | if !full { | |
| 149 | - | if !sameCells(in.bars.top, frame.Row(0)) { | |
| 162 | + | if !sameCells(in.bars.top, baseFrame.Row(0)) { | |
| 150 | 163 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | |
| 151 | 164 | } | |
| 152 | - | if !sameCells(in.bars.bottom, frame.Row(rows+1)) { | |
| 165 | + | if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) { | |
| 153 | 166 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |
| 154 | 167 | } | |
| 155 | 168 | } | |
| @@ -157,17 +170,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 157 | 170 | // toasts, so every render damages both the last and current toast coverage. | |
| 158 | 171 | // This restores cells exposed by dismissal and redraws a stable toast over | |
| 159 | 172 | // any underlying pane update without promoting either case to a full frame. | |
| 160 | - | overlaysActive := state.overlays.active() | |
| 161 | - | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |
| 162 | - | if overlaysActive || toastsVisible { | |
| 163 | - | // Without a floating frame, overlay composition would otherwise mutate | |
| 164 | - | // the base cache in place. Floating composition already owns a clone. | |
| 165 | - | if !state.floating.visible { | |
| 166 | - | baseFrame = frame.Clone() | |
| 167 | - | } | |
| 168 | - | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |
| 169 | - | } | |
| 170 | - | toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles) | |
| 171 | 173 | if full || overlaysActive { | |
| 172 | 174 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 173 | 175 | } else { | |
| @@ -222,91 +224,111 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 222 | 224 | } | |
| 223 | 225 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 224 | 226 | if snap.copySearchModel != nil { | |
| 225 | - | o.copySearch.modal = copySearchModal | |
| 226 | - | o.copySearch.focused = true | |
| 227 | - | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | |
| 227 | + | presentation := copySearchModal.Resolve(size) | |
| 228 | + | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | |
| 229 | + | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | |
| 228 | 230 | } | |
| 229 | 231 | if snap.pickerActive && snap.pickerModel != nil { | |
| 230 | - | o.picker.modal = pickerModal | |
| 231 | - | o.picker.focused = true | |
| 232 | + | presentation := pickerModal.Resolve(size) | |
| 233 | + | o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true} | |
| 232 | 234 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator} | |
| 233 | - | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 235 | + | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | |
| 234 | 236 | } | |
| 235 | 237 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |
| 236 | - | o.noticesOverlay.modal = noticesModal | |
| 237 | - | o.noticesOverlay.focused = true | |
| 238 | + | presentation := noticesModal.Resolve(size) | |
| 239 | + | o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true} | |
| 238 | 240 | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | |
| 239 | - | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles) | |
| 241 | + | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles) | |
| 240 | 242 | } | |
| 241 | 243 | if snap.paletteActive && snap.paletteModel != nil { | |
| 242 | - | o.palette.modal = paletteModalFor(size, paletteCfg) | |
| 243 | - | o.palette.focused = true | |
| 244 | + | modal := paletteModalFor(size, paletteCfg) | |
| 245 | + | presentation := modal.Resolve(size) | |
| 246 | + | o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} | |
| 244 | 247 | guidance := "" | |
| 245 | 248 | if snap.paletteHints != nil { | |
| 246 | 249 | guidance = snap.paletteHints.Feedback | |
| 247 | 250 | } | |
| 248 | 251 | o.paletteGuidance = snap.paletteFeedback | |
| 249 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback}) | |
| 252 | + | o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback}) | |
| 250 | 253 | } | |
| 251 | 254 | if snap.promptActive && snap.promptModel != nil { | |
| 252 | - | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | |
| 253 | - | o.prompt.focused = true | |
| 254 | - | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | |
| 255 | + | modal := promptModalFor(snap.promptModel.Title()) | |
| 256 | + | presentation := modal.Resolve(size) | |
| 257 | + | o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} | |
| 258 | + | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | |
| 255 | 259 | } | |
| 256 | 260 | state.cursor.hiddenByOverlay = o.active() | |
| 257 | 261 | } | |
| 258 | 262 | ||
| 259 | - | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { | |
| 263 | + | func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { | |
| 260 | 264 | o := state.overlays | |
| 261 | - | if o.copyActive { | |
| 262 | - | target := domain.Rect{} | |
| 263 | - | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |
| 264 | - | target = state.floating.geometry.translate(content.X, content.Y).Inner | |
| 265 | + | if !o.copyActive { | |
| 266 | + | return frame, damage | |
| 267 | + | } | |
| 268 | + | target := domain.Rect{} | |
| 269 | + | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |
| 270 | + | target = state.floating.geometry.translate(content.X, content.Y).Inner | |
| 271 | + | } | |
| 272 | + | if target.Width == 0 { | |
| 273 | + | id := o.copyPaneID | |
| 274 | + | if id == "" { | |
| 275 | + | id = state.layout.focus | |
| 265 | 276 | } | |
| 266 | - | if target.Width == 0 { | |
| 267 | - | id := o.copyPaneID | |
| 268 | - | if id == "" { | |
| 269 | - | id = state.layout.focus | |
| 270 | - | } | |
| 271 | - | for _, p := range state.panes { | |
| 272 | - | if p.id == id && !p.placement.Collapsed { | |
| 273 | - | target = p.placement.Content | |
| 274 | - | target.Y += content.Y | |
| 275 | - | break | |
| 276 | - | } | |
| 277 | + | for _, p := range state.panes { | |
| 278 | + | if p.id == id && !p.placement.Collapsed { | |
| 279 | + | target = p.placement.Content | |
| 280 | + | target.Y += content.Y | |
| 281 | + | break | |
| 277 | 282 | } | |
| 278 | 283 | } | |
| 279 | - | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |
| 280 | - | } | |
| 281 | - | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |
| 282 | - | if o.paletteActive && !state.floating.visible { | |
| 283 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | |
| 284 | - | } | |
| 285 | - | // This paint order intentionally differs from HandleInput's keyboard | |
| 286 | - | // priority (prompt > palette > picker > notices > copy, see | |
| 287 | - | // overlay_runtime.go), which paints the picker under notices instead of | |
| 288 | - | // over it. Currently unreachable: notices only opens via the palette, and | |
| 289 | - | // HandleInput short-circuits to the first active overlay, so picker and | |
| 290 | - | // notices are never simultaneously active. If that ever changes, this | |
| 291 | - | // mismatch would let the picker own the keyboard while notices visually | |
| 292 | - | // covers it. | |
| 293 | - | for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} { | |
| 294 | - | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |
| 284 | + | } | |
| 285 | + | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { | |
| 289 | + | o := state.overlays | |
| 290 | + | // Paint in reverse keyboard priority so the same layer that owns input is | |
| 291 | + | // visually topmost: prompt > palette > picker > notices > copy search. | |
| 292 | + | for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} { | |
| 293 | + | if !modal.active { | |
| 295 | 294 | continue | |
| 296 | 295 | } | |
| 296 | + | applyOverlayBackdrop(frame, state.theme) | |
| 297 | 297 | border := state.styles.BorderMuted | |
| 298 | 298 | if modal.focused { | |
| 299 | 299 | border = state.styles.BorderActive | |
| 300 | 300 | } | |
| 301 | - | inner := modal.modal.Composite(frame, border, state.styles.PickerBase) | |
| 302 | - | for y := range min(inner.Height, modal.inner.Height) { | |
| 303 | - | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | |
| 304 | - | } | |
| 301 | + | (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase) | |
| 302 | + | copyModalInner(frame, modal.presentation.Inner, modal.inner) | |
| 305 | 303 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 306 | 304 | } | |
| 307 | 305 | return frame, damage | |
| 308 | 306 | } | |
| 309 | 307 | ||
| 308 | + | // copyModalInner copies a captured model frame into its resolved destination. | |
| 309 | + | // Both source and destination are clipped so degenerate presentations remain | |
| 310 | + | // safe immutable composition inputs. | |
| 311 | + | func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) { | |
| 312 | + | left := max(target.X, 0) | |
| 313 | + | top := max(target.Y, 0) | |
| 314 | + | right := min(target.X+target.Width, dst.Width) | |
| 315 | + | bottom := min(target.Y+target.Height, dst.Height) | |
| 316 | + | if left >= right || top >= bottom { | |
| 317 | + | return | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | sourceX := left - target.X | |
| 321 | + | sourceY := top - target.Y | |
| 322 | + | width := min(right-left, src.Width-sourceX) | |
| 323 | + | height := min(bottom-top, src.Height-sourceY) | |
| 324 | + | if width <= 0 || height <= 0 { | |
| 325 | + | return | |
| 326 | + | } | |
| 327 | + | for y := range height { | |
| 328 | + | copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width]) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 310 | 332 | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | |
| 311 | 333 | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | |
| 312 | 334 | return nil | |
| @@ -126,30 +126,43 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 126 | damage = append(damage, d) | 126 | damage = append(damage, d) | ||
| 127 | } | 127 | } | ||
| 128 | } | 128 | } | ||
| 129 | - | // Keep the committed cache unadorned: floating composition clones this | 129 | + | // Keep the committed cache unadorned. Toasts are composed first so floating |
| 130 | - | // base, so closing or moving a popup cannot retain dimmed/bordered cells. | 130 | + | // terminals and scoped modals dim them with the rest of the complete frame. |
| 131 | baseFrame := frame | 131 | baseFrame := frame | ||
| 132 | + | overlaysActive := state.overlays.active() | |||
| 133 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |||
| 134 | + | var toastFootprints []domain.Rect | |||
| 135 | + | if toastsVisible { | |||
| 136 | + | frame = baseFrame.Clone() | |||
| 137 | + | toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles) | |||
| 138 | + | } | |||
| 132 | if state.floating.visible { | 139 | if state.floating.visible { | ||
| 133 | var floatingDamage []renderer.Damage | 140 | var floatingDamage []renderer.Damage | ||
| 134 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | 141 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | ||
| 135 | - | baseFrame: baseFrame, | 142 | + | baseFrame: frame, |
| 136 | baseDamage: damage, | 143 | baseDamage: damage, | ||
| 137 | floating: state.floating, | 144 | floating: state.floating, | ||
| 138 | content: content, | 145 | content: content, | ||
| 139 | - | layout: state.layout, | |||
| 140 | theme: state.theme, | 146 | theme: state.theme, | ||
| 141 | borderMuted: styles.BorderMuted, | 147 | borderMuted: styles.BorderMuted, | ||
| 142 | borderActive: styles.BorderActive, | 148 | borderActive: styles.BorderActive, | ||
| 143 | cache: in, | 149 | cache: in, | ||
| 144 | - | full: full || state.overlays.active(), | 150 | + | full: full || overlaysActive, |
| 145 | }) | 151 | }) | ||
| 146 | damage = floatingDamage | 152 | damage = floatingDamage | ||
| 147 | } | 153 | } | ||
| 154 | + | if overlaysActive { | |||
| 155 | + | if !toastsVisible && !state.floating.visible { | |||
| 156 | + | frame = baseFrame.Clone() | |||
| 157 | + | } | |||
| 158 | + | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |||
| 159 | + | frame, damage = composeCapturedOverlays(state, frame, damage) | |||
| 160 | + | } | |||
| 148 | if !full { | 161 | if !full { | ||
| 149 | - | if !sameCells(in.bars.top, frame.Row(0)) { | 162 | + | if !sameCells(in.bars.top, baseFrame.Row(0)) { |
| 150 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | 163 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | ||
| 151 | } | 164 | } | ||
| 152 | - | if !sameCells(in.bars.bottom, frame.Row(rows+1)) { | 165 | + | if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) { |
| 153 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | 166 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | ||
| 154 | } | 167 | } | ||
| 155 | } | 168 | } | ||
| @@ -157,17 +170,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 157 | // toasts, so every render damages both the last and current toast coverage. | 170 | // toasts, so every render damages both the last and current toast coverage. | ||
| 158 | // This restores cells exposed by dismissal and redraws a stable toast over | 171 | // This restores cells exposed by dismissal and redraws a stable toast over | ||
| 159 | // any underlying pane update without promoting either case to a full frame. | 172 | // any underlying pane update without promoting either case to a full frame. | ||
| 160 | - | overlaysActive := state.overlays.active() | |||
| 161 | - | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |||
| 162 | - | if overlaysActive || toastsVisible { | |||
| 163 | - | // Without a floating frame, overlay composition would otherwise mutate | |||
| 164 | - | // the base cache in place. Floating composition already owns a clone. | |||
| 165 | - | if !state.floating.visible { | |||
| 166 | - | baseFrame = frame.Clone() | |||
| 167 | - | } | |||
| 168 | - | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |||
| 169 | - | } | |||
| 170 | - | toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles) | |||
| 171 | if full || overlaysActive { | 173 | if full || overlaysActive { | ||
| 172 | damage = []renderer.Damage{renderer.FullRedraw()} | 174 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 173 | } else { | 175 | } else { | ||
| @@ -222,91 +224,111 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 222 | } | 224 | } | ||
| 223 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | 225 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | ||
| 224 | if snap.copySearchModel != nil { | 226 | if snap.copySearchModel != nil { | ||
| 225 | - | o.copySearch.modal = copySearchModal | 227 | + | presentation := copySearchModal.Resolve(size) |
| 226 | - | o.copySearch.focused = true | 228 | + | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} |
| 227 | - | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | 229 | + | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) |
| 228 | } | 230 | } | ||
| 229 | if snap.pickerActive && snap.pickerModel != nil { | 231 | if snap.pickerActive && snap.pickerModel != nil { | ||
| 230 | - | o.picker.modal = pickerModal | 232 | + | presentation := pickerModal.Resolve(size) |
| 231 | - | o.picker.focused = true | 233 | + | o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true} |
| 232 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator} | 234 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator} | ||
| 233 | - | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | 235 | + | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) |
| 234 | } | 236 | } | ||
| 235 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | 237 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | ||
| 236 | - | o.noticesOverlay.modal = noticesModal | 238 | + | presentation := noticesModal.Resolve(size) |
| 237 | - | o.noticesOverlay.focused = true | 239 | + | o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true} |
| 238 | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | 240 | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | ||
| 239 | - | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles) | 241 | + | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles) |
| 240 | } | 242 | } | ||
| 241 | if snap.paletteActive && snap.paletteModel != nil { | 243 | if snap.paletteActive && snap.paletteModel != nil { | ||
| 242 | - | o.palette.modal = paletteModalFor(size, paletteCfg) | 244 | + | modal := paletteModalFor(size, paletteCfg) |
| 243 | - | o.palette.focused = true | 245 | + | presentation := modal.Resolve(size) |
| 246 | + | o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} | |||
| 244 | guidance := "" | 247 | guidance := "" | ||
| 245 | if snap.paletteHints != nil { | 248 | if snap.paletteHints != nil { | ||
| 246 | guidance = snap.paletteHints.Feedback | 249 | guidance = snap.paletteHints.Feedback | ||
| 247 | } | 250 | } | ||
| 248 | o.paletteGuidance = snap.paletteFeedback | 251 | o.paletteGuidance = snap.paletteFeedback | ||
| 249 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback}) | 252 | + | o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback}) |
| 250 | } | 253 | } | ||
| 251 | if snap.promptActive && snap.promptModel != nil { | 254 | if snap.promptActive && snap.promptModel != nil { | ||
| 252 | - | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | 255 | + | modal := promptModalFor(snap.promptModel.Title()) |
| 253 | - | o.prompt.focused = true | 256 | + | presentation := modal.Resolve(size) |
| 254 | - | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | 257 | + | o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} |
| 258 | + | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | |||
| 255 | } | 259 | } | ||
| 256 | state.cursor.hiddenByOverlay = o.active() | 260 | state.cursor.hiddenByOverlay = o.active() | ||
| 257 | } | 261 | } | ||
| 258 | 262 | ||||
| 259 | - | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { | 263 | + | func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { |
| 260 | o := state.overlays | 264 | o := state.overlays | ||
| 261 | - | if o.copyActive { | 265 | + | if !o.copyActive { |
| 262 | - | target := domain.Rect{} | 266 | + | return frame, damage |
| 263 | - | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | 267 | + | } |
| 264 | - | target = state.floating.geometry.translate(content.X, content.Y).Inner | 268 | + | target := domain.Rect{} |
| 269 | + | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |||
| 270 | + | target = state.floating.geometry.translate(content.X, content.Y).Inner | |||
| 271 | + | } | |||
| 272 | + | if target.Width == 0 { | |||
| 273 | + | id := o.copyPaneID | |||
| 274 | + | if id == "" { | |||
| 275 | + | id = state.layout.focus | |||
| 265 | } | 276 | } | ||
| 266 | - | if target.Width == 0 { | 277 | + | for _, p := range state.panes { |
| 267 | - | id := o.copyPaneID | 278 | + | if p.id == id && !p.placement.Collapsed { |
| 268 | - | if id == "" { | 279 | + | target = p.placement.Content |
| 269 | - | id = state.layout.focus | 280 | + | target.Y += content.Y |
| 270 | - | } | 281 | + | break |
| 271 | - | for _, p := range state.panes { | |||
| 272 | - | if p.id == id && !p.placement.Collapsed { | |||
| 273 | - | target = p.placement.Content | |||
| 274 | - | target.Y += content.Y | |||
| 275 | - | break | |||
| 276 | - | } | |||
| 277 | } | 282 | } | ||
| 278 | } | 283 | } | ||
| 279 | - | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles) | 284 | + | } |
| 280 | - | } | 285 | + | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) |
| 281 | - | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | 286 | + | } |
| 282 | - | if o.paletteActive && !state.floating.visible { | 287 | + | |
| 283 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | 288 | + | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { |
| 284 | - | } | 289 | + | o := state.overlays |
| 285 | - | // This paint order intentionally differs from HandleInput's keyboard | 290 | + | // Paint in reverse keyboard priority so the same layer that owns input is |
| 286 | - | // priority (prompt > palette > picker > notices > copy, see | 291 | + | // visually topmost: prompt > palette > picker > notices > copy search. |
| 287 | - | // overlay_runtime.go), which paints the picker under notices instead of | 292 | + | for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} { |
| 288 | - | // over it. Currently unreachable: notices only opens via the palette, and | 293 | + | if !modal.active { |
| 289 | - | // HandleInput short-circuits to the first active overlay, so picker and | |||
| 290 | - | // notices are never simultaneously active. If that ever changes, this | |||
| 291 | - | // mismatch would let the picker own the keyboard while notices visually | |||
| 292 | - | // covers it. | |||
| 293 | - | for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} { | |||
| 294 | - | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |||
| 295 | continue | 294 | continue | ||
| 296 | } | 295 | } | ||
| 296 | + | applyOverlayBackdrop(frame, state.theme) | |||
| 297 | border := state.styles.BorderMuted | 297 | border := state.styles.BorderMuted | ||
| 298 | if modal.focused { | 298 | if modal.focused { | ||
| 299 | border = state.styles.BorderActive | 299 | border = state.styles.BorderActive | ||
| 300 | } | 300 | } | ||
| 301 | - | inner := modal.modal.Composite(frame, border, state.styles.PickerBase) | 301 | + | (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase) |
| 302 | - | for y := range min(inner.Height, modal.inner.Height) { | 302 | + | copyModalInner(frame, modal.presentation.Inner, modal.inner) |
| 303 | - | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | |||
| 304 | - | } | |||
| 305 | damage = []renderer.Damage{renderer.FullRedraw()} | 303 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 306 | } | 304 | } | ||
| 307 | return frame, damage | 305 | return frame, damage | ||
| 308 | } | 306 | } | ||
| 309 | 307 | ||||
| 308 | + | // copyModalInner copies a captured model frame into its resolved destination. | |||
| 309 | + | // Both source and destination are clipped so degenerate presentations remain | |||
| 310 | + | // safe immutable composition inputs. | |||
| 311 | + | func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) { | |||
| 312 | + | left := max(target.X, 0) | |||
| 313 | + | top := max(target.Y, 0) | |||
| 314 | + | right := min(target.X+target.Width, dst.Width) | |||
| 315 | + | bottom := min(target.Y+target.Height, dst.Height) | |||
| 316 | + | if left >= right || top >= bottom { | |||
| 317 | + | return | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | sourceX := left - target.X | |||
| 321 | + | sourceY := top - target.Y | |||
| 322 | + | width := min(right-left, src.Width-sourceX) | |||
| 323 | + | height := min(bottom-top, src.Height-sourceY) | |||
| 324 | + | if width <= 0 || height <= 0 { | |||
| 325 | + | return | |||
| 326 | + | } | |||
| 327 | + | for y := range height { | |||
| 328 | + | copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width]) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 310 | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | 332 | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | ||
| 311 | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | 333 | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | ||
| 312 | return nil | 334 | return nil | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -1
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 15 | "github.com/stretchr/testify/require" | |
| 15 | 16 | ) | |
| @@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||
| 326 | 327 | reset: false, | |
| 327 | 328 | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | |
| 328 | 329 | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 329 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 330 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 330 | 331 | bars: barState{topRight: title, bottomRight: title}, | |
| 331 | 332 | styles: resolveStyles(nil), | |
| 332 | 333 | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 13 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 14 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 15 | ) | 16 | ) | ||
| @@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||||
| 326 | reset: false, | 327 | reset: false, | ||
| 327 | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | 328 | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | ||
| 328 | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | 329 | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | ||
| 329 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | 330 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, |
| 330 | bars: barState{topRight: title, bottomRight: title}, | 331 | bars: barState{topRight: title, bottomRight: title}, | ||
| 331 | styles: resolveStyles(nil), | 332 | styles: resolveStyles(nil), | ||
| 332 | } | 333 | } | ||
M internal/usecase/daemon/render_pipeline_test.go +253 -7
| @@ -9,13 +9,20 @@ import ( | |||
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "strings" | |
| 11 | 11 | "testing" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 17 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 16 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | + | "github.com/bnema/vev/internal/usecase/notices" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 21 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 22 | + | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 17 | 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 18 | 24 | "github.com/bnema/vev/internal/usecase/ui" | |
| 25 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 19 | 26 | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 27 | "github.com/bnema/vev/pkg/vt" | |
| 21 | 28 | "github.com/stretchr/testify/require" | |
| @@ -220,21 +227,21 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||
| 220 | 227 | reset: true, | |
| 221 | 228 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}}, | |
| 222 | 229 | panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 223 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1}, | |
| 230 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1}, | |
| 224 | 231 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | |
| 225 | - | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}}, | |
| 232 | + | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}}, | |
| 226 | 233 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | |
| 227 | 234 | styles: resolveStyles(nil), | |
| 228 | 235 | } | |
| 229 | 236 | composed := composeFrame(state, composeCacheInput{}) | |
| 230 | - | require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame)) | |
| 237 | + | require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame)) | |
| 231 | 238 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | |
| 232 | 239 | ||
| 233 | 240 | stream := newOutputStateStream() | |
| 234 | 241 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | |
| 235 | 242 | require.NoError(t, err) | |
| 236 | 243 | terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...) | |
| 237 | - | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) | |
| 244 | + | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) | |
| 238 | 245 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | |
| 239 | 246 | client.Write(terminalBytes) | |
| 240 | 247 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | |
| @@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||
| 298 | 305 | active := renderer.Style{Foreground: 3, Background: 4} | |
| 299 | 306 | interior := renderer.Style{Foreground: 5, Background: 6} | |
| 300 | 307 | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | |
| 308 | + | presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8}) | |
| 301 | 309 | ||
| 302 | 310 | for _, tt := range []struct { | |
| 303 | 311 | name string | |
| @@ -310,16 +318,254 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||
| 310 | 318 | t.Run(tt.name, func(t *testing.T) { | |
| 311 | 319 | state := capturedRenderState{ | |
| 312 | 320 | styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active}, | |
| 313 | - | overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}}, | |
| 321 | + | overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}}, | |
| 314 | 322 | } | |
| 315 | - | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{}) | |
| 316 | - | bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8}) | |
| 323 | + | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil) | |
| 324 | + | bounds := presentation.Bounds | |
| 317 | 325 | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | |
| 318 | 326 | require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role") | |
| 319 | 327 | }) | |
| 320 | 328 | } | |
| 321 | 329 | } | |
| 322 | 330 | ||
| 331 | + | func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | |
| 332 | + | const ( | |
| 333 | + | width = 100 | |
| 334 | + | contentRows = 6 | |
| 335 | + | ) | |
| 336 | + | styles := resolveStyles(nil) | |
| 337 | + | theme := backdropTheme() | |
| 338 | + | leftPlacement := layout.Placement{ | |
| 339 | + | ID: "left", | |
| 340 | + | TitleBar: domain.Rect{Width: 49, Height: 1}, | |
| 341 | + | Content: domain.Rect{Y: 1, Width: 49, Height: 5}, | |
| 342 | + | } | |
| 343 | + | rightPlacement := layout.Placement{ | |
| 344 | + | ID: "right", | |
| 345 | + | TitleBar: domain.Rect{X: 50, Width: 50, Height: 1}, | |
| 346 | + | Content: domain.Rect{X: 50, Y: 1, Width: 50, Height: 5}, | |
| 347 | + | } | |
| 348 | + | state := capturedRenderState{ | |
| 349 | + | reset: true, | |
| 350 | + | layout: capturedTabLayout{ | |
| 351 | + | area: domain.Rect{Width: width, Height: contentRows}, | |
| 352 | + | focus: "left", | |
| 353 | + | placements: []layout.Placement{leftPlacement, rightPlacement}, | |
| 354 | + | dividers: []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}}, | |
| 355 | + | fingerprint: "modal-backdrop", | |
| 356 | + | valid: true, | |
| 357 | + | }, | |
| 358 | + | panes: []capturedPaneRenderState{ | |
| 359 | + | {id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}, | |
| 360 | + | {id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}}, | |
| 361 | + | }, | |
| 362 | + | overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}}, | |
| 363 | + | styles: styles, | |
| 364 | + | theme: theme, | |
| 365 | + | } | |
| 366 | + | base := composeFrame(state, composeCacheInput{}) | |
| 367 | + | require.NotEmpty(t, base.cache.toastFootprints) | |
| 368 | + | ||
| 369 | + | state.reset = false | |
| 370 | + | state.panes[0].damage = nil | |
| 371 | + | state.panes[1].damage = nil | |
| 372 | + | state.overlays.promptActive = true | |
| 373 | + | state.overlays.prompt = capturedModal{ | |
| 374 | + | active: true, | |
| 375 | + | title: "Prompt", | |
| 376 | + | presentation: ui.Presentation{ | |
| 377 | + | Bounds: domain.Rect{X: 80, Y: 5, Width: 10, Height: 2}, | |
| 378 | + | Inner: domain.Rect{X: 81, Y: 6, Width: 8, Height: 1}, | |
| 379 | + | Borders: ui.BorderAll, | |
| 380 | + | }, | |
| 381 | + | inner: renderer.NewFrame(8, 1), | |
| 382 | + | focused: true, | |
| 383 | + | } | |
| 384 | + | composed := composeFrame(state, base.cache) | |
| 385 | + | dimmer := themeui.NewDimmer(theme) | |
| 386 | + | toast := base.cache.toastFootprints[0] | |
| 387 | + | ||
| 388 | + | for name, point := range map[string][2]int{ | |
| 389 | + | "top bar": {0, 0}, | |
| 390 | + | "pane chrome": {0, 1}, | |
| 391 | + | "pane content": {0, 2}, | |
| 392 | + | "divider": {49, 2}, | |
| 393 | + | "bottom bar": {0, contentRows + 1}, | |
| 394 | + | "toast": {toast.X, toast.Y}, | |
| 395 | + | } { | |
| 396 | + | t.Run(name, func(t *testing.T) { | |
| 397 | + | x, y := point[0], point[1] | |
| 398 | + | require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |
| 399 | + | }) | |
| 400 | + | } | |
| 401 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage) | |
| 402 | + | require.False(t, composed.cache.valid) | |
| 403 | + | require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned") | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | |
| 407 | + | const ( | |
| 408 | + | width = 100 | |
| 409 | + | contentRows = 6 | |
| 410 | + | ) | |
| 411 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}} | |
| 412 | + | theme := backdropTheme() | |
| 413 | + | state := capturedRenderState{ | |
| 414 | + | reset: true, | |
| 415 | + | layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true}, | |
| 416 | + | panes: []capturedPaneRenderState{{ | |
| 417 | + | id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 418 | + | }}, | |
| 419 | + | overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}}, | |
| 420 | + | styles: resolveStyles(nil), | |
| 421 | + | theme: theme, | |
| 422 | + | } | |
| 423 | + | toastOnly := composeFrame(state, composeCacheInput{}) | |
| 424 | + | require.NotEmpty(t, toastOnly.cache.toastFootprints) | |
| 425 | + | toast := toastOnly.cache.toastFootprints[0] | |
| 426 | + | ||
| 427 | + | state.floating = capturedFloatingRenderState{ | |
| 428 | + | visible: true, | |
| 429 | + | focused: true, | |
| 430 | + | pane: capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)}, | |
| 431 | + | geometry: floatingGeometry{ | |
| 432 | + | Mode: ui.PresentationFloating, | |
| 433 | + | Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3}, | |
| 434 | + | Inner: domain.Rect{X: 41, Y: 3, Width: 8, Height: 1}, | |
| 435 | + | }, | |
| 436 | + | generation: 1, | |
| 437 | + | } | |
| 438 | + | composed := composeFrame(state, composeCacheInput{}) | |
| 439 | + | dimmer := themeui.NewDimmer(theme) | |
| 440 | + | ||
| 441 | + | for name, point := range map[string][2]int{ | |
| 442 | + | "top bar": {0, 0}, | |
| 443 | + | "bottom bar": {0, contentRows + 1}, | |
| 444 | + | "toast": {toast.X, toast.Y}, | |
| 445 | + | } { | |
| 446 | + | t.Run(name, func(t *testing.T) { | |
| 447 | + | x, y := point[0], point[1] | |
| 448 | + | require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |
| 449 | + | }) | |
| 450 | + | } | |
| 451 | + | require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache") | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |
| 455 | + | theme := backdropTheme() | |
| 456 | + | lowerStyle := renderer.Style{Foreground: 1, Background: 2} | |
| 457 | + | higherStyle := renderer.Style{Foreground: 3, Background: 4} | |
| 458 | + | modalAt := func(x int, r rune, style renderer.Style) capturedModal { | |
| 459 | + | inner := renderer.NewFrame(1, 1) | |
| 460 | + | inner.Set(0, 0, renderer.Cell{Rune: r, Style: style}) | |
| 461 | + | bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1} | |
| 462 | + | return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner} | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | for _, tt := range []struct { | |
| 466 | + | name string | |
| 467 | + | lower string | |
| 468 | + | higher string | |
| 469 | + | }{ | |
| 470 | + | {name: "notices above copy search", lower: "copy search", higher: "notices"}, | |
| 471 | + | {name: "picker above notices", lower: "notices", higher: "picker"}, | |
| 472 | + | {name: "palette above picker", lower: "picker", higher: "palette"}, | |
| 473 | + | {name: "prompt above palette", lower: "palette", higher: "prompt"}, | |
| 474 | + | } { | |
| 475 | + | t.Run(tt.name, func(t *testing.T) { | |
| 476 | + | state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}} | |
| 477 | + | setModal := func(name string, modal capturedModal) { | |
| 478 | + | switch name { | |
| 479 | + | case "copy search": | |
| 480 | + | state.overlays.copySearch = modal | |
| 481 | + | case "notices": | |
| 482 | + | state.overlays.noticesOverlay = modal | |
| 483 | + | case "picker": | |
| 484 | + | state.overlays.picker = modal | |
| 485 | + | case "palette": | |
| 486 | + | state.overlays.palette = modal | |
| 487 | + | case "prompt": | |
| 488 | + | state.overlays.prompt = modal | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | setModal(tt.lower, modalAt(1, 'L', lowerStyle)) | |
| 492 | + | setModal(tt.higher, modalAt(3, 'H', higherStyle)) | |
| 493 | + | ||
| 494 | + | frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil) | |
| 495 | + | ||
| 496 | + | require.Equal(t, 'L', frame.At(1, 1).Rune) | |
| 497 | + | require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop") | |
| 498 | + | require.Equal(t, 'H', frame.At(3, 1).Rune) | |
| 499 | + | require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost") | |
| 500 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 501 | + | }) | |
| 502 | + | } | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) { | |
| 506 | + | state := capturedRenderState{ | |
| 507 | + | layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}}, | |
| 508 | + | styles: fallbackChromeStyles, | |
| 509 | + | } | |
| 510 | + | snapshot := scopy.NewSnapshotFromRows(nil, 79, 38) | |
| 511 | + | snap := &overlayRenderSnapshot{ | |
| 512 | + | copySearchModel: visualsearch.New(snapshot), | |
| 513 | + | pickerActive: true, | |
| 514 | + | pickerModel: picker.New(nil, "", 0), | |
| 515 | + | noticesOverlayActive: true, | |
| 516 | + | noticesOverlayModel: notices.New(nil, time.Time{}), | |
| 517 | + | paletteActive: true, | |
| 518 | + | paletteModel: palette.New(nil), | |
| 519 | + | promptActive: true, | |
| 520 | + | promptModel: promptui.New(" Prompt ", ""), | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 524 | + | ||
| 525 | + | for name, modal := range map[string]capturedModal{ | |
| 526 | + | "copy search": state.overlays.copySearch, | |
| 527 | + | "picker": state.overlays.picker, | |
| 528 | + | "notices": state.overlays.noticesOverlay, | |
| 529 | + | "palette": state.overlays.palette, | |
| 530 | + | "prompt": state.overlays.prompt, | |
| 531 | + | } { | |
| 532 | + | t.Run(name, func(t *testing.T) { | |
| 533 | + | require.True(t, modal.active) | |
| 534 | + | require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode) | |
| 535 | + | require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height}) | |
| 536 | + | }) | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) { | |
| 541 | + | base := renderer.NewFrame(4, 3) | |
| 542 | + | inner := renderer.NewFrame(4, 2) | |
| 543 | + | for y, text := range []string{"ABCD", "EFGH"} { | |
| 544 | + | for x, r := range text { | |
| 545 | + | inner.Set(x, y, renderer.Cell{Rune: r}) | |
| 546 | + | } | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner) | |
| 550 | + | ||
| 551 | + | require.Equal(t, "BCD ", rowText(base.Row(2))) | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) { | |
| 555 | + | presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4}) | |
| 556 | + | require.Zero(t, presentation.Bounds.Height) | |
| 557 | + | state := capturedRenderState{ | |
| 558 | + | styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}, | |
| 559 | + | overlays: capturedOverlayRenderState{ | |
| 560 | + | copySearch: capturedModal{active: true, title: "Search", presentation: presentation}, | |
| 561 | + | }, | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil) | |
| 565 | + | ||
| 566 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 567 | + | } | |
| 568 | + | ||
| 323 | 569 | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | |
| 324 | 570 | terminal := renderer.DefaultStyle() | |
| 325 | 571 | muted := renderer.Style{Foreground: 1} | |
| @@ -9,13 +9,20 @@ import ( | |||||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | "testing" | 11 | "testing" | ||
| 12 | + | "time" | |||
| 12 | 13 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | "github.com/bnema/vev/internal/testutil/replaytest" | 16 | "github.com/bnema/vev/internal/testutil/replaytest" | ||
| 17 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 16 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 19 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 20 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 21 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 22 | + | promptui "github.com/bnema/vev/internal/usecase/prompt" | |||
| 17 | themeui "github.com/bnema/vev/internal/usecase/theme" | 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 18 | "github.com/bnema/vev/internal/usecase/ui" | 24 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 25 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |||
| 19 | "github.com/bnema/vev/pkg/renderer" | 26 | "github.com/bnema/vev/pkg/renderer" | ||
| 20 | "github.com/bnema/vev/pkg/vt" | 27 | "github.com/bnema/vev/pkg/vt" | ||
| 21 | "github.com/stretchr/testify/require" | 28 | "github.com/stretchr/testify/require" | ||
| @@ -220,21 +227,21 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||||
| 220 | reset: true, | 227 | reset: true, | ||
| 221 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}}, | 228 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}}, | ||
| 222 | panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | 229 | panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | ||
| 223 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1}, | 230 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1}, |
| 224 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | 231 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | ||
| 225 | - | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}}, | 232 | + | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}}, |
| 226 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | 233 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | ||
| 227 | styles: resolveStyles(nil), | 234 | styles: resolveStyles(nil), | ||
| 228 | } | 235 | } | ||
| 229 | composed := composeFrame(state, composeCacheInput{}) | 236 | composed := composeFrame(state, composeCacheInput{}) | ||
| 230 | - | require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame)) | 237 | + | require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame)) |
| 231 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | 238 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | ||
| 232 | 239 | ||||
| 233 | stream := newOutputStateStream() | 240 | stream := newOutputStateStream() | ||
| 234 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | 241 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | ||
| 235 | require.NoError(t, err) | 242 | require.NoError(t, err) | ||
| 236 | terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...) | 243 | terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...) | ||
| 237 | - | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) | 244 | + | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) |
| 238 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | 245 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | ||
| 239 | client.Write(terminalBytes) | 246 | client.Write(terminalBytes) | ||
| 240 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | 247 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | ||
| @@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||||
| 298 | active := renderer.Style{Foreground: 3, Background: 4} | 305 | active := renderer.Style{Foreground: 3, Background: 4} | ||
| 299 | interior := renderer.Style{Foreground: 5, Background: 6} | 306 | interior := renderer.Style{Foreground: 5, Background: 6} | ||
| 300 | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | 307 | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | ||
| 308 | + | presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8}) | |||
| 301 | 309 | ||||
| 302 | for _, tt := range []struct { | 310 | for _, tt := range []struct { | ||
| 303 | name string | 311 | name string | ||
| @@ -310,16 +318,254 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||||
| 310 | t.Run(tt.name, func(t *testing.T) { | 318 | t.Run(tt.name, func(t *testing.T) { | ||
| 311 | state := capturedRenderState{ | 319 | state := capturedRenderState{ | ||
| 312 | styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active}, | 320 | styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active}, | ||
| 313 | - | overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}}, | 321 | + | overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}}, |
| 314 | } | 322 | } | ||
| 315 | - | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{}) | 323 | + | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil) |
| 316 | - | bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8}) | 324 | + | bounds := presentation.Bounds |
| 317 | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | 325 | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | ||
| 318 | require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role") | 326 | require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role") | ||
| 319 | }) | 327 | }) | ||
| 320 | } | 328 | } | ||
| 321 | } | 329 | } | ||
| 322 | 330 | ||||
| 331 | + | func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | |||
| 332 | + | const ( | |||
| 333 | + | width = 100 | |||
| 334 | + | contentRows = 6 | |||
| 335 | + | ) | |||
| 336 | + | styles := resolveStyles(nil) | |||
| 337 | + | theme := backdropTheme() | |||
| 338 | + | leftPlacement := layout.Placement{ | |||
| 339 | + | ID: "left", | |||
| 340 | + | TitleBar: domain.Rect{Width: 49, Height: 1}, | |||
| 341 | + | Content: domain.Rect{Y: 1, Width: 49, Height: 5}, | |||
| 342 | + | } | |||
| 343 | + | rightPlacement := layout.Placement{ | |||
| 344 | + | ID: "right", | |||
| 345 | + | TitleBar: domain.Rect{X: 50, Width: 50, Height: 1}, | |||
| 346 | + | Content: domain.Rect{X: 50, Y: 1, Width: 50, Height: 5}, | |||
| 347 | + | } | |||
| 348 | + | state := capturedRenderState{ | |||
| 349 | + | reset: true, | |||
| 350 | + | layout: capturedTabLayout{ | |||
| 351 | + | area: domain.Rect{Width: width, Height: contentRows}, | |||
| 352 | + | focus: "left", | |||
| 353 | + | placements: []layout.Placement{leftPlacement, rightPlacement}, | |||
| 354 | + | dividers: []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}}, | |||
| 355 | + | fingerprint: "modal-backdrop", | |||
| 356 | + | valid: true, | |||
| 357 | + | }, | |||
| 358 | + | panes: []capturedPaneRenderState{ | |||
| 359 | + | {id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}, | |||
| 360 | + | {id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}}, | |||
| 361 | + | }, | |||
| 362 | + | overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}}, | |||
| 363 | + | styles: styles, | |||
| 364 | + | theme: theme, | |||
| 365 | + | } | |||
| 366 | + | base := composeFrame(state, composeCacheInput{}) | |||
| 367 | + | require.NotEmpty(t, base.cache.toastFootprints) | |||
| 368 | + | ||||
| 369 | + | state.reset = false | |||
| 370 | + | state.panes[0].damage = nil | |||
| 371 | + | state.panes[1].damage = nil | |||
| 372 | + | state.overlays.promptActive = true | |||
| 373 | + | state.overlays.prompt = capturedModal{ | |||
| 374 | + | active: true, | |||
| 375 | + | title: "Prompt", | |||
| 376 | + | presentation: ui.Presentation{ | |||
| 377 | + | Bounds: domain.Rect{X: 80, Y: 5, Width: 10, Height: 2}, | |||
| 378 | + | Inner: domain.Rect{X: 81, Y: 6, Width: 8, Height: 1}, | |||
| 379 | + | Borders: ui.BorderAll, | |||
| 380 | + | }, | |||
| 381 | + | inner: renderer.NewFrame(8, 1), | |||
| 382 | + | focused: true, | |||
| 383 | + | } | |||
| 384 | + | composed := composeFrame(state, base.cache) | |||
| 385 | + | dimmer := themeui.NewDimmer(theme) | |||
| 386 | + | toast := base.cache.toastFootprints[0] | |||
| 387 | + | ||||
| 388 | + | for name, point := range map[string][2]int{ | |||
| 389 | + | "top bar": {0, 0}, | |||
| 390 | + | "pane chrome": {0, 1}, | |||
| 391 | + | "pane content": {0, 2}, | |||
| 392 | + | "divider": {49, 2}, | |||
| 393 | + | "bottom bar": {0, contentRows + 1}, | |||
| 394 | + | "toast": {toast.X, toast.Y}, | |||
| 395 | + | } { | |||
| 396 | + | t.Run(name, func(t *testing.T) { | |||
| 397 | + | x, y := point[0], point[1] | |||
| 398 | + | require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |||
| 399 | + | }) | |||
| 400 | + | } | |||
| 401 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage) | |||
| 402 | + | require.False(t, composed.cache.valid) | |||
| 403 | + | require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned") | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | |||
| 407 | + | const ( | |||
| 408 | + | width = 100 | |||
| 409 | + | contentRows = 6 | |||
| 410 | + | ) | |||
| 411 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}} | |||
| 412 | + | theme := backdropTheme() | |||
| 413 | + | state := capturedRenderState{ | |||
| 414 | + | reset: true, | |||
| 415 | + | layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true}, | |||
| 416 | + | panes: []capturedPaneRenderState{{ | |||
| 417 | + | id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 418 | + | }}, | |||
| 419 | + | overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}}, | |||
| 420 | + | styles: resolveStyles(nil), | |||
| 421 | + | theme: theme, | |||
| 422 | + | } | |||
| 423 | + | toastOnly := composeFrame(state, composeCacheInput{}) | |||
| 424 | + | require.NotEmpty(t, toastOnly.cache.toastFootprints) | |||
| 425 | + | toast := toastOnly.cache.toastFootprints[0] | |||
| 426 | + | ||||
| 427 | + | state.floating = capturedFloatingRenderState{ | |||
| 428 | + | visible: true, | |||
| 429 | + | focused: true, | |||
| 430 | + | pane: capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)}, | |||
| 431 | + | geometry: floatingGeometry{ | |||
| 432 | + | Mode: ui.PresentationFloating, | |||
| 433 | + | Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3}, | |||
| 434 | + | Inner: domain.Rect{X: 41, Y: 3, Width: 8, Height: 1}, | |||
| 435 | + | }, | |||
| 436 | + | generation: 1, | |||
| 437 | + | } | |||
| 438 | + | composed := composeFrame(state, composeCacheInput{}) | |||
| 439 | + | dimmer := themeui.NewDimmer(theme) | |||
| 440 | + | ||||
| 441 | + | for name, point := range map[string][2]int{ | |||
| 442 | + | "top bar": {0, 0}, | |||
| 443 | + | "bottom bar": {0, contentRows + 1}, | |||
| 444 | + | "toast": {toast.X, toast.Y}, | |||
| 445 | + | } { | |||
| 446 | + | t.Run(name, func(t *testing.T) { | |||
| 447 | + | x, y := point[0], point[1] | |||
| 448 | + | require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |||
| 449 | + | }) | |||
| 450 | + | } | |||
| 451 | + | require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache") | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |||
| 455 | + | theme := backdropTheme() | |||
| 456 | + | lowerStyle := renderer.Style{Foreground: 1, Background: 2} | |||
| 457 | + | higherStyle := renderer.Style{Foreground: 3, Background: 4} | |||
| 458 | + | modalAt := func(x int, r rune, style renderer.Style) capturedModal { | |||
| 459 | + | inner := renderer.NewFrame(1, 1) | |||
| 460 | + | inner.Set(0, 0, renderer.Cell{Rune: r, Style: style}) | |||
| 461 | + | bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1} | |||
| 462 | + | return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner} | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | for _, tt := range []struct { | |||
| 466 | + | name string | |||
| 467 | + | lower string | |||
| 468 | + | higher string | |||
| 469 | + | }{ | |||
| 470 | + | {name: "notices above copy search", lower: "copy search", higher: "notices"}, | |||
| 471 | + | {name: "picker above notices", lower: "notices", higher: "picker"}, | |||
| 472 | + | {name: "palette above picker", lower: "picker", higher: "palette"}, | |||
| 473 | + | {name: "prompt above palette", lower: "palette", higher: "prompt"}, | |||
| 474 | + | } { | |||
| 475 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 476 | + | state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}} | |||
| 477 | + | setModal := func(name string, modal capturedModal) { | |||
| 478 | + | switch name { | |||
| 479 | + | case "copy search": | |||
| 480 | + | state.overlays.copySearch = modal | |||
| 481 | + | case "notices": | |||
| 482 | + | state.overlays.noticesOverlay = modal | |||
| 483 | + | case "picker": | |||
| 484 | + | state.overlays.picker = modal | |||
| 485 | + | case "palette": | |||
| 486 | + | state.overlays.palette = modal | |||
| 487 | + | case "prompt": | |||
| 488 | + | state.overlays.prompt = modal | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | setModal(tt.lower, modalAt(1, 'L', lowerStyle)) | |||
| 492 | + | setModal(tt.higher, modalAt(3, 'H', higherStyle)) | |||
| 493 | + | ||||
| 494 | + | frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil) | |||
| 495 | + | ||||
| 496 | + | require.Equal(t, 'L', frame.At(1, 1).Rune) | |||
| 497 | + | require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop") | |||
| 498 | + | require.Equal(t, 'H', frame.At(3, 1).Rune) | |||
| 499 | + | require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost") | |||
| 500 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 501 | + | }) | |||
| 502 | + | } | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) { | |||
| 506 | + | state := capturedRenderState{ | |||
| 507 | + | layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}}, | |||
| 508 | + | styles: fallbackChromeStyles, | |||
| 509 | + | } | |||
| 510 | + | snapshot := scopy.NewSnapshotFromRows(nil, 79, 38) | |||
| 511 | + | snap := &overlayRenderSnapshot{ | |||
| 512 | + | copySearchModel: visualsearch.New(snapshot), | |||
| 513 | + | pickerActive: true, | |||
| 514 | + | pickerModel: picker.New(nil, "", 0), | |||
| 515 | + | noticesOverlayActive: true, | |||
| 516 | + | noticesOverlayModel: notices.New(nil, time.Time{}), | |||
| 517 | + | paletteActive: true, | |||
| 518 | + | paletteModel: palette.New(nil), | |||
| 519 | + | promptActive: true, | |||
| 520 | + | promptModel: promptui.New(" Prompt ", ""), | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |||
| 524 | + | ||||
| 525 | + | for name, modal := range map[string]capturedModal{ | |||
| 526 | + | "copy search": state.overlays.copySearch, | |||
| 527 | + | "picker": state.overlays.picker, | |||
| 528 | + | "notices": state.overlays.noticesOverlay, | |||
| 529 | + | "palette": state.overlays.palette, | |||
| 530 | + | "prompt": state.overlays.prompt, | |||
| 531 | + | } { | |||
| 532 | + | t.Run(name, func(t *testing.T) { | |||
| 533 | + | require.True(t, modal.active) | |||
| 534 | + | require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode) | |||
| 535 | + | require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height}) | |||
| 536 | + | }) | |||
| 537 | + | } | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) { | |||
| 541 | + | base := renderer.NewFrame(4, 3) | |||
| 542 | + | inner := renderer.NewFrame(4, 2) | |||
| 543 | + | for y, text := range []string{"ABCD", "EFGH"} { | |||
| 544 | + | for x, r := range text { | |||
| 545 | + | inner.Set(x, y, renderer.Cell{Rune: r}) | |||
| 546 | + | } | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner) | |||
| 550 | + | ||||
| 551 | + | require.Equal(t, "BCD ", rowText(base.Row(2))) | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) { | |||
| 555 | + | presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4}) | |||
| 556 | + | require.Zero(t, presentation.Bounds.Height) | |||
| 557 | + | state := capturedRenderState{ | |||
| 558 | + | styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}, | |||
| 559 | + | overlays: capturedOverlayRenderState{ | |||
| 560 | + | copySearch: capturedModal{active: true, title: "Search", presentation: presentation}, | |||
| 561 | + | }, | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil) | |||
| 565 | + | ||||
| 566 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 567 | + | } | |||
| 568 | + | ||||
| 323 | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | 569 | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | ||
| 324 | terminal := renderer.DefaultStyle() | 570 | terminal := renderer.DefaultStyle() | ||
| 325 | muted := renderer.Style{Foreground: 1} | 571 | muted := renderer.Style{Foreground: 1} | ||
M internal/usecase/daemon/render_test.go +14 -5
| @@ -48,7 +48,10 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||
| 48 | 48 | dimmed := client.Frame.At(0, 1) | |
| 49 | 49 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | |
| 50 | 50 | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame") | |
| 51 | - | require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp") | |
| 51 | + | for x, cell := range copyBar { | |
| 52 | + | cell.Style = themeui.NewDimmer(theme).Dim(cell.Style) | |
| 53 | + | require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop") | |
| 54 | + | } | |
| 52 | 55 | paletteVisible := false | |
| 53 | 56 | for y := range client.Frame.Height { | |
| 54 | 57 | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | |
| @@ -93,7 +96,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||
| 93 | 96 | } | |
| 94 | 97 | } | |
| 95 | 98 | ||
| 96 | - | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |
| 99 | + | func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |
| 97 | 100 | p, release := newBlockingPTY(t) | |
| 98 | 101 | defer release() | |
| 99 | 102 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -110,7 +113,9 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |||
| 110 | 113 | ||
| 111 | 114 | d.enterPalette(sess, ac) | |
| 112 | 115 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 113 | - | require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp") | |
| 116 | + | dimmedPickerTitle := pickerTitle | |
| 117 | + | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | |
| 118 | + | require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop") | |
| 114 | 119 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | |
| 115 | 120 | } | |
| 116 | 121 | ||
| @@ -133,8 +138,12 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 133 | 138 | dimmed := client.Frame.At(0, 1) | |
| 134 | 139 | require.Equal(t, 'X', dimmed.Rune) | |
| 135 | 140 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style") | |
| 136 | - | require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp") | |
| 137 | - | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | |
| 141 | + | dimmedTopBar := topBar | |
| 142 | + | dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style) | |
| 143 | + | dimmedBottomBar := bottomBar | |
| 144 | + | dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style) | |
| 145 | + | require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop") | |
| 146 | + | require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop") | |
| 138 | 147 | ||
| 139 | 148 | d.handleInput(sess, ac, []byte("\x1b")) | |
| 140 | 149 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| @@ -48,7 +48,10 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||||
| 48 | dimmed := client.Frame.At(0, 1) | 48 | dimmed := client.Frame.At(0, 1) | ||
| 49 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | 49 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | ||
| 50 | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame") | 50 | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame") | ||
| 51 | - | require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp") | 51 | + | for x, cell := range copyBar { |
| 52 | + | cell.Style = themeui.NewDimmer(theme).Dim(cell.Style) | |||
| 53 | + | require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop") | |||
| 54 | + | } | |||
| 52 | paletteVisible := false | 55 | paletteVisible := false | ||
| 53 | for y := range client.Frame.Height { | 56 | for y := range client.Frame.Height { | ||
| 54 | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | 57 | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | ||
| @@ -93,7 +96,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||||
| 93 | } | 96 | } | ||
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
| 96 | - | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | 99 | + | func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { |
| 97 | p, release := newBlockingPTY(t) | 100 | p, release := newBlockingPTY(t) | ||
| 98 | defer release() | 101 | defer release() | ||
| 99 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 102 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| @@ -110,7 +113,9 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |||||
| 110 | 113 | ||||
| 111 | d.enterPalette(sess, ac) | 114 | d.enterPalette(sess, ac) | ||
| 112 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 115 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 113 | - | require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp") | 116 | + | dimmedPickerTitle := pickerTitle |
| 117 | + | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | |||
| 118 | + | require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop") | |||
| 114 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | 119 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | ||
| 115 | } | 120 | } | ||
| 116 | 121 | ||||
| @@ -133,8 +138,12 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||||
| 133 | dimmed := client.Frame.At(0, 1) | 138 | dimmed := client.Frame.At(0, 1) | ||
| 134 | require.Equal(t, 'X', dimmed.Rune) | 139 | require.Equal(t, 'X', dimmed.Rune) | ||
| 135 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style") | 140 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style") | ||
| 136 | - | require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp") | 141 | + | dimmedTopBar := topBar |
| 137 | - | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | 142 | + | dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style) |
| 143 | + | dimmedBottomBar := bottomBar | |||
| 144 | + | dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style) | |||
| 145 | + | require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop") | |||
| 146 | + | require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop") | |||
| 138 | 147 | ||||
| 139 | d.handleInput(sess, ac, []byte("\x1b")) | 148 | d.handleInput(sess, ac, []byte("\x1b")) | ||
| 140 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 149 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
M internal/usecase/daemon/transactional_resize.go +29 -8
| @@ -366,7 +366,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 366 | 366 | size := tb.size | |
| 367 | 367 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | |
| 368 | 368 | tb.mu.Unlock() | |
| 369 | - | if !geometry.valid() { | |
| 369 | + | if !geometry.committable() { | |
| 370 | + | return nil, true | |
| 371 | + | } | |
| 372 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |
| 373 | + | // A drawer can validly reserve only its separator row. Publish that | |
| 374 | + | // presentation without inventing a physical PTY size; the last usable | |
| 375 | + | // rectangle and screen remain committed until a later resize has content. | |
| 376 | + | tb.mu.Lock() | |
| 377 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && | |
| 378 | + | tb.floating.pane == p && tb.size == size | |
| 379 | + | if current != nil && !current() { | |
| 380 | + | currentSlot = false | |
| 381 | + | } | |
| 382 | + | if currentSlot { | |
| 383 | + | p.mu.Lock() | |
| 384 | + | p.popupGeometry = geometry | |
| 385 | + | p.mu.Unlock() | |
| 386 | + | tb.mu.Unlock() | |
| 387 | + | return nil, true | |
| 388 | + | } | |
| 389 | + | tb.mu.Unlock() | |
| 390 | + | if current != nil && !current() { | |
| 391 | + | return nil, false | |
| 392 | + | } | |
| 370 | 393 | return nil, true | |
| 371 | 394 | } | |
| 372 | 395 | ||
| @@ -374,7 +397,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 374 | 397 | // floating slot and tab size are revalidated. A newer client resize may | |
| 375 | 398 | // otherwise publish this obsolete popup geometry after its PTY call returns. | |
| 376 | 399 | plan := preparedTabLayout{members: []resizeMember{{ | |
| 377 | - | session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation, | |
| 400 | + | session: sess, tab: tb, pane: p, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation, | |
| 378 | 401 | }}} | |
| 379 | 402 | d.applyPreparedTabMembers(&plan) | |
| 380 | 403 | ||
| @@ -385,14 +408,12 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 385 | 408 | if current != nil && !current() { | |
| 386 | 409 | currentSlot = false | |
| 387 | 410 | } | |
| 388 | - | if currentSlot { | |
| 411 | + | if currentSlot && plan.members[0].ok { | |
| 389 | 412 | p.mu.Lock() | |
| 390 | - | p.rect = geometry.Inner | |
| 413 | + | p.rect = plan.members[0].rect | |
| 391 | 414 | p.popupGeometry = geometry | |
| 392 | - | if plan.members[0].ok { | |
| 393 | - | p.resizeRetry = false | |
| 394 | - | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 395 | - | } | |
| 415 | + | p.resizeRetry = false | |
| 416 | + | p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height) | |
| 396 | 417 | p.mu.Unlock() | |
| 397 | 418 | } | |
| 398 | 419 | tb.mu.Unlock() | |
| @@ -366,7 +366,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 366 | size := tb.size | 366 | size := tb.size | ||
| 367 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | 367 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | ||
| 368 | tb.mu.Unlock() | 368 | tb.mu.Unlock() | ||
| 369 | - | if !geometry.valid() { | 369 | + | if !geometry.committable() { |
| 370 | + | return nil, true | |||
| 371 | + | } | |||
| 372 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |||
| 373 | + | // A drawer can validly reserve only its separator row. Publish that | |||
| 374 | + | // presentation without inventing a physical PTY size; the last usable | |||
| 375 | + | // rectangle and screen remain committed until a later resize has content. | |||
| 376 | + | tb.mu.Lock() | |||
| 377 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && | |||
| 378 | + | tb.floating.pane == p && tb.size == size | |||
| 379 | + | if current != nil && !current() { | |||
| 380 | + | currentSlot = false | |||
| 381 | + | } | |||
| 382 | + | if currentSlot { | |||
| 383 | + | p.mu.Lock() | |||
| 384 | + | p.popupGeometry = geometry | |||
| 385 | + | p.mu.Unlock() | |||
| 386 | + | tb.mu.Unlock() | |||
| 387 | + | return nil, true | |||
| 388 | + | } | |||
| 389 | + | tb.mu.Unlock() | |||
| 390 | + | if current != nil && !current() { | |||
| 391 | + | return nil, false | |||
| 392 | + | } | |||
| 370 | return nil, true | 393 | return nil, true | ||
| 371 | } | 394 | } | ||
| 372 | 395 | ||||
| @@ -374,7 +397,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 374 | // floating slot and tab size are revalidated. A newer client resize may | 397 | // floating slot and tab size are revalidated. A newer client resize may | ||
| 375 | // otherwise publish this obsolete popup geometry after its PTY call returns. | 398 | // otherwise publish this obsolete popup geometry after its PTY call returns. | ||
| 376 | plan := preparedTabLayout{members: []resizeMember{{ | 399 | plan := preparedTabLayout{members: []resizeMember{{ | ||
| 377 | - | session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation, | 400 | + | session: sess, tab: tb, pane: p, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation, |
| 378 | }}} | 401 | }}} | ||
| 379 | d.applyPreparedTabMembers(&plan) | 402 | d.applyPreparedTabMembers(&plan) | ||
| 380 | 403 | ||||
| @@ -385,14 +408,12 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 385 | if current != nil && !current() { | 408 | if current != nil && !current() { | ||
| 386 | currentSlot = false | 409 | currentSlot = false | ||
| 387 | } | 410 | } | ||
| 388 | - | if currentSlot { | 411 | + | if currentSlot && plan.members[0].ok { |
| 389 | p.mu.Lock() | 412 | p.mu.Lock() | ||
| 390 | - | p.rect = geometry.Inner | 413 | + | p.rect = plan.members[0].rect |
| 391 | p.popupGeometry = geometry | 414 | p.popupGeometry = geometry | ||
| 392 | - | if plan.members[0].ok { | 415 | + | p.resizeRetry = false |
| 393 | - | p.resizeRetry = false | 416 | + | p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height) |
| 394 | - | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |||
| 395 | - | } | |||
| 396 | p.mu.Unlock() | 417 | p.mu.Unlock() | ||
| 397 | } | 418 | } | ||
| 398 | tb.mu.Unlock() | 419 | tb.mu.Unlock() | ||
M internal/usecase/daemon/transactional_resize_test.go +188 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | 14 | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -483,6 +484,193 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||
| 483 | 484 | tb.mu.Unlock() | |
| 484 | 485 | } | |
| 485 | 486 | ||
| 487 | + | func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |
| 488 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 489 | + | for _, tc := range []struct { | |
| 490 | + | name string | |
| 491 | + | from domain.Size | |
| 492 | + | to domain.Size | |
| 493 | + | wantMode ui.PresentationMode | |
| 494 | + | }{ | |
| 495 | + | {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer}, | |
| 496 | + | {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating}, | |
| 497 | + | } { | |
| 498 | + | t.Run(tc.name, func(t *testing.T) { | |
| 499 | + | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 500 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 501 | + | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | |
| 502 | + | ||
| 503 | + | tb := sess.activeTab() | |
| 504 | + | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | |
| 505 | + | toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg) | |
| 506 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 507 | + | var once sync.Once | |
| 508 | + | popupPTY := &transactionalResizePTY{onResize: func() { | |
| 509 | + | once.Do(func() { close(entered) }) | |
| 510 | + | <-release | |
| 511 | + | }} | |
| 512 | + | popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner)) | |
| 513 | + | popup.rect = fromGeometry.Inner | |
| 514 | + | popup.popupGeometry = fromGeometry | |
| 515 | + | installTestFloating(tb, popup, true) | |
| 516 | + | ||
| 517 | + | done := make(chan bool, 1) | |
| 518 | + | go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }() | |
| 519 | + | <-entered | |
| 520 | + | popup.mu.Lock() | |
| 521 | + | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | |
| 522 | + | require.Equal(t, fromGeometry.Inner, popup.rect) | |
| 523 | + | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 524 | + | popup.mu.Unlock() | |
| 525 | + | close(release) | |
| 526 | + | require.True(t, <-done) | |
| 527 | + | ||
| 528 | + | require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested()) | |
| 529 | + | popup.mu.Lock() | |
| 530 | + | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | |
| 531 | + | require.Equal(t, toGeometry, popup.popupGeometry) | |
| 532 | + | require.Equal(t, toGeometry.Inner, popup.rect) | |
| 533 | + | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 534 | + | popup.mu.Unlock() | |
| 535 | + | }) | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) { | |
| 540 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |
| 541 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg) | |
| 542 | + | requestedSize := domain.Size{Cols: 79, Rows: 40} | |
| 543 | + | requested := calculateContentFloatingGeometry(requestedSize, cfg) | |
| 544 | + | require.Equal(t, ui.PresentationFloating, committed.Mode) | |
| 545 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |
| 546 | + | resizeErr := errors.New("floating resize failed") | |
| 547 | + | pty := &transactionalResizePTY{errs: []error{resizeErr}} | |
| 548 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |
| 549 | + | popup.rect = committed.Inner | |
| 550 | + | popup.popupGeometry = committed | |
| 551 | + | tb := newTab(nil, requestedSize) | |
| 552 | + | installTestFloating(tb, popup, true) | |
| 553 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 554 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 555 | + | var tabUnlocked, paneUnlocked bool | |
| 556 | + | pty.onResize = func() { | |
| 557 | + | tabUnlocked = tb.mu.TryLock() | |
| 558 | + | if tabUnlocked { | |
| 559 | + | tb.mu.Unlock() | |
| 560 | + | } | |
| 561 | + | paneUnlocked = popup.mu.TryLock() | |
| 562 | + | if paneUnlocked { | |
| 563 | + | popup.mu.Unlock() | |
| 564 | + | } | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 568 | + | ||
| 569 | + | require.True(t, ok) | |
| 570 | + | require.Len(t, failed, 1) | |
| 571 | + | require.ErrorIs(t, failed[0].err, resizeErr) | |
| 572 | + | require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested()) | |
| 573 | + | require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu") | |
| 574 | + | require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu") | |
| 575 | + | popup.mu.Lock() | |
| 576 | + | require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle") | |
| 577 | + | require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry") | |
| 578 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 579 | + | require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable") | |
| 580 | + | require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate") | |
| 581 | + | popup.mu.Unlock() | |
| 582 | + | tb.mu.Lock() | |
| 583 | + | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 584 | + | tb.mu.Unlock() | |
| 585 | + | require.True(t, visible) | |
| 586 | + | require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry") | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) { | |
| 590 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |
| 591 | + | size := domain.Size{Cols: 79, Rows: 20} | |
| 592 | + | requested := calculateContentFloatingGeometry(size, cfg) | |
| 593 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |
| 594 | + | committed := requested | |
| 595 | + | committed.Mode = ui.PresentationFloating | |
| 596 | + | committed.Bounds.X++ | |
| 597 | + | committed.Inner.X++ | |
| 598 | + | pty := &transactionalResizePTY{} | |
| 599 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |
| 600 | + | popup.rect = committed.Inner | |
| 601 | + | popup.popupGeometry = committed | |
| 602 | + | tb := newTab(nil, size) | |
| 603 | + | installTestFloating(tb, popup, true) | |
| 604 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 605 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 606 | + | ||
| 607 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 608 | + | ||
| 609 | + | require.True(t, ok) | |
| 610 | + | require.Empty(t, failed) | |
| 611 | + | require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY") | |
| 612 | + | popup.mu.Lock() | |
| 613 | + | require.Equal(t, requested.Inner, popup.rect) | |
| 614 | + | require.Equal(t, requested, popup.popupGeometry) | |
| 615 | + | require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 616 | + | require.False(t, popup.resizeRetry) | |
| 617 | + | popup.mu.Unlock() | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) { | |
| 621 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 622 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |
| 623 | + | pty := &transactionalResizePTY{} | |
| 624 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |
| 625 | + | popup.rect = committed.Inner | |
| 626 | + | popup.popupGeometry = committed | |
| 627 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |
| 628 | + | installTestFloating(tb, popup, true) | |
| 629 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 630 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 631 | + | calls := 0 | |
| 632 | + | ||
| 633 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { | |
| 634 | + | calls++ | |
| 635 | + | return calls == 1 | |
| 636 | + | }) | |
| 637 | + | ||
| 638 | + | require.True(t, ok, "a publication accepted while current must not later report stale") | |
| 639 | + | require.Equal(t, 1, calls, "current must not be rechecked after publication") | |
| 640 | + | require.Empty(t, failed) | |
| 641 | + | require.Empty(t, pty.requested()) | |
| 642 | + | requested := calculateContentFloatingGeometry(tb.size, cfg) | |
| 643 | + | popup.mu.Lock() | |
| 644 | + | require.Equal(t, requested, popup.popupGeometry) | |
| 645 | + | require.Equal(t, committed.Inner, popup.rect) | |
| 646 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 647 | + | popup.mu.Unlock() | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) { | |
| 651 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 652 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |
| 653 | + | pty := &transactionalResizePTY{} | |
| 654 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |
| 655 | + | popup.rect = committed.Inner | |
| 656 | + | popup.popupGeometry = committed | |
| 657 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |
| 658 | + | installTestFloating(tb, popup, true) | |
| 659 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 660 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 661 | + | ||
| 662 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false }) | |
| 663 | + | ||
| 664 | + | require.False(t, ok) | |
| 665 | + | require.Empty(t, failed) | |
| 666 | + | require.Empty(t, pty.requested()) | |
| 667 | + | popup.mu.Lock() | |
| 668 | + | require.Equal(t, committed, popup.popupGeometry) | |
| 669 | + | require.Equal(t, committed.Inner, popup.rect) | |
| 670 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 671 | + | popup.mu.Unlock() | |
| 672 | + | } | |
| 673 | + | ||
| 486 | 674 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | |
| 487 | 675 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | |
| 488 | 676 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | "github.com/bnema/vev/internal/usecase/layout" | 14 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 15 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 15 | "github.com/bnema/vev/internal/usecase/visualsearch" | 16 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -483,6 +484,193 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||||
| 483 | tb.mu.Unlock() | 484 | tb.mu.Unlock() | ||
| 484 | } | 485 | } | ||
| 485 | 486 | ||||
| 487 | + | func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 488 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 489 | + | for _, tc := range []struct { | |||
| 490 | + | name string | |||
| 491 | + | from domain.Size | |||
| 492 | + | to domain.Size | |||
| 493 | + | wantMode ui.PresentationMode | |||
| 494 | + | }{ | |||
| 495 | + | {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer}, | |||
| 496 | + | {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating}, | |||
| 497 | + | } { | |||
| 498 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 499 | + | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 500 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 501 | + | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | |||
| 502 | + | ||||
| 503 | + | tb := sess.activeTab() | |||
| 504 | + | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | |||
| 505 | + | toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg) | |||
| 506 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 507 | + | var once sync.Once | |||
| 508 | + | popupPTY := &transactionalResizePTY{onResize: func() { | |||
| 509 | + | once.Do(func() { close(entered) }) | |||
| 510 | + | <-release | |||
| 511 | + | }} | |||
| 512 | + | popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner)) | |||
| 513 | + | popup.rect = fromGeometry.Inner | |||
| 514 | + | popup.popupGeometry = fromGeometry | |||
| 515 | + | installTestFloating(tb, popup, true) | |||
| 516 | + | ||||
| 517 | + | done := make(chan bool, 1) | |||
| 518 | + | go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }() | |||
| 519 | + | <-entered | |||
| 520 | + | popup.mu.Lock() | |||
| 521 | + | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | |||
| 522 | + | require.Equal(t, fromGeometry.Inner, popup.rect) | |||
| 523 | + | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 524 | + | popup.mu.Unlock() | |||
| 525 | + | close(release) | |||
| 526 | + | require.True(t, <-done) | |||
| 527 | + | ||||
| 528 | + | require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested()) | |||
| 529 | + | popup.mu.Lock() | |||
| 530 | + | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | |||
| 531 | + | require.Equal(t, toGeometry, popup.popupGeometry) | |||
| 532 | + | require.Equal(t, toGeometry.Inner, popup.rect) | |||
| 533 | + | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 534 | + | popup.mu.Unlock() | |||
| 535 | + | }) | |||
| 536 | + | } | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) { | |||
| 540 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |||
| 541 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg) | |||
| 542 | + | requestedSize := domain.Size{Cols: 79, Rows: 40} | |||
| 543 | + | requested := calculateContentFloatingGeometry(requestedSize, cfg) | |||
| 544 | + | require.Equal(t, ui.PresentationFloating, committed.Mode) | |||
| 545 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |||
| 546 | + | resizeErr := errors.New("floating resize failed") | |||
| 547 | + | pty := &transactionalResizePTY{errs: []error{resizeErr}} | |||
| 548 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 549 | + | popup.rect = committed.Inner | |||
| 550 | + | popup.popupGeometry = committed | |||
| 551 | + | tb := newTab(nil, requestedSize) | |||
| 552 | + | installTestFloating(tb, popup, true) | |||
| 553 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 554 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 555 | + | var tabUnlocked, paneUnlocked bool | |||
| 556 | + | pty.onResize = func() { | |||
| 557 | + | tabUnlocked = tb.mu.TryLock() | |||
| 558 | + | if tabUnlocked { | |||
| 559 | + | tb.mu.Unlock() | |||
| 560 | + | } | |||
| 561 | + | paneUnlocked = popup.mu.TryLock() | |||
| 562 | + | if paneUnlocked { | |||
| 563 | + | popup.mu.Unlock() | |||
| 564 | + | } | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |||
| 568 | + | ||||
| 569 | + | require.True(t, ok) | |||
| 570 | + | require.Len(t, failed, 1) | |||
| 571 | + | require.ErrorIs(t, failed[0].err, resizeErr) | |||
| 572 | + | require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested()) | |||
| 573 | + | require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu") | |||
| 574 | + | require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu") | |||
| 575 | + | popup.mu.Lock() | |||
| 576 | + | require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle") | |||
| 577 | + | require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry") | |||
| 578 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 579 | + | require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable") | |||
| 580 | + | require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate") | |||
| 581 | + | popup.mu.Unlock() | |||
| 582 | + | tb.mu.Lock() | |||
| 583 | + | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |||
| 584 | + | tb.mu.Unlock() | |||
| 585 | + | require.True(t, visible) | |||
| 586 | + | require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry") | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) { | |||
| 590 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |||
| 591 | + | size := domain.Size{Cols: 79, Rows: 20} | |||
| 592 | + | requested := calculateContentFloatingGeometry(size, cfg) | |||
| 593 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |||
| 594 | + | committed := requested | |||
| 595 | + | committed.Mode = ui.PresentationFloating | |||
| 596 | + | committed.Bounds.X++ | |||
| 597 | + | committed.Inner.X++ | |||
| 598 | + | pty := &transactionalResizePTY{} | |||
| 599 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 600 | + | popup.rect = committed.Inner | |||
| 601 | + | popup.popupGeometry = committed | |||
| 602 | + | tb := newTab(nil, size) | |||
| 603 | + | installTestFloating(tb, popup, true) | |||
| 604 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 605 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 606 | + | ||||
| 607 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |||
| 608 | + | ||||
| 609 | + | require.True(t, ok) | |||
| 610 | + | require.Empty(t, failed) | |||
| 611 | + | require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY") | |||
| 612 | + | popup.mu.Lock() | |||
| 613 | + | require.Equal(t, requested.Inner, popup.rect) | |||
| 614 | + | require.Equal(t, requested, popup.popupGeometry) | |||
| 615 | + | require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 616 | + | require.False(t, popup.resizeRetry) | |||
| 617 | + | popup.mu.Unlock() | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) { | |||
| 621 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 622 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |||
| 623 | + | pty := &transactionalResizePTY{} | |||
| 624 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 625 | + | popup.rect = committed.Inner | |||
| 626 | + | popup.popupGeometry = committed | |||
| 627 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 628 | + | installTestFloating(tb, popup, true) | |||
| 629 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 630 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 631 | + | calls := 0 | |||
| 632 | + | ||||
| 633 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { | |||
| 634 | + | calls++ | |||
| 635 | + | return calls == 1 | |||
| 636 | + | }) | |||
| 637 | + | ||||
| 638 | + | require.True(t, ok, "a publication accepted while current must not later report stale") | |||
| 639 | + | require.Equal(t, 1, calls, "current must not be rechecked after publication") | |||
| 640 | + | require.Empty(t, failed) | |||
| 641 | + | require.Empty(t, pty.requested()) | |||
| 642 | + | requested := calculateContentFloatingGeometry(tb.size, cfg) | |||
| 643 | + | popup.mu.Lock() | |||
| 644 | + | require.Equal(t, requested, popup.popupGeometry) | |||
| 645 | + | require.Equal(t, committed.Inner, popup.rect) | |||
| 646 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 647 | + | popup.mu.Unlock() | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) { | |||
| 651 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 652 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |||
| 653 | + | pty := &transactionalResizePTY{} | |||
| 654 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 655 | + | popup.rect = committed.Inner | |||
| 656 | + | popup.popupGeometry = committed | |||
| 657 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 658 | + | installTestFloating(tb, popup, true) | |||
| 659 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 660 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 661 | + | ||||
| 662 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false }) | |||
| 663 | + | ||||
| 664 | + | require.False(t, ok) | |||
| 665 | + | require.Empty(t, failed) | |||
| 666 | + | require.Empty(t, pty.requested()) | |||
| 667 | + | popup.mu.Lock() | |||
| 668 | + | require.Equal(t, committed, popup.popupGeometry) | |||
| 669 | + | require.Equal(t, committed.Inner, popup.rect) | |||
| 670 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 671 | + | popup.mu.Unlock() | |||
| 672 | + | } | |||
| 673 | + | ||||
| 486 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | 674 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | ||
| 487 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | 675 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | ||
| 488 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 676 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||
M internal/usecase/ui/modal.go +65 -39
| @@ -18,8 +18,7 @@ type Modal struct { | |||
| 18 | 18 | FixedHeight int | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | // Bounds returns the modal rectangle positioned within base and clamped to base. | |
| 22 | - | func (m Modal) Bounds(base domain.Size) domain.Rect { | |
| 21 | + | func (m Modal) preferredBounds(base domain.Size) domain.Rect { | |
| 23 | 22 | width := percentOf(base.Cols, m.WidthPct) | |
| 24 | 23 | height := percentOf(base.Rows, m.HeightPct) | |
| 25 | 24 | if m.FixedWidth > 0 { | |
| @@ -37,38 +36,41 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 37 | 36 | return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins) | |
| 38 | 37 | } | |
| 39 | 38 | ||
| 40 | - | // Inner returns the modal content rectangle after removing a one-cell border. | |
| 41 | - | func (m Modal) Inner(base domain.Size) domain.Rect { | |
| 42 | - | bounds := m.Bounds(base) | |
| 43 | - | return domain.Rect{ | |
| 44 | - | X: bounds.X + 1, | |
| 45 | - | Y: bounds.Y + 1, | |
| 46 | - | Width: max(0, bounds.Width-2), | |
| 47 | - | Height: max(0, bounds.Height-2), | |
| 39 | + | // Resolve computes the modal's responsive presentation from one preferred | |
| 40 | + | // bounds and inner pair. | |
| 41 | + | func (m Modal) Resolve(base domain.Size) Presentation { | |
| 42 | + | bounds := m.preferredBounds(base) | |
| 43 | + | return ResolvePresentation(base, bounds, modalInner(bounds)) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // CompositePresentation draws an already-resolved modal presentation and | |
| 47 | + | // returns its inner content rectangle. | |
| 48 | + | func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect { | |
| 49 | + | FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior}) | |
| 50 | + | drawBorderEdges(f, p.Bounds, p.Borders, border) | |
| 51 | + | if p.Borders&BorderTop != 0 { | |
| 52 | + | m.drawTitle(f, p.Bounds, border) | |
| 53 | + | } | |
| 54 | + | return p.Inner | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) { | |
| 58 | + | if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" { | |
| 59 | + | return | |
| 48 | 60 | } | |
| 61 | + | left := bounds.X + 1 | |
| 62 | + | right := bounds.X + bounds.Width - 1 | |
| 63 | + | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |
| 64 | + | DrawText(f, start, bounds.Y, right, m.Title, style) | |
| 49 | 65 | } | |
| 50 | 66 | ||
| 51 | - | // Composite draws the modal border and title, fills its interior, and returns | |
| 52 | - | // the inner content rectangle. Border and interior styles are deliberately | |
| 53 | - | // independent so unfocused structure and chrome surfaces retain their roles. | |
| 54 | - | // Cells outside the modal bounds are not changed. | |
| 55 | - | func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect { | |
| 56 | - | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |
| 57 | - | inner := domain.Rect{ | |
| 67 | + | func modalInner(bounds domain.Rect) domain.Rect { | |
| 68 | + | return domain.Rect{ | |
| 58 | 69 | X: bounds.X + 1, | |
| 59 | 70 | Y: bounds.Y + 1, | |
| 60 | 71 | Width: max(0, bounds.Width-2), | |
| 61 | 72 | Height: max(0, bounds.Height-2), | |
| 62 | 73 | } | |
| 63 | - | DrawBox(f, bounds, border) | |
| 64 | - | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | |
| 65 | - | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |
| 66 | - | left := bounds.X + 1 | |
| 67 | - | right := bounds.X + bounds.Width - 1 | |
| 68 | - | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |
| 69 | - | DrawText(f, start, bounds.Y, right, m.Title, border) | |
| 70 | - | } | |
| 71 | - | return inner | |
| 72 | 74 | } | |
| 73 | 75 | ||
| 74 | 76 | // FillRect fills rect with cell, clipped to the frame bounds. | |
| @@ -86,7 +88,11 @@ func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | |||
| 86 | 88 | ||
| 87 | 89 | // DrawBox draws a single-cell box clipped to the frame bounds. | |
| 88 | 90 | func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | |
| 89 | - | if rect.Width <= 0 || rect.Height <= 0 { | |
| 91 | + | drawBorderEdges(f, rect, BorderAll, style) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) { | |
| 95 | + | if rect.Width <= 0 || rect.Height <= 0 || edges == 0 { | |
| 90 | 96 | return | |
| 91 | 97 | } | |
| 92 | 98 | left := rect.X | |
| @@ -94,18 +100,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | |||
| 94 | 100 | right := rect.X + rect.Width - 1 | |
| 95 | 101 | bottom := rect.Y + rect.Height - 1 | |
| 96 | 102 | ||
| 97 | - | for x := left; x <= right; x++ { | |
| 98 | - | setCell(f, x, top, '─', style) | |
| 99 | - | setCell(f, x, bottom, '─', style) | |
| 100 | - | } | |
| 101 | - | for y := top; y <= bottom; y++ { | |
| 102 | - | setCell(f, left, y, '│', style) | |
| 103 | - | setCell(f, right, y, '│', style) | |
| 104 | - | } | |
| 105 | - | setCell(f, left, top, '┌', style) | |
| 106 | - | setCell(f, right, top, '┐', style) | |
| 107 | - | setCell(f, left, bottom, '└', style) | |
| 108 | - | setCell(f, right, bottom, '┘', style) | |
| 103 | + | if edges&BorderTop != 0 { | |
| 104 | + | for x := left; x <= right; x++ { | |
| 105 | + | setCell(f, x, top, '─', style) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if edges&BorderBottom != 0 { | |
| 109 | + | for x := left; x <= right; x++ { | |
| 110 | + | setCell(f, x, bottom, '─', style) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if edges&BorderLeft != 0 { | |
| 114 | + | for y := top; y <= bottom; y++ { | |
| 115 | + | setCell(f, left, y, '│', style) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | if edges&BorderRight != 0 { | |
| 119 | + | for y := top; y <= bottom; y++ { | |
| 120 | + | setCell(f, right, y, '│', style) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft { | |
| 124 | + | setCell(f, left, top, '┌', style) | |
| 125 | + | } | |
| 126 | + | if edges&(BorderTop|BorderRight) == BorderTop|BorderRight { | |
| 127 | + | setCell(f, right, top, '┐', style) | |
| 128 | + | } | |
| 129 | + | if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft { | |
| 130 | + | setCell(f, left, bottom, '└', style) | |
| 131 | + | } | |
| 132 | + | if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight { | |
| 133 | + | setCell(f, right, bottom, '┘', style) | |
| 134 | + | } | |
| 109 | 135 | } | |
| 110 | 136 | ||
| 111 | 137 | // DrawText draws text starting at x,y before exclusive clipX and returns the | |
| @@ -18,8 +18,7 @@ type Modal struct { | |||||
| 18 | FixedHeight int | 18 | FixedHeight int | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | // Bounds returns the modal rectangle positioned within base and clamped to base. | 21 | + | func (m Modal) preferredBounds(base domain.Size) domain.Rect { |
| 22 | - | func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 23 | width := percentOf(base.Cols, m.WidthPct) | 22 | width := percentOf(base.Cols, m.WidthPct) | ||
| 24 | height := percentOf(base.Rows, m.HeightPct) | 23 | height := percentOf(base.Rows, m.HeightPct) | ||
| 25 | if m.FixedWidth > 0 { | 24 | if m.FixedWidth > 0 { | ||
| @@ -37,38 +36,41 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||||
| 37 | return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins) | 36 | return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins) | ||
| 38 | } | 37 | } | ||
| 39 | 38 | ||||
| 40 | - | // Inner returns the modal content rectangle after removing a one-cell border. | 39 | + | // Resolve computes the modal's responsive presentation from one preferred |
| 41 | - | func (m Modal) Inner(base domain.Size) domain.Rect { | 40 | + | // bounds and inner pair. |
| 42 | - | bounds := m.Bounds(base) | 41 | + | func (m Modal) Resolve(base domain.Size) Presentation { |
| 43 | - | return domain.Rect{ | 42 | + | bounds := m.preferredBounds(base) |
| 44 | - | X: bounds.X + 1, | 43 | + | return ResolvePresentation(base, bounds, modalInner(bounds)) |
| 45 | - | Y: bounds.Y + 1, | 44 | + | } |
| 46 | - | Width: max(0, bounds.Width-2), | 45 | + | |
| 47 | - | Height: max(0, bounds.Height-2), | 46 | + | // CompositePresentation draws an already-resolved modal presentation and |
| 47 | + | // returns its inner content rectangle. | |||
| 48 | + | func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect { | |||
| 49 | + | FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior}) | |||
| 50 | + | drawBorderEdges(f, p.Bounds, p.Borders, border) | |||
| 51 | + | if p.Borders&BorderTop != 0 { | |||
| 52 | + | m.drawTitle(f, p.Bounds, border) | |||
| 53 | + | } | |||
| 54 | + | return p.Inner | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) { | |||
| 58 | + | if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" { | |||
| 59 | + | return | |||
| 48 | } | 60 | } | ||
| 61 | + | left := bounds.X + 1 | |||
| 62 | + | right := bounds.X + bounds.Width - 1 | |||
| 63 | + | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |||
| 64 | + | DrawText(f, start, bounds.Y, right, m.Title, style) | |||
| 49 | } | 65 | } | ||
| 50 | 66 | ||||
| 51 | - | // Composite draws the modal border and title, fills its interior, and returns | 67 | + | func modalInner(bounds domain.Rect) domain.Rect { |
| 52 | - | // the inner content rectangle. Border and interior styles are deliberately | 68 | + | return domain.Rect{ |
| 53 | - | // independent so unfocused structure and chrome surfaces retain their roles. | |||
| 54 | - | // Cells outside the modal bounds are not changed. | |||
| 55 | - | func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect { | |||
| 56 | - | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |||
| 57 | - | inner := domain.Rect{ | |||
| 58 | X: bounds.X + 1, | 69 | X: bounds.X + 1, | ||
| 59 | Y: bounds.Y + 1, | 70 | Y: bounds.Y + 1, | ||
| 60 | Width: max(0, bounds.Width-2), | 71 | Width: max(0, bounds.Width-2), | ||
| 61 | Height: max(0, bounds.Height-2), | 72 | Height: max(0, bounds.Height-2), | ||
| 62 | } | 73 | } | ||
| 63 | - | DrawBox(f, bounds, border) | |||
| 64 | - | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | |||
| 65 | - | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |||
| 66 | - | left := bounds.X + 1 | |||
| 67 | - | right := bounds.X + bounds.Width - 1 | |||
| 68 | - | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |||
| 69 | - | DrawText(f, start, bounds.Y, right, m.Title, border) | |||
| 70 | - | } | |||
| 71 | - | return inner | |||
| 72 | } | 74 | } | ||
| 73 | 75 | ||||
| 74 | // FillRect fills rect with cell, clipped to the frame bounds. | 76 | // FillRect fills rect with cell, clipped to the frame bounds. | ||
| @@ -86,7 +88,11 @@ func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | |||||
| 86 | 88 | ||||
| 87 | // DrawBox draws a single-cell box clipped to the frame bounds. | 89 | // DrawBox draws a single-cell box clipped to the frame bounds. | ||
| 88 | func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | 90 | func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | ||
| 89 | - | if rect.Width <= 0 || rect.Height <= 0 { | 91 | + | drawBorderEdges(f, rect, BorderAll, style) |
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) { | |||
| 95 | + | if rect.Width <= 0 || rect.Height <= 0 || edges == 0 { | |||
| 90 | return | 96 | return | ||
| 91 | } | 97 | } | ||
| 92 | left := rect.X | 98 | left := rect.X | ||
| @@ -94,18 +100,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | |||||
| 94 | right := rect.X + rect.Width - 1 | 100 | right := rect.X + rect.Width - 1 | ||
| 95 | bottom := rect.Y + rect.Height - 1 | 101 | bottom := rect.Y + rect.Height - 1 | ||
| 96 | 102 | ||||
| 97 | - | for x := left; x <= right; x++ { | 103 | + | if edges&BorderTop != 0 { |
| 98 | - | setCell(f, x, top, '─', style) | 104 | + | for x := left; x <= right; x++ { |
| 99 | - | setCell(f, x, bottom, '─', style) | 105 | + | setCell(f, x, top, '─', style) |
| 100 | - | } | 106 | + | } |
| 101 | - | for y := top; y <= bottom; y++ { | 107 | + | } |
| 102 | - | setCell(f, left, y, '│', style) | 108 | + | if edges&BorderBottom != 0 { |
| 103 | - | setCell(f, right, y, '│', style) | 109 | + | for x := left; x <= right; x++ { |
| 104 | - | } | 110 | + | setCell(f, x, bottom, '─', style) |
| 105 | - | setCell(f, left, top, '┌', style) | 111 | + | } |
| 106 | - | setCell(f, right, top, '┐', style) | 112 | + | } |
| 107 | - | setCell(f, left, bottom, '└', style) | 113 | + | if edges&BorderLeft != 0 { |
| 108 | - | setCell(f, right, bottom, '┘', style) | 114 | + | for y := top; y <= bottom; y++ { |
| 115 | + | setCell(f, left, y, '│', style) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | if edges&BorderRight != 0 { | |||
| 119 | + | for y := top; y <= bottom; y++ { | |||
| 120 | + | setCell(f, right, y, '│', style) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft { | |||
| 124 | + | setCell(f, left, top, '┌', style) | |||
| 125 | + | } | |||
| 126 | + | if edges&(BorderTop|BorderRight) == BorderTop|BorderRight { | |||
| 127 | + | setCell(f, right, top, '┐', style) | |||
| 128 | + | } | |||
| 129 | + | if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft { | |||
| 130 | + | setCell(f, left, bottom, '└', style) | |||
| 131 | + | } | |||
| 132 | + | if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight { | |||
| 133 | + | setCell(f, right, bottom, '┘', style) | |||
| 134 | + | } | |||
| 109 | } | 135 | } | ||
| 110 | 136 | ||||
| 111 | // DrawText draws text starting at x,y before exclusive clipX and returns the | 137 | // DrawText draws text starting at x,y before exclusive clipX and returns the | ||
M internal/usecase/ui/modal_test.go +178 -72
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func TestModalBounds(t *testing.T) { | |
| 11 | + | func TestModalResolveBounds(t *testing.T) { | |
| 12 | 12 | tests := []struct { | |
| 13 | 13 | name string | |
| 14 | 14 | base domain.Size | |
| @@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) { | |||
| 22 | 22 | want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, | |
| 23 | 23 | }, | |
| 24 | 24 | { | |
| 25 | - | name: "minimum clamps to base", | |
| 25 | + | name: "minimum clamps to drawer availability", | |
| 26 | 26 | base: domain.Size{Cols: 20, Rows: 6}, | |
| 27 | 27 | m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12}, | |
| 28 | - | want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}, | |
| 28 | + | want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}, | |
| 29 | 29 | }, | |
| 30 | 30 | { | |
| 31 | - | name: "odd centering rounds down", | |
| 31 | + | name: "narrow preferred height becomes drawer height", | |
| 32 | 32 | base: domain.Size{Cols: 11, Rows: 7}, | |
| 33 | 33 | m: Modal{WidthPct: 50, HeightPct: 50}, | |
| 34 | - | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | |
| 34 | + | want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3}, | |
| 35 | 35 | }, | |
| 36 | 36 | { | |
| 37 | - | name: "zero value fills base centered", | |
| 37 | + | name: "zero value fills available drawer area", | |
| 38 | 38 | base: domain.Size{Cols: 12, Rows: 8}, | |
| 39 | 39 | m: Modal{}, | |
| 40 | - | want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8}, | |
| 40 | + | want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4}, | |
| 41 | 41 | }, | |
| 42 | 42 | { | |
| 43 | 43 | name: "bottom anchor honors bottom margin", | |
| @@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) { | |||
| 63 | 63 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | |
| 64 | 64 | }, | |
| 65 | 65 | { | |
| 66 | - | name: "right margin clamps to screen", | |
| 66 | + | name: "drawer ignores right margin", | |
| 67 | 67 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 68 | 68 | m: Modal{ | |
| 69 | 69 | FixedWidth: 8, | |
| @@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) { | |||
| 71 | 71 | Anchor: domain.AnchorRight, | |
| 72 | 72 | Margins: Margins{Right: 20}, | |
| 73 | 73 | }, | |
| 74 | - | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | |
| 74 | + | want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, | |
| 75 | 75 | }, | |
| 76 | 76 | { | |
| 77 | - | name: "right anchored fixed dimensions clamp on small terminal", | |
| 77 | + | name: "right anchored drawer can have zero height", | |
| 78 | 78 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 79 | 79 | m: Modal{ | |
| 80 | 80 | FixedWidth: 30, | |
| @@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) { | |||
| 82 | 82 | Anchor: domain.AnchorRight, | |
| 83 | 83 | Margins: Margins{Right: 3}, | |
| 84 | 84 | }, | |
| 85 | - | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 85 | + | want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0}, | |
| 86 | 86 | }, | |
| 87 | 87 | { | |
| 88 | - | name: "fixed dimensions clamp on small terminal", | |
| 88 | + | name: "fixed drawer dimensions clamp on small terminal", | |
| 89 | 89 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 90 | 90 | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}}, | |
| 91 | - | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | |
| 91 | + | want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0}, | |
| 92 | 92 | }, | |
| 93 | 93 | { | |
| 94 | - | name: "bottom margin clamps to screen", | |
| 94 | + | name: "drawer ignores bottom margin", | |
| 95 | 95 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 96 | 96 | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}}, | |
| 97 | - | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | |
| 97 | + | want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, | |
| 98 | 98 | }, | |
| 99 | 99 | { | |
| 100 | 100 | name: "bottom right anchor honors combined margins", | |
| @@ -110,58 +110,171 @@ func TestModalBounds(t *testing.T) { | |||
| 110 | 110 | } | |
| 111 | 111 | for _, tt := range tests { | |
| 112 | 112 | t.Run(tt.name, func(t *testing.T) { | |
| 113 | - | if got := tt.m.Bounds(tt.base); got != tt.want { | |
| 114 | - | t.Fatalf("Bounds() = %+v, want %+v", got, tt.want) | |
| 113 | + | if got := tt.m.Resolve(tt.base).Bounds; got != tt.want { | |
| 114 | + | t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want) | |
| 115 | 115 | } | |
| 116 | 116 | }) | |
| 117 | 117 | } | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | - | func TestModalInnerDegenerate(t *testing.T) { | |
| 121 | - | m := Modal{WidthPct: 100, HeightPct: 100} | |
| 122 | - | got := m.Inner(domain.Size{Cols: 2, Rows: 2}) | |
| 123 | - | want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0} | |
| 124 | - | if got != want { | |
| 125 | - | t.Fatalf("Inner() = %+v, want %+v", got, want) | |
| 126 | - | } | |
| 120 | + | func TestModalResolveInnerDegenerate(t *testing.T) { | |
| 121 | + | got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner | |
| 122 | + | require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got) | |
| 127 | 123 | } | |
| 128 | 124 | ||
| 129 | - | func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |
| 130 | - | f := renderer.NewFrame(10, 6) | |
| 131 | - | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 132 | - | FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior) | |
| 125 | + | func TestModalResolve(t *testing.T) { | |
| 126 | + | tests := []struct { | |
| 127 | + | name string | |
| 128 | + | base domain.Size | |
| 129 | + | want Presentation | |
| 130 | + | }{ | |
| 131 | + | { | |
| 132 | + | name: "narrow modal resolves to drawer", | |
| 133 | + | base: domain.Size{Cols: 20, Rows: 10}, | |
| 134 | + | want: Presentation{ | |
| 135 | + | Mode: PresentationDrawer, | |
| 136 | + | Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, | |
| 137 | + | Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3}, | |
| 138 | + | Borders: BorderTop, | |
| 139 | + | }, | |
| 140 | + | }, | |
| 141 | + | { | |
| 142 | + | name: "complete frame preserves floating modal geometry", | |
| 143 | + | base: domain.Size{Cols: 80, Rows: 10}, | |
| 144 | + | want: Presentation{ | |
| 145 | + | Mode: PresentationFloating, | |
| 146 | + | Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4}, | |
| 147 | + | Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2}, | |
| 148 | + | Borders: BorderAll, | |
| 149 | + | }, | |
| 150 | + | }, | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |
| 154 | + | for _, tt := range tests { | |
| 155 | + | t.Run(tt.name, func(t *testing.T) { | |
| 156 | + | require.Equal(t, tt.want, modal.Resolve(tt.base)) | |
| 157 | + | }) | |
| 158 | + | } | |
| 159 | + | } | |
| 133 | 160 | ||
| 161 | + | func TestCompositePresentationDrawsFloatingChrome(t *testing.T) { | |
| 162 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |
| 163 | + | presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10}) | |
| 164 | + | frame := renderer.NewFrame(80, 10) | |
| 165 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |
| 166 | + | FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior) | |
| 134 | 167 | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 135 | 168 | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 136 | - | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | |
| 137 | - | inner := m.Composite(f, border, interior) | |
| 138 | - | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | |
| 139 | - | if inner != wantInner { | |
| 140 | - | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | |
| 169 | + | ||
| 170 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) | |
| 171 | + | require.Equal(t, presentation.Inner, inner) | |
| 172 | + | require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune) | |
| 173 | + | require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune) | |
| 174 | + | require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune) | |
| 175 | + | require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune) | |
| 176 | + | require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune) | |
| 177 | + | require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior)) | |
| 178 | + | assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) { | |
| 182 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 183 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |
| 184 | + | bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4} | |
| 185 | + | inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2} | |
| 186 | + | ||
| 187 | + | tests := []struct { | |
| 188 | + | name string | |
| 189 | + | presentation Presentation | |
| 190 | + | assert func(*testing.T, renderer.Frame) | |
| 191 | + | }{ | |
| 192 | + | { | |
| 193 | + | name: "all edges render for drawer mode", | |
| 194 | + | presentation: Presentation{ | |
| 195 | + | Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll, | |
| 196 | + | }, | |
| 197 | + | assert: func(t *testing.T, frame renderer.Frame) { | |
| 198 | + | require.Equal(t, '┌', frame.At(1, 1).Rune) | |
| 199 | + | require.Equal(t, '│', frame.At(1, 2).Rune) | |
| 200 | + | require.Equal(t, '┘', frame.At(8, 4).Rune) | |
| 201 | + | require.Equal(t, 'T', frame.At(2, 1).Rune) | |
| 202 | + | }, | |
| 203 | + | }, | |
| 204 | + | { | |
| 205 | + | name: "top edge alone renders for floating mode", | |
| 206 | + | presentation: Presentation{ | |
| 207 | + | Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop, | |
| 208 | + | }, | |
| 209 | + | assert: func(t *testing.T, frame renderer.Frame) { | |
| 210 | + | require.Equal(t, '─', frame.At(1, 1).Rune) | |
| 211 | + | require.Equal(t, 'T', frame.At(2, 1).Rune) | |
| 212 | + | assertCell(t, frame, 1, 2, exterior) | |
| 213 | + | assertCell(t, frame, 8, 4, exterior) | |
| 214 | + | }, | |
| 215 | + | }, | |
| 216 | + | { | |
| 217 | + | name: "title is hidden without top edge", | |
| 218 | + | presentation: Presentation{ | |
| 219 | + | Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft, | |
| 220 | + | }, | |
| 221 | + | assert: func(t *testing.T, frame renderer.Frame) { | |
| 222 | + | require.Equal(t, '│', frame.At(1, 1).Rune) | |
| 223 | + | require.Equal(t, '│', frame.At(1, 4).Rune) | |
| 224 | + | assertCell(t, frame, 4, 1, exterior) | |
| 225 | + | assertCell(t, frame, 8, 4, exterior) | |
| 226 | + | }, | |
| 227 | + | }, | |
| 141 | 228 | } | |
| 142 | 229 | ||
| 143 | - | assertRune(t, f, 2, 1, '┌') | |
| 144 | - | assertRune(t, f, 7, 1, '┐') | |
| 145 | - | assertRune(t, f, 2, 4, '└') | |
| 146 | - | assertRune(t, f, 7, 4, '┘') | |
| 147 | - | assertRune(t, f, 3, 1, '─') | |
| 148 | - | assertRune(t, f, 2, 2, '│') | |
| 149 | - | assertRune(t, f, 4, 1, 'H') | |
| 150 | - | assertRune(t, f, 5, 1, 'i') | |
| 151 | - | ||
| 152 | - | for y := inner.Y; y < inner.Y+inner.Height; y++ { | |
| 153 | - | for x := inner.X; x < inner.X+inner.Width; x++ { | |
| 154 | - | want := renderer.Cell{Rune: ' ', Style: interior} | |
| 155 | - | if got := f.At(x, y); !got.Equal(want) { | |
| 156 | - | t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want) | |
| 157 | - | } | |
| 158 | - | } | |
| 230 | + | for _, tt := range tests { | |
| 231 | + | t.Run(tt.name, func(t *testing.T) { | |
| 232 | + | frame := renderer.NewFrame(10, 6) | |
| 233 | + | FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior) | |
| 234 | + | (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle()) | |
| 235 | + | tt.assert(t, frame) | |
| 236 | + | }) | |
| 159 | 237 | } | |
| 160 | - | if got := f.At(2, 1).Style; !got.Equal(border) { | |
| 161 | - | t.Fatalf("border style = %+v, want %+v", got, border) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) { | |
| 241 | + | modal := Modal{FixedHeight: 4, Title: " Rename "} | |
| 242 | + | presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10}) | |
| 243 | + | frame := renderer.NewFrame(20, 10) | |
| 244 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |
| 245 | + | FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior) | |
| 246 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 247 | + | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 248 | + | ||
| 249 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) | |
| 250 | + | require.Equal(t, presentation.Inner, inner) | |
| 251 | + | require.Equal(t, '─', frame.At(0, 5).Rune) | |
| 252 | + | require.True(t, frame.At(0, 5).Style.Equal(border)) | |
| 253 | + | require.Equal(t, 'R', frame.At(7, 5).Rune) | |
| 254 | + | require.NotEqual(t, '│', frame.At(0, 6).Rune) | |
| 255 | + | require.Equal(t, ' ', frame.At(0, 6).Rune) | |
| 256 | + | require.NotEqual(t, '─', frame.At(0, 8).Rune) | |
| 257 | + | require.Equal(t, ' ', frame.At(0, 8).Rune) | |
| 258 | + | ||
| 259 | + | for _, y := range []int{0, 1, 2, 3, 4, 9} { | |
| 260 | + | for x := range frame.Width { | |
| 261 | + | assertCell(t, frame, x, y, exterior) | |
| 262 | + | } | |
| 162 | 263 | } | |
| 163 | - | assertCell(t, f, 0, 0, exterior) | |
| 164 | - | assertCell(t, f, 9, 5, exterior) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) { | |
| 267 | + | modal := Modal{FixedHeight: 4, Title: "你你"} | |
| 268 | + | presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10}) | |
| 269 | + | frame := renderer.NewFrame(4, 10) | |
| 270 | + | border := renderer.Style{Foreground: 2, Background: -1} | |
| 271 | + | ||
| 272 | + | modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle()) | |
| 273 | + | ||
| 274 | + | require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune) | |
| 275 | + | require.True(t, frame.At(2, presentation.Bounds.Y).Continuation) | |
| 276 | + | require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune) | |
| 277 | + | require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip") | |
| 165 | 278 | } | |
| 166 | 279 | ||
| 167 | 280 | func TestFillRectClips(t *testing.T) { | |
| @@ -221,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) { | |||
| 221 | 334 | } | |
| 222 | 335 | } | |
| 223 | 336 | ||
| 224 | - | func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) { | |
| 337 | + | func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) { | |
| 225 | 338 | border := renderer.Style{Foreground: 1, Background: 2} | |
| 226 | 339 | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 227 | - | for _, tt := range []struct { | |
| 228 | - | name string | |
| 229 | - | size domain.Size | |
| 230 | - | want domain.Rect | |
| 231 | - | }{ | |
| 232 | - | {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}}, | |
| 233 | - | {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}}, | |
| 234 | - | } { | |
| 235 | - | t.Run(tt.name, func(t *testing.T) { | |
| 236 | - | frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows) | |
| 237 | - | got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior) | |
| 238 | - | require.Equal(t, tt.want, got) | |
| 239 | - | for y := range frame.Height { | |
| 240 | - | for x := range frame.Width { | |
| 241 | - | require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds") | |
| 242 | - | } | |
| 243 | - | } | |
| 244 | - | }) | |
| 340 | + | frame := renderer.NewFrame(2, 2) | |
| 341 | + | modal := Modal{WidthPct: 100, HeightPct: 100} | |
| 342 | + | presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2}) | |
| 343 | + | ||
| 344 | + | got := modal.CompositePresentation(frame, presentation, border, interior) | |
| 345 | + | ||
| 346 | + | require.Equal(t, presentation.Inner, got) | |
| 347 | + | for y := range frame.Height { | |
| 348 | + | for x := range frame.Width { | |
| 349 | + | require.Equal(t, renderer.BlankCell(), frame.At(x, y)) | |
| 350 | + | } | |
| 245 | 351 | } | |
| 246 | 352 | } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | func TestModalBounds(t *testing.T) { | 11 | + | func TestModalResolveBounds(t *testing.T) { |
| 12 | tests := []struct { | 12 | tests := []struct { | ||
| 13 | name string | 13 | name string | ||
| 14 | base domain.Size | 14 | base domain.Size | ||
| @@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) { | |||||
| 22 | want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, | 22 | want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, | ||
| 23 | }, | 23 | }, | ||
| 24 | { | 24 | { | ||
| 25 | - | name: "minimum clamps to base", | 25 | + | name: "minimum clamps to drawer availability", |
| 26 | base: domain.Size{Cols: 20, Rows: 6}, | 26 | base: domain.Size{Cols: 20, Rows: 6}, | ||
| 27 | m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12}, | 27 | m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12}, | ||
| 28 | - | want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}, | 28 | + | want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}, |
| 29 | }, | 29 | }, | ||
| 30 | { | 30 | { | ||
| 31 | - | name: "odd centering rounds down", | 31 | + | name: "narrow preferred height becomes drawer height", |
| 32 | base: domain.Size{Cols: 11, Rows: 7}, | 32 | base: domain.Size{Cols: 11, Rows: 7}, | ||
| 33 | m: Modal{WidthPct: 50, HeightPct: 50}, | 33 | m: Modal{WidthPct: 50, HeightPct: 50}, | ||
| 34 | - | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | 34 | + | want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3}, |
| 35 | }, | 35 | }, | ||
| 36 | { | 36 | { | ||
| 37 | - | name: "zero value fills base centered", | 37 | + | name: "zero value fills available drawer area", |
| 38 | base: domain.Size{Cols: 12, Rows: 8}, | 38 | base: domain.Size{Cols: 12, Rows: 8}, | ||
| 39 | m: Modal{}, | 39 | m: Modal{}, | ||
| 40 | - | want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8}, | 40 | + | want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4}, |
| 41 | }, | 41 | }, | ||
| 42 | { | 42 | { | ||
| 43 | name: "bottom anchor honors bottom margin", | 43 | name: "bottom anchor honors bottom margin", | ||
| @@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) { | |||||
| 63 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | 63 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | ||
| 64 | }, | 64 | }, | ||
| 65 | { | 65 | { | ||
| 66 | - | name: "right margin clamps to screen", | 66 | + | name: "drawer ignores right margin", |
| 67 | base: domain.Size{Cols: 20, Rows: 10}, | 67 | base: domain.Size{Cols: 20, Rows: 10}, | ||
| 68 | m: Modal{ | 68 | m: Modal{ | ||
| 69 | FixedWidth: 8, | 69 | FixedWidth: 8, | ||
| @@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) { | |||||
| 71 | Anchor: domain.AnchorRight, | 71 | Anchor: domain.AnchorRight, | ||
| 72 | Margins: Margins{Right: 20}, | 72 | Margins: Margins{Right: 20}, | ||
| 73 | }, | 73 | }, | ||
| 74 | - | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | 74 | + | want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, |
| 75 | }, | 75 | }, | ||
| 76 | { | 76 | { | ||
| 77 | - | name: "right anchored fixed dimensions clamp on small terminal", | 77 | + | name: "right anchored drawer can have zero height", |
| 78 | base: domain.Size{Cols: 10, Rows: 4}, | 78 | base: domain.Size{Cols: 10, Rows: 4}, | ||
| 79 | m: Modal{ | 79 | m: Modal{ | ||
| 80 | FixedWidth: 30, | 80 | FixedWidth: 30, | ||
| @@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) { | |||||
| 82 | Anchor: domain.AnchorRight, | 82 | Anchor: domain.AnchorRight, | ||
| 83 | Margins: Margins{Right: 3}, | 83 | Margins: Margins{Right: 3}, | ||
| 84 | }, | 84 | }, | ||
| 85 | - | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | 85 | + | want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0}, |
| 86 | }, | 86 | }, | ||
| 87 | { | 87 | { | ||
| 88 | - | name: "fixed dimensions clamp on small terminal", | 88 | + | name: "fixed drawer dimensions clamp on small terminal", |
| 89 | base: domain.Size{Cols: 10, Rows: 4}, | 89 | base: domain.Size{Cols: 10, Rows: 4}, | ||
| 90 | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}}, | 90 | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}}, | ||
| 91 | - | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | 91 | + | want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0}, |
| 92 | }, | 92 | }, | ||
| 93 | { | 93 | { | ||
| 94 | - | name: "bottom margin clamps to screen", | 94 | + | name: "drawer ignores bottom margin", |
| 95 | base: domain.Size{Cols: 20, Rows: 10}, | 95 | base: domain.Size{Cols: 20, Rows: 10}, | ||
| 96 | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}}, | 96 | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}}, | ||
| 97 | - | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | 97 | + | want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, |
| 98 | }, | 98 | }, | ||
| 99 | { | 99 | { | ||
| 100 | name: "bottom right anchor honors combined margins", | 100 | name: "bottom right anchor honors combined margins", | ||
| @@ -110,58 +110,171 @@ func TestModalBounds(t *testing.T) { | |||||
| 110 | } | 110 | } | ||
| 111 | for _, tt := range tests { | 111 | for _, tt := range tests { | ||
| 112 | t.Run(tt.name, func(t *testing.T) { | 112 | t.Run(tt.name, func(t *testing.T) { | ||
| 113 | - | if got := tt.m.Bounds(tt.base); got != tt.want { | 113 | + | if got := tt.m.Resolve(tt.base).Bounds; got != tt.want { |
| 114 | - | t.Fatalf("Bounds() = %+v, want %+v", got, tt.want) | 114 | + | t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want) |
| 115 | } | 115 | } | ||
| 116 | }) | 116 | }) | ||
| 117 | } | 117 | } | ||
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | - | func TestModalInnerDegenerate(t *testing.T) { | 120 | + | func TestModalResolveInnerDegenerate(t *testing.T) { |
| 121 | - | m := Modal{WidthPct: 100, HeightPct: 100} | 121 | + | got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner |
| 122 | - | got := m.Inner(domain.Size{Cols: 2, Rows: 2}) | 122 | + | require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got) |
| 123 | - | want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0} | |||
| 124 | - | if got != want { | |||
| 125 | - | t.Fatalf("Inner() = %+v, want %+v", got, want) | |||
| 126 | - | } | |||
| 127 | } | 123 | } | ||
| 128 | 124 | ||||
| 129 | - | func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | 125 | + | func TestModalResolve(t *testing.T) { |
| 130 | - | f := renderer.NewFrame(10, 6) | 126 | + | tests := []struct { |
| 131 | - | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | 127 | + | name string |
| 132 | - | FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior) | 128 | + | base domain.Size |
| 129 | + | want Presentation | |||
| 130 | + | }{ | |||
| 131 | + | { | |||
| 132 | + | name: "narrow modal resolves to drawer", | |||
| 133 | + | base: domain.Size{Cols: 20, Rows: 10}, | |||
| 134 | + | want: Presentation{ | |||
| 135 | + | Mode: PresentationDrawer, | |||
| 136 | + | Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, | |||
| 137 | + | Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3}, | |||
| 138 | + | Borders: BorderTop, | |||
| 139 | + | }, | |||
| 140 | + | }, | |||
| 141 | + | { | |||
| 142 | + | name: "complete frame preserves floating modal geometry", | |||
| 143 | + | base: domain.Size{Cols: 80, Rows: 10}, | |||
| 144 | + | want: Presentation{ | |||
| 145 | + | Mode: PresentationFloating, | |||
| 146 | + | Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4}, | |||
| 147 | + | Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2}, | |||
| 148 | + | Borders: BorderAll, | |||
| 149 | + | }, | |||
| 150 | + | }, | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |||
| 154 | + | for _, tt := range tests { | |||
| 155 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 156 | + | require.Equal(t, tt.want, modal.Resolve(tt.base)) | |||
| 157 | + | }) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 133 | 160 | ||||
| 161 | + | func TestCompositePresentationDrawsFloatingChrome(t *testing.T) { | |||
| 162 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |||
| 163 | + | presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10}) | |||
| 164 | + | frame := renderer.NewFrame(80, 10) | |||
| 165 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |||
| 166 | + | FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior) | |||
| 134 | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | 167 | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | ||
| 135 | interior := renderer.Style{Foreground: 3, Background: 4} | 168 | interior := renderer.Style{Foreground: 3, Background: 4} | ||
| 136 | - | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | 169 | + | |
| 137 | - | inner := m.Composite(f, border, interior) | 170 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) |
| 138 | - | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | 171 | + | require.Equal(t, presentation.Inner, inner) |
| 139 | - | if inner != wantInner { | 172 | + | require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune) |
| 140 | - | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | 173 | + | require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune) |
| 174 | + | require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune) | |||
| 175 | + | require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune) | |||
| 176 | + | require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune) | |||
| 177 | + | require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior)) | |||
| 178 | + | assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) { | |||
| 182 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |||
| 183 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |||
| 184 | + | bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4} | |||
| 185 | + | inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2} | |||
| 186 | + | ||||
| 187 | + | tests := []struct { | |||
| 188 | + | name string | |||
| 189 | + | presentation Presentation | |||
| 190 | + | assert func(*testing.T, renderer.Frame) | |||
| 191 | + | }{ | |||
| 192 | + | { | |||
| 193 | + | name: "all edges render for drawer mode", | |||
| 194 | + | presentation: Presentation{ | |||
| 195 | + | Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll, | |||
| 196 | + | }, | |||
| 197 | + | assert: func(t *testing.T, frame renderer.Frame) { | |||
| 198 | + | require.Equal(t, '┌', frame.At(1, 1).Rune) | |||
| 199 | + | require.Equal(t, '│', frame.At(1, 2).Rune) | |||
| 200 | + | require.Equal(t, '┘', frame.At(8, 4).Rune) | |||
| 201 | + | require.Equal(t, 'T', frame.At(2, 1).Rune) | |||
| 202 | + | }, | |||
| 203 | + | }, | |||
| 204 | + | { | |||
| 205 | + | name: "top edge alone renders for floating mode", | |||
| 206 | + | presentation: Presentation{ | |||
| 207 | + | Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop, | |||
| 208 | + | }, | |||
| 209 | + | assert: func(t *testing.T, frame renderer.Frame) { | |||
| 210 | + | require.Equal(t, '─', frame.At(1, 1).Rune) | |||
| 211 | + | require.Equal(t, 'T', frame.At(2, 1).Rune) | |||
| 212 | + | assertCell(t, frame, 1, 2, exterior) | |||
| 213 | + | assertCell(t, frame, 8, 4, exterior) | |||
| 214 | + | }, | |||
| 215 | + | }, | |||
| 216 | + | { | |||
| 217 | + | name: "title is hidden without top edge", | |||
| 218 | + | presentation: Presentation{ | |||
| 219 | + | Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft, | |||
| 220 | + | }, | |||
| 221 | + | assert: func(t *testing.T, frame renderer.Frame) { | |||
| 222 | + | require.Equal(t, '│', frame.At(1, 1).Rune) | |||
| 223 | + | require.Equal(t, '│', frame.At(1, 4).Rune) | |||
| 224 | + | assertCell(t, frame, 4, 1, exterior) | |||
| 225 | + | assertCell(t, frame, 8, 4, exterior) | |||
| 226 | + | }, | |||
| 227 | + | }, | |||
| 141 | } | 228 | } | ||
| 142 | 229 | ||||
| 143 | - | assertRune(t, f, 2, 1, '┌') | 230 | + | for _, tt := range tests { |
| 144 | - | assertRune(t, f, 7, 1, '┐') | 231 | + | t.Run(tt.name, func(t *testing.T) { |
| 145 | - | assertRune(t, f, 2, 4, '└') | 232 | + | frame := renderer.NewFrame(10, 6) |
| 146 | - | assertRune(t, f, 7, 4, '┘') | 233 | + | FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior) |
| 147 | - | assertRune(t, f, 3, 1, '─') | 234 | + | (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle()) |
| 148 | - | assertRune(t, f, 2, 2, '│') | 235 | + | tt.assert(t, frame) |
| 149 | - | assertRune(t, f, 4, 1, 'H') | 236 | + | }) |
| 150 | - | assertRune(t, f, 5, 1, 'i') | |||
| 151 | - | ||||
| 152 | - | for y := inner.Y; y < inner.Y+inner.Height; y++ { | |||
| 153 | - | for x := inner.X; x < inner.X+inner.Width; x++ { | |||
| 154 | - | want := renderer.Cell{Rune: ' ', Style: interior} | |||
| 155 | - | if got := f.At(x, y); !got.Equal(want) { | |||
| 156 | - | t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | } | 237 | } | ||
| 160 | - | if got := f.At(2, 1).Style; !got.Equal(border) { | 238 | + | } |
| 161 | - | t.Fatalf("border style = %+v, want %+v", got, border) | 239 | + | |
| 240 | + | func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) { | |||
| 241 | + | modal := Modal{FixedHeight: 4, Title: " Rename "} | |||
| 242 | + | presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10}) | |||
| 243 | + | frame := renderer.NewFrame(20, 10) | |||
| 244 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |||
| 245 | + | FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior) | |||
| 246 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |||
| 247 | + | interior := renderer.Style{Foreground: 3, Background: 4} | |||
| 248 | + | ||||
| 249 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) | |||
| 250 | + | require.Equal(t, presentation.Inner, inner) | |||
| 251 | + | require.Equal(t, '─', frame.At(0, 5).Rune) | |||
| 252 | + | require.True(t, frame.At(0, 5).Style.Equal(border)) | |||
| 253 | + | require.Equal(t, 'R', frame.At(7, 5).Rune) | |||
| 254 | + | require.NotEqual(t, '│', frame.At(0, 6).Rune) | |||
| 255 | + | require.Equal(t, ' ', frame.At(0, 6).Rune) | |||
| 256 | + | require.NotEqual(t, '─', frame.At(0, 8).Rune) | |||
| 257 | + | require.Equal(t, ' ', frame.At(0, 8).Rune) | |||
| 258 | + | ||||
| 259 | + | for _, y := range []int{0, 1, 2, 3, 4, 9} { | |||
| 260 | + | for x := range frame.Width { | |||
| 261 | + | assertCell(t, frame, x, y, exterior) | |||
| 262 | + | } | |||
| 162 | } | 263 | } | ||
| 163 | - | assertCell(t, f, 0, 0, exterior) | 264 | + | } |
| 164 | - | assertCell(t, f, 9, 5, exterior) | 265 | + | |
| 266 | + | func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) { | |||
| 267 | + | modal := Modal{FixedHeight: 4, Title: "你你"} | |||
| 268 | + | presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10}) | |||
| 269 | + | frame := renderer.NewFrame(4, 10) | |||
| 270 | + | border := renderer.Style{Foreground: 2, Background: -1} | |||
| 271 | + | ||||
| 272 | + | modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle()) | |||
| 273 | + | ||||
| 274 | + | require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune) | |||
| 275 | + | require.True(t, frame.At(2, presentation.Bounds.Y).Continuation) | |||
| 276 | + | require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune) | |||
| 277 | + | require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip") | |||
| 165 | } | 278 | } | ||
| 166 | 279 | ||||
| 167 | func TestFillRectClips(t *testing.T) { | 280 | func TestFillRectClips(t *testing.T) { | ||
| @@ -221,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) { | |||||
| 221 | } | 334 | } | ||
| 222 | } | 335 | } | ||
| 223 | 336 | ||||
| 224 | - | func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) { | 337 | + | func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) { |
| 225 | border := renderer.Style{Foreground: 1, Background: 2} | 338 | border := renderer.Style{Foreground: 1, Background: 2} | ||
| 226 | interior := renderer.Style{Foreground: 3, Background: 4} | 339 | interior := renderer.Style{Foreground: 3, Background: 4} | ||
| 227 | - | for _, tt := range []struct { | 340 | + | frame := renderer.NewFrame(2, 2) |
| 228 | - | name string | 341 | + | modal := Modal{WidthPct: 100, HeightPct: 100} |
| 229 | - | size domain.Size | 342 | + | presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2}) |
| 230 | - | want domain.Rect | 343 | + | |
| 231 | - | }{ | 344 | + | got := modal.CompositePresentation(frame, presentation, border, interior) |
| 232 | - | {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}}, | 345 | + | |
| 233 | - | {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}}, | 346 | + | require.Equal(t, presentation.Inner, got) |
| 234 | - | } { | 347 | + | for y := range frame.Height { |
| 235 | - | t.Run(tt.name, func(t *testing.T) { | 348 | + | for x := range frame.Width { |
| 236 | - | frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows) | 349 | + | require.Equal(t, renderer.BlankCell(), frame.At(x, y)) |
| 237 | - | got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior) | 350 | + | } |
| 238 | - | require.Equal(t, tt.want, got) | |||
| 239 | - | for y := range frame.Height { | |||
| 240 | - | for x := range frame.Width { | |||
| 241 | - | require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds") | |||
| 242 | - | } | |||
| 243 | - | } | |||
| 244 | - | }) | |||
| 245 | } | 351 | } | ||
| 246 | } | 352 | } | ||
A internal/usecase/ui/presentation.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | ResponsiveDrawerBreakpoint = 80 | |
| 7 | + | DrawerContextRows = 3 | |
| 8 | + | DrawerBottomRows = 1 | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // PresentationMode describes whether an overlay uses its preferred floating | |
| 12 | + | // geometry or becomes a narrow-terminal drawer. | |
| 13 | + | type PresentationMode uint8 | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | PresentationFloating PresentationMode = iota | |
| 17 | + | PresentationDrawer | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // BorderEdges identifies the edges drawn around a presentation. | |
| 21 | + | type BorderEdges uint8 | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | BorderTop BorderEdges = 1 << iota | |
| 25 | + | BorderRight | |
| 26 | + | BorderBottom | |
| 27 | + | BorderLeft | |
| 28 | + | BorderAll = BorderTop | BorderRight | BorderBottom | BorderLeft | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // Presentation is the resolved outer and content geometry for an overlay. | |
| 32 | + | type Presentation struct { | |
| 33 | + | Mode PresentationMode | |
| 34 | + | Bounds domain.Rect | |
| 35 | + | Inner domain.Rect | |
| 36 | + | Borders BorderEdges | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ResolvePresentation preserves preferred geometry on complete frames at or | |
| 40 | + | // above the responsive breakpoint. Narrow frames use a full-width bottom | |
| 41 | + | // drawer while reserving frame rows 0-2 and the bottom bar. | |
| 42 | + | func ResolvePresentation(base domain.Size, preferredBounds, preferredInner domain.Rect) Presentation { | |
| 43 | + | if !base.Valid() { | |
| 44 | + | return Presentation{} | |
| 45 | + | } | |
| 46 | + | if base.Cols >= ResponsiveDrawerBreakpoint { | |
| 47 | + | return Presentation{ | |
| 48 | + | Mode: PresentationFloating, | |
| 49 | + | Bounds: preferredBounds, | |
| 50 | + | Inner: preferredInner, | |
| 51 | + | Borders: BorderAll, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | available := max(0, base.Rows-DrawerContextRows-DrawerBottomRows) | |
| 56 | + | height := min(max(preferredBounds.Height, 0), available) | |
| 57 | + | bounds := domain.Rect{ | |
| 58 | + | X: 0, | |
| 59 | + | Y: base.Rows - DrawerBottomRows - height, | |
| 60 | + | Width: base.Cols, | |
| 61 | + | Height: height, | |
| 62 | + | } | |
| 63 | + | inner := domain.Rect{ | |
| 64 | + | X: 0, | |
| 65 | + | Y: bounds.Y + min(1, height), | |
| 66 | + | Width: base.Cols, | |
| 67 | + | Height: max(0, height-1), | |
| 68 | + | } | |
| 69 | + | return Presentation{ | |
| 70 | + | Mode: PresentationDrawer, | |
| 71 | + | Bounds: bounds, | |
| 72 | + | Inner: inner, | |
| 73 | + | Borders: BorderTop, | |
| 74 | + | } | |
| 75 | + | } | |
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | ResponsiveDrawerBreakpoint = 80 | |||
| 7 | + | DrawerContextRows = 3 | |||
| 8 | + | DrawerBottomRows = 1 | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // PresentationMode describes whether an overlay uses its preferred floating | |||
| 12 | + | // geometry or becomes a narrow-terminal drawer. | |||
| 13 | + | type PresentationMode uint8 | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | PresentationFloating PresentationMode = iota | |||
| 17 | + | PresentationDrawer | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // BorderEdges identifies the edges drawn around a presentation. | |||
| 21 | + | type BorderEdges uint8 | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | BorderTop BorderEdges = 1 << iota | |||
| 25 | + | BorderRight | |||
| 26 | + | BorderBottom | |||
| 27 | + | BorderLeft | |||
| 28 | + | BorderAll = BorderTop | BorderRight | BorderBottom | BorderLeft | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // Presentation is the resolved outer and content geometry for an overlay. | |||
| 32 | + | type Presentation struct { | |||
| 33 | + | Mode PresentationMode | |||
| 34 | + | Bounds domain.Rect | |||
| 35 | + | Inner domain.Rect | |||
| 36 | + | Borders BorderEdges | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ResolvePresentation preserves preferred geometry on complete frames at or | |||
| 40 | + | // above the responsive breakpoint. Narrow frames use a full-width bottom | |||
| 41 | + | // drawer while reserving frame rows 0-2 and the bottom bar. | |||
| 42 | + | func ResolvePresentation(base domain.Size, preferredBounds, preferredInner domain.Rect) Presentation { | |||
| 43 | + | if !base.Valid() { | |||
| 44 | + | return Presentation{} | |||
| 45 | + | } | |||
| 46 | + | if base.Cols >= ResponsiveDrawerBreakpoint { | |||
| 47 | + | return Presentation{ | |||
| 48 | + | Mode: PresentationFloating, | |||
| 49 | + | Bounds: preferredBounds, | |||
| 50 | + | Inner: preferredInner, | |||
| 51 | + | Borders: BorderAll, | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | available := max(0, base.Rows-DrawerContextRows-DrawerBottomRows) | |||
| 56 | + | height := min(max(preferredBounds.Height, 0), available) | |||
| 57 | + | bounds := domain.Rect{ | |||
| 58 | + | X: 0, | |||
| 59 | + | Y: base.Rows - DrawerBottomRows - height, | |||
| 60 | + | Width: base.Cols, | |||
| 61 | + | Height: height, | |||
| 62 | + | } | |||
| 63 | + | inner := domain.Rect{ | |||
| 64 | + | X: 0, | |||
| 65 | + | Y: bounds.Y + min(1, height), | |||
| 66 | + | Width: base.Cols, | |||
| 67 | + | Height: max(0, height-1), | |||
| 68 | + | } | |||
| 69 | + | return Presentation{ | |||
| 70 | + | Mode: PresentationDrawer, | |||
| 71 | + | Bounds: bounds, | |||
| 72 | + | Inner: inner, | |||
| 73 | + | Borders: BorderTop, | |||
| 74 | + | } | |||
| 75 | + | } | |||
A internal/usecase/ui/presentation_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolvePresentation(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | base domain.Size | |
| 14 | + | bounds, inner domain.Rect | |
| 15 | + | want Presentation | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "79 columns becomes full-width drawer", | |
| 19 | + | base: domain.Size{Cols: 79, Rows: 24}, | |
| 20 | + | bounds: domain.Rect{X: 8, Y: 6, Width: 63, Height: 11}, | |
| 21 | + | inner: domain.Rect{X: 9, Y: 7, Width: 61, Height: 9}, | |
| 22 | + | want: Presentation{ | |
| 23 | + | Mode: PresentationDrawer, | |
| 24 | + | Bounds: domain.Rect{X: 0, Y: 12, Width: 79, Height: 11}, | |
| 25 | + | Inner: domain.Rect{X: 0, Y: 13, Width: 79, Height: 10}, | |
| 26 | + | Borders: BorderTop, | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "80 columns preserves preferred geometry", | |
| 31 | + | base: domain.Size{Cols: 80, Rows: 24}, | |
| 32 | + | bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |
| 33 | + | inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |
| 34 | + | want: Presentation{ | |
| 35 | + | Mode: PresentationFloating, | |
| 36 | + | Bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |
| 37 | + | Inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |
| 38 | + | Borders: BorderAll, | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "100 percent height preserves three rows and bottom bar", | |
| 43 | + | base: domain.Size{Cols: 79, Rows: 24}, | |
| 44 | + | bounds: domain.Rect{Width: 79, Height: 24}, | |
| 45 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 22}, | |
| 46 | + | want: Presentation{ | |
| 47 | + | Mode: PresentationDrawer, | |
| 48 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 20}, | |
| 49 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 19}, | |
| 50 | + | Borders: BorderTop, | |
| 51 | + | }, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "preferred height larger than available is capped", | |
| 55 | + | base: domain.Size{Cols: 79, Rows: 10}, | |
| 56 | + | bounds: domain.Rect{X: -100, Y: -100, Width: 1000, Height: 1000}, | |
| 57 | + | inner: domain.Rect{X: -99, Y: -99, Width: 998, Height: 998}, | |
| 58 | + | want: Presentation{ | |
| 59 | + | Mode: PresentationDrawer, | |
| 60 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 6}, | |
| 61 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 5}, | |
| 62 | + | Borders: BorderTop, | |
| 63 | + | }, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "negative preferred height becomes empty drawer", | |
| 67 | + | base: domain.Size{Cols: 79, Rows: 24}, | |
| 68 | + | bounds: domain.Rect{Height: -1}, | |
| 69 | + | inner: domain.Rect{Height: -3}, | |
| 70 | + | want: Presentation{ | |
| 71 | + | Mode: PresentationDrawer, | |
| 72 | + | Bounds: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |
| 73 | + | Inner: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |
| 74 | + | Borders: BorderTop, | |
| 75 | + | }, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "four-row terminal reserves all protected rows", | |
| 79 | + | base: domain.Size{Cols: 79, Rows: 4}, | |
| 80 | + | bounds: domain.Rect{Width: 79, Height: 4}, | |
| 81 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 2}, | |
| 82 | + | want: Presentation{ | |
| 83 | + | Mode: PresentationDrawer, | |
| 84 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79}, | |
| 85 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79}, | |
| 86 | + | Borders: BorderTop, | |
| 87 | + | }, | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "one-row terminal is panic-free and empty", | |
| 91 | + | base: domain.Size{Cols: 1, Rows: 1}, | |
| 92 | + | bounds: domain.Rect{Width: 1, Height: 1}, | |
| 93 | + | inner: domain.Rect{X: 1, Y: 1}, | |
| 94 | + | want: Presentation{ | |
| 95 | + | Mode: PresentationDrawer, | |
| 96 | + | Bounds: domain.Rect{X: 0, Y: 0, Width: 1}, | |
| 97 | + | Inner: domain.Rect{X: 0, Y: 0, Width: 1}, | |
| 98 | + | Borders: BorderTop, | |
| 99 | + | }, | |
| 100 | + | }, | |
| 101 | + | {name: "zero frame is empty", base: domain.Size{}, want: Presentation{}}, | |
| 102 | + | {name: "negative columns are empty", base: domain.Size{Cols: -1, Rows: 24}, want: Presentation{}}, | |
| 103 | + | {name: "negative rows are empty", base: domain.Size{Cols: 79, Rows: -1}, want: Presentation{}}, | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | for _, tt := range tests { | |
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |
| 108 | + | require.Equal(t, tt.want, ResolvePresentation(tt.base, tt.bounds, tt.inner)) | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolvePresentation(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | base domain.Size | |||
| 14 | + | bounds, inner domain.Rect | |||
| 15 | + | want Presentation | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "79 columns becomes full-width drawer", | |||
| 19 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 20 | + | bounds: domain.Rect{X: 8, Y: 6, Width: 63, Height: 11}, | |||
| 21 | + | inner: domain.Rect{X: 9, Y: 7, Width: 61, Height: 9}, | |||
| 22 | + | want: Presentation{ | |||
| 23 | + | Mode: PresentationDrawer, | |||
| 24 | + | Bounds: domain.Rect{X: 0, Y: 12, Width: 79, Height: 11}, | |||
| 25 | + | Inner: domain.Rect{X: 0, Y: 13, Width: 79, Height: 10}, | |||
| 26 | + | Borders: BorderTop, | |||
| 27 | + | }, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | name: "80 columns preserves preferred geometry", | |||
| 31 | + | base: domain.Size{Cols: 80, Rows: 24}, | |||
| 32 | + | bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |||
| 33 | + | inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |||
| 34 | + | want: Presentation{ | |||
| 35 | + | Mode: PresentationFloating, | |||
| 36 | + | Bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |||
| 37 | + | Inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |||
| 38 | + | Borders: BorderAll, | |||
| 39 | + | }, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "100 percent height preserves three rows and bottom bar", | |||
| 43 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 44 | + | bounds: domain.Rect{Width: 79, Height: 24}, | |||
| 45 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 22}, | |||
| 46 | + | want: Presentation{ | |||
| 47 | + | Mode: PresentationDrawer, | |||
| 48 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 20}, | |||
| 49 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 19}, | |||
| 50 | + | Borders: BorderTop, | |||
| 51 | + | }, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "preferred height larger than available is capped", | |||
| 55 | + | base: domain.Size{Cols: 79, Rows: 10}, | |||
| 56 | + | bounds: domain.Rect{X: -100, Y: -100, Width: 1000, Height: 1000}, | |||
| 57 | + | inner: domain.Rect{X: -99, Y: -99, Width: 998, Height: 998}, | |||
| 58 | + | want: Presentation{ | |||
| 59 | + | Mode: PresentationDrawer, | |||
| 60 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 6}, | |||
| 61 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 5}, | |||
| 62 | + | Borders: BorderTop, | |||
| 63 | + | }, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "negative preferred height becomes empty drawer", | |||
| 67 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 68 | + | bounds: domain.Rect{Height: -1}, | |||
| 69 | + | inner: domain.Rect{Height: -3}, | |||
| 70 | + | want: Presentation{ | |||
| 71 | + | Mode: PresentationDrawer, | |||
| 72 | + | Bounds: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |||
| 73 | + | Inner: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |||
| 74 | + | Borders: BorderTop, | |||
| 75 | + | }, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "four-row terminal reserves all protected rows", | |||
| 79 | + | base: domain.Size{Cols: 79, Rows: 4}, | |||
| 80 | + | bounds: domain.Rect{Width: 79, Height: 4}, | |||
| 81 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 2}, | |||
| 82 | + | want: Presentation{ | |||
| 83 | + | Mode: PresentationDrawer, | |||
| 84 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79}, | |||
| 85 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79}, | |||
| 86 | + | Borders: BorderTop, | |||
| 87 | + | }, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "one-row terminal is panic-free and empty", | |||
| 91 | + | base: domain.Size{Cols: 1, Rows: 1}, | |||
| 92 | + | bounds: domain.Rect{Width: 1, Height: 1}, | |||
| 93 | + | inner: domain.Rect{X: 1, Y: 1}, | |||
| 94 | + | want: Presentation{ | |||
| 95 | + | Mode: PresentationDrawer, | |||
| 96 | + | Bounds: domain.Rect{X: 0, Y: 0, Width: 1}, | |||
| 97 | + | Inner: domain.Rect{X: 0, Y: 0, Width: 1}, | |||
| 98 | + | Borders: BorderTop, | |||
| 99 | + | }, | |||
| 100 | + | }, | |||
| 101 | + | {name: "zero frame is empty", base: domain.Size{}, want: Presentation{}}, | |||
| 102 | + | {name: "negative columns are empty", base: domain.Size{Cols: -1, Rows: 24}, want: Presentation{}}, | |||
| 103 | + | {name: "negative rows are empty", base: domain.Size{Cols: 79, Rows: -1}, want: Presentation{}}, | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | for _, tt := range tests { | |||
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 108 | + | require.Equal(t, tt.want, ResolvePresentation(tt.base, tt.bounds, tt.inner)) | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||