detached 608a5b9

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.
commit
608a5b903e02696508ed604565078285738a86bb
author
brice · · 2026-10-21 05:55 UTC
parent
fd770f2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
1/2
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.
2929 | `startup` | none | Commands run at session start, separated by `;` |
3030 | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
3131 | `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 |
3333 | `border.width` | `2` | Border width in pixels; `0` hides borders |
3434 | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows |
3535 | `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.
8585 | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` |
8686 | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) |
8787
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+
8890 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
8991
9092 The position hint follows actual workspace/band changes and monitor focus, regardless
@@ -385,5 +387,3 @@ small or low-contrast text.
385387 ### Debugging hint mode
386388
387389 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, regardless92 The position hint follows actual workspace/band changes and monitor focus, regardless
@@ -385,5 +387,3 @@ small or low-contrast text.
385 ### Debugging hint mode387 ### 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
3030 # The lines along the focused window use active; width 0 disables them,
3131 # empty inactive draws only the focused lines. Floats get a full border.
3232 border.width = 2
33+# Corner radius in logical pixels (0 to 64): floats always, tiles only with gaps.
34+border.radius = 0
3335 border.active = #808080
3436 border.inactive = #111111
3537
@@ -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 = 232 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 = #80808035 border.active = #808080
34 border.inactive = #11111136 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 {
775775 c.Stash.SpawnTerminal = b
776776 case "border.radius":
777777 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")
780780 }
781781 c.Border.Radius = n
782782 case "border.width":
@@ -775,8 +775,8 @@ func set(c *ports.Config, key, v string) error {
775 c.Stash.SpawnTerminal = b775 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 = n781 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) {
10681068 if Defaults().Border.Radius != 0 {
10691069 t.Fatal("nonzero default")
10701070 }
1071- for _, v := range []string{"0", "10"} {
1071+ for _, v := range []string{"0", "10", "64"} {
10721072 c, w := parseString(t, "border.radius = "+v+"\n")
10731073 if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v {
10741074 t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w)
10751075 }
10761076 }
1077- for _, v := range []string{"-1", "bad"} {
1077+ for _, v := range []string{"-1", "bad", "65"} {
10781078 _, w := parseString(t, "border.radius = "+v+"\n")
10791079 if len(w) == 0 {
10801080 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 (
1111 // sceneWalk turns one scene into quads in paint order, copying new wl_shm
1212 // content into GPU buffers on the way. It lives for one frame.
1313 type sceneWalk struct {
14- rounded bool
1514 r *Renderer
1615 s ports.Scene
1716 contents map[ports.WindowID]ports.SurfaceContent
@@ -24,8 +23,12 @@ type sceneWalk struct {
2423 // alpha is the opacity of the window being drawn, 1 - its Fade
2524 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
2625 // 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
2932 // tileBounds is bounds cut to Scene.TileArea: where tiles and their
3033 // lines draw.
3134 tileBounds image.Rectangle
@@ -256,33 +259,31 @@ func (w *sceneWalk) windows() {
256259 body := w.physRect(c.X, c.Y, c.W, c.H)
257260 content := w.contents[win.ID]
258261 // 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))
260264 w.rounded = win.Radius > 0
265+ var radius, innerRadius float32
261266 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 {
278272 n := len(w.draws)
279273 w.fill(outer, parseColor(col))
280274 if len(w.draws) > n {
281275 dr := &w.draws[n]
282276 roundedDraw(dr, outer, radius)
283277 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)
286287 }
287288 }
288289 }
@@ -301,8 +302,6 @@ func (w *sceneWalk) windows() {
301302 w.zoom = 1
302303 }
303304 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)
306305 for j := start; j < len(w.draws); j++ {
307306 roundedDraw(&w.draws[j], body, innerRadius)
308307 }
@@ -312,11 +311,11 @@ func (w *sceneWalk) windows() {
312311 dimStart := len(w.draws)
313312 if win.Dim > 0 {
314313 // 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)
316315 }
317316 if win.Radius > 0 {
318317 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)
320319 }
321320 }
322321 w.rounded = false
@@ -335,6 +334,22 @@ func (w *sceneWalk) tileSeparators() {
335334 w.bounds = b
336335 }
337336
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+
338353 // separators draws a window's border lines in order, active lines over
339354 // inactive ones.
340355 func (w *sceneWalk) separators(id ports.WindowID) {
@@ -688,10 +703,14 @@ func parseColor(s string) [3]uint8 {
688703 return c
689704 }
690705
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.
692708 func roundRect(r image.Rectangle) [4]float32 {
693709 return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)}
694710 }
711+
712+// roundedDraw makes d a rounded draw: the shader clips it to rect with
713+// corners of radius (physical pixels).
695714 func roundedDraw(d *draw, rect image.Rectangle, radius float32) {
696715 d.pc.misc[1] |= flagRounded
697716 d.round = roundRect(rect)
@@ -11,7 +11,6 @@ import (
11 // sceneWalk turns one scene into quads in paint order, copying new wl_shm11 // 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 *Renderer14 r *Renderer
16 s ports.Scene15 s ports.Scene
17 contents map[ports.WindowID]ports.SurfaceContent16 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 Fade23 // alpha is the opacity of the window being drawn, 1 - its Fade
25 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by24 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
26 // it (premultiplied); 1 otherwise.25 // it (premultiplied); 1 otherwise.
27- alpha float3226+ alpha float32
28- bounds image.Rectangle27+ // 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 their32 // tileBounds is bounds cut to Scene.TileArea: where tiles and their
30 // lines draw.33 // lines draw.
31 tileBounds image.Rectangle34 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 > 0264 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] |= flagRing277 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 = 1302 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 = false321 w.rounded = false
@@ -335,6 +334,22 @@ func (w *sceneWalk) tileSeparators() {
335 w.bounds = b334 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 over353 // 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 c703 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] |= flagRounded715 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) {
167167 t.Fatal("batch loses clip")
168168 }
169169 }
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;
4949 // flagSmooth averages four samples a quarter of a target pixel around the
5050 // point: a thumbnail shrunk under half size keeps thin lines and text.
5151 const uint flagSmooth = 64u;
52+const uint flagRounded = 128u;
53+const uint flagRing = 256u;
5254
5355 // PQ values are absolute nits. BT.2020 is converted in linear light to
5456 // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above
@@ -162,9 +164,9 @@ void main() {
162164 // The surface's alpha modifier; premultiplied, it scales all four.
163165 c *= d.color.a;
164166 }
165- if ((d.misc.y & 128u) != 0u) {
167+ if ((d.misc.y & flagRounded) != 0u) {
166168 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);
168170 }
169171 color = c;
170172 }
@@ -49,6 +49,8 @@ const uint flagP010 = 32u;
49 // flagSmooth averages four samples a quarter of a target pixel around the49 // 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 to55 // 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 above56 // 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;
4949 // flagSmooth averages four samples a quarter of a target pixel around the
5050 // point: a thumbnail shrunk under half size keeps thin lines and text.
5151 const uint flagSmooth = 64u;
52+const uint flagRounded = 128u;
53+const uint flagRing = 256u;
5254
5355 // The HDR composition target holds linear BT.709 in SDR-white units.
5456 vec3 decodeSRGB(vec3 v) {
@@ -161,9 +163,9 @@ void main() {
161163 // The surface's alpha modifier; premultiplied, it scales all four.
162164 c *= d.color.a;
163165 }
164- if ((d.misc.y & 128u) != 0u) {
166+ if ((d.misc.y & flagRounded) != 0u) {
165167 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);
167169 }
168170 color = c;
169171 }
@@ -49,6 +49,8 @@ const uint flagP010 = 32u;
49 // flagSmooth averages four samples a quarter of a target pixel around the49 // 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) {
345345 })
346346 }
347347 }
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 {
5353 Security: c.security,
5454 Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H},
5555 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},
5757 Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}},
5858 }
5959 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 {
160160 // A hidden workspace lights nothing: its focus is not shown.
161161 for _, p := range cw.placements {
162162 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)
166164 if !p.Hidden {
167165 w.Rect = rebase(p.Rect)
168166 }
@@ -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) {
270270 }
271271 }
272272 }
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 {
6060 // that much smaller in Rect, the client keeps its size. 0 otherwise.
6161 Preview float64
6262 // 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.
6566 Neighbors, Inset ports.Sides
6667 // Leaving marks a window that closed or hid but is still drawn while
6768 // 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 float6461 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; for63+ // 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.Sides66 Neighbors, Inset ports.Sides
66 // Leaving marks a window that closed or hid but is still drawn while67 // 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
7272 // Only the focused output has an activated window.
7373 focused := p.Focused && i == c.focusScreen
7474 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)
7876 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
7977 // A scale motion: the content follows the drawn size. A
8078 // 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.focusScreen73 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. A77 // 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
22
33 import "github.com/bnema/neferwl/internal/ports"
44
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
1213 // 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
1617 // focused output's focused window can light lines; keyboard grabs by popups
1718 // and layers do not change the underlying window's visual focus.
1819
@@ -97,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
9798 continue
9899 }
99100 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 {
102103 line = half(line, s)
103104 }
104105 add(f, line, true)
@@ -158,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect {
158159 }
159160 return r
160161 }
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 a5+// 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 adjacent7+// or a fullscreen/zoomed tile. Adjacent tiles share one line, owned (and
8-// sides of the focused tile, including corners. With exactly two visible8+// 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/top9+// focused tile, including corners. With exactly two visible tiles, each
10-// first). With gaps, each tile owns a full four-sided border and lights10+// lights its own half of the shared line (left/top first). With gaps, each
11-// it whole. Hidden, scrolled-off, under-panel, other-workspace and11+// 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. Lines13 // 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 the16+// (Separator.Window), even when it is the only visible window. Only the
16 // focused output's focused window can light lines; keyboard grabs by popups17 // 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 continue98 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 r160 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 {
121121 Capture bool
122122 SpawnTerminal bool
123123 }
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.
125127 Border struct {
126128 Radius int
127129 Width int
@@ -121,7 +121,9 @@ type Config struct {
121 Capture bool121 Capture bool
122 SpawnTerminal bool122 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 int128 Radius int
127 Width int129 Width int
M internal/ports/scene.go +5 -1
@@ -168,8 +168,11 @@ func (s Scene) TileArea() (r Rect, ok bool) {
168168 }
169169
170170 // 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.
172172 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.
173176 Radius int
174177 Width int
175178 Active, Inactive string
@@ -205,6 +208,7 @@ type FocusFlash struct {
205208 // SceneWindow carries core → renderer window placement.
206209 type SceneWindow struct {
207210 // Radius rounds the window corners in logical pixels; zero is square.
211+ // Core sets Border.Radius here for floats and tiles with gaps.
208212 Radius int
209213 ID WindowID
210214 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, "" skips170 // 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 int176 Radius int
174 Width int177 Width int
175 Active, Inactive string178 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 int212 Radius int
209 ID WindowID213 ID WindowID
210 Rect Rect214 Rect Rect

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close