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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +90 -4
M internal/core/hint.go +55 -1
| @@ -3,6 +3,7 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "cmp" | |
| 5 | 5 | "context" | |
| 6 | + | "math" | |
| 6 | 7 | "slices" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -32,6 +33,7 @@ const ( | |||
| 32 | 33 | // coordinates of its centre. | |
| 33 | 34 | type hintTarget struct { | |
| 34 | 35 | x, y float64 | |
| 36 | + | score float32 | |
| 35 | 37 | window WindowID | |
| 36 | 38 | source hintSourceGeom // source window and its request-time client geometry | |
| 37 | 39 | label string | |
| @@ -168,12 +170,13 @@ func (c *Core) hintResult(res ports.HintScanResult) bool { | |||
| 168 | 170 | if !ok { | |
| 169 | 171 | 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 | 175 | if len(kept) == 0 { | |
| 174 | 176 | c.hintCancel() | |
| 175 | 177 | return true | |
| 176 | 178 | } | |
| 179 | + | kept = c.hintSpread(sc, kept) | |
| 177 | 180 | slices.SortStableFunc(kept, func(a, b hintTarget) int { | |
| 178 | 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 | 195 | 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 | 249 | // hintSource resolves the request-time source window only while its client | |
| 196 | 250 | // geometry is unchanged. | |
| 197 | 251 | 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 float64 | 35 | x, y float64 | ||
| 36 | + | score float32 | |||
| 35 | window WindowID | 37 | window WindowID | ||
| 36 | source hintSourceGeom // source window and its request-time client geometry | 38 | source hintSourceGeom // source window and its request-time client geometry | ||
| 37 | label string | 39 | label string | ||
| @@ -168,12 +170,13 @@ func (c *Core) hintResult(res ports.HintScanResult) bool { | |||||
| 168 | if !ok { | 170 | if !ok { | ||
| 169 | continue | 171 | 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 true | 177 | 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 true | 195 | 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 client | 249 | // 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 { | |||
| 132 | 132 | ||
| 133 | 133 | // inside is a target of the given size inside window 1. | |
| 134 | 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 | 138 | func TestHintRequestOnBind(t *testing.T) { | |
| @@ -226,7 +226,7 @@ func TestHintTwoLetterLabels(t *testing.T) { | |||
| 226 | 226 | h := newHint(t, core.Options{}) | |
| 227 | 227 | var targets []ports.HintTarget | |
| 228 | 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 | 231 | s := h.show(targets...) | |
| 232 | 232 | if len(s.HintLabels) != 27 { | |
| @@ -264,7 +264,7 @@ func TestHintTypingFilters(t *testing.T) { | |||
| 264 | 264 | h := newHint(t, core.Options{}) | |
| 265 | 265 | var targets []ports.HintTarget | |
| 266 | 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 | 269 | h.show(targets...) | |
| 270 | 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 | + | } | |
| @@ -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.HintTarget | 227 | 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.HintTarget | 265 | 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 | + | } | |||