refactor(render): tidy gap borders and rounded corner handling
Share windowRadius, cap border.radius at 64, hoist rounded math in vulkan, fix faded ring hole, add flag consts, docs and tests.
17 files · +172 -61
M docs/config.md +3 -3
| @@ -29,7 +29,7 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 29 | 29 | | `startup` | none | Commands run at session start, separated by `;` | | |
| 30 | 30 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | |
| 31 | 31 | | `background` | `#111111` | Background color | | |
| 32 | - | | `border.radius` | `0` | Corner radius in logical pixels, non-negative; floats always, tiles only with gaps | | |
| 32 | + | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | |
| 33 | 33 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | |
| 34 | 34 | | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows | | |
| 35 | 35 | | `floating.dim` | `0.3` | Darkening behind floating windows and the overview, 0 to 1 | | |
| @@ -85,6 +85,8 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 85 | 85 | | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` | | |
| 86 | 86 | | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) | | |
| 87 | 87 | ||
| 88 | + | With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square. | |
| 89 | + | ||
| 88 | 90 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | |
| 89 | 91 | ||
| 90 | 92 | The position hint follows actual workspace/band changes and monitor focus, regardless | |
| @@ -385,5 +387,3 @@ small or low-contrast text. | |||
| 385 | 387 | ### Debugging hint mode | |
| 386 | 388 | ||
| 387 | 389 | With `hint.enable = on` and `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (OCR links green, accessibility targets magenta, OCR window crops grey); and `<time>-<output>.json`, the targets (with their `source`, `atspi` or `ocr`) and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use. | |
| 388 | - | ||
| 389 | - | With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square. | |
| @@ -29,7 +29,7 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 29 | | `startup` | none | Commands run at session start, separated by `;` | | 29 | | `startup` | none | Commands run at session start, separated by `;` | | ||
| 30 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | 30 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | ||
| 31 | | `background` | `#111111` | Background color | | 31 | | `background` | `#111111` | Background color | | ||
| 32 | - | | `border.radius` | `0` | Corner radius in logical pixels, non-negative; floats always, tiles only with gaps | | 32 | + | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | |
| 33 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | 33 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | ||
| 34 | | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows | | 34 | | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows | | ||
| 35 | | `floating.dim` | `0.3` | Darkening behind floating windows and the overview, 0 to 1 | | 35 | | `floating.dim` | `0.3` | Darkening behind floating windows and the overview, 0 to 1 | | ||
| @@ -85,6 +85,8 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 85 | | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` | | 85 | | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` | | ||
| 86 | | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) | | 86 | | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) | | ||
| 87 | 87 | ||||
| 88 | + | With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square. | |||
| 89 | + | ||||
| 88 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | 90 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | ||
| 89 | 91 | ||||
| 90 | The position hint follows actual workspace/band changes and monitor focus, regardless | 92 | The position hint follows actual workspace/band changes and monitor focus, regardless | ||
| @@ -385,5 +387,3 @@ small or low-contrast text. | |||||
| 385 | ### Debugging hint mode | 387 | ### Debugging hint mode | ||
| 386 | 388 | ||||
| 387 | With `hint.enable = on` and `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (OCR links green, accessibility targets magenta, OCR window crops grey); and `<time>-<output>.json`, the targets (with their `source`, `atspi` or `ocr`) and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use. | 389 | With `hint.enable = on` and `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (OCR links green, accessibility targets magenta, OCR window crops grey); and `<time>-<output>.json`, the targets (with their `source`, `atspi` or `ocr`) and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use. | ||
| 388 | - | ||||
| 389 | - | With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square. | |||
M examples/config +2 -0
| @@ -30,6 +30,8 @@ background = #111111 | |||
| 30 | 30 | # The lines along the focused window use active; width 0 disables them, | |
| 31 | 31 | # empty inactive draws only the focused lines. Floats get a full border. | |
| 32 | 32 | border.width = 2 | |
| 33 | + | # Corner radius in logical pixels (0 to 64): floats always, tiles only with gaps. | |
| 34 | + | border.radius = 0 | |
| 33 | 35 | border.active = #808080 | |
| 34 | 36 | border.inactive = #111111 | |
| 35 | 37 | ||
| @@ -30,6 +30,8 @@ background = #111111 | |||||
| 30 | # The lines along the focused window use active; width 0 disables them, | 30 | # The lines along the focused window use active; width 0 disables them, | ||
| 31 | # empty inactive draws only the focused lines. Floats get a full border. | 31 | # empty inactive draws only the focused lines. Floats get a full border. | ||
| 32 | border.width = 2 | 32 | border.width = 2 | ||
| 33 | + | # Corner radius in logical pixels (0 to 64): floats always, tiles only with gaps. | |||
| 34 | + | border.radius = 0 | |||
| 33 | border.active = #808080 | 35 | border.active = #808080 | ||
| 34 | border.inactive = #111111 | 36 | border.inactive = #111111 | ||
| 35 | 37 | ||||
M internal/adapters/config/config.go +2 -2
| @@ -775,8 +775,8 @@ func set(c *ports.Config, key, v string) error { | |||
| 775 | 775 | c.Stash.SpawnTerminal = b | |
| 776 | 776 | case "border.radius": | |
| 777 | 777 | n, err := strconv.Atoi(v) | |
| 778 | - | if err != nil || n < 0 { | |
| 779 | - | return fmt.Errorf("must be a non-negative integer") | |
| 778 | + | if err != nil || n < 0 || n > 64 { | |
| 779 | + | return fmt.Errorf("must be between 0 and 64") | |
| 780 | 780 | } | |
| 781 | 781 | c.Border.Radius = n | |
| 782 | 782 | case "border.width": | |
| @@ -775,8 +775,8 @@ func set(c *ports.Config, key, v string) error { | |||||
| 775 | c.Stash.SpawnTerminal = b | 775 | c.Stash.SpawnTerminal = b | ||
| 776 | case "border.radius": | 776 | case "border.radius": | ||
| 777 | n, err := strconv.Atoi(v) | 777 | n, err := strconv.Atoi(v) | ||
| 778 | - | if err != nil || n < 0 { | 778 | + | if err != nil || n < 0 || n > 64 { |
| 779 | - | return fmt.Errorf("must be a non-negative integer") | 779 | + | return fmt.Errorf("must be between 0 and 64") |
| 780 | } | 780 | } | ||
| 781 | c.Border.Radius = n | 781 | c.Border.Radius = n | ||
| 782 | case "border.width": | 782 | case "border.width": | ||
M internal/adapters/config/config_test.go +2 -2
| @@ -1068,13 +1068,13 @@ func TestBorderRadius(t *testing.T) { | |||
| 1068 | 1068 | if Defaults().Border.Radius != 0 { | |
| 1069 | 1069 | t.Fatal("nonzero default") | |
| 1070 | 1070 | } | |
| 1071 | - | for _, v := range []string{"0", "10"} { | |
| 1071 | + | for _, v := range []string{"0", "10", "64"} { | |
| 1072 | 1072 | c, w := parseString(t, "border.radius = "+v+"\n") | |
| 1073 | 1073 | if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v { | |
| 1074 | 1074 | t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w) | |
| 1075 | 1075 | } | |
| 1076 | 1076 | } | |
| 1077 | - | for _, v := range []string{"-1", "bad"} { | |
| 1077 | + | for _, v := range []string{"-1", "bad", "65"} { | |
| 1078 | 1078 | _, w := parseString(t, "border.radius = "+v+"\n") | |
| 1079 | 1079 | if len(w) == 0 { | |
| 1080 | 1080 | t.Fatalf("accepted %s", v) | |
| @@ -1068,13 +1068,13 @@ func TestBorderRadius(t *testing.T) { | |||||
| 1068 | if Defaults().Border.Radius != 0 { | 1068 | if Defaults().Border.Radius != 0 { | ||
| 1069 | t.Fatal("nonzero default") | 1069 | t.Fatal("nonzero default") | ||
| 1070 | } | 1070 | } | ||
| 1071 | - | for _, v := range []string{"0", "10"} { | 1071 | + | for _, v := range []string{"0", "10", "64"} { |
| 1072 | c, w := parseString(t, "border.radius = "+v+"\n") | 1072 | c, w := parseString(t, "border.radius = "+v+"\n") | ||
| 1073 | if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v { | 1073 | if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v { | ||
| 1074 | t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w) | 1074 | t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w) | ||
| 1075 | } | 1075 | } | ||
| 1076 | } | 1076 | } | ||
| 1077 | - | for _, v := range []string{"-1", "bad"} { | 1077 | + | for _, v := range []string{"-1", "bad", "65"} { |
| 1078 | _, w := parseString(t, "border.radius = "+v+"\n") | 1078 | _, w := parseString(t, "border.radius = "+v+"\n") | ||
| 1079 | if len(w) == 0 { | 1079 | if len(w) == 0 { | ||
| 1080 | t.Fatalf("accepted %s", v) | 1080 | t.Fatalf("accepted %s", v) | ||
M internal/adapters/vulkan/scene.go +46 -27
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | // sceneWalk turns one scene into quads in paint order, copying new wl_shm | |
| 12 | 12 | // content into GPU buffers on the way. It lives for one frame. | |
| 13 | 13 | type sceneWalk struct { | |
| 14 | - | rounded bool | |
| 15 | 14 | r *Renderer | |
| 16 | 15 | s ports.Scene | |
| 17 | 16 | contents map[ports.WindowID]ports.SurfaceContent | |
| @@ -24,8 +23,12 @@ type sceneWalk struct { | |||
| 24 | 23 | // alpha is the opacity of the window being drawn, 1 - its Fade | |
| 25 | 24 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | |
| 26 | 25 | // it (premultiplied); 1 otherwise. | |
| 27 | - | alpha float32 | |
| 28 | - | bounds image.Rectangle | |
| 26 | + | alpha float32 | |
| 27 | + | // rounded marks the window being drawn as rounded: it suppresses | |
| 28 | + | // opaque-child culling (opaqueChildren), as the corners must show what | |
| 29 | + | // is below. | |
| 30 | + | rounded bool | |
| 31 | + | bounds image.Rectangle | |
| 29 | 32 | // tileBounds is bounds cut to Scene.TileArea: where tiles and their | |
| 30 | 33 | // lines draw. | |
| 31 | 34 | tileBounds image.Rectangle | |
| @@ -256,33 +259,31 @@ func (w *sceneWalk) windows() { | |||
| 256 | 259 | body := w.physRect(c.X, c.Y, c.W, c.H) | |
| 257 | 260 | content := w.contents[win.ID] | |
| 258 | 261 | // Damage counts only what is drawn: never the panel under a tile. | |
| 259 | - | w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds)) | |
| 262 | + | outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H) | |
| 263 | + | w.dmg.window(win.ID, content, outer.Intersect(w.bounds)) | |
| 260 | 264 | w.rounded = win.Radius > 0 | |
| 265 | + | var radius, innerRadius float32 | |
| 261 | 266 | if win.Radius > 0 { | |
| 262 | - | radius := float32(math.Round(float64(win.Radius) * w.scale)) | |
| 263 | - | innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0) | |
| 264 | - | outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H) | |
| 265 | - | col := "" | |
| 266 | - | for _, sep := range w.s.Separators { | |
| 267 | - | if sep.Window == win.ID { | |
| 268 | - | if col == "" { | |
| 269 | - | col = w.s.Border.Inactive | |
| 270 | - | } | |
| 271 | - | if sep.Active { | |
| 272 | - | col = w.s.Border.Active | |
| 273 | - | break | |
| 274 | - | } | |
| 275 | - | } | |
| 276 | - | } | |
| 277 | - | if col != "" && b > 0 { | |
| 267 | + | radius = float32(math.Round(float64(win.Radius) * w.scale)) | |
| 268 | + | // The border's physical width, from the rects actually drawn. | |
| 269 | + | bPhys := min(body.Min.X-outer.Min.X, body.Min.Y-outer.Min.Y, outer.Max.X-body.Max.X, outer.Max.Y-body.Max.Y) | |
| 270 | + | innerRadius = max(radius-float32(max(bPhys, 0)), 0) | |
| 271 | + | if col := w.ringColor(win.ID); col != "" && b > 0 { | |
| 278 | 272 | n := len(w.draws) | |
| 279 | 273 | w.fill(outer, parseColor(col)) | |
| 280 | 274 | if len(w.draws) > n { | |
| 281 | 275 | dr := &w.draws[n] | |
| 282 | 276 | roundedDraw(dr, outer, radius) | |
| 283 | 277 | dr.pc.misc[1] |= flagRing | |
| 284 | - | dr.roundInner = roundRect(body.Inset(1)) | |
| 285 | - | dr.radii[1] = math.Float32bits(max(innerRadius-1, 0)) | |
| 278 | + | // Opaque, the ring runs 1px under the body so no seam | |
| 279 | + | // shows; faded, that overlap would double up: the hole | |
| 280 | + | // is the body. | |
| 281 | + | hole, holeRadius := body.Inset(1), max(innerRadius-1, 0) | |
| 282 | + | if w.alpha < 1 { | |
| 283 | + | hole, holeRadius = body, innerRadius | |
| 284 | + | } | |
| 285 | + | dr.roundInner = roundRect(hole) | |
| 286 | + | dr.radii[1] = math.Float32bits(holeRadius) | |
| 286 | 287 | } | |
| 287 | 288 | } | |
| 288 | 289 | } | |
| @@ -301,8 +302,6 @@ func (w *sceneWalk) windows() { | |||
| 301 | 302 | w.zoom = 1 | |
| 302 | 303 | } | |
| 303 | 304 | if win.Radius > 0 { | |
| 304 | - | radius := float32(math.Round(float64(win.Radius) * w.scale)) | |
| 305 | - | innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0) | |
| 306 | 305 | for j := start; j < len(w.draws); j++ { | |
| 307 | 306 | roundedDraw(&w.draws[j], body, innerRadius) | |
| 308 | 307 | } | |
| @@ -312,11 +311,11 @@ func (w *sceneWalk) windows() { | |||
| 312 | 311 | dimStart := len(w.draws) | |
| 313 | 312 | if win.Dim > 0 { | |
| 314 | 313 | // A stashed window peeking in: dimmed with its border. | |
| 315 | - | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | |
| 314 | + | w.dim(outer, win.Dim) | |
| 316 | 315 | } | |
| 317 | 316 | if win.Radius > 0 { | |
| 318 | 317 | for j := dimStart; j < len(w.draws); j++ { | |
| 319 | - | roundedDraw(&w.draws[j], w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), float32(math.Round(float64(win.Radius)*w.scale))) | |
| 318 | + | roundedDraw(&w.draws[j], outer, radius) | |
| 320 | 319 | } | |
| 321 | 320 | } | |
| 322 | 321 | w.rounded = false | |
| @@ -335,6 +334,22 @@ func (w *sceneWalk) tileSeparators() { | |||
| 335 | 334 | w.bounds = b | |
| 336 | 335 | } | |
| 337 | 336 | ||
| 337 | + | // ringColor is the border color of the window id's lines: the inactive one, | |
| 338 | + | // or the active one when any of its lines is active; "" without lines. | |
| 339 | + | func (w *sceneWalk) ringColor(id ports.WindowID) string { | |
| 340 | + | col := "" | |
| 341 | + | for _, sep := range w.s.Separators { | |
| 342 | + | if sep.Window != id { | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if sep.Active { | |
| 346 | + | return w.s.Border.Active | |
| 347 | + | } | |
| 348 | + | col = w.s.Border.Inactive | |
| 349 | + | } | |
| 350 | + | return col | |
| 351 | + | } | |
| 352 | + | ||
| 338 | 353 | // separators draws a window's border lines in order, active lines over | |
| 339 | 354 | // inactive ones. | |
| 340 | 355 | func (w *sceneWalk) separators(id ports.WindowID) { | |
| @@ -688,10 +703,14 @@ func parseColor(s string) [3]uint8 { | |||
| 688 | 703 | return c | |
| 689 | 704 | } | |
| 690 | 705 | ||
| 691 | - | // Rounded bounds stay independent of scissor/damage clipping. | |
| 706 | + | // roundRect is r as float bounds for the shader's rounded coverage. Rounded | |
| 707 | + | // bounds stay independent of scissor/damage clipping. | |
| 692 | 708 | func roundRect(r image.Rectangle) [4]float32 { | |
| 693 | 709 | return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)} | |
| 694 | 710 | } | |
| 711 | + | ||
| 712 | + | // roundedDraw makes d a rounded draw: the shader clips it to rect with | |
| 713 | + | // corners of radius (physical pixels). | |
| 695 | 714 | func roundedDraw(d *draw, rect image.Rectangle, radius float32) { | |
| 696 | 715 | d.pc.misc[1] |= flagRounded | |
| 697 | 716 | d.round = roundRect(rect) | |
| @@ -11,7 +11,6 @@ import ( | |||||
| 11 | // sceneWalk turns one scene into quads in paint order, copying new wl_shm | 11 | // sceneWalk turns one scene into quads in paint order, copying new wl_shm | ||
| 12 | // content into GPU buffers on the way. It lives for one frame. | 12 | // content into GPU buffers on the way. It lives for one frame. | ||
| 13 | type sceneWalk struct { | 13 | type sceneWalk struct { | ||
| 14 | - | rounded bool | |||
| 15 | r *Renderer | 14 | r *Renderer | ||
| 16 | s ports.Scene | 15 | s ports.Scene | ||
| 17 | contents map[ports.WindowID]ports.SurfaceContent | 16 | contents map[ports.WindowID]ports.SurfaceContent | ||
| @@ -24,8 +23,12 @@ type sceneWalk struct { | |||||
| 24 | // alpha is the opacity of the window being drawn, 1 - its Fade | 23 | // alpha is the opacity of the window being drawn, 1 - its Fade | ||
| 25 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | 24 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | ||
| 26 | // it (premultiplied); 1 otherwise. | 25 | // it (premultiplied); 1 otherwise. | ||
| 27 | - | alpha float32 | 26 | + | alpha float32 |
| 28 | - | bounds image.Rectangle | 27 | + | // rounded marks the window being drawn as rounded: it suppresses |
| 28 | + | // opaque-child culling (opaqueChildren), as the corners must show what | |||
| 29 | + | // is below. | |||
| 30 | + | rounded bool | |||
| 31 | + | bounds image.Rectangle | |||
| 29 | // tileBounds is bounds cut to Scene.TileArea: where tiles and their | 32 | // tileBounds is bounds cut to Scene.TileArea: where tiles and their | ||
| 30 | // lines draw. | 33 | // lines draw. | ||
| 31 | tileBounds image.Rectangle | 34 | tileBounds image.Rectangle | ||
| @@ -256,33 +259,31 @@ func (w *sceneWalk) windows() { | |||||
| 256 | body := w.physRect(c.X, c.Y, c.W, c.H) | 259 | body := w.physRect(c.X, c.Y, c.W, c.H) | ||
| 257 | content := w.contents[win.ID] | 260 | content := w.contents[win.ID] | ||
| 258 | // Damage counts only what is drawn: never the panel under a tile. | 261 | // Damage counts only what is drawn: never the panel under a tile. | ||
| 259 | - | w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds)) | 262 | + | outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H) |
| 263 | + | w.dmg.window(win.ID, content, outer.Intersect(w.bounds)) | |||
| 260 | w.rounded = win.Radius > 0 | 264 | w.rounded = win.Radius > 0 | ||
| 265 | + | var radius, innerRadius float32 | |||
| 261 | if win.Radius > 0 { | 266 | if win.Radius > 0 { | ||
| 262 | - | radius := float32(math.Round(float64(win.Radius) * w.scale)) | 267 | + | radius = float32(math.Round(float64(win.Radius) * w.scale)) |
| 263 | - | innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0) | 268 | + | // The border's physical width, from the rects actually drawn. |
| 264 | - | outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H) | 269 | + | bPhys := min(body.Min.X-outer.Min.X, body.Min.Y-outer.Min.Y, outer.Max.X-body.Max.X, outer.Max.Y-body.Max.Y) |
| 265 | - | col := "" | 270 | + | innerRadius = max(radius-float32(max(bPhys, 0)), 0) |
| 266 | - | for _, sep := range w.s.Separators { | 271 | + | if col := w.ringColor(win.ID); col != "" && b > 0 { |
| 267 | - | if sep.Window == win.ID { | |||
| 268 | - | if col == "" { | |||
| 269 | - | col = w.s.Border.Inactive | |||
| 270 | - | } | |||
| 271 | - | if sep.Active { | |||
| 272 | - | col = w.s.Border.Active | |||
| 273 | - | break | |||
| 274 | - | } | |||
| 275 | - | } | |||
| 276 | - | } | |||
| 277 | - | if col != "" && b > 0 { | |||
| 278 | n := len(w.draws) | 272 | n := len(w.draws) | ||
| 279 | w.fill(outer, parseColor(col)) | 273 | w.fill(outer, parseColor(col)) | ||
| 280 | if len(w.draws) > n { | 274 | if len(w.draws) > n { | ||
| 281 | dr := &w.draws[n] | 275 | dr := &w.draws[n] | ||
| 282 | roundedDraw(dr, outer, radius) | 276 | roundedDraw(dr, outer, radius) | ||
| 283 | dr.pc.misc[1] |= flagRing | 277 | dr.pc.misc[1] |= flagRing | ||
| 284 | - | dr.roundInner = roundRect(body.Inset(1)) | 278 | + | // Opaque, the ring runs 1px under the body so no seam |
| 285 | - | dr.radii[1] = math.Float32bits(max(innerRadius-1, 0)) | 279 | + | // shows; faded, that overlap would double up: the hole |
| 280 | + | // is the body. | |||
| 281 | + | hole, holeRadius := body.Inset(1), max(innerRadius-1, 0) | |||
| 282 | + | if w.alpha < 1 { | |||
| 283 | + | hole, holeRadius = body, innerRadius | |||
| 284 | + | } | |||
| 285 | + | dr.roundInner = roundRect(hole) | |||
| 286 | + | dr.radii[1] = math.Float32bits(holeRadius) | |||
| 286 | } | 287 | } | ||
| 287 | } | 288 | } | ||
| 288 | } | 289 | } | ||
| @@ -301,8 +302,6 @@ func (w *sceneWalk) windows() { | |||||
| 301 | w.zoom = 1 | 302 | w.zoom = 1 | ||
| 302 | } | 303 | } | ||
| 303 | if win.Radius > 0 { | 304 | if win.Radius > 0 { | ||
| 304 | - | radius := float32(math.Round(float64(win.Radius) * w.scale)) | |||
| 305 | - | innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0) | |||
| 306 | for j := start; j < len(w.draws); j++ { | 305 | for j := start; j < len(w.draws); j++ { | ||
| 307 | roundedDraw(&w.draws[j], body, innerRadius) | 306 | roundedDraw(&w.draws[j], body, innerRadius) | ||
| 308 | } | 307 | } | ||
| @@ -312,11 +311,11 @@ func (w *sceneWalk) windows() { | |||||
| 312 | dimStart := len(w.draws) | 311 | dimStart := len(w.draws) | ||
| 313 | if win.Dim > 0 { | 312 | if win.Dim > 0 { | ||
| 314 | // A stashed window peeking in: dimmed with its border. | 313 | // A stashed window peeking in: dimmed with its border. | ||
| 315 | - | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | 314 | + | w.dim(outer, win.Dim) |
| 316 | } | 315 | } | ||
| 317 | if win.Radius > 0 { | 316 | if win.Radius > 0 { | ||
| 318 | for j := dimStart; j < len(w.draws); j++ { | 317 | for j := dimStart; j < len(w.draws); j++ { | ||
| 319 | - | roundedDraw(&w.draws[j], w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), float32(math.Round(float64(win.Radius)*w.scale))) | 318 | + | roundedDraw(&w.draws[j], outer, radius) |
| 320 | } | 319 | } | ||
| 321 | } | 320 | } | ||
| 322 | w.rounded = false | 321 | w.rounded = false | ||
| @@ -335,6 +334,22 @@ func (w *sceneWalk) tileSeparators() { | |||||
| 335 | w.bounds = b | 334 | w.bounds = b | ||
| 336 | } | 335 | } | ||
| 337 | 336 | ||||
| 337 | + | // ringColor is the border color of the window id's lines: the inactive one, | |||
| 338 | + | // or the active one when any of its lines is active; "" without lines. | |||
| 339 | + | func (w *sceneWalk) ringColor(id ports.WindowID) string { | |||
| 340 | + | col := "" | |||
| 341 | + | for _, sep := range w.s.Separators { | |||
| 342 | + | if sep.Window != id { | |||
| 343 | + | continue | |||
| 344 | + | } | |||
| 345 | + | if sep.Active { | |||
| 346 | + | return w.s.Border.Active | |||
| 347 | + | } | |||
| 348 | + | col = w.s.Border.Inactive | |||
| 349 | + | } | |||
| 350 | + | return col | |||
| 351 | + | } | |||
| 352 | + | ||||
| 338 | // separators draws a window's border lines in order, active lines over | 353 | // separators draws a window's border lines in order, active lines over | ||
| 339 | // inactive ones. | 354 | // inactive ones. | ||
| 340 | func (w *sceneWalk) separators(id ports.WindowID) { | 355 | func (w *sceneWalk) separators(id ports.WindowID) { | ||
| @@ -688,10 +703,14 @@ func parseColor(s string) [3]uint8 { | |||||
| 688 | return c | 703 | return c | ||
| 689 | } | 704 | } | ||
| 690 | 705 | ||||
| 691 | - | // Rounded bounds stay independent of scissor/damage clipping. | 706 | + | // roundRect is r as float bounds for the shader's rounded coverage. Rounded |
| 707 | + | // bounds stay independent of scissor/damage clipping. | |||
| 692 | func roundRect(r image.Rectangle) [4]float32 { | 708 | func roundRect(r image.Rectangle) [4]float32 { | ||
| 693 | return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)} | 709 | return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)} | ||
| 694 | } | 710 | } | ||
| 711 | + | ||||
| 712 | + | // roundedDraw makes d a rounded draw: the shader clips it to rect with | |||
| 713 | + | // corners of radius (physical pixels). | |||
| 695 | func roundedDraw(d *draw, rect image.Rectangle, radius float32) { | 714 | func roundedDraw(d *draw, rect image.Rectangle, radius float32) { | ||
| 696 | d.pc.misc[1] |= flagRounded | 715 | d.pc.misc[1] |= flagRounded | ||
| 697 | d.round = roundRect(rect) | 716 | d.round = roundRect(rect) | ||
M internal/adapters/vulkan/scene_fade_test.go +23 -0
| @@ -167,3 +167,26 @@ func TestRoundedWindowDraws(t *testing.T) { | |||
| 167 | 167 | t.Fatal("batch loses clip") | |
| 168 | 168 | } | |
| 169 | 169 | } | |
| 170 | + | ||
| 171 | + | // The ring's hole runs 1px under the body when the window is opaque; faded, | |
| 172 | + | // that overlap would show twice, so the hole is the body itself. | |
| 173 | + | func TestRoundedRingHoleFadedVersusOpaque(t *testing.T) { | |
| 174 | + | r := &Renderer{width: 80, height: 60} | |
| 175 | + | ring := func(fade float64) draw { | |
| 176 | + | s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800", Inactive: "#888888"}, | |
| 177 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 40, H: 40}, Inset: ports.SideAll, Radius: 10, Fade: fade}}, | |
| 178 | + | Separators: []ports.Separator{{Window: 1, Active: true}}} | |
| 179 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |
| 180 | + | if len(ds) == 0 || ds[0].pc.misc[1]&flagRing == 0 { | |
| 181 | + | t.Fatalf("fade %v: no ring: %+v", fade, ds) | |
| 182 | + | } | |
| 183 | + | return ds[0] | |
| 184 | + | } | |
| 185 | + | opaque, faded := ring(0), ring(0.5) | |
| 186 | + | if opaque.roundInner != roundRect(image.Rect(12, 12, 44, 44)) || math.Float32frombits(opaque.radii[1]) != 6 { | |
| 187 | + | t.Fatalf("opaque hole %v radius %v", opaque.roundInner, math.Float32frombits(opaque.radii[1])) | |
| 188 | + | } | |
| 189 | + | if faded.roundInner != roundRect(image.Rect(11, 11, 45, 45)) || math.Float32frombits(faded.radii[1]) != 7 { | |
| 190 | + | t.Fatalf("faded hole %v radius %v", faded.roundInner, math.Float32frombits(faded.radii[1])) | |
| 191 | + | } | |
| 192 | + | } | |
| @@ -167,3 +167,26 @@ func TestRoundedWindowDraws(t *testing.T) { | |||||
| 167 | t.Fatal("batch loses clip") | 167 | t.Fatal("batch loses clip") | ||
| 168 | } | 168 | } | ||
| 169 | } | 169 | } | ||
| 170 | + | ||||
| 171 | + | // The ring's hole runs 1px under the body when the window is opaque; faded, | |||
| 172 | + | // that overlap would show twice, so the hole is the body itself. | |||
| 173 | + | func TestRoundedRingHoleFadedVersusOpaque(t *testing.T) { | |||
| 174 | + | r := &Renderer{width: 80, height: 60} | |||
| 175 | + | ring := func(fade float64) draw { | |||
| 176 | + | s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800", Inactive: "#888888"}, | |||
| 177 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 40, H: 40}, Inset: ports.SideAll, Radius: 10, Fade: fade}}, | |||
| 178 | + | Separators: []ports.Separator{{Window: 1, Active: true}}} | |||
| 179 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |||
| 180 | + | if len(ds) == 0 || ds[0].pc.misc[1]&flagRing == 0 { | |||
| 181 | + | t.Fatalf("fade %v: no ring: %+v", fade, ds) | |||
| 182 | + | } | |||
| 183 | + | return ds[0] | |||
| 184 | + | } | |||
| 185 | + | opaque, faded := ring(0), ring(0.5) | |||
| 186 | + | if opaque.roundInner != roundRect(image.Rect(12, 12, 44, 44)) || math.Float32frombits(opaque.radii[1]) != 6 { | |||
| 187 | + | t.Fatalf("opaque hole %v radius %v", opaque.roundInner, math.Float32frombits(opaque.radii[1])) | |||
| 188 | + | } | |||
| 189 | + | if faded.roundInner != roundRect(image.Rect(11, 11, 45, 45)) || math.Float32frombits(faded.radii[1]) != 7 { | |||
| 190 | + | t.Fatalf("faded hole %v radius %v", faded.roundInner, math.Float32frombits(faded.radii[1])) | |||
| 191 | + | } | |||
| 192 | + | } | |||
M internal/adapters/vulkan/shaders/compose.frag +4 -2
| @@ -49,6 +49,8 @@ const uint flagP010 = 32u; | |||
| 49 | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | |
| 50 | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | |
| 51 | 51 | const uint flagSmooth = 64u; | |
| 52 | + | const uint flagRounded = 128u; | |
| 53 | + | const uint flagRing = 256u; | |
| 52 | 54 | ||
| 53 | 55 | // PQ values are absolute nits. BT.2020 is converted in linear light to | |
| 54 | 56 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | |
| @@ -162,9 +164,9 @@ void main() { | |||
| 162 | 164 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 163 | 165 | c *= d.color.a; | |
| 164 | 166 | } | |
| 165 | - | if ((d.misc.y & 128u) != 0u) { | |
| 167 | + | if ((d.misc.y & flagRounded) != 0u) { | |
| 166 | 168 | c *= coverage(d.round, d.radii.x); | |
| 167 | - | if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |
| 169 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |
| 168 | 170 | } | |
| 169 | 171 | color = c; | |
| 170 | 172 | } | |
| @@ -49,6 +49,8 @@ const uint flagP010 = 32u; | |||||
| 49 | // flagSmooth averages four samples a quarter of a target pixel around the | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | ||
| 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | ||
| 51 | const uint flagSmooth = 64u; | 51 | const uint flagSmooth = 64u; | ||
| 52 | + | const uint flagRounded = 128u; | |||
| 53 | + | const uint flagRing = 256u; | |||
| 52 | 54 | ||||
| 53 | // PQ values are absolute nits. BT.2020 is converted in linear light to | 55 | // PQ values are absolute nits. BT.2020 is converted in linear light to | ||
| 54 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | 56 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | ||
| @@ -162,9 +164,9 @@ void main() { | |||||
| 162 | // The surface's alpha modifier; premultiplied, it scales all four. | 164 | // The surface's alpha modifier; premultiplied, it scales all four. | ||
| 163 | c *= d.color.a; | 165 | c *= d.color.a; | ||
| 164 | } | 166 | } | ||
| 165 | - | if ((d.misc.y & 128u) != 0u) { | 167 | + | if ((d.misc.y & flagRounded) != 0u) { |
| 166 | c *= coverage(d.round, d.radii.x); | 168 | c *= coverage(d.round, d.radii.x); | ||
| 167 | - | if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | 169 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); |
| 168 | } | 170 | } | ||
| 169 | color = c; | 171 | color = c; | ||
| 170 | } | 172 | } | ||
M internal/adapters/vulkan/shaders/compose_hdr.frag +4 -2
| @@ -49,6 +49,8 @@ const uint flagP010 = 32u; | |||
| 49 | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | |
| 50 | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | |
| 51 | 51 | const uint flagSmooth = 64u; | |
| 52 | + | const uint flagRounded = 128u; | |
| 53 | + | const uint flagRing = 256u; | |
| 52 | 54 | ||
| 53 | 55 | // The HDR composition target holds linear BT.709 in SDR-white units. | |
| 54 | 56 | vec3 decodeSRGB(vec3 v) { | |
| @@ -161,9 +163,9 @@ void main() { | |||
| 161 | 163 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 162 | 164 | c *= d.color.a; | |
| 163 | 165 | } | |
| 164 | - | if ((d.misc.y & 128u) != 0u) { | |
| 166 | + | if ((d.misc.y & flagRounded) != 0u) { | |
| 165 | 167 | c *= coverage(d.round, d.radii.x); | |
| 166 | - | if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |
| 168 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |
| 167 | 169 | } | |
| 168 | 170 | color = c; | |
| 169 | 171 | } | |
| @@ -49,6 +49,8 @@ const uint flagP010 = 32u; | |||||
| 49 | // flagSmooth averages four samples a quarter of a target pixel around the | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | ||
| 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | ||
| 51 | const uint flagSmooth = 64u; | 51 | const uint flagSmooth = 64u; | ||
| 52 | + | const uint flagRounded = 128u; | |||
| 53 | + | const uint flagRing = 256u; | |||
| 52 | 54 | ||||
| 53 | // The HDR composition target holds linear BT.709 in SDR-white units. | 55 | // The HDR composition target holds linear BT.709 in SDR-white units. | ||
| 54 | vec3 decodeSRGB(vec3 v) { | 56 | vec3 decodeSRGB(vec3 v) { | ||
| @@ -161,9 +163,9 @@ void main() { | |||||
| 161 | // The surface's alpha modifier; premultiplied, it scales all four. | 163 | // The surface's alpha modifier; premultiplied, it scales all four. | ||
| 162 | c *= d.color.a; | 164 | c *= d.color.a; | ||
| 163 | } | 165 | } | ||
| 164 | - | if ((d.misc.y & 128u) != 0u) { | 166 | + | if ((d.misc.y & flagRounded) != 0u) { |
| 165 | c *= coverage(d.round, d.radii.x); | 167 | c *= coverage(d.round, d.radii.x); | ||
| 166 | - | if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | 168 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); |
| 167 | } | 169 | } | ||
| 168 | color = c; | 170 | color = c; | ||
| 169 | } | 171 | } | ||
M internal/core/border_invariants_test.go +19 -0
| @@ -345,3 +345,22 @@ func TestBorderRadiusScene(t *testing.T) { | |||
| 345 | 345 | }) | |
| 346 | 346 | } | |
| 347 | 347 | } | |
| 348 | + | ||
| 349 | + | // A reload that only changes border.radius republishes the scene with the | |
| 350 | + | // new radius. | |
| 351 | + | func TestReloadBorderRadiusRepublishes(t *testing.T) { | |
| 352 | + | r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 8; c.Border.Radius = 4 }, left) | |
| 353 | + | r.mapWindow(t, 1) | |
| 354 | + | cfg := r.cfg | |
| 355 | + | cfg.Border.Radius = 12 | |
| 356 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 357 | + | s := scene(t, r.scenes) | |
| 358 | + | if s.Border.Radius != 12 { | |
| 359 | + | t.Fatalf("style radius %d, want 12", s.Border.Radius) | |
| 360 | + | } | |
| 361 | + | for _, w := range s.Windows { | |
| 362 | + | if w.ID == 1 && w.Radius != 12 { | |
| 363 | + | t.Fatalf("window radius %d, want 12", w.Radius) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | } | |
| @@ -345,3 +345,22 @@ func TestBorderRadiusScene(t *testing.T) { | |||||
| 345 | }) | 345 | }) | ||
| 346 | } | 346 | } | ||
| 347 | } | 347 | } | ||
| 348 | + | ||||
| 349 | + | // A reload that only changes border.radius republishes the scene with the | |||
| 350 | + | // new radius. | |||
| 351 | + | func TestReloadBorderRadiusRepublishes(t *testing.T) { | |||
| 352 | + | r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 8; c.Border.Radius = 4 }, left) | |||
| 353 | + | r.mapWindow(t, 1) | |||
| 354 | + | cfg := r.cfg | |||
| 355 | + | cfg.Border.Radius = 12 | |||
| 356 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 357 | + | s := scene(t, r.scenes) | |||
| 358 | + | if s.Border.Radius != 12 { | |||
| 359 | + | t.Fatalf("style radius %d, want 12", s.Border.Radius) | |||
| 360 | + | } | |||
| 361 | + | for _, w := range s.Windows { | |||
| 362 | + | if w.ID == 1 && w.Radius != 12 { | |||
| 363 | + | t.Fatalf("window radius %d, want 12", w.Radius) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | } | |||
M internal/core/capture_window.go +1 -1
| @@ -53,7 +53,7 @@ func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene { | |||
| 53 | 53 | Security: c.security, | |
| 54 | 54 | Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H}, | |
| 55 | 55 | Scale: v.windowScr.scale, Background: c.cfg.Background.Color, | |
| 56 | - | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | |
| 56 | + | Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | |
| 57 | 57 | Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}}, | |
| 58 | 58 | } | |
| 59 | 59 | for _, id := range c.popupOrder { | |
| @@ -53,7 +53,7 @@ func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene { | |||||
| 53 | Security: c.security, | 53 | Security: c.security, | ||
| 54 | Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H}, | 54 | Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H}, | ||
| 55 | Scale: v.windowScr.scale, Background: c.cfg.Background.Color, | 55 | Scale: v.windowScr.scale, Background: c.cfg.Background.Color, | ||
| 56 | - | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | 56 | + | Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, |
| 57 | Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}}, | 57 | Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}}, | ||
| 58 | } | 58 | } | ||
| 59 | for _, id := range c.popupOrder { | 59 | for _, id := range c.popupOrder { | ||
M internal/core/capture_workspace.go +1 -3
| @@ -160,9 +160,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene { | |||
| 160 | 160 | // A hidden workspace lights nothing: its focus is not shown. | |
| 161 | 161 | for _, p := range cw.placements { | |
| 162 | 162 | w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | |
| 163 | - | if !p.Fullscreen && (p.Floating || cw.ws.gap() > 0) { | |
| 164 | - | w.Radius = c.cfg.Border.Radius | |
| 165 | - | } | |
| 163 | + | w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius) | |
| 166 | 164 | if !p.Hidden { | |
| 167 | 165 | w.Rect = rebase(p.Rect) | |
| 168 | 166 | } | |
| @@ -160,9 +160,7 @@ func (c *Core) captureScene(seq uint64) *ports.Scene { | |||||
| 160 | // A hidden workspace lights nothing: its focus is not shown. | 160 | // A hidden workspace lights nothing: its focus is not shown. | ||
| 161 | for _, p := range cw.placements { | 161 | for _, p := range cw.placements { | ||
| 162 | w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | 162 | w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | ||
| 163 | - | if !p.Fullscreen && (p.Floating || cw.ws.gap() > 0) { | 163 | + | w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius) |
| 164 | - | w.Radius = c.cfg.Border.Radius | |||
| 165 | - | } | |||
| 166 | if !p.Hidden { | 164 | if !p.Hidden { | ||
| 167 | w.Rect = rebase(p.Rect) | 165 | w.Rect = rebase(p.Rect) | ||
| 168 | } | 166 | } | ||
M internal/core/capture_workspace_test.go +31 -0
| @@ -270,3 +270,34 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) { | |||
| 270 | 270 | } | |
| 271 | 271 | } | |
| 272 | 272 | } | |
| 273 | + | ||
| 274 | + | // The workspace capture scene carries the corner radius like the live one: | |
| 275 | + | // a tile with gaps and a float are rounded. | |
| 276 | + | func TestCaptureSceneRadius(t *testing.T) { | |
| 277 | + | c, ws := hiddenCapture(t) | |
| 278 | + | c.cfg.Border.Radius = 9 | |
| 279 | + | ws.Gaps = 4 | |
| 280 | + | ws.AddFloating(3, 20, 20) | |
| 281 | + | c.configures.cw.load(c.screens[0], ws) | |
| 282 | + | s := c.captureScene(1) | |
| 283 | + | if s == nil || s.Border.Radius != 9 { | |
| 284 | + | t.Fatalf("scene %+v", s) | |
| 285 | + | } | |
| 286 | + | var tiles, floats int | |
| 287 | + | for _, w := range s.Windows { | |
| 288 | + | if w.Popup { | |
| 289 | + | continue | |
| 290 | + | } | |
| 291 | + | if w.Radius != 9 { | |
| 292 | + | t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius) | |
| 293 | + | } | |
| 294 | + | if w.Floating { | |
| 295 | + | floats++ | |
| 296 | + | } else { | |
| 297 | + | tiles++ | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | if tiles == 0 || floats == 0 { | |
| 301 | + | t.Fatalf("tiles %d floats %d", tiles, floats) | |
| 302 | + | } | |
| 303 | + | } | |
| @@ -270,3 +270,34 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) { | |||||
| 270 | } | 270 | } | ||
| 271 | } | 271 | } | ||
| 272 | } | 272 | } | ||
| 273 | + | ||||
| 274 | + | // The workspace capture scene carries the corner radius like the live one: | |||
| 275 | + | // a tile with gaps and a float are rounded. | |||
| 276 | + | func TestCaptureSceneRadius(t *testing.T) { | |||
| 277 | + | c, ws := hiddenCapture(t) | |||
| 278 | + | c.cfg.Border.Radius = 9 | |||
| 279 | + | ws.Gaps = 4 | |||
| 280 | + | ws.AddFloating(3, 20, 20) | |||
| 281 | + | c.configures.cw.load(c.screens[0], ws) | |||
| 282 | + | s := c.captureScene(1) | |||
| 283 | + | if s == nil || s.Border.Radius != 9 { | |||
| 284 | + | t.Fatalf("scene %+v", s) | |||
| 285 | + | } | |||
| 286 | + | var tiles, floats int | |||
| 287 | + | for _, w := range s.Windows { | |||
| 288 | + | if w.Popup { | |||
| 289 | + | continue | |||
| 290 | + | } | |||
| 291 | + | if w.Radius != 9 { | |||
| 292 | + | t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius) | |||
| 293 | + | } | |||
| 294 | + | if w.Floating { | |||
| 295 | + | floats++ | |||
| 296 | + | } else { | |||
| 297 | + | tiles++ | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | if tiles == 0 || floats == 0 { | |||
| 301 | + | t.Fatalf("tiles %d floats %d", tiles, floats) | |||
| 302 | + | } | |||
| 303 | + | } | |||
M internal/core/layout.go +3 -2
| @@ -60,8 +60,9 @@ type Placement struct { | |||
| 60 | 60 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | |
| 61 | 61 | Preview float64 | |
| 62 | 62 | // Neighbors are the sides touching a tile visible in the usable area. | |
| 63 | - | // Inset reserves room for drawn lines: all sides of a float; for | |
| 64 | - | // tiles without gaps, only the right and bottom shared sides. | |
| 63 | + | // Inset reserves room for drawn lines: all sides of a float; all sides | |
| 64 | + | // of a tile with gaps; only the right and bottom shared sides of a tile | |
| 65 | + | // without gaps. | |
| 65 | 66 | Neighbors, Inset ports.Sides | |
| 66 | 67 | // Leaving marks a window that closed or hid but is still drawn while | |
| 67 | 68 | // it fades out (transition.go). It stays Hidden, so input, focus, | |
| @@ -60,8 +60,9 @@ type Placement struct { | |||||
| 60 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | 60 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | ||
| 61 | Preview float64 | 61 | Preview float64 | ||
| 62 | // Neighbors are the sides touching a tile visible in the usable area. | 62 | // Neighbors are the sides touching a tile visible in the usable area. | ||
| 63 | - | // Inset reserves room for drawn lines: all sides of a float; for | 63 | + | // Inset reserves room for drawn lines: all sides of a float; all sides |
| 64 | - | // tiles without gaps, only the right and bottom shared sides. | 64 | + | // of a tile with gaps; only the right and bottom shared sides of a tile |
| 65 | + | // without gaps. | |||
| 65 | Neighbors, Inset ports.Sides | 66 | Neighbors, Inset ports.Sides | ||
| 66 | // Leaving marks a window that closed or hid but is still drawn while | 67 | // Leaving marks a window that closed or hid but is still drawn while | ||
| 67 | // it fades out (transition.go). It stays Hidden, so input, focus, | 68 | // it fades out (transition.go). It stays Hidden, so input, focus, | ||
M internal/core/scene.go +1 -3
| @@ -72,9 +72,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 72 | 72 | // Only the focused output has an activated window. | |
| 73 | 73 | focused := p.Focused && i == c.focusScreen | |
| 74 | 74 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade} | |
| 75 | - | if !p.Fullscreen && (p.Floating || sc.mon.Current().gap() > 0) { | |
| 76 | - | sw.Radius = c.cfg.Border.Radius | |
| 77 | - | } | |
| 75 | + | sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius) | |
| 78 | 76 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | |
| 79 | 77 | // A scale motion: the content follows the drawn size. A | |
| 80 | 78 | // card in flight drawn at its size still needs Zoom 1: | |
| @@ -72,9 +72,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 72 | // Only the focused output has an activated window. | 72 | // Only the focused output has an activated window. | ||
| 73 | focused := p.Focused && i == c.focusScreen | 73 | focused := p.Focused && i == c.focusScreen | ||
| 74 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade} | 74 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade} | ||
| 75 | - | if !p.Fullscreen && (p.Floating || sc.mon.Current().gap() > 0) { | 75 | + | sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius) |
| 76 | - | sw.Radius = c.cfg.Border.Radius | |||
| 77 | - | } | |||
| 78 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | 76 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | ||
| 79 | // A scale motion: the content follows the drawn size. A | 77 | // A scale motion: the content follows the drawn size. A | ||
| 80 | // card in flight drawn at its size still needs Zoom 1: | 78 | // card in flight drawn at its size still needs Zoom 1: | ||
M internal/core/separators.go +22 -12
| @@ -2,17 +2,18 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import "github.com/bnema/neferwl/internal/ports" | |
| 4 | 4 | ||
| 5 | - | // Like tmux screen-redraw.c, lines separate visible panes, never outline a | |
| 6 | - | // lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line, | |
| 7 | - | // owned (and inset) by the left/top tile; focus lights only the adjacent | |
| 8 | - | // sides of the focused tile, including corners. With exactly two visible | |
| 9 | - | // tiles and no gaps, each lights its own half of the shared line (left/top | |
| 10 | - | // first). With gaps, each tile owns a full four-sided border and lights | |
| 11 | - | // it whole. Hidden, scrolled-off, under-panel, other-workspace and | |
| 5 | + | // Two modes. Without gaps (gap 0), borders are tmux-style lines (like tmux | |
| 6 | + | // screen-redraw.c): lines separate visible panes, never outline a lone tile | |
| 7 | + | // or a fullscreen/zoomed tile. Adjacent tiles share one line, owned (and | |
| 8 | + | // inset) by the left/top tile; focus lights only the adjacent sides of the | |
| 9 | + | // focused tile, including corners. With exactly two visible tiles, each | |
| 10 | + | // lights its own half of the shared line (left/top first). With gaps, each | |
| 11 | + | // tile owns a full four-sided border, like a float, and lights it whole, | |
| 12 | + | // even when alone. Hidden, scrolled-off, under-panel, other-workspace and | |
| 12 | 13 | // other-output tiles do not count, nor do tiles that share no line. Lines | |
| 13 | - | // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles: | |
| 14 | - | // each gets its own full border, drawn with that window (Separator.Window), | |
| 15 | - | // even when it is the only visible window. Only the | |
| 14 | + | // at the edges of the usable area (output or panels) are omitted. Floating | |
| 15 | + | // windows also get their own full border, drawn with that window | |
| 16 | + | // (Separator.Window), even when it is the only visible window. Only the | |
| 16 | 17 | // focused output's focused window can light lines; keyboard grabs by popups | |
| 17 | 18 | // and layers do not change the underlying window's visual focus. | |
| 18 | 19 | ||
| @@ -97,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||
| 97 | 98 | continue | |
| 98 | 99 | } | |
| 99 | 100 | line = closeCorners(line, *f, s, width) | |
| 100 | - | // Two tiles share one line only without gaps. | |
| 101 | - | if tiles == 2 && gap == 0 { | |
| 101 | + | // With gaps the loop above continued, so two tiles share one line. | |
| 102 | + | if tiles == 2 { | |
| 102 | 103 | line = half(line, s) | |
| 103 | 104 | } | |
| 104 | 105 | add(f, line, true) | |
| @@ -158,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect { | |||
| 158 | 159 | } | |
| 159 | 160 | return r | |
| 160 | 161 | } | |
| 162 | + | ||
| 163 | + | // windowRadius is the corner radius of p: floats always, tiles only with | |
| 164 | + | // gaps, never fullscreen windows. | |
| 165 | + | func windowRadius(p Placement, gap, radius int) int { | |
| 166 | + | if !p.Fullscreen && (p.Floating || gap > 0) { | |
| 167 | + | return radius | |
| 168 | + | } | |
| 169 | + | return 0 | |
| 170 | + | } | |
| @@ -2,17 +2,18 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import "github.com/bnema/neferwl/internal/ports" | 3 | import "github.com/bnema/neferwl/internal/ports" | ||
| 4 | 4 | ||||
| 5 | - | // Like tmux screen-redraw.c, lines separate visible panes, never outline a | 5 | + | // Two modes. Without gaps (gap 0), borders are tmux-style lines (like tmux |
| 6 | - | // lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line, | 6 | + | // screen-redraw.c): lines separate visible panes, never outline a lone tile |
| 7 | - | // owned (and inset) by the left/top tile; focus lights only the adjacent | 7 | + | // or a fullscreen/zoomed tile. Adjacent tiles share one line, owned (and |
| 8 | - | // sides of the focused tile, including corners. With exactly two visible | 8 | + | // inset) by the left/top tile; focus lights only the adjacent sides of the |
| 9 | - | // tiles and no gaps, each lights its own half of the shared line (left/top | 9 | + | // focused tile, including corners. With exactly two visible tiles, each |
| 10 | - | // first). With gaps, each tile owns a full four-sided border and lights | 10 | + | // lights its own half of the shared line (left/top first). With gaps, each |
| 11 | - | // it whole. Hidden, scrolled-off, under-panel, other-workspace and | 11 | + | // tile owns a full four-sided border, like a float, and lights it whole, |
| 12 | + | // even when alone. Hidden, scrolled-off, under-panel, other-workspace and | |||
| 12 | // other-output tiles do not count, nor do tiles that share no line. Lines | 13 | // other-output tiles do not count, nor do tiles that share no line. Lines | ||
| 13 | - | // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles: | 14 | + | // at the edges of the usable area (output or panels) are omitted. Floating |
| 14 | - | // each gets its own full border, drawn with that window (Separator.Window), | 15 | + | // windows also get their own full border, drawn with that window |
| 15 | - | // even when it is the only visible window. Only the | 16 | + | // (Separator.Window), even when it is the only visible window. Only the |
| 16 | // focused output's focused window can light lines; keyboard grabs by popups | 17 | // focused output's focused window can light lines; keyboard grabs by popups | ||
| 17 | // and layers do not change the underlying window's visual focus. | 18 | // and layers do not change the underlying window's visual focus. | ||
| 18 | 19 | ||||
| @@ -97,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||||
| 97 | continue | 98 | continue | ||
| 98 | } | 99 | } | ||
| 99 | line = closeCorners(line, *f, s, width) | 100 | line = closeCorners(line, *f, s, width) | ||
| 100 | - | // Two tiles share one line only without gaps. | 101 | + | // With gaps the loop above continued, so two tiles share one line. |
| 101 | - | if tiles == 2 && gap == 0 { | 102 | + | if tiles == 2 { |
| 102 | line = half(line, s) | 103 | line = half(line, s) | ||
| 103 | } | 104 | } | ||
| 104 | add(f, line, true) | 105 | add(f, line, true) | ||
| @@ -158,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect { | |||||
| 158 | } | 159 | } | ||
| 159 | return r | 160 | return r | ||
| 160 | } | 161 | } | ||
| 162 | + | ||||
| 163 | + | // windowRadius is the corner radius of p: floats always, tiles only with | |||
| 164 | + | // gaps, never fullscreen windows. | |||
| 165 | + | func windowRadius(p Placement, gap, radius int) int { | |||
| 166 | + | if !p.Fullscreen && (p.Floating || gap > 0) { | |||
| 167 | + | return radius | |||
| 168 | + | } | |||
| 169 | + | return 0 | |||
| 170 | + | } | |||
M internal/ports/config.go +3 -1
| @@ -121,7 +121,9 @@ type Config struct { | |||
| 121 | 121 | Capture bool | |
| 122 | 122 | SpawnTerminal bool | |
| 123 | 123 | } | |
| 124 | - | // Border is drawn inside the window edge; Width 0 disables it. | |
| 124 | + | // Border is drawn inside the window edge; Width 0 disables it. Radius is | |
| 125 | + | // in logical pixels, >= 0 (at most 64 from the file): it rounds floats | |
| 126 | + | // always and tiles when gaps > 0; the renderer uses SceneWindow.Radius. | |
| 125 | 127 | Border struct { | |
| 126 | 128 | Radius int | |
| 127 | 129 | Width int | |
| @@ -121,7 +121,9 @@ type Config struct { | |||||
| 121 | Capture bool | 121 | Capture bool | ||
| 122 | SpawnTerminal bool | 122 | SpawnTerminal bool | ||
| 123 | } | 123 | } | ||
| 124 | - | // Border is drawn inside the window edge; Width 0 disables it. | 124 | + | // Border is drawn inside the window edge; Width 0 disables it. Radius is |
| 125 | + | // in logical pixels, >= 0 (at most 64 from the file): it rounds floats | |||
| 126 | + | // always and tiles when gaps > 0; the renderer uses SceneWindow.Radius. | |||
| 125 | Border struct { | 127 | Border struct { | ||
| 126 | Radius int | 128 | Radius int | ||
| 127 | Width int | 129 | Width int | ||
M internal/ports/scene.go +5 -1
| @@ -168,8 +168,11 @@ func (s Scene) TileArea() (r Rect, ok bool) { | |||
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | // Border carries the window border style; colors are #rrggbb, "" skips | |
| 171 | - | // drawing. Width is in logical pixels. | |
| 171 | + | // drawing. Width and Radius are in logical pixels. | |
| 172 | 172 | type Border struct { | |
| 173 | + | // Radius is the corner radius in logical pixels, >= 0. It rounds | |
| 174 | + | // floats always and tiles when gaps > 0; the renderer uses | |
| 175 | + | // SceneWindow.Radius, which core sets per window. | |
| 173 | 176 | Radius int | |
| 174 | 177 | Width int | |
| 175 | 178 | Active, Inactive string | |
| @@ -205,6 +208,7 @@ type FocusFlash struct { | |||
| 205 | 208 | // SceneWindow carries core → renderer window placement. | |
| 206 | 209 | type SceneWindow struct { | |
| 207 | 210 | // Radius rounds the window corners in logical pixels; zero is square. | |
| 211 | + | // Core sets Border.Radius here for floats and tiles with gaps. | |
| 208 | 212 | Radius int | |
| 209 | 213 | ID WindowID | |
| 210 | 214 | Rect Rect | |
| @@ -168,8 +168,11 @@ func (s Scene) TileArea() (r Rect, ok bool) { | |||||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | // Border carries the window border style; colors are #rrggbb, "" skips | 170 | // Border carries the window border style; colors are #rrggbb, "" skips | ||
| 171 | - | // drawing. Width is in logical pixels. | 171 | + | // drawing. Width and Radius are in logical pixels. |
| 172 | type Border struct { | 172 | type Border struct { | ||
| 173 | + | // Radius is the corner radius in logical pixels, >= 0. It rounds | |||
| 174 | + | // floats always and tiles when gaps > 0; the renderer uses | |||
| 175 | + | // SceneWindow.Radius, which core sets per window. | |||
| 173 | Radius int | 176 | Radius int | ||
| 174 | Width int | 177 | Width int | ||
| 175 | Active, Inactive string | 178 | Active, Inactive string | ||
| @@ -205,6 +208,7 @@ type FocusFlash struct { | |||||
| 205 | // SceneWindow carries core → renderer window placement. | 208 | // SceneWindow carries core → renderer window placement. | ||
| 206 | type SceneWindow struct { | 209 | type SceneWindow struct { | ||
| 207 | // Radius rounds the window corners in logical pixels; zero is square. | 210 | // Radius rounds the window corners in logical pixels; zero is square. | ||
| 211 | + | // Core sets Border.Radius here for floats and tiles with gaps. | |||
| 208 | Radius int | 212 | Radius int | ||
| 209 | ID WindowID | 213 | ID WindowID | ||
| 210 | Rect Rect | 214 | Rect Rect | ||