Merge pull request #67 from bnema/fix/floating-popup-coordinates
fix(daemon): align floating popup coordinates
7 files · +165 -69
M internal/usecase/daemon/copymode_test.go +1 -1
| @@ -750,7 +750,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 750 | 750 | d.enterCopyMode(sess, ac) | |
| 751 | 751 | awaitFrame(t, sends, ports.MsgOutput) | |
| 752 | 752 | viewportTop := ac.overlays.copyMode.ViewportTop | |
| 753 | - | inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner | |
| 753 | + | inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner | |
| 754 | 754 | ||
| 755 | 755 | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | |
| 756 | 756 | // offset); clicking a row must select exactly that row. | |
| @@ -750,7 +750,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||||
| 750 | d.enterCopyMode(sess, ac) | 750 | d.enterCopyMode(sess, ac) | ||
| 751 | awaitFrame(t, sends, ports.MsgOutput) | 751 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 752 | viewportTop := ac.overlays.copyMode.ViewportTop | 752 | viewportTop := ac.overlays.copyMode.ViewportTop | ||
| 753 | - | inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner | 753 | + | inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner |
| 754 | 754 | ||||
| 755 | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | 755 | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | ||
| 756 | // offset); clicking a row must select exactly that row. | 756 | // offset); clicking a row must select exactly that row. | ||
M internal/usecase/daemon/floating.go +2 -2
| @@ -133,7 +133,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, | |||
| 133 | 133 | return nil, floatingGeometry{}, false | |
| 134 | 134 | } | |
| 135 | 135 | p := tb.floating.pane | |
| 136 | - | desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |
| 136 | + | desired := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | |
| 137 | 137 | p.mu.Lock() | |
| 138 | 138 | geometry := p.committedFloatingGeometryLocked(desired) | |
| 139 | 139 | p.mu.Unlock() | |
| @@ -250,7 +250,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 250 | 250 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | |
| 251 | 251 | sess.mu.Unlock() | |
| 252 | 252 | tb.mu.Lock() | |
| 253 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |
| 253 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | |
| 254 | 254 | size := rectSize(geometry.Inner) | |
| 255 | 255 | if !size.Valid() { | |
| 256 | 256 | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | |
| @@ -133,7 +133,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, | |||||
| 133 | return nil, floatingGeometry{}, false | 133 | return nil, floatingGeometry{}, false | ||
| 134 | } | 134 | } | ||
| 135 | p := tb.floating.pane | 135 | p := tb.floating.pane | ||
| 136 | - | desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | 136 | + | desired := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) |
| 137 | p.mu.Lock() | 137 | p.mu.Lock() | ||
| 138 | geometry := p.committedFloatingGeometryLocked(desired) | 138 | geometry := p.committedFloatingGeometryLocked(desired) | ||
| 139 | p.mu.Unlock() | 139 | p.mu.Unlock() | ||
| @@ -250,7 +250,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 250 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | 250 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | ||
| 251 | sess.mu.Unlock() | 251 | sess.mu.Unlock() | ||
| 252 | tb.mu.Lock() | 252 | tb.mu.Lock() | ||
| 253 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | 253 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) |
| 254 | size := rectSize(geometry.Inner) | 254 | size := rectSize(geometry.Inner) | ||
| 255 | if !size.Valid() { | 255 | if !size.Valid() { | ||
| 256 | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | 256 | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | ||
M internal/usecase/daemon/floating_render.go +30 -18
| @@ -8,7 +8,10 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // floatingGeometry describes a popup's outer frame and terminal content area. | |
| 11 | - | // Bounds and Inner are both relative to the supplied content rectangle. | |
| 11 | + | // Bounds and Inner always share the same coordinate space. Committed pane | |
| 12 | + | // geometry is origin-zero and tab-content-relative; composeFloatingFrame | |
| 13 | + | // translates both fields and returns/caches frame-absolute geometry. Callers | |
| 14 | + | // must not translate a composed or cached value again. | |
| 12 | 15 | type floatingGeometry struct { | |
| 13 | 16 | Bounds domain.Rect | |
| 14 | 17 | Inner domain.Rect | |
| @@ -18,6 +21,14 @@ func (g floatingGeometry) valid() bool { | |||
| 18 | 21 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 24 | + | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |
| 25 | + | g.Bounds.X += dx | |
| 26 | + | g.Bounds.Y += dy | |
| 27 | + | g.Inner.X += dx | |
| 28 | + | g.Inner.Y += dy | |
| 29 | + | return g | |
| 30 | + | } | |
| 31 | + | ||
| 21 | 32 | // committedFloatingGeometryLocked returns the geometry whose PTY resize was | |
| 22 | 33 | // last committed. Newly constructed test panes fall back to the requested | |
| 23 | 34 | // geometry until their first resize. The caller holds p.mu. | |
| @@ -47,18 +58,19 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||
| 47 | 58 | return axis | |
| 48 | 59 | } | |
| 49 | 60 | ||
| 50 | - | // calculateFloatingGeometry centers a percentage-sized popup in content. | |
| 51 | - | // Launch sizing derives from Inner too, so rendering and PTY geometry share | |
| 52 | - | // the same per-axis percentage and tiny-border rules. | |
| 53 | - | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | |
| 54 | - | if content.Width <= 0 || content.Height <= 0 { | |
| 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 { | |
| 66 | + | if !content.Valid() { | |
| 55 | 67 | return floatingGeometry{} | |
| 56 | 68 | } | |
| 57 | - | x := calculateFloatingAxisGeometry(content.Width, cfg.Width) | |
| 58 | - | y := calculateFloatingAxisGeometry(content.Height, cfg.Height) | |
| 69 | + | x := calculateFloatingAxisGeometry(content.Cols, cfg.Width) | |
| 70 | + | y := calculateFloatingAxisGeometry(content.Rows, cfg.Height) | |
| 59 | 71 | bounds := domain.Rect{ | |
| 60 | - | X: content.X + (content.Width-x.BoundsSize)/2, | |
| 61 | - | Y: content.Y + (content.Height-y.BoundsSize)/2, | |
| 72 | + | X: (content.Cols - x.BoundsSize) / 2, | |
| 73 | + | Y: (content.Rows - y.BoundsSize) / 2, | |
| 62 | 74 | Width: x.BoundsSize, | |
| 63 | 75 | Height: y.BoundsSize, | |
| 64 | 76 | } | |
| @@ -76,7 +88,7 @@ func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) f | |||
| 76 | 88 | // composeFloatingFrame applies the popup to a copy of the normal composed | |
| 77 | 89 | // destination. In particular it never writes the VT screen frame: that frame | |
| 78 | 90 | // is shared with the PTY reader and is the source for future renders. | |
| 79 | - | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, cfg domain.FloatingConfig, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage) { | |
| 91 | + | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) { | |
| 80 | 92 | var frame renderer.Frame | |
| 81 | 93 | if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height { | |
| 82 | 94 | frame = cache.floatingFrame | |
| @@ -90,13 +102,13 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||
| 90 | 102 | } | |
| 91 | 103 | } | |
| 92 | 104 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | |
| 93 | - | desiredGeometry := calculateFloatingGeometry(content, cfg) | |
| 94 | - | ||
| 95 | 105 | // The PTY reader mutates both the frame and its damage under p.mu. Keep | |
| 96 | 106 | // every read of them, including the blit, in the same critical section. | |
| 97 | 107 | // In particular, Frame is not safe to retain as an alias after unlocking. | |
| 98 | 108 | p.mu.Lock() | |
| 99 | - | geometry := p.committedFloatingGeometryLocked(desiredGeometry) | |
| 109 | + | // Committed popup geometry is content-relative; translate it exactly once | |
| 110 | + | // while holding the same lock that protects the committed state and frame. | |
| 111 | + | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | |
| 100 | 112 | title := p.displayTitleLocked() | |
| 101 | 113 | titleGeneration := p.title.generation | |
| 102 | 114 | screenDamage := p.screen.Damage() | |
| @@ -121,12 +133,12 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||
| 121 | 133 | cache.floatingTitleGeneration = titleGeneration | |
| 122 | 134 | } | |
| 123 | 135 | if full || popupChanged { | |
| 124 | - | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 136 | + | return frame, []renderer.Damage{renderer.FullRedraw()}, geometry | |
| 125 | 137 | } | |
| 126 | 138 | if titleChanged && geometry.Bounds.Height >= 3 { | |
| 127 | 139 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | |
| 128 | 140 | } | |
| 129 | - | return frame, damage | |
| 141 | + | return frame, damage, geometry | |
| 130 | 142 | } | |
| 131 | 143 | ||
| 132 | 144 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | |
| @@ -201,12 +213,12 @@ func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |||
| 201 | 213 | } | |
| 202 | 214 | tb.mu.Lock() | |
| 203 | 215 | p := tb.floating.pane | |
| 204 | - | content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 216 | + | content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows} | |
| 205 | 217 | tb.mu.Unlock() | |
| 206 | 218 | if p == nil { | |
| 207 | 219 | return false | |
| 208 | 220 | } | |
| 209 | - | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig())) | |
| 221 | + | return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig())) | |
| 210 | 222 | } | |
| 211 | 223 | ||
| 212 | 224 | // resizeActiveFloating updates a visible floating pane during a client resize. | |
| @@ -8,7 +8,10 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // floatingGeometry describes a popup's outer frame and terminal content area. | 10 | // floatingGeometry describes a popup's outer frame and terminal content area. | ||
| 11 | - | // Bounds and Inner are both relative to the supplied content rectangle. | 11 | + | // Bounds and Inner always share the same coordinate space. Committed pane |
| 12 | + | // geometry is origin-zero and tab-content-relative; composeFloatingFrame | |||
| 13 | + | // translates both fields and returns/caches frame-absolute geometry. Callers | |||
| 14 | + | // must not translate a composed or cached value again. | |||
| 12 | type floatingGeometry struct { | 15 | type floatingGeometry struct { | ||
| 13 | Bounds domain.Rect | 16 | Bounds domain.Rect | ||
| 14 | Inner domain.Rect | 17 | Inner domain.Rect | ||
| @@ -18,6 +21,14 @@ func (g floatingGeometry) valid() bool { | |||||
| 18 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | 21 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | ||
| 19 | } | 22 | } | ||
| 20 | 23 | ||||
| 24 | + | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |||
| 25 | + | g.Bounds.X += dx | |||
| 26 | + | g.Bounds.Y += dy | |||
| 27 | + | g.Inner.X += dx | |||
| 28 | + | g.Inner.Y += dy | |||
| 29 | + | return g | |||
| 30 | + | } | |||
| 31 | + | ||||
| 21 | // committedFloatingGeometryLocked returns the geometry whose PTY resize was | 32 | // committedFloatingGeometryLocked returns the geometry whose PTY resize was | ||
| 22 | // last committed. Newly constructed test panes fall back to the requested | 33 | // last committed. Newly constructed test panes fall back to the requested | ||
| 23 | // geometry until their first resize. The caller holds p.mu. | 34 | // geometry until their first resize. The caller holds p.mu. | ||
| @@ -47,18 +58,19 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||||
| 47 | return axis | 58 | return axis | ||
| 48 | } | 59 | } | ||
| 49 | 60 | ||||
| 50 | - | // calculateFloatingGeometry centers a percentage-sized popup in content. | 61 | + | // calculateContentFloatingGeometry returns origin-zero, tab-content-relative |
| 51 | - | // Launch sizing derives from Inner too, so rendering and PTY geometry share | 62 | + | // coordinates for a percentage-sized popup. Launch sizing derives from Inner |
| 52 | - | // the same per-axis percentage and tiny-border rules. | 63 | + | // too, so rendering and PTY geometry share the same per-axis percentage and |
| 53 | - | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | 64 | + | // tiny-border rules. |
| 54 | - | if content.Width <= 0 || content.Height <= 0 { | 65 | + | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { |
| 66 | + | if !content.Valid() { | |||
| 55 | return floatingGeometry{} | 67 | return floatingGeometry{} | ||
| 56 | } | 68 | } | ||
| 57 | - | x := calculateFloatingAxisGeometry(content.Width, cfg.Width) | 69 | + | x := calculateFloatingAxisGeometry(content.Cols, cfg.Width) |
| 58 | - | y := calculateFloatingAxisGeometry(content.Height, cfg.Height) | 70 | + | y := calculateFloatingAxisGeometry(content.Rows, cfg.Height) |
| 59 | bounds := domain.Rect{ | 71 | bounds := domain.Rect{ | ||
| 60 | - | X: content.X + (content.Width-x.BoundsSize)/2, | 72 | + | X: (content.Cols - x.BoundsSize) / 2, |
| 61 | - | Y: content.Y + (content.Height-y.BoundsSize)/2, | 73 | + | Y: (content.Rows - y.BoundsSize) / 2, |
| 62 | Width: x.BoundsSize, | 74 | Width: x.BoundsSize, | ||
| 63 | Height: y.BoundsSize, | 75 | Height: y.BoundsSize, | ||
| 64 | } | 76 | } | ||
| @@ -76,7 +88,7 @@ func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) f | |||||
| 76 | // composeFloatingFrame applies the popup to a copy of the normal composed | 88 | // composeFloatingFrame applies the popup to a copy of the normal composed | ||
| 77 | // destination. In particular it never writes the VT screen frame: that frame | 89 | // destination. In particular it never writes the VT screen frame: that frame | ||
| 78 | // is shared with the PTY reader and is the source for future renders. | 90 | // is shared with the PTY reader and is the source for future renders. | ||
| 79 | - | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, cfg domain.FloatingConfig, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage) { | 91 | + | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) { |
| 80 | var frame renderer.Frame | 92 | var frame renderer.Frame | ||
| 81 | if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height { | 93 | if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height { | ||
| 82 | frame = cache.floatingFrame | 94 | frame = cache.floatingFrame | ||
| @@ -90,13 +102,13 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||||
| 90 | } | 102 | } | ||
| 91 | } | 103 | } | ||
| 92 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | 104 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | ||
| 93 | - | desiredGeometry := calculateFloatingGeometry(content, cfg) | |||
| 94 | - | ||||
| 95 | // The PTY reader mutates both the frame and its damage under p.mu. Keep | 105 | // The PTY reader mutates both the frame and its damage under p.mu. Keep | ||
| 96 | // every read of them, including the blit, in the same critical section. | 106 | // every read of them, including the blit, in the same critical section. | ||
| 97 | // In particular, Frame is not safe to retain as an alias after unlocking. | 107 | // In particular, Frame is not safe to retain as an alias after unlocking. | ||
| 98 | p.mu.Lock() | 108 | p.mu.Lock() | ||
| 99 | - | geometry := p.committedFloatingGeometryLocked(desiredGeometry) | 109 | + | // Committed popup geometry is content-relative; translate it exactly once |
| 110 | + | // while holding the same lock that protects the committed state and frame. | |||
| 111 | + | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | |||
| 100 | title := p.displayTitleLocked() | 112 | title := p.displayTitleLocked() | ||
| 101 | titleGeneration := p.title.generation | 113 | titleGeneration := p.title.generation | ||
| 102 | screenDamage := p.screen.Damage() | 114 | screenDamage := p.screen.Damage() | ||
| @@ -121,12 +133,12 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||||
| 121 | cache.floatingTitleGeneration = titleGeneration | 133 | cache.floatingTitleGeneration = titleGeneration | ||
| 122 | } | 134 | } | ||
| 123 | if full || popupChanged { | 135 | if full || popupChanged { | ||
| 124 | - | return frame, []renderer.Damage{renderer.FullRedraw()} | 136 | + | return frame, []renderer.Damage{renderer.FullRedraw()}, geometry |
| 125 | } | 137 | } | ||
| 126 | if titleChanged && geometry.Bounds.Height >= 3 { | 138 | if titleChanged && geometry.Bounds.Height >= 3 { | ||
| 127 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | 139 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | ||
| 128 | } | 140 | } | ||
| 129 | - | return frame, damage | 141 | + | return frame, damage, geometry |
| 130 | } | 142 | } | ||
| 131 | 143 | ||||
| 132 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | 144 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | ||
| @@ -201,12 +213,12 @@ func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |||||
| 201 | } | 213 | } | ||
| 202 | tb.mu.Lock() | 214 | tb.mu.Lock() | ||
| 203 | p := tb.floating.pane | 215 | p := tb.floating.pane | ||
| 204 | - | content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 216 | + | content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows} |
| 205 | tb.mu.Unlock() | 217 | tb.mu.Unlock() | ||
| 206 | if p == nil { | 218 | if p == nil { | ||
| 207 | return false | 219 | return false | ||
| 208 | } | 220 | } | ||
| 209 | - | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig())) | 221 | + | return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig())) |
| 210 | } | 222 | } | ||
| 211 | 223 | ||||
| 212 | // resizeActiveFloating updates a visible floating pane during a client resize. | 224 | // resizeActiveFloating updates a visible floating pane during a client resize. | ||
M internal/usecase/daemon/floating_render_test.go +90 -30
| @@ -14,22 +14,78 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | func TestFloatingGeometryTranslate(t *testing.T) { | |
| 18 | + | geometry := floatingGeometry{ | |
| 19 | + | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 20 | + | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | translated := geometry.translate(11, 13) | |
| 24 | + | require.Equal(t, floatingGeometry{ | |
| 25 | + | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | |
| 26 | + | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | |
| 27 | + | }, translated) | |
| 28 | + | require.Equal(t, floatingGeometry{ | |
| 29 | + | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 30 | + | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 31 | + | }, geometry) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |
| 35 | + | const frameX, frameY = 7, 3 | |
| 36 | + | tests := []struct { | |
| 37 | + | name string | |
| 38 | + | content domain.Size | |
| 39 | + | cfg domain.FloatingConfig | |
| 40 | + | tinyWidth, tinyHeight bool | |
| 41 | + | }{ | |
| 42 | + | {name: "normal", content: domain.Size{Cols: 80, Rows: 24}, cfg: domain.FloatingConfig{Width: 80, Height: 75}}, | |
| 43 | + | {name: "tiny width omits horizontal borders", content: domain.Size{Cols: 2, Rows: 20}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, tinyWidth: true}, | |
| 44 | + | {name: "tiny height omits vertical borders", content: domain.Size{Cols: 20, Rows: 2}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, tinyHeight: true}, | |
| 45 | + | {name: "full size", content: domain.Size{Cols: 80, Rows: 24}, cfg: domain.FloatingConfig{Width: 100, Height: 100}}, | |
| 46 | + | } | |
| 47 | + | for _, tt := range tests { | |
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |
| 49 | + | contentGeometry := calculateContentFloatingGeometry(tt.content, tt.cfg) | |
| 50 | + | frameGeometry := contentGeometry.translate(frameX, frameY) | |
| 51 | + | ||
| 52 | + | require.Equal(t, contentGeometry.Bounds.X+frameX, frameGeometry.Bounds.X) | |
| 53 | + | require.Equal(t, contentGeometry.Bounds.Y+frameY, frameGeometry.Bounds.Y) | |
| 54 | + | require.Equal(t, contentGeometry.Inner.X+frameX, frameGeometry.Inner.X) | |
| 55 | + | require.Equal(t, contentGeometry.Inner.Y+frameY, frameGeometry.Inner.Y) | |
| 56 | + | require.Equal(t, contentGeometry.Bounds.Width, frameGeometry.Bounds.Width) | |
| 57 | + | require.Equal(t, contentGeometry.Bounds.Height, frameGeometry.Bounds.Height) | |
| 58 | + | require.Equal(t, contentGeometry.Inner.Width, frameGeometry.Inner.Width) | |
| 59 | + | require.Equal(t, contentGeometry.Inner.Height, frameGeometry.Inner.Height) | |
| 60 | + | ||
| 61 | + | if tt.tinyWidth { | |
| 62 | + | require.Equal(t, frameGeometry.Bounds.X, frameGeometry.Inner.X) | |
| 63 | + | require.Equal(t, frameGeometry.Bounds.Width, frameGeometry.Inner.Width) | |
| 64 | + | } | |
| 65 | + | if tt.tinyHeight { | |
| 66 | + | require.Equal(t, frameGeometry.Bounds.Y, frameGeometry.Inner.Y) | |
| 67 | + | require.Equal(t, frameGeometry.Bounds.Height, frameGeometry.Inner.Height) | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 17 | 73 | func TestCalculateFloatingGeometry(t *testing.T) { | |
| 18 | 74 | tests := []struct { | |
| 19 | 75 | name string | |
| 20 | - | content domain.Rect | |
| 76 | + | content domain.Size | |
| 21 | 77 | cfg domain.FloatingConfig | |
| 22 | 78 | want floatingGeometry | |
| 23 | 79 | }{ | |
| 24 | - | {"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}}}, | |
| 25 | - | {"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}}}, | |
| 26 | - | {"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}}}, | |
| 27 | - | {"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}}}, | |
| 28 | - | {"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}}}, | |
| 80 | + | {"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}}}, | |
| 81 | + | {"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}}}, | |
| 82 | + | {"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}}}, | |
| 83 | + | {"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}}}, | |
| 84 | + | {"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}}}, | |
| 29 | 85 | } | |
| 30 | 86 | for _, tt := range tests { | |
| 31 | 87 | t.Run(tt.name, func(t *testing.T) { | |
| 32 | - | require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg)) | |
| 88 | + | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg)) | |
| 33 | 89 | }) | |
| 34 | 90 | } | |
| 35 | 91 | } | |
| @@ -61,9 +117,9 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |||
| 61 | 117 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | |
| 62 | 118 | content := domain.Rect{Y: 1, Width: 40, Height: 10} | |
| 63 | 119 | cache := &composedFrameCache{} | |
| 64 | - | frame, _ := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 120 | + | frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 65 | 121 | require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination") | |
| 66 | - | geometry := calculateFloatingGeometry(content, domain.FloatingConfig{Width: 80, Height: 80}) | |
| 122 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}).translate(content.X, content.Y) | |
| 67 | 123 | require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune) | |
| 68 | 124 | require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune) | |
| 69 | 125 | var gotTitle strings.Builder | |
| @@ -75,7 +131,7 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |||
| 75 | 131 | p.mu.Lock() | |
| 76 | 132 | p.title.generation++ | |
| 77 | 133 | p.mu.Unlock() | |
| 78 | - | _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 134 | + | _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 79 | 135 | require.Len(t, damage, 1) | |
| 80 | 136 | require.Equal(t, geometry.Bounds.Y, damage[0].Y) | |
| 81 | 137 | require.Equal(t, 1, damage[0].Height) | |
| @@ -106,15 +162,15 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) { | |||
| 106 | 162 | cache := &composedFrameCache{} | |
| 107 | 163 | base := renderer.NewFrame(30, 12) | |
| 108 | 164 | content := domain.Rect{Y: 1, Width: 30, Height: 10} | |
| 109 | - | _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 165 | + | _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 110 | 166 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 111 | 167 | ||
| 112 | 168 | p.mu.Lock() | |
| 113 | 169 | p.rect, p.popupGeometry = tt.next.Inner, tt.next | |
| 114 | 170 | p.mu.Unlock() | |
| 115 | - | _, damage = composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 171 | + | _, damage, _ = composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 116 | 172 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 117 | - | require.Equal(t, tt.next, cache.floatingGeometry) | |
| 173 | + | require.Equal(t, tt.next.translate(content.X, content.Y), cache.floatingGeometry) | |
| 118 | 174 | }) | |
| 119 | 175 | } | |
| 120 | 176 | } | |
| @@ -164,8 +220,8 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) { | |||
| 164 | 220 | base := renderer.NewFrame(40, 12) | |
| 165 | 221 | content := domain.Rect{Y: 1, Width: 40, Height: 10} | |
| 166 | 222 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |
| 167 | - | frame, _ := composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false) | |
| 168 | - | geometry := calculateFloatingGeometry(content, cfg) | |
| 223 | + | frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false) | |
| 224 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg).translate(content.X, content.Y) | |
| 169 | 225 | var gotTitle strings.Builder | |
| 170 | 226 | for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ { | |
| 171 | 227 | gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune) | |
| @@ -197,7 +253,7 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 197 | 253 | <-start | |
| 198 | 254 | cache := &composedFrameCache{} | |
| 199 | 255 | for range 500 { | |
| 200 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 256 | + | composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 201 | 257 | } | |
| 202 | 258 | }() | |
| 203 | 259 | close(start) | |
| @@ -253,14 +309,15 @@ func BenchmarkComposeFloatingFrameCached(b *testing.B) { | |||
| 253 | 309 | base := renderer.NewFrame(80, 24) | |
| 254 | 310 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| 255 | 311 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |
| 312 | + | desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg) | |
| 256 | 313 | cache := &composedFrameCache{} | |
| 257 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 314 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 258 | 315 | p.screen.ClearDamage() | |
| 259 | 316 | ||
| 260 | 317 | b.ReportAllocs() | |
| 261 | 318 | b.ResetTimer() | |
| 262 | 319 | for b.Loop() { | |
| 263 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 320 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 264 | 321 | } | |
| 265 | 322 | } | |
| 266 | 323 | ||
| @@ -270,12 +327,13 @@ func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) { | |||
| 270 | 327 | base := renderer.NewFrame(80, 24) | |
| 271 | 328 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| 272 | 329 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |
| 330 | + | desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg) | |
| 273 | 331 | cache := &composedFrameCache{} | |
| 274 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 332 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 275 | 333 | p.screen.ClearDamage() | |
| 276 | 334 | ||
| 277 | 335 | allocs := testing.AllocsPerRun(100, func() { | |
| 278 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 336 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 279 | 337 | }) | |
| 280 | 338 | require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes") | |
| 281 | 339 | } | |
| @@ -286,8 +344,8 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 286 | 344 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | |
| 287 | 345 | sess := &session{tabs: []*tab{tb}} | |
| 288 | 346 | ||
| 289 | - | initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()) | |
| 290 | - | currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()) | |
| 347 | + | initialGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, d.currentFloatingConfig()) | |
| 348 | + | currentGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 40}, d.currentFloatingConfig()) | |
| 291 | 349 | initial := initialGeometry.Inner | |
| 292 | 350 | current := currentGeometry.Inner | |
| 293 | 351 | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | |
| @@ -328,8 +386,8 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||
| 328 | 386 | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |
| 329 | 387 | oldContent := domain.Rect{Y: 1, Width: 80, Height: 24} | |
| 330 | 388 | newContent := domain.Rect{Y: 1, Width: 100, Height: 40} | |
| 331 | - | oldGeometry := calculateFloatingGeometry(oldContent, cfg) | |
| 332 | - | newGeometry := calculateFloatingGeometry(newContent, cfg) | |
| 389 | + | oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg) | |
| 390 | + | newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg) | |
| 333 | 391 | pty := &resizePTY{err: errors.New("resize failed")} | |
| 334 | 392 | p := newPane("floating", pty, rectSize(oldGeometry.Inner)) | |
| 335 | 393 | p.rect = oldGeometry.Inner | |
| @@ -342,17 +400,19 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||
| 342 | 400 | require.False(t, d.resizeFloatingPane(p, newGeometry)) | |
| 343 | 401 | tb.mu.Lock() | |
| 344 | 402 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 345 | - | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg) | |
| 403 | + | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | |
| 346 | 404 | tb.mu.Unlock() | |
| 347 | 405 | require.True(t, visible) | |
| 348 | 406 | require.Equal(t, oldGeometry, inputGeometry) | |
| 349 | - | require.Equal(t, oldGeometry.Inner, copyRect) | |
| 407 | + | require.Equal(t, oldGeometry.translate(newContent.X, newContent.Y).Inner, copyRect) | |
| 350 | 408 | ||
| 351 | 409 | base := renderer.NewFrame(newContent.Width, newContent.Height+2) | |
| 352 | - | frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true) | |
| 353 | - | require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune) | |
| 354 | - | if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y { | |
| 355 | - | require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune) | |
| 410 | + | frame, _, _ := composeFloatingFrame(base, nil, p, 1, newContent, calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true) | |
| 411 | + | oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y) | |
| 412 | + | newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y) | |
| 413 | + | require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune) | |
| 414 | + | if newFrameGeometry.Bounds.X != oldFrameGeometry.Bounds.X || newFrameGeometry.Bounds.Y != oldFrameGeometry.Bounds.Y { | |
| 415 | + | require.NotEqual(t, '┌', frame.At(newFrameGeometry.Bounds.X, newFrameGeometry.Bounds.Y).Rune) | |
| 356 | 416 | } | |
| 357 | 417 | } | |
| 358 | 418 | ||
| @@ -14,22 +14,78 @@ import ( | |||||
| 14 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | func TestFloatingGeometryTranslate(t *testing.T) { | |||
| 18 | + | geometry := floatingGeometry{ | |||
| 19 | + | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |||
| 20 | + | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | translated := geometry.translate(11, 13) | |||
| 24 | + | require.Equal(t, floatingGeometry{ | |||
| 25 | + | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | |||
| 26 | + | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | |||
| 27 | + | }, translated) | |||
| 28 | + | require.Equal(t, floatingGeometry{ | |||
| 29 | + | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |||
| 30 | + | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |||
| 31 | + | }, geometry) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |||
| 35 | + | const frameX, frameY = 7, 3 | |||
| 36 | + | tests := []struct { | |||
| 37 | + | name string | |||
| 38 | + | content domain.Size | |||
| 39 | + | cfg domain.FloatingConfig | |||
| 40 | + | tinyWidth, tinyHeight bool | |||
| 41 | + | }{ | |||
| 42 | + | {name: "normal", content: domain.Size{Cols: 80, Rows: 24}, cfg: domain.FloatingConfig{Width: 80, Height: 75}}, | |||
| 43 | + | {name: "tiny width omits horizontal borders", content: domain.Size{Cols: 2, Rows: 20}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, tinyWidth: true}, | |||
| 44 | + | {name: "tiny height omits vertical borders", content: domain.Size{Cols: 20, Rows: 2}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, tinyHeight: true}, | |||
| 45 | + | {name: "full size", content: domain.Size{Cols: 80, Rows: 24}, cfg: domain.FloatingConfig{Width: 100, Height: 100}}, | |||
| 46 | + | } | |||
| 47 | + | for _, tt := range tests { | |||
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 49 | + | contentGeometry := calculateContentFloatingGeometry(tt.content, tt.cfg) | |||
| 50 | + | frameGeometry := contentGeometry.translate(frameX, frameY) | |||
| 51 | + | ||||
| 52 | + | require.Equal(t, contentGeometry.Bounds.X+frameX, frameGeometry.Bounds.X) | |||
| 53 | + | require.Equal(t, contentGeometry.Bounds.Y+frameY, frameGeometry.Bounds.Y) | |||
| 54 | + | require.Equal(t, contentGeometry.Inner.X+frameX, frameGeometry.Inner.X) | |||
| 55 | + | require.Equal(t, contentGeometry.Inner.Y+frameY, frameGeometry.Inner.Y) | |||
| 56 | + | require.Equal(t, contentGeometry.Bounds.Width, frameGeometry.Bounds.Width) | |||
| 57 | + | require.Equal(t, contentGeometry.Bounds.Height, frameGeometry.Bounds.Height) | |||
| 58 | + | require.Equal(t, contentGeometry.Inner.Width, frameGeometry.Inner.Width) | |||
| 59 | + | require.Equal(t, contentGeometry.Inner.Height, frameGeometry.Inner.Height) | |||
| 60 | + | ||||
| 61 | + | if tt.tinyWidth { | |||
| 62 | + | require.Equal(t, frameGeometry.Bounds.X, frameGeometry.Inner.X) | |||
| 63 | + | require.Equal(t, frameGeometry.Bounds.Width, frameGeometry.Inner.Width) | |||
| 64 | + | } | |||
| 65 | + | if tt.tinyHeight { | |||
| 66 | + | require.Equal(t, frameGeometry.Bounds.Y, frameGeometry.Inner.Y) | |||
| 67 | + | require.Equal(t, frameGeometry.Bounds.Height, frameGeometry.Inner.Height) | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 17 | func TestCalculateFloatingGeometry(t *testing.T) { | 73 | func TestCalculateFloatingGeometry(t *testing.T) { | ||
| 18 | tests := []struct { | 74 | tests := []struct { | ||
| 19 | name string | 75 | name string | ||
| 20 | - | content domain.Rect | 76 | + | content domain.Size |
| 21 | cfg domain.FloatingConfig | 77 | cfg domain.FloatingConfig | ||
| 22 | want floatingGeometry | 78 | want floatingGeometry | ||
| 23 | }{ | 79 | }{ | ||
| 24 | - | {"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}}}, | 80 | + | {"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}}}, |
| 25 | - | {"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}}}, | 81 | + | {"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}}}, |
| 26 | - | {"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}}}, | 82 | + | {"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}}}, |
| 27 | - | {"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}}}, | 83 | + | {"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}}}, |
| 28 | - | {"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}}}, | 84 | + | {"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}}}, |
| 29 | } | 85 | } | ||
| 30 | for _, tt := range tests { | 86 | for _, tt := range tests { | ||
| 31 | t.Run(tt.name, func(t *testing.T) { | 87 | t.Run(tt.name, func(t *testing.T) { | ||
| 32 | - | require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg)) | 88 | + | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg)) |
| 33 | }) | 89 | }) | ||
| 34 | } | 90 | } | ||
| 35 | } | 91 | } | ||
| @@ -61,9 +117,9 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |||||
| 61 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | 117 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | ||
| 62 | content := domain.Rect{Y: 1, Width: 40, Height: 10} | 118 | content := domain.Rect{Y: 1, Width: 40, Height: 10} | ||
| 63 | cache := &composedFrameCache{} | 119 | cache := &composedFrameCache{} | ||
| 64 | - | frame, _ := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 120 | + | frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 65 | require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination") | 121 | require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination") | ||
| 66 | - | geometry := calculateFloatingGeometry(content, domain.FloatingConfig{Width: 80, Height: 80}) | 122 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}).translate(content.X, content.Y) |
| 67 | require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune) | 123 | require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune) | ||
| 68 | require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune) | 124 | require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune) | ||
| 69 | var gotTitle strings.Builder | 125 | var gotTitle strings.Builder | ||
| @@ -75,7 +131,7 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |||||
| 75 | p.mu.Lock() | 131 | p.mu.Lock() | ||
| 76 | p.title.generation++ | 132 | p.title.generation++ | ||
| 77 | p.mu.Unlock() | 133 | p.mu.Unlock() | ||
| 78 | - | _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 134 | + | _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 79 | require.Len(t, damage, 1) | 135 | require.Len(t, damage, 1) | ||
| 80 | require.Equal(t, geometry.Bounds.Y, damage[0].Y) | 136 | require.Equal(t, geometry.Bounds.Y, damage[0].Y) | ||
| 81 | require.Equal(t, 1, damage[0].Height) | 137 | require.Equal(t, 1, damage[0].Height) | ||
| @@ -106,15 +162,15 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) { | |||||
| 106 | cache := &composedFrameCache{} | 162 | cache := &composedFrameCache{} | ||
| 107 | base := renderer.NewFrame(30, 12) | 163 | base := renderer.NewFrame(30, 12) | ||
| 108 | content := domain.Rect{Y: 1, Width: 30, Height: 10} | 164 | content := domain.Rect{Y: 1, Width: 30, Height: 10} | ||
| 109 | - | _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 165 | + | _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 110 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | 166 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | ||
| 111 | 167 | ||||
| 112 | p.mu.Lock() | 168 | p.mu.Lock() | ||
| 113 | p.rect, p.popupGeometry = tt.next.Inner, tt.next | 169 | p.rect, p.popupGeometry = tt.next.Inner, tt.next | ||
| 114 | p.mu.Unlock() | 170 | p.mu.Unlock() | ||
| 115 | - | _, damage = composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 171 | + | _, damage, _ = composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 116 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | 172 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | ||
| 117 | - | require.Equal(t, tt.next, cache.floatingGeometry) | 173 | + | require.Equal(t, tt.next.translate(content.X, content.Y), cache.floatingGeometry) |
| 118 | }) | 174 | }) | ||
| 119 | } | 175 | } | ||
| 120 | } | 176 | } | ||
| @@ -164,8 +220,8 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) { | |||||
| 164 | base := renderer.NewFrame(40, 12) | 220 | base := renderer.NewFrame(40, 12) | ||
| 165 | content := domain.Rect{Y: 1, Width: 40, Height: 10} | 221 | content := domain.Rect{Y: 1, Width: 40, Height: 10} | ||
| 166 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | 222 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | ||
| 167 | - | frame, _ := composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false) | 223 | + | frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false) |
| 168 | - | geometry := calculateFloatingGeometry(content, cfg) | 224 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg).translate(content.X, content.Y) |
| 169 | var gotTitle strings.Builder | 225 | var gotTitle strings.Builder | ||
| 170 | for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ { | 226 | for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ { | ||
| 171 | gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune) | 227 | gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune) | ||
| @@ -197,7 +253,7 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||||
| 197 | <-start | 253 | <-start | ||
| 198 | cache := &composedFrameCache{} | 254 | cache := &composedFrameCache{} | ||
| 199 | for range 500 { | 255 | for range 500 { | ||
| 200 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 256 | + | composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 201 | } | 257 | } | ||
| 202 | }() | 258 | }() | ||
| 203 | close(start) | 259 | close(start) | ||
| @@ -253,14 +309,15 @@ func BenchmarkComposeFloatingFrameCached(b *testing.B) { | |||||
| 253 | base := renderer.NewFrame(80, 24) | 309 | base := renderer.NewFrame(80, 24) | ||
| 254 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | 310 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | ||
| 255 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | 311 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | ||
| 312 | + | desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg) | |||
| 256 | cache := &composedFrameCache{} | 313 | cache := &composedFrameCache{} | ||
| 257 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 314 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 258 | p.screen.ClearDamage() | 315 | p.screen.ClearDamage() | ||
| 259 | 316 | ||||
| 260 | b.ReportAllocs() | 317 | b.ReportAllocs() | ||
| 261 | b.ResetTimer() | 318 | b.ResetTimer() | ||
| 262 | for b.Loop() { | 319 | for b.Loop() { | ||
| 263 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 320 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 264 | } | 321 | } | ||
| 265 | } | 322 | } | ||
| 266 | 323 | ||||
| @@ -270,12 +327,13 @@ func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) { | |||||
| 270 | base := renderer.NewFrame(80, 24) | 327 | base := renderer.NewFrame(80, 24) | ||
| 271 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | 328 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | ||
| 272 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | 329 | cfg := domain.FloatingConfig{Width: 80, Height: 80} | ||
| 330 | + | desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg) | |||
| 273 | cache := &composedFrameCache{} | 331 | cache := &composedFrameCache{} | ||
| 274 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 332 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 275 | p.screen.ClearDamage() | 333 | p.screen.ClearDamage() | ||
| 276 | 334 | ||||
| 277 | allocs := testing.AllocsPerRun(100, func() { | 335 | allocs := testing.AllocsPerRun(100, func() { | ||
| 278 | - | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | 336 | + | composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) |
| 279 | }) | 337 | }) | ||
| 280 | require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes") | 338 | require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes") | ||
| 281 | } | 339 | } | ||
| @@ -286,8 +344,8 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 286 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | 344 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | ||
| 287 | sess := &session{tabs: []*tab{tb}} | 345 | sess := &session{tabs: []*tab{tb}} | ||
| 288 | 346 | ||||
| 289 | - | initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()) | 347 | + | initialGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, d.currentFloatingConfig()) |
| 290 | - | currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()) | 348 | + | currentGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 40}, d.currentFloatingConfig()) |
| 291 | initial := initialGeometry.Inner | 349 | initial := initialGeometry.Inner | ||
| 292 | current := currentGeometry.Inner | 350 | current := currentGeometry.Inner | ||
| 293 | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | 351 | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | ||
| @@ -328,8 +386,8 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||||
| 328 | cfg := domain.FloatingConfig{Width: 50, Height: 50} | 386 | cfg := domain.FloatingConfig{Width: 50, Height: 50} | ||
| 329 | oldContent := domain.Rect{Y: 1, Width: 80, Height: 24} | 387 | oldContent := domain.Rect{Y: 1, Width: 80, Height: 24} | ||
| 330 | newContent := domain.Rect{Y: 1, Width: 100, Height: 40} | 388 | newContent := domain.Rect{Y: 1, Width: 100, Height: 40} | ||
| 331 | - | oldGeometry := calculateFloatingGeometry(oldContent, cfg) | 389 | + | oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg) |
| 332 | - | newGeometry := calculateFloatingGeometry(newContent, cfg) | 390 | + | newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg) |
| 333 | pty := &resizePTY{err: errors.New("resize failed")} | 391 | pty := &resizePTY{err: errors.New("resize failed")} | ||
| 334 | p := newPane("floating", pty, rectSize(oldGeometry.Inner)) | 392 | p := newPane("floating", pty, rectSize(oldGeometry.Inner)) | ||
| 335 | p.rect = oldGeometry.Inner | 393 | p.rect = oldGeometry.Inner | ||
| @@ -342,17 +400,19 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||||
| 342 | require.False(t, d.resizeFloatingPane(p, newGeometry)) | 400 | require.False(t, d.resizeFloatingPane(p, newGeometry)) | ||
| 343 | tb.mu.Lock() | 401 | tb.mu.Lock() | ||
| 344 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | 402 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | ||
| 345 | - | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg) | 403 | + | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) |
| 346 | tb.mu.Unlock() | 404 | tb.mu.Unlock() | ||
| 347 | require.True(t, visible) | 405 | require.True(t, visible) | ||
| 348 | require.Equal(t, oldGeometry, inputGeometry) | 406 | require.Equal(t, oldGeometry, inputGeometry) | ||
| 349 | - | require.Equal(t, oldGeometry.Inner, copyRect) | 407 | + | require.Equal(t, oldGeometry.translate(newContent.X, newContent.Y).Inner, copyRect) |
| 350 | 408 | ||||
| 351 | base := renderer.NewFrame(newContent.Width, newContent.Height+2) | 409 | base := renderer.NewFrame(newContent.Width, newContent.Height+2) | ||
| 352 | - | frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true) | 410 | + | frame, _, _ := composeFloatingFrame(base, nil, p, 1, newContent, calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true) |
| 353 | - | require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune) | 411 | + | oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y) |
| 354 | - | if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y { | 412 | + | newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y) |
| 355 | - | require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune) | 413 | + | require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune) |
| 414 | + | if newFrameGeometry.Bounds.X != oldFrameGeometry.Bounds.X || newFrameGeometry.Bounds.Y != oldFrameGeometry.Bounds.Y { | |||
| 415 | + | require.NotEqual(t, '┌', frame.At(newFrameGeometry.Bounds.X, newFrameGeometry.Bounds.Y).Rune) | |||
| 356 | } | 416 | } | ||
| 357 | } | 417 | } | ||
| 358 | 418 | ||||
M internal/usecase/daemon/input_test.go +2 -2
| @@ -223,7 +223,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||
| 223 | 223 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |
| 224 | 224 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 225 | 225 | installTestFloating(tb, floating, true) | |
| 226 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |
| 226 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | |
| 227 | 227 | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | |
| 228 | 228 | ||
| 229 | 229 | d.handleInput(sess, ac, raw) | |
| @@ -240,7 +240,7 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||
| 240 | 240 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |
| 241 | 241 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 242 | 242 | installTestFloating(tb, floating, true) | |
| 243 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |
| 243 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | |
| 244 | 244 | ||
| 245 | 245 | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | |
| 246 | 246 | outside := []byte("\x1b[<0;1;2M") | |
| @@ -223,7 +223,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||||
| 223 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | 223 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | ||
| 224 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | 224 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | ||
| 225 | installTestFloating(tb, floating, true) | 225 | installTestFloating(tb, floating, true) | ||
| 226 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | 226 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) |
| 227 | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | 227 | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | ||
| 228 | 228 | ||||
| 229 | d.handleInput(sess, ac, raw) | 229 | d.handleInput(sess, ac, raw) | ||
| @@ -240,7 +240,7 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||||
| 240 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | 240 | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | ||
| 241 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | 241 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | ||
| 242 | installTestFloating(tb, floating, true) | 242 | installTestFloating(tb, floating, true) | ||
| 243 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | 243 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) |
| 244 | 244 | ||||
| 245 | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | 245 | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | ||
| 246 | outside := []byte("\x1b[<0;1;2M") | 246 | outside := []byte("\x1b[<0;1;2M") | ||
M internal/usecase/daemon/render.go +10 -14
| @@ -278,16 +278,12 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||
| 278 | 278 | // copyTargetRectLocked maps a captured copy source into the already-composed | |
| 279 | 279 | // client frame. The caller holds tb.mu, preserving the layout/floating | |
| 280 | 280 | // snapshot while the rectangle is chosen. | |
| 281 | - | func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, cfg domain.FloatingConfig) domain.Rect { | |
| 281 | + | func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect { | |
| 282 | 282 | if p == nil { | |
| 283 | 283 | return domain.Rect{} | |
| 284 | 284 | } | |
| 285 | 285 | if hasFloating && p == floating { | |
| 286 | - | desired := calculateFloatingGeometry(contentArea, cfg) | |
| 287 | - | p.mu.Lock() | |
| 288 | - | geometry := p.committedFloatingGeometryLocked(desired) | |
| 289 | - | p.mu.Unlock() | |
| 290 | - | return geometry.Inner | |
| 286 | + | return floatingFrameGeometry.Inner | |
| 291 | 287 | } | |
| 292 | 288 | for _, placement := range layoutSnap.placements { | |
| 293 | 289 | if placement.ID == p.id && !placement.Collapsed { | |
| @@ -399,15 +395,17 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 399 | 395 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |
| 400 | 396 | } | |
| 401 | 397 | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |
| 398 | + | floatingFrameGeometry := floatingGeometry{} | |
| 402 | 399 | if hasFloating { | |
| 403 | - | frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |
| 400 | + | desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg) | |
| 401 | + | frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |
| 404 | 402 | } | |
| 405 | 403 | if overlays.copyActive { | |
| 406 | 404 | copyPane := overlays.copyPane | |
| 407 | 405 | if copyPane == nil { | |
| 408 | 406 | copyPane = p | |
| 409 | 407 | } | |
| 410 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg) | |
| 408 | + | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry) | |
| 411 | 409 | frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars) | |
| 412 | 410 | } | |
| 413 | 411 | // A palette above normal/copy content dims that composed content. When a | |
| @@ -439,15 +437,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 439 | 437 | overlays.Unlock() | |
| 440 | 438 | cursorPane := p | |
| 441 | 439 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |
| 440 | + | cursorContent.X += contentArea.X | |
| 441 | + | cursorContent.Y += contentArea.Y | |
| 442 | 442 | if hasFloating { | |
| 443 | 443 | cursorPane = floating | |
| 444 | + | cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0 | |
| 444 | 445 | } | |
| 445 | 446 | cursorPane.mu.Lock() | |
| 446 | - | if hasFloating { | |
| 447 | - | desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg) | |
| 448 | - | geometry := floating.committedFloatingGeometryLocked(desiredGeometry) | |
| 449 | - | cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0 | |
| 450 | - | } | |
| 451 | 447 | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | |
| 452 | 448 | cursorPane.mu.Unlock() | |
| 453 | 449 | ac.sess.mu.Lock() | |
| @@ -519,7 +515,7 @@ func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { | |||
| 519 | 515 | if !ok { | |
| 520 | 516 | style = 1 | |
| 521 | 517 | } | |
| 522 | - | return cursorOut{row: content.Y + s.CursorRow() + 1, col: content.X + s.CursorCol(), style: style, hasStyle: true} | |
| 518 | + | return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true} | |
| 523 | 519 | } | |
| 524 | 520 | ||
| 525 | 521 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |
| @@ -278,16 +278,12 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||||
| 278 | // copyTargetRectLocked maps a captured copy source into the already-composed | 278 | // copyTargetRectLocked maps a captured copy source into the already-composed | ||
| 279 | // client frame. The caller holds tb.mu, preserving the layout/floating | 279 | // client frame. The caller holds tb.mu, preserving the layout/floating | ||
| 280 | // snapshot while the rectangle is chosen. | 280 | // snapshot while the rectangle is chosen. | ||
| 281 | - | func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, cfg domain.FloatingConfig) domain.Rect { | 281 | + | func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect { |
| 282 | if p == nil { | 282 | if p == nil { | ||
| 283 | return domain.Rect{} | 283 | return domain.Rect{} | ||
| 284 | } | 284 | } | ||
| 285 | if hasFloating && p == floating { | 285 | if hasFloating && p == floating { | ||
| 286 | - | desired := calculateFloatingGeometry(contentArea, cfg) | 286 | + | return floatingFrameGeometry.Inner |
| 287 | - | p.mu.Lock() | |||
| 288 | - | geometry := p.committedFloatingGeometryLocked(desired) | |||
| 289 | - | p.mu.Unlock() | |||
| 290 | - | return geometry.Inner | |||
| 291 | } | 287 | } | ||
| 292 | for _, placement := range layoutSnap.placements { | 288 | for _, placement := range layoutSnap.placements { | ||
| 293 | if placement.ID == p.id && !placement.Collapsed { | 289 | if placement.ID == p.id && !placement.Collapsed { | ||
| @@ -399,15 +395,17 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 399 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | 395 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | ||
| 400 | } | 396 | } | ||
| 401 | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | 397 | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | ||
| 398 | + | floatingFrameGeometry := floatingGeometry{} | |||
| 402 | if hasFloating { | 399 | if hasFloating { | ||
| 403 | - | frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | 400 | + | desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg) |
| 401 | + | frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |||
| 404 | } | 402 | } | ||
| 405 | if overlays.copyActive { | 403 | if overlays.copyActive { | ||
| 406 | copyPane := overlays.copyPane | 404 | copyPane := overlays.copyPane | ||
| 407 | if copyPane == nil { | 405 | if copyPane == nil { | ||
| 408 | copyPane = p | 406 | copyPane = p | ||
| 409 | } | 407 | } | ||
| 410 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg) | 408 | + | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry) |
| 411 | frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars) | 409 | frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars) | ||
| 412 | } | 410 | } | ||
| 413 | // A palette above normal/copy content dims that composed content. When a | 411 | // A palette above normal/copy content dims that composed content. When a | ||
| @@ -439,15 +437,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 439 | overlays.Unlock() | 437 | overlays.Unlock() | ||
| 440 | cursorPane := p | 438 | cursorPane := p | ||
| 441 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | 439 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | ||
| 440 | + | cursorContent.X += contentArea.X | |||
| 441 | + | cursorContent.Y += contentArea.Y | |||
| 442 | if hasFloating { | 442 | if hasFloating { | ||
| 443 | cursorPane = floating | 443 | cursorPane = floating | ||
| 444 | + | cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0 | |||
| 444 | } | 445 | } | ||
| 445 | cursorPane.mu.Lock() | 446 | cursorPane.mu.Lock() | ||
| 446 | - | if hasFloating { | |||
| 447 | - | desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg) | |||
| 448 | - | geometry := floating.committedFloatingGeometryLocked(desiredGeometry) | |||
| 449 | - | cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0 | |||
| 450 | - | } | |||
| 451 | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | 447 | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | ||
| 452 | cursorPane.mu.Unlock() | 448 | cursorPane.mu.Unlock() | ||
| 453 | ac.sess.mu.Lock() | 449 | ac.sess.mu.Lock() | ||
| @@ -519,7 +515,7 @@ func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { | |||||
| 519 | if !ok { | 515 | if !ok { | ||
| 520 | style = 1 | 516 | style = 1 | ||
| 521 | } | 517 | } | ||
| 522 | - | return cursorOut{row: content.Y + s.CursorRow() + 1, col: content.X + s.CursorCol(), style: style, hasStyle: true} | 518 | + | return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true} |
| 523 | } | 519 | } | ||
| 524 | 520 | ||||
| 525 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | 521 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | ||
M internal/usecase/daemon/render_test.go +30 -2
| @@ -1305,6 +1305,28 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |||
| 1305 | 1305 | require.Contains(t, string(data), "\x1b[?25l") | |
| 1306 | 1306 | } | |
| 1307 | 1307 | ||
| 1308 | + | func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) { | |
| 1309 | + | p, release := newBlockingPTY(t) | |
| 1310 | + | defer release() | |
| 1311 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1312 | + | contentArea := domain.Rect{Y: 1, Width: 80, Height: 23} | |
| 1313 | + | cfg := d.currentFloatingConfig() | |
| 1314 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg) | |
| 1315 | + | floating := newPane("floating", nil, rectSize(committed.Inner)) | |
| 1316 | + | floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |
| 1317 | + | floating.popupGeometry = committed | |
| 1318 | + | installTestFloating(sess.activeTab(), floating, true) | |
| 1319 | + | ||
| 1320 | + | d.paint(sess, ac, true) | |
| 1321 | + | data := mustOutputData(t, sends) | |
| 1322 | + | want := committed.translate(contentArea.X, contentArea.Y) | |
| 1323 | + | require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1)) | |
| 1324 | + | require.Contains(t, string(data), "F") | |
| 1325 | + | require.Equal(t, want.Inner.Y, ac.lastCursor.row) | |
| 1326 | + | require.Equal(t, want.Inner.X, ac.lastCursor.col) | |
| 1327 | + | require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, want.Inner.X+1)) | |
| 1328 | + | } | |
| 1329 | + | ||
| 1308 | 1330 | func TestCursorTailUsesFocusedPanePlacement(t *testing.T) { | |
| 1309 | 1331 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 1310 | 1332 | win := sess.tabs[0] | |
| @@ -1460,6 +1482,8 @@ func TestCopyTargetRectLocked(t *testing.T) { | |||
| 1460 | 1482 | defer tb.mu.Unlock() | |
| 1461 | 1483 | layoutSnap := solveTabLayoutLocked(tb) | |
| 1462 | 1484 | main := tb.focusedPane() | |
| 1485 | + | fp.popupGeometry = calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg) | |
| 1486 | + | floatingFrameGeometry := fp.popupGeometry.translate(contentArea.X, contentArea.Y) | |
| 1463 | 1487 | ||
| 1464 | 1488 | cases := []struct { | |
| 1465 | 1489 | name string | |
| @@ -1468,13 +1492,17 @@ func TestCopyTargetRectLocked(t *testing.T) { | |||
| 1468 | 1492 | hasFloating bool | |
| 1469 | 1493 | want domain.Rect | |
| 1470 | 1494 | }{ | |
| 1471 | - | {name: "floating source targets popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateFloatingGeometry(contentArea, cfg).Inner}, | |
| 1495 | + | {name: "floating source targets committed frame-absolute popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg).translate(contentArea.X, contentArea.Y).Inner}, | |
| 1472 | 1496 | {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}}, | |
| 1473 | 1497 | {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}}, | |
| 1474 | 1498 | } | |
| 1475 | 1499 | for _, tc := range cases { | |
| 1476 | 1500 | t.Run(tc.name, func(t *testing.T) { | |
| 1477 | - | require.Equal(t, tc.want, copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, cfg)) | |
| 1501 | + | got := copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, floatingFrameGeometry) | |
| 1502 | + | require.Equal(t, tc.want, got) | |
| 1503 | + | if tc.hasFloating { | |
| 1504 | + | require.NotEqual(t, fp.popupGeometry.Inner, got, "content-relative geometry must not target the client frame directly") | |
| 1505 | + | } | |
| 1478 | 1506 | }) | |
| 1479 | 1507 | } | |
| 1480 | 1508 | } | |
| @@ -1305,6 +1305,28 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |||||
| 1305 | require.Contains(t, string(data), "\x1b[?25l") | 1305 | require.Contains(t, string(data), "\x1b[?25l") | ||
| 1306 | } | 1306 | } | ||
| 1307 | 1307 | ||||
| 1308 | + | func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) { | |||
| 1309 | + | p, release := newBlockingPTY(t) | |||
| 1310 | + | defer release() | |||
| 1311 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1312 | + | contentArea := domain.Rect{Y: 1, Width: 80, Height: 23} | |||
| 1313 | + | cfg := d.currentFloatingConfig() | |||
| 1314 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg) | |||
| 1315 | + | floating := newPane("floating", nil, rectSize(committed.Inner)) | |||
| 1316 | + | floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |||
| 1317 | + | floating.popupGeometry = committed | |||
| 1318 | + | installTestFloating(sess.activeTab(), floating, true) | |||
| 1319 | + | ||||
| 1320 | + | d.paint(sess, ac, true) | |||
| 1321 | + | data := mustOutputData(t, sends) | |||
| 1322 | + | want := committed.translate(contentArea.X, contentArea.Y) | |||
| 1323 | + | require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1)) | |||
| 1324 | + | require.Contains(t, string(data), "F") | |||
| 1325 | + | require.Equal(t, want.Inner.Y, ac.lastCursor.row) | |||
| 1326 | + | require.Equal(t, want.Inner.X, ac.lastCursor.col) | |||
| 1327 | + | require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, want.Inner.X+1)) | |||
| 1328 | + | } | |||
| 1329 | + | ||||
| 1308 | func TestCursorTailUsesFocusedPanePlacement(t *testing.T) { | 1330 | func TestCursorTailUsesFocusedPanePlacement(t *testing.T) { | ||
| 1309 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | 1331 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | ||
| 1310 | win := sess.tabs[0] | 1332 | win := sess.tabs[0] | ||
| @@ -1460,6 +1482,8 @@ func TestCopyTargetRectLocked(t *testing.T) { | |||||
| 1460 | defer tb.mu.Unlock() | 1482 | defer tb.mu.Unlock() | ||
| 1461 | layoutSnap := solveTabLayoutLocked(tb) | 1483 | layoutSnap := solveTabLayoutLocked(tb) | ||
| 1462 | main := tb.focusedPane() | 1484 | main := tb.focusedPane() | ||
| 1485 | + | fp.popupGeometry = calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg) | |||
| 1486 | + | floatingFrameGeometry := fp.popupGeometry.translate(contentArea.X, contentArea.Y) | |||
| 1463 | 1487 | ||||
| 1464 | cases := []struct { | 1488 | cases := []struct { | ||
| 1465 | name string | 1489 | name string | ||
| @@ -1468,13 +1492,17 @@ func TestCopyTargetRectLocked(t *testing.T) { | |||||
| 1468 | hasFloating bool | 1492 | hasFloating bool | ||
| 1469 | want domain.Rect | 1493 | want domain.Rect | ||
| 1470 | }{ | 1494 | }{ | ||
| 1471 | - | {name: "floating source targets popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateFloatingGeometry(contentArea, cfg).Inner}, | 1495 | + | {name: "floating source targets committed frame-absolute popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg).translate(contentArea.X, contentArea.Y).Inner}, |
| 1472 | {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}}, | 1496 | {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}}, | ||
| 1473 | {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}}, | 1497 | {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}}, | ||
| 1474 | } | 1498 | } | ||
| 1475 | for _, tc := range cases { | 1499 | for _, tc := range cases { | ||
| 1476 | t.Run(tc.name, func(t *testing.T) { | 1500 | t.Run(tc.name, func(t *testing.T) { | ||
| 1477 | - | require.Equal(t, tc.want, copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, cfg)) | 1501 | + | got := copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, floatingFrameGeometry) |
| 1502 | + | require.Equal(t, tc.want, got) | |||
| 1503 | + | if tc.hasFloating { | |||
| 1504 | + | require.NotEqual(t, fp.popupGeometry.Inner, got, "content-relative geometry must not target the client frame directly") | |||
| 1505 | + | } | |||
| 1478 | }) | 1506 | }) | ||
| 1479 | } | 1507 | } | ||
| 1480 | } | 1508 | } | ||