detached 608a5b9

#11 feat(render): full borders with gaps and rounded corners

state
merged · 608a5b9 · fast-forward
branch
feat/gap-borders → main
opened
bnema ·
updated

Summary

  • layout.gaps = 0: tmux-style lines between tiles, unchanged.
  • layout.gaps > 0: every tile owns a full border (border.width, border.active / border.inactive), even alone.
  • New border.radius (0–64, default 0, reloadable): rounds floats always and tiles with gaps; fullscreen, popups and layers stay square.
  • Rounded corners are done in the compose shader with an SDF coverage mask (flagRounded / flagRing); square windows pay one branch. The border is one ring draw.
  • Rounded windows are never opaque occluders, direct-scanned out or put on overlay planes.
  • README no longer rules out rounded corners; docs/config.md and examples/config list border.radius.

Checks

make check, make race, make mocks-check, staticcheck, make spv-check; headless e2e with gaps 8 / radius 10 (borders, AA corner, overview) and gaps 0 (tmux line unchanged).

Not verified

Real DRM direct scanout on hardware.

CommitSubjectAuthorDateSignatureStatus
608a5b9refactor(render): tidy gap borders and rounded corner handlingbriceGPG Verified
fd770f2feat(render): add full borders with gaps and rounded cornersbriceGPG Verified

Checks 2

39 files · +452 -52
1/2
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
M internal/adapters/vulkan/shaders/compose_single.frag.spv +0 -0 binary file: no text diff
M internal/core/border_invariants_test.go +65 -5
M internal/core/capture_window.go +1 -1
M internal/core/capture_workspace.go +2 -1
M internal/core/capture_workspace_test.go +31 -0
M internal/core/core_test.go +1 -1
M internal/core/layout.go +5 -2
M internal/core/layout_test.go +1 -1
M internal/core/scene.go +2 -1
M internal/core/separators.go +29 -13
M internal/core/separators_test.go +2 -2
M internal/ports/config.go +4 -1
M internal/ports/scene.go +9 -2

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close