feat/hints a471fa4

hint.go RawHistoryFolder

1package core
2
3import (
4	"cmp"
5	"context"
6	"slices"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11// Hint mode: the hint action asks the scanner (HintScans) for the clickable
12// targets in the last frame of the focused output, labels the ones that sit
13// over a managed window, grabs the keyboard for the label letters, and clicks
14// the centre of the target whose label was typed. Everything here runs on the
15// owner goroutine.
16
17// hintAlphabet orders the label letters: home row first.
18const hintAlphabet = "fjdkslaghrueiwoqptyvbcnxmz"
19
20// hintMaxTargets is the most targets labelled: every two-letter label.
21const hintMaxTargets = len(hintAlphabet) * len(hintAlphabet)
22
23type hintPhase uint8
24
25const (
26	hintIdle hintPhase = iota
27	hintPending
28	hintShown
29)
30
31// hintTarget is a labelled click target. x and y are global logical
32// coordinates of its centre.
33type hintTarget struct {
34	x, y   float64
35	window WindowID
36	label  string
37	rect   ports.Rect
38	text   bool // found by OCR
39}
40
41type hintState struct {
42	phase  hintPhase
43	seq    uint64 // last request sequence number
44	output string
45	// ws is the workspace on the hint output when hint mode began: another
46	// one on screen ends it.
47	ws      *Workspace
48	geom    hintGeom
49	windows []ports.HintWindow // visible windows at request time
50	targets []hintTarget
51	typed   string
52}
53
54// hintGeom is the output geometry a scan was requested on: a result or a
55// label set is only good for the same one.
56type hintGeom struct {
57	x, y          int
58	w, h          int // logical size
59	width, height int // buffer size, physical pixels
60	scale         float64
61	transform     ports.BufferTransform
62}
63
64func (sc *screen) hintGeom() hintGeom {
65	r := sc.rect()
66	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}
67}
68
69// hintRequest starts hint mode on the focused screen. It does nothing
70// (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is
71// active, or on an output with a non-normal transform.
72func (c *Core) hintRequest() {
73	if c.ch.HintScans == nil {
74		if c.opts.HintDisabled != nil {
75			c.opts.HintDisabled()
76			c.opts.HintDisabled = nil
77		}
78		return
79	}
80	if c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
81		return
82	}
83	sc := c.cur()
84	if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
85		return
86	}
87	wins := c.hintWindows(sc)
88	req := ports.HintScanRequest{Seq: c.hint.seq + 1, Output: sc.name(), Width: sc.info.Width, Height: sc.info.Height, Scale: sc.scale, LinksOnly: c.cfg.Hint.LinksOnly, Windows: wins}
89	select {
90	case c.ch.HintScans <- req:
91	default:
92		return
93	}
94	c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current(), geom: sc.hintGeom(), windows: slices.Clone(wins)}
95}
96
97// hintWindows lists the windows drawn on sc, bottom to top, with their client
98// area (placements are output-local).
99func (c *Core) hintWindows(sc *screen) []ports.HintWindow {
100	view := sc.mon.viewport()
101	var out []ports.HintWindow
102	for _, p := range sc.shownLayout() {
103		if p.Hidden || p.Preview > 0 || !view.shows(p) {
104			continue
105		}
106		r := c.clientRect(p)
107		if r.W <= 0 || r.H <= 0 {
108			continue
109		}
110		w := ports.HintWindow{ID: p.ID, Rect: r, Focused: p.Focused}
111		rec := c.windows.lookup(p.ID)
112		w.PID, w.AppID = rec.client.PID, rec.client.AppID
113		out = append(out, w)
114	}
115	return out
116}
117
118// hintPointerBusy reports whether the pointer is in use by something a
119// synthetic click would disturb: a pointer constraint, a grab, a held
120// button (swallowed ones included: they stay in c.buttons), a window or data
121// drag, or the column switcher.
122func (c *Core) hintPointerBusy() bool {
123	return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
124		c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
125}
126
127// hintResult handles a scanner result and reports whether to publish.
128func (c *Core) hintResult(res ports.HintScanResult) bool {
129	h := &c.hint
130	if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
131		return false
132	}
133	if res.Err != nil || c.security.Protected {
134		c.hintCancel()
135		return true
136	}
137	i := c.screenIndex(h.output)
138	if i < 0 {
139		c.hintCancel()
140		return true
141	}
142	sc := c.screens[i]
143	if sc.hintGeom() != h.geom {
144		c.hintCancel()
145		return true
146	}
147	out := sc.rect()
148	var kept []hintTarget
149	for _, t := range res.Targets {
150		x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
151		y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
152		if x < float64(out.X) || y < float64(out.Y) || x >= float64(out.X+out.W) || y >= float64(out.Y+out.H) {
153			continue
154		}
155		id, _, _ := c.hit(x, y)
156		if id == 0 {
157			continue
158		}
159		root := c.popupRoot(id)
160		if s, _ := c.screenOf(root); s != sc {
161			// A layer surface, or a window of another output.
162			continue
163		}
164		if !c.hintOwned(sc, t, root) {
165			continue
166		}
167		kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect, text: t.Text})
168	}
169	if len(kept) == 0 {
170		c.hintCancel()
171		return true
172	}
173	slices.SortStableFunc(kept, func(a, b hintTarget) int {
174		return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
175	})
176	if len(kept) > hintMaxTargets {
177		kept = kept[:hintMaxTargets]
178	}
179	n := len(hintAlphabet)
180	for i := range kept {
181		if len(kept) <= n {
182			kept[i].label = string(hintAlphabet[i])
183		} else {
184			kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
185		}
186	}
187	h.phase, h.targets, h.typed = hintShown, kept, ""
188	return true
189}
190
191// hintOwned reports whether the window under a target is the one it was
192// scanned on. An accessibility target names its window; an OCR target only
193// counts over a window that was visible when the scan was requested and has
194// not moved or resized since.
195func (c *Core) hintOwned(sc *screen, t ports.HintTarget, root WindowID) bool {
196	if t.Window != 0 {
197		return root == t.Window
198	}
199	for _, w := range c.hint.windows {
200		if w.ID != root {
201			continue
202		}
203		for _, p := range sc.shownLayout() {
204			if p.ID == root {
205				return c.clientRect(p) == w.Rect
206			}
207		}
208	}
209	return false
210}
211
212// hintCancel ends hint mode.
213func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
214
215// hintValidate ends hint mode when what it was started on is gone: the
216// session became protected, the output went or changed geometry, another workspace is on screen
217// there, or the overview opened. It runs at the start of every publish.
218func (c *Core) hintValidate() {
219	h := &c.hint
220	if h.phase == hintIdle {
221		return
222	}
223	i := c.screenIndex(h.output)
224	if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
225		c.hintCancel()
226		return
227	}
228	if c.screens[i].hintGeom() != h.geom {
229		c.hintCancel()
230	}
231}
232
233// hintKey handles a key while hint mode is active: the key never goes on.
234// A press is the whole job; releases take the usual swallow path.
235func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
236	if c.hint.phase == hintIdle || !key.Pressed {
237		return false, nil
238	}
239	c.pressed[heldKey(key)] = true
240	h := &c.hint
241	if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
242		c.hintCancel()
243		return true, c.publish(ctx)
244	}
245	if keyName(key.Keysym) == "BackSpace" {
246		if h.typed != "" {
247			h.typed = h.typed[:len(h.typed)-1]
248		}
249		return true, c.publish(ctx)
250	}
251	name := key.Base
252	if name == "" {
253		name = key.Keysym
254	}
255	name = keyName(name)
256	if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
257		c.hintCancel()
258		return true, c.publish(ctx)
259	}
260	typed := h.typed + name
261	var match *hintTarget
262	any := false
263	for i := range h.targets {
264		if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
265			any = true
266			if h.targets[i].label == typed {
267				match = &h.targets[i]
268			}
269		}
270	}
271	if !any {
272		return true, nil
273	}
274	if match == nil {
275		h.typed = typed
276		return true, c.publish(ctx)
277	}
278	return true, c.hintClick(ctx, *match)
279}
280
281// hintClick ends hint mode and left-clicks the target's centre, provided the
282// pointer is free and the same window is still under it. The click is
283// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
284// still be down) from turning it into a window drag, without touching c.mods.
285//
286// A target found by OCR is clicked with hint.text-click held (terminals open
287// a link on a modified click). Every click gives the window the keyboard
288// first, then sends exactly the modifiers to hold (none for other targets),
289// and the real modifier state is always sent back afterwards.
290func (c *Core) hintClick(ctx context.Context, t hintTarget) (err error) {
291	c.hintCancel()
292	if err := c.publish(ctx); err != nil {
293		return err
294	}
295	if !c.hintClickable(t) {
296		return nil
297	}
298	// Every click states its modifiers (possibly none): the bind's Cmd may
299	// still be down and the client must not see it on a plain click.
300	if err := c.clickFocus(ctx, t.window); err != nil {
301		return err
302	}
303	if err := c.command(ctx, ports.SetModifiers{ID: t.window, Mods: c.hintMods(t)}); err != nil {
304		return err
305	}
306	defer func() {
307		if e := c.command(ctx, ports.SetModifiers{ID: t.window, Restore: true, State: c.keyState}); err == nil {
308			err = e
309		}
310	}()
311	c.synthetic = true
312	defer func() { c.synthetic = false }()
313	if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
314		return err
315	}
316	// The motion may have been bent (constraint) or the layout changed:
317	// press only where the target is.
318	if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
319		return nil
320	}
321	if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
322		return err
323	}
324	return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
325}
326
327// hintMods are the modifiers to hold for a click on t, read at click time so
328// a reloaded configuration applies.
329func (c *Core) hintMods(t hintTarget) ports.Mods {
330	if !t.text {
331		return 0
332	}
333	return c.cfg.Hint.TextClick
334}
335
336// hintClickable reports whether a click on t may go out now: unprotected,
337// pointer free, and the selected window is still under the target's centre.
338func (c *Core) hintClickable(t hintTarget) bool {
339	if c.security.Protected || c.hintPointerBusy() {
340		return false
341	}
342	id, _, _ := c.hit(t.x, t.y)
343	return id == t.window
344}
345
346// hintLabels are the labels to draw on sc: those still matching what was
347// typed. Nil unless sc is the hint output.
348func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
349	h := &c.hint
350	if h.phase != hintShown || sc.name() != h.output {
351		return nil
352	}
353	var out []ports.HintLabel
354	for _, t := range h.targets {
355		if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
356			continue
357		}
358		out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
359	}
360	return out
361}

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