detached 9b0b5e5

perf(app): start nothing for hint mode when it is disabled

commit
9b0b5e5716815f46761d2f7745bbf7ada11d2f80
author
brice · · 2026-10-19 14:57 UTC
parent
e757cab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +148 -19
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
M internal/app/hintdebug.go +7 -0
@@ -46,3 +46,10 @@ func hintDump(ctx context.Context) hints.Options {
4646 log.Warn().Str("dir", dir).Msg("hint debug dumps enabled")
4747 return hints.Options{DumpDir: dir}
4848 }
49+
50+// hintDisabledNote returns the once-only notice for a hint bind pressed while
51+// hint mode is off. The logger is made now; nothing else of hint mode exists.
52+func hintDisabledNote(ctx context.Context) func() {
53+ log := logging.For(ctx, "core")
54+ return func() { log.Info().Msg("hint mode is disabled; set hint.enable = on and restart") }
55+}
@@ -46,3 +46,10 @@ func hintDump(ctx context.Context) hints.Options {
46 log.Warn().Str("dir", dir).Msg("hint debug dumps enabled")46 log.Warn().Str("dir", dir).Msg("hint debug dumps enabled")
47 return hints.Options{DumpDir: dir}47 return hints.Options{DumpDir: dir}
48 }48 }
49+
50+// hintDisabledNote returns the once-only notice for a hint bind pressed while
51+// hint mode is off. The logger is made now; nothing else of hint mode exists.
52+func hintDisabledNote(ctx context.Context) func() {
53+ log := logging.For(ctx, "core")
54+ return func() { log.Info().Msg("hint mode is disabled; set hint.enable = on and restart") }
55+}
A internal/app/hintoptin_test.go +64 -0
@@ -0,0 +1,64 @@
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+ ch := off.core()
25+ require.Nil(t, ch.HintScans)
26+ require.Nil(t, ch.Hints)
27+ on := newPipes(false, true)
28+ require.NotNil(t, on.hintScans)
29+ require.NotNil(t, on.hintResults)
30+ require.NotNil(t, on.hintCaptured)
31+}
32+
33+func TestHintOffStartsNoHintGoroutine(t *testing.T) {
34+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
35+ ctx, cancel := context.WithCancel(context.Background())
36+ defer cancel()
37+ done := make(chan error, 1)
38+ go func() {
39+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Timeout: 800 * time.Millisecond})
40+ }()
41+ time.Sleep(400 * time.Millisecond)
42+ buf := make([]byte, 1<<20)
43+ dump := string(buf[:runtime.Stack(buf, true)])
44+ require.NotContains(t, dump, "adapters/hints.")
45+ require.NotContains(t, dump, "adapters/atspi.")
46+ select {
47+ case <-done:
48+ case <-time.After(3 * time.Second):
49+ t.Fatal("session did not stop")
50+ }
51+}
52+
53+func TestRelayConfigKeepsStartupHintEnable(t *testing.T) {
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ cur := config.Defaults()
57+ in := make(chan ports.ConfigChanged, 1)
58+ out := make(chan ports.ConfigChanged, 1)
59+ 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"))
60+ next := config.Defaults()
61+ next.Hint.Enable = true
62+ in <- ports.ConfigChanged{Config: next}
63+ require.False(t, (<-out).Config.Hint.Enable)
64+}
@@ -0,0 +1,64 @@
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+ ch := off.core()
25+ require.Nil(t, ch.HintScans)
26+ require.Nil(t, ch.Hints)
27+ on := newPipes(false, true)
28+ require.NotNil(t, on.hintScans)
29+ require.NotNil(t, on.hintResults)
30+ require.NotNil(t, on.hintCaptured)
31+}
32+
33+func TestHintOffStartsNoHintGoroutine(t *testing.T) {
34+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
35+ ctx, cancel := context.WithCancel(context.Background())
36+ defer cancel()
37+ done := make(chan error, 1)
38+ go func() {
39+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Timeout: 800 * time.Millisecond})
40+ }()
41+ time.Sleep(400 * time.Millisecond)
42+ buf := make([]byte, 1<<20)
43+ dump := string(buf[:runtime.Stack(buf, true)])
44+ require.NotContains(t, dump, "adapters/hints.")
45+ require.NotContains(t, dump, "adapters/atspi.")
46+ select {
47+ case <-done:
48+ case <-time.After(3 * time.Second):
49+ t.Fatal("session did not stop")
50+ }
51+}
52+
53+func TestRelayConfigKeepsStartupHintEnable(t *testing.T) {
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ cur := config.Defaults()
57+ in := make(chan ports.ConfigChanged, 1)
58+ out := make(chan ports.ConfigChanged, 1)
59+ 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"))
60+ next := config.Defaults()
61+ next.Hint.Enable = true
62+ in <- ports.ConfigChanged{Config: next}
63+ require.False(t, (<-out).Config.Hint.Enable)
64+}
M internal/app/session.go +14 -9
@@ -92,17 +92,20 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
9292 return s, err
9393 }
9494 }
95- s.p = newPipes(s.hw != nil)
95+ s.p = newPipes(s.hw != nil, opts.Config.Hint.Enable)
9696 // core() carries channels only; the gate, clock and terminal flag are
9797 // explicit core.Options.
98- 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 {
9999 return s, err
100100 }
101101 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 {
102102 return s, err
103103 }
104104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105- s.hintOpts = hintOptions(ctx)
105+ // Hint mode off: no accessibility connection, no debug env, nothing.
106+ if opts.Config.Hint.Enable {
107+ s.hintOpts = hintOptions(ctx)
108+ }
106109 // Every output renders on the same GPU: its formats are the clients'.
107110 s.dmabuf = vulkan.Probe()
108111 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -345,12 +348,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
345348 }()
346349 workers.Add(1)
347350 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- }()
351+ if p.hintScans != nil {
352+ workers.Add(1)
353+ go func() {
354+ defer workers.Done()
355+ // Run only returns when ctx is done: nothing to report.
356+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
357+ }()
358+ }
354359 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
355360 go func() { defer workers.Done(); done <- c.Run(ctx) }()
356361 workers.Add(1)
@@ -92,17 +92,20 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
92 return s, err92 return s, err
93 }93 }
94 }94 }
95- s.p = newPipes(s.hw != nil)95+ s.p = newPipes(s.hw != nil, opts.Config.Hint.Enable)
96 // core() carries channels only; the gate, clock and terminal flag are96 // core() carries channels only; the gate, clock and terminal flag are
97 // explicit core.Options.97 // explicit core.Options.
98- 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 {
99 return s, err99 return s, err
100 }100 }
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 {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 {
102 return s, err102 return s, err
103 }103 }
104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105- s.hintOpts = hintOptions(ctx)105+ // Hint mode off: no accessibility connection, no debug env, nothing.
106+ if opts.Config.Hint.Enable {
107+ s.hintOpts = hintOptions(ctx)
108+ }
106 // 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'.
107 s.dmabuf = vulkan.Probe()110 s.dmabuf = vulkan.Probe()
108 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")111 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -345,12 +348,14 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
345 }()348 }()
346 workers.Add(1)349 workers.Add(1)
347 go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()350 go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }()
348- workers.Add(1)351+ if p.hintScans != nil {
349- go func() {352+ workers.Add(1)
350- defer workers.Done()353+ go func() {
351- // Run only returns when ctx is done: nothing to report.354+ defer workers.Done()
352- _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)355+ // Run only returns when ctx is done: nothing to report.
353- }()356+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
357+ }()
358+ }
354 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()359 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
355 go func() { defer workers.Done(); done <- c.Run(ctx) }()360 go func() { defer workers.Done(); done <- c.Run(ctx) }()
356 workers.Add(1)361 workers.Add(1)
M internal/app/wiring.go +12 -8
@@ -79,7 +79,10 @@ type pipes struct {
7979 castCommands chan ports.CastCommand
8080 }
8181
82-func newPipes(realSession bool) *pipes {
82+// newPipes makes the channels. The hint channels exist only with hint set
83+// (hint.enable = on): without them core's hint action is a no-op and nothing
84+// of hint mode is allocated.
85+func newPipes(realSession, hint bool) *pipes {
8386 p := &pipes{
8487 securityChanges: make(chan ports.SecurityState, 8),
8588 securityEvents: make(chan ports.SecurityBackendEvent, 64),
@@ -107,13 +110,9 @@ func newPipes(realSession bool) *pipes {
107110
108111 frames: make(chan ports.OutputFrame, 8),
109112
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),
113+ captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
114+ capturedAll: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
115+ captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
117116
118117 outputFormats: make(chan ports.OutputFormats, 8),
119118 outputHeads: make(chan ports.OutputHeads, 8),
@@ -127,6 +126,11 @@ func newPipes(realSession bool) *pipes {
127126
128127 castCommands: make(chan ports.CastCommand, 64),
129128 }
129+ if hint {
130+ p.hintScans = make(chan ports.HintScanRequest, 1)
131+ p.hintResults = make(chan ports.HintScanResult, 1)
132+ p.hintCaptured = make(chan ports.CaptureDone, 1)
133+ }
130134 if realSession {
131135 p.scales = make(chan ports.ScaleChanged, 8)
132136 }
@@ -79,7 +79,10 @@ type pipes struct {
79 castCommands chan ports.CastCommand79 castCommands chan ports.CastCommand
80 }80 }
81 81
82-func newPipes(realSession bool) *pipes {82+// newPipes makes the channels. The hint channels exist only with hint set
83+// (hint.enable = on): without them core's hint action is a no-op and nothing
84+// of hint mode is allocated.
85+func newPipes(realSession, hint bool) *pipes {
83 p := &pipes{86 p := &pipes{
84 securityChanges: make(chan ports.SecurityState, 8),87 securityChanges: make(chan ports.SecurityState, 8),
85 securityEvents: make(chan ports.SecurityBackendEvent, 64),88 securityEvents: make(chan ports.SecurityBackendEvent, 64),
@@ -107,13 +110,9 @@ func newPipes(realSession bool) *pipes {
107 110
108 frames: make(chan ports.OutputFrame, 8),111 frames: make(chan ports.OutputFrame, 8),
109 112
110- hintScans: make(chan ports.HintScanRequest, 1),113+ captures: make(chan ports.CaptureRequest, ports.MaxCaptureInflight),
111- hintResults: make(chan ports.HintScanResult, 1),114+ capturedAll: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
112-115+ captured: make(chan ports.CaptureDone, ports.MaxCaptureInflight),
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),
117 116
118 outputFormats: make(chan ports.OutputFormats, 8),117 outputFormats: make(chan ports.OutputFormats, 8),
119 outputHeads: make(chan ports.OutputHeads, 8),118 outputHeads: make(chan ports.OutputHeads, 8),
@@ -127,6 +126,11 @@ func newPipes(realSession bool) *pipes {
127 126
128 castCommands: make(chan ports.CastCommand, 64),127 castCommands: make(chan ports.CastCommand, 64),
129 }128 }
129+ if hint {
130+ p.hintScans = make(chan ports.HintScanRequest, 1)
131+ p.hintResults = make(chan ports.HintScanResult, 1)
132+ p.hintCaptured = make(chan ports.CaptureDone, 1)
133+ }
130 if realSession {134 if realSession {
131 p.scales = make(chan ports.ScaleChanged, 8)135 p.scales = make(chan ports.ScaleChanged, 8)
132 }136 }
M internal/core/core.go +3 -0
@@ -63,6 +63,9 @@ type Options struct {
6363 Clock ports.Clock
6464 // Terminal enables automatic terminal opening according to terminal.auto-open.
6565 Terminal bool
66+ // HintDisabled, when set, is called the first time the hint action runs
67+ // without a scanner (hint mode off). It must not block.
68+ HintDisabled func()
6669 }
6770
6871 // fullscreenGrace is how long after mapping a window's fullscreen request
@@ -63,6 +63,9 @@ type Options struct {
63 Clock ports.Clock63 Clock ports.Clock
64 // Terminal enables automatic terminal opening according to terminal.auto-open.64 // Terminal enables automatic terminal opening according to terminal.auto-open.
65 Terminal bool65 Terminal bool
66+ // HintDisabled, when set, is called the first time the hint action runs
67+ // without a scanner (hint mode off). It must not block.
68+ HintDisabled func()
66 }69 }
67 70
68 // fullscreenGrace is how long after mapping a window's fullscreen request71 // fullscreenGrace is how long after mapping a window's fullscreen request
M internal/core/hint.go +9 -2
@@ -50,10 +50,17 @@ type hintState struct {
5050 }
5151
5252 // hintRequest starts hint mode on the focused screen. It does nothing
53-// without a scanner, while the session is protected, while hint mode is
53+// (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is
5454 // active, or on an output with a non-normal transform.
5555 func (c *Core) hintRequest() {
56- if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
56+ if c.ch.HintScans == nil {
57+ if c.opts.HintDisabled != nil {
58+ c.opts.HintDisabled()
59+ c.opts.HintDisabled = nil
60+ }
61+ return
62+ }
63+ if c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
5764 return
5865 }
5966 sc := c.cur()
@@ -50,10 +50,17 @@ type hintState struct {
50 }50 }
51 51
52 // hintRequest starts hint mode on the focused screen. It does nothing52 // hintRequest starts hint mode on the focused screen. It does nothing
53-// without a scanner, while the session is protected, while hint mode is53+// (reporting once through Options.HintDisabled) without a scanner, while the session is protected, while hint mode is
54 // active, or on an output with a non-normal transform.54 // active, or on an output with a non-normal transform.
55 func (c *Core) hintRequest() {55 func (c *Core) hintRequest() {
56- if c.ch.HintScans == nil || c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {56+ if c.ch.HintScans == nil {
57+ if c.opts.HintDisabled != nil {
58+ c.opts.HintDisabled()
59+ c.opts.HintDisabled = nil
60+ }
61+ return
62+ }
63+ if c.security.Protected || c.hint.phase != hintIdle || c.hintPointerBusy() {
57 return64 return
58 }65 }
59 sc := c.cur()66 sc := c.cur()
M internal/core/hint_test.go +32 -0
@@ -706,3 +706,35 @@ func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) {
706706 t.Fatalf("commands %v under a constraint", got)
707707 }
708708 }
709+
710+func TestHintDisabledNotesOnce(t *testing.T) {
711+ cfg := scrollDefaults()
712+ cfg.Binds["Cmd+g"] = "hint"
713+ var noted atomic.Int32
714+ h := &hintHarness{
715+ input: make(chan ports.InputEvent, 32),
716+ client: make(chan ports.ClientEvent, 32),
717+ output: make(chan ports.OutputEvent, 8),
718+ commands: make(chan ports.ClientCommand, 256),
719+ scenes: make(chan []ports.Scene, 1),
720+ }
721+ ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes}
722+ c, err := core.New(cfg, ch, core.Options{HintDisabled: func() { noted.Add(1) }})
723+ if err != nil {
724+ t.Fatal(err)
725+ }
726+ ctx, cancel := context.WithCancel(context.Background())
727+ done := make(chan error, 1)
728+ go func() { done <- c.Run(ctx) }()
729+ t.Cleanup(func() { cancel(); receive(t, done) })
730+ h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
731+ scene(t, h.scenes)
732+ for range 3 {
733+ h.press("g", ports.ModSuper)
734+ h.release("g", 0)
735+ }
736+ c.Ping(ctx)
737+ if got := noted.Load(); got != 1 {
738+ t.Fatalf("disabled note ran %d times, want 1", got)
739+ }
740+}
@@ -706,3 +706,35 @@ func TestHintTextClickNoModifiersWhenNotClickable(t *testing.T) {
706 t.Fatalf("commands %v under a constraint", got)706 t.Fatalf("commands %v under a constraint", got)
707 }707 }
708 }708 }
709+
710+func TestHintDisabledNotesOnce(t *testing.T) {
711+ cfg := scrollDefaults()
712+ cfg.Binds["Cmd+g"] = "hint"
713+ var noted atomic.Int32
714+ h := &hintHarness{
715+ input: make(chan ports.InputEvent, 32),
716+ client: make(chan ports.ClientEvent, 32),
717+ output: make(chan ports.OutputEvent, 8),
718+ commands: make(chan ports.ClientCommand, 256),
719+ scenes: make(chan []ports.Scene, 1),
720+ }
721+ ch := core.Channels{Client: h.client, Input: h.input, Output: h.output, Commands: h.commands, Scenes: h.scenes}
722+ c, err := core.New(cfg, ch, core.Options{HintDisabled: func() { noted.Add(1) }})
723+ if err != nil {
724+ t.Fatal(err)
725+ }
726+ ctx, cancel := context.WithCancel(context.Background())
727+ done := make(chan error, 1)
728+ go func() { done <- c.Run(ctx) }()
729+ t.Cleanup(func() { cancel(); receive(t, done) })
730+ h.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1000, Height: 800}}
731+ scene(t, h.scenes)
732+ for range 3 {
733+ h.press("g", ports.ModSuper)
734+ h.release("g", 0)
735+ }
736+ c.Ping(ctx)
737+ if got := noted.Load(); got != 1 {
738+ t.Fatalf("disabled note ran %d times, want 1", got)
739+ }
740+}

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 23

Tags 10

↑↓ moveenter openesc close