main f2d166b

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

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