v0.3.0 ea9abc7

scene.go RawHistoryFolder

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}

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