1package selection
2
3import (
4 "strings"
5
6 "github.com/bnema/nefercap/internal/core"
7 "github.com/bnema/nefercap/internal/ports"
8)
9
10// Node.Rect takes logical pixels, not CSS units. Keep geometry in em
11// multiples of the stylesheet's 1rem base (NeferGUI defaults to 16px).
12const (
13 baseFont = 16.0
14 tagPadY = baseFont * 0.25
15 tagPadX = baseFont * 0.625
16 labelW, labelH = baseFont * 9, baseFont * 2
17 footerW, footerH = baseFont * 40, baseFont * 3.5
18 headerH = labelH
19 edge = 1.0 // thin selection lines
20 margin = baseFont
21)
22
23// noIndicatorHint is shown in record mode on a compositor that cannot keep a
24// recording indicator out of the video, so none is drawn.
25const noIndicatorHint = "No recording indicator on this compositor · stop with the same shortcut or nefercap stop"
26
27// footerText is rebuilt only when the capture mode, picker kind or workspace
28// availability changes; pointer motion never formats hints.
29func footerText(mode ports.Mode, kind core.PickKind, workspace, toggle, noIndicator bool) string {
30 action, name, other := "capture", "shot", "rec"
31 if mode == ports.Record {
32 action, name, other = "record", "rec", "shot"
33 }
34 var b strings.Builder
35 b.Grow(192 + len(noIndicatorHint)) // one allocation for the whole legend
36 b.WriteString(name)
37 if toggle {
38 b.WriteString(" · Tab ")
39 b.WriteString(other)
40 }
41 b.WriteString(" · R region · M monitor")
42 if workspace {
43 b.WriteString(" · W workspace")
44 }
45 b.WriteString(" · G grid · Esc cancel\n")
46 switch kind {
47 case core.PickMonitor, core.PickWorkspace:
48 target := " monitor"
49 if kind == core.PickWorkspace {
50 target = " workspace"
51 }
52 b.WriteString("Enter ")
53 b.WriteString(action)
54 b.WriteString(target)
55 b.WriteString(" · click ")
56 b.WriteString(action)
57 b.WriteString(target)
58 default:
59 b.WriteString("drag + release ")
60 b.WriteString(action)
61 b.WriteString(" region · click ")
62 b.WriteString(action)
63 b.WriteString(" monitor")
64 }
65 if noIndicator && mode == ports.Record {
66 b.WriteByte('\n')
67 b.WriteString(noIndicatorHint)
68 }
69 return b.String()
70}
71
72// badgeText is the always-visible mode badge. Both modes share the neutral
73// tag style: a red badge would read as an active recording.
74func badgeText(mode ports.Mode) string {
75 if mode == ports.Record {
76 return "REC"
77 }
78 return "SHOT"
79}
80
81// frect is a float rectangle in surface-local logical pixels, the unit
82// Node.Rect takes.
83type frect struct{ x, y, w, h float64 }
84
85// scene is the complete geometry of one frame, derived from the picker. It is
86// a plain value so computing it never allocates.
87type scene struct {
88 dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none
89 edges [4]frect // top, bottom, left, right lines
90 grid [core.GridLineCount]frect // whole-monitor guides: three vertical then three horizontal
91 label frect
92 header frect
93 badge frect // top-left mode badge
94 footer frect
95}
96
97// highlight returns the rectangle to outline and whether the rest is dimmed.
98// A drag highlights the region and dims around it. The monitor kind outlines
99// the whole output without dimming. Otherwise the whole output is dimmed.
100func highlight(p *core.Picker) (r ports.Region, outline, dim bool) {
101 w, h := p.Bounds()
102 if r, ok := p.Rect(); ok {
103 return r, true, true
104 }
105 switch p.Kind() {
106 case core.PickMonitor:
107 return ports.Region{Width: w, Height: h}, true, false
108 case core.PickWorkspace:
109 if _, r, ok := p.Workspace(); ok {
110 return r, true, true
111 }
112 }
113 return ports.Region{Width: w, Height: h}, false, true
114}
115
116func (m *model) scene() scene {
117 var s scene
118 w, h := float64(m.w), float64(m.h)
119 r, outline, dim := highlight(&m.picker)
120 fr := frect{float64(r.X), float64(r.Y), float64(r.Width), float64(r.Height)}
121 switch {
122 case outline && dim:
123 s.dim = [4]frect{
124 {0, 0, w, fr.y},
125 {0, fr.y + fr.h, w, h - fr.y - fr.h},
126 {0, fr.y, fr.x, fr.h},
127 {fr.x + fr.w, fr.y, w - fr.x - fr.w, fr.h},
128 }
129 case dim:
130 s.dim[0] = frect{0, 0, w, h}
131 }
132 if outline {
133 s.edges = [4]frect{
134 {fr.x, fr.y, fr.w, edge},
135 {fr.x, fr.y + fr.h - edge, fr.w, edge},
136 {fr.x, fr.y, edge, fr.h},
137 {fr.x + fr.w - edge, fr.y, edge, fr.h},
138 }
139 }
140 if m.grid {
141 s.grid = m.gridLines // the whole monitor, whatever is being dragged
142 }
143 if m.label.text != "" && outline {
144 s.label = labelRect(fr, w, h)
145 }
146 hw, hh := min(headerWidth(m.header), w), min(headerH, h)
147 s.header = frect{max(w-hw-margin, 0), min(margin, max(h-hh, 0)), hw, hh}
148 s.placeBadge(m.mode, w, h)
149 // Keep workspace dimensions clear of the output header; region and
150 // monitor labels retain their independent placement.
151 if m.picker.Kind() == core.PickWorkspace && s.label.w > 0 &&
152 s.label.x < s.header.x+s.header.w && s.label.x+s.label.w > s.header.x &&
153 s.label.y < s.header.y+s.header.h && s.label.y+s.label.h > s.header.y {
154 y := max(fr.y+tagPadY, s.header.y+s.header.h+tagPadY)
155 if y+s.label.h <= h {
156 s.label.y = y
157 }
158 }
159 s.clearLabelOfBadge(fr, h) // the header move can bring the label back to it
160 fw := min(footerW, w)
161 fh := min(footerHeight(m.footerText, fw), h)
162 s.footer = frect{(w - fw) / 2, max(h-fh-margin, 0), fw, fh}
163 return s
164}
165
166// placeBadge puts the mode badge top-left; on an output too narrow to share
167// the row with the header it moves below it.
168func (s *scene) placeBadge(mode ports.Mode, w, h float64) {
169 bw, bh := min(headerWidth(badgeText(mode)), w), min(headerH, h)
170 s.badge = frect{min(margin, max(w-bw, 0)), min(margin, max(h-bh, 0)), bw, bh}
171 if overlaps(s.badge, s.header) && s.header.y+s.header.h+tagPadY+bh <= h {
172 s.badge.y = s.header.y + s.header.h + tagPadY
173 }
174 s.clearLabelOfBadge(frect{}, h)
175}
176
177// clearLabelOfBadge moves a size label that meets the badge below it, inside
178// the highlighted rectangle fr when known.
179func (s *scene) clearLabelOfBadge(fr frect, h float64) {
180 if s.label.w > 0 && overlaps(s.label, s.badge) {
181 if y := max(fr.y+tagPadY, s.badge.y+s.badge.h+tagPadY); y+s.label.h <= h {
182 s.label.y = y
183 }
184 }
185}
186
187func overlaps(a, b frect) bool {
188 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
189}
190
191// footerHeight counts every explicit row separately, reserving conservative
192// monospace cells and metric lines for wrapping. Range counts runes (including
193// the middle-dot separators) without allocating per scene.
194func footerHeight(text string, width float64) float64 {
195 cells := max(int((width-2*tagPadX)/(baseFont*0.625)), 1)
196 lines, count := 0, 0
197 for _, r := range text {
198 if r == '\n' {
199 lines += max((count+cells-1)/cells, 1)
200 count = 0
201 } else {
202 count++
203 }
204 }
205 lines += max((count+cells-1)/cells, 1)
206 return max(footerH, float64(lines)*baseFont*1.5+2*tagPadY)
207}
208
209// headerWidth is a monospace estimate of the header text plus padding.
210func headerWidth(text string) float64 {
211 return float64(len([]rune(text)))*baseFont*0.625 + 2*tagPadX
212}
213
214// labelRect places the size label above the region, or just inside it when
215// there is no room above, and keeps it on the surface.
216func labelRect(r frect, w, h float64) frect {
217 x, y := r.x, r.y-labelH-4
218 if y < 0 {
219 x, y = r.x+4, r.y+4
220 }
221 x = min(max(x, 0), max(w-labelW, 0))
222 y = min(max(y, 0), max(h-labelH, 0))
223 return frect{x, y, labelW, labelH}
224}