1package ui
2
3import (
4 renderer "github.com/bnema/vev-vt"
5 "github.com/bnema/vev/internal/domain"
6)
7
8// Modal describes a rectangular overlay.
9type Modal struct {
10 WidthPct int
11 HeightPct int
12 MinWidth int
13 MinHeight int
14 Title string
15 Anchor domain.Anchor
16 Margins Margins
17 FixedWidth int
18 FixedHeight int
19}
20
21func (m Modal) preferredBounds(base domain.Size) domain.Rect {
22 width := percentOf(base.Cols, m.WidthPct)
23 height := percentOf(base.Rows, m.HeightPct)
24 if m.FixedWidth > 0 {
25 width = m.FixedWidth
26 }
27 if m.FixedHeight > 0 {
28 height = m.FixedHeight
29 }
30 if width < m.MinWidth {
31 width = m.MinWidth
32 }
33 if height < m.MinHeight {
34 height = m.MinHeight
35 }
36 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
37}
38
39// Resolve computes the modal's responsive presentation from one preferred
40// bounds and inner pair.
41func (m Modal) Resolve(base domain.Size) Presentation {
42 bounds := m.preferredBounds(base)
43 return ResolvePresentation(base, bounds, modalInner(bounds))
44}
45
46// CompositePresentation draws an already-resolved modal presentation and
47// returns its inner content rectangle.
48func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
49 FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior})
50 drawBorderEdges(f, p.Bounds, p.Borders, border)
51 if p.Borders&BorderTop != 0 {
52 m.drawTitle(f, p.Bounds, border)
53 }
54 return p.Inner
55}
56
57func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) {
58 if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" {
59 return
60 }
61 left := bounds.X + 1
62 right := bounds.X + bounds.Width - 1
63 start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2)
64 DrawText(f, start, bounds.Y, right, m.Title, style)
65}
66
67func modalInner(bounds domain.Rect) domain.Rect {
68 return domain.Rect{
69 X: bounds.X + 1,
70 Y: bounds.Y + 1,
71 Width: max(0, bounds.Width-2),
72 Height: max(0, bounds.Height-2),
73 }
74}
75
76// FillRect fills rect with cell, clipped to the frame bounds.
77func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
78 left := clamp(rect.X, 0, f.Width)
79 top := clamp(rect.Y, 0, f.Height)
80 right := clamp(rect.X+rect.Width, 0, f.Width)
81 bottom := clamp(rect.Y+rect.Height, 0, f.Height)
82 for y := top; y < bottom; y++ {
83 for x := left; x < right; x++ {
84 f.Set(x, y, cell)
85 }
86 }
87}
88
89// DrawBox draws a single-cell box clipped to the frame bounds.
90func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
91 drawBorderEdges(f, rect, BorderAll, style)
92}
93
94func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) {
95 if rect.Width <= 0 || rect.Height <= 0 || edges == 0 {
96 return
97 }
98 left := rect.X
99 top := rect.Y
100 right := rect.X + rect.Width - 1
101 bottom := rect.Y + rect.Height - 1
102
103 if edges&BorderTop != 0 {
104 for x := left; x <= right; x++ {
105 setCell(f, x, top, '─', style)
106 }
107 }
108 if edges&BorderBottom != 0 {
109 for x := left; x <= right; x++ {
110 setCell(f, x, bottom, '─', style)
111 }
112 }
113 if edges&BorderLeft != 0 {
114 for y := top; y <= bottom; y++ {
115 setCell(f, left, y, '│', style)
116 }
117 }
118 if edges&BorderRight != 0 {
119 for y := top; y <= bottom; y++ {
120 setCell(f, right, y, '│', style)
121 }
122 }
123 if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft {
124 setCell(f, left, top, '┌', style)
125 }
126 if edges&(BorderTop|BorderRight) == BorderTop|BorderRight {
127 setCell(f, right, top, '┐', style)
128 }
129 if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft {
130 setCell(f, left, bottom, '└', style)
131 }
132 if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight {
133 setCell(f, right, bottom, '┘', style)
134 }
135}
136
137// DrawText draws text starting at x,y before exclusive clipX and returns the
138// next x position. Wide runes that would cross clipX are dropped.
139func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Style) int {
140 if y < 0 || y >= f.Height {
141 return x
142 }
143 clipX = clamp(clipX, 0, f.Width)
144 pos := x
145 for _, r := range text {
146 w := renderer.RuneWidth(r)
147 if w == 0 {
148 continue
149 }
150 if pos >= clipX {
151 break
152 }
153 if pos+w > clipX {
154 break
155 }
156 if pos >= 0 && pos < f.Width {
157 f.Set(pos, y, renderer.Cell{Rune: r, Style: style})
158 }
159 if w == 2 && pos+1 >= 0 && pos+1 < f.Width {
160 f.Set(pos+1, y, renderer.Cell{Style: style, Continuation: true})
161 }
162 pos += w
163 }
164 return pos
165}
166
167func setCell(f renderer.Frame, x, y int, r rune, style renderer.Style) {
168 if x < 0 || x >= f.Width || y < 0 || y >= f.Height {
169 return
170 }
171 f.Set(x, y, renderer.Cell{Rune: r, Style: style})
172}
173
174func percentOf(n, pct int) int {
175 if pct <= 0 {
176 return n
177 }
178 return n * pct / 100
179}
180
181func textWidth(text string) int {
182 width := 0
183 for _, r := range text {
184 width += renderer.RuneWidth(r)
185 }
186 return width
187}