GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
refactor(backend): split window_runner into app, tessellate and text
window_runner.rs held four things in one 6.9k-line file: the client
contract, the tessellator, text shaping, and the Wayland runner. Only the
last knows the window system. The first three move out verbatim into
their own modules, so a second shell (macOS, the browser) can share them
without reaching into a Wayland file:
- backend/app.rs: the Application trait and the plain types it speaks in
(WindowSettings, LayerSettings, LogicalPosition/Size, WindowAction, ...)
- backend/tessellate.rs: DisplayList -> Vertex batches, the droplet
geometry, dl_batches_2d, and every public vertex helper (with the
near-roll fallback tests that exercise it)
- backend/text.rs: the shaped-buffer cache, family resolution, the
display list's text gathered for the glyph pass, the popover clamp
window_runner re-exports all three with globs, so every public path
(`cce_ui::backend::window_runner::*`, `cce_ui::engine::*`) is unchanged
and no client needs touching. The move is line-for-line: sorting the old
file against the four new ones differs only in module docs and imports.
Verified against the pre-change build: the same test results (504 pass;
the 2 failures are pre-existing here, a material default and a button
label width with no fonts or config installed), tests/plate_golden.rs
byte-identical, and the demo driven through 18 scripted input steps under
a headless sway (lavapipe) identical to the pixel at every step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 11 +-
src/backend/app.rs | 427 +++++
src/backend/mod.rs | 3 +
src/backend/tessellate.rs | 3057 ++++++++++++++++++++++++++++++++
src/backend/text.rs | 528 ++++++
src/backend/window_runner.rs | 3991 +-----------------------------------------
6 files changed, 4027 insertions(+), 3990 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9ac2df4..511be16 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,7 +31,8 @@ set outright.
per-corner radii, vectors with caps, arcs, circles, and the **relief primitives** — the
lit-surface family: bevels, plates, recesses, bosses, ridges, fillets, grooves, lattices, box unions; see the
`Prim` enum doc in `src/scene/paint.rs`). Tessellators live in
- `backend/window_runner.rs` and are re-exported through `src/engine.rs`.
+ `backend/tessellate.rs` and are re-exported through `backend/window_runner.rs` and
+ `src/engine.rs`.
- It is **both a library and a binary.** `src/lib.rs` is the toolkit; `src/main.rs` is
`DemoApp`, the reference `Application` — a small widget gallery on the Phase 6 target
architecture (display-list frame, scene-solver layout, routed events, in-frame
@@ -62,12 +63,12 @@ no `build.rs` and no codegen step to run.
## The `Application` trait — the client contract
-Every client implements `Application` (`src/backend/window_runner.rs`, re-exported from
-`engine.rs`). A client's `main.rs` is typically a struct implementing it plus a one-line
+Every client implements `Application` (`src/backend/app.rs`, re-exported from
+`window_runner` and `engine.rs`). A client's `main.rs` is typically a struct implementing it plus a one-line
`cce_ui::engine::run::<MyApp>();`. When adding a widget or client, **mirror an existing client**
(e.g. `cce-status-interface`) — do not invent a new structure.
-Key methods (see the trait def around `window_runner.rs:1450`):
+Key methods (see the trait def in `backend/app.rs`):
- `new`, `settings()` (→ `WindowSettings`), `layer()` (→ optional `LayerSettings` for
layer-shell surfaces like the status bar), `update(msg, needs_rebuild, exit)`, `tick(dt, …)`.
**`tick` is not a clock.** Since 2026-09-11 the runner sleeps between ticks while the
@@ -190,7 +191,7 @@ nothing.
## Rendering: one paint path (the Phase 3 state)
The backend `render()` **always builds a `scene::paint::DisplayList` and tessellates that single
-list** (`window_runner.rs` ~1799). Two ways an app feeds it:
+list** (`EngineState::render` in `window_runner.rs`). Two ways an app feeds it:
1. **Migrated**: return `Some(DisplayList)` from `Application::display_list()`.
2. **Legacy (default)**: return `None`, and the backend wraps the app's `view*`/`view_vectors`
diff --git a/src/backend/app.rs b/src/backend/app.rs
new file mode 100644
index 0000000..7c16a93
--- /dev/null
+++ b/src/backend/app.rs
@@ -0,0 +1,427 @@
+//! The client contract: the `Application` trait and the plain types it
+//! speaks in (`WindowSettings`, `LayerSettings`, `LogicalPosition`, …).
+//! Moved out of `window_runner` unchanged; still re-exported from there
+//! (and from `engine`) at the old paths.
+
+use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel;
+use wayland_client::QueueHandle;
+use cosmic_text::FontSystem;
+use crate::widget::{MouseButton, ElementState, MouseScrollDelta, KeyEvent};
+use crate::vk::VkRenderer;
+use super::window_runner::{EngineState, PointerCursorIcon as CursorIcon};
+use super::tessellate::Vertex;
+
+#[derive(Debug, Clone)]
+pub struct WindowSettings {
+ pub title: String,
+ pub app_id: String,
+ pub width: u32,
+ pub height: u32,
+ pub fullscreen: bool,
+ pub min_size: Option<(u32, u32)>,
+}
+
+/// A compositor-side window operation requested by the app: an interactive
+/// move or resize grab. Returned from [`Application::take_window_action`];
+/// the runner executes it with the serial of the most recent pointer press.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WindowAction {
+ Move,
+ Resize(xdg_toplevel::ResizeEdge),
+}
+
+// Re-export the wlr-layer-shell types apps need to describe a layer surface.
+pub use smithay_client_toolkit::shell::wlr_layer::{
+ Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
+};
+
+/// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
+/// surface (panels, overlays, notifications) instead of an xdg toplevel.
+/// Return one from [`Application::layer`] to select layer-shell.
+#[derive(Debug, Clone)]
+pub struct LayerSettings {
+ pub layer: LayerKind,
+ pub anchor: LayerAnchor,
+ pub exclusive_zone: i32,
+ pub keyboard_interactivity: LayerKeyboardInteractivity,
+ /// (top, right, bottom, left) margins in logical pixels.
+ pub margin: (i32, i32, i32, i32),
+ pub namespace: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct LogicalPosition {
+ pub x: f32,
+ pub y: f32,
+}
+
+impl LogicalPosition {
+ pub fn new(x: f32, y: f32) -> Self {
+ Self { x, y }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct LogicalSize {
+ pub width: f32,
+ pub height: f32,
+}
+
+impl LogicalSize {
+ pub fn new(width: f32, height: f32) -> Self {
+ Self { width, height }
+ }
+}
+
+pub struct RenderContext<'a> {
+ pub font_system: &'a mut FontSystem,
+}
+
+pub trait Application: Sized + 'static {
+ type Message: Send + Clone + 'static;
+
+ fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self;
+ fn settings(&self) -> WindowSettings;
+ /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
+ /// instead of an xdg toplevel. Defaults to `None` (a normal window).
+ fn layer(&self) -> Option<LayerSettings> {
+ None
+ }
+ /// Declare the window a UTILITY window: a tool whose shape is decided by
+ /// its contents. The compositor then never dictates a size to it (every
+ /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
+ /// become the surface's own initial size), offers no resize affordance
+ /// (the whole border band moves the window), and never saves geometry
+ /// for it, so a stale remembered size can't be restored over what the
+ /// app asks for. Declared over the cce window-management protocol at
+ /// window creation; on a compositor too old to know the request this is
+ /// silently a plain floating window. Defaults to `false`.
+ fn utility(&self) -> bool {
+ false
+ }
+ /// Declare the window the DESKTOP-GRID layer (zcce set_grid): the
+ /// compositor world-anchors the surface to the virtual desktop and
+ /// pans/zooms it per frame like window content; the app renders only
+ /// when handed a patch (see [`Application::grid_patch`]). The surface
+ /// becomes input-transparent and lives behind all windows. Needs
+ /// manager v6; on an older compositor the declaration is skipped.
+ /// Defaults to `false`.
+ fn grid(&self) -> bool {
+ false
+ }
+ /// A grid patch to render (grid apps only): virtual origin (`x`, `y`),
+ /// virtual size (`w`, `h`), and `scale` surface px per virtual unit.
+ /// Called right before the frame that must show it; the runner has
+ /// already resized the surface to `(w*scale, h*scale)` and acks the
+ /// patch so the coming commit is latched at the new anchor.
+ fn grid_patch(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _scale: f64) {}
+ fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
+ fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
+ /// How long the runner may sleep between `tick`s while the window is
+ /// idle — nothing to draw, no animation, no key held, no frame callback
+ /// outstanding. `None` (the default) lets it sleep until a Wayland
+ /// event or a message on the app's calloop `Sender` arrives, bounded by
+ /// [`IDLE_DISPATCH`]. Override with `Some` ONLY if your `tick` polls
+ /// something the loop cannot see — a `std::sync::mpsc` receiver drained
+ /// in `tick`, say — because with the default that poll waits for the
+ /// next unrelated event. The better fix is to send through the calloop
+ /// `Sender` handed to `new`, which wakes the loop by itself.
+ fn idle_poll_interval(&self) -> Option<std::time::Duration> {
+ None
+ }
+ /// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
+ /// tray-hover highlights). Deliberately separate from the single paint path.
+ fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
+ /// The part of the surface that changed since the last frame this app
+ /// painted, as (x, y, w, h) in logical px, taken (and reset) once per
+ /// rendered frame right after `display_list`. `None` — the default —
+ /// means all of it. Returning a rect makes the frame a partial one: only
+ /// that rect is repainted and only it is reported to the compositor as
+ /// damage, which is what keeps a small change on a very large surface
+ /// (an image dragged across the desktop grid) from costing a full
+ /// repaint on both sides. The app vouches for the rect: anything that
+ /// changed outside it keeps its old pixels. A frame that was skipped is
+ /// the runner's to make up — the next one is painted in full.
+ fn take_damage(&mut self, _size: LogicalSize, _scale: f64) -> Option<(f32, f32, f32, f32)> {
+ None
+ }
+ fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
+ None
+ }
+
+ /// Transparent overflow rim, in logical px, on the RIGHT and BOTTOM of
+ /// the window. Non-zero opts into buffer-larger-than-geometry mode: the
+ /// runner sizes the surface `margin` wider/taller than the configured
+ /// window size, publishes the top-left rect as the xdg window geometry
+ /// (what the compositor tiles, borders, and snaps) and an input region of
+ /// the frame plus any open popover rects — an overhanging menu stays
+ /// clickable while empty rim falls through to whatever is behind.
+ ///
+ /// Right/bottom ONLY, deliberately: the surface grows away from its
+ /// origin, so the frame never moves relative to the surface and pointer
+ /// coordinates stay valid across the resize (a leading rim shifts the
+ /// surface under an unmoved cursor, and the compositor's stale pointer
+ /// state then drops the very next click). Frame coords == surface coords:
+ /// no input translation, no paint shift — the app's only obligation is to
+ /// lay out against the frame (`display_list`'s `size` minus the margin);
+ /// content emitted past the frame edge renders in the rim instead of
+ /// clipping at the buffer edge.
+ ///
+ /// The value may change at runtime (return the popover overhang while a
+ /// menu is open, 0 otherwise): the engine re-derives the surface from the
+ /// stored frame and resizes on drift. Quantize the answer (e.g. 64px
+ /// steps) so an animating popover doesn't resize the surface per frame.
+ /// xdg toplevels only (layer surfaces ignore it).
+ fn overflow_margin(&self) -> u32 {
+ 0
+ }
+
+ fn desired_size(&self) -> Option<(u32, u32)> {
+ None
+ }
+
+ fn ui_context(&self) -> Option<&crate::context::UiContext> {
+ None
+ }
+
+ fn ui_context_mut(&mut self) -> Option<&mut crate::context::UiContext> {
+ None
+ }
+
+ /// Where a widget's open popover is DRAWN, as an offset from the rect it
+ /// reports (`popover_rect`). A widget reports in the coordinates it was
+ /// laid out in; an app that lays its page out unscrolled and shifts what
+ /// it emits draws the popover `scroll` px away from there, and returns
+ /// `(0.0, -scroll_y)` here for the page's widgets. Everything the engine
+ /// derives from a popover rect reads it through this: the text-occlusion
+ /// clamp, the overflow input region, and the region sent to the
+ /// compositor. Without it a menu opened on a scrolled page had the page's
+ /// text drawn over it, and cut a menu-shaped hole in the text one scroll
+ /// offset away.
+ fn popover_offset(&self, _id: crate::widget::WidgetId) -> (f32, f32) {
+ (0.0, 0.0)
+ }
+
+ /// Whether a left-press at (px, py) should start a compositor window drag. Every root
+ /// root plate container is dissolved (Phase 6), so the default is "no" — apps that want
+ /// drag-anywhere override this with `ctx.drag_allowed_at(px, py)`.
+ fn is_movable_root_plate_at(&self, _px: f32, _py: f32) -> bool {
+ false
+ }
+
+ fn clear_color(&self) -> [f32; 4] {
+ [0.0, 0.0, 0.0, 0.0]
+ }
+
+ fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
+
+ fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
+ (width, height)
+ }
+
+ /// Mime types this app accepts from a drag, in the app's own preference
+ /// order (the source's order is ignored — a browser lists `text/html`
+ /// before `text/uri-list` and which is more useful is the app's call).
+ /// The default is empty: the app accepts nothing and drags over it read
+ /// as "can't drop here", which is what every client did before drops
+ /// existed. Opting in also requires [`Application::handle_drop`].
+ fn drop_mimes(&self) -> &'static [&'static str] {
+ &[]
+ }
+
+ /// A completed drop: `data` is everything the source wrote for `mime`,
+ /// and `pos` is where it was released in the app's logical coordinates.
+ /// Runs on the main loop, after the transfer finished — this is not the
+ /// place to block, since the compositor is waiting on the next frame.
+ fn handle_drop(
+ &mut self,
+ _mime: &str,
+ _data: &[u8],
+ _pos: LogicalPosition,
+ _needs_rebuild: &mut bool,
+ ) {
+ }
+
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool);
+ fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message>;
+ fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool);
+ /// Trackpad pinch (zwp_pointer_gestures pinch). `factor` is the scale
+ /// change SINCE THE LAST update (1.0 = no change, >1 = fingers spreading),
+ /// so direct-manipulation zoom is `content_scale *= factor`. Return true
+ /// to consume; returning false falls back to the engine's legacy
+ /// synthesis — a ctrl+wheel PixelDelta sized for the graph's zoom mapping
+ /// (`y = (factor-1)/0.015`) — so ctrl-scroll-zoom surfaces keep working
+ /// without implementing this.
+ fn handle_pinch(&mut self, _factor: f32, _pos: LogicalPosition, _needs_rebuild: &mut bool) -> bool {
+ false
+ }
+ fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message>;
+
+ /// Undo, after the focused widget declined the chord (a text box that is
+ /// editing takes it for its own typing). Return true when something was
+ /// undone; false lets the key fall through to `handle_key_input` like any
+ /// other. The chords are `undo` / `redo` in `input.kdl` (cce-ui domain
+ /// defaults `ctrl+z` / `ctrl+shift+z`), resolved once at startup. Build
+ /// the history on `cce_ui::history::History`.
+ fn undo(&mut self, _needs_rebuild: &mut bool) -> bool {
+ false
+ }
+
+ /// Redo — see [`undo`](Self::undo).
+ fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
+ false
+ }
+
+ /// Opt into the toolkit's keyboard navigation in plate terms: Tab and
+ /// Shift+Tab move focus to the next / previous plate or well in reading
+ /// order (`UiContext::focus_step`), a press (Enter / Space) acts on the
+ /// focused plate, a well opens for typing when focused. Default false: an
+ /// app that routes Tab itself (a terminal, a web view, its own field
+ /// order) is undisturbed. See "Plates, wells and seams" in `CLAUDE.md`.
+ fn plate_navigation(&self) -> bool {
+ false
+ }
+
+ /// Wait for the NEXT compositor when this one goes away, instead of
+ /// exiting. Default false, which is right for any window the compositor
+ /// saves and restores: its successor respawns the app itself, and a
+ /// client that rejoined too came up beside its own copy (see
+ /// [`after_session`]). Return true from a process the compositor does NOT
+ /// restore and that must outlive it — a systemd user service like the
+ /// status bar or the notifier, whose D-Bus names (the tray's
+ /// StatusNotifierWatcher, org.freedesktop.Notifications) other programs
+ /// depend on. Exiting took those names down at every logout and
+ /// compositor restart, and Dropbox, starting into the gap, found no tray.
+ fn outlives_compositor(&self) -> bool {
+ false
+ }
+
+ /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
+ /// caches its geometry until its own rebuild flag (relief carves collected
+ /// in a view pass, widget lists built on layout) raises that flag here, so
+ /// the new ring is drawn; an app that paints fresh every frame needs
+ /// nothing. Default: nothing.
+ fn focus_stepped(&mut self) {}
+ /// Keyboard focus entered/left the window (the compositor keyboard-focuses
+ /// the focused window, so this is the "am I the focused window" signal —
+ /// e.g. for focus-dependent chrome). Default: ignore.
+ fn handle_focus_change(&mut self, _focused: bool, _needs_rebuild: &mut bool) {}
+
+ fn custom_vertices(&mut self, _verts: &mut Vec<Vertex>, _size: LogicalSize, _scale: f64) {}
+
+ /// The frame's geometry, drawn via one batched, GPU-scissor-clipped pass (the single
+ /// paint path). Every rendering app implements this — the legacy `view*` sinks are gone;
+ /// `None` yields an empty frame. Overlays ([`overlay_quads`](Application::overlay_quads))
+ /// and [`custom_vertices`](Application::custom_vertices) still go through their own paths;
+ /// text renders from the list when [`display_list_text`](Application::display_list_text)
+ /// opts in. Receives the frame's logical size and HiDPI scale. Typically implemented as
+ /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, &self.root))`.
+ fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<crate::scene::paint::DisplayList> {
+ None
+ }
+
+ /// Opt in to render the display list's `Prim::Text` items through the glyph pass
+ /// (shaped via the shared buffer cache, clipped to the item clip ∩ the prim bounds). An
+ /// app's ENTIRE frame — geometry and text — is then one
+ /// [`display_list`](Application::display_list). Default `false` draws no text (an app that
+ /// only draws geometry, or none at all).
+ ///
+ /// Display-list text gets the same popover-occlusion clamp as the legacy `text_areas`
+ /// mapping (`popover_occlusion_clamp`, driven by `ui_context().active_popovers`), so an
+ /// open popover's plate clips list text beneath it on both paths.
+ fn display_list_text(&self) -> bool {
+ false
+ }
+
+ /// Opt into system fonts in the ENGINE's render `FontSystem` (the one that shapes
+ /// display-list text and rasterizes every glyph at prepare time). Default `false`: the
+ /// render FontSystem loads only the bundled CCE fonts, and text asking for a family that
+ /// exists only among installed system fonts is silently invisible — buffers shaped
+ /// app-side against a system-fonts `FontSystem` carry fontdb face IDs the engine's
+ /// database doesn't have (the cce-colors Phase 6e bug). An app whose UI must render
+ /// arbitrary installed families (the font picker) returns `true`; its own `FontSystem`,
+ /// if it keeps one for measurement, should be `create_font_system_with_system_fonts()`
+ /// so both databases load identically. Consulted once, at GPU init.
+ fn load_system_fonts(&self) -> bool {
+ false
+ }
+
+ /// Called once, right after the renderer is created and before the first
+ /// frame: create persistent renderer resources here (3D meshes via
+ /// [`VkRenderer::create_mesh`]). Most 2D apps never need this.
+ fn renderer_init(&mut self, _renderer: &mut VkRenderer) {}
+
+ /// Direct renderer staging, called every frame after the engine's own text
+ /// prep and immediately before the frame is drawn: stage 3D scene panes
+ /// (`stage_scene`), path-traced panes (`stage_rt`), flush mesh updates, or
+ /// prepare app-shaped text (`prepare_text` — an app that returns `false`
+ /// from [`display_list_text`](Application::display_list_text) fully owns
+ /// the renderer's text state, the engine never touches it). Return `true`
+ /// to request another frame immediately (e.g. while a path tracer is still
+ /// accumulating samples).
+ fn stage_renderer(&mut self, _renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
+ false
+ }
+
+ /// The surface was resized (or the scale factor changed): `width`/`height`
+ /// are the new logical size. The renderer has already been resized; use
+ /// this for stateful relayout that can't wait for the next paint callback.
+ fn handle_resize(&mut self, _width: f32, _height: f32, _scale: f64) {}
+
+ /// Whether the runner's built-in client-side decorations apply: the
+ /// titlebar move band, the movable-root plate drag regions, and — when
+ /// [`csd_resize_borders`](Application::csd_resize_borders) is also on —
+ /// the rect-edge resize grabs and their edge cursors. Return `false` for a
+ /// window whose chrome doesn't follow its rect (e.g. a circular pane) and
+ /// drive moves/resizes yourself via
+ /// [`take_window_action`](Application::take_window_action).
+ fn standard_csd(&self) -> bool {
+ true
+ }
+
+ /// Whether the standard CSD claims the outer 8px of the surface as resize
+ /// grabs (with matching edge cursors). Off by default: under the cce
+ /// compositor the server already provides a resize band just *outside* the
+ /// window, so enabling this gives a window two adjacent 8px gutters driven
+ /// by different code paths — and only the compositor's snaps to the
+ /// desktop grid. It also costs the app clicks, since a press inside the
+ /// band starts a grab and never reaches the widgets underneath.
+ ///
+ /// Turn it on for a window that must be resizable by its own edges under a
+ /// compositor that provides no such affordance. Only consulted when
+ /// [`standard_csd`](Application::standard_csd) is on.
+ fn csd_resize_borders(&self) -> bool {
+ false
+ }
+
+ /// Whether the standard CSD reserves an implicit title-bar strip (`y` in `[8, 32)`) as a
+ /// drag-to-move handle. Opt-in: off by default, so a window has no title bar and is moved
+ /// through the compositor (or via explicitly-declared handles —
+ /// [`is_movable_root_plate_at`](Application::is_movable_root_plate_at)); nothing is
+ /// implicitly draggable. An app with an actual title bar returns `true`. Separate from
+ /// [`standard_csd`](Application::standard_csd), which also gates the resize borders, and
+ /// only consulted when `standard_csd()` is on.
+ fn csd_titlebar_move(&self) -> bool {
+ false
+ }
+
+ /// Override the pointer cursor at (x, y). `None` falls back to the
+ /// runner's standard CSD edge cursors (or `Default` when
+ /// [`standard_csd`](Application::standard_csd) is off).
+ fn cursor_icon(&self, _x: f32, _y: f32) -> Option<CursorIcon> {
+ None
+ }
+
+ /// Polled after each pointer frame is dispatched: return a
+ /// [`WindowAction`] to start an interactive move/resize grab with the
+ /// serial of the most recent pointer press. This is take-semantics — the
+ /// implementation should clear its pending action when returning it.
+ fn take_window_action(&mut self) -> Option<WindowAction> {
+ None
+ }
+
+ /// Called once when the event loop ends (window closed, app-requested
+ /// exit): last-chance work like autosave. The surface is still alive.
+ fn on_exit(&mut self) {}
+}
+
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 4939ad2..1160cfd 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,5 +1,8 @@
+pub mod app;
pub mod dnd;
pub mod menu_popup;
+pub mod tessellate;
+pub mod text;
pub mod window_runner;
pub use window_runner::{
diff --git a/src/backend/tessellate.rs b/src/backend/tessellate.rs
new file mode 100644
index 0000000..e887492
--- /dev/null
+++ b/src/backend/tessellate.rs
@@ -0,0 +1,3057 @@
+//! The tessellator: a `DisplayList` (or the legacy primitive tuples) turned
+//! into `Vertex` batches with their push constants, plus the vertex helpers
+//! apps call directly. Platform-neutral — it produces the data the renderer
+//! draws and knows nothing of the window system; moved out of
+//! `window_runner` so another shell can share it.
+
+use crate::widget::WidgetHost;
+use crate::vk::Batch2D;
+
+/// A droplet spec resolved against a concrete rect: the push-constant fields
+/// that define its SILHOUETTE, in logical px.
+///
+/// Shared by [`crate::scene::paint::Prim::Droplet`] and
+/// [`crate::scene::paint::Prim::DropletScrim`] so the lit drop and the vignette
+/// drawn inside it can never disagree about the shape — the whole reason the
+/// scrim rides the droplet's shader path instead of approximating the outline
+/// with a rounded rect.
+struct DropletGeom {
+ hx: f32,
+ hy: f32,
+ sag: f32,
+ br: f32,
+ bw: f32,
+ k: f32,
+ sr: f32,
+ ar: f32,
+ band: f32,
+ bow: f32,
+ /// How far the contact shadow reaches below/beside the box (0 when the
+ /// spec has no shadow). The lit drop's cover quad grows by this; a scrim
+ /// never draws outside the silhouette and ignores it.
+ sh_reach: f32,
+}
+
+fn droplet_geom(rect: &crate::scene::layout::Rect, spec: &crate::scene::paint::DropletSpec) -> DropletGeom {
+ let hx = rect.width * 0.5;
+ let hy = rect.height * 0.5;
+ let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
+ // belly <= 0 disables the belly outright (the oval-dewdrop default) — the
+ // shader skips the smin when the radius is 0.
+ let (br, bw) = if spec.belly > 0.0 {
+ let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
+ (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
+ } else {
+ (0.0, 0.0)
+ };
+ let k = (spec.blend.max(0.0) * rect.height).max(1.0);
+ let sheet_hy = hy - sag * 0.5;
+ // Bottom (sheet_r) and top (attach) corner radii: when the pair overfills
+ // the sheet height, scale both down proportionally — 0.5 + 0.5 is the
+ // fully continuous egg.
+ let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
+ let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
+ let sheet_h = (2.0 * sheet_hy).max(0.0);
+ if sr + ar > sheet_h && sr + ar > 0.0 {
+ let f = sheet_h / (sr + ar);
+ sr *= f;
+ ar *= f;
+ }
+ let band = (spec.band.max(0.05) * rect.height).max(1.0);
+ // Bottom-bow edge rise; the shader derives the arc radius from it per drop
+ // (R = hx^2/2*rise).
+ let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
+ let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
+ DropletGeom { hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach }
+}
+
+/// The tessellated display list's batches, scissors and rounded clips scaled
+/// to physical px.
+pub(crate) fn dl_batches_2d(dl_batches: &[DlBatch], scale_f32: f32) -> Vec<Batch2D> {
+ dl_batches
+ .iter()
+ .map(|batch| Batch2D {
+ scissor: batch.scissor.map(|clip| {
+ (
+ (clip.x * scale_f32).max(0.0) as u32,
+ (clip.y * scale_f32).max(0.0) as u32,
+ (clip.width * scale_f32) as u32,
+ (clip.height * scale_f32) as u32,
+ )
+ }),
+ clip_rrect: batch
+ .clip_rrect
+ .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
+ start: batch.start,
+ end: batch.end,
+ plate: batch.plate,
+ blur_behind: batch.blur_behind,
+ })
+ .collect()
+}
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct Vertex {
+ pub position: [f32; 2],
+ pub color: [f32; 4],
+ pub clip_circle: [f32; 3], // [cx, cy, r]
+}
+
+pub fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [f32; 4]) -> [Vertex; 6] {
+ let x0 = (x / sw) * 2.0 - 1.0;
+ let y0 = 1.0 - (y / sh) * 2.0;
+ let x1 = ((x + w) / sw) * 2.0 - 1.0;
+ let y1 = 1.0 - ((y + h) / sh) * 2.0;
+ [
+ Vertex { position: [x0, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
+ Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
+ Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
+ Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
+ Vertex { position: [x1, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
+ Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
+ ]
+}
+
+pub fn quad_vertices_with_clip(
+ x: f32, y: f32, w: f32, h: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+) -> [Vertex; 6] {
+ let x0 = (x / sw) * 2.0 - 1.0;
+ let y0 = 1.0 - (y / sh) * 2.0;
+ let x1 = ((x + w) / sw) * 2.0 - 1.0;
+ let y1 = 1.0 - ((y + h) / sh) * 2.0;
+ [
+ Vertex { position: [x0, y0], color, clip_circle },
+ Vertex { position: [x1, y0], color, clip_circle },
+ Vertex { position: [x0, y1], color, clip_circle },
+ Vertex { position: [x1, y0], color, clip_circle },
+ Vertex { position: [x1, y1], color, clip_circle },
+ Vertex { position: [x0, y1], color, clip_circle },
+ ]
+}
+
+/// A quad whose four corners each carry their own color, Gouraud-interpolated across both
+/// triangles by the shader (`@location(0) color` has no `flat` qualifier). Corner order is
+/// TL, TR, BR, BL. Keep the alpha equal on all four: negative alpha is the blur sentinel,
+/// so a gradient that crossed zero would tear the triangle in half.
+pub fn quad_vertices_shaded(
+ x: f32, y: f32, w: f32, h: f32,
+ sw: f32, sh: f32,
+ c_tl: [f32; 4], c_tr: [f32; 4], c_br: [f32; 4], c_bl: [f32; 4],
+ clip_circle: [f32; 3],
+) -> [Vertex; 6] {
+ let x0 = (x / sw) * 2.0 - 1.0;
+ let y0 = 1.0 - (y / sh) * 2.0;
+ let x1 = ((x + w) / sw) * 2.0 - 1.0;
+ let y1 = 1.0 - ((y + h) / sh) * 2.0;
+ [
+ Vertex { position: [x0, y0], color: c_tl, clip_circle },
+ Vertex { position: [x1, y0], color: c_tr, clip_circle },
+ Vertex { position: [x0, y1], color: c_bl, clip_circle },
+ Vertex { position: [x1, y0], color: c_tr, clip_circle },
+ Vertex { position: [x1, y1], color: c_br, clip_circle },
+ Vertex { position: [x0, y1], color: c_bl, clip_circle },
+ ]
+}
+
+pub fn quad_vertices_clipped(
+ x: f32, y: f32, w: f32, h: f32,
+ surface_w: f32, surface_h: f32,
+ color: [f32; 4],
+ clip: (f32, f32, f32, f32),
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let (cx0, cy0, cx1, cy1) = clip;
+ let ix0 = x.max(cx0);
+ let iy0 = y.max(cy0);
+ let ix1 = (x + w).min(cx1);
+ let iy1 = (y + h).min(cy1);
+ if ix1 <= ix0 || iy1 <= iy0 {
+ return Vec::new();
+ }
+ quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, surface_w, surface_h, color, clip_circle).to_vec()
+}
+
+pub fn line_vertices(
+ x1: f32, y1: f32, x2: f32, y2: f32,
+ thickness: f32,
+ sw: f32, sh: f32,
+ c: [f32; 4]
+) -> [Vertex; 6] {
+ let dx = x2 - x1;
+ let dy = y2 - y1;
+ let len = (dx * dx + dy * dy).sqrt();
+ if len < 0.001 {
+ return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c);
+ }
+ let ux = dx / len;
+ let uy = dy / len;
+ let nx = -uy;
+ let ny = ux;
+
+ let half_t = thickness * 0.5;
+ let p0x = x1 + nx * half_t;
+ let p0y = y1 + ny * half_t;
+ let p1x = x1 - nx * half_t;
+ let p1y = y1 - ny * half_t;
+ let p2x = x2 - nx * half_t;
+ let p2y = y2 - ny * half_t;
+ let p3x = x2 + nx * half_t;
+ let p3y = y2 + ny * half_t;
+
+ let ndc_p0x = (p0x / sw) * 2.0 - 1.0;
+ let ndc_p0y = 1.0 - (p0y / sh) * 2.0;
+ let ndc_p1x = (p1x / sw) * 2.0 - 1.0;
+ let ndc_p1y = 1.0 - (p1y / sh) * 2.0;
+ let ndc_p2x = (p2x / sw) * 2.0 - 1.0;
+ let ndc_p2y = 1.0 - (p2y / sh) * 2.0;
+ let ndc_p3x = (p3x / sw) * 2.0 - 1.0;
+ let ndc_p3y = 1.0 - (p3y / sh) * 2.0;
+
+ let clip_circle = [0.0, 0.0, 0.0];
+ [
+ Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
+ Vertex { position: [ndc_p1x, ndc_p1y], color: c, clip_circle },
+ Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
+ Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
+ Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
+ Vertex { position: [ndc_p3x, ndc_p3y], color: c, clip_circle },
+ ]
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
+pub enum LineCap {
+ Arrow,
+ Round,
+ Flat,
+}
+
+pub fn vector_vertices(
+ x1: f32, y1: f32, x2: f32, y2: f32,
+ thickness: f32,
+ sw: f32, sh: f32,
+ c: [f32; 4],
+ line_cap: LineCap,
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ let dx = x2 - x1;
+ let dy = y2 - y1;
+ let len = (dx * dx + dy * dy).sqrt();
+ if len < 0.001 {
+ return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c).to_vec();
+ }
+
+ match line_cap {
+ LineCap::Arrow => {
+ let ux = dx / len;
+ let uy = dy / len;
+ let nx = -uy;
+ let ny = ux;
+
+ let arrow_len = (thickness * 3.0).max(10.0).min(len);
+ let arrow_width = (thickness * 2.5).max(8.0);
+
+ let line_x2 = x2 - ux * arrow_len;
+ let line_y2 = y2 - uy * arrow_len;
+
+ if len > arrow_len {
+ verts.extend_from_slice(&line_vertices(x1, y1, line_x2, line_y2, thickness, sw, sh, c));
+ }
+
+ let bx = line_x2;
+ let by = line_y2;
+
+ let w1x = bx + nx * (arrow_width * 0.5);
+ let w1y = by + ny * (arrow_width * 0.5);
+ let w2x = bx - nx * (arrow_width * 0.5);
+ let w2y = by - ny * (arrow_width * 0.5);
+
+ let ndc_tip_x = (x2 / sw) * 2.0 - 1.0;
+ let ndc_tip_y = 1.0 - (y2 / sh) * 2.0;
+ let ndc_w1x = (w1x / sw) * 2.0 - 1.0;
+ let ndc_w1y = 1.0 - (w1y / sh) * 2.0;
+ let ndc_w2x = (w2x / sw) * 2.0 - 1.0;
+ let ndc_w2y = 1.0 - (w2y / sh) * 2.0;
+
+ let clip_circle = [0.0, 0.0, 0.0];
+ verts.push(Vertex { position: [ndc_tip_x, ndc_tip_y], color: c, clip_circle });
+ verts.push(Vertex { position: [ndc_w1x, ndc_w1y], color: c, clip_circle });
+ verts.push(Vertex { position: [ndc_w2x, ndc_w2y], color: c, clip_circle });
+ }
+ LineCap::Round => {
+ push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
+ let clip_circle = [0.0, 0.0, 0.0];
+ verts.extend(circle_vertices(x2, y2, thickness / 2.0, sw, sh, c, 16, clip_circle));
+ }
+ LineCap::Flat => {
+ push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
+ }
+ }
+
+ verts
+}
+
+/// `line_vertices` with a half-px alpha ramp along each long edge (the arc
+/// tessellator's poor-man's AA) — diagonal strokes resolve smoothly instead of
+/// stair-stepping. Axis-aligned strokes keep the crisp single-quad path:
+/// feathering a pixel-snapped hairline would only blur it.
+fn push_feathered_line_vertices(
+ x1: f32, y1: f32, x2: f32, y2: f32,
+ thickness: f32,
+ sw: f32, sh: f32,
+ c: [f32; 4],
+ out: &mut Vec<Vertex>,
+) {
+ let dx = x2 - x1;
+ let dy = y2 - y1;
+ let len = (dx * dx + dy * dy).sqrt();
+ if len < 0.001 || dx.abs() < 0.01 || dy.abs() < 0.01 {
+ out.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
+ return;
+ }
+ let (nx, ny) = (-dy / len, dx / len);
+ let f = 0.5f32.min(thickness * 0.25);
+ let half = thickness * 0.5;
+ // (offset at band start, offset at band end, alpha at start, alpha at end)
+ let bands = [
+ (-half - f, -half + f, 0.0, c[3]),
+ (-half + f, half - f, c[3], c[3]),
+ (half - f, half + f, c[3], 0.0),
+ ];
+ for &(oa, ob, aa, ab) in &bands {
+ let ca = [c[0], c[1], c[2], aa];
+ let cb = [c[0], c[1], c[2], ab];
+ let p = |x: f32, y: f32, o: f32| -> [f32; 2] {
+ [((x + nx * o) / sw) * 2.0 - 1.0, 1.0 - ((y + ny * o) / sh) * 2.0]
+ };
+ let clip_circle = [0.0, 0.0, 0.0];
+ let (a1, b1) = (p(x1, y1, oa), p(x1, y1, ob));
+ let (a2, b2) = (p(x2, y2, oa), p(x2, y2, ob));
+ out.push(Vertex { position: a1, color: ca, clip_circle });
+ out.push(Vertex { position: b1, color: cb, clip_circle });
+ out.push(Vertex { position: b2, color: cb, clip_circle });
+ out.push(Vertex { position: a1, color: ca, clip_circle });
+ out.push(Vertex { position: b2, color: cb, clip_circle });
+ out.push(Vertex { position: a2, color: ca, clip_circle });
+ }
+}
+
+pub fn rounded_rect_vertices_corners(
+ x: f32, y: f32, ww: f32, h: f32,
+ r: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+ corners: (bool, bool, bool, bool),
+ clip_rect: Option<(f32, f32, f32, f32)>,
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ let radii = crate::widget::CornerRadii::new(
+ if corners.0 { r } else { 0.0 },
+ if corners.1 { r } else { 0.0 },
+ if corners.2 { r } else { 0.0 },
+ if corners.3 { r } else { 0.0 },
+ );
+ push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, color, clip_circle, clip_rect, &mut verts);
+ verts
+}
+
+/// Sample of the unit superellipse |x|^n + |y|^n = 1 at circle parameter θ —
+/// the (cos θ, sin θ) replacement the corner fans use. Exactly the circle at
+/// n = 2; higher `corner_shape` exponents give the DE's continuous-curvature
+/// corners, so widget silhouettes follow the same corner family as the
+/// SDF-lit plates. `e` is 2/n, hoisted by callers. Tangent points at the
+/// quadrant ends are pinned to the exact axis points (see below), so fans
+/// tile exactly against the body rects and edge strips.
+#[inline]
+fn superellipse_pt(theta: f32, e: f32) -> (f32, f32) {
+ let (s, c) = theta.sin_cos();
+ // f32 sin/cos are not exactly 0 at a quadrant end (sin(PI) is -8.7e-8),
+ // and the fractional power magnifies that noise by orders of magnitude:
+ // at corner_shape 4.5 it is 7e-4, which on the desktop grid's 138 px
+ // cell corners put the fan's tangent vertex 0.1 px short of the body
+ // quad's edge. The fan's edge then tilts away from the quad's, and the
+ // row of pixel centres between them is covered by neither: a stray
+ // gap-coloured line 32 px long at every cell's left edge, and a lone
+ // gap pixel on the bottom row where the arc meets the body. Snap the
+ // ends to the exact axis points so fans tile against the rects.
+ let axis = |v: f32| -> f32 {
+ if v.abs() < 1e-6 {
+ 0.0
+ } else if v.abs() > 1.0 - 1e-6 {
+ v.signum()
+ } else {
+ v.signum() * v.abs().powf(e)
+ }
+ };
+ (axis(c), axis(s))
+}
+
+/// Feathered glow ([`Prim::Glow`]): the rounded rect's interior fills at the
+/// color's alpha and concentric outline rings fade it to zero across `reach`
+/// px outside the boundary. Alpha rides the VERTICES, so the GPU interpolates
+/// a per-pixel-smooth falloff between rings — stacked translucent layers band
+/// visibly; this cannot. Ring alphas sit on a quadratic ease-out, giving the
+/// vignette profile piecewise-linearly with kinks below visibility at glow
+/// alphas. Corners sample [`superellipse_pt`], so a glow's silhouette sits in
+/// the same corner family as the cells, nodes, and plates it highlights.
+pub fn push_glow_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ radius: f32, reach: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ if ww <= 0.0 || h <= 0.0 || color[3].abs() <= 0.0005 || sw <= 0.0 || sh <= 0.0 {
+ return;
+ }
+ let r0 = radius.clamp(0.0, ww.min(h) * 0.5);
+ let ctl = (x + r0, y + r0);
+ let ctr = (x + ww - r0, y + r0);
+ let cbr = (x + ww - r0, y + h - r0);
+ let cbl = (x + r0, y + h - r0);
+ const K: usize = 10;
+ use std::f32::consts::PI;
+ let corner_e = 2.0 / crate::layout::corner_shape();
+ // One outline ring `off` px outside the boundary, clockwise from the
+ // top-left arc; every ring shares the layout, so strips never twist.
+ let ring = |off: f32| -> Vec<[f32; 2]> {
+ let r = (r0 + off).max(0.0);
+ let mut pts = Vec::with_capacity(4 * (K + 1));
+ let corners = [
+ (ctl, PI, 1.5 * PI),
+ (ctr, 1.5 * PI, 2.0 * PI),
+ (cbr, 0.0, 0.5 * PI),
+ (cbl, 0.5 * PI, PI),
+ ];
+ for ((cx, cy), a0, a1) in corners {
+ for k in 0..=K {
+ let a = a0 + (a1 - a0) * (k as f32 / K as f32);
+ let (ux, uy) = superellipse_pt(a, corner_e);
+ pts.push([cx + r * ux, cy + r * uy]);
+ }
+ }
+ pts
+ };
+ let to_v = |p: [f32; 2], a: f32| Vertex {
+ position: [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0],
+ color: [color[0], color[1], color[2], a],
+ clip_circle,
+ };
+
+ let rings: Vec<(Vec<[f32; 2]>, f32)> = [0.0f32, 0.35, 0.7, 1.0]
+ .iter()
+ .map(|&t| (ring(reach * t), color[3] * (1.0 - t) * (1.0 - t)))
+ .collect();
+ let n = rings[0].0.len();
+
+ // Interior: a fan from the rect center over the innermost ring (a rounded
+ // rect is convex, so the fan covers it exactly), uniform core alpha.
+ let center = [x + ww * 0.5, y + h * 0.5];
+ for i in 0..n {
+ let p1 = rings[0].0[i];
+ let p2 = rings[0].0[(i + 1) % n];
+ out.push(to_v(center, color[3]));
+ out.push(to_v(p1, color[3]));
+ out.push(to_v(p2, color[3]));
+ }
+ // The feather: strips between consecutive rings, each vertex carrying its
+ // ring's alpha.
+ for w in rings.windows(2) {
+ let (inner, ia) = (&w[0].0, w[0].1);
+ let (outer, oa) = (&w[1].0, w[1].1);
+ for i in 0..n {
+ let a1 = inner[i];
+ let a2 = inner[(i + 1) % n];
+ let b1 = outer[i];
+ let b2 = outer[(i + 1) % n];
+ out.push(to_v(a1, ia));
+ out.push(to_v(b1, oa));
+ out.push(to_v(a2, ia));
+ out.push(to_v(a2, ia));
+ out.push(to_v(b1, oa));
+ out.push(to_v(b2, oa));
+ }
+ }
+}
+
+pub fn push_rounded_rect_vertices_corners(
+ x: f32, y: f32, ww: f32, h: f32,
+ radii: crate::widget::CornerRadii,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+ clip_rect: Option<(f32, f32, f32, f32)>,
+ out: &mut Vec<Vertex>,
+) {
+ let corner_e = 2.0 / crate::layout::corner_shape();
+ let mut r_tl = radii.top_left.max(0.0);
+ let mut r_tr = radii.top_right.max(0.0);
+ let mut r_br = radii.bottom_right.max(0.0);
+ let mut r_bl = radii.bottom_left.max(0.0);
+
+ // Simple scale clamping
+ let sum_top = r_tl + r_tr;
+ if sum_top > ww {
+ let f = ww / sum_top;
+ r_tl *= f;
+ r_tr *= f;
+ }
+ let sum_bottom = r_bl + r_br;
+ if sum_bottom > ww {
+ let f = ww / sum_bottom;
+ r_bl *= f;
+ r_br *= f;
+ }
+ let sum_left = r_tl + r_bl;
+ if sum_left > h {
+ let f = h / sum_left;
+ r_tl *= f;
+ r_bl *= f;
+ }
+ let sum_right = r_tr + r_br;
+ if sum_right > h {
+ let f = h / sum_right;
+ r_tr *= f;
+ r_br *= f;
+ }
+
+ let clamp_x = |val: f32| -> f32 {
+ if let Some((cx0, _, cx1, _)) = clip_rect {
+ val.max(cx0).min(cx1)
+ } else {
+ val
+ }
+ };
+ let clamp_y = |val: f32| -> f32 {
+ if let Some((_, cy0, _, cy1)) = clip_rect {
+ val.max(cy0).min(cy1)
+ } else {
+ val
+ }
+ };
+
+ let push_quad = |verts: &mut Vec<Vertex>, qx: f32, qy: f32, qw: f32, qh: f32| {
+ let x0 = clamp_x(qx);
+ let y0 = clamp_y(qy);
+ let x1 = clamp_x(qx + qw);
+ let y1 = clamp_y(qy + qh);
+
+ if x1 <= x0 || y1 <= y0 {
+ return;
+ }
+
+ let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
+ let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
+ let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
+ let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
+
+ verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
+ };
+
+ let has_corners = r_tl > 0.1 || r_tr > 0.1 || r_br > 0.1 || r_bl > 0.1;
+ if !has_corners {
+ push_quad(out, x, y, ww, h);
+ return;
+ }
+
+ // Body rectangles
+ let mid_x0 = r_tl.max(r_bl);
+ let mid_x1 = ww - r_tr.max(r_br);
+ if mid_x1 > mid_x0 {
+ push_quad(out, x + mid_x0, y, mid_x1 - mid_x0, h);
+ }
+ if h > r_tl + r_bl {
+ push_quad(out, x, y + r_tl, mid_x0, h - r_tl - r_bl);
+ }
+ if h > r_tr + r_br {
+ push_quad(out, x + mid_x1, y + r_tr, ww - mid_x1, h - r_tr - r_br);
+ }
+
+ // Corner rendering. The fans are FEATHERED: the fan body stops half a
+ // pixel short of the silhouette and a strip fades from opaque at
+ // silhouette-0.5 to transparent at silhouette+0.5, so the arc
+ // anti-aliases instead of rasterizing a hard staircase — invisible on
+ // HiDPI widget buffers, glaring on the desktop grid's world-scale
+ // cells. Perceived size is unchanged (the 50%-coverage line stays on
+ // the exact silhouette). Radii too small to feather keep the hard fan.
+ let segments = 16;
+ let fade = [color[0], color[1], color[2], 0.0];
+ let to_ndc = |px: f32, py: f32| -> [f32; 2] {
+ [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0]
+ };
+ let push_corner = |out: &mut Vec<Vertex>, cx: f32, cy: f32, r: f32, start: f32, end: f32| {
+ let feather = r > 1.5;
+ let r_fan = if feather { r - 0.5 } else { r };
+ let r_out = r + 0.5;
+ for i in 0..segments {
+ let theta1 = start + (i as f32) * (end - start) / (segments as f32);
+ let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
+
+ let (c1, s1) = superellipse_pt(theta1, corner_e);
+ let (c2, s2) = superellipse_pt(theta2, corner_e);
+ let p0 = to_ndc(clamp_x(cx), clamp_y(cy));
+ let p1 = to_ndc(clamp_x(cx + r_fan * c1), clamp_y(cy + r_fan * s1));
+ let p2 = to_ndc(clamp_x(cx + r_fan * c2), clamp_y(cy + r_fan * s2));
+
+ out.push(Vertex { position: p0, color, clip_circle });
+ out.push(Vertex { position: p1, color, clip_circle });
+ out.push(Vertex { position: p2, color, clip_circle });
+
+ if feather {
+ let q1 = to_ndc(clamp_x(cx + r_out * c1), clamp_y(cy + r_out * s1));
+ let q2 = to_ndc(clamp_x(cx + r_out * c2), clamp_y(cy + r_out * s2));
+ out.push(Vertex { position: p1, color, clip_circle });
+ out.push(Vertex { position: q1, color: fade, clip_circle });
+ out.push(Vertex { position: q2, color: fade, clip_circle });
+ out.push(Vertex { position: p1, color, clip_circle });
+ out.push(Vertex { position: q2, color: fade, clip_circle });
+ out.push(Vertex { position: p2, color, clip_circle });
+ }
+ }
+ };
+
+ // Top-Left
+ if r_tl > 0.1 {
+ push_corner(out, x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI);
+ if mid_x0 > r_tl {
+ push_quad(out, x + r_tl, y, mid_x0 - r_tl, r_tl);
+ }
+ }
+
+ // Top-Right
+ if r_tr > 0.1 {
+ push_corner(out, x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI);
+ if ww - mid_x1 > r_tr {
+ push_quad(out, x + mid_x1, y, ww - mid_x1 - r_tr, r_tr);
+ }
+ }
+
+ // Bottom-Right
+ if r_br > 0.1 {
+ push_corner(out, x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI);
+ if ww - mid_x1 > r_br {
+ push_quad(out, x + mid_x1, y + h - r_br, ww - mid_x1 - r_br, r_br);
+ }
+ }
+
+ // Bottom-Left
+ if r_bl > 0.1 {
+ push_corner(out, x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI);
+ if mid_x0 > r_bl {
+ push_quad(out, x + r_bl, y + h - r_bl, mid_x0 - r_bl, r_bl);
+ }
+ }
+}
+
+pub fn rounded_rect_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ r: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, &mut verts);
+ verts
+}
+
+pub fn push_rounded_rect_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ r: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, out);
+}
+
+pub fn plate_bevel_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ r: f32,
+ t: f32,
+ sw: f32, sh: f32,
+ base_color: [f32; 4],
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ push_plate_bevel_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, &mut verts);
+ verts
+}
+
+pub fn push_plate_bevel_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ r: f32,
+ t: f32,
+ sw: f32, sh: f32,
+ base_color: [f32; 4],
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ push_bevel_edge_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, 1.0, out);
+}
+
+/// The bevel edge shading, with the light direction selectable: `light_sign` is `1.0`
+/// for a raised plate (edges facing `light_source_position` are lit) and `-1.0` for a
+/// recess (those same edges fall into shadow instead, and the far edges catch the
+/// light). Negating the whole light vector flips every edge and every corner segment
+/// consistently, because both the flat-edge factors and the arc-normal dot product
+/// below are linear in it.
+pub fn push_bevel_edge_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ r: f32,
+ t: f32,
+ sw: f32, sh: f32,
+ base_color: [f32; 4],
+ clip_circle: [f32; 3],
+ light_sign: f32,
+ out: &mut Vec<Vertex>,
+) {
+ push_bevel_edge_vertices_radii(
+ x, y, ww, h, (r, r, r, r), t, sw, sh, base_color, clip_circle, light_sign, out,
+ );
+}
+
+/// As [`push_bevel_edge_vertices`], but with a per-corner radius (TL, TR, BR, BL) so the
+/// lip can follow a shape whose corners differ — a recess carved along the top of a
+/// rounded plate needs the plate's radius on its top corners and square ones where it
+/// meets the content below. A uniform radius there would either square off the plate's
+/// arc (painting a notch outside it) or wrongly round the inner corners.
+pub fn push_bevel_edge_vertices_radii(
+ x: f32, y: f32, ww: f32, h: f32,
+ radii: (f32, f32, f32, f32),
+ t: f32,
+ sw: f32, sh: f32,
+ base_color: [f32; 4],
+ clip_circle: [f32; 3],
+ light_sign: f32,
+ out: &mut Vec<Vertex>,
+) {
+ push_bevel_edge_vertices_banded(
+ x, y, ww, h, radii, t, sw, sh, base_color, clip_circle, light_sign,
+ default_bevel_bands(t), (true, true, true, true), EdgeKind::Rim, out,
+ );
+}
+
+/// What kind of height change an edge represents. The two shade differently because they
+/// are different shapes, and using one where the other belongs is what makes a bevel read
+/// as a drawn line instead of a surface.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EdgeKind {
+ /// The surface *ends* here: a quarter-round rolling from face-on at the inner edge of
+ /// the lip to fully in-plane at the outer boundary, where it drops away. The shading
+ /// therefore peaks exactly at the boundary and dies inward. This is a plate's outer
+ /// perimeter.
+ Rim,
+ /// The surface *continues* at a different height: one plateau steps down to another.
+ /// A height field that falls monotonically across the transition has its normal tilted
+ /// toward the low side the whole way, steepest in the middle and flat at both ends —
+ /// so the shading is a bump straddling the boundary, not a band butted against it.
+ /// Hanging the band on one side instead leaves the seam the eye reads as a drawn line.
+ Step,
+}
+
+/// Shading across an edge at signed distance `d` from the boundary (positive = toward the
+/// shape's interior), for a transition of width `t`. Returns the light term as a fraction
+/// of full tilt.
+#[inline]
+fn bevel_profile(kind: EdgeKind, d: f32, t: f32) -> f32 {
+ if t <= 0.0 {
+ return 0.0;
+ }
+ match kind {
+ // Normal rotates from in-plane (d = 0) to face-on (d = t): sine of what tilt is
+ // left. A linear ramp here reads as a flat 45° chamfer instead of a roll.
+ EdgeKind::Rim => ((1.0 - (d / t).clamp(0.0, 1.0)) * std::f32::consts::FRAC_PI_2).sin(),
+ // Symmetric bump over [-t/2, +t/2], zero at both ends so the transition blends into
+ // both plateaus with no seam.
+ EdgeKind::Step => {
+ let s = (d / t + 0.5).clamp(0.0, 1.0);
+ (s * std::f32::consts::PI).sin()
+ }
+ }
+}
+
+/// The light-independent curvature term at signed distance `d` — the second depth cue,
+/// on top of the directional one. Curvature shading is what ambient light does: convex
+/// surface catches it from everywhere (bright), concave is self-occluded (dark). Because
+/// it does not rotate with the light, it survives exactly where the directional term
+/// dies — walls parallel to the light vector — so no edge ever vanishes entirely.
+///
+/// `high_sign` is +1 when the rect interior is the HIGH side of the transition and -1
+/// when it is the low side (a recess). Geometry, not lighting: it does not flip with
+/// `light_sign`... except that for these 2.5D shapes the two are the same number, since
+/// a raised shape is lit like a plateau and shaded like one.
+#[inline]
+fn bevel_curvature(kind: EdgeKind, d: f32, t: f32, high_sign: f32) -> f32 {
+ if t <= 0.0 {
+ return 0.0;
+ }
+ match kind {
+ // A rim is convex everywhere, tightest right at the silhouette: a bright crest
+ // line hugging the boundary and dying fast inward. This is the line that makes
+ // glass read as glass — the edge catches ambient light all the way around, even
+ // (dimmer, via the gain asymmetry below) on the side facing away from the light.
+ EdgeKind::Rim => {
+ let u = (d / t).clamp(0.0, 1.0);
+ let f = 1.0 - u;
+ CREST_RATIO * f * f * f
+ }
+ // An S-curve step is convex on its high half (the shoulder) and concave on its
+ // low half (the fillet, where the wall meets the floor): antisymmetric, zero at
+ // the ends (no seam against either plateau) and at the midpoint.
+ EdgeKind::Step => {
+ let s = (d / t + 0.5).clamp(0.0, 1.0);
+ let outer_is_high = -high_sign; // d < 0 is outside the rect
+ // sin(2πs) is positive on the outer half — the shoulder when the outside is
+ // the high side — and negative on the inner (fillet) half.
+ AO_RATIO * outer_is_high * (s * std::f32::consts::TAU).sin()
+ }
+ }
+}
+
+/// Crest amplitude as a fraction of `bevel_depth` — how much brighter a rim's silhouette
+/// line is than flat surface under even light. Must stay clearly below ~0.7 (the
+/// projection of a 135° light onto an axis edge), or it cancels the directional shadow
+/// on the dark side and the rim goes flat there instead of showing a faint bright line
+/// over a shadowed roll.
+const CREST_RATIO: f32 = 0.4;
+/// Shoulder/fillet amplitude as a fraction of `bevel_depth`.
+const AO_RATIO: f32 = 0.6;
+/// Per-sign overlay gains. These are asymmetric the opposite way from intuition: on the
+/// dark bases this DE runs, white-over blending (`b + a(1-b)`) moves the pixel far more
+/// per unit alpha than black-over (`b(1-a)`) — a dark surface has little brightness for
+/// black to take away. The old subtractive shading effectively crushed shadow sides to
+/// black in linear space; the black overlay needs a high gain to keep shadows reading
+/// at all, while white needs damping to keep highlights from blowing out.
+const LIGHT_GAIN: f32 = 0.7;
+const DARK_GAIN: f32 = 3.0;
+
+/// A shading value (already scaled by `bevel_depth`) as the two overlay passes: the lit
+/// pass is translucent white, the shadow pass translucent black. Painting the
+/// *modulation* instead of a resolved surface color is what lets relief primitives compose — a step
+/// crossing a rim shades the rim's gradient instead of stamping a flat band over it, a
+/// lip on a translucent plate no longer doubles its opacity, and a recess needs no
+/// knowledge of the surface color it carves.
+///
+/// Why two passes with fixed RGB rather than one signed color: a primitive whose value
+/// crosses zero inside a band would interpolate white→black through mid-gray at
+/// non-negligible alpha — on a dark base a *brightening* artifact right where the
+/// shading should vanish. With per-pass alphas clamped at the crossing, each pass fades
+/// to zero there and the hue can never be wrong. Alphas also stay non-negative on every
+/// vertex, which the renderer requires (negative alpha is the blur sentinel).
+#[inline]
+fn overlay_light(v: f32) -> [f32; 4] {
+ [1.0, 1.0, 1.0, (v.max(0.0) * LIGHT_GAIN).min(1.0)]
+}
+#[inline]
+fn overlay_dark(v: f32) -> [f32; 4] {
+ [0.0, 0.0, 0.0, ((-v).max(0.0) * DARK_GAIN).min(1.0)]
+}
+
+/// The signed distance range an edge's shading occupies, relative to the boundary.
+#[inline]
+fn bevel_span(kind: EdgeKind, t: f32) -> (f32, f32) {
+ match kind {
+ EdgeKind::Rim => (0.0, t),
+ EdgeKind::Step => (-0.5 * t, 0.5 * t),
+ }
+}
+
+/// How many gradient bands to slice a lip of thickness `t` into. Vertex colors interpolate
+/// linearly, so each band is a chord of the shading curve; one band per ~1.25px keeps the
+/// error under a shade step without emitting geometry finer than the display resolves.
+/// The cap rose with the curvature term: a step now has two features across its width
+/// (shoulder and fillet), so it needs double the samples a single bump did.
+fn default_bevel_bands(t: f32) -> usize {
+ ((t / 1.25).ceil() as usize).clamp(1, 12)
+}
+
+/// As [`push_bevel_edge_vertices_radii`], with the band count forced and the walls
+/// selectable — for callers that want a coarser or finer roll-off than thickness alone
+/// implies, or that are shading a step rather than a closed shape.
+///
+/// `edges` is (top, right, bottom, left). Suppressing a wall matters for a region that
+/// runs flush to the surface's own edge: a full-width menubar sunk into the top of a plate
+/// is a *plateau one step down*, not a trough, so its only real wall is the one facing the
+/// content. Drawing the other three would carve a lip along the plate's outer edge, where
+/// the plate's own roll already lives, and the two would fight.
+pub fn push_bevel_edge_vertices_banded(
+ x: f32, y: f32, ww: f32, h: f32,
+ radii: (f32, f32, f32, f32),
+ t: f32,
+ sw: f32, sh: f32,
+ base_color: [f32; 4],
+ clip_circle: [f32; 3],
+ light_sign: f32,
+ bands: usize,
+ edges: (bool, bool, bool, bool),
+ kind: EdgeKind,
+ out: &mut Vec<Vertex>,
+) {
+ // Floored for the same reason as `plate_push_raised`'s cap: a negative
+ // extent must degrade to no ring, not panic in `clamp`.
+ let cap = (ww.min(h) * 0.5).max(0.0);
+ let (tl, tr, br, bl) = (
+ radii.0.clamp(0.0, cap),
+ radii.1.clamp(0.0, cap),
+ radii.2.clamp(0.0, cap),
+ radii.3.clamp(0.0, cap),
+ );
+ let t = t.clamp(0.0, cap);
+ if t <= 0.0 {
+ return;
+ }
+ let bands = bands.max(1);
+
+ let rad = crate::layout::light_source_position();
+ let lx = rad.cos() * light_sign;
+ let ly = -rad.sin() * light_sign;
+ let depth = crate::layout::bevel_depth();
+
+ // `base_color` is no longer painted: shading is an overlay (see `overlay_color`), so
+ // the surface below shows through with its own gradients and translucency intact.
+ let _ = base_color;
+ // Shading (directional + curvature, scaled by bevel_depth) at signed distance `d`,
+ // for an edge whose outward flat normal is `dir`. A `Step` band runs negative — it
+ // straddles the boundary into the plateau outside the rect, which is exactly what
+ // removes the seam.
+ let value = |dot: f32, d: f32| {
+ depth * (bevel_profile(kind, d, t) * dot + bevel_curvature(kind, d, t, light_sign))
+ };
+ // The (up to two) overlay color pairs for a band running from value `v0` to `v1`:
+ // one white pair and/or one black pair, each pass fading to zero alpha wherever the
+ // value has the other sign. Both fire only when the band straddles the terminator.
+ let passes = |v0: f32, v1: f32| -> [Option<([f32; 4], [f32; 4])>; 2] {
+ [
+ (v0 > 0.0 || v1 > 0.0).then(|| (overlay_light(v0), overlay_light(v1))),
+ (v0 < 0.0 || v1 < 0.0).then(|| (overlay_dark(v0), overlay_dark(v1))),
+ ]
+ };
+ let (span_lo, span_hi) = bevel_span(kind, t);
+
+ // Each flat edge spans between its two adjoining corner radii, not a single uniform
+ // inset — that is what lets the corners differ. At a square corner there is no arc to
+ // cover the t×t patch where two edges meet, so the horizontal edges claim it (they run
+ // the full span) and the vertical ones inset by `t`; overlapping them instead would
+ // double-blend that patch, which shows as a dark notch on a translucent surface.
+ let (left_top, left_bot) = (if tl > 0.0 { tl } else { t }, if bl > 0.0 { bl } else { t });
+ let (right_top, right_bot) = (if tr > 0.0 { tr } else { t }, if br > 0.0 { br } else { t });
+ let top_w = ww - tl - tr;
+ let bottom_w = ww - bl - br;
+ let left_h = h - left_top - left_bot;
+ let right_h = h - right_top - right_bot;
+
+ for k in 0..bands {
+ let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
+ let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
+ let bw = d1 - d0;
+
+ // Top: outward normal (0,-1); the gradient runs downward, into the surface.
+ if top_w > 0.0 && edges.0 {
+ let (v0, v1) = (value(-ly, d0), value(-ly, d1));
+ for (c0, c1) in passes(v0, v1).into_iter().flatten() {
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + tl, y + d0, top_w, bw, sw, sh, c0, c0, c1, c1, clip_circle,
+ ));
+ }
+ }
+ // Bottom: outward normal (0,1); gradient runs upward.
+ if bottom_w > 0.0 && edges.2 {
+ let (v0, v1) = (value(ly, d0), value(ly, d1));
+ for (c0, c1) in passes(v0, v1).into_iter().flatten() {
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + bl, y + h - d1, bottom_w, bw, sw, sh, c1, c1, c0, c0, clip_circle,
+ ));
+ }
+ }
+ // Left: outward normal (-1,0); gradient runs rightward.
+ if left_h > 0.0 && edges.3 {
+ let (v0, v1) = (value(-lx, d0), value(-lx, d1));
+ for (c0, c1) in passes(v0, v1).into_iter().flatten() {
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + d0, y + left_top, bw, left_h, sw, sh, c0, c1, c1, c0, clip_circle,
+ ));
+ }
+ }
+ // Right: outward normal (1,0); gradient runs leftward.
+ if right_h > 0.0 && edges.1 {
+ let (v0, v1) = (value(lx, d0), value(lx, d1));
+ for (c0, c1) in passes(v0, v1).into_iter().flatten() {
+ out.extend_from_slice(&quad_vertices_shaded(
+ x + ww - d1, y + right_top, bw, right_h, sw, sh, c1, c0, c0, c1, clip_circle,
+ ));
+ }
+ }
+ }
+
+ // A corner arc belongs to both of its adjoining walls, so it is drawn only when both
+ // are — otherwise a suppressed wall would still get a quarter of a lip.
+ let corners = [
+ (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, edges.0 && edges.3), // Top-Left
+ (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, edges.0 && edges.1), // Top-Right
+ (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI, edges.2 && edges.1), // Bottom-Right
+ (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, edges.2 && edges.3), // Bottom-Left
+ ];
+
+ for &(cx, cy, r, start_angle, end_angle, enabled) in &corners {
+ // A square corner has no arc to sweep — the flat edges already met there.
+ if r <= 0.0 || !enabled {
+ continue;
+ }
+ // The corner is a quarter of a torus: shading varies along the sweep (the normal
+ // swings through 90° of the light) *and* across the lip (the roll-off). Both come
+ // out of the vertex colors, so one quad per (segment × band) cell is enough — no
+ // faceting, unlike the 16 flat wedges this replaced.
+ let segments = ((r * 0.75) as usize).clamp(8, 48);
+ let ct = t.min(r);
+ for j in 0..segments {
+ let theta0 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
+ let theta1 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
+ let (cos0, sin0) = (theta0.cos(), theta0.sin());
+ let (cos1, sin1) = (theta1.cos(), theta1.sin());
+ for k in 0..bands {
+ let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
+ let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
+ // Inward along the corner's radius is the same signed distance as inward
+ // from a flat edge, so the arc scales the span the same way.
+ let (r0, r1) = (r - ct * (d0 / t), r - ct * (d1 / t));
+ let p = |rho: f32, c: f32, s: f32| -> [f32; 2] {
+ [
+ ((cx + rho * c) / sw) * 2.0 - 1.0,
+ 1.0 - ((cy + rho * s) / sh) * 2.0,
+ ]
+ };
+ // Outer/inner × the two sweep ends; each vertex gets its own value, and
+ // the cell is drawn once per overlay pass that has any coverage.
+ let vals = [
+ value(cos0 * lx + sin0 * ly, d0),
+ value(cos1 * lx + sin1 * ly, d0),
+ value(cos1 * lx + sin1 * ly, d1),
+ value(cos0 * lx + sin0 * ly, d1),
+ ];
+ let geo = [
+ p(r0, cos0, sin0),
+ p(r0, cos1, sin1),
+ p(r1, cos1, sin1),
+ p(r1, cos0, sin0),
+ ];
+ let mut cells: [Option<fn(f32) -> [f32; 4]>; 2] = [None, None];
+ if vals.iter().any(|&v| v > 0.0) {
+ cells[0] = Some(overlay_light);
+ }
+ if vals.iter().any(|&v| v < 0.0) {
+ cells[1] = Some(overlay_dark);
+ }
+ for f in cells.into_iter().flatten() {
+ let c: Vec<Vertex> = (0..4)
+ .map(|i| Vertex { position: geo[i], color: f(vals[i]), clip_circle })
+ .collect();
+ out.extend_from_slice(&[c[0], c[1], c[2], c[0], c[2], c[3]]);
+ }
+ }
+ }
+ }
+}
+
+/// How strong the face gradient is, as a fraction of `bevel_depth` at the corner nearest
+/// the light. Deliberately well below the edge amplitude: the face is a plane, not a
+/// roll — it only *leans* toward the light.
+const FACE_RATIO: f32 = 0.35;
+
+/// The face lighting of a plate: a single diagonal luminance gradient across the whole
+/// surface, brightest at the corner facing `light_source_position` and darkest at the
+/// opposite one. This is the difference between an object and a sticker: a real surface
+/// under directional light is never uniform, and a perfectly flat fill makes the eye
+/// read the (much smaller) edge shading as frame decoration rather than shape.
+///
+/// Emitted as the same two-pass white/black overlays as the relief primitives (see
+/// [`overlay_light`]/[`overlay_dark`]): fixed RGB per pass, per-corner alphas clamped at
+/// the terminator, bilinear across the quad. The quad is square — its corners poke past
+/// a rounded plate's arcs — but the compositor clips the window surface to the same
+/// radius, so the overhang never reaches the screen.
+pub fn push_plate_face_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ sw: f32, sh: f32,
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ let rad = crate::layout::light_source_position();
+ let (lx, ly) = (rad.cos(), -rad.sin());
+ let amp = crate::layout::bevel_depth() * FACE_RATIO;
+ // Corner value = how much its outward diagonal faces the light.
+ let inv = std::f32::consts::FRAC_1_SQRT_2;
+ let v_tl = amp * inv * (-lx - ly);
+ let v_tr = amp * inv * (lx - ly);
+ let v_br = amp * inv * (lx + ly);
+ let v_bl = amp * inv * (-lx + ly);
+ let vs = [v_tl, v_tr, v_br, v_bl];
+ if vs.iter().any(|&v| v > 0.0) {
+ out.extend_from_slice(&quad_vertices_shaded(
+ x, y, ww, h, sw, sh,
+ overlay_light(v_tl), overlay_light(v_tr), overlay_light(v_br), overlay_light(v_bl),
+ clip_circle,
+ ));
+ }
+ if vs.iter().any(|&v| v < 0.0) {
+ out.extend_from_slice(&quad_vertices_shaded(
+ x, y, ww, h, sw, sh,
+ overlay_dark(v_tl), overlay_dark(v_tr), overlay_dark(v_br), overlay_dark(v_bl),
+ clip_circle,
+ ));
+ }
+}
+
+pub fn push_plate_solid_border_vertices(
+ x: f32, y: f32, ww: f32, h: f32,
+ radii: crate::widget::CornerRadii,
+ t: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ let mut r_tl = radii.top_left.max(0.0);
+ let mut r_tr = radii.top_right.max(0.0);
+ let mut r_br = radii.bottom_right.max(0.0);
+ let mut r_bl = radii.bottom_left.max(0.0);
+
+ // Simple scale clamping
+ let sum_top = r_tl + r_tr;
+ if sum_top > ww {
+ let f = ww / sum_top;
+ r_tl *= f;
+ r_tr *= f;
+ }
+ let sum_bottom = r_bl + r_br;
+ if sum_bottom > ww {
+ let f = ww / sum_bottom;
+ r_bl *= f;
+ r_br *= f;
+ }
+ let sum_left = r_tl + r_bl;
+ if sum_left > h {
+ let f = h / sum_left;
+ r_tl *= f;
+ r_bl *= f;
+ }
+ let sum_right = r_tr + r_br;
+ if sum_right > h {
+ let f = h / sum_right;
+ r_tr *= f;
+ r_br *= f;
+ }
+
+ out.extend_from_slice(&quad_vertices_with_clip(x + r_tl, y, ww - r_tl - r_tr, t, sw, sh, color, clip_circle));
+ out.extend_from_slice(&quad_vertices_with_clip(x, y + r_tl, t, h - r_tl - r_bl, sw, sh, color, clip_circle));
+ out.extend_from_slice(&quad_vertices_with_clip(x + r_bl, y + h - t, ww - r_bl - r_br, t, sw, sh, color, clip_circle));
+ out.extend_from_slice(&quad_vertices_with_clip(x + ww - t, y + r_tr, t, h - r_tr - r_br, sw, sh, color, clip_circle));
+
+ let segments = 16;
+ let corner_e = 2.0 / crate::layout::corner_shape();
+
+ // Corner strokes as annulus strips between the outer superellipse (radius
+ // r) and its inner scaled copy (r - t): at 1px thickness the scaled inner
+ // curve is indistinguishable from the true parallel curve, and at
+ // corner_shape 2 this is exactly the circular arc annulus. NOT
+ // push_arc_background_vertices — that stays circular for genuine arcs.
+ //
+ // Both edges of the annulus are FEATHERED, like the fill fan's corners
+ // (push_rounded_rect_vertices_corners) and push_feathered_line_vertices:
+ // each fades over `f` either side of its true curve, so the 50%-coverage
+ // lines stay on the exact silhouette and the stroke reads at the same
+ // weight, but the arc anti-aliases instead of rasterizing a staircase
+ // beside the SDF-smooth face it outlines. The straight edges stay crisp
+ // quads (a pixel-snapped hairline would only blur). A corner too tight to
+ // fit the inner fade keeps the hard annulus.
+ let f = 0.5f32.min(t * 0.25);
+ let fade = [color[0], color[1], color[2], 0.0];
+ let corner = |cx: f32, cy: f32, r: f32, start: f32, end: f32, out: &mut Vec<Vertex>| {
+ let r_in = (r - t).max(0.0);
+ // (inner radius, outer radius, inner alpha colour, outer alpha colour)
+ let bands: &[(f32, f32, [f32; 4], [f32; 4])] = if r_in - f > 0.0 {
+ &[
+ (r_in - f, r_in + f, fade, color),
+ (r_in + f, r - f, color, color),
+ (r - f, r + f, color, fade),
+ ]
+ } else {
+ &[(r_in, r, color, color)]
+ };
+ let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
+ for i in 0..segments {
+ let t1 = start + (i as f32) * (end - start) / segments as f32;
+ let t2 = start + ((i + 1) as f32) * (end - start) / segments as f32;
+ let (c1, s1) = superellipse_pt(t1, corner_e);
+ let (c2, s2) = superellipse_pt(t2, corner_e);
+ for &(ra, rb, ca, cb) in bands {
+ if rb - ra <= 0.0 {
+ continue;
+ }
+ let o1 = ndc(cx + rb * c1, cy + rb * s1);
+ let o2 = ndc(cx + rb * c2, cy + rb * s2);
+ let i1 = ndc(cx + ra * c1, cy + ra * s1);
+ let i2 = ndc(cx + ra * c2, cy + ra * s2);
+ out.push(Vertex { position: o1, color: cb, clip_circle });
+ out.push(Vertex { position: o2, color: cb, clip_circle });
+ out.push(Vertex { position: i1, color: ca, clip_circle });
+ out.push(Vertex { position: o2, color: cb, clip_circle });
+ out.push(Vertex { position: i2, color: ca, clip_circle });
+ out.push(Vertex { position: i1, color: ca, clip_circle });
+ }
+ }
+ };
+
+ if r_tl > 0.1 {
+ corner(x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, out);
+ }
+ if r_tr > 0.1 {
+ corner(x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, out);
+ }
+ if r_br > 0.1 {
+ corner(x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI, out);
+ }
+ if r_bl > 0.1 {
+ corner(x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, out);
+ }
+}
+
+pub fn push_plate_solid_border_vertices_legacy(
+ x: f32, y: f32, ww: f32, h: f32,
+ r: f32,
+ t: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ let radii = crate::widget::CornerRadii::uniform(r);
+ push_plate_solid_border_vertices(x, y, ww, h, radii, t, sw, sh, color, clip_circle, out);
+}
+
+pub fn widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3]) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ push_widget_vertices(w, sw, sh, clip_circle, &mut verts);
+ verts
+}
+
+pub fn push_widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3], out: &mut Vec<Vertex>) {
+ let (x, y, ww, h) = w.rect();
+ let radii = w.corner_radii();
+ if let Some(thickness) = w.plate_bevel() {
+ let t = thickness;
+ // Full-size fill: the bevel lip is a shading overlay now, not a paint of the
+ // outer ring, so an inset fill would leave the ring unfilled.
+ push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
+ push_plate_bevel_vertices(x, y, ww, h, radii.top_left, t, sw, sh, w.color(), clip_circle, out);
+ } else {
+ push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
+ if let Some((color, thickness)) = w.solid_border() {
+ push_plate_solid_border_vertices(x, y, ww, h, radii, thickness, sw, sh, color, clip_circle, out);
+ }
+ }
+
+ for (cx, cy, r, t, start, end, qc) in w.extra_arcs() {
+ push_arc_background_vertices(cx, cy, r, t, start, end, sw, sh, qc, 16, clip_circle, out);
+ }
+}
+
+/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
+/// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
+/// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
+/// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
+pub struct DlBatch {
+ pub scissor: Option<crate::scene::layout::Rect>,
+ pub clip_rrect: Option<[f32; 5]>,
+ pub start: u32,
+ pub end: u32,
+ /// When set, this batch is one SDF-lit plate cover quad (see
+ /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
+ pub plate: Option<crate::vk::PlatePush>,
+ /// A blur-behind plate (negative-alpha color): before drawing this batch
+ /// the renderer snapshots the swapchain-so-far into its snapshot image, so
+ /// the blur samples everything painted beneath the plate — not just the 3D
+ /// scene backdrop. Never merged.
+ pub blur_behind: bool,
+}
+
+/// An image draw from the display list: `at` is the vertex index it sorts
+/// before (its position in the tessellated stream); `clip` is the item's
+/// paint-walk clip. Logical coordinates throughout.
+pub struct DlImage {
+ pub image: u32,
+ pub rect: crate::scene::layout::Rect,
+ pub alpha: f32,
+ pub at: u32,
+ pub clip: Option<crate::scene::layout::Rect>,
+}
+
+/// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
+/// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
+/// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
+/// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
+/// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
+/// merged into one batch (the circle clip is per-vertex, so it never splits batches).
+/// `CCE_PLATE_DEBUG=1` — trace which carves group into their host plate as exact
+/// CSG features and which fall back to the standalone overlay shading.
+///
+/// The two paths do NOT look the same: a grouped carve is part of the plate's
+/// single height field, so its wall meets the plate's rolled perimeter as a real
+/// junction, while the fallback approximates that with the host-box fade. Six
+/// conditions decide it, three of them dynamic (draw order, neighbouring plates,
+/// whether another carve already claimed the host's feature run), so the SAME
+/// widget can render either way depending on what is around it — and it does so
+/// silently. That has already shipped as a bug once: a hovered button's opaque
+/// fill used to sever every later button from the root plate they carve into,
+/// which is why `plate_stack` is a stack (see its comment below).
+///
+/// Off by default and read once; the classification below runs only when set.
+/// Prim discriminant name, for `CCE_PLATE_DEBUG` reporting only.
+fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
+ use crate::scene::paint::Prim as P;
+ match p {
+ P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
+ P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
+ P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
+ P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Field { .. } => "Field", P::Plate { .. } => "Plate",
+ P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
+ P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
+ P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
+ P::DropletScrim { .. } => "DropletScrim",
+ P::ConcaveFillet { .. } => "ConcaveFillet",
+ P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice", P::Grout { .. } => "Grout", P::Fill { .. } => "Fill",
+ P::CarveUnion { .. } => "CarveUnion", P::Glow { .. } => "Glow",
+ P::Text { .. } => "Text", P::Image { .. } => "Image",
+ }
+}
+
+fn plate_debug() -> bool {
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("CCE_PLATE_DEBUG").is_ok_and(|v| v != "0"))
+}
+
+/// Debug builds make one kind of fallback LOUD without `CCE_PLATE_DEBUG`: a
+/// carve that could group (full ring, untinted) failing to while a still-open
+/// plate encloses it and the carve's shaded region reaches that plate's
+/// perimeter roll. There the grouped and overlay paths shade the junction
+/// differently, and the rejection is one of the dynamic rules — so the SAME
+/// widget can flip looks frame to frame with nothing on stderr. Not an
+/// assert/panic: every rejection is conservative-CORRECT (the audit that
+/// shipped CCE_PLATE_DEBUG found no misgrouping; a later plate overlapping the
+/// carve genuinely must be shaded over, not under) — it is the frame-to-frame
+/// LOOK that flips, so the right loudness is an unmissable warning, not a
+/// crash. The ubiquitous quiet case stays quiet by construction: ordinary
+/// geometry closing every grouping window empties `plate_stack`, so no
+/// enclosing OPEN plate exists and this never runs — that is draw-order
+/// design, not a flip.
+///
+/// Returns the dynamic rule to report, or `None` when the fallback is not the
+/// loud case. Pure so the classification is unit-testable; `later_plates` are
+/// the open plates emitted after the enclosing host.
+#[cfg(debug_assertions)]
+fn near_roll_fallback_reason(
+ carve: &crate::scene::layout::Rect,
+ depth: f32,
+ host: &crate::scene::layout::Rect,
+ roll: f32,
+ later_plates: &[crate::scene::layout::Rect],
+ budget_full: bool,
+) -> Option<&'static str> {
+ // The carve's shaded region — the overlay path's cover-quad inflation.
+ let infl = depth * 0.5 + 2.0;
+ let (sx0, sy0) = (carve.x - infl, carve.y - infl);
+ let (sx1, sy1) = (carve.x + carve.width + infl, carve.y + carve.height + infl);
+ // "Near the roll" = the shaded region leaves the host rect deflated by the
+ // host's own roll width on any side.
+ let near = sx0 < host.x + roll
+ || sy0 < host.y + roll
+ || sx1 > host.x + host.width - roll
+ || sy1 > host.y + host.height - roll;
+ if !near {
+ return None;
+ }
+ // The dynamic rules, in the order the grouping guard tests them.
+ if budget_full {
+ return Some("the feature budget is full");
+ }
+ if later_plates
+ .iter()
+ .any(|o| sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y)
+ {
+ return Some("a later plate overlaps the carve's shaded region");
+ }
+ Some("the host's feature run is closed (another plate appended features since)")
+}
+
+/// Print a near-roll fallback warning once per distinct message — a carve in a
+/// steady layout would otherwise repeat it every frame.
+#[cfg(debug_assertions)]
+fn plate_carve_warn_once(msg: String) {
+ use std::sync::{Mutex, OnceLock};
+ static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
+ let seen = SEEN.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
+ if seen.lock().unwrap().insert(msg.clone()) {
+ eprintln!("{msg}");
+ }
+}
+
+pub fn tessellate_display_list(
+ dl: &crate::scene::paint::DisplayList,
+ sw: f32,
+ sh: f32,
+ scale: f32,
+) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
+ use crate::scene::material::PlateRole;
+ use crate::scene::paint::{Cap, Prim};
+ let mut verts: Vec<Vertex> = Vec::new();
+ let mut batches: Vec<DlBatch> = Vec::new();
+ let mut images: Vec<DlImage> = Vec::new();
+ // Carves CSG'd into plates (see Frame2D::plate_features), plus the plate
+ // they group into: the most recent Plate/Bevel batch, provided only Text
+ // and Image prims (which draw through separate paths anyway) intervene.
+ let mut features: Vec<[f32; 12]> = Vec::new();
+ // Open carve-host plates, in emission order (innermost candidates last).
+ // A STACK, not a single slot: a sibling plate emitted between a root plate
+ // and its later carves (a hovered button's opaque fill among transparent
+ // ones) must not sever those carves from the root plate they are carved
+ // into — that severing rendered every button after the hovered one
+ // through the visually-different overlay fallback. Ordinary geometry
+ // still closes every open plate (the draw-order rule below).
+ let mut plate_stack: Vec<(usize, crate::scene::layout::Rect)> = Vec::new();
+ // Which plate last appended a carve feature: a plate's features are
+ // addressed as one contiguous [offset, count] run (PlatePush::host), so a
+ // plate may only receive MORE features while no other plate has appended
+ // any since.
+ let mut last_feature_plate: Option<usize> = None;
+ // `CCE_PLATE_DEBUG` bookkeeping — see `plate_debug`.
+ let dbg_plates = plate_debug();
+ let mut dbg_grouped = 0usize;
+ let mut dbg_fell_back: Vec<String> = Vec::new();
+ let mut dbg_opened = 0usize;
+ // Which prim kind closed a still-open grouping window, and how many plates
+ // it closed — the answer to "why was there no enclosing plate?".
+ let mut dbg_closed_by: std::collections::BTreeMap<&'static str, usize> =
+ std::collections::BTreeMap::new();
+
+ // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
+ // shading, plus the frame-constant lighting inputs it pushes per plate.
+ let shader_plates = crate::layout::bevel_shader();
+ // Light and material come from `scene::relief_shade`, which is also what
+ // cce-relief predicts pixels with — one definition, so the editor cannot
+ // draw a different material than the renderer applies.
+ let plate_light = crate::scene::relief_shade::light_vector();
+ // [shading strength (1.0 at the default bevel_depth), specular strength,
+ // shininess, curvature/AO strength] — the DE's finish, for the CARVES,
+ // which shade whatever is beneath them and so take the host's. A prim
+ // that carries a Material (Plate, Bevel, Sphere, Droplet) pushes its own
+ // `material.finish` instead. Curvature is kept near the raised path's
+ // crest amplitude: the recess shoulder's brightening lands on the same
+ // pixels as its specular line, and the two stack — at 0.5 the step read
+ // several times hotter than a plate roll.
+ let plate_mat = crate::scene::material::Finish::from_style().to_array();
+
+ for item in &dl.items {
+ let mut start = verts.len() as u32;
+ let mut plate: Option<crate::vk::PlatePush> = None;
+ // A frosted flat fill promoted to a zero-depth plate batch (below):
+ // it carries a recipe like any plate, but it is ordinary geometry to
+ // the carve grouping — it opens no host and closes the open ones.
+ let mut promoted = false;
+ let mut made_plate: Option<crate::scene::layout::Rect> = None;
+ // Blur-behind marker: a prim whose FILL alpha is negative asks the
+ // renderer to snapshot the frame-so-far before it draws. Every
+ // fill-bearing prim counts — the shader's a<0 branch runs for all of
+ // them, and a variant missing here still frosts, but against the
+ // stale scene backdrop instead of the frame: a flat tint with no
+ // content and no blur, which is how the Dropdown popover (Border)
+ // and the menubar panels (Quad) shipped visibly unfrosted while the
+ // context menu (Plate) worked.
+ let mut blur_behind = matches!(
+ &item.prim,
+ crate::scene::paint::Prim::Quad { color, .. }
+ | crate::scene::paint::Prim::RoundedRect { color, .. } if color[3] < 0.0
+ ) || matches!(
+ &item.prim,
+ crate::scene::paint::Prim::Bevel { material, .. }
+ | crate::scene::paint::Prim::Plate { material, .. }
+ | crate::scene::paint::Prim::Droplet { material, .. }
+ if material.fill(PlateRole::Nested)[3] < 0.0
+ ) || matches!(
+ &item.prim,
+ crate::scene::paint::Prim::Border { fill, .. } if fill[3] < 0.0
+ ) || matches!(
+ &item.prim,
+ crate::scene::paint::Prim::Fill { material, .. } if material.fill(PlateRole::Nested)[3] < 0.0
+ );
+ // Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
+ let no = item
+ .clip_circle
+ .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
+ .unwrap_or([0.0f32, 0.0, 0.0]);
+ // Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
+ // the fan with the PHYSICAL radius (capped — beyond 128 the chord error is
+ // subpixel even on HiDPI).
+ let segs = |radius: f32| -> usize { ((radius * scale) as usize).clamp(16, 128) };
+ match &item.prim {
+ Prim::Text { .. } => continue, // text goes through the glyph/text-span path
+ Prim::Image { image, rect, alpha } => {
+ images.push(DlImage {
+ image: *image,
+ rect: *rect,
+ alpha: *alpha,
+ at: verts.len() as u32,
+ clip: item.clip,
+ });
+ continue;
+ }
+ // A frosted FLAT fill — a `Flat` control face, a menu panel, a
+ // popover, an inset plate's face — is a zero-depth plate batch
+ // (RFC material § 6.2): the same shader path as every plate, so
+ // it carries its own frost recipe instead of a window-wide one,
+ // with the configured `corner_shape` and no roll, which is what the
+ // tessellated fill drew. The display list is untouched, so the
+ // legacy bridges that extract RoundedRects still see one.
+ Prim::Quad { rect, color } if shader_plates && color[3] < 0.0 => {
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+ plate = Some(flat_frost_push(rect, (0.0, 0.0, 0.0, 0.0), *color, scale, plate_light, plate_mat));
+ promoted = true;
+ }
+ Prim::RoundedRect { rect, radius, corners, color } if shader_plates && color[3] < 0.0 => {
+ let radii = (
+ if corners.0 { *radius } else { 0.0 },
+ if corners.1 { *radius } else { 0.0 },
+ if corners.2 { *radius } else { 0.0 },
+ if corners.3 { *radius } else { 0.0 },
+ );
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+ plate = Some(flat_frost_push(rect, radii, *color, scale, plate_light, plate_mat));
+ promoted = true;
+ }
+ Prim::Fill { rect, radii, material } if shader_plates && material.frost.is_frosted() => {
+ // A material's flat fill: the frosted promotion above with
+ // the MATERIAL's recipe (compression, refraction, radius)
+ // instead of the DE default's. Zero depth, the configured
+ // corner shape, no host — exactly a promoted RoundedRect.
+ let color = material.fill(PlateRole::Nested);
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
+ let mut p = plate_push_raised(rect, *radii, 0.0, scale, plate_light, plate_mat, false, None);
+ let [fz, fw] = material.frost.pack(scale);
+ p.host[2] = fz;
+ p.host[3] = fw;
+ plate = Some(p);
+ promoted = true;
+ }
+ Prim::Fill { rect, radii, material } => {
+ // Opaque (or the legacy path): a plain rounded fill.
+ let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
+ push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, material.fill(PlateRole::Nested), no, None, &mut verts);
+ }
+ Prim::Border { rect, radii, fill, border, thickness } if shader_plates && fill[3] < 0.0 => {
+ // The fill as its own plate batch, closed here; the stroke
+ // follows as ordinary geometry in the batch the tail makes.
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *fill));
+ let p = flat_frost_push(rect, *radii, *fill, scale, plate_light, plate_mat);
+ let end = verts.len() as u32;
+ plate_stack.clear();
+ batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate: Some(p), blur_behind: true });
+ start = end;
+ blur_behind = false;
+ let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
+ push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
+ }
+ Prim::Quad { rect, color } => {
+ // Quads honor an active circle clip like circles/arcs do (the
+ // Ramp's foam-cell fills draw as clipped strips).
+ verts.extend(quad_vertices_with_clip(rect.x, rect.y, rect.width, rect.height, sw, sh, *color, no));
+ }
+ Prim::RoundedRect { rect, radius, corners, color } => {
+ let radii = crate::widget::CornerRadii::new(
+ if corners.0 { *radius } else { 0.0 },
+ if corners.1 { *radius } else { 0.0 },
+ if corners.2 { *radius } else { 0.0 },
+ if corners.3 { *radius } else { 0.0 },
+ );
+ push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
+ }
+ Prim::Border { rect, radii, fill, border, thickness } => {
+ let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
+ push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, *fill, no, None, &mut verts);
+ push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
+ }
+ Prim::Glow { rect, radius, reach, color } => {
+ push_glow_vertices(rect.x, rect.y, rect.width, rect.height, *radius, *reach, sw, sh, *color, no, &mut verts);
+ }
+ Prim::Bevel { rect, radii, material, depth, tint } if shader_plates => {
+ let color = material.fill(PlateRole::Nested);
+ let mat = material.finish.to_array();
+ // SDF-lit raised plate: one cover quad; the shader owns fill,
+ // roll shading, corners, and silhouette AA. Nominal corner
+ // radii (scale_corners false): a Bevel is a WIDGET-scale plate
+ // whose silhouette must match the nominal-radius squircles of
+ // the controls around it — only window-scale `Plate`s get the
+ // curvature-matched span.
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
+ let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, false, None);
+ // The plate's own frost recipe rides host.zw (see PlatePush).
+ let [fz, fw] = material.frost.pack(scale);
+ p.host[2] = fz;
+ p.host[3] = fw;
+ // w = 1 marks an accent-tinted plate (the focused-pane
+ // treatment): the shader keeps the roll's light and shadow
+ // and recolours them — light toward the tint, shadow toward
+ // a dark tint — matching the free-carve path's tinted-well
+ // convention. Neutral white keeps w = 0 (a no-op multiply).
+ let full = if *tint == [1.0, 1.0, 1.0] { 0.0 } else { 1.0 };
+ p.specular_tint = [tint[0], tint[1], tint[2], full];
+ plate = Some(p);
+ made_plate = Some(*rect);
+ }
+ Prim::Plate { rect, radii, material, depth, shape } if shader_plates => {
+ let color = material.fill(PlateRole::Nested);
+ let mat = material.finish.to_array();
+ if *depth < 0.0 {
+ // Negative depth = fill-less roll overlay (MODE_ROLL): the
+ // window-edge roll shading alone, screened over whatever is
+ // beneath — for a root plate whose face is not a fill (the
+ // designer's 3D canvas). The cover quad carries no color,
+ // and the batch is NOT opened as a carve host: an overlay
+ // owns no surface for a CSG feature to cut into.
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
+ let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, mat, true, *shape);
+ p.mode = 11.0; // MODE_ROLL
+ plate = Some(p);
+ } else {
+ // Same lit-plate branch; the cover quad is the exact rect so the
+ // silhouette and the compositor's rounded window corners agree.
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
+ let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, true, *shape);
+ let [fz, fw] = material.frost.pack(scale);
+ p.host[2] = fz;
+ p.host[3] = fw;
+ plate = Some(p);
+ made_plate = Some(*rect);
+ }
+ }
+ // A sunken well ending in a flush run (see `Prim::Field`): one
+ // outline, one overlay — never grouped, its profile is not a
+ // monotonic step. The cover quad inflates by half the wall, as a
+ // free carve's does; the host-box slot carries the run's two
+ // ends (physical px), since nothing fades against a host here.
+ Prim::Field { rect, radii, depth, split, end, tint } if shader_plates => {
+ let infl = *depth * 0.5 + 2.0;
+ verts.extend(quad_vertices(
+ rect.x - infl, rect.y - infl,
+ rect.width + 2.0 * infl, rect.height + 2.0 * infl,
+ sw, sh, [0.0; 4],
+ ));
+ let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
+ p.mode = 16.0; // MODE_FIELD
+ if let Some(t) = tint {
+ p.specular_tint = [t[0], t[1], t[2], 1.0];
+ }
+ p.host = [*split * scale, *end * scale, 0.0, 0.0];
+ plate = Some(p);
+ }
+ Prim::Recess { rect, radii, depth, edges, .. }
+ | Prim::Boss { rect, radii, depth, edges, .. }
+ | Prim::Ridge { rect, radii, depth, edges }
+ | Prim::Trough { rect, radii, depth, edges, .. }
+ if shader_plates =>
+ {
+ let tint = match &item.prim {
+ Prim::Recess { tint, .. } => *tint,
+ Prim::Boss { tint, .. } => *tint,
+ Prim::Trough { tint, .. } => *tint,
+ _ => None,
+ };
+ // Recess carves down into the surface; Boss raises a plateau out
+ // of it (same machinery, depth sign flipped); Ridge is a raised
+ // rim straddling the boundary and Trough the sunken valley twin
+ // (their own overlay profiles — never grouped, the CSG features
+ // only model monotonic steps).
+ let mode = match &item.prim {
+ Prim::Boss { .. } => 3.0f32,
+ Prim::Ridge { .. } => 4.0,
+ Prim::Trough { .. } => 9.0,
+ _ => 2.0,
+ };
+ let raised = mode > 2.5;
+ // Grouped into the enclosing plate whenever one is live: the
+ // carve becomes a CSG feature of that plate's single draw —
+ // exact composite shading, real junctions at the plate's rolled
+ // perimeter — instead of a shading overlay (the fallback below).
+ //
+ // Edge-suppressed carves NEVER group: a suppressed wall's rect
+ // extends past the carve (below), relying on the overlay cover
+ // quad to keep that shading out of the drawn pixels — a clip
+ // the plate's whole-surface draw does not have, so grouped it
+ // smears the extended walls across the plate. Union pieces
+ // (section wells, a spinbox's field and button run) are
+ // exactly these.
+ // A tinted carve also never groups: a CSG feature is geometry only,
+ // so the tint could only land on the whole plate's specular.
+ let full_ring = *edges == (true, true, true, true);
+ let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::vk::MAX_PLATE_FEATURES {
+ // The carve's shaded region, for the occlusion test below
+ // (the overlay path's cover-quad inflation).
+ let infl = *depth * 0.5 + 2.0;
+ let (sx0, sy0) = (rect.x - infl, rect.y - infl);
+ let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
+ plate_stack
+ .iter()
+ .enumerate()
+ .rev()
+ .find(|(si, (bi, prect))| {
+ let inside = rect.x >= prect.x - 0.5
+ && rect.y >= prect.y - 0.5
+ && rect.x + rect.width <= prect.x + prect.width + 0.5
+ && rect.y + rect.height <= prect.y + prect.height + 0.5;
+ if !inside {
+ return false;
+ }
+ // Pixels drawn since this plate (a LATER plate in the
+ // stack) must not overlap the carve — its shading would
+ // land beneath them in this plate's earlier draw.
+ if plate_stack[si + 1..].iter().any(|(_, orect)| {
+ sx0 < orect.x + orect.width
+ && sx1 > orect.x
+ && sy0 < orect.y + orect.height
+ && sy1 > orect.y
+ }) {
+ return false;
+ }
+ // Contiguity: only the last feature-receiving plate (or
+ // one with no features yet) may take another.
+ batches[*bi].plate.as_ref().map_or(false, |p| p.host[1] == 0.0)
+ || last_feature_plate == Some(*bi)
+ })
+ .map(|(_, &(bi, _))| bi)
+ } else {
+ None
+ };
+ // Debug-build loudness for the silent grouped→overlay flip —
+ // see `near_roll_fallback_reason` on what qualifies and why
+ // this warns instead of panicking.
+ #[cfg(debug_assertions)]
+ if host_plate.is_none() && mode < 3.5 && full_ring && tint.is_none() {
+ let enclosing = plate_stack.iter().enumerate().rev().find(|(_, (_, p))| {
+ rect.x >= p.x - 0.5
+ && rect.y >= p.y - 0.5
+ && rect.x + rect.width <= p.x + p.width + 0.5
+ && rect.y + rect.height <= p.y + p.height + 0.5
+ });
+ if let Some((si, &(bi, prect))) = enclosing {
+ // Host roll width rides the push's light.w (physical px).
+ let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
+ let later: Vec<crate::scene::layout::Rect> =
+ plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
+ let budget_full = features.len() >= crate::vk::MAX_PLATE_FEATURES;
+ if let Some(why) =
+ near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
+ {
+ let kind = if mode > 2.5 { "boss" } else { "recess" };
+ plate_carve_warn_once(format!(
+ "plate-carve: near-roll {kind} ({:.0},{:.0} {:.0}x{:.0}) lost grouping — {why}; \
+ its junction with the host plate's roll shades through the overlay fallback, \
+ visually different from grouped frames (CCE_PLATE_DEBUG=1 traces verdicts) \
+ [debug-build warning, printed once]",
+ rect.x, rect.y, rect.width, rect.height
+ ));
+ }
+ }
+ }
+ if dbg_plates {
+ match host_plate {
+ Some(_) => dbg_grouped += 1,
+ None => {
+ // Re-derive WHY, in the same order the guard tests
+ // them. Debug-only: the hot path above is untouched.
+ let kind = match &item.prim {
+ Prim::Boss { .. } => "boss",
+ Prim::Ridge { .. } => "ridge",
+ Prim::Trough { .. } => "trough",
+ _ => "recess",
+ };
+ let infl = *depth * 0.5 + 2.0;
+ let (sx0, sy0) = (rect.x - infl, rect.y - infl);
+ let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
+ let enclosing: Vec<usize> = plate_stack
+ .iter()
+ .enumerate()
+ .filter(|(_, (_, p))| {
+ rect.x >= p.x - 0.5
+ && rect.y >= p.y - 0.5
+ && rect.x + rect.width <= p.x + p.width + 0.5
+ && rect.y + rect.height <= p.y + p.height + 0.5
+ })
+ .map(|(si, _)| si)
+ .collect();
+ let occluded = |si: usize| {
+ plate_stack[si + 1..].iter().any(|(_, o)| {
+ sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y
+ })
+ };
+ let why = if mode >= 3.5 {
+ "ridge — never groups (its bump profile is not a monotonic step)".into()
+ } else if !full_ring {
+ format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
+ } else if tint.is_some() {
+ "tinted — a CSG feature is geometry only, it carries no color".into()
+ } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
+ format!("feature budget full ({} used)", features.len())
+ } else if enclosing.is_empty() {
+ format!("no enclosing plate ({} open)", plate_stack.len())
+ } else if enclosing.iter().all(|&si| occluded(si)) {
+ "a later plate overlaps this carve's shaded region".into()
+ } else {
+ "host plate's feature run is closed (another carve appended since)".into()
+ };
+ dbg_fell_back.push(format!(
+ " overlay: {kind} ({:.0},{:.0} {:.0}x{:.0}) — {why}",
+ rect.x, rect.y, rect.width, rect.height
+ ));
+ }
+ }
+ }
+ if let Some(bi) = host_plate {
+ {
+ // A wall the carve shares with the plate's edge extends
+ // past the plate, so the carve has no wall there.
+ let ext = *depth + 4.0;
+ let (mut x0, mut y0) = (rect.x, rect.y);
+ let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
+ if !edges.0 { y0 -= ext; }
+ if !edges.1 { x1 += ext; }
+ if !edges.2 { y1 += ext; }
+ if !edges.3 { x0 -= ext; }
+ let t_px = *depth * scale;
+ // The carve's drop: the material's pinned height, else
+ // the analytic ratio of the wall saturating at the DE's
+ // roll width (`layout::carve_depth_px` states the rule
+ // once for this path and the shader's free carves).
+ let k_mag = crate::layout::carve_depth_px(*depth) * scale;
+ // Negative depth = raised (Boss); the shader's summed
+ // slope vectors and curvature sign follow it.
+ let k_px = if raised { -k_mag } else { k_mag };
+ if let Some(p) = batches[bi].plate.as_mut() {
+ if p.host[1] == 0.0 {
+ p.host[0] = features.len() as f32;
+ }
+ p.host[1] += 1.0;
+ }
+ last_feature_plate = Some(bi);
+ features.push([
+ (x0 + x1) * 0.5 * scale,
+ (y0 + y1) * 0.5 * scale,
+ (x1 - x0) * 0.5 * scale,
+ (y1 - y0) * 0.5 * scale,
+ radii.0 * scale,
+ radii.1 * scale,
+ radii.2 * scale,
+ radii.3 * scale,
+ t_px,
+ k_px,
+ 0.0,
+ 0.0,
+ ]);
+ continue;
+ }
+ }
+ // Overlay-only carve: the cover quad inflates by half the roll
+ // width (the step straddles the boundary) and carries no color —
+ // the shader emits translucent white/black over what's beneath.
+ let infl = *depth * 0.5 + 2.0;
+ verts.extend(quad_vertices(
+ rect.x - infl, rect.y - infl,
+ rect.width + 2.0 * infl, rect.height + 2.0 * infl,
+ sw, sh, [0.0; 4],
+ ));
+ // A suppressed wall is pushed past the cover quad, so its
+ // shading falls outside the drawn pixels (see Prim::Recess on
+ // why a flush region is a step, not a trough).
+ let ext = *depth + 4.0;
+ let (mut x0, mut y0) = (rect.x, rect.y);
+ let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
+ if !edges.0 { y0 -= ext; }
+ if !edges.1 { x1 += ext; }
+ if !edges.2 { y1 += ext; }
+ if !edges.3 { x0 -= ext; }
+ let sdf_rect = crate::scene::layout::Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
+ let mut p = plate_push_raised(&sdf_rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
+ p.mode = mode;
+ // w = 1.0 flags the free-carve shader path to composite its
+ // light in the tint and its shadow in a dark tint instead of
+ // white and black (plates leave w at 0.0).
+ if let Some(t) = tint {
+ p.specular_tint = [t[0], t[1], t[2], 1.0];
+ }
+ // Host-plate box for the roll fade: a suppressed wall means the
+ // recess runs flush to the host's edge there, so that side of
+ // the box sits at the original rect edge; enabled walls face
+ // host interior, pushed to ±1e5 so no fade applies.
+ const FAR: f32 = 1e5;
+ let (hx0, hy0) = (
+ if edges.3 { rect.x - FAR } else { rect.x },
+ if edges.0 { rect.y - FAR } else { rect.y },
+ );
+ let (hx1, hy1) = (
+ if edges.1 { rect.x + rect.width + FAR } else { rect.x + rect.width },
+ if edges.2 { rect.y + rect.height + FAR } else { rect.y + rect.height },
+ );
+ p.host = [
+ (hx0 + hx1) * 0.5 * scale,
+ (hy0 + hy1) * 0.5 * scale,
+ (hx1 - hx0) * 0.5 * scale,
+ (hy1 - hy0) * 0.5 * scale,
+ ];
+ plate = Some(p);
+ }
+ Prim::Bevel { rect, radii, material, depth, tint: _ } => {
+ let color = material.fill(PlateRole::Nested);
+ // Full-size fill: the lip is now a shading overlay, not a paint of the
+ // outer ring, so the fill must cover the whole rect (the old inset fill
+ // would leave the ring showing whatever lay beneath).
+ let corners = crate::widget::CornerRadii {
+ top_left: radii.0, top_right: radii.1,
+ bottom_right: radii.2, bottom_left: radii.3,
+ };
+ push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts);
+ push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, color, no, &mut verts);
+ }
+ Prim::Plate { rect, radii, material, depth, .. } => {
+ let color = material.fill(PlateRole::Nested);
+ if *depth < 0.0 {
+ // Fill-less roll overlay (negative-depth sentinel): the banded
+ // legacy tessellation has no overlay compositing, so the roll
+ // is simply absent here — the A/B path draws nothing rather
+ // than a wrong fill.
+ continue;
+ }
+ // Fill at full size (no inset — see Prim::Plate), then light the face,
+ // then roll the perimeter. The lip rides on top of the fill's outer band
+ // rather than replacing it, so the plate's silhouette and the
+ // compositor's rounded window corners still agree exactly.
+ let corners = crate::widget::CornerRadii {
+ top_left: radii.0, top_right: radii.1,
+ bottom_right: radii.2, bottom_left: radii.3,
+ };
+ push_rounded_rect_vertices_corners(
+ rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts,
+ );
+ push_plate_face_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, no, &mut verts);
+ push_bevel_edge_vertices_radii(
+ rect.x, rect.y, rect.width, rect.height, *radii, *depth,
+ sw, sh, color, no, 1.0, &mut verts,
+ );
+ }
+ Prim::Recess { rect, radii, depth, edges, .. } => {
+ // Edges only — no fill: the shading is an overlay, so whatever is painted
+ // below (fill, rim gradient, blur) shows through the carve modulated
+ // rather than repainted. `light_sign = -1.0` shadows the lit-facing edges,
+ // which is the raised->recessed inversion.
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, rect.width, rect.height, *radii, *depth,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ Prim::Boss { rect, radii, depth, edges, .. } => {
+ // Legacy raised step: the recess overlay with the light sign upright.
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, rect.width, rect.height, *radii, *depth,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(*depth), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ Prim::Ridge { rect, radii, depth, edges } => {
+ // Legacy approximation: a raised step up at the boundary plus a
+ // recessed step down half a width in (the banded machinery has no
+ // bump profile; the double-pass hot crest is accepted here — the
+ // legacy path exists only for A/B comparison).
+ let half = *depth * 0.5;
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, rect.width, rect.height, *radii, half,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ let ir = (radii.0 - half).max(0.0);
+ push_bevel_edge_vertices_banded(
+ rect.x + half, rect.y + half,
+ rect.width - *depth, rect.height - *depth,
+ (ir, ir, ir, ir), half,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ Prim::Trough { rect, radii, depth, edges, .. } => {
+ // Legacy approximation, the Ridge arm's two steps with the light
+ // signs swapped: down at the boundary, back up half a width in.
+ // The banded machinery has no valley profile, so this is the old
+ // stacked look — accepted here, as the legacy path exists only
+ // for A/B comparison against the SDF one.
+ let half = *depth * 0.5;
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, rect.width, rect.height, *radii, half,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ let ir = (radii.0 - half).max(0.0);
+ push_bevel_edge_vertices_banded(
+ rect.x + half, rect.y + half,
+ rect.width - *depth, rect.height - *depth,
+ (ir, ir, ir, ir), half,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ Prim::Field { rect, radii, depth, split, end, .. } => {
+ // Legacy approximation: the two-box form `Prim::Field`
+ // replaced — a well either side of the run a step down, the
+ // run the Trough arm's down-then-up stack. The banded
+ // machinery has no blended outline, and the legacy path
+ // exists only for A/B comparison.
+ let all = (true, true, true, true);
+ let (fl, fr) = (rect.x, rect.x + rect.width);
+ let (well_l, well_r) = (*split > fl, *end < fr);
+ let rx = split.max(fl);
+ let rw = (end.min(fr) - rx).max(0.0);
+ if well_l {
+ push_bevel_edge_vertices_banded(
+ fl, rect.y, rx - fl, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ if well_r {
+ push_bevel_edge_vertices_banded(
+ rx + rw, rect.y, fr - rx - rw, rect.height, (0.0, radii.1, radii.2, 0.0), *depth,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ // A run's own corners where it reaches the outline; square at a seam.
+ let (l0, l3) = if well_l { (0.0, 0.0) } else { (radii.0, radii.3) };
+ let (r1, r2) = if well_r { (0.0, 0.0) } else { (radii.1, radii.2) };
+ let half = *depth * 0.5;
+ push_bevel_edge_vertices_banded(
+ rx, rect.y, rw, rect.height, (l0, r1, r2, l3), half,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), all,
+ EdgeKind::Step, &mut verts,
+ );
+ let ir = if well_r { 0.0 } else { (radii.1 - half).max(0.0) };
+ let il = if well_l { 0.0 } else { (radii.0 - half).max(0.0) };
+ push_bevel_edge_vertices_banded(
+ rx + half, rect.y + half, rw - *depth, rect.height - *depth, (il, ir, ir, il), half,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
+ EdgeKind::Step, &mut verts,
+ );
+ }
+ Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
+ push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
+ }
+ Prim::ArcShaded { cx, cy, radius, thickness, start: sa, end: ea, inner, crest, outer } => {
+ push_arc_shaded_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *inner, *crest, *outer, segs(*radius), no, &mut verts);
+ }
+ Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
+ let lc = match cap {
+ Cap::Flat => LineCap::Flat,
+ Cap::Round => LineCap::Round,
+ Cap::Arrow => LineCap::Arrow,
+ };
+ verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
+ }
+ Prim::Circle { cx, cy, radius, color } => {
+ if item.clip_circle.is_none() && *radius > 1.5 {
+ // Cover quad with the disc itself as the (feathered) circle
+ // clip: a per-pixel smooth silhouette instead of a hard-edged
+ // fan. The quad overhangs by 1px for the feather. Only when
+ // no ancestor clip holds the slot — then it's the fan path.
+ let own = [cx * scale, cy * scale, radius * scale];
+ let d = *radius + 1.0;
+ verts.extend(quad_vertices_with_clip(
+ cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color, own,
+ ));
+ } else {
+ verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
+ }
+ }
+ Prim::Sphere { cx, cy, radius, material } if shader_plates => {
+ let color = material.fill(PlateRole::Nested);
+ let mat = material.finish.to_array();
+ // A hemisphere lit per pixel by the plate branch (mode 5): one
+ // cover quad, its own never-merged batch. The quad overhangs
+ // the disc by 1px for the shader's silhouette anti-aliasing.
+ let d = *radius + 1.0;
+ verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
+ plate = Some(crate::vk::PlatePush {
+ // Center + radius in physical px; the SDF box machinery is
+ // unused in this mode, so .w is free.
+ rect: [cx * scale, cy * scale, radius * scale, 0.0],
+ radii: [0.0; 4],
+ light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
+ material: mat,
+ host: [0.0; 4],
+ specular_tint: [1.0, 1.0, 1.0, 0.0],
+ mode: 5.0,
+ shape: 2.0,
+ });
+ }
+ Prim::Sphere { cx, cy, radius, material } => {
+ let color = material.fill(PlateRole::Nested);
+ // Legacy path: the flat disc, exactly a Circle.
+ verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, color, segs(*radius), no));
+ }
+ Prim::DropletScrim { rect, material, spec, feather } if shader_plates => {
+ let color = material.fill(PlateRole::Nested);
+ let mat = material.finish.to_array();
+ // Shader mode 12: the droplet's own SDF, filled flat and
+ // feathered inward. No contact shadow, so unlike the lit drop
+ // the cover quad is exactly the box — a scrim never draws
+ // outside the silhouette.
+ let g = droplet_geom(rect, spec);
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
+ plate = Some(crate::vk::PlatePush {
+ rect: [
+ (rect.x + rect.width * 0.5) * scale,
+ (rect.y + rect.height * 0.5) * scale,
+ g.hx * scale,
+ g.hy * scale,
+ ],
+ radii: [g.sag * scale, g.br * scale, g.bw * scale, g.k * scale],
+ // p_light.w carries the FEATHER here; mode 12 returns
+ // before the shading band it otherwise holds is read.
+ light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
+ material: [mat[0], 0.0, 0.0, 0.0],
+ host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
+ specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
+ mode: 12.0,
+ shape: spec.curve.clamp(2.0, 6.0),
+ });
+ }
+ Prim::Droplet { rect, material, spec } if shader_plates => {
+ let color = material.fill(PlateRole::Nested);
+ let mat = material.finish.to_array();
+ // A water droplet lit by shader mode 10: one cover quad; the
+ // shader owns silhouette (sheet ∪smin belly), dome shading,
+ // fresnel rim and thin-edge clarity. The spec's height
+ // fractions resolve against the concrete rect here, clamped so
+ // small or narrow boxes stay well-formed (a belly wider than
+ // the box would turn the SDF interior inside out).
+ // The cover quad grows sideways and BELOW the box by the
+ // contact shadow's reach — shadow fragments live outside the
+ // silhouette, so they need covered pixels to shade.
+ let g = droplet_geom(rect, spec);
+ let (hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach) =
+ (g.hx, g.hy, g.sag, g.br, g.bw, g.k, g.sr, g.ar, g.band, g.bow, g.sh_reach);
+ verts.extend(quad_vertices(
+ rect.x - sh_reach,
+ rect.y,
+ rect.width + 2.0 * sh_reach,
+ rect.height + sh_reach,
+ sw, sh, color,
+ ));
+ plate = Some(crate::vk::PlatePush {
+ rect: [
+ (rect.x + rect.width * 0.5) * scale,
+ (rect.y + rect.height * 0.5) * scale,
+ hx * scale,
+ hy * scale,
+ ],
+ radii: [sag * scale, br * scale, bw * scale, k * scale],
+ light: [plate_light[0], plate_light[1], plate_light[2], band * scale],
+ // Slots y/z/w feed roll_spec and the rim term directly:
+ // a droplet's material carries its own gleam/shine/rim
+ // there (`DropletSpec::finish`; a drop is wetter than the
+ // DE's plates), so this is the material's finish like any
+ // plate's.
+ material: mat,
+ host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
+ // Droplet glints are always white, so the tint RGB slots
+ // carry droplet params instead: x = core density,
+ // y = contact-shadow reach px, z = shadow strength.
+ specular_tint: [
+ spec.core.clamp(0.0, 2.0),
+ sh_reach * scale,
+ spec.shadow.clamp(0.0, 1.0),
+ bow * scale,
+ ],
+ mode: 10.0,
+ shape: spec.curve.clamp(2.0, 6.0),
+ });
+ }
+ Prim::DropletScrim { rect, material, spec, .. } => {
+ let color = material.fill(PlateRole::Nested);
+ // Legacy banded path: no SDF to feather against, so the scrim
+ // degrades to the same flat outline the drop itself does —
+ // hard-edged, but present. A prim with no arm here VANISHES.
+ let cap = (rect.height * 0.5).min(rect.width * 0.5);
+ let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
+ let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
+ let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
+ push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
+ }
+ Prim::Droplet { rect, material, spec } => {
+ let color = material.fill(PlateRole::Nested);
+ // Legacy banded path: the flat drop outline — attach-tapered
+ // top, round bottom. Degrades the material but keeps the
+ // silhouette (a prim with no arm here VANISHES, it doesn't
+ // degrade — see Ridge/Groove above).
+ let cap = (rect.height * 0.5).min(rect.width * 0.5);
+ let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
+ let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
+ let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
+ push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
+ }
+ Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
+ // A quarter-arc carve wall (shader mode 6/7): one cover quad
+ // over the wedge's reach; the wall straddles the arc by ±t/2
+ // like every carve boundary. p_rect carries centre + radius,
+ // p_radii.x the wedge start angle. Host box pushed far out —
+ // an inside-corner fillet never fades.
+ let m = *depth * 0.5 + 2.0;
+ let r = *radius + m;
+ verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
+ plate = Some(crate::vk::PlatePush {
+ rect: [cx * scale, cy * scale, *radius * scale, 0.0],
+ radii: [*a0, 0.0, 0.0, 0.0],
+ light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
+ material: plate_mat,
+ host: [0.0, 0.0, 1e6, 1e6],
+ specular_tint: [1.0, 1.0, 1.0, 0.0],
+ mode: if *raised { 7.0 } else { 6.0 },
+ shape: crate::layout::corner_shape(),
+ });
+ }
+ // Legacy banded path has no radial wall — the composed corner
+ // stays square there (A/B comparison path only).
+ Prim::ConcaveFillet { .. } => {}
+ Prim::Groove { a, b, width, depth, host, strength } if shader_plates => {
+ // A slab carve about the line a–b (shader mode 8): the cover
+ // quad is the segment's bounding box grown by the groove's own
+ // half-width plus the wall's reach. Off-band corners of that
+ // box sit at u = 1 (plateau), so the box overhang shades
+ // nothing — the slab is what bounds the mark, not the quad.
+ let m = *width * 0.5 + *depth * 0.5 + 2.0;
+ let (x0, x1) = (a.0.min(b.0) - m, a.0.max(b.0) + m);
+ let (y0, y1) = (a.1.min(b.1) - m, a.1.max(b.1) + m);
+ verts.extend(quad_vertices(x0, y0, x1 - x0, y1 - y0, sw, sh, [0.0; 4]));
+ // Unit normal of the line — the direction the slab's distance is
+ // measured along. A degenerate segment falls back to vertical so
+ // a zero-length groove is a no-op wall rather than a NaN.
+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);
+ let len = (dx * dx + dy * dy).sqrt();
+ let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
+ plate = Some(crate::vk::PlatePush {
+ // Centre + slab half-width in physical px; .w unused.
+ rect: [
+ (a.0 + b.0) * 0.5 * scale,
+ (a.1 + b.1) * 0.5 * scale,
+ *width * 0.5 * scale,
+ 0.0,
+ ],
+ radii: [n.0, n.1, 0.0, 0.0],
+ light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
+ // The finish's shading, specular and AO, at the groove's
+ // strength; shininess is a shape, not an amount.
+ material: {
+ let s = strength.clamp(0.0, 1.0);
+ [plate_mat[0] * s, plate_mat[1] * s, plate_mat[2], plate_mat[3] * s]
+ },
+ host: [
+ (host.x + host.width * 0.5) * scale,
+ (host.y + host.height * 0.5) * scale,
+ host.width * 0.5 * scale,
+ host.height * 0.5 * scale,
+ ],
+ specular_tint: [1.0, 1.0, 1.0, 0.0],
+ mode: 8.0,
+ shape: crate::layout::corner_shape(),
+ });
+ }
+ Prim::Groove { a, b, width, depth, host: _, strength } => {
+ // Legacy approximation. The banded tessellators walk BOX edges —
+ // exactly the axis-aligned assumption a groove exists to escape —
+ // so the walls are drawn directly as two feathered lines meeting
+ // at the centerline: the engraved-line fake, one half in shadow
+ // and one lit. Coarser than the SDF (no profile curve, no host
+ // fade), but this path exists for A/B comparison, and drawing
+ // NOTHING would silently delete the mark rather than degrade it
+ // — see `Prim::Ridge` above, which accepts a hot crest for the
+ // same reason.
+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);
+ let len = (dx * dx + dy * dy).sqrt();
+ if len < 0.001 {
+ continue;
+ }
+ let n = (-dy / len, dx / len);
+ // Same convention as `push_bevel_edge_vertices_banded`: the
+ // light folded through `light_sign` (-1.0 — a groove is a
+ // carve), dotted with each wall's OUTWARD normal, amplitude on
+ // `bevel_depth`. So a groove re-lights with the DE's light
+ // instead of hardcoding which side is dark.
+ let rad = crate::layout::light_source_position();
+ let (lx, ly) = (-rad.cos(), rad.sin());
+ let v = crate::layout::bevel_depth() * (n.0 * lx + n.1 * ly);
+ // Each wall covers its own half, centreline to outer edge —
+ // abutting rather than overlapping. The SDF gets away with
+ // walls that overlap across a sub-pixel floor because it is one
+ // evaluation of |distance|; two opposite-signed overlays would
+ // just blend to mud.
+ let half = (*width * 0.5 + *depth * 0.5).max(0.5);
+ for side in [1.0f32, -1.0] {
+ let sv = v * side;
+ let mut c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
+ c[3] *= strength.clamp(0.0, 1.0);
+ if c[3] <= 0.0 {
+ continue;
+ }
+ let off = side * half * 0.5;
+ push_feathered_line_vertices(
+ a.0 + n.0 * off, a.1 + n.1 * off,
+ b.0 + n.0 * off, b.1 + n.1 * off,
+ half, sw, sh, c, &mut verts,
+ );
+ }
+ }
+ Prim::Lattice { rect, period, origin, cell, radius, depth } if shader_plates => {
+ // A periodic well field (shader mode 13): one cover quad over
+ // `rect`; the shader folds each pixel into the period and
+ // measures the nearest cell, so the whole lattice is a single
+ // evaluation. p_rect = one cell's centre + half-extents,
+ // p_radii = the corner radius, p_host.xy = the period; the
+ // host-box fade sides are pushed far out (a lattice never
+ // fades against a host — its own rect bounds it).
+ let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
+ plate = Some(crate::vk::PlatePush {
+ rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
+ radii: [*radius * scale; 4],
+ light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
+ material: plate_mat,
+ host: [pw * scale, ph * scale, 1e6, 1e6],
+ specular_tint: [1.0, 1.0, 1.0, 0.0],
+ mode: 13.0,
+ shape: crate::layout::corner_shape(),
+ });
+ }
+ Prim::Grout { rect, period, origin, cell, radius, color } if shader_plates => {
+ // The lattice's fold, painted flat (shader mode 15): one cover
+ // quad in the grout colour; the shader keeps it outside the
+ // cells. Same push layout as the lattice; light/material are
+ // carried but unread.
+ let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
+ verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+ plate = Some(crate::vk::PlatePush {
+ rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
+ radii: [*radius * scale; 4],
+ light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
+ material: plate_mat,
+ host: [pw * scale, ph * scale, 1e6, 1e6],
+ specular_tint: [1.0, 1.0, 1.0, 0.0],
+ mode: 15.0,
+ shape: crate::layout::corner_shape(),
+ });
+ }
+ // Legacy banded path: no periodic wall — the lattice and the grout
+ // draw nothing there, like the fillet (A/B comparison path only).
+ Prim::Lattice { .. } | Prim::Grout { .. } => {}
+ Prim::CarveUnion { boxes, depth, raised } if shader_plates => {
+ // The union of several boxes as ONE wall (shader mode 14): the
+ // boxes go into the frame's feature buffer as a contiguous run
+ // and the shader takes the nearest one per pixel. The cover
+ // quad is the union's bounding box grown by the wall's reach;
+ // off-shape corners of it sit at the plateau and shade nothing.
+ let budget = crate::vk::MAX_PLATE_FEATURES.saturating_sub(features.len());
+ let take = boxes.len().min(budget);
+ if take < boxes.len() && plate_debug() {
+ eprintln!(
+ "plate-carve: union of {} boxes gets {} — feature budget full ({} used)",
+ boxes.len(), take, features.len()
+ );
+ }
+ if take == 0 {
+ continue;
+ }
+ let kept = &boxes[..take];
+ let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
+ for (r, _) in kept {
+ x0 = x0.min(r.x);
+ y0 = y0.min(r.y);
+ x1 = x1.max(r.x + r.width);
+ y1 = y1.max(r.y + r.height);
+ }
+ let infl = *depth * 0.5 + 2.0;
+ verts.extend(quad_vertices(
+ x0 - infl, y0 - infl,
+ (x1 - x0) + 2.0 * infl, (y1 - y0) + 2.0 * infl,
+ sw, sh, [0.0; 4],
+ ));
+ let off = features.len() as f32;
+ for (r, radii) in kept {
+ features.push([
+ (r.x + r.width * 0.5) * scale,
+ (r.y + r.height * 0.5) * scale,
+ r.width * 0.5 * scale,
+ r.height * 0.5 * scale,
+ radii.0 * scale,
+ radii.1 * scale,
+ radii.2 * scale,
+ radii.3 * scale,
+ *depth * scale,
+ 0.0,
+ 0.0,
+ 0.0,
+ ]);
+ }
+ // The run is complete: a plate with an open feature run must
+ // not append past it (its features would no longer be
+ // contiguous), so it is closed here like any other appender.
+ last_feature_plate = None;
+ plate = Some(crate::vk::PlatePush {
+ rect: [
+ (x0 + x1) * 0.5 * scale,
+ (y0 + y1) * 0.5 * scale,
+ (x1 - x0) * 0.5 * scale,
+ (y1 - y0) * 0.5 * scale,
+ ],
+ // x: the raised flag; the shader reads nothing else here.
+ radii: [if *raised { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
+ light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
+ material: plate_mat,
+ // Feature run [offset, count] (the renderer rebases the
+ // offset onto the frame slot, as for mode 1); zw far out
+ // so the host-box fade never applies.
+ host: [off, take as f32, 1e6, 1e6],
+ specular_tint: [1.0, 1.0, 1.0, 0.0],
+ mode: 14.0,
+ shape: crate::layout::corner_shape(),
+ });
+ }
+ // Legacy banded path: no union — nothing is drawn there, like the
+ // fillet and the lattice (A/B comparison path only).
+ Prim::CarveUnion { .. } => {}
+ }
+ let end = verts.len() as u32;
+ if end == start {
+ continue;
+ }
+ // Some tessellators (quad_vertices, vector_vertices) don't thread the circle clip —
+ // stamp the whole emitted range so every prim kind honors it uniformly.
+ if item.clip_circle.is_some() {
+ for v in verts[start as usize..].iter_mut() {
+ v.clip_circle = no;
+ }
+ }
+ // Merge into the previous batch if it shares this clip pair and is contiguous.
+ // Plate batches carry per-draw push constants, and blur-behind batches
+ // trigger the renderer's snapshot copy, so neither ever merges.
+ if plate.is_none() && !blur_behind {
+ // Ordinary geometry painted after a plate ends its carve-grouping
+ // window: a recess emitted later must overlay this geometry (the
+ // fallback path), not shade beneath it inside the plate's draw.
+ if dbg_plates && !plate_stack.is_empty() {
+ *dbg_closed_by.entry(prim_kind(&item.prim)).or_insert(0) += plate_stack.len();
+ }
+ plate_stack.clear();
+ if let Some(last) = batches.last_mut() {
+ if last.plate.is_none()
+ && last.scissor == item.clip
+ && last.clip_rrect == item.clip_rrect
+ && last.end == start
+ {
+ last.end = end;
+ continue;
+ }
+ }
+ }
+ if promoted {
+ plate_stack.clear();
+ }
+ batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate, blur_behind });
+ if let Some(prect) = made_plate {
+ plate_stack.push((batches.len() - 1, prect));
+ if dbg_plates {
+ dbg_opened += 1;
+ }
+ }
+ }
+
+ if dbg_plates && (dbg_grouped > 0 || !dbg_fell_back.is_empty()) {
+ eprintln!(
+ "plate-dbg: {} carves — {dbg_grouped} grouped (exact CSG), {} overlay fallback",
+ dbg_grouped + dbg_fell_back.len(),
+ dbg_fell_back.len(),
+ );
+ eprintln!(
+ "plate-dbg: {dbg_opened} grouping window(s) opened by a filled plate; closed early by {}",
+ if dbg_closed_by.is_empty() {
+ "nothing".to_string()
+ } else {
+ dbg_closed_by
+ .iter()
+ .map(|(k, n)| format!("{k}x{n}"))
+ .collect::<Vec<_>>()
+ .join(", ")
+ }
+ );
+ for line in &dbg_fell_back {
+ eprintln!("plate-dbg: {line}");
+ }
+ }
+
+ (verts, batches, images, features)
+}
+
+/// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
+/// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
+///
+/// `shape` is a per-plate corner exponent (`Prim::Plate`'s override); `None`
+/// follows the DE-wide `layout::corner_shape`. The span factor follows the
+/// exponent actually used, so a circular override (2.0) spans nothing and a
+/// half-extent radius lands on a true circle.
+#[allow(clippy::too_many_arguments)]
+/// The push block of a frosted flat fill promoted to a zero-depth plate: a
+/// mode-1 plate with no roll (`t` = 0.001, so the face is exactly the fill),
+/// corners at the nominal radii in the configured `corner_shape`, and the
+/// fill's own frost recipe in `host.zw` (`Material::from_fill` decodes the
+/// sentinel).
+///
+/// The shape must be `corner_shape`, not a fixed circle: a frosted `Border`
+/// draws its stroke as `push_plate_solid_border_vertices` geometry in that
+/// shape, and the unfrosted fill fan uses it too. A circular face under a
+/// squircle stroke left the stroke cutting inside the face's corners.
+fn flat_frost_push(
+ rect: &crate::scene::layout::Rect,
+ radii: (f32, f32, f32, f32),
+ fill: [f32; 4],
+ scale: f32,
+ light: [f32; 3],
+ material: [f32; 4],
+) -> crate::vk::PlatePush {
+ let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, None);
+ let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
+ p.host[2] = fz;
+ p.host[3] = fw;
+ p
+}
+
+fn plate_push_raised(
+ rect: &crate::scene::layout::Rect,
+ radii: (f32, f32, f32, f32),
+ width: f32,
+ scale: f32,
+ light: [f32; 3],
+ material: [f32; 4],
+ scale_corners: bool,
+ shape: Option<f32>,
+) -> crate::vk::PlatePush {
+ // Floored: a rect already shrunk past its padding (a window dragged
+ // below what its layout can hold) has a NEGATIVE extent here, and
+ // `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
+ let cap = (rect.width.min(rect.height) * 0.5).max(0.0);
+ let shape = shape.map_or_else(crate::layout::corner_shape, |n| n.clamp(2.0, 16.0));
+ // For PLATES (`scale_corners`), widen the corner span by the
+ // curvature-match factor (see `layout::corner_span_factor`): the diagonal
+ // curvature radius equals the configured radius, the corner reads as the
+ // same size as a circular one, and every roll inset ≤ r stays crease-free
+ // (past the diagonal curvature radius the offset curve the specular band
+ // follows creases into a visible square corner). Widget-scale overlay
+ // reliefs (recess/boss/ridge fallbacks) pass false: their radii must MATCH
+ // the nominal-radius squircles of the widget silhouettes around them, and
+ // at their few-px roll widths the offset crease is subpixel.
+ let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
+ crate::vk::PlatePush {
+ rect: [
+ (rect.x + rect.width * 0.5) * scale,
+ (rect.y + rect.height * 0.5) * scale,
+ rect.width * 0.5 * scale,
+ rect.height * 0.5 * scale,
+ ],
+ radii: [
+ (radii.0 * rscale).clamp(0.0, cap) * scale,
+ (radii.1 * rscale).clamp(0.0, cap) * scale,
+ (radii.2 * rscale).clamp(0.0, cap) * scale,
+ (radii.3 * rscale).clamp(0.0, cap) * scale,
+ ],
+ light: [light[0], light[1], light[2], width * scale],
+ material,
+ // Mode-1 semantics: [feature offset, feature count] — no carves yet;
+ // the tessellator fills these in as recesses group into this plate.
+ host: [0.0, 0.0, 0.0, 0.0],
+ specular_tint: [1.0, 1.0, 1.0, 0.0],
+ mode: 1.0,
+ shape,
+ }
+}
+
+pub fn extra_quad_vertices(
+ w: &dyn crate::widget::WidgetHost,
+ qx: f32, qy: f32, qw: f32, qh: f32,
+ sw: f32, sh: f32,
+ qc: [f32; 4],
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ push_extra_quad_vertices(w, qx, qy, qw, qh, sw, sh, qc, clip_circle, &mut verts);
+ verts
+}
+
+fn get_child_widget_for_quad<'a>(
+ w: &'a dyn crate::widget::WidgetHost,
+ qx: f32, qy: f32, qw: f32, qh: f32,
+) -> &'a dyn crate::widget::WidgetHost {
+ if let Some(pbg) = w.as_any().downcast_ref::<crate::widget::ParametersBg>() {
+ for s_opt in &pbg.sliders {
+ if let Some(s) = s_opt {
+ let (sx, sy, sww, shh) = s.rect();
+ if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
+ return s;
+ }
+ }
+ }
+ for f_opt in &pbg.float3s {
+ if let Some(f) = f_opt {
+ let (fx, fy, fww, fhh) = f.rect();
+ if qx >= fx - 0.1 && qx + qw <= fx + fww + 0.1 && qy >= fy - 0.1 && qy + qh <= fy + fhh + 0.1 {
+ return f;
+ }
+ }
+ }
+ for sb_opt in &pbg.spinboxes {
+ if let Some(sb) = sb_opt {
+ let (sx, sy, sww, shh) = sb.rect();
+ if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
+ return sb;
+ }
+ }
+ }
+ for btn_opt in &pbg.buttons {
+ if let Some(btn) = btn_opt {
+ let (bx, by, bww, bhh) = btn.rect();
+ if qx >= bx - 0.1 && qx + qw <= bx + bww + 0.1 && qy >= by - 0.1 && qy + qh <= by + bhh + 0.1 {
+ return btn;
+ }
+ }
+ }
+ for ch_opt in &pbg.choices {
+ if let Some(ch) = ch_opt {
+ let (cx, cy, cww, chh) = ch.rect();
+ if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
+ return ch;
+ }
+ }
+ }
+ for t_opt in &pbg.texts {
+ if let Some(t) = t_opt {
+ let (tx, ty, tww, thh) = t.rect();
+ if qx >= tx - 0.1 && qx + qw <= tx + tww + 0.1 && qy >= ty - 0.1 && qy + qh <= ty + thh + 0.1 {
+ return t;
+ }
+ }
+ }
+ for cb_opt in &pbg.toggles {
+ if let Some(cb) = cb_opt {
+ let (cx, cy, cww, chh) = cb.rect();
+ if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
+ return cb;
+ }
+ }
+ }
+ for c_opt in &pbg.colors {
+ if let Some(c) = c_opt {
+ let (cx, cy, cww, chh) = c.rect();
+ if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
+ return c;
+ }
+ }
+ }
+ }
+ w
+}
+
+pub fn push_extra_quad_vertices(
+ w: &dyn crate::widget::WidgetHost,
+ qx: f32, qy: f32, qw: f32, qh: f32,
+ sw: f32, sh: f32,
+ qc: [f32; 4],
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
+ if graph.is_node_rect(qx, qy, qw, qh) {
+ let r = crate::layout::graph_node_corner_radius();
+ let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
+ push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
+ return;
+ }
+ }
+
+ let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
+ let radii = target_w.corner_radii();
+ if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
+ out.extend_from_slice(&quad_vertices_with_clip(qx, qy, qw, qh, sw, sh, qc, clip_circle));
+ if let Some((color, thickness)) = target_w.solid_border() {
+ let (wx, wy, ww, wh) = target_w.rect();
+ if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
+ push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
+ }
+ }
+ return;
+ }
+
+ let (wx, mut wy, ww, mut wh) = target_w.rect();
+ let top_room = target_w.label_strip();
+ wy += top_room;
+ wh -= top_room;
+ let extra_radii = crate::widget::CornerRadii::new(
+ if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
+ if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
+ if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
+ if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
+ );
+
+ push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
+
+ if let Some((color, thickness)) = target_w.solid_border() {
+ let (rx, mut ry, rw, mut rh) = target_w.rect();
+ let top = target_w.label_strip();
+ ry += top;
+ rh -= top;
+ if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
+ push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
+ }
+ }
+}
+
+pub fn extra_quad_vertices_clipped(
+ w: &dyn crate::widget::WidgetHost,
+ qx: f32, qy: f32, qw: f32, qh: f32,
+ sw: f32, sh: f32,
+ qc: [f32; 4],
+ clip: (f32, f32, f32, f32),
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ push_extra_quad_vertices_clipped(w, qx, qy, qw, qh, sw, sh, qc, clip, clip_circle, &mut verts);
+ verts
+}
+
+pub fn push_extra_quad_vertices_clipped(
+ w: &dyn crate::widget::WidgetHost,
+ qx: f32, qy: f32, qw: f32, qh: f32,
+ sw: f32, sh: f32,
+ qc: [f32; 4],
+ clip: (f32, f32, f32, f32),
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
+ if graph.is_node_rect(qx, qy, qw, qh) {
+ let r = crate::layout::graph_node_corner_radius();
+ let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
+ push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
+ return;
+ }
+ }
+
+ let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
+ let radii = target_w.corner_radii();
+ if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
+ let (cx0, cy0, cx1, cy1) = clip;
+ let ix0 = qx.max(cx0);
+ let iy0 = qy.max(cy0);
+ let ix1 = (qx + qw).min(cx1);
+ let iy1 = (qy + qh).min(cy1);
+ if ix1 <= ix0 || iy1 <= iy0 {
+ return;
+ }
+ out.extend_from_slice(&quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, sw, sh, qc, clip_circle));
+ if let Some((color, thickness)) = target_w.solid_border() {
+ let (wx, wy, ww, wh) = target_w.rect();
+ if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
+ push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
+ }
+ }
+ return;
+ }
+
+ let (wx, mut wy, ww, mut wh) = target_w.rect();
+ let top_room = target_w.label_strip();
+ wy += top_room;
+ wh -= top_room;
+ let extra_radii = crate::widget::CornerRadii::new(
+ if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
+ if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
+ if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
+ if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
+ );
+
+ push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
+
+ if let Some((color, thickness)) = target_w.solid_border() {
+ let (rx, mut ry, rw, mut rh) = target_w.rect();
+ let top = target_w.label_strip();
+ ry += top;
+ rh -= top;
+ if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
+ push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
+ }
+ }
+}
+
+pub fn circle_vertices(
+ cx: f32, cy: f32, r: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ segments: usize,
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ for i in 0..segments {
+ let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
+ let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
+ let x0 = cx;
+ let y0 = cy;
+ let x1 = cx + r * theta1.cos();
+ let y1 = cy + r * theta1.sin();
+ let x2 = cx + r * theta2.cos();
+ let y2 = cy + r * theta2.sin();
+
+ let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
+ let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
+ let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
+ let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
+ let ndc_x2 = (x2 / sw) * 2.0 - 1.0;
+ let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
+
+ verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
+ }
+ verts
+}
+
+pub fn circle_border_vertices(
+ cx: f32, cy: f32, r: f32,
+ thickness: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ segments: usize,
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ for i in 0..segments {
+ let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
+ let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
+
+ let x0 = cx + (r - thickness) * theta1.cos();
+ let y0 = cy + (r - thickness) * theta1.sin();
+ let x1 = cx + r * theta1.cos();
+ let y1 = cy + r * theta1.sin();
+
+ let x2 = cx + r * theta2.cos();
+ let y2 = cy + r * theta2.sin();
+ let x3 = cx + (r - thickness) * theta2.cos();
+ let y3 = cy + (r - thickness) * theta2.sin();
+
+ let ndc_x0 = (x0 / sw) * 2.0 - 1.0; let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
+ let ndc_x1 = (x1 / sw) * 2.0 - 1.0; let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
+ let ndc_x2 = (x2 / sw) * 2.0 - 1.0; let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
+ let ndc_x3 = (x3 / sw) * 2.0 - 1.0; let ndc_y3 = 1.0 - (y3 / sh) * 2.0;
+
+ verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
+
+ verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
+ verts.push(Vertex { position: [ndc_x3, ndc_y3], color, clip_circle });
+ }
+ verts
+}
+
+pub fn arc_background_vertices(
+ cx: f32, cy: f32, r: f32,
+ thickness: f32,
+ start_angle: f32, end_angle: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ segments: usize,
+ clip_circle: [f32; 3],
+) -> Vec<Vertex> {
+ let mut verts = Vec::new();
+ push_arc_background_vertices(cx, cy, r, thickness, start_angle, end_angle, sw, sh, color, segments, clip_circle, &mut verts);
+ verts
+}
+
+/// A ring band with radial Gouraud shading: two sub-bands (inner rim → crest
+/// centerline, crest → outer rim) whose vertex colors interpolate across the
+/// stroke — the rounded-bevel profile — plus the half-px alpha feathers at
+/// both true rims (colors matched to the adjacent band, so no seams).
+#[allow(clippy::too_many_arguments)]
+pub fn push_arc_shaded_vertices(
+ cx: f32, cy: f32, r: f32,
+ thickness: f32,
+ start_angle: f32, end_angle: f32,
+ sw: f32, sh: f32,
+ inner: [f32; 4], crest: [f32; 4], outer: [f32; 4],
+ segments: usize,
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ let f = 0.5f32.min(thickness * 0.25);
+ let r_out = r;
+ let r_in = (r - thickness).max(0.0);
+ let r_mid = (r_in + r_out) / 2.0;
+ let fade_in = [inner[0], inner[1], inner[2], 0.0];
+ let fade_out = [outer[0], outer[1], outer[2], 0.0];
+ // (inner radius, outer radius, color at inner edge, color at outer edge)
+ let bands = [
+ ((r_in - f).max(0.0), r_in + f, fade_in, inner),
+ (r_in + f, r_mid, inner, crest),
+ (r_mid, r_out - f, crest, outer),
+ (r_out - f, r_out + f, outer, fade_out),
+ ];
+ for i in 0..segments {
+ let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
+ let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
+ let (c1, s1) = (theta1.cos(), theta1.sin());
+ let (c2, s2) = (theta2.cos(), theta2.sin());
+ for &(ra, rb, ca, cb) in &bands {
+ if rb <= ra {
+ continue;
+ }
+ let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
+ [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
+ };
+ let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
+ let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
+ out.push(Vertex { position: i1, color: ca, clip_circle });
+ out.push(Vertex { position: o1, color: cb, clip_circle });
+ out.push(Vertex { position: o2, color: cb, clip_circle });
+ out.push(Vertex { position: i1, color: ca, clip_circle });
+ out.push(Vertex { position: o2, color: cb, clip_circle });
+ out.push(Vertex { position: i2, color: ca, clip_circle });
+ }
+ }
+}
+
+pub fn push_arc_background_vertices(
+ cx: f32, cy: f32, r: f32,
+ thickness: f32,
+ start_angle: f32, end_angle: f32,
+ sw: f32, sh: f32,
+ color: [f32; 4],
+ segments: usize,
+ clip_circle: [f32; 3],
+ out: &mut Vec<Vertex>,
+) {
+ // The stroke band [r - thickness, r], with a half-px alpha ramp on each rim
+ // (Gouraud across thin edge bands) so curved edges resolve smoothly instead
+ // of hard-stepping — the poor-man's AA the flat pipeline doesn't provide.
+ let f = 0.5f32.min(thickness * 0.25);
+ let r_in = (r - thickness).max(0.0);
+ // (inner radius, outer radius, alpha at inner rim, alpha at outer rim)
+ let bands = [
+ ((r_in - f).max(0.0), r_in + f, 0.0, color[3]),
+ (r_in + f, r - f, color[3], color[3]),
+ (r - f, r + f, color[3], 0.0),
+ ];
+ for i in 0..segments {
+ let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
+ let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
+ let (c1, s1) = (theta1.cos(), theta1.sin());
+ let (c2, s2) = (theta2.cos(), theta2.sin());
+ for &(ra, rb, aa, ab) in &bands {
+ if rb <= ra {
+ continue;
+ }
+ let ca = [color[0], color[1], color[2], aa];
+ let cb = [color[0], color[1], color[2], ab];
+ let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
+ [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
+ };
+ let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
+ let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
+ out.push(Vertex { position: i1, color: ca, clip_circle });
+ out.push(Vertex { position: o1, color: cb, clip_circle });
+ out.push(Vertex { position: o2, color: cb, clip_circle });
+ out.push(Vertex { position: i1, color: ca, clip_circle });
+ out.push(Vertex { position: o2, color: cb, clip_circle });
+ out.push(Vertex { position: i2, color: ca, clip_circle });
+ }
+ }
+}
+
+// `all(test, debug_assertions)`: the function under test only exists in
+// debug builds, so a `cargo test --release` must compile the module out too.
+#[cfg(all(test, debug_assertions))]
+mod near_roll_fallback_tests {
+ use super::near_roll_fallback_reason;
+ use crate::scene::layout::Rect;
+
+ fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
+ Rect { x, y, width: w, height: h }
+ }
+
+ const HOST: Rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
+ const ROLL: f32 = 8.0;
+
+ #[test]
+ fn interior_carve_is_quiet() {
+ // Well inside the deflated host: the overlay fallback is exact there.
+ let carve = r(100.0, 100.0, 200.0, 100.0);
+ assert_eq!(near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false), None);
+ }
+
+ #[test]
+ fn shaded_region_reaching_the_roll_is_loud() {
+ // Carve rect stops 3px short of the roll band, but its shaded region
+ // (depth*0.5 + 2 = 5px) crosses in — the inflation must count.
+ let carve = r(ROLL + 3.0, 100.0, 200.0, 100.0);
+ assert_eq!(
+ near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false),
+ Some("the host's feature run is closed (another plate appended features since)")
+ );
+ }
+
+ #[test]
+ fn occlusion_is_named_before_run_contiguity() {
+ let carve = r(2.0, 100.0, 200.0, 100.0);
+ let occluder = r(150.0, 150.0, 100.0, 100.0);
+ assert_eq!(
+ near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], false),
+ Some("a later plate overlaps the carve's shaded region")
+ );
+ }
+
+ #[test]
+ fn non_overlapping_later_plate_is_not_occlusion() {
+ let carve = r(2.0, 100.0, 200.0, 100.0);
+ let elsewhere = r(500.0, 400.0, 100.0, 100.0);
+ assert_eq!(
+ near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[elsewhere], false),
+ Some("the host's feature run is closed (another plate appended features since)")
+ );
+ }
+
+ #[test]
+ fn budget_wins_over_every_other_reason() {
+ let carve = r(2.0, 100.0, 200.0, 100.0);
+ let occluder = r(150.0, 150.0, 100.0, 100.0);
+ assert_eq!(
+ near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], true),
+ Some("the feature budget is full")
+ );
+ }
+}
diff --git a/src/backend/text.rs b/src/backend/text.rs
new file mode 100644
index 0000000..9d98bda
--- /dev/null
+++ b/src/backend/text.rs
@@ -0,0 +1,528 @@
+//! Text shaping for the runner: the shaped-buffer cache, family resolution,
+//! the display list's text prims gathered for the glyph pass, and the
+//! popover-occlusion clamp. Platform-neutral — nothing here knows the window
+//! system; moved out of `window_runner` so another shell can share it.
+
+use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
+use crate::widget::TextItem;
+use crate::vk::TextSpan;
+
+#[derive(Hash, PartialEq, Eq, Clone)]
+struct BufferCacheKey {
+ text: String,
+ size_milli: u32,
+ font: Option<String>,
+ is_vertical: bool,
+ attrs: crate::scene::paint::TextAttrs,
+}
+
+#[derive(Clone)]
+struct CachedBuffer {
+ buffer: Buffer,
+ last_accessed: std::time::Instant,
+}
+
+std::thread_local! {
+ static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<BufferCacheKey, CachedBuffer>> = std::cell::RefCell::new(std::collections::HashMap::new());
+}
+
+fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
+ let lower_name = name.to_lowercase();
+ for face in fs.db().faces() {
+ for (family, _) in &face.families {
+ if family.to_lowercase() == lower_name {
+ return Some(family.clone());
+ }
+ }
+ }
+ None
+}
+
+thread_local! {
+ /// Family name → is-monospaced, resolved once per family from fontdb's
+ /// face metadata (the post table's isFixedPitch, as fontdb records it).
+ static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
+ std::cell::RefCell::new(std::collections::HashMap::new());
+}
+
+fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
+ MONO_FAMILY_CACHE.with(|cache| {
+ if let Some(&mono) = cache.borrow().get(name) {
+ return mono;
+ }
+ let lower = name.to_lowercase();
+ let mono = fs
+ .db()
+ .faces()
+ .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
+ .map(|face| face.monospaced)
+ .unwrap_or(false);
+ cache.borrow_mut().insert(name.to_string(), mono);
+ mono
+ })
+}
+
+/// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
+/// shapes `Basic`, everything else `Advanced`.
+///
+/// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
+/// mono face that is exactly right: a mono font's ligatures are the one thing
+/// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
+/// squeezes f+i into a single-advance fi glyph, which is why the bar's window
+/// titles rendered "file" with a cramped fi — while mono faces carry no
+/// kerning to lose. Proportional faces keep `Advanced` (their kerning and
+/// ligatures are wanted — a font preview must not misrepresent the face), and
+/// any non-ASCII text keeps real shaping (combining marks, emoji, complex
+/// scripts) whatever the face.
+pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
+ if text.is_ascii() {
+ if let cosmic_text::Family::Name(name) = family {
+ if family_is_monospaced(fs, name) {
+ return cosmic_text::Shaping::Basic;
+ }
+ }
+ }
+ cosmic_text::Shaping::Advanced
+}
+
+pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
+ get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
+}
+
+/// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
+/// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
+pub fn get_text_buffer_attrs(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+ text_attrs: crate::scene::paint::TextAttrs,
+) -> Buffer {
+ let scale = crate::scale::scale_factor();
+ let mut font_size = size;
+ let mut family_name = None;
+
+ if let Some(font_str) = font {
+ let (parsed_family, parsed_size) = crate::layout::parse_font_string(font_str);
+ if let Some(ps) = parsed_size {
+ font_size = ps;
+ }
+ family_name = Some(parsed_family);
+ }
+
+ let physical_size = font_size * scale;
+ let size_key = (physical_size * 1000.0).round() as u32;
+ let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
+ let key = BufferCacheKey {
+ text: text.to_string(),
+ size_milli: size_key,
+ font: family_name.clone(),
+ is_vertical,
+ attrs: text_attrs,
+ };
+
+ let cached = BUFFER_CACHE.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ if let Some(cached_item) = cache.get_mut(&key) {
+ cached_item.last_accessed = std::time::Instant::now();
+ Some(cached_item.buffer.clone())
+ } else {
+ None
+ }
+ });
+
+ if let Some(buf) = cached {
+ return buf;
+ }
+
+ let line_height = if is_vertical {
+ physical_size * 1.05
+ } else {
+ physical_size * 1.0
+ };
+ let metrics = Metrics::new(physical_size, line_height);
+ let mut buf = Buffer::new(fs, metrics);
+ let mut attrs = Attrs::new();
+
+ let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
+
+ let resolved_storage = family_name.as_deref().and_then(|font_name| match font_name {
+ "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
+ "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
+ "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
+ _ => None,
+ });
+
+ let resolved_sans = if !sans_fallback.is_empty() {
+ find_cased_family(fs, &sans_fallback)
+ } else {
+ None
+ };
+
+ let family = if let Some(ref font_family) = family_name {
+ match font_family.as_str() {
+ "monospace" => {
+ if !mono_fallback.is_empty() {
+ if let Some(ref cased) = resolved_storage {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
+ }
+ } else {
+ cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
+ }
+ }
+ "sans-serif" => {
+ if !sans_fallback.is_empty() {
+ if let Some(ref cased) = resolved_storage {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ }
+ "serif" => {
+ if !serif_fallback.is_empty() {
+ if let Some(ref cased) = resolved_storage {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::Serif
+ }
+ } else {
+ cosmic_text::Family::Serif
+ }
+ }
+ name => cosmic_text::Family::Name(name),
+ }
+ } else {
+ if !sans_fallback.is_empty() {
+ if let Some(ref cased) = resolved_sans {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ };
+ attrs = attrs.family(family);
+ if text_attrs.italic {
+ attrs = attrs.style(cosmic_text::Style::Italic);
+ }
+ if let Some(w) = text_attrs.weight {
+ attrs = attrs.weight(cosmic_text::Weight(w));
+ }
+ let shaping = shaping_for(fs, text, &family);
+ buf.set_text(fs, text, attrs, shaping);
+ buf.shape_until_scroll(fs, true);
+
+ BUFFER_CACHE.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ if cache.len() >= 2000 {
+ let mut items: Vec<(BufferCacheKey, std::time::Instant)> = cache
+ .iter()
+ .map(|(k, v)| (k.clone(), v.last_accessed))
+ .collect();
+ items.sort_by_key(|&(_, time)| time);
+ for (k, _) in items.iter().take(100) {
+ cache.remove(k);
+ }
+ }
+ cache.insert(key, CachedBuffer {
+ buffer: buf.clone(),
+ last_accessed: std::time::Instant::now(),
+ });
+ });
+
+ buf
+}
+
+/// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
+/// same buffer cache as the draw, so this is a lookup when the text is already on screen.
+/// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
+/// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
+/// source for caret placement and click→cursor mapping in hand-rolled text fields:
+/// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
+/// resolution, which disagrees with cosmic-text's advance and can even resolve a
+/// different face — a caret placed with it drifts off the drawn glyphs.
+pub fn shaped_cluster_offsets(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+) -> Vec<(usize, f32)> {
+ let scale = crate::scale::scale_factor().max(1.0);
+ let buffer = get_text_buffer(fs, text, size, font);
+ let mut out: Vec<(usize, f32)> = Vec::new();
+ let mut total: f32 = 0.0;
+ for (start, x, w) in normalized_glyph_starts(&buffer, text) {
+ if out.last().map_or(true, |&(b, _)| b != start) {
+ out.push((start, x / scale));
+ }
+ total = total.max((x + w) / scale);
+ }
+ out.push((text.len(), total));
+ out
+}
+
+/// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
+/// with `start` normalized to be text-relative.
+///
+/// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
+/// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
+/// word — while the Advanced path emits line-relative starts. `shaping_for`
+/// picks Basic exactly for ASCII text in a monospace family (the DE's default
+/// control font), so any multi-word value hit the bug: offsets keyed by those
+/// starts collide on the low columns and the caret/selection walk off the
+/// glyphs. A reset can ONLY come from that path, which shapes strictly one
+/// glyph per char in logical order — so when one is seen, byte starts are
+/// rebuilt by walking the text's chars. `text` must be the single line the
+/// buffer was shaped from.
+pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
+ let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
+ let mut monotonic = true;
+ let mut prev = 0usize;
+ for run in buffer.layout_runs() {
+ for g in run.glyphs {
+ if g.start < prev {
+ monotonic = false;
+ }
+ prev = g.start;
+ glyphs.push((g.start, g.x, g.w));
+ }
+ }
+ if !monotonic {
+ let mut starts = text.char_indices().map(|(i, _)| i);
+ for g in glyphs.iter_mut() {
+ g.0 = starts.next().unwrap_or(text.len());
+ }
+ }
+ glyphs
+}
+
+/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
+/// Reuses [`get_text_buffer_attrs`] for all the family resolution — that returns a *clone* of the
+/// cached single-run buffer, so re-applying metrics/size/align here does not touch the cache — then
+/// re-lays-it-out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
+/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside the
+/// box per `align_v`. Uncached by construction (each box may differ in width/align).
+pub fn get_text_buffer_laid_out(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+ text_attrs: crate::scene::paint::TextAttrs,
+ layout: crate::scene::paint::TextLayout,
+) -> (Buffer, f32) {
+ use crate::scene::paint::{AlignH, AlignV};
+ let scale = crate::scale::scale_factor();
+
+ // Resolved family + attrs come for free (a cache clone we are free to mutate).
+ let mut buf = get_text_buffer_attrs(fs, text, size, font, text_attrs);
+
+ // The font string may override the size ("family:size") — mirror get_text_buffer_attrs.
+ let mut font_size = size;
+ if let Some(font_str) = font {
+ if let (_, Some(ps)) = crate::layout::parse_font_string(font_str) {
+ font_size = ps;
+ }
+ }
+ let physical_size = font_size * scale;
+ let line_height = physical_size * 1.4;
+ buf.set_metrics(fs, Metrics::new(physical_size, line_height));
+ buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
+
+ let align = match layout.align_h {
+ AlignH::Left => cosmic_text::Align::Left,
+ AlignH::Center => cosmic_text::Align::Center,
+ AlignH::Right => cosmic_text::Align::Right,
+ };
+ for line in &mut buf.lines {
+ line.set_align(Some(align));
+ }
+ buf.shape_until_scroll(fs, true);
+
+ // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
+ let runs = buf.layout_runs().count();
+ let total_h = runs as f32 * font_size * 1.4;
+ let voff = match layout.align_v {
+ AlignV::Top => 0.0,
+ AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
+ AlignV::Bottom => (layout.box_height - total_h).max(0.0),
+ };
+ (buf, voff)
+}
+
+/// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
+/// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
+/// it is defined here now that the dependency is cosmic-text directly. Same plain
+/// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TextBounds {
+ pub left: i32,
+ pub top: i32,
+ pub right: i32,
+ pub bottom: i32,
+}
+
+/// The display list's Text prims, shaped through the shared buffer cache and
+/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
+/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
+/// logical space. Shared by the window's frame and the context-menu popup's.
+pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<TextItem>) {
+ for item in &dl.items {
+ if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
+ let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
+ let merged = match (clip, *bounds) {
+ (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
+ (Some(a), None) => Some(a),
+ (None, b) => b,
+ };
+ // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
+ // offset; ordinary labels take the shared cached buffer.
+ let (buffer, y_off) = match layout {
+ Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
+ None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
+ };
+ out.push(TextItem {
+ buffer,
+ x: *x,
+ y: *y + y_off,
+ color: cosmic_text::Color::rgba(
+ color[0],
+ color[1],
+ color[2],
+ (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
+ ),
+ bounds: merged,
+ clip_circle: item.clip_circle,
+ clip_rrect: item.clip_rrect,
+ });
+ }
+ }
+}
+
+/// The glyph pass's spans for `items`: each clamped to the surface and its
+/// own bounds, then by the popover-occlusion clamp against `overlays`.
+pub(crate) fn dl_text_spans<'a>(
+ items: &'a [TextItem],
+ scale_f32: f32,
+ bounds: TextBounds,
+ overlays: &[(f32, f32, f32, f32)],
+) -> Vec<TextSpan<'a>> {
+ let mut spans: Vec<TextSpan<'a>> = Vec::new();
+ for ti in items {
+ let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
+ TextBounds {
+ left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
+ top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
+ right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
+ bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
+ }
+ } else {
+ bounds
+ };
+ popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
+ spans.push(TextSpan {
+ buffer: &ti.buffer,
+ left: (ti.x * scale_f32).round(),
+ top: (ti.y * scale_f32).round(),
+ // Buffers are shaped at physical size (get_text_buffer_attrs).
+ scale: 1.0,
+ bounds: Some([
+ item_bounds.left,
+ item_bounds.top,
+ item_bounds.right,
+ item_bounds.bottom,
+ ]),
+ default_color: [
+ ti.color.r() as f32 / 255.0,
+ ti.color.g() as f32 / 255.0,
+ ti.color.b() as f32 / 255.0,
+ ti.color.a() as f32 / 255.0,
+ ],
+ rotation: None,
+ // Circle wins when both are set (the circular pane's innermost clip);
+ // otherwise a rounded-rect clip rides as center+radius with extents.
+ clip_circle: match (ti.clip_circle, ti.clip_rrect) {
+ (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
+ (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
+ (None, None) => [0.0; 3],
+ },
+ clip_extents: match (ti.clip_circle, ti.clip_rrect) {
+ (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
+ _ => [0.0; 2],
+ },
+ });
+ }
+ spans
+}
+
+/// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
+/// the display-list text path: clip a text item's bounds so it does not bleed through an open
+/// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
+/// text and is left alone; anything else that intersects gets clamped horizontally toward
+/// whichever side of the popover it starts on.
+/// Clamp a text item's bounds away from the registered popover rects it
+/// runs under, so page text does not bleed through a floating plate.
+///
+/// A text item BELONGS to a popover when it carries exactly that popover's
+/// rect as its bounds (the convention every popover's own labels follow),
+/// and it is then clamped only against the popovers registered AFTER its
+/// own — `overlay_rects` is in stacking order, the shared context menu
+/// last. Before 2026-09-22 a popover's text was exempt from its own rect
+/// alone and clamped against every other, so a context menu opened over a
+/// modal dialog had its labels clipped by the dialog it was drawn on top
+/// of, and showed as a plate with no legible entries.
+fn popover_occlusion_clamp(
+ overlay_rects: &[(f32, f32, f32, f32)],
+ ti: &TextItem,
+ scale_f32: f32,
+ item_bounds: &mut TextBounds,
+) {
+ let owner = ti.bounds.and_then(|[l, t, r, b]| {
+ overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
+ (l - ox).abs() < 1.0
+ && (t - oy).abs() < 1.0
+ && (r - (ox + ow)).abs() < 1.0
+ && (b - (oy + oh)).abs() < 1.0
+ })
+ });
+ let first_above = owner.map_or(0, |k| k + 1);
+ for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
+ let ol = (ox * scale_f32).round() as i32;
+ let ot = (oy * scale_f32).round() as i32;
+ let or = ((ox + ow) * scale_f32).round() as i32;
+ let ob = ((oy + oh) * scale_f32).round() as i32;
+
+ let tx_pixel = ti.x * scale_f32;
+ let ty_pixel = ti.y * scale_f32;
+
+ let mut text_w = 0.0f32;
+ let mut run_count = 0;
+ for run in ti.buffer.layout_runs() {
+ text_w = text_w.max(run.line_w);
+ run_count += 1;
+ }
+ let text_h = run_count as f32 * ti.buffer.metrics().line_height;
+
+ let actual_left = tx_pixel;
+ let actual_right = tx_pixel + text_w;
+ let actual_top = ty_pixel;
+ let actual_bottom = ty_pixel + text_h;
+
+ if actual_left < or as f32
+ && actual_right > ol as f32
+ && actual_top < ob as f32
+ && actual_bottom > ot as f32
+ {
+ if tx_pixel < ol as f32 {
+ item_bounds.right = item_bounds.right.min(ol);
+ } else {
+ item_bounds.left = item_bounds.left.max(or);
+ }
+ }
+ }
+}
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 798b53d..36437e9 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -36,3930 +36,14 @@ pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_to
pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
use calloop::EventLoop;
use calloop_wayland_source::WaylandSource;
-use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
-use crate::widget::{WidgetHost, TextItem, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
+use cosmic_text::FontSystem;
+use crate::widget::{TextItem, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
use crate::wayland::detect_scale_factor;
-use crate::vk::{Batch2D, Frame2D, TextSpan, VkRenderer};
-
-#[derive(Hash, PartialEq, Eq, Clone)]
-struct BufferCacheKey {
- text: String,
- size_milli: u32,
- font: Option<String>,
- is_vertical: bool,
- attrs: crate::scene::paint::TextAttrs,
-}
-
-#[derive(Clone)]
-struct CachedBuffer {
- buffer: Buffer,
- last_accessed: std::time::Instant,
-}
-
-std::thread_local! {
- static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<BufferCacheKey, CachedBuffer>> = std::cell::RefCell::new(std::collections::HashMap::new());
-}
-
-/// A droplet spec resolved against a concrete rect: the push-constant fields
-/// that define its SILHOUETTE, in logical px.
-///
-/// Shared by [`crate::scene::paint::Prim::Droplet`] and
-/// [`crate::scene::paint::Prim::DropletScrim`] so the lit drop and the vignette
-/// drawn inside it can never disagree about the shape — the whole reason the
-/// scrim rides the droplet's shader path instead of approximating the outline
-/// with a rounded rect.
-struct DropletGeom {
- hx: f32,
- hy: f32,
- sag: f32,
- br: f32,
- bw: f32,
- k: f32,
- sr: f32,
- ar: f32,
- band: f32,
- bow: f32,
- /// How far the contact shadow reaches below/beside the box (0 when the
- /// spec has no shadow). The lit drop's cover quad grows by this; a scrim
- /// never draws outside the silhouette and ignores it.
- sh_reach: f32,
-}
-
-fn droplet_geom(rect: &crate::scene::layout::Rect, spec: &crate::scene::paint::DropletSpec) -> DropletGeom {
- let hx = rect.width * 0.5;
- let hy = rect.height * 0.5;
- let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
- // belly <= 0 disables the belly outright (the oval-dewdrop default) — the
- // shader skips the smin when the radius is 0.
- let (br, bw) = if spec.belly > 0.0 {
- let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
- (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
- } else {
- (0.0, 0.0)
- };
- let k = (spec.blend.max(0.0) * rect.height).max(1.0);
- let sheet_hy = hy - sag * 0.5;
- // Bottom (sheet_r) and top (attach) corner radii: when the pair overfills
- // the sheet height, scale both down proportionally — 0.5 + 0.5 is the
- // fully continuous egg.
- let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
- let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
- let sheet_h = (2.0 * sheet_hy).max(0.0);
- if sr + ar > sheet_h && sr + ar > 0.0 {
- let f = sheet_h / (sr + ar);
- sr *= f;
- ar *= f;
- }
- let band = (spec.band.max(0.05) * rect.height).max(1.0);
- // Bottom-bow edge rise; the shader derives the arc radius from it per drop
- // (R = hx^2/2*rise).
- let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
- let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
- DropletGeom { hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach }
-}
-
-fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
- let lower_name = name.to_lowercase();
- for face in fs.db().faces() {
- for (family, _) in &face.families {
- if family.to_lowercase() == lower_name {
- return Some(family.clone());
- }
- }
- }
- None
-}
-
-thread_local! {
- /// Family name → is-monospaced, resolved once per family from fontdb's
- /// face metadata (the post table's isFixedPitch, as fontdb records it).
- static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
- std::cell::RefCell::new(std::collections::HashMap::new());
-}
-
-fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
- MONO_FAMILY_CACHE.with(|cache| {
- if let Some(&mono) = cache.borrow().get(name) {
- return mono;
- }
- let lower = name.to_lowercase();
- let mono = fs
- .db()
- .faces()
- .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
- .map(|face| face.monospaced)
- .unwrap_or(false);
- cache.borrow_mut().insert(name.to_string(), mono);
- mono
- })
-}
-
-/// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
-/// shapes `Basic`, everything else `Advanced`.
-///
-/// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
-/// mono face that is exactly right: a mono font's ligatures are the one thing
-/// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
-/// squeezes f+i into a single-advance fi glyph, which is why the bar's window
-/// titles rendered "file" with a cramped fi — while mono faces carry no
-/// kerning to lose. Proportional faces keep `Advanced` (their kerning and
-/// ligatures are wanted — a font preview must not misrepresent the face), and
-/// any non-ASCII text keeps real shaping (combining marks, emoji, complex
-/// scripts) whatever the face.
-pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
- if text.is_ascii() {
- if let cosmic_text::Family::Name(name) = family {
- if family_is_monospaced(fs, name) {
- return cosmic_text::Shaping::Basic;
- }
- }
- }
- cosmic_text::Shaping::Advanced
-}
-
-pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
- get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
-}
-
-/// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
-/// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
-pub fn get_text_buffer_attrs(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
- text_attrs: crate::scene::paint::TextAttrs,
-) -> Buffer {
- let scale = crate::scale::scale_factor();
- let mut font_size = size;
- let mut family_name = None;
-
- if let Some(font_str) = font {
- let (parsed_family, parsed_size) = crate::layout::parse_font_string(font_str);
- if let Some(ps) = parsed_size {
- font_size = ps;
- }
- family_name = Some(parsed_family);
- }
-
- let physical_size = font_size * scale;
- let size_key = (physical_size * 1000.0).round() as u32;
- let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
- let key = BufferCacheKey {
- text: text.to_string(),
- size_milli: size_key,
- font: family_name.clone(),
- is_vertical,
- attrs: text_attrs,
- };
-
- let cached = BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- if let Some(cached_item) = cache.get_mut(&key) {
- cached_item.last_accessed = std::time::Instant::now();
- Some(cached_item.buffer.clone())
- } else {
- None
- }
- });
-
- if let Some(buf) = cached {
- return buf;
- }
-
- let line_height = if is_vertical {
- physical_size * 1.05
- } else {
- physical_size * 1.0
- };
- let metrics = Metrics::new(physical_size, line_height);
- let mut buf = Buffer::new(fs, metrics);
- let mut attrs = Attrs::new();
-
- let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
-
- let resolved_storage = family_name.as_deref().and_then(|font_name| match font_name {
- "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
- "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
- "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
- _ => None,
- });
-
- let resolved_sans = if !sans_fallback.is_empty() {
- find_cased_family(fs, &sans_fallback)
- } else {
- None
- };
-
- let family = if let Some(ref font_family) = family_name {
- match font_family.as_str() {
- "monospace" => {
- if !mono_fallback.is_empty() {
- if let Some(ref cased) = resolved_storage {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
- }
- } else {
- cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
- }
- }
- "sans-serif" => {
- if !sans_fallback.is_empty() {
- if let Some(ref cased) = resolved_storage {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::SansSerif
- }
- } else {
- cosmic_text::Family::SansSerif
- }
- }
- "serif" => {
- if !serif_fallback.is_empty() {
- if let Some(ref cased) = resolved_storage {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::Serif
- }
- } else {
- cosmic_text::Family::Serif
- }
- }
- name => cosmic_text::Family::Name(name),
- }
- } else {
- if !sans_fallback.is_empty() {
- if let Some(ref cased) = resolved_sans {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::SansSerif
- }
- } else {
- cosmic_text::Family::SansSerif
- }
- };
- attrs = attrs.family(family);
- if text_attrs.italic {
- attrs = attrs.style(cosmic_text::Style::Italic);
- }
- if let Some(w) = text_attrs.weight {
- attrs = attrs.weight(cosmic_text::Weight(w));
- }
- let shaping = shaping_for(fs, text, &family);
- buf.set_text(fs, text, attrs, shaping);
- buf.shape_until_scroll(fs, true);
-
- BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- if cache.len() >= 2000 {
- let mut items: Vec<(BufferCacheKey, std::time::Instant)> = cache
- .iter()
- .map(|(k, v)| (k.clone(), v.last_accessed))
- .collect();
- items.sort_by_key(|&(_, time)| time);
- for (k, _) in items.iter().take(100) {
- cache.remove(k);
- }
- }
- cache.insert(key, CachedBuffer {
- buffer: buf.clone(),
- last_accessed: std::time::Instant::now(),
- });
- });
-
- buf
-}
-
-/// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
-/// same buffer cache as the draw, so this is a lookup when the text is already on screen.
-/// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
-/// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
-/// source for caret placement and click→cursor mapping in hand-rolled text fields:
-/// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
-/// resolution, which disagrees with cosmic-text's advance and can even resolve a
-/// different face — a caret placed with it drifts off the drawn glyphs.
-pub fn shaped_cluster_offsets(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
-) -> Vec<(usize, f32)> {
- let scale = crate::scale::scale_factor().max(1.0);
- let buffer = get_text_buffer(fs, text, size, font);
- let mut out: Vec<(usize, f32)> = Vec::new();
- let mut total: f32 = 0.0;
- for (start, x, w) in normalized_glyph_starts(&buffer, text) {
- if out.last().map_or(true, |&(b, _)| b != start) {
- out.push((start, x / scale));
- }
- total = total.max((x + w) / scale);
- }
- out.push((text.len(), total));
- out
-}
-
-/// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
-/// with `start` normalized to be text-relative.
-///
-/// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
-/// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
-/// word — while the Advanced path emits line-relative starts. `shaping_for`
-/// picks Basic exactly for ASCII text in a monospace family (the DE's default
-/// control font), so any multi-word value hit the bug: offsets keyed by those
-/// starts collide on the low columns and the caret/selection walk off the
-/// glyphs. A reset can ONLY come from that path, which shapes strictly one
-/// glyph per char in logical order — so when one is seen, byte starts are
-/// rebuilt by walking the text's chars. `text` must be the single line the
-/// buffer was shaped from.
-pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
- let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
- let mut monotonic = true;
- let mut prev = 0usize;
- for run in buffer.layout_runs() {
- for g in run.glyphs {
- if g.start < prev {
- monotonic = false;
- }
- prev = g.start;
- glyphs.push((g.start, g.x, g.w));
- }
- }
- if !monotonic {
- let mut starts = text.char_indices().map(|(i, _)| i);
- for g in glyphs.iter_mut() {
- g.0 = starts.next().unwrap_or(text.len());
- }
- }
- glyphs
-}
-
-/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
-/// Reuses [`get_text_buffer_attrs`] for all the family resolution — that returns a *clone* of the
-/// cached single-run buffer, so re-applying metrics/size/align here does not touch the cache — then
-/// re-lays-it-out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
-/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside the
-/// box per `align_v`. Uncached by construction (each box may differ in width/align).
-pub fn get_text_buffer_laid_out(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
- text_attrs: crate::scene::paint::TextAttrs,
- layout: crate::scene::paint::TextLayout,
-) -> (Buffer, f32) {
- use crate::scene::paint::{AlignH, AlignV};
- let scale = crate::scale::scale_factor();
-
- // Resolved family + attrs come for free (a cache clone we are free to mutate).
- let mut buf = get_text_buffer_attrs(fs, text, size, font, text_attrs);
-
- // The font string may override the size ("family:size") — mirror get_text_buffer_attrs.
- let mut font_size = size;
- if let Some(font_str) = font {
- if let (_, Some(ps)) = crate::layout::parse_font_string(font_str) {
- font_size = ps;
- }
- }
- let physical_size = font_size * scale;
- let line_height = physical_size * 1.4;
- buf.set_metrics(fs, Metrics::new(physical_size, line_height));
- buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
-
- let align = match layout.align_h {
- AlignH::Left => cosmic_text::Align::Left,
- AlignH::Center => cosmic_text::Align::Center,
- AlignH::Right => cosmic_text::Align::Right,
- };
- for line in &mut buf.lines {
- line.set_align(Some(align));
- }
- buf.shape_until_scroll(fs, true);
-
- // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
- let runs = buf.layout_runs().count();
- let total_h = runs as f32 * font_size * 1.4;
- let voff = match layout.align_v {
- AlignV::Top => 0.0,
- AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
- AlignV::Bottom => (layout.box_height - total_h).max(0.0),
- };
- (buf, voff)
-}
-
-/// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
-/// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
-/// it is defined here now that the dependency is cosmic-text directly. Same plain
-/// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct TextBounds {
- pub left: i32,
- pub top: i32,
- pub right: i32,
- pub bottom: i32,
-}
-
-/// The display list's Text prims, shaped through the shared buffer cache and
-/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
-/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
-/// logical space. Shared by the window's frame and the context-menu popup's.
-pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<TextItem>) {
- for item in &dl.items {
- if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
- let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
- let merged = match (clip, *bounds) {
- (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
- (Some(a), None) => Some(a),
- (None, b) => b,
- };
- // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
- // offset; ordinary labels take the shared cached buffer.
- let (buffer, y_off) = match layout {
- Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
- None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
- };
- out.push(TextItem {
- buffer,
- x: *x,
- y: *y + y_off,
- color: cosmic_text::Color::rgba(
- color[0],
- color[1],
- color[2],
- (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
- ),
- bounds: merged,
- clip_circle: item.clip_circle,
- clip_rrect: item.clip_rrect,
- });
- }
- }
-}
-
-/// The glyph pass's spans for `items`: each clamped to the surface and its
-/// own bounds, then by the popover-occlusion clamp against `overlays`.
-pub(crate) fn dl_text_spans<'a>(
- items: &'a [TextItem],
- scale_f32: f32,
- bounds: TextBounds,
- overlays: &[(f32, f32, f32, f32)],
-) -> Vec<TextSpan<'a>> {
- let mut spans: Vec<TextSpan<'a>> = Vec::new();
- for ti in items {
- let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
- TextBounds {
- left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
- top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
- right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
- bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
- }
- } else {
- bounds
- };
- popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
- spans.push(TextSpan {
- buffer: &ti.buffer,
- left: (ti.x * scale_f32).round(),
- top: (ti.y * scale_f32).round(),
- // Buffers are shaped at physical size (get_text_buffer_attrs).
- scale: 1.0,
- bounds: Some([
- item_bounds.left,
- item_bounds.top,
- item_bounds.right,
- item_bounds.bottom,
- ]),
- default_color: [
- ti.color.r() as f32 / 255.0,
- ti.color.g() as f32 / 255.0,
- ti.color.b() as f32 / 255.0,
- ti.color.a() as f32 / 255.0,
- ],
- rotation: None,
- // Circle wins when both are set (the circular pane's innermost clip);
- // otherwise a rounded-rect clip rides as center+radius with extents.
- clip_circle: match (ti.clip_circle, ti.clip_rrect) {
- (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
- (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
- (None, None) => [0.0; 3],
- },
- clip_extents: match (ti.clip_circle, ti.clip_rrect) {
- (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
- _ => [0.0; 2],
- },
- });
- }
- spans
-}
-
-/// The tessellated display list's batches, scissors and rounded clips scaled
-/// to physical px.
-pub(crate) fn dl_batches_2d(dl_batches: &[DlBatch], scale_f32: f32) -> Vec<Batch2D> {
- dl_batches
- .iter()
- .map(|batch| Batch2D {
- scissor: batch.scissor.map(|clip| {
- (
- (clip.x * scale_f32).max(0.0) as u32,
- (clip.y * scale_f32).max(0.0) as u32,
- (clip.width * scale_f32) as u32,
- (clip.height * scale_f32) as u32,
- )
- }),
- clip_rrect: batch
- .clip_rrect
- .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
- start: batch.start,
- end: batch.end,
- plate: batch.plate,
- blur_behind: batch.blur_behind,
- })
- .collect()
-}
-
-/// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
-/// the display-list text path: clip a text item's bounds so it does not bleed through an open
-/// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
-/// text and is left alone; anything else that intersects gets clamped horizontally toward
-/// whichever side of the popover it starts on.
-/// Clamp a text item's bounds away from the registered popover rects it
-/// runs under, so page text does not bleed through a floating plate.
-///
-/// A text item BELONGS to a popover when it carries exactly that popover's
-/// rect as its bounds (the convention every popover's own labels follow),
-/// and it is then clamped only against the popovers registered AFTER its
-/// own — `overlay_rects` is in stacking order, the shared context menu
-/// last. Before 2026-09-22 a popover's text was exempt from its own rect
-/// alone and clamped against every other, so a context menu opened over a
-/// modal dialog had its labels clipped by the dialog it was drawn on top
-/// of, and showed as a plate with no legible entries.
-fn popover_occlusion_clamp(
- overlay_rects: &[(f32, f32, f32, f32)],
- ti: &TextItem,
- scale_f32: f32,
- item_bounds: &mut TextBounds,
-) {
- let owner = ti.bounds.and_then(|[l, t, r, b]| {
- overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
- (l - ox).abs() < 1.0
- && (t - oy).abs() < 1.0
- && (r - (ox + ow)).abs() < 1.0
- && (b - (oy + oh)).abs() < 1.0
- })
- });
- let first_above = owner.map_or(0, |k| k + 1);
- for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
- let ol = (ox * scale_f32).round() as i32;
- let ot = (oy * scale_f32).round() as i32;
- let or = ((ox + ow) * scale_f32).round() as i32;
- let ob = ((oy + oh) * scale_f32).round() as i32;
-
- let tx_pixel = ti.x * scale_f32;
- let ty_pixel = ti.y * scale_f32;
-
- let mut text_w = 0.0f32;
- let mut run_count = 0;
- for run in ti.buffer.layout_runs() {
- text_w = text_w.max(run.line_w);
- run_count += 1;
- }
- let text_h = run_count as f32 * ti.buffer.metrics().line_height;
-
- let actual_left = tx_pixel;
- let actual_right = tx_pixel + text_w;
- let actual_top = ty_pixel;
- let actual_bottom = ty_pixel + text_h;
-
- if actual_left < or as f32
- && actual_right > ol as f32
- && actual_top < ob as f32
- && actual_bottom > ot as f32
- {
- if tx_pixel < ol as f32 {
- item_bounds.right = item_bounds.right.min(ol);
- } else {
- item_bounds.left = item_bounds.left.max(or);
- }
- }
- }
-}
-
-#[repr(C)]
-#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-pub struct Vertex {
- pub position: [f32; 2],
- pub color: [f32; 4],
- pub clip_circle: [f32; 3], // [cx, cy, r]
-}
-
-pub fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [f32; 4]) -> [Vertex; 6] {
- let x0 = (x / sw) * 2.0 - 1.0;
- let y0 = 1.0 - (y / sh) * 2.0;
- let x1 = ((x + w) / sw) * 2.0 - 1.0;
- let y1 = 1.0 - ((y + h) / sh) * 2.0;
- [
- Vertex { position: [x0, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x1, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
- ]
-}
-
-pub fn quad_vertices_with_clip(
- x: f32, y: f32, w: f32, h: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
-) -> [Vertex; 6] {
- let x0 = (x / sw) * 2.0 - 1.0;
- let y0 = 1.0 - (y / sh) * 2.0;
- let x1 = ((x + w) / sw) * 2.0 - 1.0;
- let y1 = 1.0 - ((y + h) / sh) * 2.0;
- [
- Vertex { position: [x0, y0], color, clip_circle },
- Vertex { position: [x1, y0], color, clip_circle },
- Vertex { position: [x0, y1], color, clip_circle },
- Vertex { position: [x1, y0], color, clip_circle },
- Vertex { position: [x1, y1], color, clip_circle },
- Vertex { position: [x0, y1], color, clip_circle },
- ]
-}
-
-/// A quad whose four corners each carry their own color, Gouraud-interpolated across both
-/// triangles by the shader (`@location(0) color` has no `flat` qualifier). Corner order is
-/// TL, TR, BR, BL. Keep the alpha equal on all four: negative alpha is the blur sentinel,
-/// so a gradient that crossed zero would tear the triangle in half.
-pub fn quad_vertices_shaded(
- x: f32, y: f32, w: f32, h: f32,
- sw: f32, sh: f32,
- c_tl: [f32; 4], c_tr: [f32; 4], c_br: [f32; 4], c_bl: [f32; 4],
- clip_circle: [f32; 3],
-) -> [Vertex; 6] {
- let x0 = (x / sw) * 2.0 - 1.0;
- let y0 = 1.0 - (y / sh) * 2.0;
- let x1 = ((x + w) / sw) * 2.0 - 1.0;
- let y1 = 1.0 - ((y + h) / sh) * 2.0;
- [
- Vertex { position: [x0, y0], color: c_tl, clip_circle },
- Vertex { position: [x1, y0], color: c_tr, clip_circle },
- Vertex { position: [x0, y1], color: c_bl, clip_circle },
- Vertex { position: [x1, y0], color: c_tr, clip_circle },
- Vertex { position: [x1, y1], color: c_br, clip_circle },
- Vertex { position: [x0, y1], color: c_bl, clip_circle },
- ]
-}
-
-pub fn quad_vertices_clipped(
- x: f32, y: f32, w: f32, h: f32,
- surface_w: f32, surface_h: f32,
- color: [f32; 4],
- clip: (f32, f32, f32, f32),
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let (cx0, cy0, cx1, cy1) = clip;
- let ix0 = x.max(cx0);
- let iy0 = y.max(cy0);
- let ix1 = (x + w).min(cx1);
- let iy1 = (y + h).min(cy1);
- if ix1 <= ix0 || iy1 <= iy0 {
- return Vec::new();
- }
- quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, surface_w, surface_h, color, clip_circle).to_vec()
-}
-
-pub fn line_vertices(
- x1: f32, y1: f32, x2: f32, y2: f32,
- thickness: f32,
- sw: f32, sh: f32,
- c: [f32; 4]
-) -> [Vertex; 6] {
- let dx = x2 - x1;
- let dy = y2 - y1;
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 {
- return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c);
- }
- let ux = dx / len;
- let uy = dy / len;
- let nx = -uy;
- let ny = ux;
-
- let half_t = thickness * 0.5;
- let p0x = x1 + nx * half_t;
- let p0y = y1 + ny * half_t;
- let p1x = x1 - nx * half_t;
- let p1y = y1 - ny * half_t;
- let p2x = x2 - nx * half_t;
- let p2y = y2 - ny * half_t;
- let p3x = x2 + nx * half_t;
- let p3y = y2 + ny * half_t;
-
- let ndc_p0x = (p0x / sw) * 2.0 - 1.0;
- let ndc_p0y = 1.0 - (p0y / sh) * 2.0;
- let ndc_p1x = (p1x / sw) * 2.0 - 1.0;
- let ndc_p1y = 1.0 - (p1y / sh) * 2.0;
- let ndc_p2x = (p2x / sw) * 2.0 - 1.0;
- let ndc_p2y = 1.0 - (p2y / sh) * 2.0;
- let ndc_p3x = (p3x / sw) * 2.0 - 1.0;
- let ndc_p3y = 1.0 - (p3y / sh) * 2.0;
-
- let clip_circle = [0.0, 0.0, 0.0];
- [
- Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
- Vertex { position: [ndc_p1x, ndc_p1y], color: c, clip_circle },
- Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
- Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
- Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
- Vertex { position: [ndc_p3x, ndc_p3y], color: c, clip_circle },
- ]
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
-pub enum LineCap {
- Arrow,
- Round,
- Flat,
-}
-
-pub fn vector_vertices(
- x1: f32, y1: f32, x2: f32, y2: f32,
- thickness: f32,
- sw: f32, sh: f32,
- c: [f32; 4],
- line_cap: LineCap,
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- let dx = x2 - x1;
- let dy = y2 - y1;
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 {
- return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c).to_vec();
- }
-
- match line_cap {
- LineCap::Arrow => {
- let ux = dx / len;
- let uy = dy / len;
- let nx = -uy;
- let ny = ux;
-
- let arrow_len = (thickness * 3.0).max(10.0).min(len);
- let arrow_width = (thickness * 2.5).max(8.0);
-
- let line_x2 = x2 - ux * arrow_len;
- let line_y2 = y2 - uy * arrow_len;
-
- if len > arrow_len {
- verts.extend_from_slice(&line_vertices(x1, y1, line_x2, line_y2, thickness, sw, sh, c));
- }
-
- let bx = line_x2;
- let by = line_y2;
-
- let w1x = bx + nx * (arrow_width * 0.5);
- let w1y = by + ny * (arrow_width * 0.5);
- let w2x = bx - nx * (arrow_width * 0.5);
- let w2y = by - ny * (arrow_width * 0.5);
-
- let ndc_tip_x = (x2 / sw) * 2.0 - 1.0;
- let ndc_tip_y = 1.0 - (y2 / sh) * 2.0;
- let ndc_w1x = (w1x / sw) * 2.0 - 1.0;
- let ndc_w1y = 1.0 - (w1y / sh) * 2.0;
- let ndc_w2x = (w2x / sw) * 2.0 - 1.0;
- let ndc_w2y = 1.0 - (w2y / sh) * 2.0;
-
- let clip_circle = [0.0, 0.0, 0.0];
- verts.push(Vertex { position: [ndc_tip_x, ndc_tip_y], color: c, clip_circle });
- verts.push(Vertex { position: [ndc_w1x, ndc_w1y], color: c, clip_circle });
- verts.push(Vertex { position: [ndc_w2x, ndc_w2y], color: c, clip_circle });
- }
- LineCap::Round => {
- push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
- let clip_circle = [0.0, 0.0, 0.0];
- verts.extend(circle_vertices(x2, y2, thickness / 2.0, sw, sh, c, 16, clip_circle));
- }
- LineCap::Flat => {
- push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
- }
- }
-
- verts
-}
-
-/// `line_vertices` with a half-px alpha ramp along each long edge (the arc
-/// tessellator's poor-man's AA) — diagonal strokes resolve smoothly instead of
-/// stair-stepping. Axis-aligned strokes keep the crisp single-quad path:
-/// feathering a pixel-snapped hairline would only blur it.
-fn push_feathered_line_vertices(
- x1: f32, y1: f32, x2: f32, y2: f32,
- thickness: f32,
- sw: f32, sh: f32,
- c: [f32; 4],
- out: &mut Vec<Vertex>,
-) {
- let dx = x2 - x1;
- let dy = y2 - y1;
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 || dx.abs() < 0.01 || dy.abs() < 0.01 {
- out.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
- return;
- }
- let (nx, ny) = (-dy / len, dx / len);
- let f = 0.5f32.min(thickness * 0.25);
- let half = thickness * 0.5;
- // (offset at band start, offset at band end, alpha at start, alpha at end)
- let bands = [
- (-half - f, -half + f, 0.0, c[3]),
- (-half + f, half - f, c[3], c[3]),
- (half - f, half + f, c[3], 0.0),
- ];
- for &(oa, ob, aa, ab) in &bands {
- let ca = [c[0], c[1], c[2], aa];
- let cb = [c[0], c[1], c[2], ab];
- let p = |x: f32, y: f32, o: f32| -> [f32; 2] {
- [((x + nx * o) / sw) * 2.0 - 1.0, 1.0 - ((y + ny * o) / sh) * 2.0]
- };
- let clip_circle = [0.0, 0.0, 0.0];
- let (a1, b1) = (p(x1, y1, oa), p(x1, y1, ob));
- let (a2, b2) = (p(x2, y2, oa), p(x2, y2, ob));
- out.push(Vertex { position: a1, color: ca, clip_circle });
- out.push(Vertex { position: b1, color: cb, clip_circle });
- out.push(Vertex { position: b2, color: cb, clip_circle });
- out.push(Vertex { position: a1, color: ca, clip_circle });
- out.push(Vertex { position: b2, color: cb, clip_circle });
- out.push(Vertex { position: a2, color: ca, clip_circle });
- }
-}
-
-pub fn rounded_rect_vertices_corners(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- corners: (bool, bool, bool, bool),
- clip_rect: Option<(f32, f32, f32, f32)>,
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- let radii = crate::widget::CornerRadii::new(
- if corners.0 { r } else { 0.0 },
- if corners.1 { r } else { 0.0 },
- if corners.2 { r } else { 0.0 },
- if corners.3 { r } else { 0.0 },
- );
- push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, color, clip_circle, clip_rect, &mut verts);
- verts
-}
-
-/// Sample of the unit superellipse |x|^n + |y|^n = 1 at circle parameter θ —
-/// the (cos θ, sin θ) replacement the corner fans use. Exactly the circle at
-/// n = 2; higher `corner_shape` exponents give the DE's continuous-curvature
-/// corners, so widget silhouettes follow the same corner family as the
-/// SDF-lit plates. `e` is 2/n, hoisted by callers. Tangent points at the
-/// quadrant ends are pinned to the exact axis points (see below), so fans
-/// tile exactly against the body rects and edge strips.
-#[inline]
-fn superellipse_pt(theta: f32, e: f32) -> (f32, f32) {
- let (s, c) = theta.sin_cos();
- // f32 sin/cos are not exactly 0 at a quadrant end (sin(PI) is -8.7e-8),
- // and the fractional power magnifies that noise by orders of magnitude:
- // at corner_shape 4.5 it is 7e-4, which on the desktop grid's 138 px
- // cell corners put the fan's tangent vertex 0.1 px short of the body
- // quad's edge. The fan's edge then tilts away from the quad's, and the
- // row of pixel centres between them is covered by neither: a stray
- // gap-coloured line 32 px long at every cell's left edge, and a lone
- // gap pixel on the bottom row where the arc meets the body. Snap the
- // ends to the exact axis points so fans tile against the rects.
- let axis = |v: f32| -> f32 {
- if v.abs() < 1e-6 {
- 0.0
- } else if v.abs() > 1.0 - 1e-6 {
- v.signum()
- } else {
- v.signum() * v.abs().powf(e)
- }
- };
- (axis(c), axis(s))
-}
-
-/// Feathered glow ([`Prim::Glow`]): the rounded rect's interior fills at the
-/// color's alpha and concentric outline rings fade it to zero across `reach`
-/// px outside the boundary. Alpha rides the VERTICES, so the GPU interpolates
-/// a per-pixel-smooth falloff between rings — stacked translucent layers band
-/// visibly; this cannot. Ring alphas sit on a quadratic ease-out, giving the
-/// vignette profile piecewise-linearly with kinks below visibility at glow
-/// alphas. Corners sample [`superellipse_pt`], so a glow's silhouette sits in
-/// the same corner family as the cells, nodes, and plates it highlights.
-pub fn push_glow_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- radius: f32, reach: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- if ww <= 0.0 || h <= 0.0 || color[3].abs() <= 0.0005 || sw <= 0.0 || sh <= 0.0 {
- return;
- }
- let r0 = radius.clamp(0.0, ww.min(h) * 0.5);
- let ctl = (x + r0, y + r0);
- let ctr = (x + ww - r0, y + r0);
- let cbr = (x + ww - r0, y + h - r0);
- let cbl = (x + r0, y + h - r0);
- const K: usize = 10;
- use std::f32::consts::PI;
- let corner_e = 2.0 / crate::layout::corner_shape();
- // One outline ring `off` px outside the boundary, clockwise from the
- // top-left arc; every ring shares the layout, so strips never twist.
- let ring = |off: f32| -> Vec<[f32; 2]> {
- let r = (r0 + off).max(0.0);
- let mut pts = Vec::with_capacity(4 * (K + 1));
- let corners = [
- (ctl, PI, 1.5 * PI),
- (ctr, 1.5 * PI, 2.0 * PI),
- (cbr, 0.0, 0.5 * PI),
- (cbl, 0.5 * PI, PI),
- ];
- for ((cx, cy), a0, a1) in corners {
- for k in 0..=K {
- let a = a0 + (a1 - a0) * (k as f32 / K as f32);
- let (ux, uy) = superellipse_pt(a, corner_e);
- pts.push([cx + r * ux, cy + r * uy]);
- }
- }
- pts
- };
- let to_v = |p: [f32; 2], a: f32| Vertex {
- position: [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0],
- color: [color[0], color[1], color[2], a],
- clip_circle,
- };
-
- let rings: Vec<(Vec<[f32; 2]>, f32)> = [0.0f32, 0.35, 0.7, 1.0]
- .iter()
- .map(|&t| (ring(reach * t), color[3] * (1.0 - t) * (1.0 - t)))
- .collect();
- let n = rings[0].0.len();
-
- // Interior: a fan from the rect center over the innermost ring (a rounded
- // rect is convex, so the fan covers it exactly), uniform core alpha.
- let center = [x + ww * 0.5, y + h * 0.5];
- for i in 0..n {
- let p1 = rings[0].0[i];
- let p2 = rings[0].0[(i + 1) % n];
- out.push(to_v(center, color[3]));
- out.push(to_v(p1, color[3]));
- out.push(to_v(p2, color[3]));
- }
- // The feather: strips between consecutive rings, each vertex carrying its
- // ring's alpha.
- for w in rings.windows(2) {
- let (inner, ia) = (&w[0].0, w[0].1);
- let (outer, oa) = (&w[1].0, w[1].1);
- for i in 0..n {
- let a1 = inner[i];
- let a2 = inner[(i + 1) % n];
- let b1 = outer[i];
- let b2 = outer[(i + 1) % n];
- out.push(to_v(a1, ia));
- out.push(to_v(b1, oa));
- out.push(to_v(a2, ia));
- out.push(to_v(a2, ia));
- out.push(to_v(b1, oa));
- out.push(to_v(b2, oa));
- }
- }
-}
-
-pub fn push_rounded_rect_vertices_corners(
- x: f32, y: f32, ww: f32, h: f32,
- radii: crate::widget::CornerRadii,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- clip_rect: Option<(f32, f32, f32, f32)>,
- out: &mut Vec<Vertex>,
-) {
- let corner_e = 2.0 / crate::layout::corner_shape();
- let mut r_tl = radii.top_left.max(0.0);
- let mut r_tr = radii.top_right.max(0.0);
- let mut r_br = radii.bottom_right.max(0.0);
- let mut r_bl = radii.bottom_left.max(0.0);
-
- // Simple scale clamping
- let sum_top = r_tl + r_tr;
- if sum_top > ww {
- let f = ww / sum_top;
- r_tl *= f;
- r_tr *= f;
- }
- let sum_bottom = r_bl + r_br;
- if sum_bottom > ww {
- let f = ww / sum_bottom;
- r_bl *= f;
- r_br *= f;
- }
- let sum_left = r_tl + r_bl;
- if sum_left > h {
- let f = h / sum_left;
- r_tl *= f;
- r_bl *= f;
- }
- let sum_right = r_tr + r_br;
- if sum_right > h {
- let f = h / sum_right;
- r_tr *= f;
- r_br *= f;
- }
-
- let clamp_x = |val: f32| -> f32 {
- if let Some((cx0, _, cx1, _)) = clip_rect {
- val.max(cx0).min(cx1)
- } else {
- val
- }
- };
- let clamp_y = |val: f32| -> f32 {
- if let Some((_, cy0, _, cy1)) = clip_rect {
- val.max(cy0).min(cy1)
- } else {
- val
- }
- };
-
- let push_quad = |verts: &mut Vec<Vertex>, qx: f32, qy: f32, qw: f32, qh: f32| {
- let x0 = clamp_x(qx);
- let y0 = clamp_y(qy);
- let x1 = clamp_x(qx + qw);
- let y1 = clamp_y(qy + qh);
-
- if x1 <= x0 || y1 <= y0 {
- return;
- }
-
- let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
- let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
- let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
- let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
- };
-
- let has_corners = r_tl > 0.1 || r_tr > 0.1 || r_br > 0.1 || r_bl > 0.1;
- if !has_corners {
- push_quad(out, x, y, ww, h);
- return;
- }
-
- // Body rectangles
- let mid_x0 = r_tl.max(r_bl);
- let mid_x1 = ww - r_tr.max(r_br);
- if mid_x1 > mid_x0 {
- push_quad(out, x + mid_x0, y, mid_x1 - mid_x0, h);
- }
- if h > r_tl + r_bl {
- push_quad(out, x, y + r_tl, mid_x0, h - r_tl - r_bl);
- }
- if h > r_tr + r_br {
- push_quad(out, x + mid_x1, y + r_tr, ww - mid_x1, h - r_tr - r_br);
- }
-
- // Corner rendering. The fans are FEATHERED: the fan body stops half a
- // pixel short of the silhouette and a strip fades from opaque at
- // silhouette-0.5 to transparent at silhouette+0.5, so the arc
- // anti-aliases instead of rasterizing a hard staircase — invisible on
- // HiDPI widget buffers, glaring on the desktop grid's world-scale
- // cells. Perceived size is unchanged (the 50%-coverage line stays on
- // the exact silhouette). Radii too small to feather keep the hard fan.
- let segments = 16;
- let fade = [color[0], color[1], color[2], 0.0];
- let to_ndc = |px: f32, py: f32| -> [f32; 2] {
- [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0]
- };
- let push_corner = |out: &mut Vec<Vertex>, cx: f32, cy: f32, r: f32, start: f32, end: f32| {
- let feather = r > 1.5;
- let r_fan = if feather { r - 0.5 } else { r };
- let r_out = r + 0.5;
- for i in 0..segments {
- let theta1 = start + (i as f32) * (end - start) / (segments as f32);
- let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
-
- let (c1, s1) = superellipse_pt(theta1, corner_e);
- let (c2, s2) = superellipse_pt(theta2, corner_e);
- let p0 = to_ndc(clamp_x(cx), clamp_y(cy));
- let p1 = to_ndc(clamp_x(cx + r_fan * c1), clamp_y(cy + r_fan * s1));
- let p2 = to_ndc(clamp_x(cx + r_fan * c2), clamp_y(cy + r_fan * s2));
-
- out.push(Vertex { position: p0, color, clip_circle });
- out.push(Vertex { position: p1, color, clip_circle });
- out.push(Vertex { position: p2, color, clip_circle });
-
- if feather {
- let q1 = to_ndc(clamp_x(cx + r_out * c1), clamp_y(cy + r_out * s1));
- let q2 = to_ndc(clamp_x(cx + r_out * c2), clamp_y(cy + r_out * s2));
- out.push(Vertex { position: p1, color, clip_circle });
- out.push(Vertex { position: q1, color: fade, clip_circle });
- out.push(Vertex { position: q2, color: fade, clip_circle });
- out.push(Vertex { position: p1, color, clip_circle });
- out.push(Vertex { position: q2, color: fade, clip_circle });
- out.push(Vertex { position: p2, color, clip_circle });
- }
- }
- };
-
- // Top-Left
- if r_tl > 0.1 {
- push_corner(out, x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI);
- if mid_x0 > r_tl {
- push_quad(out, x + r_tl, y, mid_x0 - r_tl, r_tl);
- }
- }
-
- // Top-Right
- if r_tr > 0.1 {
- push_corner(out, x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI);
- if ww - mid_x1 > r_tr {
- push_quad(out, x + mid_x1, y, ww - mid_x1 - r_tr, r_tr);
- }
- }
-
- // Bottom-Right
- if r_br > 0.1 {
- push_corner(out, x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI);
- if ww - mid_x1 > r_br {
- push_quad(out, x + mid_x1, y + h - r_br, ww - mid_x1 - r_br, r_br);
- }
- }
-
- // Bottom-Left
- if r_bl > 0.1 {
- push_corner(out, x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI);
- if mid_x0 > r_bl {
- push_quad(out, x + r_bl, y + h - r_bl, mid_x0 - r_bl, r_bl);
- }
- }
-}
-
-pub fn rounded_rect_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, &mut verts);
- verts
-}
-
-pub fn push_rounded_rect_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, out);
-}
-
-pub fn plate_bevel_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_plate_bevel_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, &mut verts);
- verts
-}
-
-pub fn push_plate_bevel_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- push_bevel_edge_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, 1.0, out);
-}
-
-/// The bevel edge shading, with the light direction selectable: `light_sign` is `1.0`
-/// for a raised plate (edges facing `light_source_position` are lit) and `-1.0` for a
-/// recess (those same edges fall into shadow instead, and the far edges catch the
-/// light). Negating the whole light vector flips every edge and every corner segment
-/// consistently, because both the flat-edge factors and the arc-normal dot product
-/// below are linear in it.
-pub fn push_bevel_edge_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- light_sign: f32,
- out: &mut Vec<Vertex>,
-) {
- push_bevel_edge_vertices_radii(
- x, y, ww, h, (r, r, r, r), t, sw, sh, base_color, clip_circle, light_sign, out,
- );
-}
-
-/// As [`push_bevel_edge_vertices`], but with a per-corner radius (TL, TR, BR, BL) so the
-/// lip can follow a shape whose corners differ — a recess carved along the top of a
-/// rounded plate needs the plate's radius on its top corners and square ones where it
-/// meets the content below. A uniform radius there would either square off the plate's
-/// arc (painting a notch outside it) or wrongly round the inner corners.
-pub fn push_bevel_edge_vertices_radii(
- x: f32, y: f32, ww: f32, h: f32,
- radii: (f32, f32, f32, f32),
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- light_sign: f32,
- out: &mut Vec<Vertex>,
-) {
- push_bevel_edge_vertices_banded(
- x, y, ww, h, radii, t, sw, sh, base_color, clip_circle, light_sign,
- default_bevel_bands(t), (true, true, true, true), EdgeKind::Rim, out,
- );
-}
-
-/// What kind of height change an edge represents. The two shade differently because they
-/// are different shapes, and using one where the other belongs is what makes a bevel read
-/// as a drawn line instead of a surface.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum EdgeKind {
- /// The surface *ends* here: a quarter-round rolling from face-on at the inner edge of
- /// the lip to fully in-plane at the outer boundary, where it drops away. The shading
- /// therefore peaks exactly at the boundary and dies inward. This is a plate's outer
- /// perimeter.
- Rim,
- /// The surface *continues* at a different height: one plateau steps down to another.
- /// A height field that falls monotonically across the transition has its normal tilted
- /// toward the low side the whole way, steepest in the middle and flat at both ends —
- /// so the shading is a bump straddling the boundary, not a band butted against it.
- /// Hanging the band on one side instead leaves the seam the eye reads as a drawn line.
- Step,
-}
-
-/// Shading across an edge at signed distance `d` from the boundary (positive = toward the
-/// shape's interior), for a transition of width `t`. Returns the light term as a fraction
-/// of full tilt.
-#[inline]
-fn bevel_profile(kind: EdgeKind, d: f32, t: f32) -> f32 {
- if t <= 0.0 {
- return 0.0;
- }
- match kind {
- // Normal rotates from in-plane (d = 0) to face-on (d = t): sine of what tilt is
- // left. A linear ramp here reads as a flat 45° chamfer instead of a roll.
- EdgeKind::Rim => ((1.0 - (d / t).clamp(0.0, 1.0)) * std::f32::consts::FRAC_PI_2).sin(),
- // Symmetric bump over [-t/2, +t/2], zero at both ends so the transition blends into
- // both plateaus with no seam.
- EdgeKind::Step => {
- let s = (d / t + 0.5).clamp(0.0, 1.0);
- (s * std::f32::consts::PI).sin()
- }
- }
-}
-
-/// The light-independent curvature term at signed distance `d` — the second depth cue,
-/// on top of the directional one. Curvature shading is what ambient light does: convex
-/// surface catches it from everywhere (bright), concave is self-occluded (dark). Because
-/// it does not rotate with the light, it survives exactly where the directional term
-/// dies — walls parallel to the light vector — so no edge ever vanishes entirely.
-///
-/// `high_sign` is +1 when the rect interior is the HIGH side of the transition and -1
-/// when it is the low side (a recess). Geometry, not lighting: it does not flip with
-/// `light_sign`... except that for these 2.5D shapes the two are the same number, since
-/// a raised shape is lit like a plateau and shaded like one.
-#[inline]
-fn bevel_curvature(kind: EdgeKind, d: f32, t: f32, high_sign: f32) -> f32 {
- if t <= 0.0 {
- return 0.0;
- }
- match kind {
- // A rim is convex everywhere, tightest right at the silhouette: a bright crest
- // line hugging the boundary and dying fast inward. This is the line that makes
- // glass read as glass — the edge catches ambient light all the way around, even
- // (dimmer, via the gain asymmetry below) on the side facing away from the light.
- EdgeKind::Rim => {
- let u = (d / t).clamp(0.0, 1.0);
- let f = 1.0 - u;
- CREST_RATIO * f * f * f
- }
- // An S-curve step is convex on its high half (the shoulder) and concave on its
- // low half (the fillet, where the wall meets the floor): antisymmetric, zero at
- // the ends (no seam against either plateau) and at the midpoint.
- EdgeKind::Step => {
- let s = (d / t + 0.5).clamp(0.0, 1.0);
- let outer_is_high = -high_sign; // d < 0 is outside the rect
- // sin(2πs) is positive on the outer half — the shoulder when the outside is
- // the high side — and negative on the inner (fillet) half.
- AO_RATIO * outer_is_high * (s * std::f32::consts::TAU).sin()
- }
- }
-}
-
-/// Crest amplitude as a fraction of `bevel_depth` — how much brighter a rim's silhouette
-/// line is than flat surface under even light. Must stay clearly below ~0.7 (the
-/// projection of a 135° light onto an axis edge), or it cancels the directional shadow
-/// on the dark side and the rim goes flat there instead of showing a faint bright line
-/// over a shadowed roll.
-const CREST_RATIO: f32 = 0.4;
-/// Shoulder/fillet amplitude as a fraction of `bevel_depth`.
-const AO_RATIO: f32 = 0.6;
-/// Per-sign overlay gains. These are asymmetric the opposite way from intuition: on the
-/// dark bases this DE runs, white-over blending (`b + a(1-b)`) moves the pixel far more
-/// per unit alpha than black-over (`b(1-a)`) — a dark surface has little brightness for
-/// black to take away. The old subtractive shading effectively crushed shadow sides to
-/// black in linear space; the black overlay needs a high gain to keep shadows reading
-/// at all, while white needs damping to keep highlights from blowing out.
-const LIGHT_GAIN: f32 = 0.7;
-const DARK_GAIN: f32 = 3.0;
-
-/// A shading value (already scaled by `bevel_depth`) as the two overlay passes: the lit
-/// pass is translucent white, the shadow pass translucent black. Painting the
-/// *modulation* instead of a resolved surface color is what lets relief primitives compose — a step
-/// crossing a rim shades the rim's gradient instead of stamping a flat band over it, a
-/// lip on a translucent plate no longer doubles its opacity, and a recess needs no
-/// knowledge of the surface color it carves.
-///
-/// Why two passes with fixed RGB rather than one signed color: a primitive whose value
-/// crosses zero inside a band would interpolate white→black through mid-gray at
-/// non-negligible alpha — on a dark base a *brightening* artifact right where the
-/// shading should vanish. With per-pass alphas clamped at the crossing, each pass fades
-/// to zero there and the hue can never be wrong. Alphas also stay non-negative on every
-/// vertex, which the renderer requires (negative alpha is the blur sentinel).
-#[inline]
-fn overlay_light(v: f32) -> [f32; 4] {
- [1.0, 1.0, 1.0, (v.max(0.0) * LIGHT_GAIN).min(1.0)]
-}
-#[inline]
-fn overlay_dark(v: f32) -> [f32; 4] {
- [0.0, 0.0, 0.0, ((-v).max(0.0) * DARK_GAIN).min(1.0)]
-}
-
-/// The signed distance range an edge's shading occupies, relative to the boundary.
-#[inline]
-fn bevel_span(kind: EdgeKind, t: f32) -> (f32, f32) {
- match kind {
- EdgeKind::Rim => (0.0, t),
- EdgeKind::Step => (-0.5 * t, 0.5 * t),
- }
-}
-
-/// How many gradient bands to slice a lip of thickness `t` into. Vertex colors interpolate
-/// linearly, so each band is a chord of the shading curve; one band per ~1.25px keeps the
-/// error under a shade step without emitting geometry finer than the display resolves.
-/// The cap rose with the curvature term: a step now has two features across its width
-/// (shoulder and fillet), so it needs double the samples a single bump did.
-fn default_bevel_bands(t: f32) -> usize {
- ((t / 1.25).ceil() as usize).clamp(1, 12)
-}
-
-/// As [`push_bevel_edge_vertices_radii`], with the band count forced and the walls
-/// selectable — for callers that want a coarser or finer roll-off than thickness alone
-/// implies, or that are shading a step rather than a closed shape.
-///
-/// `edges` is (top, right, bottom, left). Suppressing a wall matters for a region that
-/// runs flush to the surface's own edge: a full-width menubar sunk into the top of a plate
-/// is a *plateau one step down*, not a trough, so its only real wall is the one facing the
-/// content. Drawing the other three would carve a lip along the plate's outer edge, where
-/// the plate's own roll already lives, and the two would fight.
-pub fn push_bevel_edge_vertices_banded(
- x: f32, y: f32, ww: f32, h: f32,
- radii: (f32, f32, f32, f32),
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- light_sign: f32,
- bands: usize,
- edges: (bool, bool, bool, bool),
- kind: EdgeKind,
- out: &mut Vec<Vertex>,
-) {
- // Floored for the same reason as `plate_push_raised`'s cap: a negative
- // extent must degrade to no ring, not panic in `clamp`.
- let cap = (ww.min(h) * 0.5).max(0.0);
- let (tl, tr, br, bl) = (
- radii.0.clamp(0.0, cap),
- radii.1.clamp(0.0, cap),
- radii.2.clamp(0.0, cap),
- radii.3.clamp(0.0, cap),
- );
- let t = t.clamp(0.0, cap);
- if t <= 0.0 {
- return;
- }
- let bands = bands.max(1);
-
- let rad = crate::layout::light_source_position();
- let lx = rad.cos() * light_sign;
- let ly = -rad.sin() * light_sign;
- let depth = crate::layout::bevel_depth();
-
- // `base_color` is no longer painted: shading is an overlay (see `overlay_color`), so
- // the surface below shows through with its own gradients and translucency intact.
- let _ = base_color;
- // Shading (directional + curvature, scaled by bevel_depth) at signed distance `d`,
- // for an edge whose outward flat normal is `dir`. A `Step` band runs negative — it
- // straddles the boundary into the plateau outside the rect, which is exactly what
- // removes the seam.
- let value = |dot: f32, d: f32| {
- depth * (bevel_profile(kind, d, t) * dot + bevel_curvature(kind, d, t, light_sign))
- };
- // The (up to two) overlay color pairs for a band running from value `v0` to `v1`:
- // one white pair and/or one black pair, each pass fading to zero alpha wherever the
- // value has the other sign. Both fire only when the band straddles the terminator.
- let passes = |v0: f32, v1: f32| -> [Option<([f32; 4], [f32; 4])>; 2] {
- [
- (v0 > 0.0 || v1 > 0.0).then(|| (overlay_light(v0), overlay_light(v1))),
- (v0 < 0.0 || v1 < 0.0).then(|| (overlay_dark(v0), overlay_dark(v1))),
- ]
- };
- let (span_lo, span_hi) = bevel_span(kind, t);
-
- // Each flat edge spans between its two adjoining corner radii, not a single uniform
- // inset — that is what lets the corners differ. At a square corner there is no arc to
- // cover the t×t patch where two edges meet, so the horizontal edges claim it (they run
- // the full span) and the vertical ones inset by `t`; overlapping them instead would
- // double-blend that patch, which shows as a dark notch on a translucent surface.
- let (left_top, left_bot) = (if tl > 0.0 { tl } else { t }, if bl > 0.0 { bl } else { t });
- let (right_top, right_bot) = (if tr > 0.0 { tr } else { t }, if br > 0.0 { br } else { t });
- let top_w = ww - tl - tr;
- let bottom_w = ww - bl - br;
- let left_h = h - left_top - left_bot;
- let right_h = h - right_top - right_bot;
-
- for k in 0..bands {
- let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
- let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
- let bw = d1 - d0;
-
- // Top: outward normal (0,-1); the gradient runs downward, into the surface.
- if top_w > 0.0 && edges.0 {
- let (v0, v1) = (value(-ly, d0), value(-ly, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + tl, y + d0, top_w, bw, sw, sh, c0, c0, c1, c1, clip_circle,
- ));
- }
- }
- // Bottom: outward normal (0,1); gradient runs upward.
- if bottom_w > 0.0 && edges.2 {
- let (v0, v1) = (value(ly, d0), value(ly, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + bl, y + h - d1, bottom_w, bw, sw, sh, c1, c1, c0, c0, clip_circle,
- ));
- }
- }
- // Left: outward normal (-1,0); gradient runs rightward.
- if left_h > 0.0 && edges.3 {
- let (v0, v1) = (value(-lx, d0), value(-lx, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + d0, y + left_top, bw, left_h, sw, sh, c0, c1, c1, c0, clip_circle,
- ));
- }
- }
- // Right: outward normal (1,0); gradient runs leftward.
- if right_h > 0.0 && edges.1 {
- let (v0, v1) = (value(lx, d0), value(lx, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + ww - d1, y + right_top, bw, right_h, sw, sh, c1, c0, c0, c1, clip_circle,
- ));
- }
- }
- }
-
- // A corner arc belongs to both of its adjoining walls, so it is drawn only when both
- // are — otherwise a suppressed wall would still get a quarter of a lip.
- let corners = [
- (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, edges.0 && edges.3), // Top-Left
- (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, edges.0 && edges.1), // Top-Right
- (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI, edges.2 && edges.1), // Bottom-Right
- (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, edges.2 && edges.3), // Bottom-Left
- ];
-
- for &(cx, cy, r, start_angle, end_angle, enabled) in &corners {
- // A square corner has no arc to sweep — the flat edges already met there.
- if r <= 0.0 || !enabled {
- continue;
- }
- // The corner is a quarter of a torus: shading varies along the sweep (the normal
- // swings through 90° of the light) *and* across the lip (the roll-off). Both come
- // out of the vertex colors, so one quad per (segment × band) cell is enough — no
- // faceting, unlike the 16 flat wedges this replaced.
- let segments = ((r * 0.75) as usize).clamp(8, 48);
- let ct = t.min(r);
- for j in 0..segments {
- let theta0 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
- let theta1 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
- let (cos0, sin0) = (theta0.cos(), theta0.sin());
- let (cos1, sin1) = (theta1.cos(), theta1.sin());
- for k in 0..bands {
- let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
- let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
- // Inward along the corner's radius is the same signed distance as inward
- // from a flat edge, so the arc scales the span the same way.
- let (r0, r1) = (r - ct * (d0 / t), r - ct * (d1 / t));
- let p = |rho: f32, c: f32, s: f32| -> [f32; 2] {
- [
- ((cx + rho * c) / sw) * 2.0 - 1.0,
- 1.0 - ((cy + rho * s) / sh) * 2.0,
- ]
- };
- // Outer/inner × the two sweep ends; each vertex gets its own value, and
- // the cell is drawn once per overlay pass that has any coverage.
- let vals = [
- value(cos0 * lx + sin0 * ly, d0),
- value(cos1 * lx + sin1 * ly, d0),
- value(cos1 * lx + sin1 * ly, d1),
- value(cos0 * lx + sin0 * ly, d1),
- ];
- let geo = [
- p(r0, cos0, sin0),
- p(r0, cos1, sin1),
- p(r1, cos1, sin1),
- p(r1, cos0, sin0),
- ];
- let mut cells: [Option<fn(f32) -> [f32; 4]>; 2] = [None, None];
- if vals.iter().any(|&v| v > 0.0) {
- cells[0] = Some(overlay_light);
- }
- if vals.iter().any(|&v| v < 0.0) {
- cells[1] = Some(overlay_dark);
- }
- for f in cells.into_iter().flatten() {
- let c: Vec<Vertex> = (0..4)
- .map(|i| Vertex { position: geo[i], color: f(vals[i]), clip_circle })
- .collect();
- out.extend_from_slice(&[c[0], c[1], c[2], c[0], c[2], c[3]]);
- }
- }
- }
- }
-}
-
-/// How strong the face gradient is, as a fraction of `bevel_depth` at the corner nearest
-/// the light. Deliberately well below the edge amplitude: the face is a plane, not a
-/// roll — it only *leans* toward the light.
-const FACE_RATIO: f32 = 0.35;
-
-/// The face lighting of a plate: a single diagonal luminance gradient across the whole
-/// surface, brightest at the corner facing `light_source_position` and darkest at the
-/// opposite one. This is the difference between an object and a sticker: a real surface
-/// under directional light is never uniform, and a perfectly flat fill makes the eye
-/// read the (much smaller) edge shading as frame decoration rather than shape.
-///
-/// Emitted as the same two-pass white/black overlays as the relief primitives (see
-/// [`overlay_light`]/[`overlay_dark`]): fixed RGB per pass, per-corner alphas clamped at
-/// the terminator, bilinear across the quad. The quad is square — its corners poke past
-/// a rounded plate's arcs — but the compositor clips the window surface to the same
-/// radius, so the overhang never reaches the screen.
-pub fn push_plate_face_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- sw: f32, sh: f32,
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let rad = crate::layout::light_source_position();
- let (lx, ly) = (rad.cos(), -rad.sin());
- let amp = crate::layout::bevel_depth() * FACE_RATIO;
- // Corner value = how much its outward diagonal faces the light.
- let inv = std::f32::consts::FRAC_1_SQRT_2;
- let v_tl = amp * inv * (-lx - ly);
- let v_tr = amp * inv * (lx - ly);
- let v_br = amp * inv * (lx + ly);
- let v_bl = amp * inv * (-lx + ly);
- let vs = [v_tl, v_tr, v_br, v_bl];
- if vs.iter().any(|&v| v > 0.0) {
- out.extend_from_slice(&quad_vertices_shaded(
- x, y, ww, h, sw, sh,
- overlay_light(v_tl), overlay_light(v_tr), overlay_light(v_br), overlay_light(v_bl),
- clip_circle,
- ));
- }
- if vs.iter().any(|&v| v < 0.0) {
- out.extend_from_slice(&quad_vertices_shaded(
- x, y, ww, h, sw, sh,
- overlay_dark(v_tl), overlay_dark(v_tr), overlay_dark(v_br), overlay_dark(v_bl),
- clip_circle,
- ));
- }
-}
-
-pub fn push_plate_solid_border_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- radii: crate::widget::CornerRadii,
- t: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let mut r_tl = radii.top_left.max(0.0);
- let mut r_tr = radii.top_right.max(0.0);
- let mut r_br = radii.bottom_right.max(0.0);
- let mut r_bl = radii.bottom_left.max(0.0);
-
- // Simple scale clamping
- let sum_top = r_tl + r_tr;
- if sum_top > ww {
- let f = ww / sum_top;
- r_tl *= f;
- r_tr *= f;
- }
- let sum_bottom = r_bl + r_br;
- if sum_bottom > ww {
- let f = ww / sum_bottom;
- r_bl *= f;
- r_br *= f;
- }
- let sum_left = r_tl + r_bl;
- if sum_left > h {
- let f = h / sum_left;
- r_tl *= f;
- r_bl *= f;
- }
- let sum_right = r_tr + r_br;
- if sum_right > h {
- let f = h / sum_right;
- r_tr *= f;
- r_br *= f;
- }
-
- out.extend_from_slice(&quad_vertices_with_clip(x + r_tl, y, ww - r_tl - r_tr, t, sw, sh, color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x, y + r_tl, t, h - r_tl - r_bl, sw, sh, color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x + r_bl, y + h - t, ww - r_bl - r_br, t, sw, sh, color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x + ww - t, y + r_tr, t, h - r_tr - r_br, sw, sh, color, clip_circle));
-
- let segments = 16;
- let corner_e = 2.0 / crate::layout::corner_shape();
-
- // Corner strokes as annulus strips between the outer superellipse (radius
- // r) and its inner scaled copy (r - t): at 1px thickness the scaled inner
- // curve is indistinguishable from the true parallel curve, and at
- // corner_shape 2 this is exactly the circular arc annulus. NOT
- // push_arc_background_vertices — that stays circular for genuine arcs.
- //
- // Both edges of the annulus are FEATHERED, like the fill fan's corners
- // (push_rounded_rect_vertices_corners) and push_feathered_line_vertices:
- // each fades over `f` either side of its true curve, so the 50%-coverage
- // lines stay on the exact silhouette and the stroke reads at the same
- // weight, but the arc anti-aliases instead of rasterizing a staircase
- // beside the SDF-smooth face it outlines. The straight edges stay crisp
- // quads (a pixel-snapped hairline would only blur). A corner too tight to
- // fit the inner fade keeps the hard annulus.
- let f = 0.5f32.min(t * 0.25);
- let fade = [color[0], color[1], color[2], 0.0];
- let corner = |cx: f32, cy: f32, r: f32, start: f32, end: f32, out: &mut Vec<Vertex>| {
- let r_in = (r - t).max(0.0);
- // (inner radius, outer radius, inner alpha colour, outer alpha colour)
- let bands: &[(f32, f32, [f32; 4], [f32; 4])] = if r_in - f > 0.0 {
- &[
- (r_in - f, r_in + f, fade, color),
- (r_in + f, r - f, color, color),
- (r - f, r + f, color, fade),
- ]
- } else {
- &[(r_in, r, color, color)]
- };
- let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
- for i in 0..segments {
- let t1 = start + (i as f32) * (end - start) / segments as f32;
- let t2 = start + ((i + 1) as f32) * (end - start) / segments as f32;
- let (c1, s1) = superellipse_pt(t1, corner_e);
- let (c2, s2) = superellipse_pt(t2, corner_e);
- for &(ra, rb, ca, cb) in bands {
- if rb - ra <= 0.0 {
- continue;
- }
- let o1 = ndc(cx + rb * c1, cy + rb * s1);
- let o2 = ndc(cx + rb * c2, cy + rb * s2);
- let i1 = ndc(cx + ra * c1, cy + ra * s1);
- let i2 = ndc(cx + ra * c2, cy + ra * s2);
- out.push(Vertex { position: o1, color: cb, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i2, color: ca, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- }
- }
- };
-
- if r_tl > 0.1 {
- corner(x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, out);
- }
- if r_tr > 0.1 {
- corner(x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, out);
- }
- if r_br > 0.1 {
- corner(x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI, out);
- }
- if r_bl > 0.1 {
- corner(x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, out);
- }
-}
-
-pub fn push_plate_solid_border_vertices_legacy(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let radii = crate::widget::CornerRadii::uniform(r);
- push_plate_solid_border_vertices(x, y, ww, h, radii, t, sw, sh, color, clip_circle, out);
-}
-
-pub fn widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3]) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_widget_vertices(w, sw, sh, clip_circle, &mut verts);
- verts
-}
-
-pub fn push_widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3], out: &mut Vec<Vertex>) {
- let (x, y, ww, h) = w.rect();
- let radii = w.corner_radii();
- if let Some(thickness) = w.plate_bevel() {
- let t = thickness;
- // Full-size fill: the bevel lip is a shading overlay now, not a paint of the
- // outer ring, so an inset fill would leave the ring unfilled.
- push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
- push_plate_bevel_vertices(x, y, ww, h, radii.top_left, t, sw, sh, w.color(), clip_circle, out);
- } else {
- push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
- if let Some((color, thickness)) = w.solid_border() {
- push_plate_solid_border_vertices(x, y, ww, h, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
-
- for (cx, cy, r, t, start, end, qc) in w.extra_arcs() {
- push_arc_background_vertices(cx, cy, r, t, start, end, sw, sh, qc, 16, clip_circle, out);
- }
-}
-
-/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
-/// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
-/// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
-/// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
-pub struct DlBatch {
- pub scissor: Option<crate::scene::layout::Rect>,
- pub clip_rrect: Option<[f32; 5]>,
- pub start: u32,
- pub end: u32,
- /// When set, this batch is one SDF-lit plate cover quad (see
- /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
- pub plate: Option<crate::vk::PlatePush>,
- /// A blur-behind plate (negative-alpha color): before drawing this batch
- /// the renderer snapshots the swapchain-so-far into its snapshot image, so
- /// the blur samples everything painted beneath the plate — not just the 3D
- /// scene backdrop. Never merged.
- pub blur_behind: bool,
-}
-
-/// An image draw from the display list: `at` is the vertex index it sorts
-/// before (its position in the tessellated stream); `clip` is the item's
-/// paint-walk clip. Logical coordinates throughout.
-pub struct DlImage {
- pub image: u32,
- pub rect: crate::scene::layout::Rect,
- pub alpha: f32,
- pub at: u32,
- pub clip: Option<crate::scene::layout::Rect>,
-}
-
-/// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
-/// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
-/// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
-/// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
-/// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
-/// merged into one batch (the circle clip is per-vertex, so it never splits batches).
-/// `CCE_PLATE_DEBUG=1` — trace which carves group into their host plate as exact
-/// CSG features and which fall back to the standalone overlay shading.
-///
-/// The two paths do NOT look the same: a grouped carve is part of the plate's
-/// single height field, so its wall meets the plate's rolled perimeter as a real
-/// junction, while the fallback approximates that with the host-box fade. Six
-/// conditions decide it, three of them dynamic (draw order, neighbouring plates,
-/// whether another carve already claimed the host's feature run), so the SAME
-/// widget can render either way depending on what is around it — and it does so
-/// silently. That has already shipped as a bug once: a hovered button's opaque
-/// fill used to sever every later button from the root plate they carve into,
-/// which is why `plate_stack` is a stack (see its comment below).
-///
-/// Off by default and read once; the classification below runs only when set.
-/// Prim discriminant name, for `CCE_PLATE_DEBUG` reporting only.
-fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
- use crate::scene::paint::Prim as P;
- match p {
- P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
- P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
- P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
- P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Field { .. } => "Field", P::Plate { .. } => "Plate",
- P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
- P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
- P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
- P::DropletScrim { .. } => "DropletScrim",
- P::ConcaveFillet { .. } => "ConcaveFillet",
- P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice", P::Grout { .. } => "Grout", P::Fill { .. } => "Fill",
- P::CarveUnion { .. } => "CarveUnion", P::Glow { .. } => "Glow",
- P::Text { .. } => "Text", P::Image { .. } => "Image",
- }
-}
-
-fn plate_debug() -> bool {
- static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
- *ON.get_or_init(|| std::env::var("CCE_PLATE_DEBUG").is_ok_and(|v| v != "0"))
-}
-
-/// Debug builds make one kind of fallback LOUD without `CCE_PLATE_DEBUG`: a
-/// carve that could group (full ring, untinted) failing to while a still-open
-/// plate encloses it and the carve's shaded region reaches that plate's
-/// perimeter roll. There the grouped and overlay paths shade the junction
-/// differently, and the rejection is one of the dynamic rules — so the SAME
-/// widget can flip looks frame to frame with nothing on stderr. Not an
-/// assert/panic: every rejection is conservative-CORRECT (the audit that
-/// shipped CCE_PLATE_DEBUG found no misgrouping; a later plate overlapping the
-/// carve genuinely must be shaded over, not under) — it is the frame-to-frame
-/// LOOK that flips, so the right loudness is an unmissable warning, not a
-/// crash. The ubiquitous quiet case stays quiet by construction: ordinary
-/// geometry closing every grouping window empties `plate_stack`, so no
-/// enclosing OPEN plate exists and this never runs — that is draw-order
-/// design, not a flip.
-///
-/// Returns the dynamic rule to report, or `None` when the fallback is not the
-/// loud case. Pure so the classification is unit-testable; `later_plates` are
-/// the open plates emitted after the enclosing host.
-#[cfg(debug_assertions)]
-fn near_roll_fallback_reason(
- carve: &crate::scene::layout::Rect,
- depth: f32,
- host: &crate::scene::layout::Rect,
- roll: f32,
- later_plates: &[crate::scene::layout::Rect],
- budget_full: bool,
-) -> Option<&'static str> {
- // The carve's shaded region — the overlay path's cover-quad inflation.
- let infl = depth * 0.5 + 2.0;
- let (sx0, sy0) = (carve.x - infl, carve.y - infl);
- let (sx1, sy1) = (carve.x + carve.width + infl, carve.y + carve.height + infl);
- // "Near the roll" = the shaded region leaves the host rect deflated by the
- // host's own roll width on any side.
- let near = sx0 < host.x + roll
- || sy0 < host.y + roll
- || sx1 > host.x + host.width - roll
- || sy1 > host.y + host.height - roll;
- if !near {
- return None;
- }
- // The dynamic rules, in the order the grouping guard tests them.
- if budget_full {
- return Some("the feature budget is full");
- }
- if later_plates
- .iter()
- .any(|o| sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y)
- {
- return Some("a later plate overlaps the carve's shaded region");
- }
- Some("the host's feature run is closed (another plate appended features since)")
-}
-
-/// Print a near-roll fallback warning once per distinct message — a carve in a
-/// steady layout would otherwise repeat it every frame.
-#[cfg(debug_assertions)]
-fn plate_carve_warn_once(msg: String) {
- use std::sync::{Mutex, OnceLock};
- static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
- let seen = SEEN.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
- if seen.lock().unwrap().insert(msg.clone()) {
- eprintln!("{msg}");
- }
-}
-
-pub fn tessellate_display_list(
- dl: &crate::scene::paint::DisplayList,
- sw: f32,
- sh: f32,
- scale: f32,
-) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
- use crate::scene::material::PlateRole;
- use crate::scene::paint::{Cap, Prim};
- let mut verts: Vec<Vertex> = Vec::new();
- let mut batches: Vec<DlBatch> = Vec::new();
- let mut images: Vec<DlImage> = Vec::new();
- // Carves CSG'd into plates (see Frame2D::plate_features), plus the plate
- // they group into: the most recent Plate/Bevel batch, provided only Text
- // and Image prims (which draw through separate paths anyway) intervene.
- let mut features: Vec<[f32; 12]> = Vec::new();
- // Open carve-host plates, in emission order (innermost candidates last).
- // A STACK, not a single slot: a sibling plate emitted between a root plate
- // and its later carves (a hovered button's opaque fill among transparent
- // ones) must not sever those carves from the root plate they are carved
- // into — that severing rendered every button after the hovered one
- // through the visually-different overlay fallback. Ordinary geometry
- // still closes every open plate (the draw-order rule below).
- let mut plate_stack: Vec<(usize, crate::scene::layout::Rect)> = Vec::new();
- // Which plate last appended a carve feature: a plate's features are
- // addressed as one contiguous [offset, count] run (PlatePush::host), so a
- // plate may only receive MORE features while no other plate has appended
- // any since.
- let mut last_feature_plate: Option<usize> = None;
- // `CCE_PLATE_DEBUG` bookkeeping — see `plate_debug`.
- let dbg_plates = plate_debug();
- let mut dbg_grouped = 0usize;
- let mut dbg_fell_back: Vec<String> = Vec::new();
- let mut dbg_opened = 0usize;
- // Which prim kind closed a still-open grouping window, and how many plates
- // it closed — the answer to "why was there no enclosing plate?".
- let mut dbg_closed_by: std::collections::BTreeMap<&'static str, usize> =
- std::collections::BTreeMap::new();
-
- // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
- // shading, plus the frame-constant lighting inputs it pushes per plate.
- let shader_plates = crate::layout::bevel_shader();
- // Light and material come from `scene::relief_shade`, which is also what
- // cce-relief predicts pixels with — one definition, so the editor cannot
- // draw a different material than the renderer applies.
- let plate_light = crate::scene::relief_shade::light_vector();
- // [shading strength (1.0 at the default bevel_depth), specular strength,
- // shininess, curvature/AO strength] — the DE's finish, for the CARVES,
- // which shade whatever is beneath them and so take the host's. A prim
- // that carries a Material (Plate, Bevel, Sphere, Droplet) pushes its own
- // `material.finish` instead. Curvature is kept near the raised path's
- // crest amplitude: the recess shoulder's brightening lands on the same
- // pixels as its specular line, and the two stack — at 0.5 the step read
- // several times hotter than a plate roll.
- let plate_mat = crate::scene::material::Finish::from_style().to_array();
-
- for item in &dl.items {
- let mut start = verts.len() as u32;
- let mut plate: Option<crate::vk::PlatePush> = None;
- // A frosted flat fill promoted to a zero-depth plate batch (below):
- // it carries a recipe like any plate, but it is ordinary geometry to
- // the carve grouping — it opens no host and closes the open ones.
- let mut promoted = false;
- let mut made_plate: Option<crate::scene::layout::Rect> = None;
- // Blur-behind marker: a prim whose FILL alpha is negative asks the
- // renderer to snapshot the frame-so-far before it draws. Every
- // fill-bearing prim counts — the shader's a<0 branch runs for all of
- // them, and a variant missing here still frosts, but against the
- // stale scene backdrop instead of the frame: a flat tint with no
- // content and no blur, which is how the Dropdown popover (Border)
- // and the menubar panels (Quad) shipped visibly unfrosted while the
- // context menu (Plate) worked.
- let mut blur_behind = matches!(
- &item.prim,
- crate::scene::paint::Prim::Quad { color, .. }
- | crate::scene::paint::Prim::RoundedRect { color, .. } if color[3] < 0.0
- ) || matches!(
- &item.prim,
- crate::scene::paint::Prim::Bevel { material, .. }
- | crate::scene::paint::Prim::Plate { material, .. }
- | crate::scene::paint::Prim::Droplet { material, .. }
- if material.fill(PlateRole::Nested)[3] < 0.0
- ) || matches!(
- &item.prim,
- crate::scene::paint::Prim::Border { fill, .. } if fill[3] < 0.0
- ) || matches!(
- &item.prim,
- crate::scene::paint::Prim::Fill { material, .. } if material.fill(PlateRole::Nested)[3] < 0.0
- );
- // Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
- let no = item
- .clip_circle
- .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
- .unwrap_or([0.0f32, 0.0, 0.0]);
- // Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
- // the fan with the PHYSICAL radius (capped — beyond 128 the chord error is
- // subpixel even on HiDPI).
- let segs = |radius: f32| -> usize { ((radius * scale) as usize).clamp(16, 128) };
- match &item.prim {
- Prim::Text { .. } => continue, // text goes through the glyph/text-span path
- Prim::Image { image, rect, alpha } => {
- images.push(DlImage {
- image: *image,
- rect: *rect,
- alpha: *alpha,
- at: verts.len() as u32,
- clip: item.clip,
- });
- continue;
- }
- // A frosted FLAT fill — a `Flat` control face, a menu panel, a
- // popover, an inset plate's face — is a zero-depth plate batch
- // (RFC material § 6.2): the same shader path as every plate, so
- // it carries its own frost recipe instead of a window-wide one,
- // with the configured `corner_shape` and no roll, which is what the
- // tessellated fill drew. The display list is untouched, so the
- // legacy bridges that extract RoundedRects still see one.
- Prim::Quad { rect, color } if shader_plates && color[3] < 0.0 => {
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
- plate = Some(flat_frost_push(rect, (0.0, 0.0, 0.0, 0.0), *color, scale, plate_light, plate_mat));
- promoted = true;
- }
- Prim::RoundedRect { rect, radius, corners, color } if shader_plates && color[3] < 0.0 => {
- let radii = (
- if corners.0 { *radius } else { 0.0 },
- if corners.1 { *radius } else { 0.0 },
- if corners.2 { *radius } else { 0.0 },
- if corners.3 { *radius } else { 0.0 },
- );
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
- plate = Some(flat_frost_push(rect, radii, *color, scale, plate_light, plate_mat));
- promoted = true;
- }
- Prim::Fill { rect, radii, material } if shader_plates && material.frost.is_frosted() => {
- // A material's flat fill: the frosted promotion above with
- // the MATERIAL's recipe (compression, refraction, radius)
- // instead of the DE default's. Zero depth, the configured
- // corner shape, no host — exactly a promoted RoundedRect.
- let color = material.fill(PlateRole::Nested);
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- let mut p = plate_push_raised(rect, *radii, 0.0, scale, plate_light, plate_mat, false, None);
- let [fz, fw] = material.frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- plate = Some(p);
- promoted = true;
- }
- Prim::Fill { rect, radii, material } => {
- // Opaque (or the legacy path): a plain rounded fill.
- let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, material.fill(PlateRole::Nested), no, None, &mut verts);
- }
- Prim::Border { rect, radii, fill, border, thickness } if shader_plates && fill[3] < 0.0 => {
- // The fill as its own plate batch, closed here; the stroke
- // follows as ordinary geometry in the batch the tail makes.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *fill));
- let p = flat_frost_push(rect, *radii, *fill, scale, plate_light, plate_mat);
- let end = verts.len() as u32;
- plate_stack.clear();
- batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate: Some(p), blur_behind: true });
- start = end;
- blur_behind = false;
- let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
- push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
- }
- Prim::Quad { rect, color } => {
- // Quads honor an active circle clip like circles/arcs do (the
- // Ramp's foam-cell fills draw as clipped strips).
- verts.extend(quad_vertices_with_clip(rect.x, rect.y, rect.width, rect.height, sw, sh, *color, no));
- }
- Prim::RoundedRect { rect, radius, corners, color } => {
- let radii = crate::widget::CornerRadii::new(
- if corners.0 { *radius } else { 0.0 },
- if corners.1 { *radius } else { 0.0 },
- if corners.2 { *radius } else { 0.0 },
- if corners.3 { *radius } else { 0.0 },
- );
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
- }
- Prim::Border { rect, radii, fill, border, thickness } => {
- let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, *fill, no, None, &mut verts);
- push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
- }
- Prim::Glow { rect, radius, reach, color } => {
- push_glow_vertices(rect.x, rect.y, rect.width, rect.height, *radius, *reach, sw, sh, *color, no, &mut verts);
- }
- Prim::Bevel { rect, radii, material, depth, tint } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // SDF-lit raised plate: one cover quad; the shader owns fill,
- // roll shading, corners, and silhouette AA. Nominal corner
- // radii (scale_corners false): a Bevel is a WIDGET-scale plate
- // whose silhouette must match the nominal-radius squircles of
- // the controls around it — only window-scale `Plate`s get the
- // curvature-matched span.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, false, None);
- // The plate's own frost recipe rides host.zw (see PlatePush).
- let [fz, fw] = material.frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- // w = 1 marks an accent-tinted plate (the focused-pane
- // treatment): the shader keeps the roll's light and shadow
- // and recolours them — light toward the tint, shadow toward
- // a dark tint — matching the free-carve path's tinted-well
- // convention. Neutral white keeps w = 0 (a no-op multiply).
- let full = if *tint == [1.0, 1.0, 1.0] { 0.0 } else { 1.0 };
- p.specular_tint = [tint[0], tint[1], tint[2], full];
- plate = Some(p);
- made_plate = Some(*rect);
- }
- Prim::Plate { rect, radii, material, depth, shape } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- if *depth < 0.0 {
- // Negative depth = fill-less roll overlay (MODE_ROLL): the
- // window-edge roll shading alone, screened over whatever is
- // beneath — for a root plate whose face is not a fill (the
- // designer's 3D canvas). The cover quad carries no color,
- // and the batch is NOT opened as a carve host: an overlay
- // owns no surface for a CSG feature to cut into.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
- let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, mat, true, *shape);
- p.mode = 11.0; // MODE_ROLL
- plate = Some(p);
- } else {
- // Same lit-plate branch; the cover quad is the exact rect so the
- // silhouette and the compositor's rounded window corners agree.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, true, *shape);
- let [fz, fw] = material.frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- plate = Some(p);
- made_plate = Some(*rect);
- }
- }
- // A sunken well ending in a flush run (see `Prim::Field`): one
- // outline, one overlay — never grouped, its profile is not a
- // monotonic step. The cover quad inflates by half the wall, as a
- // free carve's does; the host-box slot carries the run's two
- // ends (physical px), since nothing fades against a host here.
- Prim::Field { rect, radii, depth, split, end, tint } if shader_plates => {
- let infl = *depth * 0.5 + 2.0;
- verts.extend(quad_vertices(
- rect.x - infl, rect.y - infl,
- rect.width + 2.0 * infl, rect.height + 2.0 * infl,
- sw, sh, [0.0; 4],
- ));
- let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
- p.mode = 16.0; // MODE_FIELD
- if let Some(t) = tint {
- p.specular_tint = [t[0], t[1], t[2], 1.0];
- }
- p.host = [*split * scale, *end * scale, 0.0, 0.0];
- plate = Some(p);
- }
- Prim::Recess { rect, radii, depth, edges, .. }
- | Prim::Boss { rect, radii, depth, edges, .. }
- | Prim::Ridge { rect, radii, depth, edges }
- | Prim::Trough { rect, radii, depth, edges, .. }
- if shader_plates =>
- {
- let tint = match &item.prim {
- Prim::Recess { tint, .. } => *tint,
- Prim::Boss { tint, .. } => *tint,
- Prim::Trough { tint, .. } => *tint,
- _ => None,
- };
- // Recess carves down into the surface; Boss raises a plateau out
- // of it (same machinery, depth sign flipped); Ridge is a raised
- // rim straddling the boundary and Trough the sunken valley twin
- // (their own overlay profiles — never grouped, the CSG features
- // only model monotonic steps).
- let mode = match &item.prim {
- Prim::Boss { .. } => 3.0f32,
- Prim::Ridge { .. } => 4.0,
- Prim::Trough { .. } => 9.0,
- _ => 2.0,
- };
- let raised = mode > 2.5;
- // Grouped into the enclosing plate whenever one is live: the
- // carve becomes a CSG feature of that plate's single draw —
- // exact composite shading, real junctions at the plate's rolled
- // perimeter — instead of a shading overlay (the fallback below).
- //
- // Edge-suppressed carves NEVER group: a suppressed wall's rect
- // extends past the carve (below), relying on the overlay cover
- // quad to keep that shading out of the drawn pixels — a clip
- // the plate's whole-surface draw does not have, so grouped it
- // smears the extended walls across the plate. Union pieces
- // (section wells, a spinbox's field and button run) are
- // exactly these.
- // A tinted carve also never groups: a CSG feature is geometry only,
- // so the tint could only land on the whole plate's specular.
- let full_ring = *edges == (true, true, true, true);
- let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::vk::MAX_PLATE_FEATURES {
- // The carve's shaded region, for the occlusion test below
- // (the overlay path's cover-quad inflation).
- let infl = *depth * 0.5 + 2.0;
- let (sx0, sy0) = (rect.x - infl, rect.y - infl);
- let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
- plate_stack
- .iter()
- .enumerate()
- .rev()
- .find(|(si, (bi, prect))| {
- let inside = rect.x >= prect.x - 0.5
- && rect.y >= prect.y - 0.5
- && rect.x + rect.width <= prect.x + prect.width + 0.5
- && rect.y + rect.height <= prect.y + prect.height + 0.5;
- if !inside {
- return false;
- }
- // Pixels drawn since this plate (a LATER plate in the
- // stack) must not overlap the carve — its shading would
- // land beneath them in this plate's earlier draw.
- if plate_stack[si + 1..].iter().any(|(_, orect)| {
- sx0 < orect.x + orect.width
- && sx1 > orect.x
- && sy0 < orect.y + orect.height
- && sy1 > orect.y
- }) {
- return false;
- }
- // Contiguity: only the last feature-receiving plate (or
- // one with no features yet) may take another.
- batches[*bi].plate.as_ref().map_or(false, |p| p.host[1] == 0.0)
- || last_feature_plate == Some(*bi)
- })
- .map(|(_, &(bi, _))| bi)
- } else {
- None
- };
- // Debug-build loudness for the silent grouped→overlay flip —
- // see `near_roll_fallback_reason` on what qualifies and why
- // this warns instead of panicking.
- #[cfg(debug_assertions)]
- if host_plate.is_none() && mode < 3.5 && full_ring && tint.is_none() {
- let enclosing = plate_stack.iter().enumerate().rev().find(|(_, (_, p))| {
- rect.x >= p.x - 0.5
- && rect.y >= p.y - 0.5
- && rect.x + rect.width <= p.x + p.width + 0.5
- && rect.y + rect.height <= p.y + p.height + 0.5
- });
- if let Some((si, &(bi, prect))) = enclosing {
- // Host roll width rides the push's light.w (physical px).
- let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
- let later: Vec<crate::scene::layout::Rect> =
- plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
- let budget_full = features.len() >= crate::vk::MAX_PLATE_FEATURES;
- if let Some(why) =
- near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
- {
- let kind = if mode > 2.5 { "boss" } else { "recess" };
- plate_carve_warn_once(format!(
- "plate-carve: near-roll {kind} ({:.0},{:.0} {:.0}x{:.0}) lost grouping — {why}; \
- its junction with the host plate's roll shades through the overlay fallback, \
- visually different from grouped frames (CCE_PLATE_DEBUG=1 traces verdicts) \
- [debug-build warning, printed once]",
- rect.x, rect.y, rect.width, rect.height
- ));
- }
- }
- }
- if dbg_plates {
- match host_plate {
- Some(_) => dbg_grouped += 1,
- None => {
- // Re-derive WHY, in the same order the guard tests
- // them. Debug-only: the hot path above is untouched.
- let kind = match &item.prim {
- Prim::Boss { .. } => "boss",
- Prim::Ridge { .. } => "ridge",
- Prim::Trough { .. } => "trough",
- _ => "recess",
- };
- let infl = *depth * 0.5 + 2.0;
- let (sx0, sy0) = (rect.x - infl, rect.y - infl);
- let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
- let enclosing: Vec<usize> = plate_stack
- .iter()
- .enumerate()
- .filter(|(_, (_, p))| {
- rect.x >= p.x - 0.5
- && rect.y >= p.y - 0.5
- && rect.x + rect.width <= p.x + p.width + 0.5
- && rect.y + rect.height <= p.y + p.height + 0.5
- })
- .map(|(si, _)| si)
- .collect();
- let occluded = |si: usize| {
- plate_stack[si + 1..].iter().any(|(_, o)| {
- sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y
- })
- };
- let why = if mode >= 3.5 {
- "ridge — never groups (its bump profile is not a monotonic step)".into()
- } else if !full_ring {
- format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
- } else if tint.is_some() {
- "tinted — a CSG feature is geometry only, it carries no color".into()
- } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
- format!("feature budget full ({} used)", features.len())
- } else if enclosing.is_empty() {
- format!("no enclosing plate ({} open)", plate_stack.len())
- } else if enclosing.iter().all(|&si| occluded(si)) {
- "a later plate overlaps this carve's shaded region".into()
- } else {
- "host plate's feature run is closed (another carve appended since)".into()
- };
- dbg_fell_back.push(format!(
- " overlay: {kind} ({:.0},{:.0} {:.0}x{:.0}) — {why}",
- rect.x, rect.y, rect.width, rect.height
- ));
- }
- }
- }
- if let Some(bi) = host_plate {
- {
- // A wall the carve shares with the plate's edge extends
- // past the plate, so the carve has no wall there.
- let ext = *depth + 4.0;
- let (mut x0, mut y0) = (rect.x, rect.y);
- let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
- if !edges.0 { y0 -= ext; }
- if !edges.1 { x1 += ext; }
- if !edges.2 { y1 += ext; }
- if !edges.3 { x0 -= ext; }
- let t_px = *depth * scale;
- // The carve's drop: the material's pinned height, else
- // the analytic ratio of the wall saturating at the DE's
- // roll width (`layout::carve_depth_px` states the rule
- // once for this path and the shader's free carves).
- let k_mag = crate::layout::carve_depth_px(*depth) * scale;
- // Negative depth = raised (Boss); the shader's summed
- // slope vectors and curvature sign follow it.
- let k_px = if raised { -k_mag } else { k_mag };
- if let Some(p) = batches[bi].plate.as_mut() {
- if p.host[1] == 0.0 {
- p.host[0] = features.len() as f32;
- }
- p.host[1] += 1.0;
- }
- last_feature_plate = Some(bi);
- features.push([
- (x0 + x1) * 0.5 * scale,
- (y0 + y1) * 0.5 * scale,
- (x1 - x0) * 0.5 * scale,
- (y1 - y0) * 0.5 * scale,
- radii.0 * scale,
- radii.1 * scale,
- radii.2 * scale,
- radii.3 * scale,
- t_px,
- k_px,
- 0.0,
- 0.0,
- ]);
- continue;
- }
- }
- // Overlay-only carve: the cover quad inflates by half the roll
- // width (the step straddles the boundary) and carries no color —
- // the shader emits translucent white/black over what's beneath.
- let infl = *depth * 0.5 + 2.0;
- verts.extend(quad_vertices(
- rect.x - infl, rect.y - infl,
- rect.width + 2.0 * infl, rect.height + 2.0 * infl,
- sw, sh, [0.0; 4],
- ));
- // A suppressed wall is pushed past the cover quad, so its
- // shading falls outside the drawn pixels (see Prim::Recess on
- // why a flush region is a step, not a trough).
- let ext = *depth + 4.0;
- let (mut x0, mut y0) = (rect.x, rect.y);
- let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
- if !edges.0 { y0 -= ext; }
- if !edges.1 { x1 += ext; }
- if !edges.2 { y1 += ext; }
- if !edges.3 { x0 -= ext; }
- let sdf_rect = crate::scene::layout::Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
- let mut p = plate_push_raised(&sdf_rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
- p.mode = mode;
- // w = 1.0 flags the free-carve shader path to composite its
- // light in the tint and its shadow in a dark tint instead of
- // white and black (plates leave w at 0.0).
- if let Some(t) = tint {
- p.specular_tint = [t[0], t[1], t[2], 1.0];
- }
- // Host-plate box for the roll fade: a suppressed wall means the
- // recess runs flush to the host's edge there, so that side of
- // the box sits at the original rect edge; enabled walls face
- // host interior, pushed to ±1e5 so no fade applies.
- const FAR: f32 = 1e5;
- let (hx0, hy0) = (
- if edges.3 { rect.x - FAR } else { rect.x },
- if edges.0 { rect.y - FAR } else { rect.y },
- );
- let (hx1, hy1) = (
- if edges.1 { rect.x + rect.width + FAR } else { rect.x + rect.width },
- if edges.2 { rect.y + rect.height + FAR } else { rect.y + rect.height },
- );
- p.host = [
- (hx0 + hx1) * 0.5 * scale,
- (hy0 + hy1) * 0.5 * scale,
- (hx1 - hx0) * 0.5 * scale,
- (hy1 - hy0) * 0.5 * scale,
- ];
- plate = Some(p);
- }
- Prim::Bevel { rect, radii, material, depth, tint: _ } => {
- let color = material.fill(PlateRole::Nested);
- // Full-size fill: the lip is now a shading overlay, not a paint of the
- // outer ring, so the fill must cover the whole rect (the old inset fill
- // would leave the ring showing whatever lay beneath).
- let corners = crate::widget::CornerRadii {
- top_left: radii.0, top_right: radii.1,
- bottom_right: radii.2, bottom_left: radii.3,
- };
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts);
- push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, color, no, &mut verts);
- }
- Prim::Plate { rect, radii, material, depth, .. } => {
- let color = material.fill(PlateRole::Nested);
- if *depth < 0.0 {
- // Fill-less roll overlay (negative-depth sentinel): the banded
- // legacy tessellation has no overlay compositing, so the roll
- // is simply absent here — the A/B path draws nothing rather
- // than a wrong fill.
- continue;
- }
- // Fill at full size (no inset — see Prim::Plate), then light the face,
- // then roll the perimeter. The lip rides on top of the fill's outer band
- // rather than replacing it, so the plate's silhouette and the
- // compositor's rounded window corners still agree exactly.
- let corners = crate::widget::CornerRadii {
- top_left: radii.0, top_right: radii.1,
- bottom_right: radii.2, bottom_left: radii.3,
- };
- push_rounded_rect_vertices_corners(
- rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts,
- );
- push_plate_face_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, no, &mut verts);
- push_bevel_edge_vertices_radii(
- rect.x, rect.y, rect.width, rect.height, *radii, *depth,
- sw, sh, color, no, 1.0, &mut verts,
- );
- }
- Prim::Recess { rect, radii, depth, edges, .. } => {
- // Edges only — no fill: the shading is an overlay, so whatever is painted
- // below (fill, rim gradient, blur) shows through the carve modulated
- // rather than repainted. `light_sign = -1.0` shadows the lit-facing edges,
- // which is the raised->recessed inversion.
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, *depth,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Boss { rect, radii, depth, edges, .. } => {
- // Legacy raised step: the recess overlay with the light sign upright.
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, *depth,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(*depth), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Ridge { rect, radii, depth, edges } => {
- // Legacy approximation: a raised step up at the boundary plus a
- // recessed step down half a width in (the banded machinery has no
- // bump profile; the double-pass hot crest is accepted here — the
- // legacy path exists only for A/B comparison).
- let half = *depth * 0.5;
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, half,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- let ir = (radii.0 - half).max(0.0);
- push_bevel_edge_vertices_banded(
- rect.x + half, rect.y + half,
- rect.width - *depth, rect.height - *depth,
- (ir, ir, ir, ir), half,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Trough { rect, radii, depth, edges, .. } => {
- // Legacy approximation, the Ridge arm's two steps with the light
- // signs swapped: down at the boundary, back up half a width in.
- // The banded machinery has no valley profile, so this is the old
- // stacked look — accepted here, as the legacy path exists only
- // for A/B comparison against the SDF one.
- let half = *depth * 0.5;
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, half,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- let ir = (radii.0 - half).max(0.0);
- push_bevel_edge_vertices_banded(
- rect.x + half, rect.y + half,
- rect.width - *depth, rect.height - *depth,
- (ir, ir, ir, ir), half,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Field { rect, radii, depth, split, end, .. } => {
- // Legacy approximation: the two-box form `Prim::Field`
- // replaced — a well either side of the run a step down, the
- // run the Trough arm's down-then-up stack. The banded
- // machinery has no blended outline, and the legacy path
- // exists only for A/B comparison.
- let all = (true, true, true, true);
- let (fl, fr) = (rect.x, rect.x + rect.width);
- let (well_l, well_r) = (*split > fl, *end < fr);
- let rx = split.max(fl);
- let rw = (end.min(fr) - rx).max(0.0);
- if well_l {
- push_bevel_edge_vertices_banded(
- fl, rect.y, rx - fl, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
- EdgeKind::Step, &mut verts,
- );
- }
- if well_r {
- push_bevel_edge_vertices_banded(
- rx + rw, rect.y, fr - rx - rw, rect.height, (0.0, radii.1, radii.2, 0.0), *depth,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
- EdgeKind::Step, &mut verts,
- );
- }
- // A run's own corners where it reaches the outline; square at a seam.
- let (l0, l3) = if well_l { (0.0, 0.0) } else { (radii.0, radii.3) };
- let (r1, r2) = if well_r { (0.0, 0.0) } else { (radii.1, radii.2) };
- let half = *depth * 0.5;
- push_bevel_edge_vertices_banded(
- rx, rect.y, rw, rect.height, (l0, r1, r2, l3), half,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), all,
- EdgeKind::Step, &mut verts,
- );
- let ir = if well_r { 0.0 } else { (radii.1 - half).max(0.0) };
- let il = if well_l { 0.0 } else { (radii.0 - half).max(0.0) };
- push_bevel_edge_vertices_banded(
- rx + half, rect.y + half, rw - *depth, rect.height - *depth, (il, ir, ir, il), half,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
- push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
- }
- Prim::ArcShaded { cx, cy, radius, thickness, start: sa, end: ea, inner, crest, outer } => {
- push_arc_shaded_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *inner, *crest, *outer, segs(*radius), no, &mut verts);
- }
- Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
- let lc = match cap {
- Cap::Flat => LineCap::Flat,
- Cap::Round => LineCap::Round,
- Cap::Arrow => LineCap::Arrow,
- };
- verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
- }
- Prim::Circle { cx, cy, radius, color } => {
- if item.clip_circle.is_none() && *radius > 1.5 {
- // Cover quad with the disc itself as the (feathered) circle
- // clip: a per-pixel smooth silhouette instead of a hard-edged
- // fan. The quad overhangs by 1px for the feather. Only when
- // no ancestor clip holds the slot — then it's the fan path.
- let own = [cx * scale, cy * scale, radius * scale];
- let d = *radius + 1.0;
- verts.extend(quad_vertices_with_clip(
- cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color, own,
- ));
- } else {
- verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
- }
- }
- Prim::Sphere { cx, cy, radius, material } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // A hemisphere lit per pixel by the plate branch (mode 5): one
- // cover quad, its own never-merged batch. The quad overhangs
- // the disc by 1px for the shader's silhouette anti-aliasing.
- let d = *radius + 1.0;
- verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
- plate = Some(crate::vk::PlatePush {
- // Center + radius in physical px; the SDF box machinery is
- // unused in this mode, so .w is free.
- rect: [cx * scale, cy * scale, radius * scale, 0.0],
- radii: [0.0; 4],
- light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
- material: mat,
- host: [0.0; 4],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 5.0,
- shape: 2.0,
- });
- }
- Prim::Sphere { cx, cy, radius, material } => {
- let color = material.fill(PlateRole::Nested);
- // Legacy path: the flat disc, exactly a Circle.
- verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, color, segs(*radius), no));
- }
- Prim::DropletScrim { rect, material, spec, feather } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // Shader mode 12: the droplet's own SDF, filled flat and
- // feathered inward. No contact shadow, so unlike the lit drop
- // the cover quad is exactly the box — a scrim never draws
- // outside the silhouette.
- let g = droplet_geom(rect, spec);
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- plate = Some(crate::vk::PlatePush {
- rect: [
- (rect.x + rect.width * 0.5) * scale,
- (rect.y + rect.height * 0.5) * scale,
- g.hx * scale,
- g.hy * scale,
- ],
- radii: [g.sag * scale, g.br * scale, g.bw * scale, g.k * scale],
- // p_light.w carries the FEATHER here; mode 12 returns
- // before the shading band it otherwise holds is read.
- light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
- material: [mat[0], 0.0, 0.0, 0.0],
- host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
- specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
- mode: 12.0,
- shape: spec.curve.clamp(2.0, 6.0),
- });
- }
- Prim::Droplet { rect, material, spec } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // A water droplet lit by shader mode 10: one cover quad; the
- // shader owns silhouette (sheet ∪smin belly), dome shading,
- // fresnel rim and thin-edge clarity. The spec's height
- // fractions resolve against the concrete rect here, clamped so
- // small or narrow boxes stay well-formed (a belly wider than
- // the box would turn the SDF interior inside out).
- // The cover quad grows sideways and BELOW the box by the
- // contact shadow's reach — shadow fragments live outside the
- // silhouette, so they need covered pixels to shade.
- let g = droplet_geom(rect, spec);
- let (hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach) =
- (g.hx, g.hy, g.sag, g.br, g.bw, g.k, g.sr, g.ar, g.band, g.bow, g.sh_reach);
- verts.extend(quad_vertices(
- rect.x - sh_reach,
- rect.y,
- rect.width + 2.0 * sh_reach,
- rect.height + sh_reach,
- sw, sh, color,
- ));
- plate = Some(crate::vk::PlatePush {
- rect: [
- (rect.x + rect.width * 0.5) * scale,
- (rect.y + rect.height * 0.5) * scale,
- hx * scale,
- hy * scale,
- ],
- radii: [sag * scale, br * scale, bw * scale, k * scale],
- light: [plate_light[0], plate_light[1], plate_light[2], band * scale],
- // Slots y/z/w feed roll_spec and the rim term directly:
- // a droplet's material carries its own gleam/shine/rim
- // there (`DropletSpec::finish`; a drop is wetter than the
- // DE's plates), so this is the material's finish like any
- // plate's.
- material: mat,
- host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
- // Droplet glints are always white, so the tint RGB slots
- // carry droplet params instead: x = core density,
- // y = contact-shadow reach px, z = shadow strength.
- specular_tint: [
- spec.core.clamp(0.0, 2.0),
- sh_reach * scale,
- spec.shadow.clamp(0.0, 1.0),
- bow * scale,
- ],
- mode: 10.0,
- shape: spec.curve.clamp(2.0, 6.0),
- });
- }
- Prim::DropletScrim { rect, material, spec, .. } => {
- let color = material.fill(PlateRole::Nested);
- // Legacy banded path: no SDF to feather against, so the scrim
- // degrades to the same flat outline the drop itself does —
- // hard-edged, but present. A prim with no arm here VANISHES.
- let cap = (rect.height * 0.5).min(rect.width * 0.5);
- let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
- let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
- let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
- }
- Prim::Droplet { rect, material, spec } => {
- let color = material.fill(PlateRole::Nested);
- // Legacy banded path: the flat drop outline — attach-tapered
- // top, round bottom. Degrades the material but keeps the
- // silhouette (a prim with no arm here VANISHES, it doesn't
- // degrade — see Ridge/Groove above).
- let cap = (rect.height * 0.5).min(rect.width * 0.5);
- let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
- let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
- let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
- }
- Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
- // A quarter-arc carve wall (shader mode 6/7): one cover quad
- // over the wedge's reach; the wall straddles the arc by ±t/2
- // like every carve boundary. p_rect carries centre + radius,
- // p_radii.x the wedge start angle. Host box pushed far out —
- // an inside-corner fillet never fades.
- let m = *depth * 0.5 + 2.0;
- let r = *radius + m;
- verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
- plate = Some(crate::vk::PlatePush {
- rect: [cx * scale, cy * scale, *radius * scale, 0.0],
- radii: [*a0, 0.0, 0.0, 0.0],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- material: plate_mat,
- host: [0.0, 0.0, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: if *raised { 7.0 } else { 6.0 },
- shape: crate::layout::corner_shape(),
- });
- }
- // Legacy banded path has no radial wall — the composed corner
- // stays square there (A/B comparison path only).
- Prim::ConcaveFillet { .. } => {}
- Prim::Groove { a, b, width, depth, host, strength } if shader_plates => {
- // A slab carve about the line a–b (shader mode 8): the cover
- // quad is the segment's bounding box grown by the groove's own
- // half-width plus the wall's reach. Off-band corners of that
- // box sit at u = 1 (plateau), so the box overhang shades
- // nothing — the slab is what bounds the mark, not the quad.
- let m = *width * 0.5 + *depth * 0.5 + 2.0;
- let (x0, x1) = (a.0.min(b.0) - m, a.0.max(b.0) + m);
- let (y0, y1) = (a.1.min(b.1) - m, a.1.max(b.1) + m);
- verts.extend(quad_vertices(x0, y0, x1 - x0, y1 - y0, sw, sh, [0.0; 4]));
- // Unit normal of the line — the direction the slab's distance is
- // measured along. A degenerate segment falls back to vertical so
- // a zero-length groove is a no-op wall rather than a NaN.
- let (dx, dy) = (b.0 - a.0, b.1 - a.1);
- let len = (dx * dx + dy * dy).sqrt();
- let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
- plate = Some(crate::vk::PlatePush {
- // Centre + slab half-width in physical px; .w unused.
- rect: [
- (a.0 + b.0) * 0.5 * scale,
- (a.1 + b.1) * 0.5 * scale,
- *width * 0.5 * scale,
- 0.0,
- ],
- radii: [n.0, n.1, 0.0, 0.0],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- // The finish's shading, specular and AO, at the groove's
- // strength; shininess is a shape, not an amount.
- material: {
- let s = strength.clamp(0.0, 1.0);
- [plate_mat[0] * s, plate_mat[1] * s, plate_mat[2], plate_mat[3] * s]
- },
- host: [
- (host.x + host.width * 0.5) * scale,
- (host.y + host.height * 0.5) * scale,
- host.width * 0.5 * scale,
- host.height * 0.5 * scale,
- ],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 8.0,
- shape: crate::layout::corner_shape(),
- });
- }
- Prim::Groove { a, b, width, depth, host: _, strength } => {
- // Legacy approximation. The banded tessellators walk BOX edges —
- // exactly the axis-aligned assumption a groove exists to escape —
- // so the walls are drawn directly as two feathered lines meeting
- // at the centerline: the engraved-line fake, one half in shadow
- // and one lit. Coarser than the SDF (no profile curve, no host
- // fade), but this path exists for A/B comparison, and drawing
- // NOTHING would silently delete the mark rather than degrade it
- // — see `Prim::Ridge` above, which accepts a hot crest for the
- // same reason.
- let (dx, dy) = (b.0 - a.0, b.1 - a.1);
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 {
- continue;
- }
- let n = (-dy / len, dx / len);
- // Same convention as `push_bevel_edge_vertices_banded`: the
- // light folded through `light_sign` (-1.0 — a groove is a
- // carve), dotted with each wall's OUTWARD normal, amplitude on
- // `bevel_depth`. So a groove re-lights with the DE's light
- // instead of hardcoding which side is dark.
- let rad = crate::layout::light_source_position();
- let (lx, ly) = (-rad.cos(), rad.sin());
- let v = crate::layout::bevel_depth() * (n.0 * lx + n.1 * ly);
- // Each wall covers its own half, centreline to outer edge —
- // abutting rather than overlapping. The SDF gets away with
- // walls that overlap across a sub-pixel floor because it is one
- // evaluation of |distance|; two opposite-signed overlays would
- // just blend to mud.
- let half = (*width * 0.5 + *depth * 0.5).max(0.5);
- for side in [1.0f32, -1.0] {
- let sv = v * side;
- let mut c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
- c[3] *= strength.clamp(0.0, 1.0);
- if c[3] <= 0.0 {
- continue;
- }
- let off = side * half * 0.5;
- push_feathered_line_vertices(
- a.0 + n.0 * off, a.1 + n.1 * off,
- b.0 + n.0 * off, b.1 + n.1 * off,
- half, sw, sh, c, &mut verts,
- );
- }
- }
- Prim::Lattice { rect, period, origin, cell, radius, depth } if shader_plates => {
- // A periodic well field (shader mode 13): one cover quad over
- // `rect`; the shader folds each pixel into the period and
- // measures the nearest cell, so the whole lattice is a single
- // evaluation. p_rect = one cell's centre + half-extents,
- // p_radii = the corner radius, p_host.xy = the period; the
- // host-box fade sides are pushed far out (a lattice never
- // fades against a host — its own rect bounds it).
- let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
- plate = Some(crate::vk::PlatePush {
- rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
- radii: [*radius * scale; 4],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- material: plate_mat,
- host: [pw * scale, ph * scale, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 13.0,
- shape: crate::layout::corner_shape(),
- });
- }
- Prim::Grout { rect, period, origin, cell, radius, color } if shader_plates => {
- // The lattice's fold, painted flat (shader mode 15): one cover
- // quad in the grout colour; the shader keeps it outside the
- // cells. Same push layout as the lattice; light/material are
- // carried but unread.
- let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
- plate = Some(crate::vk::PlatePush {
- rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
- radii: [*radius * scale; 4],
- light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
- material: plate_mat,
- host: [pw * scale, ph * scale, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 15.0,
- shape: crate::layout::corner_shape(),
- });
- }
- // Legacy banded path: no periodic wall — the lattice and the grout
- // draw nothing there, like the fillet (A/B comparison path only).
- Prim::Lattice { .. } | Prim::Grout { .. } => {}
- Prim::CarveUnion { boxes, depth, raised } if shader_plates => {
- // The union of several boxes as ONE wall (shader mode 14): the
- // boxes go into the frame's feature buffer as a contiguous run
- // and the shader takes the nearest one per pixel. The cover
- // quad is the union's bounding box grown by the wall's reach;
- // off-shape corners of it sit at the plateau and shade nothing.
- let budget = crate::vk::MAX_PLATE_FEATURES.saturating_sub(features.len());
- let take = boxes.len().min(budget);
- if take < boxes.len() && plate_debug() {
- eprintln!(
- "plate-carve: union of {} boxes gets {} — feature budget full ({} used)",
- boxes.len(), take, features.len()
- );
- }
- if take == 0 {
- continue;
- }
- let kept = &boxes[..take];
- let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
- for (r, _) in kept {
- x0 = x0.min(r.x);
- y0 = y0.min(r.y);
- x1 = x1.max(r.x + r.width);
- y1 = y1.max(r.y + r.height);
- }
- let infl = *depth * 0.5 + 2.0;
- verts.extend(quad_vertices(
- x0 - infl, y0 - infl,
- (x1 - x0) + 2.0 * infl, (y1 - y0) + 2.0 * infl,
- sw, sh, [0.0; 4],
- ));
- let off = features.len() as f32;
- for (r, radii) in kept {
- features.push([
- (r.x + r.width * 0.5) * scale,
- (r.y + r.height * 0.5) * scale,
- r.width * 0.5 * scale,
- r.height * 0.5 * scale,
- radii.0 * scale,
- radii.1 * scale,
- radii.2 * scale,
- radii.3 * scale,
- *depth * scale,
- 0.0,
- 0.0,
- 0.0,
- ]);
- }
- // The run is complete: a plate with an open feature run must
- // not append past it (its features would no longer be
- // contiguous), so it is closed here like any other appender.
- last_feature_plate = None;
- plate = Some(crate::vk::PlatePush {
- rect: [
- (x0 + x1) * 0.5 * scale,
- (y0 + y1) * 0.5 * scale,
- (x1 - x0) * 0.5 * scale,
- (y1 - y0) * 0.5 * scale,
- ],
- // x: the raised flag; the shader reads nothing else here.
- radii: [if *raised { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- material: plate_mat,
- // Feature run [offset, count] (the renderer rebases the
- // offset onto the frame slot, as for mode 1); zw far out
- // so the host-box fade never applies.
- host: [off, take as f32, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 14.0,
- shape: crate::layout::corner_shape(),
- });
- }
- // Legacy banded path: no union — nothing is drawn there, like the
- // fillet and the lattice (A/B comparison path only).
- Prim::CarveUnion { .. } => {}
- }
- let end = verts.len() as u32;
- if end == start {
- continue;
- }
- // Some tessellators (quad_vertices, vector_vertices) don't thread the circle clip —
- // stamp the whole emitted range so every prim kind honors it uniformly.
- if item.clip_circle.is_some() {
- for v in verts[start as usize..].iter_mut() {
- v.clip_circle = no;
- }
- }
- // Merge into the previous batch if it shares this clip pair and is contiguous.
- // Plate batches carry per-draw push constants, and blur-behind batches
- // trigger the renderer's snapshot copy, so neither ever merges.
- if plate.is_none() && !blur_behind {
- // Ordinary geometry painted after a plate ends its carve-grouping
- // window: a recess emitted later must overlay this geometry (the
- // fallback path), not shade beneath it inside the plate's draw.
- if dbg_plates && !plate_stack.is_empty() {
- *dbg_closed_by.entry(prim_kind(&item.prim)).or_insert(0) += plate_stack.len();
- }
- plate_stack.clear();
- if let Some(last) = batches.last_mut() {
- if last.plate.is_none()
- && last.scissor == item.clip
- && last.clip_rrect == item.clip_rrect
- && last.end == start
- {
- last.end = end;
- continue;
- }
- }
- }
- if promoted {
- plate_stack.clear();
- }
- batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate, blur_behind });
- if let Some(prect) = made_plate {
- plate_stack.push((batches.len() - 1, prect));
- if dbg_plates {
- dbg_opened += 1;
- }
- }
- }
-
- if dbg_plates && (dbg_grouped > 0 || !dbg_fell_back.is_empty()) {
- eprintln!(
- "plate-dbg: {} carves — {dbg_grouped} grouped (exact CSG), {} overlay fallback",
- dbg_grouped + dbg_fell_back.len(),
- dbg_fell_back.len(),
- );
- eprintln!(
- "plate-dbg: {dbg_opened} grouping window(s) opened by a filled plate; closed early by {}",
- if dbg_closed_by.is_empty() {
- "nothing".to_string()
- } else {
- dbg_closed_by
- .iter()
- .map(|(k, n)| format!("{k}x{n}"))
- .collect::<Vec<_>>()
- .join(", ")
- }
- );
- for line in &dbg_fell_back {
- eprintln!("plate-dbg: {line}");
- }
- }
-
- (verts, batches, images, features)
-}
-
-/// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
-/// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
-///
-/// `shape` is a per-plate corner exponent (`Prim::Plate`'s override); `None`
-/// follows the DE-wide `layout::corner_shape`. The span factor follows the
-/// exponent actually used, so a circular override (2.0) spans nothing and a
-/// half-extent radius lands on a true circle.
-#[allow(clippy::too_many_arguments)]
-/// The push block of a frosted flat fill promoted to a zero-depth plate: a
-/// mode-1 plate with no roll (`t` = 0.001, so the face is exactly the fill),
-/// corners at the nominal radii in the configured `corner_shape`, and the
-/// fill's own frost recipe in `host.zw` (`Material::from_fill` decodes the
-/// sentinel).
-///
-/// The shape must be `corner_shape`, not a fixed circle: a frosted `Border`
-/// draws its stroke as `push_plate_solid_border_vertices` geometry in that
-/// shape, and the unfrosted fill fan uses it too. A circular face under a
-/// squircle stroke left the stroke cutting inside the face's corners.
-fn flat_frost_push(
- rect: &crate::scene::layout::Rect,
- radii: (f32, f32, f32, f32),
- fill: [f32; 4],
- scale: f32,
- light: [f32; 3],
- material: [f32; 4],
-) -> crate::vk::PlatePush {
- let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, None);
- let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- p
-}
-
-fn plate_push_raised(
- rect: &crate::scene::layout::Rect,
- radii: (f32, f32, f32, f32),
- width: f32,
- scale: f32,
- light: [f32; 3],
- material: [f32; 4],
- scale_corners: bool,
- shape: Option<f32>,
-) -> crate::vk::PlatePush {
- // Floored: a rect already shrunk past its padding (a window dragged
- // below what its layout can hold) has a NEGATIVE extent here, and
- // `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
- let cap = (rect.width.min(rect.height) * 0.5).max(0.0);
- let shape = shape.map_or_else(crate::layout::corner_shape, |n| n.clamp(2.0, 16.0));
- // For PLATES (`scale_corners`), widen the corner span by the
- // curvature-match factor (see `layout::corner_span_factor`): the diagonal
- // curvature radius equals the configured radius, the corner reads as the
- // same size as a circular one, and every roll inset ≤ r stays crease-free
- // (past the diagonal curvature radius the offset curve the specular band
- // follows creases into a visible square corner). Widget-scale overlay
- // reliefs (recess/boss/ridge fallbacks) pass false: their radii must MATCH
- // the nominal-radius squircles of the widget silhouettes around them, and
- // at their few-px roll widths the offset crease is subpixel.
- let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
- crate::vk::PlatePush {
- rect: [
- (rect.x + rect.width * 0.5) * scale,
- (rect.y + rect.height * 0.5) * scale,
- rect.width * 0.5 * scale,
- rect.height * 0.5 * scale,
- ],
- radii: [
- (radii.0 * rscale).clamp(0.0, cap) * scale,
- (radii.1 * rscale).clamp(0.0, cap) * scale,
- (radii.2 * rscale).clamp(0.0, cap) * scale,
- (radii.3 * rscale).clamp(0.0, cap) * scale,
- ],
- light: [light[0], light[1], light[2], width * scale],
- material,
- // Mode-1 semantics: [feature offset, feature count] — no carves yet;
- // the tessellator fills these in as recesses group into this plate.
- host: [0.0, 0.0, 0.0, 0.0],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 1.0,
- shape,
- }
-}
-
-pub fn extra_quad_vertices(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_extra_quad_vertices(w, qx, qy, qw, qh, sw, sh, qc, clip_circle, &mut verts);
- verts
-}
-
-fn get_child_widget_for_quad<'a>(
- w: &'a dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
-) -> &'a dyn crate::widget::WidgetHost {
- if let Some(pbg) = w.as_any().downcast_ref::<crate::widget::ParametersBg>() {
- for s_opt in &pbg.sliders {
- if let Some(s) = s_opt {
- let (sx, sy, sww, shh) = s.rect();
- if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
- return s;
- }
- }
- }
- for f_opt in &pbg.float3s {
- if let Some(f) = f_opt {
- let (fx, fy, fww, fhh) = f.rect();
- if qx >= fx - 0.1 && qx + qw <= fx + fww + 0.1 && qy >= fy - 0.1 && qy + qh <= fy + fhh + 0.1 {
- return f;
- }
- }
- }
- for sb_opt in &pbg.spinboxes {
- if let Some(sb) = sb_opt {
- let (sx, sy, sww, shh) = sb.rect();
- if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
- return sb;
- }
- }
- }
- for btn_opt in &pbg.buttons {
- if let Some(btn) = btn_opt {
- let (bx, by, bww, bhh) = btn.rect();
- if qx >= bx - 0.1 && qx + qw <= bx + bww + 0.1 && qy >= by - 0.1 && qy + qh <= by + bhh + 0.1 {
- return btn;
- }
- }
- }
- for ch_opt in &pbg.choices {
- if let Some(ch) = ch_opt {
- let (cx, cy, cww, chh) = ch.rect();
- if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
- return ch;
- }
- }
- }
- for t_opt in &pbg.texts {
- if let Some(t) = t_opt {
- let (tx, ty, tww, thh) = t.rect();
- if qx >= tx - 0.1 && qx + qw <= tx + tww + 0.1 && qy >= ty - 0.1 && qy + qh <= ty + thh + 0.1 {
- return t;
- }
- }
- }
- for cb_opt in &pbg.toggles {
- if let Some(cb) = cb_opt {
- let (cx, cy, cww, chh) = cb.rect();
- if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
- return cb;
- }
- }
- }
- for c_opt in &pbg.colors {
- if let Some(c) = c_opt {
- let (cx, cy, cww, chh) = c.rect();
- if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
- return c;
- }
- }
- }
- }
- w
-}
-
-pub fn push_extra_quad_vertices(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
- if graph.is_node_rect(qx, qy, qw, qh) {
- let r = crate::layout::graph_node_corner_radius();
- let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
- return;
- }
- }
-
- let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
- let radii = target_w.corner_radii();
- if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
- out.extend_from_slice(&quad_vertices_with_clip(qx, qy, qw, qh, sw, sh, qc, clip_circle));
- if let Some((color, thickness)) = target_w.solid_border() {
- let (wx, wy, ww, wh) = target_w.rect();
- if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
- return;
- }
-
- let (wx, mut wy, ww, mut wh) = target_w.rect();
- let top_room = target_w.label_strip();
- wy += top_room;
- wh -= top_room;
- let extra_radii = crate::widget::CornerRadii::new(
- if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
- if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
- );
-
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
-
- if let Some((color, thickness)) = target_w.solid_border() {
- let (rx, mut ry, rw, mut rh) = target_w.rect();
- let top = target_w.label_strip();
- ry += top;
- rh -= top;
- if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
-}
-
-pub fn extra_quad_vertices_clipped(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip: (f32, f32, f32, f32),
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_extra_quad_vertices_clipped(w, qx, qy, qw, qh, sw, sh, qc, clip, clip_circle, &mut verts);
- verts
-}
-
-pub fn push_extra_quad_vertices_clipped(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip: (f32, f32, f32, f32),
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
- if graph.is_node_rect(qx, qy, qw, qh) {
- let r = crate::layout::graph_node_corner_radius();
- let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
- return;
- }
- }
-
- let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
- let radii = target_w.corner_radii();
- if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
- let (cx0, cy0, cx1, cy1) = clip;
- let ix0 = qx.max(cx0);
- let iy0 = qy.max(cy0);
- let ix1 = (qx + qw).min(cx1);
- let iy1 = (qy + qh).min(cy1);
- if ix1 <= ix0 || iy1 <= iy0 {
- return;
- }
- out.extend_from_slice(&quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, sw, sh, qc, clip_circle));
- if let Some((color, thickness)) = target_w.solid_border() {
- let (wx, wy, ww, wh) = target_w.rect();
- if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
- return;
- }
-
- let (wx, mut wy, ww, mut wh) = target_w.rect();
- let top_room = target_w.label_strip();
- wy += top_room;
- wh -= top_room;
- let extra_radii = crate::widget::CornerRadii::new(
- if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
- if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
- );
-
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
-
- if let Some((color, thickness)) = target_w.solid_border() {
- let (rx, mut ry, rw, mut rh) = target_w.rect();
- let top = target_w.label_strip();
- ry += top;
- rh -= top;
- if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
-}
-
-pub fn circle_vertices(
- cx: f32, cy: f32, r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- for i in 0..segments {
- let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
- let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
- let x0 = cx;
- let y0 = cy;
- let x1 = cx + r * theta1.cos();
- let y1 = cy + r * theta1.sin();
- let x2 = cx + r * theta2.cos();
- let y2 = cy + r * theta2.sin();
-
- let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
- let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
- let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
- let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
- let ndc_x2 = (x2 / sw) * 2.0 - 1.0;
- let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
- }
- verts
-}
-
-pub fn circle_border_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- for i in 0..segments {
- let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
- let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
-
- let x0 = cx + (r - thickness) * theta1.cos();
- let y0 = cy + (r - thickness) * theta1.sin();
- let x1 = cx + r * theta1.cos();
- let y1 = cy + r * theta1.sin();
-
- let x2 = cx + r * theta2.cos();
- let y2 = cy + r * theta2.sin();
- let x3 = cx + (r - thickness) * theta2.cos();
- let y3 = cy + (r - thickness) * theta2.sin();
-
- let ndc_x0 = (x0 / sw) * 2.0 - 1.0; let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
- let ndc_x1 = (x1 / sw) * 2.0 - 1.0; let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
- let ndc_x2 = (x2 / sw) * 2.0 - 1.0; let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
- let ndc_x3 = (x3 / sw) * 2.0 - 1.0; let ndc_y3 = 1.0 - (y3 / sh) * 2.0;
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
- verts.push(Vertex { position: [ndc_x3, ndc_y3], color, clip_circle });
- }
- verts
-}
-
-pub fn arc_background_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- start_angle: f32, end_angle: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_arc_background_vertices(cx, cy, r, thickness, start_angle, end_angle, sw, sh, color, segments, clip_circle, &mut verts);
- verts
-}
-
-/// A ring band with radial Gouraud shading: two sub-bands (inner rim → crest
-/// centerline, crest → outer rim) whose vertex colors interpolate across the
-/// stroke — the rounded-bevel profile — plus the half-px alpha feathers at
-/// both true rims (colors matched to the adjacent band, so no seams).
-#[allow(clippy::too_many_arguments)]
-pub fn push_arc_shaded_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- start_angle: f32, end_angle: f32,
- sw: f32, sh: f32,
- inner: [f32; 4], crest: [f32; 4], outer: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let f = 0.5f32.min(thickness * 0.25);
- let r_out = r;
- let r_in = (r - thickness).max(0.0);
- let r_mid = (r_in + r_out) / 2.0;
- let fade_in = [inner[0], inner[1], inner[2], 0.0];
- let fade_out = [outer[0], outer[1], outer[2], 0.0];
- // (inner radius, outer radius, color at inner edge, color at outer edge)
- let bands = [
- ((r_in - f).max(0.0), r_in + f, fade_in, inner),
- (r_in + f, r_mid, inner, crest),
- (r_mid, r_out - f, crest, outer),
- (r_out - f, r_out + f, outer, fade_out),
- ];
- for i in 0..segments {
- let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
- let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
- let (c1, s1) = (theta1.cos(), theta1.sin());
- let (c2, s2) = (theta2.cos(), theta2.sin());
- for &(ra, rb, ca, cb) in &bands {
- if rb <= ra {
- continue;
- }
- let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
- [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
- };
- let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
- let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o1, color: cb, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i2, color: ca, clip_circle });
- }
- }
-}
-
-pub fn push_arc_background_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- start_angle: f32, end_angle: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- // The stroke band [r - thickness, r], with a half-px alpha ramp on each rim
- // (Gouraud across thin edge bands) so curved edges resolve smoothly instead
- // of hard-stepping — the poor-man's AA the flat pipeline doesn't provide.
- let f = 0.5f32.min(thickness * 0.25);
- let r_in = (r - thickness).max(0.0);
- // (inner radius, outer radius, alpha at inner rim, alpha at outer rim)
- let bands = [
- ((r_in - f).max(0.0), r_in + f, 0.0, color[3]),
- (r_in + f, r - f, color[3], color[3]),
- (r - f, r + f, color[3], 0.0),
- ];
- for i in 0..segments {
- let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
- let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
- let (c1, s1) = (theta1.cos(), theta1.sin());
- let (c2, s2) = (theta2.cos(), theta2.sin());
- for &(ra, rb, aa, ab) in &bands {
- if rb <= ra {
- continue;
- }
- let ca = [color[0], color[1], color[2], aa];
- let cb = [color[0], color[1], color[2], ab];
- let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
- [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
- };
- let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
- let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o1, color: cb, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i2, color: ca, clip_circle });
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct WindowSettings {
- pub title: String,
- pub app_id: String,
- pub width: u32,
- pub height: u32,
- pub fullscreen: bool,
- pub min_size: Option<(u32, u32)>,
-}
-
-/// A compositor-side window operation requested by the app: an interactive
-/// move or resize grab. Returned from [`Application::take_window_action`];
-/// the runner executes it with the serial of the most recent pointer press.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum WindowAction {
- Move,
- Resize(xdg_toplevel::ResizeEdge),
-}
-
-// Re-export the wlr-layer-shell types apps need to describe a layer surface.
-pub use smithay_client_toolkit::shell::wlr_layer::{
- Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
-};
-
-/// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
-/// surface (panels, overlays, notifications) instead of an xdg toplevel.
-/// Return one from [`Application::layer`] to select layer-shell.
-#[derive(Debug, Clone)]
-pub struct LayerSettings {
- pub layer: LayerKind,
- pub anchor: LayerAnchor,
- pub exclusive_zone: i32,
- pub keyboard_interactivity: LayerKeyboardInteractivity,
- /// (top, right, bottom, left) margins in logical pixels.
- pub margin: (i32, i32, i32, i32),
- pub namespace: String,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct LogicalPosition {
- pub x: f32,
- pub y: f32,
-}
-
-impl LogicalPosition {
- pub fn new(x: f32, y: f32) -> Self {
- Self { x, y }
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct LogicalSize {
- pub width: f32,
- pub height: f32,
-}
-
-impl LogicalSize {
- pub fn new(width: f32, height: f32) -> Self {
- Self { width, height }
- }
-}
-
-pub struct RenderContext<'a> {
- pub font_system: &'a mut FontSystem,
-}
-
-pub trait Application: Sized + 'static {
- type Message: Send + Clone + 'static;
-
- fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self;
- fn settings(&self) -> WindowSettings;
- /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
- /// instead of an xdg toplevel. Defaults to `None` (a normal window).
- fn layer(&self) -> Option<LayerSettings> {
- None
- }
- /// Declare the window a UTILITY window: a tool whose shape is decided by
- /// its contents. The compositor then never dictates a size to it (every
- /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
- /// become the surface's own initial size), offers no resize affordance
- /// (the whole border band moves the window), and never saves geometry
- /// for it, so a stale remembered size can't be restored over what the
- /// app asks for. Declared over the cce window-management protocol at
- /// window creation; on a compositor too old to know the request this is
- /// silently a plain floating window. Defaults to `false`.
- fn utility(&self) -> bool {
- false
- }
- /// Declare the window the DESKTOP-GRID layer (zcce set_grid): the
- /// compositor world-anchors the surface to the virtual desktop and
- /// pans/zooms it per frame like window content; the app renders only
- /// when handed a patch (see [`Application::grid_patch`]). The surface
- /// becomes input-transparent and lives behind all windows. Needs
- /// manager v6; on an older compositor the declaration is skipped.
- /// Defaults to `false`.
- fn grid(&self) -> bool {
- false
- }
- /// A grid patch to render (grid apps only): virtual origin (`x`, `y`),
- /// virtual size (`w`, `h`), and `scale` surface px per virtual unit.
- /// Called right before the frame that must show it; the runner has
- /// already resized the surface to `(w*scale, h*scale)` and acks the
- /// patch so the coming commit is latched at the new anchor.
- fn grid_patch(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _scale: f64) {}
- fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
- fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
- /// How long the runner may sleep between `tick`s while the window is
- /// idle — nothing to draw, no animation, no key held, no frame callback
- /// outstanding. `None` (the default) lets it sleep until a Wayland
- /// event or a message on the app's calloop `Sender` arrives, bounded by
- /// [`IDLE_DISPATCH`]. Override with `Some` ONLY if your `tick` polls
- /// something the loop cannot see — a `std::sync::mpsc` receiver drained
- /// in `tick`, say — because with the default that poll waits for the
- /// next unrelated event. The better fix is to send through the calloop
- /// `Sender` handed to `new`, which wakes the loop by itself.
- fn idle_poll_interval(&self) -> Option<std::time::Duration> {
- None
- }
- /// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
- /// tray-hover highlights). Deliberately separate from the single paint path.
- fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
- /// The part of the surface that changed since the last frame this app
- /// painted, as (x, y, w, h) in logical px, taken (and reset) once per
- /// rendered frame right after `display_list`. `None` — the default —
- /// means all of it. Returning a rect makes the frame a partial one: only
- /// that rect is repainted and only it is reported to the compositor as
- /// damage, which is what keeps a small change on a very large surface
- /// (an image dragged across the desktop grid) from costing a full
- /// repaint on both sides. The app vouches for the rect: anything that
- /// changed outside it keeps its old pixels. A frame that was skipped is
- /// the runner's to make up — the next one is painted in full.
- fn take_damage(&mut self, _size: LogicalSize, _scale: f64) -> Option<(f32, f32, f32, f32)> {
- None
- }
- fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
- None
- }
-
- /// Transparent overflow rim, in logical px, on the RIGHT and BOTTOM of
- /// the window. Non-zero opts into buffer-larger-than-geometry mode: the
- /// runner sizes the surface `margin` wider/taller than the configured
- /// window size, publishes the top-left rect as the xdg window geometry
- /// (what the compositor tiles, borders, and snaps) and an input region of
- /// the frame plus any open popover rects — an overhanging menu stays
- /// clickable while empty rim falls through to whatever is behind.
- ///
- /// Right/bottom ONLY, deliberately: the surface grows away from its
- /// origin, so the frame never moves relative to the surface and pointer
- /// coordinates stay valid across the resize (a leading rim shifts the
- /// surface under an unmoved cursor, and the compositor's stale pointer
- /// state then drops the very next click). Frame coords == surface coords:
- /// no input translation, no paint shift — the app's only obligation is to
- /// lay out against the frame (`display_list`'s `size` minus the margin);
- /// content emitted past the frame edge renders in the rim instead of
- /// clipping at the buffer edge.
- ///
- /// The value may change at runtime (return the popover overhang while a
- /// menu is open, 0 otherwise): the engine re-derives the surface from the
- /// stored frame and resizes on drift. Quantize the answer (e.g. 64px
- /// steps) so an animating popover doesn't resize the surface per frame.
- /// xdg toplevels only (layer surfaces ignore it).
- fn overflow_margin(&self) -> u32 {
- 0
- }
-
- fn desired_size(&self) -> Option<(u32, u32)> {
- None
- }
-
- fn ui_context(&self) -> Option<&crate::context::UiContext> {
- None
- }
-
- fn ui_context_mut(&mut self) -> Option<&mut crate::context::UiContext> {
- None
- }
-
- /// Where a widget's open popover is DRAWN, as an offset from the rect it
- /// reports (`popover_rect`). A widget reports in the coordinates it was
- /// laid out in; an app that lays its page out unscrolled and shifts what
- /// it emits draws the popover `scroll` px away from there, and returns
- /// `(0.0, -scroll_y)` here for the page's widgets. Everything the engine
- /// derives from a popover rect reads it through this: the text-occlusion
- /// clamp, the overflow input region, and the region sent to the
- /// compositor. Without it a menu opened on a scrolled page had the page's
- /// text drawn over it, and cut a menu-shaped hole in the text one scroll
- /// offset away.
- fn popover_offset(&self, _id: crate::widget::WidgetId) -> (f32, f32) {
- (0.0, 0.0)
- }
-
- /// Whether a left-press at (px, py) should start a compositor window drag. Every root
- /// root plate container is dissolved (Phase 6), so the default is "no" — apps that want
- /// drag-anywhere override this with `ctx.drag_allowed_at(px, py)`.
- fn is_movable_root_plate_at(&self, _px: f32, _py: f32) -> bool {
- false
- }
-
- fn clear_color(&self) -> [f32; 4] {
- [0.0, 0.0, 0.0, 0.0]
- }
-
- fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
-
- fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
- (width, height)
- }
-
- /// Mime types this app accepts from a drag, in the app's own preference
- /// order (the source's order is ignored — a browser lists `text/html`
- /// before `text/uri-list` and which is more useful is the app's call).
- /// The default is empty: the app accepts nothing and drags over it read
- /// as "can't drop here", which is what every client did before drops
- /// existed. Opting in also requires [`Application::handle_drop`].
- fn drop_mimes(&self) -> &'static [&'static str] {
- &[]
- }
-
- /// A completed drop: `data` is everything the source wrote for `mime`,
- /// and `pos` is where it was released in the app's logical coordinates.
- /// Runs on the main loop, after the transfer finished — this is not the
- /// place to block, since the compositor is waiting on the next frame.
- fn handle_drop(
- &mut self,
- _mime: &str,
- _data: &[u8],
- _pos: LogicalPosition,
- _needs_rebuild: &mut bool,
- ) {
- }
-
- fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool);
- fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message>;
- fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool);
- /// Trackpad pinch (zwp_pointer_gestures pinch). `factor` is the scale
- /// change SINCE THE LAST update (1.0 = no change, >1 = fingers spreading),
- /// so direct-manipulation zoom is `content_scale *= factor`. Return true
- /// to consume; returning false falls back to the engine's legacy
- /// synthesis — a ctrl+wheel PixelDelta sized for the graph's zoom mapping
- /// (`y = (factor-1)/0.015`) — so ctrl-scroll-zoom surfaces keep working
- /// without implementing this.
- fn handle_pinch(&mut self, _factor: f32, _pos: LogicalPosition, _needs_rebuild: &mut bool) -> bool {
- false
- }
- fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message>;
-
- /// Undo, after the focused widget declined the chord (a text box that is
- /// editing takes it for its own typing). Return true when something was
- /// undone; false lets the key fall through to `handle_key_input` like any
- /// other. The chords are `undo` / `redo` in `input.kdl` (cce-ui domain
- /// defaults `ctrl+z` / `ctrl+shift+z`), resolved once at startup. Build
- /// the history on `cce_ui::history::History`.
- fn undo(&mut self, _needs_rebuild: &mut bool) -> bool {
- false
- }
-
- /// Redo — see [`undo`](Self::undo).
- fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
- false
- }
-
- /// Opt into the toolkit's keyboard navigation in plate terms: Tab and
- /// Shift+Tab move focus to the next / previous plate or well in reading
- /// order (`UiContext::focus_step`), a press (Enter / Space) acts on the
- /// focused plate, a well opens for typing when focused. Default false: an
- /// app that routes Tab itself (a terminal, a web view, its own field
- /// order) is undisturbed. See "Plates, wells and seams" in `CLAUDE.md`.
- fn plate_navigation(&self) -> bool {
- false
- }
-
- /// Wait for the NEXT compositor when this one goes away, instead of
- /// exiting. Default false, which is right for any window the compositor
- /// saves and restores: its successor respawns the app itself, and a
- /// client that rejoined too came up beside its own copy (see
- /// [`after_session`]). Return true from a process the compositor does NOT
- /// restore and that must outlive it — a systemd user service like the
- /// status bar or the notifier, whose D-Bus names (the tray's
- /// StatusNotifierWatcher, org.freedesktop.Notifications) other programs
- /// depend on. Exiting took those names down at every logout and
- /// compositor restart, and Dropbox, starting into the gap, found no tray.
- fn outlives_compositor(&self) -> bool {
- false
- }
-
- /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
- /// caches its geometry until its own rebuild flag (relief carves collected
- /// in a view pass, widget lists built on layout) raises that flag here, so
- /// the new ring is drawn; an app that paints fresh every frame needs
- /// nothing. Default: nothing.
- fn focus_stepped(&mut self) {}
- /// Keyboard focus entered/left the window (the compositor keyboard-focuses
- /// the focused window, so this is the "am I the focused window" signal —
- /// e.g. for focus-dependent chrome). Default: ignore.
- fn handle_focus_change(&mut self, _focused: bool, _needs_rebuild: &mut bool) {}
-
- fn custom_vertices(&mut self, _verts: &mut Vec<Vertex>, _size: LogicalSize, _scale: f64) {}
-
- /// The frame's geometry, drawn via one batched, GPU-scissor-clipped pass (the single
- /// paint path). Every rendering app implements this — the legacy `view*` sinks are gone;
- /// `None` yields an empty frame. Overlays ([`overlay_quads`](Application::overlay_quads))
- /// and [`custom_vertices`](Application::custom_vertices) still go through their own paths;
- /// text renders from the list when [`display_list_text`](Application::display_list_text)
- /// opts in. Receives the frame's logical size and HiDPI scale. Typically implemented as
- /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, &self.root))`.
- fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<crate::scene::paint::DisplayList> {
- None
- }
-
- /// Opt in to render the display list's `Prim::Text` items through the glyph pass
- /// (shaped via the shared buffer cache, clipped to the item clip ∩ the prim bounds). An
- /// app's ENTIRE frame — geometry and text — is then one
- /// [`display_list`](Application::display_list). Default `false` draws no text (an app that
- /// only draws geometry, or none at all).
- ///
- /// Display-list text gets the same popover-occlusion clamp as the legacy `text_areas`
- /// mapping (`popover_occlusion_clamp`, driven by `ui_context().active_popovers`), so an
- /// open popover's plate clips list text beneath it on both paths.
- fn display_list_text(&self) -> bool {
- false
- }
-
- /// Opt into system fonts in the ENGINE's render `FontSystem` (the one that shapes
- /// display-list text and rasterizes every glyph at prepare time). Default `false`: the
- /// render FontSystem loads only the bundled CCE fonts, and text asking for a family that
- /// exists only among installed system fonts is silently invisible — buffers shaped
- /// app-side against a system-fonts `FontSystem` carry fontdb face IDs the engine's
- /// database doesn't have (the cce-colors Phase 6e bug). An app whose UI must render
- /// arbitrary installed families (the font picker) returns `true`; its own `FontSystem`,
- /// if it keeps one for measurement, should be `create_font_system_with_system_fonts()`
- /// so both databases load identically. Consulted once, at GPU init.
- fn load_system_fonts(&self) -> bool {
- false
- }
-
- /// Called once, right after the renderer is created and before the first
- /// frame: create persistent renderer resources here (3D meshes via
- /// [`VkRenderer::create_mesh`]). Most 2D apps never need this.
- fn renderer_init(&mut self, _renderer: &mut VkRenderer) {}
-
- /// Direct renderer staging, called every frame after the engine's own text
- /// prep and immediately before the frame is drawn: stage 3D scene panes
- /// (`stage_scene`), path-traced panes (`stage_rt`), flush mesh updates, or
- /// prepare app-shaped text (`prepare_text` — an app that returns `false`
- /// from [`display_list_text`](Application::display_list_text) fully owns
- /// the renderer's text state, the engine never touches it). Return `true`
- /// to request another frame immediately (e.g. while a path tracer is still
- /// accumulating samples).
- fn stage_renderer(&mut self, _renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
- false
- }
-
- /// The surface was resized (or the scale factor changed): `width`/`height`
- /// are the new logical size. The renderer has already been resized; use
- /// this for stateful relayout that can't wait for the next paint callback.
- fn handle_resize(&mut self, _width: f32, _height: f32, _scale: f64) {}
-
- /// Whether the runner's built-in client-side decorations apply: the
- /// titlebar move band, the movable-root plate drag regions, and — when
- /// [`csd_resize_borders`](Application::csd_resize_borders) is also on —
- /// the rect-edge resize grabs and their edge cursors. Return `false` for a
- /// window whose chrome doesn't follow its rect (e.g. a circular pane) and
- /// drive moves/resizes yourself via
- /// [`take_window_action`](Application::take_window_action).
- fn standard_csd(&self) -> bool {
- true
- }
-
- /// Whether the standard CSD claims the outer 8px of the surface as resize
- /// grabs (with matching edge cursors). Off by default: under the cce
- /// compositor the server already provides a resize band just *outside* the
- /// window, so enabling this gives a window two adjacent 8px gutters driven
- /// by different code paths — and only the compositor's snaps to the
- /// desktop grid. It also costs the app clicks, since a press inside the
- /// band starts a grab and never reaches the widgets underneath.
- ///
- /// Turn it on for a window that must be resizable by its own edges under a
- /// compositor that provides no such affordance. Only consulted when
- /// [`standard_csd`](Application::standard_csd) is on.
- fn csd_resize_borders(&self) -> bool {
- false
- }
-
- /// Whether the standard CSD reserves an implicit title-bar strip (`y` in `[8, 32)`) as a
- /// drag-to-move handle. Opt-in: off by default, so a window has no title bar and is moved
- /// through the compositor (or via explicitly-declared handles —
- /// [`is_movable_root_plate_at`](Application::is_movable_root_plate_at)); nothing is
- /// implicitly draggable. An app with an actual title bar returns `true`. Separate from
- /// [`standard_csd`](Application::standard_csd), which also gates the resize borders, and
- /// only consulted when `standard_csd()` is on.
- fn csd_titlebar_move(&self) -> bool {
- false
- }
-
- /// Override the pointer cursor at (x, y). `None` falls back to the
- /// runner's standard CSD edge cursors (or `Default` when
- /// [`standard_csd`](Application::standard_csd) is off).
- fn cursor_icon(&self, _x: f32, _y: f32) -> Option<CursorIcon> {
- None
- }
-
- /// Polled after each pointer frame is dispatched: return a
- /// [`WindowAction`] to start an interactive move/resize grab with the
- /// serial of the most recent pointer press. This is take-semantics — the
- /// implementation should clear its pending action when returning it.
- fn take_window_action(&mut self) -> Option<WindowAction> {
- None
- }
+use crate::vk::{Frame2D, VkRenderer};
- /// Called once when the event loop ends (window closed, app-requested
- /// exit): last-chance work like autosave. The surface is still alive.
- fn on_exit(&mut self) {}
-}
+pub use super::app::*;
+pub use super::tessellate::*;
+pub use super::text::*;
pub struct PressedKey {
pub logical_key: Key,
@@ -6672,69 +2756,6 @@ fn run_session<'l, A: Application>(
(app, end)
}
-// `all(test, debug_assertions)`: the function under test only exists in
-// debug builds, so a `cargo test --release` must compile the module out too.
-#[cfg(all(test, debug_assertions))]
-mod near_roll_fallback_tests {
- use super::near_roll_fallback_reason;
- use crate::scene::layout::Rect;
-
- fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
- Rect { x, y, width: w, height: h }
- }
-
- const HOST: Rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
- const ROLL: f32 = 8.0;
-
- #[test]
- fn interior_carve_is_quiet() {
- // Well inside the deflated host: the overlay fallback is exact there.
- let carve = r(100.0, 100.0, 200.0, 100.0);
- assert_eq!(near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false), None);
- }
-
- #[test]
- fn shaded_region_reaching_the_roll_is_loud() {
- // Carve rect stops 3px short of the roll band, but its shaded region
- // (depth*0.5 + 2 = 5px) crosses in — the inflation must count.
- let carve = r(ROLL + 3.0, 100.0, 200.0, 100.0);
- assert_eq!(
- near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false),
- Some("the host's feature run is closed (another plate appended features since)")
- );
- }
-
- #[test]
- fn occlusion_is_named_before_run_contiguity() {
- let carve = r(2.0, 100.0, 200.0, 100.0);
- let occluder = r(150.0, 150.0, 100.0, 100.0);
- assert_eq!(
- near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], false),
- Some("a later plate overlaps the carve's shaded region")
- );
- }
-
- #[test]
- fn non_overlapping_later_plate_is_not_occlusion() {
- let carve = r(2.0, 100.0, 200.0, 100.0);
- let elsewhere = r(500.0, 400.0, 100.0, 100.0);
- assert_eq!(
- near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[elsewhere], false),
- Some("the host's feature run is closed (another plate appended features since)")
- );
- }
-
- #[test]
- fn budget_wins_over_every_other_reason() {
- let carve = r(2.0, 100.0, 200.0, 100.0);
- let occluder = r(150.0, 150.0, 100.0, 100.0);
- assert_eq!(
- near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], true),
- Some("the feature budget is full")
- );
- }
-}
-
#[cfg(test)]
mod reconnect_tests {
use super::{after_session, AfterSession, SessionEnd, RECONNECT_ATTEMPTS, RECONNECT_RESET};