Merge 608a5b903e02696508ed604565078285738a86bb into fe393734fd06ea20291657795182f4d60b999056
39 files · +452 -52
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, `0` to `64`; 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 | | |
| @@ -84,6 +85,8 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 84 | 85 | | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` | | |
| 85 | 86 | | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) | | |
| 86 | 87 | ||
| 88 | + | With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square. | |
| 89 | + | ||
| 87 | 90 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | |
| 88 | 91 | ||
| 89 | 92 | The position hint follows actual workspace/band changes and monitor focus, regardless | |
| @@ -29,6 +29,7 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 29 | | `startup` | none | Commands run at session start, separated by `;` | | 29 | | `startup` | none | Commands run at session start, separated by `;` | | ||
| 30 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | 30 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | ||
| 31 | | `background` | `#111111` | Background color | | 31 | | `background` | `#111111` | Background color | | ||
| 32 | + | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | |||
| 32 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | 33 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | ||
| 33 | | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows | | 34 | | `border.active` / `.inactive` | `#808080` / `#111111` | Border colors of the focused / other windows | | ||
| 34 | | `floating.dim` | `0.3` | Darkening behind floating windows and the overview, 0 to 1 | | 35 | | `floating.dim` | `0.3` | Darkening behind floating windows and the overview, 0 to 1 | | ||
| @@ -84,6 +85,8 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 84 | | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` | | 85 | | `log.level` / `log.debug` | `info` / empty | Log level / debug categories or `all` | | ||
| 85 | | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) | | 86 | | `bind.<keys>` | see [Default binds](#default-binds) | [Binds](#binds) | | ||
| 86 | 87 | ||||
| 88 | + | With `layout.gaps = 0`, borders are tmux-style lines between tiles. With gaps, every tile gets a full border, even alone. `border.radius` rounds floating windows always and tiles with gaps; fullscreen windows, popups and layers stay square. | |||
| 89 | + | ||||
| 87 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | 90 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | ||
| 88 | 91 | ||||
| 89 | The position hint follows actual workspace/band changes and monitor focus, regardless | 92 | The position hint follows actual workspace/band changes and monitor focus, regardless | ||
M examples/config +2 -0
| @@ -30,6 +30,8 @@ background = #111111 | |||
| 30 | 30 | # The lines along the focused window use active; width 0 disables them, | |
| 31 | 31 | # empty inactive draws only the focused lines. Floats get a full border. | |
| 32 | 32 | border.width = 2 | |
| 33 | + | # Corner radius in logical pixels (0 to 64): floats always, tiles only with gaps. | |
| 34 | + | border.radius = 0 | |
| 33 | 35 | border.active = #808080 | |
| 34 | 36 | border.inactive = #111111 | |
| 35 | 37 | ||
| @@ -30,6 +30,8 @@ background = #111111 | |||||
| 30 | # The lines along the focused window use active; width 0 disables them, | 30 | # The lines along the focused window use active; width 0 disables them, | ||
| 31 | # empty inactive draws only the focused lines. Floats get a full border. | 31 | # empty inactive draws only the focused lines. Floats get a full border. | ||
| 32 | border.width = 2 | 32 | border.width = 2 | ||
| 33 | + | # Corner radius in logical pixels (0 to 64): floats always, tiles only with gaps. | |||
| 34 | + | border.radius = 0 | |||
| 33 | border.active = #808080 | 35 | border.active = #808080 | ||
| 34 | border.inactive = #111111 | 36 | border.inactive = #111111 | ||
| 35 | 37 | ||||
M internal/adapters/config/config.go +6 -0
| @@ -773,6 +773,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 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 || n > 64 { | |
| 779 | + | return fmt.Errorf("must be between 0 and 64") | |
| 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 || n > 64 { | |||
| 779 | + | return fmt.Errorf("must be between 0 and 64") | |||
| 780 | + | } | |||
| 781 | + | c.Border.Radius = n | |||
| 776 | case "border.width": | 782 | case "border.width": | ||
| 777 | n, err := strconv.Atoi(v) | 783 | n, err := strconv.Atoi(v) | ||
| 778 | if err != nil || n < 0 || n > 32 { | 784 | if err != nil || n < 0 || n > 32 { | ||
M internal/adapters/config/config_test.go +18 -0
| @@ -1063,3 +1063,21 @@ func TestPositionHint(t *testing.T) { | |||
| 1063 | 1063 | } | |
| 1064 | 1064 | } | |
| 1065 | 1065 | } | |
| 1066 | + | ||
| 1067 | + | func TestBorderRadius(t *testing.T) { | |
| 1068 | + | if Defaults().Border.Radius != 0 { | |
| 1069 | + | t.Fatal("nonzero default") | |
| 1070 | + | } | |
| 1071 | + | for _, v := range []string{"0", "10", "64"} { | |
| 1072 | + | c, w := parseString(t, "border.radius = "+v+"\n") | |
| 1073 | + | if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v { | |
| 1074 | + | t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w) | |
| 1075 | + | } | |
| 1076 | + | } | |
| 1077 | + | for _, v := range []string{"-1", "bad", "65"} { | |
| 1078 | + | _, w := parseString(t, "border.radius = "+v+"\n") | |
| 1079 | + | if len(w) == 0 { | |
| 1080 | + | t.Fatalf("accepted %s", v) | |
| 1081 | + | } | |
| 1082 | + | } | |
| 1083 | + | } | |
| @@ -1063,3 +1063,21 @@ func TestPositionHint(t *testing.T) { | |||||
| 1063 | } | 1063 | } | ||
| 1064 | } | 1064 | } | ||
| 1065 | } | 1065 | } | ||
| 1066 | + | ||||
| 1067 | + | func TestBorderRadius(t *testing.T) { | |||
| 1068 | + | if Defaults().Border.Radius != 0 { | |||
| 1069 | + | t.Fatal("nonzero default") | |||
| 1070 | + | } | |||
| 1071 | + | for _, v := range []string{"0", "10", "64"} { | |||
| 1072 | + | c, w := parseString(t, "border.radius = "+v+"\n") | |||
| 1073 | + | if len(w) != 0 || fmt.Sprint(c.Border.Radius) != v { | |||
| 1074 | + | t.Fatalf("radius %s: %v %v", v, c.Border.Radius, w) | |||
| 1075 | + | } | |||
| 1076 | + | } | |||
| 1077 | + | for _, v := range []string{"-1", "bad", "65"} { | |||
| 1078 | + | _, w := parseString(t, "border.radius = "+v+"\n") | |||
| 1079 | + | if len(w) == 0 { | |||
| 1080 | + | t.Fatalf("accepted %s", v) | |||
| 1081 | + | } | |||
| 1082 | + | } | |||
| 1083 | + | } | |||
M internal/adapters/config/watch_test.go +3 -3
| @@ -93,17 +93,17 @@ func TestWatch(t *testing.T) { | |||
| 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 +81 -5
| @@ -23,8 +23,12 @@ type sceneWalk struct { | |||
| 23 | 23 | // alpha is the opacity of the window being drawn, 1 - its Fade | |
| 24 | 24 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | |
| 25 | 25 | // it (premultiplied); 1 otherwise. | |
| 26 | - | alpha float32 | |
| 27 | - | bounds image.Rectangle | |
| 26 | + | alpha float32 | |
| 27 | + | // rounded marks the window being drawn as rounded: it suppresses | |
| 28 | + | // opaque-child culling (opaqueChildren), as the corners must show what | |
| 29 | + | // is below. | |
| 30 | + | rounded bool | |
| 31 | + | bounds image.Rectangle | |
| 28 | 32 | // tileBounds is bounds cut to Scene.TileArea: where tiles and their | |
| 29 | 33 | // lines draw. | |
| 30 | 34 | tileBounds image.Rectangle | |
| @@ -255,7 +259,35 @@ func (w *sceneWalk) windows() { | |||
| 255 | 259 | body := w.physRect(c.X, c.Y, c.W, c.H) | |
| 256 | 260 | content := w.contents[win.ID] | |
| 257 | 261 | // Damage counts only what is drawn: never the panel under a tile. | |
| 258 | - | w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds)) | |
| 262 | + | outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H) | |
| 263 | + | w.dmg.window(win.ID, content, outer.Intersect(w.bounds)) | |
| 264 | + | w.rounded = win.Radius > 0 | |
| 265 | + | var radius, innerRadius float32 | |
| 266 | + | if win.Radius > 0 { | |
| 267 | + | radius = float32(math.Round(float64(win.Radius) * w.scale)) | |
| 268 | + | // The border's physical width, from the rects actually drawn. | |
| 269 | + | bPhys := min(body.Min.X-outer.Min.X, body.Min.Y-outer.Min.Y, outer.Max.X-body.Max.X, outer.Max.Y-body.Max.Y) | |
| 270 | + | innerRadius = max(radius-float32(max(bPhys, 0)), 0) | |
| 271 | + | if col := w.ringColor(win.ID); col != "" && b > 0 { | |
| 272 | + | n := len(w.draws) | |
| 273 | + | w.fill(outer, parseColor(col)) | |
| 274 | + | if len(w.draws) > n { | |
| 275 | + | dr := &w.draws[n] | |
| 276 | + | roundedDraw(dr, outer, radius) | |
| 277 | + | dr.pc.misc[1] |= flagRing | |
| 278 | + | // Opaque, the ring runs 1px under the body so no seam | |
| 279 | + | // shows; faded, that overlap would double up: the hole | |
| 280 | + | // is the body. | |
| 281 | + | hole, holeRadius := body.Inset(1), max(innerRadius-1, 0) | |
| 282 | + | if w.alpha < 1 { | |
| 283 | + | hole, holeRadius = body, innerRadius | |
| 284 | + | } | |
| 285 | + | dr.roundInner = roundRect(hole) | |
| 286 | + | dr.radii[1] = math.Float32bits(holeRadius) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | start := len(w.draws) | |
| 259 | 291 | // Until its first buffer, a window shows the background: no flash. | |
| 260 | 292 | w.fill(body, parseColor(w.s.Background)) | |
| 261 | 293 | if !content.Empty() { | |
| @@ -269,13 +301,24 @@ func (w *sceneWalk) windows() { | |||
| 269 | 301 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | |
| 270 | 302 | w.zoom = 1 | |
| 271 | 303 | } | |
| 272 | - | if win.Floating { | |
| 304 | + | if win.Radius > 0 { | |
| 305 | + | for j := start; j < len(w.draws); j++ { | |
| 306 | + | roundedDraw(&w.draws[j], body, innerRadius) | |
| 307 | + | } | |
| 308 | + | } else { | |
| 273 | 309 | w.separators(win.ID) | |
| 274 | 310 | } | |
| 311 | + | dimStart := len(w.draws) | |
| 275 | 312 | if win.Dim > 0 { | |
| 276 | 313 | // A stashed window peeking in: dimmed with its border. | |
| 277 | - | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | |
| 314 | + | w.dim(outer, win.Dim) | |
| 315 | + | } | |
| 316 | + | if win.Radius > 0 { | |
| 317 | + | for j := dimStart; j < len(w.draws); j++ { | |
| 318 | + | roundedDraw(&w.draws[j], outer, radius) | |
| 319 | + | } | |
| 278 | 320 | } | |
| 321 | + | w.rounded = false | |
| 279 | 322 | w.alpha, w.bounds = 1, windowBounds | |
| 280 | 323 | } | |
| 281 | 324 | if !tileLines { | |
| @@ -291,6 +334,22 @@ func (w *sceneWalk) tileSeparators() { | |||
| 291 | 334 | w.bounds = b | |
| 292 | 335 | } | |
| 293 | 336 | ||
| 337 | + | // ringColor is the border color of the window id's lines: the inactive one, | |
| 338 | + | // or the active one when any of its lines is active; "" without lines. | |
| 339 | + | func (w *sceneWalk) ringColor(id ports.WindowID) string { | |
| 340 | + | col := "" | |
| 341 | + | for _, sep := range w.s.Separators { | |
| 342 | + | if sep.Window != id { | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if sep.Active { | |
| 346 | + | return w.s.Border.Active | |
| 347 | + | } | |
| 348 | + | col = w.s.Border.Inactive | |
| 349 | + | } | |
| 350 | + | return col | |
| 351 | + | } | |
| 352 | + | ||
| 294 | 353 | // separators draws a window's border lines in order, active lines over | |
| 295 | 354 | // inactive ones. | |
| 296 | 355 | func (w *sceneWalk) separators(id ports.WindowID) { | |
| @@ -354,6 +413,9 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||
| 354 | 413 | // dmabuf that fails to import (opaqueChildren pre-imports it) or an | |
| 355 | 414 | // unreadable shm buffer. | |
| 356 | 415 | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle { | |
| 416 | + | if w.rounded { | |
| 417 | + | return nil | |
| 418 | + | } | |
| 357 | 419 | covers := w.r.scratchCovers[:0] | |
| 358 | 420 | for i := range content.Children { | |
| 359 | 421 | ch := &content.Children[i] | |
| @@ -640,3 +702,17 @@ func parseColor(s string) [3]uint8 { | |||
| 640 | 702 | } | |
| 641 | 703 | return c | |
| 642 | 704 | } | |
| 705 | + | ||
| 706 | + | // roundRect is r as float bounds for the shader's rounded coverage. Rounded | |
| 707 | + | // bounds stay independent of scissor/damage clipping. | |
| 708 | + | func roundRect(r image.Rectangle) [4]float32 { | |
| 709 | + | return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)} | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | // roundedDraw makes d a rounded draw: the shader clips it to rect with | |
| 713 | + | // corners of radius (physical pixels). | |
| 714 | + | func roundedDraw(d *draw, rect image.Rectangle, radius float32) { | |
| 715 | + | d.pc.misc[1] |= flagRounded | |
| 716 | + | d.round = roundRect(rect) | |
| 717 | + | d.radii[0] = math.Float32bits(radius) | |
| 718 | + | } | |
| @@ -23,8 +23,12 @@ type sceneWalk struct { | |||||
| 23 | // alpha is the opacity of the window being drawn, 1 - its Fade | 23 | // alpha is the opacity of the window being drawn, 1 - its Fade | ||
| 24 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | 24 | // (ports.SceneWindow.Fade): its fills, lines and surfaces are scaled by | ||
| 25 | // it (premultiplied); 1 otherwise. | 25 | // it (premultiplied); 1 otherwise. | ||
| 26 | - | alpha float32 | 26 | + | alpha float32 |
| 27 | - | bounds image.Rectangle | 27 | + | // rounded marks the window being drawn as rounded: it suppresses |
| 28 | + | // opaque-child culling (opaqueChildren), as the corners must show what | |||
| 29 | + | // is below. | |||
| 30 | + | rounded bool | |||
| 31 | + | bounds image.Rectangle | |||
| 28 | // tileBounds is bounds cut to Scene.TileArea: where tiles and their | 32 | // tileBounds is bounds cut to Scene.TileArea: where tiles and their | ||
| 29 | // lines draw. | 33 | // lines draw. | ||
| 30 | tileBounds image.Rectangle | 34 | tileBounds image.Rectangle | ||
| @@ -255,7 +259,35 @@ func (w *sceneWalk) windows() { | |||||
| 255 | body := w.physRect(c.X, c.Y, c.W, c.H) | 259 | body := w.physRect(c.X, c.Y, c.W, c.H) | ||
| 256 | content := w.contents[win.ID] | 260 | content := w.contents[win.ID] | ||
| 257 | // Damage counts only what is drawn: never the panel under a tile. | 261 | // Damage counts only what is drawn: never the panel under a tile. | ||
| 258 | - | w.dmg.window(win.ID, content, w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H).Intersect(w.bounds)) | 262 | + | outer := w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H) |
| 263 | + | w.dmg.window(win.ID, content, outer.Intersect(w.bounds)) | |||
| 264 | + | w.rounded = win.Radius > 0 | |||
| 265 | + | var radius, innerRadius float32 | |||
| 266 | + | if win.Radius > 0 { | |||
| 267 | + | radius = float32(math.Round(float64(win.Radius) * w.scale)) | |||
| 268 | + | // The border's physical width, from the rects actually drawn. | |||
| 269 | + | bPhys := min(body.Min.X-outer.Min.X, body.Min.Y-outer.Min.Y, outer.Max.X-body.Max.X, outer.Max.Y-body.Max.Y) | |||
| 270 | + | innerRadius = max(radius-float32(max(bPhys, 0)), 0) | |||
| 271 | + | if col := w.ringColor(win.ID); col != "" && b > 0 { | |||
| 272 | + | n := len(w.draws) | |||
| 273 | + | w.fill(outer, parseColor(col)) | |||
| 274 | + | if len(w.draws) > n { | |||
| 275 | + | dr := &w.draws[n] | |||
| 276 | + | roundedDraw(dr, outer, radius) | |||
| 277 | + | dr.pc.misc[1] |= flagRing | |||
| 278 | + | // Opaque, the ring runs 1px under the body so no seam | |||
| 279 | + | // shows; faded, that overlap would double up: the hole | |||
| 280 | + | // is the body. | |||
| 281 | + | hole, holeRadius := body.Inset(1), max(innerRadius-1, 0) | |||
| 282 | + | if w.alpha < 1 { | |||
| 283 | + | hole, holeRadius = body, innerRadius | |||
| 284 | + | } | |||
| 285 | + | dr.roundInner = roundRect(hole) | |||
| 286 | + | dr.radii[1] = math.Float32bits(holeRadius) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | start := len(w.draws) | |||
| 259 | // Until its first buffer, a window shows the background: no flash. | 291 | // Until its first buffer, a window shows the background: no flash. | ||
| 260 | w.fill(body, parseColor(w.s.Background)) | 292 | w.fill(body, parseColor(w.s.Background)) | ||
| 261 | if !content.Empty() { | 293 | if !content.Empty() { | ||
| @@ -269,13 +301,24 @@ func (w *sceneWalk) windows() { | |||||
| 269 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | 301 | w.place(win.ID, &content, c.X, c.Y, c.W, c.H) | ||
| 270 | w.zoom = 1 | 302 | w.zoom = 1 | ||
| 271 | } | 303 | } | ||
| 272 | - | if win.Floating { | 304 | + | if win.Radius > 0 { |
| 305 | + | for j := start; j < len(w.draws); j++ { | |||
| 306 | + | roundedDraw(&w.draws[j], body, innerRadius) | |||
| 307 | + | } | |||
| 308 | + | } else { | |||
| 273 | w.separators(win.ID) | 309 | w.separators(win.ID) | ||
| 274 | } | 310 | } | ||
| 311 | + | dimStart := len(w.draws) | |||
| 275 | if win.Dim > 0 { | 312 | if win.Dim > 0 { | ||
| 276 | // A stashed window peeking in: dimmed with its border. | 313 | // A stashed window peeking in: dimmed with its border. | ||
| 277 | - | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | 314 | + | w.dim(outer, win.Dim) |
| 315 | + | } | |||
| 316 | + | if win.Radius > 0 { | |||
| 317 | + | for j := dimStart; j < len(w.draws); j++ { | |||
| 318 | + | roundedDraw(&w.draws[j], outer, radius) | |||
| 319 | + | } | |||
| 278 | } | 320 | } | ||
| 321 | + | w.rounded = false | |||
| 279 | w.alpha, w.bounds = 1, windowBounds | 322 | w.alpha, w.bounds = 1, windowBounds | ||
| 280 | } | 323 | } | ||
| 281 | if !tileLines { | 324 | if !tileLines { | ||
| @@ -291,6 +334,22 @@ func (w *sceneWalk) tileSeparators() { | |||||
| 291 | w.bounds = b | 334 | w.bounds = b | ||
| 292 | } | 335 | } | ||
| 293 | 336 | ||||
| 337 | + | // ringColor is the border color of the window id's lines: the inactive one, | |||
| 338 | + | // or the active one when any of its lines is active; "" without lines. | |||
| 339 | + | func (w *sceneWalk) ringColor(id ports.WindowID) string { | |||
| 340 | + | col := "" | |||
| 341 | + | for _, sep := range w.s.Separators { | |||
| 342 | + | if sep.Window != id { | |||
| 343 | + | continue | |||
| 344 | + | } | |||
| 345 | + | if sep.Active { | |||
| 346 | + | return w.s.Border.Active | |||
| 347 | + | } | |||
| 348 | + | col = w.s.Border.Inactive | |||
| 349 | + | } | |||
| 350 | + | return col | |||
| 351 | + | } | |||
| 352 | + | ||||
| 294 | // separators draws a window's border lines in order, active lines over | 353 | // separators draws a window's border lines in order, active lines over | ||
| 295 | // inactive ones. | 354 | // inactive ones. | ||
| 296 | func (w *sceneWalk) separators(id ports.WindowID) { | 355 | func (w *sceneWalk) separators(id ports.WindowID) { | ||
| @@ -354,6 +413,9 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||||
| 354 | // dmabuf that fails to import (opaqueChildren pre-imports it) or an | 413 | // dmabuf that fails to import (opaqueChildren pre-imports it) or an | ||
| 355 | // unreadable shm buffer. | 414 | // unreadable shm buffer. | ||
| 356 | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle { | 415 | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy float64, clip image.Rectangle) []image.Rectangle { | ||
| 416 | + | if w.rounded { | |||
| 417 | + | return nil | |||
| 418 | + | } | |||
| 357 | covers := w.r.scratchCovers[:0] | 419 | covers := w.r.scratchCovers[:0] | ||
| 358 | for i := range content.Children { | 420 | for i := range content.Children { | ||
| 359 | ch := &content.Children[i] | 421 | ch := &content.Children[i] | ||
| @@ -640,3 +702,17 @@ func parseColor(s string) [3]uint8 { | |||||
| 640 | } | 702 | } | ||
| 641 | return c | 703 | return c | ||
| 642 | } | 704 | } | ||
| 705 | + | ||||
| 706 | + | // roundRect is r as float bounds for the shader's rounded coverage. Rounded | |||
| 707 | + | // bounds stay independent of scissor/damage clipping. | |||
| 708 | + | func roundRect(r image.Rectangle) [4]float32 { | |||
| 709 | + | return [4]float32{float32(r.Min.X), float32(r.Min.Y), float32(r.Max.X), float32(r.Max.Y)} | |||
| 710 | + | } | |||
| 711 | + | ||||
| 712 | + | // roundedDraw makes d a rounded draw: the shader clips it to rect with | |||
| 713 | + | // corners of radius (physical pixels). | |||
| 714 | + | func roundedDraw(d *draw, rect image.Rectangle, radius float32) { | |||
| 715 | + | d.pc.misc[1] |= flagRounded | |||
| 716 | + | d.round = roundRect(rect) | |||
| 717 | + | d.radii[0] = math.Float32bits(radius) | |||
| 718 | + | } | |||
M internal/adapters/vulkan/scene_fade_test.go +49 -0
| @@ -141,3 +141,52 @@ func TestSceneZoomedFloatOpensFloats(t *testing.T) { | |||
| 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 | + | } | |
| 170 | + | ||
| 171 | + | // The ring's hole runs 1px under the body when the window is opaque; faded, | |
| 172 | + | // that overlap would show twice, so the hole is the body itself. | |
| 173 | + | func TestRoundedRingHoleFadedVersusOpaque(t *testing.T) { | |
| 174 | + | r := &Renderer{width: 80, height: 60} | |
| 175 | + | ring := func(fade float64) draw { | |
| 176 | + | s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800", Inactive: "#888888"}, | |
| 177 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 40, H: 40}, Inset: ports.SideAll, Radius: 10, Fade: fade}}, | |
| 178 | + | Separators: []ports.Separator{{Window: 1, Active: true}}} | |
| 179 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |
| 180 | + | if len(ds) == 0 || ds[0].pc.misc[1]&flagRing == 0 { | |
| 181 | + | t.Fatalf("fade %v: no ring: %+v", fade, ds) | |
| 182 | + | } | |
| 183 | + | return ds[0] | |
| 184 | + | } | |
| 185 | + | opaque, faded := ring(0), ring(0.5) | |
| 186 | + | if opaque.roundInner != roundRect(image.Rect(12, 12, 44, 44)) || math.Float32frombits(opaque.radii[1]) != 6 { | |
| 187 | + | t.Fatalf("opaque hole %v radius %v", opaque.roundInner, math.Float32frombits(opaque.radii[1])) | |
| 188 | + | } | |
| 189 | + | if faded.roundInner != roundRect(image.Rect(11, 11, 45, 45)) || math.Float32frombits(faded.radii[1]) != 7 { | |
| 190 | + | t.Fatalf("faded hole %v radius %v", faded.roundInner, math.Float32frombits(faded.radii[1])) | |
| 191 | + | } | |
| 192 | + | } | |
| @@ -141,3 +141,52 @@ func TestSceneZoomedFloatOpensFloats(t *testing.T) { | |||||
| 141 | t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds) | 141 | t.Fatalf("card draws = %d, want 5 (lines after, no veil): %+v", len(ds), ds) | ||
| 142 | } | 142 | } | ||
| 143 | } | 143 | } | ||
| 144 | + | ||||
| 145 | + | func TestRoundedWindowDraws(t *testing.T) { | |||
| 146 | + | r := &Renderer{width: 80, height: 60} | |||
| 147 | + | s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800", Inactive: "#888888"}, | |||
| 148 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 40, H: 40}, Inset: ports.SideAll, Radius: 10, Dim: 0.5}}, | |||
| 149 | + | Separators: []ports.Separator{{Window: 1, Active: true}}} | |||
| 150 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |||
| 151 | + | if len(ds) != 3 { | |||
| 152 | + | t.Fatalf("draws %d", len(ds)) | |||
| 153 | + | } | |||
| 154 | + | for i, d := range ds { | |||
| 155 | + | if d.pc.misc[1]&flagRounded == 0 { | |||
| 156 | + | t.Fatalf("draw %d not rounded", i) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | if ds[0].pc.misc[1]&flagRing == 0 || math.Float32frombits(ds[0].radii[0]) != 10 || math.Float32frombits(ds[0].radii[1]) != 6 { | |||
| 160 | + | t.Fatal("ring radii") | |||
| 161 | + | } | |||
| 162 | + | if ds[1].round != roundRect(image.Rect(11, 11, 45, 45)) { | |||
| 163 | + | t.Fatal("body clip") | |||
| 164 | + | } | |||
| 165 | + | var b composeBatch | |||
| 166 | + | if !b.add(ds[0], 64, 64) || b.instances[0].round != ds[0].round || b.instances[0].resource[1] != ds[0].radii[0] { | |||
| 167 | + | t.Fatal("batch loses clip") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // The ring's hole runs 1px under the body when the window is opaque; faded, | |||
| 172 | + | // that overlap would show twice, so the hole is the body itself. | |||
| 173 | + | func TestRoundedRingHoleFadedVersusOpaque(t *testing.T) { | |||
| 174 | + | r := &Renderer{width: 80, height: 60} | |||
| 175 | + | ring := func(fade float64) draw { | |||
| 176 | + | s := ports.Scene{Scale: 1, Background: "#ffffff", Border: ports.Border{Width: 3, Active: "#ff8800", Inactive: "#888888"}, | |||
| 177 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 40, H: 40}, Inset: ports.SideAll, Radius: 10, Fade: fade}}, | |||
| 178 | + | Separators: []ports.Separator{{Window: 1, Active: true}}} | |||
| 179 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |||
| 180 | + | if len(ds) == 0 || ds[0].pc.misc[1]&flagRing == 0 { | |||
| 181 | + | t.Fatalf("fade %v: no ring: %+v", fade, ds) | |||
| 182 | + | } | |||
| 183 | + | return ds[0] | |||
| 184 | + | } | |||
| 185 | + | opaque, faded := ring(0), ring(0.5) | |||
| 186 | + | if opaque.roundInner != roundRect(image.Rect(12, 12, 44, 44)) || math.Float32frombits(opaque.radii[1]) != 6 { | |||
| 187 | + | t.Fatalf("opaque hole %v radius %v", opaque.roundInner, math.Float32frombits(opaque.radii[1])) | |||
| 188 | + | } | |||
| 189 | + | if faded.roundInner != roundRect(image.Rect(11, 11, 45, 45)) || math.Float32frombits(faded.radii[1]) != 7 { | |||
| 190 | + | t.Fatalf("faded hole %v radius %v", faded.roundInner, math.Float32frombits(faded.radii[1])) | |||
| 191 | + | } | |||
| 192 | + | } | |||
M internal/adapters/vulkan/scene_opaque_region_test.go +8 -0
| @@ -330,3 +330,11 @@ func TestRendererSplitBoundKeepsRoot(t *testing.T) { | |||
| 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 +18 -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 | |
| @@ -46,6 +49,8 @@ const uint flagP010 = 32u; | |||
| 46 | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | |
| 47 | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | |
| 48 | 51 | const uint flagSmooth = 64u; | |
| 52 | + | const uint flagRounded = 128u; | |
| 53 | + | const uint flagRing = 256u; | |
| 49 | 54 | ||
| 50 | 55 | // PQ values are absolute nits. BT.2020 is converted in linear light to | |
| 51 | 56 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | |
| @@ -121,6 +126,15 @@ vec4 sampleBuffer(vec2 src, bool exact) { | |||
| 121 | 126 | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |
| 122 | 127 | } | |
| 123 | 128 | ||
| 129 | + | float coverage(vec4 rect, float radius) { | |
| 130 | + | vec2 halfSize = (rect.zw - rect.xy) * 0.5; | |
| 131 | + | radius = min(radius, min(halfSize.x, halfSize.y)); | |
| 132 | + | vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius; | |
| 133 | + | if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0; | |
| 134 | + | float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; | |
| 135 | + | return clamp(0.5 - sd, 0.0, 1.0); | |
| 136 | + | } | |
| 137 | + | ||
| 124 | 138 | void main() { | |
| 125 | 139 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | |
| 126 | 140 | bool exact = (d.misc.y & flagExact) != 0u; | |
| @@ -150,5 +164,9 @@ void main() { | |||
| 150 | 164 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 151 | 165 | c *= d.color.a; | |
| 152 | 166 | } | |
| 167 | + | if ((d.misc.y & flagRounded) != 0u) { | |
| 168 | + | c *= coverage(d.round, d.radii.x); | |
| 169 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |
| 170 | + | } | |
| 153 | 171 | color = c; | |
| 154 | 172 | } | |
| @@ -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 | ||
| @@ -46,6 +49,8 @@ const uint flagP010 = 32u; | |||||
| 46 | // flagSmooth averages four samples a quarter of a target pixel around the | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | ||
| 47 | // point: a thumbnail shrunk under half size keeps thin lines and text. | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | ||
| 48 | const uint flagSmooth = 64u; | 51 | const uint flagSmooth = 64u; | ||
| 52 | + | const uint flagRounded = 128u; | |||
| 53 | + | const uint flagRing = 256u; | |||
| 49 | 54 | ||||
| 50 | // PQ values are absolute nits. BT.2020 is converted in linear light to | 55 | // PQ values are absolute nits. BT.2020 is converted in linear light to | ||
| 51 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | 56 | // BT.709, then clipped to the 8-bit sRGB intermediate. Highlights above | ||
| @@ -121,6 +126,15 @@ vec4 sampleBuffer(vec2 src, bool exact) { | |||||
| 121 | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | 126 | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | ||
| 122 | } | 127 | } | ||
| 123 | 128 | ||||
| 129 | + | float coverage(vec4 rect, float radius) { | |||
| 130 | + | vec2 halfSize = (rect.zw - rect.xy) * 0.5; | |||
| 131 | + | radius = min(radius, min(halfSize.x, halfSize.y)); | |||
| 132 | + | vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius; | |||
| 133 | + | if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0; | |||
| 134 | + | float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; | |||
| 135 | + | return clamp(0.5 - sd, 0.0, 1.0); | |||
| 136 | + | } | |||
| 137 | + | ||||
| 124 | void main() { | 138 | void main() { | ||
| 125 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | 139 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | ||
| 126 | bool exact = (d.misc.y & flagExact) != 0u; | 140 | bool exact = (d.misc.y & flagExact) != 0u; | ||
| @@ -150,5 +164,9 @@ void main() { | |||||
| 150 | // The surface's alpha modifier; premultiplied, it scales all four. | 164 | // The surface's alpha modifier; premultiplied, it scales all four. | ||
| 151 | c *= d.color.a; | 165 | c *= d.color.a; | ||
| 152 | } | 166 | } | ||
| 167 | + | if ((d.misc.y & flagRounded) != 0u) { | |||
| 168 | + | c *= coverage(d.round, d.radii.x); | |||
| 169 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |||
| 170 | + | } | |||
| 153 | color = c; | 171 | color = c; | ||
| 154 | } | 172 | } | ||
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/adapters/vulkan/shaders/compose.vert +9 -1
| @@ -10,7 +10,9 @@ struct Draw { | |||
| 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 +18 -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 | |
| @@ -46,6 +49,8 @@ const uint flagP010 = 32u; | |||
| 46 | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | |
| 47 | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | |
| 48 | 51 | const uint flagSmooth = 64u; | |
| 52 | + | const uint flagRounded = 128u; | |
| 53 | + | const uint flagRing = 256u; | |
| 49 | 54 | ||
| 50 | 55 | // The HDR composition target holds linear BT.709 in SDR-white units. | |
| 51 | 56 | vec3 decodeSRGB(vec3 v) { | |
| @@ -120,6 +125,15 @@ vec4 sampleBuffer(vec2 src, bool exact) { | |||
| 120 | 125 | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | |
| 121 | 126 | } | |
| 122 | 127 | ||
| 128 | + | float coverage(vec4 rect, float radius) { | |
| 129 | + | vec2 halfSize = (rect.zw - rect.xy) * 0.5; | |
| 130 | + | radius = min(radius, min(halfSize.x, halfSize.y)); | |
| 131 | + | vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius; | |
| 132 | + | if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0; | |
| 133 | + | float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; | |
| 134 | + | return clamp(0.5 - sd, 0.0, 1.0); | |
| 135 | + | } | |
| 136 | + | ||
| 123 | 137 | void main() { | |
| 124 | 138 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | |
| 125 | 139 | bool exact = (d.misc.y & flagExact) != 0u; | |
| @@ -149,5 +163,9 @@ void main() { | |||
| 149 | 163 | // The surface's alpha modifier; premultiplied, it scales all four. | |
| 150 | 164 | c *= d.color.a; | |
| 151 | 165 | } | |
| 166 | + | if ((d.misc.y & flagRounded) != 0u) { | |
| 167 | + | c *= coverage(d.round, d.radii.x); | |
| 168 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |
| 169 | + | } | |
| 152 | 170 | color = c; | |
| 153 | 171 | } | |
| @@ -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 | ||
| @@ -46,6 +49,8 @@ const uint flagP010 = 32u; | |||||
| 46 | // flagSmooth averages four samples a quarter of a target pixel around the | 49 | // flagSmooth averages four samples a quarter of a target pixel around the | ||
| 47 | // point: a thumbnail shrunk under half size keeps thin lines and text. | 50 | // point: a thumbnail shrunk under half size keeps thin lines and text. | ||
| 48 | const uint flagSmooth = 64u; | 51 | const uint flagSmooth = 64u; | ||
| 52 | + | const uint flagRounded = 128u; | |||
| 53 | + | const uint flagRing = 256u; | |||
| 49 | 54 | ||||
| 50 | // The HDR composition target holds linear BT.709 in SDR-white units. | 55 | // The HDR composition target holds linear BT.709 in SDR-white units. | ||
| 51 | vec3 decodeSRGB(vec3 v) { | 56 | vec3 decodeSRGB(vec3 v) { | ||
| @@ -120,6 +125,15 @@ vec4 sampleBuffer(vec2 src, bool exact) { | |||||
| 120 | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | 125 | return mix(mix(texel(i), texel(i + ivec2(1, 0)), f.x), mix(texel(i + ivec2(0, 1)), texel(i + ivec2(1, 1)), f.x), f.y); | ||
| 121 | } | 126 | } | ||
| 122 | 127 | ||||
| 128 | + | float coverage(vec4 rect, float radius) { | |||
| 129 | + | vec2 halfSize = (rect.zw - rect.xy) * 0.5; | |||
| 130 | + | radius = min(radius, min(halfSize.x, halfSize.y)); | |||
| 131 | + | vec2 q = abs(gl_FragCoord.xy - (rect.xy + rect.zw) * 0.5) - halfSize + radius; | |||
| 132 | + | if ((q.x < 0.0 || q.y < 0.0) && max(q.x, q.y) <= radius - 0.5) return 1.0; | |||
| 133 | + | float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; | |||
| 134 | + | return clamp(0.5 - sd, 0.0, 1.0); | |||
| 135 | + | } | |||
| 136 | + | ||||
| 123 | void main() { | 137 | void main() { | ||
| 124 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | 138 | vec2 src = d.map.xy + gl_FragCoord.x * d.map.zw + gl_FragCoord.y * d.mapy.xy; | ||
| 125 | bool exact = (d.misc.y & flagExact) != 0u; | 139 | bool exact = (d.misc.y & flagExact) != 0u; | ||
| @@ -149,5 +163,9 @@ void main() { | |||||
| 149 | // The surface's alpha modifier; premultiplied, it scales all four. | 163 | // The surface's alpha modifier; premultiplied, it scales all four. | ||
| 150 | c *= d.color.a; | 164 | c *= d.color.a; | ||
| 151 | } | 165 | } | ||
| 166 | + | if ((d.misc.y & flagRounded) != 0u) { | |||
| 167 | + | c *= coverage(d.round, d.radii.x); | |||
| 168 | + | if ((d.misc.y & flagRing) != 0u) c *= 1.0 - coverage(d.roundInner, d.radii.y); | |||
| 169 | + | } | |||
| 152 | color = c; | 170 | color = c; | ||
| 153 | } | 171 | } | ||
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/adapters/vulkan/shaders/compose_hdr_single.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/adapters/vulkan/shaders/compose_single.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/core/border_invariants_test.go +65 -5
| @@ -207,7 +207,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) { | |||
| 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,60 @@ func TestBorderMoveBackAlone(t *testing.T) { | |||
| 304 | 307 | } | |
| 305 | 308 | } | |
| 306 | 309 | } | |
| 310 | + | ||
| 311 | + | func TestBorderRadiusScene(t *testing.T) { | |
| 312 | + | for _, gap := range []int{0, 8} { | |
| 313 | + | t.Run(map[int]string{0: "flush", 8: "gaps"}[gap], func(t *testing.T) { | |
| 314 | + | r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = gap; c.Border.Radius = 10 }, left, right) | |
| 315 | + | scenes := r.mapWindow(t, 1) | |
| 316 | + | for _, s := range scenes { | |
| 317 | + | for _, w := range s.Windows { | |
| 318 | + | if w.ID == 1 { | |
| 319 | + | want := 0 | |
| 320 | + | if gap > 0 { | |
| 321 | + | want = 10 | |
| 322 | + | } | |
| 323 | + | if w.Radius != want || s.Border.Radius != 10 { | |
| 324 | + | t.Fatalf("radius %d, style %d", w.Radius, s.Border.Radius) | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | for _, action := range []struct { | |
| 330 | + | sym string | |
| 331 | + | mods ports.Mods | |
| 332 | + | want int | |
| 333 | + | }{ | |
| 334 | + | {"space", ports.ModAlt | ports.ModShift, 10}, | |
| 335 | + | {"f", ports.ModAlt | ports.ModShift, 0}, | |
| 336 | + | } { | |
| 337 | + | for _, s := range r.key(t, action.sym, action.mods) { | |
| 338 | + | for _, w := range s.Windows { | |
| 339 | + | if w.ID == 1 && w.Radius != action.want { | |
| 340 | + | t.Fatalf("%s radius %d, want %d", action.sym, w.Radius, action.want) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | }) | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // A reload that only changes border.radius republishes the scene with the | |
| 350 | + | // new radius. | |
| 351 | + | func TestReloadBorderRadiusRepublishes(t *testing.T) { | |
| 352 | + | r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 8; c.Border.Radius = 4 }, left) | |
| 353 | + | r.mapWindow(t, 1) | |
| 354 | + | cfg := r.cfg | |
| 355 | + | cfg.Border.Radius = 12 | |
| 356 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 357 | + | s := scene(t, r.scenes) | |
| 358 | + | if s.Border.Radius != 12 { | |
| 359 | + | t.Fatalf("style radius %d, want 12", s.Border.Radius) | |
| 360 | + | } | |
| 361 | + | for _, w := range s.Windows { | |
| 362 | + | if w.ID == 1 && w.Radius != 12 { | |
| 363 | + | t.Fatalf("window radius %d, want 12", w.Radius) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | } | |
| @@ -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,60 @@ func TestBorderMoveBackAlone(t *testing.T) { | |||||
| 304 | } | 307 | } | ||
| 305 | } | 308 | } | ||
| 306 | } | 309 | } | ||
| 310 | + | ||||
| 311 | + | func TestBorderRadiusScene(t *testing.T) { | |||
| 312 | + | for _, gap := range []int{0, 8} { | |||
| 313 | + | t.Run(map[int]string{0: "flush", 8: "gaps"}[gap], func(t *testing.T) { | |||
| 314 | + | r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = gap; c.Border.Radius = 10 }, left, right) | |||
| 315 | + | scenes := r.mapWindow(t, 1) | |||
| 316 | + | for _, s := range scenes { | |||
| 317 | + | for _, w := range s.Windows { | |||
| 318 | + | if w.ID == 1 { | |||
| 319 | + | want := 0 | |||
| 320 | + | if gap > 0 { | |||
| 321 | + | want = 10 | |||
| 322 | + | } | |||
| 323 | + | if w.Radius != want || s.Border.Radius != 10 { | |||
| 324 | + | t.Fatalf("radius %d, style %d", w.Radius, s.Border.Radius) | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | for _, action := range []struct { | |||
| 330 | + | sym string | |||
| 331 | + | mods ports.Mods | |||
| 332 | + | want int | |||
| 333 | + | }{ | |||
| 334 | + | {"space", ports.ModAlt | ports.ModShift, 10}, | |||
| 335 | + | {"f", ports.ModAlt | ports.ModShift, 0}, | |||
| 336 | + | } { | |||
| 337 | + | for _, s := range r.key(t, action.sym, action.mods) { | |||
| 338 | + | for _, w := range s.Windows { | |||
| 339 | + | if w.ID == 1 && w.Radius != action.want { | |||
| 340 | + | t.Fatalf("%s radius %d, want %d", action.sym, w.Radius, action.want) | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | }) | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // A reload that only changes border.radius republishes the scene with the | |||
| 350 | + | // new radius. | |||
| 351 | + | func TestReloadBorderRadiusRepublishes(t *testing.T) { | |||
| 352 | + | r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 8; c.Border.Radius = 4 }, left) | |||
| 353 | + | r.mapWindow(t, 1) | |||
| 354 | + | cfg := r.cfg | |||
| 355 | + | cfg.Border.Radius = 12 | |||
| 356 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 357 | + | s := scene(t, r.scenes) | |||
| 358 | + | if s.Border.Radius != 12 { | |||
| 359 | + | t.Fatalf("style radius %d, want 12", s.Border.Radius) | |||
| 360 | + | } | |||
| 361 | + | for _, w := range s.Windows { | |||
| 362 | + | if w.ID == 1 && w.Radius != 12 { | |||
| 363 | + | t.Fatalf("window radius %d, want 12", w.Radius) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | } | |||
M internal/core/capture_window.go +1 -1
| @@ -53,7 +53,7 @@ func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene { | |||
| 53 | 53 | Security: c.security, | |
| 54 | 54 | Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H}, | |
| 55 | 55 | Scale: v.windowScr.scale, Background: c.cfg.Background.Color, | |
| 56 | - | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | |
| 56 | + | Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | |
| 57 | 57 | Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}}, | |
| 58 | 58 | } | |
| 59 | 59 | for _, id := range c.popupOrder { | |
| @@ -53,7 +53,7 @@ func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene { | |||||
| 53 | Security: c.security, | 53 | Security: c.security, | ||
| 54 | Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H}, | 54 | Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H}, | ||
| 55 | Scale: v.windowScr.scale, Background: c.cfg.Background.Color, | 55 | Scale: v.windowScr.scale, Background: c.cfg.Background.Color, | ||
| 56 | - | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | 56 | + | Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, |
| 57 | Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}}, | 57 | Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}}, | ||
| 58 | } | 58 | } | ||
| 59 | for _, id := range c.popupOrder { | 59 | for _, id := range c.popupOrder { | ||
M internal/core/capture_workspace.go +2 -1
| @@ -153,13 +153,14 @@ func (c *Core) captureScene(seq uint64) *ports.Scene { | |||
| 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 | + | w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius) | |
| 163 | 164 | if !p.Hidden { | |
| 164 | 165 | w.Rect = rebase(p.Rect) | |
| 165 | 166 | } | |
| @@ -153,13 +153,14 @@ func (c *Core) captureScene(seq uint64) *ports.Scene { | |||||
| 153 | Security: c.security, | 153 | Security: c.security, | ||
| 154 | Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H}, | 154 | Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H}, | ||
| 155 | Scale: cw.sc.scale, Background: c.cfg.Background.Color, | 155 | Scale: cw.sc.scale, Background: c.cfg.Background.Color, | ||
| 156 | - | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | 156 | + | Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, |
| 157 | Windows: make([]ports.SceneWindow, 0, len(cw.placements)), | 157 | Windows: make([]ports.SceneWindow, 0, len(cw.placements)), | ||
| 158 | Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim), | 158 | Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim), | ||
| 159 | } | 159 | } | ||
| 160 | // A hidden workspace lights nothing: its focus is not shown. | 160 | // A hidden workspace lights nothing: its focus is not shown. | ||
| 161 | for _, p := range cw.placements { | 161 | for _, p := range cw.placements { | ||
| 162 | w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | 162 | w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | ||
| 163 | + | w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius) | |||
| 163 | if !p.Hidden { | 164 | if !p.Hidden { | ||
| 164 | w.Rect = rebase(p.Rect) | 165 | w.Rect = rebase(p.Rect) | ||
| 165 | } | 166 | } | ||
M internal/core/capture_workspace_test.go +31 -0
| @@ -270,3 +270,34 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) { | |||
| 270 | 270 | } | |
| 271 | 271 | } | |
| 272 | 272 | } | |
| 273 | + | ||
| 274 | + | // The workspace capture scene carries the corner radius like the live one: | |
| 275 | + | // a tile with gaps and a float are rounded. | |
| 276 | + | func TestCaptureSceneRadius(t *testing.T) { | |
| 277 | + | c, ws := hiddenCapture(t) | |
| 278 | + | c.cfg.Border.Radius = 9 | |
| 279 | + | ws.Gaps = 4 | |
| 280 | + | ws.AddFloating(3, 20, 20) | |
| 281 | + | c.configures.cw.load(c.screens[0], ws) | |
| 282 | + | s := c.captureScene(1) | |
| 283 | + | if s == nil || s.Border.Radius != 9 { | |
| 284 | + | t.Fatalf("scene %+v", s) | |
| 285 | + | } | |
| 286 | + | var tiles, floats int | |
| 287 | + | for _, w := range s.Windows { | |
| 288 | + | if w.Popup { | |
| 289 | + | continue | |
| 290 | + | } | |
| 291 | + | if w.Radius != 9 { | |
| 292 | + | t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius) | |
| 293 | + | } | |
| 294 | + | if w.Floating { | |
| 295 | + | floats++ | |
| 296 | + | } else { | |
| 297 | + | tiles++ | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | if tiles == 0 || floats == 0 { | |
| 301 | + | t.Fatalf("tiles %d floats %d", tiles, floats) | |
| 302 | + | } | |
| 303 | + | } | |
| @@ -270,3 +270,34 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) { | |||||
| 270 | } | 270 | } | ||
| 271 | } | 271 | } | ||
| 272 | } | 272 | } | ||
| 273 | + | ||||
| 274 | + | // The workspace capture scene carries the corner radius like the live one: | |||
| 275 | + | // a tile with gaps and a float are rounded. | |||
| 276 | + | func TestCaptureSceneRadius(t *testing.T) { | |||
| 277 | + | c, ws := hiddenCapture(t) | |||
| 278 | + | c.cfg.Border.Radius = 9 | |||
| 279 | + | ws.Gaps = 4 | |||
| 280 | + | ws.AddFloating(3, 20, 20) | |||
| 281 | + | c.configures.cw.load(c.screens[0], ws) | |||
| 282 | + | s := c.captureScene(1) | |||
| 283 | + | if s == nil || s.Border.Radius != 9 { | |||
| 284 | + | t.Fatalf("scene %+v", s) | |||
| 285 | + | } | |||
| 286 | + | var tiles, floats int | |||
| 287 | + | for _, w := range s.Windows { | |||
| 288 | + | if w.Popup { | |||
| 289 | + | continue | |||
| 290 | + | } | |||
| 291 | + | if w.Radius != 9 { | |||
| 292 | + | t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius) | |||
| 293 | + | } | |||
| 294 | + | if w.Floating { | |||
| 295 | + | floats++ | |||
| 296 | + | } else { | |||
| 297 | + | tiles++ | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | if tiles == 0 || floats == 0 { | |||
| 301 | + | t.Fatalf("tiles %d floats %d", tiles, floats) | |||
| 302 | + | } | |||
| 303 | + | } | |||
M internal/core/core_test.go +1 -1
| @@ -124,7 +124,7 @@ func owner(t *testing.T, animated bool) { | |||
| 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 +5 -2
| @@ -60,8 +60,9 @@ type Placement struct { | |||
| 60 | 60 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | |
| 61 | 61 | Preview float64 | |
| 62 | 62 | // Neighbors are the sides touching a tile visible in the usable area. | |
| 63 | - | // Inset reserves room for drawn lines: all sides of a float; for | |
| 64 | - | // tiles without gaps, only the right and bottom shared sides. | |
| 63 | + | // Inset reserves room for drawn lines: all sides of a float; all sides | |
| 64 | + | // of a tile with gaps; only the right and bottom shared sides of a tile | |
| 65 | + | // without gaps. | |
| 65 | 66 | Neighbors, Inset ports.Sides | |
| 66 | 67 | // Leaving marks a window that closed or hid but is still drawn while | |
| 67 | 68 | // it fades out (transition.go). It stays Hidden, so input, focus, | |
| @@ -1988,6 +1989,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) { | |||
| 1988 | 1989 | a.Inset = a.Neighbors | |
| 1989 | 1990 | if gap == 0 { | |
| 1990 | 1991 | a.Inset &= ports.SideRight | ports.SideBottom | |
| 1992 | + | } else { | |
| 1993 | + | a.Inset = ports.SideAll | |
| 1991 | 1994 | } | |
| 1992 | 1995 | } | |
| 1993 | 1996 | } | |
| @@ -60,8 +60,9 @@ type Placement struct { | |||||
| 60 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | 60 | // that much smaller in Rect, the client keeps its size. 0 otherwise. | ||
| 61 | Preview float64 | 61 | Preview float64 | ||
| 62 | // Neighbors are the sides touching a tile visible in the usable area. | 62 | // Neighbors are the sides touching a tile visible in the usable area. | ||
| 63 | - | // Inset reserves room for drawn lines: all sides of a float; for | 63 | + | // Inset reserves room for drawn lines: all sides of a float; all sides |
| 64 | - | // tiles without gaps, only the right and bottom shared sides. | 64 | + | // of a tile with gaps; only the right and bottom shared sides of a tile |
| 65 | + | // without gaps. | |||
| 65 | Neighbors, Inset ports.Sides | 66 | Neighbors, Inset ports.Sides | ||
| 66 | // Leaving marks a window that closed or hid but is still drawn while | 67 | // Leaving marks a window that closed or hid but is still drawn while | ||
| 67 | // it fades out (transition.go). It stays Hidden, so input, focus, | 68 | // it fades out (transition.go). It stays Hidden, so input, focus, | ||
| @@ -1988,6 +1989,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) { | |||||
| 1988 | a.Inset = a.Neighbors | 1989 | a.Inset = a.Neighbors | ||
| 1989 | if gap == 0 { | 1990 | if gap == 0 { | ||
| 1990 | a.Inset &= ports.SideRight | ports.SideBottom | 1991 | a.Inset &= ports.SideRight | ports.SideBottom | ||
| 1992 | + | } else { | |||
| 1993 | + | a.Inset = ports.SideAll | |||
| 1991 | } | 1994 | } | ||
| 1992 | } | 1995 | } | ||
| 1993 | } | 1996 | } | ||
M internal/core/layout_test.go +1 -1
| @@ -151,7 +151,7 @@ func TestStackAndLayout(t *testing.T) { | |||
| 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 +2 -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,7 @@ 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 | + | sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius) | |
| 75 | 76 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | |
| 76 | 77 | // A scale motion: the content follows the drawn size. A | |
| 77 | 78 | // 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,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 72 | // Only the focused output has an activated window. | 72 | // Only the focused output has an activated window. | ||
| 73 | focused := p.Focused && i == c.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 | + | sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius) | |||
| 75 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | 76 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | ||
| 76 | // A scale motion: the content follows the drawn size. A | 77 | // A scale motion: the content follows the drawn size. A | ||
| 77 | // card in flight drawn at its size still needs Zoom 1: | 78 | // card in flight drawn at its size still needs Zoom 1: | ||
M internal/core/separators.go +29 -13
| @@ -2,17 +2,18 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import "github.com/bnema/neferwl/internal/ports" | |
| 4 | 4 | ||
| 5 | - | // Like tmux screen-redraw.c, lines separate visible panes, never outline a | |
| 6 | - | // lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line, | |
| 7 | - | // owned (and inset) by the left/top tile; focus lights only the adjacent | |
| 8 | - | // sides of the focused tile, including corners. With exactly two visible | |
| 9 | - | // tiles and no gaps, each lights its own half of the shared line (left/top | |
| 10 | - | // first). With gaps, each tile owns its neighbor-facing lines and lights | |
| 11 | - | // them whole. Hidden, scrolled-off, under-panel, other-workspace and | |
| 5 | + | // Two modes. Without gaps (gap 0), borders are tmux-style lines (like tmux | |
| 6 | + | // screen-redraw.c): lines separate visible panes, never outline a lone tile | |
| 7 | + | // or a fullscreen/zoomed tile. Adjacent tiles share one line, owned (and | |
| 8 | + | // inset) by the left/top tile; focus lights only the adjacent sides of the | |
| 9 | + | // focused tile, including corners. With exactly two visible tiles, each | |
| 10 | + | // lights its own half of the shared line (left/top first). With gaps, each | |
| 11 | + | // tile owns a full four-sided border, like a float, and lights it whole, | |
| 12 | + | // even when alone. Hidden, scrolled-off, under-panel, other-workspace and | |
| 12 | 13 | // other-output tiles do not count, nor do tiles that share no line. Lines | |
| 13 | - | // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles: | |
| 14 | - | // each gets its own full border, drawn with that window (Separator.Window), | |
| 15 | - | // even when it is the only visible window. Only the | |
| 14 | + | // at the edges of the usable area (output or panels) are omitted. Floating | |
| 15 | + | // windows also get their own full border, drawn with that window | |
| 16 | + | // (Separator.Window), even when it is the only visible window. Only the | |
| 16 | 17 | // focused output's focused window can light lines; keyboard grabs by popups | |
| 17 | 18 | // and layers do not change the underlying window's visual focus. | |
| 18 | 19 | ||
| @@ -39,7 +40,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||
| 39 | 40 | } | |
| 40 | 41 | r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} | |
| 41 | 42 | var id WindowID | |
| 42 | - | if p.Floating { | |
| 43 | + | if p.Floating || gap > 0 { | |
| 43 | 44 | id = p.ID | |
| 44 | 45 | } | |
| 45 | 46 | if out == nil { | |
| @@ -58,6 +59,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||
| 58 | 59 | if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) { | |
| 59 | 60 | continue | |
| 60 | 61 | } | |
| 62 | + | if gap > 0 { | |
| 63 | + | for _, s := range sides { | |
| 64 | + | add(p, strip(p.Rect, s, 0, width), p.Focused && lit) | |
| 65 | + | } | |
| 66 | + | continue | |
| 67 | + | } | |
| 61 | 68 | // Tiles sharing a line: Neighbors only counts tiles visible in | |
| 62 | 69 | // the usable area, not those under a panel. | |
| 63 | 70 | if !p.Floating && p.Neighbors != 0 { | |
| @@ -91,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||
| 91 | 98 | continue | |
| 92 | 99 | } | |
| 93 | 100 | line = closeCorners(line, *f, s, width) | |
| 94 | - | // Two tiles share one line only without gaps. | |
| 95 | - | if tiles == 2 && gap == 0 { | |
| 101 | + | // With gaps the loop above continued, so two tiles share one line. | |
| 102 | + | if tiles == 2 { | |
| 96 | 103 | line = half(line, s) | |
| 97 | 104 | } | |
| 98 | 105 | add(f, line, true) | |
| @@ -152,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect { | |||
| 152 | 159 | } | |
| 153 | 160 | return r | |
| 154 | 161 | } | |
| 162 | + | ||
| 163 | + | // windowRadius is the corner radius of p: floats always, tiles only with | |
| 164 | + | // gaps, never fullscreen windows. | |
| 165 | + | func windowRadius(p Placement, gap, radius int) int { | |
| 166 | + | if !p.Fullscreen && (p.Floating || gap > 0) { | |
| 167 | + | return radius | |
| 168 | + | } | |
| 169 | + | return 0 | |
| 170 | + | } | |
| @@ -2,17 +2,18 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import "github.com/bnema/neferwl/internal/ports" | 3 | import "github.com/bnema/neferwl/internal/ports" | ||
| 4 | 4 | ||||
| 5 | - | // Like tmux screen-redraw.c, lines separate visible panes, never outline a | 5 | + | // Two modes. Without gaps (gap 0), borders are tmux-style lines (like tmux |
| 6 | - | // lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line, | 6 | + | // screen-redraw.c): lines separate visible panes, never outline a lone tile |
| 7 | - | // owned (and inset) by the left/top tile; focus lights only the adjacent | 7 | + | // or a fullscreen/zoomed tile. Adjacent tiles share one line, owned (and |
| 8 | - | // sides of the focused tile, including corners. With exactly two visible | 8 | + | // inset) by the left/top tile; focus lights only the adjacent sides of the |
| 9 | - | // tiles and no gaps, each lights its own half of the shared line (left/top | 9 | + | // focused tile, including corners. With exactly two visible tiles, each |
| 10 | - | // first). With gaps, each tile owns its neighbor-facing lines and lights | 10 | + | // lights its own half of the shared line (left/top first). With gaps, each |
| 11 | - | // them whole. Hidden, scrolled-off, under-panel, other-workspace and | 11 | + | // tile owns a full four-sided border, like a float, and lights it whole, |
| 12 | + | // even when alone. Hidden, scrolled-off, under-panel, other-workspace and | |||
| 12 | // other-output tiles do not count, nor do tiles that share no line. Lines | 13 | // other-output tiles do not count, nor do tiles that share no line. Lines | ||
| 13 | - | // at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles: | 14 | + | // at the edges of the usable area (output or panels) are omitted. Floating |
| 14 | - | // each gets its own full border, drawn with that window (Separator.Window), | 15 | + | // windows also get their own full border, drawn with that window |
| 15 | - | // even when it is the only visible window. Only the | 16 | + | // (Separator.Window), even when it is the only visible window. Only the |
| 16 | // focused output's focused window can light lines; keyboard grabs by popups | 17 | // focused output's focused window can light lines; keyboard grabs by popups | ||
| 17 | // and layers do not change the underlying window's visual focus. | 18 | // and layers do not change the underlying window's visual focus. | ||
| 18 | 19 | ||||
| @@ -39,7 +40,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||||
| 39 | } | 40 | } | ||
| 40 | r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} | 41 | r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} | ||
| 41 | var id WindowID | 42 | var id WindowID | ||
| 42 | - | if p.Floating { | 43 | + | if p.Floating || gap > 0 { |
| 43 | id = p.ID | 44 | id = p.ID | ||
| 44 | } | 45 | } | ||
| 45 | if out == nil { | 46 | if out == nil { | ||
| @@ -58,6 +59,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||||
| 58 | if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) { | 59 | if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) { | ||
| 59 | continue | 60 | continue | ||
| 60 | } | 61 | } | ||
| 62 | + | if gap > 0 { | |||
| 63 | + | for _, s := range sides { | |||
| 64 | + | add(p, strip(p.Rect, s, 0, width), p.Focused && lit) | |||
| 65 | + | } | |||
| 66 | + | continue | |||
| 67 | + | } | |||
| 61 | // Tiles sharing a line: Neighbors only counts tiles visible in | 68 | // Tiles sharing a line: Neighbors only counts tiles visible in | ||
| 62 | // the usable area, not those under a panel. | 69 | // the usable area, not those under a panel. | ||
| 63 | if !p.Floating && p.Neighbors != 0 { | 70 | if !p.Floating && p.Neighbors != 0 { | ||
| @@ -91,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se | |||||
| 91 | continue | 98 | continue | ||
| 92 | } | 99 | } | ||
| 93 | line = closeCorners(line, *f, s, width) | 100 | line = closeCorners(line, *f, s, width) | ||
| 94 | - | // Two tiles share one line only without gaps. | 101 | + | // With gaps the loop above continued, so two tiles share one line. |
| 95 | - | if tiles == 2 && gap == 0 { | 102 | + | if tiles == 2 { |
| 96 | line = half(line, s) | 103 | line = half(line, s) | ||
| 97 | } | 104 | } | ||
| 98 | add(f, line, true) | 105 | add(f, line, true) | ||
| @@ -152,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect { | |||||
| 152 | } | 159 | } | ||
| 153 | return r | 160 | return r | ||
| 154 | } | 161 | } | ||
| 162 | + | ||||
| 163 | + | // windowRadius is the corner radius of p: floats always, tiles only with | |||
| 164 | + | // gaps, never fullscreen windows. | |||
| 165 | + | func windowRadius(p Placement, gap, radius int) int { | |||
| 166 | + | if !p.Fullscreen && (p.Floating || gap > 0) { | |||
| 167 | + | return radius | |||
| 168 | + | } | |||
| 169 | + | return 0 | |||
| 170 | + | } | |||
M internal/core/separators_test.go +2 -2
| @@ -201,12 +201,12 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) { | |||
| 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 +4 -1
| @@ -121,8 +121,11 @@ type Config struct { | |||
| 121 | 121 | Capture bool | |
| 122 | 122 | SpawnTerminal bool | |
| 123 | 123 | } | |
| 124 | - | // Border is drawn inside the window edge; Width 0 disables it. | |
| 124 | + | // Border is drawn inside the window edge; Width 0 disables it. Radius is | |
| 125 | + | // in logical pixels, >= 0 (at most 64 from the file): it rounds floats | |
| 126 | + | // always and tiles when gaps > 0; the renderer uses SceneWindow.Radius. | |
| 125 | 127 | Border struct { | |
| 128 | + | Radius int | |
| 126 | 129 | Width int | |
| 127 | 130 | Active string | |
| 128 | 131 | Inactive string | |
| @@ -121,8 +121,11 @@ type Config struct { | |||||
| 121 | Capture bool | 121 | Capture bool | ||
| 122 | SpawnTerminal bool | 122 | SpawnTerminal bool | ||
| 123 | } | 123 | } | ||
| 124 | - | // Border is drawn inside the window edge; Width 0 disables it. | 124 | + | // Border is drawn inside the window edge; Width 0 disables it. Radius is |
| 125 | + | // in logical pixels, >= 0 (at most 64 from the file): it rounds floats | |||
| 126 | + | // always and tiles when gaps > 0; the renderer uses SceneWindow.Radius. | |||
| 125 | Border struct { | 127 | Border struct { | ||
| 128 | + | Radius int | |||
| 126 | Width int | 129 | Width int | ||
| 127 | Active string | 130 | Active string | ||
| 128 | Inactive string | 131 | Inactive string | ||
M internal/ports/scene.go +9 -2
| @@ -168,15 +168,19 @@ func (s Scene) TileArea() (r Rect, ok bool) { | |||
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | // Border carries the window border style; colors are #rrggbb, "" skips | |
| 171 | - | // drawing. Width is in logical pixels. | |
| 171 | + | // drawing. Width and Radius are in logical pixels. | |
| 172 | 172 | type Border struct { | |
| 173 | + | // Radius is the corner radius in logical pixels, >= 0. It rounds | |
| 174 | + | // floats always and tiles when gaps > 0; the renderer uses | |
| 175 | + | // SceneWindow.Radius, which core sets per window. | |
| 176 | + | Radius int | |
| 173 | 177 | Width int | |
| 174 | 178 | Active, Inactive string | |
| 175 | 179 | } | |
| 176 | 180 | ||
| 177 | 181 | // Separator is a line between windows, in logical pixels. Active lines | |
| 178 | 182 | // mark the focused window and use Border.Active, others Border.Inactive. | |
| 179 | - | // Window is the float whose border it is; 0 for lines between tiles. | |
| 183 | + | // Window owns the border (floats and tiles with gaps); 0 for shared tile lines. | |
| 180 | 184 | type Separator struct { | |
| 181 | 185 | Rect Rect | |
| 182 | 186 | Active bool | |
| @@ -203,6 +207,9 @@ type FocusFlash struct { | |||
| 203 | 207 | ||
| 204 | 208 | // SceneWindow carries core → renderer window placement. | |
| 205 | 209 | type SceneWindow struct { | |
| 210 | + | // Radius rounds the window corners in logical pixels; zero is square. | |
| 211 | + | // Core sets Border.Radius here for floats and tiles with gaps. | |
| 212 | + | Radius int | |
| 206 | 213 | ID WindowID | |
| 207 | 214 | Rect Rect | |
| 208 | 215 | Focused, Fullscreen, Hidden bool | |
| @@ -168,15 +168,19 @@ func (s Scene) TileArea() (r Rect, ok bool) { | |||||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | // Border carries the window border style; colors are #rrggbb, "" skips | 170 | // Border carries the window border style; colors are #rrggbb, "" skips | ||
| 171 | - | // drawing. Width is in logical pixels. | 171 | + | // drawing. Width and Radius are in logical pixels. |
| 172 | type Border struct { | 172 | type Border struct { | ||
| 173 | + | // Radius is the corner radius in logical pixels, >= 0. It rounds | |||
| 174 | + | // floats always and tiles when gaps > 0; the renderer uses | |||
| 175 | + | // SceneWindow.Radius, which core sets per window. | |||
| 176 | + | Radius int | |||
| 173 | Width int | 177 | Width int | ||
| 174 | Active, Inactive string | 178 | Active, Inactive string | ||
| 175 | } | 179 | } | ||
| 176 | 180 | ||||
| 177 | // Separator is a line between windows, in logical pixels. Active lines | 181 | // Separator is a line between windows, in logical pixels. Active lines | ||
| 178 | // mark the focused window and use Border.Active, others Border.Inactive. | 182 | // mark the focused window and use Border.Active, others Border.Inactive. | ||
| 179 | - | // Window is the float whose border it is; 0 for lines between tiles. | 183 | + | // Window owns the border (floats and tiles with gaps); 0 for shared tile lines. |
| 180 | type Separator struct { | 184 | type Separator struct { | ||
| 181 | Rect Rect | 185 | Rect Rect | ||
| 182 | Active bool | 186 | Active bool | ||
| @@ -203,6 +207,9 @@ type FocusFlash struct { | |||||
| 203 | 207 | ||||
| 204 | // SceneWindow carries core → renderer window placement. | 208 | // SceneWindow carries core → renderer window placement. | ||
| 205 | type SceneWindow struct { | 209 | type SceneWindow struct { | ||
| 210 | + | // Radius rounds the window corners in logical pixels; zero is square. | |||
| 211 | + | // Core sets Border.Radius here for floats and tiles with gaps. | |||
| 212 | + | Radius int | |||
| 206 | ID WindowID | 213 | ID WindowID | ||
| 207 | Rect Rect | 214 | Rect Rect | ||
| 208 | Focused, Fullscreen, Hidden bool | 215 | Focused, Fullscreen, Hidden bool | ||