detached 76522c2

feat(input): log pointer motions only with --debug=input-motion and warn when the input queue backs up

Pointer motions are counted in the input stats instead of logged; the input-motion debug category, never part of all, logs each one. The forwarder queue stays unbounded (no event dropped) and warns once past 4096 queued events and once when it drains.
commit
76522c2902d77107df1f86e325e0d6c2cfca1fdd
author
brice · · 2026-09-26 12:14 UTC
parent
9454687
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +120 -9
M cmd/nefertty/main.go +1 -1
@@ -119,7 +119,7 @@ func run() error {
119119 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
120120 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
121121 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
122- debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
122+ debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
123123 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
124124 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services")
125125 if err := flags.Parse(os.Args[1:]); err != nil {
@@ -119,7 +119,7 @@ func run() error {
119 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")119 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
120 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")120 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
121 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")121 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
122- debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")122+ debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
123 configFlag := flags.String("config", "", "config path (empty uses XDG default)")123 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
124 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services")124 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services")
125 if err := flags.Parse(os.Args[1:]); err != nil {125 if err := flags.Parse(os.Args[1:]); err != nil {
M internal/adapters/libinput/forward.go +19 -1
@@ -6,6 +6,7 @@ import (
66 "time"
77
88 "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/zerowrap"
910 )
1011
1112 // forwarder decouples reading libinput from core. libinput keeps reading
@@ -17,8 +18,15 @@ type forwarder struct {
1718 queue []ports.InputEvent
1819 wake chan struct{}
1920 stats forwardStats
21+ // The queue is unbounded (no event is ever dropped); long is set
22+ // while it is past forwardWarn entries, warned once each way.
23+ log zerowrap.Logger
24+ long bool
2025 }
2126
27+// forwardWarn is the queue length that means core has stalled.
28+const forwardWarn = 4096
29+
2230 // forwardStats is reset by take.
2331 type forwardStats struct {
2432 Motions int // pointer motions read
@@ -27,7 +35,9 @@ type forwardStats struct {
2735 MaxBlock time.Duration // longest wait for core to accept one event
2836 }
2937
30-func newForwarder() *forwarder { return &forwarder{wake: make(chan struct{}, 1)} }
38+func newForwarder(log zerowrap.Logger) *forwarder {
39+ return &forwarder{wake: make(chan struct{}, 1), log: log}
40+}
3141
3242 // push queues ev without blocking.
3343 func (f *forwarder) push(ev ports.InputEvent) {
@@ -49,6 +59,10 @@ func (f *forwarder) push(ev ports.InputEvent) {
4959 }
5060 }
5161 f.queue = append(f.queue, ev)
62+ if len(f.queue) > forwardWarn && !f.long {
63+ f.long = true
64+ f.log.Warn().Str("component", "input").Int("queued", len(f.queue)).Msg("core is not reading input; events queue up")
65+ }
5266 f.mu.Unlock()
5367 select {
5468 case f.wake <- struct{}{}:
@@ -65,6 +79,10 @@ func (f *forwarder) pop() (ports.InputEvent, bool) {
6579 ev := f.queue[0]
6680 f.queue[0] = nil
6781 f.queue = f.queue[1:]
82+ if f.long && len(f.queue) < forwardWarn {
83+ f.long = false
84+ f.log.Warn().Str("component", "input").Int("queued", len(f.queue)).Msg("input queue drained")
85+ }
6886 return ev, true
6987 }
7088
@@ -6,6 +6,7 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/nefertty/internal/ports"8 "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/zerowrap"
9 )10 )
10 11
11 // forwarder decouples reading libinput from core. libinput keeps reading12 // forwarder decouples reading libinput from core. libinput keeps reading
@@ -17,8 +18,15 @@ type forwarder struct {
17 queue []ports.InputEvent18 queue []ports.InputEvent
18 wake chan struct{}19 wake chan struct{}
19 stats forwardStats20 stats forwardStats
21+ // The queue is unbounded (no event is ever dropped); long is set
22+ // while it is past forwardWarn entries, warned once each way.
23+ log zerowrap.Logger
24+ long bool
20 }25 }
21 26
27+// forwardWarn is the queue length that means core has stalled.
28+const forwardWarn = 4096
29+
22 // forwardStats is reset by take.30 // forwardStats is reset by take.
23 type forwardStats struct {31 type forwardStats struct {
24 Motions int // pointer motions read32 Motions int // pointer motions read
@@ -27,7 +35,9 @@ type forwardStats struct {
27 MaxBlock time.Duration // longest wait for core to accept one event35 MaxBlock time.Duration // longest wait for core to accept one event
28 }36 }
29 37
30-func newForwarder() *forwarder { return &forwarder{wake: make(chan struct{}, 1)} }38+func newForwarder(log zerowrap.Logger) *forwarder {
39+ return &forwarder{wake: make(chan struct{}, 1), log: log}
40+}
31 41
32 // push queues ev without blocking.42 // push queues ev without blocking.
33 func (f *forwarder) push(ev ports.InputEvent) {43 func (f *forwarder) push(ev ports.InputEvent) {
@@ -49,6 +59,10 @@ func (f *forwarder) push(ev ports.InputEvent) {
49 }59 }
50 }60 }
51 f.queue = append(f.queue, ev)61 f.queue = append(f.queue, ev)
62+ if len(f.queue) > forwardWarn && !f.long {
63+ f.long = true
64+ f.log.Warn().Str("component", "input").Int("queued", len(f.queue)).Msg("core is not reading input; events queue up")
65+ }
52 f.mu.Unlock()66 f.mu.Unlock()
53 select {67 select {
54 case f.wake <- struct{}{}:68 case f.wake <- struct{}{}:
@@ -65,6 +79,10 @@ func (f *forwarder) pop() (ports.InputEvent, bool) {
65 ev := f.queue[0]79 ev := f.queue[0]
66 f.queue[0] = nil80 f.queue[0] = nil
67 f.queue = f.queue[1:]81 f.queue = f.queue[1:]
82+ if f.long && len(f.queue) < forwardWarn {
83+ f.long = false
84+ f.log.Warn().Str("component", "input").Int("queued", len(f.queue)).Msg("input queue drained")
85+ }
68 return ev, true86 return ev, true
69 }87 }
70 88
M internal/adapters/libinput/forward_test.go +63 -3
@@ -1,17 +1,22 @@
11 package libinput
22
33 import (
4+ "bytes"
45 "context"
6+ "strings"
7+ "sync"
58 "testing"
69 "time"
710
811 "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/zerowrap"
13+ "github.com/rs/zerolog"
914 )
1015
1116 // A slow consumer must not block push, and stale motion must collapse into
1217 // the latest position while keys and buttons keep their order.
1318 func TestForwarderCoalescesMotionWithoutBlocking(t *testing.T) {
14- f := newForwarder()
19+ f := newForwarder(zerowrap.Default())
1520 input := make(chan ports.InputEvent) // unbuffered: core is busy
1621 start := time.Now()
1722 for i := range 1000 {
@@ -56,7 +61,7 @@ func TestForwarderCoalescesMotionWithoutBlocking(t *testing.T) {
5661 }
5762
5863 func TestForwarderStopsOnCancel(t *testing.T) {
59- f := newForwarder()
64+ f := newForwarder(zerowrap.Default())
6065 f.push(ports.PointerMotion{X: 1})
6166 ctx, cancel := context.WithCancel(context.Background())
6267 done := make(chan struct{})
@@ -71,7 +76,7 @@ func TestForwarderStopsOnCancel(t *testing.T) {
7176
7277 // Coalesced motions keep the sum of their relative deltas.
7378 func TestForwarderSumsDeltas(t *testing.T) {
74- f := newForwarder()
79+ f := newForwarder(zerowrap.Default())
7580 f.push(ports.PointerMotion{X: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
7681 f.push(ports.PointerMotion{X: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
7782 ev, _ := f.pop()
@@ -79,3 +84,58 @@ func TestForwarderSumsDeltas(t *testing.T) {
7984 t.Fatalf("got %+v, want %+v", ev, want)
8085 }
8186 }
87+
88+// 10 000 motions with core stalled are one event with exact delta sums.
89+func TestForwarderManyMotionsOneEvent(t *testing.T) {
90+ f := newForwarder(zerowrap.Default())
91+ for i := range 10000 {
92+ f.push(ports.PointerMotion{X: float64(i), DX: 0.5, UnaccelDY: 1})
93+ }
94+ if len(f.queue) != 1 {
95+ t.Fatalf("%d queued", len(f.queue))
96+ }
97+ ev, _ := f.pop()
98+ if m := ev.(ports.PointerMotion); m.X != 9999 || m.DX != 5000 || m.UnaccelDY != 10000 {
99+ t.Fatalf("%+v", m)
100+ }
101+}
102+
103+// Past forwardWarn queued events the forwarder warns once, and once more
104+// when the queue drains below it; nothing is dropped.
105+func TestForwarderWarnsOnceEachWay(t *testing.T) {
106+ var buf lockedBuffer
107+ f := newForwarder(zerowrap.Logger{Logger: zerolog.New(&buf)})
108+ for range forwardWarn + 100 {
109+ f.push(ports.PointerButton{Button: 272})
110+ }
111+ if n := strings.Count(buf.String(), "events queue up"); n != 1 {
112+ t.Fatalf("%d warnings", n)
113+ }
114+ for range 200 {
115+ f.pop()
116+ }
117+ if n := strings.Count(buf.String(), "input queue drained"); n != 1 {
118+ t.Fatalf("%d drained logs", n)
119+ }
120+ if len(f.queue) != forwardWarn-100 {
121+ t.Fatalf("dropped: %d left", len(f.queue))
122+ }
123+}
124+
125+// lockedBuffer is an io.Writer for a logger.
126+type lockedBuffer struct {
127+ mu sync.Mutex
128+ b bytes.Buffer
129+}
130+
131+func (l *lockedBuffer) Write(p []byte) (int, error) {
132+ l.mu.Lock()
133+ defer l.mu.Unlock()
134+ return l.b.Write(p)
135+}
136+
137+func (l *lockedBuffer) String() string {
138+ l.mu.Lock()
139+ defer l.mu.Unlock()
140+ return l.b.String()
141+}
@@ -1,17 +1,22 @@
1 package libinput1 package libinput
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "strings"
7+ "sync"
5 "testing"8 "testing"
6 "time"9 "time"
7 10
8 "github.com/bnema/nefertty/internal/ports"11 "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/zerowrap"
13+ "github.com/rs/zerolog"
9 )14 )
10 15
11 // A slow consumer must not block push, and stale motion must collapse into16 // A slow consumer must not block push, and stale motion must collapse into
12 // the latest position while keys and buttons keep their order.17 // the latest position while keys and buttons keep their order.
13 func TestForwarderCoalescesMotionWithoutBlocking(t *testing.T) {18 func TestForwarderCoalescesMotionWithoutBlocking(t *testing.T) {
14- f := newForwarder()19+ f := newForwarder(zerowrap.Default())
15 input := make(chan ports.InputEvent) // unbuffered: core is busy20 input := make(chan ports.InputEvent) // unbuffered: core is busy
16 start := time.Now()21 start := time.Now()
17 for i := range 1000 {22 for i := range 1000 {
@@ -56,7 +61,7 @@ func TestForwarderCoalescesMotionWithoutBlocking(t *testing.T) {
56 }61 }
57 62
58 func TestForwarderStopsOnCancel(t *testing.T) {63 func TestForwarderStopsOnCancel(t *testing.T) {
59- f := newForwarder()64+ f := newForwarder(zerowrap.Default())
60 f.push(ports.PointerMotion{X: 1})65 f.push(ports.PointerMotion{X: 1})
61 ctx, cancel := context.WithCancel(context.Background())66 ctx, cancel := context.WithCancel(context.Background())
62 done := make(chan struct{})67 done := make(chan struct{})
@@ -71,7 +76,7 @@ func TestForwarderStopsOnCancel(t *testing.T) {
71 76
72 // Coalesced motions keep the sum of their relative deltas.77 // Coalesced motions keep the sum of their relative deltas.
73 func TestForwarderSumsDeltas(t *testing.T) {78 func TestForwarderSumsDeltas(t *testing.T) {
74- f := newForwarder()79+ f := newForwarder(zerowrap.Default())
75 f.push(ports.PointerMotion{X: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})80 f.push(ports.PointerMotion{X: 1, DX: 1, DY: 2, UnaccelDX: 3, UnaccelDY: 4})
76 f.push(ports.PointerMotion{X: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})81 f.push(ports.PointerMotion{X: 2, DX: 10, DY: 20, UnaccelDX: 30, UnaccelDY: 40})
77 ev, _ := f.pop()82 ev, _ := f.pop()
@@ -79,3 +84,58 @@ func TestForwarderSumsDeltas(t *testing.T) {
79 t.Fatalf("got %+v, want %+v", ev, want)84 t.Fatalf("got %+v, want %+v", ev, want)
80 }85 }
81 }86 }
87+
88+// 10 000 motions with core stalled are one event with exact delta sums.
89+func TestForwarderManyMotionsOneEvent(t *testing.T) {
90+ f := newForwarder(zerowrap.Default())
91+ for i := range 10000 {
92+ f.push(ports.PointerMotion{X: float64(i), DX: 0.5, UnaccelDY: 1})
93+ }
94+ if len(f.queue) != 1 {
95+ t.Fatalf("%d queued", len(f.queue))
96+ }
97+ ev, _ := f.pop()
98+ if m := ev.(ports.PointerMotion); m.X != 9999 || m.DX != 5000 || m.UnaccelDY != 10000 {
99+ t.Fatalf("%+v", m)
100+ }
101+}
102+
103+// Past forwardWarn queued events the forwarder warns once, and once more
104+// when the queue drains below it; nothing is dropped.
105+func TestForwarderWarnsOnceEachWay(t *testing.T) {
106+ var buf lockedBuffer
107+ f := newForwarder(zerowrap.Logger{Logger: zerolog.New(&buf)})
108+ for range forwardWarn + 100 {
109+ f.push(ports.PointerButton{Button: 272})
110+ }
111+ if n := strings.Count(buf.String(), "events queue up"); n != 1 {
112+ t.Fatalf("%d warnings", n)
113+ }
114+ for range 200 {
115+ f.pop()
116+ }
117+ if n := strings.Count(buf.String(), "input queue drained"); n != 1 {
118+ t.Fatalf("%d drained logs", n)
119+ }
120+ if len(f.queue) != forwardWarn-100 {
121+ t.Fatalf("dropped: %d left", len(f.queue))
122+ }
123+}
124+
125+// lockedBuffer is an io.Writer for a logger.
126+type lockedBuffer struct {
127+ mu sync.Mutex
128+ b bytes.Buffer
129+}
130+
131+func (l *lockedBuffer) Write(p []byte) (int, error) {
132+ l.mu.Lock()
133+ defer l.mu.Unlock()
134+ return l.b.Write(p)
135+}
136+
137+func (l *lockedBuffer) String() string {
138+ l.mu.Lock()
139+ defer l.mu.Unlock()
140+ return l.b.String()
141+}
M internal/adapters/libinput/libinput.go +7 -2
@@ -160,6 +160,9 @@ type Options struct {
160160 // the physical position on it.
161161 MoveCursor func(output string, x, y float64)
162162 Log zerowrap.Logger
163+ // LogMotion logs every pointer motion (--debug=input-motion); motions
164+ // are otherwise only counted in the input stats.
165+ LogMotion bool
163166 }
164167
165168 // statsEvery is how often Run logs input throughput.
@@ -192,7 +195,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
192195 p := newPointer(opts.Layout)
193196 p.moved(opts.MoveCursor)
194197 fd := getFD(li)
195- fwd := newForwarder()
198+ fwd := newForwarder(opts.Log)
196199 fwdCtx, stopFwd := context.WithCancel(ctx)
197200 var fwdDone sync.WaitGroup
198201 fwdDone.Go(func() { fwd.run(fwdCtx, input) })
@@ -278,7 +281,9 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
278281 m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)
279282 m.TimeMsec, m.TimeUsec = now, pointerUsec(pe)
280283 p.moved(opts.MoveCursor)
281- log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")
284+ if opts.LogMotion {
285+ log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")
286+ }
282287 return m, nil
283288 case evPointerAbs:
284289 // Absolute devices (tablets, VMs) map to the whole layout.
@@ -160,6 +160,9 @@ type Options struct {
160 // the physical position on it.160 // the physical position on it.
161 MoveCursor func(output string, x, y float64)161 MoveCursor func(output string, x, y float64)
162 Log zerowrap.Logger162 Log zerowrap.Logger
163+ // LogMotion logs every pointer motion (--debug=input-motion); motions
164+ // are otherwise only counted in the input stats.
165+ LogMotion bool
163 }166 }
164 167
165 // statsEvery is how often Run logs input throughput.168 // statsEvery is how often Run logs input throughput.
@@ -192,7 +195,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
192 p := newPointer(opts.Layout)195 p := newPointer(opts.Layout)
193 p.moved(opts.MoveCursor)196 p.moved(opts.MoveCursor)
194 fd := getFD(li)197 fd := getFD(li)
195- fwd := newForwarder()198+ fwd := newForwarder(opts.Log)
196 fwdCtx, stopFwd := context.WithCancel(ctx)199 fwdCtx, stopFwd := context.WithCancel(ctx)
197 var fwdDone sync.WaitGroup200 var fwdDone sync.WaitGroup
198 fwdDone.Go(func() { fwd.run(fwdCtx, input) })201 fwdDone.Go(func() { fwd.run(fwdCtx, input) })
@@ -278,7 +281,9 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
278 m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)281 m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)
279 m.TimeMsec, m.TimeUsec = now, pointerUsec(pe)282 m.TimeMsec, m.TimeUsec = now, pointerUsec(pe)
280 p.moved(opts.MoveCursor)283 p.moved(opts.MoveCursor)
281- log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")284+ if opts.LogMotion {
285+ log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")
286+ }
282 return m, nil287 return m, nil
283 case evPointerAbs:288 case evPointerAbs:
284 // Absolute devices (tablets, VMs) map to the whole layout.289 // Absolute devices (tablets, VMs) map to the whole layout.
M internal/app/app.go +1 -1
@@ -161,7 +161,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
161161 return
162162 }
163163 done <- safe("input", func() error {
164- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input")}, input)
164+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)
165165 })
166166 return
167167 }
@@ -161,7 +161,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
161 return161 return
162 }162 }
163 done <- safe("input", func() error {163 done <- safe("input", func() error {
164- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input")}, input)164+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input)
165 })165 })
166 return166 return
167 }167 }
M internal/logging/logging.go +11 -1
@@ -20,6 +20,10 @@ var components = map[string]bool{
2020 "render": true, "sync": true, "config": true, "app": true,
2121 }
2222
23+// categories are debug switches narrower than a component, only on when
24+// named: "all" leaves them off (input-motion logs every pointer motion).
25+var categories = map[string]bool{"input-motion": true}
26+
2327 type debugKey struct{}
2428 type levelKey struct{}
2529 type debugSet map[string]bool
@@ -34,7 +38,7 @@ func ParseDebug(value string) (map[string]bool, error) {
3438 selected[name] = true
3539 continue
3640 }
37- if !components[name] {
41+ if !components[name] && !categories[name] {
3842 return nil, fmt.Errorf("invalid debug component %q", name)
3943 }
4044 selected[name] = true
@@ -104,6 +108,12 @@ func (w *consoleAndFile) Write(p []byte) (int, error) {
104108 return w.file.Write(p)
105109 }
106110
111+// Enabled reports whether a debug category was named in --debug.
112+func Enabled(ctx context.Context, category string) bool {
113+ selected, _ := ctx.Value(debugKey{}).(debugSet)
114+ return selected[category]
115+}
116+
107117 // For returns a component logger with debug enabled only for selected components.
108118 func For(ctx context.Context, component string) zerowrap.Logger {
109119 log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)
@@ -20,6 +20,10 @@ var components = map[string]bool{
20 "render": true, "sync": true, "config": true, "app": true,20 "render": true, "sync": true, "config": true, "app": true,
21 }21 }
22 22
23+// categories are debug switches narrower than a component, only on when
24+// named: "all" leaves them off (input-motion logs every pointer motion).
25+var categories = map[string]bool{"input-motion": true}
26+
23 type debugKey struct{}27 type debugKey struct{}
24 type levelKey struct{}28 type levelKey struct{}
25 type debugSet map[string]bool29 type debugSet map[string]bool
@@ -34,7 +38,7 @@ func ParseDebug(value string) (map[string]bool, error) {
34 selected[name] = true38 selected[name] = true
35 continue39 continue
36 }40 }
37- if !components[name] {41+ if !components[name] && !categories[name] {
38 return nil, fmt.Errorf("invalid debug component %q", name)42 return nil, fmt.Errorf("invalid debug component %q", name)
39 }43 }
40 selected[name] = true44 selected[name] = true
@@ -104,6 +108,12 @@ func (w *consoleAndFile) Write(p []byte) (int, error) {
104 return w.file.Write(p)108 return w.file.Write(p)
105 }109 }
106 110
111+// Enabled reports whether a debug category was named in --debug.
112+func Enabled(ctx context.Context, category string) bool {
113+ selected, _ := ctx.Value(debugKey{}).(debugSet)
114+ return selected[category]
115+}
116+
107 // For returns a component logger with debug enabled only for selected components.117 // For returns a component logger with debug enabled only for selected components.
108 func For(ctx context.Context, component string) zerowrap.Logger {118 func For(ctx context.Context, component string) zerowrap.Logger {
109 log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)119 log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)
M internal/logging/logging_test.go +18 -0
@@ -18,6 +18,7 @@ func TestParseDebug(t *testing.T) {
1818 }{
1919 {"", false, nil}, {"core,app", false, []string{"core", "app"}},
2020 {"all", false, []string{"all"}}, {"unknown", true, nil}, {"core,", true, nil},
21+ {"all,input-motion", false, []string{"all", "input-motion"}},
2122 } {
2223 got, err := ParseDebug(tc.input)
2324 if (err != nil) != tc.wantError {
@@ -82,3 +83,20 @@ func splitLines(data []byte) [][]byte {
8283 }
8384 return lines
8485 }
86+
87+// Narrow categories are on only when named, never through "all".
88+func TestEnabledCategories(t *testing.T) {
89+ for _, tc := range []struct {
90+ debug string
91+ want bool
92+ }{{"all", false}, {"input", false}, {"input-motion", true}, {"all,input-motion", true}} {
93+ sel, err := ParseDebug(tc.debug)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ ctx := context.WithValue(context.Background(), debugKey{}, debugSet(sel))
98+ if got := Enabled(ctx, "input-motion"); got != tc.want {
99+ t.Errorf("%q: %v", tc.debug, got)
100+ }
101+ }
102+}
@@ -18,6 +18,7 @@ func TestParseDebug(t *testing.T) {
18 }{18 }{
19 {"", false, nil}, {"core,app", false, []string{"core", "app"}},19 {"", false, nil}, {"core,app", false, []string{"core", "app"}},
20 {"all", false, []string{"all"}}, {"unknown", true, nil}, {"core,", true, nil},20 {"all", false, []string{"all"}}, {"unknown", true, nil}, {"core,", true, nil},
21+ {"all,input-motion", false, []string{"all", "input-motion"}},
21 } {22 } {
22 got, err := ParseDebug(tc.input)23 got, err := ParseDebug(tc.input)
23 if (err != nil) != tc.wantError {24 if (err != nil) != tc.wantError {
@@ -82,3 +83,20 @@ func splitLines(data []byte) [][]byte {
82 }83 }
83 return lines84 return lines
84 }85 }
86+
87+// Narrow categories are on only when named, never through "all".
88+func TestEnabledCategories(t *testing.T) {
89+ for _, tc := range []struct {
90+ debug string
91+ want bool
92+ }{{"all", false}, {"input", false}, {"input-motion", true}, {"all,input-motion", true}} {
93+ sel, err := ParseDebug(tc.debug)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ ctx := context.WithValue(context.Background(), debugKey{}, debugSet(sel))
98+ if got := Enabled(ctx, "input-motion"); got != tc.want {
99+ t.Errorf("%q: %v", tc.debug, got)
100+ }
101+ }
102+}

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