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}