detached ab2b2af

fix(core): keep hint labels from overlapping

commit
ab2b2af3ea7e0852439616414a56d6bdf072ec54
author
brice · · 2026-10-21 15:08 UTC
committer
brice · · 2026-10-22 20:27 UTC
parent
8a43081
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +90 -4
M internal/core/hint.go +55 -1
@@ -3,6 +3,7 @@ package core
33 import (
44 "cmp"
55 "context"
6+ "math"
67 "slices"
78
89 "github.com/bnema/neferwl/internal/ports"
@@ -32,6 +33,7 @@ const (
3233 // coordinates of its centre.
3334 type hintTarget struct {
3435 x, y float64
36+ score float32
3537 window WindowID
3638 source hintSourceGeom // source window and its request-time client geometry
3739 label string
@@ -168,12 +170,13 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
168170 if !ok {
169171 continue
170172 }
171- kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect})
173+ kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect, score: t.Score})
172174 }
173175 if len(kept) == 0 {
174176 c.hintCancel()
175177 return true
176178 }
179+ kept = c.hintSpread(sc, kept)
177180 slices.SortStableFunc(kept, func(a, b hintTarget) int {
178181 return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
179182 })
@@ -192,6 +195,57 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
192195 return true
193196 }
194197
198+// hintLabelSize estimates, in logical pixels, the box of a two-letter label on
199+// sc: a monospace face (advance 0.6 em) plus padding and outline, rounded up.
200+// The font size follows the renderer: hint.size, or the output's density when
201+// it is automatic (same rules as hintlabel.FontPixels, which core cannot
202+// import).
203+func (c *Core) hintLabelSize(sc *screen) (w, h int) {
204+ scale := sc.scale
205+ if scale <= 0 {
206+ scale = 1
207+ }
208+ font := c.cfg.Hint.Size // logical
209+ if font <= 0 {
210+ px := 13 * scale * math.Sqrt(math.Max(1, float64(sc.info.Height)/(1080*scale)))
211+ if sc.info.Width > 0 && sc.info.Height > 0 && sc.info.PhysicalW > 0 && sc.info.PhysicalH > 0 {
212+ ratio := (float64(sc.info.Width) / float64(sc.info.Height)) / (float64(sc.info.PhysicalW) / float64(sc.info.PhysicalH))
213+ if math.Abs(ratio-1) <= .2 {
214+ px = math.Min(math.Round(40*scale), math.Max(math.Round(13*scale), math.Round(3.2*float64(sc.info.Width)/float64(sc.info.PhysicalW))))
215+ }
216+ }
217+ font = px / scale
218+ }
219+ return int(math.Ceil(2*0.6*font + 8)), int(math.Ceil(1.25*font + 4))
220+}
221+
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.
227+func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget {
228+ lw, lh := c.hintLabelSize(sc)
229+ order := slices.Clone(ts)
230+ 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))
232+ })
233+ kept := order[:0:0]
234+ for _, t := range order {
235+ clash := false
236+ for _, k := range kept {
237+ if abs(t.rect.X-k.rect.X) < lw && abs(t.rect.Y-k.rect.Y) < lh {
238+ clash = true
239+ break
240+ }
241+ }
242+ if !clash {
243+ kept = append(kept, t)
244+ }
245+ }
246+ return kept
247+}
248+
195249 // hintSource resolves the request-time source window only while its client
196250 // geometry is unchanged.
197251 func (c *Core) hintSource(sc *screen, root WindowID) (hintSourceGeom, bool) {
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "cmp"4 "cmp"
5 "context"5 "context"
6+ "math"
6 "slices"7 "slices"
7 8
8 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
@@ -32,6 +33,7 @@ const (
32 // coordinates of its centre.33 // coordinates of its centre.
33 type hintTarget struct {34 type hintTarget struct {
34 x, y float6435 x, y float64
36+ score float32
35 window WindowID37 window WindowID
36 source hintSourceGeom // source window and its request-time client geometry38 source hintSourceGeom // source window and its request-time client geometry
37 label string39 label string
@@ -168,12 +170,13 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
168 if !ok {170 if !ok {
169 continue171 continue
170 }172 }
171- kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect})173+ kept = append(kept, hintTarget{x: x, y: y, window: id, source: source, rect: t.Rect, score: t.Score})
172 }174 }
173 if len(kept) == 0 {175 if len(kept) == 0 {
174 c.hintCancel()176 c.hintCancel()
175 return true177 return true
176 }178 }
179+ kept = c.hintSpread(sc, kept)
177 slices.SortStableFunc(kept, func(a, b hintTarget) int {180 slices.SortStableFunc(kept, func(a, b hintTarget) int {
178 return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))181 return cmp.Or(cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X))
179 })182 })
@@ -192,6 +195,57 @@ func (c *Core) hintResult(res ports.HintScanResult) bool {
192 return true195 return true
193 }196 }
194 197
198+// hintLabelSize estimates, in logical pixels, the box of a two-letter label on
199+// sc: a monospace face (advance 0.6 em) plus padding and outline, rounded up.
200+// The font size follows the renderer: hint.size, or the output's density when
201+// it is automatic (same rules as hintlabel.FontPixels, which core cannot
202+// import).
203+func (c *Core) hintLabelSize(sc *screen) (w, h int) {
204+ scale := sc.scale
205+ if scale <= 0 {
206+ scale = 1
207+ }
208+ font := c.cfg.Hint.Size // logical
209+ if font <= 0 {
210+ px := 13 * scale * math.Sqrt(math.Max(1, float64(sc.info.Height)/(1080*scale)))
211+ if sc.info.Width > 0 && sc.info.Height > 0 && sc.info.PhysicalW > 0 && sc.info.PhysicalH > 0 {
212+ ratio := (float64(sc.info.Width) / float64(sc.info.Height)) / (float64(sc.info.PhysicalW) / float64(sc.info.PhysicalH))
213+ if math.Abs(ratio-1) <= .2 {
214+ px = math.Min(math.Round(40*scale), math.Max(math.Round(13*scale), math.Round(3.2*float64(sc.info.Width)/float64(sc.info.PhysicalW))))
215+ }
216+ }
217+ font = px / scale
218+ }
219+ return int(math.Ceil(2*0.6*font + 8)), int(math.Ceil(1.25*font + 4))
220+}
221+
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.
227+func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget {
228+ lw, lh := c.hintLabelSize(sc)
229+ order := slices.Clone(ts)
230+ 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))
232+ })
233+ kept := order[:0:0]
234+ for _, t := range order {
235+ clash := false
236+ for _, k := range kept {
237+ if abs(t.rect.X-k.rect.X) < lw && abs(t.rect.Y-k.rect.Y) < lh {
238+ clash = true
239+ break
240+ }
241+ }
242+ if !clash {
243+ kept = append(kept, t)
244+ }
245+ }
246+ return kept
247+}
248+
195 // hintSource resolves the request-time source window only while its client249 // hintSource resolves the request-time source window only while its client
196 // geometry is unchanged.250 // geometry is unchanged.
197 func (c *Core) hintSource(sc *screen, root WindowID) (hintSourceGeom, bool) {251 func (c *Core) hintSource(sc *screen, root WindowID) (hintSourceGeom, bool) {
M internal/core/hint_test.go +35 -3
@@ -132,7 +132,7 @@ func (h *hintHarness) labelled(n, typed int) ports.Scene {
132132
133133 // inside is a target of the given size inside window 1.
134134 func (h *hintHarness) inside(i int) ports.HintTarget {
135- return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}}
135+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + 100*(i%4), Y: h.win.Y + 10 + 40*(i/4), W: 40, H: 10}}
136136 }
137137
138138 func TestHintRequestOnBind(t *testing.T) {
@@ -226,7 +226,7 @@ func TestHintTwoLetterLabels(t *testing.T) {
226226 h := newHint(t, core.Options{})
227227 var targets []ports.HintTarget
228228 for i := 0; i < 27; i++ {
229- targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
229+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5 + 100*(i%4), Y: h.win.Y + 5 + 40*(i/4), W: 30, H: 10}})
230230 }
231231 s := h.show(targets...)
232232 if len(s.HintLabels) != 27 {
@@ -264,7 +264,7 @@ func TestHintTypingFilters(t *testing.T) {
264264 h := newHint(t, core.Options{})
265265 var targets []ports.HintTarget
266266 for i := 0; i < 27; i++ {
267- targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})
267+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5 + 100*(i%4), Y: h.win.Y + 5 + 40*(i/4), W: 30, H: 10}})
268268 }
269269 h.show(targets...)
270270 h.press("f", 0)
@@ -818,3 +818,35 @@ func TestHintCaptureReachesScene(t *testing.T) {
818818 }
819819 }
820820 }
821+
822+// Labels hang off the targets' top-left corners: targets whose corners are
823+// closer than a label drop, the lowest score first, whatever the input order.
824+func TestHintOverlappingLabelsDropLowerScore(t *testing.T) {
825+ at := func(h *hintHarness, dx, dy int, score float32) ports.HintTarget {
826+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 20 + dx, Y: h.win.Y + 20 + dy, W: 80, H: 30}, Score: score}
827+ }
828+ for _, rev := range []bool{false, true} {
829+ h := newHint(t, core.Options{})
830+ ts := []ports.HintTarget{
831+ at(h, 0, 0, 0.4), // clashes with the next one, loses
832+ at(h, 3, 2, 0.9), // wins
833+ at(h, 300, 0, 0.1), // far away: kept, whatever its score
834+ at(h, 0, 200, 0.5), // far below
835+ at(h, 4, 200, 0.45), // clashes with the previous one
836+ }
837+ if rev {
838+ slices.Reverse(ts)
839+ }
840+ req := h.start()
841+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: ts}
842+ s := h.labelled(3, 0)
843+ var got [][2]int
844+ for _, l := range s.HintLabels {
845+ got = append(got, [2]int{l.Rect.X - h.win.X - 20, l.Rect.Y - h.win.Y - 20})
846+ }
847+ want := [][2]int{{300, 0}, {3, 2}, {0, 200}} // sorted by Y, then X
848+ if !slices.Equal(got, want) {
849+ t.Fatalf("rev=%v: label corners %v, want %v", rev, got, want)
850+ }
851+ }
852+}
@@ -132,7 +132,7 @@ func (h *hintHarness) labelled(n, typed int) ports.Scene {
132 132
133 // inside is a target of the given size inside window 1.133 // inside is a target of the given size inside window 1.
134 func (h *hintHarness) inside(i int) ports.HintTarget {134 func (h *hintHarness) inside(i int) ports.HintTarget {
135- return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + i, Y: h.win.Y + 10 + 12*i, W: 40, H: 10}}135+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 10 + 100*(i%4), Y: h.win.Y + 10 + 40*(i/4), W: 40, H: 10}}
136 }136 }
137 137
138 func TestHintRequestOnBind(t *testing.T) {138 func TestHintRequestOnBind(t *testing.T) {
@@ -226,7 +226,7 @@ func TestHintTwoLetterLabels(t *testing.T) {
226 h := newHint(t, core.Options{})226 h := newHint(t, core.Options{})
227 var targets []ports.HintTarget227 var targets []ports.HintTarget
228 for i := 0; i < 27; i++ {228 for i := 0; i < 27; i++ {
229- targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})229+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5 + 100*(i%4), Y: h.win.Y + 5 + 40*(i/4), W: 30, H: 10}})
230 }230 }
231 s := h.show(targets...)231 s := h.show(targets...)
232 if len(s.HintLabels) != 27 {232 if len(s.HintLabels) != 27 {
@@ -264,7 +264,7 @@ func TestHintTypingFilters(t *testing.T) {
264 h := newHint(t, core.Options{})264 h := newHint(t, core.Options{})
265 var targets []ports.HintTarget265 var targets []ports.HintTarget
266 for i := 0; i < 27; i++ {266 for i := 0; i < 27; i++ {
267- targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5, Y: h.win.Y + 5 + 14*i, W: 30, H: 10}})267+ targets = append(targets, ports.HintTarget{Rect: ports.Rect{X: h.win.X + 5 + 100*(i%4), Y: h.win.Y + 5 + 40*(i/4), W: 30, H: 10}})
268 }268 }
269 h.show(targets...)269 h.show(targets...)
270 h.press("f", 0)270 h.press("f", 0)
@@ -818,3 +818,35 @@ func TestHintCaptureReachesScene(t *testing.T) {
818 }818 }
819 }819 }
820 }820 }
821+
822+// Labels hang off the targets' top-left corners: targets whose corners are
823+// closer than a label drop, the lowest score first, whatever the input order.
824+func TestHintOverlappingLabelsDropLowerScore(t *testing.T) {
825+ at := func(h *hintHarness, dx, dy int, score float32) ports.HintTarget {
826+ return ports.HintTarget{Rect: ports.Rect{X: h.win.X + 20 + dx, Y: h.win.Y + 20 + dy, W: 80, H: 30}, Score: score}
827+ }
828+ for _, rev := range []bool{false, true} {
829+ h := newHint(t, core.Options{})
830+ ts := []ports.HintTarget{
831+ at(h, 0, 0, 0.4), // clashes with the next one, loses
832+ at(h, 3, 2, 0.9), // wins
833+ at(h, 300, 0, 0.1), // far away: kept, whatever its score
834+ at(h, 0, 200, 0.5), // far below
835+ at(h, 4, 200, 0.45), // clashes with the previous one
836+ }
837+ if rev {
838+ slices.Reverse(ts)
839+ }
840+ req := h.start()
841+ h.hints <- ports.HintScanResult{Seq: req.Seq, Output: req.Output, Targets: ts}
842+ s := h.labelled(3, 0)
843+ var got [][2]int
844+ for _, l := range s.HintLabels {
845+ got = append(got, [2]int{l.Rect.X - h.win.X - 20, l.Rect.Y - h.win.Y - 20})
846+ }
847+ want := [][2]int{{300, 0}, {3, 2}, {0, 200}} // sorted by Y, then X
848+ if !slices.Equal(got, want) {
849+ t.Fatalf("rev=%v: label corners %v, want %v", rev, got, want)
850+ }
851+ }
852+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close