1package selection
2
3import (
4 "strings"
5 "unicode/utf8"
6
7 "github.com/bnema/nefercap/internal/core"
8 "github.com/bnema/nefercap/internal/ports"
9)
10
11// Node.Rect takes logical pixels, not CSS units. Keep geometry in em
12// multiples of the stylesheet's 1rem base (NeferGUI defaults to 16px).
13const (
14 baseFont = 16.0
15 tagPadY = baseFont * 0.25
16 tagPadX = baseFont * 0.625
17 labelW, labelH = baseFont * 9, baseFont * 2
18 footerW, footerH = baseFont * 40, baseFont * 3.5
19 headerH = labelH
20 edge = 1.0 // thin selection lines
21 margin = baseFont
22 chipGap = baseFont * 0.25 // between chips of one group
23 groupGap = baseFont * 0.75 // between the mode and target groups
24 toastFont = baseFont * 1.5
25 toastH = toastFont * 2
26)
27
28// noIndicatorHint is shown in record mode on a compositor that cannot keep a
29// recording indicator out of the video, so none is drawn.
30const noIndicatorHint = "No recording indicator on this compositor · stop with the same shortcut or nefercap stop"
31
32// freezeState is the F hint of the footer.
33type freezeState uint8
34
35const (
36 freezeOff freezeState = iota // no frozen screen in this mode: F is hidden
37 freezeOn // the frozen screen is shown: F goes live
38 freezeLive // F turned it off: F freezes again
39)
40
41// footerText is rebuilt only when the capture mode, picker kind, workspace
42// availability or frozen state changes; pointer motion never formats hints.
43func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndicator bool, freeze freezeState) string {
44 action, name, other := "capture", "shot", "rec"
45 if mode == ports.Record {
46 action, name, other = "record", "rec", "shot"
47 }
48 var b strings.Builder
49 b.Grow(192 + len(noIndicatorHint)) // one allocation for the whole legend
50 b.WriteString(name)
51 if toggle {
52 b.WriteString(" · Tab ")
53 b.WriteString(other)
54 }
55 b.WriteString(" · R region · M monitor")
56 if workspace {
57 b.WriteString(" · W workspace")
58 }
59 b.WriteString(" · G grid")
60 switch freeze {
61 case freezeOn:
62 b.WriteString(" · F live")
63 case freezeLive:
64 b.WriteString(" · F freeze")
65 }
66 b.WriteString(" · Esc cancel\n")
67 switch kind {
68 case core.PickMonitor, core.PickWorkspace:
69 target := " monitor"
70 if kind == core.PickWorkspace {
71 target = " workspace"
72 }
73 b.WriteString("Enter ")
74 b.WriteString(action)
75 b.WriteString(target)
76 b.WriteString(" · click ")
77 b.WriteString(action)
78 b.WriteString(target)
79 default:
80 b.WriteString("drag + release ")
81 b.WriteString(action)
82 b.WriteString(" region · click ")
83 b.WriteString(action)
84 b.WriteString(" monitor")
85 }
86 if noIndicator && mode == ports.Record {
87 b.WriteByte('\n')
88 b.WriteString(noIndicatorHint)
89 }
90 return b.String()
91}
92
93// badgeText is the label of a mode chip.
94func badgeText(mode ports.Mode) string {
95 if mode == ports.Record {
96 return "REC"
97 }
98 return "SHOT"
99}
100
101// modeClass is the stylesheet class that colors the active mode: cyan for
102// screenshots, coral red for recordings.
103func modeClass(mode ports.Mode) string {
104 if mode == ports.Record {
105 return "rec"
106 }
107 return "shot"
108}
109
110// Chip labels: the key, then the target. Indexes match targetKinds.
111var (
112 modeChips = [2]ports.Mode{ports.Screenshot, ports.Record}
113 targetKinds = [3]core.PickKind{core.PickRegion, core.PickMonitor, core.PickWorkspace}
114 targetTexts = [3]string{"R Region", "M Monitor", "W Workspace"}
115)
116
117// frect is a float rectangle in surface-local logical pixels, the unit
118// Node.Rect takes.
119type frect struct{ x, y, w, h float64 }
120
121// scene is the complete geometry of one frame, derived from the picker. It is
122// a plain value so computing it never allocates.
123type scene struct {
124 dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none
125 edges [4]frect // top, bottom, left, right lines
126 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
127 label frect
128 header frect
129 badge frect // the mode chips together, top-left
130 modes [2]frect // SHOT, REC; only the current one without Tab
131 target frect // the target chips together
132 kinds [3]frect // region, monitor, workspace
133 // Chip classes: shot or rec for the active chip, idle, or off for a
134 // workspace this output does not offer.
135 modeState [2]string
136 kindState [3]string
137 toast frect
138 footer frect
139}
140
141// highlight returns the rectangle to outline and whether the rest is dimmed.
142// A drag highlights the region and dims around it. The monitor kind outlines
143// the whole output without dimming. Otherwise the whole output is dimmed.
144func highlight(p *core.Picker) (r ports.Region, outline, dim bool) {
145 w, h := p.Bounds()
146 if r, ok := p.Rect(); ok {
147 return r, true, true
148 }
149 switch p.Kind() {
150 case core.PickMonitor:
151 return ports.Region{Width: w, Height: h}, true, false
152 case core.PickWorkspace:
153 if _, r, ok := p.Workspace(); ok {
154 return r, true, true
155 }
156 }
157 return ports.Region{Width: w, Height: h}, false, true
158}
159
160func (m *model) scene() scene {
161 var s scene
162 w, h := float64(m.w), float64(m.h)
163 r, outline, dim := highlight(&m.picker)
164 fr := frect{float64(r.X), float64(r.Y), float64(r.Width), float64(r.Height)}
165 switch {
166 case outline && dim:
167 s.dim = [4]frect{
168 {0, 0, w, fr.y},
169 {0, fr.y + fr.h, w, h - fr.y - fr.h},
170 {0, fr.y, fr.x, fr.h},
171 {fr.x + fr.w, fr.y, w - fr.x - fr.w, fr.h},
172 }
173 case dim:
174 s.dim[0] = frect{0, 0, w, h}
175 }
176 if outline {
177 e := min(max(m.edgeW, edge), fr.w/2, fr.h/2)
178 s.edges = [4]frect{
179 {fr.x, fr.y, fr.w, e},
180 {fr.x, fr.y + fr.h - e, fr.w, e},
181 {fr.x, fr.y, e, fr.h},
182 {fr.x + fr.w - e, fr.y, e, fr.h},
183 }
184 }
185 if m.grid {
186 s.grid = m.gridLines // the whole monitor, whatever is being dragged
187 }
188 if m.label.text != "" && outline {
189 s.label = labelRect(fr, w, h)
190 }
191 if m.hudHidden() {
192 // While a region is drawn only the framing aids stay: dimming,
193 // border, guides and size. The rest has faded out.
194 return s
195 }
196 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
197 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
198 s.placeChips(m, w, h)
199 s.chipStates(m)
200 // Keep workspace dimensions clear of the output header; region and
201 // monitor labels retain their independent placement.
202 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
203 s.label.x < s.header.x+s.header.w && s.label.x+s.label.w > s.header.x &&
204 s.label.y < s.header.y+s.header.h && s.label.y+s.label.h > s.header.y {
205 y := max(fr.y+tagPadY, s.header.y+s.header.h+tagPadY)
206 if y+s.label.h <= h {
207 s.label.y = y
208 }
209 }
210 if !m.picker.Dragging() { // a fading HUD never pushes the drag's size around
211 s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it
212 }
213 fw := min(footerW, w)
214 fh := min(footerHeight(m.footerText, fw), h)
215 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
216 if m.toast.on {
217 tw, th := min(toastWidth(m.toast.text), w), min(toastH, h)
218 s.toast = frect{(w - tw) / 2, (h - th) / 2, tw, th}
219 }
220 return s
221}
222
223// placeChips puts the mode chips top-left and the target chips after them,
224// or below them when the row would reach the output header. On an output too
225// narrow to share the row with the header, both move below it.
226func (s *scene) placeChips(m *model, w, h float64) {
227 ch := min(headerH, h)
228 x := 0.0
229 for i, mode := range modeChips {
230 if !m.toggle && mode != m.mode {
231 continue
232 }
233 cw := headerWidth(badgeText(mode))
234 if x > 0 {
235 x += chipGap
236 }
237 s.modes[i] = frect{x, 0, cw, ch}
238 x += cw
239 }
240 s.badge = frect{0, 0, x, ch}
241 x = 0
242 for i, text := range targetTexts {
243 cw := headerWidth(text)
244 if x > 0 {
245 x += chipGap
246 }
247 s.kinds[i] = frect{x, 0, cw, ch}
248 x += cw
249 }
250 s.target = frect{0, 0, x, ch}
251
252 top := min(margin, max(h-ch, 0))
253 left := min(margin, max(w-s.badge.w, 0))
254 row := frect{left, top, s.badge.w + groupGap + s.target.w, ch}
255 below := overlaps(row, s.header) || row.x+row.w > w
256 if below && overlaps(frect{left, top, s.badge.w, ch}, s.header) && s.header.y+s.header.h+tagPadY+ch <= h {
257 top = s.header.y + s.header.h + tagPadY
258 row.y = top
259 below = row.x+row.w > w
260 }
261 s.badge.x, s.badge.y = left, top
262 s.target.x, s.target.y = left+s.badge.w+groupGap, top
263 if below {
264 s.target.x, s.target.y = left, top+ch+tagPadY
265 }
266 for i := range s.modes {
267 place(&s.modes[i], s.badge, w, h)
268 }
269 for i := range s.kinds {
270 place(&s.kinds[i], s.target, w, h)
271 }
272 s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))
273 s.target.w = min(s.target.w, max(w-s.target.x, 0))
274}
275
276// chipStates colors the current mode and target with the mode class.
277func (s *scene) chipStates(m *model) {
278 mc := modeClass(m.mode)
279 for i, mode := range modeChips {
280 s.modeState[i] = "idle"
281 if mode == m.mode {
282 s.modeState[i] = mc
283 }
284 }
285 _, _, workspace := m.picker.Workspace()
286 for i, kind := range targetKinds {
287 switch {
288 case kind == m.picker.Kind():
289 s.kindState[i] = mc
290 case kind == core.PickWorkspace && !workspace:
291 s.kindState[i] = "off"
292 default:
293 s.kindState[i] = "idle"
294 }
295 }
296}
297
298// place moves a chip laid out from the origin into its group and clips it to
299// the surface; a chip with no room left gets no area and is not drawn.
300func place(c *frect, group frect, w, h float64) {
301 if c.w == 0 {
302 return
303 }
304 c.x += group.x
305 c.y = group.y
306 c.w = min(c.w, max(w-c.x, 0))
307 c.h = min(c.h, max(h-c.y, 0))
308 if c.w <= 0 || c.h <= 0 {
309 *c = frect{}
310 }
311}
312
313// clearLabelOfBadge moves a size label that meets the chips below them,
314// inside the highlighted rectangle fr when known.
315func (s *scene) clearLabelOfBadge(fr frect, h float64) {
316 for _, r := range [2]frect{s.badge, s.target} {
317 if s.label.w > 0 && r.w > 0 && overlaps(s.label, r) {
318 if y := max(fr.y+tagPadY, r.y+r.h+tagPadY); y+s.label.h <= h {
319 s.label.y = y
320 }
321 }
322 }
323}
324
325func overlaps(a, b frect) bool {
326 return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y
327}
328
329// footerHeight counts every explicit row separately, reserving conservative
330// monospace cells and metric lines for wrapping. Range counts runes (including
331// the middle-dot separators) without allocating per scene.
332func footerHeight(text string, width float64) float64 {
333 cells := max(int((width-2*tagPadX)/(baseFont*0.625)), 1)
334 lines, count := 0, 0
335 for _, r := range text {
336 if r == '\n' {
337 lines += max((count+cells-1)/cells, 1)
338 count = 0
339 } else {
340 count++
341 }
342 }
343 lines += max((count+cells-1)/cells, 1)
344 return max(footerH, float64(lines)*baseFont*1.5+2*tagPadY)
345}
346
347// headerWidth is a monospace estimate of the header text plus padding.
348func headerWidth(text string) float64 {
349 return float64(utf8.RuneCountInString(text))*baseFont*0.625 + 2*tagPadX
350}
351
352// toastWidth is the same estimate at the toast's larger font.
353func toastWidth(text string) float64 {
354 return float64(utf8.RuneCountInString(text))*toastFont*0.625 + 4*tagPadX
355}
356
357// labelRect places the size label above the region, or just inside it when
358// there is no room above, and keeps it on the surface.
359func labelRect(r frect, w, h float64) frect {
360 x, y := r.x, r.y-labelH-4
361 if y < 0 {
362 x, y = r.x+4, r.y+4
363 }
364 x = min(max(x, 0), max(w-labelW, 0))
365 y = min(max(y, 0), max(h-labelH, 0))
366 return frect{x, y, labelW, labelH}
367}