detached af14106

Merge 608a5b903e02696508ed604565078285738a86bb into fe393734fd06ea20291657795182f4d60b999056

commit
af14106d6ff66e523d8ad25547a3f93f999fcf27
author
ermite · · 2026-10-21 05:53 UTC
merge of
fe39373 + 608a5b9 · diff against the first parent
signature
unsigned
39 files · +452 -52
M README.md +1 -1
@@ -27,7 +27,7 @@ https://github.com/user-attachments/assets/54f25914-8335-4e66-b458-dabc5493d355
2727
2828 Written in Go, without cgo. Each piece of state has one owner goroutine, connected to the others by channels; hot paths reuse memory and client buffers stay off the Go heap. See [Architecture](docs/dev/architecture.md) for the concurrency model, the GC strategy and the reusable Go libraries.
2929
30-Small on purpose, and staying small: no blur, shadows, rounded corners or themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.
30+Small on purpose, and staying small: no blur, shadows or themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.
3131
3232 ## Install
3333
@@ -27,7 +27,7 @@ https://github.com/user-attachments/assets/54f25914-8335-4e66-b458-dabc5493d355
27 27
28 Written in Go, without cgo. Each piece of state has one owner goroutine, connected to the others by channels; hot paths reuse memory and client buffers stay off the Go heap. See [Architecture](docs/dev/architecture.md) for the concurrency model, the GC strategy and the reusable Go libraries.28 Written in Go, without cgo. Each piece of state has one owner goroutine, connected to the others by channels; hot paths reuse memory and client buffers stay off the Go heap. See [Architecture](docs/dev/architecture.md) for the concurrency model, the GC strategy and the reusable Go libraries.
29 29
30-Small on purpose, and staying small: no blur, shadows, rounded corners or themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.30+Small on purpose, and staying small: no blur, shadows or themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.
31 31
32 ## Install32 ## Install
33 33
M docs/config.md +3 -0
@@ -29,6 +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, `0` to `64`; floats always, tiles only with gaps |
3233 | `border.width` | `2` | Border width in pixels; `0` hides borders |
3334 | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows |
3435 | `floating.dim` | `0.3` | Darkening behind floating windows and the overview, 0 to 1 |
@@ -84,6 +85,8 @@ See [examples/config](../examples/config) for every key with its default.
8485 | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` |
8586 | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) |
8687
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+
8790 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
8891
8992 The position hint follows actual workspace/band changes and monitor focus, regardless
@@ -29,6 +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, `0` to `64`; floats always, tiles only with gaps |
32 | `border.width` | `2` | Border width in pixels; `0` hides borders |33 | `border.width` | `2` | Border width in pixels; `0` hides borders |
33 | `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 |
34 | `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 |
@@ -84,6 +85,8 @@ See [examples/config](../examples/config) for every key with its default.
84 | `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` |
85 | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) |86 | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) |
86 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+
87 `<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`).
88 91
89 The position hint follows actual workspace/band changes and monitor focus, regardless92 The position hint follows actual workspace/band changes and monitor focus, regardless
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 +6 -0
@@ -773,6 +773,12 @@ func set(c *ports.Config, key, v string) error {
773773 return err
774774 }
775775 c.Stash.SpawnTerminal = b
776+ case "border.radius":
777+ n, err := strconv.Atoi(v)
778+ if err != nil || n < 0 || n > 64 {
779+ return fmt.Errorf("must be between 0 and 64")
780+ }
781+ c.Border.Radius = n
776782 case "border.width":
777783 n, err := strconv.Atoi(v)
778784 if err != nil || n < 0 || n > 32 {
@@ -773,6 +773,12 @@ func set(c *ports.Config, key, v string) error {
773 return err773 return err
774 }774 }
775 c.Stash.SpawnTerminal = b775 c.Stash.SpawnTerminal = b
776+ case "border.radius":
777+ n, err := strconv.Atoi(v)
778+ if err != nil || n < 0 || n > 64 {
779+ return fmt.Errorf("must be between 0 and 64")
780+ }
781+ c.Border.Radius = n
776 case "border.width":782 case "border.width":
777 n, err := strconv.Atoi(v)783 n, err := strconv.Atoi(v)
778 if err != nil || n < 0 || n > 32 {784 if err != nil || n < 0 || n > 32 {
M internal/adapters/config/config_test.go +18 -0
@@ -1063,3 +1063,21 @@ func TestPositionHint(t *testing.T) {
10631063 }
10641064 }
10651065 }
1066+
1067+func TestBorderRadius(t *testing.T) {
1068+ if Defaults().Border.Radius != 0 {
1069+ t.Fatal("nonzero default")
1070+ }
1071+ for _, v := range []string{"0", "10", "64"} {
1072+ c, w := parseString(t, "border.radius = "+v+"\n")
1073+ if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v {
1074+ t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w)
1075+ }
1076+ }
1077+ for _, v := range []string{"-1", "bad", "65"} {
1078+ _, w := parseString(t, "border.radius = "+v+"\n")
1079+ if len(w) == 0 {
1080+ t.Fatalf("accepted %s", v)
1081+ }
1082+ }
1083+}
@@ -1063,3 +1063,21 @@ func TestPositionHint(t *testing.T) {
1063 }1063 }
1064 }1064 }
1065 }1065 }
1066+
1067+func TestBorderRadius(t *testing.T) {
1068+ if Defaults().Border.Radius != 0 {
1069+ t.Fatal("nonzero default")
1070+ }
1071+ for _, v := range []string{"0", "10", "64"} {
1072+ c, w := parseString(t, "border.radius = "+v+"\n")
1073+ if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v {
1074+ t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w)
1075+ }
1076+ }
1077+ for _, v := range []string{"-1", "bad", "65"} {
1078+ _, w := parseString(t, "border.radius = "+v+"\n")
1079+ if len(w) == 0 {
1080+ t.Fatalf("accepted %s", v)
1081+ }
1082+ }
1083+}
M internal/adapters/config/watch_test.go +3 -3
@@ -93,17 +93,17 @@ func TestWatch(t *testing.T) {
9393 }
9494 }
9595 // An invalid value keeps the default for that key; other keys still apply.
96- write(path, "background = bad\nborder.width = 5\n")
96+ write(path, "background = bad\nborder.width = 5\nborder.radius = 10\n")
9797 select {
9898 case c := <-out:
99- if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 {
99+ if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 || c.Config.Border.Radius != 10 {
100100 t.Fatalf("partial reload: %+v", c.Config)
101101 }
102102 case <-time.After(time.Second):
103103 t.Fatal("no partial reload")
104104 }
105105 // Rewriting the same content sends nothing.
106- write(path, "background = bad\nborder.width = 5\n")
106+ write(path, "background = bad\nborder.width = 5\nborder.radius = 10\n")
107107 select {
108108 case <-out:
109109 t.Fatal("unchanged config sent")
@@ -93,17 +93,17 @@ func TestWatch(t *testing.T) {
93 }93 }
94 }94 }
95 // An invalid value keeps the default for that key; other keys still apply.95 // An invalid value keeps the default for that key; other keys still apply.
96- write(path, "background = bad\nborder.width = 5\n")96+ write(path, "background = bad\nborder.width = 5\nborder.radius = 10\n")
97 select {97 select {
98 case c := <-out:98 case c := <-out:
99- if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 {99+ if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 || c.Config.Border.Radius != 10 {
100 t.Fatalf("partial reload: %+v", c.Config)100 t.Fatalf("partial reload: %+v", c.Config)
101 }101 }
102 case <-time.After(time.Second):102 case <-time.After(time.Second):
103 t.Fatal("no partial reload")103 t.Fatal("no partial reload")
104 }104 }
105 // Rewriting the same content sends nothing.105 // Rewriting the same content sends nothing.
106- write(path, "background = bad\nborder.width = 5\n")106+ write(path, "background = bad\nborder.width = 5\nborder.radius = 10\n")
107 select {107 select {
108 case <-out:108 case <-out:
109 t.Fatal("unchanged config sent")109 t.Fatal("unchanged config sent")
M internal/adapters/drm/overlay.go +1 -1
@@ -105,7 +105,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
105105 // window (a peeking stashed one) needs the veil drawn over it, a
106106 // fading one its translucency; an overview preview is drawn smaller
107107 // than its buffer.
108- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
108+ if w.Radius == 0 && w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
109109 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
110110 pick, pickAt, mode = w, i, m
111111 continue
@@ -105,7 +105,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
105 // window (a peeking stashed one) needs the veil drawn over it, a105 // window (a peeking stashed one) needs the veil drawn over it, a
106 // fading one its translucency; an overview preview is drawn smaller106 // fading one its translucency; an overview preview is drawn smaller
107 // than its buffer.107 // than its buffer.
108- if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {108+ if w.Radius == 0 && w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) {
109 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {109 if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
110 pick, pickAt, mode = w, i, m110 pick, pickAt, mode = w, i, m
111 continue111 continue
M internal/adapters/drm/overlay_test.go +9 -0
@@ -401,3 +401,12 @@ func TestOverlayCarriesAcquireFence(t *testing.T) {
401401 t.Fatal("duplicate left open")
402402 }
403403 }
404+
405+func TestRoundedOverlayRejected(t *testing.T) {
406+ s, contents := overlayScene()
407+ s.Windows[1].Radius = 10
408+ w, _, _, _ := overlayCandidate(s, contents, false, nil, nil)
409+ if w.ID != 0 {
410+ t.Fatal("rounded window on overlay")
411+ }
412+}
@@ -401,3 +401,12 @@ func TestOverlayCarriesAcquireFence(t *testing.T) {
401 t.Fatal("duplicate left open")401 t.Fatal("duplicate left open")
402 }402 }
403 }403 }
404+
405+func TestRoundedOverlayRejected(t *testing.T) {
406+ s, contents := overlayScene()
407+ s.Windows[1].Radius = 10
408+ w, _, _, _ := overlayCandidate(s, contents, false, nil, nil)
409+ if w.ID != 0 {
410+ t.Fatal("rounded window on overlay")
411+ }
412+}
M internal/adapters/drm/scanout.go +1 -1
@@ -133,7 +133,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
133133 // Its veil is composed over it: the plane would show it bright.
134134 return ports.SurfaceContent{}, "dim"
135135 }
136- if full.Fade > 0 || full.Preview > 0 || full.Zoom > 0 {
136+ if full.Radius > 0 || full.Fade > 0 || full.Preview > 0 || full.Zoom > 0 {
137137 // A fading or zoomed window is composed: the plane would show it
138138 // whole and opaque.
139139 return ports.SurfaceContent{}, "window_fade"
@@ -133,7 +133,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
133 // Its veil is composed over it: the plane would show it bright.133 // Its veil is composed over it: the plane would show it bright.
134 return ports.SurfaceContent{}, "dim"134 return ports.SurfaceContent{}, "dim"
135 }135 }
136- if full.Fade > 0 || full.Preview > 0 || full.Zoom > 0 {136+ if full.Radius > 0 || full.Fade > 0 || full.Preview > 0 || full.Zoom > 0 {
137 // A fading or zoomed window is composed: the plane would show it137 // A fading or zoomed window is composed: the plane would show it
138 // whole and opaque.138 // whole and opaque.
139 return ports.SurfaceContent{}, "window_fade"139 return ports.SurfaceContent{}, "window_fade"
M internal/adapters/drm/scanout_test.go +1 -0
@@ -27,6 +27,7 @@ func TestScanoutCandidate(t *testing.T) {
2727 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
2828 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
2929 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
30+ {"rounded", func(s *ports.Scene) { s.Windows[0].Radius = 10 }, nil, "window_fade"},
3031 {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},
3132 {"window preview", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
3233 {"window zoom", func(s *ports.Scene) { s.Windows[0].Zoom = 0.9 }, nil, "window_fade"},
@@ -27,6 +27,7 @@ func TestScanoutCandidate(t *testing.T) {
27 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},27 {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"},
28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},28 {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"},
29 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},29 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
30+ {"rounded", func(s *ports.Scene) { s.Windows[0].Radius = 10 }, nil, "window_fade"},
30 {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},31 {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"},
31 {"window preview", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},32 {"window preview", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"},
32 {"window zoom", func(s *ports.Scene) { s.Windows[0].Zoom = 0.9 }, nil, "window_fade"},33 {"window zoom", func(s *ports.Scene) { s.Windows[0].Zoom = 0.9 }, nil, "window_fade"},
M internal/adapters/vulkan/compose.go +8 -4
@@ -52,7 +52,9 @@ const (
5252 flagP010 = 32
5353 // flagSmooth supersamples a buffer shrunk under half size (overview
5454 // previews): four bilinear samples per pixel instead of one.
55- flagSmooth = 64
55+ flagSmooth = 64
56+ flagRounded = 128
57+ flagRing = 256
5658 )
5759
5860 // drawParameters is the parameter portion of the shaders' std430 Draw.
@@ -68,9 +70,11 @@ type drawParameters struct {
6870
6971 // draw is one quad of a frame.
7072 type draw struct {
71- pc drawParameters
72- shm *shmCopy
73- im *imported // sampled client dmabuf, for ownership barriers
73+ round, roundInner [4]float32
74+ radii [2]uint32
75+ pc drawParameters
76+ shm *shmCopy
77+ im *imported // sampled client dmabuf, for ownership barriers
7478 // acquire is the content's explicit-sync fence (nil: implicit).
7579 acquire *os.File
7680 }
@@ -52,7 +52,9 @@ const (
52 flagP010 = 3252 flagP010 = 32
53 // flagSmooth supersamples a buffer shrunk under half size (overview53 // flagSmooth supersamples a buffer shrunk under half size (overview
54 // previews): four bilinear samples per pixel instead of one.54 // previews): four bilinear samples per pixel instead of one.
55- flagSmooth = 6455+ flagSmooth = 64
56+ flagRounded = 128
57+ flagRing = 256
56 )58 )
57 59
58 // drawParameters is the parameter portion of the shaders' std430 Draw.60 // drawParameters is the parameter portion of the shaders' std430 Draw.
@@ -68,9 +70,11 @@ type drawParameters struct {
68 70
69 // draw is one quad of a frame.71 // draw is one quad of a frame.
70 type draw struct {72 type draw struct {
71- pc drawParameters73+ round, roundInner [4]float32
72- shm *shmCopy74+ radii [2]uint32
73- im *imported // sampled client dmabuf, for ownership barriers75+ pc drawParameters
76+ shm *shmCopy
77+ im *imported // sampled client dmabuf, for ownership barriers
74 // acquire is the content's explicit-sync fence (nil: implicit).78 // acquire is the content's explicit-sync fence (nil: implicit).
75 acquire *os.File79 acquire *os.File
76 }80 }
M internal/adapters/vulkan/compose_batch.go +5 -4
@@ -11,10 +11,11 @@ import (
1111 // Bound descriptor-table work and memory even on devices with huge limits.
1212 const maxComposeResources = 64
1313
14-// instanceDraw matches Draw in compose.vert (std430, 128-byte stride).
14+// instanceDraw matches Draw in compose.vert (std430, 160-byte stride).
1515 type instanceDraw struct {
16- pc drawParameters
17- resource [4]uint32
16+ pc drawParameters
17+ resource [4]uint32
18+ round, roundInner [4]float32
1819 }
1920
2021 // resourceKey retains the Go identity as well as the Vulkan handles: a
@@ -64,7 +65,7 @@ func (b *composeBatch) add(dr draw, resources, maxDraws int) bool {
6465 b.used++
6566 }
6667 }
67- b.instances = append(b.instances, instanceDraw{pc: dr.pc, resource: [4]uint32{uint32(index)}})
68+ b.instances = append(b.instances, instanceDraw{pc: dr.pc, resource: [4]uint32{uint32(index), dr.radii[0], dr.radii[1]}, round: dr.round, roundInner: dr.roundInner})
6869 return true
6970 }
7071
@@ -11,10 +11,11 @@ import (
11 // Bound descriptor-table work and memory even on devices with huge limits.11 // Bound descriptor-table work and memory even on devices with huge limits.
12 const maxComposeResources = 6412 const maxComposeResources = 64
13 13
14-// instanceDraw matches Draw in compose.vert (std430, 128-byte stride).14+// instanceDraw matches Draw in compose.vert (std430, 160-byte stride).
15 type instanceDraw struct {15 type instanceDraw struct {
16- pc drawParameters16+ pc drawParameters
17- resource [4]uint3217+ resource [4]uint32
18+ round, roundInner [4]float32
18 }19 }
19 20
20 // resourceKey retains the Go identity as well as the Vulkan handles: a21 // resourceKey retains the Go identity as well as the Vulkan handles: a
@@ -64,7 +65,7 @@ func (b *composeBatch) add(dr draw, resources, maxDraws int) bool {
64 b.used++65 b.used++
65 }66 }
66 }67 }
67- b.instances = append(b.instances, instanceDraw{pc: dr.pc, resource: [4]uint32{uint32(index)}})68+ b.instances = append(b.instances, instanceDraw{pc: dr.pc, resource: [4]uint32{uint32(index), dr.radii[0], dr.radii[1]}, round: dr.round, roundInner: dr.roundInner})
68 return true69 return true
69 }70 }
70 71
M internal/adapters/vulkan/compose_batch_test.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 )
1313
1414 func TestInstanceDrawLayout(t *testing.T) {
15- if unsafe.Sizeof(drawParameters{}) != 112 || unsafe.Sizeof(instanceDraw{}) != 128 || unsafe.Offsetof(instanceDraw{}.resource) != 112 {
15+ if unsafe.Sizeof(drawParameters{}) != 112 || unsafe.Sizeof(instanceDraw{}) != 160 || unsafe.Offsetof(instanceDraw{}.resource) != 112 || unsafe.Offsetof(instanceDraw{}.round) != 128 || unsafe.Offsetof(instanceDraw{}.roundInner) != 144 {
1616 t.Fatal("draw layout differs from compose.vert std430 layout")
1717 }
1818 }
@@ -12,7 +12,7 @@ import (
12 )12 )
13 13
14 func TestInstanceDrawLayout(t *testing.T) {14 func TestInstanceDrawLayout(t *testing.T) {
15- if unsafe.Sizeof(drawParameters{}) != 112 || unsafe.Sizeof(instanceDraw{}) != 128 || unsafe.Offsetof(instanceDraw{}.resource) != 112 {15+ if unsafe.Sizeof(drawParameters{}) != 112 || unsafe.Sizeof(instanceDraw{}) != 160 || unsafe.Offsetof(instanceDraw{}.resource) != 112 || unsafe.Offsetof(instanceDraw{}.round) != 128 || unsafe.Offsetof(instanceDraw{}.roundInner) != 144 {
16 t.Fatal("draw layout differs from compose.vert std430 layout")16 t.Fatal("draw layout differs from compose.vert std430 layout")
17 }17 }
18 }18 }
M internal/adapters/vulkan/damage_delta_test.go +1 -1
@@ -28,7 +28,7 @@ func TestSceneFieldCount(t *testing.T) {
2828 // sceneDelta compares windows by struct equality, so a new field is covered
2929 // as long as it only changes where or how that window draws (inside Rect).
3030 func TestSceneWindowFieldCount(t *testing.T) {
31- const fields = 14
31+ const fields = 15
3232 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
3333 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
3434 }
@@ -28,7 +28,7 @@ func TestSceneFieldCount(t *testing.T) {
28 // sceneDelta compares windows by struct equality, so a new field is covered28 // sceneDelta compares windows by struct equality, so a new field is covered
29 // as long as it only changes where or how that window draws (inside Rect).29 // as long as it only changes where or how that window draws (inside Rect).
30 func TestSceneWindowFieldCount(t *testing.T) {30 func TestSceneWindowFieldCount(t *testing.T) {
31- const fields = 1431+ const fields = 15
32 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),32 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
33 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")33 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
34 }34 }
M internal/adapters/vulkan/orient.go +6 -0
@@ -43,6 +43,12 @@ func (r *Renderer) orient(ds []draw, t ports.BufferTransform) {
4343 pc := &ds[i].pc
4444 rect := t.RectToBuffer(image.Rect(int(pc.rect[0]), int(pc.rect[1]), int(pc.rect[2]), int(pc.rect[3])), sw, sh)
4545 pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}
46+ if pc.misc[1]&flagRounded != 0 {
47+ for _, bounds := range []*[4]float32{&ds[i].round, &ds[i].roundInner} {
48+ rect := t.RectToBuffer(image.Rect(int(bounds[0]), int(bounds[1]), int(bounds[2]), int(bounds[3])), sw, sh)
49+ *bounds = roundRect(rect)
50+ }
51+ }
4652 if pc.misc[0] == modeSolid {
4753 continue
4854 }
@@ -43,6 +43,12 @@ func (r *Renderer) orient(ds []draw, t ports.BufferTransform) {
43 pc := &ds[i].pc43 pc := &ds[i].pc
44 rect := t.RectToBuffer(image.Rect(int(pc.rect[0]), int(pc.rect[1]), int(pc.rect[2]), int(pc.rect[3])), sw, sh)44 rect := t.RectToBuffer(image.Rect(int(pc.rect[0]), int(pc.rect[1]), int(pc.rect[2]), int(pc.rect[3])), sw, sh)
45 pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}45 pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}
46+ if pc.misc[1]&flagRounded != 0 {
47+ for _, bounds := range []*[4]float32{&ds[i].round, &ds[i].roundInner} {
48+ rect := t.RectToBuffer(image.Rect(int(bounds[0]), int(bounds[1]), int(bounds[2]), int(bounds[3])), sw, sh)
49+ *bounds = roundRect(rect)
50+ }
51+ }
46 if pc.misc[0] == modeSolid {52 if pc.misc[0] == modeSolid {
47 continue53 continue
48 }54 }
A internal/adapters/vulkan/orient_rounded_test.go +51 -0
@@ -0,0 +1,51 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestOrientRoundedWindow(t *testing.T) {
11+ for transform := ports.BufferTransform(0); transform < 8; transform++ {
12+ r := &Renderer{width: 120, height: 90}
13+ sw, sh := r.sceneSize(transform)
14+ outer := image.Rect(9, 13, 69, 77)
15+ body := outer.Inset(3)
16+ s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800"},
17+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 9, Y: 13, W: 60, H: 64}, Inset: ports.SideAll, Radius: 10, Dim: 0.5}},
18+ Separators: []ports.Separator{{Window: 1, Active: true}}}
19+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 1, Height: 1, LogicalW: 54, LogicalH: 58, Solid: &ports.SolidColor{R: 1, A: 1}}}
20+ ds := r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, sw, sh)))
21+ if len(ds) != 4 {
22+ t.Fatalf("transform %d: %d draws", transform, len(ds))
23+ }
24+ // Exercise the non-solid orient path as well as ring/body/dim solids.
25+ ds[2].pc.misc[0] = modeImage
26+ radii := ds[0].radii
27+ r.orient(ds, transform)
28+ wantOuter := roundRect(transform.RectToBuffer(outer, sw, sh))
29+ wantBody := roundRect(transform.RectToBuffer(body, sw, sh))
30+ wantHole := roundRect(transform.RectToBuffer(body.Inset(1), sw, sh))
31+ if ds[0].round != wantOuter || ds[0].roundInner != wantHole || ds[0].radii != radii {
32+ t.Fatalf("transform %d: ring clip %+v", transform, ds[0])
33+ }
34+ if ds[0].pc.misc[1]&flagRing == 0 {
35+ t.Fatal("ring must precede content")
36+ }
37+ for i, d := range ds {
38+ want := wantBody
39+ if i == 0 || i == 3 {
40+ want = wantOuter
41+ }
42+ if d.round != want {
43+ t.Fatalf("transform %d draw %d clip %v want %v", transform, i, d.round, want)
44+ }
45+ rect := [4]float32{float32(d.pc.rect[0]), float32(d.pc.rect[1]), float32(d.pc.rect[2]), float32(d.pc.rect[3])}
46+ if rect != want {
47+ t.Fatalf("transform %d draw %d rect %v want %v", transform, i, rect, want)
48+ }
49+ }
50+ }
51+}
@@ -0,0 +1,51 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestOrientRoundedWindow(t *testing.T) {
11+ for transform := ports.BufferTransform(0); transform < 8; transform++ {
12+ r := &Renderer{width: 120, height: 90}
13+ sw, sh := r.sceneSize(transform)
14+ outer := image.Rect(9, 13, 69, 77)
15+ body := outer.Inset(3)
16+ s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800"},
17+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 9, Y: 13, W: 60, H: 64}, Inset: ports.SideAll, Radius: 10, Dim: 0.5}},
18+ Separators: []ports.Separator{{Window: 1, Active: true}}}
19+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 1, Height: 1, LogicalW: 54, LogicalH: 58, Solid: &ports.SolidColor{R: 1, A: 1}}}
20+ ds := r.draws(s, contents, newDamage(&target{}, s, image.Rect(0, 0, sw, sh)))
21+ if len(ds) != 4 {
22+ t.Fatalf("transform %d: %d draws", transform, len(ds))
23+ }
24+ // Exercise the non-solid orient path as well as ring/body/dim solids.
25+ ds[2].pc.misc[0] = modeImage
26+ radii := ds[0].radii
27+ r.orient(ds, transform)
28+ wantOuter := roundRect(transform.RectToBuffer(outer, sw, sh))
29+ wantBody := roundRect(transform.RectToBuffer(body, sw, sh))
30+ wantHole := roundRect(transform.RectToBuffer(body.Inset(1), sw, sh))
31+ if ds[0].round != wantOuter || ds[0].roundInner != wantHole || ds[0].radii != radii {
32+ t.Fatalf("transform %d: ring clip %+v", transform, ds[0])
33+ }
34+ if ds[0].pc.misc[1]&flagRing == 0 {
35+ t.Fatal("ring must precede content")
36+ }
37+ for i, d := range ds {
38+ want := wantBody
39+ if i == 0 || i == 3 {
40+ want = wantOuter
41+ }
42+ if d.round != want {
43+ t.Fatalf("transform %d draw %d clip %v want %v", transform, i, d.round, want)
44+ }
45+ rect := [4]float32{float32(d.pc.rect[0]), float32(d.pc.rect[1]), float32(d.pc.rect[2]), float32(d.pc.rect[3])}
46+ if rect != want {
47+ t.Fatalf("transform %d draw %d rect %v want %v", transform, i, rect, want)
48+ }
49+ }
50+ }
51+}
M internal/adapters/vulkan/scene.go +81 -5
@@ -23,8 +23,12 @@ type sceneWalk struct {
2323 // alpha is the opacity of the window being drawn, 1 - its Fade
2424 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
2525 // it (premultiplied); 1 otherwise.
26- alpha float32
27- 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
2832 // tileBounds is bounds cut to Scene.TileArea: where tiles and their
2933 // lines draw.
3034 tileBounds image.Rectangle
@@ -255,7 +259,35 @@ func (w *sceneWalk) windows() {
255259 body := w.physRect(c.X, c.Y, c.W, c.H)
256260 content := w.contents[win.ID]
257261 // Damage counts only what is drawn: never the panel under a tile.
258- 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))
264+ w.rounded = win.Radius > 0
265+ var radius, innerRadius float32
266+ if win.Radius > 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 {
272+ n := len(w.draws)
273+ w.fill(outer, parseColor(col))
274+ if len(w.draws) > n {
275+ dr := &w.draws[n]
276+ roundedDraw(dr, outer, radius)
277+ dr.pc.misc[1] |= flagRing
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)
287+ }
288+ }
289+ }
290+ start := len(w.draws)
259291 // Until its first buffer, a window shows the background: no flash.
260292 w.fill(body, parseColor(w.s.Background))
261293 if !content.Empty() {
@@ -269,13 +301,24 @@ func (w *sceneWalk) windows() {
269301 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
270302 w.zoom = 1
271303 }
272- if win.Floating {
304+ if win.Radius > 0 {
305+ for j := start; j < len(w.draws); j++ {
306+ roundedDraw(&w.draws[j], body, innerRadius)
307+ }
308+ } else {
273309 w.separators(win.ID)
274310 }
311+ dimStart := len(w.draws)
275312 if win.Dim > 0 {
276313 // A stashed window peeking in: dimmed with its border.
277- w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
314+ w.dim(outer, win.Dim)
315+ }
316+ if win.Radius > 0 {
317+ for j := dimStart; j < len(w.draws); j++ {
318+ roundedDraw(&w.draws[j], outer, radius)
319+ }
278320 }
321+ w.rounded = false
279322 w.alpha, w.bounds = 1, windowBounds
280323 }
281324 if !tileLines {
@@ -291,6 +334,22 @@ func (w *sceneWalk) tileSeparators() {
291334 w.bounds = b
292335 }
293336
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+
294353 // separators draws a window's border lines in order, active lines over
295354 // inactive ones.
296355 func (w *sceneWalk) separators(id ports.WindowID) {
@@ -354,6 +413,9 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
354413 // dmabuf that fails to import (opaqueChildren pre-imports it) or an
355414 // unreadable shm buffer.
356415 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {
416+ if w.rounded {
417+ return nil
418+ }
357419 covers := w.r.scratchCovers[:0]
358420 for i := range content.Children {
359421 ch := &content.Children[i]
@@ -640,3 +702,17 @@ func parseColor(s string) [3]uint8 {
640702 }
641703 return c
642704 }
705+
706+// roundRect is r as float bounds for the shader's rounded coverage. Rounded
707+// bounds stay independent of scissor/damage clipping.
708+func roundRect(r image.Rectangle) [4]float32 {
709+ return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)}
710+}
711+
712+// roundedDraw makes d a rounded draw: the shader clips it to rect with
713+// corners of radius (physical pixels).
714+func roundedDraw(d *draw, rect image.Rectangle, radius float32) {
715+ d.pc.misc[1] |= flagRounded
716+ d.round = roundRect(rect)
717+ d.radii[0] = math.Float32bits(radius)
718+}
@@ -23,8 +23,12 @@ type sceneWalk struct {
23 // alpha is the opacity of the window being drawn, 1 - its Fade23 // alpha is the opacity of the window being drawn, 1 - its Fade
24 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by24 // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by
25 // it (premultiplied); 1 otherwise.25 // it (premultiplied); 1 otherwise.
26- alpha float3226+ alpha float32
27- 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
28 // tileBounds is bounds cut to Scene.TileArea: where tiles and their32 // tileBounds is bounds cut to Scene.TileArea: where tiles and their
29 // lines draw.33 // lines draw.
30 tileBounds image.Rectangle34 tileBounds image.Rectangle
@@ -255,7 +259,35 @@ func (w *sceneWalk) windows() {
255 body := w.physRect(c.X, c.Y, c.W, c.H)259 body := w.physRect(c.X, c.Y, c.W, c.H)
256 content := w.contents[win.ID]260 content := w.contents[win.ID]
257 // 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.
258- 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))
264+ w.rounded = win.Radius > 0
265+ var radius, innerRadius float32
266+ if win.Radius > 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 {
272+ n := len(w.draws)
273+ w.fill(outer, parseColor(col))
274+ if len(w.draws) > n {
275+ dr := &w.draws[n]
276+ roundedDraw(dr, outer, radius)
277+ dr.pc.misc[1] |= flagRing
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)
287+ }
288+ }
289+ }
290+ start := len(w.draws)
259 // Until its first buffer, a window shows the background: no flash.291 // Until its first buffer, a window shows the background: no flash.
260 w.fill(body, parseColor(w.s.Background))292 w.fill(body, parseColor(w.s.Background))
261 if !content.Empty() {293 if !content.Empty() {
@@ -269,13 +301,24 @@ func (w *sceneWalk) windows() {
269 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)301 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
270 w.zoom = 1302 w.zoom = 1
271 }303 }
272- if win.Floating {304+ if win.Radius > 0 {
305+ for j := start; j < len(w.draws); j++ {
306+ roundedDraw(&w.draws[j], body, innerRadius)
307+ }
308+ } else {
273 w.separators(win.ID)309 w.separators(win.ID)
274 }310 }
311+ dimStart := len(w.draws)
275 if win.Dim > 0 {312 if win.Dim > 0 {
276 // A stashed window peeking in: dimmed with its border.313 // A stashed window peeking in: dimmed with its border.
277- w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)314+ w.dim(outer, win.Dim)
315+ }
316+ if win.Radius > 0 {
317+ for j := dimStart; j < len(w.draws); j++ {
318+ roundedDraw(&w.draws[j], outer, radius)
319+ }
278 }320 }
321+ w.rounded = false
279 w.alpha, w.bounds = 1, windowBounds322 w.alpha, w.bounds = 1, windowBounds
280 }323 }
281 if !tileLines {324 if !tileLines {
@@ -291,6 +334,22 @@ func (w *sceneWalk) tileSeparators() {
291 w.bounds = b334 w.bounds = b
292 }335 }
293 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+
294 // separators draws a window's border lines in order, active lines over353 // separators draws a window's border lines in order, active lines over
295 // inactive ones.354 // inactive ones.
296 func (w *sceneWalk) separators(id ports.WindowID) {355 func (w *sceneWalk) separators(id ports.WindowID) {
@@ -354,6 +413,9 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
354 // dmabuf that fails to import (opaqueChildren pre-imports it) or an413 // dmabuf that fails to import (opaqueChildren pre-imports it) or an
355 // unreadable shm buffer.414 // unreadable shm buffer.
356 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {415 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {
416+ if w.rounded {
417+ return nil
418+ }
357 covers := w.r.scratchCovers[:0]419 covers := w.r.scratchCovers[:0]
358 for i := range content.Children {420 for i := range content.Children {
359 ch := &content.Children[i]421 ch := &content.Children[i]
@@ -640,3 +702,17 @@ func parseColor(s string) [3]uint8 {
640 }702 }
641 return c703 return c
642 }704 }
705+
706+// roundRect is r as float bounds for the shader's rounded coverage. Rounded
707+// bounds stay independent of scissor/damage clipping.
708+func roundRect(r image.Rectangle) [4]float32 {
709+ return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)}
710+}
711+
712+// roundedDraw makes d a rounded draw: the shader clips it to rect with
713+// corners of radius (physical pixels).
714+func roundedDraw(d *draw, rect image.Rectangle, radius float32) {
715+ d.pc.misc[1] |= flagRounded
716+ d.round = roundRect(rect)
717+ d.radii[0] = math.Float32bits(radius)
718+}
M internal/adapters/vulkan/scene_fade_test.go +49 -0
@@ -141,3 +141,52 @@ func TestSceneZoomedFloatOpensFloats(t *testing.T) {
141141 t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds)
142142 }
143143 }
144+
145+func TestRoundedWindowDraws(t *testing.T) {
146+ r := &Renderer{width: 80, height: 60}
147+ s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800", Inactive: "#888888"},
148+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 40, H: 40}, Inset: ports.SideAll, Radius: 10, Dim: 0.5}},
149+ Separators: []ports.Separator{{Window: 1, Active: true}}}
150+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
151+ if len(ds) != 3 {
152+ t.Fatalf("draws %d", len(ds))
153+ }
154+ for i, d := range ds {
155+ if d.pc.misc[1]&flagRounded == 0 {
156+ t.Fatalf("draw %d not rounded", i)
157+ }
158+ }
159+ if ds[0].pc.misc[1]&flagRing == 0 || math.Float32frombits(ds[0].radii[0]) != 10 || math.Float32frombits(ds[0].radii[1]) != 6 {
160+ t.Fatal("ring radii")
161+ }
162+ if ds[1].round != roundRect(image.Rect(11, 11, 45, 45)) {
163+ t.Fatal("body clip")
164+ }
165+ var b composeBatch
166+ if !b.add(ds[0], 64, 64) || b.instances[0].round != ds[0].round || b.instances[0].resource[1] != ds[0].radii[0] {
167+ t.Fatal("batch loses clip")
168+ }
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+}
@@ -141,3 +141,52 @@ func TestSceneZoomedFloatOpensFloats(t *testing.T) {
141 t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds)141 t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds)
142 }142 }
143 }143 }
144+
145+func TestRoundedWindowDraws(t *testing.T) {
146+ r := &Renderer{width: 80, height: 60}
147+ s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800", Inactive: "#888888"},
148+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 40, H: 40}, Inset: ports.SideAll, Radius: 10, Dim: 0.5}},
149+ Separators: []ports.Separator{{Window: 1, Active: true}}}
150+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60)))
151+ if len(ds) != 3 {
152+ t.Fatalf("draws %d", len(ds))
153+ }
154+ for i, d := range ds {
155+ if d.pc.misc[1]&flagRounded == 0 {
156+ t.Fatalf("draw %d not rounded", i)
157+ }
158+ }
159+ if ds[0].pc.misc[1]&flagRing == 0 || math.Float32frombits(ds[0].radii[0]) != 10 || math.Float32frombits(ds[0].radii[1]) != 6 {
160+ t.Fatal("ring radii")
161+ }
162+ if ds[1].round != roundRect(image.Rect(11, 11, 45, 45)) {
163+ t.Fatal("body clip")
164+ }
165+ var b composeBatch
166+ if !b.add(ds[0], 64, 64) || b.instances[0].round != ds[0].round || b.instances[0].resource[1] != ds[0].radii[0] {
167+ t.Fatal("batch loses clip")
168+ }
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/scene_opaque_region_test.go +8 -0
@@ -330,3 +330,11 @@ func TestRendererSplitBoundKeepsRoot(t *testing.T) {
330330 }
331331 }
332332 }
333+
334+func TestRoundedChildDoesNotCullRoot(t *testing.T) {
335+ w := &sceneWalk{rounded: true}
336+ content := ports.SurfaceContent{Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Opaque: true}}}}
337+ if covers := w.opaqueChildren(&content, 0, 0, image.Rect(0, 0, 20, 20)); len(covers) != 0 {
338+ t.Fatal("rounded child occludes root")
339+ }
340+}
@@ -330,3 +330,11 @@ func TestRendererSplitBoundKeepsRoot(t *testing.T) {
330 }330 }
331 }331 }
332 }332 }
333+
334+func TestRoundedChildDoesNotCullRoot(t *testing.T) {
335+ w := &sceneWalk{rounded: true}
336+ content := ports.SurfaceContent{Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Opaque: true}}}}
337+ if covers := w.opaqueChildren(&content, 0, 0, image.Rect(0, 0, 20, 20)); len(covers) != 0 {
338+ t.Fatal("rounded child occludes root")
339+ }
340+}
M internal/adapters/vulkan/shaders/compose.frag +18 -0
@@ -16,6 +16,9 @@ layout(location = 0) in Parameters {
1616 flat uvec4 buf;
1717 flat uvec4 misc;
1818 flat uint resourceIndex;
19+ flat vec4 round;
20+ flat vec4 roundInner;
21+ flat vec2 radii;
1922 } d;
2023
2124 #ifdef SINGLE_RESOURCE
@@ -46,6 +49,8 @@ const uint flagP010 = 32u;
4649 // flagSmooth averages four samples a quarter of a target pixel around the
4750 // point: a thumbnail shrunk under half size keeps thin lines and text.
4851 const uint flagSmooth = 64u;
52+const uint flagRounded = 128u;
53+const uint flagRing = 256u;
4954
5055 // PQ values are absolute nits. BT.2020 is converted in linear light to
5156 // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above
@@ -121,6 +126,15 @@ vec4 sampleBuffer(vec2 src, bool exact) {
121126 return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);
122127 }
123128
129+float coverage(vec4 rect, float radius) {
130+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
131+ radius = min(radius, min(halfSize.x, halfSize.y));
132+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
133+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
134+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
135+ return clamp(0.5 - sd, 0.0, 1.0);
136+}
137+
124138 void main() {
125139 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
126140 bool exact = (d.misc.y & flagExact) != 0u;
@@ -150,5 +164,9 @@ void main() {
150164 // The surface's alpha modifier; premultiplied, it scales all four.
151165 c *= d.color.a;
152166 }
167+ if ((d.misc.y & flagRounded) != 0u) {
168+ c *= coverage(d.round, d.radii.x);
169+ if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
170+ }
153171 color = c;
154172 }
@@ -16,6 +16,9 @@ layout(location = 0) in Parameters {
16 flat uvec4 buf;16 flat uvec4 buf;
17 flat uvec4 misc;17 flat uvec4 misc;
18 flat uint resourceIndex;18 flat uint resourceIndex;
19+ flat vec4 round;
20+ flat vec4 roundInner;
21+ flat vec2 radii;
19 } d;22 } d;
20 23
21 #ifdef SINGLE_RESOURCE24 #ifdef SINGLE_RESOURCE
@@ -46,6 +49,8 @@ const uint flagP010 = 32u;
46 // flagSmooth averages four samples a quarter of a target pixel around the49 // flagSmooth averages four samples a quarter of a target pixel around the
47 // 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.
48 const uint flagSmooth = 64u;51 const uint flagSmooth = 64u;
52+const uint flagRounded = 128u;
53+const uint flagRing = 256u;
49 54
50 // 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
51 // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above56 // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above
@@ -121,6 +126,15 @@ vec4 sampleBuffer(vec2 src, bool exact) {
121 return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);126 return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);
122 }127 }
123 128
129+float coverage(vec4 rect, float radius) {
130+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
131+ radius = min(radius, min(halfSize.x, halfSize.y));
132+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
133+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
134+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
135+ return clamp(0.5 - sd, 0.0, 1.0);
136+}
137+
124 void main() {138 void main() {
125 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;139 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
126 bool exact = (d.misc.y & flagExact) != 0u;140 bool exact = (d.misc.y & flagExact) != 0u;
@@ -150,5 +164,9 @@ void main() {
150 // The surface's alpha modifier; premultiplied, it scales all four.164 // The surface's alpha modifier; premultiplied, it scales all four.
151 c *= d.color.a;165 c *= d.color.a;
152 }166 }
167+ if ((d.misc.y & flagRounded) != 0u) {
168+ c *= coverage(d.round, d.radii.x);
169+ if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
170+ }
153 color = c;171 color = c;
154 }172 }
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose.vert +9 -1
@@ -10,7 +10,9 @@ struct Draw {
1010 vec4 crop; // source crop bounds in buffer pixels
1111 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w
1212 uvec4 misc; // mode, flags, target width, target height
13- uvec4 resourceIndex; // resource-table slot in x; remaining words padding
13+ uvec4 resourceIndex; // slot, outer radius bits, inner radius bits, padding
14+ vec4 round;
15+ vec4 roundInner;
1416 };
1517 layout(std430, set = 0, binding = 3) readonly buffer Draws { Draw draws[]; };
1618 layout(location = 0) out Parameters {
@@ -21,6 +23,9 @@ layout(location = 0) out Parameters {
2123 flat uvec4 buf;
2224 flat uvec4 misc;
2325 flat uint resourceIndex;
26+ flat vec4 round;
27+ flat vec4 roundInner;
28+ flat vec2 radii;
2429 } parameters;
2530
2631 void main() {
@@ -36,4 +41,7 @@ void main() {
3641 parameters.buf = d.buf;
3742 parameters.misc = d.misc;
3843 parameters.resourceIndex = d.resourceIndex.x;
44+ parameters.round = d.round;
45+ parameters.roundInner = d.roundInner;
46+ parameters.radii = uintBitsToFloat(d.resourceIndex.yz);
3947 }
@@ -10,7 +10,9 @@ struct Draw {
10 vec4 crop; // source crop bounds in buffer pixels10 vec4 crop; // source crop bounds in buffer pixels
11 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w11 uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w
12 uvec4 misc; // mode, flags, target width, target height12 uvec4 misc; // mode, flags, target width, target height
13- uvec4 resourceIndex; // resource-table slot in x; remaining words padding13+ uvec4 resourceIndex; // slot, outer radius bits, inner radius bits, padding
14+ vec4 round;
15+ vec4 roundInner;
14 };16 };
15 layout(std430, set = 0, binding = 3) readonly buffer Draws { Draw draws[]; };17 layout(std430, set = 0, binding = 3) readonly buffer Draws { Draw draws[]; };
16 layout(location = 0) out Parameters {18 layout(location = 0) out Parameters {
@@ -21,6 +23,9 @@ layout(location = 0) out Parameters {
21 flat uvec4 buf;23 flat uvec4 buf;
22 flat uvec4 misc;24 flat uvec4 misc;
23 flat uint resourceIndex;25 flat uint resourceIndex;
26+ flat vec4 round;
27+ flat vec4 roundInner;
28+ flat vec2 radii;
24 } parameters;29 } parameters;
25 30
26 void main() {31 void main() {
@@ -36,4 +41,7 @@ void main() {
36 parameters.buf = d.buf;41 parameters.buf = d.buf;
37 parameters.misc = d.misc;42 parameters.misc = d.misc;
38 parameters.resourceIndex = d.resourceIndex.x;43 parameters.resourceIndex = d.resourceIndex.x;
44+ parameters.round = d.round;
45+ parameters.roundInner = d.roundInner;
46+ parameters.radii = uintBitsToFloat(d.resourceIndex.yz);
39 }47 }
M internal/adapters/vulkan/shaders/compose.vert.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_hdr.frag +18 -0
@@ -16,6 +16,9 @@ layout(location = 0) in Parameters {
1616 flat uvec4 buf;
1717 flat uvec4 misc;
1818 flat uint resourceIndex;
19+ flat vec4 round;
20+ flat vec4 roundInner;
21+ flat vec2 radii;
1922 } d;
2023
2124 #ifdef SINGLE_RESOURCE
@@ -46,6 +49,8 @@ const uint flagP010 = 32u;
4649 // flagSmooth averages four samples a quarter of a target pixel around the
4750 // point: a thumbnail shrunk under half size keeps thin lines and text.
4851 const uint flagSmooth = 64u;
52+const uint flagRounded = 128u;
53+const uint flagRing = 256u;
4954
5055 // The HDR composition target holds linear BT.709 in SDR-white units.
5156 vec3 decodeSRGB(vec3 v) {
@@ -120,6 +125,15 @@ vec4 sampleBuffer(vec2 src, bool exact) {
120125 return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);
121126 }
122127
128+float coverage(vec4 rect, float radius) {
129+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
130+ radius = min(radius, min(halfSize.x, halfSize.y));
131+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
132+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
133+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
134+ return clamp(0.5 - sd, 0.0, 1.0);
135+}
136+
123137 void main() {
124138 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
125139 bool exact = (d.misc.y & flagExact) != 0u;
@@ -149,5 +163,9 @@ void main() {
149163 // The surface's alpha modifier; premultiplied, it scales all four.
150164 c *= d.color.a;
151165 }
166+ if ((d.misc.y & flagRounded) != 0u) {
167+ c *= coverage(d.round, d.radii.x);
168+ if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
169+ }
152170 color = c;
153171 }
@@ -16,6 +16,9 @@ layout(location = 0) in Parameters {
16 flat uvec4 buf;16 flat uvec4 buf;
17 flat uvec4 misc;17 flat uvec4 misc;
18 flat uint resourceIndex;18 flat uint resourceIndex;
19+ flat vec4 round;
20+ flat vec4 roundInner;
21+ flat vec2 radii;
19 } d;22 } d;
20 23
21 #ifdef SINGLE_RESOURCE24 #ifdef SINGLE_RESOURCE
@@ -46,6 +49,8 @@ const uint flagP010 = 32u;
46 // flagSmooth averages four samples a quarter of a target pixel around the49 // flagSmooth averages four samples a quarter of a target pixel around the
47 // 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.
48 const uint flagSmooth = 64u;51 const uint flagSmooth = 64u;
52+const uint flagRounded = 128u;
53+const uint flagRing = 256u;
49 54
50 // 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.
51 vec3 decodeSRGB(vec3 v) {56 vec3 decodeSRGB(vec3 v) {
@@ -120,6 +125,15 @@ vec4 sampleBuffer(vec2 src, bool exact) {
120 return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);125 return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y);
121 }126 }
122 127
128+float coverage(vec4 rect, float radius) {
129+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
130+ radius = min(radius, min(halfSize.x, halfSize.y));
131+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
132+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
133+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
134+ return clamp(0.5 - sd, 0.0, 1.0);
135+}
136+
123 void main() {137 void main() {
124 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;138 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
125 bool exact = (d.misc.y & flagExact) != 0u;139 bool exact = (d.misc.y & flagExact) != 0u;
@@ -149,5 +163,9 @@ void main() {
149 // The surface's alpha modifier; premultiplied, it scales all four.163 // The surface's alpha modifier; premultiplied, it scales all four.
150 c *= d.color.a;164 c *= d.color.a;
151 }165 }
166+ if ((d.misc.y & flagRounded) != 0u) {
167+ c *= coverage(d.round, d.radii.x);
168+ if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
169+ }
152 color = c;170 color = c;
153 }171 }
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_hdr_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/core/border_invariants_test.go +65 -5
@@ -207,7 +207,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
207207 }
208208 }
209209 }
210- if tiles <= 1 {
210+ if tiles <= 1 && gap == 0 {
211211 for _, w := range s.Windows {
212212 if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {
213213 t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)
@@ -222,7 +222,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
222222 if sep.Window != 0 {
223223 found := false
224224 for _, w := range s.Windows {
225- if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
225+ if w.ID == sep.Window && (w.Floating || gap > 0) && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
226226 found = true
227227 }
228228 }
@@ -243,7 +243,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
243243 }
244244 near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}
245245 for _, sep := range s.Separators {
246- if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) {
246+ if sep.Active && (sep.Window == 0 || gap > 0 && sep.Window == focus.ID) && sep.Rect.Overlaps(near) {
247247 return
248248 }
249249 }
@@ -276,9 +276,12 @@ func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
276276 return sides
277277 }
278278
279-// wantInset is the exact inset of a visible tile: every neighbor side with
280-// gaps, only the right and bottom (shared-line owner) sides without.
279+// wantInset is the exact inset of a visible tile: all sides with gaps,
280+// only the right and bottom shared-line owner sides without.
281281 func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
282+ if gap > 0 {
283+ return ports.SideAll
284+ }
282285 sides := neighborSides(s, w, gap)
283286 if gap == 0 {
284287 sides &= ports.SideRight | ports.SideBottom
@@ -304,3 +307,60 @@ func TestBorderMoveBackAlone(t *testing.T) {
304307 }
305308 }
306309 }
310+
311+func TestBorderRadiusScene(t *testing.T) {
312+ for _, gap := range []int{0, 8} {
313+ t.Run(map[int]string{0: "flush", 8: "gaps"}[gap], func(t *testing.T) {
314+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = gap; c.Border.Radius = 10 }, left, right)
315+ scenes := r.mapWindow(t, 1)
316+ for _, s := range scenes {
317+ for _, w := range s.Windows {
318+ if w.ID == 1 {
319+ want := 0
320+ if gap > 0 {
321+ want = 10
322+ }
323+ if w.Radius != want || s.Border.Radius != 10 {
324+ t.Fatalf("radius %d, style %d", w.Radius, s.Border.Radius)
325+ }
326+ }
327+ }
328+ }
329+ for _, action := range []struct {
330+ sym string
331+ mods ports.Mods
332+ want int
333+ }{
334+ {"space", ports.ModAlt | ports.ModShift, 10},
335+ {"f", ports.ModAlt | ports.ModShift, 0},
336+ } {
337+ for _, s := range r.key(t, action.sym, action.mods) {
338+ for _, w := range s.Windows {
339+ if w.ID == 1 && w.Radius != action.want {
340+ t.Fatalf("%s radius %d, want %d", action.sym, w.Radius, action.want)
341+ }
342+ }
343+ }
344+ }
345+ })
346+ }
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+}
@@ -207,7 +207,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
207 }207 }
208 }208 }
209 }209 }
210- if tiles <= 1 {210+ if tiles <= 1 && gap == 0 {
211 for _, w := range s.Windows {211 for _, w := range s.Windows {
212 if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {212 if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {
213 t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)213 t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)
@@ -222,7 +222,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
222 if sep.Window != 0 {222 if sep.Window != 0 {
223 found := false223 found := false
224 for _, w := range s.Windows {224 for _, w := range s.Windows {
225- if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {225+ if w.ID == sep.Window && (w.Floating || gap > 0) && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
226 found = true226 found = true
227 }227 }
228 }228 }
@@ -243,7 +243,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
243 }243 }
244 near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}244 near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}
245 for _, sep := range s.Separators {245 for _, sep := range s.Separators {
246- if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) {246+ if sep.Active && (sep.Window == 0 || gap > 0 && sep.Window == focus.ID) && sep.Rect.Overlaps(near) {
247 return247 return
248 }248 }
249 }249 }
@@ -276,9 +276,12 @@ func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
276 return sides276 return sides
277 }277 }
278 278
279-// wantInset is the exact inset of a visible tile: every neighbor side with279+// wantInset is the exact inset of a visible tile: all sides with gaps,
280-// gaps, only the right and bottom (shared-line owner) sides without.280+// only the right and bottom shared-line owner sides without.
281 func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {281 func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
282+ if gap > 0 {
283+ return ports.SideAll
284+ }
282 sides := neighborSides(s, w, gap)285 sides := neighborSides(s, w, gap)
283 if gap == 0 {286 if gap == 0 {
284 sides &= ports.SideRight | ports.SideBottom287 sides &= ports.SideRight | ports.SideBottom
@@ -304,3 +307,60 @@ func TestBorderMoveBackAlone(t *testing.T) {
304 }307 }
305 }308 }
306 }309 }
310+
311+func TestBorderRadiusScene(t *testing.T) {
312+ for _, gap := range []int{0, 8} {
313+ t.Run(map[int]string{0: "flush", 8: "gaps"}[gap], func(t *testing.T) {
314+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = gap; c.Border.Radius = 10 }, left, right)
315+ scenes := r.mapWindow(t, 1)
316+ for _, s := range scenes {
317+ for _, w := range s.Windows {
318+ if w.ID == 1 {
319+ want := 0
320+ if gap > 0 {
321+ want = 10
322+ }
323+ if w.Radius != want || s.Border.Radius != 10 {
324+ t.Fatalf("radius %d, style %d", w.Radius, s.Border.Radius)
325+ }
326+ }
327+ }
328+ }
329+ for _, action := range []struct {
330+ sym string
331+ mods ports.Mods
332+ want int
333+ }{
334+ {"space", ports.ModAlt | ports.ModShift, 10},
335+ {"f", ports.ModAlt | ports.ModShift, 0},
336+ } {
337+ for _, s := range r.key(t, action.sym, action.mods) {
338+ for _, w := range s.Windows {
339+ if w.ID == 1 && w.Radius != action.want {
340+ t.Fatalf("%s radius %d, want %d", action.sym, w.Radius, action.want)
341+ }
342+ }
343+ }
344+ }
345+ })
346+ }
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 +2 -1
@@ -153,13 +153,14 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
153153 Security: c.security,
154154 Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},
155155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
156- Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
156+ Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
157157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
158158 Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),
159159 }
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+ w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius)
163164 if !p.Hidden {
164165 w.Rect = rebase(p.Rect)
165166 }
@@ -153,13 +153,14 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
153 Security: c.security,153 Security: c.security,
154 Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},154 Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},
155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
156- Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},156+ Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
158 Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),158 Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),
159 }159 }
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+ w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius)
163 if !p.Hidden {164 if !p.Hidden {
164 w.Rect = rebase(p.Rect)165 w.Rect = rebase(p.Rect)
165 }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/core_test.go +1 -1
@@ -124,7 +124,7 @@ func owner(t *testing.T, animated bool) {
124124 client <- ports.WindowMapped{ID: 1}
125125 s = l.one(scene(t, scenes))
126126 // A single column fills the usable width.
127- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
127+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != ports.SideAll {
128128 t.Fatal(s)
129129 }
130130 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {
@@ -124,7 +124,7 @@ func owner(t *testing.T, animated bool) {
124 client <- ports.WindowMapped{ID: 1}124 client <- ports.WindowMapped{ID: 1}
125 s = l.one(scene(t, scenes))125 s = l.one(scene(t, scenes))
126 // A single column fills the usable width.126 // A single column fills the usable width.
127- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {127+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != ports.SideAll {
128 t.Fatal(s)128 t.Fatal(s)
129 }129 }
130 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {130 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {
M internal/core/layout.go +5 -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,
@@ -1988,6 +1989,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
19881989 a.Inset = a.Neighbors
19891990 if gap == 0 {
19901991 a.Inset &= ports.SideRight | ports.SideBottom
1992+ } else {
1993+ a.Inset = ports.SideAll
19911994 }
19921995 }
19931996 }
@@ -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,
@@ -1988,6 +1989,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
1988 a.Inset = a.Neighbors1989 a.Inset = a.Neighbors
1989 if gap == 0 {1990 if gap == 0 {
1990 a.Inset &= ports.SideRight | ports.SideBottom1991 a.Inset &= ports.SideRight | ports.SideBottom
1992+ } else {
1993+ a.Inset = ports.SideAll
1991 }1994 }
1992 }1995 }
1993 }1996 }
M internal/core/layout_test.go +1 -1
@@ -151,7 +151,7 @@ func TestStackAndLayout(t *testing.T) {
151151 }
152152 w.FocusWindow(-1)
153153 p := w.Layout()
154- if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop {
154+ if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideAll {
155155 t.Fatal(p)
156156 }
157157 w.ToggleFullscreen()
@@ -151,7 +151,7 @@ func TestStackAndLayout(t *testing.T) {
151 }151 }
152 w.FocusWindow(-1)152 w.FocusWindow(-1)
153 p := w.Layout()153 p := w.Layout()
154- if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop {154+ if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideAll {
155 t.Fatal(p)155 t.Fatal(p)
156 }156 }
157 w.ToggleFullscreen()157 w.ToggleFullscreen()
M internal/core/scene.go +2 -1
@@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
2020 // layout is what is drawn; settled (same indexes) is where the
2121 // windows are going, and alone sizes the configures.
2222 layout, settled := sc.shown, sc.settledLayout
23- scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}
23+ scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}
2424 if !sc.mon.previewing() {
2525 // Tiles stop at the panels; the overview lays out previews over
2626 // the whole output.
@@ -72,6 +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+ sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius)
7576 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
7677 // A scale motion: the content follows the drawn size. A
7778 // card in flight drawn at its size still needs Zoom 1:
@@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
20 // layout is what is drawn; settled (same indexes) is where the20 // layout is what is drawn; settled (same indexes) is where the
21 // windows are going, and alone sizes the configures.21 // windows are going, and alone sizes the configures.
22 layout, settled := sc.shown, sc.settledLayout22 layout, settled := sc.shown, sc.settledLayout
23- scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}23+ scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}
24 if !sc.mon.previewing() {24 if !sc.mon.previewing() {
25 // Tiles stop at the panels; the overview lays out previews over25 // Tiles stop at the panels; the overview lays out previews over
26 // the whole output.26 // the whole output.
@@ -72,6 +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+ sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius)
75 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {76 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
76 // A scale motion: the content follows the drawn size. A77 // A scale motion: the content follows the drawn size. A
77 // 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 +29 -13
@@ -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 its neighbor-facing lines and lights
11-// them 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
@@ -39,7 +40,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
3940 }
4041 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
4142 var id WindowID
42- if p.Floating {
43+ if p.Floating || gap > 0 {
4344 id = p.ID
4445 }
4546 if out == nil {
@@ -58,6 +59,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
5859 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
5960 continue
6061 }
62+ if gap > 0 {
63+ for _, s := range sides {
64+ add(p, strip(p.Rect, s, 0, width), p.Focused && lit)
65+ }
66+ continue
67+ }
6168 // Tiles sharing a line: Neighbors only counts tiles visible in
6269 // the usable area, not those under a panel.
6370 if !p.Floating && p.Neighbors != 0 {
@@ -91,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
9198 continue
9299 }
93100 line = closeCorners(line, *f, s, width)
94- // Two tiles share one line only without gaps.
95- if tiles == 2 && gap == 0 {
101+ // With gaps the loop above continued, so two tiles share one line.
102+ if tiles == 2 {
96103 line = half(line, s)
97104 }
98105 add(f, line, true)
@@ -152,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect {
152159 }
153160 return r
154161 }
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 its neighbor-facing lines and lights10+// lights its own half of the shared line (left/top first). With gaps, each
11-// them 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
@@ -39,7 +40,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
39 }40 }
40 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}41 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
41 var id WindowID42 var id WindowID
42- if p.Floating {43+ if p.Floating || gap > 0 {
43 id = p.ID44 id = p.ID
44 }45 }
45 if out == nil {46 if out == nil {
@@ -58,6 +59,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
58 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {59 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
59 continue60 continue
60 }61 }
62+ if gap > 0 {
63+ for _, s := range sides {
64+ add(p, strip(p.Rect, s, 0, width), p.Focused && lit)
65+ }
66+ continue
67+ }
61 // Tiles sharing a line: Neighbors only counts tiles visible in68 // Tiles sharing a line: Neighbors only counts tiles visible in
62 // the usable area, not those under a panel.69 // the usable area, not those under a panel.
63 if !p.Floating && p.Neighbors != 0 {70 if !p.Floating && p.Neighbors != 0 {
@@ -91,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
91 continue98 continue
92 }99 }
93 line = closeCorners(line, *f, s, width)100 line = closeCorners(line, *f, s, width)
94- // Two tiles share one line only without gaps.101+ // With gaps the loop above continued, so two tiles share one line.
95- if tiles == 2 && gap == 0 {102+ if tiles == 2 {
96 line = half(line, s)103 line = half(line, s)
97 }104 }
98 add(f, line, true)105 add(f, line, true)
@@ -152,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect {
152 }159 }
153 return r160 return r
154 }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/core/separators_test.go +2 -2
@@ -201,12 +201,12 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
201201 }
202202 }
203203
204-// With gaps each tile has its own line: the focused one lights it whole.
204+// With gaps each tile owns four full border sides, including outer edges.
205205 func TestSeparatorsGaps(t *testing.T) {
206206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
207207 setVisibleNeighbors(ps, 4, bigOutput)
208208 seps := separators(ps, 2, 4, whole(bigOutput), true)
209- for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {
209+ for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {0, 8, "lit"}, {8, 0, "lit"}, {8, 15, "lit"}, {20, 8, "gray"}, {35, 8, "gray"}, {28, 0, "gray"}, {28, 15, "gray"}, {17, 8, ""}} {
210210 if got := colorAt(seps, c.x, c.y); got != c.want {
211211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
212212 }
@@ -201,12 +201,12 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
201 }201 }
202 }202 }
203 203
204-// With gaps each tile has its own line: the focused one lights it whole.204+// With gaps each tile owns four full border sides, including outer edges.
205 func TestSeparatorsGaps(t *testing.T) {205 func TestSeparatorsGaps(t *testing.T) {
206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
207 setVisibleNeighbors(ps, 4, bigOutput)207 setVisibleNeighbors(ps, 4, bigOutput)
208 seps := separators(ps, 2, 4, whole(bigOutput), true)208 seps := separators(ps, 2, 4, whole(bigOutput), true)
209- for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {209+ for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {0, 8, "lit"}, {8, 0, "lit"}, {8, 15, "lit"}, {20, 8, "gray"}, {35, 8, "gray"}, {28, 0, "gray"}, {28, 15, "gray"}, {17, 8, ""}} {
210 if got := colorAt(seps, c.x, c.y); got != c.want {210 if got := colorAt(seps, c.x, c.y); got != c.want {
211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
212 }212 }
M internal/ports/config.go +4 -1
@@ -121,8 +121,11 @@ 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 {
128+ Radius int
126129 Width int
127130 Active string
128131 Inactive string
@@ -121,8 +121,11 @@ 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 {
128+ Radius int
126 Width int129 Width int
127 Active string130 Active string
128 Inactive string131 Inactive string
M internal/ports/scene.go +9 -2
@@ -168,15 +168,19 @@ 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.
176+ Radius int
173177 Width int
174178 Active, Inactive string
175179 }
176180
177181 // Separator is a line between windows, in logical pixels. Active lines
178182 // mark the focused window and use Border.Active, others Border.Inactive.
179-// Window is the float whose border it is; 0 for lines between tiles.
183+// Window owns the border (floats and tiles with gaps); 0 for shared tile lines.
180184 type Separator struct {
181185 Rect Rect
182186 Active bool
@@ -203,6 +207,9 @@ type FocusFlash struct {
203207
204208 // SceneWindow carries core → renderer window placement.
205209 type SceneWindow struct {
210+ // Radius rounds the window corners in logical pixels; zero is square.
211+ // Core sets Border.Radius here for floats and tiles with gaps.
212+ Radius int
206213 ID WindowID
207214 Rect Rect
208215 Focused, Fullscreen, Hidden bool
@@ -168,15 +168,19 @@ 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.
176+ Radius int
173 Width int177 Width int
174 Active, Inactive string178 Active, Inactive string
175 }179 }
176 180
177 // Separator is a line between windows, in logical pixels. Active lines181 // Separator is a line between windows, in logical pixels. Active lines
178 // mark the focused window and use Border.Active, others Border.Inactive.182 // mark the focused window and use Border.Active, others Border.Inactive.
179-// Window is the float whose border it is; 0 for lines between tiles.183+// Window owns the border (floats and tiles with gaps); 0 for shared tile lines.
180 type Separator struct {184 type Separator struct {
181 Rect Rect185 Rect Rect
182 Active bool186 Active bool
@@ -203,6 +207,9 @@ type FocusFlash struct {
203 207
204 // SceneWindow carries core → renderer window placement.208 // SceneWindow carries core → renderer window placement.
205 type SceneWindow struct {209 type SceneWindow struct {
210+ // Radius rounds the window corners in logical pixels; zero is square.
211+ // Core sets Border.Radius here for floats and tiles with gaps.
212+ Radius int
206 ID WindowID213 ID WindowID
207 Rect Rect214 Rect Rect
208 Focused, Fullscreen, Hidden bool215 Focused, Fullscreen, Hidden bool

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