feat(vulkan): draw focus flash as inner border
- commit
- 3b72e8ebbbe42037e56afcd09905aa99c3359541
- author
- brice · · 2026-10-19 04:43 UTC
- parent
- 4e6e449
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +117 -5
M README.md +4 -0
| @@ -325,6 +325,10 @@ bind.cmd+code:13 = scale-up | |||
| 325 | 325 | bind.cmd+code:12 = scale-down | |
| 326 | 326 | ``` | |
| 327 | 327 | ||
| 328 | + | The focus flash marks the window, column or output an action changes with a brief | |
| 329 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |
| 330 | + | It runs when `animations = on`. | |
| 331 | + | ||
| 328 | 332 | Per-output defaults (replace `<name>` with a connector such as `DP-1`): | |
| 329 | 333 | ||
| 330 | 334 | ```text | |
| @@ -325,6 +325,10 @@ bind.cmd+code:13 = scale-up | |||||
| 325 | bind.cmd+code:12 = scale-down | 325 | bind.cmd+code:12 = scale-down | ||
| 326 | ``` | 326 | ``` | ||
| 327 | 327 | ||||
| 328 | + | The focus flash marks the window, column or output an action changes with a brief | |||
| 329 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |||
| 330 | + | It runs when `animations = on`. | |||
| 331 | + | ||||
| 328 | Per-output defaults (replace `<name>` with a connector such as `DP-1`): | 332 | Per-output defaults (replace `<name>` with a connector such as `DP-1`): | ||
| 329 | 333 | ||||
| 330 | ```text | 334 | ```text | ||
M docs/config.md +4 -0
| @@ -77,6 +77,10 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 77 | 77 | ||
| 78 | 78 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | |
| 79 | 79 | ||
| 80 | + | The focus flash marks the window, column or output an action changes with a brief | |
| 81 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |
| 82 | + | It runs when `animations = on`. | |
| 83 | + | ||
| 80 | 84 | ## Window rules | |
| 81 | 85 | ||
| 82 | 86 | Rules place an app's windows when they first open. | |
| @@ -77,6 +77,10 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 77 | 77 | ||||
| 78 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | 78 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | ||
| 79 | 79 | ||||
| 80 | + | The focus flash marks the window, column or output an action changes with a brief | |||
| 81 | + | inner border. Workspace switches and mouse focus do not trigger a flash. | |||
| 82 | + | It runs when `animations = on`. | |||
| 83 | + | ||||
| 80 | ## Window rules | 84 | ## Window rules | ||
| 81 | 85 | ||||
| 82 | Rules place an app's windows when they first open. | 86 | Rules place an app's windows when they first open. | ||
M docs/headless.md +2 -0
| @@ -8,6 +8,8 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 8 | 8 | ``` | |
| 9 | 9 | ||
| 10 | 10 | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | |
| 11 | + | - `--size 640x360,640x360` creates two outputs, arranged left to right, | |
| 12 | + | for testing `focus-monitor-left/right` and moving columns between outputs. | |
| 11 | 13 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | |
| 12 | 14 | - `--timeout 5s` stops NeferWL after 5 seconds. | |
| 13 | 15 | - `grim` and recorders work too; see [Screen capture](capture.md). | |
| @@ -8,6 +8,8 @@ WAYLAND_DISPLAY=<name from the log> foot | |||||
| 8 | ``` | 8 | ``` | ||
| 9 | 9 | ||||
| 10 | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | 10 | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | ||
| 11 | + | - `--size 640x360,640x360` creates two outputs, arranged left to right, | |||
| 12 | + | for testing `focus-monitor-left/right` and moving columns between outputs. | |||
| 11 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | 13 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | ||
| 12 | - `--timeout 5s` stops NeferWL after 5 seconds. | 14 | - `--timeout 5s` stops NeferWL after 5 seconds. | ||
| 13 | - `grim` and recorders work too; see [Screen capture](capture.md). | 15 | - `grim` and recorders work too; see [Screen capture](capture.md). | ||
A internal/adapters/drm/focus_flash_test.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "github.com/stretchr/testify/require" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFocusFlashRequiresComposition(t *testing.T) { | |
| 10 | + | s, contents := fullscreenScene() | |
| 11 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |
| 12 | + | _, reason := scanoutCandidate(s, contents, 200, 100, nil) | |
| 13 | + | require.Equal(t, "focus_flash", reason) | |
| 14 | + | s, contents = overlayScene() | |
| 15 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |
| 16 | + | _, _, _, reason = overlayCandidate(s, contents, false, nil, nil) | |
| 17 | + | require.Equal(t, "focus_flash", reason) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "github.com/stretchr/testify/require" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFocusFlashRequiresComposition(t *testing.T) { | |||
| 10 | + | s, contents := fullscreenScene() | |||
| 11 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |||
| 12 | + | _, reason := scanoutCandidate(s, contents, 200, 100, nil) | |||
| 13 | + | require.Equal(t, "focus_flash", reason) | |||
| 14 | + | s, contents = overlayScene() | |||
| 15 | + | s.Flash = &ports.FocusFlash{Alpha: 0.5} | |||
| 16 | + | _, _, _, reason = overlayCandidate(s, contents, false, nil, nil) | |||
| 17 | + | require.Equal(t, "focus_flash", reason) | |||
| 18 | + | } | |||
M internal/adapters/drm/overlay.go +6 -2
| @@ -66,6 +66,10 @@ type overlayWin struct { | |||
| 66 | 66 | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | |
| 67 | 67 | // composed, never put on the plane. | |
| 68 | 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | |
| 69 | + | // A hardware plane would bypass the compositor-owned flash border. | |
| 70 | + | if s.Flash != nil { | |
| 71 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | |
| 72 | + | } | |
| 69 | 73 | if s.Transform != 0 { | |
| 70 | 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | |
| 71 | 75 | } | |
| @@ -93,8 +97,8 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 93 | 97 | // buffer, and so is a colour the plane cannot show (raw PQ values | |
| 94 | 98 | // on an SDR output, SDR on HDR without a pipeline). A dimmed | |
| 95 | 99 | // window (a peeking stashed one) needs the veil drawn over it, a | |
| 96 | - | // fading one its translucency. | |
| 97 | - | // lift; an overview preview is drawn smaller than its buffer. | |
| 100 | + | // fading one its translucency; an overview preview is drawn smaller | |
| 101 | + | // than its buffer. | |
| 98 | 102 | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | |
| 99 | 103 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | |
| 100 | 104 | pick, pickAt, mode = w, i, m | |
| @@ -66,6 +66,10 @@ type overlayWin struct { | |||||
| 66 | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | 66 | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | ||
| 67 | // composed, never put on the plane. | 67 | // composed, never put on the plane. | ||
| 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | ||
| 69 | + | // A hardware plane would bypass the compositor-owned flash border. | |||
| 70 | + | if s.Flash != nil { | |||
| 71 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | |||
| 72 | + | } | |||
| 69 | if s.Transform != 0 { | 73 | if s.Transform != 0 { | ||
| 70 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | ||
| 71 | } | 75 | } | ||
| @@ -93,8 +97,8 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 93 | // buffer, and so is a colour the plane cannot show (raw PQ values | 97 | // buffer, and so is a colour the plane cannot show (raw PQ values | ||
| 94 | // on an SDR output, SDR on HDR without a pipeline). A dimmed | 98 | // on an SDR output, SDR on HDR without a pipeline). A dimmed | ||
| 95 | // window (a peeking stashed one) needs the veil drawn over it, a | 99 | // window (a peeking stashed one) needs the veil drawn over it, a | ||
| 96 | - | // fading one its translucency. | 100 | + | // fading one its translucency; an overview preview is drawn smaller |
| 97 | - | // lift; an overview preview is drawn smaller than its buffer. | 101 | + | // than its buffer. |
| 98 | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | 102 | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | ||
| 99 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | 103 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | ||
| 100 | pick, pickAt, mode = w, i, m | 104 | pick, pickAt, mode = w, i, m | ||
M internal/adapters/drm/scanout.go +4 -0
| @@ -90,6 +90,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||
| 90 | 90 | // closed and fades out): the client may already have its buffer back, so | |
| 91 | 91 | // it is never put on a plane (nil: none is). | |
| 92 | 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | |
| 93 | + | // A hardware plane would bypass the compositor-owned flash border. | |
| 94 | + | if s.Flash != nil { | |
| 95 | + | return ports.SurfaceContent{}, "focus_flash" | |
| 96 | + | } | |
| 93 | 97 | if s.Transform != 0 { | |
| 94 | 98 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 95 | 99 | return ports.SurfaceContent{}, "output_transform" | |
| @@ -90,6 +90,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||||
| 90 | // closed and fades out): the client may already have its buffer back, so | 90 | // closed and fades out): the client may already have its buffer back, so | ||
| 91 | // it is never put on a plane (nil: none is). | 91 | // it is never put on a plane (nil: none is). | ||
| 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | ||
| 93 | + | // A hardware plane would bypass the compositor-owned flash border. | |||
| 94 | + | if s.Flash != nil { | |||
| 95 | + | return ports.SurfaceContent{}, "focus_flash" | |||
| 96 | + | } | |||
| 93 | if s.Transform != 0 { | 97 | if s.Transform != 0 { | ||
| 94 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | 98 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | ||
| 95 | return ports.SurfaceContent{}, "output_transform" | 99 | return ports.SurfaceContent{}, "output_transform" | ||
A internal/adapters/vulkan/focus_flash_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "github.com/stretchr/testify/require" | |
| 6 | + | "image" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestFocusFlashGeometryAlphaAndOrder(t *testing.T) { | |
| 11 | + | for _, scale := range []int{1, 2} { | |
| 12 | + | r := &Renderer{width: 128, height: 128} | |
| 13 | + | s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"}, | |
| 14 | + | Separators: []ports.Separator{{Rect: ports.Rect{W: 2, H: 20}, Active: true}}, | |
| 15 | + | Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 0.5}, | |
| 16 | + | CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}} | |
| 17 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |
| 18 | + | require.Len(t, ds, 9) | |
| 19 | + | require.Equal(t, [4]float32{0, 1, 0, 1}, ds[0].pc.color) | |
| 20 | + | for i, rect := range [][4]int32{{8, 4, 24, 6}, {8, 10, 24, 12}, {8, 6, 10, 10}, {22, 6, 24, 10}} { | |
| 21 | + | for j := range rect { | |
| 22 | + | rect[j] *= int32(scale) | |
| 23 | + | } | |
| 24 | + | require.Equal(t, rect, ds[i+1].pc.rect) | |
| 25 | + | require.Equal(t, float32(0.5), ds[i+1].pc.color[3]) | |
| 26 | + | require.Greater(t, ds[i+1].pc.color[0], float32(0)) | |
| 27 | + | require.Zero(t, ds[i+1].pc.color[1]) | |
| 28 | + | } | |
| 29 | + | require.Equal(t, float32(1), ds[5].pc.color[3], "capture indicator remains opaque and above flash") | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestFocusFlashAbsentEmptyAndThin(t *testing.T) { | |
| 34 | + | r := &Renderer{width: 64, height: 64} | |
| 35 | + | for _, f := range []*ports.FocusFlash{nil, {Alpha: 0}, {Alpha: -1}, {Alpha: 1, Rect: ports.Rect{W: 0, H: 10}}} { | |
| 36 | + | s := ports.Scene{Scale: 1, Flash: f} | |
| 37 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |
| 38 | + | } | |
| 39 | + | for _, width := range []int{0, 8} { | |
| 40 | + | s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Alpha: 1, Width: width, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 10}}} | |
| 41 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))) | |
| 42 | + | require.Len(t, ds, 1) | |
| 43 | + | require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect) | |
| 44 | + | } | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "github.com/stretchr/testify/require" | |||
| 6 | + | "image" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFocusFlashGeometryAlphaAndOrder(t *testing.T) { | |||
| 11 | + | for _, scale := range []int{1, 2} { | |||
| 12 | + | r := &Renderer{width: 128, height: 128} | |||
| 13 | + | s := ports.Scene{Scale: float64(scale), Border: ports.Border{Active: "#00ff00"}, | |||
| 14 | + | Separators: []ports.Separator{{Rect: ports.Rect{W: 2, H: 20}, Active: true}}, | |||
| 15 | + | Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 0.5}, | |||
| 16 | + | CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 64, H: 64}}}} | |||
| 17 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128))) | |||
| 18 | + | require.Len(t, ds, 9) | |||
| 19 | + | require.Equal(t, [4]float32{0, 1, 0, 1}, ds[0].pc.color) | |||
| 20 | + | for i, rect := range [][4]int32{{8, 4, 24, 6}, {8, 10, 24, 12}, {8, 6, 10, 10}, {22, 6, 24, 10}} { | |||
| 21 | + | for j := range rect { | |||
| 22 | + | rect[j] *= int32(scale) | |||
| 23 | + | } | |||
| 24 | + | require.Equal(t, rect, ds[i+1].pc.rect) | |||
| 25 | + | require.Equal(t, float32(0.5), ds[i+1].pc.color[3]) | |||
| 26 | + | require.Greater(t, ds[i+1].pc.color[0], float32(0)) | |||
| 27 | + | require.Zero(t, ds[i+1].pc.color[1]) | |||
| 28 | + | } | |||
| 29 | + | require.Equal(t, float32(1), ds[5].pc.color[3], "capture indicator remains opaque and above flash") | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestFocusFlashAbsentEmptyAndThin(t *testing.T) { | |||
| 34 | + | r := &Renderer{width: 64, height: 64} | |||
| 35 | + | for _, f := range []*ports.FocusFlash{nil, {Alpha: 0}, {Alpha: -1}, {Alpha: 1, Rect: ports.Rect{W: 0, H: 10}}} { | |||
| 36 | + | s := ports.Scene{Scale: 1, Flash: f} | |||
| 37 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |||
| 38 | + | } | |||
| 39 | + | for _, width := range []int{0, 8} { | |||
| 40 | + | s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Alpha: 1, Width: width, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 10}}} | |||
| 41 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))) | |||
| 42 | + | require.Len(t, ds, 1) | |||
| 43 | + | require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect) | |||
| 44 | + | } | |||
| 45 | + | } | |||
M internal/adapters/vulkan/scene.go +24 -0
| @@ -58,11 +58,35 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 58 | 58 | w.bounds = outputBounds | |
| 59 | 59 | w.layers(true) | |
| 60 | 60 | w.popups(true) | |
| 61 | + | w.focusFlash() | |
| 61 | 62 | w.captureIndicators() | |
| 62 | 63 | r.scratchDraws = w.draws | |
| 63 | 64 | return w.draws | |
| 64 | 65 | } | |
| 65 | 66 | ||
| 67 | + | // focusFlash draws an inner border in logical coordinates, above the scene | |
| 68 | + | // but below capture indicators. Restore alpha so later overlays stay opaque. | |
| 69 | + | func (w *sceneWalk) focusFlash() { | |
| 70 | + | f := w.s.Flash | |
| 71 | + | if f == nil || f.Alpha <= 0 || f.Rect.W <= 0 || f.Rect.H <= 0 { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | alpha := w.alpha | |
| 75 | + | w.alpha = float32(f.Alpha) | |
| 76 | + | defer func() { w.alpha = alpha }() | |
| 77 | + | t := f.Rect | |
| 78 | + | col := parseColor(f.Color) | |
| 79 | + | b := min(f.Width, t.W/2, t.H/2) | |
| 80 | + | if b <= 0 { | |
| 81 | + | w.fill(w.physRect(t.X, t.Y, t.W, t.H), col) | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | w.fill(w.physRect(t.X, t.Y, t.W, b), col) | |
| 85 | + | w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col) | |
| 86 | + | w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col) | |
| 87 | + | w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col) | |
| 88 | + | } | |
| 89 | + | ||
| 66 | 90 | // captureIndicators draws the compositor's capture indicator over everything | |
| 67 | 91 | // (ports.CaptureIndicator). The border is four fills on the inside edge of the | |
| 68 | 92 | // target, so it stays visible on a monitor edge; the pill is a small rounded | |
| @@ -58,11 +58,35 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 58 | w.bounds = outputBounds | 58 | w.bounds = outputBounds | ||
| 59 | w.layers(true) | 59 | w.layers(true) | ||
| 60 | w.popups(true) | 60 | w.popups(true) | ||
| 61 | + | w.focusFlash() | |||
| 61 | w.captureIndicators() | 62 | w.captureIndicators() | ||
| 62 | r.scratchDraws = w.draws | 63 | r.scratchDraws = w.draws | ||
| 63 | return w.draws | 64 | return w.draws | ||
| 64 | } | 65 | } | ||
| 65 | 66 | ||||
| 67 | + | // focusFlash draws an inner border in logical coordinates, above the scene | |||
| 68 | + | // but below capture indicators. Restore alpha so later overlays stay opaque. | |||
| 69 | + | func (w *sceneWalk) focusFlash() { | |||
| 70 | + | f := w.s.Flash | |||
| 71 | + | if f == nil || f.Alpha <= 0 || f.Rect.W <= 0 || f.Rect.H <= 0 { | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | alpha := w.alpha | |||
| 75 | + | w.alpha = float32(f.Alpha) | |||
| 76 | + | defer func() { w.alpha = alpha }() | |||
| 77 | + | t := f.Rect | |||
| 78 | + | col := parseColor(f.Color) | |||
| 79 | + | b := min(f.Width, t.W/2, t.H/2) | |||
| 80 | + | if b <= 0 { | |||
| 81 | + | w.fill(w.physRect(t.X, t.Y, t.W, t.H), col) | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | w.fill(w.physRect(t.X, t.Y, t.W, b), col) | |||
| 85 | + | w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col) | |||
| 86 | + | w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col) | |||
| 87 | + | w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 66 | // captureIndicators draws the compositor's capture indicator over everything | 90 | // captureIndicators draws the compositor's capture indicator over everything | ||
| 67 | // (ports.CaptureIndicator). The border is four fills on the inside edge of the | 91 | // (ports.CaptureIndicator). The border is four fills on the inside edge of the | ||
| 68 | // target, so it stays visible on a monitor edge; the pill is a small rounded | 92 | // target, so it stays visible on a monitor edge; the pill is a small rounded | ||
M internal/core/scene.go +1 -3
| @@ -138,9 +138,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 138 | 138 | // while it draws the same, so an idle output is not recomposed. | |
| 139 | 139 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | |
| 140 | 140 | animating := scene.Animating && (only == nil || only == sc) | |
| 141 | - | // Ports' scene equality does not yet compare the phase-3 overlay. | |
| 142 | - | flashSame := scene.Flash == nil && sc.last.Flash == nil || scene.Flash != nil && sc.last.Flash != nil && *scene.Flash == *sc.last.Flash | |
| 143 | - | if !withCapture && !animating && flashSame && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |
| 141 | + | if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |
| 144 | 142 | scene.Seq = sc.last.Seq | |
| 145 | 143 | } else { | |
| 146 | 144 | c.seq++ | |
| @@ -138,9 +138,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 138 | // while it draws the same, so an idle output is not recomposed. | 138 | // while it draws the same, so an idle output is not recomposed. | ||
| 139 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | 139 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | ||
| 140 | animating := scene.Animating && (only == nil || only == sc) | 140 | animating := scene.Animating && (only == nil || only == sc) | ||
| 141 | - | // Ports' scene equality does not yet compare the phase-3 overlay. | 141 | + | if !withCapture && !animating && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { |
| 142 | - | flashSame := scene.Flash == nil && sc.last.Flash == nil || scene.Flash != nil && sc.last.Flash != nil && *scene.Flash == *sc.last.Flash | |||
| 143 | - | if !withCapture && !animating && flashSame && sc.last.Animating == scene.Animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |||
| 144 | scene.Seq = sc.last.Seq | 142 | scene.Seq = sc.last.Seq | ||
| 145 | } else { | 143 | } else { | ||
| 146 | c.seq++ | 144 | c.seq++ | ||
M internal/ports/scene.go +3 -0
| @@ -94,6 +94,9 @@ func (s Scene) SameAs(o Scene) bool { | |||
| 94 | 94 | s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | |
| 95 | 95 | return false | |
| 96 | 96 | } | |
| 97 | + | if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) { | |
| 98 | + | return false | |
| 99 | + | } | |
| 97 | 100 | if s.CaptureScene != nil || o.CaptureScene != nil { | |
| 98 | 101 | return false | |
| 99 | 102 | } | |
| @@ -94,6 +94,9 @@ func (s Scene) SameAs(o Scene) bool { | |||||
| 94 | s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | 94 | s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind { | ||
| 95 | return false | 95 | return false | ||
| 96 | } | 96 | } | ||
| 97 | + | if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) { | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 97 | if s.CaptureScene != nil || o.CaptureScene != nil { | 100 | if s.CaptureScene != nil || o.CaptureScene != nil { | ||
| 98 | return false | 101 | return false | ||
| 99 | } | 102 | } | ||
M internal/ports/scene_same_test.go +6 -0
| @@ -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 | + | Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6}, | |
| 26 | 27 | CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}}, | |
| 27 | 28 | } | |
| 28 | 29 | } | |
| @@ -36,6 +37,11 @@ func TestSceneSameAs(t *testing.T) { | |||
| 36 | 37 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | |
| 37 | 38 | ||
| 38 | 39 | for name, edit := range map[string]func(*Scene){ | |
| 40 | + | "flash nil": func(s *Scene) { s.Flash = nil }, | |
| 41 | + | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | |
| 42 | + | "flash width": func(s *Scene) { s.Flash.Width++ }, | |
| 43 | + | "flash color": func(s *Scene) { s.Flash.Color = "#ffffff" }, | |
| 44 | + | "flash alpha": func(s *Scene) { s.Flash.Alpha = 0.2 }, | |
| 39 | 45 | "window rect": func(s *Scene) { s.Windows[0].Rect.X++ }, | |
| 40 | 46 | "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 }, | |
| 41 | 47 | "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 }, | |
| @@ -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 | + | Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6}, | |||
| 26 | CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}}, | 27 | CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}}, | ||
| 27 | } | 28 | } | ||
| 28 | } | 29 | } | ||
| @@ -36,6 +37,11 @@ func TestSceneSameAs(t *testing.T) { | |||||
| 36 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | 37 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | ||
| 37 | 38 | ||||
| 38 | for name, edit := range map[string]func(*Scene){ | 39 | for name, edit := range map[string]func(*Scene){ | ||
| 40 | + | "flash nil": func(s *Scene) { s.Flash = nil }, | |||
| 41 | + | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | |||
| 42 | + | "flash width": func(s *Scene) { s.Flash.Width++ }, | |||
| 43 | + | "flash color": func(s *Scene) { s.Flash.Color = "#ffffff" }, | |||
| 44 | + | "flash alpha": func(s *Scene) { s.Flash.Alpha = 0.2 }, | |||
| 39 | "window rect": func(s *Scene) { s.Windows[0].Rect.X++ }, | 45 | "window rect": func(s *Scene) { s.Windows[0].Rect.X++ }, | ||
| 40 | "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 }, | 46 | "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 }, | ||
| 41 | "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 }, | 47 | "window zoom": func(s *Scene) { s.Windows[0].Zoom = 0.9 }, | ||