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}