detached 8fe83c3

Merge pull request #67 from bnema/fix/floating-popup-coordinates

fix(daemon): align floating popup coordinates
commit
8fe83c322395efc14616d57c38451f280c0423aa
author
bnema · · 2026-07-11 06:09 UTC
committer
GitHub · · 2026-07-11 06:09 UTC
merge of
5fcde4c + 883aaa9 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +165 -69
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -750,7 +750,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
750750 d.enterCopyMode(sess, ac)
751751 awaitFrame(t, sends, ports.MsgOutput)
752752 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
754754
755755 // Copy body row k renders at wire row inner.Y+k+2 (top bar + content
756756 // 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.ViewportTop752 viewportTop := ac.overlays.copyMode.ViewportTop
753- inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner753+ 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 + content755 // 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,
133133 return nil, floatingGeometry{}, false
134134 }
135135 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)
137137 p.mu.Lock()
138138 geometry := p.committedFloatingGeometryLocked(desired)
139139 p.mu.Unlock()
@@ -250,7 +250,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
250250 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
251251 sess.mu.Unlock()
252252 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)
254254 size := rectSize(geometry.Inner)
255255 if !size.Valid() {
256256 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{}, false133 return nil, floatingGeometry{}, false
134 }134 }
135 p := tb.floating.pane135 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.ctx250 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 (
88 )
99
1010 // 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.
1215 type floatingGeometry struct {
1316 Bounds domain.Rect
1417 Inner domain.Rect
@@ -18,6 +21,14 @@ func (g floatingGeometry) valid() bool {
1821 return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
1922 }
2023
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+
2132 // committedFloatingGeometryLocked returns the geometry whose PTY resize was
2233 // last committed. Newly constructed test panes fall back to the requested
2334 // geometry until their first resize. The caller holds p.mu.
@@ -47,18 +58,19 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry
4758 return axis
4859 }
4960
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() {
5567 return floatingGeometry{}
5668 }
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)
5971 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,
6274 Width: x.BoundsSize,
6375 Height: y.BoundsSize,
6476 }
@@ -76,7 +88,7 @@ func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) f
7688 // composeFloatingFrame applies the popup to a copy of the normal composed
7789 // destination. In particular it never writes the VT screen frame: that frame
7890 // 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) {
8092 var frame renderer.Frame
8193 if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
8294 frame = cache.floatingFrame
@@ -90,13 +102,13 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
90102 }
91103 }
92104 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
93- desiredGeometry := calculateFloatingGeometry(content, cfg)
94-
95105 // The PTY reader mutates both the frame and its damage under p.mu. Keep
96106 // every read of them, including the blit, in the same critical section.
97107 // In particular, Frame is not safe to retain as an alias after unlocking.
98108 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)
100112 title := p.displayTitleLocked()
101113 titleGeneration := p.title.generation
102114 screenDamage := p.screen.Damage()
@@ -121,12 +133,12 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
121133 cache.floatingTitleGeneration = titleGeneration
122134 }
123135 if full || popupChanged {
124- return frame, []renderer.Damage{renderer.FullRedraw()}
136+ return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
125137 }
126138 if titleChanged && geometry.Bounds.Height >= 3 {
127139 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
128140 }
129- return frame, damage
141+ return frame, damage, geometry
130142 }
131143
132144 func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
@@ -201,12 +213,12 @@ func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
201213 }
202214 tb.mu.Lock()
203215 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}
205217 tb.mu.Unlock()
206218 if p == nil {
207219 return false
208220 }
209- return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()))
221+ return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig()))
210222 }
211223
212224 // 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.Rect16 Bounds domain.Rect
14 Inner domain.Rect17 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 > 021 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 was32 // committedFloatingGeometryLocked returns the geometry whose PTY resize was
22 // last committed. Newly constructed test panes fall back to the requested33 // 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 axis58 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 share62+// 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 composed88 // composeFloatingFrame applies the popup to a copy of the normal composed
77 // destination. In particular it never writes the VT screen frame: that frame89 // 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.Frame92 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.floatingFrame94 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. Keep105 // 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.generation113 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 = titleGeneration133 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, damage141+ 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.pane215 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 false219 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 (
1414 "github.com/bnema/vev/pkg/renderer"
1515 )
1616
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+
1773 func TestCalculateFloatingGeometry(t *testing.T) {
1874 tests := []struct {
1975 name string
20- content domain.Rect
76+ content domain.Size
2177 cfg domain.FloatingConfig
2278 want floatingGeometry
2379 }{
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}}},
2985 }
3086 for _, tt := range tests {
3187 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))
3389 })
3490 }
3591 }
@@ -61,9 +117,9 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
61117 base.Set(2, 2, renderer.Cell{Rune: 'B'})
62118 content := domain.Rect{Y: 1, Width: 40, Height: 10}
63119 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)
65121 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)
67123 require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune)
68124 require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune)
69125 var gotTitle strings.Builder
@@ -75,7 +131,7 @@ func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
75131 p.mu.Lock()
76132 p.title.generation++
77133 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)
79135 require.Len(t, damage, 1)
80136 require.Equal(t, geometry.Bounds.Y, damage[0].Y)
81137 require.Equal(t, 1, damage[0].Height)
@@ -106,15 +162,15 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
106162 cache := &composedFrameCache{}
107163 base := renderer.NewFrame(30, 12)
108164 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)
110166 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
111167
112168 p.mu.Lock()
113169 p.rect, p.popupGeometry = tt.next.Inner, tt.next
114170 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)
116172 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)
118174 })
119175 }
120176 }
@@ -164,8 +220,8 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
164220 base := renderer.NewFrame(40, 12)
165221 content := domain.Rect{Y: 1, Width: 40, Height: 10}
166222 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)
169225 var gotTitle strings.Builder
170226 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
171227 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
@@ -197,7 +253,7 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
197253 <-start
198254 cache := &composedFrameCache{}
199255 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)
201257 }
202258 }()
203259 close(start)
@@ -253,14 +309,15 @@ func BenchmarkComposeFloatingFrameCached(b *testing.B) {
253309 base := renderer.NewFrame(80, 24)
254310 content := domain.Rect{Y: 1, Width: 80, Height: 22}
255311 cfg := domain.FloatingConfig{Width: 80, Height: 80}
312+ desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
256313 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)
258315 p.screen.ClearDamage()
259316
260317 b.ReportAllocs()
261318 b.ResetTimer()
262319 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)
264321 }
265322 }
266323
@@ -270,12 +327,13 @@ func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {
270327 base := renderer.NewFrame(80, 24)
271328 content := domain.Rect{Y: 1, Width: 80, Height: 22}
272329 cfg := domain.FloatingConfig{Width: 80, Height: 80}
330+ desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
273331 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)
275333 p.screen.ClearDamage()
276334
277335 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)
279337 })
280338 require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")
281339 }
@@ -286,8 +344,8 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
286344 tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
287345 sess := &session{tabs: []*tab{tb}}
288346
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())
291349 initial := initialGeometry.Inner
292350 current := currentGeometry.Inner
293351 pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}
@@ -328,8 +386,8 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
328386 cfg := domain.FloatingConfig{Width: 50, Height: 50}
329387 oldContent := domain.Rect{Y: 1, Width: 80, Height: 24}
330388 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)
333391 pty := &resizePTY{err: errors.New("resize failed")}
334392 p := newPane("floating", pty, rectSize(oldGeometry.Inner))
335393 p.rect = oldGeometry.Inner
@@ -342,17 +400,19 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
342400 require.False(t, d.resizeFloatingPane(p, newGeometry))
343401 tb.mu.Lock()
344402 _, 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))
346404 tb.mu.Unlock()
347405 require.True(t, visible)
348406 require.Equal(t, oldGeometry, inputGeometry)
349- require.Equal(t, oldGeometry.Inner, copyRect)
407+ require.Equal(t, oldGeometry.translate(newContent.X, newContent.Y).Inner, copyRect)
350408
351409 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)
356416 }
357417 }
358418
@@ -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 string75 name string
20- content domain.Rect76+ content domain.Size
21 cfg domain.FloatingConfig77 cfg domain.FloatingConfig
22 want floatingGeometry78 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.Builder125 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.next169 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.Builder225 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 <-start253 <-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.Inner349 initial := initialGeometry.Inner
292 current := currentGeometry.Inner350 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.Inner393 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) {
223223 floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
224224 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
225225 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())
227227 raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2))
228228
229229 d.handleInput(sess, ac, raw)
@@ -240,7 +240,7 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
240240 floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
241241 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
242242 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())
244244
245245 border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2))
246246 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) {
278278 // copyTargetRectLocked maps a captured copy source into the already-composed
279279 // client frame. The caller holds tb.mu, preserving the layout/floating
280280 // 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 {
282282 if p == nil {
283283 return domain.Rect{}
284284 }
285285 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
291287 }
292288 for _, placement := range layoutSnap.placements {
293289 if placement.ID == p.id && !placement.Collapsed {
@@ -399,15 +395,17 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
399395 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
400396 }
401397 contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
398+ floatingFrameGeometry := floatingGeometry{}
402399 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)
404402 }
405403 if overlays.copyActive {
406404 copyPane := overlays.copyPane
407405 if copyPane == nil {
408406 copyPane = p
409407 }
410- copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg)
408+ copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
411409 frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
412410 }
413411 // 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) {
439437 overlays.Unlock()
440438 cursorPane := p
441439 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
440+ cursorContent.X += contentArea.X
441+ cursorContent.Y += contentArea.Y
442442 if hasFloating {
443443 cursorPane = floating
444+ cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0
444445 }
445446 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- }
451447 desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
452448 cursorPane.mu.Unlock()
453449 ac.sess.mu.Lock()
@@ -519,7 +515,7 @@ func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut {
519515 if !ok {
520516 style = 1
521517 }
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}
523519 }
524520
525521 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-composed278 // copyTargetRectLocked maps a captured copy source into the already-composed
279 // client frame. The caller holds tb.mu, preserving the layout/floating279 // 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.copyPane404 copyPane := overlays.copyPane
407 if copyPane == nil {405 if copyPane == nil {
408 copyPane = p406 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 a411 // 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 := p438 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 = floating443 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 = 1516 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) {
13051305 require.Contains(t, string(data), "\x1b[?25l")
13061306 }
13071307
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+
13081330 func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
13091331 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
13101332 win := sess.tabs[0]
@@ -1460,6 +1482,8 @@ func TestCopyTargetRectLocked(t *testing.T) {
14601482 defer tb.mu.Unlock()
14611483 layoutSnap := solveTabLayoutLocked(tb)
14621484 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)
14631487
14641488 cases := []struct {
14651489 name string
@@ -1468,13 +1492,17 @@ func TestCopyTargetRectLocked(t *testing.T) {
14681492 hasFloating bool
14691493 want domain.Rect
14701494 }{
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},
14721496 {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
14731497 {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
14741498 }
14751499 for _, tc := range cases {
14761500 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+ }
14781506 })
14791507 }
14801508 }
@@ -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 string1489 name string
@@ -1468,13 +1492,17 @@ func TestCopyTargetRectLocked(t *testing.T) {
1468 hasFloating bool1492 hasFloating bool
1469 want domain.Rect1493 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close