detached 3b72e8e

feat(vulkan): draw focus flash as inner border

commit
3b72e8ebbbe42037e56afcd09905aa99c3359541
author
brice · · 2026-10-19 04:43 UTC
parent
4e6e449
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
325325 bind.cmd+code:12 = scale-down
326326 ```
327327
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+
328332 Per-output defaults (replace `<name>` with a connector such as `DP-1`):
329333
330334 ```text
@@ -325,6 +325,10 @@ bind.cmd+code:13 = scale-up
325 bind.cmd+code:12 = scale-down325 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 ```text334 ```text
M docs/config.md +4 -0
@@ -77,6 +77,10 @@ See [examples/config](../examples/config) for every key with its default.
7777
7878 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
7979
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+
8084 ## Window rules
8185
8286 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 rules84 ## 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
88 ```
99
1010 - `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.
1113 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
1214 - `--timeout 5s` stops NeferWL after 5 seconds.
1315 - `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 {
6666 // kept is scanoutCandidate's: a window drawn from a withdrawn content is
6767 // composed, never put on the plane.
6868 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+ }
6973 if s.Transform != 0 {
7074 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
7175 }
@@ -93,8 +97,8 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9397 // buffer, and so is a colour the plane cannot show (raw PQ values
9498 // on an SDR output, SDR on HDR without a pipeline). A dimmed
9599 // 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.
98102 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)) {
99103 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
100104 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 is66 // 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 values97 // 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 dimmed98 // on an SDR output, SDR on HDR without a pipeline). A dimmed
95 // window (a peeking stashed one) needs the veil drawn over it, a99 // 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, m104 pick, pickAt, mode = w, i, m
M internal/adapters/drm/scanout.go +4 -0
@@ -90,6 +90,10 @@ func fullscreenShown(s *ports.Scene) bool {
9090 // closed and fades out): the client may already have its buffer back, so
9191 // it is never put on a plane (nil: none is).
9292 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+ }
9397 if s.Transform != 0 {
9498 // The scanout buffer would have to be pre-rotated: rotated outputs compose.
9599 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, so90 // 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
5858 w.bounds = outputBounds
5959 w.layers(true)
6060 w.popups(true)
61+ w.focusFlash()
6162 w.captureIndicators()
6263 r.scratchDraws = w.draws
6364 return w.draws
6465 }
6566
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+
6690 // captureIndicators draws the compositor's capture indicator over everything
6791 // (ports.CaptureIndicator). The border is four fills on the inside edge of the
6892 // 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 = outputBounds58 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.draws63 r.scratchDraws = w.draws
63 return w.draws64 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 everything90 // captureIndicators draws the compositor's capture indicator over everything
67 // (ports.CaptureIndicator). The border is four fills on the inside edge of the91 // (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 rounded92 // 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
138138 // while it draws the same, so an idle output is not recomposed.
139139 withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
140140 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) {
144142 scene.Seq = sc.last.Seq
145143 } else {
146144 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.Seq142 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 {
9494 s.WorkspaceClip != o.WorkspaceClip || s.TileInset != o.TileInset || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
9595 return false
9696 }
97+ if (s.Flash == nil) != (o.Flash == nil) || (s.Flash != nil && *s.Flash != *o.Flash) {
98+ return false
99+ }
97100 if s.CaptureScene != nil || o.CaptureScene != nil {
98101 return false
99102 }
@@ -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 false95 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 false101 return false
99 }102 }
M internal/ports/scene_same_test.go +6 -0
@@ -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+ Flash: &FocusFlash{Rect: Rect{W: 20, H: 10}, Width: 2, Color: "#ff0000", Alpha: 0.6},
2627 CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
2728 }
2829 }
@@ -36,6 +37,11 @@ func TestSceneSameAs(t *testing.T) {
3637 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
3738
3839 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 },
3945 "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
4046 "window fade": func(s *Scene) { s.Windows[0].Fade = 0.5 },
4147 "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 },

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 24

Tags 10

↑↓ moveenter openesc close