diff --git a/mseries/BUG-FIX-ROADMAP.md b/mseries/BUG-FIX-ROADMAP.md index 7de9920a1..37a9ca4e7 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 windows do not consistently follow the rounded shape of the Mac's built-in display, and the current GTK window-radius tweak does not cover every application. The Gala screen-corner mask only rounds the display boundary; it does not round individual windows. | Define and implement a system-level window-surface policy for the built-in display, then qualify native GTK, libadwaita, and third-party windows, including client-side decorations, maximized/fullscreen windows, dialogs, popovers and external displays. Keep application content intact and document any surfaces the compositor cannot clip cleanly. Apple HIG guidance supports system-consistent, system-provided window framing, but does not publish one mandatory numeric radius for all app windows: [Windows](https://developer.apple.com/design/human-interface-guidelines/windows). | New; needs implementation and visual qualification | +| P2 | App windows do not consistently follow the rounded shape of the Mac's built-in display, and the current GTK window-radius tweak does not cover every application. The Gala screen-corner mask only rounds the display boundary; it does not round individual windows. | Define and implement a system-level window-surface policy for the built-in display, then qualify native GTK, libadwaita, and third-party windows, including client-side decorations, maximized/fullscreen windows, dialogs, popovers and external displays. Keep application content intact and document any surfaces the compositor cannot clip cleanly. Apple HIG guidance supports system-consistent, system-provided window framing, but does not publish one mandatory numeric radius for all app windows: [Windows](https://developer.apple.com/design/human-interface-guidelines/windows). | Built 2026-10-23 in the Rust compositor (branch `claude/compositor-corners`: screen mask, window clip shader, hug-the-display rule, popups, panels, per-output policy; see `SDK-CORNERS.md` section 7). Not installed; needs the owner's visual qualification on the built-in display and the app matrix | | 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..627da41e2 100644 --- a/mseries/SDK-CORNERS.md +++ b/mseries/SDK-CORNERS.md @@ -33,7 +33,7 @@ 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) +## 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 +72,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 8937e2f0c..9c9f1031d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1905,6 +1905,7 @@ dependencies = [ "bitflags", "gio", "image", + "msdk", "smithay", "tracing", "tracing-subscriber", 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..3550824aa --- /dev/null +++ b/rust/pantheon-compositor/src/corners.rs @@ -0,0 +1,621 @@ +//! 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, +} + +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(); + Self { + screen: 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, 14.0); + assert_eq!(t.window, 12.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}" + ); + } +} diff --git a/rust/pantheon-compositor/src/cursor.rs b/rust/pantheon-compositor/src/cursor.rs index d0bd253c2..a5702eeb9 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 @@ -69,11 +72,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>>, } @@ -118,7 +122,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 { @@ -211,7 +215,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()?; @@ -241,7 +245,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 { @@ -392,7 +396,7 @@ 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(); @@ -402,6 +406,7 @@ where let scale = Scale::from(output.current_scale().fractional_scale()); // The pointer (and any volume/brightness OSD) above the lock surface. let mut elements = render_cursor_elements(renderer, state, output); + elements.extend(render_screen_mask_elements(renderer, state, output)); elements.extend( render_elements_from_surface_tree::>( renderer, @@ -415,6 +420,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. @@ -424,7 +456,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}; @@ -513,7 +545,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; @@ -522,6 +554,7 @@ where utils::{Relocate, RelocateRenderElement, RescaleRenderElement}, }; let mut elements = render_cursor_elements(renderer, state, output); + elements.extend(render_screen_mask_elements(renderer, state, output)); elements.extend(render_snap_assist_elements(renderer, state, output)); if let (Some(animation), Some(area)) = ( &state.gesture_animation, @@ -626,10 +659,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 57c0a9e67..07c9368e3 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; diff --git a/rust/pantheon-compositor/src/native.rs b/rust/pantheon-compositor/src/native.rs index 79e92e42c..e9972de6f 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/state.rs b/rust/pantheon-compositor/src/state.rs index e196cbcb2..2194b57eb 100644 --- a/rust/pantheon-compositor/src/state.rs +++ b/rust/pantheon-compositor/src/state.rs @@ -228,6 +228,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, + /// 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, @@ -571,6 +575,8 @@ impl PantheonCompositor { gala_snapshot_at: Cell::new(None), fractional_scale_fingerprint: Cell::new(None), solid_buffers: Default::default(), + corner_tokens: crate::corners::CornerTokens::load(), + corner_masks: Default::default(), wallpaper_scaled_sender, idle_monitor, idle_notifier_state,