1package daemon
2
3import (
4 renderer "github.com/bnema/vev-vt"
5 "github.com/bnema/vev/internal/domain"
6 themeui "github.com/bnema/vev/internal/usecase/theme"
7 "github.com/bnema/vev/internal/usecase/ui"
8)
9
10// floatingGeometry describes a popup's outer frame and terminal content area.
11// Bounds and Inner always share the same coordinate space. Committed pane
12// geometry is origin-zero and tab-content-relative; composeCapturedFloatingFrame
13// translates both fields and returns/caches frame-absolute geometry. Callers
14// must not translate a composed or cached value again.
15type floatingGeometry struct {
16 Mode ui.PresentationMode
17 Bounds domain.Rect
18 Inner domain.Rect
19}
20
21func (g floatingGeometry) valid() bool {
22 return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
23}
24
25// committable accepts a drawer with no terminal content. Its presentation must
26// remain canonical even though the PTY and VT screen use a 1x1 fallback.
27func (g floatingGeometry) committable() bool {
28 if g.Mode == ui.PresentationDrawer {
29 return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0
30 }
31 return g.valid()
32}
33
34func (g floatingGeometry) ptyRect() domain.Rect {
35 if g.Inner.Width > 0 && g.Inner.Height > 0 {
36 return g.Inner
37 }
38 return domain.Rect{Width: 1, Height: 1}
39}
40
41func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
42 g.Bounds.X += dx
43 g.Bounds.Y += dy
44 g.Inner.X += dx
45 g.Inner.Y += dy
46 return g
47}
48
49// committedFloatingGeometryLocked returns the geometry whose PTY resize was
50// last committed. Newly constructed test panes fall back to the requested
51// geometry until their first resize. The caller holds p.mu.
52func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
53 if p.popupGeometry.committable() {
54 return p.popupGeometry
55 }
56 return fallback
57}
58
59// floatingAxisGeometry is the canonical percentage and border calculation for
60// one popup axis. Popups smaller than three cells spend no cells on borders.
61type floatingAxisGeometry struct {
62 BoundsSize int
63 InnerSize int
64 BorderOffset int
65}
66
67func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry {
68 percent = min(max(percent, 1), 100)
69 bounds := min(max(available*percent/100, 1), available)
70 axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds}
71 if bounds >= 3 {
72 axis.InnerSize -= 2
73 axis.BorderOffset = 1
74 }
75 return axis
76}
77
78// calculatePreferredFloatingGeometry retains the canonical percentage and
79// tiny-axis rules used by wide floating terminals.
80func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
81 if !content.Valid() {
82 return floatingGeometry{}
83 }
84 x := calculateFloatingAxisGeometry(content.Cols, cfg.Width)
85 y := calculateFloatingAxisGeometry(content.Rows, cfg.Height)
86 bounds := domain.Rect{
87 X: (content.Cols - x.BoundsSize) / 2,
88 Y: (content.Rows - y.BoundsSize) / 2,
89 Width: x.BoundsSize,
90 Height: y.BoundsSize,
91 }
92 return floatingGeometry{
93 Mode: ui.PresentationFloating,
94 Bounds: bounds,
95 Inner: domain.Rect{
96 X: bounds.X + x.BorderOffset,
97 Y: bounds.Y + y.BorderOffset,
98 Width: x.InnerSize,
99 Height: y.InnerSize,
100 },
101 }
102}
103
104// calculateContentFloatingGeometry returns canonical tab-content-relative
105// coordinates. Presentation resolution consumes complete-frame coordinates,
106// so the preferred geometry crosses the top-bar boundary exactly once in each
107// direction.
108func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
109 preferred := calculatePreferredFloatingGeometry(content, cfg)
110 if !preferred.valid() {
111 return floatingGeometry{}
112 }
113 framePreferred := preferred.translate(0, 1)
114 presentation := ui.ResolvePresentation(
115 domain.Size{Cols: content.Cols, Rows: content.Rows + 2},
116 framePreferred.Bounds,
117 framePreferred.Inner,
118 )
119 return floatingGeometry{
120 Mode: presentation.Mode,
121 Bounds: presentation.Bounds,
122 Inner: presentation.Inner,
123 }.translate(0, -1)
124}
125
126type floatingComposeInput struct {
127 baseFrame renderer.Frame
128 baseDamage []renderer.Damage
129 floating capturedFloatingRenderState
130 content domain.Rect
131 theme themeui.Theme
132 borderMuted renderer.Style
133 borderActive renderer.Style
134 cache composeCacheInput
135 full bool
136 // scratch, when non-nil, is the page the popup frame is composed into. It
137 // must not alias baseFrame's page; nil allocates a clone.
138 scratch *renderer.Frame
139}
140
141func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
142 base := input.baseFrame
143 baseDamage := input.baseDamage
144 floating := input.floating
145 content := input.content
146 theme := input.theme
147 borderMuted := input.borderMuted
148 borderActive := input.borderActive
149 cache := input.cache
150 full := input.full
151
152 frame := copyFrameInto(input.scratch, base)
153 applyOverlayBackdrop(frame, theme)
154 geometry := floating.geometry.translate(content.X, content.Y)
155 blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
156 damage := append([]renderer.Damage(nil), baseDamage...)
157 for _, d := range floating.pane.damage {
158 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
159 }
160 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused
161 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
162 border := borderMuted
163 if floating.focused {
164 border = borderActive
165 }
166 drawFloatingBorder(frame, geometry, floating.title, border)
167 if full || popupChanged {
168 return frame, []renderer.Damage{renderer.FullRedraw()}
169 }
170 hasTitleBorder := geometry.Bounds.Height >= 3
171 if geometry.Mode == ui.PresentationDrawer {
172 hasTitleBorder = geometry.Bounds.Height > 0
173 }
174 if titleChanged && hasTitleBorder {
175 if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
176 damage = append(damage, titleDamage)
177 }
178 }
179 return frame, damage
180}
181
182type floatingSpan struct {
183 source, destination, length int
184}
185
186// clippedFloatingSpan intersects a source span placed at origin with the
187// destination interval [0, limit). The source offset preserves pane cell
188// mapping when the retained committed geometry starts above or left of frame.
189func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) {
190 if length <= 0 || limit <= 0 {
191 return floatingSpan{}, false
192 }
193 if origin < 0 {
194 if origin <= -length {
195 return floatingSpan{}, false
196 }
197 trim := -origin
198 length -= trim
199 origin = 0
200 if length <= 0 {
201 return floatingSpan{}, false
202 }
203 return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true
204 }
205 if origin >= limit {
206 return floatingSpan{}, false
207 }
208 length = min(length, limit-origin)
209 return floatingSpan{destination: origin, length: length}, length > 0
210}
211
212func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) {
213 x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width)
214 if !ok {
215 return
216 }
217 y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height)
218 if !ok {
219 return
220 }
221 for dy := range y.length {
222 for dx := range x.length {
223 dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy))
224 }
225 }
226}
227
228func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) {
229 if bounds.Y < 0 || bounds.Y >= frame.Height {
230 return renderer.Damage{}, false
231 }
232 x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width)
233 if !ok {
234 return renderer.Damage{}, false
235 }
236 return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true
237}
238
239func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
240 bounds := geometry.Bounds
241 if bounds.Width <= 0 || bounds.Height <= 0 {
242 return
243 }
244 if geometry.Mode == ui.PresentationDrawer {
245 drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
246 drawFloatingTitle(frame, bounds, title, style)
247 return
248 }
249 // Each axis omits its borders independently for tiny popups, matching
250 // floatingInnerSize and leaving every cell available to the terminal.
251 right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width)
252 bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height)
253 if bounds.Height >= 3 {
254 drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
255 if hasBottom {
256 drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
257 }
258 }
259 if bounds.Width >= 3 {
260 drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
261 if hasRight {
262 drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
263 }
264 }
265 if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
266 setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
267 setFloatingCell(frame, right, bounds.Y, '┐', style)
268 setFloatingCell(frame, bounds.X, bottom, '└', style)
269 setFloatingCell(frame, right, bottom, '┘', style)
270 drawFloatingTitle(frame, bounds, title, style)
271 }
272}
273
274func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
275 if y < 0 || y >= frame.Height {
276 return
277 }
278 span, ok := clippedFloatingSpan(x, width, frame.Width)
279 if !ok {
280 return
281 }
282 for dx := range span.length {
283 frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
284 }
285}
286
287func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
288 if x < 0 || x >= frame.Width {
289 return
290 }
291 span, ok := clippedFloatingSpan(y, height, frame.Height)
292 if !ok {
293 return
294 }
295 for dy := range span.length {
296 frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
297 }
298}
299
300func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
301 if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
302 return
303 }
304 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
305}
306
307func floatingLastCoordinate(origin, size int) (int, bool) {
308 if size <= 0 {
309 return 0, false
310 }
311 last := origin + size - 1
312 if last < origin {
313 return 0, false
314 }
315 return last, true
316}
317
318func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
319 const inset = 2
320 titleWidth := bounds.Width - 2*inset
321 if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
322 return
323 }
324 titleX := bounds.X + inset
325 span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
326 if !ok {
327 return
328 }
329 ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
330}