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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 27 | 27 | ||
| 28 | 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 | 32 | ## Install | |
| 33 | 33 | ||
| @@ -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 | ## Install | 32 | ## Install | ||
| 33 | 33 | ||||
M docs/config.md +3 -0
| @@ -29,6 +29,7 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 29 | 29 | | `startup` | none | Commands run at session start, separated by `;` | | |
| 30 | 30 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | |
| 31 | 31 | | `background` | `#111111` | Background color | | |
| 32 | + | | `border.radius` | `0` | Corner radius in logical pixels, non-negative; floats always, tiles only with gaps | | |
| 32 | 33 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | |
| 33 | 34 | | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows | | |
| 34 | 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 | 385 | ### Debugging hint mode | |
| 385 | 386 | ||
| 386 | 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. | |
| @@ -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 mode | 385 | ### 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 { | |||
| 773 | 773 | return err | |
| 774 | 774 | } | |
| 775 | 775 | 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 | 782 | case "border.width": | |
| 777 | 783 | n, err := strconv.Atoi(v) | |
| 778 | 784 | if err != nil || n < 0 || n > 32 { | |
| @@ -773,6 +773,12 @@ func set(c *ports.Config, key, v string) error { | |||||
| 773 | return err | 773 | return err | ||
| 774 | } | 774 | } | ||
| 775 | c.Stash.SpawnTerminal = b | 775 | 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) { | |||
| 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 | + | } | |
| @@ -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) { | |||
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | 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 | 97 | select { | |
| 98 | 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 | 100 | t.Fatalf("partial reload: %+v", c.Config) | |
| 101 | 101 | } | |
| 102 | 102 | case <-time.After(time.Second): | |
| 103 | 103 | t.Fatal("no partial reload") | |
| 104 | 104 | } | |
| 105 | 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 | 107 | select { | |
| 108 | 108 | case <-out: | |
| 109 | 109 | 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 | |||
| 105 | 105 | // window (a peeking stashed one) needs the veil drawn over it, a | |
| 106 | 106 | // fading one its translucency; an overview preview is drawn smaller | |
| 107 | 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 | 109 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | |
| 110 | 110 | pick, pickAt, mode = w, i, m | |
| 111 | 111 | 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, a | 105 | // window (a peeking stashed one) needs the veil drawn over it, a | ||
| 106 | // fading one its translucency; an overview preview is drawn smaller | 106 | // 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, m | 110 | pick, pickAt, mode = w, i, m | ||
| 111 | continue | 111 | continue | ||
M internal/adapters/drm/overlay_test.go +9 -0
| @@ -401,3 +401,12 @@ func TestOverlayCarriesAcquireFence(t *testing.T) { | |||
| 401 | 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 | + | } | |
| @@ -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 | |||
| 133 | 133 | // Its veil is composed over it: the plane would show it bright. | |
| 134 | 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 | 137 | // A fading or zoomed window is composed: the plane would show it | |
| 138 | 138 | // whole and opaque. | |
| 139 | 139 | 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 it | 137 | // 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) { | |||
| 27 | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | |
| 28 | 28 | {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"}, | |
| 29 | 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 | 31 | {"window fade", func(s *ports.Scene) { s.Windows[0].Fade = 0.5 }, nil, "window_fade"}, | |
| 31 | 32 | {"window preview", func(s *ports.Scene) { s.Windows[0].Preview = 0.9 }, nil, "window_fade"}, | |
| 32 | 33 | {"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 ( | |||
| 52 | 52 | flagP010 = 32 | |
| 53 | 53 | // flagSmooth supersamples a buffer shrunk under half size (overview | |
| 54 | 54 | // previews): four bilinear samples per pixel instead of one. | |
| 55 | - | flagSmooth = 64 | |
| 55 | + | flagSmooth = 64 | |
| 56 | + | flagRounded = 128 | |
| 57 | + | flagRing = 256 | |
| 56 | 58 | ) | |
| 57 | 59 | ||
| 58 | 60 | // drawParameters is the parameter portion of the shaders' std430 Draw. | |
| @@ -68,9 +70,11 @@ type drawParameters struct { | |||
| 68 | 70 | ||
| 69 | 71 | // draw is one quad of a frame. | |
| 70 | 72 | 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 | |
| 74 | 78 | // acquire is the content's explicit-sync fence (nil: implicit). | |
| 75 | 79 | acquire *os.File | |
| 76 | 80 | } | |
| @@ -52,7 +52,9 @@ const ( | |||||
| 52 | flagP010 = 32 | 52 | flagP010 = 32 | ||
| 53 | // flagSmooth supersamples a buffer shrunk under half size (overview | 53 | // 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 = 64 | 55 | + | 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 drawParameters | 73 | + | round, roundInner [4]float32 |
| 72 | - | shm *shmCopy | 74 | + | radii [2]uint32 |
| 73 | - | im *imported // sampled client dmabuf, for ownership barriers | 75 | + | 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.File | 79 | acquire *os.File | ||
| 76 | } | 80 | } | ||
M internal/adapters/vulkan/compose_batch.go +5 -4
| @@ -11,10 +11,11 @@ import ( | |||
| 11 | 11 | // Bound descriptor-table work and memory even on devices with huge limits. | |
| 12 | 12 | 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 | 15 | type instanceDraw struct { | |
| 16 | - | pc drawParameters | |
| 17 | - | resource [4]uint32 | |
| 16 | + | pc drawParameters | |
| 17 | + | resource [4]uint32 | |
| 18 | + | round, roundInner [4]float32 | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | // 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 | 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 | 69 | return true | |
| 69 | 70 | } | |
| 70 | 71 | ||
| @@ -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 = 64 | 12 | 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 drawParameters | 16 | + | pc drawParameters |
| 17 | - | resource [4]uint32 | 17 | + | resource [4]uint32 |
| 18 | + | round, roundInner [4]float32 | |||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | // resourceKey retains the Go identity as well as the Vulkan handles: a | 21 | // 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 true | 69 | return true | ||
| 69 | } | 70 | } | ||
| 70 | 71 | ||||
M internal/adapters/vulkan/compose_batch_test.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 16 | t.Fatal("draw layout differs from compose.vert std430 layout") | |
| 17 | 17 | } | |
| 18 | 18 | } | |
| @@ -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) { | |||
| 28 | 28 | // sceneDelta compares windows by struct equality, so a new field is covered | |
| 29 | 29 | // as long as it only changes where or how that window draws (inside Rect). | |
| 30 | 30 | func TestSceneWindowFieldCount(t *testing.T) { | |
| 31 | - | const fields = 14 | |
| 31 | + | const fields = 15 | |
| 32 | 32 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))), | |
| 33 | 33 | "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count") | |
| 34 | 34 | } | |
| @@ -28,7 +28,7 @@ func TestSceneFieldCount(t *testing.T) { | |||||
| 28 | // sceneDelta compares windows by struct equality, so a new field is covered | 28 | // 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 = 14 | 31 | + | 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) { | |||
| 43 | 43 | pc := &ds[i].pc | |
| 44 | 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 | 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 | 52 | if pc.misc[0] == modeSolid { | |
| 47 | 53 | continue | |
| 48 | 54 | } | |
| @@ -43,6 +43,12 @@ func (r *Renderer) orient(ds []draw, t ports.BufferTransform) { | |||||
| 43 | pc := &ds[i].pc | 43 | 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 | continue | 53 | 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 ( | |||
| 11 | 11 | // sceneWalk turns one scene into quads in paint order, copying new wl_shm | |
| 12 | 12 | // content into GPU buffers on the way. It lives for one frame. | |
| 13 | 13 | type sceneWalk struct { | |
| 14 | + | rounded bool | |
| 14 | 15 | r *Renderer | |
| 15 | 16 | s ports.Scene | |
| 16 | 17 | contents map[ports.WindowID]ports.SurfaceContent | |
| @@ -256,6 +257,36 @@ func (w *sceneWalk) windows() { | |||
| 256 | 257 | content := w.contents[win.ID] | |
| 257 | 258 | // Damage counts only what is drawn: never the panel under a tile. | |
| 258 | 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 | 290 | // Until its first buffer, a window shows the background: no flash. | |
| 260 | 291 | w.fill(body, parseColor(w.s.Background)) | |
| 261 | 292 | if !content.Empty() { | |
| @@ -269,13 +300,26 @@ func (w *sceneWalk) windows() { | |||
| 269 | 300 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | |
| 270 | 301 | 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 | 310 | w.separators(win.ID) | |
| 274 | 311 | } | |
| 312 | + | dimStart := len(w.draws) | |
| 275 | 313 | if win.Dim > 0 { | |
| 276 | 314 | // A stashed window peeking in: dimmed with its border. | |
| 277 | 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 | 323 | w.alpha, w.bounds = 1, windowBounds | |
| 280 | 324 | } | |
| 281 | 325 | if !tileLines { | |
| @@ -354,6 +398,9 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||
| 354 | 398 | // dmabuf that fails to import (opaqueChildren pre-imports it) or an | |
| 355 | 399 | // unreadable shm buffer. | |
| 356 | 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 | 404 | covers := w.r.scratchCovers[:0] | |
| 358 | 405 | for i := range content.Children { | |
| 359 | 406 | ch := &content.Children[i] | |
| @@ -640,3 +687,13 @@ func parseColor(s string) [3]uint8 { | |||
| 640 | 687 | } | |
| 641 | 688 | 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 | + | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | // sceneWalk turns one scene into quads in paint order, copying new wl_shm | 11 | // sceneWalk turns one scene into quads in paint order, copying new wl_shm | ||
| 12 | // content into GPU buffers on the way. It lives for one frame. | 12 | // content into GPU buffers on the way. It lives for one frame. | ||
| 13 | type sceneWalk struct { | 13 | type sceneWalk struct { | ||
| 14 | + | rounded bool | |||
| 14 | r *Renderer | 15 | r *Renderer | ||
| 15 | s ports.Scene | 16 | s ports.Scene | ||
| 16 | contents map[ports.WindowID]ports.SurfaceContent | 17 | 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 = 1 | 301 | 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, windowBounds | 323 | 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 an | 398 | // 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 c | 688 | 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) { | |||
| 141 | 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 | + | } | |
| @@ -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) { | |||
| 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 | + | } | |
| @@ -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 { | |||
| 16 | 16 | flat uvec4 buf; | |
| 17 | 17 | flat uvec4 misc; | |
| 18 | 18 | flat uint resourceIndex; | |
| 19 | + | flat vec4 round; | |
| 20 | + | flat vec4 roundInner; | |
| 21 | + | flat vec2 radii; | |
| 19 | 22 | } d; | |
| 20 | 23 | ||
| 21 | 24 | #ifdef SINGLE_RESOURCE | |
| @@ -121,6 +124,15 @@ vec4 sampleBuffer(vec2 src, bool exact) { | |||
| 121 | 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 | 136 | void main() { | |
| 125 | 137 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | |
| 126 | 138 | bool exact = (d.misc.y & flagExact) != 0u; | |
| @@ -150,5 +162,9 @@ void main() { | |||
| 150 | 162 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 151 | 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 | 169 | color = c; | |
| 154 | 170 | } | |
| @@ -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_RESOURCE | 24 | #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 { | |||
| 10 | 10 | vec4 crop; // source crop bounds in buffer pixels | |
| 11 | 11 | uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w | |
| 12 | 12 | 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; | |
| 14 | 16 | }; | |
| 15 | 17 | layout(std430, set = 0, binding = 3) readonly buffer Draws { Draw draws[]; }; | |
| 16 | 18 | layout(location = 0) out Parameters { | |
| @@ -21,6 +23,9 @@ layout(location = 0) out Parameters { | |||
| 21 | 23 | flat uvec4 buf; | |
| 22 | 24 | flat uvec4 misc; | |
| 23 | 25 | flat uint resourceIndex; | |
| 26 | + | flat vec4 round; | |
| 27 | + | flat vec4 roundInner; | |
| 28 | + | flat vec2 radii; | |
| 24 | 29 | } parameters; | |
| 25 | 30 | ||
| 26 | 31 | void main() { | |
| @@ -36,4 +41,7 @@ void main() { | |||
| 36 | 41 | parameters.buf = d.buf; | |
| 37 | 42 | parameters.misc = d.misc; | |
| 38 | 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 | } | |
| @@ -10,7 +10,9 @@ struct Draw { | |||||
| 10 | vec4 crop; // source crop bounds in buffer pixels | 10 | vec4 crop; // source crop bounds in buffer pixels | ||
| 11 | uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w | 11 | uvec4 buf; // pixel-buffer first word, width, height; YUV metadata in w | ||
| 12 | uvec4 misc; // mode, flags, target width, target height | 12 | 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; | |||
| 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 { | |||
| 16 | 16 | flat uvec4 buf; | |
| 17 | 17 | flat uvec4 misc; | |
| 18 | 18 | flat uint resourceIndex; | |
| 19 | + | flat vec4 round; | |
| 20 | + | flat vec4 roundInner; | |
| 21 | + | flat vec2 radii; | |
| 19 | 22 | } d; | |
| 20 | 23 | ||
| 21 | 24 | #ifdef SINGLE_RESOURCE | |
| @@ -120,6 +123,15 @@ vec4 sampleBuffer(vec2 src, bool exact) { | |||
| 120 | 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 | 135 | void main() { | |
| 124 | 136 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | |
| 125 | 137 | bool exact = (d.misc.y & flagExact) != 0u; | |
| @@ -149,5 +161,9 @@ void main() { | |||
| 149 | 161 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 150 | 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 | 168 | color = c; | |
| 153 | 169 | } | |
| @@ -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_RESOURCE | 24 | #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) { | |||
| 207 | 207 | } | |
| 208 | 208 | } | |
| 209 | 209 | } | |
| 210 | - | if tiles <= 1 { | |
| 210 | + | if tiles <= 1 && gap == 0 { | |
| 211 | 211 | for _, w := range s.Windows { | |
| 212 | 212 | if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 { | |
| 213 | 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 | 222 | if sep.Window != 0 { | |
| 223 | 223 | found := false | |
| 224 | 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 | 226 | found = true | |
| 227 | 227 | } | |
| 228 | 228 | } | |
| @@ -243,7 +243,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) { | |||
| 243 | 243 | } | |
| 244 | 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 | 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 | 247 | return | |
| 248 | 248 | } | |
| 249 | 249 | } | |
| @@ -276,9 +276,12 @@ func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides { | |||
| 276 | 276 | return sides | |
| 277 | 277 | } | |
| 278 | 278 | ||
| 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. | |
| 281 | 281 | func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides { | |
| 282 | + | if gap > 0 { | |
| 283 | + | return ports.SideAll | |
| 284 | + | } | |
| 282 | 285 | sides := neighborSides(s, w, gap) | |
| 283 | 286 | if gap == 0 { | |
| 284 | 287 | 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 | + | } | |
| @@ -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 := false | 223 | 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 = true | 226 | 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 | return | 247 | return | ||
| 248 | } | 248 | } | ||
| 249 | } | 249 | } | ||
| @@ -276,9 +276,12 @@ func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides { | |||||
| 276 | return sides | 276 | return sides | ||
| 277 | } | 277 | } | ||
| 278 | 278 | ||||
| 279 | - | // wantInset is the exact inset of a visible tile: every neighbor side with | 279 | + | // 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.SideBottom | 287 | 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 { | |||
| 153 | 153 | Security: c.security, | |
| 154 | 154 | Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H}, | |
| 155 | 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 | 157 | Windows: make([]ports.SceneWindow, 0, len(cw.placements)), | |
| 158 | 158 | Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim), | |
| 159 | 159 | } | |
| 160 | 160 | // A hidden workspace lights nothing: its focus is not shown. | |
| 161 | 161 | for _, p := range cw.placements { | |
| 162 | 162 | w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | |
| 163 | + | if !p.Fullscreen && (p.Floating || cw.ws.gap() > 0) { | |
| 164 | + | w.Radius = c.cfg.Border.Radius | |
| 165 | + | } | |
| 163 | 166 | if !p.Hidden { | |
| 164 | 167 | w.Rect = rebase(p.Rect) | |
| 165 | 168 | } | |
| @@ -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) { | |||
| 124 | 124 | client <- ports.WindowMapped{ID: 1} | |
| 125 | 125 | s = l.one(scene(t, scenes)) | |
| 126 | 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 | 128 | t.Fatal(s) | |
| 129 | 129 | } | |
| 130 | 130 | 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) { | |||
| 1988 | 1988 | a.Inset = a.Neighbors | |
| 1989 | 1989 | if gap == 0 { | |
| 1990 | 1990 | a.Inset &= ports.SideRight | ports.SideBottom | |
| 1991 | + | } else { | |
| 1992 | + | a.Inset = ports.SideAll | |
| 1991 | 1993 | } | |
| 1992 | 1994 | } | |
| 1993 | 1995 | } | |
| @@ -1988,6 +1988,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) { | |||||
| 1988 | a.Inset = a.Neighbors | 1988 | a.Inset = a.Neighbors | ||
| 1989 | if gap == 0 { | 1989 | if gap == 0 { | ||
| 1990 | a.Inset &= ports.SideRight | ports.SideBottom | 1990 | 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) { | |||
| 151 | 151 | } | |
| 152 | 152 | w.FocusWindow(-1) | |
| 153 | 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 | 155 | t.Fatal(p) | |
| 156 | 156 | } | |
| 157 | 157 | 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 | |||
| 20 | 20 | // layout is what is drawn; settled (same indexes) is where the | |
| 21 | 21 | // windows are going, and alone sizes the configures. | |
| 22 | 22 | 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 | 24 | if !sc.mon.previewing() { | |
| 25 | 25 | // Tiles stop at the panels; the overview lays out previews over | |
| 26 | 26 | // the whole output. | |
| @@ -72,6 +72,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 72 | 72 | // Only the focused output has an activated window. | |
| 73 | 73 | focused := p.Focused && i == c.focusScreen | |
| 74 | 74 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade} | |
| 75 | + | if !p.Fullscreen && (p.Floating || sc.mon.Current().gap() > 0) { | |
| 76 | + | sw.Radius = c.cfg.Border.Radius | |
| 77 | + | } | |
| 75 | 78 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | |
| 76 | 79 | // A scale motion: the content follows the drawn size. A | |
| 77 | 80 | // 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 the | 20 | // 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.settledLayout | 22 | 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 over | 25 | // 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.focusScreen | 73 | focused := p.Focused && i == c.focusScreen | ||
| 74 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade} | 74 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade} | ||
| 75 | + | if !p.Fullscreen && (p.Floating || sc.mon.Current().gap() > 0) { | |||
| 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. A | 79 | // 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" | |||
| 7 | 7 | // owned (and inset) by the left/top tile; focus lights only the adjacent | |
| 8 | 8 | // sides of the focused tile, including corners. With exactly two visible | |
| 9 | 9 | // tiles and no gaps, each lights its own half of the shared line (left/top | |
| 10 | - | // first). With gaps, each tile owns its neighbor-facing lines and lights | |
| 11 | - | // them whole. Hidden, scrolled-off, under-panel, other-workspace and | |
| 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 | |
| 12 | 12 | // other-output tiles do not count, nor do tiles that share no line. Lines | |
| 13 | 13 | // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles: | |
| 14 | 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 | 40 | r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} | |
| 41 | 41 | var id WindowID | |
| 42 | - | if p.Floating { | |
| 42 | + | if p.Floating || gap > 0 { | |
| 43 | 43 | id = p.ID | |
| 44 | 44 | } | |
| 45 | 45 | if out == nil { | |
| @@ -58,6 +58,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||
| 58 | 58 | if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) { | |
| 59 | 59 | 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 | 67 | // Tiles sharing a line: Neighbors only counts tiles visible in | |
| 62 | 68 | // the usable area, not those under a panel. | |
| 63 | 69 | 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 adjacent | 7 | // owned (and inset) by the left/top tile; focus lights only the adjacent | ||
| 8 | // sides of the focused tile, including corners. With exactly two visible | 8 | // sides of the focused tile, including corners. With exactly two visible | ||
| 9 | // tiles and no gaps, each lights its own half of the shared line (left/top | 9 | // tiles and no gaps, each lights its own half of the shared line (left/top | ||
| 10 | - | // first). With gaps, each tile owns its neighbor-facing lines and lights | 10 | + | // first). With gaps, each tile owns a full four-sided border and lights |
| 11 | - | // them whole. Hidden, scrolled-off, under-panel, other-workspace and | 11 | + | // it whole. Hidden, scrolled-off, under-panel, other-workspace and |
| 12 | // other-output tiles do not count, nor do tiles that share no line. Lines | 12 | // 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 WindowID | 41 | var id WindowID | ||
| 42 | - | if p.Floating { | 42 | + | if p.Floating || gap > 0 { |
| 43 | id = p.ID | 43 | 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 | continue | 59 | 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 in | 67 | // 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) { | |||
| 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 | 205 | func TestSeparatorsGaps(t *testing.T) { | |
| 206 | 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 | 207 | setVisibleNeighbors(ps, 4, bigOutput) | |
| 208 | 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 | 210 | if got := colorAt(seps, c.x, c.y); got != c.want { | |
| 211 | 211 | t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want) | |
| 212 | 212 | } | |
| @@ -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 { | |||
| 123 | 123 | } | |
| 124 | 124 | // Border is drawn inside the window edge; Width 0 disables it. | |
| 125 | 125 | Border struct { | |
| 126 | + | Radius int | |
| 126 | 127 | Width int | |
| 127 | 128 | Active string | |
| 128 | 129 | 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 int | 127 | Width int | ||
| 127 | Active string | 128 | Active string | ||
| 128 | Inactive string | 129 | Inactive string | ||
M internal/ports/scene.go +4 -1
| @@ -170,13 +170,14 @@ func (s Scene) TileArea() (r Rect, ok bool) { | |||
| 170 | 170 | // Border carries the window border style; colors are #rrggbb, "" skips | |
| 171 | 171 | // drawing. Width is in logical pixels. | |
| 172 | 172 | type Border struct { | |
| 173 | + | Radius int | |
| 173 | 174 | Width int | |
| 174 | 175 | Active, Inactive string | |
| 175 | 176 | } | |
| 176 | 177 | ||
| 177 | 178 | // Separator is a line between windows, in logical pixels. Active lines | |
| 178 | 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 | 181 | type Separator struct { | |
| 181 | 182 | Rect Rect | |
| 182 | 183 | Active bool | |
| @@ -203,6 +204,8 @@ type FocusFlash struct { | |||
| 203 | 204 | ||
| 204 | 205 | // SceneWindow carries core → renderer window placement. | |
| 205 | 206 | type SceneWindow struct { | |
| 207 | + | // Radius rounds the window corners in logical pixels; zero is square. | |
| 208 | + | Radius int | |
| 206 | 209 | ID WindowID | |
| 207 | 210 | Rect Rect | |
| 208 | 211 | 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, "" skips | 170 | // 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 int | 174 | Width int | ||
| 174 | Active, Inactive string | 175 | Active, Inactive string | ||
| 175 | } | 176 | } | ||
| 176 | 177 | ||||
| 177 | // Separator is a line between windows, in logical pixels. Active lines | 178 | // 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 Rect | 182 | Rect Rect | ||
| 182 | Active bool | 183 | 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 WindowID | 209 | ID WindowID | ||
| 207 | Rect Rect | 210 | Rect Rect | ||
| 208 | Focused, Fullscreen, Hidden bool | 211 | Focused, Fullscreen, Hidden bool | ||