detached a329128

feat(core): add keyboard hint mode

commit
a329128cfdeaae0b7a75fe177430550aaa6e35a2
author
brice · · 2026-10-19 09:54 UTC
parent
1824dbd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +733 -6
M internal/adapters/config/config.go +10 -0
@@ -114,6 +114,7 @@ var actions = map[string]bool{
114114 "move-workspace-prev": true,
115115 "move-workspace-next": true,
116116 "toggle-floating": true,
117+ "hint": true,
117118 }
118119 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119120 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
@@ -847,6 +848,15 @@ func set(c *ports.Config, key, v string) error {
847848 return err
848849 }
849850 c.Focus.Flash = b
851+ case "hint.targets":
852+ switch v {
853+ case "clickable":
854+ c.Hint.LinksOnly = false
855+ case "links":
856+ c.Hint.LinksOnly = true
857+ default:
858+ return fmt.Errorf("must be clickable or links")
859+ }
850860 case "focus.flash-width":
851861 n, err := strconv.Atoi(v)
852862 if err != nil || n < 1 || n > 8 {
@@ -114,6 +114,7 @@ var actions = map[string]bool{
114 "move-workspace-prev": true,114 "move-workspace-prev": true,
115 "move-workspace-next": true,115 "move-workspace-next": true,
116 "toggle-floating": true,116 "toggle-floating": true,
117+ "hint": true,
117 }118 }
118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}119 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)120 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
@@ -847,6 +848,15 @@ func set(c *ports.Config, key, v string) error {
847 return err848 return err
848 }849 }
849 c.Focus.Flash = b850 c.Focus.Flash = b
851+ case "hint.targets":
852+ switch v {
853+ case "clickable":
854+ c.Hint.LinksOnly = false
855+ case "links":
856+ c.Hint.LinksOnly = true
857+ default:
858+ return fmt.Errorf("must be clickable or links")
859+ }
850 case "focus.flash-width":860 case "focus.flash-width":
851 n, err := strconv.Atoi(v)861 n, err := strconv.Atoi(v)
852 if err != nil || n < 1 || n > 8 {862 if err != nil || n < 1 || n > 8 {
M internal/adapters/config/config_test.go +23 -0
@@ -953,3 +953,26 @@ func TestLoadRejectsFIFO(t *testing.T) {
953953 t.Fatal("Load blocked on a FIFO")
954954 }
955955 }
956+
957+func TestHintTargets(t *testing.T) {
958+ if Defaults().Hint.LinksOnly {
959+ t.Fatal("hint targets default must be clickable")
960+ }
961+ c, w := parseString(t, "hint.targets = links\n")
962+ if !c.Hint.LinksOnly || len(w) != 0 {
963+ t.Fatal(c.Hint, w)
964+ }
965+ c, w = parseString(t, "hint.targets = clickable\n")
966+ if c.Hint.LinksOnly || len(w) != 0 {
967+ t.Fatal(c.Hint, w)
968+ }
969+ // An invalid value warns and keeps the default.
970+ c, w = parseString(t, "hint.targets = buttons\n")
971+ if c.Hint.LinksOnly || len(w) != 1 {
972+ t.Fatal(c.Hint, w)
973+ }
974+ c, w = parseString(t, "bind.cmd+g = hint\n")
975+ if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
976+ t.Fatal(c.Binds["Cmd+g"], w)
977+ }
978+}
@@ -953,3 +953,26 @@ func TestLoadRejectsFIFO(t *testing.T) {
953 t.Fatal("Load blocked on a FIFO")953 t.Fatal("Load blocked on a FIFO")
954 }954 }
955 }955 }
956+
957+func TestHintTargets(t *testing.T) {
958+ if Defaults().Hint.LinksOnly {
959+ t.Fatal("hint targets default must be clickable")
960+ }
961+ c, w := parseString(t, "hint.targets = links\n")
962+ if !c.Hint.LinksOnly || len(w) != 0 {
963+ t.Fatal(c.Hint, w)
964+ }
965+ c, w = parseString(t, "hint.targets = clickable\n")
966+ if c.Hint.LinksOnly || len(w) != 0 {
967+ t.Fatal(c.Hint, w)
968+ }
969+ // An invalid value warns and keeps the default.
970+ c, w = parseString(t, "hint.targets = buttons\n")
971+ if c.Hint.LinksOnly || len(w) != 1 {
972+ t.Fatal(c.Hint, w)
973+ }
974+ c, w = parseString(t, "bind.cmd+g = hint\n")
975+ if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
976+ t.Fatal(c.Binds["Cmd+g"], w)
977+ }
978+}
M internal/adapters/outputkit/capture/excluded.go +2 -2
@@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool {
6464 return false
6565 }
6666
67-// plain returns s without its capture state and indicators, Seq 0 so the
67+// plain returns s without its capture state, indicators and hint labels, Seq 0 so the
6868 // renderer redraws the whole target instead of reusing a displayed frame.
6969 func plain(s ports.Scene) ports.Scene {
70- s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 0
70+ s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
7171 return s
7272 }
7373
@@ -64,10 +64,10 @@ func excludedShown(s ports.Scene) bool {
64 return false64 return false
65 }65 }
66 66
67-// plain returns s without its capture state and indicators, Seq 0 so the67+// plain returns s without its capture state, indicators and hint labels, Seq 0 so the
68 // renderer redraws the whole target instead of reusing a displayed frame.68 // renderer redraws the whole target instead of reusing a displayed frame.
69 func plain(s ports.Scene) ports.Scene {69 func plain(s ports.Scene) ports.Scene {
70- s.Capture, s.CaptureIndicators, s.Seq = nil, nil, 070+ s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
71 return s71 return s
72 }72 }
73 73
M internal/core/action.go +6 -0
@@ -63,6 +63,9 @@ const (
6363 // command key is held (switcher.go).
6464 ActionSwitchColumnNext Action = "switch-column-next"
6565 ActionSwitchColumnPrev Action = "switch-column-prev"
66+ // Show keyboard hint labels over the links and buttons of the focused
67+ // output (hint.go).
68+ ActionHint Action = "hint"
6669 )
6770
6871 // monitorDirection returns the direction of the neighbor screen targeted by
@@ -302,6 +305,9 @@ func (c *Core) dispatchAction(a Action) Effect {
302305 return Effect{}
303306 }
304307 switch a {
308+ case ActionHint:
309+ c.hintRequest()
310+ return Effect{}
305311 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
306312 if i := c.neighbor(monitorDirection(a)); i >= 0 {
307313 c.focusScreen = i
@@ -63,6 +63,9 @@ const (
63 // command key is held (switcher.go).63 // command key is held (switcher.go).
64 ActionSwitchColumnNext Action = "switch-column-next"64 ActionSwitchColumnNext Action = "switch-column-next"
65 ActionSwitchColumnPrev Action = "switch-column-prev"65 ActionSwitchColumnPrev Action = "switch-column-prev"
66+ // Show keyboard hint labels over the links and buttons of the focused
67+ // output (hint.go).
68+ ActionHint Action = "hint"
66 )69 )
67 70
68 // monitorDirection returns the direction of the neighbor screen targeted by71 // monitorDirection returns the direction of the neighbor screen targeted by
@@ -302,6 +305,9 @@ func (c *Core) dispatchAction(a Action) Effect {
302 return Effect{}305 return Effect{}
303 }306 }
304 switch a {307 switch a {
308+ case ActionHint:
309+ c.hintRequest()
310+ return Effect{}
305 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:311 case ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown:
306 if i := c.neighbor(monitorDirection(a)); i >= 0 {312 if i := c.neighbor(monitorDirection(a)); i >= 0 {
307 c.focusScreen = i313 c.focusScreen = i
M internal/core/core.go +21 -1
@@ -47,6 +47,12 @@ type Channels struct {
4747 // animating scene (Scene.Animating): a running slide moves one step per
4848 // report. Without them it moves on a timer.
4949 Frames <-chan ports.OutputFrame
50+ // HintScans, when set, receives the scan request of each hint action
51+ // (capacity 1; core never waits: a full channel cancels the hint).
52+ // Hints carries the results back. With HintScans nil the hint action
53+ // does nothing.
54+ HintScans chan<- ports.HintScanRequest
55+ Hints <-chan ports.HintScanResult
5056 }
5157
5258 // Options are the services and policy core is constructed with.
@@ -157,6 +163,8 @@ type Core struct {
157163 // capt is the capture state (capture.go, capindicator.go).
158164 capt captureState
159165 flash focusFlash
166+ // hint is keyboard hint mode (hint.go).
167+ hint hintState
160168 // switcher is the column switcher in progress: the screen it is on and
161169 // the timer that shows its cards (switcher.go).
162170 switcher struct {
@@ -275,7 +283,7 @@ func (c *Core) apply(cfg ports.Config) error {
275283 }
276284 switch Action(a) {
277285 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
278- ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev:
286+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint:
279287 default:
280288 return fmt.Errorf("invalid action %q", a)
281289 }
@@ -634,6 +642,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
634642 return errSecurityChanged
635643 }
636644 c.syncSecurity()
645+ c.hintValidate()
637646 if c.security.Protected {
638647 return c.publishProtected(ctx)
639648 }
@@ -1206,6 +1215,17 @@ func (c *Core) Run(ctx context.Context) error {
12061215 return nil
12071216 }
12081217 continue
1218+ case res, ok := <-c.ch.Hints:
1219+ if !ok {
1220+ c.ch.Hints = nil
1221+ continue
1222+ }
1223+ if c.securityCheckpoint(ctx) != nil {
1224+ return nil
1225+ }
1226+ if c.hintResult(res) && c.publish(ctx) != nil {
1227+ return nil
1228+ }
12091229 case ev, ok := <-c.ch.Output:
12101230 if !ok {
12111231 c.ch.Output = nil
@@ -47,6 +47,12 @@ type Channels struct {
47 // animating scene (Scene.Animating): a running slide moves one step per47 // animating scene (Scene.Animating): a running slide moves one step per
48 // report. Without them it moves on a timer.48 // report. Without them it moves on a timer.
49 Frames <-chan ports.OutputFrame49 Frames <-chan ports.OutputFrame
50+ // HintScans, when set, receives the scan request of each hint action
51+ // (capacity 1; core never waits: a full channel cancels the hint).
52+ // Hints carries the results back. With HintScans nil the hint action
53+ // does nothing.
54+ HintScans chan<- ports.HintScanRequest
55+ Hints <-chan ports.HintScanResult
50 }56 }
51 57
52 // Options are the services and policy core is constructed with.58 // Options are the services and policy core is constructed with.
@@ -157,6 +163,8 @@ type Core struct {
157 // capt is the capture state (capture.go, capindicator.go).163 // capt is the capture state (capture.go, capindicator.go).
158 capt captureState164 capt captureState
159 flash focusFlash165 flash focusFlash
166+ // hint is keyboard hint mode (hint.go).
167+ hint hintState
160 // switcher is the column switcher in progress: the screen it is on and168 // switcher is the column switcher in progress: the screen it is on and
161 // the timer that shows its cards (switcher.go).169 // the timer that shows its cards (switcher.go).
162 switcher struct {170 switcher struct {
@@ -275,7 +283,7 @@ func (c *Core) apply(cfg ports.Config) error {
275 }283 }
276 switch Action(a) {284 switch Action(a) {
277 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,285 case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionToggleOverview, ActionCloseWindow, ActionQuit, ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveColumnToWorkspacePrev, ActionMoveColumnToWorkspaceNext, ActionMoveWindowToWorkspacePrev, ActionMoveWindowToWorkspaceNext, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight,
278- ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev:286+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint:
279 default:287 default:
280 return fmt.Errorf("invalid action %q", a)288 return fmt.Errorf("invalid action %q", a)
281 }289 }
@@ -634,6 +642,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
634 return errSecurityChanged642 return errSecurityChanged
635 }643 }
636 c.syncSecurity()644 c.syncSecurity()
645+ c.hintValidate()
637 if c.security.Protected {646 if c.security.Protected {
638 return c.publishProtected(ctx)647 return c.publishProtected(ctx)
639 }648 }
@@ -1206,6 +1215,17 @@ func (c *Core) Run(ctx context.Context) error {
1206 return nil1215 return nil
1207 }1216 }
1208 continue1217 continue
1218+ case res, ok := <-c.ch.Hints:
1219+ if !ok {
1220+ c.ch.Hints = nil
1221+ continue
1222+ }
1223+ if c.securityCheckpoint(ctx) != nil {
1224+ return nil
1225+ }
1226+ if c.hintResult(res) && c.publish(ctx) != nil {
1227+ return nil
1228+ }
1209 case ev, ok := <-c.ch.Output:1229 case ev, ok := <-c.ch.Output:
1210 if !ok {1230 if !ok {
1211 c.ch.Output = nil1231 c.ch.Output = nil
A internal/core/hint.go +225 -0
@@ -0,0 +1,225 @@
1+package core
2+
3+import (
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.
18+const hintAlphabet = "fjdkslaghrueiwoqptyvbcnxmz"
19+
20+// hintMaxTargets is the most targets labelled: every two-letter label.
21+const hintMaxTargets = len(hintAlphabet) * len(hintAlphabet)
22+
23+type hintPhase uint8
24+
25+const (
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.
33+type hintTarget struct {
34+ x, y float64
35+ window WindowID
36+ label string
37+ rect ports.Rect
38+}
39+
40+type hintState struct {
41+ phase hintPhase
42+ seq uint64 // last request sequence number
43+ output string
44+ // ws is the workspace on the hint output when hint mode began: another
45+ // one on screen ends it.
46+ ws *Workspace
47+ targets []hintTarget
48+ typed string
49+}
50+
51+// hintRequest starts hint mode on the focused screen. It does nothing
52+// without a scanner, while the session is protected, while hint mode is
53+// active, or on an output with a non-normal transform.
54+func (c *Core) hintRequest() {
55+ if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle {
56+ return
57+ }
58+ sc := c.cur()
59+ if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
60+ return
61+ }
62+ 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}
63+ select {
64+ case c.ch.HintScans <- req:
65+ default:
66+ return
67+ }
68+ c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}
69+}
70+
71+// hintResult handles a scanner result and reports whether to publish.
72+func (c *Core) hintResult(res ports.HintScanResult) bool {
73+ h := &c.hint
74+ if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
75+ return false
76+ }
77+ if res.Err != nil || c.security.Protected {
78+ c.hintCancel()
79+ return true
80+ }
81+ i := c.screenIndex(h.output)
82+ if i < 0 {
83+ c.hintCancel()
84+ return true
85+ }
86+ sc := c.screens[i]
87+ var kept []hintTarget
88+ for _, t := range res.Targets {
89+ x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
90+ y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
91+ id, _, _ := c.hit(x, y)
92+ if id == 0 {
93+ continue
94+ }
95+ if s, _ := c.screenOf(c.popupRoot(id)); s == nil {
96+ // A layer surface, not a managed window.
97+ continue
98+ }
99+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})
100+ }
101+ if len(kept) == 0 {
102+ c.hintCancel()
103+ return true
104+ }
105+ slices.SortStableFunc(kept, func(a, b hintTarget) int {
106+ return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
107+ })
108+ if len(kept) > hintMaxTargets {
109+ kept = kept[:hintMaxTargets]
110+ }
111+ n := len(hintAlphabet)
112+ for i := range kept {
113+ if len(kept) <= n {
114+ kept[i].label = string(hintAlphabet[i])
115+ } else {
116+ kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
117+ }
118+ }
119+ h.phase, h.targets, h.typed = hintShown, kept, ""
120+ return true
121+}
122+
123+// hintCancel ends hint mode.
124+func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
125+
126+// hintValidate ends hint mode when what it was started on is gone: the
127+// session became protected, the output went, another workspace is on screen
128+// there, or the overview opened. It runs at the start of every publish.
129+func (c *Core) hintValidate() {
130+ h := &c.hint
131+ if h.phase == hintIdle {
132+ return
133+ }
134+ i := c.screenIndex(h.output)
135+ if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
136+ c.hintCancel()
137+ }
138+}
139+
140+// hintKey handles a key while hint mode is active: the key never goes on.
141+// A press is the whole job; releases take the usual swallow path.
142+func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
143+ if c.hint.phase == hintIdle || !key.Pressed {
144+ return false, nil
145+ }
146+ c.pressed[heldKey(key)] = true
147+ h := &c.hint
148+ if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
149+ c.hintCancel()
150+ return true, c.publish(ctx)
151+ }
152+ if keyName(key.Keysym) == "BackSpace" {
153+ if h.typed != "" {
154+ h.typed = h.typed[:len(h.typed)-1]
155+ }
156+ return true, c.publish(ctx)
157+ }
158+ name := key.Base
159+ if name == "" {
160+ name = key.Keysym
161+ }
162+ name = keyName(name)
163+ if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
164+ c.hintCancel()
165+ return true, c.publish(ctx)
166+ }
167+ typed := h.typed + name
168+ var match *hintTarget
169+ any := false
170+ for i := range h.targets {
171+ if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
172+ any = true
173+ if h.targets[i].label == typed {
174+ match = &h.targets[i]
175+ }
176+ }
177+ }
178+ if !any {
179+ return true, nil
180+ }
181+ if match == nil {
182+ h.typed = typed
183+ return true, c.publish(ctx)
184+ }
185+ return true, c.hintClick(ctx, *match)
186+}
187+
188+// hintClick ends hint mode and left-clicks the target's centre, provided the
189+// same window is still there.
190+func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
191+ c.hintCancel()
192+ if err := c.publish(ctx); err != nil {
193+ return err
194+ }
195+ if c.security.Protected {
196+ return nil
197+ }
198+ if id, _, _ := c.hit(t.x, t.y); id != t.window {
199+ return nil
200+ }
201+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
202+ return err
203+ }
204+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
205+ return err
206+ }
207+ return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
208+}
209+
210+// hintLabels are the labels to draw on sc: those still matching what was
211+// typed. Nil unless sc is the hint output.
212+func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
213+ h := &c.hint
214+ if h.phase != hintShown || sc.name() != h.output {
215+ return nil
216+ }
217+ var out []ports.HintLabel
218+ for _, t := range h.targets {
219+ if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
220+ continue
221+ }
222+ out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
223+ }
224+ return out
225+}
@@ -0,0 +1,225 @@
1+package core
2+
3+import (
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.
18+const hintAlphabet = "fjdkslaghrueiwoqptyvbcnxmz"
19+
20+// hintMaxTargets is the most targets labelled: every two-letter label.
21+const hintMaxTargets = len(hintAlphabet) * len(hintAlphabet)
22+
23+type hintPhase uint8
24+
25+const (
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.
33+type hintTarget struct {
34+ x, y float64
35+ window WindowID
36+ label string
37+ rect ports.Rect
38+}
39+
40+type hintState struct {
41+ phase hintPhase
42+ seq uint64 // last request sequence number
43+ output string
44+ // ws is the workspace on the hint output when hint mode began: another
45+ // one on screen ends it.
46+ ws *Workspace
47+ targets []hintTarget
48+ typed string
49+}
50+
51+// hintRequest starts hint mode on the focused screen. It does nothing
52+// without a scanner, while the session is protected, while hint mode is
53+// active, or on an output with a non-normal transform.
54+func (c *Core) hintRequest() {
55+ if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle {
56+ return
57+ }
58+ sc := c.cur()
59+ if sc.name() == "" || sc.transform != 0 || sc.mon.ov.open || sc.info.Width <= 0 || sc.info.Height <= 0 {
60+ return
61+ }
62+ 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}
63+ select {
64+ case c.ch.HintScans <- req:
65+ default:
66+ return
67+ }
68+ c.hint = hintState{phase: hintPending, seq: req.Seq, output: sc.name(), ws: sc.mon.Current()}
69+}
70+
71+// hintResult handles a scanner result and reports whether to publish.
72+func (c *Core) hintResult(res ports.HintScanResult) bool {
73+ h := &c.hint
74+ if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
75+ return false
76+ }
77+ if res.Err != nil || c.security.Protected {
78+ c.hintCancel()
79+ return true
80+ }
81+ i := c.screenIndex(h.output)
82+ if i < 0 {
83+ c.hintCancel()
84+ return true
85+ }
86+ sc := c.screens[i]
87+ var kept []hintTarget
88+ for _, t := range res.Targets {
89+ x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
90+ y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
91+ id, _, _ := c.hit(x, y)
92+ if id == 0 {
93+ continue
94+ }
95+ if s, _ := c.screenOf(c.popupRoot(id)); s == nil {
96+ // A layer surface, not a managed window.
97+ continue
98+ }
99+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})
100+ }
101+ if len(kept) == 0 {
102+ c.hintCancel()
103+ return true
104+ }
105+ slices.SortStableFunc(kept, func(a, b hintTarget) int {
106+ return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
107+ })
108+ if len(kept) > hintMaxTargets {
109+ kept = kept[:hintMaxTargets]
110+ }
111+ n := len(hintAlphabet)
112+ for i := range kept {
113+ if len(kept) <= n {
114+ kept[i].label = string(hintAlphabet[i])
115+ } else {
116+ kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
117+ }
118+ }
119+ h.phase, h.targets, h.typed = hintShown, kept, ""
120+ return true
121+}
122+
123+// hintCancel ends hint mode.
124+func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
125+
126+// hintValidate ends hint mode when what it was started on is gone: the
127+// session became protected, the output went, another workspace is on screen
128+// there, or the overview opened. It runs at the start of every publish.
129+func (c *Core) hintValidate() {
130+ h := &c.hint
131+ if h.phase == hintIdle {
132+ return
133+ }
134+ i := c.screenIndex(h.output)
135+ if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
136+ c.hintCancel()
137+ }
138+}
139+
140+// hintKey handles a key while hint mode is active: the key never goes on.
141+// A press is the whole job; releases take the usual swallow path.
142+func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
143+ if c.hint.phase == hintIdle || !key.Pressed {
144+ return false, nil
145+ }
146+ c.pressed[heldKey(key)] = true
147+ h := &c.hint
148+ if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
149+ c.hintCancel()
150+ return true, c.publish(ctx)
151+ }
152+ if keyName(key.Keysym) == "BackSpace" {
153+ if h.typed != "" {
154+ h.typed = h.typed[:len(h.typed)-1]
155+ }
156+ return true, c.publish(ctx)
157+ }
158+ name := key.Base
159+ if name == "" {
160+ name = key.Keysym
161+ }
162+ name = keyName(name)
163+ if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
164+ c.hintCancel()
165+ return true, c.publish(ctx)
166+ }
167+ typed := h.typed + name
168+ var match *hintTarget
169+ any := false
170+ for i := range h.targets {
171+ if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
172+ any = true
173+ if h.targets[i].label == typed {
174+ match = &h.targets[i]
175+ }
176+ }
177+ }
178+ if !any {
179+ return true, nil
180+ }
181+ if match == nil {
182+ h.typed = typed
183+ return true, c.publish(ctx)
184+ }
185+ return true, c.hintClick(ctx, *match)
186+}
187+
188+// hintClick ends hint mode and left-clicks the target's centre, provided the
189+// same window is still there.
190+func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
191+ c.hintCancel()
192+ if err := c.publish(ctx); err != nil {
193+ return err
194+ }
195+ if c.security.Protected {
196+ return nil
197+ }
198+ if id, _, _ := c.hit(t.x, t.y); id != t.window {
199+ return nil
200+ }
201+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
202+ return err
203+ }
204+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
205+ return err
206+ }
207+ return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
208+}
209+
210+// hintLabels are the labels to draw on sc: those still matching what was
211+// typed. Nil unless sc is the hint output.
212+func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
213+ h := &c.hint
214+ if h.phase != hintShown || sc.name() != h.output {
215+ return nil
216+ }
217+ var out []ports.HintLabel
218+ for _, t := range h.targets {
219+ if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
220+ continue
221+ }
222+ out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
223+ }
224+ return out
225+}
A internal/core/hint_test.go +426 -0
@@ -0,0 +1,426 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+type hintHarness struct {
16+ t *testing.T
17+ input chan ports.InputEvent
18+ client chan ports.ClientEvent
19+ output chan ports.OutputEvent
20+ commands chan ports.ClientCommand
21+ scenes chan []ports.Scene
22+ scans chan ports.HintScanRequest
23+ hints chan ports.HintScanResult
24+ win ports.Rect // window 1, output-local
25+ // state is the security state input is stamped with.
26+ state ports.SecurityState
27+}
28+
29+// newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.
30+func newHint(t *testing.T, opts core.Options) *hintHarness {
31+ t.Helper()
32+ cfg := scrollDefaults()
33+ cfg.Border.Width = 0
34+ cfg.Layout.Gaps = 20
35+ cfg.Binds["Cmd+g"] = "hint"
36+ h := &hintHarness{
37+ t: t,
38+ input: make(chan ports.InputEvent, 32),
39+ client: make(chan ports.ClientEvent, 32),
40+ output: make(chan ports.OutputEvent, 8),
41+ commands: make(chan ports.ClientCommand, 256),
42+ scenes: make(chan []ports.Scene, 1),
43+ scans: make(chan ports.HintScanRequest, 1),
44+ // Unbuffered: a send returns once core took the result.
45+ hints: make(chan ports.HintScanResult),
46+ }
47+ ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints}
48+ c, err := core.New(cfg, ch, opts)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ ctx, cancel := context.WithCancel(context.Background())
53+ done := make(chan error, 1)
54+ go func() { done <- c.Run(ctx) }()
55+ t.Cleanup(func() { cancel(); receive(t, done) })
56+ h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
57+ scene(t, h.scenes)
58+ h.client <- ports.WindowMapped{ID: 1}
59+ s := scene(t, h.scenes)
60+ h.win = s.Windows[0].Rect
61+ h.drain()
62+ return h
63+}
64+
65+func (h *hintHarness) drain() {
66+ for {
67+ select {
68+ case <-h.commands:
69+ default:
70+ return
71+ }
72+ }
73+}
74+
75+// press and release send one key event.
76+func (h *hintHarness) press(sym string, mods ports.Mods) {
77+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true})
78+}
79+
80+func (h *hintHarness) release(sym string, mods ports.Mods) {
81+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods})
82+}
83+
84+// send gives core an input event stamped with the current security state.
85+func (h *hintHarness) send(ev ports.InputEvent) {
86+ h.input <- ports.SecurityInput{State: h.state, Event: ev}
87+}
88+
89+// start presses the hint bind and returns the request.
90+func (h *hintHarness) start() ports.HintScanRequest {
91+ h.t.Helper()
92+ h.press("g", ports.ModSuper)
93+ req := receive(h.t, h.scans)
94+ h.release("g", ports.ModSuper)
95+ return req
96+}
97+
98+// show starts hint mode and answers with targets, returning the next scene.
99+func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene {
100+ h.t.Helper()
101+ req := h.start()
102+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets}
103+ return h.labelled(len(targets), 0)
104+}
105+
106+// until returns the first scene, from the next one on, that satisfies ok:
107+// the bind press and other input publish scenes before the one waited for.
108+func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene {
109+ h.t.Helper()
110+ for {
111+ if s := scene(h.t, h.scenes); ok(s) {
112+ return s
113+ }
114+ }
115+}
116+
117+// labelled waits for a scene with n labels, all typed with typed letters.
118+func (h *hintHarness) labelled(n, typed int) ports.Scene {
119+ h.t.Helper()
120+ return h.until(func(s ports.Scene) bool {
121+ return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed)
122+ })
123+}
124+
125+// inside is a target of the given size inside window 1.
126+func (h *hintHarness) inside(i int) ports.HintTarget {
127+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}}
128+}
129+
130+func TestHintRequestOnBind(t *testing.T) {
131+ h := newHint(t, core.Options{})
132+ req := h.start()
133+ if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 {
134+ t.Fatalf("request %+v", req)
135+ }
136+ // A second bind while pending does not ask again; the pending phase
137+ // cancels on any key.
138+ h.press("g", ports.ModSuper)
139+ select {
140+ case r := <-h.scans:
141+ t.Fatalf("second request %+v", r)
142+ case <-time.After(50 * time.Millisecond):
143+ }
144+}
145+
146+func TestHintRequestNilScansIsNoop(t *testing.T) {
147+ cfg := scrollDefaults()
148+ cfg.Binds["Cmd+g"] = "hint"
149+ input := make(chan ports.InputEvent, 4)
150+ client := make(chan ports.ClientEvent, 4)
151+ output := make(chan ports.OutputEvent, 4)
152+ commands := make(chan ports.ClientCommand, 32)
153+ scenes := make(chan []ports.Scene, 1)
154+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ ctx, cancel := context.WithCancel(context.Background())
159+ done := make(chan error, 1)
160+ go func() { done <- c.Run(ctx) }()
161+ defer func() { cancel(); receive(t, done) }()
162+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
163+ scene(t, scenes)
164+ input <- ports.KeyEvent{Keysym: "g", Base: "g", Mods: ports.ModSuper, Pressed: true}
165+ if s := scene(t, scenes); len(s.HintLabels) != 0 {
166+ t.Fatal(s.HintLabels)
167+ }
168+}
169+
170+func TestHintRefusedWhenProtected(t *testing.T) {
171+ var state atomic.Pointer[ports.SecurityState]
172+ state.Store(&ports.SecurityState{})
173+ gate := portsmocks.NewMockSessionSecurity(t)
174+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe()
175+ h := newHint(t, core.Options{Security: gate})
176+ // Hint mode shown, then the session locks: the labels go away.
177+ h.show(h.inside(0))
178+ state.Store(&ports.SecurityState{Generation: 2, Protected: true})
179+ h.state = *state.Load()
180+ h.client <- ports.WindowMapped{ID: 2}
181+ s := h.until(func(s ports.Scene) bool { return s.Security.Protected })
182+ if len(s.HintLabels) != 0 {
183+ t.Fatalf("labels while protected %+v", s.HintLabels)
184+ }
185+ // And a new request is refused while it lasts.
186+ h.press("g", ports.ModSuper)
187+ select {
188+ case r := <-h.scans:
189+ t.Fatalf("request while protected %+v", r)
190+ case <-time.After(100 * time.Millisecond):
191+ }
192+}
193+
194+func TestHintResultShowsLabels(t *testing.T) {
195+ h := newHint(t, core.Options{})
196+ var targets []ports.HintTarget
197+ for i := 0; i < 26; i++ {
198+ targets = append(targets, h.inside(i))
199+ }
200+ s := h.show(targets...)
201+ if len(s.HintLabels) != 26 {
202+ t.Fatalf("%d labels", len(s.HintLabels))
203+ }
204+ seen := map[string]bool{}
205+ for _, l := range s.HintLabels {
206+ if len(l.Text) != 1 || l.Typed != 0 || seen[l.Text] {
207+ t.Fatalf("label %+v", l)
208+ }
209+ seen[l.Text] = true
210+ }
211+ // Sorted by position: the first target gets the first letter.
212+ if s.HintLabels[0].Text != "f" || s.HintLabels[0].Rect.X != targets[0].Rect.X || s.HintLabels[0].Rect.Y != targets[0].Rect.Y {
213+ t.Fatalf("first %+v", s.HintLabels[0])
214+ }
215+}
216+
217+func TestHintTwoLetterLabels(t *testing.T) {
218+ h := newHint(t, core.Options{})
219+ var targets []ports.HintTarget
220+ for i := 0; i < 27; i++ {
221+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
222+ }
223+ s := h.show(targets...)
224+ if len(s.HintLabels) != 27 {
225+ t.Fatalf("%d labels", len(s.HintLabels))
226+ }
227+ seen := map[string]bool{}
228+ for _, l := range s.HintLabels {
229+ if len(l.Text) != 2 || seen[l.Text] {
230+ t.Fatalf("label %+v", l)
231+ }
232+ seen[l.Text] = true
233+ }
234+ if s.HintLabels[0].Text != "ff" || s.HintLabels[1].Text != "fj" {
235+ t.Fatalf("labels %v %v", s.HintLabels[0].Text, s.HintLabels[1].Text)
236+ }
237+}
238+
239+func TestHintDropsTargetsOutsideWindows(t *testing.T) {
240+ h := newHint(t, core.Options{})
241+ in := h.inside(0)
242+ // In the gap around the window, over the background.
243+ out := ports.HintTarget{Rect: ports.Rect{X: 2, Y: 2, W: 4, H: 4}}
244+ if h.win.X < 8 || h.win.Y < 8 {
245+ t.Fatalf("window %+v leaves no gap", h.win)
246+ }
247+ req := h.start()
248+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, in}}
249+ s := h.labelled(1, 0)
250+ if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.X != in.Rect.X {
251+ t.Fatalf("labels %+v (window %+v)", s.HintLabels, h.win)
252+ }
253+}
254+
255+func TestHintTypingFilters(t *testing.T) {
256+ h := newHint(t, core.Options{})
257+ var targets []ports.HintTarget
258+ for i := 0; i < 27; i++ {
259+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
260+ }
261+ h.show(targets...)
262+ h.press("f", 0)
263+ s := h.labelled(26, 1)
264+ if len(s.HintLabels) != 26 {
265+ t.Fatalf("%d labels after f", len(s.HintLabels))
266+ }
267+ for _, l := range s.HintLabels {
268+ if l.Typed != 1 || l.Text[0] != 'f' {
269+ t.Fatalf("label %+v", l)
270+ }
271+ }
272+ h.press("BackSpace", 0)
273+ h.labelled(27, 0)
274+ // Only the 27th target starts with j: its label is "jf".
275+ h.press("j", 0)
276+ s = h.labelled(1, 1)
277+ if len(s.HintLabels) != 1 || s.HintLabels[0].Text != "jf" {
278+ t.Fatalf("after j %+v", s.HintLabels)
279+ }
280+}
281+
282+func TestHintBackspace(t *testing.T) {
283+ h := newHint(t, core.Options{})
284+ var targets []ports.HintTarget
285+ for i := 0; i < 27; i++ {
286+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
287+ }
288+ h.show(targets...)
289+ h.press("f", 0)
290+ h.labelled(26, 1)
291+ h.press("BackSpace", 0)
292+ s := h.labelled(27, 0)
293+ if len(s.HintLabels) != 27 || s.HintLabels[0].Typed != 0 {
294+ t.Fatalf("labels %d typed %d", len(s.HintLabels), s.HintLabels[0].Typed)
295+ }
296+}
297+
298+func TestHintClick(t *testing.T) {
299+ h := newHint(t, core.Options{})
300+ tg := h.inside(0)
301+ h.show(tg)
302+ h.drain()
303+ h.press("f", 0)
304+ s := h.labelled(0, 0)
305+ if len(s.HintLabels) != 0 {
306+ t.Fatalf("labels after click %+v", s.HintLabels)
307+ }
308+ cx := float64(tg.Rect.X) + float64(tg.Rect.W)/2 - float64(h.win.X)
309+ cy := float64(tg.Rect.Y) + float64(tg.Rect.H)/2 - float64(h.win.Y)
310+ var gotMotion, gotPress, gotRelease, gotFocus bool
311+ for len(h.commands) > 0 || !gotRelease {
312+ var cmd ports.ClientCommand
313+ select {
314+ case cmd = <-h.commands:
315+ case <-time.After(time.Second):
316+ t.Fatalf("missing commands: focus %v motion %v press %v release %v", gotFocus, gotMotion, gotPress, gotRelease)
317+ }
318+ switch v := cmd.(type) {
319+ case ports.PointerFocus:
320+ if v.ID == 1 && v.X == cx && v.Y == cy {
321+ gotFocus = true
322+ }
323+ case ports.PointerMotionTo:
324+ if v.ID == 1 && v.X == cx && v.Y == cy {
325+ gotMotion = true
326+ }
327+ case ports.PointerButtonTo:
328+ if v.ID != 1 || v.Button != 0x110 {
329+ t.Fatalf("button %+v", v)
330+ }
331+ if v.Pressed {
332+ gotPress = true
333+ } else {
334+ gotRelease = gotPress
335+ }
336+ }
337+ }
338+ if !gotFocus || !gotMotion {
339+ t.Fatalf("focus %v motion %v", gotFocus, gotMotion)
340+ }
341+}
342+
343+func TestHintEscapeCancels(t *testing.T) {
344+ h := newHint(t, core.Options{})
345+ h.show(h.inside(0))
346+ h.drain()
347+ h.press("Escape", 0)
348+ s := h.labelled(0, 0)
349+ if len(s.HintLabels) != 0 {
350+ t.Fatalf("labels %+v", s.HintLabels)
351+ }
352+ h.release("Escape", 0)
353+ // A later key goes to the window again: the Escape release did not.
354+ h.press("x", 0)
355+ cmd := command(t, h.commands)
356+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
357+ t.Fatalf("command %#v", cmd)
358+ }
359+ for len(h.commands) > 0 {
360+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
361+ t.Fatal("button sent")
362+ }
363+ }
364+}
365+
366+func TestHintKeysNotForwarded(t *testing.T) {
367+ h := newHint(t, core.Options{})
368+ h.show(h.inside(0), h.inside(1))
369+ h.drain()
370+ // A letter that matches nothing is swallowed and does not cancel.
371+ h.press("q", 0)
372+ h.release("q", 0)
373+ // Any other key cancels and is swallowed.
374+ h.press("Return", 0)
375+ s := h.labelled(0, 0)
376+ if len(s.HintLabels) != 0 {
377+ t.Fatalf("labels %+v", s.HintLabels)
378+ }
379+ h.release("Return", 0)
380+ h.press("x", 0)
381+ cmd := command(t, h.commands)
382+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
383+ t.Fatalf("first forwarded command %#v", cmd)
384+ }
385+}
386+
387+func TestHintStaleSeqIgnored(t *testing.T) {
388+ h := newHint(t, core.Options{})
389+ req := h.start()
390+ h.hints <- ports.HintScanResult{Seq: req.Seq + 7, Output: req.Output, Targets: []ports.HintTarget{h.inside(0)}}
391+ // A matching result afterwards shows: the stale one changed nothing.
392+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{h.inside(0), h.inside(1)}}
393+ s := h.labelled(2, 0)
394+ if len(s.HintLabels) != 2 {
395+ t.Fatalf("labels %+v", s.HintLabels)
396+ }
397+}
398+
399+func TestHintErrorCancels(t *testing.T) {
400+ h := newHint(t, core.Options{})
401+ req := h.start()
402+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Err: fmt.Errorf("boom")}
403+ h.press("x", 0)
404+ cmd := command(t, h.commands)
405+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
406+ t.Fatalf("command %#v", cmd)
407+ }
408+}
409+
410+func TestHintWindowMovedAborts(t *testing.T) {
411+ h := newHint(t, core.Options{})
412+ tg := h.inside(0)
413+ h.show(tg)
414+ // The window closes before the label is typed: the click must not go
415+ // anywhere.
416+ h.client <- ports.WindowUnmapped{ID: 1}
417+ h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })
418+ h.drain()
419+ h.press("f", 0)
420+ time.Sleep(50 * time.Millisecond)
421+ for len(h.commands) > 0 {
422+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
423+ t.Fatal("button sent to a moved window")
424+ }
425+ }
426+}
@@ -0,0 +1,426 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+type hintHarness struct {
16+ t *testing.T
17+ input chan ports.InputEvent
18+ client chan ports.ClientEvent
19+ output chan ports.OutputEvent
20+ commands chan ports.ClientCommand
21+ scenes chan []ports.Scene
22+ scans chan ports.HintScanRequest
23+ hints chan ports.HintScanResult
24+ win ports.Rect // window 1, output-local
25+ // state is the security state input is stamped with.
26+ state ports.SecurityState
27+}
28+
29+// newHint starts a core with one 1000x800 output "OUT-1" and window 1 mapped.
30+func newHint(t *testing.T, opts core.Options) *hintHarness {
31+ t.Helper()
32+ cfg := scrollDefaults()
33+ cfg.Border.Width = 0
34+ cfg.Layout.Gaps = 20
35+ cfg.Binds["Cmd+g"] = "hint"
36+ h := &hintHarness{
37+ t: t,
38+ input: make(chan ports.InputEvent, 32),
39+ client: make(chan ports.ClientEvent, 32),
40+ output: make(chan ports.OutputEvent, 8),
41+ commands: make(chan ports.ClientCommand, 256),
42+ scenes: make(chan []ports.Scene, 1),
43+ scans: make(chan ports.HintScanRequest, 1),
44+ // Unbuffered: a send returns once core took the result.
45+ hints: make(chan ports.HintScanResult),
46+ }
47+ ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes, HintScans: h.scans, Hints: h.hints}
48+ c, err := core.New(cfg, ch, opts)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ ctx, cancel := context.WithCancel(context.Background())
53+ done := make(chan error, 1)
54+ go func() { done <- c.Run(ctx) }()
55+ t.Cleanup(func() { cancel(); receive(t, done) })
56+ h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
57+ scene(t, h.scenes)
58+ h.client <- ports.WindowMapped{ID: 1}
59+ s := scene(t, h.scenes)
60+ h.win = s.Windows[0].Rect
61+ h.drain()
62+ return h
63+}
64+
65+func (h *hintHarness) drain() {
66+ for {
67+ select {
68+ case <-h.commands:
69+ default:
70+ return
71+ }
72+ }
73+}
74+
75+// press and release send one key event.
76+func (h *hintHarness) press(sym string, mods ports.Mods) {
77+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods, Pressed: true})
78+}
79+
80+func (h *hintHarness) release(sym string, mods ports.Mods) {
81+ h.send(ports.KeyEvent{Keysym: sym, Base: sym, Mods: mods})
82+}
83+
84+// send gives core an input event stamped with the current security state.
85+func (h *hintHarness) send(ev ports.InputEvent) {
86+ h.input <- ports.SecurityInput{State: h.state, Event: ev}
87+}
88+
89+// start presses the hint bind and returns the request.
90+func (h *hintHarness) start() ports.HintScanRequest {
91+ h.t.Helper()
92+ h.press("g", ports.ModSuper)
93+ req := receive(h.t, h.scans)
94+ h.release("g", ports.ModSuper)
95+ return req
96+}
97+
98+// show starts hint mode and answers with targets, returning the next scene.
99+func (h *hintHarness) show(targets ...ports.HintTarget) ports.Scene {
100+ h.t.Helper()
101+ req := h.start()
102+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: targets}
103+ return h.labelled(len(targets), 0)
104+}
105+
106+// until returns the first scene, from the next one on, that satisfies ok:
107+// the bind press and other input publish scenes before the one waited for.
108+func (h *hintHarness) until(ok func(ports.Scene) bool) ports.Scene {
109+ h.t.Helper()
110+ for {
111+ if s := scene(h.t, h.scenes); ok(s) {
112+ return s
113+ }
114+ }
115+}
116+
117+// labelled waits for a scene with n labels, all typed with typed letters.
118+func (h *hintHarness) labelled(n, typed int) ports.Scene {
119+ h.t.Helper()
120+ return h.until(func(s ports.Scene) bool {
121+ return len(s.HintLabels) == n && (n == 0 || s.HintLabels[0].Typed == typed)
122+ })
123+}
124+
125+// inside is a target of the given size inside window 1.
126+func (h *hintHarness) inside(i int) ports.HintTarget {
127+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}}
128+}
129+
130+func TestHintRequestOnBind(t *testing.T) {
131+ h := newHint(t, core.Options{})
132+ req := h.start()
133+ if req.Output != "OUT-1" || req.Width != 1000 || req.Height != 800 || req.Scale != 1 || req.LinksOnly || req.Seq == 0 {
134+ t.Fatalf("request %+v", req)
135+ }
136+ // A second bind while pending does not ask again; the pending phase
137+ // cancels on any key.
138+ h.press("g", ports.ModSuper)
139+ select {
140+ case r := <-h.scans:
141+ t.Fatalf("second request %+v", r)
142+ case <-time.After(50 * time.Millisecond):
143+ }
144+}
145+
146+func TestHintRequestNilScansIsNoop(t *testing.T) {
147+ cfg := scrollDefaults()
148+ cfg.Binds["Cmd+g"] = "hint"
149+ input := make(chan ports.InputEvent, 4)
150+ client := make(chan ports.ClientEvent, 4)
151+ output := make(chan ports.OutputEvent, 4)
152+ commands := make(chan ports.ClientCommand, 32)
153+ scenes := make(chan []ports.Scene, 1)
154+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ ctx, cancel := context.WithCancel(context.Background())
159+ done := make(chan error, 1)
160+ go func() { done <- c.Run(ctx) }()
161+ defer func() { cancel(); receive(t, done) }()
162+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
163+ scene(t, scenes)
164+ input <- ports.KeyEvent{Keysym: "g", Base: "g", Mods: ports.ModSuper, Pressed: true}
165+ if s := scene(t, scenes); len(s.HintLabels) != 0 {
166+ t.Fatal(s.HintLabels)
167+ }
168+}
169+
170+func TestHintRefusedWhenProtected(t *testing.T) {
171+ var state atomic.Pointer[ports.SecurityState]
172+ state.Store(&ports.SecurityState{})
173+ gate := portsmocks.NewMockSessionSecurity(t)
174+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe()
175+ h := newHint(t, core.Options{Security: gate})
176+ // Hint mode shown, then the session locks: the labels go away.
177+ h.show(h.inside(0))
178+ state.Store(&ports.SecurityState{Generation: 2, Protected: true})
179+ h.state = *state.Load()
180+ h.client <- ports.WindowMapped{ID: 2}
181+ s := h.until(func(s ports.Scene) bool { return s.Security.Protected })
182+ if len(s.HintLabels) != 0 {
183+ t.Fatalf("labels while protected %+v", s.HintLabels)
184+ }
185+ // And a new request is refused while it lasts.
186+ h.press("g", ports.ModSuper)
187+ select {
188+ case r := <-h.scans:
189+ t.Fatalf("request while protected %+v", r)
190+ case <-time.After(100 * time.Millisecond):
191+ }
192+}
193+
194+func TestHintResultShowsLabels(t *testing.T) {
195+ h := newHint(t, core.Options{})
196+ var targets []ports.HintTarget
197+ for i := 0; i < 26; i++ {
198+ targets = append(targets, h.inside(i))
199+ }
200+ s := h.show(targets...)
201+ if len(s.HintLabels) != 26 {
202+ t.Fatalf("%d labels", len(s.HintLabels))
203+ }
204+ seen := map[string]bool{}
205+ for _, l := range s.HintLabels {
206+ if len(l.Text) != 1 || l.Typed != 0 || seen[l.Text] {
207+ t.Fatalf("label %+v", l)
208+ }
209+ seen[l.Text] = true
210+ }
211+ // Sorted by position: the first target gets the first letter.
212+ if s.HintLabels[0].Text != "f" || s.HintLabels[0].Rect.X != targets[0].Rect.X || s.HintLabels[0].Rect.Y != targets[0].Rect.Y {
213+ t.Fatalf("first %+v", s.HintLabels[0])
214+ }
215+}
216+
217+func TestHintTwoLetterLabels(t *testing.T) {
218+ h := newHint(t, core.Options{})
219+ var targets []ports.HintTarget
220+ for i := 0; i < 27; i++ {
221+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
222+ }
223+ s := h.show(targets...)
224+ if len(s.HintLabels) != 27 {
225+ t.Fatalf("%d labels", len(s.HintLabels))
226+ }
227+ seen := map[string]bool{}
228+ for _, l := range s.HintLabels {
229+ if len(l.Text) != 2 || seen[l.Text] {
230+ t.Fatalf("label %+v", l)
231+ }
232+ seen[l.Text] = true
233+ }
234+ if s.HintLabels[0].Text != "ff" || s.HintLabels[1].Text != "fj" {
235+ t.Fatalf("labels %v %v", s.HintLabels[0].Text, s.HintLabels[1].Text)
236+ }
237+}
238+
239+func TestHintDropsTargetsOutsideWindows(t *testing.T) {
240+ h := newHint(t, core.Options{})
241+ in := h.inside(0)
242+ // In the gap around the window, over the background.
243+ out := ports.HintTarget{Rect: ports.Rect{X: 2, Y: 2, W: 4, H: 4}}
244+ if h.win.X < 8 || h.win.Y < 8 {
245+ t.Fatalf("window %+v leaves no gap", h.win)
246+ }
247+ req := h.start()
248+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{out, in}}
249+ s := h.labelled(1, 0)
250+ if len(s.HintLabels) != 1 || s.HintLabels[0].Rect.X != in.Rect.X {
251+ t.Fatalf("labels %+v (window %+v)", s.HintLabels, h.win)
252+ }
253+}
254+
255+func TestHintTypingFilters(t *testing.T) {
256+ h := newHint(t, core.Options{})
257+ var targets []ports.HintTarget
258+ for i := 0; i < 27; i++ {
259+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
260+ }
261+ h.show(targets...)
262+ h.press("f", 0)
263+ s := h.labelled(26, 1)
264+ if len(s.HintLabels) != 26 {
265+ t.Fatalf("%d labels after f", len(s.HintLabels))
266+ }
267+ for _, l := range s.HintLabels {
268+ if l.Typed != 1 || l.Text[0] != 'f' {
269+ t.Fatalf("label %+v", l)
270+ }
271+ }
272+ h.press("BackSpace", 0)
273+ h.labelled(27, 0)
274+ // Only the 27th target starts with j: its label is "jf".
275+ h.press("j", 0)
276+ s = h.labelled(1, 1)
277+ if len(s.HintLabels) != 1 || s.HintLabels[0].Text != "jf" {
278+ t.Fatalf("after j %+v", s.HintLabels)
279+ }
280+}
281+
282+func TestHintBackspace(t *testing.T) {
283+ h := newHint(t, core.Options{})
284+ var targets []ports.HintTarget
285+ for i := 0; i < 27; i++ {
286+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
287+ }
288+ h.show(targets...)
289+ h.press("f", 0)
290+ h.labelled(26, 1)
291+ h.press("BackSpace", 0)
292+ s := h.labelled(27, 0)
293+ if len(s.HintLabels) != 27 || s.HintLabels[0].Typed != 0 {
294+ t.Fatalf("labels %d typed %d", len(s.HintLabels), s.HintLabels[0].Typed)
295+ }
296+}
297+
298+func TestHintClick(t *testing.T) {
299+ h := newHint(t, core.Options{})
300+ tg := h.inside(0)
301+ h.show(tg)
302+ h.drain()
303+ h.press("f", 0)
304+ s := h.labelled(0, 0)
305+ if len(s.HintLabels) != 0 {
306+ t.Fatalf("labels after click %+v", s.HintLabels)
307+ }
308+ cx := float64(tg.Rect.X) + float64(tg.Rect.W)/2 - float64(h.win.X)
309+ cy := float64(tg.Rect.Y) + float64(tg.Rect.H)/2 - float64(h.win.Y)
310+ var gotMotion, gotPress, gotRelease, gotFocus bool
311+ for len(h.commands) > 0 || !gotRelease {
312+ var cmd ports.ClientCommand
313+ select {
314+ case cmd = <-h.commands:
315+ case <-time.After(time.Second):
316+ t.Fatalf("missing commands: focus %v motion %v press %v release %v", gotFocus, gotMotion, gotPress, gotRelease)
317+ }
318+ switch v := cmd.(type) {
319+ case ports.PointerFocus:
320+ if v.ID == 1 && v.X == cx && v.Y == cy {
321+ gotFocus = true
322+ }
323+ case ports.PointerMotionTo:
324+ if v.ID == 1 && v.X == cx && v.Y == cy {
325+ gotMotion = true
326+ }
327+ case ports.PointerButtonTo:
328+ if v.ID != 1 || v.Button != 0x110 {
329+ t.Fatalf("button %+v", v)
330+ }
331+ if v.Pressed {
332+ gotPress = true
333+ } else {
334+ gotRelease = gotPress
335+ }
336+ }
337+ }
338+ if !gotFocus || !gotMotion {
339+ t.Fatalf("focus %v motion %v", gotFocus, gotMotion)
340+ }
341+}
342+
343+func TestHintEscapeCancels(t *testing.T) {
344+ h := newHint(t, core.Options{})
345+ h.show(h.inside(0))
346+ h.drain()
347+ h.press("Escape", 0)
348+ s := h.labelled(0, 0)
349+ if len(s.HintLabels) != 0 {
350+ t.Fatalf("labels %+v", s.HintLabels)
351+ }
352+ h.release("Escape", 0)
353+ // A later key goes to the window again: the Escape release did not.
354+ h.press("x", 0)
355+ cmd := command(t, h.commands)
356+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
357+ t.Fatalf("command %#v", cmd)
358+ }
359+ for len(h.commands) > 0 {
360+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
361+ t.Fatal("button sent")
362+ }
363+ }
364+}
365+
366+func TestHintKeysNotForwarded(t *testing.T) {
367+ h := newHint(t, core.Options{})
368+ h.show(h.inside(0), h.inside(1))
369+ h.drain()
370+ // A letter that matches nothing is swallowed and does not cancel.
371+ h.press("q", 0)
372+ h.release("q", 0)
373+ // Any other key cancels and is swallowed.
374+ h.press("Return", 0)
375+ s := h.labelled(0, 0)
376+ if len(s.HintLabels) != 0 {
377+ t.Fatalf("labels %+v", s.HintLabels)
378+ }
379+ h.release("Return", 0)
380+ h.press("x", 0)
381+ cmd := command(t, h.commands)
382+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
383+ t.Fatalf("first forwarded command %#v", cmd)
384+ }
385+}
386+
387+func TestHintStaleSeqIgnored(t *testing.T) {
388+ h := newHint(t, core.Options{})
389+ req := h.start()
390+ h.hints <- ports.HintScanResult{Seq: req.Seq + 7, Output: req.Output, Targets: []ports.HintTarget{h.inside(0)}}
391+ // A matching result afterwards shows: the stale one changed nothing.
392+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{h.inside(0), h.inside(1)}}
393+ s := h.labelled(2, 0)
394+ if len(s.HintLabels) != 2 {
395+ t.Fatalf("labels %+v", s.HintLabels)
396+ }
397+}
398+
399+func TestHintErrorCancels(t *testing.T) {
400+ h := newHint(t, core.Options{})
401+ req := h.start()
402+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Err: fmt.Errorf("boom")}
403+ h.press("x", 0)
404+ cmd := command(t, h.commands)
405+ if v, ok := cmd.(ports.ForwardKey); !ok || v.Key.Keysym != "x" {
406+ t.Fatalf("command %#v", cmd)
407+ }
408+}
409+
410+func TestHintWindowMovedAborts(t *testing.T) {
411+ h := newHint(t, core.Options{})
412+ tg := h.inside(0)
413+ h.show(tg)
414+ // The window closes before the label is typed: the click must not go
415+ // anywhere.
416+ h.client <- ports.WindowUnmapped{ID: 1}
417+ h.until(func(s ports.Scene) bool { return len(s.Windows) == 0 })
418+ h.drain()
419+ h.press("f", 0)
420+ time.Sleep(50 * time.Millisecond)
421+ for len(h.commands) > 0 {
422+ if _, ok := (<-h.commands).(ports.PointerButtonTo); ok {
423+ t.Fatal("button sent to a moved window")
424+ }
425+ }
426+}
M internal/core/input.go +4 -0
@@ -277,6 +277,10 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
277277 }
278278 return c.rehit(ctx)
279279 }
280+ // Hint mode takes every key press before binds and windows.
281+ if handled, err := c.hintKey(ctx, key); handled || err != nil {
282+ return err
283+ }
280284 // The overview takes its keys before any window; others still
281285 // run binds, and are not forwarded.
282286 // A launcher or a menu holding the keyboard gets them first.
@@ -277,6 +277,10 @@ func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
277 }277 }
278 return c.rehit(ctx)278 return c.rehit(ctx)
279 }279 }
280+ // Hint mode takes every key press before binds and windows.
281+ if handled, err := c.hintKey(ctx, key); handled || err != nil {
282+ return err
283+ }
280 // The overview takes its keys before any window; others still284 // The overview takes its keys before any window; others still
281 // run binds, and are not forwarded.285 // run binds, and are not forwarded.
282 // A launcher or a menu holding the keyboard gets them first.286 // A launcher or a menu holding the keyboard gets them first.
M internal/core/scene.go +1 -0
@@ -123,6 +123,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
123123 }
124124 scene.Flash = c.flashFor(sc)
125125 scene.CaptureIndicators = c.captureIndicators(sc)
126+ scene.HintLabels = c.hintLabels(sc)
126127 scene.Capture = c.captureSceneFor(sc, capture)
127128 // The springs of this frame are advanced: a scene shown while one still
128129 // runs wakes core when it flips; the one a spring settles on does not.
@@ -123,6 +123,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
123 }123 }
124 scene.Flash = c.flashFor(sc)124 scene.Flash = c.flashFor(sc)
125 scene.CaptureIndicators = c.captureIndicators(sc)125 scene.CaptureIndicators = c.captureIndicators(sc)
126+ scene.HintLabels = c.hintLabels(sc)
126 scene.Capture = c.captureSceneFor(sc, capture)127 scene.Capture = c.captureSceneFor(sc, capture)
127 // The springs of this frame are advanced: a scene shown while one still128 // The springs of this frame are advanced: a scene shown while one still
128 // runs wakes core when it flips; the one a spring settles on does not.129 // runs wakes core when it flips; the one a spring settles on does not.
M internal/ports/config.go +6 -0
@@ -161,6 +161,12 @@ type Config struct {
161161 FlashDuration time.Duration
162162 FlashColor string
163163 }
164+ // Hint configures keyboard hint mode (action hint).
165+ Hint struct {
166+ // LinksOnly limits the targets to links and URLs; false also
167+ // includes buttons (hint.targets = clickable).
168+ LinksOnly bool
169+ }
164170 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
165171 Workspaces []WorkspaceConfig
166172 // Rules are the window rules (rule.<name>.* keys), in first-seen order.
@@ -161,6 +161,12 @@ type Config struct {
161 FlashDuration time.Duration161 FlashDuration time.Duration
162 FlashColor string162 FlashColor string
163 }163 }
164+ // Hint configures keyboard hint mode (action hint).
165+ Hint struct {
166+ // LinksOnly limits the targets to links and URLs; false also
167+ // includes buttons (hint.targets = clickable).
168+ LinksOnly bool
169+ }
164 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.170 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
165 Workspaces []WorkspaceConfig171 Workspaces []WorkspaceConfig
166 // Rules are the window rules (rule.<name>.* keys), in first-seen order.172 // Rules are the window rules (rule.<name>.* keys), in first-seen order.
M internal/ports/scene.go +6 -1
@@ -69,6 +69,10 @@ type Scene struct {
6969 // drawn over everything; nil when none. Captures never hold them: the
7070 // capture pipeline serves requests from a scene without them.
7171 CaptureIndicators []CaptureIndicator
72+ // HintLabels are the keyboard hint labels of this output, drawn over
73+ // everything, after CaptureIndicators; nil when hint mode is not shown
74+ // here. Captures never contain them.
75+ HintLabels []HintLabel
7276 // Flash is the focus flash drawn over everything of this output; nil
7377 // draws nothing.
7478 Flash *FocusFlash
@@ -112,7 +116,8 @@ func (s Scene) SameAs(o Scene) bool {
112116 }
113117 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
114118 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
115- slices.Equal(s.CaptureIndicators, o.CaptureIndicators)
119+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
120+ slices.Equal(s.HintLabels, o.HintLabels)
116121 }
117122
118123 // Shows reports whether the scene draws the surface of id: only its
@@ -69,6 +69,10 @@ type Scene struct {
69 // drawn over everything; nil when none. Captures never hold them: the69 // drawn over everything; nil when none. Captures never hold them: the
70 // capture pipeline serves requests from a scene without them.70 // capture pipeline serves requests from a scene without them.
71 CaptureIndicators []CaptureIndicator71 CaptureIndicators []CaptureIndicator
72+ // HintLabels are the keyboard hint labels of this output, drawn over
73+ // everything, after CaptureIndicators; nil when hint mode is not shown
74+ // here. Captures never contain them.
75+ HintLabels []HintLabel
72 // Flash is the focus flash drawn over everything of this output; nil76 // Flash is the focus flash drawn over everything of this output; nil
73 // draws nothing.77 // draws nothing.
74 Flash *FocusFlash78 Flash *FocusFlash
@@ -112,7 +116,8 @@ func (s Scene) SameAs(o Scene) bool {
112 }116 }
113 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&117 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
114 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&118 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
115- slices.Equal(s.CaptureIndicators, o.CaptureIndicators)119+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
120+ slices.Equal(s.HintLabels, o.HintLabels)
116 }121 }
117 122
118 // Shows reports whether the scene draws the surface of id: only its123 // Shows reports whether the scene draws the surface of id: only its
M internal/ports/scene_same_test.go +3 -2
@@ -25,6 +25,7 @@ func sameSceneBase() Scene {
2525
2626 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
2727 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
28+ HintLabels: []HintLabel{{Rect: Rect{X: 3, Y: 4}, Text: "fj", Typed: 1}},
2829 }
2930 }
3031
@@ -89,9 +90,9 @@ func TestSceneSameAs(t *testing.T) {
8990 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
9091 // update Scene.SameAs, then this count.
9192 func TestSceneSameAsCoversEveryField(t *testing.T) {
92- // 23 with Animating, which SameAs intentionally does not compare: it
93+ // 24 with Animating, which SameAs intentionally does not compare: it
9394 // only schedules the next frame, it draws nothing.
94- const fields = 23
95+ const fields = 24
9596 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
9697 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
9798 }
@@ -25,6 +25,7 @@ func sameSceneBase() Scene {
25 25
26 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},26 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
27 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},27 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
28+ HintLabels: []HintLabel{{Rect: Rect{X: 3, Y: 4}, Text: "fj", Typed: 1}},
28 }29 }
29 }30 }
30 31
@@ -89,9 +90,9 @@ func TestSceneSameAs(t *testing.T) {
89 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:90 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
90 // update Scene.SameAs, then this count.91 // update Scene.SameAs, then this count.
91 func TestSceneSameAsCoversEveryField(t *testing.T) {92 func TestSceneSameAsCoversEveryField(t *testing.T) {
92- // 23 with Animating, which SameAs intentionally does not compare: it93+ // 24 with Animating, which SameAs intentionally does not compare: it
93 // only schedules the next frame, it draws nothing.94 // only schedules the next frame, it draws nothing.
94- const fields = 2395+ const fields = 24
95 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),96 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
96 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")97 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
97 }98 }

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