detached 54fa090

fix(hints): bound detector work and account for clamped labels

commit
54fa09059eb686468fd8420342c1a15bff6eb5de
author
brice · · 2026-10-22 05:39 UTC
parent
08d5eeb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/hint-detector
7 files · +228 -12
M internal/adapters/hints/detector.go +5 -2
@@ -57,7 +57,7 @@ const (
5757 // maxString and maxBoxes bound what a misbehaving child can make us
5858 // allocate.
5959 maxString = 1 << 16
60- maxBoxes = 1 << 20
60+ maxBoxes = 64 * 1000 // runner default max-det per region, at most 64 regions
6161 )
6262
6363 var (
@@ -318,7 +318,9 @@ func (d *procDetector) start(ctx context.Context) error {
318318 timedOut := !timer.Stop()
319319 if err != nil {
320320 p.kill()
321- if timedOut {
321+ if ctx.Err() != nil {
322+ err = ctx.Err()
323+ } else if timedOut {
322324 err = errors.New("hello timed out")
323325 }
324326 return fmt.Errorf("hints: detector hello: %w", err)
@@ -384,6 +386,7 @@ func (d *procDetector) Close() error {
384386 if p == nil {
385387 return nil
386388 }
389+ defer func() { _ = p.out.Close() }()
387390 _ = p.in.Close()
388391 done := make(chan struct{})
389392 go func() { p.wait(); close(done) }()
@@ -57,7 +57,7 @@ const (
57 // maxString and maxBoxes bound what a misbehaving child can make us57 // maxString and maxBoxes bound what a misbehaving child can make us
58 // allocate.58 // allocate.
59 maxString = 1 << 1659 maxString = 1 << 16
60- maxBoxes = 1 << 2060+ maxBoxes = 64 * 1000 // runner default max-det per region, at most 64 regions
61 )61 )
62 62
63 var (63 var (
@@ -318,7 +318,9 @@ func (d *procDetector) start(ctx context.Context) error {
318 timedOut := !timer.Stop()318 timedOut := !timer.Stop()
319 if err != nil {319 if err != nil {
320 p.kill()320 p.kill()
321- if timedOut {321+ if ctx.Err() != nil {
322+ err = ctx.Err()
323+ } else if timedOut {
322 err = errors.New("hello timed out")324 err = errors.New("hello timed out")
323 }325 }
324 return fmt.Errorf("hints: detector hello: %w", err)326 return fmt.Errorf("hints: detector hello: %w", err)
@@ -384,6 +386,7 @@ func (d *procDetector) Close() error {
384 if p == nil {386 if p == nil {
385 return nil387 return nil
386 }388 }
389+ defer func() { _ = p.out.Close() }()
387 _ = p.in.Close()390 _ = p.in.Close()
388 done := make(chan struct{})391 done := make(chan struct{})
389 go func() { p.wait(); close(done) }()392 go func() { p.wait(); close(done) }()
M internal/adapters/hints/detector_test.go +115 -0
@@ -4,8 +4,12 @@ import (
44 "bytes"
55 "context"
66 "encoding/binary"
7+ "fmt"
8+ "io"
79 "math"
10+ "net"
811 "os"
12+ "path/filepath"
913 "testing"
1014 "time"
1115
@@ -156,3 +160,114 @@ func TestProcDetectorCommand(t *testing.T) {
156160 require.Equal(t, want, d.cmd().Args)
157161 }
158162 }
163+
164+func TestResponseBoxLimit(t *testing.T) {
165+ _, err := readResponse(bytes.NewReader(cat([]byte("NHRS"), u32(1), u64(1), u32(0), f32(0), u32(64001))), 1)
166+ require.ErrorIs(t, err, errProtocol)
167+}
168+
169+func TestPrepareDetections(t *testing.T) {
170+ regions := []region{{0, 0, 100, 100}, {50, 0, 100, 100}}
171+ valid := detection{x1: 90, y1: 10, x2: 100, y2: 30, score: .5}
172+ invalid := detection{x1: 80, y1: 0, x2: 120, y2: 40, score: float32(math.NaN()), region: 1}
173+ badRegion := valid
174+ badRegion.region = 2
175+ got := prepareDetections([]detection{valid, invalid, badRegion}, regions, 150, 100)
176+ require.Equal(t, []detection{valid}, dropCutBoxes(got, regions))
177+ many := make([]detection, maxWorkBoxes+5)
178+ for i := range many {
179+ many[i] = valid
180+ many[i].score = float32(i) / float32(len(many))
181+ }
182+ got = prepareDetections(many, nil, 150, 100)
183+ require.Len(t, got, maxWorkBoxes)
184+ require.Equal(t, many[len(many)-1].score, got[0].score)
185+ require.Equal(t, many[5].score, got[len(got)-1].score)
186+}
187+
188+// TestDetectorChild is a real subprocess speaking just enough protocol to
189+// synchronize cancellation without timers or project-interface doubles.
190+func TestDetectorChild(t *testing.T) {
191+ mode := os.Getenv("HINT_CHILD_MODE")
192+ if mode == "" {
193+ return
194+ }
195+ conn, err := net.Dial("unix", os.Getenv("HINT_CHILD_SOCKET"))
196+ if err != nil {
197+ os.Exit(2)
198+ }
199+ defer conn.Close()
200+ if mode != "hello" {
201+ _, _ = os.Stdout.Write(cat([]byte("NHNT"), u32(1), u32(0), u32(3), []byte("cpu"), u32(1280)))
202+ }
203+ if mode == "request" {
204+ b := make([]byte, 44) // request header, confidence and one BGRA pixel
205+ if _, err := io.ReadFull(os.Stdin, b); err != nil {
206+ os.Exit(3)
207+ }
208+ }
209+ _, _ = conn.Write([]byte{1})
210+ _, _ = io.Copy(io.Discard, os.Stdin)
211+ os.Exit(0)
212+}
213+
214+func childDetector(t *testing.T, mode string) (*procDetector, net.Listener) {
215+ t.Helper()
216+ dir := t.TempDir()
217+ socket := filepath.Join(dir, "ready")
218+ listener, err := net.Listen("unix", socket)
219+ require.NoError(t, err)
220+ t.Cleanup(func() { _ = listener.Close() })
221+ t.Setenv("HINT_CHILD_MODE", mode)
222+ t.Setenv("HINT_CHILD_SOCKET", socket)
223+ exe, err := os.Executable()
224+ require.NoError(t, err)
225+ script := filepath.Join(dir, "detector")
226+ require.NoError(t, os.WriteFile(script, fmt.Appendf(nil, "#!/bin/sh\nexec %q -test.run=^TestDetectorChild$\n", exe), 0700))
227+ d := newProcDetector(script, dir, 0)
228+ t.Cleanup(func() { _ = d.Close() })
229+ return d, listener
230+}
231+
232+func TestDetectorCancellation(t *testing.T) {
233+ for _, mode := range []string{"hello", "request"} {
234+ t.Run(mode, func(t *testing.T) {
235+ d, l := childDetector(t, mode)
236+ ctx, cancel := context.WithCancel(context.Background())
237+ defer cancel()
238+ done := make(chan error, 1)
239+ go func() {
240+ _, err := d.Detect(ctx, detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: make([]byte, 4)}})
241+ done <- err
242+ }()
243+ require.NoError(t, l.(*net.UnixListener).SetDeadline(time.Now().Add(5*time.Second)))
244+ conn, err := l.Accept()
245+ require.NoError(t, err)
246+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
247+ var ready [1]byte
248+ _, err = io.ReadFull(conn, ready[:])
249+ _ = conn.Close()
250+ require.NoError(t, err)
251+ cancel()
252+ select {
253+ case err := <-done:
254+ require.ErrorIs(t, err, context.Canceled)
255+ case <-time.After(5 * time.Second):
256+ t.Fatal("cancellation did not finish")
257+ }
258+ })
259+ }
260+}
261+
262+func TestDetectorCloseClosesOutput(t *testing.T) {
263+ d, l := childDetector(t, "close")
264+ require.NoError(t, d.start(context.Background()))
265+ conn, err := l.Accept()
266+ require.NoError(t, err)
267+ _ = conn.Close()
268+ p := d.proc
269+ require.NoError(t, d.Close())
270+ _, err = p.out.Read(make([]byte, 1))
271+ require.ErrorIs(t, err, os.ErrClosed)
272+ require.NoError(t, d.Close())
273+}
@@ -4,8 +4,12 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "encoding/binary"6 "encoding/binary"
7+ "fmt"
8+ "io"
7 "math"9 "math"
10+ "net"
8 "os"11 "os"
12+ "path/filepath"
9 "testing"13 "testing"
10 "time"14 "time"
11 15
@@ -156,3 +160,114 @@ func TestProcDetectorCommand(t *testing.T) {
156 require.Equal(t, want, d.cmd().Args)160 require.Equal(t, want, d.cmd().Args)
157 }161 }
158 }162 }
163+
164+func TestResponseBoxLimit(t *testing.T) {
165+ _, err := readResponse(bytes.NewReader(cat([]byte("NHRS"), u32(1), u64(1), u32(0), f32(0), u32(64001))), 1)
166+ require.ErrorIs(t, err, errProtocol)
167+}
168+
169+func TestPrepareDetections(t *testing.T) {
170+ regions := []region{{0, 0, 100, 100}, {50, 0, 100, 100}}
171+ valid := detection{x1: 90, y1: 10, x2: 100, y2: 30, score: .5}
172+ invalid := detection{x1: 80, y1: 0, x2: 120, y2: 40, score: float32(math.NaN()), region: 1}
173+ badRegion := valid
174+ badRegion.region = 2
175+ got := prepareDetections([]detection{valid, invalid, badRegion}, regions, 150, 100)
176+ require.Equal(t, []detection{valid}, dropCutBoxes(got, regions))
177+ many := make([]detection, maxWorkBoxes+5)
178+ for i := range many {
179+ many[i] = valid
180+ many[i].score = float32(i) / float32(len(many))
181+ }
182+ got = prepareDetections(many, nil, 150, 100)
183+ require.Len(t, got, maxWorkBoxes)
184+ require.Equal(t, many[len(many)-1].score, got[0].score)
185+ require.Equal(t, many[5].score, got[len(got)-1].score)
186+}
187+
188+// TestDetectorChild is a real subprocess speaking just enough protocol to
189+// synchronize cancellation without timers or project-interface doubles.
190+func TestDetectorChild(t *testing.T) {
191+ mode := os.Getenv("HINT_CHILD_MODE")
192+ if mode == "" {
193+ return
194+ }
195+ conn, err := net.Dial("unix", os.Getenv("HINT_CHILD_SOCKET"))
196+ if err != nil {
197+ os.Exit(2)
198+ }
199+ defer conn.Close()
200+ if mode != "hello" {
201+ _, _ = os.Stdout.Write(cat([]byte("NHNT"), u32(1), u32(0), u32(3), []byte("cpu"), u32(1280)))
202+ }
203+ if mode == "request" {
204+ b := make([]byte, 44) // request header, confidence and one BGRA pixel
205+ if _, err := io.ReadFull(os.Stdin, b); err != nil {
206+ os.Exit(3)
207+ }
208+ }
209+ _, _ = conn.Write([]byte{1})
210+ _, _ = io.Copy(io.Discard, os.Stdin)
211+ os.Exit(0)
212+}
213+
214+func childDetector(t *testing.T, mode string) (*procDetector, net.Listener) {
215+ t.Helper()
216+ dir := t.TempDir()
217+ socket := filepath.Join(dir, "ready")
218+ listener, err := net.Listen("unix", socket)
219+ require.NoError(t, err)
220+ t.Cleanup(func() { _ = listener.Close() })
221+ t.Setenv("HINT_CHILD_MODE", mode)
222+ t.Setenv("HINT_CHILD_SOCKET", socket)
223+ exe, err := os.Executable()
224+ require.NoError(t, err)
225+ script := filepath.Join(dir, "detector")
226+ require.NoError(t, os.WriteFile(script, fmt.Appendf(nil, "#!/bin/sh\nexec %q -test.run=^TestDetectorChild$\n", exe), 0700))
227+ d := newProcDetector(script, dir, 0)
228+ t.Cleanup(func() { _ = d.Close() })
229+ return d, listener
230+}
231+
232+func TestDetectorCancellation(t *testing.T) {
233+ for _, mode := range []string{"hello", "request"} {
234+ t.Run(mode, func(t *testing.T) {
235+ d, l := childDetector(t, mode)
236+ ctx, cancel := context.WithCancel(context.Background())
237+ defer cancel()
238+ done := make(chan error, 1)
239+ go func() {
240+ _, err := d.Detect(ctx, detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: make([]byte, 4)}})
241+ done <- err
242+ }()
243+ require.NoError(t, l.(*net.UnixListener).SetDeadline(time.Now().Add(5*time.Second)))
244+ conn, err := l.Accept()
245+ require.NoError(t, err)
246+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
247+ var ready [1]byte
248+ _, err = io.ReadFull(conn, ready[:])
249+ _ = conn.Close()
250+ require.NoError(t, err)
251+ cancel()
252+ select {
253+ case err := <-done:
254+ require.ErrorIs(t, err, context.Canceled)
255+ case <-time.After(5 * time.Second):
256+ t.Fatal("cancellation did not finish")
257+ }
258+ })
259+ }
260+}
261+
262+func TestDetectorCloseClosesOutput(t *testing.T) {
263+ d, l := childDetector(t, "close")
264+ require.NoError(t, d.start(context.Background()))
265+ conn, err := l.Accept()
266+ require.NoError(t, err)
267+ _ = conn.Close()
268+ p := d.proc
269+ require.NoError(t, d.Close())
270+ _, err = p.out.Read(make([]byte, 1))
271+ require.ErrorIs(t, err, os.ErrClosed)
272+ require.NoError(t, d.Close())
273+}
M internal/adapters/hints/hints.go +40 -0
@@ -4,11 +4,13 @@
44 package hints
55
66 import (
7+ "cmp"
78 "context"
89 "errors"
910 "fmt"
1011 "image"
1112 "math"
13+ "slices"
1214 "time"
1315
1416 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -210,6 +212,9 @@ func (w *worker) reap(id uint64) {
210212 // minTarget is the smallest target side kept, in logical pixels.
211213 const minTarget = 4
212214
215+// maxWorkBoxes bounds the quadratic cut-box and merge passes.
216+const maxWorkBoxes = 2000
217+
213218 // scan sends the frame to the detector and turns the boxes into targets. It
214219 // releases the frame, or hands it to the dump job.
215220 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
@@ -227,6 +232,7 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
227232 // Boxes cut by a tile's inner edge are dropped when a neighbour tile
228233 // saw the element whole; then, on the union of all tiles and after
229234 // rounding to logical pixels, duplicates and nested boxes are merged.
235+ dets = prepareDetections(dets, regions, req.Width, req.Height)
230236 res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height))
231237 if w.dumpDir != "" {
232238 // The dump reads the frame after the result is sent and releases it.
@@ -271,3 +277,37 @@ func targetsOf(dets []detection, scale float64, width, height int) []ports.HintT
271277 }
272278 return out
273279 }
280+
281+// prepareDetections validates and clamps boxes before any pairwise work,
282+// preserving their region metadata for cut-edge suppression.
283+func prepareDetections(in []detection, regions []region, width, height int) []detection {
284+ out := make([]detection, 0, len(in))
285+ for _, d := range in {
286+ if d.region < 0 || d.region >= max(1, len(regions)) || !(d.score >= 0 && d.score <= 1) {
287+ continue
288+ }
289+ valid := true
290+ for _, v := range []float32{d.x1, d.y1, d.x2, d.y2} {
291+ if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) {
292+ valid = false
293+ break
294+ }
295+ }
296+ if !valid {
297+ continue
298+ }
299+ d.x1 = max(0, min(d.x1, float32(width)))
300+ d.x2 = max(0, min(d.x2, float32(width)))
301+ d.y1 = max(0, min(d.y1, float32(height)))
302+ d.y2 = max(0, min(d.y2, float32(height)))
303+ if d.x2 <= d.x1 || d.y2 <= d.y1 {
304+ continue
305+ }
306+ out = append(out, d)
307+ }
308+ slices.SortStableFunc(out, func(a, b detection) int { return cmp.Compare(b.score, a.score) })
309+ if len(out) > maxWorkBoxes {
310+ out = out[:maxWorkBoxes]
311+ }
312+ return out
313+}
@@ -4,11 +4,13 @@
4 package hints4 package hints
5 5
6 import (6 import (
7+ "cmp"
7 "context"8 "context"
8 "errors"9 "errors"
9 "fmt"10 "fmt"
10 "image"11 "image"
11 "math"12 "math"
13+ "slices"
12 "time"14 "time"
13 15
14 "github.com/bnema/neferwl/internal/adapters/logging"16 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -210,6 +212,9 @@ func (w *worker) reap(id uint64) {
210 // minTarget is the smallest target side kept, in logical pixels.212 // minTarget is the smallest target side kept, in logical pixels.
211 const minTarget = 4213 const minTarget = 4
212 214
215+// maxWorkBoxes bounds the quadratic cut-box and merge passes.
216+const maxWorkBoxes = 2000
217+
213 // scan sends the frame to the detector and turns the boxes into targets. It218 // scan sends the frame to the detector and turns the boxes into targets. It
214 // releases the frame, or hands it to the dump job.219 // releases the frame, or hands it to the dump job.
215 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {220 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
@@ -227,6 +232,7 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
227 // Boxes cut by a tile's inner edge are dropped when a neighbour tile232 // Boxes cut by a tile's inner edge are dropped when a neighbour tile
228 // saw the element whole; then, on the union of all tiles and after233 // saw the element whole; then, on the union of all tiles and after
229 // rounding to logical pixels, duplicates and nested boxes are merged.234 // rounding to logical pixels, duplicates and nested boxes are merged.
235+ dets = prepareDetections(dets, regions, req.Width, req.Height)
230 res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height))236 res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height))
231 if w.dumpDir != "" {237 if w.dumpDir != "" {
232 // The dump reads the frame after the result is sent and releases it.238 // The dump reads the frame after the result is sent and releases it.
@@ -271,3 +277,37 @@ func targetsOf(dets []detection, scale float64, width, height int) []ports.HintT
271 }277 }
272 return out278 return out
273 }279 }
280+
281+// prepareDetections validates and clamps boxes before any pairwise work,
282+// preserving their region metadata for cut-edge suppression.
283+func prepareDetections(in []detection, regions []region, width, height int) []detection {
284+ out := make([]detection, 0, len(in))
285+ for _, d := range in {
286+ if d.region < 0 || d.region >= max(1, len(regions)) || !(d.score >= 0 && d.score <= 1) {
287+ continue
288+ }
289+ valid := true
290+ for _, v := range []float32{d.x1, d.y1, d.x2, d.y2} {
291+ if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) {
292+ valid = false
293+ break
294+ }
295+ }
296+ if !valid {
297+ continue
298+ }
299+ d.x1 = max(0, min(d.x1, float32(width)))
300+ d.x2 = max(0, min(d.x2, float32(width)))
301+ d.y1 = max(0, min(d.y1, float32(height)))
302+ d.y2 = max(0, min(d.y2, float32(height)))
303+ if d.x2 <= d.x1 || d.y2 <= d.y1 {
304+ continue
305+ }
306+ out = append(out, d)
307+ }
308+ slices.SortStableFunc(out, func(a, b detection) int { return cmp.Compare(b.score, a.score) })
309+ if len(out) > maxWorkBoxes {
310+ out = out[:maxWorkBoxes]
311+ }
312+ return out
313+}
M internal/adapters/hints/merge.go +1 -1
@@ -51,7 +51,7 @@ func mergeTargets(in []ports.HintTarget) []ports.HintTarget {
5151 return cmp.Or(cmp.Compare(b.Score, a.Score), cmp.Compare(a.Rect.Y, b.Rect.Y), cmp.Compare(a.Rect.X, b.Rect.X),
5252 cmp.Compare(area(a.Rect), area(b.Rect)))
5353 })
54- kept := s[:0:0]
54+ kept := s[:0]
5555 for _, t := range s {
5656 dup := false
5757 for _, k := range kept {
@@ -51,7 +51,7 @@ func mergeTargets(in []ports.HintTarget) []ports.HintTarget {
51 return cmp.Or(cmp.Compare(b.Score, a.Score), cmp.Compare(a.Rect.Y, b.Rect.Y), cmp.Compare(a.Rect.X, b.Rect.X),51 return cmp.Or(cmp.Compare(b.Score, a.Score), cmp.Compare(a.Rect.Y, b.Rect.Y), cmp.Compare(a.Rect.X, b.Rect.X),
52 cmp.Compare(area(a.Rect), area(b.Rect)))52 cmp.Compare(area(a.Rect), area(b.Rect)))
53 })53 })
54- kept := s[:0:0]54+ kept := s[:0]
55 for _, t := range s {55 for _, t := range s {
56 dup := false56 dup := false
57 for _, k := range kept {57 for _, k := range kept {
M internal/adapters/hints/tile.go +1 -1
@@ -19,7 +19,7 @@ type region struct{ x, y, w, h int }
1919 // tileRegions returns the crops to detect separately on a width x height
2020 // frame, or nil for one whole-frame detection (a 16:9 frame, or one whose
2121 // long side is at most wholeMaxSide). Otherwise the frame is covered by exact
22-// 16:9 tiles (w*9 == h*16: the detector then uses its rectangular graph) of
22+// 16:9 tiles (w*9 == h*16; explicit crops always use square letterboxing) of
2323 // height min(height, 1440) rounded down to a multiple of 9, overlapping by at
2424 // least 10 % of the tile side on each axis; the last tile of a row or column
2525 // is aligned to the frame edge. Tiles are listed row by row. A frame that
@@ -19,7 +19,7 @@ type region struct{ x, y, w, h int }
19 // tileRegions returns the crops to detect separately on a width x height19 // tileRegions returns the crops to detect separately on a width x height
20 // frame, or nil for one whole-frame detection (a 16:9 frame, or one whose20 // frame, or nil for one whole-frame detection (a 16:9 frame, or one whose
21 // long side is at most wholeMaxSide). Otherwise the frame is covered by exact21 // long side is at most wholeMaxSide). Otherwise the frame is covered by exact
22-// 16:9 tiles (w*9 == h*16: the detector then uses its rectangular graph) of22+// 16:9 tiles (w*9 == h*16; explicit crops always use square letterboxing) of
23 // height min(height, 1440) rounded down to a multiple of 9, overlapping by at23 // height min(height, 1440) rounded down to a multiple of 9, overlapping by at
24 // least 10 % of the tile side on each axis; the last tile of a row or column24 // least 10 % of the tile side on each axis; the last tile of a row or column
25 // is aligned to the frame edge. Tiles are listed row by row. A frame that25 // is aligned to the frame edge. Tiles are listed row by row. A frame that
M internal/core/hint.go +21 -8
@@ -216,25 +216,38 @@ func (c *Core) hintLabelSize(sc *screen) (w, h int) {
216216 }
217217 font = px / scale
218218 }
219- return int(math.Ceil(2*0.6*font + 8)), int(math.Ceil(1.25*font + 4))
219+ font = math.Min(font, 256/scale)
220+ s := font / 13
221+ return int(math.Ceil(2*0.6*font + 6*s + 2/scale + 1)), int(math.Ceil(1.25*font + 2*s + 2/scale + 1))
220222 }
221223
222-// hintSpread drops the targets whose labels would overlap another label. A
223-// label hangs off its target's top-left corner, so two labels overlap when
224-// the corners are closer than the label size on both axes. Targets are
225-// considered by descending score (then top-left to bottom-right): the first
226-// keeps its place, a later one that collides with a kept one is dropped.
224+// hintLabelRect mirrors the renderer's outside top-left anchor and output
225+// clamping, in output-local logical coordinates.
226+func hintLabelRect(sc *screen, target ports.Rect, lw, lh int) ports.Rect {
227+ bounds := sc.rect()
228+ inset := int(math.Round(0.25 * float64(lh)))
229+ return ports.Rect{X: min(max(target.X-lw+inset, 0), bounds.W-lw), Y: min(max(target.Y-lh+inset, 0), bounds.H-lh), W: lw, H: lh}
230+}
231+
232+// hintSpread drops targets whose output-clamped label rectangles intersect.
233+// Highest scores win; four times the label budget gives spreading headroom
234+// while bounding the quadratic pass. hintlabel.FontPixels is the source of
235+// truth for the font rule mirrored above.
227236 func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget {
228237 lw, lh := c.hintLabelSize(sc)
229238 order := slices.Clone(ts)
230239 slices.SortStableFunc(order, func(a, b hintTarget) int {
231240 return cmp.Or(cmp.Compare(b.score, a.score), cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
232241 })
233- kept := order[:0:0]
242+ if len(order) > 4*hintMaxTargets {
243+ order = order[:4*hintMaxTargets]
244+ }
245+ kept := order[:0]
234246 for _, t := range order {
235247 clash := false
236248 for _, k := range kept {
237- if abs(t.rect.X-k.rect.X) < lw && abs(t.rect.Y-k.rect.Y) < lh {
249+ a, b := hintLabelRect(sc, t.rect, lw, lh), hintLabelRect(sc, k.rect, lw, lh)
250+ if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
238251 clash = true
239252 break
240253 }
@@ -216,25 +216,38 @@ func (c *Core) hintLabelSize(sc *screen) (w, h int) {
216 }216 }
217 font = px / scale217 font = px / scale
218 }218 }
219- return int(math.Ceil(2*0.6*font + 8)), int(math.Ceil(1.25*font + 4))219+ font = math.Min(font, 256/scale)
220+ s := font / 13
221+ return int(math.Ceil(2*0.6*font + 6*s + 2/scale + 1)), int(math.Ceil(1.25*font + 2*s + 2/scale + 1))
220 }222 }
221 223
222-// hintSpread drops the targets whose labels would overlap another label. A224+// hintLabelRect mirrors the renderer's outside top-left anchor and output
223-// label hangs off its target's top-left corner, so two labels overlap when225+// clamping, in output-local logical coordinates.
224-// the corners are closer than the label size on both axes. Targets are226+func hintLabelRect(sc *screen, target ports.Rect, lw, lh int) ports.Rect {
225-// considered by descending score (then top-left to bottom-right): the first227+ bounds := sc.rect()
226-// keeps its place, a later one that collides with a kept one is dropped.228+ inset := int(math.Round(0.25 * float64(lh)))
229+ return ports.Rect{X: min(max(target.X-lw+inset, 0), bounds.W-lw), Y: min(max(target.Y-lh+inset, 0), bounds.H-lh), W: lw, H: lh}
230+}
231+
232+// hintSpread drops targets whose output-clamped label rectangles intersect.
233+// Highest scores win; four times the label budget gives spreading headroom
234+// while bounding the quadratic pass. hintlabel.FontPixels is the source of
235+// truth for the font rule mirrored above.
227 func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget {236 func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget {
228 lw, lh := c.hintLabelSize(sc)237 lw, lh := c.hintLabelSize(sc)
229 order := slices.Clone(ts)238 order := slices.Clone(ts)
230 slices.SortStableFunc(order, func(a, b hintTarget) int {239 slices.SortStableFunc(order, func(a, b hintTarget) int {
231 return cmp.Or(cmp.Compare(b.score, a.score), cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))240 return cmp.Or(cmp.Compare(b.score, a.score), cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
232 })241 })
233- kept := order[:0:0]242+ if len(order) > 4*hintMaxTargets {
243+ order = order[:4*hintMaxTargets]
244+ }
245+ kept := order[:0]
234 for _, t := range order {246 for _, t := range order {
235 clash := false247 clash := false
236 for _, k := range kept {248 for _, k := range kept {
237- if abs(t.rect.X-k.rect.X) < lw && abs(t.rect.Y-k.rect.Y) < lh {249+ a, b := hintLabelRect(sc, t.rect, lw, lh), hintLabelRect(sc, k.rect, lw, lh)
250+ if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
238 clash = true251 clash = true
239 break252 break
240 }253 }
A internal/core/hint_internal_test.go +45 -0
@@ -0,0 +1,45 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "math"
7+ "testing"
8+)
9+
10+func TestHintLabelGeometry(t *testing.T) {
11+ for _, scale := range []float64{1, 1.5} {
12+ sc := &screen{scale: scale, info: ports.OutputInfo{Width: 1500, Height: 1200}}
13+ sc.mon = newMonitor("", "")
14+ sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale))
15+ c := &Core{}
16+ c.cfg.Hint.Size = 200
17+ w, h := c.hintLabelSize(sc)
18+ font := math.Min(200, 256/scale)
19+ require.Equal(t, int(math.Ceil(1.2*font+6*font/13+2/scale+1)), w)
20+ require.Equal(t, int(math.Ceil(1.25*font+2*font/13+2/scale+1)), h)
21+ bounds := sc.rect()
22+ // Corner separation exceeds label width, but right-edge clamping folds
23+ // the second label back over the first.
24+ a := hintTarget{rect: ports.Rect{X: bounds.W - w - 1, Y: bounds.H - h - 1}, score: 1}
25+ b := hintTarget{rect: ports.Rect{X: bounds.W - 1, Y: bounds.H - 1}, score: .5}
26+ require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 1)
27+ b.rect = ports.Rect{X: 0, Y: 0}
28+ require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 2)
29+ }
30+}
31+
32+func TestHintSpreadBudget(t *testing.T) {
33+ sc := &screen{scale: 1, info: ports.OutputInfo{Width: 1000000, Height: 1000}}
34+ sc.mon = newMonitor("", "")
35+ sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale))
36+ c := &Core{}
37+ ts := make([]hintTarget, 4*hintMaxTargets+10)
38+ for i := range ts {
39+ ts[i] = hintTarget{rect: ports.Rect{X: 100 + i*100, Y: 100}, score: float32(i)}
40+ }
41+ got := c.hintSpread(sc, ts)
42+ require.Len(t, got, 4*hintMaxTargets)
43+ require.Equal(t, float32(len(ts)-1), got[0].score)
44+ require.Equal(t, float32(10), got[len(got)-1].score)
45+}
@@ -0,0 +1,45 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "math"
7+ "testing"
8+)
9+
10+func TestHintLabelGeometry(t *testing.T) {
11+ for _, scale := range []float64{1, 1.5} {
12+ sc := &screen{scale: scale, info: ports.OutputInfo{Width: 1500, Height: 1200}}
13+ sc.mon = newMonitor("", "")
14+ sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale))
15+ c := &Core{}
16+ c.cfg.Hint.Size = 200
17+ w, h := c.hintLabelSize(sc)
18+ font := math.Min(200, 256/scale)
19+ require.Equal(t, int(math.Ceil(1.2*font+6*font/13+2/scale+1)), w)
20+ require.Equal(t, int(math.Ceil(1.25*font+2*font/13+2/scale+1)), h)
21+ bounds := sc.rect()
22+ // Corner separation exceeds label width, but right-edge clamping folds
23+ // the second label back over the first.
24+ a := hintTarget{rect: ports.Rect{X: bounds.W - w - 1, Y: bounds.H - h - 1}, score: 1}
25+ b := hintTarget{rect: ports.Rect{X: bounds.W - 1, Y: bounds.H - 1}, score: .5}
26+ require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 1)
27+ b.rect = ports.Rect{X: 0, Y: 0}
28+ require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 2)
29+ }
30+}
31+
32+func TestHintSpreadBudget(t *testing.T) {
33+ sc := &screen{scale: 1, info: ports.OutputInfo{Width: 1000000, Height: 1000}}
34+ sc.mon = newMonitor("", "")
35+ sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale))
36+ c := &Core{}
37+ ts := make([]hintTarget, 4*hintMaxTargets+10)
38+ for i := range ts {
39+ ts[i] = hintTarget{rect: ports.Rect{X: 100 + i*100, Y: 100}, score: float32(i)}
40+ }
41+ got := c.hintSpread(sc, ts)
42+ require.Len(t, got, 4*hintMaxTargets)
43+ require.Equal(t, float32(len(ts)-1), got[0].score)
44+ require.Equal(t, float32(10), got[len(got)-1].score)
45+}

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 24

Tags 10

↑↓ moveenter openesc close