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}