hint.go RawHistoryFolder

1package core
2
3import (
4	"cmp"
5	"context"
6	"math"
7	"slices"
8
9	"github.com/bnema/neferwl/internal/ports"
10)
11
12// Hint mode: the hint action asks the scanner (HintScans) for the clickable
13// targets in the last frame of the focused output, labels the ones that sit
14// over a managed window, grabs the keyboard for the label letters, and clicks
15// the centre of the target whose label was typed. Everything here runs on the
16// owner goroutine.
17
18// hintAlphabet orders the label letters: home row first.
19const hintAlphabet = "fjdkslaghrueiwoqptyvbcnxmz"
20
21// hintMaxTargets is the most targets labelled: every two-letter label.
22const hintMaxTargets = len(hintAlphabet) * len(hintAlphabet)
23
24type hintPhase uint8
25
26const (
27	hintIdle hintPhase = iota
28	hintPending
29	hintShown
30)
31
32// hintTarget is a labelled click target. x and y are global logical
33// coordinates of its centre.
34type hintTarget struct {
35	x, y   float64
36	score  float32
37	window WindowID
38	source hintSourceGeom // source window and its request-time client geometry
39	label  string
40	rect   ports.Rect
41}
42
43type hintSourceGeom struct {
44	id   WindowID
45	rect ports.Rect
46}
47
48type hintState struct {
49	phase  hintPhase
50	seq    uint64 // last request sequence number
51	output string
52	// ws is the workspace on the hint output when hint mode began: another
53	// one on screen ends it.
54	ws      *Workspace
55	geom    hintGeom
56	windows []hintSourceGeom // visible windows and their client geometry at request time
57	targets []hintTarget
58	typed   string
59}
60
61// hintGeom is the output geometry a scan was requested on: a result or a
62// label set is only good for the same one.
63type hintGeom struct {
64	x, y          int
65	w, h          int // logical size
66	width, height int // buffer size, physical pixels
67	scale         float64
68	transform     ports.BufferTransform
69}
70
71func (sc *screen) hintGeom() hintGeom {
72	r := sc.rect()
73	return hintGeom{x: sc.x, y: sc.y, w: r.W, h: r.H, width: sc.info.Width, height: sc.info.Height, scale: sc.scale, transform: sc.transform}
74}
75
76// hintRequest starts hint mode on the focused screen. It does nothing
77// (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is
78// active, or on an output with a non-normal transform.
79func (c *Core) hintRequest() {
80	if c.ch.HintScans == nil {
81		if c.opts.HintDisabled != nil {
82			c.opts.HintDisabled()
83			c.opts.HintDisabled = nil
84		}
85		return
86	}
87	if c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
88		return
89	}
90	sc := c.cur()
91	if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
92		return
93	}
94	wins := c.hintWindows(sc)
95	req := ports.HintScanRequest{Seq: c.hint.seq + 1, Output: sc.name(), Width: sc.info.Width, Height: sc.info.Height, Scale: sc.scale}
96	select {
97	case c.ch.HintScans <- req:
98	default:
99		return
100	}
101	c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current(), geom: sc.hintGeom(), windows: slices.Clone(wins)}
102}
103
104// hintWindows lists the windows drawn on sc, bottom to top, with their client
105// area (placements are output-local). A target is only clicked while the
106// geometry of its window is the one seen here.
107func (c *Core) hintWindows(sc *screen) []hintSourceGeom {
108	view := sc.mon.viewport()
109	var out []hintSourceGeom
110	for _, p := range sc.shownLayout() {
111		if p.Hidden || p.Preview > 0 || !view.shows(p) {
112			continue
113		}
114		r := c.clientRect(p)
115		if r.W <= 0 || r.H <= 0 {
116			continue
117		}
118		out = append(out, hintSourceGeom{id: p.ID, rect: r})
119	}
120	return out
121}
122
123// hintPointerBusy reports whether the pointer is in use by something a
124// synthetic click would disturb: a pointer constraint, a grab, a held
125// button (swallowed ones included: they stay in c.buttons), a window or data
126// drag, or the column switcher.
127func (c *Core) hintPointerBusy() bool {
128	return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
129		c.grab != 0 || len(c.buttons) > 0 || c.pointerBusy() || c.dataDrag != nil || c.switching()
130}
131
132// hintResult handles a scanner result and reports whether to publish.
133func (c *Core) hintResult(res ports.HintScanResult) bool {
134	h := &c.hint
135	if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
136		return false
137	}
138	if res.Err != nil || c.security.Protected {
139		c.hintCancel()
140		return true
141	}
142	i := c.screenIndex(h.output)
143	if i < 0 {
144		c.hintCancel()
145		return true
146	}
147	sc := c.screens[i]
148	if sc.hintGeom() != h.geom {
149		c.hintCancel()
150		return true
151	}
152	out := sc.rect()
153	var kept []hintTarget
154	for _, t := range res.Targets {
155		x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
156		y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
157		if x < float64(out.X) || y < float64(out.Y) || x >= float64(out.X+out.W) || y >= float64(out.Y+out.H) {
158			continue
159		}
160		id, _, _ := c.hit(x, y)
161		if id == 0 {
162			continue
163		}
164		root := c.popupRoot(id)
165		if s, _ := c.screenOf(root); s != sc {
166			// A layer surface, or a window of another output.
167			continue
168		}
169		source, ok := c.hintSource(sc, root)
170		if !ok {
171			continue
172		}
173		kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect, score: t.Score})
174	}
175	if len(kept) == 0 {
176		c.hintCancel()
177		return true
178	}
179	kept = c.hintSpread(sc, kept)
180	slices.SortStableFunc(kept, func(a, b hintTarget) int {
181		return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
182	})
183	if len(kept) > hintMaxTargets {
184		kept = kept[:hintMaxTargets]
185	}
186	n := len(hintAlphabet)
187	for i := range kept {
188		if len(kept) <= n {
189			kept[i].label = string(hintAlphabet[i])
190		} else {
191			kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
192		}
193	}
194	h.phase, h.targets, h.typed = hintShown, kept, ""
195	return true
196}
197
198// hintLabelSize estimates, in logical pixels, the box of a two-letter label on
199// sc: a monospace face (advance 0.6 em) plus padding and outline, rounded up.
200// The font size follows the renderer: hint.size, or the output's density when
201// it is automatic (same rules as hintlabel.FontPixels, which core cannot
202// import).
203func (c *Core) hintLabelSize(sc *screen) (w, h int) {
204	scale := sc.scale
205	if scale <= 0 {
206		scale = 1
207	}
208	font := c.cfg.Hint.Size // logical
209	if font <= 0 {
210		px := 13 * scale * math.Sqrt(math.Max(1, float64(sc.info.Height)/(1080*scale)))
211		if sc.info.Width > 0 && sc.info.Height > 0 && sc.info.PhysicalW > 0 && sc.info.PhysicalH > 0 {
212			ratio := (float64(sc.info.Width) / float64(sc.info.Height)) / (float64(sc.info.PhysicalW) / float64(sc.info.PhysicalH))
213			if math.Abs(ratio-1) <= .2 {
214				px = math.Min(math.Round(40*scale), math.Max(math.Round(13*scale), math.Round(3.2*float64(sc.info.Width)/float64(sc.info.PhysicalW))))
215			}
216		}
217		font = px / scale
218	}
219	font = math.Min(font, 256/scale)
220	s := font / 13
221	return int(math.Ceil(2*0.6*font + 6*s + 2/scale + 1)), int(math.Ceil(1.25*font + 2*s + 2/scale + 1))
222}
223
224// hintLabelRect mirrors the renderer's outside top-left anchor and output
225// clamping, in output-local logical coordinates.
226func hintLabelRect(sc *screen, target ports.Rect, lw, lh int) ports.Rect {
227	bounds := sc.rect()
228	inset := int(math.Round(0.25 * float64(lh)))
229	return ports.Rect{X: min(max(target.X-lw+inset, 0), bounds.W-lw), Y: min(max(target.Y-lh+inset, 0), bounds.H-lh), W: lw, H: lh}
230}
231
232// hintSpread drops targets whose output-clamped label rectangles intersect.
233// Highest scores win; four times the label budget gives spreading headroom
234// while bounding the quadratic pass. hintlabel.FontPixels is the source of
235// truth for the font rule mirrored above.
236func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget {
237	lw, lh := c.hintLabelSize(sc)
238	order := slices.Clone(ts)
239	slices.SortStableFunc(order, func(a, b hintTarget) int {
240		return cmp.Or(cmp.Compare(b.score, a.score), cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
241	})
242	if len(order) > 4*hintMaxTargets {
243		order = order[:4*hintMaxTargets]
244	}
245	kept := order[:0]
246	for _, t := range order {
247		clash := false
248		for _, k := range kept {
249			a, b := hintLabelRect(sc, t.rect, lw, lh), hintLabelRect(sc, k.rect, lw, lh)
250			if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
251				clash = true
252				break
253			}
254		}
255		if !clash {
256			kept = append(kept, t)
257		}
258	}
259	return kept
260}
261
262// hintSource resolves the request-time source window only while its client
263// geometry is unchanged.
264func (c *Core) hintSource(sc *screen, root WindowID) (hintSourceGeom, bool) {
265	for _, w := range c.hint.windows {
266		if w.id == root && c.hintSourceUnchanged(sc, w) {
267			return w, true
268		}
269	}
270	return hintSourceGeom{}, false
271}
272
273func (c *Core) hintSourceUnchanged(sc *screen, w hintSourceGeom) bool {
274	for _, p := range sc.shownLayout() {
275		if p.ID == w.id {
276			return c.clientRect(p) == w.rect
277		}
278	}
279	return false
280}
281
282// hintCancel ends hint mode.
283func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
284
285// hintValidate ends hint mode when what it was started on is gone: the
286// session became protected, the output went or changed geometry, another workspace is on screen
287// there, or the overview opened. It runs at the start of every publish.
288func (c *Core) hintValidate() {
289	h := &c.hint
290	if h.phase == hintIdle {
291		return
292	}
293	i := c.screenIndex(h.output)
294	if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
295		c.hintCancel()
296		return
297	}
298	if c.screens[i].hintGeom() != h.geom {
299		c.hintCancel()
300	}
301}
302
303// hintKey handles a key while hint mode is active: the key never goes on.
304// A press is the whole job; releases take the usual swallow path.
305func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
306	if c.hint.phase == hintIdle || !key.Pressed {
307		return false, nil
308	}
309	c.pressed[heldKey(key)] = true
310	h := &c.hint
311	if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
312		c.hintCancel()
313		return true, c.publish(ctx)
314	}
315	if keyName(key.Keysym) == "BackSpace" {
316		if h.typed != "" {
317			h.typed = h.typed[:len(h.typed)-1]
318		}
319		return true, c.publish(ctx)
320	}
321	name := key.Base
322	if name == "" {
323		name = key.Keysym
324	}
325	name = keyName(name)
326	if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
327		c.hintCancel()
328		return true, c.publish(ctx)
329	}
330	typed := h.typed + name
331	var match *hintTarget
332	any := false
333	for i := range h.targets {
334		if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
335			any = true
336			if h.targets[i].label == typed {
337				match = &h.targets[i]
338			}
339		}
340	}
341	if !any {
342		return true, nil
343	}
344	if match == nil {
345		h.typed = typed
346		return true, c.publish(ctx)
347	}
348	return true, c.hintClick(ctx, *match)
349}
350
351// hintClick ends hint mode and left-clicks the target's centre, provided the
352// pointer is free and the same window is still under it. The click is
353// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
354// still be down) from turning it into a window drag, without touching c.mods.
355//
356// The window gets the keyboard first.
357func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
358	c.hintCancel()
359	if err := c.publish(ctx); err != nil {
360		return err
361	}
362	if !c.hintClickable(t) {
363		return nil
364	}
365	if err := c.clickFocus(ctx, t.window); err != nil {
366		return err
367	}
368	// The physical modifiers (bind's Super, Ctrl, Shift) must not reach the
369	// client: state none, and always send the real state back afterwards.
370	if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: 0}); err != nil {
371		return err
372	}
373	defer func() {
374		if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true, State: c.keyState}); err == nil {
375			err = e
376		}
377	}()
378	c.synthetic = true
379	defer func() { c.synthetic = false }()
380	if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
381		return err
382	}
383	// The motion may have been bent (constraint) or the layout changed:
384	// press only where the target is.
385	if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
386		return nil
387	}
388	if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
389		return err
390	}
391	return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
392}
393
394// hintClickable reports whether a click on t may go out now: unprotected,
395// pointer free, and the selected window is still under the target's centre.
396func (c *Core) hintClickable(t hintTarget) bool {
397	if c.security.Protected || c.hintPointerBusy() {
398		return false
399	}
400	id, _, _ := c.hit(t.x, t.y)
401	sc, _ := c.screenOf(t.source.id)
402	return id == t.window && sc != nil && c.hintSourceUnchanged(sc, t.source)
403}
404
405// hintLabels are the labels to draw on sc: those still matching what was
406// typed. Nil unless sc is the hint output.
407func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
408	h := &c.hint
409	if h.phase != hintShown || sc.name() != h.output {
410		return nil
411	}
412	var out []ports.HintLabel
413	for _, t := range h.targets {
414		if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
415			continue
416		}
417		out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
418	}
419	return out
420}

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 23

Tags 10

↑↓ moveenter openesc close