detached 4f9bb17

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

state
merged · 4f9bb17 · fast-forward
branch
fix/flash-radius → main
opened
bnema ·
updated

What

The focus flash is drawn inside the window border and follows its rounded corners.

Before, the flash used the full tile rect (border included) with square corners: with the defaults (focus.flash-width = 2, border.width = 2, same border.active color) it covered the border exactly, so only its square corners showed over the rounded border.

How

  • ports.FocusFlash.Radius (logical px, 0 = square).
  • Core: window and column flashes use the client rect (tile minus its drawn border sides, clientRect) and the inner radius max(windowRadius(...) - border.width, 0), the same values the renderer uses for the window body. Monitor flash unchanged.
  • Vulkan: roundedInnerBorder draws one rounded ring (flagRounded|flagRing, rounded hole) when Radius > 0; square strips otherwise.

Checks

  • make check, make race, make mocks-check, staticcheck ./... pass.
  • Tests: TestFlashFollowsInnerBorderAndRadius (tile/column with gaps, tile without gaps, float, monitor), TestFocusFlashRoundedRing (scale 1 and 2), TestFocusFlashZeroRadiusStaysSquare.
  • Headless e2e (gaps 12, border 3, radius 14, red flash): mid-flash frames show a 3 px rounded flash ring inside the grey border, no square corners.
CommitSubjectAuthorDateSignatureStatus
4f9bb17fix(render): round the focus flash like the window cornersbriceGPG Verified

Checks 2

6 files · +135 -19
2/2
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.

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close