detached be629c4

#3 WIP: feat(hint): keyboard hint mode for links and buttons

state
closed
branch
feat/hint-mode → main
opened
bnema ·
updated

Adds a hint action: NeferWL captures the focused output internally (no client, no capture indicator), finds links, URLs and buttons with bnema.dev/hintscan, shows short labels, and clicks the chosen target through its own pointer path.

  • Core: hint state, keyboard grab (letters, BackSpace, Escape), synthetic click guarded against held modifiers, pointer constraints, grabs, held buttons and drags; refused while locked.
  • App: internal capture IDs (bit 63) and a reply demux; hints adapter wired.
  • Vulkan: labels rasterized with Go Mono (internal/adapters/hintlabel), drawn over everything, never in captures; direct scanout off while labels show.
  • Config: hint action (no default bind), hint.targets = clickable|links.

Blocker before merge: go.mod has replace bnema.dev/hintscan => ../../../hintscan/.worktrees/scanner. CI fails until hintscan v0.1.0 is published (hintscan!1) and the replace is removed.

Checks: make check, make race, make mocks-check, staticcheck ./... pass locally. Headless e2e (Chromium, 1280x720): labels page 11 targets (9 links, 2 buttons), capture ~1 ms, scan ~2 ms, no indicator; click page Super+g, f → page logs hint-clicked; Escape cancels without a click.

Known visual follow-ups: labels cover the first glyph of their target; label font rendering to check.

CommitSubjectAuthorDateSignatureStatus
be629c4feat(hints): dump scanned frames when NEFERWL_HINT_DEBUG is setbriceGPG Verified
8ed066edocs(config): document hint modebriceGPG Verified
2a96044feat(app): wire the hints adapterbriceGPG Verified
4b455b2feat(hints): capture and scan the output for hint targetsbriceGPG Verified
df4b2febuild(deps): use local hintscan modulebriceGPG Verified
09a5c8cfix(core): make hint clicks safe against modifiers, constraints and grabsbriceGPG Verified
90d23d4fix(capture): keep hint labels out of captured framesbriceGPG Verified
9ad3054feat(vulkan): draw hint labelsbriceGPG Verified
a329128feat(core): add keyboard hint modebriceGPG Verified
1824dbdfeat(app): route internal capture repliesbriceGPG Verified
7f28d9cfeat(ports): add hint scan messages and internal capture idsbriceGPG Verified

Checks 1

44 files · +2287 -24
0/1
M .hexcheck.yaml +1 -1
M Makefile +1 -1
M README.md +1 -0
M docs/config.md +18 -0
M examples/config +5 -0
M go.mod +5 -0
M go.sum +4 -0
M internal/adapters/config/config.go +10 -0
M internal/adapters/config/config_test.go +23 -0
M internal/adapters/drm/overlay.go +3 -0
M internal/adapters/drm/scanout.go +4 -0
A internal/adapters/hintlabel/hintlabel.go +131 -0
A internal/adapters/hintlabel/hintlabel_test.go +60 -0
A internal/adapters/hints/dump.go +214 -0
A internal/adapters/hints/hints.go +200 -0
A internal/adapters/hints/hints_test.go +287 -0
A internal/adapters/hints/testdata/browser.png +0 -0 binary file: no text diff
M internal/adapters/outputkit/capture/excluded.go +5 -5
M internal/adapters/outputkit/capture/indicator_test.go +40 -0
M internal/adapters/vulkan/damage.go +3 -1
M internal/adapters/vulkan/damage_delta_test.go +2 -2
A internal/adapters/vulkan/hint_labels.go +44 -0
A internal/adapters/vulkan/hint_labels_test.go +97 -0
M internal/adapters/vulkan/renderer.go +3 -0
M internal/adapters/vulkan/scene.go +4 -3
M internal/adapters/vulkan/shm.go +6 -0
@@ -154,6 +154,12 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) {
154154 type shmKey struct {
155155 win ports.WindowID
156156 surface uint64
157+ // label, typed and scale name a compositor-drawn hint label (hint_labels.go)
158+ // instead of a client surface: win and surface are 0 and label is never
159+ // empty, so such a key cannot collide with a client's.
160+ label string
161+ typed int
162+ scale float64
157163 }
158164
159165 // shmState is what a GPU buffer holds: a w×h client buffer at a content
@@ -154,6 +154,12 @@ func (r *Renderer) freeGPUBuffer(b *gpuBuffer) {
154 type shmKey struct {154 type shmKey struct {
155 win ports.WindowID155 win ports.WindowID
156 surface uint64156 surface uint64
157+ // label, typed and scale name a compositor-drawn hint label (hint_labels.go)
158+ // instead of a client surface: win and surface are 0 and label is never
159+ // empty, so such a key cannot collide with a client's.
160+ label string
161+ typed int
162+ scale float64
157 }163 }
158 164
159 // shmState is what a GPU buffer holds: a w×h client buffer at a content165 // shmState is what a GPU buffer holds: a w×h client buffer at a content
M internal/adapters/vulkan/targets.go +1 -0
@@ -43,6 +43,7 @@ type target struct {
4343 heldLayers []ports.SceneLayer
4444 heldHints []ports.Rect
4545 heldIndicators []ports.CaptureIndicator
46+ heldLabels []ports.HintLabel
4647 windows map[ports.WindowID]heldWindow
4748 }
4849
@@ -43,6 +43,7 @@ type target struct {
43 heldLayers []ports.SceneLayer43 heldLayers []ports.SceneLayer
44 heldHints []ports.Rect44 heldHints []ports.Rect
45 heldIndicators []ports.CaptureIndicator45 heldIndicators []ports.CaptureIndicator
46+ heldLabels []ports.HintLabel
46 windows map[ports.WindowID]heldWindow47 windows map[ports.WindowID]heldWindow
47 }48 }
48 49
A internal/app/captured.go +41 -0
@@ -0,0 +1,41 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// routeCaptured demultiplexes capture replies from the outputs: replies whose
11+// ID carries ports.CaptureIDInternal go to internal, all others to wayland.
12+// Both sends give way to ctx. A nil internal means no internal consumer is
13+// wired: internal replies are dropped (logged at debug) instead of blocking
14+// forever, since nothing would ever receive them. It returns when ctx is done
15+// or in is closed.
16+func routeCaptured(ctx context.Context, in <-chan ports.CaptureDone, wayland, internal chan<- ports.CaptureDone) {
17+ log := logging.For(ctx, "app")
18+ for {
19+ select {
20+ case <-ctx.Done():
21+ return
22+ case done, ok := <-in:
23+ if !ok {
24+ return
25+ }
26+ dst := wayland
27+ if done.ID&ports.CaptureIDInternal != 0 {
28+ if internal == nil {
29+ log.Debug().Uint64("id", done.ID).Msg("internal capture reply dropped: no consumer")
30+ continue
31+ }
32+ dst = internal
33+ }
34+ select {
35+ case dst <- done:
36+ case <-ctx.Done():
37+ return
38+ }
39+ }
40+ }
41+}
@@ -0,0 +1,41 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// routeCaptured demultiplexes capture replies from the outputs: replies whose
11+// ID carries ports.CaptureIDInternal go to internal, all others to wayland.
12+// Both sends give way to ctx. A nil internal means no internal consumer is
13+// wired: internal replies are dropped (logged at debug) instead of blocking
14+// forever, since nothing would ever receive them. It returns when ctx is done
15+// or in is closed.
16+func routeCaptured(ctx context.Context, in <-chan ports.CaptureDone, wayland, internal chan<- ports.CaptureDone) {
17+ log := logging.For(ctx, "app")
18+ for {
19+ select {
20+ case <-ctx.Done():
21+ return
22+ case done, ok := <-in:
23+ if !ok {
24+ return
25+ }
26+ dst := wayland
27+ if done.ID&ports.CaptureIDInternal != 0 {
28+ if internal == nil {
29+ log.Debug().Uint64("id", done.ID).Msg("internal capture reply dropped: no consumer")
30+ continue
31+ }
32+ dst = internal
33+ }
34+ select {
35+ case dst <- done:
36+ case <-ctx.Done():
37+ return
38+ }
39+ }
40+ }
41+}
A internal/app/captured_test.go +72 -0
@@ -0,0 +1,72 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+func TestCapturedDemux(t *testing.T) {
14+ ctx, cancel := context.WithCancel(context.Background())
15+ defer cancel()
16+ in := make(chan ports.CaptureDone, 4)
17+ wl := make(chan ports.CaptureDone, 4)
18+ internal := make(chan ports.CaptureDone, 4)
19+ exited := make(chan struct{})
20+ go func() { routeCaptured(ctx, in, wl, internal); close(exited) }()
21+
22+ in <- ports.CaptureDone{ID: 7}
23+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 3}
24+ require.Equal(t, uint64(7), recvDone(t, wl).ID)
25+ require.Equal(t, ports.CaptureIDInternal|3, recvDone(t, internal).ID)
26+ require.Empty(t, wl)
27+ require.Empty(t, internal)
28+
29+ cancel()
30+ select {
31+ case <-exited:
32+ case <-time.After(2 * time.Second):
33+ t.Fatal("routeCaptured did not return on cancel")
34+ }
35+}
36+
37+func TestCapturedDemuxCancelWhileBlocked(t *testing.T) {
38+ ctx, cancel := context.WithCancel(context.Background())
39+ in := make(chan ports.CaptureDone, 1)
40+ wl := make(chan ports.CaptureDone) // never read
41+ exited := make(chan struct{})
42+ go func() { routeCaptured(ctx, in, wl, nil); close(exited) }()
43+ in <- ports.CaptureDone{ID: 1}
44+ cancel()
45+ select {
46+ case <-exited:
47+ case <-time.After(2 * time.Second):
48+ t.Fatal("routeCaptured blocked on a full destination after cancel")
49+ }
50+}
51+
52+func TestCapturedDemuxDropsInternalWithoutConsumer(t *testing.T) {
53+ ctx, cancel := context.WithCancel(context.Background())
54+ defer cancel()
55+ in := make(chan ports.CaptureDone, 2)
56+ wl := make(chan ports.CaptureDone, 2)
57+ go routeCaptured(ctx, in, wl, nil)
58+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 1}
59+ in <- ports.CaptureDone{ID: 2}
60+ require.Equal(t, uint64(2), recvDone(t, wl).ID)
61+}
62+
63+func recvDone(t *testing.T, ch <-chan ports.CaptureDone) ports.CaptureDone {
64+ t.Helper()
65+ select {
66+ case d := <-ch:
67+ return d
68+ case <-time.After(2 * time.Second):
69+ t.Fatal("no capture reply")
70+ return ports.CaptureDone{}
71+ }
72+}
@@ -0,0 +1,72 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+func TestCapturedDemux(t *testing.T) {
14+ ctx, cancel := context.WithCancel(context.Background())
15+ defer cancel()
16+ in := make(chan ports.CaptureDone, 4)
17+ wl := make(chan ports.CaptureDone, 4)
18+ internal := make(chan ports.CaptureDone, 4)
19+ exited := make(chan struct{})
20+ go func() { routeCaptured(ctx, in, wl, internal); close(exited) }()
21+
22+ in <- ports.CaptureDone{ID: 7}
23+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 3}
24+ require.Equal(t, uint64(7), recvDone(t, wl).ID)
25+ require.Equal(t, ports.CaptureIDInternal|3, recvDone(t, internal).ID)
26+ require.Empty(t, wl)
27+ require.Empty(t, internal)
28+
29+ cancel()
30+ select {
31+ case <-exited:
32+ case <-time.After(2 * time.Second):
33+ t.Fatal("routeCaptured did not return on cancel")
34+ }
35+}
36+
37+func TestCapturedDemuxCancelWhileBlocked(t *testing.T) {
38+ ctx, cancel := context.WithCancel(context.Background())
39+ in := make(chan ports.CaptureDone, 1)
40+ wl := make(chan ports.CaptureDone) // never read
41+ exited := make(chan struct{})
42+ go func() { routeCaptured(ctx, in, wl, nil); close(exited) }()
43+ in <- ports.CaptureDone{ID: 1}
44+ cancel()
45+ select {
46+ case <-exited:
47+ case <-time.After(2 * time.Second):
48+ t.Fatal("routeCaptured blocked on a full destination after cancel")
49+ }
50+}
51+
52+func TestCapturedDemuxDropsInternalWithoutConsumer(t *testing.T) {
53+ ctx, cancel := context.WithCancel(context.Background())
54+ defer cancel()
55+ in := make(chan ports.CaptureDone, 2)
56+ wl := make(chan ports.CaptureDone, 2)
57+ go routeCaptured(ctx, in, wl, nil)
58+ in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 1}
59+ in <- ports.CaptureDone{ID: 2}
60+ require.Equal(t, uint64(2), recvDone(t, wl).ID)
61+}
62+
63+func recvDone(t *testing.T, ch <-chan ports.CaptureDone) ports.CaptureDone {
64+ t.Helper()
65+ select {
66+ case d := <-ch:
67+ return d
68+ case <-time.After(2 * time.Second):
69+ t.Fatal("no capture reply")
70+ return ports.CaptureDone{}
71+ }
72+}
A internal/app/hintdebug.go +31 -0
@@ -0,0 +1,31 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/neferwl/internal/adapters/hints"
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+)
11+
12+// hintOptions reads NEFERWL_HINT_DEBUG once. When it is "1", scanned frames
13+// are written under $XDG_STATE_HOME/neferwl/hints.
14+func hintOptions(ctx context.Context) hints.Options {
15+ if os.Getenv("NEFERWL_HINT_DEBUG") != "1" {
16+ return hints.Options{}
17+ }
18+ log := logging.For(ctx, "hints")
19+ state := os.Getenv("XDG_STATE_HOME")
20+ if state == "" {
21+ home, err := os.UserHomeDir()
22+ if err != nil {
23+ log.Warn().Err(err).Msg("hint debug dumps disabled")
24+ return hints.Options{}
25+ }
26+ state = filepath.Join(home, ".local", "state")
27+ }
28+ dir := filepath.Join(state, "neferwl", "hints")
29+ log.Warn().Str("dir", dir).Msg("hint debug dumps enabled")
30+ return hints.Options{DumpDir: dir}
31+}
@@ -0,0 +1,31 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/neferwl/internal/adapters/hints"
9+ "github.com/bnema/neferwl/internal/adapters/logging"
10+)
11+
12+// hintOptions reads NEFERWL_HINT_DEBUG once. When it is "1", scanned frames
13+// are written under $XDG_STATE_HOME/neferwl/hints.
14+func hintOptions(ctx context.Context) hints.Options {
15+ if os.Getenv("NEFERWL_HINT_DEBUG") != "1" {
16+ return hints.Options{}
17+ }
18+ log := logging.For(ctx, "hints")
19+ state := os.Getenv("XDG_STATE_HOME")
20+ if state == "" {
21+ home, err := os.UserHomeDir()
22+ if err != nil {
23+ log.Warn().Err(err).Msg("hint debug dumps disabled")
24+ return hints.Options{}
25+ }
26+ state = filepath.Join(home, ".local", "state")
27+ }
28+ dir := filepath.Join(state, "neferwl", "hints")
29+ log.Warn().Str("dir", dir).Msg("hint debug dumps enabled")
30+ return hints.Options{DumpDir: dir}
31+}
M internal/app/session.go +11 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/neferwl/internal/adapters/config"
1515 "github.com/bnema/neferwl/internal/adapters/drm"
1616 "github.com/bnema/neferwl/internal/adapters/headlessinput"
17+ "github.com/bnema/neferwl/internal/adapters/hints"
1718 "github.com/bnema/neferwl/internal/adapters/launcher"
1819 "github.com/bnema/neferwl/internal/adapters/libinput"
1920 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -51,6 +52,7 @@ type session struct {
5152 km *xkb.Keymap
5253 core *core.Core
5354 dmabuf ports.DMABufSupport
55+ hintOpts hints.Options
5456 wsIDs *workspaceid.IDs
5557 captureAllow *captureallow.Store
5658 cast *screencast.Service // nil without the D-Bus services
@@ -100,6 +102,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
100102 return s, err
101103 }
102104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105+ s.hintOpts = hintOptions(ctx)
103106 // Every output renders on the same GPU: its formats are the clients'.
104107 s.dmabuf = vulkan.Probe()
105108 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -340,6 +343,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
340343 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
341344 }
342345 }()
346+ workers.Add(1)
347+ go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()
348+ workers.Add(1)
349+ go func() {
350+ defer workers.Done()
351+ // Run only returns when ctx is done: nothing to report.
352+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
353+ }()
343354 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344355 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345356 workers.Add(1)
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/neferwl/internal/adapters/config"14 "github.com/bnema/neferwl/internal/adapters/config"
15 "github.com/bnema/neferwl/internal/adapters/drm"15 "github.com/bnema/neferwl/internal/adapters/drm"
16 "github.com/bnema/neferwl/internal/adapters/headlessinput"16 "github.com/bnema/neferwl/internal/adapters/headlessinput"
17+ "github.com/bnema/neferwl/internal/adapters/hints"
17 "github.com/bnema/neferwl/internal/adapters/launcher"18 "github.com/bnema/neferwl/internal/adapters/launcher"
18 "github.com/bnema/neferwl/internal/adapters/libinput"19 "github.com/bnema/neferwl/internal/adapters/libinput"
19 "github.com/bnema/neferwl/internal/adapters/logging"20 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -51,6 +52,7 @@ type session struct {
51 km *xkb.Keymap52 km *xkb.Keymap
52 core *core.Core53 core *core.Core
53 dmabuf ports.DMABufSupport54 dmabuf ports.DMABufSupport
55+ hintOpts hints.Options
54 wsIDs *workspaceid.IDs56 wsIDs *workspaceid.IDs
55 captureAllow *captureallow.Store57 captureAllow *captureallow.Store
56 cast *screencast.Service // nil without the D-Bus services58 cast *screencast.Service // nil without the D-Bus services
@@ -100,6 +102,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
100 return s, err102 return s, err
101 }103 }
102 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105+ s.hintOpts = hintOptions(ctx)
103 // Every output renders on the same GPU: its formats are the clients'.106 // Every output renders on the same GPU: its formats are the clients'.
104 s.dmabuf = vulkan.Probe()107 s.dmabuf = vulkan.Probe()
105 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")108 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -340,6 +343,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
340 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")343 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
341 }344 }
342 }()345 }()
346+ workers.Add(1)
347+ go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()
348+ workers.Add(1)
349+ go func() {
350+ defer workers.Done()
351+ // Run only returns when ctx is done: nothing to report.
352+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
353+ }()
343 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()354 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344 go func() { defer workers.Done(); done <- c.Run(ctx) }()355 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345 workers.Add(1)356 workers.Add(1)
M internal/app/wiring.go +27 -6
@@ -46,10 +46,20 @@ type pipes struct {
4646 // move running slides.
4747 frames chan ports.OutputFrame
4848
49+ // Hint mode: core asks the hints adapter for a scan and gets the result.
50+ hintScans chan ports.HintScanRequest
51+ hintResults chan ports.HintScanResult
52+
4953 captures chan ports.CaptureRequest
50- // Every admitted capture owns one reply slot until wayland consumes it.
51- // Keep this capacity tied to admission so output routing never waits.
52- captured chan ports.CaptureDone
54+ // Every admitted capture owns one reply slot until routeCaptured takes
55+ // it. Keep this capacity tied to admission so output routing never waits.
56+ // The outputs reply on capturedAll; routeCaptured splits the replies by
57+ // ports.CaptureIDInternal into captured (the wayland consumer) and
58+ // hintCaptured (internal consumers).
59+ capturedAll chan ports.CaptureDone
60+ captured chan ports.CaptureDone
61+ // hintCaptured carries internal capture replies to the hints adapter.
62+ hintCaptured chan ports.CaptureDone
5363
5464 outputFormats chan ports.OutputFormats
5565 outputHeads chan ports.OutputHeads
@@ -97,8 +107,13 @@ func newPipes(realSession bool) *pipes {
97107
98108 frames: make(chan ports.OutputFrame, 8),
99109
100- captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
101- captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
110+ hintScans: make(chan ports.HintScanRequest, 1),
111+ hintResults: make(chan ports.HintScanResult, 1),
112+
113+ captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
114+ capturedAll: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
115+ captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
116+ hintCaptured: make(chan ports.CaptureDone, 1),
102117
103118 outputFormats: make(chan ports.OutputFormats, 8),
104119 outputHeads: make(chan ports.OutputHeads, 8),
@@ -136,9 +151,15 @@ func (p *pipes) core() core.Channels {
136151 ConfigErrors: p.configErrors,
137152 Scales: p.scales,
138153 Frames: p.frames,
154+ HintScans: p.hintScans,
155+ Hints: p.hintResults,
139156 }
140157 }
141158
159+// internalCaptured is the channel internal capture replies are routed to: the
160+// hints adapter reads it.
161+func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured }
162+
142163 // wayland is the wayland server's side.
143164 func (p *pipes) wayland() wayland.Channels {
144165 return wayland.Channels{
@@ -175,7 +196,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta
175196 content: content,
176197 frames: p.frames,
177198 captures: p.captures,
178- captured: p.captured,
199+ captured: p.capturedAll,
179200 formats: p.outputFormats,
180201 heads: p.outputHeads,
181202 leaseRequests: p.leaseRequests,
@@ -46,10 +46,20 @@ type pipes struct {
46 // move running slides.46 // move running slides.
47 frames chan ports.OutputFrame47 frames chan ports.OutputFrame
48 48
49+ // Hint mode: core asks the hints adapter for a scan and gets the result.
50+ hintScans chan ports.HintScanRequest
51+ hintResults chan ports.HintScanResult
52+
49 captures chan ports.CaptureRequest53 captures chan ports.CaptureRequest
50- // Every admitted capture owns one reply slot until wayland consumes it.54+ // Every admitted capture owns one reply slot until routeCaptured takes
51- // Keep this capacity tied to admission so output routing never waits.55+ // it. Keep this capacity tied to admission so output routing never waits.
52- captured chan ports.CaptureDone56+ // The outputs reply on capturedAll; routeCaptured splits the replies by
57+ // ports.CaptureIDInternal into captured (the wayland consumer) and
58+ // hintCaptured (internal consumers).
59+ capturedAll chan ports.CaptureDone
60+ captured chan ports.CaptureDone
61+ // hintCaptured carries internal capture replies to the hints adapter.
62+ hintCaptured chan ports.CaptureDone
53 63
54 outputFormats chan ports.OutputFormats64 outputFormats chan ports.OutputFormats
55 outputHeads chan ports.OutputHeads65 outputHeads chan ports.OutputHeads
@@ -97,8 +107,13 @@ func newPipes(realSession bool) *pipes {
97 107
98 frames: make(chan ports.OutputFrame, 8),108 frames: make(chan ports.OutputFrame, 8),
99 109
100- captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),110+ hintScans: make(chan ports.HintScanRequest, 1),
101- captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),111+ hintResults: make(chan ports.HintScanResult, 1),
112+
113+ captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
114+ capturedAll: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
115+ captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
116+ hintCaptured: make(chan ports.CaptureDone, 1),
102 117
103 outputFormats: make(chan ports.OutputFormats, 8),118 outputFormats: make(chan ports.OutputFormats, 8),
104 outputHeads: make(chan ports.OutputHeads, 8),119 outputHeads: make(chan ports.OutputHeads, 8),
@@ -136,9 +151,15 @@ func (p *pipes) core() core.Channels {
136 ConfigErrors: p.configErrors,151 ConfigErrors: p.configErrors,
137 Scales: p.scales,152 Scales: p.scales,
138 Frames: p.frames,153 Frames: p.frames,
154+ HintScans: p.hintScans,
155+ Hints: p.hintResults,
139 }156 }
140 }157 }
141 158
159+// internalCaptured is the channel internal capture replies are routed to: the
160+// hints adapter reads it.
161+func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured }
162+
142 // wayland is the wayland server's side.163 // wayland is the wayland server's side.
143 func (p *pipes) wayland() wayland.Channels {164 func (p *pipes) wayland() wayland.Channels {
144 return wayland.Channels{165 return wayland.Channels{
@@ -175,7 +196,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta
175 content: content,196 content: content,
176 frames: p.frames,197 frames: p.frames,
177 captures: p.captures,198 captures: p.captures,
178- captured: p.captured,199+ captured: p.capturedAll,
179 formats: p.outputFormats,200 formats: p.outputFormats,
180 heads: p.outputHeads,201 heads: p.outputHeads,
181 leaseRequests: p.leaseRequests,202 leaseRequests: p.leaseRequests,
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 +24 -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,11 @@ 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
168+ // synthetic is set while a hint click runs through the pointer handlers:
169+ // compositor modifier+button actions (window drag) do not apply to it.
170+ synthetic bool
160171 // switcher is the column switcher in progress: the screen it is on and
161172 // the timer that shows its cards (switcher.go).
162173 switcher struct {
@@ -275,7 +286,7 @@ func (c *Core) apply(cfg ports.Config) error {
275286 }
276287 switch Action(a) {
277288 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:
289+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint:
279290 default:
280291 return fmt.Errorf("invalid action %q", a)
281292 }
@@ -634,6 +645,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
634645 return errSecurityChanged
635646 }
636647 c.syncSecurity()
648+ c.hintValidate()
637649 if c.security.Protected {
638650 return c.publishProtected(ctx)
639651 }
@@ -1206,6 +1218,17 @@ func (c *Core) Run(ctx context.Context) error {
12061218 return nil
12071219 }
12081220 continue
1221+ case res, ok := <-c.ch.Hints:
1222+ if !ok {
1223+ c.ch.Hints = nil
1224+ continue
1225+ }
1226+ if c.securityCheckpoint(ctx) != nil {
1227+ return nil
1228+ }
1229+ if c.hintResult(res) && c.publish(ctx) != nil {
1230+ return nil
1231+ }
12091232 case ev, ok := <-c.ch.Output:
12101233 if !ok {
12111234 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,11 @@ 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
168+ // synthetic is set while a hint click runs through the pointer handlers:
169+ // compositor modifier+button actions (window drag) do not apply to it.
170+ synthetic bool
160 // switcher is the column switcher in progress: the screen it is on and171 // switcher is the column switcher in progress: the screen it is on and
161 // the timer that shows its cards (switcher.go).172 // the timer that shows its cards (switcher.go).
162 switcher struct {173 switcher struct {
@@ -275,7 +286,7 @@ func (c *Core) apply(cfg ports.Config) error {
275 }286 }
276 switch Action(a) {287 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,288 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:289+ ActionMoveWindowUp, ActionMoveWindowDown, ActionMoveWorkspacePrev, ActionMoveWorkspaceNext, ActionToggleFloating, ActionSwitchColumnNext, ActionSwitchColumnPrev, ActionHint:
279 default:290 default:
280 return fmt.Errorf("invalid action %q", a)291 return fmt.Errorf("invalid action %q", a)
281 }292 }
@@ -634,6 +645,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
634 return errSecurityChanged645 return errSecurityChanged
635 }646 }
636 c.syncSecurity()647 c.syncSecurity()
648+ c.hintValidate()
637 if c.security.Protected {649 if c.security.Protected {
638 return c.publishProtected(ctx)650 return c.publishProtected(ctx)
639 }651 }
@@ -1206,6 +1218,17 @@ func (c *Core) Run(ctx context.Context) error {
1206 return nil1218 return nil
1207 }1219 }
1208 continue1220 continue
1221+ case res, ok := <-c.ch.Hints:
1222+ if !ok {
1223+ c.ch.Hints = nil
1224+ continue
1225+ }
1226+ if c.securityCheckpoint(ctx) != nil {
1227+ return nil
1228+ }
1229+ if c.hintResult(res) && c.publish(ctx) != nil {
1230+ return nil
1231+ }
1209 case ev, ok := <-c.ch.Output:1232 case ev, ok := <-c.ch.Output:
1210 if !ok {1233 if !ok {
1211 c.ch.Output = nil1234 c.ch.Output = nil
M internal/core/drag.go +1 -1
@@ -90,7 +90,7 @@ func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
9090 // startDrag starts a cmd + button drag on the window under the pointer.
9191 // It reports whether it did: the press is then not forwarded.
9292 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
93- if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
93+ if c.synthetic || !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
9494 return false, nil
9595 }
9696 if v.Button != btnLeft && v.Button != btnRight {
@@ -90,7 +90,7 @@ func (c *Core) draggable(id WindowID) (*screen, *Workspace, bool) {
90 // startDrag starts a cmd + button drag on the window under the pointer.90 // startDrag starts a cmd + button drag on the window under the pointer.
91 // It reports whether it did: the press is then not forwarded.91 // It reports whether it did: the press is then not forwarded.
92 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {92 func (c *Core) startDrag(ctx context.Context, v ports.PointerButton) (bool, error) {
93- if !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {93+ if c.synthetic || !v.Pressed || c.drag != nil || c.held() != 0 || c.grab != 0 || c.cmdMod == 0 || c.mods&c.cmdMod == 0 {
94 return false, nil94 return false, nil
95 }95 }
96 if v.Button != btnLeft && v.Button != btnRight {96 if v.Button != btnLeft && v.Button != btnRight {
A internal/core/hint.go +250 -0
@@ -0,0 +1,250 @@
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 || c.hintPointerBusy() {
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+// hintPointerBusy reports whether the pointer is in use by something a
72+// synthetic click would disturb: a pointer constraint, a grab, a held
73+// button (swallowed ones included: they stay in c.buttons), a window or data
74+// drag, or the column switcher.
75+func (c *Core) hintPointerBusy() bool {
76+ return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
77+ c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
78+}
79+
80+// hintResult handles a scanner result and reports whether to publish.
81+func (c *Core) hintResult(res ports.HintScanResult) bool {
82+ h := &c.hint
83+ if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
84+ return false
85+ }
86+ if res.Err != nil || c.security.Protected {
87+ c.hintCancel()
88+ return true
89+ }
90+ i := c.screenIndex(h.output)
91+ if i < 0 {
92+ c.hintCancel()
93+ return true
94+ }
95+ sc := c.screens[i]
96+ var kept []hintTarget
97+ for _, t := range res.Targets {
98+ x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
99+ y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
100+ id, _, _ := c.hit(x, y)
101+ if id == 0 {
102+ continue
103+ }
104+ if s, _ := c.screenOf(c.popupRoot(id)); s == nil {
105+ // A layer surface, not a managed window.
106+ continue
107+ }
108+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})
109+ }
110+ if len(kept) == 0 {
111+ c.hintCancel()
112+ return true
113+ }
114+ slices.SortStableFunc(kept, func(a, b hintTarget) int {
115+ return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
116+ })
117+ if len(kept) > hintMaxTargets {
118+ kept = kept[:hintMaxTargets]
119+ }
120+ n := len(hintAlphabet)
121+ for i := range kept {
122+ if len(kept) <= n {
123+ kept[i].label = string(hintAlphabet[i])
124+ } else {
125+ kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
126+ }
127+ }
128+ h.phase, h.targets, h.typed = hintShown, kept, ""
129+ return true
130+}
131+
132+// hintCancel ends hint mode.
133+func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
134+
135+// hintValidate ends hint mode when what it was started on is gone: the
136+// session became protected, the output went, another workspace is on screen
137+// there, or the overview opened. It runs at the start of every publish.
138+func (c *Core) hintValidate() {
139+ h := &c.hint
140+ if h.phase == hintIdle {
141+ return
142+ }
143+ i := c.screenIndex(h.output)
144+ if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
145+ c.hintCancel()
146+ }
147+}
148+
149+// hintKey handles a key while hint mode is active: the key never goes on.
150+// A press is the whole job; releases take the usual swallow path.
151+func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
152+ if c.hint.phase == hintIdle || !key.Pressed {
153+ return false, nil
154+ }
155+ c.pressed[heldKey(key)] = true
156+ h := &c.hint
157+ if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
158+ c.hintCancel()
159+ return true, c.publish(ctx)
160+ }
161+ if keyName(key.Keysym) == "BackSpace" {
162+ if h.typed != "" {
163+ h.typed = h.typed[:len(h.typed)-1]
164+ }
165+ return true, c.publish(ctx)
166+ }
167+ name := key.Base
168+ if name == "" {
169+ name = key.Keysym
170+ }
171+ name = keyName(name)
172+ if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
173+ c.hintCancel()
174+ return true, c.publish(ctx)
175+ }
176+ typed := h.typed + name
177+ var match *hintTarget
178+ any := false
179+ for i := range h.targets {
180+ if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
181+ any = true
182+ if h.targets[i].label == typed {
183+ match = &h.targets[i]
184+ }
185+ }
186+ }
187+ if !any {
188+ return true, nil
189+ }
190+ if match == nil {
191+ h.typed = typed
192+ return true, c.publish(ctx)
193+ }
194+ return true, c.hintClick(ctx, *match)
195+}
196+
197+// hintClick ends hint mode and left-clicks the target's centre, provided the
198+// pointer is free and the same window is still under it. The click is
199+// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
200+// still be down) from turning it into a window drag, without touching c.mods.
201+func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
202+ c.hintCancel()
203+ if err := c.publish(ctx); err != nil {
204+ return err
205+ }
206+ if !c.hintClickable(t) {
207+ return nil
208+ }
209+ c.synthetic = true
210+ defer func() { c.synthetic = false }()
211+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
212+ return err
213+ }
214+ // The motion may have been bent (constraint) or the layout changed:
215+ // press only where the target is.
216+ if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
217+ return nil
218+ }
219+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
220+ return err
221+ }
222+ return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
223+}
224+
225+// hintClickable reports whether a click on t may go out now: unprotected,
226+// pointer free, and the selected window is still under the target's centre.
227+func (c *Core) hintClickable(t hintTarget) bool {
228+ if c.security.Protected || c.hintPointerBusy() {
229+ return false
230+ }
231+ id, _, _ := c.hit(t.x, t.y)
232+ return id == t.window
233+}
234+
235+// hintLabels are the labels to draw on sc: those still matching what was
236+// typed. Nil unless sc is the hint output.
237+func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
238+ h := &c.hint
239+ if h.phase != hintShown || sc.name() != h.output {
240+ return nil
241+ }
242+ var out []ports.HintLabel
243+ for _, t := range h.targets {
244+ if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
245+ continue
246+ }
247+ out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
248+ }
249+ return out
250+}
@@ -0,0 +1,250 @@
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 || c.hintPointerBusy() {
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+// hintPointerBusy reports whether the pointer is in use by something a
72+// synthetic click would disturb: a pointer constraint, a grab, a held
73+// button (swallowed ones included: they stay in c.buttons), a window or data
74+// drag, or the column switcher.
75+func (c *Core) hintPointerBusy() bool {
76+ return c.constraint.Mode != ports.ConstraintNone || c.constrained.Mode != ports.ConstraintNone ||
77+ c.grab != 0 || len(c.buttons) > 0 || c.drag != nil || c.dataDrag != nil || c.switching()
78+}
79+
80+// hintResult handles a scanner result and reports whether to publish.
81+func (c *Core) hintResult(res ports.HintScanResult) bool {
82+ h := &c.hint
83+ if h.phase != hintPending || res.Seq != h.seq || res.Output != h.output {
84+ return false
85+ }
86+ if res.Err != nil || c.security.Protected {
87+ c.hintCancel()
88+ return true
89+ }
90+ i := c.screenIndex(h.output)
91+ if i < 0 {
92+ c.hintCancel()
93+ return true
94+ }
95+ sc := c.screens[i]
96+ var kept []hintTarget
97+ for _, t := range res.Targets {
98+ x := float64(sc.x) + float64(t.Rect.X) + float64(t.Rect.W)/2
99+ y := float64(sc.y) + float64(t.Rect.Y) + float64(t.Rect.H)/2
100+ id, _, _ := c.hit(x, y)
101+ if id == 0 {
102+ continue
103+ }
104+ if s, _ := c.screenOf(c.popupRoot(id)); s == nil {
105+ // A layer surface, not a managed window.
106+ continue
107+ }
108+ kept = append(kept, hintTarget{x: x, y: y, window: id, rect: t.Rect})
109+ }
110+ if len(kept) == 0 {
111+ c.hintCancel()
112+ return true
113+ }
114+ slices.SortStableFunc(kept, func(a, b hintTarget) int {
115+ return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
116+ })
117+ if len(kept) > hintMaxTargets {
118+ kept = kept[:hintMaxTargets]
119+ }
120+ n := len(hintAlphabet)
121+ for i := range kept {
122+ if len(kept) <= n {
123+ kept[i].label = string(hintAlphabet[i])
124+ } else {
125+ kept[i].label = string([]byte{hintAlphabet[i/n], hintAlphabet[i%n]})
126+ }
127+ }
128+ h.phase, h.targets, h.typed = hintShown, kept, ""
129+ return true
130+}
131+
132+// hintCancel ends hint mode.
133+func (c *Core) hintCancel() { c.hint = hintState{seq: c.hint.seq} }
134+
135+// hintValidate ends hint mode when what it was started on is gone: the
136+// session became protected, the output went, another workspace is on screen
137+// there, or the overview opened. It runs at the start of every publish.
138+func (c *Core) hintValidate() {
139+ h := &c.hint
140+ if h.phase == hintIdle {
141+ return
142+ }
143+ i := c.screenIndex(h.output)
144+ if c.security.Protected || i < 0 || c.screens[i].mon.ov.open || c.screens[i].mon.Current() != h.ws || c.cur().mon.ov.open {
145+ c.hintCancel()
146+ }
147+}
148+
149+// hintKey handles a key while hint mode is active: the key never goes on.
150+// A press is the whole job; releases take the usual swallow path.
151+func (c *Core) hintKey(ctx context.Context, key ports.KeyEvent) (bool, error) {
152+ if c.hint.phase == hintIdle || !key.Pressed {
153+ return false, nil
154+ }
155+ c.pressed[heldKey(key)] = true
156+ h := &c.hint
157+ if h.phase == hintPending || keyName(key.Keysym) == "Escape" {
158+ c.hintCancel()
159+ return true, c.publish(ctx)
160+ }
161+ if keyName(key.Keysym) == "BackSpace" {
162+ if h.typed != "" {
163+ h.typed = h.typed[:len(h.typed)-1]
164+ }
165+ return true, c.publish(ctx)
166+ }
167+ name := key.Base
168+ if name == "" {
169+ name = key.Keysym
170+ }
171+ name = keyName(name)
172+ if len(name) != 1 || name[0] < 'a' || name[0] > 'z' {
173+ c.hintCancel()
174+ return true, c.publish(ctx)
175+ }
176+ typed := h.typed + name
177+ var match *hintTarget
178+ any := false
179+ for i := range h.targets {
180+ if len(h.targets[i].label) >= len(typed) && h.targets[i].label[:len(typed)] == typed {
181+ any = true
182+ if h.targets[i].label == typed {
183+ match = &h.targets[i]
184+ }
185+ }
186+ }
187+ if !any {
188+ return true, nil
189+ }
190+ if match == nil {
191+ h.typed = typed
192+ return true, c.publish(ctx)
193+ }
194+ return true, c.hintClick(ctx, *match)
195+}
196+
197+// hintClick ends hint mode and left-clicks the target's centre, provided the
198+// pointer is free and the same window is still under it. The click is
199+// synthetic: c.synthetic keeps the physical modifiers (the bind's Cmd may
200+// still be down) from turning it into a window drag, without touching c.mods.
201+func (c *Core) hintClick(ctx context.Context, t hintTarget) error {
202+ c.hintCancel()
203+ if err := c.publish(ctx); err != nil {
204+ return err
205+ }
206+ if !c.hintClickable(t) {
207+ return nil
208+ }
209+ c.synthetic = true
210+ defer func() { c.synthetic = false }()
211+ if err := c.pointerMotion(ctx, ports.PointerMotion{X: t.x, Y: t.y, Time: c.motionTime}); err != nil {
212+ return err
213+ }
214+ // The motion may have been bent (constraint) or the layout changed:
215+ // press only where the target is.
216+ if c.cursorX != t.x || c.cursorY != t.y || !c.hintClickable(t) {
217+ return nil
218+ }
219+ if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true, Time: c.motionTime}); err != nil {
220+ return err
221+ }
222+ return c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Time: c.motionTime})
223+}
224+
225+// hintClickable reports whether a click on t may go out now: unprotected,
226+// pointer free, and the selected window is still under the target's centre.
227+func (c *Core) hintClickable(t hintTarget) bool {
228+ if c.security.Protected || c.hintPointerBusy() {
229+ return false
230+ }
231+ id, _, _ := c.hit(t.x, t.y)
232+ return id == t.window
233+}
234+
235+// hintLabels are the labels to draw on sc: those still matching what was
236+// typed. Nil unless sc is the hint output.
237+func (c *Core) hintLabels(sc *screen) []ports.HintLabel {
238+ h := &c.hint
239+ if h.phase != hintShown || sc.name() != h.output {
240+ return nil
241+ }
242+ var out []ports.HintLabel
243+ for _, t := range h.targets {
244+ if len(t.label) < len(h.typed) || t.label[:len(h.typed)] != h.typed {
245+ continue
246+ }
247+ out = append(out, ports.HintLabel{Rect: ports.Rect{X: t.rect.X, Y: t.rect.Y}, Text: t.label, Typed: len(h.typed)})
248+ }
249+ return out
250+}
A internal/core/hint_test.go +577 -0
@@ -0,0 +1,577 @@
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", 0) // the user lets go of the whole chord
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+}
427+
428+// buttonsSent drains the commands and counts pointer button commands.
429+func (h *hintHarness) buttonsSent() (presses, releases int) {
430+ time.Sleep(50 * time.Millisecond)
431+ for {
432+ select {
433+ case cmd := <-h.commands:
434+ if b, ok := cmd.(ports.PointerButtonTo); ok {
435+ if b.Pressed {
436+ presses++
437+ } else {
438+ releases++
439+ }
440+ }
441+ default:
442+ return
443+ }
444+ }
445+}
446+
447+// The bind's modifier is still held while the label is typed: the synthetic
448+// click must reach the client, not start a Cmd+left-button window drag.
449+func TestHintClickWithBindModifierHeld(t *testing.T) {
450+ h := newHint(t, core.Options{})
451+ tg := h.inside(0)
452+ h.press("g", ports.ModSuper)
453+ req := receive(t, h.scans)
454+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
455+ h.labelled(1, 0)
456+ h.drain()
457+ h.press("f", ports.ModSuper) // Super is still down
458+ h.labelled(0, 0)
459+ presses, releases := h.buttonsSent()
460+ if presses != 1 || releases != 1 {
461+ t.Fatalf("client got %d presses, %d releases", presses, releases)
462+ }
463+ // No drag is left behind: a later plain motion and a click work as usual.
464+ h.release("f", ports.ModSuper)
465+ h.release("Super_L", 0)
466+ h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
467+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
468+ h.send(ports.PointerButton{Button: 0x110})
469+ if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
470+ t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
471+ }
472+}
473+
474+// constrain gives window 1 a pointer constraint and waits until core applied it.
475+func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
476+ h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
477+ h.client <- ports.WindowMapped{ID: 1} // a publish after the event
478+ h.until(func(ports.Scene) bool { return true })
479+}
480+
481+func TestHintRefusedWithPointerConstraint(t *testing.T) {
482+ for name, c := range map[string]ports.PointerConstraint{
483+ "lock": {Mode: ports.ConstraintLock},
484+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
485+ } {
486+ t.Run(name, func(t *testing.T) {
487+ h := newHint(t, core.Options{})
488+ h.constrain(c.Mode, c.Rect)
489+ h.press("g", ports.ModSuper)
490+ select {
491+ case r := <-h.scans:
492+ t.Fatalf("request under a %s: %+v", name, r)
493+ case <-time.After(100 * time.Millisecond):
494+ }
495+ })
496+ }
497+}
498+
499+// A constraint that appears while the labels are shown (the pointer would be
500+// held or bent away from the target) cancels the click.
501+func TestHintNoClickWhenConstraintAppears(t *testing.T) {
502+ for name, c := range map[string]ports.PointerConstraint{
503+ "lock": {Mode: ports.ConstraintLock},
504+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
505+ } {
506+ t.Run(name, func(t *testing.T) {
507+ h := newHint(t, core.Options{})
508+ h.show(h.inside(0))
509+ h.constrain(c.Mode, c.Rect)
510+ h.drain()
511+ h.press("f", 0)
512+ if p, r := h.buttonsSent(); p+r != 0 {
513+ t.Fatalf("%d presses, %d releases under a %s", p, r, name)
514+ }
515+ })
516+ }
517+}
518+
519+// pressReal sends a real left press over window 1 and waits for the client to
520+// see it: the window then holds the pointer grab.
521+func (h *hintHarness) pressReal() {
522+ h.t.Helper()
523+ h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
524+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
525+ for {
526+ cmd := command(h.t, h.commands)
527+ if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
528+ return
529+ }
530+ }
531+}
532+
533+func TestHintRefusedWhileButtonHeld(t *testing.T) {
534+ h := newHint(t, core.Options{})
535+ h.pressReal()
536+ h.press("g", ports.ModSuper)
537+ select {
538+ case r := <-h.scans:
539+ t.Fatalf("request with a button held: %+v", r)
540+ case <-time.After(100 * time.Millisecond):
541+ }
542+}
543+
544+// A button pressed (and so a grab taken) after the labels appeared: the label
545+// is typed, no synthetic button goes out and the real press stays held.
546+func TestHintNoClickWhileGrabbed(t *testing.T) {
547+ h := newHint(t, core.Options{})
548+ h.show(h.inside(0))
549+ h.pressReal()
550+ h.press("f", 0)
551+ if p, r := h.buttonsSent(); p+r != 0 {
552+ t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
553+ }
554+ // The real release still reaches the client: c.buttons was not touched.
555+ h.send(ports.PointerButton{Button: 0x110})
556+ if _, r := h.buttonsSent(); r != 1 {
557+ t.Fatalf("real release delivered %d times", r)
558+ }
559+}
560+
561+// The window stays mapped but moves away from the target's centre.
562+func TestHintMappedWindowMovedAborts(t *testing.T) {
563+ h := newHint(t, core.Options{})
564+ // A target at the right edge of window 1.
565+ tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
566+ h.show(tg)
567+ h.client <- ports.WindowMapped{ID: 2}
568+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
569+ if s.Windows[0].Rect == h.win {
570+ t.Fatalf("window 1 did not move: %+v", s.Windows)
571+ }
572+ h.drain()
573+ h.press("f", 0)
574+ if p, r := h.buttonsSent(); p+r != 0 {
575+ t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
576+ }
577+}
@@ -0,0 +1,577 @@
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", 0) // the user lets go of the whole chord
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+}
427+
428+// buttonsSent drains the commands and counts pointer button commands.
429+func (h *hintHarness) buttonsSent() (presses, releases int) {
430+ time.Sleep(50 * time.Millisecond)
431+ for {
432+ select {
433+ case cmd := <-h.commands:
434+ if b, ok := cmd.(ports.PointerButtonTo); ok {
435+ if b.Pressed {
436+ presses++
437+ } else {
438+ releases++
439+ }
440+ }
441+ default:
442+ return
443+ }
444+ }
445+}
446+
447+// The bind's modifier is still held while the label is typed: the synthetic
448+// click must reach the client, not start a Cmd+left-button window drag.
449+func TestHintClickWithBindModifierHeld(t *testing.T) {
450+ h := newHint(t, core.Options{})
451+ tg := h.inside(0)
452+ h.press("g", ports.ModSuper)
453+ req := receive(t, h.scans)
454+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: []ports.HintTarget{tg}}
455+ h.labelled(1, 0)
456+ h.drain()
457+ h.press("f", ports.ModSuper) // Super is still down
458+ h.labelled(0, 0)
459+ presses, releases := h.buttonsSent()
460+ if presses != 1 || releases != 1 {
461+ t.Fatalf("client got %d presses, %d releases", presses, releases)
462+ }
463+ // No drag is left behind: a later plain motion and a click work as usual.
464+ h.release("f", ports.ModSuper)
465+ h.release("Super_L", 0)
466+ h.send(ports.PointerMotion{X: float64(tg.Rect.X), Y: float64(tg.Rect.Y)})
467+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
468+ h.send(ports.PointerButton{Button: 0x110})
469+ if presses, releases = h.buttonsSent(); presses != 1 || releases != 1 {
470+ t.Fatalf("after: %d presses, %d releases (drag left active?)", presses, releases)
471+ }
472+}
473+
474+// constrain gives window 1 a pointer constraint and waits until core applied it.
475+func (h *hintHarness) constrain(mode ports.ConstraintMode, r ports.Rect) {
476+ h.client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}
477+ h.client <- ports.WindowMapped{ID: 1} // a publish after the event
478+ h.until(func(ports.Scene) bool { return true })
479+}
480+
481+func TestHintRefusedWithPointerConstraint(t *testing.T) {
482+ for name, c := range map[string]ports.PointerConstraint{
483+ "lock": {Mode: ports.ConstraintLock},
484+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
485+ } {
486+ t.Run(name, func(t *testing.T) {
487+ h := newHint(t, core.Options{})
488+ h.constrain(c.Mode, c.Rect)
489+ h.press("g", ports.ModSuper)
490+ select {
491+ case r := <-h.scans:
492+ t.Fatalf("request under a %s: %+v", name, r)
493+ case <-time.After(100 * time.Millisecond):
494+ }
495+ })
496+ }
497+}
498+
499+// A constraint that appears while the labels are shown (the pointer would be
500+// held or bent away from the target) cancels the click.
501+func TestHintNoClickWhenConstraintAppears(t *testing.T) {
502+ for name, c := range map[string]ports.PointerConstraint{
503+ "lock": {Mode: ports.ConstraintLock},
504+ "confine": {Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 0, Y: 0, W: 5, H: 5}},
505+ } {
506+ t.Run(name, func(t *testing.T) {
507+ h := newHint(t, core.Options{})
508+ h.show(h.inside(0))
509+ h.constrain(c.Mode, c.Rect)
510+ h.drain()
511+ h.press("f", 0)
512+ if p, r := h.buttonsSent(); p+r != 0 {
513+ t.Fatalf("%d presses, %d releases under a %s", p, r, name)
514+ }
515+ })
516+ }
517+}
518+
519+// pressReal sends a real left press over window 1 and waits for the client to
520+// see it: the window then holds the pointer grab.
521+func (h *hintHarness) pressReal() {
522+ h.t.Helper()
523+ h.send(ports.PointerMotion{X: float64(h.win.X + 1), Y: float64(h.win.Y + 1)})
524+ h.send(ports.PointerButton{Button: 0x110, Pressed: true})
525+ for {
526+ cmd := command(h.t, h.commands)
527+ if b, ok := cmd.(ports.PointerButtonTo); ok && b.Pressed {
528+ return
529+ }
530+ }
531+}
532+
533+func TestHintRefusedWhileButtonHeld(t *testing.T) {
534+ h := newHint(t, core.Options{})
535+ h.pressReal()
536+ h.press("g", ports.ModSuper)
537+ select {
538+ case r := <-h.scans:
539+ t.Fatalf("request with a button held: %+v", r)
540+ case <-time.After(100 * time.Millisecond):
541+ }
542+}
543+
544+// A button pressed (and so a grab taken) after the labels appeared: the label
545+// is typed, no synthetic button goes out and the real press stays held.
546+func TestHintNoClickWhileGrabbed(t *testing.T) {
547+ h := newHint(t, core.Options{})
548+ h.show(h.inside(0))
549+ h.pressReal()
550+ h.press("f", 0)
551+ if p, r := h.buttonsSent(); p+r != 0 {
552+ t.Fatalf("synthetic click during a grab: %d presses, %d releases", p, r)
553+ }
554+ // The real release still reaches the client: c.buttons was not touched.
555+ h.send(ports.PointerButton{Button: 0x110})
556+ if _, r := h.buttonsSent(); r != 1 {
557+ t.Fatalf("real release delivered %d times", r)
558+ }
559+}
560+
561+// The window stays mapped but moves away from the target's centre.
562+func TestHintMappedWindowMovedAborts(t *testing.T) {
563+ h := newHint(t, core.Options{})
564+ // A target at the right edge of window 1.
565+ tg := ports.HintTarget{Rect: ports.Rect{X: h.win.X + h.win.W - 50, Y: h.win.Y + 10, W: 40, H: 10}}
566+ h.show(tg)
567+ h.client <- ports.WindowMapped{ID: 2}
568+ s := h.until(func(s ports.Scene) bool { return len(s.Windows) == 2 && len(s.HintLabels) <= 1 })
569+ if s.Windows[0].Rect == h.win {
570+ t.Fatalf("window 1 did not move: %+v", s.Windows)
571+ }
572+ h.drain()
573+ h.press("f", 0)
574+ if p, r := h.buttonsSent(); p+r != 0 {
575+ t.Fatalf("click went to %d presses %d releases after the window moved", p, r)
576+ }
577+}
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/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/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.
A internal/ports/hint_messages.go +49 -0
@@ -0,0 +1,49 @@
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+}
24+
25+// HintTarget is a clickable region found in a frame.
26+type HintTarget struct {
27+ Rect Rect // logical, output-local
28+ Kind HintKind
29+}
30+
31+// HintScanResult answers a HintScanRequest. Core owns Targets.
32+type HintScanResult struct {
33+ Seq uint64
34+ Output string
35+ Targets []HintTarget
36+ Err error
37+}
38+
39+// HintLabel is a label drawn over a hint target.
40+type HintLabel struct {
41+ // Rect is the label box in logical output-local coordinates, placed at
42+ // the target's top-left. W and H may be 0: the renderer sizes the box
43+ // from Text.
44+ Rect Rect
45+ // Text is the full label, lowercase a–z.
46+ Text string
47+ // Typed is the number of leading characters already typed (drawn dimmed).
48+ Typed int
49+}
@@ -0,0 +1,49 @@
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+}
24+
25+// HintTarget is a clickable region found in a frame.
26+type HintTarget struct {
27+ Rect Rect // logical, output-local
28+ Kind HintKind
29+}
30+
31+// HintScanResult answers a HintScanRequest. Core owns Targets.
32+type HintScanResult struct {
33+ Seq uint64
34+ Output string
35+ Targets []HintTarget
36+ Err error
37+}
38+
39+// HintLabel is a label drawn over a hint target.
40+type HintLabel struct {
41+ // Rect is the label box in logical output-local coordinates, placed at
42+ // the target's top-left. W and H may be 0: the renderer sizes the box
43+ // from Text.
44+ Rect Rect
45+ // Text is the full label, lowercase a–z.
46+ Text string
47+ // Typed is the number of leading characters already typed (drawn dimmed).
48+ Typed int
49+}
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 }

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close