Merge ec21f3234eec62047f4cd53d14e21a5b02c00735 into e0a861187399be3c440f515e867abc7866b40953
14 files · +177 -16
M internal/adapters/drm/focus_flash_test.go +11 -0
| @@ -16,3 +16,14 @@ func TestFocusFlashRequiresComposition(t *testing.T) { | |||
| 16 | 16 | _, _, _, reason = overlayCandidate(s, contents, false, nil, nil) | |
| 17 | 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 | + | } | |
| @@ -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 | |||
| 70 | 70 | if s.PositionHint.Shown() { | |
| 71 | 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 | 76 | if s.Flash != nil { | |
| 74 | 77 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | |
| 75 | 78 | } | |
| @@ -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 | |||
| 94 | 94 | if s.PositionHint.Shown() { | |
| 95 | 95 | return ports.SurfaceContent{}, "position_hint" | |
| 96 | 96 | } | |
| 97 | + | if s.Outline != nil { | |
| 98 | + | return ports.SurfaceContent{}, "selection_outline" | |
| 99 | + | } | |
| 97 | 100 | if s.Flash != nil { | |
| 98 | 101 | return ports.SurfaceContent{}, "focus_flash" | |
| 99 | 102 | } | |
| @@ -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 | |||
| 304 | 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 | 315 | for i := range old.Windows { | |
| 308 | 316 | a, b := old.Windows[i], cur.Windows[i] | |
| 309 | 317 | 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 ( | |||
| 19 | 19 | func TestSceneFieldCount(t *testing.T) { | |
| 20 | 20 | // 25 with Animating, which sceneDelta intentionally ignores: it only | |
| 21 | 21 | // schedules the next frame, it draws nothing. | |
| 22 | - | const fields = 26 | |
| 22 | + | const fields = 27 | |
| 23 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 24 | 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 | 439 | _, ok := sceneDelta(old, cur, phys, 1) | |
| 440 | 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 | + | } | |
| @@ -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 only | 20 | // 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 = 26 | 22 | + | 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() { | |||
| 362 | 362 | b := w.bounds | |
| 363 | 363 | w.bounds = w.tileBounds | |
| 364 | 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 | 368 | w.bounds = b | |
| 366 | 369 | } | |
| 367 | 370 | ||
| @@ -362,6 +362,9 @@ func (w *sceneWalk) tileSeparators() { | |||||
| 362 | b := w.bounds | 362 | b := w.bounds | ||
| 363 | w.bounds = w.tileBounds | 363 | 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 = b | 368 | 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 | |||
| 1326 | 1326 | // across the pile; a selected card keeps its complete frame. The lines are | |
| 1327 | 1327 | // appended to dst, which the caller sizes for them (room for outlineLines | |
| 1328 | 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 | 1332 | sel := -1 | |
| 1331 | 1333 | for i, p := range layout { | |
| 1332 | 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 | 1338 | if sel < 0 { | |
| 1337 | - | return dst | |
| 1339 | + | return dst, nil | |
| 1338 | 1340 | } | |
| 1339 | - | dst = slices.Grow(dst, outlineLines) | |
| 1340 | 1341 | r := layout[sel].Rect | |
| 1341 | 1342 | edges := [4]Rect{ | |
| 1342 | 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 | 1361 | if !cut { | |
| 1357 | 1362 | for _, e := range edges { | |
| 1358 | 1363 | dst = append(dst, ports.Separator{Rect: e, Active: true}) | |
| 1359 | 1364 | } | |
| 1360 | - | return dst | |
| 1365 | + | return dst, nil | |
| 1361 | 1366 | } | |
| 1362 | 1367 | // Stack buffers: the outline is rebuilt every overview frame. | |
| 1363 | 1368 | var cardBuf [overviewCards]Rect | |
| @@ -1373,7 +1378,7 @@ func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.S | |||
| 1373 | 1378 | dst = append(dst, ports.Separator{Rect: piece, Active: true}) | |
| 1374 | 1379 | } | |
| 1375 | 1380 | } | |
| 1376 | - | return dst | |
| 1381 | + | return dst, nil | |
| 1377 | 1382 | } | |
| 1378 | 1383 | ||
| 1379 | 1384 | // 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 are | 1326 | // 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 outlineLines | 1327 | // 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 := -1 | 1332 | 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 dst | 1339 | + | return dst, nil |
| 1338 | } | 1340 | } | ||
| 1339 | - | dst = slices.Grow(dst, outlineLines) | |||
| 1340 | r := layout[sel].Rect | 1341 | 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 dst | 1365 | + | 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]Rect | 1368 | 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 dst | 1381 | + | return dst, nil |
| 1377 | } | 1382 | } | ||
| 1378 | 1383 | ||||
| 1379 | // subtractRects is what of r no rect of cuts covers, as rects. The edges of | 1384 | // 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) { | |||
| 1054 | 1054 | ||
| 1055 | 1055 | // outlineHits reports whether any outline line of ps covers a point of r. | |
| 1056 | 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 | 1058 | if s.Rect.Overlaps(r) { | |
| 1059 | 1059 | return true | |
| 1060 | 1060 | } | |
| @@ -1126,7 +1126,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) { | |||
| 1126 | 1126 | if framed(ps) != 1 || !previewOf(t, ps, 8).Focused { | |
| 1127 | 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 | 1130 | t.Fatal("lane selected: no frame") | |
| 1131 | 1131 | } | |
| 1132 | 1132 | for _, id := range []WindowID{40, 41, 42} { | |
| @@ -1155,7 +1155,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) { | |||
| 1155 | 1155 | if !card.Focused || !card.Card { | |
| 1156 | 1156 | t.Fatalf("selected card not framed: %+v", card) | |
| 1157 | 1157 | } | |
| 1158 | - | out := overviewOutline(nil, ps, 2) | |
| 1158 | + | out := squareOverviewOutline(nil, ps, 2) | |
| 1159 | 1159 | if len(out) != 4 { | |
| 1160 | 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 | 1189 | {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true}, | |
| 1190 | 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 | 1193 | if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) { | |
| 1194 | 1194 | t.Fatalf("outline: %+v", out) | |
| 1195 | 1195 | } | |
| @@ -1207,14 +1207,29 @@ func TestOverviewOutlinePileAllocations(t *testing.T) { | |||
| 1207 | 1207 | for _, p := range ps { | |
| 1208 | 1208 | 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 | 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 | 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 | 1233 | func TestSubtractRects(t *testing.T) { | |
| 1219 | 1234 | h := Rect{X: 0, Y: 10, W: 100, H: 2} | |
| 1220 | 1235 | 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 true | 1059 | 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.Hidden | 1208 | 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) { | |||
| 508 | 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 | 512 | t.Fatal("missing selection outline") | |
| 513 | 513 | } | |
| 514 | 514 | 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 | |||
| 59 | 59 | if len(dividers) > 0 { | |
| 60 | 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 | 63 | } else if sc.mon.switcherShown() { | |
| 64 | 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 | 66 | } else { | |
| 67 | 67 | // Only the focused output lights the focused window's lines. | |
| 68 | 68 | 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 | |||
| 2 | 2 | ||
| 3 | 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 | 14 | // Scene carries core → renderer immutable snapshots with fresh Windows slices, | |
| 6 | 15 | // one per output. Rects and the output size are logical and local to the | |
| 7 | 16 | // output; the renderer multiplies by Scale. | |
| 8 | 17 | type Scene struct { | |
| 18 | + | Outline *SelectionOutline | |
| 9 | 19 | // Security is the owner epoch captured when this scene was published. | |
| 10 | 20 | // Output owners reject stale scenes rather than showing a queued desktop | |
| 11 | 21 | // or accepting old locker buffers across acquisition/release. | |
| @@ -105,6 +115,9 @@ func (s Scene) SameAs(o Scene) bool { | |||
| 105 | 115 | if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) { | |
| 106 | 116 | return false | |
| 107 | 117 | } | |
| 118 | + | if (s.Outline == nil) != (o.Outline == nil) || (s.Outline != nil && *s.Outline != *o.Outline) { | |
| 119 | + | return false | |
| 120 | + | } | |
| 108 | 121 | if s.CaptureScene != nil || o.CaptureScene != nil { | |
| 109 | 122 | return false | |
| 110 | 123 | } | |
| @@ -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 the | 15 | // 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 desktop | 20 | // 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 false | 116 | 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 false | 122 | return false | ||
| 110 | } | 123 | } | ||
M internal/ports/scene_same_test.go +7 -1
| @@ -23,6 +23,7 @@ func sameSceneBase() Scene { | |||
| 23 | 23 | Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}}, | |
| 24 | 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 | 27 | Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6}, | |
| 27 | 28 | CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}}, | |
| 28 | 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 | 41 | for name, edit := range map[string]func(*Scene){ | |
| 41 | 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 | 48 | "flash nil": func(s *Scene) { s.Flash = nil }, | |
| 43 | 49 | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | |
| 44 | 50 | "flash width": func(s *Scene) { s.Flash.Width++ }, | |
| @@ -95,7 +101,7 @@ func TestSceneSameAs(t *testing.T) { | |||
| 95 | 101 | func TestSceneSameAsCoversEveryField(t *testing.T) { | |
| 96 | 102 | // 25 with Animating, which SameAs intentionally does not compare: it | |
| 97 | 103 | // only schedules the next frame, it draws nothing. | |
| 98 | - | const fields = 26 | |
| 104 | + | const fields = 27 | |
| 99 | 105 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 100 | 106 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 101 | 107 | } | |
| @@ -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: it | 102 | // 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 = 26 | 104 | + | 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 | } | ||