detached dae353e

fix(floating): clip retained geometry during composition

commit
dae353edacbc6a0d06712752a8f39b56ed6f183b
author
brice · · 2026-07-27 16:53 UTC
committer
brice · · 2026-07-27 17:06 UTC
parent
0c7c6a2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +267 -18
M internal/usecase/daemon/floating_render.go +135 -18
@@ -149,7 +149,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
149149 frame := base.Clone()
150150 applyOverlayBackdrop(frame, theme)
151151 geometry := floating.geometry.translate(content.X, content.Y)
152- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
152+ blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
153153 damage := append([]renderer.Damage(nil), baseDamage...)
154154 for _, d := range floating.pane.damage {
155155 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -169,42 +169,159 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
169169 hasTitleBorder = geometry.Bounds.Height > 0
170170 }
171171 if titleChanged && hasTitleBorder {
172- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
172+ if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
173+ damage = append(damage, titleDamage)
174+ }
173175 }
174176 return frame, damage
175177 }
176178
179+type floatingSpan struct {
180+ source, destination, length int
181+}
182+
183+// clippedFloatingSpan intersects a source span placed at origin with the
184+// destination interval [0, limit). The source offset preserves pane cell
185+// mapping when the retained committed geometry starts above or left of frame.
186+func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) {
187+ if length <= 0 || limit <= 0 {
188+ return floatingSpan{}, false
189+ }
190+ if origin < 0 {
191+ if origin <= -length {
192+ return floatingSpan{}, false
193+ }
194+ trim := -origin
195+ length -= trim
196+ origin = 0
197+ if length <= 0 {
198+ return floatingSpan{}, false
199+ }
200+ return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true
201+ }
202+ if origin >= limit {
203+ return floatingSpan{}, false
204+ }
205+ length = min(length, limit-origin)
206+ return floatingSpan{destination: origin, length: length}, length > 0
207+}
208+
209+func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) {
210+ x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width)
211+ if !ok {
212+ return
213+ }
214+ y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height)
215+ if !ok {
216+ return
217+ }
218+ for dy := range y.length {
219+ for dx := range x.length {
220+ dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy))
221+ }
222+ }
223+}
224+
225+func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) {
226+ if bounds.Y < 0 || bounds.Y >= frame.Height {
227+ return renderer.Damage{}, false
228+ }
229+ x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width)
230+ if !ok {
231+ return renderer.Damage{}, false
232+ }
233+ return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true
234+}
235+
177236 func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
178237 bounds := geometry.Bounds
179238 if bounds.Width <= 0 || bounds.Height <= 0 {
180239 return
181240 }
182241 if geometry.Mode == ui.PresentationDrawer {
183- for x := bounds.X; x < bounds.X+bounds.Width; x++ {
184- frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})
185- }
186- ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
242+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
243+ drawFloatingTitle(frame, bounds, title, style)
187244 return
188245 }
189246 // Each axis omits its borders independently for tiny popups, matching
190247 // floatingInnerSize and leaving every cell available to the terminal.
248+ right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width)
249+ bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height)
191250 if bounds.Height >= 3 {
192- for x := bounds.X; x < bounds.X+bounds.Width; x++ {
193- frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})
194- frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style})
251+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
252+ if hasBottom {
253+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
195254 }
196255 }
197256 if bounds.Width >= 3 {
198- for y := bounds.Y; y < bounds.Y+bounds.Height; y++ {
199- frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style})
200- frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style})
257+ drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
258+ if hasRight {
259+ drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
201260 }
202261 }
203- if bounds.Width >= 3 && bounds.Height >= 3 {
204- frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style})
205- frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style})
206- frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style})
207- frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style})
208- ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
262+ if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
263+ setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
264+ setFloatingCell(frame, right, bounds.Y, '┐', style)
265+ setFloatingCell(frame, bounds.X, bottom, '└', style)
266+ setFloatingCell(frame, right, bottom, '┘', style)
267+ drawFloatingTitle(frame, bounds, title, style)
268+ }
269+}
270+
271+func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
272+ if y < 0 || y >= frame.Height {
273+ return
274+ }
275+ span, ok := clippedFloatingSpan(x, width, frame.Width)
276+ if !ok {
277+ return
278+ }
279+ for dx := range span.length {
280+ frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
281+ }
282+}
283+
284+func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
285+ if x < 0 || x >= frame.Width {
286+ return
287+ }
288+ span, ok := clippedFloatingSpan(y, height, frame.Height)
289+ if !ok {
290+ return
291+ }
292+ for dy := range span.length {
293+ frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
294+ }
295+}
296+
297+func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
298+ if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
299+ return
300+ }
301+ frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
302+}
303+
304+func floatingLastCoordinate(origin, size int) (int, bool) {
305+ if size <= 0 {
306+ return 0, false
307+ }
308+ last := origin + size - 1
309+ if last < origin {
310+ return 0, false
311+ }
312+ return last, true
313+}
314+
315+func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
316+ const inset = 2
317+ titleWidth := bounds.Width - 2*inset
318+ if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
319+ return
320+ }
321+ titleX := bounds.X + inset
322+ span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
323+ if !ok {
324+ return
209325 }
326+ ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
210327 }
@@ -149,7 +149,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
149 frame := base.Clone()149 frame := base.Clone()
150 applyOverlayBackdrop(frame, theme)150 applyOverlayBackdrop(frame, theme)
151 geometry := floating.geometry.translate(content.X, content.Y)151 geometry := floating.geometry.translate(content.X, content.Y)
152- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))152+ blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
153 damage := append([]renderer.Damage(nil), baseDamage...)153 damage := append([]renderer.Damage(nil), baseDamage...)
154 for _, d := range floating.pane.damage {154 for _, d := range floating.pane.damage {
155 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)155 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -169,42 +169,159 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
169 hasTitleBorder = geometry.Bounds.Height > 0169 hasTitleBorder = geometry.Bounds.Height > 0
170 }170 }
171 if titleChanged && hasTitleBorder {171 if titleChanged && hasTitleBorder {
172- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})172+ if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
173+ damage = append(damage, titleDamage)
174+ }
173 }175 }
174 return frame, damage176 return frame, damage
175 }177 }
176 178
179+type floatingSpan struct {
180+ source, destination, length int
181+}
182+
183+// clippedFloatingSpan intersects a source span placed at origin with the
184+// destination interval [0, limit). The source offset preserves pane cell
185+// mapping when the retained committed geometry starts above or left of frame.
186+func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) {
187+ if length <= 0 || limit <= 0 {
188+ return floatingSpan{}, false
189+ }
190+ if origin < 0 {
191+ if origin <= -length {
192+ return floatingSpan{}, false
193+ }
194+ trim := -origin
195+ length -= trim
196+ origin = 0
197+ if length <= 0 {
198+ return floatingSpan{}, false
199+ }
200+ return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true
201+ }
202+ if origin >= limit {
203+ return floatingSpan{}, false
204+ }
205+ length = min(length, limit-origin)
206+ return floatingSpan{destination: origin, length: length}, length > 0
207+}
208+
209+func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) {
210+ x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width)
211+ if !ok {
212+ return
213+ }
214+ y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height)
215+ if !ok {
216+ return
217+ }
218+ for dy := range y.length {
219+ for dx := range x.length {
220+ dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy))
221+ }
222+ }
223+}
224+
225+func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) {
226+ if bounds.Y < 0 || bounds.Y >= frame.Height {
227+ return renderer.Damage{}, false
228+ }
229+ x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width)
230+ if !ok {
231+ return renderer.Damage{}, false
232+ }
233+ return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true
234+}
235+
177 func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {236 func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
178 bounds := geometry.Bounds237 bounds := geometry.Bounds
179 if bounds.Width <= 0 || bounds.Height <= 0 {238 if bounds.Width <= 0 || bounds.Height <= 0 {
180 return239 return
181 }240 }
182 if geometry.Mode == ui.PresentationDrawer {241 if geometry.Mode == ui.PresentationDrawer {
183- for x := bounds.X; x < bounds.X+bounds.Width; x++ {242+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
184- frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})243+ drawFloatingTitle(frame, bounds, title, style)
185- }
186- ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
187 return244 return
188 }245 }
189 // Each axis omits its borders independently for tiny popups, matching246 // Each axis omits its borders independently for tiny popups, matching
190 // floatingInnerSize and leaving every cell available to the terminal.247 // floatingInnerSize and leaving every cell available to the terminal.
248+ right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width)
249+ bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height)
191 if bounds.Height >= 3 {250 if bounds.Height >= 3 {
192- for x := bounds.X; x < bounds.X+bounds.Width; x++ {251+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
193- frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})252+ if hasBottom {
194- frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style})253+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
195 }254 }
196 }255 }
197 if bounds.Width >= 3 {256 if bounds.Width >= 3 {
198- for y := bounds.Y; y < bounds.Y+bounds.Height; y++ {257+ drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
199- frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style})258+ if hasRight {
200- frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style})259+ drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
201 }260 }
202 }261 }
203- if bounds.Width >= 3 && bounds.Height >= 3 {262+ if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
204- frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style})263+ setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
205- frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style})264+ setFloatingCell(frame, right, bounds.Y, '┐', style)
206- frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style})265+ setFloatingCell(frame, bounds.X, bottom, '└', style)
207- frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style})266+ setFloatingCell(frame, right, bottom, '┘', style)
208- ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)267+ drawFloatingTitle(frame, bounds, title, style)
268+ }
269+}
270+
271+func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
272+ if y < 0 || y >= frame.Height {
273+ return
274+ }
275+ span, ok := clippedFloatingSpan(x, width, frame.Width)
276+ if !ok {
277+ return
278+ }
279+ for dx := range span.length {
280+ frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
281+ }
282+}
283+
284+func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
285+ if x < 0 || x >= frame.Width {
286+ return
287+ }
288+ span, ok := clippedFloatingSpan(y, height, frame.Height)
289+ if !ok {
290+ return
291+ }
292+ for dy := range span.length {
293+ frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
294+ }
295+}
296+
297+func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
298+ if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
299+ return
300+ }
301+ frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
302+}
303+
304+func floatingLastCoordinate(origin, size int) (int, bool) {
305+ if size <= 0 {
306+ return 0, false
307+ }
308+ last := origin + size - 1
309+ if last < origin {
310+ return 0, false
311+ }
312+ return last, true
313+}
314+
315+func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
316+ const inset = 2
317+ titleWidth := bounds.Width - 2*inset
318+ if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
319+ return
320+ }
321+ titleX := bounds.X + inset
322+ span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
323+ if !ok {
324+ return
209 }325 }
326+ ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
210 }327 }
M internal/usecase/daemon/floating_render_test.go +132 -0
@@ -278,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
278278 require.Equal(t, 1, damage[0].Height)
279279 }
280280
281+func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) {
282+ base := renderer.NewFrame(15, 7)
283+ for y := range base.Height {
284+ for x := range base.Width {
285+ base.Set(x, y, renderer.Cell{Rune: '·'})
286+ }
287+ }
288+ content := domain.Rect{Y: 1, Width: 15, Height: 5}
289+ retained := calculateContentFloatingGeometry(
290+ domain.Size{Cols: 100, Rows: 30},
291+ domain.FloatingConfig{Width: 80, Height: 80},
292+ )
293+ pty := &resizePTY{err: errors.New("resize failed")}
294+ floatingPane := newPane("floating", pty, rectSize(retained.Inner))
295+ floatingPane.rect = retained.Inner
296+ floatingPane.popupGeometry = retained
297+ for y := range floatingPane.screen.Frame.Height {
298+ for x := range floatingPane.screen.Frame.Width {
299+ floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
300+ }
301+ }
302+ requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})
303+ d := newTestDaemon(t, nil, stubClock{})
304+ require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))
305+ require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry")
306+ floatingPane.mu.Lock()
307+ captured := capturePaneRenderStateLocked(floatingPane, retained.Inner)
308+ floatingPane.mu.Unlock()
309+
310+ var frame renderer.Frame
311+ require.NotPanics(t, func() {
312+ frame, _ = composeCapturedFloatingFrame(floatingComposeInput{
313+ baseFrame: base,
314+ floating: capturedFloatingRenderState{
315+ visible: true,
316+ pane: captured,
317+ geometry: retained,
318+ generation: 1,
319+ },
320+ content: content,
321+ full: true,
322+ })
323+ })
324+
325+ for y := range 4 {
326+ require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y)
327+ }
328+ require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted")
329+ require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates")
330+ require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source")
331+}
332+
333+func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) {
334+ base := renderer.NewFrame(4, 4)
335+ pane := renderer.NewFrame(5, 5)
336+ for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} {
337+ for x, r := range text {
338+ pane.Set(x, y, renderer.Cell{Rune: r})
339+ }
340+ }
341+ geometry := floatingGeometry{
342+ Mode: ui.PresentationFloating,
343+ Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7},
344+ Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5},
345+ }
346+
347+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
348+ baseFrame: base,
349+ floating: capturedFloatingRenderState{
350+ visible: true,
351+ pane: capturedPaneRenderState{frame: pane},
352+ geometry: geometry,
353+ generation: 1,
354+ },
355+ full: true,
356+ })
357+
358+ require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{
359+ rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)),
360+ })
361+}
362+
363+func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) {
364+ tests := []struct {
365+ name string
366+ geometry floatingGeometry
367+ want []string
368+ }{
369+ {
370+ name: "negative top and left retain bottom and right edges",
371+ geometry: floatingGeometry{
372+ Mode: ui.PresentationFloating,
373+ Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5},
374+ },
375+ want: []string{"···│", "···│", "···│", "───┘"},
376+ },
377+ {
378+ name: "overflowing bottom and right retain top and left edges",
379+ geometry: floatingGeometry{
380+ Mode: ui.PresentationFloating,
381+ Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4},
382+ },
383+ want: []string{"····", "····", "··┌─", "··│·"},
384+ },
385+ {
386+ name: "drawer clips its separator",
387+ geometry: floatingGeometry{
388+ Mode: ui.PresentationDrawer,
389+ Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3},
390+ },
391+ want: []string{"····", "····", "xy──", "····"},
392+ },
393+ }
394+ for _, tt := range tests {
395+ t.Run(tt.name, func(t *testing.T) {
396+ frame := renderer.NewFrame(4, 4)
397+ for y := range frame.Height {
398+ for x := range frame.Width {
399+ frame.Set(x, y, renderer.Cell{Rune: '·'})
400+ }
401+ }
402+
403+ require.NotPanics(t, func() {
404+ drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{})
405+ })
406+ for y, want := range tt.want {
407+ require.Equal(t, want, rowText(frame.Row(y)), "row %d", y)
408+ }
409+ })
410+ }
411+}
412+
281413 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
282414 base := renderer.NewFrame(20, 9)
283415 content := domain.Rect{Y: 1, Width: 20, Height: 7}
@@ -278,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
278 require.Equal(t, 1, damage[0].Height)278 require.Equal(t, 1, damage[0].Height)
279 }279 }
280 280
281+func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) {
282+ base := renderer.NewFrame(15, 7)
283+ for y := range base.Height {
284+ for x := range base.Width {
285+ base.Set(x, y, renderer.Cell{Rune: '·'})
286+ }
287+ }
288+ content := domain.Rect{Y: 1, Width: 15, Height: 5}
289+ retained := calculateContentFloatingGeometry(
290+ domain.Size{Cols: 100, Rows: 30},
291+ domain.FloatingConfig{Width: 80, Height: 80},
292+ )
293+ pty := &resizePTY{err: errors.New("resize failed")}
294+ floatingPane := newPane("floating", pty, rectSize(retained.Inner))
295+ floatingPane.rect = retained.Inner
296+ floatingPane.popupGeometry = retained
297+ for y := range floatingPane.screen.Frame.Height {
298+ for x := range floatingPane.screen.Frame.Width {
299+ floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
300+ }
301+ }
302+ requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})
303+ d := newTestDaemon(t, nil, stubClock{})
304+ require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))
305+ require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry")
306+ floatingPane.mu.Lock()
307+ captured := capturePaneRenderStateLocked(floatingPane, retained.Inner)
308+ floatingPane.mu.Unlock()
309+
310+ var frame renderer.Frame
311+ require.NotPanics(t, func() {
312+ frame, _ = composeCapturedFloatingFrame(floatingComposeInput{
313+ baseFrame: base,
314+ floating: capturedFloatingRenderState{
315+ visible: true,
316+ pane: captured,
317+ geometry: retained,
318+ generation: 1,
319+ },
320+ content: content,
321+ full: true,
322+ })
323+ })
324+
325+ for y := range 4 {
326+ require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y)
327+ }
328+ require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted")
329+ require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates")
330+ require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source")
331+}
332+
333+func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) {
334+ base := renderer.NewFrame(4, 4)
335+ pane := renderer.NewFrame(5, 5)
336+ for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} {
337+ for x, r := range text {
338+ pane.Set(x, y, renderer.Cell{Rune: r})
339+ }
340+ }
341+ geometry := floatingGeometry{
342+ Mode: ui.PresentationFloating,
343+ Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7},
344+ Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5},
345+ }
346+
347+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
348+ baseFrame: base,
349+ floating: capturedFloatingRenderState{
350+ visible: true,
351+ pane: capturedPaneRenderState{frame: pane},
352+ geometry: geometry,
353+ generation: 1,
354+ },
355+ full: true,
356+ })
357+
358+ require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{
359+ rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)),
360+ })
361+}
362+
363+func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) {
364+ tests := []struct {
365+ name string
366+ geometry floatingGeometry
367+ want []string
368+ }{
369+ {
370+ name: "negative top and left retain bottom and right edges",
371+ geometry: floatingGeometry{
372+ Mode: ui.PresentationFloating,
373+ Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5},
374+ },
375+ want: []string{"···│", "···│", "···│", "───┘"},
376+ },
377+ {
378+ name: "overflowing bottom and right retain top and left edges",
379+ geometry: floatingGeometry{
380+ Mode: ui.PresentationFloating,
381+ Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4},
382+ },
383+ want: []string{"····", "····", "··┌─", "··│·"},
384+ },
385+ {
386+ name: "drawer clips its separator",
387+ geometry: floatingGeometry{
388+ Mode: ui.PresentationDrawer,
389+ Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3},
390+ },
391+ want: []string{"····", "····", "xy──", "····"},
392+ },
393+ }
394+ for _, tt := range tests {
395+ t.Run(tt.name, func(t *testing.T) {
396+ frame := renderer.NewFrame(4, 4)
397+ for y := range frame.Height {
398+ for x := range frame.Width {
399+ frame.Set(x, y, renderer.Cell{Rune: '·'})
400+ }
401+ }
402+
403+ require.NotPanics(t, func() {
404+ drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{})
405+ })
406+ for y, want := range tt.want {
407+ require.Equal(t, want, rowText(frame.Row(y)), "row %d", y)
408+ }
409+ })
410+ }
411+}
412+
281 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {413 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
282 base := renderer.NewFrame(20, 9)414 base := renderer.NewFrame(20, 9)
283 content := domain.Rect{Y: 1, Width: 20, Height: 7}415 content := domain.Rect{Y: 1, Width: 20, Height: 7}

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 2

Tags 13

↑↓ moveenter openesc close