detached 689e994

feat(ports): add hint scan, modifier override and hint config messages

commit
689e994ef8fc44f11aafc324989a4066be2c9e28
author
brice · · 2026-10-19 16:03 UTC
committer
brice · · 2026-10-19 17:36 UTC
parent
9793569
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +119 -3
M internal/ports/capture_messages.go +6 -0
@@ -65,3 +65,9 @@ type CaptureDone struct {
6565 Err error
6666 Time time.Time
6767 }
68+
69+// CaptureIDInternal is the bit set in the ID of captures requested by the
70+// compositor itself (hint scanning) rather than by a Wayland client. Wayland
71+// IDs start at 1 and increment, so they never set it. Replies carrying it are
72+// demultiplexed away from the Wayland consumer.
73+const CaptureIDInternal uint64 = 1 << 63
@@ -65,3 +65,9 @@ type CaptureDone struct {
65 Err error65 Err error
66 Time time.Time66 Time time.Time
67 }67 }
68+
69+// CaptureIDInternal is the bit set in the ID of captures requested by the
70+// compositor itself (hint scanning) rather than by a Wayland client. Wayland
71+// IDs start at 1 and increment, so they never set it. Replies carrying it are
72+// demultiplexed away from the Wayland consumer.
73+const CaptureIDInternal uint64 = 1 << 63
M internal/ports/client_commands.go +14 -0
@@ -289,3 +289,17 @@ type SpawnRequest struct {
289289 // Env adds KEY=value entries to the child environment.
290290 Env []string
291291 }
292+
293+// SetModifiers carries core → wayland a temporary modifier override for the
294+// focused window's keyboards: Mods are sent as depressed modifiers (mapped
295+// to the current keymap's masks), latched/locked/group kept from the real
296+// state. Mods 0 with Restore true sends the real modifier state again: State
297+// is that state as core last saw it, which the seat adopts.
298+type SetModifiers struct {
299+ ID WindowID
300+ Mods Mods
301+ Restore bool
302+ State ModState
303+}
304+
305+func (SetModifiers) clientCommand() {}
@@ -289,3 +289,17 @@ type SpawnRequest struct {
289 // Env adds KEY=value entries to the child environment.289 // Env adds KEY=value entries to the child environment.
290 Env []string290 Env []string
291 }291 }
292+
293+// SetModifiers carries core → wayland a temporary modifier override for the
294+// focused window's keyboards: Mods are sent as depressed modifiers (mapped
295+// to the current keymap's masks), latched/locked/group kept from the real
296+// state. Mods 0 with Restore true sends the real modifier state again: State
297+// is that state as core last saw it, which the seat adopts.
298+type SetModifiers struct {
299+ ID WindowID
300+ Mods Mods
301+ Restore bool
302+ State ModState
303+}
304+
305+func (SetModifiers) clientCommand() {}
M internal/ports/config.go +14 -0
@@ -161,6 +161,20 @@ type Config struct {
161161 FlashDuration time.Duration
162162 FlashColor string
163163 }
164+ // Hint configures keyboard hint mode (action hint).
165+ Hint struct {
166+ // Enable turns the experimental hint mode on (hint.enable). It is
167+ // read at startup: a reload keeps the startup value.
168+ Enable bool
169+ // LinksOnly limits the targets to links and URLs; false also
170+ // includes buttons (hint.targets = clickable).
171+ LinksOnly bool
172+ // TextClick are the modifiers held for a click on a link found by
173+ // OCR (hint.text-click); zero sends a plain click.
174+ TextClick Mods
175+ Size float64 // logical pixels; zero selects automatic density sizing
176+ Background, Foreground string // empty derives from Border.Active
177+ }
164178 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
165179 Workspaces []WorkspaceConfig
166180 // Rules are the window rules (rule.<name>.* keys), in first-seen order.
@@ -161,6 +161,20 @@ 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+ // Enable turns the experimental hint mode on (hint.enable). It is
167+ // read at startup: a reload keeps the startup value.
168+ Enable bool
169+ // LinksOnly limits the targets to links and URLs; false also
170+ // includes buttons (hint.targets = clickable).
171+ LinksOnly bool
172+ // TextClick are the modifiers held for a click on a link found by
173+ // OCR (hint.text-click); zero sends a plain click.
174+ TextClick Mods
175+ Size float64 // logical pixels; zero selects automatic density sizing
176+ Background, Foreground string // empty derives from Border.Active
177+ }
164 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.178 // Workspaces are declared with workspace.<name>.* keys, in first-seen order.
165 Workspaces []WorkspaceConfig179 Workspaces []WorkspaceConfig
166 // Rules are the window rules (rule.<name>.* keys), in first-seen order.180 // Rules are the window rules (rule.<name>.* keys), in first-seen order.
A internal/ports/hint_messages.go +67 -0
@@ -0,0 +1,67 @@
1+package ports
2+
3+// HintKind is the kind of a clickable hint target.
4+type HintKind uint8
5+
6+const (
7+ // HintLink is a link or URL.
8+ HintLink HintKind = iota
9+ // HintButton is a button or control.
10+ HintButton
11+)
12+
13+// HintScanRequest asks the hint scanner to find clickable targets in the last
14+// frame of Output. Core sends it and expects one HintScanResult echoing Seq.
15+type HintScanRequest struct {
16+ Seq uint64 // core-chosen, echoed back
17+ Output string
18+ // Width and Height are the output buffer size in physical pixels
19+ // (OutputInfo.Width/Height).
20+ Width, Height int
21+ Scale float64 // screen scale
22+ LinksOnly bool
23+ // Windows are the windows visible on Output, for sources that read a
24+ // toolkit's accessibility tree. Core owns the slice per request.
25+ Windows []HintWindow
26+}
27+
28+// HintWindow is a window visible on the hint output.
29+type HintWindow struct {
30+ ID WindowID
31+ PID int
32+ AppID string
33+ // Rect is the client area, logical output-local.
34+ Rect Rect
35+ Focused bool
36+}
37+
38+// HintTarget is a clickable region found in a frame.
39+type HintTarget struct {
40+ Rect Rect // logical, output-local
41+ Kind HintKind
42+ // Text is set for targets found by OCR (read from pixels, not from an
43+ // accessibility tree).
44+ Text bool
45+ // Window is the source window of an accessibility target, 0 for OCR.
46+ Window WindowID
47+}
48+
49+// HintScanResult answers a HintScanRequest. Core owns Targets.
50+type HintScanResult struct {
51+ Seq uint64
52+ Output string
53+ Targets []HintTarget
54+ Err error
55+}
56+
57+// HintLabel is a label drawn over a hint target.
58+type HintLabel struct {
59+ // Rect is the label box in logical output-local coordinates; X and Y are
60+ // the target's top-left corner, which the renderer anchors the label
61+ // outside of. W and H may be 0: the renderer sizes the box from Text.
62+ Rect Rect
63+ // Text is the full label, lowercase a–z.
64+ Text string
65+ // Typed is the number of leading characters already typed (drawn dimmed).
66+ Typed int
67+}
@@ -0,0 +1,67 @@
1+package ports
2+
3+// HintKind is the kind of a clickable hint target.
4+type HintKind uint8
5+
6+const (
7+ // HintLink is a link or URL.
8+ HintLink HintKind = iota
9+ // HintButton is a button or control.
10+ HintButton
11+)
12+
13+// HintScanRequest asks the hint scanner to find clickable targets in the last
14+// frame of Output. Core sends it and expects one HintScanResult echoing Seq.
15+type HintScanRequest struct {
16+ Seq uint64 // core-chosen, echoed back
17+ Output string
18+ // Width and Height are the output buffer size in physical pixels
19+ // (OutputInfo.Width/Height).
20+ Width, Height int
21+ Scale float64 // screen scale
22+ LinksOnly bool
23+ // Windows are the windows visible on Output, for sources that read a
24+ // toolkit's accessibility tree. Core owns the slice per request.
25+ Windows []HintWindow
26+}
27+
28+// HintWindow is a window visible on the hint output.
29+type HintWindow struct {
30+ ID WindowID
31+ PID int
32+ AppID string
33+ // Rect is the client area, logical output-local.
34+ Rect Rect
35+ Focused bool
36+}
37+
38+// HintTarget is a clickable region found in a frame.
39+type HintTarget struct {
40+ Rect Rect // logical, output-local
41+ Kind HintKind
42+ // Text is set for targets found by OCR (read from pixels, not from an
43+ // accessibility tree).
44+ Text bool
45+ // Window is the source window of an accessibility target, 0 for OCR.
46+ Window WindowID
47+}
48+
49+// HintScanResult answers a HintScanRequest. Core owns Targets.
50+type HintScanResult struct {
51+ Seq uint64
52+ Output string
53+ Targets []HintTarget
54+ Err error
55+}
56+
57+// HintLabel is a label drawn over a hint target.
58+type HintLabel struct {
59+ // Rect is the label box in logical output-local coordinates; X and Y are
60+ // the target's top-left corner, which the renderer anchors the label
61+ // outside of. W and H may be 0: the renderer sizes the box from Text.
62+ Rect Rect
63+ // Text is the full label, lowercase a–z.
64+ Text string
65+ // Typed is the number of leading characters already typed (drawn dimmed).
66+ Typed int
67+}
M internal/ports/scene.go +14 -1
@@ -69,6 +69,11 @@ 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
76+ HintStyle HintStyle
7277 // Flash is the focus flash drawn over everything of this output; nil
7378 // draws nothing.
7479 Flash *FocusFlash
@@ -112,7 +117,8 @@ func (s Scene) SameAs(o Scene) bool {
112117 }
113118 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
114119 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
115- slices.Equal(s.CaptureIndicators, o.CaptureIndicators)
120+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
121+ s.HintStyle == o.HintStyle && slices.Equal(s.HintLabels, o.HintLabels)
116122 }
117123
118124 // Shows reports whether the scene draws the surface of id: only its
@@ -233,3 +239,10 @@ type SceneWindow struct {
233239 func (w SceneWindow) Tile() bool {
234240 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
235241 }
242+
243+// HintStyle carries reloadable label settings and untransformed output density.
244+type HintStyle struct {
245+ Size float64
246+ Background, Foreground string
247+ Width, Height, PhysicalW, PhysicalH int
248+}
@@ -69,6 +69,11 @@ 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
76+ HintStyle HintStyle
72 // Flash is the focus flash drawn over everything of this output; nil77 // Flash is the focus flash drawn over everything of this output; nil
73 // draws nothing.78 // draws nothing.
74 Flash *FocusFlash79 Flash *FocusFlash
@@ -112,7 +117,8 @@ func (s Scene) SameAs(o Scene) bool {
112 }117 }
113 return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&118 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) &&119 slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
115- slices.Equal(s.CaptureIndicators, o.CaptureIndicators)120+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators) &&
121+ s.HintStyle == o.HintStyle && slices.Equal(s.HintLabels, o.HintLabels)
116 }122 }
117 123
118 // Shows reports whether the scene draws the surface of id: only its124 // Shows reports whether the scene draws the surface of id: only its
@@ -233,3 +239,10 @@ type SceneWindow struct {
233 func (w SceneWindow) Tile() bool {239 func (w SceneWindow) Tile() bool {
234 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0240 return !w.Floating && !w.Fullscreen && !w.Popup && w.Preview == 0
235 }241 }
242+
243+// HintStyle carries reloadable label settings and untransformed output density.
244+type HintStyle struct {
245+ Size float64
246+ Background, Foreground string
247+ Width, Height, PhysicalW, PhysicalH int
248+}
M internal/ports/scene_same_test.go +4 -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
@@ -61,6 +62,7 @@ func TestSceneSameAs(t *testing.T) {
6162 "drop hint": func(s *Scene) { s.DropHints[0].W++ },
6263 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
6364 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
65+ "hint style": func(s *Scene) { s.HintStyle.Size = 24 },
6466 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
6567 "transform": func(s *Scene) { s.Transform = 1 },
6668 "capture shown": func(s *Scene) { s.Capture.Shown++ },
@@ -89,9 +91,9 @@ func TestSceneSameAs(t *testing.T) {
8991 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
9092 // update Scene.SameAs, then this count.
9193 func TestSceneSameAsCoversEveryField(t *testing.T) {
92- // 23 with Animating, which SameAs intentionally does not compare: it
94+ // 25 with Animating, which SameAs intentionally does not compare: it
9395 // only schedules the next frame, it draws nothing.
94- const fields = 23
96+ const fields = 25
9597 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
9698 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
9799 }
@@ -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
@@ -61,6 +62,7 @@ func TestSceneSameAs(t *testing.T) {
61 "drop hint": func(s *Scene) { s.DropHints[0].W++ },62 "drop hint": func(s *Scene) { s.DropHints[0].W++ },
62 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },63 "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
63 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },64 "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
65+ "hint style": func(s *Scene) { s.HintStyle.Size = 24 },
64 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },66 "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
65 "transform": func(s *Scene) { s.Transform = 1 },67 "transform": func(s *Scene) { s.Transform = 1 },
66 "capture shown": func(s *Scene) { s.Capture.Shown++ },68 "capture shown": func(s *Scene) { s.Capture.Shown++ },
@@ -89,9 +91,9 @@ func TestSceneSameAs(t *testing.T) {
89 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:91 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
90 // update Scene.SameAs, then this count.92 // update Scene.SameAs, then this count.
91 func TestSceneSameAsCoversEveryField(t *testing.T) {93 func TestSceneSameAsCoversEveryField(t *testing.T) {
92- // 23 with Animating, which SameAs intentionally does not compare: it94+ // 25 with Animating, which SameAs intentionally does not compare: it
93 // only schedules the next frame, it draws nothing.95 // only schedules the next frame, it draws nothing.
94- const fields = 2396+ const fields = 25
95 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),97 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
96 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")98 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
97 }99 }

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