feat/hints a471fa4

capindicator.go RawHistoryFolder

1package core
2
3import (
4	"slices"
5	"time"
6
7	"github.com/bnema/neferwl/internal/ports"
8)
9
10// The capture indicator: the compositor shows that something is captured,
11// for every capture of every client and protocol, and no client can turn it
12// off. Core owns what is shown and until when; the renderer draws it over the
13// physical output only (Scene.CaptureIndicators) and never in a capture.
14//
15//   - a session that produced a frame (CaptureFrameTaken with a Session)
16//     marks its target for as long as it lives;
17//   - every captured frame marks its target for ports.CaptureFlash after it
18//     (a flash), so a one-shot screenshot shows for that long and periodic
19//     captures stay visible;
20//   - a target drawn on screen (output, region, workspace frame) gets a
21//     border; a workspace rendered for capture only has no place on screen, so
22//     its output gets a pill.
23//
24// Marks are the union of all live sessions and flashes. Expiry follows the
25// core clock: one timer, armed at the earliest flash, is the only time
26// source. Only the core goroutine touches this state; nothing here runs, or
27// allocates, while no session produced a frame and no flash is live.
28
29// maxCaptureFlashes bounds the live flashes. Past it the flashes of regions
30// of an output collapse into one flash of the whole output (a larger mark,
31// never a missing one); targets that are not regions are bounded by the
32// outputs and workspaces that exist.
33const maxCaptureFlashes = 256
34
35// capFlash is a captured frame's target and when its mark ends.
36type capFlash struct {
37	target capTarget
38	until  time.Time
39}
40
41// captureFrame records a captured frame. It reports whether what is shown
42// changed, so a scene must be published; extending a flash that shows
43// already does not.
44func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
45	changed := false
46	if s := c.capt.session(v.Session); v.Session != 0 && s != nil && !s.recording {
47		s.recording, changed = true, true
48	}
49	t := capTarget{output: v.Output, workspace: v.Workspace, window: v.Window, rect: v.Region}
50	if _, reason := c.capResolve(t); reason != ports.CaptureReasonNone {
51		// Nothing of the target is left on screen to mark.
52		return changed
53	}
54	return c.capt.flash(t, c.now(), c.opts.Clock) || changed
55}
56
57// flash marks t until ports.CaptureFlash after now and arms the timer. It
58// reports whether a new mark shows; extending one does not.
59func (s *captureState) flash(t capTarget, now time.Time, clock ports.Clock) bool {
60	until := now.Add(ports.CaptureFlash)
61	defer s.armTimer(now, clock)
62	for i := range s.flashes {
63		if s.flashes[i].target == t {
64			s.flashes[i].until = until
65			return false
66		}
67	}
68	if len(s.flashes) >= maxCaptureFlashes {
69		s.collapseFlashes(t)
70	}
71	s.flashes = append(s.flashes, capFlash{target: t, until: until})
72	return true
73}
74
75// collapseFlashes makes room: the flashes of regions of t's output become one
76// whole-output flash lasting as long as the longest of them; when that is
77// not enough the oldest flash goes.
78func (s *captureState) collapseFlashes(t capTarget) {
79	if t.workspace == 0 && t.output != "" {
80		var until time.Time
81		n := 0
82		s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool {
83			if f.target.workspace != 0 || f.target.output != t.output || f.target.rect == (Rect{}) {
84				return false
85			}
86			if f.until.After(until) {
87				until = f.until
88			}
89			n++
90			return true
91		})
92		if n > 0 {
93			s.flashes = append(s.flashes, capFlash{target: capTarget{output: t.output}, until: until})
94		}
95	}
96	if len(s.flashes) >= maxCaptureFlashes {
97		old := 0
98		for i, f := range s.flashes {
99			if f.until.Before(s.flashes[old].until) {
100				old = i
101			}
102		}
103		s.flashes = slices.Delete(s.flashes, old, old+1)
104	}
105}
106
107// expire forgets the flashes that ended by now.
108func (s *captureState) expire(now time.Time) {
109	s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool { return !f.until.After(now) })
110}
111
112// armTimer arms the timer at the earliest flash when none runs. A flash that
113// was extended leaves it: the timer fires early, finds nothing to forget and
114// arms again. No flash stops it. A nil clock uses the system timer.
115func (s *captureState) armTimer(now time.Time, clock ports.Clock) {
116	if len(s.flashes) == 0 {
117		s.stopTimer()
118		return
119	}
120	if s.timerStop != nil {
121		return
122	}
123	first := s.flashes[0].until
124	for _, f := range s.flashes[1:] {
125		if f.until.Before(first) {
126			first = f.until
127		}
128	}
129	s.timerC, s.timerStop = newTimer(clock, max(first.Sub(now), time.Millisecond))
130}
131
132func (s *captureState) stopTimer() {
133	if s.timerStop != nil {
134		s.timerStop()
135	}
136	s.timerC, s.timerStop = nil, nil
137}
138
139// captureExpire forgets the flashes that ended. With none it reads no clock.
140func (c *Core) captureExpire() {
141	if len(c.capt.flashes) > 0 {
142		c.capt.expire(c.now())
143	}
144}
145
146// captureFlashTick runs when the flash timer fired: it forgets the flashes
147// that ended and re-arms for the next. It reports whether one ended.
148func (c *Core) captureFlashTick() bool {
149	n := len(c.capt.flashes)
150	if n == 0 {
151		c.capt.stopTimer()
152		return false
153	}
154	now := c.now()
155	c.capt.expire(now)
156	c.capt.armTimer(now, c.opts.Clock)
157	return len(c.capt.flashes) != n
158}
159
160func (c *Core) armCaptureTimer() {
161	if len(c.capt.flashes) == 0 {
162		c.capt.stopTimer()
163		return
164	}
165	if c.capt.timerStop == nil {
166		c.capt.armTimer(c.now(), c.opts.Clock)
167	}
168}
169
170func (c *Core) stopCaptureTimer() { c.capt.stopTimer() }
171
172// captureIndicators is the marks of one output's scene; nil when there are
173// none. The slice is immutable once published and shared with the next scene
174// of the screen while the marks are the same; it is cloned only when they
175// change, so a steady recording allocates nothing.
176func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
177	if c.capt.idle() {
178		sc.capMarks = nil
179		return nil
180	}
181	out := c.capt.marks[:0]
182	add := func(t capTarget) {
183		r, reason := c.capResolve(t)
184		if reason != ports.CaptureReasonNone || r.sc != sc {
185			return
186		}
187		o := sc.mon.Output()
188		mark := func(m ports.CaptureIndicator) {
189			if !slices.Contains(out, m) {
190				out = append(out, m)
191			}
192		}
193		switch {
194		case t.window != 0:
195			// A window is rendered off screen, so its frames need the pill;
196			// while it is on screen it is outlined too.
197			mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
198			if r.visible {
199				mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
200			}
201		case r.hidden:
202			mark(ports.CaptureIndicator{Pill: true, Rect: pillRect(o)})
203		default:
204			mark(ports.CaptureIndicator{Rect: thickenMark(r.rect, o)})
205		}
206	}
207	for _, s := range c.capt.sessions {
208		if s.recording {
209			add(s.target())
210		}
211	}
212	for _, f := range c.capt.flashes {
213		add(f.target)
214	}
215	c.capt.marks = out
216	switch {
217	case len(out) == 0:
218		sc.capMarks = nil
219	case !slices.Equal(sc.capMarks, out):
220		sc.capMarks = slices.Clone(out)
221	}
222	return sc.capMarks
223}
224
225// thickenMark inflates a mark to at least ports.MinCaptureMark on each side,
226// around the target and clamped to the output (a target one logical pixel
227// wide gets a border with something to draw; the border then covers the
228// target whole). An output smaller than that is marked whole.
229func thickenMark(r, out Rect) Rect {
230	grow := func(pos, size, lo, span int) (int, int) {
231		if size >= ports.MinCaptureMark {
232			return pos, size
233		}
234		if span <= ports.MinCaptureMark {
235			return lo, span
236		}
237		pos -= (ports.MinCaptureMark - size) / 2
238		pos = min(max(pos, lo), lo+span-ports.MinCaptureMark)
239		return pos, ports.MinCaptureMark
240	}
241	r.X, r.W = grow(r.X, r.W, 0, out.W)
242	r.Y, r.H = grow(r.Y, r.H, 0, out.H)
243	return r
244}
245
246// pillRect is the pill in the top-right corner of an output, inset, and always
247// whole inside it: an output too small for the pill (clamped to the output it
248// would be cut) is filled whole instead, so the mark is never empty.
249func pillRect(o Rect) Rect {
250	r := Rect{X: max(o.W-ports.CapturePillInset-ports.CapturePillSize, 0), Y: ports.CapturePillInset, W: ports.CapturePillSize, H: ports.CapturePillSize}
251	if in := intersect(r, Rect{W: o.W, H: o.H}); in != r {
252		return Rect{W: o.W, H: o.H}
253	}
254	return r
255}

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 25

Tags 10

↑↓ moveenter openesc close