From 9a084403477b86011b8efba7b65bdfc2b10aeaf5 Mon Sep 17 00:00:00 2001 From: sdolbec Date: Tue, 27 Oct 2026 14:41:37 -0400 Subject: [PATCH 1/2] Compositor: rounded windows that hug the display, token-driven radius (rebased from #623 without build artifacts); screenshots skip the screen-corner mask Co-Authored-By: Claude Sonnet 5.5 --- mseries/BUG-FIX-ROADMAP.md | 2 +- mseries/SDK-CORNERS.md | 35 +- rust/Cargo.lock | 1 + rust/msdk/data/tokens.json | 4 +- rust/pantheon-compositor/Cargo.toml | 1 + rust/pantheon-compositor/src/corner_render.rs | 584 ++++++++++++++++ rust/pantheon-compositor/src/corners.rs | 655 ++++++++++++++++++ rust/pantheon-compositor/src/cursor.rs | 79 ++- rust/pantheon-compositor/src/main.rs | 3 +- rust/pantheon-compositor/src/native.rs | 18 + rust/pantheon-compositor/src/popup_stack.rs | 51 +- .../pantheon-compositor/src/screen_corners.rs | 269 ------- .../src/screenshot_pixels.rs | 6 +- rust/pantheon-compositor/src/state.rs | 9 +- 14 files changed, 1401 insertions(+), 316 deletions(-) create mode 100644 rust/pantheon-compositor/src/corner_render.rs create mode 100644 rust/pantheon-compositor/src/corners.rs delete mode 100644 rust/pantheon-compositor/src/screen_corners.rs diff --git a/mseries/BUG-FIX-ROADMAP.md b/mseries/BUG-FIX-ROADMAP.md index 50159c000..0f55a528e 100644 --- a/mseries/BUG-FIX-ROADMAP.md +++ b/mseries/BUG-FIX-ROADMAP.md @@ -180,7 +180,7 @@ Ranked by how often the owner hits it, how small the fix is, and what it depends | P2 | Ambient keyboard lighting has a successful sensor/API check, but lock/lid transitions and suspend/resume are not qualified. | Verify that the helper leaves the light alone while locked or closed, then resumes safely after unlock/open and suspend. | Needs interactive check | | P2 | The transformed desktop can play through the Apple speaker safety/DSP chain using packages already available in Ubuntu resolute, but the factory image does not install them or the matching PipeWire LV2 plugin. Correction 2026-10-13: Ubuntu does ship that plugin, in `libspa-0.2-modules-extra`, so it can be installed rather than built; untested with the Asahi chain. AVD firmware and its VA-API bridge are also absent. | Package the tested audio chain into the Ubuntu image without bypassing speakersafetyd; separately identify compatible AVD kernel, firmware, userspace sources, and licensing. | Image packaging gap | | P2 | Wi-Fi firmware provisioning and resume recovery are present in the image layer but lack a fresh end-to-end test on an M1 Pro booted from that image. | Confirm firmware source, connect, suspend/resume repeatedly, and capture service/kernel logs from the factory image. | Needs image test | -| P2 | App corners do not yet match the MacBook Pro's built-in panel curve. The user-session override had a 20 logical px display mask and a 12 logical px Gala window clip, while the GTK CSD theme used 20 px. On 2026-10-22 the Gala clip override was changed to 20 px and the systemd user manager was reloaded; it applies next Gala session. The source now uses a continuous n=4 superellipse approximation for the screen mask and Gala window clip. The physical panel and curve have not been visually checked. | Compare a clean full-screen capture against the panel edge and calibrate one logical-pixel radius and continuous curve for the built-in display, GTK CSD, and Gala clipping. Qualify native GTK, libadwaita, third-party windows, maximized/fullscreen windows, dialogs, popovers, shadows and external monitors. Keep the desktop usable at the panel edge and leave external displays square. Apple HIG does not prescribe one numeric radius for every app window and distinguishes continuous from circular corner styles: [Windows](https://developer.apple.com/design/human-interface-guidelines/windows), [UICornerCurve.continuous](https://developer.apple.com/documentation/uikit/uicornercurve/continuous). | Shared continuous curve approximation added; hardware qualification open | +| P2 | App corners do not yet match the MacBook Pro's built-in panel curve. The user-session override had a 20 logical px display mask and a 12 logical px Gala window clip, while the GTK CSD theme used 20 px. On 2026-10-22 the Gala clip override was changed to 20 px and the systemd user manager was reloaded; it applies next Gala session. The source now uses a continuous n=4 superellipse approximation for the screen mask and Gala window clip. The physical panel and curve have not been visually checked. | Compare a clean full-screen capture against the panel edge and calibrate one logical-pixel radius and continuous curve for the built-in display, GTK CSD, and Gala clipping. Qualify native GTK, libadwaita, third-party windows, maximized/fullscreen windows, dialogs, popovers, shadows and external monitors. Keep the desktop usable at the panel edge and leave external displays square. Apple HIG does not prescribe one numeric radius for every app window and distinguishes continuous from circular corner styles: [Windows](https://developer.apple.com/design/human-interface-guidelines/windows), [UICornerCurve.continuous](https://developer.apple.com/documentation/uikit/uicornercurve/continuous). | Shared continuous curve approximation added; hardware qualification open. **2026-10-27:** the Rust compositor now clips every window to the display curve (rebased PR #555: window clip shader, hug-the-display rule, popups, panels, external outputs square) and draws the screen mask from the same tokens, replacing the provisional mask; one radius (`corners.screen`, now 20 like the earlier session overrides) drives both, `PANTHEON_SCREEN_CORNER_RADIUS` still overrides it. Not installed; needs visual calibration on the built-in display | | P2 | The blue and red palette still feels harsh in use, especially for accents and error/destructive states, despite the existing softened GTK overrides. Current overrides do not necessarily reach every app or toolkit. | Review the reds and blues on the M1 Pro display and move them toward a calmer pastel treatment where appropriate. Apply through shared theme/system settings so native and installed apps follow when supported; check text and icon contrast, selection/focus visibility, and error/warning/success distinction. Record toolkit-specific exceptions rather than assuming one GTK stylesheet covers every app. | New; needs palette review | | P2 | The network usage/speed readouts in the network menu and panel do not always recover correctly when Ethernet is present across suspend/resume or reboot. | Reproduce separately for suspend/resume and reboot with Ethernet connected. Compare the network indicator and panel readout with interface counters before sleep, immediately after wake/startup, and after the interface settles; inspect sampling timer/baseline resets and interface re-enumeration. Verify both displayed rates and the history graph recover without stale spikes or a panel restart. | New; needs reproduction | | P2 | The network speed readout gives no visual cue when traffic is approaching a saturated link. The owner wants a muted pastel red-orange hue at that point. Current counters aggregate physical-interface throughput but do not determine link capacity or upstream service congestion. | Define “saturated” in measurable terms (for example, sustained utilization near the negotiated interface rate, with a short hold and hysteresis to prevent flicker). Decide whether the cue applies per-interface or to the aggregate readout, then test on Wi-Fi and Ethernet. Use a soft red-orange that remains legible and distinguishable from error states; document that interface utilization cannot by itself prove an ISP/WAN bottleneck. | New; needs saturation definition | diff --git a/mseries/SDK-CORNERS.md b/mseries/SDK-CORNERS.md index 19d412ba3..19a1bbb1d 100644 --- a/mseries/SDK-CORNERS.md +++ b/mseries/SDK-CORNERS.md @@ -19,8 +19,8 @@ Logical pixels, shared by SDK apps and meant to be read by the compositor: | Token | Value | Used for | |---|---:|---| -| `screen` | 14 | The built-in display's physical corner (the mask) | -| `window` | 12 | Floating window outline and title band | +| `screen` | 20 | The built-in display's physical corner (the mask) | +| `window` | 20 | Floating window outline and title band | | `popover` | 10 | Menus, popovers | | `card` | 12 | Cards, editors, the Transcript bubbles' parent shapes | | `bubble` | 14 | Chat bubbles | @@ -33,7 +33,12 @@ Logical pixels, shared by SDK apps and meant to be read by the compositor: has a larger radius than the display (`window <= screen`; tested). Maximized and fullscreen SDK windows have radius 0 in their own stylesheet because an app cannot know whether it is on the built-in display or a square external one; the compositor decides (below). -## 3. Compositor contract (not built in the Rust compositor) +> **2026-10-27:** `screen` and `window` are now 20, the value the owner had already set by hand for the display mask, the +> window clip and the GTK theme on 2026-10-22 (the tokens said 14 and 12, so three different radii were in use). The +> compositor draws the screen mask and clips windows from these same tokens; `PANTHEON_SCREEN_CORNER_RADIUS` (0-128, +> `0` = off) overrides `screen` for one session. 20 is still a calibration starting point, not a measured panel radius. + +## 3. Compositor contract (built in the Rust compositor 2026-10-23; see section 7) The compositor is the only layer that can make every app, including third-party and client-side-decorated windows, follow the curve. It should read the same `corners` tokens and implement: @@ -72,3 +77,27 @@ number in `tokens.json` and this file. The current 14 came from eye-tuning (rais 3. Calibration pass and a record of the result. 4. Qualify native GTK, libadwaita, Electron/Chromium and X11 windows, CSD and SSD, maximized, fullscreen, dialogs and popovers, and an external display, with screenshots. + +## 7. Receipt: compositor implementation (2026-10-23, branch `claude/compositor-corners`) + +What it does (`rust/pantheon-compositor`, `src/corners.rs` = numbers and policy, `src/corner_render.rs` = drawing): + +- Reads `corners` from `rust/msdk/data/tokens.json` at start-up through the `msdk` crate (`CornerTokens::load`), including `style`. +- **Screen mask:** four premultiplied-black bitmaps (not a shader) in the four display corners, front-most after the pointer, so above windows, dock, panels and the lock screen; built-in output only. +- **Window clip:** each toplevel's surface elements that touch a corner box are drawn through a GLES texture shader (signed distance of a circle or of a superellipse, antialiased over one pixel). Only the pixels inside the four corner boxes of the *geometry* rectangle change, so client-drawn shadows outside the geometry, the interior and the client's size are untouched. Opaque regions exclude the corner boxes (so the corners blend), and clipped elements are never scanned out directly. `circular` is exponent 2; `continuous` is exponent 3 with the corner box 1.2x the radius (`CONTINUOUS_EXPONENT`, `CONTINUOUS_EXTENT` in `corners.rs`; eye-tuning, like the radii, is the owner's call). +- **Hug the display (geometry based, no placement state):** a toplevel corner within 1 px of a display corner uses `screen`; a corner on a screen edge but not a display corner is square; every other corner uses `window`. This covers maximized (with a top bar the top corners sit under the bar, so they are square and the bottom ones follow the display), fullscreen, tiled halves and quarters, and a floating window dragged into a corner. Radii are capped at half the shorter side. +- **Popups and menus:** `popover` on all four corners (X11 override-redirect windows are treated as popups). Dialogs are toplevels, so `window`. +- **Panels and the dock:** a corner is rounded (`window`) only if it touches no screen edge. A full-width top bar therefore stays square and a floating dock is rounded. +- **Per-output policy:** only the built-in output is rounded; every other output is square (no clipper, no mask, no cost). Built-in means the connector names the display configuration already treats as the laptop panel (`eDP-*`, plus `LVDS-*`, `DSI-*`); the nested development window (`winit`) counts as built-in so the corners can be seen without a second display. + +Verified (2026-10-23): + +- `cargo fmt --check`, `cargo clippy --all-targets -D warnings` (default features and `native-drm`) clean; `cargo test -p pantheon-compositor --features native-drm`: 183 + 19 passed, 28 of them new (which corner gets which radius for floating, maximized with and without the bar, fullscreen, tiled halves and quarters with and without the bar, one pixel of slack, external outputs, popups, bar and dock, tiny surfaces, token loading, the distance function, the mask bitmap and its area). +- The nested compositor starts and draws, in a private Xvfb with its own D-Bus and runtime directory (software GL, not the Asahi GPU): a plain GTK 3 window gets 12 px corners against the wallpaper; the same window maximized and fullscreen is cut at the display curve, flush with the black mask; no shader error in the log. The shader first applied to nothing on the nested output because Winit renders a vertically flipped frame (`Transform::Flipped180`); `gl_FragCoord` is now mapped for `Normal` and `Flipped180` frames. Rotated frames are drawn unclipped. + +Not verified: + +- **Visual acceptance on the built-in display** (curve match against the glass, light/dark, the 14 and 12 values, the continuous shape): needs the owner's eyes. Nothing was installed and nothing was loaded: the owner's running session still has the old compositor binary; the new build lives only in the shared cargo target (`debug/pantheon-compositor`) of the worktree `~/Projects/mseries-corners`. +- The **native DRM path on the Asahi GPU**: it compiles (`MultiRenderer` hands out the render device's GLES renderer and frame) but was not run there. It assumes the native frame is `Normal` (as in Smithay's DRM compositor) and that the clip shader works on the Honeykrisp driver; if the shader fails to compile the log says so and windows stay square. +- Surfaces that are **not** clipped: Snap Assist thumbnails and the workspace/overview gesture previews (scaled copies), and X11 windows are clipped only through the same Wayland surface elements XWayland provides (not separately qualified). Direct scanout of a clipped window is intentionally off while it has rounded corners. Chromium/Electron, libadwaita, CSD with shadows, dialogs and popovers on real apps are not yet qualified (work item 4); a window that straddles two outputs is clipped per output with that output's rules. +- Damage when only the radii change without a geometry change (a token edit) is not tracked; the radii are read once at start-up, so a restart applies a changed token. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a01497e2b..8e57f62d9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2319,6 +2319,7 @@ dependencies = [ "bitflags", "gio", "image", + "msdk", "smithay", "tracing", "tracing-subscriber", diff --git a/rust/msdk/data/tokens.json b/rust/msdk/data/tokens.json index b66a186f8..e0987ccb8 100644 --- a/rust/msdk/data/tokens.json +++ b/rust/msdk/data/tokens.json @@ -70,8 +70,8 @@ "bubble_alpha": 0.22 }, "corners": { - "screen": 14, - "window": 12, + "screen": 20, + "window": 20, "popover": 10, "card": 12, "bubble": 14, diff --git a/rust/pantheon-compositor/Cargo.toml b/rust/pantheon-compositor/Cargo.toml index e737a9d00..f2f64e6df 100644 --- a/rust/pantheon-compositor/Cargo.toml +++ b/rust/pantheon-compositor/Cargo.toml @@ -45,6 +45,7 @@ wayland-server = "0.31.14" zbus = "=5.19.0" gio = "0.22" xcursor = "0.3" +msdk = { path = "../msdk" } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } [dependencies.smithay] diff --git a/rust/pantheon-compositor/src/corner_render.rs b/rust/pantheon-compositor/src/corner_render.rs new file mode 100644 index 000000000..872a48c71 --- /dev/null +++ b/rust/pantheon-compositor/src/corner_render.rs @@ -0,0 +1,584 @@ +//! Drawing the corner system (`corners.rs` decides the numbers). +//! +//! * [`screen_mask_elements`]: four small premultiplied-black bitmaps over the built-in +//! display's corners. Plain memory textures, so they work on every renderer and sit above +//! anything (windows, the dock, the lock screen). +//! * [`RoundedClip`]: wraps a surface element and draws it through a GLES texture shader that +//! multiplies the colour by the coverage of a rounded rectangle (a signed-distance +//! function; circular or superellipse). Only pixels inside the geometry's corner boxes +//! change, so client-drawn shadows outside the geometry and the window interior are +//! untouched, and nothing is resized or cropped. +//! +//! The shader finds a pixel with `gl_FragCoord`. Smithay renders with a projection that puts +//! the top row of the output at `gl_FragCoord.y == 0` for the `Normal` frame transform, so +//! output-local physical coordinates are `gl_FragCoord.xy` directly. Frames with any other +//! transform are drawn unclipped rather than wrongly clipped. + +use std::{cell::RefCell, collections::HashMap}; + +use smithay::{ + backend::{ + allocator::Fourcc, + renderer::{ + Frame, ImportAll, ImportMem, Renderer, RendererSuper, Texture, + element::{ + Element, Id, Kind, RenderElement, UnderlyingStorage, + memory::{MemoryRenderBuffer, MemoryRenderBufferRenderElement}, + surface::WaylandSurfaceRenderElement, + }, + gles::{GlesFrame, GlesRenderer, GlesTexProgram, Uniform, UniformName, UniformType}, + utils::{CommitCounter, DamageSet, OpaqueRegions}, + }, + }, + desktop::Window, + utils::{Buffer as BufferCoords, Logical, Physical, Point, Rectangle, Scale, Size, Transform}, +}; + +use crate::corners::{ + CornerStyle, CornerTokens, OutputPolicy, Radii, is_builtin_output, mask_pixels, panel_radii, + popup_radii, window_radii, +}; + +const SHADER: &str = r#" +//_DEFINES_ +#if defined(EXTERNAL) +#extension GL_OES_EGL_image_external : require +#endif +precision highp float; +#if defined(EXTERNAL) +uniform samplerExternalOES tex; +#else +uniform sampler2D tex; +#endif +uniform float alpha; +varying vec2 v_coords; +#if defined(DEBUG_FLAGS) +uniform float tint; +#endif + +// Geometry rectangle in output-local physical pixels: x, y, width, height. +uniform vec4 clip_rect; +// Corner radii in physical pixels: top-left, top-right, bottom-right, bottom-left. +uniform vec4 clip_radii; +// x: superellipse exponent (2 is a circle), y: corner box size as a multiple of the radius. +uniform vec2 clip_shape; +// x: frame height in pixels, y: 1.0 when the frame is flipped vertically (nested Winit output). +uniform vec2 clip_view; + +// Coverage of the rounded shape at offset d from the arc centre towards the corner. +// Mirrors corners::corner_distance. +float corner_cover(vec2 d, float extent, float n) { + d = max(d, vec2(0.0)); + float dist; + if (n < 2.001) { + dist = length(d) - extent; + } else { + vec2 a = max(d, vec2(0.0001)); + float f = pow(pow(a.x, n) + pow(a.y, n), 1.0 / n); + vec2 g = pow(a / f, vec2(n - 1.0)); + dist = (f - extent) / max(length(g), 0.0001); + } + return clamp(0.5 - dist, 0.0, 1.0); +} + +void main() { + vec4 color = texture2D(tex, v_coords); + +#if defined(NO_ALPHA) + color = vec4(color.rgb, 1.0) * alpha; +#else + color = color * alpha; +#endif + + vec2 p = gl_FragCoord.xy; + if (clip_view.y > 0.5) { + p.y = clip_view.x - p.y; + } + vec2 q = p - clip_rect.xy; + vec2 size = clip_rect.zw; + if (q.x >= 0.0 && q.y >= 0.0 && q.x <= size.x && q.y <= size.y) { + bool left = q.x < size.x * 0.5; + bool top = q.y < size.y * 0.5; + float r = top ? (left ? clip_radii.x : clip_radii.y) + : (left ? clip_radii.w : clip_radii.z); + if (r > 0.0) { + float e = r * clip_shape.y; + vec2 d = vec2(left ? e - q.x : q.x - (size.x - e), + top ? e - q.y : q.y - (size.y - e)); + if (d.x > 0.0 && d.y > 0.0) { + color *= corner_cover(d, e, clip_shape.x); + } + } + } + +#if defined(DEBUG_FLAGS) + if (tint == 1.0) + color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8; +#endif + + gl_FragColor = color; +} +"#; + +/// A renderer that can draw [`RoundedClip`] elements: it is (or wraps) a [`GlesRenderer`]. +pub trait CornerRenderer: Renderer { + /// The compiled clip shader of this renderer's GL context (compiled once, cached in the + /// context). `None` if it failed to compile; windows are then drawn square. + fn corner_program(&mut self) -> Option; + + /// The GLES frame behind a frame of this renderer. + fn gles_frame<'a, 'b, 'c>( + frame: &'c mut ::Frame<'a, 'b>, + ) -> &'c mut GlesFrame<'a, 'b> + where + 'b: 'a, + Self: 'a; +} + +struct CachedProgram(Option); + +/// Compile (once per GL context) the clip shader. +pub fn gles_corner_program(renderer: &mut GlesRenderer) -> Option { + if let Some(cached) = renderer.egl_context().user_data().get::() { + return cached.0.clone(); + } + let compiled = renderer + .compile_custom_texture_shader( + SHADER, + &[ + UniformName::new("clip_rect", UniformType::_4f), + UniformName::new("clip_radii", UniformType::_4f), + UniformName::new("clip_shape", UniformType::_2f), + UniformName::new("clip_view", UniformType::_2f), + ], + ) + .map_err(|error| tracing::error!(%error, "corner clip shader failed to compile; windows stay square")) + .ok(); + renderer + .egl_context() + .user_data() + .insert_if_missing_threadsafe(|| CachedProgram(compiled.clone())); + compiled +} + +impl CornerRenderer for GlesRenderer { + fn corner_program(&mut self) -> Option { + gles_corner_program(self) + } + + fn gles_frame<'a, 'b, 'c>(frame: &'c mut GlesFrame<'a, 'b>) -> &'c mut GlesFrame<'a, 'b> + where + 'b: 'a, + Self: 'a, + { + frame + } +} + +/// A surface element drawn through the corner-clip shader. +#[derive(Debug)] +pub struct RoundedClip { + inner: E, + program: GlesTexProgram, + /// The geometry rectangle in output-local physical pixels. + rect: Rectangle, + /// Corner radii in physical pixels. + radii: Radii, + style: CornerStyle, +} + +impl RoundedClip { + /// The four corner boxes of the geometry rectangle (empty ones for square corners), + /// in output-local physical pixels, clockwise from the top left. + fn corner_boxes(&self) -> [Rectangle; 4] { + corner_boxes(self.rect, self.radii, self.style) + } + + fn uniforms(&self, frame_height: i32, flipped: bool) -> Vec> { + let rect = self.rect; + vec![ + Uniform::new( + "clip_rect", + [ + rect.loc.x as f32, + rect.loc.y as f32, + rect.size.w as f32, + rect.size.h as f32, + ], + ), + Uniform::new( + "clip_radii", + [ + self.radii.top_left, + self.radii.top_right, + self.radii.bottom_right, + self.radii.bottom_left, + ], + ), + Uniform::new( + "clip_shape", + [self.style.exponent(), self.style.extent(1.0)], + ), + Uniform::new( + "clip_view", + [frame_height as f32, if flipped { 1.0 } else { 0.0 }], + ), + ] + } +} + +fn corner_boxes( + rect: Rectangle, + radii: Radii, + style: CornerStyle, +) -> [Rectangle; 4] { + let side = |radius: f32| style.extent(radius).ceil() as i32; + let (tl, tr, br, bl) = ( + side(radii.top_left), + side(radii.top_right), + side(radii.bottom_right), + side(radii.bottom_left), + ); + let (x0, y0) = (rect.loc.x, rect.loc.y); + let (x1, y1) = (x0 + rect.size.w, y0 + rect.size.h); + let boxed = |x, y, side| Rectangle::new((x, y).into(), (side, side).into()); + [ + boxed(x0, y0, tl), + boxed(x1 - tr, y0, tr), + boxed(x1 - br, y1 - br, br), + boxed(x0, y1 - bl, bl), + ] +} + +impl Element for RoundedClip { + fn id(&self) -> &Id { + self.inner.id() + } + + fn current_commit(&self) -> CommitCounter { + self.inner.current_commit() + } + + fn location(&self, scale: Scale) -> Point { + self.inner.location(scale) + } + + fn src(&self) -> Rectangle { + self.inner.src() + } + + fn transform(&self) -> Transform { + self.inner.transform() + } + + fn geometry(&self, scale: Scale) -> Rectangle { + self.inner.geometry(scale) + } + + fn damage_since( + &self, + scale: Scale, + commit: Option, + ) -> DamageSet { + self.inner.damage_since(scale, commit) + } + + /// The inner element's opaque regions without the corner boxes: the corners now have + /// partial alpha, so they must be blended rather than overwritten. + fn opaque_regions(&self, scale: Scale) -> OpaqueRegions { + let inner = self.inner.opaque_regions(scale); + if inner.is_empty() { + return inner; + } + let origin = self.inner.geometry(scale).loc; + let boxes = self + .corner_boxes() + .map(|corner| Rectangle::new(corner.loc - origin, corner.size)); + inner + .iter() + .flat_map(|region| region.subtract_rects(boxes)) + .collect() + } + + fn alpha(&self) -> f32 { + self.inner.alpha() + } + + fn kind(&self) -> Kind { + self.inner.kind() + } +} + +impl RenderElement for RoundedClip +where + R: CornerRenderer, + E: RenderElement, +{ + fn draw( + &self, + frame: &mut R::Frame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque_regions: &[Rectangle], + cache: Option<&smithay::utils::user_data::UserDataMap>, + ) -> Result<(), R::Error> { + // The projection puts output row 0 at `gl_FragCoord.y == 0` for a normal frame and at + // the far end for the vertically flipped frame of the nested Winit output. Rotated + // frames are drawn unclipped. + let flipped = match frame.transformation() { + Transform::Normal => Some(false), + Transform::Flipped180 => Some(true), + _ => None, + }; + if let Some(flipped) = flipped { + let height = frame.output_size().h; + R::gles_frame(frame) + .override_default_tex_program(self.program.clone(), self.uniforms(height, flipped)); + } + let result = self + .inner + .draw(frame, src, dst, damage, opaque_regions, cache); + if flipped.is_some() { + R::gles_frame(frame).clear_tex_program_override(); + } + result + } + + /// Never scanned out directly: the clip needs the compositor's own pass. + fn underlying_storage(&self, _renderer: &mut R) -> Option> { + None + } +} + +/// Everything needed to round the elements of one output's frame. +pub struct Clipper<'a> { + tokens: CornerTokens, + program: GlesTexProgram, + scale: f64, + output: OutputPolicy, + is_panel: Box bool + 'a>, +} + +impl<'a> Clipper<'a> { + /// `None` for outputs that stay square (anything but the built-in panel), when the + /// tokens ask for no rounding, or when the shader is not available. + pub fn new( + renderer: &mut R, + tokens: CornerTokens, + output: &smithay::output::Output, + geometry: Rectangle, + is_panel: impl Fn(&Window) -> bool + 'a, + ) -> Option { + let builtin = is_builtin_output(&output.name()); + if !builtin || tokens.window.max(tokens.screen).max(tokens.popover) <= 0.0 { + return None; + } + Some(Self { + tokens, + program: renderer.corner_program()?, + scale: output.current_scale().fractional_scale(), + output: OutputPolicy { geometry, builtin }, + is_panel: Box::new(is_panel), + }) + } + + /// Radii for a toplevel window (or panel) whose geometry is `rect` in space coordinates. + pub fn toplevel_radii(&self, window: &Window, rect: Rectangle) -> Radii { + if window + .x11_surface() + .is_some_and(|surface| surface.is_override_redirect()) + { + // X11 menus and tooltips are popups in everything but name. + return popup_radii(&self.tokens, rect, self.output.builtin); + } + if (self.is_panel)(window) { + panel_radii(&self.tokens, rect, self.output) + } else { + window_radii(&self.tokens, rect, self.output) + } + } + + pub fn popup_radii(&self, rect: Rectangle) -> Radii { + popup_radii(&self.tokens, rect, self.output.builtin) + } + + /// Wrap the elements of one surface tree whose geometry is `rect` (space coordinates) + /// with `radii`. Elements clear of every corner box stay plain. + pub fn wrap( + &self, + elements: Vec>, + rect: Rectangle, + radii: Radii, + ) -> Vec + where + R: Renderer + ImportAll, + R::TextureId: Texture + Clone + 'static, + C: From> + From>>, + { + if radii.is_square() { + return elements.into_iter().map(C::from).collect(); + } + let scale = Scale::from(self.scale); + let origin = (rect.loc - self.output.geometry.loc).to_physical_precise_round(scale); + let size: Size = rect.size.to_physical_precise_round(scale); + let rect = Rectangle::new(origin, size); + let radii = radii.scaled(self.scale); + let boxes = corner_boxes(rect, radii, self.tokens.style); + elements + .into_iter() + .map(|element| { + let geometry = element.geometry(scale); + if boxes.iter().any(|corner| geometry.overlaps(*corner)) { + C::from(RoundedClip { + inner: element, + program: self.program.clone(), + rect, + radii, + style: self.tokens.style, + }) + } else { + C::from(element) + } + }) + .collect() + } +} + +/// The four mask bitmaps per output and radius, kept across frames so the damage tracker +/// sees the same buffers (and so repaints nothing) while the radius does not change. +#[derive(Default)] +pub(crate) struct MaskBuffers(RefCell>); + +#[derive(Clone, PartialEq, Eq, Hash)] +struct MaskKey { + output: String, + side: u32, + radius_milli: u32, + style: u8, +} + +/// Flip a square Argb8888 bitmap so the corner lands in another corner of its box. +fn mirrored(pixels: &[u8], side: u32, flip_x: bool, flip_y: bool) -> Vec { + let mut out = Vec::with_capacity(pixels.len()); + for j in 0..side { + for i in 0..side { + let (si, sj) = ( + if flip_x { side - 1 - i } else { i }, + if flip_y { side - 1 - j } else { j }, + ); + let at = ((sj * side + si) * 4) as usize; + out.extend_from_slice(&pixels[at..at + 4]); + } + } + out +} + +/// Four screen-corner mask elements for the built-in output, front-most first. `pixels` is +/// the output's physical size and `scale` its (fractional) scale. +pub(crate) fn screen_mask_elements( + renderer: &mut R, + buffers: &MaskBuffers, + tokens: &CornerTokens, + output: &smithay::output::Output, + pixels: (i32, i32), + scale: f64, +) -> Vec> +where + R: Renderer + ImportMem, + R::TextureId: Texture + Clone + Send + 'static, +{ + if !is_builtin_output(&output.name()) || tokens.screen <= 0.0 { + return Vec::new(); + } + // Logical side of the box, whole so the element's physical size rounds to the bitmap. + let logical_side = tokens.style.extent(tokens.screen).ceil() as i32; + let side = (logical_side as f64 * scale).round() as u32; + let radius = tokens.screen * scale as f32; + if side == 0 || side as i32 > pixels.0.min(pixels.1) { + return Vec::new(); + } + let key = MaskKey { + output: output.name(), + side, + radius_milli: (radius * 1000.0).round() as u32, + style: tokens.style as u8, + }; + let set = buffers + .0 + .borrow_mut() + .entry(key) + .or_insert_with(|| { + let top_left = mask_pixels(radius, side, tokens.style); + [(false, false), (true, false), (true, true), (false, true)].map(|(fx, fy)| { + MemoryRenderBuffer::from_slice( + &mirrored(&top_left, side, fx, fy), + Fourcc::Argb8888, + (side as i32, side as i32), + 1, + Transform::Normal, + None, + ) + }) + }) + .clone(); + let (width, height) = (pixels.0 as f64, pixels.1 as f64); + let far = side as f64; + let at = [ + (0.0, 0.0), + (width - far, 0.0), + (width - far, height - far), + (0.0, height - far), + ]; + set.iter() + .zip(at) + .filter_map(|(buffer, location)| { + MemoryRenderBufferRenderElement::from_buffer( + renderer, + location, + buffer, + None, + None, + Some((logical_side, logical_side).into()), + Kind::Unspecified, + ) + .ok() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mirroring_moves_the_corner_to_the_requested_box_corner() { + // 2x2 bitmap with a distinct alpha per pixel: tl=1 tr=2 bl=3 br=4. + let px = |a: u8| [0, 0, 0, a]; + let bitmap: Vec = [px(1), px(2), px(3), px(4)].concat(); + let alphas = |bytes: Vec| bytes.chunks(4).map(|p| p[3]).collect::>(); + assert_eq!(alphas(mirrored(&bitmap, 2, false, false)), [1, 2, 3, 4]); + assert_eq!(alphas(mirrored(&bitmap, 2, true, false)), [2, 1, 4, 3]); + assert_eq!(alphas(mirrored(&bitmap, 2, false, true)), [3, 4, 1, 2]); + assert_eq!(alphas(mirrored(&bitmap, 2, true, true)), [4, 3, 2, 1]); + } + + #[test] + fn corner_boxes_sit_in_the_four_corners_and_skip_square_corners() { + let rect = Rectangle::::new((100, 50).into(), (400, 300).into()); + let radii = Radii { + top_left: 0.0, + top_right: 10.0, + bottom_right: 20.0, + bottom_left: 0.0, + }; + let boxes = corner_boxes(rect, radii, CornerStyle::Circular); + assert!(boxes[0].is_empty()); + assert_eq!(boxes[1], Rectangle::new((490, 50).into(), (10, 10).into())); + assert_eq!(boxes[2], Rectangle::new((480, 330).into(), (20, 20).into())); + assert!(boxes[3].is_empty()); + // The continuous box is larger than the nominal radius. + let continuous = corner_boxes(rect, radii, CornerStyle::Continuous); + assert_eq!(continuous[1].size.w, 12); + } +} diff --git a/rust/pantheon-compositor/src/corners.rs b/rust/pantheon-compositor/src/corners.rs new file mode 100644 index 000000000..8fa59d6cc --- /dev/null +++ b/rust/pantheon-compositor/src/corners.rs @@ -0,0 +1,655 @@ +//! Corner system: the radius of every window corner and the shape of the curve. +//! +//! The contract is `mseries/SDK-CORNERS.md` (sections 3 and 4) and the numbers are the +//! `corners` tokens of `msdk/data/tokens.json`, read once at start-up. Nothing here touches +//! Wayland or GL, so every rule is unit-tested; `corner_render.rs` draws the result. +//! +//! Policy, per output: +//! * An output that is not the built-in panel is square: every radius is 0. +//! * On the built-in panel a toplevel corner that sits exactly on a corner of the display +//! (maximized, tiled halves and quarters, fullscreen) follows the display's curve +//! (`screen`); a corner on a screen edge but not on a display corner is square; every other +//! corner uses `window`. +//! * Popups and menus use `popover` on all four corners. +//! * Panels and the dock round only the corners that are away from every screen edge, with +//! `window` (a full-width top bar therefore stays square, a floating dock is rounded). +//! +//! Radii are logical pixels; the renderer scales them by the output scale. + +use smithay::utils::{Logical, Rectangle}; + +type Rect = Rectangle; + +/// A corner within this many logical pixels of a screen edge counts as touching it. +const EDGE_TOLERANCE: i32 = 1; + +/// Superellipse exponent and corner-box growth of the `continuous` style. A circular arc has +/// exponent 2 and extent 1; the continuous curve starts further from the corner and eases +/// in, so its box is larger than the nominal radius. Tuned by eye, like the radii. +pub const CONTINUOUS_EXPONENT: f32 = 3.0; +pub const CONTINUOUS_EXTENT: f32 = 1.2; + +/// Corner shape from `corners.style`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CornerStyle { + Circular, + #[default] + Continuous, +} + +impl CornerStyle { + pub fn parse(value: &str) -> Self { + match value { + "circular" => Self::Circular, + _ => Self::Continuous, + } + } + + /// Exponent of the superellipse (2 is a circle). + pub fn exponent(self) -> f32 { + match self { + Self::Circular => 2.0, + Self::Continuous => CONTINUOUS_EXPONENT, + } + } + + /// Side of the square that holds a corner of nominal radius `radius`. + pub fn extent(self, radius: f32) -> f32 { + match self { + Self::Circular => radius, + Self::Continuous => radius * CONTINUOUS_EXTENT, + } + } +} + +/// The `corners` tokens, in logical pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CornerTokens { + pub screen: f32, + pub window: f32, + pub popover: f32, + pub min: f32, + pub style: CornerStyle, +} + +/// Overrides `corners.screen` for this session. `0` turns the screen mask (and the display-corner +/// contact rule) off; positive values are logical pixels. Kept from the first compositor mask. +pub const ENV_SCREEN_RADIUS: &str = "PANTHEON_SCREEN_CORNER_RADIUS"; +const MAX_SCREEN_RADIUS: i32 = 128; + +/// `Some(radius)` for a valid override, `None` (use the token) when unset or invalid. +pub fn parse_screen_radius(value: Option<&str>) -> Option { + let value = value?; + match value.trim().parse::() { + Ok(radius) if (0..=MAX_SCREEN_RADIUS).contains(&radius) => Some(radius as f32), + _ => { + tracing::warn!( + value, + maximum = MAX_SCREEN_RADIUS, + "ignoring invalid screen-corner radius" + ); + None + } + } +} + +impl CornerTokens { + /// Read from the SDK's `tokens.json` (embedded in the `msdk` crate at build time). + pub fn load() -> Self { + let tokens = msdk::tokens::tokens(); + let screen_override = std::env::var(ENV_SCREEN_RADIUS).ok(); + Self { + screen: parse_screen_radius(screen_override.as_deref()) + .unwrap_or(msdk::tokens::corner_px("screen") as f32), + window: msdk::tokens::corner_px("window") as f32, + popover: msdk::tokens::corner_px("popover") as f32, + min: msdk::tokens::corner_px("min") as f32, + style: CornerStyle::parse(tokens["corners"]["style"].as_str().unwrap_or("continuous")), + } + } +} + +impl Default for CornerTokens { + fn default() -> Self { + Self::load() + } +} + +/// Radius of each corner, logical pixels, clockwise from the top left. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Radii { + pub top_left: f32, + pub top_right: f32, + pub bottom_right: f32, + pub bottom_left: f32, +} + +impl Radii { + pub const SQUARE: Self = Self::all(0.0); + + pub const fn all(radius: f32) -> Self { + Self { + top_left: radius, + top_right: radius, + bottom_right: radius, + bottom_left: radius, + } + } + + pub fn is_square(self) -> bool { + self == Self::SQUARE + } + + pub fn scaled(self, factor: f64) -> Self { + let f = factor as f32; + Self { + top_left: self.top_left * f, + top_right: self.top_right * f, + bottom_right: self.bottom_right * f, + bottom_left: self.bottom_left * f, + } + } + + /// No corner may be rounder than half the shorter side of `size` (width, height), and + /// the continuous box (which is larger than the radius) must fit as well. + fn capped(self, size: (i32, i32), style: CornerStyle) -> Self { + let half = size.0.min(size.1).max(0) as f32 / 2.0; + let limit = half / style.extent(1.0); + let cap = |r: f32| r.min(limit); + Self { + top_left: cap(self.top_left), + top_right: cap(self.top_right), + bottom_right: cap(self.bottom_right), + bottom_left: cap(self.bottom_left), + } + } +} + +/// Which output a window is on, and whether it is the built-in panel. +#[derive(Clone, Copy, Debug)] +pub struct OutputPolicy { + /// The output's rectangle in space coordinates. + pub geometry: Rect, + pub builtin: bool, +} + +/// Whether a connector name is the laptop's own panel. The compositor's display +/// configuration already treats `eDP-*` as the built-in output (it sorts first and is the +/// default primary). `winit` is the nested development window: it stands in for the panel so +/// the corners can be seen without a second display. +pub fn is_builtin_output(name: &str) -> bool { + ["eDP-", "LVDS-", "DSI-"] + .iter() + .any(|prefix| name.starts_with(prefix)) + || name == "winit" +} + +fn near(a: i32, b: i32) -> bool { + (a - b).abs() <= EDGE_TOLERANCE +} + +/// Where a corner point lies relative to the display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Contact { + /// On a corner of the display. + DisplayCorner, + /// On a screen edge, but not at a display corner. + Edge, + Free, +} + +fn contact(x: i32, y: i32, output: Rect) -> Contact { + let on_x = near(x, output.loc.x) || near(x, output.loc.x + output.size.w); + let on_y = near(y, output.loc.y) || near(y, output.loc.y + output.size.h); + match (on_x, on_y) { + (true, true) => Contact::DisplayCorner, + (false, false) => Contact::Free, + _ => Contact::Edge, + } +} + +/// The four corner points of `rect`, clockwise from the top left. +fn corner_points(rect: Rect) -> [(i32, i32); 4] { + let (x0, y0) = (rect.loc.x, rect.loc.y); + let (x1, y1) = (x0 + rect.size.w, y0 + rect.size.h); + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] +} + +fn radii_from(values: [f32; 4]) -> Radii { + Radii { + top_left: values[0], + top_right: values[1], + bottom_right: values[2], + bottom_left: values[3], + } +} + +/// Radii of a toplevel (an application window or a dialog) whose geometry is `rect`. +pub fn window_radii(tokens: &CornerTokens, rect: Rect, output: OutputPolicy) -> Radii { + if !output.builtin || rect.is_empty() { + return Radii::SQUARE; + } + let corners = corner_points(rect).map(|(x, y)| match contact(x, y, output.geometry) { + Contact::DisplayCorner => tokens.screen, + Contact::Edge => 0.0, + Contact::Free => tokens.window, + }); + radii_from(corners).capped((rect.size.w, rect.size.h), tokens.style) +} + +/// Radii of a popup or menu. +pub fn popup_radii(tokens: &CornerTokens, rect: Rect, builtin: bool) -> Radii { + if !builtin || rect.is_empty() { + return Radii::SQUARE; + } + Radii::all(tokens.popover).capped((rect.size.w, rect.size.h), tokens.style) +} + +/// Radii of a panel or the dock: only corners away from every screen edge are rounded. +pub fn panel_radii(tokens: &CornerTokens, rect: Rect, output: OutputPolicy) -> Radii { + if !output.builtin || rect.is_empty() { + return Radii::SQUARE; + } + let corners = corner_points(rect).map(|(x, y)| match contact(x, y, output.geometry) { + Contact::Free => tokens.window, + _ => 0.0, + }); + radii_from(corners).capped((rect.size.w, rect.size.h), tokens.style) +} + +/// Distance from the shape's outline, in the corner's own pixels: negative inside the +/// rounded shape, positive outside. `(dx, dy)` is the offset from the arc's centre towards +/// the corner (both clamped to zero or more) and `extent` the side of the corner box. The +/// GLSL in `corner_render.rs` computes the same function. +pub fn corner_distance(dx: f32, dy: f32, extent: f32, style: CornerStyle) -> f32 { + let (dx, dy) = (dx.max(0.0), dy.max(0.0)); + match style { + CornerStyle::Circular => dx.hypot(dy) - extent, + CornerStyle::Continuous => { + let n = style.exponent(); + let (a, b) = (dx.max(1e-4), dy.max(1e-4)); + let f = (a.powf(n) + b.powf(n)).powf(1.0 / n); + // Gradient of the superellipse norm, so the distance is in pixels and the edge + // antialiases at a constant width. + let gx = a.powf(n - 1.0) / f.powf(n - 1.0); + let gy = b.powf(n - 1.0) / f.powf(n - 1.0); + (f - extent) / gx.hypot(gy).max(1e-4) + } + } +} + +/// Alpha of the screen mask (black, premultiplied) at the centre of pixel `(i, j)` of a +/// corner box of `size` pixels whose corner is at `(0, 0)`: 0 inside the display's curve, +/// 1 outside it, antialiased over one pixel. `radius` is in pixels, `size` the box side. +pub fn mask_alpha(i: u32, j: u32, radius: f32, size: u32, style: CornerStyle) -> f32 { + let extent = style.extent(radius); + let cx = extent - (i as f32 + 0.5); + let cy = extent - (j as f32 + 0.5); + if cx <= 0.0 && cy <= 0.0 || size == 0 { + return 0.0; + } + let distance = corner_distance(cx, cy, extent, style); + (distance + 0.5).clamp(0.0, 1.0) +} + +/// Pixel size of the (square) box that holds a corner of `radius` physical pixels. +#[cfg(test)] +pub fn mask_box(radius: f32, style: CornerStyle) -> u32 { + style.extent(radius).ceil().max(0.0) as u32 +} + +/// The mask for one display corner as Argb8888 bytes (premultiplied black), with the corner +/// at the box's top left; the other three corners flip it. +pub fn mask_pixels(radius: f32, size: u32, style: CornerStyle) -> Vec { + let mut pixels = Vec::with_capacity((size * size * 4) as usize); + for j in 0..size { + for i in 0..size { + let alpha = (mask_alpha(i, j, radius, size, style) * 255.0).round() as u8; + pixels.extend_from_slice(&[0, 0, 0, alpha]); + } + } + pixels +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tokens() -> CornerTokens { + CornerTokens { + screen: 14.0, + window: 12.0, + popover: 10.0, + min: 4.0, + style: CornerStyle::Continuous, + } + } + + fn output_rect() -> Rect { + Rectangle::new((0, 0).into(), (1512, 982).into()) + } + const TOP_BAR: i32 = 32; + + fn builtin() -> OutputPolicy { + OutputPolicy { + geometry: output_rect(), + builtin: true, + } + } + + fn external() -> OutputPolicy { + OutputPolicy { + geometry: output_rect(), + builtin: false, + } + } + + fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect { + Rectangle::new((x, y).into(), (w, h).into()) + } + + /// The work area under a top bar, as `work_area_for_output` leaves it. + fn work_area() -> Rect { + rect( + 0, + TOP_BAR, + output_rect().size.w, + output_rect().size.h - TOP_BAR, + ) + } + + fn r(tl: f32, tr: f32, br: f32, bl: f32) -> Radii { + Radii { + top_left: tl, + top_right: tr, + bottom_right: br, + bottom_left: bl, + } + } + + #[test] + fn floating_window_uses_the_window_radius_everywhere() { + let radii = window_radii(&tokens(), rect(200, 150, 800, 600), builtin()); + assert_eq!(radii, Radii::all(12.0)); + } + + #[test] + fn maximized_window_hugs_the_bottom_corners_and_is_square_under_the_bar() { + let radii = window_radii(&tokens(), work_area(), builtin()); + assert_eq!(radii, r(0.0, 0.0, 14.0, 14.0)); + } + + #[test] + fn maximized_window_with_the_bar_hidden_follows_all_four_display_corners() { + let radii = window_radii(&tokens(), output_rect(), builtin()); + assert_eq!(radii, Radii::all(14.0)); + } + + #[test] + fn fullscreen_window_follows_all_four_display_corners() { + assert_eq!( + window_radii(&tokens(), output_rect(), builtin()), + Radii::all(14.0) + ); + } + + #[test] + fn tiled_halves() { + let area = work_area(); + let half = area.size.w / 2; + let left = rect(area.loc.x, area.loc.y, half, area.size.h); + let right = rect(half, area.loc.y, area.size.w - half, area.size.h); + // The inner top corner is free (window), the inner bottom corner is on the bottom edge. + assert_eq!( + window_radii(&tokens(), left, builtin()), + r(0.0, 12.0, 0.0, 14.0) + ); + assert_eq!( + window_radii(&tokens(), right, builtin()), + r(12.0, 0.0, 14.0, 0.0) + ); + } + + #[test] + fn tiled_halves_without_a_bar_use_the_display_curve_on_the_outer_corners() { + let half = output_rect().size.w / 2; + let left = rect(0, 0, half, output_rect().size.h); + assert_eq!( + window_radii(&tokens(), left, builtin()), + r(14.0, 0.0, 0.0, 14.0) + ); + } + + #[test] + fn tiled_quarters() { + let area = work_area(); + let (hw, hh) = (area.size.w / 2, area.size.h / 2); + let top_left = rect(0, area.loc.y, hw, hh); + let top_right = rect(hw, area.loc.y, area.size.w - hw, hh); + let bottom_left = rect(0, area.loc.y + hh, hw, area.size.h - hh); + let bottom_right = rect(hw, area.loc.y + hh, area.size.w - hw, area.size.h - hh); + let t = tokens(); + // Under the bar the top outer corners are square; the quarter's inner corners that + // meet other quarters are free, so they take the window radius. + assert_eq!( + window_radii(&t, top_left, builtin()), + r(0.0, 12.0, 12.0, 0.0) + ); + assert_eq!( + window_radii(&t, top_right, builtin()), + r(12.0, 0.0, 0.0, 12.0) + ); + assert_eq!( + window_radii(&t, bottom_left, builtin()), + r(0.0, 12.0, 0.0, 14.0) + ); + assert_eq!( + window_radii(&t, bottom_right, builtin()), + r(12.0, 0.0, 14.0, 0.0) + ); + } + + #[test] + fn quarters_without_a_bar_hug_exactly_one_display_corner_each() { + let (hw, hh) = (output_rect().size.w / 2, output_rect().size.h / 2); + let t = tokens(); + assert_eq!( + window_radii(&t, rect(0, 0, hw, hh), builtin()), + r(14.0, 0.0, 12.0, 0.0) + ); + assert_eq!( + window_radii( + &t, + rect(hw, hh, output_rect().size.w - hw, output_rect().size.h - hh), + builtin() + ), + r(12.0, 0.0, 14.0, 0.0) + ); + } + + #[test] + fn one_pixel_of_slack_still_counts_as_touching() { + let almost = rect(1, 1, output_rect().size.w - 2, output_rect().size.h - 2); + assert_eq!(window_radii(&tokens(), almost, builtin()), Radii::all(14.0)); + let apart = rect(4, 4, output_rect().size.w - 8, output_rect().size.h - 8); + assert_eq!(window_radii(&tokens(), apart, builtin()), Radii::all(12.0)); + } + + #[test] + fn external_outputs_are_square_for_every_placement() { + let t = tokens(); + for placement in [ + rect(200, 150, 800, 600), + work_area(), + output_rect(), + rect(0, 0, 756, 982), + ] { + assert_eq!(window_radii(&t, placement, external()), Radii::SQUARE); + } + assert_eq!( + popup_radii(&t, rect(10, 10, 200, 100), false), + Radii::SQUARE + ); + assert_eq!( + panel_radii(&t, rect(500, 900, 400, 60), external()), + Radii::SQUARE + ); + } + + #[test] + fn popups_use_the_popover_radius_on_all_corners() { + assert_eq!( + popup_radii(&tokens(), rect(300, 60, 220, 180), true), + Radii::all(10.0) + ); + } + + #[test] + fn full_width_top_bar_stays_square() { + let bar = rect(0, 0, output_rect().size.w, TOP_BAR); + assert_eq!(panel_radii(&tokens(), bar, builtin()), Radii::SQUARE); + } + + #[test] + fn floating_dock_rounds_the_corners_away_from_the_edge() { + // Flush with the bottom edge: only the top corners are away from every edge. + let flush = rect(400, 922, 700, 60); + assert_eq!( + panel_radii(&tokens(), flush, builtin()), + r(12.0, 12.0, 0.0, 0.0) + ); + // Lifted off the edge: all four corners are away from it. + let lifted = rect(400, 910, 700, 60); + assert_eq!(panel_radii(&tokens(), lifted, builtin()), Radii::all(12.0)); + } + + #[test] + fn a_tiny_surface_cannot_be_rounder_than_half_its_height() { + let sliver = rect(300, 300, 400, 10); + let radii = window_radii(&tokens(), sliver, builtin()); + let limit = 5.0 / CONTINUOUS_EXTENT; + assert!((radii.top_left - limit).abs() < 1e-4, "{radii:?}"); + assert!(radii.top_right <= limit + 1e-4 && radii.bottom_left <= limit + 1e-4); + } + + #[test] + fn empty_rectangles_get_no_rounding() { + assert_eq!( + window_radii(&tokens(), rect(0, 0, 0, 0), builtin()), + Radii::SQUARE + ); + } + + #[test] + fn builtin_output_names() { + assert!(is_builtin_output("eDP-1")); + assert!(is_builtin_output("LVDS-1")); + assert!(is_builtin_output("winit")); + assert!(!is_builtin_output("DP-1")); + assert!(!is_builtin_output("HDMI-A-1")); + } + + #[test] + fn style_parses_and_defaults_to_continuous() { + assert_eq!(CornerStyle::parse("circular"), CornerStyle::Circular); + assert_eq!(CornerStyle::parse("continuous"), CornerStyle::Continuous); + assert_eq!(CornerStyle::parse("anything else"), CornerStyle::Continuous); + } + + #[test] + fn tokens_are_read_from_the_sdk_file() { + let t = CornerTokens::load(); + assert_eq!(t.screen, 20.0); + assert_eq!(t.window, 20.0); + assert_eq!(t.popover, 10.0); + assert_eq!(t.min, 4.0); + assert_eq!(t.style, CornerStyle::Continuous); + assert!(t.window <= t.screen); + } + + #[test] + fn circular_distance_is_euclidean() { + let d = corner_distance(3.0, 4.0, 4.0, CornerStyle::Circular); + assert!((d - 1.0).abs() < 1e-5); + assert!(corner_distance(1.0, 1.0, 4.0, CornerStyle::Circular) < 0.0); + } + + #[test] + fn continuous_distance_is_zero_on_the_axes_and_matches_circular_at_exponent_two() { + // On an axis the curve touches the extent for every exponent. + let d = corner_distance(10.0, 0.0, 10.0, CornerStyle::Continuous); + assert!(d.abs() < 1e-2, "{d}"); + // Inside, outside. + assert!(corner_distance(3.0, 3.0, 10.0, CornerStyle::Continuous) < 0.0); + assert!(corner_distance(9.0, 9.0, 10.0, CornerStyle::Continuous) > 0.0); + } + + #[test] + fn continuous_corner_cuts_less_at_the_diagonal_than_its_box_suggests() { + // Along the diagonal the superellipse lies further out than the circle of the same + // box, so the corner is tighter in the middle and starts earlier on the edge. + let e = 10.0; + let diagonal = |style| { + let (mut lo, mut hi) = (0.0f32, e); + for _ in 0..40 { + let mid = (lo + hi) / 2.0; + if corner_distance(mid, mid, e, style) > 0.0 { + hi = mid; + } else { + lo = mid; + } + } + lo + }; + assert!(diagonal(CornerStyle::Continuous) > diagonal(CornerStyle::Circular)); + } + + #[test] + fn mask_is_opaque_at_the_corner_and_clear_at_the_inner_corner() { + for style in [CornerStyle::Circular, CornerStyle::Continuous] { + let radius = 14.0; + let size = mask_box(radius, style); + assert!(mask_alpha(0, 0, radius, size, style) > 0.99, "{style:?}"); + assert_eq!(mask_alpha(size - 1, size - 1, radius, size, style), 0.0); + // The edge pixels next to the straight sides are untouched. + assert!(mask_alpha(size - 1, 0, radius, size, style) < 0.2); + assert!(mask_alpha(0, size - 1, radius, size, style) < 0.2); + } + } + + #[test] + fn mask_pixels_are_premultiplied_black_with_the_alpha_in_the_last_byte() { + let size = mask_box(14.0, CornerStyle::Circular); + let pixels = mask_pixels(14.0, size, CornerStyle::Circular); + assert_eq!(pixels.len(), (size * size * 4) as usize); + assert_eq!(&pixels[..4], &[0, 0, 0, 255]); + assert!(pixels.chunks(4).all(|px| px[..3] == [0, 0, 0])); + } + + #[test] + fn circular_mask_area_matches_the_geometry() { + // Area outside a quarter circle inside its box is r^2 * (1 - pi/4). + let radius = 40.0; + let size = mask_box(radius, CornerStyle::Circular); + let area: f32 = (0..size) + .flat_map(|j| (0..size).map(move |i| (i, j))) + .map(|(i, j)| mask_alpha(i, j, radius, size, CornerStyle::Circular)) + .sum(); + let expected = radius * radius * (1.0 - std::f32::consts::FRAC_PI_4); + assert!( + (area - expected).abs() / expected < 0.01, + "{area} vs {expected}" + ); + } + + #[test] + fn screen_radius_override_accepts_zero_to_the_maximum_only() { + assert_eq!(parse_screen_radius(None), None); + assert_eq!(parse_screen_radius(Some("0")), Some(0.0)); + assert_eq!(parse_screen_radius(Some(" 18 ")), Some(18.0)); + assert_eq!(parse_screen_radius(Some("128")), Some(128.0)); + assert_eq!(parse_screen_radius(Some("129")), None); + assert_eq!(parse_screen_radius(Some("-1")), None); + assert_eq!(parse_screen_radius(Some("big")), None); + } +} diff --git a/rust/pantheon-compositor/src/cursor.rs b/rust/pantheon-compositor/src/cursor.rs index 50cc2d376..1c52b618d 100644 --- a/rust/pantheon-compositor/src/cursor.rs +++ b/rust/pantheon-compositor/src/cursor.rs @@ -17,7 +17,10 @@ use smithay::{ wayland::compositor::with_states, }; -use crate::PantheonCompositor; +use crate::{ + PantheonCompositor, + corner_render::{CornerRenderer, RoundedClip}, +}; /// Solid-colour overlay pieces kept across frames. A new `SolidColorBuffer` /// every frame has a new id, so the damage tracker repaints it every frame @@ -70,11 +73,12 @@ pub(crate) fn output_pixel_size( } render_elements! { - pub CursorRenderElement where R: ImportAll + ImportMem; + pub CursorRenderElement where R: ImportAll + ImportMem + CornerRenderer; Surface=WaylandSurfaceRenderElement, Memory=MemoryRenderBufferRenderElement, Solid=SolidColorRenderElement, WrappedSurface=Wrap>, + Rounded=RoundedClip>, Animated=smithay::backend::renderer::element::utils::RelocateRenderElement>>, } @@ -119,7 +123,7 @@ pub fn render_cursor_elements( output: &smithay::output::Output, ) -> Vec> where - R: Renderer + ImportAll + ImportMem, + R: Renderer + ImportAll + ImportMem + CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { let Some(output_geometry) = state.space.output_geometry(output) else { @@ -219,7 +223,7 @@ pub fn render_wallpaper_element( output: &smithay::output::Output, ) -> Option> where - R: Renderer + ImportAll + ImportMem, + R: Renderer + ImportAll + ImportMem + CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { let wallpaper = state.wallpaper.as_ref()?; @@ -249,7 +253,7 @@ fn render_shell_osd_elements( output: &smithay::output::Output, ) -> Vec> where - R: Renderer + ImportAll + ImportMem, + R: Renderer + ImportAll + ImportMem + CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { let Some(osd) = state.shell_compat.active_osd() else { @@ -400,17 +404,13 @@ pub fn render_lock_surface_elements( output: &smithay::output::Output, ) -> Vec> where - R: Renderer + ImportAll + ImportMem, + R: Renderer + ImportAll + ImportMem + CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { let output_name = output.name(); let scale = Scale::from(output.current_scale().fractional_scale()); // The pointer (and any volume/brightness OSD) above the lock surface. - let mut elements = state.screen_corners.elements( - renderer, - output, - state.space.output_geometry(output).map(|g| g.size), - ); + let mut elements = render_screen_mask_elements(renderer, state, output); if let Some(surface) = state.lock_surfaces.get(&output_name) { elements.extend(render_screenshot_overlay(state, output)); elements.extend(render_cursor_elements(renderer, state, output)); @@ -428,6 +428,33 @@ where elements } +/// The built-in display's rounded corners, above windows, the dock, panels and the lock +/// screen. External outputs get none. +fn render_screen_mask_elements( + renderer: &mut R, + state: &PantheonCompositor, + output: &smithay::output::Output, +) -> Vec> +where + R: Renderer + ImportAll + ImportMem + CornerRenderer, + R::TextureId: Texture + Clone + Send + 'static, +{ + let Some(pixels) = output_pixel_size(state, output) else { + return Vec::new(); + }; + crate::corner_render::screen_mask_elements( + renderer, + &state.corner_masks, + &state.corner_tokens, + output, + pixels, + output.current_scale().fractional_scale(), + ) + .into_iter() + .map(CursorRenderElement::from) + .collect() +} + /// Snap Assist: live, scaled thumbnails of the other windows in the empty /// half, on plates over a dimmed backdrop, the selection on an accent plate. /// Clients are not resized. Front to back: thumbnails, plates, backdrop. @@ -437,7 +464,7 @@ fn render_snap_assist_elements( output: &smithay::output::Output, ) -> Vec> where - R: Renderer + ImportAll + ImportMem, + R: Renderer + ImportAll + ImportMem + CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { use crate::snap_assist::{BACKDROP_ALPHA, PLATE_ALPHA}; @@ -618,7 +645,7 @@ pub fn render_desktop_elements( output: &smithay::output::Output, ) -> Result>, smithay::output::OutputNoMode> where - R: Renderer + ImportAll + ImportMem, + R: Renderer + ImportAll + ImportMem + CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { use crate::gesture_animation::PreviewKind; @@ -626,14 +653,7 @@ where AsRenderElements, utils::{Relocate, RelocateRenderElement, RescaleRenderElement}, }; - let mut elements = state.screen_corners.elements( - renderer, - output, - state - .space - .output_geometry(output) - .map(|geometry| geometry.size), - ); + let mut elements = render_screen_mask_elements(renderer, state, output); elements.extend(render_cursor_elements(renderer, state, output)); // Selection feedback sits above windows and below the pointer. Capture // starts only after the selection ends, so it never lands in the image. @@ -742,10 +762,25 @@ where // Not `space_render_elements`: that draws each window's popups at the // window's own place in the stack, so a lower window's menu was clipped // by a higher neighbour. This draws every popup above every toplevel. + let clipper = state.space.output_geometry(output).and_then(|geometry| { + crate::corner_render::Clipper::new( + renderer, + state.corner_tokens, + output, + geometry, + |window| { + window + .toplevel() + .is_some_and(|toplevel| state.is_panel_surface(toplevel.wl_surface())) + }, + ) + }); elements.extend(crate::popup_stack::stacked_render_elements::< R, CursorRenderElement, - >(renderer, &state.space, output, 1.0)); + >( + renderer, &state.space, output, 1.0, clipper.as_ref() + )); } if let Some(wallpaper) = render_wallpaper_element(renderer, state, output) { elements.push(wallpaper); diff --git a/rust/pantheon-compositor/src/main.rs b/rust/pantheon-compositor/src/main.rs index 37c05bb09..709fd8d2c 100644 --- a/rust/pantheon-compositor/src/main.rs +++ b/rust/pantheon-compositor/src/main.rs @@ -2,6 +2,8 @@ mod activation; mod attention; +mod corner_render; +mod corners; mod cursor; mod cursor_theme; mod display_config; @@ -37,7 +39,6 @@ mod popup_stack; #[cfg(feature = "native-drm")] mod prewarm; mod protocols; -mod screen_corners; pub mod screenshot; mod terminal_keys; pub mod screenshot_pixels; diff --git a/rust/pantheon-compositor/src/native.rs b/rust/pantheon-compositor/src/native.rs index 9cf3cd82b..375cde457 100644 --- a/rust/pantheon-compositor/src/native.rs +++ b/rust/pantheon-compositor/src/native.rs @@ -77,6 +77,24 @@ pub type NativeExporter = GbmFramebufferExporter; pub type NativeOutputManager = DrmOutputManager; pub type NativeDrmOutput = DrmOutput; type NativeMultiRenderer<'a> = MultiRenderer<'a, 'a, GbmRendererBackend, GbmRendererBackend>; + +/// The corner clip runs on the render device's GLES renderer and frame, which the multi-GPU +/// wrapper hands out; a frame copied to another GPU afterwards keeps the clipped pixels. +impl crate::corner_render::CornerRenderer for NativeMultiRenderer<'_> { + fn corner_program(&mut self) -> Option { + crate::corner_render::gles_corner_program(self.as_mut()) + } + + fn gles_frame<'a, 'b, 'c>( + frame: &'c mut ::Frame<'a, 'b>, + ) -> &'c mut smithay::backend::renderer::gles::GlesFrame<'a, 'b> + where + 'b: 'a, + Self: 'a, + { + frame.as_mut() + } +} type HardwareNode = (DrmNode, GbmDevice); const OUTPUT_FORMATS: &[smithay::backend::allocator::Fourcc] = &[ diff --git a/rust/pantheon-compositor/src/popup_stack.rs b/rust/pantheon-compositor/src/popup_stack.rs index 134aa6b3c..7200ac5d6 100644 --- a/rust/pantheon-compositor/src/popup_stack.rs +++ b/rust/pantheon-compositor/src/popup_stack.rs @@ -15,9 +15,10 @@ //! [`popup_under`] is the matching hit test, so a popup that now draws above a //! neighbour also receives the pointer instead of the neighbour it covers. +use crate::corner_render::{Clipper, RoundedClip}; use smithay::{ backend::renderer::{ - ImportAll, Renderer, + ImportAll, Renderer, Texture, element::{ AsRenderElements, Kind, surface::{WaylandSurfaceRenderElement, render_elements_from_surface_tree}, @@ -71,16 +72,20 @@ fn popup_offset( /// Render elements for `output`, top-most first: every popup of every mapped window, /// then every toplevel. Windows outside the output are skipped. An output that is not /// mapped to the space yields no elements, like `space_render_elements`. +/// +/// With a `clipper` (the built-in output), popups are clipped to the popover corner radius +/// and toplevels to the radii the corner policy gives their geometry. pub fn stacked_render_elements( renderer: &mut R, space: &Space, output: &Output, alpha: f32, + clipper: Option<&Clipper<'_>>, ) -> Vec where R: Renderer + ImportAll, - R::TextureId: Clone + 'static, - C: From>, + R::TextureId: Texture + Clone + 'static, + C: From> + From>>, { let Some(output_geometry) = space.output_geometry(output) else { return Vec::new(); @@ -105,34 +110,54 @@ where for (popup, popup_location) in PopupManager::popups_for_surface(toplevel.wl_surface()) { let offset = popup_offset(entry.window, &popup, popup_location).to_physical_precise_round(scale); - elements.extend(render_elements_from_surface_tree( + let tree: Vec> = render_elements_from_surface_tree( renderer, popup.wl_surface(), location + offset, scale, alpha, Kind::Unspecified, - )); + ); + match clipper { + Some(clipper) => { + let geometry = popup.geometry(); + let rect = Rectangle::new( + entry.render_location + + popup_offset(entry.window, &popup, popup_location) + + geometry.loc, + geometry.size, + ); + elements.extend(clipper.wrap(tree, rect, clipper.popup_radii(rect))); + } + None => elements.extend(tree.into_iter().map(C::from)), + } } } for entry in &visible { let location = physical_location(entry); - match entry.window.toplevel() { - Some(toplevel) => elements.extend(render_elements_from_surface_tree( + let tree: Vec> = match entry.window.toplevel() { + Some(toplevel) => render_elements_from_surface_tree( renderer, toplevel.wl_surface(), location, scale, alpha, Kind::Unspecified, - )), - // `Window::render_elements` only delegates to the X11 surface here. - None => elements.extend( - entry - .window - .render_elements::(renderer, location, scale, alpha), ), + // `Window::render_elements` only delegates to the X11 surface here. + None => entry + .window + .render_elements(renderer, location, scale, alpha), + }; + match clipper { + Some(clipper) => { + let geometry = entry.window.geometry(); + let rect = Rectangle::new(entry.render_location + geometry.loc, geometry.size); + let radii = clipper.toplevel_radii(entry.window, rect); + elements.extend(clipper.wrap(tree, rect, radii)); + } + None => elements.extend(tree.into_iter().map(C::from)), } } elements diff --git a/rust/pantheon-compositor/src/screen_corners.rs b/rust/pantheon-compositor/src/screen_corners.rs deleted file mode 100644 index 51273db07..000000000 --- a/rust/pantheon-compositor/src/screen_corners.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! A lightweight output mask for rounded built-in display panels. -//! -//! This belongs in the compositor because client-side window styling cannot -//! mask the physical display. The masks are small, cached memory textures and -//! are drawn as four final scene elements; they add no polling or animation. - -use std::{cell::RefCell, collections::HashMap}; - -use smithay::{ - backend::{ - allocator::Fourcc, - renderer::{ - ImportAll, ImportMem, Renderer, Texture, - element::{Kind, memory::MemoryRenderBuffer}, - }, - }, - utils::{Logical, Point, Scale, Size, Transform}, -}; - -use crate::cursor::CursorRenderElement; - -const ENV_RADIUS: &str = "PANTHEON_SCREEN_CORNER_RADIUS"; -const DEFAULT_RADIUS: i32 = 20; -const MAX_RADIUS: i32 = 128; -/// Supersampling makes the curve smooth at fractional and high-DPI scales. -const BUFFER_SCALE: i32 = 4; -const COVERAGE_SAMPLES: i32 = 2; - -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -enum Corner { - TopLeft, - TopRight, - BottomLeft, - BottomRight, -} - -impl Corner { - const ALL: [Self; 4] = [ - Self::TopLeft, - Self::TopRight, - Self::BottomLeft, - Self::BottomRight, - ]; - - fn is_right(self) -> bool { - matches!(self, Self::TopRight | Self::BottomRight) - } - - fn is_bottom(self) -> bool { - matches!(self, Self::BottomLeft | Self::BottomRight) - } -} - -/// Rounded corner support is opt-in only for recognized internal panels. -/// `PANTHEON_SCREEN_CORNER_RADIUS=0` disables it; positive values are logical -/// pixels. The default follows the existing M-series Gala mask provisionally. -pub(crate) struct ScreenCorners { - radius: i32, - buffers: RefCell>, -} - -impl Default for ScreenCorners { - fn default() -> Self { - Self { - radius: DEFAULT_RADIUS, - buffers: RefCell::new(HashMap::new()), - } - } -} - -impl ScreenCorners { - pub(crate) fn from_environment() -> Self { - let radius = match std::env::var(ENV_RADIUS) { - Ok(value) => match value.parse::() { - Ok(radius) if (0..=MAX_RADIUS).contains(&radius) => radius, - _ => { - tracing::warn!( - value, - maximum = MAX_RADIUS, - "ignoring invalid screen-corner radius" - ); - DEFAULT_RADIUS - } - }, - Err(std::env::VarError::NotPresent) => DEFAULT_RADIUS, - Err(error) => { - tracing::warn!(%error, "ignoring unreadable screen-corner radius"); - DEFAULT_RADIUS - } - }; - Self { - radius, - buffers: RefCell::new(HashMap::new()), - } - } - - pub(crate) fn elements( - &self, - renderer: &mut R, - output: &smithay::output::Output, - output_size: Option>, - ) -> Vec> - where - R: Renderer + ImportAll + ImportMem, - R::TextureId: Texture + Clone + Send + 'static, - { - if self.radius == 0 || !is_builtin_panel(&output.name()) { - return Vec::new(); - } - let Some(output_size) = output_size else { - return Vec::new(); - }; - let radius = self.radius.min(output_size.w / 2).min(output_size.h / 2); - if radius <= 0 { - return Vec::new(); - } - - let scale = Scale::from(output.current_scale().fractional_scale()); - let physical_size = output_size - .to_f64() - .to_physical(scale) - .to_i32_round::(); - let radius_physical = Size::from((radius, radius)) - .to_f64() - .to_physical(scale) - .to_i32_round::(); - - let mut elements = Vec::with_capacity(4); - for corner in Corner::ALL { - let buffer = self.buffer(corner, radius); - let x = if corner.is_right() { - physical_size.w - radius_physical.w - } else { - 0 - }; - let y = if corner.is_bottom() { - physical_size.h - radius_physical.h - } else { - 0 - }; - if let Ok(element) = - smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement::from_buffer( - renderer, - Point::::from((x, y)).to_f64(), - &buffer, - None, - None, - Some((radius, radius).into()), - Kind::Unspecified, - ) - { - elements.push(CursorRenderElement::from(element)); - } - } - elements - } - - fn buffer(&self, corner: Corner, radius: i32) -> MemoryRenderBuffer { - let mut buffers = self.buffers.borrow_mut(); - buffers - .entry((corner, radius)) - .or_insert_with(|| make_corner_buffer(corner, radius)) - .clone() - } -} - -fn is_builtin_panel(name: &str) -> bool { - let name = name.to_ascii_lowercase(); - ["edp-", "lvds-", "dsi-"] - .iter() - .any(|prefix| name.starts_with(prefix)) -} - -fn make_corner_buffer(corner: Corner, radius: i32) -> MemoryRenderBuffer { - let pixels = make_corner_pixels(corner, radius); - let size = (radius * BUFFER_SCALE) as usize; - MemoryRenderBuffer::from_slice( - &pixels, - Fourcc::Argb8888, - (size as i32, size as i32), - BUFFER_SCALE, - Transform::Normal, - None, - ) -} - -fn make_corner_pixels(corner: Corner, radius: i32) -> Vec { - let size = (radius * BUFFER_SCALE) as usize; - let mut pixels = vec![0; size * size * 4]; - let samples_per_pixel = COVERAGE_SAMPLES * COVERAGE_SAMPLES; - - for pixel_y in 0..size { - for pixel_x in 0..size { - let mut outside = 0; - for sample_y in 0..COVERAGE_SAMPLES { - for sample_x in 0..COVERAGE_SAMPLES { - let local_x = (pixel_x as f64 - + (sample_x as f64 + 0.5) / COVERAGE_SAMPLES as f64) - / BUFFER_SCALE as f64; - let local_y = (pixel_y as f64 - + (sample_y as f64 + 0.5) / COVERAGE_SAMPLES as f64) - / BUFFER_SCALE as f64; - let distance_x = if corner.is_right() { - radius as f64 - local_x - } else { - local_x - }; - let distance_y = if corner.is_bottom() { - radius as f64 - local_y - } else { - local_y - }; - if !inside_superellipse(distance_x, distance_y, radius as f64) { - outside += 1; - } - } - } - let alpha = (outside * 255 / samples_per_pixel) as u8; - let offset = (pixel_y * size + pixel_x) * 4; - // Fourcc::Argb8888 is BGRA byte order on little-endian hosts. - pixels[offset..offset + 4].copy_from_slice(&[0, 0, 0, alpha]); - } - } - pixels -} - -fn inside_superellipse(distance_x: f64, distance_y: f64, radius: f64) -> bool { - let x = (1.0 - distance_x / radius).clamp(0.0, 1.0); - let y = (1.0 - distance_y / radius).clamp(0.0, 1.0); - x.powi(4) + y.powi(4) <= 1.0 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_internal_panel_connector_names_match() { - for name in ["eDP-1", "LVDS-0", "DSI-1"] { - assert!(is_builtin_panel(name)); - } - for name in ["HDMI-A-1", "DP-1", "Virtual-1"] { - assert!(!is_builtin_panel(name)); - } - } - - #[test] - fn generated_masks_mirror_the_curve_by_corner() { - let radius = 12; - let size = (radius * BUFFER_SCALE) as usize; - let masks = Corner::ALL.map(|corner| make_corner_pixels(corner, radius)); - let alpha = |mask: &[u8], x: usize, y: usize| mask[(y * size + x) * 4 + 3]; - let (top_left, top_right, bottom_left, bottom_right) = - (&masks[0], &masks[1], &masks[2], &masks[3]); - - assert!(alpha(top_left, 0, 0) > 0); - assert_eq!(alpha(top_left, size / 2, size / 2), 0); - for y in 0..size { - for x in 0..size { - assert_eq!(alpha(top_left, x, y), alpha(top_right, size - 1 - x, y)); - assert_eq!(alpha(top_left, x, y), alpha(bottom_left, x, size - 1 - y)); - assert_eq!( - alpha(top_left, x, y), - alpha(bottom_right, size - 1 - x, size - 1 - y) - ); - } - } - } -} diff --git a/rust/pantheon-compositor/src/screenshot_pixels.rs b/rust/pantheon-compositor/src/screenshot_pixels.rs index 27c769ea5..69022859b 100644 --- a/rust/pantheon-compositor/src/screenshot_pixels.rs +++ b/rust/pantheon-compositor/src/screenshot_pixels.rs @@ -64,7 +64,8 @@ where R: smithay::backend::renderer::ImportAll + smithay::backend::renderer::ImportMem + smithay::backend::renderer::Offscreen - + ExportMem, + + ExportMem + + crate::corner_render::CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { if state.session_locked { @@ -213,7 +214,8 @@ pub fn process_request( R: smithay::backend::renderer::ImportAll + smithay::backend::renderer::ImportMem + smithay::backend::renderer::Offscreen - + ExportMem, + + ExportMem + + crate::corner_render::CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, { use std::sync::atomic::Ordering; diff --git a/rust/pantheon-compositor/src/state.rs b/rust/pantheon-compositor/src/state.rs index b07ec4286..84df6e4fd 100644 --- a/rust/pantheon-compositor/src/state.rs +++ b/rust/pantheon-compositor/src/state.rs @@ -234,8 +234,10 @@ pub struct PantheonCompositor { fractional_scale_fingerprint: Cell>, /// Snap preview and OSD rectangles, kept so unchanged ones add no damage. pub(crate) solid_buffers: crate::cursor::SolidBuffers, - /// Cached physical-panel corner masks; no timers or per-frame raster work. - pub(crate) screen_corners: crate::screen_corners::ScreenCorners, + /// The `corners` design tokens, read once at start-up. + pub(crate) corner_tokens: crate::corners::CornerTokens, + /// Screen-corner mask bitmaps kept across frames. + pub(crate) corner_masks: crate::corner_render::MaskBuffers, /// Where wallpaper scaling threads deliver their result. pub(crate) wallpaper_scaled_sender: channel::Sender, @@ -584,7 +586,8 @@ impl PantheonCompositor { gala_snapshot_at: Cell::new(None), fractional_scale_fingerprint: Cell::new(None), solid_buffers: Default::default(), - screen_corners: crate::screen_corners::ScreenCorners::from_environment(), + corner_tokens: crate::corners::CornerTokens::load(), + corner_masks: Default::default(), wallpaper_scaled_sender, idle_monitor, idle_notifier_state, From bbe457247e91631010a0793e2e4279100c21bbba Mon Sep 17 00:00:00 2001 From: sdolbec Date: Tue, 27 Oct 2026 14:53:16 -0400 Subject: [PATCH 2/2] Screenshots skip the screen-corner mask; move tests to the end of screenshot_pixels; nested output-change cancel Co-Authored-By: Claude Sonnet 5.5 --- rust/pantheon-compositor/src/cursor.rs | 23 ++- .../src/screenshot_pixels.rs | 136 +++++++++--------- rust/pantheon-compositor/src/winit.rs | 1 + 3 files changed, 91 insertions(+), 69 deletions(-) diff --git a/rust/pantheon-compositor/src/cursor.rs b/rust/pantheon-compositor/src/cursor.rs index 1c52b618d..312ec687a 100644 --- a/rust/pantheon-compositor/src/cursor.rs +++ b/rust/pantheon-compositor/src/cursor.rs @@ -644,6 +644,23 @@ pub fn render_desktop_elements( state: &PantheonCompositor, output: &smithay::output::Output, ) -> Result>, smithay::output::OutputNoMode> +where + R: Renderer + ImportAll + ImportMem + CornerRenderer, + R::TextureId: Texture + Clone + Send + 'static, +{ + render_desktop_elements_with(renderer, state, output, true) +} + +/// `screen_mask` draws the built-in display's rounded corners over everything. +/// Screenshots pass false: the mask is a property of the panel, not of the +/// desktop, so a capture keeps full square content (as on macOS) while window +/// rounding stays. +pub fn render_desktop_elements_with( + renderer: &mut R, + state: &PantheonCompositor, + output: &smithay::output::Output, + screen_mask: bool, +) -> Result>, smithay::output::OutputNoMode> where R: Renderer + ImportAll + ImportMem + CornerRenderer, R::TextureId: Texture + Clone + Send + 'static, @@ -653,7 +670,11 @@ where AsRenderElements, utils::{Relocate, RelocateRenderElement, RescaleRenderElement}, }; - let mut elements = render_screen_mask_elements(renderer, state, output); + let mut elements = if screen_mask { + render_screen_mask_elements(renderer, state, output) + } else { + Vec::new() + }; elements.extend(render_cursor_elements(renderer, state, output)); // Selection feedback sits above windows and below the pointer. Capture // starts only after the selection ends, so it never lands in the image. diff --git a/rust/pantheon-compositor/src/screenshot_pixels.rs b/rust/pantheon-compositor/src/screenshot_pixels.rs index 69022859b..8aea73b0e 100644 --- a/rust/pantheon-compositor/src/screenshot_pixels.rs +++ b/rust/pantheon-compositor/src/screenshot_pixels.rs @@ -78,7 +78,7 @@ where if size.w <= 0 || size.h <= 0 || i64::from(size.w) * i64::from(size.h) > MAX_PIXELS as i64 { return Err("Screenshot dimensions exceed the capture limit".into()); } - let elements = crate::cursor::render_desktop_elements(renderer, state, output) + let elements = crate::cursor::render_desktop_elements_with(renderer, state, output, false) .map_err(|error| format!("Could not collect screenshot surfaces: {error}"))?; let mut target = renderer .create_buffer(Fourcc::Abgr8888, size) @@ -138,73 +138,6 @@ pub fn crop_output( .then(|| image::imageops::crop_imm(image, x, y, end_x - x, end_y - y).to_image()) } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn preserves_channels_and_corrects_bottom_up_rows() { - let bytes = [255, 0, 0, 255, 0, 0, 255, 128]; - let upright = rgba_image(1, 2, &bytes, false).unwrap(); - assert_eq!(upright.get_pixel(0, 0).0, [255, 0, 0, 255]); - let flipped = rgba_image(1, 2, &bytes, true).unwrap(); - assert_eq!(flipped.get_pixel(0, 0).0, [0, 0, 255, 128]); - assert_eq!(flipped.get_pixel(0, 1).0, [255, 0, 0, 255]); - } - #[test] - fn region_crop_handles_negative_origins_fractional_scale_and_clipping() { - use crate::screenshot::Region; - let image = - image::RgbaImage::from_fn(150, 120, |x, y| image::Rgba([x as u8, y as u8, 0, 255])); - let output = Region { - x: -100, - y: -80, - width: 100, - height: 80, - }; - let selected = Region { - x: -90, - y: -70, - width: 20, - height: 20, - }; - let cropped = crop_output(&image, output, selected).unwrap(); - assert_eq!(cropped.dimensions(), (30, 30)); - assert_eq!(cropped.get_pixel(0, 0).0, [15, 15, 0, 255]); - let clipped = crop_output( - &image, - output, - Region { - x: -110, - y: -90, - width: 20, - height: 20, - }, - ) - .unwrap(); - assert_eq!(clipped.dimensions(), (15, 15)); - assert!( - crop_output( - &image, - output, - Region { - x: 1, - y: 1, - width: 10, - height: 10 - } - ) - .is_none() - ); - } - - #[test] - fn rejects_empty_oversized_and_truncated_buffers() { - assert!(rgba_image(0, 1, &[], false).is_err()); - assert!(rgba_image(u32::MAX, u32::MAX, &[], false).is_err()); - assert!(rgba_image(1, 1, &[0; 3], false).is_err()); - } -} - /// Consume one explicit request; encoding and file I/O run off the render loop. pub fn process_request( renderer: &mut R, @@ -307,6 +240,73 @@ pub fn process_request( } } +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preserves_channels_and_corrects_bottom_up_rows() { + let bytes = [255, 0, 0, 255, 0, 0, 255, 128]; + let upright = rgba_image(1, 2, &bytes, false).unwrap(); + assert_eq!(upright.get_pixel(0, 0).0, [255, 0, 0, 255]); + let flipped = rgba_image(1, 2, &bytes, true).unwrap(); + assert_eq!(flipped.get_pixel(0, 0).0, [0, 0, 255, 128]); + assert_eq!(flipped.get_pixel(0, 1).0, [255, 0, 0, 255]); + } + #[test] + fn region_crop_handles_negative_origins_fractional_scale_and_clipping() { + use crate::screenshot::Region; + let image = + image::RgbaImage::from_fn(150, 120, |x, y| image::Rgba([x as u8, y as u8, 0, 255])); + let output = Region { + x: -100, + y: -80, + width: 100, + height: 80, + }; + let selected = Region { + x: -90, + y: -70, + width: 20, + height: 20, + }; + let cropped = crop_output(&image, output, selected).unwrap(); + assert_eq!(cropped.dimensions(), (30, 30)); + assert_eq!(cropped.get_pixel(0, 0).0, [15, 15, 0, 255]); + let clipped = crop_output( + &image, + output, + Region { + x: -110, + y: -90, + width: 20, + height: 20, + }, + ) + .unwrap(); + assert_eq!(clipped.dimensions(), (15, 15)); + assert!( + crop_output( + &image, + output, + Region { + x: 1, + y: 1, + width: 10, + height: 10 + } + ) + .is_none() + ); + } + + #[test] + fn rejects_empty_oversized_and_truncated_buffers() { + assert!(rgba_image(0, 1, &[], false).is_err()); + assert!(rgba_image(u32::MAX, u32::MAX, &[], false).is_err()); + assert!(rgba_image(1, 1, &[0; 3], false).is_err()); + } +} + /// Orientation through the exact renderer stack the native session uses /// (GbmGles multi-GPU renderer, offscreen texture, damage-tracker render, /// `read_framebuffer`). Needs a hardware render node; skipped without one. diff --git a/rust/pantheon-compositor/src/winit.rs b/rust/pantheon-compositor/src/winit.rs index 30c34966d..d183e7404 100644 --- a/rust/pantheon-compositor/src/winit.rs +++ b/rust/pantheon-compositor/src/winit.rs @@ -138,6 +138,7 @@ pub fn init_winit( } WinitEvent::Redraw => { tracing::trace!("rendering nested compositor frame"); + state.cancel_screenshot_on_output_change(); state.update_fractional_scale_preferences(); let size = backend.window_size(); let damage = Rectangle::from_size(size);