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}
137
138func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
139 base := input.baseFrame
140 baseDamage := input.baseDamage
141 floating := input.floating
142 content := input.content
143 theme := input.theme
144 borderMuted := input.borderMuted
145 borderActive := input.borderActive
146 cache := input.cache
147 full := input.full
148
149 frame := base.Clone()
150 applyOverlayBackdrop(frame, theme)
151 geometry := floating.geometry.translate(content.X, content.Y)
152 blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
153 damage := append([]renderer.Damage(nil), baseDamage...)
154 for _, d := range floating.pane.damage {
155 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
156 }
157 popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry || cache.floatingFocused != floating.focused
158 titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
159 border := borderMuted
160 if floating.focused {
161 border = borderActive
162 }
163 drawFloatingBorder(frame, geometry, floating.title, border)
164 if full || popupChanged {
165 return frame, []renderer.Damage{renderer.FullRedraw()}
166 }
167 hasTitleBorder := geometry.Bounds.Height >= 3
168 if geometry.Mode == ui.PresentationDrawer {
169 hasTitleBorder = geometry.Bounds.Height > 0
170 }
171 if titleChanged && hasTitleBorder {
172 if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
173 damage = append(damage, titleDamage)
174 }
175 }
176 return frame, damage
177}
178
179type 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.
186func 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
209func 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
225func 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
236func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
237 bounds := geometry.Bounds
238 if bounds.Width <= 0 || bounds.Height <= 0 {
239 return
240 }
241 if geometry.Mode == ui.PresentationDrawer {
242 drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
243 drawFloatingTitle(frame, bounds, title, style)
244 return
245 }
246 // Each axis omits its borders independently for tiny popups, matching
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)
250 if bounds.Height >= 3 {
251 drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
252 if hasBottom {
253 drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
254 }
255 }
256 if bounds.Width >= 3 {
257 drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
258 if hasRight {
259 drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
260 }
261 }
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
271func 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
284func 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
297func 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
304func 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
315func 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
325 }
326 ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
327}