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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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, [ | |||
| 149 | 149 | frame := base.Clone() | |
| 150 | 150 | applyOverlayBackdrop(frame, theme) | |
| 151 | 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 | 153 | damage := append([]renderer.Damage(nil), baseDamage...) | |
| 154 | 154 | for _, d := range floating.pane.damage { | |
| 155 | 155 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| @@ -169,42 +169,159 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 169 | 169 | hasTitleBorder = geometry.Bounds.Height > 0 | |
| 170 | 170 | } | |
| 171 | 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 | 176 | 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 | 236 | func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) { | |
| 178 | 237 | bounds := geometry.Bounds | |
| 179 | 238 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 180 | 239 | return | |
| 181 | 240 | } | |
| 182 | 241 | 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) | |
| 187 | 244 | return | |
| 188 | 245 | } | |
| 189 | 246 | // Each axis omits its borders independently for tiny popups, matching | |
| 190 | 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 | 250 | 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) | |
| 195 | 254 | } | |
| 196 | 255 | } | |
| 197 | 256 | 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) | |
| 201 | 260 | } | |
| 202 | 261 | } | |
| 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 | |
| 209 | 325 | } | |
| 326 | + | ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style) | |
| 210 | 327 | } | |
| @@ -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 > 0 | 169 | 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, damage | 176 | 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.Bounds | 237 | bounds := geometry.Bounds | ||
| 179 | if bounds.Width <= 0 || bounds.Height <= 0 { | 238 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 180 | return | 239 | 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 | return | 244 | return | ||
| 188 | } | 245 | } | ||
| 189 | // Each axis omits its borders independently for tiny popups, matching | 246 | // 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 | |||
| 278 | 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 | 413 | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | |
| 282 | 414 | base := renderer.NewFrame(20, 9) | |
| 283 | 415 | 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} | ||