detached 4f9bb17

fix(render): round the focus flash like the window corners

The flash is now drawn inside the window border, following the body's inner corner radius (ports.FocusFlash.Radius), instead of square over the border.
commit
4f9bb17e8dadfc56011ec4288f299155b53159b6
author
brice · · 2026-10-21 07:33 UTC
parent
318e2d8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
2/2
6 files · +135 -19
M docs/config.md +1 -1
@@ -54,7 +54,7 @@ See [examples/config](../examples/config) for every key with its default.
5454 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
5555 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
5656 | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured |
57-| `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed |
57+| `focus.flash` | `on` | Briefly draw an inner border, inside the window border, on the window, column or output an action changed |
5858 | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
5959 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
6060 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
@@ -54,7 +54,7 @@ See [examples/config](../examples/config) for every key with its default.
54 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |54 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
55 | `focus.follow-move` | `off` | Follow a column moved to another workspace |55 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
56 | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured |56 | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured |
57-| `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed |57+| `focus.flash` | `on` | Briefly draw an inner border, inside the window border, on the window, column or output an action changed |
58 | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |58 | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
59 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |59 | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` |
60 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |60 | `focus.flash-duration` | `200ms` | Fade duration, `50ms` to `1s`; not scaled by `animations.speed` |
M internal/adapters/vulkan/focus_flash_test.go +28 -0
@@ -4,6 +4,7 @@ import (
44 "github.com/bnema/neferwl/internal/ports"
55 "github.com/stretchr/testify/require"
66 "image"
7+ "math"
78 "testing"
89 )
910
@@ -43,3 +44,30 @@ func TestFocusFlashAbsentEmptyAndThin(t *testing.T) {
4344 require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect)
4445 }
4546 }
47+
48+func TestFocusFlashRoundedRing(t *testing.T) {
49+ for _, scale := range []int{1, 2} {
50+ r := &Renderer{width: 128, height: 128}
51+ s := ports.Scene{Scale: float64(scale),
52+ Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 40, H: 30}, Width: 3, Radius: 11, Color: "#ff0000", Alpha: 0.5}}
53+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
54+ require.Len(t, ds, 1)
55+ d := ds[0]
56+ require.Equal(t, uint32(flagRounded|flagRing), d.pc.misc[1]&(flagRounded|flagRing))
57+ require.Equal(t, [4]int32{8 * int32(scale), 4 * int32(scale), 48 * int32(scale), 34 * int32(scale)}, d.pc.rect)
58+ require.Equal(t, float32(0.5), d.pc.color[3])
59+ require.Equal(t, math.Float32bits(float32(11*scale)), d.radii[0])
60+ require.Equal(t, math.Float32bits(float32(8*scale)), d.radii[1])
61+ require.Equal(t, roundRect(image.Rect(11*scale, 7*scale, 45*scale, 31*scale)), d.roundInner)
62+ }
63+}
64+
65+func TestFocusFlashZeroRadiusStaysSquare(t *testing.T) {
66+ r := &Renderer{width: 64, height: 64}
67+ s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 1}}
68+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
69+ require.Len(t, ds, 4)
70+ for _, d := range ds {
71+ require.Zero(t, d.pc.misc[1]&(flagRounded|flagRing))
72+ }
73+}
@@ -4,6 +4,7 @@ import (
4 "github.com/bnema/neferwl/internal/ports"4 "github.com/bnema/neferwl/internal/ports"
5 "github.com/stretchr/testify/require"5 "github.com/stretchr/testify/require"
6 "image"6 "image"
7+ "math"
7 "testing"8 "testing"
8 )9 )
9 10
@@ -43,3 +44,30 @@ func TestFocusFlashAbsentEmptyAndThin(t *testing.T) {
43 require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect)44 require.Equal(t, [4]int32{2, 3, 3, 13}, ds[0].pc.rect)
44 }45 }
45 }46 }
47+
48+func TestFocusFlashRoundedRing(t *testing.T) {
49+ for _, scale := range []int{1, 2} {
50+ r := &Renderer{width: 128, height: 128}
51+ s := ports.Scene{Scale: float64(scale),
52+ Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 40, H: 30}, Width: 3, Radius: 11, Color: "#ff0000", Alpha: 0.5}}
53+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 128, 128)))
54+ require.Len(t, ds, 1)
55+ d := ds[0]
56+ require.Equal(t, uint32(flagRounded|flagRing), d.pc.misc[1]&(flagRounded|flagRing))
57+ require.Equal(t, [4]int32{8 * int32(scale), 4 * int32(scale), 48 * int32(scale), 34 * int32(scale)}, d.pc.rect)
58+ require.Equal(t, float32(0.5), d.pc.color[3])
59+ require.Equal(t, math.Float32bits(float32(11*scale)), d.radii[0])
60+ require.Equal(t, math.Float32bits(float32(8*scale)), d.radii[1])
61+ require.Equal(t, roundRect(image.Rect(11*scale, 7*scale, 45*scale, 31*scale)), d.roundInner)
62+ }
63+}
64+
65+func TestFocusFlashZeroRadiusStaysSquare(t *testing.T) {
66+ r := &Renderer{width: 64, height: 64}
67+ s := ports.Scene{Scale: 1, Flash: &ports.FocusFlash{Rect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, Width: 2, Color: "#ff0000", Alpha: 1}}
68+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
69+ require.Len(t, ds, 4)
70+ for _, d := range ds {
71+ require.Zero(t, d.pc.misc[1]&(flagRounded|flagRing))
72+ }
73+}
M internal/adapters/vulkan/scene.go +25 -1
@@ -79,10 +79,34 @@ func (w *sceneWalk) focusFlash() {
7979 }
8080 alpha := w.alpha
8181 w.alpha = float32(f.Alpha)
82- w.innerBorder(f.Rect, min(f.Width, f.Rect.W/2, f.Rect.H/2), parseColor(f.Color))
82+ b := min(f.Width, f.Rect.W/2, f.Rect.H/2)
83+ if f.Radius > 0 && b > 0 {
84+ w.roundedInnerBorder(f.Rect, b, f.Radius, parseColor(f.Color))
85+ } else {
86+ w.innerBorder(f.Rect, b, parseColor(f.Color))
87+ }
8388 w.alpha = alpha
8489 }
8590
91+// roundedInnerBorder draws the flash as one rounded ring: the target rect with
92+// radius, minus a hole inset by b with radius max(radius-b, 0).
93+func (w *sceneWalk) roundedInnerBorder(t ports.Rect, b, radius int, col [3]uint8) {
94+ outer := w.physRect(t.X, t.Y, t.W, t.H)
95+ n := len(w.draws)
96+ w.fill(outer, col)
97+ if len(w.draws) == n {
98+ return
99+ }
100+ r := float32(math.Round(float64(radius) * w.scale))
101+ dr := &w.draws[n]
102+ roundedDraw(dr, outer, r)
103+ dr.pc.misc[1] |= flagRing
104+ hole := w.physRect(t.X+b, t.Y+b, t.W-2*b, t.H-2*b)
105+ bPhys := min(hole.Min.X-outer.Min.X, hole.Min.Y-outer.Min.Y, outer.Max.X-hole.Max.X, outer.Max.Y-hole.Max.Y)
106+ dr.roundInner = roundRect(hole)
107+ dr.radii[1] = math.Float32bits(max(r-float32(max(bPhys, 0)), 0))
108+}
109+
86110 // innerBorder draws four non-overlapping strips, or fills a thin target.
87111 func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) {
88112 if b <= 0 {
@@ -79,10 +79,34 @@ func (w *sceneWalk) focusFlash() {
79 }79 }
80 alpha := w.alpha80 alpha := w.alpha
81 w.alpha = float32(f.Alpha)81 w.alpha = float32(f.Alpha)
82- w.innerBorder(f.Rect, min(f.Width, f.Rect.W/2, f.Rect.H/2), parseColor(f.Color))82+ b := min(f.Width, f.Rect.W/2, f.Rect.H/2)
83+ if f.Radius > 0 && b > 0 {
84+ w.roundedInnerBorder(f.Rect, b, f.Radius, parseColor(f.Color))
85+ } else {
86+ w.innerBorder(f.Rect, b, parseColor(f.Color))
87+ }
83 w.alpha = alpha88 w.alpha = alpha
84 }89 }
85 90
91+// roundedInnerBorder draws the flash as one rounded ring: the target rect with
92+// radius, minus a hole inset by b with radius max(radius-b, 0).
93+func (w *sceneWalk) roundedInnerBorder(t ports.Rect, b, radius int, col [3]uint8) {
94+ outer := w.physRect(t.X, t.Y, t.W, t.H)
95+ n := len(w.draws)
96+ w.fill(outer, col)
97+ if len(w.draws) == n {
98+ return
99+ }
100+ r := float32(math.Round(float64(radius) * w.scale))
101+ dr := &w.draws[n]
102+ roundedDraw(dr, outer, r)
103+ dr.pc.misc[1] |= flagRing
104+ hole := w.physRect(t.X+b, t.Y+b, t.W-2*b, t.H-2*b)
105+ bPhys := min(hole.Min.X-outer.Min.X, hole.Min.Y-outer.Min.Y, outer.Max.X-hole.Max.X, outer.Max.Y-hole.Max.Y)
106+ dr.roundInner = roundRect(hole)
107+ dr.radii[1] = math.Float32bits(max(r-float32(max(bPhys, 0)), 0))
108+}
109+
86 // innerBorder draws four non-overlapping strips, or fills a thin target.110 // innerBorder draws four non-overlapping strips, or fills a thin target.
87 func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) {111 func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) {
88 if b <= 0 {112 if b <= 0 {
M internal/core/flash.go +19 -15
@@ -31,6 +31,7 @@ type focusFlash struct {
3131 timerStop func() bool
3232 running *flashTarget
3333 rect Rect
34+ radius int // Corner radius of the target, as SceneWindow.Radius.
3435 start time.Time
3536 value float64 // Retained between this output's own flips.
3637 lastStarts map[flashTarget]time.Time
@@ -87,7 +88,7 @@ func (c *Core) flashTick() bool {
8788 f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)
8889 return false
8990 }
90- rect, ok := c.flashRect(target, sc)
91+ rect, radius, ok := c.flashRect(target, sc)
9192 if !ok {
9293 return false
9394 }
@@ -103,7 +104,7 @@ func (c *Core) flashTick() bool {
103104 }
104105 }
105106 f.lastStarts[target] = now
106- f.running, f.rect, f.start = &target, rect, now
107+ f.running, f.rect, f.radius, f.start = &target, rect, radius, now
107108 f.value = c.cfg.Focus.FlashOpacity
108109 return true
109110 }
@@ -118,7 +119,7 @@ func (c *Core) advanceFlash(only *screen) {
118119 return
119120 }
120121 sc := c.screens[c.screenIndex(f.running.output)]
121- if rect, ok := c.flashRect(*f.running, sc); !ok || rect != f.rect {
122+ if rect, radius, ok := c.flashRect(*f.running, sc); !ok || rect != f.rect || radius != f.radius {
122123 c.stopFlash()
123124 return
124125 }
@@ -138,47 +139,50 @@ func (c *Core) advanceFlash(only *screen) {
138139 // flashRect resolves the current target, never retaining a border after its
139140 // visibility or geometry changes. stopFlash leaves the published sample intact
140141 // so the scene diff also damages the old border.
141-func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, bool) {
142+func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, int, bool) {
142143 if sc.mon.ov.open {
143- return Rect{}, false
144+ return Rect{}, 0, false
144145 }
145146 if target.level == flashMonitor {
146147 o := sc.mon.Output()
147- return Rect{W: o.W, H: o.H}, true
148+ return Rect{W: o.W, H: o.H}, 0, true
148149 }
149150 owner, _ := c.screenOf(target.window)
150151 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
151- return Rect{}, false
152+ return Rect{}, 0, false
152153 }
153154 view := sc.mon.viewport()
154155 c.flash.layout = sc.mon.layoutInto(c.flash.layout)
155156 var rect Rect
157+ radius := 0
156158 found := false
157159 for _, p := range c.flash.layout {
158160 if p.ID == target.window {
159161 if p.Fullscreen || p.Preview != 0 || !view.shows(p) {
160- return Rect{}, false
162+ return Rect{}, 0, false
161163 }
162- rect, found = p.Rect, true
164+ // The flash sits inside the drawn border, hugging the body.
165+ rect, found = c.clientRect(p), true
166+ radius = max(windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius)-c.cfg.Border.Width, 0)
163167 break
164168 }
165169 }
166170 if !found {
167- return Rect{}, false
171+ return Rect{}, 0, false
168172 }
169173 if target.level == flashColumn {
170174 w := sc.mon.Current()
171175 col := w.columnOf(target.window)
172176 if col < 0 {
173- return Rect{}, false
177+ return Rect{}, 0, false
174178 }
175179 for _, p := range c.flash.layout {
176180 if !p.Hidden && slices.Contains(w.Columns[col].Windows, p.ID) {
177- rect = union(rect, p.Rect)
181+ rect = union(rect, c.clientRect(p))
178182 }
179183 }
180184 }
181- return rect, true
185+ return rect, radius, true
182186 }
183187
184188 func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
@@ -190,9 +194,9 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
190194 if color == "" {
191195 color = c.cfg.Border.Active
192196 }
193- sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}
197+ sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Radius: f.radius, Color: color, Alpha: f.value}
194198 if f.published == nil || *f.published != sample {
195- f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha}
199+ f.published = &sample
196200 }
197201 return f.published
198202 }
@@ -31,6 +31,7 @@ type focusFlash struct {
31 timerStop func() bool31 timerStop func() bool
32 running *flashTarget32 running *flashTarget
33 rect Rect33 rect Rect
34+ radius int // Corner radius of the target, as SceneWindow.Radius.
34 start time.Time35 start time.Time
35 value float64 // Retained between this output's own flips.36 value float64 // Retained between this output's own flips.
36 lastStarts map[flashTarget]time.Time37 lastStarts map[flashTarget]time.Time
@@ -87,7 +88,7 @@ func (c *Core) flashTick() bool {
87 f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)88 f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)
88 return false89 return false
89 }90 }
90- rect, ok := c.flashRect(target, sc)91+ rect, radius, ok := c.flashRect(target, sc)
91 if !ok {92 if !ok {
92 return false93 return false
93 }94 }
@@ -103,7 +104,7 @@ func (c *Core) flashTick() bool {
103 }104 }
104 }105 }
105 f.lastStarts[target] = now106 f.lastStarts[target] = now
106- f.running, f.rect, f.start = &target, rect, now107+ f.running, f.rect, f.radius, f.start = &target, rect, radius, now
107 f.value = c.cfg.Focus.FlashOpacity108 f.value = c.cfg.Focus.FlashOpacity
108 return true109 return true
109 }110 }
@@ -118,7 +119,7 @@ func (c *Core) advanceFlash(only *screen) {
118 return119 return
119 }120 }
120 sc := c.screens[c.screenIndex(f.running.output)]121 sc := c.screens[c.screenIndex(f.running.output)]
121- if rect, ok := c.flashRect(*f.running, sc); !ok || rect != f.rect {122+ if rect, radius, ok := c.flashRect(*f.running, sc); !ok || rect != f.rect || radius != f.radius {
122 c.stopFlash()123 c.stopFlash()
123 return124 return
124 }125 }
@@ -138,47 +139,50 @@ func (c *Core) advanceFlash(only *screen) {
138 // flashRect resolves the current target, never retaining a border after its139 // flashRect resolves the current target, never retaining a border after its
139 // visibility or geometry changes. stopFlash leaves the published sample intact140 // visibility or geometry changes. stopFlash leaves the published sample intact
140 // so the scene diff also damages the old border.141 // so the scene diff also damages the old border.
141-func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, bool) {142+func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, int, bool) {
142 if sc.mon.ov.open {143 if sc.mon.ov.open {
143- return Rect{}, false144+ return Rect{}, 0, false
144 }145 }
145 if target.level == flashMonitor {146 if target.level == flashMonitor {
146 o := sc.mon.Output()147 o := sc.mon.Output()
147- return Rect{W: o.W, H: o.H}, true148+ return Rect{W: o.W, H: o.H}, 0, true
148 }149 }
149 owner, _ := c.screenOf(target.window)150 owner, _ := c.screenOf(target.window)
150 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {151 if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
151- return Rect{}, false152+ return Rect{}, 0, false
152 }153 }
153 view := sc.mon.viewport()154 view := sc.mon.viewport()
154 c.flash.layout = sc.mon.layoutInto(c.flash.layout)155 c.flash.layout = sc.mon.layoutInto(c.flash.layout)
155 var rect Rect156 var rect Rect
157+ radius := 0
156 found := false158 found := false
157 for _, p := range c.flash.layout {159 for _, p := range c.flash.layout {
158 if p.ID == target.window {160 if p.ID == target.window {
159 if p.Fullscreen || p.Preview != 0 || !view.shows(p) {161 if p.Fullscreen || p.Preview != 0 || !view.shows(p) {
160- return Rect{}, false162+ return Rect{}, 0, false
161 }163 }
162- rect, found = p.Rect, true164+ // The flash sits inside the drawn border, hugging the body.
165+ rect, found = c.clientRect(p), true
166+ radius = max(windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius)-c.cfg.Border.Width, 0)
163 break167 break
164 }168 }
165 }169 }
166 if !found {170 if !found {
167- return Rect{}, false171+ return Rect{}, 0, false
168 }172 }
169 if target.level == flashColumn {173 if target.level == flashColumn {
170 w := sc.mon.Current()174 w := sc.mon.Current()
171 col := w.columnOf(target.window)175 col := w.columnOf(target.window)
172 if col < 0 {176 if col < 0 {
173- return Rect{}, false177+ return Rect{}, 0, false
174 }178 }
175 for _, p := range c.flash.layout {179 for _, p := range c.flash.layout {
176 if !p.Hidden && slices.Contains(w.Columns[col].Windows, p.ID) {180 if !p.Hidden && slices.Contains(w.Columns[col].Windows, p.ID) {
177- rect = union(rect, p.Rect)181+ rect = union(rect, c.clientRect(p))
178 }182 }
179 }183 }
180 }184 }
181- return rect, true185+ return rect, radius, true
182 }186 }
183 187
184 func (c *Core) flashFor(sc *screen) *ports.FocusFlash {188 func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
@@ -190,9 +194,9 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
190 if color == "" {194 if color == "" {
191 color = c.cfg.Border.Active195 color = c.cfg.Border.Active
192 }196 }
193- sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}197+ sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Radius: f.radius, Color: color, Alpha: f.value}
194 if f.published == nil || *f.published != sample {198 if f.published == nil || *f.published != sample {
195- f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha}199+ f.published = &sample
196 }200 }
197 return f.published201 return f.published
198 }202 }
M internal/core/flash_test.go +57 -0
@@ -717,3 +717,60 @@ func TestFlashGeometryRevalidationAllocations(t *testing.T) {
717717 })
718718 }
719719 }
720+
721+func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
722+ for _, test := range []struct {
723+ name string
724+ gaps int
725+ float bool
726+ level flashLevel
727+ radius int
728+ }{
729+ {"tile with gaps", 12, false, flashWindow, 11},
730+ {"column with gaps", 12, false, flashColumn, 11},
731+ {"tile without gaps", 0, false, flashWindow, 0},
732+ {"float", 0, true, flashWindow, 11},
733+ {"monitor", 12, false, flashMonitor, 0},
734+ } {
735+ t.Run(test.name, func(t *testing.T) {
736+ c, clock := flashCore(t)
737+ c.cfg.Border.Width, c.cfg.Border.Radius = 3, 14
738+ w := c.cur().mon.Current()
739+ w.Gaps = test.gaps
740+ if test.float {
741+ c.applyAction(ActionToggleFloating)
742+ c.stopFlash()
743+ c.stopAnimations()
744+ }
745+ id := WindowID(3)
746+ if f, ok := w.Focused(); ok && test.float {
747+ id = f
748+ }
749+ startFlash(t, c, clock, test.level, id)
750+ want := Rect{W: 300, H: 200}
751+ if test.level != flashMonitor {
752+ for _, p := range c.cur().mon.Layout() {
753+ if p.ID == id {
754+ want = p.Rect.Inset(p.Inset, 3)
755+ if test.level == flashColumn {
756+ want = c.flash.rect
757+ }
758+ }
759+ }
760+ }
761+ if c.flash.rect != want || c.flash.radius != test.radius {
762+ t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
763+ }
764+ if f := c.flashFor(c.cur()); f == nil || f.Radius != test.radius {
765+ t.Fatalf("published %+v", f)
766+ }
767+ if test.level == flashWindow && test.gaps > 0 {
768+ for _, p := range c.cur().mon.Layout() {
769+ if p.ID == id && (p.Inset != ports.SideAll || c.flash.rect.W != p.Rect.W-6 || c.flash.rect.H != p.Rect.H-6) {
770+ t.Fatalf("tile %+v not inset by 3 on all sides: %+v", p, c.flash.rect)
771+ }
772+ }
773+ }
774+ })
775+ }
776+}
@@ -717,3 +717,60 @@ func TestFlashGeometryRevalidationAllocations(t *testing.T) {
717 })717 })
718 }718 }
719 }719 }
720+
721+func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
722+ for _, test := range []struct {
723+ name string
724+ gaps int
725+ float bool
726+ level flashLevel
727+ radius int
728+ }{
729+ {"tile with gaps", 12, false, flashWindow, 11},
730+ {"column with gaps", 12, false, flashColumn, 11},
731+ {"tile without gaps", 0, false, flashWindow, 0},
732+ {"float", 0, true, flashWindow, 11},
733+ {"monitor", 12, false, flashMonitor, 0},
734+ } {
735+ t.Run(test.name, func(t *testing.T) {
736+ c, clock := flashCore(t)
737+ c.cfg.Border.Width, c.cfg.Border.Radius = 3, 14
738+ w := c.cur().mon.Current()
739+ w.Gaps = test.gaps
740+ if test.float {
741+ c.applyAction(ActionToggleFloating)
742+ c.stopFlash()
743+ c.stopAnimations()
744+ }
745+ id := WindowID(3)
746+ if f, ok := w.Focused(); ok && test.float {
747+ id = f
748+ }
749+ startFlash(t, c, clock, test.level, id)
750+ want := Rect{W: 300, H: 200}
751+ if test.level != flashMonitor {
752+ for _, p := range c.cur().mon.Layout() {
753+ if p.ID == id {
754+ want = p.Rect.Inset(p.Inset, 3)
755+ if test.level == flashColumn {
756+ want = c.flash.rect
757+ }
758+ }
759+ }
760+ }
761+ if c.flash.rect != want || c.flash.radius != test.radius {
762+ t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
763+ }
764+ if f := c.flashFor(c.cur()); f == nil || f.Radius != test.radius {
765+ t.Fatalf("published %+v", f)
766+ }
767+ if test.level == flashWindow && test.gaps > 0 {
768+ for _, p := range c.cur().mon.Layout() {
769+ if p.ID == id && (p.Inset != ports.SideAll || c.flash.rect.W != p.Rect.W-6 || c.flash.rect.H != p.Rect.H-6) {
770+ t.Fatalf("tile %+v not inset by 3 on all sides: %+v", p, c.flash.rect)
771+ }
772+ }
773+ }
774+ })
775+ }
776+}
M internal/ports/scene.go +5 -2
@@ -201,8 +201,11 @@ type SceneLayer struct {
201201 type FocusFlash struct {
202202 Rect Rect
203203 Width int
204- Color string
205- Alpha float64
204+ // Radius is the target's corner radius in logical pixels (0 = square),
205+ // the same as SceneWindow.Radius; the border follows it.
206+ Radius int
207+ Color string
208+ Alpha float64
206209 }
207210
208211 // SceneWindow carries core → renderer window placement.
@@ -201,8 +201,11 @@ type SceneLayer struct {
201 type FocusFlash struct {201 type FocusFlash struct {
202 Rect Rect202 Rect Rect
203 Width int203 Width int
204- Color string204+ // Radius is the target's corner radius in logical pixels (0 = square),
205- Alpha float64205+ // the same as SceneWindow.Radius; the border follows it.
206+ Radius int
207+ Color string
208+ Alpha float64
206 }209 }
207 210
208 // SceneWindow carries core → renderer window placement.211 // SceneWindow carries core → renderer window placement.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close