feat/freeze-key 753c87f

scene.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close