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.
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. | |||
| 54 | 54 | | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` | | |
| 55 | 55 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 56 | 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 | 58 | | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` | | |
| 59 | 59 | | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` | | |
| 60 | 60 | | `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 ( | |||
| 4 | 4 | "github.com/bnema/neferwl/internal/ports" | |
| 5 | 5 | "github.com/stretchr/testify/require" | |
| 6 | 6 | "image" | |
| 7 | + | "math" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -43,3 +44,30 @@ func TestFocusFlashAbsentEmptyAndThin(t *testing.T) { | |||
| 43 | 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 | + | } | |
| @@ -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() { | |||
| 79 | 79 | } | |
| 80 | 80 | alpha := w.alpha | |
| 81 | 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 | 88 | 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 | 110 | // innerBorder draws four non-overlapping strips, or fills a thin target. | |
| 87 | 111 | func (w *sceneWalk) innerBorder(t ports.Rect, b int, col [3]uint8) { | |
| 88 | 112 | if b <= 0 { | |
| @@ -79,10 +79,34 @@ func (w *sceneWalk) focusFlash() { | |||||
| 79 | } | 79 | } | ||
| 80 | alpha := w.alpha | 80 | 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 = alpha | 88 | 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 { | |||
| 31 | 31 | timerStop func() bool | |
| 32 | 32 | running *flashTarget | |
| 33 | 33 | rect Rect | |
| 34 | + | radius int // Corner radius of the target, as SceneWindow.Radius. | |
| 34 | 35 | start time.Time | |
| 35 | 36 | value float64 // Retained between this output's own flips. | |
| 36 | 37 | lastStarts map[flashTarget]time.Time | |
| @@ -87,7 +88,7 @@ func (c *Core) flashTick() bool { | |||
| 87 | 88 | f.timerC, f.timerStop = newTimer(c.opts.Clock, wait) | |
| 88 | 89 | return false | |
| 89 | 90 | } | |
| 90 | - | rect, ok := c.flashRect(target, sc) | |
| 91 | + | rect, radius, ok := c.flashRect(target, sc) | |
| 91 | 92 | if !ok { | |
| 92 | 93 | return false | |
| 93 | 94 | } | |
| @@ -103,7 +104,7 @@ func (c *Core) flashTick() bool { | |||
| 103 | 104 | } | |
| 104 | 105 | } | |
| 105 | 106 | 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 | |
| 107 | 108 | f.value = c.cfg.Focus.FlashOpacity | |
| 108 | 109 | return true | |
| 109 | 110 | } | |
| @@ -118,7 +119,7 @@ func (c *Core) advanceFlash(only *screen) { | |||
| 118 | 119 | return | |
| 119 | 120 | } | |
| 120 | 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 | 123 | c.stopFlash() | |
| 123 | 124 | return | |
| 124 | 125 | } | |
| @@ -138,47 +139,50 @@ func (c *Core) advanceFlash(only *screen) { | |||
| 138 | 139 | // flashRect resolves the current target, never retaining a border after its | |
| 139 | 140 | // visibility or geometry changes. stopFlash leaves the published sample intact | |
| 140 | 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 | 143 | if sc.mon.ov.open { | |
| 143 | - | return Rect{}, false | |
| 144 | + | return Rect{}, 0, false | |
| 144 | 145 | } | |
| 145 | 146 | if target.level == flashMonitor { | |
| 146 | 147 | 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 | |
| 148 | 149 | } | |
| 149 | 150 | owner, _ := c.screenOf(target.window) | |
| 150 | 151 | if owner != sc || c.windows.lookup(target.window).client.ID == 0 { | |
| 151 | - | return Rect{}, false | |
| 152 | + | return Rect{}, 0, false | |
| 152 | 153 | } | |
| 153 | 154 | view := sc.mon.viewport() | |
| 154 | 155 | c.flash.layout = sc.mon.layoutInto(c.flash.layout) | |
| 155 | 156 | var rect Rect | |
| 157 | + | radius := 0 | |
| 156 | 158 | found := false | |
| 157 | 159 | for _, p := range c.flash.layout { | |
| 158 | 160 | if p.ID == target.window { | |
| 159 | 161 | if p.Fullscreen || p.Preview != 0 || !view.shows(p) { | |
| 160 | - | return Rect{}, false | |
| 162 | + | return Rect{}, 0, false | |
| 161 | 163 | } | |
| 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) | |
| 163 | 167 | break | |
| 164 | 168 | } | |
| 165 | 169 | } | |
| 166 | 170 | if !found { | |
| 167 | - | return Rect{}, false | |
| 171 | + | return Rect{}, 0, false | |
| 168 | 172 | } | |
| 169 | 173 | if target.level == flashColumn { | |
| 170 | 174 | w := sc.mon.Current() | |
| 171 | 175 | col := w.columnOf(target.window) | |
| 172 | 176 | if col < 0 { | |
| 173 | - | return Rect{}, false | |
| 177 | + | return Rect{}, 0, false | |
| 174 | 178 | } | |
| 175 | 179 | for _, p := range c.flash.layout { | |
| 176 | 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, true | |
| 185 | + | return rect, radius, true | |
| 182 | 186 | } | |
| 183 | 187 | ||
| 184 | 188 | func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |
| @@ -190,9 +194,9 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |||
| 190 | 194 | if color == "" { | |
| 191 | 195 | 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 | 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 | 201 | return f.published | |
| 198 | 202 | } | |
| @@ -31,6 +31,7 @@ type focusFlash struct { | |||||
| 31 | timerStop func() bool | 31 | timerStop func() bool | ||
| 32 | running *flashTarget | 32 | running *flashTarget | ||
| 33 | rect Rect | 33 | rect Rect | ||
| 34 | + | radius int // Corner radius of the target, as SceneWindow.Radius. | |||
| 34 | start time.Time | 35 | 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.Time | 37 | 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 false | 89 | 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 false | 93 | return false | ||
| 93 | } | 94 | } | ||
| @@ -103,7 +104,7 @@ func (c *Core) flashTick() bool { | |||||
| 103 | } | 104 | } | ||
| 104 | } | 105 | } | ||
| 105 | f.lastStarts[target] = now | 106 | 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 |
| 107 | f.value = c.cfg.Focus.FlashOpacity | 108 | f.value = c.cfg.Focus.FlashOpacity | ||
| 108 | return true | 109 | return true | ||
| 109 | } | 110 | } | ||
| @@ -118,7 +119,7 @@ func (c *Core) advanceFlash(only *screen) { | |||||
| 118 | return | 119 | 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 | return | 124 | return | ||
| 124 | } | 125 | } | ||
| @@ -138,47 +139,50 @@ func (c *Core) advanceFlash(only *screen) { | |||||
| 138 | // flashRect resolves the current target, never retaining a border after its | 139 | // flashRect resolves the current target, never retaining a border after its | ||
| 139 | // visibility or geometry changes. stopFlash leaves the published sample intact | 140 | // 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{}, false | 144 | + | 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}, true | 148 | + | 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{}, false | 152 | + | 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 Rect | 156 | var rect Rect | ||
| 157 | + | radius := 0 | |||
| 156 | found := false | 158 | 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{}, false | 162 | + | return Rect{}, 0, false |
| 161 | } | 163 | } | ||
| 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) | |||
| 163 | break | 167 | break | ||
| 164 | } | 168 | } | ||
| 165 | } | 169 | } | ||
| 166 | if !found { | 170 | if !found { | ||
| 167 | - | return Rect{}, false | 171 | + | 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{}, false | 177 | + | 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, true | 185 | + | 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.Active | 195 | 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.published | 201 | return f.published | ||
| 198 | } | 202 | } | ||
M internal/core/flash_test.go +57 -0
| @@ -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 | + | } | |
| @@ -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 { | |||
| 201 | 201 | type FocusFlash struct { | |
| 202 | 202 | Rect Rect | |
| 203 | 203 | 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 | |
| 206 | 209 | } | |
| 207 | 210 | ||
| 208 | 211 | // SceneWindow carries core → renderer window placement. | |
| @@ -201,8 +201,11 @@ type SceneLayer struct { | |||||
| 201 | type FocusFlash struct { | 201 | type FocusFlash struct { | ||
| 202 | Rect Rect | 202 | Rect Rect | ||
| 203 | Width int | 203 | Width int | ||
| 204 | - | Color string | 204 | + | // Radius is the target's corner radius in logical pixels (0 = square), |
| 205 | - | Alpha float64 | 205 | + | // 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. | ||