feat(daemon): size floating pane overlays
- commit
- fe081c762e4a25a67cbd49fc86ac57858a764da0
- author
- brice · · 2026-07-10 05:58 UTC
- parent
- 1469f94
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +198 -4
M internal/usecase/daemon/client.go +3 -0
| @@ -593,6 +593,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 593 | 593 | d.applyLayoutLocked(tb) | |
| 594 | 594 | tb.mu.Unlock() | |
| 595 | 595 | } | |
| 596 | + | // Only the shown tab's floating terminal tracks the client size. This is | |
| 597 | + | // outside tab.mu so its PTY resize cannot block tab state. | |
| 598 | + | d.resizeActiveFloating(sess.activeTab()) | |
| 596 | 599 | markSnapshotDirty(sess) | |
| 597 | 600 | if ac != nil { | |
| 598 | 601 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| @@ -593,6 +593,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 593 | d.applyLayoutLocked(tb) | 593 | d.applyLayoutLocked(tb) | ||
| 594 | tb.mu.Unlock() | 594 | tb.mu.Unlock() | ||
| 595 | } | 595 | } | ||
| 596 | + | // Only the shown tab's floating terminal tracks the client size. This is | |||
| 597 | + | // outside tab.mu so its PTY resize cannot block tab state. | |||
| 598 | + | d.resizeActiveFloating(sess.activeTab()) | |||
| 596 | markSnapshotDirty(sess) | 599 | markSnapshotDirty(sess) | ||
| 597 | if ac != nil { | 600 | if ac != nil { | ||
| 598 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 601 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
M internal/usecase/daemon/floating.go +13 -4
| @@ -175,6 +175,9 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||
| 175 | 175 | d.launchFloating(sess, tb, cfg, generation, visible) | |
| 176 | 176 | return nil | |
| 177 | 177 | } | |
| 178 | + | if visible { | |
| 179 | + | d.resizeActiveFloating(tb) | |
| 180 | + | } | |
| 178 | 181 | if ac != nil { | |
| 179 | 182 | d.paint(sess, ac, true) | |
| 180 | 183 | } | |
| @@ -198,6 +201,7 @@ type floatingLaunchSpec struct { | |||
| 198 | 201 | sessionName string | |
| 199 | 202 | cwd string | |
| 200 | 203 | size domain.Size | |
| 204 | + | innerRect domain.Rect | |
| 201 | 205 | paneStableID string | |
| 202 | 206 | env []string | |
| 203 | 207 | command string | |
| @@ -226,13 +230,15 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 226 | 230 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | |
| 227 | 231 | sess.mu.Unlock() | |
| 228 | 232 | tb.mu.Lock() | |
| 229 | - | size := floatingInnerSize(tb.size, cfg) | |
| 233 | + | inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner | |
| 234 | + | size := rectSize(inner) | |
| 235 | + | if !size.Valid() { | |
| 236 | + | inner = domain.Rect{Width: 1, Height: 1} | |
| 237 | + | size = rectSize(inner) | |
| 238 | + | } | |
| 230 | 239 | focused := tb.focusedPane() | |
| 231 | 240 | tabStableID, tabCtx := tb.stableID, tb.ctx | |
| 232 | 241 | tb.mu.Unlock() | |
| 233 | - | if !size.Valid() { | |
| 234 | - | size = domain.Size{Cols: 1, Rows: 1} | |
| 235 | - | } | |
| 236 | 242 | if focused != nil && d.procCwd != nil && focused.pty != nil { | |
| 237 | 243 | if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" { | |
| 238 | 244 | cwd = live | |
| @@ -259,6 +265,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 259 | 265 | sessionName: name, | |
| 260 | 266 | cwd: cwd, | |
| 261 | 267 | size: size, | |
| 268 | + | innerRect: inner, | |
| 262 | 269 | paneStableID: paneStableID, | |
| 263 | 270 | env: d.childEnv(name, tabStableID, paneStableID, term), | |
| 264 | 271 | command: command, | |
| @@ -278,6 +285,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 278 | 285 | return | |
| 279 | 286 | } | |
| 280 | 287 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | |
| 288 | + | p.rect = spec.innerRect | |
| 281 | 289 | p.title.displayFallback = spec.fallback | |
| 282 | 290 | p.ctx, p.cancel = context.WithCancel(spec.parentCtx) | |
| 283 | 291 | // The reader may run as soon as install returns; make its exit policy | |
| @@ -312,6 +320,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 312 | 320 | } | |
| 313 | 321 | d.startPaneGoroutines(sess, tb, p) | |
| 314 | 322 | if visible { | |
| 323 | + | d.resizeActiveFloating(tb) | |
| 315 | 324 | sess.mu.Lock() | |
| 316 | 325 | ac := sess.client | |
| 317 | 326 | sess.mu.Unlock() | |
| @@ -175,6 +175,9 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||||
| 175 | d.launchFloating(sess, tb, cfg, generation, visible) | 175 | d.launchFloating(sess, tb, cfg, generation, visible) | ||
| 176 | return nil | 176 | return nil | ||
| 177 | } | 177 | } | ||
| 178 | + | if visible { | |||
| 179 | + | d.resizeActiveFloating(tb) | |||
| 180 | + | } | |||
| 178 | if ac != nil { | 181 | if ac != nil { | ||
| 179 | d.paint(sess, ac, true) | 182 | d.paint(sess, ac, true) | ||
| 180 | } | 183 | } | ||
| @@ -198,6 +201,7 @@ type floatingLaunchSpec struct { | |||||
| 198 | sessionName string | 201 | sessionName string | ||
| 199 | cwd string | 202 | cwd string | ||
| 200 | size domain.Size | 203 | size domain.Size | ||
| 204 | + | innerRect domain.Rect | |||
| 201 | paneStableID string | 205 | paneStableID string | ||
| 202 | env []string | 206 | env []string | ||
| 203 | command string | 207 | command string | ||
| @@ -226,13 +230,15 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 226 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | 230 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | ||
| 227 | sess.mu.Unlock() | 231 | sess.mu.Unlock() | ||
| 228 | tb.mu.Lock() | 232 | tb.mu.Lock() | ||
| 229 | - | size := floatingInnerSize(tb.size, cfg) | 233 | + | inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner |
| 234 | + | size := rectSize(inner) | |||
| 235 | + | if !size.Valid() { | |||
| 236 | + | inner = domain.Rect{Width: 1, Height: 1} | |||
| 237 | + | size = rectSize(inner) | |||
| 238 | + | } | |||
| 230 | focused := tb.focusedPane() | 239 | focused := tb.focusedPane() | ||
| 231 | tabStableID, tabCtx := tb.stableID, tb.ctx | 240 | tabStableID, tabCtx := tb.stableID, tb.ctx | ||
| 232 | tb.mu.Unlock() | 241 | tb.mu.Unlock() | ||
| 233 | - | if !size.Valid() { | |||
| 234 | - | size = domain.Size{Cols: 1, Rows: 1} | |||
| 235 | - | } | |||
| 236 | if focused != nil && d.procCwd != nil && focused.pty != nil { | 242 | if focused != nil && d.procCwd != nil && focused.pty != nil { | ||
| 237 | if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" { | 243 | if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" { | ||
| 238 | cwd = live | 244 | cwd = live | ||
| @@ -259,6 +265,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 259 | sessionName: name, | 265 | sessionName: name, | ||
| 260 | cwd: cwd, | 266 | cwd: cwd, | ||
| 261 | size: size, | 267 | size: size, | ||
| 268 | + | innerRect: inner, | |||
| 262 | paneStableID: paneStableID, | 269 | paneStableID: paneStableID, | ||
| 263 | env: d.childEnv(name, tabStableID, paneStableID, term), | 270 | env: d.childEnv(name, tabStableID, paneStableID, term), | ||
| 264 | command: command, | 271 | command: command, | ||
| @@ -278,6 +285,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 278 | return | 285 | return | ||
| 279 | } | 286 | } | ||
| 280 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | 287 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | ||
| 288 | + | p.rect = spec.innerRect | |||
| 281 | p.title.displayFallback = spec.fallback | 289 | p.title.displayFallback = spec.fallback | ||
| 282 | p.ctx, p.cancel = context.WithCancel(spec.parentCtx) | 290 | p.ctx, p.cancel = context.WithCancel(spec.parentCtx) | ||
| 283 | // The reader may run as soon as install returns; make its exit policy | 291 | // The reader may run as soon as install returns; make its exit policy | ||
| @@ -312,6 +320,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||||
| 312 | } | 320 | } | ||
| 313 | d.startPaneGoroutines(sess, tb, p) | 321 | d.startPaneGoroutines(sess, tb, p) | ||
| 314 | if visible { | 322 | if visible { | ||
| 323 | + | d.resizeActiveFloating(tb) | |||
| 315 | sess.mu.Lock() | 324 | sess.mu.Lock() | ||
| 316 | ac := sess.client | 325 | ac := sess.client | ||
| 317 | sess.mu.Unlock() | 326 | sess.mu.Unlock() | ||
A internal/usecase/daemon/floating_render.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | // floatingGeometry describes a popup's outer frame and terminal content area. | |
| 6 | + | // Bounds and Inner are both relative to the supplied content rectangle. | |
| 7 | + | type floatingGeometry struct { | |
| 8 | + | Bounds domain.Rect | |
| 9 | + | Inner domain.Rect | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // calculateFloatingGeometry centers a percentage-sized popup in content. Its | |
| 13 | + | // terminal area deliberately uses floatingInnerSize so launch and rendering | |
| 14 | + | // always share the same border and percentage rules. | |
| 15 | + | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | |
| 16 | + | if content.Width <= 0 || content.Height <= 0 { | |
| 17 | + | return floatingGeometry{} | |
| 18 | + | } | |
| 19 | + | bounds := domain.Rect{ | |
| 20 | + | Width: floatingBoundsAxis(content.Width, cfg.Width), | |
| 21 | + | Height: floatingBoundsAxis(content.Height, cfg.Height), | |
| 22 | + | } | |
| 23 | + | bounds.X = content.X + (content.Width-bounds.Width)/2 | |
| 24 | + | bounds.Y = content.Y + (content.Height-bounds.Height)/2 | |
| 25 | + | innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100}) | |
| 26 | + | inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows} | |
| 27 | + | if bounds.Width >= 3 { | |
| 28 | + | inner.X++ | |
| 29 | + | } | |
| 30 | + | if bounds.Height >= 3 { | |
| 31 | + | inner.Y++ | |
| 32 | + | } | |
| 33 | + | return floatingGeometry{Bounds: bounds, Inner: inner} | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func floatingBoundsAxis(available, percent int) int { | |
| 37 | + | percent = min(max(percent, 1), 100) | |
| 38 | + | return min(max(available*percent/100, 1), available) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | |
| 42 | + | // is intentionally outside pane.mu; failed resizes do not publish any state. | |
| 43 | + | func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool { | |
| 44 | + | if p == nil || inner.Width <= 0 || inner.Height <= 0 { | |
| 45 | + | return false | |
| 46 | + | } | |
| 47 | + | p.resizeMu.Lock() | |
| 48 | + | defer p.resizeMu.Unlock() | |
| 49 | + | ||
| 50 | + | p.mu.Lock() | |
| 51 | + | old := p.rect | |
| 52 | + | pty := p.pty | |
| 53 | + | p.mu.Unlock() | |
| 54 | + | if old == inner { | |
| 55 | + | return true | |
| 56 | + | } | |
| 57 | + | if pty != nil { | |
| 58 | + | if err := pty.Resize(rectSize(inner)); err != nil { | |
| 59 | + | d.log.Warn("floating pty resize failed", "err", err) | |
| 60 | + | return false | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | p.mu.Lock() | |
| 64 | + | p.screen.Resize(inner.Width, inner.Height) | |
| 65 | + | p.rect = inner | |
| 66 | + | p.mu.Unlock() | |
| 67 | + | return true | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // resizeActiveFloating updates only a visible floating pane. Hidden panes | |
| 71 | + | // retain their remembered geometry until they are shown again. | |
| 72 | + | func (d *Daemon) resizeActiveFloating(tb *tab) bool { | |
| 73 | + | if d == nil || tb == nil { | |
| 74 | + | return false | |
| 75 | + | } | |
| 76 | + | tb.mu.Lock() | |
| 77 | + | visible := tb.floating.state == floatingVisible | |
| 78 | + | p := tb.floating.pane | |
| 79 | + | content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 80 | + | tb.mu.Unlock() | |
| 81 | + | if !visible || p == nil { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner) | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // floatingGeometry describes a popup's outer frame and terminal content area. | |||
| 6 | + | // Bounds and Inner are both relative to the supplied content rectangle. | |||
| 7 | + | type floatingGeometry struct { | |||
| 8 | + | Bounds domain.Rect | |||
| 9 | + | Inner domain.Rect | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // calculateFloatingGeometry centers a percentage-sized popup in content. Its | |||
| 13 | + | // terminal area deliberately uses floatingInnerSize so launch and rendering | |||
| 14 | + | // always share the same border and percentage rules. | |||
| 15 | + | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | |||
| 16 | + | if content.Width <= 0 || content.Height <= 0 { | |||
| 17 | + | return floatingGeometry{} | |||
| 18 | + | } | |||
| 19 | + | bounds := domain.Rect{ | |||
| 20 | + | Width: floatingBoundsAxis(content.Width, cfg.Width), | |||
| 21 | + | Height: floatingBoundsAxis(content.Height, cfg.Height), | |||
| 22 | + | } | |||
| 23 | + | bounds.X = content.X + (content.Width-bounds.Width)/2 | |||
| 24 | + | bounds.Y = content.Y + (content.Height-bounds.Height)/2 | |||
| 25 | + | innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100}) | |||
| 26 | + | inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows} | |||
| 27 | + | if bounds.Width >= 3 { | |||
| 28 | + | inner.X++ | |||
| 29 | + | } | |||
| 30 | + | if bounds.Height >= 3 { | |||
| 31 | + | inner.Y++ | |||
| 32 | + | } | |||
| 33 | + | return floatingGeometry{Bounds: bounds, Inner: inner} | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func floatingBoundsAxis(available, percent int) int { | |||
| 37 | + | percent = min(max(percent, 1), 100) | |||
| 38 | + | return min(max(available*percent/100, 1), available) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | |||
| 42 | + | // is intentionally outside pane.mu; failed resizes do not publish any state. | |||
| 43 | + | func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool { | |||
| 44 | + | if p == nil || inner.Width <= 0 || inner.Height <= 0 { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | p.resizeMu.Lock() | |||
| 48 | + | defer p.resizeMu.Unlock() | |||
| 49 | + | ||||
| 50 | + | p.mu.Lock() | |||
| 51 | + | old := p.rect | |||
| 52 | + | pty := p.pty | |||
| 53 | + | p.mu.Unlock() | |||
| 54 | + | if old == inner { | |||
| 55 | + | return true | |||
| 56 | + | } | |||
| 57 | + | if pty != nil { | |||
| 58 | + | if err := pty.Resize(rectSize(inner)); err != nil { | |||
| 59 | + | d.log.Warn("floating pty resize failed", "err", err) | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | p.mu.Lock() | |||
| 64 | + | p.screen.Resize(inner.Width, inner.Height) | |||
| 65 | + | p.rect = inner | |||
| 66 | + | p.mu.Unlock() | |||
| 67 | + | return true | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // resizeActiveFloating updates only a visible floating pane. Hidden panes | |||
| 71 | + | // retain their remembered geometry until they are shown again. | |||
| 72 | + | func (d *Daemon) resizeActiveFloating(tb *tab) bool { | |||
| 73 | + | if d == nil || tb == nil { | |||
| 74 | + | return false | |||
| 75 | + | } | |||
| 76 | + | tb.mu.Lock() | |||
| 77 | + | visible := tb.floating.state == floatingVisible | |||
| 78 | + | p := tb.floating.pane | |||
| 79 | + | content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |||
| 80 | + | tb.mu.Unlock() | |||
| 81 | + | if !visible || p == nil { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner) | |||
| 85 | + | } | |||
A internal/usecase/daemon/floating_render_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestCalculateFloatingGeometry(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | content domain.Rect | |
| 19 | + | cfg domain.FloatingConfig | |
| 20 | + | want floatingGeometry | |
| 21 | + | }{ | |
| 22 | + | {"eighty percent centered", domain.Rect{X: 3, Y: 1, Width: 101, Height: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 13, Y: 6, Width: 80, Height: 40}, Inner: domain.Rect{X: 14, Y: 7, Width: 78, Height: 38}}}, | |
| 23 | + | {"one percent clamps to one", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}}}, | |
| 24 | + | {"full size", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Y: 1, Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 2, Width: 98, Height: 18}}}, | |
| 25 | + | {"tiny axes omit borders", domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}}, | |
| 26 | + | {"percent clamps", domain.Rect{Width: 10, Height: 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}}}, | |
| 27 | + | } | |
| 28 | + | for _, tt := range tests { | |
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |
| 30 | + | require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg)) | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |
| 36 | + | t.Run("failure preserves state", func(t *testing.T) { | |
| 37 | + | pty := &resizePTY{err: errors.New("nope")} | |
| 38 | + | p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4}) | |
| 39 | + | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | |
| 40 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 41 | + | require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7})) | |
| 42 | + | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |
| 43 | + | require.Equal(t, 5, p.screen.Frame.Width) | |
| 44 | + | require.Equal(t, 4, p.screen.Frame.Height) | |
| 45 | + | }) | |
| 46 | + | ||
| 47 | + | t.Run("competing resizes serialize", func(t *testing.T) { | |
| 48 | + | pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})} | |
| 49 | + | p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2}) | |
| 50 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 51 | + | first := domain.Rect{Width: 4, Height: 3} | |
| 52 | + | second := domain.Rect{Width: 8, Height: 6} | |
| 53 | + | done1 := make(chan bool, 1) | |
| 54 | + | done2 := make(chan bool, 1) | |
| 55 | + | go func() { done1 <- d.resizeFloatingPane(p, first) }() | |
| 56 | + | <-pty.entered | |
| 57 | + | go func() { done2 <- d.resizeFloatingPane(p, second) }() | |
| 58 | + | require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond) | |
| 59 | + | close(pty.release) | |
| 60 | + | require.True(t, <-done1) | |
| 61 | + | require.True(t, <-done2) | |
| 62 | + | require.Equal(t, second, p.rect) | |
| 63 | + | require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes()) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | type resizePTY struct { | |
| 68 | + | mu sync.Mutex | |
| 69 | + | resizes []domain.Size | |
| 70 | + | err error | |
| 71 | + | entered chan struct{} | |
| 72 | + | release chan struct{} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (p *resizePTY) Resize(sz domain.Size) error { | |
| 76 | + | p.mu.Lock() | |
| 77 | + | p.resizes = append(p.resizes, sz) | |
| 78 | + | n := len(p.resizes) | |
| 79 | + | p.mu.Unlock() | |
| 80 | + | if n == 1 && p.entered != nil { | |
| 81 | + | close(p.entered) | |
| 82 | + | <-p.release | |
| 83 | + | } | |
| 84 | + | return p.err | |
| 85 | + | } | |
| 86 | + | func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) } | |
| 87 | + | func (p *resizePTY) sizes() []domain.Size { | |
| 88 | + | p.mu.Lock() | |
| 89 | + | defer p.mu.Unlock() | |
| 90 | + | return append([]domain.Size(nil), p.resizes...) | |
| 91 | + | } | |
| 92 | + | func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF } | |
| 93 | + | func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 94 | + | func (*resizePTY) Close() error { return nil } | |
| 95 | + | func (*resizePTY) Pid() int { return 0 } | |
| 96 | + | func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "io" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestCalculateFloatingGeometry(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | content domain.Rect | |||
| 19 | + | cfg domain.FloatingConfig | |||
| 20 | + | want floatingGeometry | |||
| 21 | + | }{ | |||
| 22 | + | {"eighty percent centered", domain.Rect{X: 3, Y: 1, Width: 101, Height: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 13, Y: 6, Width: 80, Height: 40}, Inner: domain.Rect{X: 14, Y: 7, Width: 78, Height: 38}}}, | |||
| 23 | + | {"one percent clamps to one", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}}}, | |||
| 24 | + | {"full size", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Y: 1, Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 2, Width: 98, Height: 18}}}, | |||
| 25 | + | {"tiny axes omit borders", domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}}, | |||
| 26 | + | {"percent clamps", domain.Rect{Width: 10, Height: 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}}}, | |||
| 27 | + | } | |||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | + | require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg)) | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 36 | + | t.Run("failure preserves state", func(t *testing.T) { | |||
| 37 | + | pty := &resizePTY{err: errors.New("nope")} | |||
| 38 | + | p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4}) | |||
| 39 | + | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | |||
| 40 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 41 | + | require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7})) | |||
| 42 | + | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |||
| 43 | + | require.Equal(t, 5, p.screen.Frame.Width) | |||
| 44 | + | require.Equal(t, 4, p.screen.Frame.Height) | |||
| 45 | + | }) | |||
| 46 | + | ||||
| 47 | + | t.Run("competing resizes serialize", func(t *testing.T) { | |||
| 48 | + | pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 49 | + | p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2}) | |||
| 50 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 51 | + | first := domain.Rect{Width: 4, Height: 3} | |||
| 52 | + | second := domain.Rect{Width: 8, Height: 6} | |||
| 53 | + | done1 := make(chan bool, 1) | |||
| 54 | + | done2 := make(chan bool, 1) | |||
| 55 | + | go func() { done1 <- d.resizeFloatingPane(p, first) }() | |||
| 56 | + | <-pty.entered | |||
| 57 | + | go func() { done2 <- d.resizeFloatingPane(p, second) }() | |||
| 58 | + | require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond) | |||
| 59 | + | close(pty.release) | |||
| 60 | + | require.True(t, <-done1) | |||
| 61 | + | require.True(t, <-done2) | |||
| 62 | + | require.Equal(t, second, p.rect) | |||
| 63 | + | require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes()) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | type resizePTY struct { | |||
| 68 | + | mu sync.Mutex | |||
| 69 | + | resizes []domain.Size | |||
| 70 | + | err error | |||
| 71 | + | entered chan struct{} | |||
| 72 | + | release chan struct{} | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (p *resizePTY) Resize(sz domain.Size) error { | |||
| 76 | + | p.mu.Lock() | |||
| 77 | + | p.resizes = append(p.resizes, sz) | |||
| 78 | + | n := len(p.resizes) | |||
| 79 | + | p.mu.Unlock() | |||
| 80 | + | if n == 1 && p.entered != nil { | |||
| 81 | + | close(p.entered) | |||
| 82 | + | <-p.release | |||
| 83 | + | } | |||
| 84 | + | return p.err | |||
| 85 | + | } | |||
| 86 | + | func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) } | |||
| 87 | + | func (p *resizePTY) sizes() []domain.Size { | |||
| 88 | + | p.mu.Lock() | |||
| 89 | + | defer p.mu.Unlock() | |||
| 90 | + | return append([]domain.Size(nil), p.resizes...) | |||
| 91 | + | } | |||
| 92 | + | func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF } | |||
| 93 | + | func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 94 | + | func (*resizePTY) Close() error { return nil } | |||
| 95 | + | func (*resizePTY) Pid() int { return 0 } | |||
| 96 | + | func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil } | |||
M internal/usecase/daemon/pane.go +1 -0
| @@ -20,6 +20,7 @@ type pane struct { | |||
| 20 | 20 | stableID string | |
| 21 | 21 | pty ports.PTY | |
| 22 | 22 | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | |
| 23 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 23 | 24 | screen *vt.Screen | |
| 24 | 25 | scrollback *scopy.Scrollback | |
| 25 | 26 | dirty chan struct{} | |
| @@ -20,6 +20,7 @@ type pane struct { | |||||
| 20 | stableID string | 20 | stableID string | ||
| 21 | pty ports.PTY | 21 | pty ports.PTY | ||
| 22 | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | 22 | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | ||
| 23 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |||
| 23 | screen *vt.Screen | 24 | screen *vt.Screen | ||
| 24 | scrollback *scopy.Scrollback | 25 | scrollback *scopy.Scrollback | ||
| 25 | dirty chan struct{} | 26 | dirty chan struct{} | ||