detached f9d2447

Merge ec21f3234eec62047f4cd53d14e21a5b02c00735 into e0a861187399be3c440f515e867abc7866b40953

commit
f9d2447a6d239d7dae272802f9af614ce6ee3ca3
author
ermite · · 2026-10-21 14:37 UTC
merge of
e0a8611 + ec21f32 · diff against the first parent
signature
unsigned
14 files · +177 -16
M internal/adapters/drm/focus_flash_test.go +11 -0
@@ -16,3 +16,14 @@ func TestFocusFlashRequiresComposition(t *testing.T) {
1616 _, _, _, reason = overlayCandidate(s, contents, false, nil, nil)
1717 require.Equal(t, "focus_flash", reason)
1818 }
19+
20+func TestSelectionOutlineRequiresComposition(t *testing.T) {
21+ s, contents := fullscreenScene()
22+ s.Outline = &ports.SelectionOutline{Radius: 12, Width: 2}
23+ _, reason := scanoutCandidate(s, contents, 200, 100, nil)
24+ require.Equal(t, "selection_outline", reason)
25+ s, contents = overlayScene()
26+ s.Outline = &ports.SelectionOutline{Radius: 12, Width: 2}
27+ _, _, _, reason = overlayCandidate(s, contents, false, nil, nil)
28+ require.Equal(t, "selection_outline", reason)
29+}
@@ -16,3 +16,14 @@ func TestFocusFlashRequiresComposition(t *testing.T) {
16 _, _, _, reason = overlayCandidate(s, contents, false, nil, nil)16 _, _, _, reason = overlayCandidate(s, contents, false, nil, nil)
17 require.Equal(t, "focus_flash", reason)17 require.Equal(t, "focus_flash", reason)
18 }18 }
19+
20+func TestSelectionOutlineRequiresComposition(t *testing.T) {
21+ s, contents := fullscreenScene()
22+ s.Outline = &ports.SelectionOutline{Radius: 12, Width: 2}
23+ _, reason := scanoutCandidate(s, contents, 200, 100, nil)
24+ require.Equal(t, "selection_outline", reason)
25+ s, contents = overlayScene()
26+ s.Outline = &ports.SelectionOutline{Radius: 12, Width: 2}
27+ _, _, _, reason = overlayCandidate(s, contents, false, nil, nil)
28+ require.Equal(t, "selection_outline", reason)
29+}
M internal/adapters/drm/overlay.go +3 -0
@@ -70,6 +70,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7070 if s.PositionHint.Shown() {
7171 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "position_hint"
7272 }
73+ if s.Outline != nil {
74+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "selection_outline"
75+ }
7376 if s.Flash != nil {
7477 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
7578 }
@@ -70,6 +70,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
70 if s.PositionHint.Shown() {70 if s.PositionHint.Shown() {
71 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "position_hint"71 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "position_hint"
72 }72 }
73+ if s.Outline != nil {
74+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "selection_outline"
75+ }
73 if s.Flash != nil {76 if s.Flash != nil {
74 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"77 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
75 }78 }
M internal/adapters/drm/scanout.go +3 -0
@@ -94,6 +94,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9494 if s.PositionHint.Shown() {
9595 return ports.SurfaceContent{}, "position_hint"
9696 }
97+ if s.Outline != nil {
98+ return ports.SurfaceContent{}, "selection_outline"
99+ }
97100 if s.Flash != nil {
98101 return ports.SurfaceContent{}, "focus_flash"
99102 }
@@ -94,6 +94,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
94 if s.PositionHint.Shown() {94 if s.PositionHint.Shown() {
95 return ports.SurfaceContent{}, "position_hint"95 return ports.SurfaceContent{}, "position_hint"
96 }96 }
97+ if s.Outline != nil {
98+ return ports.SurfaceContent{}, "selection_outline"
99+ }
97 if s.Flash != nil {100 if s.Flash != nil {
98 return ports.SurfaceContent{}, "focus_flash"101 return ports.SurfaceContent{}, "focus_flash"
99 }102 }
M internal/adapters/vulkan/damage.go +8 -0
@@ -304,6 +304,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
304304 area = area.Union(phys(cur.Flash.Rect))
305305 }
306306 }
307+ if (old.Outline == nil) != (cur.Outline == nil) || (old.Outline != nil && cur.Outline != nil && *old.Outline != *cur.Outline) {
308+ if old.Outline != nil {
309+ area = area.Union(phys(old.Outline.Rect))
310+ }
311+ if cur.Outline != nil {
312+ area = area.Union(phys(cur.Outline.Rect))
313+ }
314+ }
307315 for i := range old.Windows {
308316 a, b := old.Windows[i], cur.Windows[i]
309317 if a.ID != b.ID {
@@ -304,6 +304,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim
304 area = area.Union(phys(cur.Flash.Rect))304 area = area.Union(phys(cur.Flash.Rect))
305 }305 }
306 }306 }
307+ if (old.Outline == nil) != (cur.Outline == nil) || (old.Outline != nil && cur.Outline != nil && *old.Outline != *cur.Outline) {
308+ if old.Outline != nil {
309+ area = area.Union(phys(old.Outline.Rect))
310+ }
311+ if cur.Outline != nil {
312+ area = area.Union(phys(cur.Outline.Rect))
313+ }
314+ }
307 for i := range old.Windows {315 for i := range old.Windows {
308 a, b := old.Windows[i], cur.Windows[i]316 a, b := old.Windows[i], cur.Windows[i]
309 if a.ID != b.ID {317 if a.ID != b.ID {
M internal/adapters/vulkan/damage_delta_test.go +26 -1
@@ -19,7 +19,7 @@ import (
1919 func TestSceneFieldCount(t *testing.T) {
2020 // 25 with Animating, which sceneDelta intentionally ignores: it only
2121 // schedules the next frame, it draws nothing.
22- const fields = 26
22+ const fields = 27
2323 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2424 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2525 }
@@ -439,3 +439,28 @@ func TestSceneDeltaFlashRectDamage(t *testing.T) {
439439 _, ok := sceneDelta(old, cur, phys, 1)
440440 require.False(t, ok, "large damage must retain full-redraw fallback")
441441 }
442+
443+func TestDamageSelectionOutlineDelta(t *testing.T) {
444+ a := &ports.SelectionOutline{Rect: ports.Rect{X: 10, Y: 10, W: 40, H: 30}, Width: 2, Radius: 10, Active: true}
445+ b := *a
446+ b.Rect.X = 60
447+ wider, rounder := *a, *a
448+ wider.Width++
449+ rounder.Radius++
450+ for _, pair := range [][2]*ports.SelectionOutline{{nil, a}, {a, nil}, {a, &b}, {a, &wider}, {a, &rounder}, {a, a}} {
451+ old, cur := deltaScene(1), deltaScene(2)
452+ old.Outline, cur.Outline = pair[0], pair[1]
453+ area, ok := sceneDelta(old, cur, func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }, 500000)
454+ require.True(t, ok)
455+ var want image.Rectangle
456+ if pair[0] != pair[1] {
457+ for _, o := range pair {
458+ if o != nil {
459+ r := o.Rect
460+ want = want.Union(image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H))
461+ }
462+ }
463+ }
464+ require.Equal(t, want, area)
465+ }
466+}
@@ -19,7 +19,7 @@ import (
19 func TestSceneFieldCount(t *testing.T) {19 func TestSceneFieldCount(t *testing.T) {
20 // 25 with Animating, which sceneDelta intentionally ignores: it only20 // 25 with Animating, which sceneDelta intentionally ignores: it only
21 // schedules the next frame, it draws nothing.21 // schedules the next frame, it draws nothing.
22- const fields = 2622+ const fields = 27
23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),23 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")24 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
25 }25 }
@@ -439,3 +439,28 @@ func TestSceneDeltaFlashRectDamage(t *testing.T) {
439 _, ok := sceneDelta(old, cur, phys, 1)439 _, ok := sceneDelta(old, cur, phys, 1)
440 require.False(t, ok, "large damage must retain full-redraw fallback")440 require.False(t, ok, "large damage must retain full-redraw fallback")
441 }441 }
442+
443+func TestDamageSelectionOutlineDelta(t *testing.T) {
444+ a := &ports.SelectionOutline{Rect: ports.Rect{X: 10, Y: 10, W: 40, H: 30}, Width: 2, Radius: 10, Active: true}
445+ b := *a
446+ b.Rect.X = 60
447+ wider, rounder := *a, *a
448+ wider.Width++
449+ rounder.Radius++
450+ for _, pair := range [][2]*ports.SelectionOutline{{nil, a}, {a, nil}, {a, &b}, {a, &wider}, {a, &rounder}, {a, a}} {
451+ old, cur := deltaScene(1), deltaScene(2)
452+ old.Outline, cur.Outline = pair[0], pair[1]
453+ area, ok := sceneDelta(old, cur, func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }, 500000)
454+ require.True(t, ok)
455+ var want image.Rectangle
456+ if pair[0] != pair[1] {
457+ for _, o := range pair {
458+ if o != nil {
459+ r := o.Rect
460+ want = want.Union(image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H))
461+ }
462+ }
463+ }
464+ require.Equal(t, want, area)
465+ }
466+}
M internal/adapters/vulkan/scene.go +3 -0
@@ -362,6 +362,9 @@ func (w *sceneWalk) tileSeparators() {
362362 b := w.bounds
363363 w.bounds = w.tileBounds
364364 w.separators(0)
365+ if o := w.s.Outline; o != nil {
366+ w.roundedInnerBorder(o.Rect, o.Width, o.Radius, parseColor(w.s.Border.Active))
367+ }
365368 w.bounds = b
366369 }
367370
@@ -362,6 +362,9 @@ func (w *sceneWalk) tileSeparators() {
362 b := w.bounds362 b := w.bounds
363 w.bounds = w.tileBounds363 w.bounds = w.tileBounds
364 w.separators(0)364 w.separators(0)
365+ if o := w.s.Outline; o != nil {
366+ w.roundedInnerBorder(o.Rect, o.Width, o.Radius, parseColor(w.s.Border.Active))
367+ }
365 w.bounds = b368 w.bounds = b
366 }369 }
367 370
A internal/adapters/vulkan/selection_outline_test.go +25 -0
@@ -0,0 +1,25 @@
1+package vulkan
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "image"
7+ "math"
8+ "testing"
9+)
10+
11+func TestSelectionOutlineRoundedRing(t *testing.T) {
12+ for _, scale := range []int{1, 2} {
13+ r := &Renderer{width: 128, height: 128}
14+ s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"}, Outline: &ports.SelectionOutline{Rect: ports.Rect{X: 8, Y: 4, W: 40, H: 30}, Width: 3, Radius: 11, Active: true}}
15+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
16+ require.Len(t, ds, 1)
17+ d := ds[0]
18+ require.Equal(t, uint32(flagRounded|flagRing), d.pc.misc[1]&(flagRounded|flagRing))
19+ require.Equal(t, [4]int32{8 * int32(scale), 4 * int32(scale), 48 * int32(scale), 34 * int32(scale)}, d.pc.rect)
20+ require.Equal(t, [4]float32{0, 1, 0, 1}, d.pc.color)
21+ require.Equal(t, math.Float32bits(float32(11*scale)), d.radii[0])
22+ require.Equal(t, math.Float32bits(float32(8*scale)), d.radii[1])
23+ require.Equal(t, roundRect(image.Rect(11*scale, 7*scale, 45*scale, 31*scale)), d.roundInner)
24+ }
25+}
@@ -0,0 +1,25 @@
1+package vulkan
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "image"
7+ "math"
8+ "testing"
9+)
10+
11+func TestSelectionOutlineRoundedRing(t *testing.T) {
12+ for _, scale := range []int{1, 2} {
13+ r := &Renderer{width: 128, height: 128}
14+ s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"}, Outline: &ports.SelectionOutline{Rect: ports.Rect{X: 8, Y: 4, W: 40, H: 30}, Width: 3, Radius: 11, Active: true}}
15+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
16+ require.Len(t, ds, 1)
17+ d := ds[0]
18+ require.Equal(t, uint32(flagRounded|flagRing), d.pc.misc[1]&(flagRounded|flagRing))
19+ require.Equal(t, [4]int32{8 * int32(scale), 4 * int32(scale), 48 * int32(scale), 34 * int32(scale)}, d.pc.rect)
20+ require.Equal(t, [4]float32{0, 1, 0, 1}, d.pc.color)
21+ require.Equal(t, math.Float32bits(float32(11*scale)), d.radii[0])
22+ require.Equal(t, math.Float32bits(float32(8*scale)), d.radii[1])
23+ require.Equal(t, roundRect(image.Rect(11*scale, 7*scale, 45*scale, 31*scale)), d.roundInner)
24+ }
25+}
M internal/core/overview.go +10 -5
@@ -1326,7 +1326,9 @@ const outlineLines = 4
13261326 // across the pile; a selected card keeps its complete frame. The lines are
13271327 // appended to dst, which the caller sizes for them (room for outlineLines
13281328 // past its length) so the scene's separators are allocated once.
1329-func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.Separator {
1329+// overviewOutline uses a rounded ring only for uncut frames. Stash cuts retain
1330+// the square strips so the frame never draws across the cards.
1331+func overviewOutline(dst []ports.Separator, layout []Placement, b, gap, radius int) ([]ports.Separator, *ports.SelectionOutline) {
13301332 sel := -1
13311333 for i, p := range layout {
13321334 if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {
@@ -1334,9 +1336,8 @@ func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.S
13341336 }
13351337 }
13361338 if sel < 0 {
1337- return dst
1339+ return dst, nil
13381340 }
1339- dst = slices.Grow(dst, outlineLines)
13401341 r := layout[sel].Rect
13411342 edges := [4]Rect{
13421343 {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},
@@ -1353,11 +1354,15 @@ func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.S
13531354 }
13541355 }
13551356 }
1357+ if radius := windowRadius(layout[sel], gap, radius); !cut && radius > 0 {
1358+ return dst, &ports.SelectionOutline{Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: r.H + 2*b}, Width: b, Radius: radius + b, Active: true}
1359+ }
1360+ dst = slices.Grow(dst, outlineLines)
13561361 if !cut {
13571362 for _, e := range edges {
13581363 dst = append(dst, ports.Separator{Rect: e, Active: true})
13591364 }
1360- return dst
1365+ return dst, nil
13611366 }
13621367 // Stack buffers: the outline is rebuilt every overview frame.
13631368 var cardBuf [overviewCards]Rect
@@ -1373,7 +1378,7 @@ func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.S
13731378 dst = append(dst, ports.Separator{Rect: piece, Active: true})
13741379 }
13751380 }
1376- return dst
1381+ return dst, nil
13771382 }
13781383
13791384 // subtractRects is what of r no rect of cuts covers, as rects. The edges of
@@ -1326,7 +1326,9 @@ const outlineLines = 4
1326 // across the pile; a selected card keeps its complete frame. The lines are1326 // across the pile; a selected card keeps its complete frame. The lines are
1327 // appended to dst, which the caller sizes for them (room for outlineLines1327 // appended to dst, which the caller sizes for them (room for outlineLines
1328 // past its length) so the scene's separators are allocated once.1328 // past its length) so the scene's separators are allocated once.
1329-func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.Separator {1329+// overviewOutline uses a rounded ring only for uncut frames. Stash cuts retain
1330+// the square strips so the frame never draws across the cards.
1331+func overviewOutline(dst []ports.Separator, layout []Placement, b, gap, radius int) ([]ports.Separator, *ports.SelectionOutline) {
1330 sel := -11332 sel := -1
1331 for i, p := range layout {1333 for i, p := range layout {
1332 if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {1334 if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {
@@ -1334,9 +1336,8 @@ func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.S
1334 }1336 }
1335 }1337 }
1336 if sel < 0 {1338 if sel < 0 {
1337- return dst1339+ return dst, nil
1338 }1340 }
1339- dst = slices.Grow(dst, outlineLines)
1340 r := layout[sel].Rect1341 r := layout[sel].Rect
1341 edges := [4]Rect{1342 edges := [4]Rect{
1342 {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},1343 {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},
@@ -1353,11 +1354,15 @@ func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.S
1353 }1354 }
1354 }1355 }
1355 }1356 }
1357+ if radius := windowRadius(layout[sel], gap, radius); !cut && radius > 0 {
1358+ return dst, &ports.SelectionOutline{Rect: Rect{X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: r.H + 2*b}, Width: b, Radius: radius + b, Active: true}
1359+ }
1360+ dst = slices.Grow(dst, outlineLines)
1356 if !cut {1361 if !cut {
1357 for _, e := range edges {1362 for _, e := range edges {
1358 dst = append(dst, ports.Separator{Rect: e, Active: true})1363 dst = append(dst, ports.Separator{Rect: e, Active: true})
1359 }1364 }
1360- return dst1365+ return dst, nil
1361 }1366 }
1362 // Stack buffers: the outline is rebuilt every overview frame.1367 // Stack buffers: the outline is rebuilt every overview frame.
1363 var cardBuf [overviewCards]Rect1368 var cardBuf [overviewCards]Rect
@@ -1373,7 +1378,7 @@ func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.S
1373 dst = append(dst, ports.Separator{Rect: piece, Active: true})1378 dst = append(dst, ports.Separator{Rect: piece, Active: true})
1374 }1379 }
1375 }1380 }
1376- return dst1381+ return dst, nil
1377 }1382 }
1378 1383
1379 // subtractRects is what of r no rect of cuts covers, as rects. The edges of1384 // subtractRects is what of r no rect of cuts covers, as rects. The edges of
M internal/core/overview_cascade_test.go +21 -6
@@ -1054,7 +1054,7 @@ func TestOverviewCascadeScroll(t *testing.T) {
10541054
10551055 // outlineHits reports whether any outline line of ps covers a point of r.
10561056 func outlineHits(ps []Placement, r Rect) bool {
1057- for _, s := range overviewOutline(nil, ps, 2) {
1057+ for _, s := range squareOverviewOutline(nil, ps, 2) {
10581058 if s.Rect.Overlaps(r) {
10591059 return true
10601060 }
@@ -1126,7 +1126,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
11261126 if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {
11271127 t.Fatalf("lane selected: %d framed", framed(ps))
11281128 }
1129- if len(overviewOutline(nil, ps, 2)) == 0 {
1129+ if len(squareOverviewOutline(nil, ps, 2)) == 0 {
11301130 t.Fatal("lane selected: no frame")
11311131 }
11321132 for _, id := range []WindowID{40, 41, 42} {
@@ -1155,7 +1155,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
11551155 if !card.Focused || !card.Card {
11561156 t.Fatalf("selected card not framed: %+v", card)
11571157 }
1158- out := overviewOutline(nil, ps, 2)
1158+ out := squareOverviewOutline(nil, ps, 2)
11591159 if len(out) != 4 {
11601160 t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)
11611161 }
@@ -1189,7 +1189,7 @@ func TestOverviewOutlinePrefersCard(t *testing.T) {
11891189 {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},
11901190 {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},
11911191 }
1192- out := overviewOutline(nil, ps, 2)
1192+ out := squareOverviewOutline(nil, ps, 2)
11931193 if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {
11941194 t.Fatalf("outline: %+v", out)
11951195 }
@@ -1207,14 +1207,29 @@ func TestOverviewOutlinePileAllocations(t *testing.T) {
12071207 for _, p := range ps {
12081208 card = card || p.Card && p.Preview > 0 && !p.Hidden
12091209 }
1210- if !card || len(overviewOutline(nil, ps, 2)) == 0 {
1210+ if !card || len(squareOverviewOutline(nil, ps, 2)) == 0 {
12111211 t.Fatal("setup: no pile under a framed lane tile")
12121212 }
1213- if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(nil, ps, 2) }); n > 2 {
1213+ if n := testing.AllocsPerRun(50, func() { _ = squareOverviewOutline(nil, ps, 2) }); n > 2 {
12141214 t.Fatalf("overviewOutline allocs = %v, want <= 2", n)
12151215 }
12161216 }
12171217
1218+func TestOverviewOutlineRoundedAllocations(t *testing.T) {
1219+ ps := []Placement{{Rect: Rect{X: 20, Y: 20, W: 100, H: 80}, Preview: 0.5, Focused: true, Floating: true}}
1220+ // Keep the returned outline alive so its one snapshot allocation is counted.
1221+ var outline *ports.SelectionOutline
1222+ n := testing.AllocsPerRun(50, func() {
1223+ _, outline = overviewOutline(nil, ps, 2, 0, 12)
1224+ })
1225+ if outline == nil {
1226+ t.Fatal("setup: no rounded outline")
1227+ }
1228+ if n != 1 {
1229+ t.Fatalf("rounded overviewOutline allocs = %v, want 1 (outline only)", n)
1230+ }
1231+}
1232+
12181233 func TestSubtractRects(t *testing.T) {
12191234 h := Rect{X: 0, Y: 10, W: 100, H: 2}
12201235 v := Rect{X: 10, Y: 0, W: 2, H: 100}
@@ -1054,7 +1054,7 @@ func TestOverviewCascadeScroll(t *testing.T) {
1054 1054
1055 // outlineHits reports whether any outline line of ps covers a point of r.1055 // outlineHits reports whether any outline line of ps covers a point of r.
1056 func outlineHits(ps []Placement, r Rect) bool {1056 func outlineHits(ps []Placement, r Rect) bool {
1057- for _, s := range overviewOutline(nil, ps, 2) {1057+ for _, s := range squareOverviewOutline(nil, ps, 2) {
1058 if s.Rect.Overlaps(r) {1058 if s.Rect.Overlaps(r) {
1059 return true1059 return true
1060 }1060 }
@@ -1126,7 +1126,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
1126 if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {1126 if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {
1127 t.Fatalf("lane selected: %d framed", framed(ps))1127 t.Fatalf("lane selected: %d framed", framed(ps))
1128 }1128 }
1129- if len(overviewOutline(nil, ps, 2)) == 0 {1129+ if len(squareOverviewOutline(nil, ps, 2)) == 0 {
1130 t.Fatal("lane selected: no frame")1130 t.Fatal("lane selected: no frame")
1131 }1131 }
1132 for _, id := range []WindowID{40, 41, 42} {1132 for _, id := range []WindowID{40, 41, 42} {
@@ -1155,7 +1155,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
1155 if !card.Focused || !card.Card {1155 if !card.Focused || !card.Card {
1156 t.Fatalf("selected card not framed: %+v", card)1156 t.Fatalf("selected card not framed: %+v", card)
1157 }1157 }
1158- out := overviewOutline(nil, ps, 2)1158+ out := squareOverviewOutline(nil, ps, 2)
1159 if len(out) != 4 {1159 if len(out) != 4 {
1160 t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)1160 t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)
1161 }1161 }
@@ -1189,7 +1189,7 @@ func TestOverviewOutlinePrefersCard(t *testing.T) {
1189 {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},1189 {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},
1190 {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},1190 {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},
1191 }1191 }
1192- out := overviewOutline(nil, ps, 2)1192+ out := squareOverviewOutline(nil, ps, 2)
1193 if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {1193 if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {
1194 t.Fatalf("outline: %+v", out)1194 t.Fatalf("outline: %+v", out)
1195 }1195 }
@@ -1207,14 +1207,29 @@ func TestOverviewOutlinePileAllocations(t *testing.T) {
1207 for _, p := range ps {1207 for _, p := range ps {
1208 card = card || p.Card && p.Preview > 0 && !p.Hidden1208 card = card || p.Card && p.Preview > 0 && !p.Hidden
1209 }1209 }
1210- if !card || len(overviewOutline(nil, ps, 2)) == 0 {1210+ if !card || len(squareOverviewOutline(nil, ps, 2)) == 0 {
1211 t.Fatal("setup: no pile under a framed lane tile")1211 t.Fatal("setup: no pile under a framed lane tile")
1212 }1212 }
1213- if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(nil, ps, 2) }); n > 2 {1213+ if n := testing.AllocsPerRun(50, func() { _ = squareOverviewOutline(nil, ps, 2) }); n > 2 {
1214 t.Fatalf("overviewOutline allocs = %v, want <= 2", n)1214 t.Fatalf("overviewOutline allocs = %v, want <= 2", n)
1215 }1215 }
1216 }1216 }
1217 1217
1218+func TestOverviewOutlineRoundedAllocations(t *testing.T) {
1219+ ps := []Placement{{Rect: Rect{X: 20, Y: 20, W: 100, H: 80}, Preview: 0.5, Focused: true, Floating: true}}
1220+ // Keep the returned outline alive so its one snapshot allocation is counted.
1221+ var outline *ports.SelectionOutline
1222+ n := testing.AllocsPerRun(50, func() {
1223+ _, outline = overviewOutline(nil, ps, 2, 0, 12)
1224+ })
1225+ if outline == nil {
1226+ t.Fatal("setup: no rounded outline")
1227+ }
1228+ if n != 1 {
1229+ t.Fatalf("rounded overviewOutline allocs = %v, want 1 (outline only)", n)
1230+ }
1231+}
1232+
1218 func TestSubtractRects(t *testing.T) {1233 func TestSubtractRects(t *testing.T) {
1219 h := Rect{X: 0, Y: 10, W: 100, H: 2}1234 h := Rect{X: 0, Y: 10, W: 100, H: 2}
1220 v := Rect{X: 10, Y: 0, W: 2, H: 100}1235 v := Rect{X: 10, Y: 0, W: 2, H: 100}
A internal/core/overview_outline_test.go +44 -0
@@ -0,0 +1,44 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "testing"
7+)
8+
9+func squareOverviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.Separator {
10+ dst, _ = overviewOutline(dst, layout, b, 0, 0)
11+ return dst
12+}
13+
14+func TestOverviewRoundedOutline(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ gap, radius int
18+ floating, card, cut, rounded bool
19+ }{
20+ {name: "tile gaps", gap: 8, radius: 12, rounded: true},
21+ {name: "tile no gaps", radius: 12},
22+ {name: "float no gaps", radius: 12, floating: true, rounded: true},
23+ {name: "square", gap: 8},
24+ {name: "cut", gap: 8, radius: 12, cut: true},
25+ {name: "selected card", radius: 12, floating: true, card: true, cut: true, rounded: true},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ p := Placement{Rect: Rect{X: 20, Y: 20, W: 100, H: 80}, Preview: 0.5, Focused: true, Floating: tc.floating, Card: tc.card}
29+ layout := []Placement{p}
30+ if tc.cut {
31+ layout = append(layout, Placement{Rect: Rect{X: 80, Y: 20, W: 60, H: 40}, Preview: 0.5, Card: true})
32+ }
33+ divider := ports.Separator{Rect: Rect{W: 200, H: 1}}
34+ strips, outline := overviewOutline([]ports.Separator{divider}, layout, 2, tc.gap, tc.radius)
35+ if tc.rounded {
36+ require.Equal(t, []ports.Separator{divider}, strips)
37+ require.Equal(t, &ports.SelectionOutline{Rect: Rect{X: 18, Y: 18, W: 104, H: 84}, Width: 2, Radius: 14, Active: true}, outline)
38+ } else {
39+ require.Nil(t, outline)
40+ require.Equal(t, append([]ports.Separator{divider}, squareOverviewOutline(nil, layout, 2)...), strips)
41+ }
42+ })
43+ }
44+}
@@ -0,0 +1,44 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "testing"
7+)
8+
9+func squareOverviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.Separator {
10+ dst, _ = overviewOutline(dst, layout, b, 0, 0)
11+ return dst
12+}
13+
14+func TestOverviewRoundedOutline(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ gap, radius int
18+ floating, card, cut, rounded bool
19+ }{
20+ {name: "tile gaps", gap: 8, radius: 12, rounded: true},
21+ {name: "tile no gaps", radius: 12},
22+ {name: "float no gaps", radius: 12, floating: true, rounded: true},
23+ {name: "square", gap: 8},
24+ {name: "cut", gap: 8, radius: 12, cut: true},
25+ {name: "selected card", radius: 12, floating: true, card: true, cut: true, rounded: true},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ p := Placement{Rect: Rect{X: 20, Y: 20, W: 100, H: 80}, Preview: 0.5, Focused: true, Floating: tc.floating, Card: tc.card}
29+ layout := []Placement{p}
30+ if tc.cut {
31+ layout = append(layout, Placement{Rect: Rect{X: 80, Y: 20, W: 60, H: 40}, Preview: 0.5, Card: true})
32+ }
33+ divider := ports.Separator{Rect: Rect{W: 200, H: 1}}
34+ strips, outline := overviewOutline([]ports.Separator{divider}, layout, 2, tc.gap, tc.radius)
35+ if tc.rounded {
36+ require.Equal(t, []ports.Separator{divider}, strips)
37+ require.Equal(t, &ports.SelectionOutline{Rect: Rect{X: 18, Y: 18, W: 104, H: 84}, Width: 2, Radius: 14, Active: true}, outline)
38+ } else {
39+ require.Nil(t, outline)
40+ require.Equal(t, append([]ports.Separator{divider}, squareOverviewOutline(nil, layout, 2)...), strips)
41+ }
42+ })
43+ }
44+}
M internal/core/overview_test.go +1 -1
@@ -508,7 +508,7 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
508508 t.Fatalf("%d not hit at %+v", id, p.Rect)
509509 }
510510 }
511- if len(overviewOutline(nil, ps, 2)) != 4 {
511+ if len(squareOverviewOutline(nil, ps, 2)) != 4 {
512512 t.Fatal("missing selection outline")
513513 }
514514 m.OverviewMove(-1, 0)
@@ -508,7 +508,7 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
508 t.Fatalf("%d not hit at %+v", id, p.Rect)508 t.Fatalf("%d not hit at %+v", id, p.Rect)
509 }509 }
510 }510 }
511- if len(overviewOutline(nil, ps, 2)) != 4 {511+ if len(squareOverviewOutline(nil, ps, 2)) != 4 {
512 t.Fatal("missing selection outline")512 t.Fatal("missing selection outline")
513 }513 }
514 m.OverviewMove(-1, 0)514 m.OverviewMove(-1, 0)
M internal/core/scene.go +2 -2
@@ -59,10 +59,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
5959 if len(dividers) > 0 {
6060 seps = append(make([]ports.Separator, 0, len(dividers)+outlineLines), dividers...)
6161 }
62- scene.Separators = overviewOutline(seps, layout, max(c.cfg.Border.Width, 2))
62+ scene.Separators, scene.Outline = overviewOutline(seps, layout, max(c.cfg.Border.Width, 2), sc.mon.Current().gap(), c.cfg.Border.Radius)
6363 } else if sc.mon.switcherShown() {
6464 // The switcher's cards: only the selection is framed.
65- scene.Separators = overviewOutline(nil, layout, max(c.cfg.Border.Width, 2))
65+ scene.Separators, scene.Outline = overviewOutline(nil, layout, max(c.cfg.Border.Width, 2), sc.mon.Current().gap(), c.cfg.Border.Radius)
6666 } else {
6767 // Only the focused output lights the focused window's lines.
6868 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
@@ -59,10 +59,10 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
59 if len(dividers) > 0 {59 if len(dividers) > 0 {
60 seps = append(make([]ports.Separator, 0, len(dividers)+outlineLines), dividers...)60 seps = append(make([]ports.Separator, 0, len(dividers)+outlineLines), dividers...)
61 }61 }
62- scene.Separators = overviewOutline(seps, layout, max(c.cfg.Border.Width, 2))62+ scene.Separators, scene.Outline = overviewOutline(seps, layout, max(c.cfg.Border.Width, 2), sc.mon.Current().gap(), c.cfg.Border.Radius)
63 } else if sc.mon.switcherShown() {63 } else if sc.mon.switcherShown() {
64 // The switcher's cards: only the selection is framed.64 // The switcher's cards: only the selection is framed.
65- scene.Separators = overviewOutline(nil, layout, max(c.cfg.Border.Width, 2))65+ scene.Separators, scene.Outline = overviewOutline(nil, layout, max(c.cfg.Border.Width, 2), sc.mon.Current().gap(), c.cfg.Border.Radius)
66 } else {66 } else {
67 // Only the focused output lights the focused window's lines.67 // Only the focused output lights the focused window's lines.
68 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)68 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
M internal/ports/scene.go +13 -0
@@ -2,10 +2,20 @@ package ports
22
33 import "slices"
44
5+// SelectionOutline is a selection ring outside a preview. Rect and Radius
6+// describe its outer edge; Radius zero denotes square corners.
7+type SelectionOutline struct {
8+ Rect Rect
9+ Width int
10+ Radius int
11+ Active bool
12+}
13+
514 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
615 // one per output. Rects and the output size are logical and local to the
716 // output; the renderer multiplies by Scale.
817 type Scene struct {
18+ Outline *SelectionOutline
919 // Security is the owner epoch captured when this scene was published.
1020 // Output owners reject stale scenes rather than showing a queued desktop
1121 // or accepting old locker buffers across acquisition/release.
@@ -105,6 +115,9 @@ func (s Scene) SameAs(o Scene) bool {
105115 if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) {
106116 return false
107117 }
118+ if (s.Outline == nil) != (o.Outline == nil) || (s.Outline != nil && *s.Outline != *o.Outline) {
119+ return false
120+ }
108121 if s.CaptureScene != nil || o.CaptureScene != nil {
109122 return false
110123 }
@@ -2,10 +2,20 @@ package ports
2 2
3 import "slices"3 import "slices"
4 4
5+// SelectionOutline is a selection ring outside a preview. Rect and Radius
6+// describe its outer edge; Radius zero denotes square corners.
7+type SelectionOutline struct {
8+ Rect Rect
9+ Width int
10+ Radius int
11+ Active bool
12+}
13+
5 // Scene carries core → renderer immutable snapshots with fresh Windows slices,14 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
6 // one per output. Rects and the output size are logical and local to the15 // one per output. Rects and the output size are logical and local to the
7 // output; the renderer multiplies by Scale.16 // output; the renderer multiplies by Scale.
8 type Scene struct {17 type Scene struct {
18+ Outline *SelectionOutline
9 // Security is the owner epoch captured when this scene was published.19 // Security is the owner epoch captured when this scene was published.
10 // Output owners reject stale scenes rather than showing a queued desktop20 // Output owners reject stale scenes rather than showing a queued desktop
11 // or accepting old locker buffers across acquisition/release.21 // or accepting old locker buffers across acquisition/release.
@@ -105,6 +115,9 @@ func (s Scene) SameAs(o Scene) bool {
105 if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) {115 if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) {
106 return false116 return false
107 }117 }
118+ if (s.Outline == nil) != (o.Outline == nil) || (s.Outline != nil && *s.Outline != *o.Outline) {
119+ return false
120+ }
108 if s.CaptureScene != nil || o.CaptureScene != nil {121 if s.CaptureScene != nil || o.CaptureScene != nil {
109 return false122 return false
110 }123 }
M internal/ports/scene_same_test.go +7 -1
@@ -23,6 +23,7 @@ func sameSceneBase() Scene {
2323 Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},
2424 Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},
2525
26+ Outline: &SelectionOutline{Rect: Rect{W: 20, H: 10}, Width: 2, Radius: 8, Active: true},
2627 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
2728 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
2829 HintLabels: []HintLabel{{Rect: Rect{X: 3, Y: 4}, Text: "fj", Typed: 1}},
@@ -39,6 +40,11 @@ func TestSceneSameAs(t *testing.T) {
3940
4041 for name, edit := range map[string]func(*Scene){
4142 "position hint": func(s *Scene) { s.PositionHint.Opacity = 1 },
43+ "outline nil": func(s *Scene) { s.Outline = nil },
44+ "outline rect": func(s *Scene) { s.Outline.Rect.X++ },
45+ "outline width": func(s *Scene) { s.Outline.Width++ },
46+ "outline radius": func(s *Scene) { s.Outline.Radius++ },
47+ "outline active": func(s *Scene) { s.Outline.Active = false },
4248 "flash nil": func(s *Scene) { s.Flash = nil },
4349 "flash rect": func(s *Scene) { s.Flash.Rect.X++ },
4450 "flash width": func(s *Scene) { s.Flash.Width++ },
@@ -95,7 +101,7 @@ func TestSceneSameAs(t *testing.T) {
95101 func TestSceneSameAsCoversEveryField(t *testing.T) {
96102 // 25 with Animating, which SameAs intentionally does not compare: it
97103 // only schedules the next frame, it draws nothing.
98- const fields = 26
104+ const fields = 27
99105 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
100106 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
101107 }
@@ -23,6 +23,7 @@ func sameSceneBase() Scene {
23 Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},23 Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},
24 Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},24 Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},
25 25
26+ Outline: &SelectionOutline{Rect: Rect{W: 20, H: 10}, Width: 2, Radius: 8, Active: true},
26 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},27 Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
27 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},28 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
28 HintLabels: []HintLabel{{Rect: Rect{X: 3, Y: 4}, Text: "fj", Typed: 1}},29 HintLabels: []HintLabel{{Rect: Rect{X: 3, Y: 4}, Text: "fj", Typed: 1}},
@@ -39,6 +40,11 @@ func TestSceneSameAs(t *testing.T) {
39 40
40 for name, edit := range map[string]func(*Scene){41 for name, edit := range map[string]func(*Scene){
41 "position hint": func(s *Scene) { s.PositionHint.Opacity = 1 },42 "position hint": func(s *Scene) { s.PositionHint.Opacity = 1 },
43+ "outline nil": func(s *Scene) { s.Outline = nil },
44+ "outline rect": func(s *Scene) { s.Outline.Rect.X++ },
45+ "outline width": func(s *Scene) { s.Outline.Width++ },
46+ "outline radius": func(s *Scene) { s.Outline.Radius++ },
47+ "outline active": func(s *Scene) { s.Outline.Active = false },
42 "flash nil": func(s *Scene) { s.Flash = nil },48 "flash nil": func(s *Scene) { s.Flash = nil },
43 "flash rect": func(s *Scene) { s.Flash.Rect.X++ },49 "flash rect": func(s *Scene) { s.Flash.Rect.X++ },
44 "flash width": func(s *Scene) { s.Flash.Width++ },50 "flash width": func(s *Scene) { s.Flash.Width++ },
@@ -95,7 +101,7 @@ func TestSceneSameAs(t *testing.T) {
95 func TestSceneSameAsCoversEveryField(t *testing.T) {101 func TestSceneSameAsCoversEveryField(t *testing.T) {
96 // 25 with Animating, which SameAs intentionally does not compare: it102 // 25 with Animating, which SameAs intentionally does not compare: it
97 // only schedules the next frame, it draws nothing.103 // only schedules the next frame, it draws nothing.
98- const fields = 26104+ const fields = 27
99 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),105 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
100 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")106 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
101 }107 }

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