detached d7c4662

feat(app): wire hint mode when hint.enable is on

commit
d7c466227b11082346a1ebcb76b03e44713cc977
author
brice · · 2026-10-19 16:03 UTC
committer
brice · · 2026-10-19 17:36 UTC
parent
463ec0a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +311 -9
M .hexcheck.yaml +1 -1
@@ -25,7 +25,7 @@ allow:
2525 ruleSettings:
2626 missing-generated-mock-for-port:
2727 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
28- excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go]
28+ excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go, internal/ports/hint_messages.go]
2929 suspicious-business-logic-in-adapter:
3030 # app is the composition root; starting core and wiring channels is its job.
3131 excludePaths: [internal/app/app.go, internal/app/session.go]
@@ -25,7 +25,7 @@ allow:
25 ruleSettings:25 ruleSettings:
26 missing-generated-mock-for-port:26 missing-generated-mock-for-port:
27 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).27 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
28- excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go]28+ excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go, internal/ports/hint_messages.go]
29 suspicious-business-logic-in-adapter:29 suspicious-business-logic-in-adapter:
30 # app is the composition root; starting core and wiring channels is its job.30 # app is the composition root; starting core and wiring channels is its job.
31 excludePaths: [internal/app/app.go, internal/app/session.go]31 excludePaths: [internal/app/app.go, internal/app/session.go]
M .mockery.yml +19 -0
@@ -111,6 +111,25 @@ packages:
111111 dir: internal/adapters/statefile
112112 pkgname: statefile
113113 filename: proc_tree_mock_test.go
114+ github.com/bnema/neferwl/internal/adapters/hints:
115+ interfaces:
116+ targetSource:
117+ config:
118+ dir: internal/adapters/hints
119+ pkgname: hints
120+ filename: target_source_mock_test.go
121+ github.com/bnema/neferwl/internal/adapters/atspi:
122+ interfaces:
123+ transport:
124+ config:
125+ dir: internal/adapters/atspi
126+ pkgname: atspi
127+ filename: transport_mock_test.go
128+ connector:
129+ config:
130+ dir: internal/adapters/atspi
131+ pkgname: atspi
132+ filename: connector_mock_test.go
114133 github.com/bnema/neferwl/internal/adapters/screencast:
115134 interfaces:
116135 bus:
@@ -111,6 +111,25 @@ packages:
111 dir: internal/adapters/statefile111 dir: internal/adapters/statefile
112 pkgname: statefile112 pkgname: statefile
113 filename: proc_tree_mock_test.go113 filename: proc_tree_mock_test.go
114+ github.com/bnema/neferwl/internal/adapters/hints:
115+ interfaces:
116+ targetSource:
117+ config:
118+ dir: internal/adapters/hints
119+ pkgname: hints
120+ filename: target_source_mock_test.go
121+ github.com/bnema/neferwl/internal/adapters/atspi:
122+ interfaces:
123+ transport:
124+ config:
125+ dir: internal/adapters/atspi
126+ pkgname: atspi
127+ filename: transport_mock_test.go
128+ connector:
129+ config:
130+ dir: internal/adapters/atspi
131+ pkgname: atspi
132+ filename: connector_mock_test.go
114 github.com/bnema/neferwl/internal/adapters/screencast:133 github.com/bnema/neferwl/internal/adapters/screencast:
115 interfaces:134 interfaces:
116 bus:135 bus:
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
M internal/app/app.go +7 -0
@@ -84,6 +84,7 @@ const busRetry = 2 * time.Second
8484 func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
8585 kb := cur.Keyboard
8686 devCfg := cur.InputDevicesConfig
87+ hintOn := cur.Hint.Enable
8788 for {
8889 var ev ports.ConfigChanged
8990 select {
@@ -91,6 +92,12 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
9192 return
9293 case ev = <-in:
9394 }
95+ // hint.enable is read at startup: nothing of hint mode starts or
96+ // stops on a reload.
97+ if ev.Config.Hint.Enable != hintOn {
98+ log.Info().Bool("running", hintOn).Msg("hint.enable changes apply after restart")
99+ ev.Config.Hint.Enable = hintOn
100+ }
94101 next := ev.Config.Keyboard
95102 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
96103 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
@@ -84,6 +84,7 @@ const busRetry = 2 * time.Second
84 func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {84 func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
85 kb := cur.Keyboard85 kb := cur.Keyboard
86 devCfg := cur.InputDevicesConfig86 devCfg := cur.InputDevicesConfig
87+ hintOn := cur.Hint.Enable
87 for {88 for {
88 var ev ports.ConfigChanged89 var ev ports.ConfigChanged
89 select {90 select {
@@ -91,6 +92,12 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
91 return92 return
92 case ev = <-in:93 case ev = <-in:
93 }94 }
95+ // hint.enable is read at startup: nothing of hint mode starts or
96+ // stops on a reload.
97+ if ev.Config.Hint.Enable != hintOn {
98+ log.Info().Bool("running", hintOn).Msg("hint.enable changes apply after restart")
99+ ev.Config.Hint.Enable = hintOn
100+ }
94 next := ev.Config.Keyboard101 next := ev.Config.Keyboard
95 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options102 layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options
96 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay103 repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay
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 +51 -0
@@ -0,0 +1,51 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/neferwl/internal/adapters/atspi"
9+ "github.com/bnema/neferwl/internal/adapters/hints"
10+ "github.com/bnema/neferwl/internal/adapters/logging"
11+)
12+
13+// hintOptions builds the hint worker options: the accessibility source, and,
14+// when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames,
15+// $XDG_STATE_HOME/neferwl/hints. The accessibility bus is only contacted
16+// by the first scan; without it the worker reads windows with OCR only.
17+func hintOptions(ctx context.Context) hints.Options {
18+ opts := hintDump(ctx)
19+ // The source connects on its first scan, and again after a failure. The
20+ // hints worker owns it and closes it when it returns.
21+ src := atspi.New()
22+ opts.Source = src
23+ return opts
24+}
25+
26+// hintDump reads NEFERWL_HINT_DEBUG once.
27+func hintDump(ctx context.Context) hints.Options {
28+ if os.Getenv("NEFERWL_HINT_DEBUG") != "1" {
29+ return hints.Options{}
30+ }
31+ log := logging.For(ctx, "hints")
32+ state := os.Getenv("XDG_STATE_HOME")
33+ if state == "" {
34+ home, err := os.UserHomeDir()
35+ if err != nil {
36+ log.Warn().Err(err).Msg("hint debug dumps disabled")
37+ return hints.Options{}
38+ }
39+ state = filepath.Join(home, ".local", "state")
40+ }
41+ dir := filepath.Join(state, "neferwl", "hints")
42+ log.Warn().Str("dir", dir).Msg("hint debug dumps enabled")
43+ return hints.Options{DumpDir: dir}
44+}
45+
46+// hintDisabledNote returns the once-only notice for a hint bind pressed while
47+// hint mode is off. The logger is made now; nothing else of hint mode exists.
48+func hintDisabledNote(ctx context.Context) func() {
49+ log := logging.For(ctx, "core")
50+ return func() { log.Info().Msg("hint mode is disabled; set hint.enable = on and restart") }
51+}
@@ -0,0 +1,51 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/neferwl/internal/adapters/atspi"
9+ "github.com/bnema/neferwl/internal/adapters/hints"
10+ "github.com/bnema/neferwl/internal/adapters/logging"
11+)
12+
13+// hintOptions builds the hint worker options: the accessibility source, and,
14+// when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames,
15+// $XDG_STATE_HOME/neferwl/hints. The accessibility bus is only contacted
16+// by the first scan; without it the worker reads windows with OCR only.
17+func hintOptions(ctx context.Context) hints.Options {
18+ opts := hintDump(ctx)
19+ // The source connects on its first scan, and again after a failure. The
20+ // hints worker owns it and closes it when it returns.
21+ src := atspi.New()
22+ opts.Source = src
23+ return opts
24+}
25+
26+// hintDump reads NEFERWL_HINT_DEBUG once.
27+func hintDump(ctx context.Context) hints.Options {
28+ if os.Getenv("NEFERWL_HINT_DEBUG") != "1" {
29+ return hints.Options{}
30+ }
31+ log := logging.For(ctx, "hints")
32+ state := os.Getenv("XDG_STATE_HOME")
33+ if state == "" {
34+ home, err := os.UserHomeDir()
35+ if err != nil {
36+ log.Warn().Err(err).Msg("hint debug dumps disabled")
37+ return hints.Options{}
38+ }
39+ state = filepath.Join(home, ".local", "state")
40+ }
41+ dir := filepath.Join(state, "neferwl", "hints")
42+ log.Warn().Str("dir", dir).Msg("hint debug dumps enabled")
43+ return hints.Options{DumpDir: dir}
44+}
45+
46+// hintDisabledNote returns the once-only notice for a hint bind pressed while
47+// hint mode is off. The logger is made now; nothing else of hint mode exists.
48+func hintDisabledNote(ctx context.Context) func() {
49+ log := logging.For(ctx, "core")
50+ return func() { log.Info().Msg("hint mode is disabled; set hint.enable = on and restart") }
51+}
A internal/app/hintoptin_test.go +68 -0
@@ -0,0 +1,68 @@
1+package app
2+
3+import (
4+ "context"
5+ "runtime"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/neferwl/internal/adapters/clock"
12+ "github.com/bnema/neferwl/internal/adapters/config"
13+ "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/adapters/xkb"
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+func TestHintPipesOffByDefault(t *testing.T) {
19+ require.False(t, config.Defaults().Hint.Enable)
20+ off := newPipes(false, false)
21+ require.Nil(t, off.hintScans)
22+ require.Nil(t, off.hintResults)
23+ require.Nil(t, off.hintCaptured)
24+ // No demultiplexing: the outputs reply on the wayland channel itself.
25+ require.Equal(t, off.captured, off.capturedAll)
26+ ch := off.core()
27+ require.Nil(t, ch.HintScans)
28+ require.Nil(t, ch.Hints)
29+ on := newPipes(false, true)
30+ require.NotNil(t, on.hintScans)
31+ require.NotNil(t, on.hintResults)
32+ require.NotNil(t, on.hintCaptured)
33+ require.NotEqual(t, on.captured, on.capturedAll)
34+}
35+
36+func TestHintOffStartsNoHintGoroutine(t *testing.T) {
37+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
38+ ctx, cancel := context.WithCancel(context.Background())
39+ defer cancel()
40+ done := make(chan error, 1)
41+ go func() {
42+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Timeout: 800 * time.Millisecond})
43+ }()
44+ time.Sleep(400 * time.Millisecond)
45+ buf := make([]byte, 1<<20)
46+ dump := string(buf[:runtime.Stack(buf, true)])
47+ require.NotContains(t, dump, "adapters/hints.")
48+ require.NotContains(t, dump, "adapters/atspi.")
49+ require.NotContains(t, dump, "routeCaptured")
50+ select {
51+ case <-done:
52+ case <-time.After(3 * time.Second):
53+ t.Fatal("session did not stop")
54+ }
55+}
56+
57+func TestRelayConfigKeepsStartupHintEnable(t *testing.T) {
58+ ctx, cancel := context.WithCancel(context.Background())
59+ defer cancel()
60+ cur := config.Defaults()
61+ in := make(chan ports.ConfigChanged, 1)
62+ out := make(chan ports.ConfigChanged, 1)
63+ go relayConfig(ctx, cur, in, out, make(chan *xkb.Keymap, 1), make(chan ports.InputDevicesConfig, 1), newCursors(clock.System{}, 0), make(chan ports.ClientCommand, 1), logging.For(ctx, "config"))
64+ next := config.Defaults()
65+ next.Hint.Enable = true
66+ in <- ports.ConfigChanged{Config: next}
67+ require.False(t, (<-out).Config.Hint.Enable)
68+}
@@ -0,0 +1,68 @@
1+package app
2+
3+import (
4+ "context"
5+ "runtime"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/neferwl/internal/adapters/clock"
12+ "github.com/bnema/neferwl/internal/adapters/config"
13+ "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/adapters/xkb"
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+func TestHintPipesOffByDefault(t *testing.T) {
19+ require.False(t, config.Defaults().Hint.Enable)
20+ off := newPipes(false, false)
21+ require.Nil(t, off.hintScans)
22+ require.Nil(t, off.hintResults)
23+ require.Nil(t, off.hintCaptured)
24+ // No demultiplexing: the outputs reply on the wayland channel itself.
25+ require.Equal(t, off.captured, off.capturedAll)
26+ ch := off.core()
27+ require.Nil(t, ch.HintScans)
28+ require.Nil(t, ch.Hints)
29+ on := newPipes(false, true)
30+ require.NotNil(t, on.hintScans)
31+ require.NotNil(t, on.hintResults)
32+ require.NotNil(t, on.hintCaptured)
33+ require.NotEqual(t, on.captured, on.capturedAll)
34+}
35+
36+func TestHintOffStartsNoHintGoroutine(t *testing.T) {
37+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
38+ ctx, cancel := context.WithCancel(context.Background())
39+ defer cancel()
40+ done := make(chan error, 1)
41+ go func() {
42+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Timeout: 800 * time.Millisecond})
43+ }()
44+ time.Sleep(400 * time.Millisecond)
45+ buf := make([]byte, 1<<20)
46+ dump := string(buf[:runtime.Stack(buf, true)])
47+ require.NotContains(t, dump, "adapters/hints.")
48+ require.NotContains(t, dump, "adapters/atspi.")
49+ require.NotContains(t, dump, "routeCaptured")
50+ select {
51+ case <-done:
52+ case <-time.After(3 * time.Second):
53+ t.Fatal("session did not stop")
54+ }
55+}
56+
57+func TestRelayConfigKeepsStartupHintEnable(t *testing.T) {
58+ ctx, cancel := context.WithCancel(context.Background())
59+ defer cancel()
60+ cur := config.Defaults()
61+ in := make(chan ports.ConfigChanged, 1)
62+ out := make(chan ports.ConfigChanged, 1)
63+ go relayConfig(ctx, cur, in, out, make(chan *xkb.Keymap, 1), make(chan ports.InputDevicesConfig, 1), newCursors(clock.System{}, 0), make(chan ports.ClientCommand, 1), logging.For(ctx, "config"))
64+ next := config.Defaults()
65+ next.Hint.Enable = true
66+ in <- ports.ConfigChanged{Config: next}
67+ require.False(t, (<-out).Config.Hint.Enable)
68+}
M internal/app/session.go +17 -2
@@ -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
@@ -90,16 +92,20 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
9092 return s, err
9193 }
9294 }
93- s.p = newPipes(s.hw != nil)
95+ s.p = newPipes(s.hw != nil, opts.Config.Hint.Enable)
9496 // core() carries channels only; the gate, clock and terminal flag are
9597 // explicit core.Options.
96- if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal}); err != nil {
98+ if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal, HintDisabled: hintDisabledNote(ctx)}); err != nil {
9799 return s, err
98100 }
99101 if s.km, err = xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}); err != nil {
100102 return s, err
101103 }
102104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105+ // Hint mode off: no accessibility connection, no debug env, nothing.
106+ if opts.Config.Hint.Enable {
107+ s.hintOpts = hintOptions(ctx)
108+ }
103109 // Every output renders on the same GPU: its formats are the clients'.
104110 s.dmabuf = vulkan.Probe()
105111 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -340,6 +346,15 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
340346 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
341347 }
342348 }()
349+ if p.hintScans != nil {
350+ workers.Add(2)
351+ go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()
352+ go func() {
353+ defer workers.Done()
354+ // Run only returns when ctx is done: nothing to report.
355+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
356+ }()
357+ }
343358 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344359 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345360 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
@@ -90,16 +92,20 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
90 return s, err92 return s, err
91 }93 }
92 }94 }
93- s.p = newPipes(s.hw != nil)95+ s.p = newPipes(s.hw != nil, opts.Config.Hint.Enable)
94 // core() carries channels only; the gate, clock and terminal flag are96 // core() carries channels only; the gate, clock and terminal flag are
95 // explicit core.Options.97 // explicit core.Options.
96- if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal}); err != nil {98+ if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal, HintDisabled: hintDisabledNote(ctx)}); err != nil {
97 return s, err99 return s, err
98 }100 }
99 if s.km, err = xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}); err != nil {101 if s.km, err = xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}); err != nil {
100 return s, err102 return s, err
101 }103 }
102 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105+ // Hint mode off: no accessibility connection, no debug env, nothing.
106+ if opts.Config.Hint.Enable {
107+ s.hintOpts = hintOptions(ctx)
108+ }
103 // Every output renders on the same GPU: its formats are the clients'.109 // Every output renders on the same GPU: its formats are the clients'.
104 s.dmabuf = vulkan.Probe()110 s.dmabuf = vulkan.Probe()
105 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")111 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -340,6 +346,15 @@ 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")346 log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
341 }347 }
342 }()348 }()
349+ if p.hintScans != nil {
350+ workers.Add(2)
351+ go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()
352+ go func() {
353+ defer workers.Done()
354+ // Run only returns when ctx is done: nothing to report.
355+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
356+ }()
357+ }
343 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()358 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
344 go func() { defer workers.Done(); done <- c.Run(ctx) }()359 go func() { defer workers.Done(); done <- c.Run(ctx) }()
345 workers.Add(1)360 workers.Add(1)
M internal/app/wiring.go +34 -5
@@ -46,10 +46,21 @@ 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+ // With hint mode on, the outputs reply on capturedAll and routeCaptured splits the replies by
57+ // ports.CaptureIDInternal into captured (the wayland consumer) and
58+ // hintCaptured (internal consumers). With hint mode off, capturedAll is
59+ // captured itself and there is no routeCaptured.
60+ capturedAll chan ports.CaptureDone
61+ captured chan ports.CaptureDone
62+ // hintCaptured carries internal capture replies to the hints adapter.
63+ hintCaptured chan ports.CaptureDone
5364
5465 outputFormats chan ports.OutputFormats
5566 outputHeads chan ports.OutputHeads
@@ -69,7 +80,10 @@ type pipes struct {
6980 castCommands chan ports.CastCommand
7081 }
7182
72-func newPipes(realSession bool) *pipes {
83+// newPipes makes the channels. The hint channels exist only with hint set
84+// (hint.enable = on): without them core's hint action is a no-op and nothing
85+// of hint mode is allocated.
86+func newPipes(realSession, hint bool) *pipes {
7387 p := &pipes{
7488 securityChanges: make(chan ports.SecurityState, 8),
7589 securityEvents: make(chan ports.SecurityBackendEvent, 64),
@@ -112,6 +126,15 @@ func newPipes(realSession bool) *pipes {
112126
113127 castCommands: make(chan ports.CastCommand, 64),
114128 }
129+ // Without hint mode every reply is for the wayland consumer: the outputs
130+ // reply on its channel directly and nothing demultiplexes.
131+ p.capturedAll = p.captured
132+ if hint {
133+ p.capturedAll = make(chan ports.CaptureDone, ports.MaxCaptureInflight)
134+ p.hintScans = make(chan ports.HintScanRequest, 1)
135+ p.hintResults = make(chan ports.HintScanResult, 1)
136+ p.hintCaptured = make(chan ports.CaptureDone, 1)
137+ }
115138 if realSession {
116139 p.scales = make(chan ports.ScaleChanged, 8)
117140 }
@@ -136,9 +159,15 @@ func (p *pipes) core() core.Channels {
136159 ConfigErrors: p.configErrors,
137160 Scales: p.scales,
138161 Frames: p.frames,
162+ HintScans: p.hintScans,
163+ Hints: p.hintResults,
139164 }
140165 }
141166
167+// internalCaptured is the channel internal capture replies are routed to: the
168+// hints adapter reads it.
169+func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured }
170+
142171 // wayland is the wayland server's side.
143172 func (p *pipes) wayland() wayland.Channels {
144173 return wayland.Channels{
@@ -175,7 +204,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta
175204 content: content,
176205 frames: p.frames,
177206 captures: p.captures,
178- captured: p.captured,
207+ captured: p.capturedAll,
179208 formats: p.outputFormats,
180209 heads: p.outputHeads,
181210 leaseRequests: p.leaseRequests,
@@ -46,10 +46,21 @@ 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+ // With hint mode on, the outputs reply on capturedAll and routeCaptured splits the replies by
57+ // ports.CaptureIDInternal into captured (the wayland consumer) and
58+ // hintCaptured (internal consumers). With hint mode off, capturedAll is
59+ // captured itself and there is no routeCaptured.
60+ capturedAll chan ports.CaptureDone
61+ captured chan ports.CaptureDone
62+ // hintCaptured carries internal capture replies to the hints adapter.
63+ hintCaptured chan ports.CaptureDone
53 64
54 outputFormats chan ports.OutputFormats65 outputFormats chan ports.OutputFormats
55 outputHeads chan ports.OutputHeads66 outputHeads chan ports.OutputHeads
@@ -69,7 +80,10 @@ type pipes struct {
69 castCommands chan ports.CastCommand80 castCommands chan ports.CastCommand
70 }81 }
71 82
72-func newPipes(realSession bool) *pipes {83+// newPipes makes the channels. The hint channels exist only with hint set
84+// (hint.enable = on): without them core's hint action is a no-op and nothing
85+// of hint mode is allocated.
86+func newPipes(realSession, hint bool) *pipes {
73 p := &pipes{87 p := &pipes{
74 securityChanges: make(chan ports.SecurityState, 8),88 securityChanges: make(chan ports.SecurityState, 8),
75 securityEvents: make(chan ports.SecurityBackendEvent, 64),89 securityEvents: make(chan ports.SecurityBackendEvent, 64),
@@ -112,6 +126,15 @@ func newPipes(realSession bool) *pipes {
112 126
113 castCommands: make(chan ports.CastCommand, 64),127 castCommands: make(chan ports.CastCommand, 64),
114 }128 }
129+ // Without hint mode every reply is for the wayland consumer: the outputs
130+ // reply on its channel directly and nothing demultiplexes.
131+ p.capturedAll = p.captured
132+ if hint {
133+ p.capturedAll = make(chan ports.CaptureDone, ports.MaxCaptureInflight)
134+ p.hintScans = make(chan ports.HintScanRequest, 1)
135+ p.hintResults = make(chan ports.HintScanResult, 1)
136+ p.hintCaptured = make(chan ports.CaptureDone, 1)
137+ }
115 if realSession {138 if realSession {
116 p.scales = make(chan ports.ScaleChanged, 8)139 p.scales = make(chan ports.ScaleChanged, 8)
117 }140 }
@@ -136,9 +159,15 @@ func (p *pipes) core() core.Channels {
136 ConfigErrors: p.configErrors,159 ConfigErrors: p.configErrors,
137 Scales: p.scales,160 Scales: p.scales,
138 Frames: p.frames,161 Frames: p.frames,
162+ HintScans: p.hintScans,
163+ Hints: p.hintResults,
139 }164 }
140 }165 }
141 166
167+// internalCaptured is the channel internal capture replies are routed to: the
168+// hints adapter reads it.
169+func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured }
170+
142 // wayland is the wayland server's side.171 // wayland is the wayland server's side.
143 func (p *pipes) wayland() wayland.Channels {172 func (p *pipes) wayland() wayland.Channels {
144 return wayland.Channels{173 return wayland.Channels{
@@ -175,7 +204,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta
175 content: content,204 content: content,
176 frames: p.frames,205 frames: p.frames,
177 captures: p.captures,206 captures: p.captures,
178- captured: p.captured,207+ captured: p.capturedAll,
179 formats: p.outputFormats,208 formats: p.outputFormats,
180 heads: p.outputHeads,209 heads: p.outputHeads,
181 leaseRequests: p.leaseRequests,210 leaseRequests: p.leaseRequests,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close