detached be629c4

feat(hints): dump scanned frames when NEFERWL_HINT_DEBUG is set

Opt-in: writes the frame, an annotated copy and a JSON summary per scan under $XDG_STATE_HOME/neferwl/hints, keeping the 50 most recent. Documented in docs/config.md.
commit
be629c41ba6ca03587683bb646765eb0bc7d0bf0
author
brice · · 2026-10-19 12:24 UTC
parent
8ed066e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
0/1
6 files · +414 -6
M docs/config.md +4 -0
@@ -309,3 +309,7 @@ Otherwise, left from the first column unfolds the stash. Up/down walk the cards,
309309 - `hint.targets = links` labels links and URLs only, not buttons.
310310 - `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens.
311311 - No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels.
312+
313+#### Debugging hint mode
314+
315+With `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (links green, buttons blue, text grey); and `<time>-<output>.json`, the targets and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use.
@@ -309,3 +309,7 @@ Otherwise, left from the first column unfolds the stash. Up/down walk the cards,
309 - `hint.targets = links` labels links and URLs only, not buttons.309 - `hint.targets = links` labels links and URLs only, not buttons.
310 - `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens.310 - `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens.
311 - No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels.311 - No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels.
312+
313+#### Debugging hint mode
314+
315+With `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (links green, buttons blue, text grey); and `<time>-<output>.json`, the targets and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use.
A internal/adapters/hints/dump.go +214 -0
@@ -0,0 +1,214 @@
1+package hints
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "image"
7+ "image/color"
8+ "image/png"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "strings"
13+ "time"
14+
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+const (
19+ // maxDumps is the number of dump groups kept in the directory.
20+ maxDumps = 50
21+ outlineSize = 2
22+)
23+
24+// Options tunes Run.
25+type Options struct {
26+ // DumpDir, when not empty, receives the scanned frames (PNG, annotated
27+ // PNG and JSON). They contain the screen in clear.
28+ DumpDir string
29+}
30+
31+// dumpJob is one scan to write. Pix is the worker buffer, not a copy: it
32+// stays untouched until the worker serves the next request.
33+type dumpJob struct {
34+ when time.Time
35+ req ports.HintScanRequest
36+ pix []byte
37+ stride int
38+ targets []ports.HintTarget
39+ captureD time.Duration
40+ scanD time.Duration
41+}
42+
43+type dumpTarget struct {
44+ Kind string `json:"kind"`
45+ Rect [4]int `json:"rect"`
46+}
47+
48+type dumpMeta struct {
49+ Output string `json:"output"`
50+ Width int `json:"width"`
51+ Height int `json:"height"`
52+ Stride int `json:"stride"`
53+ Scale float64 `json:"scale"`
54+ LinksOnly bool `json:"links_only"`
55+ CaptureMS float64 `json:"capture_ms"`
56+ ScanMS float64 `json:"scan_ms"`
57+ Targets []dumpTarget `json:"targets"`
58+}
59+
60+func kindName(k ports.HintKind) string {
61+ switch k {
62+ case ports.HintLink:
63+ return "link"
64+ case ports.HintButton:
65+ return "button"
66+ default:
67+ return "text"
68+ }
69+}
70+
71+func kindColor(k ports.HintKind) color.RGBA {
72+ switch k {
73+ case ports.HintLink:
74+ return color.RGBA{0x00, 0xc8, 0x53, 0xff}
75+ case ports.HintButton:
76+ return color.RGBA{0x29, 0x62, 0xff, 0xff}
77+ default:
78+ return color.RGBA{0x80, 0x80, 0x80, 0xff}
79+ }
80+}
81+
82+func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
83+
84+// writeDump writes the three files of j into dir, then prunes old groups.
85+func writeDump(dir string, j *dumpJob) error {
86+ if err := os.MkdirAll(dir, 0o700); err != nil {
87+ return err
88+ }
89+ prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output))
90+
91+ img := &image.RGBA{Pix: make([]byte, j.req.Height*j.stride), Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)}
92+ for y := 0; y < j.req.Height; y++ {
93+ row := j.pix[y*j.stride : y*j.stride+j.req.Width*4]
94+ out := img.Pix[y*j.stride:]
95+ for x := 0; x < len(row); x += 4 {
96+ out[x], out[x+1], out[x+2], out[x+3] = row[x+2], row[x+1], row[x], 0xff
97+ }
98+ }
99+ if err := writePNG(prefix+".png", img); err != nil {
100+ return err
101+ }
102+
103+ scale := j.req.Scale
104+ if scale <= 0 {
105+ scale = 1
106+ }
107+ for _, t := range j.targets {
108+ r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),
109+ int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))
110+ outline(img, r, kindColor(t.Kind))
111+ }
112+ if err := writePNG(prefix+".annotated.png", img); err != nil {
113+ return err
114+ }
115+
116+ meta := dumpMeta{
117+ Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,
118+ Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,
119+ CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD),
120+ Targets: make([]dumpTarget, 0, len(j.targets)),
121+ }
122+ for _, t := range j.targets {
123+ meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}})
124+ }
125+ data, err := json.Marshal(meta)
126+ if err != nil {
127+ return err
128+ }
129+ if err := writeFile(prefix+".json", func(f *os.File) error { _, err := f.Write(append(data, '\n')); return err }); err != nil {
130+ return err
131+ }
132+ return prune(dir, maxDumps)
133+}
134+
135+func safeName(s string) string {
136+ return strings.Map(func(r rune) rune {
137+ if r == '/' || r == 0 {
138+ return '_'
139+ }
140+ return r
141+ }, s)
142+}
143+
144+func writePNG(path string, img image.Image) error {
145+ enc := png.Encoder{CompressionLevel: png.BestSpeed}
146+ return writeFile(path, func(f *os.File) error { return enc.Encode(f, img) })
147+}
148+
149+func writeFile(path string, fill func(*os.File) error) error {
150+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
151+ if err != nil {
152+ return err
153+ }
154+ if err := fill(f); err != nil {
155+ _ = f.Close()
156+ return err
157+ }
158+ return f.Close()
159+}
160+
161+// outline draws a 2 px frame inside r, clipped to img.
162+func outline(img *image.RGBA, r image.Rectangle, c color.RGBA) {
163+ r = r.Intersect(img.Rect)
164+ if r.Empty() {
165+ return
166+ }
167+ for y := r.Min.Y; y < r.Max.Y; y++ {
168+ for x := r.Min.X; x < r.Max.X; x++ {
169+ if x-r.Min.X < outlineSize || r.Max.X-1-x < outlineSize || y-r.Min.Y < outlineSize || r.Max.Y-1-y < outlineSize {
170+ img.SetRGBA(x, y, c)
171+ }
172+ }
173+ }
174+}
175+
176+// groupOf returns the "<timestamp>-<output>" prefix of a dump file name.
177+func groupOf(name string) string {
178+ for _, suf := range []string{".annotated.png", ".png", ".json"} {
179+ if strings.HasSuffix(name, suf) {
180+ return strings.TrimSuffix(name, suf)
181+ }
182+ }
183+ return ""
184+}
185+
186+// prune keeps the keep most recent groups of dir; timestamps sort by name.
187+func prune(dir string, keep int) error {
188+ entries, err := os.ReadDir(dir)
189+ if err != nil {
190+ return err
191+ }
192+ groups := map[string][]string{}
193+ for _, e := range entries {
194+ if g := groupOf(e.Name()); g != "" && !e.IsDir() {
195+ groups[g] = append(groups[g], e.Name())
196+ }
197+ }
198+ if len(groups) <= keep {
199+ return nil
200+ }
201+ names := make([]string, 0, len(groups))
202+ for g := range groups {
203+ names = append(names, g)
204+ }
205+ sort.Strings(names)
206+ for _, g := range names[:len(names)-keep] {
207+ for _, f := range groups[g] {
208+ if err := os.Remove(filepath.Join(dir, f)); err != nil {
209+ return fmt.Errorf("prune: %w", err)
210+ }
211+ }
212+ }
213+ return nil
214+}
@@ -0,0 +1,214 @@
1+package hints
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "image"
7+ "image/color"
8+ "image/png"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "strings"
13+ "time"
14+
15+ "github.com/bnema/neferwl/internal/ports"
16+)
17+
18+const (
19+ // maxDumps is the number of dump groups kept in the directory.
20+ maxDumps = 50
21+ outlineSize = 2
22+)
23+
24+// Options tunes Run.
25+type Options struct {
26+ // DumpDir, when not empty, receives the scanned frames (PNG, annotated
27+ // PNG and JSON). They contain the screen in clear.
28+ DumpDir string
29+}
30+
31+// dumpJob is one scan to write. Pix is the worker buffer, not a copy: it
32+// stays untouched until the worker serves the next request.
33+type dumpJob struct {
34+ when time.Time
35+ req ports.HintScanRequest
36+ pix []byte
37+ stride int
38+ targets []ports.HintTarget
39+ captureD time.Duration
40+ scanD time.Duration
41+}
42+
43+type dumpTarget struct {
44+ Kind string `json:"kind"`
45+ Rect [4]int `json:"rect"`
46+}
47+
48+type dumpMeta struct {
49+ Output string `json:"output"`
50+ Width int `json:"width"`
51+ Height int `json:"height"`
52+ Stride int `json:"stride"`
53+ Scale float64 `json:"scale"`
54+ LinksOnly bool `json:"links_only"`
55+ CaptureMS float64 `json:"capture_ms"`
56+ ScanMS float64 `json:"scan_ms"`
57+ Targets []dumpTarget `json:"targets"`
58+}
59+
60+func kindName(k ports.HintKind) string {
61+ switch k {
62+ case ports.HintLink:
63+ return "link"
64+ case ports.HintButton:
65+ return "button"
66+ default:
67+ return "text"
68+ }
69+}
70+
71+func kindColor(k ports.HintKind) color.RGBA {
72+ switch k {
73+ case ports.HintLink:
74+ return color.RGBA{0x00, 0xc8, 0x53, 0xff}
75+ case ports.HintButton:
76+ return color.RGBA{0x29, 0x62, 0xff, 0xff}
77+ default:
78+ return color.RGBA{0x80, 0x80, 0x80, 0xff}
79+ }
80+}
81+
82+func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
83+
84+// writeDump writes the three files of j into dir, then prunes old groups.
85+func writeDump(dir string, j *dumpJob) error {
86+ if err := os.MkdirAll(dir, 0o700); err != nil {
87+ return err
88+ }
89+ prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output))
90+
91+ img := &image.RGBA{Pix: make([]byte, j.req.Height*j.stride), Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)}
92+ for y := 0; y < j.req.Height; y++ {
93+ row := j.pix[y*j.stride : y*j.stride+j.req.Width*4]
94+ out := img.Pix[y*j.stride:]
95+ for x := 0; x < len(row); x += 4 {
96+ out[x], out[x+1], out[x+2], out[x+3] = row[x+2], row[x+1], row[x], 0xff
97+ }
98+ }
99+ if err := writePNG(prefix+".png", img); err != nil {
100+ return err
101+ }
102+
103+ scale := j.req.Scale
104+ if scale <= 0 {
105+ scale = 1
106+ }
107+ for _, t := range j.targets {
108+ r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale),
109+ int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale))
110+ outline(img, r, kindColor(t.Kind))
111+ }
112+ if err := writePNG(prefix+".annotated.png", img); err != nil {
113+ return err
114+ }
115+
116+ meta := dumpMeta{
117+ Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride,
118+ Scale: j.req.Scale, LinksOnly: j.req.LinksOnly,
119+ CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD),
120+ Targets: make([]dumpTarget, 0, len(j.targets)),
121+ }
122+ for _, t := range j.targets {
123+ meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}})
124+ }
125+ data, err := json.Marshal(meta)
126+ if err != nil {
127+ return err
128+ }
129+ if err := writeFile(prefix+".json", func(f *os.File) error { _, err := f.Write(append(data, '\n')); return err }); err != nil {
130+ return err
131+ }
132+ return prune(dir, maxDumps)
133+}
134+
135+func safeName(s string) string {
136+ return strings.Map(func(r rune) rune {
137+ if r == '/' || r == 0 {
138+ return '_'
139+ }
140+ return r
141+ }, s)
142+}
143+
144+func writePNG(path string, img image.Image) error {
145+ enc := png.Encoder{CompressionLevel: png.BestSpeed}
146+ return writeFile(path, func(f *os.File) error { return enc.Encode(f, img) })
147+}
148+
149+func writeFile(path string, fill func(*os.File) error) error {
150+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
151+ if err != nil {
152+ return err
153+ }
154+ if err := fill(f); err != nil {
155+ _ = f.Close()
156+ return err
157+ }
158+ return f.Close()
159+}
160+
161+// outline draws a 2 px frame inside r, clipped to img.
162+func outline(img *image.RGBA, r image.Rectangle, c color.RGBA) {
163+ r = r.Intersect(img.Rect)
164+ if r.Empty() {
165+ return
166+ }
167+ for y := r.Min.Y; y < r.Max.Y; y++ {
168+ for x := r.Min.X; x < r.Max.X; x++ {
169+ if x-r.Min.X < outlineSize || r.Max.X-1-x < outlineSize || y-r.Min.Y < outlineSize || r.Max.Y-1-y < outlineSize {
170+ img.SetRGBA(x, y, c)
171+ }
172+ }
173+ }
174+}
175+
176+// groupOf returns the "<timestamp>-<output>" prefix of a dump file name.
177+func groupOf(name string) string {
178+ for _, suf := range []string{".annotated.png", ".png", ".json"} {
179+ if strings.HasSuffix(name, suf) {
180+ return strings.TrimSuffix(name, suf)
181+ }
182+ }
183+ return ""
184+}
185+
186+// prune keeps the keep most recent groups of dir; timestamps sort by name.
187+func prune(dir string, keep int) error {
188+ entries, err := os.ReadDir(dir)
189+ if err != nil {
190+ return err
191+ }
192+ groups := map[string][]string{}
193+ for _, e := range entries {
194+ if g := groupOf(e.Name()); g != "" && !e.IsDir() {
195+ groups[g] = append(groups[g], e.Name())
196+ }
197+ }
198+ if len(groups) <= keep {
199+ return nil
200+ }
201+ names := make([]string, 0, len(groups))
202+ for g := range groups {
203+ names = append(names, g)
204+ }
205+ sort.Strings(names)
206+ for _, g := range names[:len(names)-keep] {
207+ for _, f := range groups[g] {
208+ if err := os.Remove(filepath.Join(dir, f)); err != nil {
209+ return fmt.Errorf("prune: %w", err)
210+ }
211+ }
212+ }
213+ return nil
214+}
M internal/adapters/hints/hints.go +23 -4
@@ -34,8 +34,8 @@ const (
3434 // Run serves requests until ctx is done. It handles one request at a time;
3535 // a request that arrives meanwhile is answered with ErrBusy. Every request
3636 // gets exactly one result on results.
37-func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error {
38- return run(ctx, requests, captures, captured, results, captureTimeout)
37+func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, opts Options) error {
38+ return run(ctx, requests, captures, captured, results, captureTimeout, opts)
3939 }
4040
4141 type worker struct {
@@ -43,10 +43,12 @@ type worker struct {
4343 buf []byte
4444 dst []hintscan.Target
4545 n uint64
46+ dumpDir string
47+ pending *dumpJob
4648 }
4749
48-func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error {
49- w := &worker{scanner: hintscan.NewScanner()}
50+func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {
51+ w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir}
5052 for {
5153 select {
5254 case <-ctx.Done():
@@ -56,10 +58,24 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
5658 if !send(ctx, results, res) {
5759 return nil
5860 }
61+ w.dump(ctx)
5962 }
6063 }
6164 }
6265
66+// dump writes the last successful scan, after its result was sent.
67+func (w *worker) dump(ctx context.Context) {
68+ j := w.pending
69+ w.pending = nil
70+ if j == nil {
71+ return
72+ }
73+ if err := writeDump(w.dumpDir, j); err != nil {
74+ log := logging.For(ctx, "hints")
75+ log.Warn().Err(err).Str("dir", w.dumpDir).Msg("hint debug dump failed")
76+ }
77+}
78+
6379 func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool {
6480 select {
6581 case results <- res:
@@ -171,6 +187,9 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte
171187 })
172188 }
173189 res.Targets = out
190+ if w.dumpDir != "" {
191+ w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, captureD: captureDur, scanD: scanDur}
192+ }
174193 log.Info().
175194 Str("output", req.Output).
176195 Int("targets", len(out)).
@@ -34,8 +34,8 @@ const (
34 // Run serves requests until ctx is done. It handles one request at a time;34 // Run serves requests until ctx is done. It handles one request at a time;
35 // a request that arrives meanwhile is answered with ErrBusy. Every request35 // a request that arrives meanwhile is answered with ErrBusy. Every request
36 // gets exactly one result on results.36 // gets exactly one result on results.
37-func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error {37+func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, opts Options) error {
38- return run(ctx, requests, captures, captured, results, captureTimeout)38+ return run(ctx, requests, captures, captured, results, captureTimeout, opts)
39 }39 }
40 40
41 type worker struct {41 type worker struct {
@@ -43,10 +43,12 @@ type worker struct {
43 buf []byte43 buf []byte
44 dst []hintscan.Target44 dst []hintscan.Target
45 n uint6445 n uint64
46+ dumpDir string
47+ pending *dumpJob
46 }48 }
47 49
48-func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error {50+func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error {
49- w := &worker{scanner: hintscan.NewScanner()}51+ w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir}
50 for {52 for {
51 select {53 select {
52 case <-ctx.Done():54 case <-ctx.Done():
@@ -56,10 +58,24 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
56 if !send(ctx, results, res) {58 if !send(ctx, results, res) {
57 return nil59 return nil
58 }60 }
61+ w.dump(ctx)
59 }62 }
60 }63 }
61 }64 }
62 65
66+// dump writes the last successful scan, after its result was sent.
67+func (w *worker) dump(ctx context.Context) {
68+ j := w.pending
69+ w.pending = nil
70+ if j == nil {
71+ return
72+ }
73+ if err := writeDump(w.dumpDir, j); err != nil {
74+ log := logging.For(ctx, "hints")
75+ log.Warn().Err(err).Str("dir", w.dumpDir).Msg("hint debug dump failed")
76+ }
77+}
78+
63 func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool {79 func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool {
64 select {80 select {
65 case results <- res:81 case results <- res:
@@ -171,6 +187,9 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte
171 })187 })
172 }188 }
173 res.Targets = out189 res.Targets = out
190+ if w.dumpDir != "" {
191+ w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, captureD: captureDur, scanD: scanDur}
192+ }
174 log.Info().193 log.Info().
175 Str("output", req.Output).194 Str("output", req.Output).
176 Int("targets", len(out)).195 Int("targets", len(out)).
M internal/adapters/hints/hints_test.go +139 -1
@@ -2,11 +2,15 @@ package hints
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
7+ "fmt"
68 "image"
79 "image/draw"
810 _ "image/png"
911 "os"
12+ "path/filepath"
13+ "strings"
1014 "testing"
1115 "time"
1216
@@ -23,6 +27,10 @@ type harness struct {
2327 }
2428
2529 func start(t *testing.T, timeout time.Duration) *harness {
30+ return startWith(t, timeout, Options{})
31+}
32+
33+func startWith(t *testing.T, timeout time.Duration, opts Options) *harness {
2634 t.Helper()
2735 h := &harness{
2836 requests: make(chan ports.HintScanRequest, 1),
@@ -34,7 +42,7 @@ func start(t *testing.T, timeout time.Duration) *harness {
3442 exited := make(chan struct{})
3543 go func() {
3644 defer close(exited)
37- _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout)
45+ _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout, opts)
3846 }()
3947 t.Cleanup(func() { cancel(); <-exited })
4048 return h
@@ -147,3 +155,133 @@ func TestHintBadSize(t *testing.T) {
147155 res := <-h.results
148156 require.ErrorIs(t, res.Err, ErrBadRequest)
149157 }
158+
159+func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) ports.HintScanResult {
160+ t.Helper()
161+ go func() {
162+ creq := <-h.captures
163+ copy(creq.Mapped, pix)
164+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
165+ }()
166+ h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
167+ res := <-h.results
168+ require.NoError(t, res.Err)
169+ return res
170+}
171+
172+// waitFiles waits for the dump the worker writes after sending the result.
173+func waitFiles(t *testing.T, dir string, n int) []os.DirEntry {
174+ t.Helper()
175+ var entries []os.DirEntry
176+ require.Eventually(t, func() bool {
177+ entries, _ = os.ReadDir(dir)
178+ return len(entries) >= n
179+ }, 5*time.Second, 10*time.Millisecond)
180+ return entries
181+}
182+
183+func TestHintDump(t *testing.T) {
184+ pix, w, hgt := loadBGRA(t)
185+ dir := filepath.Join(t.TempDir(), "hints")
186+ h := startWith(t, 5*time.Second, Options{DumpDir: dir})
187+ res := scanOnce(t, h, pix, w, hgt, 1)
188+ require.NotEmpty(t, res.Targets)
189+
190+ entries := waitFiles(t, dir, 3)
191+ // A second request is only served once the dump is written.
192+ _ = scanOnceDone(t, h)
193+ var png, ann, js string
194+ for _, e := range entries {
195+ info, err := e.Info()
196+ require.NoError(t, err)
197+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
198+ switch {
199+ case strings.HasSuffix(e.Name(), ".annotated.png"):
200+ ann = e.Name()
201+ case strings.HasSuffix(e.Name(), ".png"):
202+ png = e.Name()
203+ case strings.HasSuffix(e.Name(), ".json"):
204+ js = e.Name()
205+ }
206+ }
207+ require.NotEmpty(t, png)
208+ require.NotEmpty(t, ann)
209+ require.Regexp(t, `^\d{8}-\d{6}\.\d{3}-HEADLESS-1\.json$`, js)
210+
211+ st, err := os.Stat(dir)
212+ require.NoError(t, err)
213+ require.Equal(t, os.FileMode(0o700), st.Mode().Perm())
214+
215+ data, err := os.ReadFile(filepath.Join(dir, js))
216+ require.NoError(t, err)
217+ var meta struct {
218+ Output string `json:"output"`
219+ Width int `json:"width"`
220+ Height int `json:"height"`
221+ Stride int `json:"stride"`
222+ Scale float64 `json:"scale"`
223+ LinksOnly bool `json:"links_only"`
224+ Targets []struct {
225+ Kind string `json:"kind"`
226+ Rect [4]int `json:"rect"`
227+ } `json:"targets"`
228+ }
229+ require.NoError(t, json.Unmarshal(data, &meta))
230+ require.Equal(t, "HEADLESS-1", meta.Output)
231+ require.Equal(t, w, meta.Width)
232+ require.Equal(t, hgt, meta.Height)
233+ require.Equal(t, w*4, meta.Stride)
234+ require.Equal(t, 1.0, meta.Scale)
235+ require.Len(t, meta.Targets, len(res.Targets))
236+ for i, tg := range res.Targets {
237+ require.Equal(t, [4]int{tg.Rect.X, tg.Rect.Y, tg.Rect.X + tg.Rect.W, tg.Rect.Y + tg.Rect.H}, meta.Targets[i].Rect)
238+ }
239+
240+ f, err := os.Open(filepath.Join(dir, png))
241+ require.NoError(t, err)
242+ defer f.Close()
243+ img, _, err := image.Decode(f)
244+ require.NoError(t, err)
245+ require.Equal(t, image.Rect(0, 0, w, hgt), img.Bounds())
246+ r, g, b, _ := img.At(0, 0).RGBA()
247+ require.Equal(t, uint32(pix[2])*0x101, r)
248+ require.Equal(t, uint32(pix[1])*0x101, g)
249+ require.Equal(t, uint32(pix[0])*0x101, b)
250+}
251+
252+// scanOnceDone sends a request that fails fast on a blank capture and waits
253+// for its result, which proves the previous dump finished.
254+func scanOnceDone(t *testing.T, h *harness) ports.HintScanResult {
255+ t.Helper()
256+ go func() {
257+ creq := <-h.captures
258+ h.captured <- ports.CaptureDone{ID: creq.ID, Err: errors.New("stop")}
259+ }()
260+ h.requests <- ports.HintScanRequest{Seq: 99, Output: "x", Width: 8, Height: 8, Scale: 1}
261+ return <-h.results
262+}
263+
264+func TestHintDumpDisabled(t *testing.T) {
265+ pix, w, hgt := loadBGRA(t)
266+ dir := filepath.Join(t.TempDir(), "hints")
267+ h := start(t, 5*time.Second)
268+ scanOnce(t, h, pix, w, hgt, 1)
269+ scanOnceDone(t, h)
270+ _, err := os.Stat(dir)
271+ require.ErrorIs(t, err, os.ErrNotExist)
272+}
273+
274+func TestHintDumpRetention(t *testing.T) {
275+ dir := t.TempDir()
276+ for i := 0; i < 60; i++ {
277+ for _, ext := range []string{".png", ".annotated.png", ".json"} {
278+ name := fmt.Sprintf("20260101-000000.%03d-o%s", i, ext)
279+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o600))
280+ }
281+ }
282+ require.NoError(t, prune(dir, maxDumps))
283+ entries, err := os.ReadDir(dir)
284+ require.NoError(t, err)
285+ require.Len(t, entries, maxDumps*3)
286+ require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
287+}
@@ -2,11 +2,15 @@ package hints
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "errors"6 "errors"
7+ "fmt"
6 "image"8 "image"
7 "image/draw"9 "image/draw"
8 _ "image/png"10 _ "image/png"
9 "os"11 "os"
12+ "path/filepath"
13+ "strings"
10 "testing"14 "testing"
11 "time"15 "time"
12 16
@@ -23,6 +27,10 @@ type harness struct {
23 }27 }
24 28
25 func start(t *testing.T, timeout time.Duration) *harness {29 func start(t *testing.T, timeout time.Duration) *harness {
30+ return startWith(t, timeout, Options{})
31+}
32+
33+func startWith(t *testing.T, timeout time.Duration, opts Options) *harness {
26 t.Helper()34 t.Helper()
27 h := &harness{35 h := &harness{
28 requests: make(chan ports.HintScanRequest, 1),36 requests: make(chan ports.HintScanRequest, 1),
@@ -34,7 +42,7 @@ func start(t *testing.T, timeout time.Duration) *harness {
34 exited := make(chan struct{})42 exited := make(chan struct{})
35 go func() {43 go func() {
36 defer close(exited)44 defer close(exited)
37- _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout)45+ _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout, opts)
38 }()46 }()
39 t.Cleanup(func() { cancel(); <-exited })47 t.Cleanup(func() { cancel(); <-exited })
40 return h48 return h
@@ -147,3 +155,133 @@ func TestHintBadSize(t *testing.T) {
147 res := <-h.results155 res := <-h.results
148 require.ErrorIs(t, res.Err, ErrBadRequest)156 require.ErrorIs(t, res.Err, ErrBadRequest)
149 }157 }
158+
159+func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) ports.HintScanResult {
160+ t.Helper()
161+ go func() {
162+ creq := <-h.captures
163+ copy(creq.Mapped, pix)
164+ h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
165+ }()
166+ h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1}
167+ res := <-h.results
168+ require.NoError(t, res.Err)
169+ return res
170+}
171+
172+// waitFiles waits for the dump the worker writes after sending the result.
173+func waitFiles(t *testing.T, dir string, n int) []os.DirEntry {
174+ t.Helper()
175+ var entries []os.DirEntry
176+ require.Eventually(t, func() bool {
177+ entries, _ = os.ReadDir(dir)
178+ return len(entries) >= n
179+ }, 5*time.Second, 10*time.Millisecond)
180+ return entries
181+}
182+
183+func TestHintDump(t *testing.T) {
184+ pix, w, hgt := loadBGRA(t)
185+ dir := filepath.Join(t.TempDir(), "hints")
186+ h := startWith(t, 5*time.Second, Options{DumpDir: dir})
187+ res := scanOnce(t, h, pix, w, hgt, 1)
188+ require.NotEmpty(t, res.Targets)
189+
190+ entries := waitFiles(t, dir, 3)
191+ // A second request is only served once the dump is written.
192+ _ = scanOnceDone(t, h)
193+ var png, ann, js string
194+ for _, e := range entries {
195+ info, err := e.Info()
196+ require.NoError(t, err)
197+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
198+ switch {
199+ case strings.HasSuffix(e.Name(), ".annotated.png"):
200+ ann = e.Name()
201+ case strings.HasSuffix(e.Name(), ".png"):
202+ png = e.Name()
203+ case strings.HasSuffix(e.Name(), ".json"):
204+ js = e.Name()
205+ }
206+ }
207+ require.NotEmpty(t, png)
208+ require.NotEmpty(t, ann)
209+ require.Regexp(t, `^\d{8}-\d{6}\.\d{3}-HEADLESS-1\.json$`, js)
210+
211+ st, err := os.Stat(dir)
212+ require.NoError(t, err)
213+ require.Equal(t, os.FileMode(0o700), st.Mode().Perm())
214+
215+ data, err := os.ReadFile(filepath.Join(dir, js))
216+ require.NoError(t, err)
217+ var meta struct {
218+ Output string `json:"output"`
219+ Width int `json:"width"`
220+ Height int `json:"height"`
221+ Stride int `json:"stride"`
222+ Scale float64 `json:"scale"`
223+ LinksOnly bool `json:"links_only"`
224+ Targets []struct {
225+ Kind string `json:"kind"`
226+ Rect [4]int `json:"rect"`
227+ } `json:"targets"`
228+ }
229+ require.NoError(t, json.Unmarshal(data, &meta))
230+ require.Equal(t, "HEADLESS-1", meta.Output)
231+ require.Equal(t, w, meta.Width)
232+ require.Equal(t, hgt, meta.Height)
233+ require.Equal(t, w*4, meta.Stride)
234+ require.Equal(t, 1.0, meta.Scale)
235+ require.Len(t, meta.Targets, len(res.Targets))
236+ for i, tg := range res.Targets {
237+ require.Equal(t, [4]int{tg.Rect.X, tg.Rect.Y, tg.Rect.X + tg.Rect.W, tg.Rect.Y + tg.Rect.H}, meta.Targets[i].Rect)
238+ }
239+
240+ f, err := os.Open(filepath.Join(dir, png))
241+ require.NoError(t, err)
242+ defer f.Close()
243+ img, _, err := image.Decode(f)
244+ require.NoError(t, err)
245+ require.Equal(t, image.Rect(0, 0, w, hgt), img.Bounds())
246+ r, g, b, _ := img.At(0, 0).RGBA()
247+ require.Equal(t, uint32(pix[2])*0x101, r)
248+ require.Equal(t, uint32(pix[1])*0x101, g)
249+ require.Equal(t, uint32(pix[0])*0x101, b)
250+}
251+
252+// scanOnceDone sends a request that fails fast on a blank capture and waits
253+// for its result, which proves the previous dump finished.
254+func scanOnceDone(t *testing.T, h *harness) ports.HintScanResult {
255+ t.Helper()
256+ go func() {
257+ creq := <-h.captures
258+ h.captured <- ports.CaptureDone{ID: creq.ID, Err: errors.New("stop")}
259+ }()
260+ h.requests <- ports.HintScanRequest{Seq: 99, Output: "x", Width: 8, Height: 8, Scale: 1}
261+ return <-h.results
262+}
263+
264+func TestHintDumpDisabled(t *testing.T) {
265+ pix, w, hgt := loadBGRA(t)
266+ dir := filepath.Join(t.TempDir(), "hints")
267+ h := start(t, 5*time.Second)
268+ scanOnce(t, h, pix, w, hgt, 1)
269+ scanOnceDone(t, h)
270+ _, err := os.Stat(dir)
271+ require.ErrorIs(t, err, os.ErrNotExist)
272+}
273+
274+func TestHintDumpRetention(t *testing.T) {
275+ dir := t.TempDir()
276+ for i := 0; i < 60; i++ {
277+ for _, ext := range []string{".png", ".annotated.png", ".json"} {
278+ name := fmt.Sprintf("20260101-000000.%03d-o%s", i, ext)
279+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o600))
280+ }
281+ }
282+ require.NoError(t, prune(dir, maxDumps))
283+ entries, err := os.ReadDir(dir)
284+ require.NoError(t, err)
285+ require.Len(t, entries, maxDumps*3)
286+ require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
287+}
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 +3 -1
@@ -52,6 +52,7 @@ type session struct {
5252 km *xkb.Keymap
5353 core *core.Core
5454 dmabuf ports.DMABufSupport
55+ hintOpts hints.Options
5556 wsIDs *workspaceid.IDs
5657 captureAllow *captureallow.Store
5758 cast *screencast.Service // nil without the D-Bus services
@@ -101,6 +102,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
101102 return s, err
102103 }
103104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105+ s.hintOpts = hintOptions(ctx)
104106 // Every output renders on the same GPU: its formats are the clients'.
105107 s.dmabuf = vulkan.Probe()
106108 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -347,7 +349,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
347349 go func() {
348350 defer workers.Done()
349351 // Run only returns when ctx is done: nothing to report.
350- _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults)
352+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
351353 }()
352354 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
353355 go func() { defer workers.Done(); done <- c.Run(ctx) }()
@@ -52,6 +52,7 @@ type session struct {
52 km *xkb.Keymap52 km *xkb.Keymap
53 core *core.Core53 core *core.Core
54 dmabuf ports.DMABufSupport54 dmabuf ports.DMABufSupport
55+ hintOpts hints.Options
55 wsIDs *workspaceid.IDs56 wsIDs *workspaceid.IDs
56 captureAllow *captureallow.Store57 captureAllow *captureallow.Store
57 cast *screencast.Service // nil without the D-Bus services58 cast *screencast.Service // nil without the D-Bus services
@@ -101,6 +102,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
101 return s, err102 return s, err
102 }103 }
103 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")104 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
105+ s.hintOpts = hintOptions(ctx)
104 // 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'.
105 s.dmabuf = vulkan.Probe()107 s.dmabuf = vulkan.Probe()
106 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")108 log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
@@ -347,7 +349,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
347 go func() {349 go func() {
348 defer workers.Done()350 defer workers.Done()
349 // Run only returns when ctx is done: nothing to report.351 // Run only returns when ctx is done: nothing to report.
350- _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults)352+ _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts)
351 }()353 }()
352 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()354 go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
353 go func() { defer workers.Done(); done <- c.Run(ctx) }()355 go func() { defer workers.Done(); done <- c.Run(ctx) }()

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