detached fd770f2

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

With layout.gaps = 0, borders stay tmux-style lines between tiles. With gaps, every tile owns a full border colored by focus. New border.radius rounds the borders and content of floats and gapped tiles in the compose shader (SDF coverage); rounded windows are never opaque occluders, scanned out or put on overlay planes.
commit
fd770f2d7a096cf030cd06a9dc6f2f8f7018159d
author
brice · · 2026-10-21 05:48 UTC
committer
brice · · 2026-10-21 05:49 UTC
parent
fe39373
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
36 files · +322 -33
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, non-negative; 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 |
@@ -384,3 +385,5 @@ small or low-contrast text.
384385 ### Debugging hint mode
385386
386387 With `hint.enable = on` and `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (OCR links green, accessibility targets magenta, OCR window crops grey); and `<time>-<output>.json`, the targets (with their `source`, `atspi` or `ocr`) and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use.
388+
389+With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square.
@@ -29,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, non-negative; 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 |
@@ -384,3 +385,5 @@ small or low-contrast text.
384 ### Debugging hint mode385 ### Debugging hint mode
385 386
386 With `hint.enable = on` and `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (OCR links green, accessibility targets magenta, OCR window crops grey); and `<time>-<output>.json`, the targets (with their `source`, `atspi` or `ocr`) and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use.387 With `hint.enable = on` and `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (OCR links green, accessibility targets magenta, OCR window crops grey); and `<time>-<output>.json`, the targets (with their `source`, `atspi` or `ocr`) and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use.
388+
389+With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square.
M 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 {
779+ return fmt.Errorf("must be a non-negative integer")
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 {
779+ return fmt.Errorf("must be a non-negative integer")
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"} {
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"} {
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"} {
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"} {
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 +58 -1
@@ -11,6 +11,7 @@ import (
1111 // sceneWalk turns one scene into quads in paint order, copying new wl_shm
1212 // content into GPU buffers on the way. It lives for one frame.
1313 type sceneWalk struct {
14+ rounded bool
1415 r *Renderer
1516 s ports.Scene
1617 contents map[ports.WindowID]ports.SurfaceContent
@@ -256,6 +257,36 @@ func (w *sceneWalk) windows() {
256257 content := w.contents[win.ID]
257258 // Damage counts only what is drawn: never the panel under a tile.
258259 w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds))
260+ w.rounded = win.Radius > 0
261+ if win.Radius > 0 {
262+ radius := float32(math.Round(float64(win.Radius) * w.scale))
263+ innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0)
264+ outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H)
265+ col := ""
266+ for _, sep := range w.s.Separators {
267+ if sep.Window == win.ID {
268+ if col == "" {
269+ col = w.s.Border.Inactive
270+ }
271+ if sep.Active {
272+ col = w.s.Border.Active
273+ break
274+ }
275+ }
276+ }
277+ if col != "" && b > 0 {
278+ n := len(w.draws)
279+ w.fill(outer, parseColor(col))
280+ if len(w.draws) > n {
281+ dr := &w.draws[n]
282+ roundedDraw(dr, outer, radius)
283+ dr.pc.misc[1] |= flagRing
284+ dr.roundInner = roundRect(body.Inset(1))
285+ dr.radii[1] = math.Float32bits(max(innerRadius-1, 0))
286+ }
287+ }
288+ }
289+ start := len(w.draws)
259290 // Until its first buffer, a window shows the background: no flash.
260291 w.fill(body, parseColor(w.s.Background))
261292 if !content.Empty() {
@@ -269,13 +300,26 @@ func (w *sceneWalk) windows() {
269300 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
270301 w.zoom = 1
271302 }
272- if win.Floating {
303+ if win.Radius > 0 {
304+ radius := float32(math.Round(float64(win.Radius) * w.scale))
305+ innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0)
306+ for j := start; j < len(w.draws); j++ {
307+ roundedDraw(&w.draws[j], body, innerRadius)
308+ }
309+ } else {
273310 w.separators(win.ID)
274311 }
312+ dimStart := len(w.draws)
275313 if win.Dim > 0 {
276314 // A stashed window peeking in: dimmed with its border.
277315 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
278316 }
317+ if win.Radius > 0 {
318+ for j := dimStart; j < len(w.draws); j++ {
319+ roundedDraw(&w.draws[j], w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), float32(math.Round(float64(win.Radius)*w.scale)))
320+ }
321+ }
322+ w.rounded = false
279323 w.alpha, w.bounds = 1, windowBounds
280324 }
281325 if !tileLines {
@@ -354,6 +398,9 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
354398 // dmabuf that fails to import (opaqueChildren pre-imports it) or an
355399 // unreadable shm buffer.
356400 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {
401+ if w.rounded {
402+ return nil
403+ }
357404 covers := w.r.scratchCovers[:0]
358405 for i := range content.Children {
359406 ch := &content.Children[i]
@@ -640,3 +687,13 @@ func parseColor(s string) [3]uint8 {
640687 }
641688 return c
642689 }
690+
691+// Rounded bounds stay independent of scissor/damage clipping.
692+func roundRect(r image.Rectangle) [4]float32 {
693+ return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)}
694+}
695+func roundedDraw(d *draw, rect image.Rectangle, radius float32) {
696+ d.pc.misc[1] |= flagRounded
697+ d.round = roundRect(rect)
698+ d.radii[0] = math.Float32bits(radius)
699+}
@@ -11,6 +11,7 @@ import (
11 // sceneWalk turns one scene into quads in paint order, copying new wl_shm11 // sceneWalk turns one scene into quads in paint order, copying new wl_shm
12 // content into GPU buffers on the way. It lives for one frame.12 // content into GPU buffers on the way. It lives for one frame.
13 type sceneWalk struct {13 type sceneWalk struct {
14+ rounded bool
14 r *Renderer15 r *Renderer
15 s ports.Scene16 s ports.Scene
16 contents map[ports.WindowID]ports.SurfaceContent17 contents map[ports.WindowID]ports.SurfaceContent
@@ -256,6 +257,36 @@ func (w *sceneWalk) windows() {
256 content := w.contents[win.ID]257 content := w.contents[win.ID]
257 // Damage counts only what is drawn: never the panel under a tile.258 // 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))259 w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds))
260+ w.rounded = win.Radius > 0
261+ if win.Radius > 0 {
262+ radius := float32(math.Round(float64(win.Radius) * w.scale))
263+ innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0)
264+ outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H)
265+ col := ""
266+ for _, sep := range w.s.Separators {
267+ if sep.Window == win.ID {
268+ if col == "" {
269+ col = w.s.Border.Inactive
270+ }
271+ if sep.Active {
272+ col = w.s.Border.Active
273+ break
274+ }
275+ }
276+ }
277+ if col != "" && b > 0 {
278+ n := len(w.draws)
279+ w.fill(outer, parseColor(col))
280+ if len(w.draws) > n {
281+ dr := &w.draws[n]
282+ roundedDraw(dr, outer, radius)
283+ dr.pc.misc[1] |= flagRing
284+ dr.roundInner = roundRect(body.Inset(1))
285+ dr.radii[1] = math.Float32bits(max(innerRadius-1, 0))
286+ }
287+ }
288+ }
289+ start := len(w.draws)
259 // Until its first buffer, a window shows the background: no flash.290 // Until its first buffer, a window shows the background: no flash.
260 w.fill(body, parseColor(w.s.Background))291 w.fill(body, parseColor(w.s.Background))
261 if !content.Empty() {292 if !content.Empty() {
@@ -269,13 +300,26 @@ func (w *sceneWalk) windows() {
269 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)300 w.place(win.ID, &content, c.X, c.Y, c.W, c.H)
270 w.zoom = 1301 w.zoom = 1
271 }302 }
272- if win.Floating {303+ if win.Radius > 0 {
304+ radius := float32(math.Round(float64(win.Radius) * w.scale))
305+ innerRadius := max(radius-float32(math.Round(float64(b)*w.scale)), 0)
306+ for j := start; j < len(w.draws); j++ {
307+ roundedDraw(&w.draws[j], body, innerRadius)
308+ }
309+ } else {
273 w.separators(win.ID)310 w.separators(win.ID)
274 }311 }
312+ dimStart := len(w.draws)
275 if win.Dim > 0 {313 if win.Dim > 0 {
276 // A stashed window peeking in: dimmed with its border.314 // 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)315 w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim)
278 }316 }
317+ if win.Radius > 0 {
318+ for j := dimStart; j < len(w.draws); j++ {
319+ roundedDraw(&w.draws[j], w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), float32(math.Round(float64(win.Radius)*w.scale)))
320+ }
321+ }
322+ w.rounded = false
279 w.alpha, w.bounds = 1, windowBounds323 w.alpha, w.bounds = 1, windowBounds
280 }324 }
281 if !tileLines {325 if !tileLines {
@@ -354,6 +398,9 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
354 // dmabuf that fails to import (opaqueChildren pre-imports it) or an398 // dmabuf that fails to import (opaqueChildren pre-imports it) or an
355 // unreadable shm buffer.399 // unreadable shm buffer.
356 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {400 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle {
401+ if w.rounded {
402+ return nil
403+ }
357 covers := w.r.scratchCovers[:0]404 covers := w.r.scratchCovers[:0]
358 for i := range content.Children {405 for i := range content.Children {
359 ch := &content.Children[i]406 ch := &content.Children[i]
@@ -640,3 +687,13 @@ func parseColor(s string) [3]uint8 {
640 }687 }
641 return c688 return c
642 }689 }
690+
691+// Rounded bounds stay independent of scissor/damage clipping.
692+func roundRect(r image.Rectangle) [4]float32 {
693+ return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)}
694+}
695+func roundedDraw(d *draw, rect image.Rectangle, radius float32) {
696+ d.pc.misc[1] |= flagRounded
697+ d.round = roundRect(rect)
698+ d.radii[0] = math.Float32bits(radius)
699+}
M internal/adapters/vulkan/scene_fade_test.go +26 -0
@@ -141,3 +141,29 @@ 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+}
@@ -141,3 +141,29 @@ 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+}
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 +16 -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
@@ -121,6 +124,15 @@ vec4 sampleBuffer(vec2 src, bool exact) {
121124 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);
122125 }
123126
127+float coverage(vec4 rect, float radius) {
128+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
129+ radius = min(radius, min(halfSize.x, halfSize.y));
130+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
131+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
132+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
133+ return clamp(0.5 - sd, 0.0, 1.0);
134+}
135+
124136 void main() {
125137 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
126138 bool exact = (d.misc.y & flagExact) != 0u;
@@ -150,5 +162,9 @@ void main() {
150162 // The surface's alpha modifier; premultiplied, it scales all four.
151163 c *= d.color.a;
152164 }
165+ if ((d.misc.y & 128u) != 0u) {
166+ c *= coverage(d.round, d.radii.x);
167+ if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
168+ }
153169 color = c;
154170 }
@@ -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
@@ -121,6 +124,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);124 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 }125 }
123 126
127+float coverage(vec4 rect, float radius) {
128+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
129+ radius = min(radius, min(halfSize.x, halfSize.y));
130+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
131+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
132+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
133+ return clamp(0.5 - sd, 0.0, 1.0);
134+}
135+
124 void main() {136 void main() {
125 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;137 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;138 bool exact = (d.misc.y & flagExact) != 0u;
@@ -150,5 +162,9 @@ void main() {
150 // The surface's alpha modifier; premultiplied, it scales all four.162 // The surface's alpha modifier; premultiplied, it scales all four.
151 c *= d.color.a;163 c *= d.color.a;
152 }164 }
165+ if ((d.misc.y & 128u) != 0u) {
166+ c *= coverage(d.round, d.radii.x);
167+ if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
168+ }
153 color = c;169 color = c;
154 }170 }
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 +16 -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
@@ -120,6 +123,15 @@ vec4 sampleBuffer(vec2 src, bool exact) {
120123 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);
121124 }
122125
126+float coverage(vec4 rect, float radius) {
127+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
128+ radius = min(radius, min(halfSize.x, halfSize.y));
129+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
130+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
131+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
132+ return clamp(0.5 - sd, 0.0, 1.0);
133+}
134+
123135 void main() {
124136 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;
125137 bool exact = (d.misc.y & flagExact) != 0u;
@@ -149,5 +161,9 @@ void main() {
149161 // The surface's alpha modifier; premultiplied, it scales all four.
150162 c *= d.color.a;
151163 }
164+ if ((d.misc.y & 128u) != 0u) {
165+ c *= coverage(d.round, d.radii.x);
166+ if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
167+ }
152168 color = c;
153169 }
@@ -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
@@ -120,6 +123,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);123 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 }124 }
122 125
126+float coverage(vec4 rect, float radius) {
127+ vec2 halfSize = (rect.zw - rect.xy) * 0.5;
128+ radius = min(radius, min(halfSize.x, halfSize.y));
129+ vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius;
130+ if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0;
131+ float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
132+ return clamp(0.5 - sd, 0.0, 1.0);
133+}
134+
123 void main() {135 void main() {
124 vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy;136 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;137 bool exact = (d.misc.y & flagExact) != 0u;
@@ -149,5 +161,9 @@ void main() {
149 // The surface's alpha modifier; premultiplied, it scales all four.161 // The surface's alpha modifier; premultiplied, it scales all four.
150 c *= d.color.a;162 c *= d.color.a;
151 }163 }
164+ if ((d.misc.y & 128u) != 0u) {
165+ c *= coverage(d.round, d.radii.x);
166+ if ((d.misc.y & 256u) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y);
167+ }
152 color = c;168 color = c;
153 }169 }
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 +46 -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,41 @@ 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+}
@@ -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,41 @@ 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+}
M internal/core/capture_workspace.go +4 -1
@@ -153,13 +153,16 @@ 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+ if !p.Fullscreen && (p.Floating || cw.ws.gap() > 0) {
164+ w.Radius = c.cfg.Border.Radius
165+ }
163166 if !p.Hidden {
164167 w.Rect = rebase(p.Rect)
165168 }
@@ -153,13 +153,16 @@ 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+ if !p.Fullscreen && (p.Floating || cw.ws.gap() > 0) {
164+ w.Radius = c.cfg.Border.Radius
165+ }
163 if !p.Hidden {166 if !p.Hidden {
164 w.Rect = rebase(p.Rect)167 w.Rect = rebase(p.Rect)
165 }168 }
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 +2 -0
@@ -1988,6 +1988,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
19881988 a.Inset = a.Neighbors
19891989 if gap == 0 {
19901990 a.Inset &= ports.SideRight | ports.SideBottom
1991+ } else {
1992+ a.Inset = ports.SideAll
19911993 }
19921994 }
19931995 }
@@ -1988,6 +1988,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
1988 a.Inset = a.Neighbors1988 a.Inset = a.Neighbors
1989 if gap == 0 {1989 if gap == 0 {
1990 a.Inset &= ports.SideRight | ports.SideBottom1990 a.Inset &= ports.SideRight | ports.SideBottom
1991+ } else {
1992+ a.Inset = ports.SideAll
1991 }1993 }
1992 }1994 }
1993 }1995 }
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 +4 -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,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
7272 // Only the focused output has an activated window.
7373 focused := p.Focused && i == c.focusScreen
7474 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
75+ if !p.Fullscreen && (p.Floating || sc.mon.Current().gap() > 0) {
76+ sw.Radius = c.cfg.Border.Radius
77+ }
7578 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
7679 // A scale motion: the content follows the drawn size. A
7780 // 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,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
72 // Only the focused output has an activated window.72 // Only the focused output has an activated window.
73 focused := p.Focused && i == c.focusScreen73 focused := p.Focused && i == c.focusScreen
74 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}74 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
75+ if !p.Fullscreen && (p.Floating || sc.mon.Current().gap() > 0) {
76+ sw.Radius = c.cfg.Border.Radius
77+ }
75 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {78 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
76 // A scale motion: the content follows the drawn size. A79 // A scale motion: the content follows the drawn size. A
77 // card in flight drawn at its size still needs Zoom 1:80 // card in flight drawn at its size still needs Zoom 1:
M internal/core/separators.go +9 -3
@@ -7,8 +7,8 @@ import "github.com/bnema/neferwl/internal/ports"
77 // owned (and inset) by the left/top tile; focus lights only the adjacent
88 // sides of the focused tile, including corners. With exactly two visible
99 // 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
10+// first). With gaps, each tile owns a full four-sided border and lights
11+// it whole. Hidden, scrolled-off, under-panel, other-workspace and
1212 // other-output tiles do not count, nor do tiles that share no line. Lines
1313 // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles:
1414 // each gets its own full border, drawn with that window (Separator.Window),
@@ -39,7 +39,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
3939 }
4040 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
4141 var id WindowID
42- if p.Floating {
42+ if p.Floating || gap > 0 {
4343 id = p.ID
4444 }
4545 if out == nil {
@@ -58,6 +58,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
5858 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
5959 continue
6060 }
61+ if gap > 0 {
62+ for _, s := range sides {
63+ add(p, strip(p.Rect, s, 0, width), p.Focused && lit)
64+ }
65+ continue
66+ }
6167 // Tiles sharing a line: Neighbors only counts tiles visible in
6268 // the usable area, not those under a panel.
6369 if !p.Floating && p.Neighbors != 0 {
@@ -7,8 +7,8 @@ import "github.com/bnema/neferwl/internal/ports"
7 // owned (and inset) by the left/top tile; focus lights only the adjacent7 // owned (and inset) by the left/top tile; focus lights only the adjacent
8 // sides of the focused tile, including corners. With exactly two visible8 // 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/top9 // 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 lights10+// first). With gaps, each tile owns a full four-sided border and lights
11-// them whole. Hidden, scrolled-off, under-panel, other-workspace and11+// it whole. Hidden, scrolled-off, under-panel, other-workspace and
12 // other-output tiles do not count, nor do tiles that share no line. Lines12 // 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: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),14 // each gets its own full border, drawn with that window (Separator.Window),
@@ -39,7 +39,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
39 }39 }
40 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}40 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
41 var id WindowID41 var id WindowID
42- if p.Floating {42+ if p.Floating || gap > 0 {
43 id = p.ID43 id = p.ID
44 }44 }
45 if out == nil {45 if out == nil {
@@ -58,6 +58,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)) {58 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
59 continue59 continue
60 }60 }
61+ if gap > 0 {
62+ for _, s := range sides {
63+ add(p, strip(p.Rect, s, 0, width), p.Focused && lit)
64+ }
65+ continue
66+ }
61 // Tiles sharing a line: Neighbors only counts tiles visible in67 // Tiles sharing a line: Neighbors only counts tiles visible in
62 // the usable area, not those under a panel.68 // the usable area, not those under a panel.
63 if !p.Floating && p.Neighbors != 0 {69 if !p.Floating && p.Neighbors != 0 {
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 +1 -0
@@ -123,6 +123,7 @@ type Config struct {
123123 }
124124 // Border is drawn inside the window edge; Width 0 disables it.
125125 Border struct {
126+ Radius int
126127 Width int
127128 Active string
128129 Inactive string
@@ -123,6 +123,7 @@ type Config struct {
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.
125 Border struct {125 Border struct {
126+ Radius int
126 Width int127 Width int
127 Active string128 Active string
128 Inactive string129 Inactive string
M internal/ports/scene.go +4 -1
@@ -170,13 +170,14 @@ func (s Scene) TileArea() (r Rect, ok bool) {
170170 // Border carries the window border style; colors are #rrggbb, "" skips
171171 // drawing. Width is in logical pixels.
172172 type Border struct {
173+ Radius int
173174 Width int
174175 Active, Inactive string
175176 }
176177
177178 // Separator is a line between windows, in logical pixels. Active lines
178179 // 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.
180+// Window owns the border (floats and tiles with gaps); 0 for shared tile lines.
180181 type Separator struct {
181182 Rect Rect
182183 Active bool
@@ -203,6 +204,8 @@ type FocusFlash struct {
203204
204205 // SceneWindow carries core → renderer window placement.
205206 type SceneWindow struct {
207+ // Radius rounds the window corners in logical pixels; zero is square.
208+ Radius int
206209 ID WindowID
207210 Rect Rect
208211 Focused, Fullscreen, Hidden bool
@@ -170,13 +170,14 @@ func (s Scene) TileArea() (r Rect, ok bool) {
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 is in logical pixels.
172 type Border struct {172 type Border struct {
173+ Radius int
173 Width int174 Width int
174 Active, Inactive string175 Active, Inactive string
175 }176 }
176 177
177 // Separator is a line between windows, in logical pixels. Active lines178 // Separator is a line between windows, in logical pixels. Active lines
178 // mark the focused window and use Border.Active, others Border.Inactive.179 // 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.180+// Window owns the border (floats and tiles with gaps); 0 for shared tile lines.
180 type Separator struct {181 type Separator struct {
181 Rect Rect182 Rect Rect
182 Active bool183 Active bool
@@ -203,6 +204,8 @@ type FocusFlash struct {
203 204
204 // SceneWindow carries core → renderer window placement.205 // SceneWindow carries core → renderer window placement.
205 type SceneWindow struct {206 type SceneWindow struct {
207+ // Radius rounds the window corners in logical pixels; zero is square.
208+ Radius int
206 ID WindowID209 ID WindowID
207 Rect Rect210 Rect Rect
208 Focused, Fullscreen, Hidden bool211 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