GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/backend/window_runner.rs (292.2K)
1 use std::time::Instant;
2 use smithay_client_toolkit::{
3 compositor::{CompositorHandler, CompositorState},
4 data_device_manager::DataDeviceManagerState,
5 delegate_compositor, delegate_keyboard, delegate_pointer, delegate_registry,
6 delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window, delegate_output,
7 delegate_layer,
8 registry::{ProvidesRegistryState, RegistryState},
9 output::{OutputHandler, OutputState},
10 seat::{
11 keyboard::KeyboardHandler,
12 pointer::{PointerHandler, ThemedPointer, ThemeSpec, CursorIcon},
13 Capability, SeatHandler, SeatState,
14 },
15 shell::{
16 xdg::{
17 window::{Window as XdgWindow, WindowConfigure, WindowHandler, WindowDecorations},
18 XdgShell, XdgSurface as XdgSurfaceExt,
19 },
20 wlr_layer::{LayerShell, LayerShellHandler, LayerSurface, LayerSurfaceConfigure},
21 WaylandSurface,
22 },
23 shm::{Shm, ShmHandler},
24 };
25 use wayland_client::{
26 globals::{registry_queue_init, GlobalList},
27 protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface, wl_registry, wl_region, wl_callback},
28 Connection, QueueHandle, Proxy,
29 };
30
31 use wayland_protocols::wp::pointer_gestures::zv1::client::{
32 zwp_pointer_gesture_pinch_v1::{self, ZwpPointerGesturePinchV1},
33 zwp_pointer_gestures_v1::{self as zwp_pointer_gestures, ZwpPointerGesturesV1},
34 };
35 pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel;
36 pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
37 use calloop::EventLoop;
38 use calloop_wayland_source::WaylandSource;
39 use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
40 use crate::widget::{WidgetHost, TextItem, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
41 use crate::wayland::detect_scale_factor;
42 use crate::vk::{Batch2D, Frame2D, TextSpan, VkRenderer};
43
44 #[derive(Hash, PartialEq, Eq, Clone)]
45 struct BufferCacheKey {
46 text: String,
47 size_milli: u32,
48 font: Option<String>,
49 is_vertical: bool,
50 attrs: crate::scene::paint::TextAttrs,
51 }
52
53 #[derive(Clone)]
54 struct CachedBuffer {
55 buffer: Buffer,
56 last_accessed: std::time::Instant,
57 }
58
59 std::thread_local! {
60 static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<BufferCacheKey, CachedBuffer>> = std::cell::RefCell::new(std::collections::HashMap::new());
61 }
62
63 /// A droplet spec resolved against a concrete rect: the push-constant fields
64 /// that define its SILHOUETTE, in logical px.
65 ///
66 /// Shared by [`crate::scene::paint::Prim::Droplet`] and
67 /// [`crate::scene::paint::Prim::DropletScrim`] so the lit drop and the vignette
68 /// drawn inside it can never disagree about the shape — the whole reason the
69 /// scrim rides the droplet's shader path instead of approximating the outline
70 /// with a rounded rect.
71 struct DropletGeom {
72 hx: f32,
73 hy: f32,
74 sag: f32,
75 br: f32,
76 bw: f32,
77 k: f32,
78 sr: f32,
79 ar: f32,
80 band: f32,
81 bow: f32,
82 /// How far the contact shadow reaches below/beside the box (0 when the
83 /// spec has no shadow). The lit drop's cover quad grows by this; a scrim
84 /// never draws outside the silhouette and ignores it.
85 sh_reach: f32,
86 }
87
88 fn droplet_geom(rect: &crate::scene::layout::Rect, spec: &crate::scene::paint::DropletSpec) -> DropletGeom {
89 let hx = rect.width * 0.5;
90 let hy = rect.height * 0.5;
91 let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
92 // belly <= 0 disables the belly outright (the oval-dewdrop default) — the
93 // shader skips the smin when the radius is 0.
94 let (br, bw) = if spec.belly > 0.0 {
95 let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
96 (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
97 } else {
98 (0.0, 0.0)
99 };
100 let k = (spec.blend.max(0.0) * rect.height).max(1.0);
101 let sheet_hy = hy - sag * 0.5;
102 // Bottom (sheet_r) and top (attach) corner radii: when the pair overfills
103 // the sheet height, scale both down proportionally — 0.5 + 0.5 is the
104 // fully continuous egg.
105 let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
106 let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
107 let sheet_h = (2.0 * sheet_hy).max(0.0);
108 if sr + ar > sheet_h && sr + ar > 0.0 {
109 let f = sheet_h / (sr + ar);
110 sr *= f;
111 ar *= f;
112 }
113 let band = (spec.band.max(0.05) * rect.height).max(1.0);
114 // Bottom-bow edge rise; the shader derives the arc radius from it per drop
115 // (R = hx^2/2*rise).
116 let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
117 let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
118 DropletGeom { hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach }
119 }
120
121 fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
122 let lower_name = name.to_lowercase();
123 for face in fs.db().faces() {
124 for (family, _) in &face.families {
125 if family.to_lowercase() == lower_name {
126 return Some(family.clone());
127 }
128 }
129 }
130 None
131 }
132
133 thread_local! {
134 /// Family name → is-monospaced, resolved once per family from fontdb's
135 /// face metadata (the post table's isFixedPitch, as fontdb records it).
136 static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
137 std::cell::RefCell::new(std::collections::HashMap::new());
138 }
139
140 fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
141 MONO_FAMILY_CACHE.with(|cache| {
142 if let Some(&mono) = cache.borrow().get(name) {
143 return mono;
144 }
145 let lower = name.to_lowercase();
146 let mono = fs
147 .db()
148 .faces()
149 .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
150 .map(|face| face.monospaced)
151 .unwrap_or(false);
152 cache.borrow_mut().insert(name.to_string(), mono);
153 mono
154 })
155 }
156
157 /// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
158 /// shapes `Basic`, everything else `Advanced`.
159 ///
160 /// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
161 /// mono face that is exactly right: a mono font's ligatures are the one thing
162 /// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
163 /// squeezes f+i into a single-advance fi glyph, which is why the bar's window
164 /// titles rendered "file" with a cramped fi — while mono faces carry no
165 /// kerning to lose. Proportional faces keep `Advanced` (their kerning and
166 /// ligatures are wanted — a font preview must not misrepresent the face), and
167 /// any non-ASCII text keeps real shaping (combining marks, emoji, complex
168 /// scripts) whatever the face.
169 pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
170 if text.is_ascii() {
171 if let cosmic_text::Family::Name(name) = family {
172 if family_is_monospaced(fs, name) {
173 return cosmic_text::Shaping::Basic;
174 }
175 }
176 }
177 cosmic_text::Shaping::Advanced
178 }
179
180 pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
181 get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
182 }
183
184 /// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
185 /// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
186 pub fn get_text_buffer_attrs(
187 fs: &mut FontSystem,
188 text: &str,
189 size: f32,
190 font: Option<&str>,
191 text_attrs: crate::scene::paint::TextAttrs,
192 ) -> Buffer {
193 let scale = crate::scale::scale_factor();
194 let mut font_size = size;
195 let mut family_name = None;
196
197 if let Some(font_str) = font {
198 let (parsed_family, parsed_size) = crate::layout::parse_font_string(font_str);
199 if let Some(ps) = parsed_size {
200 font_size = ps;
201 }
202 family_name = Some(parsed_family);
203 }
204
205 let physical_size = font_size * scale;
206 let size_key = (physical_size * 1000.0).round() as u32;
207 let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
208 let key = BufferCacheKey {
209 text: text.to_string(),
210 size_milli: size_key,
211 font: family_name.clone(),
212 is_vertical,
213 attrs: text_attrs,
214 };
215
216 let cached = BUFFER_CACHE.with(|cache| {
217 let mut cache = cache.borrow_mut();
218 if let Some(cached_item) = cache.get_mut(&key) {
219 cached_item.last_accessed = std::time::Instant::now();
220 Some(cached_item.buffer.clone())
221 } else {
222 None
223 }
224 });
225
226 if let Some(buf) = cached {
227 return buf;
228 }
229
230 let line_height = if is_vertical {
231 physical_size * 1.05
232 } else {
233 physical_size * 1.0
234 };
235 let metrics = Metrics::new(physical_size, line_height);
236 let mut buf = Buffer::new(fs, metrics);
237 let mut attrs = Attrs::new();
238
239 let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
240
241 let resolved_storage = family_name.as_deref().and_then(|font_name| match font_name {
242 "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
243 "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
244 "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
245 _ => None,
246 });
247
248 let resolved_sans = if !sans_fallback.is_empty() {
249 find_cased_family(fs, &sans_fallback)
250 } else {
251 None
252 };
253
254 let family = if let Some(ref font_family) = family_name {
255 match font_family.as_str() {
256 "monospace" => {
257 if !mono_fallback.is_empty() {
258 if let Some(ref cased) = resolved_storage {
259 cosmic_text::Family::Name(cased)
260 } else {
261 cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
262 }
263 } else {
264 cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
265 }
266 }
267 "sans-serif" => {
268 if !sans_fallback.is_empty() {
269 if let Some(ref cased) = resolved_storage {
270 cosmic_text::Family::Name(cased)
271 } else {
272 cosmic_text::Family::SansSerif
273 }
274 } else {
275 cosmic_text::Family::SansSerif
276 }
277 }
278 "serif" => {
279 if !serif_fallback.is_empty() {
280 if let Some(ref cased) = resolved_storage {
281 cosmic_text::Family::Name(cased)
282 } else {
283 cosmic_text::Family::Serif
284 }
285 } else {
286 cosmic_text::Family::Serif
287 }
288 }
289 name => cosmic_text::Family::Name(name),
290 }
291 } else {
292 if !sans_fallback.is_empty() {
293 if let Some(ref cased) = resolved_sans {
294 cosmic_text::Family::Name(cased)
295 } else {
296 cosmic_text::Family::SansSerif
297 }
298 } else {
299 cosmic_text::Family::SansSerif
300 }
301 };
302 attrs = attrs.family(family);
303 if text_attrs.italic {
304 attrs = attrs.style(cosmic_text::Style::Italic);
305 }
306 if let Some(w) = text_attrs.weight {
307 attrs = attrs.weight(cosmic_text::Weight(w));
308 }
309 let shaping = shaping_for(fs, text, &family);
310 buf.set_text(fs, text, attrs, shaping);
311 buf.shape_until_scroll(fs, true);
312
313 BUFFER_CACHE.with(|cache| {
314 let mut cache = cache.borrow_mut();
315 if cache.len() >= 2000 {
316 let mut items: Vec<(BufferCacheKey, std::time::Instant)> = cache
317 .iter()
318 .map(|(k, v)| (k.clone(), v.last_accessed))
319 .collect();
320 items.sort_by_key(|&(_, time)| time);
321 for (k, _) in items.iter().take(100) {
322 cache.remove(k);
323 }
324 }
325 cache.insert(key, CachedBuffer {
326 buffer: buf.clone(),
327 last_accessed: std::time::Instant::now(),
328 });
329 });
330
331 buf
332 }
333
334 /// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
335 /// same buffer cache as the draw, so this is a lookup when the text is already on screen.
336 /// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
337 /// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
338 /// source for caret placement and click→cursor mapping in hand-rolled text fields:
339 /// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
340 /// resolution, which disagrees with cosmic-text's advance and can even resolve a
341 /// different face — a caret placed with it drifts off the drawn glyphs.
342 pub fn shaped_cluster_offsets(
343 fs: &mut FontSystem,
344 text: &str,
345 size: f32,
346 font: Option<&str>,
347 ) -> Vec<(usize, f32)> {
348 let scale = crate::scale::scale_factor().max(1.0);
349 let buffer = get_text_buffer(fs, text, size, font);
350 let mut out: Vec<(usize, f32)> = Vec::new();
351 let mut total: f32 = 0.0;
352 for (start, x, w) in normalized_glyph_starts(&buffer, text) {
353 if out.last().map_or(true, |&(b, _)| b != start) {
354 out.push((start, x / scale));
355 }
356 total = total.max((x + w) / scale);
357 }
358 out.push((text.len(), total));
359 out
360 }
361
362 /// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
363 /// with `start` normalized to be text-relative.
364 ///
365 /// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
366 /// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
367 /// word — while the Advanced path emits line-relative starts. `shaping_for`
368 /// picks Basic exactly for ASCII text in a monospace family (the DE's default
369 /// control font), so any multi-word value hit the bug: offsets keyed by those
370 /// starts collide on the low columns and the caret/selection walk off the
371 /// glyphs. A reset can ONLY come from that path, which shapes strictly one
372 /// glyph per char in logical order — so when one is seen, byte starts are
373 /// rebuilt by walking the text's chars. `text` must be the single line the
374 /// buffer was shaped from.
375 pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
376 let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
377 let mut monotonic = true;
378 let mut prev = 0usize;
379 for run in buffer.layout_runs() {
380 for g in run.glyphs {
381 if g.start < prev {
382 monotonic = false;
383 }
384 prev = g.start;
385 glyphs.push((g.start, g.x, g.w));
386 }
387 }
388 if !monotonic {
389 let mut starts = text.char_indices().map(|(i, _)| i);
390 for g in glyphs.iter_mut() {
391 g.0 = starts.next().unwrap_or(text.len());
392 }
393 }
394 glyphs
395 }
396
397 /// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
398 /// Reuses [`get_text_buffer_attrs`] for all the family resolution — that returns a *clone* of the
399 /// cached single-run buffer, so re-applying metrics/size/align here does not touch the cache — then
400 /// re-lays-it-out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
401 /// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside the
402 /// box per `align_v`. Uncached by construction (each box may differ in width/align).
403 pub fn get_text_buffer_laid_out(
404 fs: &mut FontSystem,
405 text: &str,
406 size: f32,
407 font: Option<&str>,
408 text_attrs: crate::scene::paint::TextAttrs,
409 layout: crate::scene::paint::TextLayout,
410 ) -> (Buffer, f32) {
411 use crate::scene::paint::{AlignH, AlignV};
412 let scale = crate::scale::scale_factor();
413
414 // Resolved family + attrs come for free (a cache clone we are free to mutate).
415 let mut buf = get_text_buffer_attrs(fs, text, size, font, text_attrs);
416
417 // The font string may override the size ("family:size") — mirror get_text_buffer_attrs.
418 let mut font_size = size;
419 if let Some(font_str) = font {
420 if let (_, Some(ps)) = crate::layout::parse_font_string(font_str) {
421 font_size = ps;
422 }
423 }
424 let physical_size = font_size * scale;
425 let line_height = physical_size * 1.4;
426 buf.set_metrics(fs, Metrics::new(physical_size, line_height));
427 buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
428
429 let align = match layout.align_h {
430 AlignH::Left => cosmic_text::Align::Left,
431 AlignH::Center => cosmic_text::Align::Center,
432 AlignH::Right => cosmic_text::Align::Right,
433 };
434 for line in &mut buf.lines {
435 line.set_align(Some(align));
436 }
437 buf.shape_until_scroll(fs, true);
438
439 // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
440 let runs = buf.layout_runs().count();
441 let total_h = runs as f32 * font_size * 1.4;
442 let voff = match layout.align_v {
443 AlignV::Top => 0.0,
444 AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
445 AlignV::Bottom => (layout.box_height - total_h).max(0.0),
446 };
447 (buf, voff)
448 }
449
450 /// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
451 /// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
452 /// it is defined here now that the dependency is cosmic-text directly. Same plain
453 /// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
454 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
455 pub struct TextBounds {
456 pub left: i32,
457 pub top: i32,
458 pub right: i32,
459 pub bottom: i32,
460 }
461
462 /// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
463 /// the display-list text path: clip a text item's bounds so it does not bleed through an open
464 /// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
465 /// text and is left alone; anything else that intersects gets clamped horizontally toward
466 /// whichever side of the popover it starts on.
467 /// Clamp a text item's bounds away from the registered popover rects it
468 /// runs under, so page text does not bleed through a floating plate.
469 ///
470 /// A text item BELONGS to a popover when it carries exactly that popover's
471 /// rect as its bounds (the convention every popover's own labels follow),
472 /// and it is then clamped only against the popovers registered AFTER its
473 /// own — `overlay_rects` is in stacking order, the shared context menu
474 /// last. Before 2026-09-22 a popover's text was exempt from its own rect
475 /// alone and clamped against every other, so a context menu opened over a
476 /// modal dialog had its labels clipped by the dialog it was drawn on top
477 /// of, and showed as a plate with no legible entries.
478 fn popover_occlusion_clamp(
479 overlay_rects: &[(f32, f32, f32, f32)],
480 ti: &TextItem,
481 scale_f32: f32,
482 item_bounds: &mut TextBounds,
483 ) {
484 let owner = ti.bounds.and_then(|[l, t, r, b]| {
485 overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
486 (l - ox).abs() < 1.0
487 && (t - oy).abs() < 1.0
488 && (r - (ox + ow)).abs() < 1.0
489 && (b - (oy + oh)).abs() < 1.0
490 })
491 });
492 let first_above = owner.map_or(0, |k| k + 1);
493 for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
494 let ol = (ox * scale_f32).round() as i32;
495 let ot = (oy * scale_f32).round() as i32;
496 let or = ((ox + ow) * scale_f32).round() as i32;
497 let ob = ((oy + oh) * scale_f32).round() as i32;
498
499 let tx_pixel = ti.x * scale_f32;
500 let ty_pixel = ti.y * scale_f32;
501
502 let mut text_w = 0.0f32;
503 let mut run_count = 0;
504 for run in ti.buffer.layout_runs() {
505 text_w = text_w.max(run.line_w);
506 run_count += 1;
507 }
508 let text_h = run_count as f32 * ti.buffer.metrics().line_height;
509
510 let actual_left = tx_pixel;
511 let actual_right = tx_pixel + text_w;
512 let actual_top = ty_pixel;
513 let actual_bottom = ty_pixel + text_h;
514
515 if actual_left < or as f32
516 && actual_right > ol as f32
517 && actual_top < ob as f32
518 && actual_bottom > ot as f32
519 {
520 if tx_pixel < ol as f32 {
521 item_bounds.right = item_bounds.right.min(ol);
522 } else {
523 item_bounds.left = item_bounds.left.max(or);
524 }
525 }
526 }
527 }
528
529 #[repr(C)]
530 #[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
531 pub struct Vertex {
532 pub position: [f32; 2],
533 pub color: [f32; 4],
534 pub clip_circle: [f32; 3], // [cx, cy, r]
535 }
536
537 pub fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [f32; 4]) -> [Vertex; 6] {
538 let x0 = (x / sw) * 2.0 - 1.0;
539 let y0 = 1.0 - (y / sh) * 2.0;
540 let x1 = ((x + w) / sw) * 2.0 - 1.0;
541 let y1 = 1.0 - ((y + h) / sh) * 2.0;
542 [
543 Vertex { position: [x0, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
544 Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
545 Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
546 Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
547 Vertex { position: [x1, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
548 Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
549 ]
550 }
551
552 pub fn quad_vertices_with_clip(
553 x: f32, y: f32, w: f32, h: f32,
554 sw: f32, sh: f32,
555 color: [f32; 4],
556 clip_circle: [f32; 3],
557 ) -> [Vertex; 6] {
558 let x0 = (x / sw) * 2.0 - 1.0;
559 let y0 = 1.0 - (y / sh) * 2.0;
560 let x1 = ((x + w) / sw) * 2.0 - 1.0;
561 let y1 = 1.0 - ((y + h) / sh) * 2.0;
562 [
563 Vertex { position: [x0, y0], color, clip_circle },
564 Vertex { position: [x1, y0], color, clip_circle },
565 Vertex { position: [x0, y1], color, clip_circle },
566 Vertex { position: [x1, y0], color, clip_circle },
567 Vertex { position: [x1, y1], color, clip_circle },
568 Vertex { position: [x0, y1], color, clip_circle },
569 ]
570 }
571
572 /// A quad whose four corners each carry their own color, Gouraud-interpolated across both
573 /// triangles by the shader (`@location(0) color` has no `flat` qualifier). Corner order is
574 /// TL, TR, BR, BL. Keep the alpha equal on all four: negative alpha is the blur sentinel,
575 /// so a gradient that crossed zero would tear the triangle in half.
576 pub fn quad_vertices_shaded(
577 x: f32, y: f32, w: f32, h: f32,
578 sw: f32, sh: f32,
579 c_tl: [f32; 4], c_tr: [f32; 4], c_br: [f32; 4], c_bl: [f32; 4],
580 clip_circle: [f32; 3],
581 ) -> [Vertex; 6] {
582 let x0 = (x / sw) * 2.0 - 1.0;
583 let y0 = 1.0 - (y / sh) * 2.0;
584 let x1 = ((x + w) / sw) * 2.0 - 1.0;
585 let y1 = 1.0 - ((y + h) / sh) * 2.0;
586 [
587 Vertex { position: [x0, y0], color: c_tl, clip_circle },
588 Vertex { position: [x1, y0], color: c_tr, clip_circle },
589 Vertex { position: [x0, y1], color: c_bl, clip_circle },
590 Vertex { position: [x1, y0], color: c_tr, clip_circle },
591 Vertex { position: [x1, y1], color: c_br, clip_circle },
592 Vertex { position: [x0, y1], color: c_bl, clip_circle },
593 ]
594 }
595
596 pub fn quad_vertices_clipped(
597 x: f32, y: f32, w: f32, h: f32,
598 surface_w: f32, surface_h: f32,
599 color: [f32; 4],
600 clip: (f32, f32, f32, f32),
601 clip_circle: [f32; 3],
602 ) -> Vec<Vertex> {
603 let (cx0, cy0, cx1, cy1) = clip;
604 let ix0 = x.max(cx0);
605 let iy0 = y.max(cy0);
606 let ix1 = (x + w).min(cx1);
607 let iy1 = (y + h).min(cy1);
608 if ix1 <= ix0 || iy1 <= iy0 {
609 return Vec::new();
610 }
611 quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, surface_w, surface_h, color, clip_circle).to_vec()
612 }
613
614 pub fn line_vertices(
615 x1: f32, y1: f32, x2: f32, y2: f32,
616 thickness: f32,
617 sw: f32, sh: f32,
618 c: [f32; 4]
619 ) -> [Vertex; 6] {
620 let dx = x2 - x1;
621 let dy = y2 - y1;
622 let len = (dx * dx + dy * dy).sqrt();
623 if len < 0.001 {
624 return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c);
625 }
626 let ux = dx / len;
627 let uy = dy / len;
628 let nx = -uy;
629 let ny = ux;
630
631 let half_t = thickness * 0.5;
632 let p0x = x1 + nx * half_t;
633 let p0y = y1 + ny * half_t;
634 let p1x = x1 - nx * half_t;
635 let p1y = y1 - ny * half_t;
636 let p2x = x2 - nx * half_t;
637 let p2y = y2 - ny * half_t;
638 let p3x = x2 + nx * half_t;
639 let p3y = y2 + ny * half_t;
640
641 let ndc_p0x = (p0x / sw) * 2.0 - 1.0;
642 let ndc_p0y = 1.0 - (p0y / sh) * 2.0;
643 let ndc_p1x = (p1x / sw) * 2.0 - 1.0;
644 let ndc_p1y = 1.0 - (p1y / sh) * 2.0;
645 let ndc_p2x = (p2x / sw) * 2.0 - 1.0;
646 let ndc_p2y = 1.0 - (p2y / sh) * 2.0;
647 let ndc_p3x = (p3x / sw) * 2.0 - 1.0;
648 let ndc_p3y = 1.0 - (p3y / sh) * 2.0;
649
650 let clip_circle = [0.0, 0.0, 0.0];
651 [
652 Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
653 Vertex { position: [ndc_p1x, ndc_p1y], color: c, clip_circle },
654 Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
655 Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
656 Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
657 Vertex { position: [ndc_p3x, ndc_p3y], color: c, clip_circle },
658 ]
659 }
660
661 #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
662 pub enum LineCap {
663 Arrow,
664 Round,
665 Flat,
666 }
667
668 pub fn vector_vertices(
669 x1: f32, y1: f32, x2: f32, y2: f32,
670 thickness: f32,
671 sw: f32, sh: f32,
672 c: [f32; 4],
673 line_cap: LineCap,
674 ) -> Vec<Vertex> {
675 let mut verts = Vec::new();
676 let dx = x2 - x1;
677 let dy = y2 - y1;
678 let len = (dx * dx + dy * dy).sqrt();
679 if len < 0.001 {
680 return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c).to_vec();
681 }
682
683 match line_cap {
684 LineCap::Arrow => {
685 let ux = dx / len;
686 let uy = dy / len;
687 let nx = -uy;
688 let ny = ux;
689
690 let arrow_len = (thickness * 3.0).max(10.0).min(len);
691 let arrow_width = (thickness * 2.5).max(8.0);
692
693 let line_x2 = x2 - ux * arrow_len;
694 let line_y2 = y2 - uy * arrow_len;
695
696 if len > arrow_len {
697 verts.extend_from_slice(&line_vertices(x1, y1, line_x2, line_y2, thickness, sw, sh, c));
698 }
699
700 let bx = line_x2;
701 let by = line_y2;
702
703 let w1x = bx + nx * (arrow_width * 0.5);
704 let w1y = by + ny * (arrow_width * 0.5);
705 let w2x = bx - nx * (arrow_width * 0.5);
706 let w2y = by - ny * (arrow_width * 0.5);
707
708 let ndc_tip_x = (x2 / sw) * 2.0 - 1.0;
709 let ndc_tip_y = 1.0 - (y2 / sh) * 2.0;
710 let ndc_w1x = (w1x / sw) * 2.0 - 1.0;
711 let ndc_w1y = 1.0 - (w1y / sh) * 2.0;
712 let ndc_w2x = (w2x / sw) * 2.0 - 1.0;
713 let ndc_w2y = 1.0 - (w2y / sh) * 2.0;
714
715 let clip_circle = [0.0, 0.0, 0.0];
716 verts.push(Vertex { position: [ndc_tip_x, ndc_tip_y], color: c, clip_circle });
717 verts.push(Vertex { position: [ndc_w1x, ndc_w1y], color: c, clip_circle });
718 verts.push(Vertex { position: [ndc_w2x, ndc_w2y], color: c, clip_circle });
719 }
720 LineCap::Round => {
721 push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
722 let clip_circle = [0.0, 0.0, 0.0];
723 verts.extend(circle_vertices(x2, y2, thickness / 2.0, sw, sh, c, 16, clip_circle));
724 }
725 LineCap::Flat => {
726 push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
727 }
728 }
729
730 verts
731 }
732
733 /// `line_vertices` with a half-px alpha ramp along each long edge (the arc
734 /// tessellator's poor-man's AA) — diagonal strokes resolve smoothly instead of
735 /// stair-stepping. Axis-aligned strokes keep the crisp single-quad path:
736 /// feathering a pixel-snapped hairline would only blur it.
737 fn push_feathered_line_vertices(
738 x1: f32, y1: f32, x2: f32, y2: f32,
739 thickness: f32,
740 sw: f32, sh: f32,
741 c: [f32; 4],
742 out: &mut Vec<Vertex>,
743 ) {
744 let dx = x2 - x1;
745 let dy = y2 - y1;
746 let len = (dx * dx + dy * dy).sqrt();
747 if len < 0.001 || dx.abs() < 0.01 || dy.abs() < 0.01 {
748 out.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
749 return;
750 }
751 let (nx, ny) = (-dy / len, dx / len);
752 let f = 0.5f32.min(thickness * 0.25);
753 let half = thickness * 0.5;
754 // (offset at band start, offset at band end, alpha at start, alpha at end)
755 let bands = [
756 (-half - f, -half + f, 0.0, c[3]),
757 (-half + f, half - f, c[3], c[3]),
758 (half - f, half + f, c[3], 0.0),
759 ];
760 for &(oa, ob, aa, ab) in &bands {
761 let ca = [c[0], c[1], c[2], aa];
762 let cb = [c[0], c[1], c[2], ab];
763 let p = |x: f32, y: f32, o: f32| -> [f32; 2] {
764 [((x + nx * o) / sw) * 2.0 - 1.0, 1.0 - ((y + ny * o) / sh) * 2.0]
765 };
766 let clip_circle = [0.0, 0.0, 0.0];
767 let (a1, b1) = (p(x1, y1, oa), p(x1, y1, ob));
768 let (a2, b2) = (p(x2, y2, oa), p(x2, y2, ob));
769 out.push(Vertex { position: a1, color: ca, clip_circle });
770 out.push(Vertex { position: b1, color: cb, clip_circle });
771 out.push(Vertex { position: b2, color: cb, clip_circle });
772 out.push(Vertex { position: a1, color: ca, clip_circle });
773 out.push(Vertex { position: b2, color: cb, clip_circle });
774 out.push(Vertex { position: a2, color: ca, clip_circle });
775 }
776 }
777
778 pub fn rounded_rect_vertices_corners(
779 x: f32, y: f32, ww: f32, h: f32,
780 r: f32,
781 sw: f32, sh: f32,
782 color: [f32; 4],
783 clip_circle: [f32; 3],
784 corners: (bool, bool, bool, bool),
785 clip_rect: Option<(f32, f32, f32, f32)>,
786 ) -> Vec<Vertex> {
787 let mut verts = Vec::new();
788 let radii = crate::widget::CornerRadii::new(
789 if corners.0 { r } else { 0.0 },
790 if corners.1 { r } else { 0.0 },
791 if corners.2 { r } else { 0.0 },
792 if corners.3 { r } else { 0.0 },
793 );
794 push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, color, clip_circle, clip_rect, &mut verts);
795 verts
796 }
797
798 /// Sample of the unit superellipse |x|^n + |y|^n = 1 at circle parameter θ —
799 /// the (cos θ, sin θ) replacement the corner fans use. Exactly the circle at
800 /// n = 2; higher `corner_shape` exponents give the DE's continuous-curvature
801 /// corners, so widget silhouettes follow the same corner family as the
802 /// SDF-lit plates. `e` is 2/n, hoisted by callers. Tangent points at the
803 /// quadrant ends are unchanged, so fans still tile exactly against the body
804 /// rects and edge strips.
805 #[inline]
806 fn superellipse_pt(theta: f32, e: f32) -> (f32, f32) {
807 let (s, c) = theta.sin_cos();
808 (c.signum() * c.abs().powf(e), s.signum() * s.abs().powf(e))
809 }
810
811 /// Feathered glow ([`Prim::Glow`]): the rounded rect's interior fills at the
812 /// color's alpha and concentric outline rings fade it to zero across `reach`
813 /// px outside the boundary. Alpha rides the VERTICES, so the GPU interpolates
814 /// a per-pixel-smooth falloff between rings — stacked translucent layers band
815 /// visibly; this cannot. Ring alphas sit on a quadratic ease-out, giving the
816 /// vignette profile piecewise-linearly with kinks below visibility at glow
817 /// alphas. Corners sample [`superellipse_pt`], so a glow's silhouette sits in
818 /// the same corner family as the cells, nodes, and plates it highlights.
819 pub fn push_glow_vertices(
820 x: f32, y: f32, ww: f32, h: f32,
821 radius: f32, reach: f32,
822 sw: f32, sh: f32,
823 color: [f32; 4],
824 clip_circle: [f32; 3],
825 out: &mut Vec<Vertex>,
826 ) {
827 if ww <= 0.0 || h <= 0.0 || color[3].abs() <= 0.0005 || sw <= 0.0 || sh <= 0.0 {
828 return;
829 }
830 let r0 = radius.clamp(0.0, ww.min(h) * 0.5);
831 let ctl = (x + r0, y + r0);
832 let ctr = (x + ww - r0, y + r0);
833 let cbr = (x + ww - r0, y + h - r0);
834 let cbl = (x + r0, y + h - r0);
835 const K: usize = 10;
836 use std::f32::consts::PI;
837 let corner_e = 2.0 / crate::layout::corner_shape();
838 // One outline ring `off` px outside the boundary, clockwise from the
839 // top-left arc; every ring shares the layout, so strips never twist.
840 let ring = |off: f32| -> Vec<[f32; 2]> {
841 let r = (r0 + off).max(0.0);
842 let mut pts = Vec::with_capacity(4 * (K + 1));
843 let corners = [
844 (ctl, PI, 1.5 * PI),
845 (ctr, 1.5 * PI, 2.0 * PI),
846 (cbr, 0.0, 0.5 * PI),
847 (cbl, 0.5 * PI, PI),
848 ];
849 for ((cx, cy), a0, a1) in corners {
850 for k in 0..=K {
851 let a = a0 + (a1 - a0) * (k as f32 / K as f32);
852 let (ux, uy) = superellipse_pt(a, corner_e);
853 pts.push([cx + r * ux, cy + r * uy]);
854 }
855 }
856 pts
857 };
858 let to_v = |p: [f32; 2], a: f32| Vertex {
859 position: [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0],
860 color: [color[0], color[1], color[2], a],
861 clip_circle,
862 };
863
864 let rings: Vec<(Vec<[f32; 2]>, f32)> = [0.0f32, 0.35, 0.7, 1.0]
865 .iter()
866 .map(|&t| (ring(reach * t), color[3] * (1.0 - t) * (1.0 - t)))
867 .collect();
868 let n = rings[0].0.len();
869
870 // Interior: a fan from the rect center over the innermost ring (a rounded
871 // rect is convex, so the fan covers it exactly), uniform core alpha.
872 let center = [x + ww * 0.5, y + h * 0.5];
873 for i in 0..n {
874 let p1 = rings[0].0[i];
875 let p2 = rings[0].0[(i + 1) % n];
876 out.push(to_v(center, color[3]));
877 out.push(to_v(p1, color[3]));
878 out.push(to_v(p2, color[3]));
879 }
880 // The feather: strips between consecutive rings, each vertex carrying its
881 // ring's alpha.
882 for w in rings.windows(2) {
883 let (inner, ia) = (&w[0].0, w[0].1);
884 let (outer, oa) = (&w[1].0, w[1].1);
885 for i in 0..n {
886 let a1 = inner[i];
887 let a2 = inner[(i + 1) % n];
888 let b1 = outer[i];
889 let b2 = outer[(i + 1) % n];
890 out.push(to_v(a1, ia));
891 out.push(to_v(b1, oa));
892 out.push(to_v(a2, ia));
893 out.push(to_v(a2, ia));
894 out.push(to_v(b1, oa));
895 out.push(to_v(b2, oa));
896 }
897 }
898 }
899
900 pub fn push_rounded_rect_vertices_corners(
901 x: f32, y: f32, ww: f32, h: f32,
902 radii: crate::widget::CornerRadii,
903 sw: f32, sh: f32,
904 color: [f32; 4],
905 clip_circle: [f32; 3],
906 clip_rect: Option<(f32, f32, f32, f32)>,
907 out: &mut Vec<Vertex>,
908 ) {
909 let corner_e = 2.0 / crate::layout::corner_shape();
910 let mut r_tl = radii.top_left.max(0.0);
911 let mut r_tr = radii.top_right.max(0.0);
912 let mut r_br = radii.bottom_right.max(0.0);
913 let mut r_bl = radii.bottom_left.max(0.0);
914
915 // Simple scale clamping
916 let sum_top = r_tl + r_tr;
917 if sum_top > ww {
918 let f = ww / sum_top;
919 r_tl *= f;
920 r_tr *= f;
921 }
922 let sum_bottom = r_bl + r_br;
923 if sum_bottom > ww {
924 let f = ww / sum_bottom;
925 r_bl *= f;
926 r_br *= f;
927 }
928 let sum_left = r_tl + r_bl;
929 if sum_left > h {
930 let f = h / sum_left;
931 r_tl *= f;
932 r_bl *= f;
933 }
934 let sum_right = r_tr + r_br;
935 if sum_right > h {
936 let f = h / sum_right;
937 r_tr *= f;
938 r_br *= f;
939 }
940
941 let clamp_x = |val: f32| -> f32 {
942 if let Some((cx0, _, cx1, _)) = clip_rect {
943 val.max(cx0).min(cx1)
944 } else {
945 val
946 }
947 };
948 let clamp_y = |val: f32| -> f32 {
949 if let Some((_, cy0, _, cy1)) = clip_rect {
950 val.max(cy0).min(cy1)
951 } else {
952 val
953 }
954 };
955
956 let push_quad = |verts: &mut Vec<Vertex>, qx: f32, qy: f32, qw: f32, qh: f32| {
957 let x0 = clamp_x(qx);
958 let y0 = clamp_y(qy);
959 let x1 = clamp_x(qx + qw);
960 let y1 = clamp_y(qy + qh);
961
962 if x1 <= x0 || y1 <= y0 {
963 return;
964 }
965
966 let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
967 let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
968 let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
969 let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
970
971 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
972 verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
973 verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
974 verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
975 verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
976 verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
977 };
978
979 let has_corners = r_tl > 0.1 || r_tr > 0.1 || r_br > 0.1 || r_bl > 0.1;
980 if !has_corners {
981 push_quad(out, x, y, ww, h);
982 return;
983 }
984
985 // Body rectangles
986 let mid_x0 = r_tl.max(r_bl);
987 let mid_x1 = ww - r_tr.max(r_br);
988 if mid_x1 > mid_x0 {
989 push_quad(out, x + mid_x0, y, mid_x1 - mid_x0, h);
990 }
991 if h > r_tl + r_bl {
992 push_quad(out, x, y + r_tl, mid_x0, h - r_tl - r_bl);
993 }
994 if h > r_tr + r_br {
995 push_quad(out, x + mid_x1, y + r_tr, ww - mid_x1, h - r_tr - r_br);
996 }
997
998 // Corner rendering. The fans are FEATHERED: the fan body stops half a
999 // pixel short of the silhouette and a strip fades from opaque at
1000 // silhouette-0.5 to transparent at silhouette+0.5, so the arc
1001 // anti-aliases instead of rasterizing a hard staircase — invisible on
1002 // HiDPI widget buffers, glaring on the desktop grid's world-scale
1003 // cells. Perceived size is unchanged (the 50%-coverage line stays on
1004 // the exact silhouette). Radii too small to feather keep the hard fan.
1005 let segments = 16;
1006 let fade = [color[0], color[1], color[2], 0.0];
1007 let to_ndc = |px: f32, py: f32| -> [f32; 2] {
1008 [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0]
1009 };
1010 let push_corner = |out: &mut Vec<Vertex>, cx: f32, cy: f32, r: f32, start: f32, end: f32| {
1011 let feather = r > 1.5;
1012 let r_fan = if feather { r - 0.5 } else { r };
1013 let r_out = r + 0.5;
1014 for i in 0..segments {
1015 let theta1 = start + (i as f32) * (end - start) / (segments as f32);
1016 let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
1017
1018 let (c1, s1) = superellipse_pt(theta1, corner_e);
1019 let (c2, s2) = superellipse_pt(theta2, corner_e);
1020 let p0 = to_ndc(clamp_x(cx), clamp_y(cy));
1021 let p1 = to_ndc(clamp_x(cx + r_fan * c1), clamp_y(cy + r_fan * s1));
1022 let p2 = to_ndc(clamp_x(cx + r_fan * c2), clamp_y(cy + r_fan * s2));
1023
1024 out.push(Vertex { position: p0, color, clip_circle });
1025 out.push(Vertex { position: p1, color, clip_circle });
1026 out.push(Vertex { position: p2, color, clip_circle });
1027
1028 if feather {
1029 let q1 = to_ndc(clamp_x(cx + r_out * c1), clamp_y(cy + r_out * s1));
1030 let q2 = to_ndc(clamp_x(cx + r_out * c2), clamp_y(cy + r_out * s2));
1031 out.push(Vertex { position: p1, color, clip_circle });
1032 out.push(Vertex { position: q1, color: fade, clip_circle });
1033 out.push(Vertex { position: q2, color: fade, clip_circle });
1034 out.push(Vertex { position: p1, color, clip_circle });
1035 out.push(Vertex { position: q2, color: fade, clip_circle });
1036 out.push(Vertex { position: p2, color, clip_circle });
1037 }
1038 }
1039 };
1040
1041 // Top-Left
1042 if r_tl > 0.1 {
1043 push_corner(out, x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI);
1044 if mid_x0 > r_tl {
1045 push_quad(out, x + r_tl, y, mid_x0 - r_tl, r_tl);
1046 }
1047 }
1048
1049 // Top-Right
1050 if r_tr > 0.1 {
1051 push_corner(out, x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI);
1052 if ww - mid_x1 > r_tr {
1053 push_quad(out, x + mid_x1, y, ww - mid_x1 - r_tr, r_tr);
1054 }
1055 }
1056
1057 // Bottom-Right
1058 if r_br > 0.1 {
1059 push_corner(out, x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI);
1060 if ww - mid_x1 > r_br {
1061 push_quad(out, x + mid_x1, y + h - r_br, ww - mid_x1 - r_br, r_br);
1062 }
1063 }
1064
1065 // Bottom-Left
1066 if r_bl > 0.1 {
1067 push_corner(out, x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI);
1068 if mid_x0 > r_bl {
1069 push_quad(out, x + r_bl, y + h - r_bl, mid_x0 - r_bl, r_bl);
1070 }
1071 }
1072 }
1073
1074 pub fn rounded_rect_vertices(
1075 x: f32, y: f32, ww: f32, h: f32,
1076 r: f32,
1077 sw: f32, sh: f32,
1078 color: [f32; 4],
1079 clip_circle: [f32; 3],
1080 ) -> Vec<Vertex> {
1081 let mut verts = Vec::new();
1082 push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, &mut verts);
1083 verts
1084 }
1085
1086 pub fn push_rounded_rect_vertices(
1087 x: f32, y: f32, ww: f32, h: f32,
1088 r: f32,
1089 sw: f32, sh: f32,
1090 color: [f32; 4],
1091 clip_circle: [f32; 3],
1092 out: &mut Vec<Vertex>,
1093 ) {
1094 push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, out);
1095 }
1096
1097 pub fn plate_bevel_vertices(
1098 x: f32, y: f32, ww: f32, h: f32,
1099 r: f32,
1100 t: f32,
1101 sw: f32, sh: f32,
1102 base_color: [f32; 4],
1103 clip_circle: [f32; 3],
1104 ) -> Vec<Vertex> {
1105 let mut verts = Vec::new();
1106 push_plate_bevel_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, &mut verts);
1107 verts
1108 }
1109
1110 pub fn push_plate_bevel_vertices(
1111 x: f32, y: f32, ww: f32, h: f32,
1112 r: f32,
1113 t: f32,
1114 sw: f32, sh: f32,
1115 base_color: [f32; 4],
1116 clip_circle: [f32; 3],
1117 out: &mut Vec<Vertex>,
1118 ) {
1119 push_bevel_edge_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, 1.0, out);
1120 }
1121
1122 /// The bevel edge shading, with the light direction selectable: `light_sign` is `1.0`
1123 /// for a raised plate (edges facing `light_source_position` are lit) and `-1.0` for a
1124 /// recess (those same edges fall into shadow instead, and the far edges catch the
1125 /// light). Negating the whole light vector flips every edge and every corner segment
1126 /// consistently, because both the flat-edge factors and the arc-normal dot product
1127 /// below are linear in it.
1128 pub fn push_bevel_edge_vertices(
1129 x: f32, y: f32, ww: f32, h: f32,
1130 r: f32,
1131 t: f32,
1132 sw: f32, sh: f32,
1133 base_color: [f32; 4],
1134 clip_circle: [f32; 3],
1135 light_sign: f32,
1136 out: &mut Vec<Vertex>,
1137 ) {
1138 push_bevel_edge_vertices_radii(
1139 x, y, ww, h, (r, r, r, r), t, sw, sh, base_color, clip_circle, light_sign, out,
1140 );
1141 }
1142
1143 /// As [`push_bevel_edge_vertices`], but with a per-corner radius (TL, TR, BR, BL) so the
1144 /// lip can follow a shape whose corners differ — a recess carved along the top of a
1145 /// rounded plate needs the plate's radius on its top corners and square ones where it
1146 /// meets the content below. A uniform radius there would either square off the plate's
1147 /// arc (painting a notch outside it) or wrongly round the inner corners.
1148 pub fn push_bevel_edge_vertices_radii(
1149 x: f32, y: f32, ww: f32, h: f32,
1150 radii: (f32, f32, f32, f32),
1151 t: f32,
1152 sw: f32, sh: f32,
1153 base_color: [f32; 4],
1154 clip_circle: [f32; 3],
1155 light_sign: f32,
1156 out: &mut Vec<Vertex>,
1157 ) {
1158 push_bevel_edge_vertices_banded(
1159 x, y, ww, h, radii, t, sw, sh, base_color, clip_circle, light_sign,
1160 default_bevel_bands(t), (true, true, true, true), EdgeKind::Rim, out,
1161 );
1162 }
1163
1164 /// What kind of height change an edge represents. The two shade differently because they
1165 /// are different shapes, and using one where the other belongs is what makes a bevel read
1166 /// as a drawn line instead of a surface.
1167 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1168 pub enum EdgeKind {
1169 /// The surface *ends* here: a quarter-round rolling from face-on at the inner edge of
1170 /// the lip to fully in-plane at the outer boundary, where it drops away. The shading
1171 /// therefore peaks exactly at the boundary and dies inward. This is a plate's outer
1172 /// perimeter.
1173 Rim,
1174 /// The surface *continues* at a different height: one plateau steps down to another.
1175 /// A height field that falls monotonically across the transition has its normal tilted
1176 /// toward the low side the whole way, steepest in the middle and flat at both ends —
1177 /// so the shading is a bump straddling the boundary, not a band butted against it.
1178 /// Hanging the band on one side instead leaves the seam the eye reads as a drawn line.
1179 Step,
1180 }
1181
1182 /// Shading across an edge at signed distance `d` from the boundary (positive = toward the
1183 /// shape's interior), for a transition of width `t`. Returns the light term as a fraction
1184 /// of full tilt.
1185 #[inline]
1186 fn bevel_profile(kind: EdgeKind, d: f32, t: f32) -> f32 {
1187 if t <= 0.0 {
1188 return 0.0;
1189 }
1190 match kind {
1191 // Normal rotates from in-plane (d = 0) to face-on (d = t): sine of what tilt is
1192 // left. A linear ramp here reads as a flat 45° chamfer instead of a roll.
1193 EdgeKind::Rim => ((1.0 - (d / t).clamp(0.0, 1.0)) * std::f32::consts::FRAC_PI_2).sin(),
1194 // Symmetric bump over [-t/2, +t/2], zero at both ends so the transition blends into
1195 // both plateaus with no seam.
1196 EdgeKind::Step => {
1197 let s = (d / t + 0.5).clamp(0.0, 1.0);
1198 (s * std::f32::consts::PI).sin()
1199 }
1200 }
1201 }
1202
1203 /// The light-independent curvature term at signed distance `d` — the second depth cue,
1204 /// on top of the directional one. Curvature shading is what ambient light does: convex
1205 /// surface catches it from everywhere (bright), concave is self-occluded (dark). Because
1206 /// it does not rotate with the light, it survives exactly where the directional term
1207 /// dies — walls parallel to the light vector — so no edge ever vanishes entirely.
1208 ///
1209 /// `high_sign` is +1 when the rect interior is the HIGH side of the transition and -1
1210 /// when it is the low side (a recess). Geometry, not lighting: it does not flip with
1211 /// `light_sign`... except that for these 2.5D shapes the two are the same number, since
1212 /// a raised shape is lit like a plateau and shaded like one.
1213 #[inline]
1214 fn bevel_curvature(kind: EdgeKind, d: f32, t: f32, high_sign: f32) -> f32 {
1215 if t <= 0.0 {
1216 return 0.0;
1217 }
1218 match kind {
1219 // A rim is convex everywhere, tightest right at the silhouette: a bright crest
1220 // line hugging the boundary and dying fast inward. This is the line that makes
1221 // glass read as glass — the edge catches ambient light all the way around, even
1222 // (dimmer, via the gain asymmetry below) on the side facing away from the light.
1223 EdgeKind::Rim => {
1224 let u = (d / t).clamp(0.0, 1.0);
1225 let f = 1.0 - u;
1226 CREST_RATIO * f * f * f
1227 }
1228 // An S-curve step is convex on its high half (the shoulder) and concave on its
1229 // low half (the fillet, where the wall meets the floor): antisymmetric, zero at
1230 // the ends (no seam against either plateau) and at the midpoint.
1231 EdgeKind::Step => {
1232 let s = (d / t + 0.5).clamp(0.0, 1.0);
1233 let outer_is_high = -high_sign; // d < 0 is outside the rect
1234 // sin(2πs) is positive on the outer half — the shoulder when the outside is
1235 // the high side — and negative on the inner (fillet) half.
1236 AO_RATIO * outer_is_high * (s * std::f32::consts::TAU).sin()
1237 }
1238 }
1239 }
1240
1241 /// Crest amplitude as a fraction of `bevel_depth` — how much brighter a rim's silhouette
1242 /// line is than flat surface under even light. Must stay clearly below ~0.7 (the
1243 /// projection of a 135° light onto an axis edge), or it cancels the directional shadow
1244 /// on the dark side and the rim goes flat there instead of showing a faint bright line
1245 /// over a shadowed roll.
1246 const CREST_RATIO: f32 = 0.4;
1247 /// Shoulder/fillet amplitude as a fraction of `bevel_depth`.
1248 const AO_RATIO: f32 = 0.6;
1249 /// Per-sign overlay gains. These are asymmetric the opposite way from intuition: on the
1250 /// dark bases this DE runs, white-over blending (`b + a(1-b)`) moves the pixel far more
1251 /// per unit alpha than black-over (`b(1-a)`) — a dark surface has little brightness for
1252 /// black to take away. The old subtractive shading effectively crushed shadow sides to
1253 /// black in linear space; the black overlay needs a high gain to keep shadows reading
1254 /// at all, while white needs damping to keep highlights from blowing out.
1255 const LIGHT_GAIN: f32 = 0.7;
1256 const DARK_GAIN: f32 = 3.0;
1257
1258 /// A shading value (already scaled by `bevel_depth`) as the two overlay passes: the lit
1259 /// pass is translucent white, the shadow pass translucent black. Painting the
1260 /// *modulation* instead of a resolved surface color is what lets relief primitives compose — a step
1261 /// crossing a rim shades the rim's gradient instead of stamping a flat band over it, a
1262 /// lip on a translucent plate no longer doubles its opacity, and a recess needs no
1263 /// knowledge of the surface color it carves.
1264 ///
1265 /// Why two passes with fixed RGB rather than one signed color: a primitive whose value
1266 /// crosses zero inside a band would interpolate white→black through mid-gray at
1267 /// non-negligible alpha — on a dark base a *brightening* artifact right where the
1268 /// shading should vanish. With per-pass alphas clamped at the crossing, each pass fades
1269 /// to zero there and the hue can never be wrong. Alphas also stay non-negative on every
1270 /// vertex, which the renderer requires (negative alpha is the blur sentinel).
1271 #[inline]
1272 fn overlay_light(v: f32) -> [f32; 4] {
1273 [1.0, 1.0, 1.0, (v.max(0.0) * LIGHT_GAIN).min(1.0)]
1274 }
1275 #[inline]
1276 fn overlay_dark(v: f32) -> [f32; 4] {
1277 [0.0, 0.0, 0.0, ((-v).max(0.0) * DARK_GAIN).min(1.0)]
1278 }
1279
1280 /// The signed distance range an edge's shading occupies, relative to the boundary.
1281 #[inline]
1282 fn bevel_span(kind: EdgeKind, t: f32) -> (f32, f32) {
1283 match kind {
1284 EdgeKind::Rim => (0.0, t),
1285 EdgeKind::Step => (-0.5 * t, 0.5 * t),
1286 }
1287 }
1288
1289 /// How many gradient bands to slice a lip of thickness `t` into. Vertex colors interpolate
1290 /// linearly, so each band is a chord of the shading curve; one band per ~1.25px keeps the
1291 /// error under a shade step without emitting geometry finer than the display resolves.
1292 /// The cap rose with the curvature term: a step now has two features across its width
1293 /// (shoulder and fillet), so it needs double the samples a single bump did.
1294 fn default_bevel_bands(t: f32) -> usize {
1295 ((t / 1.25).ceil() as usize).clamp(1, 12)
1296 }
1297
1298 /// As [`push_bevel_edge_vertices_radii`], with the band count forced and the walls
1299 /// selectable — for callers that want a coarser or finer roll-off than thickness alone
1300 /// implies, or that are shading a step rather than a closed shape.
1301 ///
1302 /// `edges` is (top, right, bottom, left). Suppressing a wall matters for a region that
1303 /// runs flush to the surface's own edge: a full-width menubar sunk into the top of a plate
1304 /// is a *plateau one step down*, not a trough, so its only real wall is the one facing the
1305 /// content. Drawing the other three would carve a lip along the plate's outer edge, where
1306 /// the plate's own roll already lives, and the two would fight.
1307 pub fn push_bevel_edge_vertices_banded(
1308 x: f32, y: f32, ww: f32, h: f32,
1309 radii: (f32, f32, f32, f32),
1310 t: f32,
1311 sw: f32, sh: f32,
1312 base_color: [f32; 4],
1313 clip_circle: [f32; 3],
1314 light_sign: f32,
1315 bands: usize,
1316 edges: (bool, bool, bool, bool),
1317 kind: EdgeKind,
1318 out: &mut Vec<Vertex>,
1319 ) {
1320 // Floored for the same reason as `plate_push_raised`'s cap: a negative
1321 // extent must degrade to no ring, not panic in `clamp`.
1322 let cap = (ww.min(h) * 0.5).max(0.0);
1323 let (tl, tr, br, bl) = (
1324 radii.0.clamp(0.0, cap),
1325 radii.1.clamp(0.0, cap),
1326 radii.2.clamp(0.0, cap),
1327 radii.3.clamp(0.0, cap),
1328 );
1329 let t = t.clamp(0.0, cap);
1330 if t <= 0.0 {
1331 return;
1332 }
1333 let bands = bands.max(1);
1334
1335 let rad = crate::layout::light_source_position();
1336 let lx = rad.cos() * light_sign;
1337 let ly = -rad.sin() * light_sign;
1338 let depth = crate::layout::bevel_depth();
1339
1340 // `base_color` is no longer painted: shading is an overlay (see `overlay_color`), so
1341 // the surface below shows through with its own gradients and translucency intact.
1342 let _ = base_color;
1343 // Shading (directional + curvature, scaled by bevel_depth) at signed distance `d`,
1344 // for an edge whose outward flat normal is `dir`. A `Step` band runs negative — it
1345 // straddles the boundary into the plateau outside the rect, which is exactly what
1346 // removes the seam.
1347 let value = |dot: f32, d: f32| {
1348 depth * (bevel_profile(kind, d, t) * dot + bevel_curvature(kind, d, t, light_sign))
1349 };
1350 // The (up to two) overlay color pairs for a band running from value `v0` to `v1`:
1351 // one white pair and/or one black pair, each pass fading to zero alpha wherever the
1352 // value has the other sign. Both fire only when the band straddles the terminator.
1353 let passes = |v0: f32, v1: f32| -> [Option<([f32; 4], [f32; 4])>; 2] {
1354 [
1355 (v0 > 0.0 || v1 > 0.0).then(|| (overlay_light(v0), overlay_light(v1))),
1356 (v0 < 0.0 || v1 < 0.0).then(|| (overlay_dark(v0), overlay_dark(v1))),
1357 ]
1358 };
1359 let (span_lo, span_hi) = bevel_span(kind, t);
1360
1361 // Each flat edge spans between its two adjoining corner radii, not a single uniform
1362 // inset — that is what lets the corners differ. At a square corner there is no arc to
1363 // cover the t×t patch where two edges meet, so the horizontal edges claim it (they run
1364 // the full span) and the vertical ones inset by `t`; overlapping them instead would
1365 // double-blend that patch, which shows as a dark notch on a translucent surface.
1366 let (left_top, left_bot) = (if tl > 0.0 { tl } else { t }, if bl > 0.0 { bl } else { t });
1367 let (right_top, right_bot) = (if tr > 0.0 { tr } else { t }, if br > 0.0 { br } else { t });
1368 let top_w = ww - tl - tr;
1369 let bottom_w = ww - bl - br;
1370 let left_h = h - left_top - left_bot;
1371 let right_h = h - right_top - right_bot;
1372
1373 for k in 0..bands {
1374 let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
1375 let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
1376 let bw = d1 - d0;
1377
1378 // Top: outward normal (0,-1); the gradient runs downward, into the surface.
1379 if top_w > 0.0 && edges.0 {
1380 let (v0, v1) = (value(-ly, d0), value(-ly, d1));
1381 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
1382 out.extend_from_slice(&quad_vertices_shaded(
1383 x + tl, y + d0, top_w, bw, sw, sh, c0, c0, c1, c1, clip_circle,
1384 ));
1385 }
1386 }
1387 // Bottom: outward normal (0,1); gradient runs upward.
1388 if bottom_w > 0.0 && edges.2 {
1389 let (v0, v1) = (value(ly, d0), value(ly, d1));
1390 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
1391 out.extend_from_slice(&quad_vertices_shaded(
1392 x + bl, y + h - d1, bottom_w, bw, sw, sh, c1, c1, c0, c0, clip_circle,
1393 ));
1394 }
1395 }
1396 // Left: outward normal (-1,0); gradient runs rightward.
1397 if left_h > 0.0 && edges.3 {
1398 let (v0, v1) = (value(-lx, d0), value(-lx, d1));
1399 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
1400 out.extend_from_slice(&quad_vertices_shaded(
1401 x + d0, y + left_top, bw, left_h, sw, sh, c0, c1, c1, c0, clip_circle,
1402 ));
1403 }
1404 }
1405 // Right: outward normal (1,0); gradient runs leftward.
1406 if right_h > 0.0 && edges.1 {
1407 let (v0, v1) = (value(lx, d0), value(lx, d1));
1408 for (c0, c1) in passes(v0, v1).into_iter().flatten() {
1409 out.extend_from_slice(&quad_vertices_shaded(
1410 x + ww - d1, y + right_top, bw, right_h, sw, sh, c1, c0, c0, c1, clip_circle,
1411 ));
1412 }
1413 }
1414 }
1415
1416 // A corner arc belongs to both of its adjoining walls, so it is drawn only when both
1417 // are — otherwise a suppressed wall would still get a quarter of a lip.
1418 let corners = [
1419 (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, edges.0 && edges.3), // Top-Left
1420 (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, edges.0 && edges.1), // Top-Right
1421 (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI, edges.2 && edges.1), // Bottom-Right
1422 (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, edges.2 && edges.3), // Bottom-Left
1423 ];
1424
1425 for &(cx, cy, r, start_angle, end_angle, enabled) in &corners {
1426 // A square corner has no arc to sweep — the flat edges already met there.
1427 if r <= 0.0 || !enabled {
1428 continue;
1429 }
1430 // The corner is a quarter of a torus: shading varies along the sweep (the normal
1431 // swings through 90° of the light) *and* across the lip (the roll-off). Both come
1432 // out of the vertex colors, so one quad per (segment × band) cell is enough — no
1433 // faceting, unlike the 16 flat wedges this replaced.
1434 let segments = ((r * 0.75) as usize).clamp(8, 48);
1435 let ct = t.min(r);
1436 for j in 0..segments {
1437 let theta0 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
1438 let theta1 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
1439 let (cos0, sin0) = (theta0.cos(), theta0.sin());
1440 let (cos1, sin1) = (theta1.cos(), theta1.sin());
1441 for k in 0..bands {
1442 let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
1443 let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
1444 // Inward along the corner's radius is the same signed distance as inward
1445 // from a flat edge, so the arc scales the span the same way.
1446 let (r0, r1) = (r - ct * (d0 / t), r - ct * (d1 / t));
1447 let p = |rho: f32, c: f32, s: f32| -> [f32; 2] {
1448 [
1449 ((cx + rho * c) / sw) * 2.0 - 1.0,
1450 1.0 - ((cy + rho * s) / sh) * 2.0,
1451 ]
1452 };
1453 // Outer/inner × the two sweep ends; each vertex gets its own value, and
1454 // the cell is drawn once per overlay pass that has any coverage.
1455 let vals = [
1456 value(cos0 * lx + sin0 * ly, d0),
1457 value(cos1 * lx + sin1 * ly, d0),
1458 value(cos1 * lx + sin1 * ly, d1),
1459 value(cos0 * lx + sin0 * ly, d1),
1460 ];
1461 let geo = [
1462 p(r0, cos0, sin0),
1463 p(r0, cos1, sin1),
1464 p(r1, cos1, sin1),
1465 p(r1, cos0, sin0),
1466 ];
1467 let mut cells: [Option<fn(f32) -> [f32; 4]>; 2] = [None, None];
1468 if vals.iter().any(|&v| v > 0.0) {
1469 cells[0] = Some(overlay_light);
1470 }
1471 if vals.iter().any(|&v| v < 0.0) {
1472 cells[1] = Some(overlay_dark);
1473 }
1474 for f in cells.into_iter().flatten() {
1475 let c: Vec<Vertex> = (0..4)
1476 .map(|i| Vertex { position: geo[i], color: f(vals[i]), clip_circle })
1477 .collect();
1478 out.extend_from_slice(&[c[0], c[1], c[2], c[0], c[2], c[3]]);
1479 }
1480 }
1481 }
1482 }
1483 }
1484
1485 /// How strong the face gradient is, as a fraction of `bevel_depth` at the corner nearest
1486 /// the light. Deliberately well below the edge amplitude: the face is a plane, not a
1487 /// roll — it only *leans* toward the light.
1488 const FACE_RATIO: f32 = 0.35;
1489
1490 /// The face lighting of a plate: a single diagonal luminance gradient across the whole
1491 /// surface, brightest at the corner facing `light_source_position` and darkest at the
1492 /// opposite one. This is the difference between an object and a sticker: a real surface
1493 /// under directional light is never uniform, and a perfectly flat fill makes the eye
1494 /// read the (much smaller) edge shading as frame decoration rather than shape.
1495 ///
1496 /// Emitted as the same two-pass white/black overlays as the relief primitives (see
1497 /// [`overlay_light`]/[`overlay_dark`]): fixed RGB per pass, per-corner alphas clamped at
1498 /// the terminator, bilinear across the quad. The quad is square — its corners poke past
1499 /// a rounded plate's arcs — but the compositor clips the window surface to the same
1500 /// radius, so the overhang never reaches the screen.
1501 pub fn push_plate_face_vertices(
1502 x: f32, y: f32, ww: f32, h: f32,
1503 sw: f32, sh: f32,
1504 clip_circle: [f32; 3],
1505 out: &mut Vec<Vertex>,
1506 ) {
1507 let rad = crate::layout::light_source_position();
1508 let (lx, ly) = (rad.cos(), -rad.sin());
1509 let amp = crate::layout::bevel_depth() * FACE_RATIO;
1510 // Corner value = how much its outward diagonal faces the light.
1511 let inv = std::f32::consts::FRAC_1_SQRT_2;
1512 let v_tl = amp * inv * (-lx - ly);
1513 let v_tr = amp * inv * (lx - ly);
1514 let v_br = amp * inv * (lx + ly);
1515 let v_bl = amp * inv * (-lx + ly);
1516 let vs = [v_tl, v_tr, v_br, v_bl];
1517 if vs.iter().any(|&v| v > 0.0) {
1518 out.extend_from_slice(&quad_vertices_shaded(
1519 x, y, ww, h, sw, sh,
1520 overlay_light(v_tl), overlay_light(v_tr), overlay_light(v_br), overlay_light(v_bl),
1521 clip_circle,
1522 ));
1523 }
1524 if vs.iter().any(|&v| v < 0.0) {
1525 out.extend_from_slice(&quad_vertices_shaded(
1526 x, y, ww, h, sw, sh,
1527 overlay_dark(v_tl), overlay_dark(v_tr), overlay_dark(v_br), overlay_dark(v_bl),
1528 clip_circle,
1529 ));
1530 }
1531 }
1532
1533 pub fn push_plate_solid_border_vertices(
1534 x: f32, y: f32, ww: f32, h: f32,
1535 radii: crate::widget::CornerRadii,
1536 t: f32,
1537 sw: f32, sh: f32,
1538 color: [f32; 4],
1539 clip_circle: [f32; 3],
1540 out: &mut Vec<Vertex>,
1541 ) {
1542 let mut r_tl = radii.top_left.max(0.0);
1543 let mut r_tr = radii.top_right.max(0.0);
1544 let mut r_br = radii.bottom_right.max(0.0);
1545 let mut r_bl = radii.bottom_left.max(0.0);
1546
1547 // Simple scale clamping
1548 let sum_top = r_tl + r_tr;
1549 if sum_top > ww {
1550 let f = ww / sum_top;
1551 r_tl *= f;
1552 r_tr *= f;
1553 }
1554 let sum_bottom = r_bl + r_br;
1555 if sum_bottom > ww {
1556 let f = ww / sum_bottom;
1557 r_bl *= f;
1558 r_br *= f;
1559 }
1560 let sum_left = r_tl + r_bl;
1561 if sum_left > h {
1562 let f = h / sum_left;
1563 r_tl *= f;
1564 r_bl *= f;
1565 }
1566 let sum_right = r_tr + r_br;
1567 if sum_right > h {
1568 let f = h / sum_right;
1569 r_tr *= f;
1570 r_br *= f;
1571 }
1572
1573 out.extend_from_slice(&quad_vertices_with_clip(x + r_tl, y, ww - r_tl - r_tr, t, sw, sh, color, clip_circle));
1574 out.extend_from_slice(&quad_vertices_with_clip(x, y + r_tl, t, h - r_tl - r_bl, sw, sh, color, clip_circle));
1575 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));
1576 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));
1577
1578 let segments = 16;
1579 let corner_e = 2.0 / crate::layout::corner_shape();
1580
1581 // Corner strokes as annulus strips between the outer superellipse (radius
1582 // r) and its inner scaled copy (r - t): at 1px thickness the scaled inner
1583 // curve is indistinguishable from the true parallel curve, and at
1584 // corner_shape 2 this is exactly the circular arc annulus. NOT
1585 // push_arc_background_vertices — that stays circular for genuine arcs.
1586 let corner = |cx: f32, cy: f32, r: f32, start: f32, end: f32, out: &mut Vec<Vertex>| {
1587 let r_in = (r - t).max(0.0);
1588 let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
1589 for i in 0..segments {
1590 let t1 = start + (i as f32) * (end - start) / segments as f32;
1591 let t2 = start + ((i + 1) as f32) * (end - start) / segments as f32;
1592 let (c1, s1) = superellipse_pt(t1, corner_e);
1593 let (c2, s2) = superellipse_pt(t2, corner_e);
1594 let o1 = ndc(cx + r * c1, cy + r * s1);
1595 let o2 = ndc(cx + r * c2, cy + r * s2);
1596 let i1 = ndc(cx + r_in * c1, cy + r_in * s1);
1597 let i2 = ndc(cx + r_in * c2, cy + r_in * s2);
1598 out.push(Vertex { position: o1, color, clip_circle });
1599 out.push(Vertex { position: o2, color, clip_circle });
1600 out.push(Vertex { position: i1, color, clip_circle });
1601 out.push(Vertex { position: o2, color, clip_circle });
1602 out.push(Vertex { position: i2, color, clip_circle });
1603 out.push(Vertex { position: i1, color, clip_circle });
1604 }
1605 };
1606
1607 if r_tl > 0.1 {
1608 corner(x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, out);
1609 }
1610 if r_tr > 0.1 {
1611 corner(x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, out);
1612 }
1613 if r_br > 0.1 {
1614 corner(x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI, out);
1615 }
1616 if r_bl > 0.1 {
1617 corner(x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, out);
1618 }
1619 }
1620
1621 pub fn push_plate_solid_border_vertices_legacy(
1622 x: f32, y: f32, ww: f32, h: f32,
1623 r: f32,
1624 t: f32,
1625 sw: f32, sh: f32,
1626 color: [f32; 4],
1627 clip_circle: [f32; 3],
1628 out: &mut Vec<Vertex>,
1629 ) {
1630 let radii = crate::widget::CornerRadii::uniform(r);
1631 push_plate_solid_border_vertices(x, y, ww, h, radii, t, sw, sh, color, clip_circle, out);
1632 }
1633
1634 pub fn widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3]) -> Vec<Vertex> {
1635 let mut verts = Vec::new();
1636 push_widget_vertices(w, sw, sh, clip_circle, &mut verts);
1637 verts
1638 }
1639
1640 pub fn push_widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3], out: &mut Vec<Vertex>) {
1641 let (x, y, ww, h) = w.rect();
1642 let radii = w.corner_radii();
1643 if let Some(thickness) = w.plate_bevel() {
1644 let t = thickness;
1645 // Full-size fill: the bevel lip is a shading overlay now, not a paint of the
1646 // outer ring, so an inset fill would leave the ring unfilled.
1647 push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
1648 push_plate_bevel_vertices(x, y, ww, h, radii.top_left, t, sw, sh, w.color(), clip_circle, out);
1649 } else {
1650 push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
1651 if let Some((color, thickness)) = w.solid_border() {
1652 push_plate_solid_border_vertices(x, y, ww, h, radii, thickness, sw, sh, color, clip_circle, out);
1653 }
1654 }
1655
1656 for (cx, cy, r, t, start, end, qc) in w.extra_arcs() {
1657 push_arc_background_vertices(cx, cy, r, t, start, end, sw, sh, qc, 16, clip_circle, out);
1658 }
1659 }
1660
1661 /// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
1662 /// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
1663 /// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
1664 /// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
1665 pub struct DlBatch {
1666 pub scissor: Option<crate::scene::layout::Rect>,
1667 pub clip_rrect: Option<[f32; 5]>,
1668 pub start: u32,
1669 pub end: u32,
1670 /// When set, this batch is one SDF-lit plate cover quad (see
1671 /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
1672 pub plate: Option<crate::vk::PlatePush>,
1673 /// A blur-behind plate (negative-alpha color): before drawing this batch
1674 /// the renderer snapshots the swapchain-so-far into its snapshot image, so
1675 /// the blur samples everything painted beneath the plate — not just the 3D
1676 /// scene backdrop. Never merged.
1677 pub blur_behind: bool,
1678 }
1679
1680 /// An image draw from the display list: `at` is the vertex index it sorts
1681 /// before (its position in the tessellated stream); `clip` is the item's
1682 /// paint-walk clip. Logical coordinates throughout.
1683 pub struct DlImage {
1684 pub image: u32,
1685 pub rect: crate::scene::layout::Rect,
1686 pub alpha: f32,
1687 pub at: u32,
1688 pub clip: Option<crate::scene::layout::Rect>,
1689 }
1690
1691 /// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
1692 /// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
1693 /// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
1694 /// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
1695 /// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
1696 /// merged into one batch (the circle clip is per-vertex, so it never splits batches).
1697 /// `CCE_PLATE_DEBUG=1` — trace which carves group into their host plate as exact
1698 /// CSG features and which fall back to the standalone overlay shading.
1699 ///
1700 /// The two paths do NOT look the same: a grouped carve is part of the plate's
1701 /// single height field, so its wall meets the plate's rolled perimeter as a real
1702 /// junction, while the fallback approximates that with the host-box fade. Six
1703 /// conditions decide it, three of them dynamic (draw order, neighbouring plates,
1704 /// whether another carve already claimed the host's feature run), so the SAME
1705 /// widget can render either way depending on what is around it — and it does so
1706 /// silently. That has already shipped as a bug once: a hovered button's opaque
1707 /// fill used to sever every later button from the root plate they carve into,
1708 /// which is why `plate_stack` is a stack (see its comment below).
1709 ///
1710 /// Off by default and read once; the classification below runs only when set.
1711 /// Prim discriminant name, for `CCE_PLATE_DEBUG` reporting only.
1712 fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
1713 use crate::scene::paint::Prim as P;
1714 match p {
1715 P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
1716 P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
1717 P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
1718 P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Plate { .. } => "Plate",
1719 P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
1720 P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
1721 P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
1722 P::DropletScrim { .. } => "DropletScrim",
1723 P::ConcaveFillet { .. } => "ConcaveFillet",
1724 P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice", P::Grout { .. } => "Grout", P::Fill { .. } => "Fill",
1725 P::CarveUnion { .. } => "CarveUnion", P::Glow { .. } => "Glow",
1726 P::Text { .. } => "Text", P::Image { .. } => "Image",
1727 }
1728 }
1729
1730 fn plate_debug() -> bool {
1731 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1732 *ON.get_or_init(|| std::env::var("CCE_PLATE_DEBUG").is_ok_and(|v| v != "0"))
1733 }
1734
1735 /// Debug builds make one kind of fallback LOUD without `CCE_PLATE_DEBUG`: a
1736 /// carve that could group (full ring, untinted) failing to while a still-open
1737 /// plate encloses it and the carve's shaded region reaches that plate's
1738 /// perimeter roll. There the grouped and overlay paths shade the junction
1739 /// differently, and the rejection is one of the dynamic rules — so the SAME
1740 /// widget can flip looks frame to frame with nothing on stderr. Not an
1741 /// assert/panic: every rejection is conservative-CORRECT (the audit that
1742 /// shipped CCE_PLATE_DEBUG found no misgrouping; a later plate overlapping the
1743 /// carve genuinely must be shaded over, not under) — it is the frame-to-frame
1744 /// LOOK that flips, so the right loudness is an unmissable warning, not a
1745 /// crash. The ubiquitous quiet case stays quiet by construction: ordinary
1746 /// geometry closing every grouping window empties `plate_stack`, so no
1747 /// enclosing OPEN plate exists and this never runs — that is draw-order
1748 /// design, not a flip.
1749 ///
1750 /// Returns the dynamic rule to report, or `None` when the fallback is not the
1751 /// loud case. Pure so the classification is unit-testable; `later_plates` are
1752 /// the open plates emitted after the enclosing host.
1753 #[cfg(debug_assertions)]
1754 fn near_roll_fallback_reason(
1755 carve: &crate::scene::layout::Rect,
1756 depth: f32,
1757 host: &crate::scene::layout::Rect,
1758 roll: f32,
1759 later_plates: &[crate::scene::layout::Rect],
1760 budget_full: bool,
1761 ) -> Option<&'static str> {
1762 // The carve's shaded region — the overlay path's cover-quad inflation.
1763 let infl = depth * 0.5 + 2.0;
1764 let (sx0, sy0) = (carve.x - infl, carve.y - infl);
1765 let (sx1, sy1) = (carve.x + carve.width + infl, carve.y + carve.height + infl);
1766 // "Near the roll" = the shaded region leaves the host rect deflated by the
1767 // host's own roll width on any side.
1768 let near = sx0 < host.x + roll
1769 || sy0 < host.y + roll
1770 || sx1 > host.x + host.width - roll
1771 || sy1 > host.y + host.height - roll;
1772 if !near {
1773 return None;
1774 }
1775 // The dynamic rules, in the order the grouping guard tests them.
1776 if budget_full {
1777 return Some("the feature budget is full");
1778 }
1779 if later_plates
1780 .iter()
1781 .any(|o| sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y)
1782 {
1783 return Some("a later plate overlaps the carve's shaded region");
1784 }
1785 Some("the host's feature run is closed (another plate appended features since)")
1786 }
1787
1788 /// Print a near-roll fallback warning once per distinct message — a carve in a
1789 /// steady layout would otherwise repeat it every frame.
1790 #[cfg(debug_assertions)]
1791 fn plate_carve_warn_once(msg: String) {
1792 use std::sync::{Mutex, OnceLock};
1793 static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
1794 let seen = SEEN.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
1795 if seen.lock().unwrap().insert(msg.clone()) {
1796 eprintln!("{msg}");
1797 }
1798 }
1799
1800 pub fn tessellate_display_list(
1801 dl: &crate::scene::paint::DisplayList,
1802 sw: f32,
1803 sh: f32,
1804 scale: f32,
1805 ) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
1806 use crate::scene::material::PlateRole;
1807 use crate::scene::paint::{Cap, Prim};
1808 let mut verts: Vec<Vertex> = Vec::new();
1809 let mut batches: Vec<DlBatch> = Vec::new();
1810 let mut images: Vec<DlImage> = Vec::new();
1811 // Carves CSG'd into plates (see Frame2D::plate_features), plus the plate
1812 // they group into: the most recent Plate/Bevel batch, provided only Text
1813 // and Image prims (which draw through separate paths anyway) intervene.
1814 let mut features: Vec<[f32; 12]> = Vec::new();
1815 // Open carve-host plates, in emission order (innermost candidates last).
1816 // A STACK, not a single slot: a sibling plate emitted between a root plate
1817 // and its later carves (a hovered button's opaque fill among transparent
1818 // ones) must not sever those carves from the root plate they are carved
1819 // into — that severing rendered every button after the hovered one
1820 // through the visually-different overlay fallback. Ordinary geometry
1821 // still closes every open plate (the draw-order rule below).
1822 let mut plate_stack: Vec<(usize, crate::scene::layout::Rect)> = Vec::new();
1823 // Which plate last appended a carve feature: a plate's features are
1824 // addressed as one contiguous [offset, count] run (PlatePush::host), so a
1825 // plate may only receive MORE features while no other plate has appended
1826 // any since.
1827 let mut last_feature_plate: Option<usize> = None;
1828 // `CCE_PLATE_DEBUG` bookkeeping — see `plate_debug`.
1829 let dbg_plates = plate_debug();
1830 let mut dbg_grouped = 0usize;
1831 let mut dbg_fell_back: Vec<String> = Vec::new();
1832 let mut dbg_opened = 0usize;
1833 // Which prim kind closed a still-open grouping window, and how many plates
1834 // it closed — the answer to "why was there no enclosing plate?".
1835 let mut dbg_closed_by: std::collections::BTreeMap<&'static str, usize> =
1836 std::collections::BTreeMap::new();
1837
1838 // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
1839 // shading, plus the frame-constant lighting inputs it pushes per plate.
1840 let shader_plates = crate::layout::bevel_shader();
1841 // Light and material come from `scene::relief_shade`, which is also what
1842 // cce-relief predicts pixels with — one definition, so the editor cannot
1843 // draw a different material than the renderer applies.
1844 let plate_light = crate::scene::relief_shade::light_vector();
1845 // [shading strength (1.0 at the default bevel_depth), specular strength,
1846 // shininess, curvature/AO strength] — the DE's finish, for the CARVES,
1847 // which shade whatever is beneath them and so take the host's. A prim
1848 // that carries a Material (Plate, Bevel, Sphere, Droplet) pushes its own
1849 // `material.finish` instead. Curvature is kept near the raised path's
1850 // crest amplitude: the recess shoulder's brightening lands on the same
1851 // pixels as its specular line, and the two stack — at 0.5 the step read
1852 // several times hotter than a plate roll.
1853 let plate_mat = crate::scene::material::Finish::from_style().to_array();
1854
1855 for item in &dl.items {
1856 let mut start = verts.len() as u32;
1857 let mut plate: Option<crate::vk::PlatePush> = None;
1858 // A frosted flat fill promoted to a zero-depth plate batch (below):
1859 // it carries a recipe like any plate, but it is ordinary geometry to
1860 // the carve grouping — it opens no host and closes the open ones.
1861 let mut promoted = false;
1862 let mut made_plate: Option<crate::scene::layout::Rect> = None;
1863 // Blur-behind marker: a prim whose FILL alpha is negative asks the
1864 // renderer to snapshot the frame-so-far before it draws. Every
1865 // fill-bearing prim counts — the shader's a<0 branch runs for all of
1866 // them, and a variant missing here still frosts, but against the
1867 // stale scene backdrop instead of the frame: a flat tint with no
1868 // content and no blur, which is how the Dropdown popover (Border)
1869 // and the menubar panels (Quad) shipped visibly unfrosted while the
1870 // context menu (Plate) worked.
1871 let mut blur_behind = matches!(
1872 &item.prim,
1873 crate::scene::paint::Prim::Quad { color, .. }
1874 | crate::scene::paint::Prim::RoundedRect { color, .. } if color[3] < 0.0
1875 ) || matches!(
1876 &item.prim,
1877 crate::scene::paint::Prim::Bevel { material, .. }
1878 | crate::scene::paint::Prim::Plate { material, .. }
1879 | crate::scene::paint::Prim::Droplet { material, .. }
1880 if material.fill(PlateRole::Nested)[3] < 0.0
1881 ) || matches!(
1882 &item.prim,
1883 crate::scene::paint::Prim::Border { fill, .. } if fill[3] < 0.0
1884 ) || matches!(
1885 &item.prim,
1886 crate::scene::paint::Prim::Fill { material, .. } if material.fill(PlateRole::Nested)[3] < 0.0
1887 );
1888 // Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
1889 let no = item
1890 .clip_circle
1891 .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
1892 .unwrap_or([0.0f32, 0.0, 0.0]);
1893 // Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
1894 // the fan with the PHYSICAL radius (capped — beyond 128 the chord error is
1895 // subpixel even on HiDPI).
1896 let segs = |radius: f32| -> usize { ((radius * scale) as usize).clamp(16, 128) };
1897 match &item.prim {
1898 Prim::Text { .. } => continue, // text goes through the glyph/text-span path
1899 Prim::Image { image, rect, alpha } => {
1900 images.push(DlImage {
1901 image: *image,
1902 rect: *rect,
1903 alpha: *alpha,
1904 at: verts.len() as u32,
1905 clip: item.clip,
1906 });
1907 continue;
1908 }
1909 // A frosted FLAT fill — a `Flat` control face, a menu panel, a
1910 // popover, an inset plate's face — is a zero-depth plate batch
1911 // (RFC material § 6.2): the same shader path as every plate, so
1912 // it carries its own frost recipe instead of a window-wide one,
1913 // with circular corners (shape 2) and no roll, which is what the
1914 // tessellated fill drew. The display list is untouched, so the
1915 // legacy bridges that extract RoundedRects still see one.
1916 Prim::Quad { rect, color } if shader_plates && color[3] < 0.0 => {
1917 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
1918 plate = Some(flat_frost_push(rect, (0.0, 0.0, 0.0, 0.0), *color, scale, plate_light, plate_mat));
1919 promoted = true;
1920 }
1921 Prim::RoundedRect { rect, radius, corners, color } if shader_plates && color[3] < 0.0 => {
1922 let radii = (
1923 if corners.0 { *radius } else { 0.0 },
1924 if corners.1 { *radius } else { 0.0 },
1925 if corners.2 { *radius } else { 0.0 },
1926 if corners.3 { *radius } else { 0.0 },
1927 );
1928 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
1929 plate = Some(flat_frost_push(rect, radii, *color, scale, plate_light, plate_mat));
1930 promoted = true;
1931 }
1932 Prim::Fill { rect, radii, material } if shader_plates && material.frost.is_frosted() => {
1933 // A material's flat fill: the frosted promotion above with
1934 // the MATERIAL's recipe (compression, refraction, radius)
1935 // instead of the DE default's. Zero depth, circular
1936 // corners, no host — exactly a promoted RoundedRect.
1937 let color = material.fill(PlateRole::Nested);
1938 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
1939 let mut p = plate_push_raised(rect, *radii, 0.0, scale, plate_light, plate_mat, false, Some(2.0));
1940 let [fz, fw] = material.frost.pack(scale);
1941 p.host[2] = fz;
1942 p.host[3] = fw;
1943 plate = Some(p);
1944 promoted = true;
1945 }
1946 Prim::Fill { rect, radii, material } => {
1947 // Opaque (or the legacy path): a plain rounded fill.
1948 let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
1949 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, material.fill(PlateRole::Nested), no, None, &mut verts);
1950 }
1951 Prim::Border { rect, radii, fill, border, thickness } if shader_plates && fill[3] < 0.0 => {
1952 // The fill as its own plate batch, closed here; the stroke
1953 // follows as ordinary geometry in the batch the tail makes.
1954 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *fill));
1955 let p = flat_frost_push(rect, *radii, *fill, scale, plate_light, plate_mat);
1956 let end = verts.len() as u32;
1957 plate_stack.clear();
1958 batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate: Some(p), blur_behind: true });
1959 start = end;
1960 blur_behind = false;
1961 let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
1962 push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
1963 }
1964 Prim::Quad { rect, color } => {
1965 // Quads honor an active circle clip like circles/arcs do (the
1966 // Ramp's foam-cell fills draw as clipped strips).
1967 verts.extend(quad_vertices_with_clip(rect.x, rect.y, rect.width, rect.height, sw, sh, *color, no));
1968 }
1969 Prim::RoundedRect { rect, radius, corners, color } => {
1970 let radii = crate::widget::CornerRadii::new(
1971 if corners.0 { *radius } else { 0.0 },
1972 if corners.1 { *radius } else { 0.0 },
1973 if corners.2 { *radius } else { 0.0 },
1974 if corners.3 { *radius } else { 0.0 },
1975 );
1976 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
1977 }
1978 Prim::Border { rect, radii, fill, border, thickness } => {
1979 let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
1980 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, *fill, no, None, &mut verts);
1981 push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
1982 }
1983 Prim::Glow { rect, radius, reach, color } => {
1984 push_glow_vertices(rect.x, rect.y, rect.width, rect.height, *radius, *reach, sw, sh, *color, no, &mut verts);
1985 }
1986 Prim::Bevel { rect, radii, material, depth, tint } if shader_plates => {
1987 let color = material.fill(PlateRole::Nested);
1988 let mat = material.finish.to_array();
1989 // SDF-lit raised plate: one cover quad; the shader owns fill,
1990 // roll shading, corners, and silhouette AA. Nominal corner
1991 // radii (scale_corners false): a Bevel is a WIDGET-scale plate
1992 // whose silhouette must match the nominal-radius squircles of
1993 // the controls around it — only window-scale `Plate`s get the
1994 // curvature-matched span.
1995 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
1996 let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, false, None);
1997 // The plate's own frost recipe rides host.zw (see PlatePush).
1998 let [fz, fw] = material.frost.pack(scale);
1999 p.host[2] = fz;
2000 p.host[3] = fw;
2001 // w = 1 marks an accent-tinted plate (the focused-pane
2002 // treatment): the shader then colors the WHOLE rolled edge
2003 // with the tint, not just the specular glint — matching the
2004 // free-carve path's tinted-well convention. Neutral white
2005 // keeps w = 0 (spec-only, a no-op multiply).
2006 let full = if *tint == [1.0, 1.0, 1.0] { 0.0 } else { 1.0 };
2007 p.specular_tint = [tint[0], tint[1], tint[2], full];
2008 plate = Some(p);
2009 made_plate = Some(*rect);
2010 }
2011 Prim::Plate { rect, radii, material, depth, shape } if shader_plates => {
2012 let color = material.fill(PlateRole::Nested);
2013 let mat = material.finish.to_array();
2014 if *depth < 0.0 {
2015 // Negative depth = fill-less roll overlay (MODE_ROLL): the
2016 // window-edge roll shading alone, screened over whatever is
2017 // beneath — for a root plate whose face is not a fill (the
2018 // designer's 3D canvas). The cover quad carries no color,
2019 // and the batch is NOT opened as a carve host: an overlay
2020 // owns no surface for a CSG feature to cut into.
2021 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
2022 let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, mat, true, *shape);
2023 p.mode = 11.0; // MODE_ROLL
2024 plate = Some(p);
2025 } else {
2026 // Same lit-plate branch; the cover quad is the exact rect so the
2027 // silhouette and the compositor's rounded window corners agree.
2028 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
2029 let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, true, *shape);
2030 let [fz, fw] = material.frost.pack(scale);
2031 p.host[2] = fz;
2032 p.host[3] = fw;
2033 plate = Some(p);
2034 made_plate = Some(*rect);
2035 }
2036 }
2037 Prim::Recess { rect, radii, depth, edges, .. }
2038 | Prim::Boss { rect, radii, depth, edges, .. }
2039 | Prim::Ridge { rect, radii, depth, edges }
2040 | Prim::Trough { rect, radii, depth, edges, .. }
2041 if shader_plates =>
2042 {
2043 let tint = match &item.prim {
2044 Prim::Recess { tint, .. } => *tint,
2045 Prim::Boss { tint, .. } => *tint,
2046 Prim::Trough { tint, .. } => *tint,
2047 _ => None,
2048 };
2049 // Recess carves down into the surface; Boss raises a plateau out
2050 // of it (same machinery, depth sign flipped); Ridge is a raised
2051 // rim straddling the boundary and Trough the sunken valley twin
2052 // (their own overlay profiles — never grouped, the CSG features
2053 // only model monotonic steps).
2054 let mode = match &item.prim {
2055 Prim::Boss { .. } => 3.0f32,
2056 Prim::Ridge { .. } => 4.0,
2057 Prim::Trough { .. } => 9.0,
2058 _ => 2.0,
2059 };
2060 let raised = mode > 2.5;
2061 // Grouped into the enclosing plate whenever one is live: the
2062 // carve becomes a CSG feature of that plate's single draw —
2063 // exact composite shading, real junctions at the plate's rolled
2064 // perimeter — instead of a shading overlay (the fallback below).
2065 //
2066 // Edge-suppressed carves NEVER group: a suppressed wall's rect
2067 // extends past the carve (below), relying on the overlay cover
2068 // quad to keep that shading out of the drawn pixels — a clip
2069 // the plate's whole-surface draw does not have, so grouped it
2070 // smears the extended walls across the plate. Union pieces
2071 // (section wells, a spinbox's field and button run) are
2072 // exactly these.
2073 // A tinted carve also never groups: a CSG feature is geometry only,
2074 // so the tint could only land on the whole plate's specular.
2075 let full_ring = *edges == (true, true, true, true);
2076 let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::vk::MAX_PLATE_FEATURES {
2077 // The carve's shaded region, for the occlusion test below
2078 // (the overlay path's cover-quad inflation).
2079 let infl = *depth * 0.5 + 2.0;
2080 let (sx0, sy0) = (rect.x - infl, rect.y - infl);
2081 let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
2082 plate_stack
2083 .iter()
2084 .enumerate()
2085 .rev()
2086 .find(|(si, (bi, prect))| {
2087 let inside = rect.x >= prect.x - 0.5
2088 && rect.y >= prect.y - 0.5
2089 && rect.x + rect.width <= prect.x + prect.width + 0.5
2090 && rect.y + rect.height <= prect.y + prect.height + 0.5;
2091 if !inside {
2092 return false;
2093 }
2094 // Pixels drawn since this plate (a LATER plate in the
2095 // stack) must not overlap the carve — its shading would
2096 // land beneath them in this plate's earlier draw.
2097 if plate_stack[si + 1..].iter().any(|(_, orect)| {
2098 sx0 < orect.x + orect.width
2099 && sx1 > orect.x
2100 && sy0 < orect.y + orect.height
2101 && sy1 > orect.y
2102 }) {
2103 return false;
2104 }
2105 // Contiguity: only the last feature-receiving plate (or
2106 // one with no features yet) may take another.
2107 batches[*bi].plate.as_ref().map_or(false, |p| p.host[1] == 0.0)
2108 || last_feature_plate == Some(*bi)
2109 })
2110 .map(|(_, &(bi, _))| bi)
2111 } else {
2112 None
2113 };
2114 // Debug-build loudness for the silent grouped→overlay flip —
2115 // see `near_roll_fallback_reason` on what qualifies and why
2116 // this warns instead of panicking.
2117 #[cfg(debug_assertions)]
2118 if host_plate.is_none() && mode < 3.5 && full_ring && tint.is_none() {
2119 let enclosing = plate_stack.iter().enumerate().rev().find(|(_, (_, p))| {
2120 rect.x >= p.x - 0.5
2121 && rect.y >= p.y - 0.5
2122 && rect.x + rect.width <= p.x + p.width + 0.5
2123 && rect.y + rect.height <= p.y + p.height + 0.5
2124 });
2125 if let Some((si, &(bi, prect))) = enclosing {
2126 // Host roll width rides the push's light.w (physical px).
2127 let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
2128 let later: Vec<crate::scene::layout::Rect> =
2129 plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
2130 let budget_full = features.len() >= crate::vk::MAX_PLATE_FEATURES;
2131 if let Some(why) =
2132 near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
2133 {
2134 let kind = if mode > 2.5 { "boss" } else { "recess" };
2135 plate_carve_warn_once(format!(
2136 "plate-carve: near-roll {kind} ({:.0},{:.0} {:.0}x{:.0}) lost grouping — {why}; \
2137 its junction with the host plate's roll shades through the overlay fallback, \
2138 visually different from grouped frames (CCE_PLATE_DEBUG=1 traces verdicts) \
2139 [debug-build warning, printed once]",
2140 rect.x, rect.y, rect.width, rect.height
2141 ));
2142 }
2143 }
2144 }
2145 if dbg_plates {
2146 match host_plate {
2147 Some(_) => dbg_grouped += 1,
2148 None => {
2149 // Re-derive WHY, in the same order the guard tests
2150 // them. Debug-only: the hot path above is untouched.
2151 let kind = match &item.prim {
2152 Prim::Boss { .. } => "boss",
2153 Prim::Ridge { .. } => "ridge",
2154 Prim::Trough { .. } => "trough",
2155 _ => "recess",
2156 };
2157 let infl = *depth * 0.5 + 2.0;
2158 let (sx0, sy0) = (rect.x - infl, rect.y - infl);
2159 let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
2160 let enclosing: Vec<usize> = plate_stack
2161 .iter()
2162 .enumerate()
2163 .filter(|(_, (_, p))| {
2164 rect.x >= p.x - 0.5
2165 && rect.y >= p.y - 0.5
2166 && rect.x + rect.width <= p.x + p.width + 0.5
2167 && rect.y + rect.height <= p.y + p.height + 0.5
2168 })
2169 .map(|(si, _)| si)
2170 .collect();
2171 let occluded = |si: usize| {
2172 plate_stack[si + 1..].iter().any(|(_, o)| {
2173 sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y
2174 })
2175 };
2176 let why = if mode >= 3.5 {
2177 "ridge — never groups (its bump profile is not a monotonic step)".into()
2178 } else if !full_ring {
2179 format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
2180 } else if tint.is_some() {
2181 "tinted — a CSG feature is geometry only, it carries no color".into()
2182 } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
2183 format!("feature budget full ({} used)", features.len())
2184 } else if enclosing.is_empty() {
2185 format!("no enclosing plate ({} open)", plate_stack.len())
2186 } else if enclosing.iter().all(|&si| occluded(si)) {
2187 "a later plate overlaps this carve's shaded region".into()
2188 } else {
2189 "host plate's feature run is closed (another carve appended since)".into()
2190 };
2191 dbg_fell_back.push(format!(
2192 " overlay: {kind} ({:.0},{:.0} {:.0}x{:.0}) — {why}",
2193 rect.x, rect.y, rect.width, rect.height
2194 ));
2195 }
2196 }
2197 }
2198 if let Some(bi) = host_plate {
2199 {
2200 // A wall the carve shares with the plate's edge extends
2201 // past the plate, so the carve has no wall there.
2202 let ext = *depth + 4.0;
2203 let (mut x0, mut y0) = (rect.x, rect.y);
2204 let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
2205 if !edges.0 { y0 -= ext; }
2206 if !edges.1 { x1 += ext; }
2207 if !edges.2 { y1 += ext; }
2208 if !edges.3 { x0 -= ext; }
2209 let t_px = *depth * scale;
2210 // The carve's drop: the material's pinned height, else
2211 // the analytic ratio of the wall saturating at the DE's
2212 // roll width (`layout::carve_depth_px` states the rule
2213 // once for this path and the shader's free carves).
2214 let k_mag = crate::layout::carve_depth_px(*depth) * scale;
2215 // Negative depth = raised (Boss); the shader's summed
2216 // slope vectors and curvature sign follow it.
2217 let k_px = if raised { -k_mag } else { k_mag };
2218 if let Some(p) = batches[bi].plate.as_mut() {
2219 if p.host[1] == 0.0 {
2220 p.host[0] = features.len() as f32;
2221 }
2222 p.host[1] += 1.0;
2223 }
2224 last_feature_plate = Some(bi);
2225 features.push([
2226 (x0 + x1) * 0.5 * scale,
2227 (y0 + y1) * 0.5 * scale,
2228 (x1 - x0) * 0.5 * scale,
2229 (y1 - y0) * 0.5 * scale,
2230 radii.0 * scale,
2231 radii.1 * scale,
2232 radii.2 * scale,
2233 radii.3 * scale,
2234 t_px,
2235 k_px,
2236 0.0,
2237 0.0,
2238 ]);
2239 continue;
2240 }
2241 }
2242 // Overlay-only carve: the cover quad inflates by half the roll
2243 // width (the step straddles the boundary) and carries no color —
2244 // the shader emits translucent white/black over what's beneath.
2245 let infl = *depth * 0.5 + 2.0;
2246 verts.extend(quad_vertices(
2247 rect.x - infl, rect.y - infl,
2248 rect.width + 2.0 * infl, rect.height + 2.0 * infl,
2249 sw, sh, [0.0; 4],
2250 ));
2251 // A suppressed wall is pushed past the cover quad, so its
2252 // shading falls outside the drawn pixels (see Prim::Recess on
2253 // why a flush region is a step, not a trough).
2254 let ext = *depth + 4.0;
2255 let (mut x0, mut y0) = (rect.x, rect.y);
2256 let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
2257 if !edges.0 { y0 -= ext; }
2258 if !edges.1 { x1 += ext; }
2259 if !edges.2 { y1 += ext; }
2260 if !edges.3 { x0 -= ext; }
2261 let sdf_rect = crate::scene::layout::Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
2262 let mut p = plate_push_raised(&sdf_rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
2263 p.mode = mode;
2264 // w = 1.0 flags the free-carve shader path to mix its white
2265 // highlight screen toward the tint (plates leave w at 0.0).
2266 if let Some(t) = tint {
2267 p.specular_tint = [t[0], t[1], t[2], 1.0];
2268 }
2269 // Host-plate box for the roll fade: a suppressed wall means the
2270 // recess runs flush to the host's edge there, so that side of
2271 // the box sits at the original rect edge; enabled walls face
2272 // host interior, pushed to ±1e5 so no fade applies.
2273 const FAR: f32 = 1e5;
2274 let (hx0, hy0) = (
2275 if edges.3 { rect.x - FAR } else { rect.x },
2276 if edges.0 { rect.y - FAR } else { rect.y },
2277 );
2278 let (hx1, hy1) = (
2279 if edges.1 { rect.x + rect.width + FAR } else { rect.x + rect.width },
2280 if edges.2 { rect.y + rect.height + FAR } else { rect.y + rect.height },
2281 );
2282 p.host = [
2283 (hx0 + hx1) * 0.5 * scale,
2284 (hy0 + hy1) * 0.5 * scale,
2285 (hx1 - hx0) * 0.5 * scale,
2286 (hy1 - hy0) * 0.5 * scale,
2287 ];
2288 plate = Some(p);
2289 }
2290 Prim::Bevel { rect, radii, material, depth, tint: _ } => {
2291 let color = material.fill(PlateRole::Nested);
2292 // Full-size fill: the lip is now a shading overlay, not a paint of the
2293 // outer ring, so the fill must cover the whole rect (the old inset fill
2294 // would leave the ring showing whatever lay beneath).
2295 let corners = crate::widget::CornerRadii {
2296 top_left: radii.0, top_right: radii.1,
2297 bottom_right: radii.2, bottom_left: radii.3,
2298 };
2299 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts);
2300 push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, color, no, &mut verts);
2301 }
2302 Prim::Plate { rect, radii, material, depth, .. } => {
2303 let color = material.fill(PlateRole::Nested);
2304 if *depth < 0.0 {
2305 // Fill-less roll overlay (negative-depth sentinel): the banded
2306 // legacy tessellation has no overlay compositing, so the roll
2307 // is simply absent here — the A/B path draws nothing rather
2308 // than a wrong fill.
2309 continue;
2310 }
2311 // Fill at full size (no inset — see Prim::Plate), then light the face,
2312 // then roll the perimeter. The lip rides on top of the fill's outer band
2313 // rather than replacing it, so the plate's silhouette and the
2314 // compositor's rounded window corners still agree exactly.
2315 let corners = crate::widget::CornerRadii {
2316 top_left: radii.0, top_right: radii.1,
2317 bottom_right: radii.2, bottom_left: radii.3,
2318 };
2319 push_rounded_rect_vertices_corners(
2320 rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts,
2321 );
2322 push_plate_face_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, no, &mut verts);
2323 push_bevel_edge_vertices_radii(
2324 rect.x, rect.y, rect.width, rect.height, *radii, *depth,
2325 sw, sh, color, no, 1.0, &mut verts,
2326 );
2327 }
2328 Prim::Recess { rect, radii, depth, edges, .. } => {
2329 // Edges only — no fill: the shading is an overlay, so whatever is painted
2330 // below (fill, rim gradient, blur) shows through the carve modulated
2331 // rather than repainted. `light_sign = -1.0` shadows the lit-facing edges,
2332 // which is the raised->recessed inversion.
2333 push_bevel_edge_vertices_banded(
2334 rect.x, rect.y, rect.width, rect.height, *radii, *depth,
2335 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), *edges,
2336 EdgeKind::Step, &mut verts,
2337 );
2338 }
2339 Prim::Boss { rect, radii, depth, edges, .. } => {
2340 // Legacy raised step: the recess overlay with the light sign upright.
2341 push_bevel_edge_vertices_banded(
2342 rect.x, rect.y, rect.width, rect.height, *radii, *depth,
2343 sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(*depth), *edges,
2344 EdgeKind::Step, &mut verts,
2345 );
2346 }
2347 Prim::Ridge { rect, radii, depth, edges } => {
2348 // Legacy approximation: a raised step up at the boundary plus a
2349 // recessed step down half a width in (the banded machinery has no
2350 // bump profile; the double-pass hot crest is accepted here — the
2351 // legacy path exists only for A/B comparison).
2352 let half = *depth * 0.5;
2353 push_bevel_edge_vertices_banded(
2354 rect.x, rect.y, rect.width, rect.height, *radii, half,
2355 sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
2356 EdgeKind::Step, &mut verts,
2357 );
2358 let ir = (radii.0 - half).max(0.0);
2359 push_bevel_edge_vertices_banded(
2360 rect.x + half, rect.y + half,
2361 rect.width - *depth, rect.height - *depth,
2362 (ir, ir, ir, ir), half,
2363 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
2364 EdgeKind::Step, &mut verts,
2365 );
2366 }
2367 Prim::Trough { rect, radii, depth, edges, .. } => {
2368 // Legacy approximation, the Ridge arm's two steps with the light
2369 // signs swapped: down at the boundary, back up half a width in.
2370 // The banded machinery has no valley profile, so this is the old
2371 // stacked look — accepted here, as the legacy path exists only
2372 // for A/B comparison against the SDF one.
2373 let half = *depth * 0.5;
2374 push_bevel_edge_vertices_banded(
2375 rect.x, rect.y, rect.width, rect.height, *radii, half,
2376 sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
2377 EdgeKind::Step, &mut verts,
2378 );
2379 let ir = (radii.0 - half).max(0.0);
2380 push_bevel_edge_vertices_banded(
2381 rect.x + half, rect.y + half,
2382 rect.width - *depth, rect.height - *depth,
2383 (ir, ir, ir, ir), half,
2384 sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
2385 EdgeKind::Step, &mut verts,
2386 );
2387 }
2388 Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
2389 push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
2390 }
2391 Prim::ArcShaded { cx, cy, radius, thickness, start: sa, end: ea, inner, crest, outer } => {
2392 push_arc_shaded_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *inner, *crest, *outer, segs(*radius), no, &mut verts);
2393 }
2394 Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
2395 let lc = match cap {
2396 Cap::Flat => LineCap::Flat,
2397 Cap::Round => LineCap::Round,
2398 Cap::Arrow => LineCap::Arrow,
2399 };
2400 verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
2401 }
2402 Prim::Circle { cx, cy, radius, color } => {
2403 if item.clip_circle.is_none() && *radius > 1.5 {
2404 // Cover quad with the disc itself as the (feathered) circle
2405 // clip: a per-pixel smooth silhouette instead of a hard-edged
2406 // fan. The quad overhangs by 1px for the feather. Only when
2407 // no ancestor clip holds the slot — then it's the fan path.
2408 let own = [cx * scale, cy * scale, radius * scale];
2409 let d = *radius + 1.0;
2410 verts.extend(quad_vertices_with_clip(
2411 cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color, own,
2412 ));
2413 } else {
2414 verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
2415 }
2416 }
2417 Prim::Sphere { cx, cy, radius, material } if shader_plates => {
2418 let color = material.fill(PlateRole::Nested);
2419 let mat = material.finish.to_array();
2420 // A hemisphere lit per pixel by the plate branch (mode 5): one
2421 // cover quad, its own never-merged batch. The quad overhangs
2422 // the disc by 1px for the shader's silhouette anti-aliasing.
2423 let d = *radius + 1.0;
2424 verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
2425 plate = Some(crate::vk::PlatePush {
2426 // Center + radius in physical px; the SDF box machinery is
2427 // unused in this mode, so .w is free.
2428 rect: [cx * scale, cy * scale, radius * scale, 0.0],
2429 radii: [0.0; 4],
2430 light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
2431 material: mat,
2432 host: [0.0; 4],
2433 specular_tint: [1.0, 1.0, 1.0, 0.0],
2434 mode: 5.0,
2435 shape: 2.0,
2436 });
2437 }
2438 Prim::Sphere { cx, cy, radius, material } => {
2439 let color = material.fill(PlateRole::Nested);
2440 // Legacy path: the flat disc, exactly a Circle.
2441 verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, color, segs(*radius), no));
2442 }
2443 Prim::DropletScrim { rect, material, spec, feather } if shader_plates => {
2444 let color = material.fill(PlateRole::Nested);
2445 let mat = material.finish.to_array();
2446 // Shader mode 12: the droplet's own SDF, filled flat and
2447 // feathered inward. No contact shadow, so unlike the lit drop
2448 // the cover quad is exactly the box — a scrim never draws
2449 // outside the silhouette.
2450 let g = droplet_geom(rect, spec);
2451 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
2452 plate = Some(crate::vk::PlatePush {
2453 rect: [
2454 (rect.x + rect.width * 0.5) * scale,
2455 (rect.y + rect.height * 0.5) * scale,
2456 g.hx * scale,
2457 g.hy * scale,
2458 ],
2459 radii: [g.sag * scale, g.br * scale, g.bw * scale, g.k * scale],
2460 // p_light.w carries the FEATHER here; mode 12 returns
2461 // before the shading band it otherwise holds is read.
2462 light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
2463 material: [mat[0], 0.0, 0.0, 0.0],
2464 host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
2465 specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
2466 mode: 12.0,
2467 shape: spec.curve.clamp(2.0, 6.0),
2468 });
2469 }
2470 Prim::Droplet { rect, material, spec } if shader_plates => {
2471 let color = material.fill(PlateRole::Nested);
2472 let mat = material.finish.to_array();
2473 // A water droplet lit by shader mode 10: one cover quad; the
2474 // shader owns silhouette (sheet ∪smin belly), dome shading,
2475 // fresnel rim and thin-edge clarity. The spec's height
2476 // fractions resolve against the concrete rect here, clamped so
2477 // small or narrow boxes stay well-formed (a belly wider than
2478 // the box would turn the SDF interior inside out).
2479 // The cover quad grows sideways and BELOW the box by the
2480 // contact shadow's reach — shadow fragments live outside the
2481 // silhouette, so they need covered pixels to shade.
2482 let g = droplet_geom(rect, spec);
2483 let (hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach) =
2484 (g.hx, g.hy, g.sag, g.br, g.bw, g.k, g.sr, g.ar, g.band, g.bow, g.sh_reach);
2485 verts.extend(quad_vertices(
2486 rect.x - sh_reach,
2487 rect.y,
2488 rect.width + 2.0 * sh_reach,
2489 rect.height + sh_reach,
2490 sw, sh, color,
2491 ));
2492 plate = Some(crate::vk::PlatePush {
2493 rect: [
2494 (rect.x + rect.width * 0.5) * scale,
2495 (rect.y + rect.height * 0.5) * scale,
2496 hx * scale,
2497 hy * scale,
2498 ],
2499 radii: [sag * scale, br * scale, bw * scale, k * scale],
2500 light: [plate_light[0], plate_light[1], plate_light[2], band * scale],
2501 // Slots y/z/w feed roll_spec and the rim term directly:
2502 // a droplet's material carries its own gleam/shine/rim
2503 // there (`DropletSpec::finish`; a drop is wetter than the
2504 // DE's plates), so this is the material's finish like any
2505 // plate's.
2506 material: mat,
2507 host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
2508 // Droplet glints are always white, so the tint RGB slots
2509 // carry droplet params instead: x = core density,
2510 // y = contact-shadow reach px, z = shadow strength.
2511 specular_tint: [
2512 spec.core.clamp(0.0, 2.0),
2513 sh_reach * scale,
2514 spec.shadow.clamp(0.0, 1.0),
2515 bow * scale,
2516 ],
2517 mode: 10.0,
2518 shape: spec.curve.clamp(2.0, 6.0),
2519 });
2520 }
2521 Prim::DropletScrim { rect, material, spec, .. } => {
2522 let color = material.fill(PlateRole::Nested);
2523 // Legacy banded path: no SDF to feather against, so the scrim
2524 // degrades to the same flat outline the drop itself does —
2525 // hard-edged, but present. A prim with no arm here VANISHES.
2526 let cap = (rect.height * 0.5).min(rect.width * 0.5);
2527 let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
2528 let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
2529 let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
2530 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
2531 }
2532 Prim::Droplet { rect, material, spec } => {
2533 let color = material.fill(PlateRole::Nested);
2534 // Legacy banded path: the flat drop outline — attach-tapered
2535 // top, round bottom. Degrades the material but keeps the
2536 // silhouette (a prim with no arm here VANISHES, it doesn't
2537 // degrade — see Ridge/Groove above).
2538 let cap = (rect.height * 0.5).min(rect.width * 0.5);
2539 let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
2540 let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
2541 let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
2542 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
2543 }
2544 Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
2545 // A quarter-arc carve wall (shader mode 6/7): one cover quad
2546 // over the wedge's reach; the wall straddles the arc by ±t/2
2547 // like every carve boundary. p_rect carries centre + radius,
2548 // p_radii.x the wedge start angle. Host box pushed far out —
2549 // an inside-corner fillet never fades.
2550 let m = *depth * 0.5 + 2.0;
2551 let r = *radius + m;
2552 verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
2553 plate = Some(crate::vk::PlatePush {
2554 rect: [cx * scale, cy * scale, *radius * scale, 0.0],
2555 radii: [*a0, 0.0, 0.0, 0.0],
2556 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2557 material: plate_mat,
2558 host: [0.0, 0.0, 1e6, 1e6],
2559 specular_tint: [1.0, 1.0, 1.0, 0.0],
2560 mode: if *raised { 7.0 } else { 6.0 },
2561 shape: crate::layout::corner_shape(),
2562 });
2563 }
2564 // Legacy banded path has no radial wall — the composed corner
2565 // stays square there (A/B comparison path only).
2566 Prim::ConcaveFillet { .. } => {}
2567 Prim::Groove { a, b, width, depth, host } if shader_plates => {
2568 // A slab carve about the line a–b (shader mode 8): the cover
2569 // quad is the segment's bounding box grown by the groove's own
2570 // half-width plus the wall's reach. Off-band corners of that
2571 // box sit at u = 1 (plateau), so the box overhang shades
2572 // nothing — the slab is what bounds the mark, not the quad.
2573 let m = *width * 0.5 + *depth * 0.5 + 2.0;
2574 let (x0, x1) = (a.0.min(b.0) - m, a.0.max(b.0) + m);
2575 let (y0, y1) = (a.1.min(b.1) - m, a.1.max(b.1) + m);
2576 verts.extend(quad_vertices(x0, y0, x1 - x0, y1 - y0, sw, sh, [0.0; 4]));
2577 // Unit normal of the line — the direction the slab's distance is
2578 // measured along. A degenerate segment falls back to vertical so
2579 // a zero-length groove is a no-op wall rather than a NaN.
2580 let (dx, dy) = (b.0 - a.0, b.1 - a.1);
2581 let len = (dx * dx + dy * dy).sqrt();
2582 let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
2583 plate = Some(crate::vk::PlatePush {
2584 // Centre + slab half-width in physical px; .w unused.
2585 rect: [
2586 (a.0 + b.0) * 0.5 * scale,
2587 (a.1 + b.1) * 0.5 * scale,
2588 *width * 0.5 * scale,
2589 0.0,
2590 ],
2591 radii: [n.0, n.1, 0.0, 0.0],
2592 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2593 material: plate_mat,
2594 host: [
2595 (host.x + host.width * 0.5) * scale,
2596 (host.y + host.height * 0.5) * scale,
2597 host.width * 0.5 * scale,
2598 host.height * 0.5 * scale,
2599 ],
2600 specular_tint: [1.0, 1.0, 1.0, 0.0],
2601 mode: 8.0,
2602 shape: crate::layout::corner_shape(),
2603 });
2604 }
2605 Prim::Groove { a, b, width, depth, host: _ } => {
2606 // Legacy approximation. The banded tessellators walk BOX edges —
2607 // exactly the axis-aligned assumption a groove exists to escape —
2608 // so the walls are drawn directly as two feathered lines meeting
2609 // at the centerline: the engraved-line fake, one half in shadow
2610 // and one lit. Coarser than the SDF (no profile curve, no host
2611 // fade), but this path exists for A/B comparison, and drawing
2612 // NOTHING would silently delete the mark rather than degrade it
2613 // — see `Prim::Ridge` above, which accepts a hot crest for the
2614 // same reason.
2615 let (dx, dy) = (b.0 - a.0, b.1 - a.1);
2616 let len = (dx * dx + dy * dy).sqrt();
2617 if len < 0.001 {
2618 continue;
2619 }
2620 let n = (-dy / len, dx / len);
2621 // Same convention as `push_bevel_edge_vertices_banded`: the
2622 // light folded through `light_sign` (-1.0 — a groove is a
2623 // carve), dotted with each wall's OUTWARD normal, amplitude on
2624 // `bevel_depth`. So a groove re-lights with the DE's light
2625 // instead of hardcoding which side is dark.
2626 let rad = crate::layout::light_source_position();
2627 let (lx, ly) = (-rad.cos(), rad.sin());
2628 let v = crate::layout::bevel_depth() * (n.0 * lx + n.1 * ly);
2629 // Each wall covers its own half, centreline to outer edge —
2630 // abutting rather than overlapping. The SDF gets away with
2631 // walls that overlap across a sub-pixel floor because it is one
2632 // evaluation of |distance|; two opposite-signed overlays would
2633 // just blend to mud.
2634 let half = (*width * 0.5 + *depth * 0.5).max(0.5);
2635 for side in [1.0f32, -1.0] {
2636 let sv = v * side;
2637 let c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
2638 if c[3] <= 0.0 {
2639 continue;
2640 }
2641 let off = side * half * 0.5;
2642 push_feathered_line_vertices(
2643 a.0 + n.0 * off, a.1 + n.1 * off,
2644 b.0 + n.0 * off, b.1 + n.1 * off,
2645 half, sw, sh, c, &mut verts,
2646 );
2647 }
2648 }
2649 Prim::Lattice { rect, period, origin, cell, radius, depth } if shader_plates => {
2650 // A periodic well field (shader mode 13): one cover quad over
2651 // `rect`; the shader folds each pixel into the period and
2652 // measures the nearest cell, so the whole lattice is a single
2653 // evaluation. p_rect = one cell's centre + half-extents,
2654 // p_radii = the corner radius, p_host.xy = the period; the
2655 // host-box fade sides are pushed far out (a lattice never
2656 // fades against a host — its own rect bounds it).
2657 let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
2658 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
2659 plate = Some(crate::vk::PlatePush {
2660 rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
2661 radii: [*radius * scale; 4],
2662 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2663 material: plate_mat,
2664 host: [pw * scale, ph * scale, 1e6, 1e6],
2665 specular_tint: [1.0, 1.0, 1.0, 0.0],
2666 mode: 13.0,
2667 shape: crate::layout::corner_shape(),
2668 });
2669 }
2670 Prim::Grout { rect, period, origin, cell, radius, color } if shader_plates => {
2671 // The lattice's fold, painted flat (shader mode 15): one cover
2672 // quad in the grout colour; the shader keeps it outside the
2673 // cells. Same push layout as the lattice; light/material are
2674 // carried but unread.
2675 let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
2676 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
2677 plate = Some(crate::vk::PlatePush {
2678 rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
2679 radii: [*radius * scale; 4],
2680 light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
2681 material: plate_mat,
2682 host: [pw * scale, ph * scale, 1e6, 1e6],
2683 specular_tint: [1.0, 1.0, 1.0, 0.0],
2684 mode: 15.0,
2685 shape: crate::layout::corner_shape(),
2686 });
2687 }
2688 // Legacy banded path: no periodic wall — the lattice and the grout
2689 // draw nothing there, like the fillet (A/B comparison path only).
2690 Prim::Lattice { .. } | Prim::Grout { .. } => {}
2691 Prim::CarveUnion { boxes, depth, raised } if shader_plates => {
2692 // The union of several boxes as ONE wall (shader mode 14): the
2693 // boxes go into the frame's feature buffer as a contiguous run
2694 // and the shader takes the nearest one per pixel. The cover
2695 // quad is the union's bounding box grown by the wall's reach;
2696 // off-shape corners of it sit at the plateau and shade nothing.
2697 let budget = crate::vk::MAX_PLATE_FEATURES.saturating_sub(features.len());
2698 let take = boxes.len().min(budget);
2699 if take < boxes.len() && plate_debug() {
2700 eprintln!(
2701 "plate-carve: union of {} boxes gets {} — feature budget full ({} used)",
2702 boxes.len(), take, features.len()
2703 );
2704 }
2705 if take == 0 {
2706 continue;
2707 }
2708 let kept = &boxes[..take];
2709 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
2710 for (r, _) in kept {
2711 x0 = x0.min(r.x);
2712 y0 = y0.min(r.y);
2713 x1 = x1.max(r.x + r.width);
2714 y1 = y1.max(r.y + r.height);
2715 }
2716 let infl = *depth * 0.5 + 2.0;
2717 verts.extend(quad_vertices(
2718 x0 - infl, y0 - infl,
2719 (x1 - x0) + 2.0 * infl, (y1 - y0) + 2.0 * infl,
2720 sw, sh, [0.0; 4],
2721 ));
2722 let off = features.len() as f32;
2723 for (r, radii) in kept {
2724 features.push([
2725 (r.x + r.width * 0.5) * scale,
2726 (r.y + r.height * 0.5) * scale,
2727 r.width * 0.5 * scale,
2728 r.height * 0.5 * scale,
2729 radii.0 * scale,
2730 radii.1 * scale,
2731 radii.2 * scale,
2732 radii.3 * scale,
2733 *depth * scale,
2734 0.0,
2735 0.0,
2736 0.0,
2737 ]);
2738 }
2739 // The run is complete: a plate with an open feature run must
2740 // not append past it (its features would no longer be
2741 // contiguous), so it is closed here like any other appender.
2742 last_feature_plate = None;
2743 plate = Some(crate::vk::PlatePush {
2744 rect: [
2745 (x0 + x1) * 0.5 * scale,
2746 (y0 + y1) * 0.5 * scale,
2747 (x1 - x0) * 0.5 * scale,
2748 (y1 - y0) * 0.5 * scale,
2749 ],
2750 // x: the raised flag; the shader reads nothing else here.
2751 radii: [if *raised { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
2752 light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
2753 material: plate_mat,
2754 // Feature run [offset, count] (the renderer rebases the
2755 // offset onto the frame slot, as for mode 1); zw far out
2756 // so the host-box fade never applies.
2757 host: [off, take as f32, 1e6, 1e6],
2758 specular_tint: [1.0, 1.0, 1.0, 0.0],
2759 mode: 14.0,
2760 shape: crate::layout::corner_shape(),
2761 });
2762 }
2763 // Legacy banded path: no union — nothing is drawn there, like the
2764 // fillet and the lattice (A/B comparison path only).
2765 Prim::CarveUnion { .. } => {}
2766 }
2767 let end = verts.len() as u32;
2768 if end == start {
2769 continue;
2770 }
2771 // Some tessellators (quad_vertices, vector_vertices) don't thread the circle clip —
2772 // stamp the whole emitted range so every prim kind honors it uniformly.
2773 if item.clip_circle.is_some() {
2774 for v in verts[start as usize..].iter_mut() {
2775 v.clip_circle = no;
2776 }
2777 }
2778 // Merge into the previous batch if it shares this clip pair and is contiguous.
2779 // Plate batches carry per-draw push constants, and blur-behind batches
2780 // trigger the renderer's snapshot copy, so neither ever merges.
2781 if plate.is_none() && !blur_behind {
2782 // Ordinary geometry painted after a plate ends its carve-grouping
2783 // window: a recess emitted later must overlay this geometry (the
2784 // fallback path), not shade beneath it inside the plate's draw.
2785 if dbg_plates && !plate_stack.is_empty() {
2786 *dbg_closed_by.entry(prim_kind(&item.prim)).or_insert(0) += plate_stack.len();
2787 }
2788 plate_stack.clear();
2789 if let Some(last) = batches.last_mut() {
2790 if last.plate.is_none()
2791 && last.scissor == item.clip
2792 && last.clip_rrect == item.clip_rrect
2793 && last.end == start
2794 {
2795 last.end = end;
2796 continue;
2797 }
2798 }
2799 }
2800 if promoted {
2801 plate_stack.clear();
2802 }
2803 batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate, blur_behind });
2804 if let Some(prect) = made_plate {
2805 plate_stack.push((batches.len() - 1, prect));
2806 if dbg_plates {
2807 dbg_opened += 1;
2808 }
2809 }
2810 }
2811
2812 if dbg_plates && (dbg_grouped > 0 || !dbg_fell_back.is_empty()) {
2813 eprintln!(
2814 "plate-dbg: {} carves — {dbg_grouped} grouped (exact CSG), {} overlay fallback",
2815 dbg_grouped + dbg_fell_back.len(),
2816 dbg_fell_back.len(),
2817 );
2818 eprintln!(
2819 "plate-dbg: {dbg_opened} grouping window(s) opened by a filled plate; closed early by {}",
2820 if dbg_closed_by.is_empty() {
2821 "nothing".to_string()
2822 } else {
2823 dbg_closed_by
2824 .iter()
2825 .map(|(k, n)| format!("{k}x{n}"))
2826 .collect::<Vec<_>>()
2827 .join(", ")
2828 }
2829 );
2830 for line in &dbg_fell_back {
2831 eprintln!("plate-dbg: {line}");
2832 }
2833 }
2834
2835 (verts, batches, images, features)
2836 }
2837
2838 /// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
2839 /// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
2840 ///
2841 /// `shape` is a per-plate corner exponent (`Prim::Plate`'s override); `None`
2842 /// follows the DE-wide `layout::corner_shape`. The span factor follows the
2843 /// exponent actually used, so a circular override (2.0) spans nothing and a
2844 /// half-extent radius lands on a true circle.
2845 #[allow(clippy::too_many_arguments)]
2846 /// The push block of a frosted flat fill promoted to a zero-depth plate: a
2847 /// mode-1 plate with no roll (`t` = 0.001, so the face is exactly the fill),
2848 /// circular corners at the nominal radii, and the fill's own frost recipe in
2849 /// `host.zw` (`Material::from_fill` decodes the sentinel).
2850 fn flat_frost_push(
2851 rect: &crate::scene::layout::Rect,
2852 radii: (f32, f32, f32, f32),
2853 fill: [f32; 4],
2854 scale: f32,
2855 light: [f32; 3],
2856 material: [f32; 4],
2857 ) -> crate::vk::PlatePush {
2858 let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, Some(2.0));
2859 let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
2860 p.host[2] = fz;
2861 p.host[3] = fw;
2862 p
2863 }
2864
2865 fn plate_push_raised(
2866 rect: &crate::scene::layout::Rect,
2867 radii: (f32, f32, f32, f32),
2868 width: f32,
2869 scale: f32,
2870 light: [f32; 3],
2871 material: [f32; 4],
2872 scale_corners: bool,
2873 shape: Option<f32>,
2874 ) -> crate::vk::PlatePush {
2875 // Floored: a rect already shrunk past its padding (a window dragged
2876 // below what its layout can hold) has a NEGATIVE extent here, and
2877 // `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
2878 let cap = (rect.width.min(rect.height) * 0.5).max(0.0);
2879 let shape = shape.map_or_else(crate::layout::corner_shape, |n| n.clamp(2.0, 16.0));
2880 // For PLATES (`scale_corners`), widen the corner span by the
2881 // curvature-match factor (see `layout::corner_span_factor`): the diagonal
2882 // curvature radius equals the configured radius, the corner reads as the
2883 // same size as a circular one, and every roll inset ≤ r stays crease-free
2884 // (past the diagonal curvature radius the offset curve the specular band
2885 // follows creases into a visible square corner). Widget-scale overlay
2886 // reliefs (recess/boss/ridge fallbacks) pass false: their radii must MATCH
2887 // the nominal-radius squircles of the widget silhouettes around them, and
2888 // at their few-px roll widths the offset crease is subpixel.
2889 let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
2890 crate::vk::PlatePush {
2891 rect: [
2892 (rect.x + rect.width * 0.5) * scale,
2893 (rect.y + rect.height * 0.5) * scale,
2894 rect.width * 0.5 * scale,
2895 rect.height * 0.5 * scale,
2896 ],
2897 radii: [
2898 (radii.0 * rscale).clamp(0.0, cap) * scale,
2899 (radii.1 * rscale).clamp(0.0, cap) * scale,
2900 (radii.2 * rscale).clamp(0.0, cap) * scale,
2901 (radii.3 * rscale).clamp(0.0, cap) * scale,
2902 ],
2903 light: [light[0], light[1], light[2], width * scale],
2904 material,
2905 // Mode-1 semantics: [feature offset, feature count] — no carves yet;
2906 // the tessellator fills these in as recesses group into this plate.
2907 host: [0.0, 0.0, 0.0, 0.0],
2908 specular_tint: [1.0, 1.0, 1.0, 0.0],
2909 mode: 1.0,
2910 shape,
2911 }
2912 }
2913
2914 pub fn extra_quad_vertices(
2915 w: &dyn crate::widget::WidgetHost,
2916 qx: f32, qy: f32, qw: f32, qh: f32,
2917 sw: f32, sh: f32,
2918 qc: [f32; 4],
2919 clip_circle: [f32; 3],
2920 ) -> Vec<Vertex> {
2921 let mut verts = Vec::new();
2922 push_extra_quad_vertices(w, qx, qy, qw, qh, sw, sh, qc, clip_circle, &mut verts);
2923 verts
2924 }
2925
2926 fn get_child_widget_for_quad<'a>(
2927 w: &'a dyn crate::widget::WidgetHost,
2928 qx: f32, qy: f32, qw: f32, qh: f32,
2929 ) -> &'a dyn crate::widget::WidgetHost {
2930 if let Some(pbg) = w.as_any().downcast_ref::<crate::widget::ParametersBg>() {
2931 for s_opt in &pbg.sliders {
2932 if let Some(s) = s_opt {
2933 let (sx, sy, sww, shh) = s.rect();
2934 if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
2935 return s;
2936 }
2937 }
2938 }
2939 for f_opt in &pbg.float3s {
2940 if let Some(f) = f_opt {
2941 let (fx, fy, fww, fhh) = f.rect();
2942 if qx >= fx - 0.1 && qx + qw <= fx + fww + 0.1 && qy >= fy - 0.1 && qy + qh <= fy + fhh + 0.1 {
2943 return f;
2944 }
2945 }
2946 }
2947 for sb_opt in &pbg.spinboxes {
2948 if let Some(sb) = sb_opt {
2949 let (sx, sy, sww, shh) = sb.rect();
2950 if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
2951 return sb;
2952 }
2953 }
2954 }
2955 for btn_opt in &pbg.buttons {
2956 if let Some(btn) = btn_opt {
2957 let (bx, by, bww, bhh) = btn.rect();
2958 if qx >= bx - 0.1 && qx + qw <= bx + bww + 0.1 && qy >= by - 0.1 && qy + qh <= by + bhh + 0.1 {
2959 return btn;
2960 }
2961 }
2962 }
2963 for ch_opt in &pbg.choices {
2964 if let Some(ch) = ch_opt {
2965 let (cx, cy, cww, chh) = ch.rect();
2966 if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
2967 return ch;
2968 }
2969 }
2970 }
2971 for t_opt in &pbg.texts {
2972 if let Some(t) = t_opt {
2973 let (tx, ty, tww, thh) = t.rect();
2974 if qx >= tx - 0.1 && qx + qw <= tx + tww + 0.1 && qy >= ty - 0.1 && qy + qh <= ty + thh + 0.1 {
2975 return t;
2976 }
2977 }
2978 }
2979 for cb_opt in &pbg.toggles {
2980 if let Some(cb) = cb_opt {
2981 let (cx, cy, cww, chh) = cb.rect();
2982 if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
2983 return cb;
2984 }
2985 }
2986 }
2987 for c_opt in &pbg.colors {
2988 if let Some(c) = c_opt {
2989 let (cx, cy, cww, chh) = c.rect();
2990 if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
2991 return c;
2992 }
2993 }
2994 }
2995 }
2996 w
2997 }
2998
2999 pub fn push_extra_quad_vertices(
3000 w: &dyn crate::widget::WidgetHost,
3001 qx: f32, qy: f32, qw: f32, qh: f32,
3002 sw: f32, sh: f32,
3003 qc: [f32; 4],
3004 clip_circle: [f32; 3],
3005 out: &mut Vec<Vertex>,
3006 ) {
3007 if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
3008 if graph.is_node_rect(qx, qy, qw, qh) {
3009 let r = crate::layout::graph_node_corner_radius();
3010 let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
3011 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
3012 return;
3013 }
3014 }
3015
3016 let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
3017 let radii = target_w.corner_radii();
3018 if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
3019 out.extend_from_slice(&quad_vertices_with_clip(qx, qy, qw, qh, sw, sh, qc, clip_circle));
3020 if let Some((color, thickness)) = target_w.solid_border() {
3021 let (wx, wy, ww, wh) = target_w.rect();
3022 if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
3023 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
3024 }
3025 }
3026 return;
3027 }
3028
3029 let (wx, mut wy, ww, mut wh) = target_w.rect();
3030 let top_room = target_w.label_strip();
3031 wy += top_room;
3032 wh -= top_room;
3033 let extra_radii = crate::widget::CornerRadii::new(
3034 if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
3035 if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
3036 if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
3037 if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
3038 );
3039
3040 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
3041
3042 if let Some((color, thickness)) = target_w.solid_border() {
3043 let (rx, mut ry, rw, mut rh) = target_w.rect();
3044 let top = target_w.label_strip();
3045 ry += top;
3046 rh -= top;
3047 if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
3048 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
3049 }
3050 }
3051 }
3052
3053 pub fn extra_quad_vertices_clipped(
3054 w: &dyn crate::widget::WidgetHost,
3055 qx: f32, qy: f32, qw: f32, qh: f32,
3056 sw: f32, sh: f32,
3057 qc: [f32; 4],
3058 clip: (f32, f32, f32, f32),
3059 clip_circle: [f32; 3],
3060 ) -> Vec<Vertex> {
3061 let mut verts = Vec::new();
3062 push_extra_quad_vertices_clipped(w, qx, qy, qw, qh, sw, sh, qc, clip, clip_circle, &mut verts);
3063 verts
3064 }
3065
3066 pub fn push_extra_quad_vertices_clipped(
3067 w: &dyn crate::widget::WidgetHost,
3068 qx: f32, qy: f32, qw: f32, qh: f32,
3069 sw: f32, sh: f32,
3070 qc: [f32; 4],
3071 clip: (f32, f32, f32, f32),
3072 clip_circle: [f32; 3],
3073 out: &mut Vec<Vertex>,
3074 ) {
3075 if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
3076 if graph.is_node_rect(qx, qy, qw, qh) {
3077 let r = crate::layout::graph_node_corner_radius();
3078 let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
3079 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
3080 return;
3081 }
3082 }
3083
3084 let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
3085 let radii = target_w.corner_radii();
3086 if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
3087 let (cx0, cy0, cx1, cy1) = clip;
3088 let ix0 = qx.max(cx0);
3089 let iy0 = qy.max(cy0);
3090 let ix1 = (qx + qw).min(cx1);
3091 let iy1 = (qy + qh).min(cy1);
3092 if ix1 <= ix0 || iy1 <= iy0 {
3093 return;
3094 }
3095 out.extend_from_slice(&quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, sw, sh, qc, clip_circle));
3096 if let Some((color, thickness)) = target_w.solid_border() {
3097 let (wx, wy, ww, wh) = target_w.rect();
3098 if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
3099 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
3100 }
3101 }
3102 return;
3103 }
3104
3105 let (wx, mut wy, ww, mut wh) = target_w.rect();
3106 let top_room = target_w.label_strip();
3107 wy += top_room;
3108 wh -= top_room;
3109 let extra_radii = crate::widget::CornerRadii::new(
3110 if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
3111 if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
3112 if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
3113 if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
3114 );
3115
3116 push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
3117
3118 if let Some((color, thickness)) = target_w.solid_border() {
3119 let (rx, mut ry, rw, mut rh) = target_w.rect();
3120 let top = target_w.label_strip();
3121 ry += top;
3122 rh -= top;
3123 if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
3124 push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
3125 }
3126 }
3127 }
3128
3129 pub fn circle_vertices(
3130 cx: f32, cy: f32, r: f32,
3131 sw: f32, sh: f32,
3132 color: [f32; 4],
3133 segments: usize,
3134 clip_circle: [f32; 3],
3135 ) -> Vec<Vertex> {
3136 let mut verts = Vec::new();
3137 for i in 0..segments {
3138 let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
3139 let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
3140 let x0 = cx;
3141 let y0 = cy;
3142 let x1 = cx + r * theta1.cos();
3143 let y1 = cy + r * theta1.sin();
3144 let x2 = cx + r * theta2.cos();
3145 let y2 = cy + r * theta2.sin();
3146
3147 let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
3148 let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
3149 let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
3150 let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
3151 let ndc_x2 = (x2 / sw) * 2.0 - 1.0;
3152 let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
3153
3154 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
3155 verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
3156 verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
3157 }
3158 verts
3159 }
3160
3161 pub fn circle_border_vertices(
3162 cx: f32, cy: f32, r: f32,
3163 thickness: f32,
3164 sw: f32, sh: f32,
3165 color: [f32; 4],
3166 segments: usize,
3167 clip_circle: [f32; 3],
3168 ) -> Vec<Vertex> {
3169 let mut verts = Vec::new();
3170 for i in 0..segments {
3171 let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
3172 let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
3173
3174 let x0 = cx + (r - thickness) * theta1.cos();
3175 let y0 = cy + (r - thickness) * theta1.sin();
3176 let x1 = cx + r * theta1.cos();
3177 let y1 = cy + r * theta1.sin();
3178
3179 let x2 = cx + r * theta2.cos();
3180 let y2 = cy + r * theta2.sin();
3181 let x3 = cx + (r - thickness) * theta2.cos();
3182 let y3 = cy + (r - thickness) * theta2.sin();
3183
3184 let ndc_x0 = (x0 / sw) * 2.0 - 1.0; let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
3185 let ndc_x1 = (x1 / sw) * 2.0 - 1.0; let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
3186 let ndc_x2 = (x2 / sw) * 2.0 - 1.0; let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
3187 let ndc_x3 = (x3 / sw) * 2.0 - 1.0; let ndc_y3 = 1.0 - (y3 / sh) * 2.0;
3188
3189 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
3190 verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
3191 verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
3192
3193 verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
3194 verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
3195 verts.push(Vertex { position: [ndc_x3, ndc_y3], color, clip_circle });
3196 }
3197 verts
3198 }
3199
3200 pub fn arc_background_vertices(
3201 cx: f32, cy: f32, r: f32,
3202 thickness: f32,
3203 start_angle: f32, end_angle: f32,
3204 sw: f32, sh: f32,
3205 color: [f32; 4],
3206 segments: usize,
3207 clip_circle: [f32; 3],
3208 ) -> Vec<Vertex> {
3209 let mut verts = Vec::new();
3210 push_arc_background_vertices(cx, cy, r, thickness, start_angle, end_angle, sw, sh, color, segments, clip_circle, &mut verts);
3211 verts
3212 }
3213
3214 /// A ring band with radial Gouraud shading: two sub-bands (inner rim → crest
3215 /// centerline, crest → outer rim) whose vertex colors interpolate across the
3216 /// stroke — the rounded-bevel profile — plus the half-px alpha feathers at
3217 /// both true rims (colors matched to the adjacent band, so no seams).
3218 #[allow(clippy::too_many_arguments)]
3219 pub fn push_arc_shaded_vertices(
3220 cx: f32, cy: f32, r: f32,
3221 thickness: f32,
3222 start_angle: f32, end_angle: f32,
3223 sw: f32, sh: f32,
3224 inner: [f32; 4], crest: [f32; 4], outer: [f32; 4],
3225 segments: usize,
3226 clip_circle: [f32; 3],
3227 out: &mut Vec<Vertex>,
3228 ) {
3229 let f = 0.5f32.min(thickness * 0.25);
3230 let r_out = r;
3231 let r_in = (r - thickness).max(0.0);
3232 let r_mid = (r_in + r_out) / 2.0;
3233 let fade_in = [inner[0], inner[1], inner[2], 0.0];
3234 let fade_out = [outer[0], outer[1], outer[2], 0.0];
3235 // (inner radius, outer radius, color at inner edge, color at outer edge)
3236 let bands = [
3237 ((r_in - f).max(0.0), r_in + f, fade_in, inner),
3238 (r_in + f, r_mid, inner, crest),
3239 (r_mid, r_out - f, crest, outer),
3240 (r_out - f, r_out + f, outer, fade_out),
3241 ];
3242 for i in 0..segments {
3243 let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
3244 let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
3245 let (c1, s1) = (theta1.cos(), theta1.sin());
3246 let (c2, s2) = (theta2.cos(), theta2.sin());
3247 for &(ra, rb, ca, cb) in &bands {
3248 if rb <= ra {
3249 continue;
3250 }
3251 let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
3252 [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
3253 };
3254 let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
3255 let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
3256 out.push(Vertex { position: i1, color: ca, clip_circle });
3257 out.push(Vertex { position: o1, color: cb, clip_circle });
3258 out.push(Vertex { position: o2, color: cb, clip_circle });
3259 out.push(Vertex { position: i1, color: ca, clip_circle });
3260 out.push(Vertex { position: o2, color: cb, clip_circle });
3261 out.push(Vertex { position: i2, color: ca, clip_circle });
3262 }
3263 }
3264 }
3265
3266 pub fn push_arc_background_vertices(
3267 cx: f32, cy: f32, r: f32,
3268 thickness: f32,
3269 start_angle: f32, end_angle: f32,
3270 sw: f32, sh: f32,
3271 color: [f32; 4],
3272 segments: usize,
3273 clip_circle: [f32; 3],
3274 out: &mut Vec<Vertex>,
3275 ) {
3276 // The stroke band [r - thickness, r], with a half-px alpha ramp on each rim
3277 // (Gouraud across thin edge bands) so curved edges resolve smoothly instead
3278 // of hard-stepping — the poor-man's AA the flat pipeline doesn't provide.
3279 let f = 0.5f32.min(thickness * 0.25);
3280 let r_in = (r - thickness).max(0.0);
3281 // (inner radius, outer radius, alpha at inner rim, alpha at outer rim)
3282 let bands = [
3283 ((r_in - f).max(0.0), r_in + f, 0.0, color[3]),
3284 (r_in + f, r - f, color[3], color[3]),
3285 (r - f, r + f, color[3], 0.0),
3286 ];
3287 for i in 0..segments {
3288 let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
3289 let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
3290 let (c1, s1) = (theta1.cos(), theta1.sin());
3291 let (c2, s2) = (theta2.cos(), theta2.sin());
3292 for &(ra, rb, aa, ab) in &bands {
3293 if rb <= ra {
3294 continue;
3295 }
3296 let ca = [color[0], color[1], color[2], aa];
3297 let cb = [color[0], color[1], color[2], ab];
3298 let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
3299 [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
3300 };
3301 let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
3302 let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
3303 out.push(Vertex { position: i1, color: ca, clip_circle });
3304 out.push(Vertex { position: o1, color: cb, clip_circle });
3305 out.push(Vertex { position: o2, color: cb, clip_circle });
3306 out.push(Vertex { position: i1, color: ca, clip_circle });
3307 out.push(Vertex { position: o2, color: cb, clip_circle });
3308 out.push(Vertex { position: i2, color: ca, clip_circle });
3309 }
3310 }
3311 }
3312
3313 #[derive(Debug, Clone)]
3314 pub struct WindowSettings {
3315 pub title: String,
3316 pub app_id: String,
3317 pub width: u32,
3318 pub height: u32,
3319 pub fullscreen: bool,
3320 pub min_size: Option<(u32, u32)>,
3321 }
3322
3323 /// A compositor-side window operation requested by the app: an interactive
3324 /// move or resize grab. Returned from [`Application::take_window_action`];
3325 /// the runner executes it with the serial of the most recent pointer press.
3326 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3327 pub enum WindowAction {
3328 Move,
3329 Resize(xdg_toplevel::ResizeEdge),
3330 }
3331
3332 // Re-export the wlr-layer-shell types apps need to describe a layer surface.
3333 pub use smithay_client_toolkit::shell::wlr_layer::{
3334 Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
3335 };
3336
3337 /// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
3338 /// surface (panels, overlays, notifications) instead of an xdg toplevel.
3339 /// Return one from [`Application::layer`] to select layer-shell.
3340 #[derive(Debug, Clone)]
3341 pub struct LayerSettings {
3342 pub layer: LayerKind,
3343 pub anchor: LayerAnchor,
3344 pub exclusive_zone: i32,
3345 pub keyboard_interactivity: LayerKeyboardInteractivity,
3346 /// (top, right, bottom, left) margins in logical pixels.
3347 pub margin: (i32, i32, i32, i32),
3348 pub namespace: String,
3349 }
3350
3351 #[derive(Debug, Clone, Copy, PartialEq)]
3352 pub struct LogicalPosition {
3353 pub x: f32,
3354 pub y: f32,
3355 }
3356
3357 impl LogicalPosition {
3358 pub fn new(x: f32, y: f32) -> Self {
3359 Self { x, y }
3360 }
3361 }
3362
3363 #[derive(Debug, Clone, Copy, PartialEq)]
3364 pub struct LogicalSize {
3365 pub width: f32,
3366 pub height: f32,
3367 }
3368
3369 impl LogicalSize {
3370 pub fn new(width: f32, height: f32) -> Self {
3371 Self { width, height }
3372 }
3373 }
3374
3375 pub struct RenderContext<'a> {
3376 pub font_system: &'a mut FontSystem,
3377 }
3378
3379 pub trait Application: Sized + 'static {
3380 type Message: Send + Clone + 'static;
3381
3382 fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self;
3383 fn settings(&self) -> WindowSettings;
3384 /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
3385 /// instead of an xdg toplevel. Defaults to `None` (a normal window).
3386 fn layer(&self) -> Option<LayerSettings> {
3387 None
3388 }
3389 /// Declare the window a UTILITY window: a tool whose shape is decided by
3390 /// its contents. The compositor then never dictates a size to it (every
3391 /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
3392 /// become the surface's own initial size), offers no resize affordance
3393 /// (the whole border band moves the window), and never saves geometry
3394 /// for it, so a stale remembered size can't be restored over what the
3395 /// app asks for. Declared over the cce window-management protocol at
3396 /// window creation; on a compositor too old to know the request this is
3397 /// silently a plain floating window. Defaults to `false`.
3398 fn utility(&self) -> bool {
3399 false
3400 }
3401 /// Declare the window the DESKTOP-GRID layer (zcce set_grid): the
3402 /// compositor world-anchors the surface to the virtual desktop and
3403 /// pans/zooms it per frame like window content; the app renders only
3404 /// when handed a patch (see [`Application::grid_patch`]). The surface
3405 /// becomes input-transparent and lives behind all windows. Needs
3406 /// manager v6; on an older compositor the declaration is skipped.
3407 /// Defaults to `false`.
3408 fn grid(&self) -> bool {
3409 false
3410 }
3411 /// A grid patch to render (grid apps only): virtual origin (`x`, `y`),
3412 /// virtual size (`w`, `h`), and `scale` surface px per virtual unit.
3413 /// Called right before the frame that must show it; the runner has
3414 /// already resized the surface to `(w*scale, h*scale)` and acks the
3415 /// patch so the coming commit is latched at the new anchor.
3416 fn grid_patch(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _scale: f64) {}
3417 fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
3418 fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
3419 /// How long the runner may sleep between `tick`s while the window is
3420 /// idle — nothing to draw, no animation, no key held, no frame callback
3421 /// outstanding. `None` (the default) lets it sleep until a Wayland
3422 /// event or a message on the app's calloop `Sender` arrives, bounded by
3423 /// [`IDLE_DISPATCH`]. Override with `Some` ONLY if your `tick` polls
3424 /// something the loop cannot see — a `std::sync::mpsc` receiver drained
3425 /// in `tick`, say — because with the default that poll waits for the
3426 /// next unrelated event. The better fix is to send through the calloop
3427 /// `Sender` handed to `new`, which wakes the loop by itself.
3428 fn idle_poll_interval(&self) -> Option<std::time::Duration> {
3429 None
3430 }
3431 /// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
3432 /// tray-hover highlights). Deliberately separate from the single paint path.
3433 fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
3434 fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
3435 None
3436 }
3437
3438 /// Transparent overflow rim, in logical px, on the RIGHT and BOTTOM of
3439 /// the window. Non-zero opts into buffer-larger-than-geometry mode: the
3440 /// runner sizes the surface `margin` wider/taller than the configured
3441 /// window size, publishes the top-left rect as the xdg window geometry
3442 /// (what the compositor tiles, borders, and snaps) and an input region of
3443 /// the frame plus any open popover rects — an overhanging menu stays
3444 /// clickable while empty rim falls through to whatever is behind.
3445 ///
3446 /// Right/bottom ONLY, deliberately: the surface grows away from its
3447 /// origin, so the frame never moves relative to the surface and pointer
3448 /// coordinates stay valid across the resize (a leading rim shifts the
3449 /// surface under an unmoved cursor, and the compositor's stale pointer
3450 /// state then drops the very next click). Frame coords == surface coords:
3451 /// no input translation, no paint shift — the app's only obligation is to
3452 /// lay out against the frame (`display_list`'s `size` minus the margin);
3453 /// content emitted past the frame edge renders in the rim instead of
3454 /// clipping at the buffer edge.
3455 ///
3456 /// The value may change at runtime (return the popover overhang while a
3457 /// menu is open, 0 otherwise): the engine re-derives the surface from the
3458 /// stored frame and resizes on drift. Quantize the answer (e.g. 64px
3459 /// steps) so an animating popover doesn't resize the surface per frame.
3460 /// xdg toplevels only (layer surfaces ignore it).
3461 fn overflow_margin(&self) -> u32 {
3462 0
3463 }
3464
3465 fn desired_size(&self) -> Option<(u32, u32)> {
3466 None
3467 }
3468
3469 fn ui_context(&self) -> Option<&crate::context::UiContext> {
3470 None
3471 }
3472
3473 fn ui_context_mut(&mut self) -> Option<&mut crate::context::UiContext> {
3474 None
3475 }
3476
3477 /// Whether a left-press at (px, py) should start a compositor window drag. Every root
3478 /// root plate container is dissolved (Phase 6), so the default is "no" — apps that want
3479 /// drag-anywhere override this with `ctx.drag_allowed_at(px, py)`.
3480 fn is_movable_root_plate_at(&self, _px: f32, _py: f32) -> bool {
3481 false
3482 }
3483
3484 fn clear_color(&self) -> [f32; 4] {
3485 [0.0, 0.0, 0.0, 0.0]
3486 }
3487
3488 fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
3489
3490 fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
3491 (width, height)
3492 }
3493
3494 /// Mime types this app accepts from a drag, in the app's own preference
3495 /// order (the source's order is ignored — a browser lists `text/html`
3496 /// before `text/uri-list` and which is more useful is the app's call).
3497 /// The default is empty: the app accepts nothing and drags over it read
3498 /// as "can't drop here", which is what every client did before drops
3499 /// existed. Opting in also requires [`Application::handle_drop`].
3500 fn drop_mimes(&self) -> &'static [&'static str] {
3501 &[]
3502 }
3503
3504 /// A completed drop: `data` is everything the source wrote for `mime`,
3505 /// and `pos` is where it was released in the app's logical coordinates.
3506 /// Runs on the main loop, after the transfer finished — this is not the
3507 /// place to block, since the compositor is waiting on the next frame.
3508 fn handle_drop(
3509 &mut self,
3510 _mime: &str,
3511 _data: &[u8],
3512 _pos: LogicalPosition,
3513 _needs_rebuild: &mut bool,
3514 ) {
3515 }
3516
3517 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool);
3518 fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message>;
3519 fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool);
3520 /// Trackpad pinch (zwp_pointer_gestures pinch). `factor` is the scale
3521 /// change SINCE THE LAST update (1.0 = no change, >1 = fingers spreading),
3522 /// so direct-manipulation zoom is `content_scale *= factor`. Return true
3523 /// to consume; returning false falls back to the engine's legacy
3524 /// synthesis — a ctrl+wheel PixelDelta sized for the graph's zoom mapping
3525 /// (`y = (factor-1)/0.015`) — so ctrl-scroll-zoom surfaces keep working
3526 /// without implementing this.
3527 fn handle_pinch(&mut self, _factor: f32, _pos: LogicalPosition, _needs_rebuild: &mut bool) -> bool {
3528 false
3529 }
3530 fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message>;
3531
3532 /// Undo, after the focused widget declined the chord (a text box that is
3533 /// editing takes it for its own typing). Return true when something was
3534 /// undone; false lets the key fall through to `handle_key_input` like any
3535 /// other. The chords are `undo` / `redo` in `input.kdl` (cce-ui domain
3536 /// defaults `ctrl+z` / `ctrl+shift+z`), resolved once at startup. Build
3537 /// the history on `cce_ui::history::History`.
3538 fn undo(&mut self, _needs_rebuild: &mut bool) -> bool {
3539 false
3540 }
3541
3542 /// Redo — see [`undo`](Self::undo).
3543 fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
3544 false
3545 }
3546
3547 /// Opt into the toolkit's keyboard navigation in plate terms: Tab and
3548 /// Shift+Tab move focus to the next / previous plate or well in reading
3549 /// order (`UiContext::focus_step`), a press (Enter / Space) acts on the
3550 /// focused plate, a well opens for typing when focused. Default false: an
3551 /// app that routes Tab itself (a terminal, a web view, its own field
3552 /// order) is undisturbed. See "Plates, wells and seams" in `CLAUDE.md`.
3553 fn plate_navigation(&self) -> bool {
3554 false
3555 }
3556
3557 /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
3558 /// caches its geometry until its own rebuild flag (relief carves collected
3559 /// in a view pass, widget lists built on layout) raises that flag here, so
3560 /// the new ring is drawn; an app that paints fresh every frame needs
3561 /// nothing. Default: nothing.
3562 fn focus_stepped(&mut self) {}
3563 /// Keyboard focus entered/left the window (the compositor keyboard-focuses
3564 /// the focused window, so this is the "am I the focused window" signal —
3565 /// e.g. for focus-dependent chrome). Default: ignore.
3566 fn handle_focus_change(&mut self, _focused: bool, _needs_rebuild: &mut bool) {}
3567
3568 fn custom_vertices(&mut self, _verts: &mut Vec<Vertex>, _size: LogicalSize, _scale: f64) {}
3569
3570 /// The frame's geometry, drawn via one batched, GPU-scissor-clipped pass (the single
3571 /// paint path). Every rendering app implements this — the legacy `view*` sinks are gone;
3572 /// `None` yields an empty frame. Overlays ([`overlay_quads`](Application::overlay_quads))
3573 /// and [`custom_vertices`](Application::custom_vertices) still go through their own paths;
3574 /// text renders from the list when [`display_list_text`](Application::display_list_text)
3575 /// opts in. Receives the frame's logical size and HiDPI scale. Typically implemented as
3576 /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, &self.root))`.
3577 fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<crate::scene::paint::DisplayList> {
3578 None
3579 }
3580
3581 /// Opt in to render the display list's `Prim::Text` items through the glyph pass
3582 /// (shaped via the shared buffer cache, clipped to the item clip ∩ the prim bounds). An
3583 /// app's ENTIRE frame — geometry and text — is then one
3584 /// [`display_list`](Application::display_list). Default `false` draws no text (an app that
3585 /// only draws geometry, or none at all).
3586 ///
3587 /// Display-list text gets the same popover-occlusion clamp as the legacy `text_areas`
3588 /// mapping (`popover_occlusion_clamp`, driven by `ui_context().active_popovers`), so an
3589 /// open popover's plate clips list text beneath it on both paths.
3590 fn display_list_text(&self) -> bool {
3591 false
3592 }
3593
3594 /// Opt into system fonts in the ENGINE's render `FontSystem` (the one that shapes
3595 /// display-list text and rasterizes every glyph at prepare time). Default `false`: the
3596 /// render FontSystem loads only the bundled CCE fonts, and text asking for a family that
3597 /// exists only among installed system fonts is silently invisible — buffers shaped
3598 /// app-side against a system-fonts `FontSystem` carry fontdb face IDs the engine's
3599 /// database doesn't have (the cce-colors Phase 6e bug). An app whose UI must render
3600 /// arbitrary installed families (the font picker) returns `true`; its own `FontSystem`,
3601 /// if it keeps one for measurement, should be `create_font_system_with_system_fonts()`
3602 /// so both databases load identically. Consulted once, at GPU init.
3603 fn load_system_fonts(&self) -> bool {
3604 false
3605 }
3606
3607 /// Called once, right after the renderer is created and before the first
3608 /// frame: create persistent renderer resources here (3D meshes via
3609 /// [`VkRenderer::create_mesh`]). Most 2D apps never need this.
3610 fn renderer_init(&mut self, _renderer: &mut VkRenderer) {}
3611
3612 /// Direct renderer staging, called every frame after the engine's own text
3613 /// prep and immediately before the frame is drawn: stage 3D scene panes
3614 /// (`stage_scene`), path-traced panes (`stage_rt`), flush mesh updates, or
3615 /// prepare app-shaped text (`prepare_text` — an app that returns `false`
3616 /// from [`display_list_text`](Application::display_list_text) fully owns
3617 /// the renderer's text state, the engine never touches it). Return `true`
3618 /// to request another frame immediately (e.g. while a path tracer is still
3619 /// accumulating samples).
3620 fn stage_renderer(&mut self, _renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
3621 false
3622 }
3623
3624 /// The surface was resized (or the scale factor changed): `width`/`height`
3625 /// are the new logical size. The renderer has already been resized; use
3626 /// this for stateful relayout that can't wait for the next paint callback.
3627 fn handle_resize(&mut self, _width: f32, _height: f32, _scale: f64) {}
3628
3629 /// Whether the runner's built-in client-side decorations apply: the
3630 /// titlebar move band, the movable-root plate drag regions, and — when
3631 /// [`csd_resize_borders`](Application::csd_resize_borders) is also on —
3632 /// the rect-edge resize grabs and their edge cursors. Return `false` for a
3633 /// window whose chrome doesn't follow its rect (e.g. a circular pane) and
3634 /// drive moves/resizes yourself via
3635 /// [`take_window_action`](Application::take_window_action).
3636 fn standard_csd(&self) -> bool {
3637 true
3638 }
3639
3640 /// Whether the standard CSD claims the outer 8px of the surface as resize
3641 /// grabs (with matching edge cursors). Off by default: under the cce
3642 /// compositor the server already provides a resize band just *outside* the
3643 /// window, so enabling this gives a window two adjacent 8px gutters driven
3644 /// by different code paths — and only the compositor's snaps to the
3645 /// desktop grid. It also costs the app clicks, since a press inside the
3646 /// band starts a grab and never reaches the widgets underneath.
3647 ///
3648 /// Turn it on for a window that must be resizable by its own edges under a
3649 /// compositor that provides no such affordance. Only consulted when
3650 /// [`standard_csd`](Application::standard_csd) is on.
3651 fn csd_resize_borders(&self) -> bool {
3652 false
3653 }
3654
3655 /// Whether the standard CSD reserves an implicit title-bar strip (`y` in `[8, 32)`) as a
3656 /// drag-to-move handle. Opt-in: off by default, so a window has no title bar and is moved
3657 /// through the compositor (or via explicitly-declared handles —
3658 /// [`is_movable_root_plate_at`](Application::is_movable_root_plate_at)); nothing is
3659 /// implicitly draggable. An app with an actual title bar returns `true`. Separate from
3660 /// [`standard_csd`](Application::standard_csd), which also gates the resize borders, and
3661 /// only consulted when `standard_csd()` is on.
3662 fn csd_titlebar_move(&self) -> bool {
3663 false
3664 }
3665
3666 /// Override the pointer cursor at (x, y). `None` falls back to the
3667 /// runner's standard CSD edge cursors (or `Default` when
3668 /// [`standard_csd`](Application::standard_csd) is off).
3669 fn cursor_icon(&self, _x: f32, _y: f32) -> Option<CursorIcon> {
3670 None
3671 }
3672
3673 /// Polled after each pointer frame is dispatched: return a
3674 /// [`WindowAction`] to start an interactive move/resize grab with the
3675 /// serial of the most recent pointer press. This is take-semantics — the
3676 /// implementation should clear its pending action when returning it.
3677 fn take_window_action(&mut self) -> Option<WindowAction> {
3678 None
3679 }
3680
3681 /// Called once when the event loop ends (window closed, app-requested
3682 /// exit): last-chance work like autosave. The surface is still alive.
3683 fn on_exit(&mut self) {}
3684 }
3685
3686 pub struct PressedKey {
3687 pub logical_key: Key,
3688 pub text: Option<String>,
3689 pub first_pressed: Instant,
3690 pub last_repeated: Instant,
3691 }
3692
3693 fn is_repeatable_key(key: &Key) -> bool {
3694 match key {
3695 Key::Named(NamedKey::Backspace) |
3696 Key::Named(NamedKey::Delete) |
3697 Key::Named(NamedKey::ArrowLeft) |
3698 Key::Named(NamedKey::ArrowRight) |
3699 Key::Named(NamedKey::ArrowUp) |
3700 Key::Named(NamedKey::ArrowDown) |
3701 Key::Named(NamedKey::Home) |
3702 Key::Named(NamedKey::End) |
3703 Key::Character(_) => true,
3704 _ => false,
3705 }
3706 }
3707
3708 /// Default cap on the runner's idle sleep — see `Application::idle_poll_interval`.
3709 pub const IDLE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(1000);
3710
3711 pub struct EngineState<A: Application> {
3712 pub registry_state: RegistryState,
3713 pub compositor_state: CompositorState,
3714 pub xdg_shell_state: XdgShell,
3715 pub layer_shell_state: Option<LayerShell>,
3716 pub shm_state: Shm,
3717 pub seat_state: SeatState,
3718 pub output_state: OutputState,
3719 pub seats: Vec<wl_seat::WlSeat>,
3720 pub pointer: Option<ThemedPointer>,
3721 pub keyboard: Option<wl_keyboard::WlKeyboard>,
3722
3723 pub window: Option<XdgWindow>,
3724 pub layer_surface: Option<LayerSurface>,
3725 pub surface: Option<wl_surface::WlSurface>,
3726
3727 pub inner: Option<A>,
3728
3729 pub renderer: Option<VkRenderer>,
3730 pub font_system: Option<FontSystem>,
3731 pub swash_cache: cosmic_text::SwashCache,
3732
3733 pub scale_factor: f64,
3734 /// The buffer scale last sent to the surface. Updated in [`Self::render`],
3735 /// paired with the present that commits a matching-size buffer — never on
3736 /// the scale event itself, which races in-flight presents of old buffers.
3737 pub committed_buffer_scale: i32,
3738 /// Outputs the surface has entered and not left. Used by
3739 /// `scale_factor_changed` to reject the SCTK no-outputs fallback: on
3740 /// suspend/resume the DRM connector is destroyed and re-created, the
3741 /// surface briefly sits on zero (live) outputs, and SCTK reports scale 1.
3742 /// Acting on that report rebuilds the buffer at scale-1 size while the
3743 /// surface's latched scale can still be 2 — a fatal `invalid_size`
3744 /// protocol error for odd-sized surfaces (the status bar crash-loop on
3745 /// every resume) and a silently HALF-SIZE window for even-sized ones
3746 /// (the compositor reads buffer/scale as a self-resize and the halving
3747 /// sticks, compounding per resume).
3748 pub entered_outputs: Vec<wl_output::WlOutput>,
3749 pub logical_width: f32,
3750 pub logical_height: f32,
3751 /// The window-frame logical size (surface minus the overflow rim) as of
3752 /// the last configure/desired-size — what the surface is re-derived from
3753 /// when [`Application::overflow_margin`] changes at runtime.
3754 pub frame_logical: (f32, f32),
3755 /// The overflow margin the current surface was actually sized with. Input
3756 /// translation and the dl-text overlay offsets use THIS, never a live
3757 /// `overflow_margin()` read — the app may have changed its answer since.
3758 pub applied_margin: f32,
3759 /// True while the previous frame ran with a nonzero margin — lets the
3760 /// per-frame geometry publish reset state exactly once on deactivation.
3761 pub overflow_was_active: bool,
3762 /// The popover-union rect last sent via zcce set_popover_region, logical
3763 /// surface px; None once a clear has been sent (or never anything).
3764 pub sent_popover_region: Option<(i32, i32, i32, i32)>,
3765
3766 pub exit: bool,
3767 pub redraw: bool,
3768 pub frame_callback_pending: bool,
3769 /// When the pending frame callback was armed — the starvation fallback's
3770 /// clock (see the render gate in `run`).
3771 pub frame_callback_armed_at: Option<std::time::Instant>,
3772 /// Keep rendering (vsync-paced) briefly after the last genuine dirty frame.
3773 /// Sparse, isolated commits get their frame callbacks serviced multiple
3774 /// compositor frames late (measured 22-128ms on cce-fx, growing per sparse
3775 /// commit), while a continuously committing surface is serviced in one
3776 /// frame (~16ms). A short warm-down keeps interactive sequences (hover,
3777 /// typing, scrolling) in the healthy continuous regime; idle still idles.
3778 pub warm_until: Option<std::time::Instant>,
3779 /// Consecutive renders skipped by the extent gate (pending swapchain size
3780 /// != the size the current logical size and scale call for). Normally 0 or
3781 /// 1; a persistent count means no frame is presenting and deserves a warn.
3782 pub extent_gate_skips: u32,
3783 pub first_configure_received: bool,
3784 pub ctrl_pressed: bool,
3785 /// The `undo` / `redo` chords, resolved from `input.kdl` at startup.
3786 pub undo_chord: String,
3787 /// `focus_next_group` / `focus_prev_group` (input.kdl, cce-ui domain):
3788 /// the plate-navigation group jump, for apps that opt in.
3789 pub group_next_chord: String,
3790 pub group_prev_chord: String,
3791 pub redo_chord: String,
3792 pub shift_pressed: bool,
3793 pub alt_pressed: bool,
3794 pub logo_pressed: bool,
3795 pub pressed_key: Option<PressedKey>,
3796 pub sender: calloop::channel::Sender<A::Message>,
3797 pub current_cursor_icon: Option<CursorIcon>,
3798 pub qh: QueueHandle<EngineState<A>>,
3799 pub just_configured: bool,
3800 pub pointer_gestures: Option<ZwpPointerGesturesV1>,
3801 pub pinch_gesture: Option<ZwpPointerGesturePinchV1>,
3802 /// The cce window-management toplevel handle, held for the window's
3803 /// lifetime once [`Application::utility`] declared the mode.
3804 pub cce_toplevel: Option<crate::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>,
3805 /// Latest unrendered grid_patch (serial, x, y, w, h, scale) — a newer
3806 /// event supersedes an unconsumed older one, per protocol.
3807 pub pending_grid_patch: Option<(u32, f64, f64, f64, f64, f64)>,
3808 pub last_pinch_scale: f32,
3809 pub cursor_pos: (f32, f32),
3810 /// Serial of the most recent pointer press, kept for
3811 /// [`Application::take_window_action`] move/resize grabs.
3812 pub last_press_serial: Option<u32>,
3813 /// Mouse buttons currently held, as a bitmask (1 Left / 2 Right /
3814 /// 4 Middle). On pointer Leave mid-gesture the real Release goes to
3815 /// whatever surface takes the pointer next (fullscreen switches, layout
3816 /// animations), so Leave synthesizes releases for the held set — a drag
3817 /// must end, not stay armed and steered by later motion — and only then
3818 /// runs the off-screen hover-clear (which would otherwise corrupt the
3819 /// drag: a ramp key snapped to the graph corner).
3820 pub buttons_down: u32,
3821 /// This frame's display-list text, shaped and held here so the `TextSpan`s built
3822 /// in the render pass can borrow the buffers (Phase 6 —
3823 /// [`Application::display_list_text`]).
3824 pub dl_text_items: Vec<TextItem>,
3825
3826 /// Drag-and-drop destination state (see [`crate::backend::dnd`]). The
3827 /// manager is absent when the compositor exposes no wl_data_device_manager;
3828 /// every drop path then no-ops.
3829 pub data_device_manager: Option<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
3830 pub data_devices: Vec<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
3831 /// Mime type accepted for the in-flight drag; `None` means the app wants
3832 /// nothing this offer carries, so the drop is declined.
3833 pub drag_mime: Option<String>,
3834 /// Surface-local logical position of the last drag enter/motion — the
3835 /// drop point handed to [`Application::handle_drop`].
3836 pub drag_pos: LogicalPosition,
3837 /// Reader threads post completed drops here; the main loop drains it.
3838 pub drop_tx: Option<calloop::channel::Sender<crate::backend::dnd::DroppedData>>,
3839 /// The offer being read right now, held so it can be finished only once
3840 /// the transfer is actually done (see `dnd::drop_performed`).
3841 pub pending_drop_offer:
3842 Option<smithay_client_toolkit::data_device_manager::data_offer::DragOffer>,
3843 /// The input region last sent to the compositor, so a per-frame
3844 /// [`Application::input_regions`] only costs protocol traffic on change.
3845 pub applied_input_regions: Option<Vec<(i32, i32, i32, i32)>>,
3846 }
3847
3848 impl<A: Application> EngineState<A> {
3849 pub fn init_gpu(&mut self, conn: &Connection, width_logical: f32, height_logical: f32) {
3850 let s = self.scale_factor as f32;
3851 let pw = (width_logical * s) as u32;
3852 let ph = (height_logical * s) as u32;
3853
3854 let surface = self.surface.as_ref().expect("surface missing");
3855
3856 let display_ptr = conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
3857 let surface_ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
3858
3859 let load_system_fonts = self.inner.as_ref().map_or(false, |a| a.load_system_fonts());
3860 // Corner radius 0: runner apps tessellate their own rounded corners.
3861 let renderer =
3862 unsafe { VkRenderer::new(display_ptr, surface_ptr, pw, ph, 0.0) };
3863 self.font_system = Some(if load_system_fonts {
3864 crate::create_font_system_with_system_fonts()
3865 } else {
3866 crate::create_font_system()
3867 });
3868 self.renderer = Some(renderer);
3869 self.logical_width = width_logical;
3870 self.logical_height = height_logical;
3871 }
3872
3873 /// Buffer scale and physical extent for a logical size under the current
3874 /// scale factor: rounded, then snapped up so the extent divides by the
3875 /// buffer scale (a wl_surface requirement). In forced-scale mode the
3876 /// surface stays at buffer_scale 1 (the compositor believes scale 1).
3877 ///
3878 /// This is the single source of the buffer-size formula: `resize` sizes
3879 /// the swapchain with it and `render` refuses to present any extent that
3880 /// disagrees with it — a mispaired buffer/scale commit is how the resume
3881 /// output bounce halved even-sized windows (buffer at the old scale's
3882 /// size, new scale latched; the compositor reads it as a self-resize).
3883 fn buffer_geometry(scale_factor: f64, w: f32, h: f32) -> (i32, u32, u32) {
3884 let s = if crate::scale::forced_scale().is_some() {
3885 1
3886 } else {
3887 (scale_factor.round() as i32).max(1)
3888 };
3889 let su = s as u32;
3890 let pw = ((w as f64 * scale_factor).round() as u32).max(1).div_ceil(su) * su;
3891 let ph = ((h as f64 * scale_factor).round() as u32).max(1).div_ceil(su) * su;
3892 (s, pw, ph)
3893 }
3894
3895 pub fn resize(&mut self, w: f32, h: f32) {
3896 let (w, h) = self.inner.as_ref().unwrap().adjust_size(w, h);
3897 if w > 0.0 && h > 0.0 {
3898 self.logical_width = w;
3899 self.logical_height = h;
3900 let (_, pw, ph) = Self::buffer_geometry(self.scale_factor, w, h);
3901 if let Some(ref mut renderer) = self.renderer {
3902 renderer.resize(pw, ph);
3903 }
3904 let scale = self.scale_factor;
3905 self.inner.as_mut().unwrap().handle_resize(w, h, scale);
3906 self.publish_window_geometry();
3907 }
3908 }
3909
3910 /// Overflow-margin mode ([`Application::overflow_margin`]): re-publish the
3911 /// window frame — the surface rect inset by the margin — as the xdg window
3912 /// geometry, and an input region of the frame PLUS any open popover rects
3913 /// (an overhanging menu's rows must stay clickable; empty rim still falls
3914 /// through). Applied on every resize and, while the rim is live, every
3915 /// loop (the popover rects animate). Margin back at 0 resets both — a
3916 /// no-op only for apps that never had a rim. (All double-buffered surface
3917 /// state, latched by the next commit.)
3918 fn publish_window_geometry(&mut self) {
3919 let m = self.applied_margin;
3920 let Some(ref window) = self.window else { return };
3921 if m <= 0.0 {
3922 if self.overflow_was_active {
3923 let gw = (self.logical_width as i32).max(1);
3924 let gh = (self.logical_height as i32).max(1);
3925 window.xdg_surface().set_window_geometry(0, 0, gw, gh);
3926 if let Some(ref surface) = self.surface {
3927 surface.set_input_region(None);
3928 }
3929 }
3930 return;
3931 }
3932 // Right/bottom rim: the frame keeps the surface origin — no offset,
3933 // frame coords == surface coords.
3934 let gw = ((self.logical_width - m) as i32).max(1);
3935 let gh = ((self.logical_height - m) as i32).max(1);
3936 window.xdg_surface().set_window_geometry(0, 0, gw, gh);
3937 if let Some(ref surface) = self.surface {
3938 let compositor = self.compositor_state.wl_compositor();
3939 let wl_region = compositor.create_region(&self.qh, ());
3940 wl_region.add(0, 0, gw, gh);
3941 // Open popovers, clamped to the surface.
3942 if let Some(ctx) = self.inner.as_ref().unwrap().ui_context() {
3943 for (_id, ptr) in ctx.tree.iter_registered() {
3944 unsafe {
3945 let Some(w) = ptr.as_ref() else { continue };
3946 if !w.visible() {
3947 continue;
3948 }
3949 let Some((px, py, pw, ph)) = w.popover_rect() else { continue };
3950 let x0 = px.max(0.0) as i32;
3951 let y0 = py.max(0.0) as i32;
3952 let x1 = ((px + pw).min(self.logical_width)) as i32;
3953 let y1 = ((py + ph).min(self.logical_height)) as i32;
3954 if x1 > x0 && y1 > y0 {
3955 wl_region.add(x0, y0, x1 - x0, y1 - y0);
3956 }
3957 }
3958 }
3959 }
3960 surface.set_input_region(Some(&wl_region));
3961 wl_region.destroy();
3962 }
3963 }
3964
3965 /// Report the union of the open popover rects to the compositor
3966 /// (zcce set_popover_region, manager v7), so its window chrome — the
3967 /// overview resize ring — stays out from under an in-surface menu. Sent
3968 /// only on change, and a clear is sent when the last popover closes;
3969 /// rects are clamped to the surface in logical px, the coordinate space
3970 /// the protocol specifies. Popovers animate, so this runs every loop —
3971 /// the change gate is what keeps it quiet.
3972 fn send_popover_region(&mut self) {
3973 let Some(tl) = &self.cce_toplevel else { return };
3974 // Version gate on the MANAGER numbering the resource carries (the
3975 // toplevel inherits its bind version): 7 is where the request
3976 // appeared. An older compositor would kill the client on the
3977 // unknown opcode.
3978 if tl.version() < 7 {
3979 return;
3980 }
3981 let mut union: Option<(f32, f32, f32, f32)> = None;
3982 if let Some(ctx) = self.inner.as_ref().unwrap().ui_context() {
3983 for (_id, ptr) in ctx.tree.iter_registered() {
3984 unsafe {
3985 let Some(w) = ptr.as_ref() else { continue };
3986 if !w.visible() {
3987 continue;
3988 }
3989 let Some((px, py, pw, ph)) = w.popover_rect() else { continue };
3990 let (x0, y0) = (px.max(0.0), py.max(0.0));
3991 let x1 = (px + pw).min(self.logical_width);
3992 let y1 = (py + ph).min(self.logical_height);
3993 if x1 <= x0 || y1 <= y0 {
3994 continue;
3995 }
3996 union = Some(match union {
3997 None => (x0, y0, x1, y1),
3998 Some((ux0, uy0, ux1, uy1)) => {
3999 (ux0.min(x0), uy0.min(y0), ux1.max(x1), uy1.max(y1))
4000 }
4001 });
4002 }
4003 }
4004 }
4005 let next = union.map(|(x0, y0, x1, y1)| {
4006 (x0 as i32, y0 as i32, (x1 - x0).ceil() as i32, (y1 - y0).ceil() as i32)
4007 });
4008 if next == self.sent_popover_region {
4009 return;
4010 }
4011 match next {
4012 Some((x, y, w, h)) => tl.set_popover_region(x, y, w, h),
4013 None => tl.set_popover_region(0, 0, 0, 0),
4014 }
4015 self.sent_popover_region = next;
4016 }
4017
4018 /// The cursor for the pointer at (lx, ly): the app's
4019 /// [`Application::cursor_icon`] override, else the standard-CSD edge
4020 /// cursors (status bars and non-standard-CSD apps fall back to Default).
4021 fn cursor_icon_at(&self, lx: f32, ly: f32) -> CursorIcon {
4022 let inner = self.inner.as_ref().unwrap();
4023 if let Some(icon) = inner.cursor_icon(lx, ly) {
4024 return icon;
4025 }
4026 if inner.settings().app_id.starts_with("cce-status")
4027 || !inner.standard_csd()
4028 || !inner.csd_resize_borders()
4029 {
4030 return CursorIcon::Default;
4031 }
4032 let border = 8.0f32;
4033 if ly < border {
4034 if lx < border {
4035 CursorIcon::NwResize
4036 } else if lx > self.logical_width - border {
4037 CursorIcon::NeResize
4038 } else {
4039 CursorIcon::NResize
4040 }
4041 } else if ly > self.logical_height - border {
4042 if lx < border {
4043 CursorIcon::SwResize
4044 } else if lx > self.logical_width - border {
4045 CursorIcon::SeResize
4046 } else {
4047 CursorIcon::SResize
4048 }
4049 } else if lx < border {
4050 CursorIcon::WResize
4051 } else if lx > self.logical_width - border {
4052 CursorIcon::EResize
4053 } else {
4054 CursorIcon::Default
4055 }
4056 }
4057
4058 pub fn render(&mut self) {
4059 // Grid patch: resize to the patch's buffer size, tell the app what
4060 // world region this frame covers, and ack — the commit this render
4061 // produces is the one the compositor latches at the new anchor.
4062 if let Some((serial, px, py, pw, ph, pscale)) = self.pending_grid_patch.take() {
4063 self.resize((pw * pscale) as f32, (ph * pscale) as f32);
4064 self.inner.as_mut().unwrap().grid_patch(px, py, pw, ph, pscale);
4065 if let Some(tl) = &self.cce_toplevel {
4066 tl.ack_grid_patch(serial);
4067 }
4068 }
4069 let logical_w = self.logical_width;
4070 let logical_h = self.logical_height;
4071 let scale_factor = self.scale_factor;
4072
4073 if let Some(ref surface) = self.surface {
4074 if let Some(regions) = self.inner.as_ref().unwrap().input_regions() {
4075 // Only re-send when it actually changes. This runs per frame,
4076 // and a client whose region tracks its content (the desktop
4077 // grid's items follow every pan) would otherwise create and
4078 // destroy a wl_region on every frame of a camera flight.
4079 if self.applied_input_regions.as_deref() != Some(regions.as_slice()) {
4080 let compositor = self.compositor_state.wl_compositor();
4081 let wl_region = compositor.create_region(&self.qh, ());
4082 for &(rx, ry, rw, rh) in ®ions {
4083 wl_region.add(rx, ry, rw, rh);
4084 }
4085 surface.set_input_region(Some(&wl_region));
4086 wl_region.destroy();
4087 self.applied_input_regions = Some(regions);
4088 }
4089 }
4090 }
4091
4092 // 0. Shape every registered widget against the SAME FontSystem the glyph pass draws
4093 // with, before the app builds its frame. A widget's caret/selection/click→index math
4094 // reads per-glyph advances its `prepare_text` records; nothing else calls it on the
4095 // display-list path (the paint walk is `&dyn`, and apps were left to remember —
4096 // cce-list, cce-secrets, and the reference DemoApp all forgot, so their carets fell
4097 // back to `measure_text_width("M")`, an inked extent that drifts off the glyphs).
4098 // The flat path shapes in `layout::render_widget`; apps that hand-shape still work —
4099 // their call and this one hit the same shaped-buffer cache. Pointers are collected
4100 // first so the registry borrow ends before any widget is mutated (the missed-press
4101 // walk dereferences the same registry the same way).
4102 {
4103 let ptrs: Vec<*mut (dyn crate::widget::WidgetHost + 'static)> = self
4104 .inner
4105 .as_ref()
4106 .unwrap()
4107 .ui_context()
4108 .map(|ctx| ctx.tree.iter_registered().map(|(_, p)| p).collect())
4109 .unwrap_or_default();
4110 if !ptrs.is_empty() {
4111 let fs = self.font_system.as_mut().unwrap();
4112 for ptr in ptrs {
4113 unsafe {
4114 if let Some(w) = ptr.as_mut() {
4115 w.prepare_text(fs);
4116 }
4117 }
4118 }
4119 }
4120 }
4121
4122 // 1. The frame's geometry IS the app's display list — the single paint path. Tessellated
4123 // below as one batched, GPU-scissor-clipped pass. An app that draws nothing returns
4124 // `None`, giving an empty frame (the legacy view*/tuple-wrapping path is gone).
4125 let dl = self.inner.as_mut().unwrap()
4126 .display_list(LogicalSize::new(logical_w, logical_h), scale_factor)
4127 .unwrap_or_else(|| crate::scene::paint::PaintCtx::new().finish());
4128
4129 // 1a. Phase 6 display-list text: shape the list's Text prims through the shared buffer
4130 // cache and hold them for the glyph pass (the TextSpans built below borrow these).
4131 // Clip = the paint walk's item clip ∩ the prim's own bounds, in logical space.
4132 self.dl_text_items.clear();
4133 if self.inner.as_ref().unwrap().display_list_text() {
4134 let fs = self.font_system.as_mut().unwrap();
4135 for item in &dl.items {
4136 if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
4137 let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
4138 let merged = match (clip, *bounds) {
4139 (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])]),
4140 (Some(a), None) => Some(a),
4141 (None, b) => b,
4142 };
4143 // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
4144 // offset; ordinary labels take the shared cached buffer.
4145 let (buffer, y_off) = match layout {
4146 Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
4147 None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
4148 };
4149 self.dl_text_items.push(TextItem {
4150 buffer,
4151 x: *x,
4152 y: *y + y_off,
4153 color: cosmic_text::Color::rgba(
4154 color[0],
4155 color[1],
4156 color[2],
4157 (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
4158 ),
4159 bounds: merged,
4160 clip_circle: item.clip_circle,
4161 clip_rrect: item.clip_rrect,
4162 });
4163 }
4164 }
4165 }
4166
4167 let (mut verts, mut dl_batches, dl_images, plate_features) = tessellate_display_list(&dl, logical_w, logical_h, scale_factor as f32);
4168 // A pending height-field export (`CCE_HEIGHTMAP`, or an app's
4169 // `scene::heightfield::request`): the plates of THIS frame, sampled
4170 // as the geometry the shader is about to shade.
4171 if let Some(req) = crate::scene::heightfield::take_request() {
4172 let s = scale_factor as f32;
4173 let (pw, ph) = ((logical_w * s).round() as usize, (logical_h * s).round() as usize);
4174 let hf = crate::scene::heightfield::HeightField::from_frame(&dl_batches, &plate_features, pw, ph, s);
4175 let (lo, hi) = hf.range_px();
4176 match crate::scene::heightfield::export_png(&hf, &req.path, req.mm_per_sample) {
4177 Ok(()) => log::info!(
4178 "[heightfield] wrote {} ({}x{} px, {:.3}..{:.3} mm, metric {})",
4179 req.path.display(), pw, ph, lo / hf.px_per_mm, hi / hf.px_per_mm, hf.source.as_str()
4180 ),
4181 Err(e) => log::warn!("[heightfield] export to {} failed: {e}", req.path.display()),
4182 }
4183 }
4184 // custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
4185 let pre_custom = verts.len() as u32;
4186 self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
4187 if (verts.len() as u32) > pre_custom {
4188 dl_batches.push(DlBatch { scissor: None, clip_rrect: None, start: pre_custom, end: verts.len() as u32, plate: None, blur_behind: false });
4189 }
4190
4191 // 1b. Overlay quads (drawn after the text pass).
4192 let mut overlay_quads = Vec::new();
4193 self.inner.as_mut().unwrap().overlay_quads(&mut overlay_quads, LogicalSize::new(logical_w, logical_h), scale_factor);
4194 let mut overlay_verts = Vec::new();
4195 for &(qx, qy, qw, qh, qc) in &overlay_quads {
4196 overlay_verts.extend(quad_vertices(qx, qy, qw, qh, logical_w, logical_h, qc));
4197 }
4198
4199 // 2. Prepare text
4200 let scale_f32 = scale_factor as f32;
4201 let pw = (logical_w * scale_f32) as u32;
4202 let ph = (logical_h * scale_f32) as u32;
4203
4204 let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
4205 // All text is display-list text now (the legacy text_items/text_areas path is gone):
4206 // map each dl Text prim with the default mapping (scale + surface clamp) plus the
4207 // popover-occlusion clamp against the app's registered popovers.
4208 let mut dl_overlay_rects: Vec<(f32, f32, f32, f32)> = Vec::new();
4209 if let Some(ctx) = self.inner.as_ref().unwrap().ui_context() {
4210 for &pop_id in &ctx.active_popovers {
4211 if let Some(ptr) = ctx.tree.get_ptr(pop_id) {
4212 unsafe {
4213 if let Some((x, y, w, h)) = (*ptr).popover_rect() {
4214 dl_overlay_rects.push((x, y, w, h));
4215 }
4216 }
4217 }
4218 }
4219 }
4220 // The global context menu draws into the app's display list (the render-only xdg
4221 // popup is gone), so it gets the same occlusion: the menu rect clamps list text
4222 // beneath, and the menu's own labels are exempt because they carry bounds equal
4223 // to the rect.
4224 if crate::widget::context_menu::is_visible() {
4225 dl_overlay_rects.push((
4226 crate::widget::context_menu::x(),
4227 crate::widget::context_menu::y(),
4228 crate::widget::context_menu::w(),
4229 crate::widget::context_menu::h(),
4230 ));
4231 }
4232 let mut spans: Vec<TextSpan> = Vec::new();
4233 for ti in &self.dl_text_items {
4234 let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
4235 TextBounds {
4236 left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
4237 top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
4238 right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
4239 bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
4240 }
4241 } else {
4242 bounds
4243 };
4244 popover_occlusion_clamp(&dl_overlay_rects, ti, scale_f32, &mut item_bounds);
4245 spans.push(TextSpan {
4246 buffer: &ti.buffer,
4247 left: (ti.x * scale_f32).round(),
4248 top: (ti.y * scale_f32).round(),
4249 // Buffers are shaped at physical size (get_text_buffer_attrs).
4250 scale: 1.0,
4251 bounds: Some([
4252 item_bounds.left,
4253 item_bounds.top,
4254 item_bounds.right,
4255 item_bounds.bottom,
4256 ]),
4257 default_color: [
4258 ti.color.r() as f32 / 255.0,
4259 ti.color.g() as f32 / 255.0,
4260 ti.color.b() as f32 / 255.0,
4261 ti.color.a() as f32 / 255.0,
4262 ],
4263 rotation: None,
4264 // Circle wins when both are set (the circular pane's innermost clip);
4265 // otherwise a rounded-rect clip rides as center+radius with extents.
4266 clip_circle: match (ti.clip_circle, ti.clip_rrect) {
4267 (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
4268 (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
4269 (None, None) => [0.0; 3],
4270 },
4271 clip_extents: match (ti.clip_circle, ti.clip_rrect) {
4272 (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
4273 _ => [0.0; 2],
4274 },
4275 });
4276 }
4277
4278 // 3. Frame: display-list batches under their physical scissors, then
4279 // text, then overlays. The renderer owns swapchain rebuild/recovery.
4280 // An app without display-list text owns the renderer's text state
4281 // itself (it stages via stage_renderer below); don't wipe it here.
4282 let renderer = self.renderer.as_mut().unwrap();
4283 if self.inner.as_ref().unwrap().display_list_text() {
4284 renderer.prepare_text(self.font_system.as_mut().unwrap(), &mut self.swash_cache, &spans);
4285 }
4286
4287 let image_quads: Vec<crate::vk::ImageQuad> = dl_images
4288 .iter()
4289 .map(|di| crate::vk::ImageQuad {
4290 image: di.image,
4291 rect: (
4292 di.rect.x * scale_f32,
4293 di.rect.y * scale_f32,
4294 di.rect.width * scale_f32,
4295 di.rect.height * scale_f32,
4296 ),
4297 alpha: di.alpha,
4298 z_before: di.at,
4299 clip: di.clip.map(|c| {
4300 (
4301 (c.x * scale_f32).max(0.0) as u32,
4302 (c.y * scale_f32).max(0.0) as u32,
4303 (c.width * scale_f32) as u32,
4304 (c.height * scale_f32) as u32,
4305 )
4306 }),
4307 })
4308 .collect();
4309
4310 let batches: Vec<Batch2D> = dl_batches
4311 .iter()
4312 .map(|batch| Batch2D {
4313 scissor: batch.scissor.map(|clip| {
4314 (
4315 (clip.x * scale_f32).max(0.0) as u32,
4316 (clip.y * scale_f32).max(0.0) as u32,
4317 (clip.width * scale_f32) as u32,
4318 (clip.height * scale_f32) as u32,
4319 )
4320 }),
4321 clip_rrect: batch
4322 .clip_rrect
4323 .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
4324 start: batch.start,
4325 end: batch.end,
4326 plate: batch.plate,
4327 blur_behind: batch.blur_behind,
4328 })
4329 .collect();
4330
4331 let cc = self.inner.as_ref().unwrap().clear_color();
4332 let clear_color = [cc[0].powf(2.2), cc[1].powf(2.2), cc[2].powf(2.2), cc[3]];
4333
4334 // Commit the buffer scale together with a buffer it is legal for: the
4335 // present inside draw_frame_2d is the only commit on this surface, so
4336 // sending the request here orders it right before a matching-size
4337 // attach+commit.
4338 //
4339 // Present only the EXACT extent the current logical size and scale
4340 // call for. Divisibility is not enough: mid scale-transition (the
4341 // resume output bounce) the pending extent can belong to the other
4342 // scale, and an even-sized old-scale buffer divides cleanly by the
4343 // new scale — the commit is protocol-legal, so the compositor reads
4344 // it as a self-resize to half/double and reconfigures the window to
4345 // match (how the color editor came back from suspend at exactly half
4346 // size with the divisibility guard green). Odd sizes at least die
4347 // loudly (invalid_size). On mismatch, re-request the right extent
4348 // and skip — before the frame-callback request below, so the loop
4349 // isn't left waiting on a callback no commit will ever latch.
4350 if let Some(ref surface) = self.surface {
4351 let (s, epw, eph) =
4352 Self::buffer_geometry(self.scale_factor, self.logical_width, self.logical_height);
4353 let e = renderer.pending_extent();
4354 if e.width != epw || e.height != eph {
4355 renderer.resize(epw, eph);
4356 self.extent_gate_skips += 1;
4357 // ~5s of continuous skipping at the 16ms loop cadence: nothing
4358 // is presenting and nothing else will say so — this is the
4359 // only witness to a wedged pending extent.
4360 if self.extent_gate_skips % 300 == 0 {
4361 log::warn!(
4362 "[window_runner] extent gate: pending {}x{} != expected {}x{} for {} consecutive renders; no frame is presenting",
4363 e.width, e.height, epw, eph, self.extent_gate_skips,
4364 );
4365 }
4366 self.redraw = true;
4367 return;
4368 }
4369 self.extent_gate_skips = 0;
4370 if s != self.committed_buffer_scale {
4371 surface.set_buffer_scale(s);
4372 self.committed_buffer_scale = s;
4373 }
4374 }
4375
4376 if let Some(ref surface) = self.surface {
4377 let _callback = surface.frame(&self.qh, ());
4378 self.frame_callback_pending = true;
4379 self.frame_callback_armed_at = Some(std::time::Instant::now());
4380 if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
4381 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
4382 eprintln!("[vk] t={} armed frame callback", t);
4383 }
4384 }
4385
4386 // Direct renderer staging (3D scenes, RT panes, app-shaped text).
4387 if self.inner.as_mut().unwrap().stage_renderer(
4388 renderer,
4389 LogicalSize::new(logical_w, logical_h),
4390 scale_factor,
4391 ) {
4392 self.redraw = true;
4393 }
4394
4395 if !renderer.draw_frame_2d(Frame2D {
4396 verts: &verts,
4397 batches: &batches,
4398 overlay_verts: &overlay_verts,
4399 images: &image_quads,
4400 plate_features: &plate_features,
4401 clear_color,
4402 }) {
4403 // No present happened (swapchain out-of-date, or the created
4404 // swapchain didn't match the requested extent). The frame
4405 // callback requested above will never latch without a commit —
4406 // clear it or the demand-driven loop stalls waiting forever.
4407 self.frame_callback_pending = false;
4408 self.redraw = true;
4409 }
4410 }
4411 }
4412
4413 impl<A: Application> Drop for EngineState<A> {
4414 fn drop(&mut self) {
4415 self.renderer = None;
4416 }
4417 }
4418
4419 impl<A: Application> CompositorHandler for EngineState<A> {
4420 fn scale_factor_changed(
4421 &mut self,
4422 _conn: &Connection,
4423 _qh: &QueueHandle<Self>,
4424 _surface: &wl_surface::WlSurface,
4425 scale_factor: i32,
4426 ) {
4427 // Don't send set_buffer_scale here: an in-flight present can commit an
4428 // old-scale-sized buffer right after it, which is a fatal invalid_size
4429 // protocol error (seen on resume, when outputs bounce 2→1→2). The scale
4430 // request is sent in `render`, paired with a matching-size present.
4431 if crate::scale::forced_scale().is_some() {
4432 // Forced mode: the compositor's opinion (scale 1 under cage) must
4433 // not clobber the override.
4434 return;
4435 }
4436 if self.inner.as_ref().map_or(false, |a| a.grid()) {
4437 // Grid surfaces stay at scale 1 — patch.scale is the sole
4438 // resolution authority (see the pin at surface creation).
4439 return;
4440 }
4441 // Resume bounce: when the surface sits on no LIVE output (the DRM
4442 // connector was destroyed and not yet re-created), the reported
4443 // factor is SCTK's no-outputs fallback, not information — hold the
4444 // last real scale. When the reborn output arrives, surface enter
4445 // recomputes and this handler runs again with a live output backing
4446 // it. Liveness matters (not just enter/leave counting): the leave
4447 // for a destroyed output may never be delivered.
4448 let on_live_output = self
4449 .entered_outputs
4450 .iter()
4451 .any(|o| self.output_state.info(o).is_some());
4452 if !on_live_output && (scale_factor as f64) < self.scale_factor {
4453 return;
4454 }
4455 self.scale_factor = scale_factor as f64;
4456 self.resize(self.logical_width, self.logical_height);
4457 self.redraw = true;
4458 }
4459
4460 fn transform_changed(
4461 &mut self,
4462 _conn: &Connection,
4463 _qh: &QueueHandle<Self>,
4464 _surface: &wl_surface::WlSurface,
4465 _new_transform: wl_output::Transform,
4466 ) {}
4467
4468 fn frame(
4469 &mut self,
4470 _conn: &Connection,
4471 _qh: &QueueHandle<Self>,
4472 _surface: &wl_surface::WlSurface,
4473 _time: u32,
4474 ) {}
4475
4476 fn surface_enter(
4477 &mut self,
4478 _conn: &Connection,
4479 _qh: &QueueHandle<Self>,
4480 _surface: &wl_surface::WlSurface,
4481 output: &wl_output::WlOutput,
4482 ) {
4483 if !self.entered_outputs.contains(output) {
4484 self.entered_outputs.push(output.clone());
4485 }
4486 // Dead entries (destroyed outputs never send leave) are harmless —
4487 // the liveness check in scale_factor_changed skips them — but drop
4488 // them here so the list doesn't grow across suspend cycles.
4489 self.entered_outputs
4490 .retain(|o| self.output_state.info(o).is_some());
4491 self.redraw = true;
4492 }
4493
4494 fn surface_leave(
4495 &mut self,
4496 _conn: &Connection,
4497 _qh: &QueueHandle<Self>,
4498 _surface: &wl_surface::WlSurface,
4499 output: &wl_output::WlOutput,
4500 ) {
4501 self.entered_outputs.retain(|o| o != output);
4502 }
4503 }
4504
4505 impl<A: Application> OutputHandler for EngineState<A> {
4506 fn output_state(&mut self) -> &mut OutputState {
4507 &mut self.output_state
4508 }
4509
4510 fn new_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
4511 let scale = crate::wayland::detect_scale_factor(&self.output_state);
4512 crate::scale::set_scale_factor(scale as f32);
4513 crate::units::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
4514 }
4515 fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
4516 let scale = crate::wayland::detect_scale_factor(&self.output_state);
4517 crate::scale::set_scale_factor(scale as f32);
4518 crate::units::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
4519 }
4520 fn output_destroyed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {}
4521 }
4522
4523 impl<A: Application> ShmHandler for EngineState<A> {
4524 fn shm_state(&mut self) -> &mut Shm {
4525 &mut self.shm_state
4526 }
4527 }
4528
4529 impl<A: Application> ProvidesRegistryState for EngineState<A> {
4530 fn registry(&mut self) -> &mut RegistryState {
4531 &mut self.registry_state
4532 }
4533
4534 fn runtime_add_global(
4535 &mut self,
4536 _conn: &Connection,
4537 _qh: &QueueHandle<Self>,
4538 _name: u32,
4539 _interface: &str,
4540 _version: u32,
4541 ) {}
4542
4543 fn runtime_remove_global(
4544 &mut self,
4545 _conn: &Connection,
4546 _qh: &QueueHandle<Self>,
4547 _name: u32,
4548 _interface: &str,
4549 ) {}
4550 }
4551
4552 impl<A: Application> WindowHandler for EngineState<A> {
4553 fn configure(
4554 &mut self,
4555 _conn: &Connection,
4556 _qh: &QueueHandle<Self>,
4557 _window: &XdgWindow,
4558 configure: WindowConfigure,
4559 _serial: u32,
4560 ) {
4561 let is_fs = configure.is_fullscreen();
4562 let is_max = configure.is_maximized();
4563 crate::scale::set_fullscreen(is_fs);
4564 crate::scale::set_maximized(is_max);
4565
4566 let (w, h) = configure.new_size;
4567 // Configure sizes are window-geometry sizes; with an overflow margin
4568 // the surface is a rim larger on the right and bottom.
4569 let m = self.inner.as_ref().unwrap().overflow_margin() as f32;
4570 if let (Some(w), Some(h)) = (w, h) {
4571 let width = w.get();
4572 let height = h.get();
4573 // Forced mode: the compositor's logical size is really physical
4574 // pixels (scale-1 output); divide to get the app's logical space.
4575 let f = crate::scale::forced_scale().unwrap_or(1.0);
4576 self.frame_logical = (width as f32 / f, height as f32 / f);
4577 self.applied_margin = m;
4578 self.resize(width as f32 / f + m, height as f32 / f + m);
4579 } else if self.inner.as_ref().unwrap().grid() && self.logical_width > 1.0 {
4580 // A grid app's size belongs to its PATCHES: the compositor's
4581 // "you choose" 0x0 must not bounce the surface back to the
4582 // settings size — that thrash recreated multi-hundred-MB
4583 // swapchains per bounce (6.3G peak in 10s). Keep the current
4584 // size; the next grid_patch is the only resizer.
4585 } else {
4586 let settings = self.inner.as_ref().unwrap().settings();
4587 self.frame_logical = (settings.width as f32, settings.height as f32);
4588 self.applied_margin = m;
4589 self.resize(settings.width as f32 + m, settings.height as f32 + m);
4590 }
4591 self.redraw = true;
4592 self.frame_callback_pending = false;
4593 self.first_configure_received = true;
4594 self.just_configured = true;
4595 }
4596
4597 fn request_close(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _window: &XdgWindow) {
4598 self.exit = true;
4599 }
4600 }
4601
4602 impl<A: Application> LayerShellHandler for EngineState<A> {
4603 fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _layer: &LayerSurface) {
4604 self.exit = true;
4605 }
4606
4607 fn configure(
4608 &mut self,
4609 _conn: &Connection,
4610 _qh: &QueueHandle<Self>,
4611 _layer: &LayerSurface,
4612 configure: LayerSurfaceConfigure,
4613 _serial: u32,
4614 ) {
4615 // new_size is in logical pixels; 0 means "client decides", so fall back
4616 // to the app's requested size (mirrors the xdg WindowHandler above).
4617 let (w, h) = configure.new_size;
4618 if w > 0 && h > 0 {
4619 self.resize(w as f32, h as f32);
4620 } else {
4621 let settings = self.inner.as_ref().unwrap().settings();
4622 self.resize(settings.width as f32, settings.height as f32);
4623 }
4624 self.redraw = true;
4625 self.frame_callback_pending = false;
4626 self.first_configure_received = true;
4627 self.just_configured = true;
4628 }
4629 }
4630
4631 impl<A: Application> SeatHandler for EngineState<A> {
4632 fn seat_state(&mut self) -> &mut SeatState {
4633 &mut self.seat_state
4634 }
4635
4636 fn new_seat(&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
4637 self.ensure_data_device(qh, &seat);
4638 self.seats.push(seat);
4639 }
4640
4641 fn new_capability(
4642 &mut self,
4643 _conn: &Connection,
4644 qh: &QueueHandle<Self>,
4645 seat: wl_seat::WlSeat,
4646 capability: Capability,
4647 ) {
4648 // Every seat arrives here, unlike `new_seat` — SCTK binds the seats
4649 // that already exist at startup without announcing them, so a device
4650 // created only there is never created at all on a normal launch.
4651 self.ensure_data_device(qh, &seat);
4652 if capability == Capability::Pointer && self.pointer.is_none() {
4653 let surface = self.compositor_state.create_surface::<Self>(qh);
4654 let themed_pointer = self.seat_state.get_pointer_with_theme(
4655 qh,
4656 &seat,
4657 self.shm_state.wl_shm(),
4658 surface,
4659 ThemeSpec::System,
4660 ).unwrap();
4661 if let Some(ref pg) = self.pointer_gestures {
4662 self.pinch_gesture = Some(pg.get_pinch_gesture(themed_pointer.pointer(), qh, ()));
4663 }
4664 self.pointer = Some(themed_pointer);
4665 }
4666 if capability == Capability::Keyboard && self.keyboard.is_none() {
4667 let keyboard = self.seat_state.get_keyboard(qh, &seat, None).unwrap();
4668 self.keyboard = Some(keyboard);
4669 }
4670 }
4671
4672 fn remove_capability(
4673 &mut self,
4674 _conn: &Connection,
4675 _qh: &QueueHandle<Self>,
4676 _seat: wl_seat::WlSeat,
4677 capability: Capability,
4678 ) {
4679 if capability == Capability::Pointer {
4680 self.pinch_gesture = None;
4681 self.pointer = None;
4682 }
4683 if capability == Capability::Keyboard {
4684 self.keyboard = None;
4685 }
4686 }
4687
4688 fn remove_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
4689 self.seats.retain(|s| s != &seat);
4690 }
4691 }
4692
4693 impl<A: Application> PointerHandler for EngineState<A> {
4694 fn pointer_frame(
4695 &mut self,
4696 _conn: &Connection,
4697 _qh: &QueueHandle<Self>,
4698 _pointer: &wl_pointer::WlPointer,
4699 events: &[smithay_client_toolkit::seat::pointer::PointerEvent],
4700 ) {
4701 use smithay_client_toolkit::seat::pointer::PointerEventKind;
4702 let mut coalesced_h = 0.0f64;
4703 let mut coalesced_v = 0.0f64;
4704 let mut discrete_h = 0;
4705 let mut discrete_v = 0;
4706 let mut has_scroll = false;
4707 let mut axis_source: Option<wl_pointer::AxisSource> = None;
4708 let mut axis_stop = false;
4709 let (mut last_lx, mut last_ly) = (0.0f32, 0.0f32);
4710
4711 // Forced mode: pointer positions arrive in the compositor's scale-1
4712 // logical space (= physical); divide into the app's logical space.
4713 let forced = crate::scale::forced_scale().unwrap_or(1.0);
4714 for event in events {
4715 let (x, y) = event.position;
4716 // Overflow-margin mode needs no translation: the rim is
4717 // right/bottom-only, so frame coords == surface coords.
4718 let lx = x as f32 / forced;
4719 let ly = y as f32 / forced;
4720
4721 self.cursor_pos = (lx, ly);
4722 match &event.kind {
4723 PointerEventKind::Enter { .. } => {
4724 // Enter carries the pointer's position but no Motion follows until it
4725 // actually moves — without this the app's hover state is stale from
4726 // enter to first move, and a press in that window can misroute (e.g. a
4727 // divider press falling through to the movable-root plate window drag).
4728 let mut rebuild = false;
4729 self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(lx, ly), &mut rebuild);
4730 if rebuild {
4731 self.redraw = true;
4732 }
4733
4734 let cursor_icon = self.cursor_icon_at(lx, ly);
4735 self.current_cursor_icon = Some(cursor_icon);
4736 if let Some(ref themed_pointer) = self.pointer {
4737 let _ = themed_pointer.set_cursor(_conn, cursor_icon);
4738 }
4739 }
4740 PointerEventKind::Leave { .. } => {
4741 self.current_cursor_icon = None;
4742 // Focus can move mid-gesture (a fullscreen switch, a
4743 // relayout sliding the window away): the real Release
4744 // then lands on another surface, and an armed drag would
4745 // live forever, steered by whatever motion arrives next.
4746 // End held gestures with synthetic releases at the last
4747 // known cursor position before anything else.
4748 if self.buttons_down != 0 {
4749 let (px, py) = self.cursor_pos;
4750 for (bit, btn) in
4751 [(1u32, MouseButton::Left), (2, MouseButton::Right), (4, MouseButton::Middle)]
4752 {
4753 if self.buttons_down & bit == 0 {
4754 continue;
4755 }
4756 let mut rebuild = false;
4757 if let Some(msg) = self.inner.as_mut().unwrap().handle_mouse_input(
4758 btn,
4759 ElementState::Released,
4760 LogicalPosition::new(px, py),
4761 &mut rebuild,
4762 ) {
4763 let mut update_rebuild = false;
4764 self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
4765 if update_rebuild {
4766 rebuild = true;
4767 }
4768 }
4769 if rebuild {
4770 self.redraw = true;
4771 }
4772 }
4773 self.buttons_down = 0;
4774 }
4775 // Then clear hover with an off-screen move — safe now
4776 // that no drag is held.
4777 let mut rebuild = false;
4778 self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
4779 if rebuild {
4780 self.redraw = true;
4781 }
4782 }
4783 PointerEventKind::Motion { .. } => {
4784 let mut rebuild = false;
4785 self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(lx, ly), &mut rebuild);
4786 if rebuild {
4787 self.redraw = true;
4788 }
4789
4790 let cursor_icon = self.cursor_icon_at(lx, ly);
4791
4792 if self.current_cursor_icon != Some(cursor_icon) {
4793 self.current_cursor_icon = Some(cursor_icon);
4794 if let Some(ref themed_pointer) = self.pointer {
4795 let _ = themed_pointer.set_cursor(_conn, cursor_icon);
4796 }
4797 }
4798 }
4799 PointerEventKind::Press { button, serial, .. } => {
4800 let btn = match *button {
4801 272 => MouseButton::Left,
4802 273 => MouseButton::Right,
4803 274 => MouseButton::Middle,
4804 _ => continue,
4805 };
4806 self.last_press_serial = Some(*serial);
4807 self.buttons_down |= match btn {
4808 MouseButton::Left => 1,
4809 MouseButton::Right => 2,
4810 _ => 4,
4811 };
4812
4813 // Client-Side Decorations (CSD) Drag & Resize Handling
4814 let is_status_bar = self.inner.as_ref().unwrap().settings().app_id.starts_with("cce-status");
4815 if btn == MouseButton::Left && !is_status_bar && self.inner.as_ref().unwrap().standard_csd() {
4816 let border = 8.0f32;
4817 let mut edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::None;
4818 if !self.inner.as_ref().unwrap().csd_resize_borders() {
4819 // Resize borders are off: the compositor's own band
4820 // outside the window handles it. Fall through to the
4821 // move checks so drag-to-move still works.
4822 } else if ly < border {
4823 if lx < border {
4824 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::TopLeft;
4825 } else if lx > self.logical_width - border {
4826 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::TopRight;
4827 } else {
4828 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Top;
4829 }
4830 } else if ly > self.logical_height - border {
4831 if lx < border {
4832 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::BottomLeft;
4833 } else if lx > self.logical_width - border {
4834 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::BottomRight;
4835 } else {
4836 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Bottom;
4837 }
4838 } else if lx < border {
4839 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Left;
4840 } else if lx > self.logical_width - border {
4841 edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Right;
4842 }
4843
4844 if edge != smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::None {
4845 if let Some(ref window) = self.window {
4846 let seat_owned = self.seats.first().cloned().or_else(|| self.seat_state.seats().next());
4847 if let Some(ref seat) = seat_owned {
4848 window.resize(seat, *serial, edge);
4849 continue;
4850 }
4851 }
4852 }
4853
4854 // Titlebar drag check: y is in [8.0, 32.0], and x is not in the top-right button area
4855 let mut should_move = false;
4856 let mut is_widget = false;
4857 if let Some(ctx) = self.inner.as_ref().unwrap().ui_context() {
4858 if ctx.is_widget_at(lx, ly) {
4859 is_widget = true;
4860 }
4861 }
4862 if !is_widget
4863 && self.inner.as_ref().unwrap().csd_titlebar_move()
4864 && ly >= border && ly < 32.0 && lx < self.logical_width - 70.0
4865 {
4866 should_move = true;
4867 } else if self.inner.as_ref().unwrap().is_movable_root_plate_at(lx, ly) {
4868 should_move = true;
4869 }
4870
4871 if should_move {
4872 if let Some(ref window) = self.window {
4873 let seat_owned = self.seats.first().cloned().or_else(|| self.seat_state.seats().next());
4874 if let Some(ref seat) = seat_owned {
4875 window.move_(seat, *serial);
4876 continue;
4877 }
4878 }
4879 }
4880 }
4881
4882 // Outside-press close for open popovers, BEFORE the app's
4883 // dispatch: apps commonly region-gate their routing, so an
4884 // open menu's owner may never hear about a press elsewhere.
4885 if btn == MouseButton::Left {
4886 if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
4887 ctx.close_popovers_missed_by_press(lx, ly);
4888 }
4889 }
4890
4891 let mut rebuild = false;
4892 if let Some(msg) = self.inner.as_mut().unwrap().handle_mouse_input(btn, ElementState::Pressed, LogicalPosition::new(lx, ly), &mut rebuild) {
4893 let mut update_rebuild = false;
4894 self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
4895 if update_rebuild {
4896 rebuild = true;
4897 }
4898 }
4899 if rebuild {
4900 self.redraw = true;
4901 }
4902 }
4903 PointerEventKind::Release { button, .. } => {
4904 let btn = match *button {
4905 272 => MouseButton::Left,
4906 273 => MouseButton::Right,
4907 274 => MouseButton::Middle,
4908 _ => continue,
4909 };
4910 self.buttons_down &= !match btn {
4911 MouseButton::Left => 1,
4912 MouseButton::Right => 2,
4913 _ => 4,
4914 };
4915 let mut rebuild = false;
4916 if let Some(msg) = self.inner.as_mut().unwrap().handle_mouse_input(btn, ElementState::Released, LogicalPosition::new(lx, ly), &mut rebuild) {
4917 let mut update_rebuild = false;
4918 self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
4919 if update_rebuild {
4920 rebuild = true;
4921 }
4922 }
4923 if rebuild {
4924 self.redraw = true;
4925 }
4926 }
4927 PointerEventKind::Axis { horizontal, vertical, source, .. } => {
4928 coalesced_h += horizontal.absolute;
4929 coalesced_v += vertical.absolute;
4930 discrete_h += horizontal.discrete;
4931 discrete_v += vertical.discrete;
4932 // The source and the finger-lift stop ride in the same
4933 // frame as the deltas (or alone, for the lift): they
4934 // decide the smooth-scroll phase below.
4935 if source.is_some() {
4936 axis_source = *source;
4937 }
4938 axis_stop |= horizontal.stop || vertical.stop;
4939 last_lx = lx;
4940 last_ly = ly;
4941 has_scroll = true;
4942 }
4943 }
4944 }
4945
4946 if has_scroll {
4947 // Per-app scroll factors from input.kdl (`<app>`/`cce-ui` domain
4948 // `input { }` blocks); the compositor's global device scaling has
4949 // already been applied at the source.
4950 let factors = crate::input::scroll_factors();
4951 // Smooth-scroll phase for this dispatch: a finger lift is a stop
4952 // frame (no delta); finger/continuous sources track 1:1 and may
4953 // fling on the lift; everything else is a wheel notch that glides.
4954 let no_delta = coalesced_h == 0.0 && coalesced_v == 0.0 && discrete_h == 0 && discrete_v == 0;
4955 let phase = if axis_stop && no_delta {
4956 crate::widget::ScrollPhase::FingerEnd
4957 } else if discrete_h == 0 && discrete_v == 0
4958 && matches!(
4959 axis_source,
4960 None | Some(wl_pointer::AxisSource::Finger) | Some(wl_pointer::AxisSource::Continuous)
4961 )
4962 {
4963 crate::widget::ScrollPhase::Finger
4964 } else {
4965 crate::widget::ScrollPhase::Wheel
4966 };
4967 crate::widget::scroll_motion::set_scroll_phase(phase);
4968 let delta = if discrete_h == 0 && discrete_v == 0 {
4969 // Pixel scroll event from touchpad / smooth mouse
4970 MouseScrollDelta::PixelDelta(Position {
4971 x: -coalesced_h * factors.trackpad,
4972 y: -coalesced_v * factors.trackpad,
4973 })
4974 } else {
4975 // Discrete scroll event (e.g. wheel clicks)
4976 let h_lines = if discrete_h != 0 { discrete_h as f32 } else { coalesced_h as f32 / 10.0 };
4977 let v_lines = if discrete_v != 0 { discrete_v as f32 } else { coalesced_v as f32 / 10.0 };
4978 MouseScrollDelta::LineDelta(-h_lines * factors.mouse as f32, -v_lines * factors.mouse as f32)
4979 };
4980 if crate::scroll_debug() {
4981 static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
4982 let t = T0.get_or_init(std::time::Instant::now).elapsed().as_millis();
4983 eprintln!(
4984 "[scroll {t}ms] runner: coalesced=({coalesced_h:.2},{coalesced_v:.2}) discrete=({discrete_h},{discrete_v}) source={axis_source:?} stop={axis_stop} phase={phase:?} factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({last_lx:.0},{last_ly:.0})",
4985 factors.trackpad, factors.mouse
4986 );
4987 }
4988 let mut rebuild = false;
4989 if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
4990 ctx.ctrl_pressed = self.ctrl_pressed;
4991 ctx.shift_pressed = self.shift_pressed;
4992 ctx.alt_pressed = self.alt_pressed;
4993 ctx.logo_pressed = self.logo_pressed;
4994 }
4995 self.inner.as_mut().unwrap().handle_mouse_wheel(&delta, LogicalPosition::new(last_lx, last_ly), &mut rebuild);
4996 if rebuild {
4997 self.redraw = true;
4998 }
4999 }
5000
5001 // App-driven window move/resize (non-standard CSD; see WindowAction):
5002 // executed with the serial of the most recent pointer press.
5003 if let Some(action) = self.inner.as_mut().unwrap().take_window_action() {
5004 if let (Some(ref window), Some(serial)) = (&self.window, self.last_press_serial) {
5005 let seat_owned = self.seats.first().cloned().or_else(|| self.seat_state.seats().next());
5006 if let Some(ref seat) = seat_owned {
5007 match action {
5008 WindowAction::Move => window.move_(seat, serial),
5009 WindowAction::Resize(edge) => window.resize(seat, serial, edge),
5010 }
5011 }
5012 }
5013 }
5014 }
5015 }
5016
5017 impl<A: Application> KeyboardHandler for EngineState<A> {
5018 fn enter(
5019 &mut self,
5020 _conn: &Connection,
5021 _qh: &QueueHandle<Self>,
5022 _keyboard: &wl_keyboard::WlKeyboard,
5023 _surface: &wl_surface::WlSurface,
5024 _serial: u32,
5025 _raw_modifiers: &[u32],
5026 _keysyms: &[xkeysym::Keysym],
5027 ) {
5028 let mut rebuild = false;
5029 self.inner.as_mut().unwrap().handle_focus_change(true, &mut rebuild);
5030 if rebuild {
5031 self.redraw = true;
5032 }
5033 }
5034
5035 fn leave(
5036 &mut self,
5037 _conn: &Connection,
5038 _qh: &QueueHandle<Self>,
5039 _keyboard: &wl_keyboard::WlKeyboard,
5040 _surface: &wl_surface::WlSurface,
5041 _serial: u32,
5042 ) {
5043 self.pressed_key = None;
5044 self.ctrl_pressed = false;
5045 self.shift_pressed = false;
5046 self.alt_pressed = false;
5047 let mut rebuild = false;
5048 self.inner.as_mut().unwrap().handle_focus_change(false, &mut rebuild);
5049 if rebuild {
5050 self.redraw = true;
5051 }
5052 }
5053
5054 fn press_key(
5055 &mut self,
5056 _conn: &Connection,
5057 _qh: &QueueHandle<Self>,
5058 _keyboard: &wl_keyboard::WlKeyboard,
5059 _serial: u32,
5060 event: smithay_client_toolkit::seat::keyboard::KeyEvent,
5061 ) {
5062 self.handle_key(event, ElementState::Pressed);
5063 }
5064
5065 fn release_key(
5066 &mut self,
5067 _conn: &Connection,
5068 _qh: &QueueHandle<Self>,
5069 _keyboard: &wl_keyboard::WlKeyboard,
5070 _serial: u32,
5071 event: smithay_client_toolkit::seat::keyboard::KeyEvent,
5072 ) {
5073 self.handle_key(event, ElementState::Released);
5074 }
5075
5076 fn update_modifiers(
5077 &mut self,
5078 _conn: &Connection,
5079 _qh: &QueueHandle<Self>,
5080 _keyboard: &wl_keyboard::WlKeyboard,
5081 _serial: u32,
5082 modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
5083 _layout: u32,
5084 ) {
5085 self.ctrl_pressed = modifiers.ctrl;
5086 self.shift_pressed = modifiers.shift;
5087 self.alt_pressed = modifiers.alt;
5088 self.logo_pressed = modifiers.logo;
5089
5090 if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
5091 ctx.ctrl_pressed = self.ctrl_pressed;
5092 ctx.shift_pressed = self.shift_pressed;
5093 ctx.alt_pressed = self.alt_pressed;
5094 ctx.logo_pressed = self.logo_pressed;
5095 }
5096 }
5097
5098 fn update_repeat_info(
5099 &mut self,
5100 _conn: &Connection,
5101 _qh: &QueueHandle<Self>,
5102 _keyboard: &wl_keyboard::WlKeyboard,
5103 info: smithay_client_toolkit::seat::keyboard::RepeatInfo,
5104 ) {
5105 match info {
5106 smithay_client_toolkit::seat::keyboard::RepeatInfo::Repeat { rate, delay } => {
5107 // Store/expose delay/rate if required by the application
5108 let _ = (rate, delay);
5109 }
5110 smithay_client_toolkit::seat::keyboard::RepeatInfo::Disable => {}
5111 }
5112 }
5113 }
5114
5115 impl<A: Application> EngineState<A> {
5116 /// The toolkit-wide undo/redo routing: a press matching the `undo` /
5117 /// `redo` chord goes to the focused widget first (`ContextAction::Undo`
5118 /// / `Redo` — a text box that is editing steps its own typing), then to
5119 /// the app's `Application::undo` / `redo`. Returns whether either took
5120 /// it; otherwise the key is dispatched as usual, so an app with its own
5121 /// scheme is undisturbed. Runs for repeats too — holding the chord walks
5122 /// the history like holding Backspace walks the text.
5123 /// The toolkit's Tab traversal, for apps that opt in
5124 /// (`Application::plate_navigation`): a bare Tab / Shift+Tab press moves
5125 /// keyboard focus to the next / previous plate or well. Returns whether it
5126 /// moved; otherwise the key is dispatched as usual.
5127 fn route_plate_navigation(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
5128 if event.state != ElementState::Pressed {
5129 return false;
5130 }
5131 // The group jump first (its chords carry ctrl); then a bare Tab.
5132 let group_next = crate::widget::match_key_shortcut(event, &self.group_next_chord);
5133 let group_prev = !group_next && crate::widget::match_key_shortcut(event, &self.group_prev_chord);
5134 let bare_tab = event.logical_key == Key::Named(NamedKey::Tab)
5135 && !self.ctrl_pressed
5136 && !self.alt_pressed
5137 && !self.logo_pressed;
5138 if !group_next && !group_prev && !bare_tab {
5139 return false;
5140 }
5141 let reverse = if bare_tab { self.shift_pressed } else { group_prev };
5142 let app = self.inner.as_mut().unwrap();
5143 if !app.plate_navigation() {
5144 return false;
5145 }
5146 let moved = app.ui_context_mut().is_some_and(|ctx| if bare_tab { ctx.focus_step(reverse) } else { ctx.focus_step_group(reverse) });
5147 if moved {
5148 app.focus_stepped();
5149 *rebuild = true;
5150 }
5151 moved
5152 }
5153
5154 fn route_history_chord(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
5155 if event.state != ElementState::Pressed {
5156 return false;
5157 }
5158 let undo = crate::widget::match_key_shortcut(event, &self.undo_chord);
5159 let redo = !undo && crate::widget::match_key_shortcut(event, &self.redo_chord);
5160 if !undo && !redo {
5161 return false;
5162 }
5163 let app = self.inner.as_mut().unwrap();
5164 let action = if undo { crate::widget::ContextAction::Undo } else { crate::widget::ContextAction::Redo };
5165 if let Some(ctx) = app.ui_context_mut() {
5166 if ctx.focused_context_action(action) {
5167 *rebuild = true;
5168 return true;
5169 }
5170 }
5171 let taken = if undo { app.undo(rebuild) } else { app.redo(rebuild) };
5172 if taken {
5173 *rebuild = true;
5174 }
5175 taken
5176 }
5177
5178 fn handle_key(&mut self, event: smithay_client_toolkit::seat::keyboard::KeyEvent, state: ElementState) {
5179 let logical_key = match event.keysym {
5180 xkeysym::Keysym::Escape => Key::Named(NamedKey::Escape),
5181 xkeysym::Keysym::Return => Key::Named(NamedKey::Enter),
5182 xkeysym::Keysym::BackSpace => Key::Named(NamedKey::Backspace),
5183 xkeysym::Keysym::Down => Key::Named(NamedKey::ArrowDown),
5184 xkeysym::Keysym::Up => Key::Named(NamedKey::ArrowUp),
5185 xkeysym::Keysym::Left => Key::Named(NamedKey::ArrowLeft),
5186 xkeysym::Keysym::Right => Key::Named(NamedKey::ArrowRight),
5187 // xkb reports Shift+Tab as ISO_Left_Tab; apps see plain Tab plus
5188 // the shift modifier, matching winit.
5189 xkeysym::Keysym::Tab | xkeysym::Keysym::ISO_Left_Tab => Key::Named(NamedKey::Tab),
5190 xkeysym::Keysym::Delete => Key::Named(NamedKey::Delete),
5191 xkeysym::Keysym::space => Key::Named(NamedKey::Space),
5192 xkeysym::Keysym::Page_Up => Key::Named(NamedKey::PageUp),
5193 xkeysym::Keysym::Page_Down => Key::Named(NamedKey::PageDown),
5194 xkeysym::Keysym::Home => Key::Named(NamedKey::Home),
5195 xkeysym::Keysym::End => Key::Named(NamedKey::End),
5196 xkeysym::Keysym::Super_L | xkeysym::Keysym::Super_R => Key::Named(NamedKey::Super),
5197 xkeysym::Keysym::Alt_L | xkeysym::Keysym::Alt_R => Key::Named(NamedKey::Alt),
5198 xkeysym::Keysym::Control_L | xkeysym::Keysym::Control_R => Key::Named(NamedKey::Control),
5199 xkeysym::Keysym::Shift_L | xkeysym::Keysym::Shift_R => Key::Named(NamedKey::Shift),
5200 xkeysym::Keysym::F5 => Key::Named(NamedKey::F5),
5201 _ => {
5202 // With Ctrl held, xkb's utf8 goes through the legacy control-character
5203 // transformation (ctrl+j = "\n", ctrl+a = 0x01, ...); the keysym is
5204 // untransformed, so prefer it there or ctrl+<letter> shortcuts can
5205 // never match their letter.
5206 if self.ctrl_pressed {
5207 if let Some(ch) = event.keysym.key_char() {
5208 Key::Character(ch.to_string())
5209 } else if let Some(ref text) = event.utf8 {
5210 Key::Character(text.clone())
5211 } else {
5212 return;
5213 }
5214 } else if let Some(ref text) = event.utf8 {
5215 Key::Character(text.clone())
5216 } else if let Some(ch) = event.keysym.key_char() {
5217 Key::Character(ch.to_string())
5218 } else {
5219 return;
5220 }
5221 }
5222 };
5223
5224 let custom_event = KeyEvent {
5225 state,
5226 logical_key,
5227 text: event.utf8.clone(),
5228 repeat: false,
5229 ctrl: self.ctrl_pressed,
5230 shift: self.shift_pressed,
5231 alt: self.alt_pressed,
5232 };
5233
5234 if state == ElementState::Pressed {
5235 if is_repeatable_key(&custom_event.logical_key) {
5236 self.pressed_key = Some(PressedKey {
5237 logical_key: custom_event.logical_key.clone(),
5238 text: custom_event.text.clone(),
5239 first_pressed: Instant::now(),
5240 last_repeated: Instant::now(),
5241 });
5242 } else {
5243 self.pressed_key = None;
5244 }
5245 } else if state == ElementState::Released {
5246 if let Some(ref pk) = self.pressed_key {
5247 if pk.logical_key == custom_event.logical_key {
5248 self.pressed_key = None;
5249 }
5250 }
5251 }
5252
5253 if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
5254 ctx.ctrl_pressed = self.ctrl_pressed;
5255 ctx.shift_pressed = self.shift_pressed;
5256 ctx.alt_pressed = self.alt_pressed;
5257 ctx.logo_pressed = self.logo_pressed;
5258 }
5259
5260 // Escape dismisses the shared context menu before app dispatch — the
5261 // toolkit-wide default, mirroring the click-outside dismissal. Consumed:
5262 // while a menu is open, Escape means "close it", nothing else.
5263 if state == ElementState::Pressed
5264 && custom_event.logical_key == Key::Named(NamedKey::Escape)
5265 && crate::widget::context_menu::is_visible()
5266 {
5267 crate::widget::context_menu::hide();
5268 self.redraw = true;
5269 return;
5270 }
5271
5272 let mut rebuild = false;
5273 if self.route_history_chord(&custom_event, &mut rebuild)
5274 || self.route_plate_navigation(&custom_event, &mut rebuild)
5275 {
5276 self.redraw = true;
5277 return;
5278 }
5279 if let Some(msg) = self.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut rebuild) {
5280 let mut update_rebuild = false;
5281 self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
5282 if update_rebuild {
5283 rebuild = true;
5284 }
5285 }
5286 if rebuild {
5287 self.redraw = true;
5288 }
5289 }
5290 }
5291
5292 impl<A: Application> wayland_client::Dispatch<wl_registry::WlRegistry, GlobalList, Self> for EngineState<A> {
5293 fn event(
5294 _state: &mut Self,
5295 _proxy: &wl_registry::WlRegistry,
5296 _event: wl_registry::Event,
5297 _data: &GlobalList,
5298 _conn: &Connection,
5299 _qh: &QueueHandle<Self>,
5300 ) {}
5301 }
5302
5303 impl<A: Application> wayland_client::Dispatch<crate::protocol::zcce_inspector_v1::ZcceInspectorV1, ()> for EngineState<A> {
5304 fn event(
5305 _state: &mut Self,
5306 _proxy: &crate::protocol::zcce_inspector_v1::ZcceInspectorV1,
5307 _event: crate::protocol::zcce_inspector_v1::Event,
5308 _data: &(),
5309 _conn: &Connection,
5310 _qh: &QueueHandle<Self>,
5311 ) {}
5312 }
5313
5314 impl<A: Application> wayland_client::Dispatch<crate::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1, ()> for EngineState<A> {
5315 fn event(
5316 _state: &mut Self,
5317 _proxy: &crate::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1,
5318 _event: crate::protocol::cce_window_management_v1::zcce_window_manager_v1::Event,
5319 _data: &(),
5320 _conn: &Connection,
5321 _qh: &QueueHandle<Self>,
5322 ) {}
5323
5324 wayland_client::event_created_child!(
5325 EngineState<A>,
5326 crate::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1,
5327 [
5328 6 => (crate::protocol::cce_window_management_v1::zcce_window_v1::ZcceWindowV1, ()),
5329 7 => (crate::protocol::cce_window_management_v1::zcce_output_v1::ZcceOutputV1, ()),
5330 8 => (crate::protocol::cce_window_management_v1::zcce_seat_v1::ZcceSeatV1, ()),
5331 ]
5332 );
5333 }
5334
5335 impl<A: Application> wayland_client::Dispatch<crate::protocol::cce_window_management_v1::zcce_window_v1::ZcceWindowV1, ()> for EngineState<A> {
5336 fn event(
5337 _state: &mut Self,
5338 _proxy: &crate::protocol::cce_window_management_v1::zcce_window_v1::ZcceWindowV1,
5339 _event: crate::protocol::cce_window_management_v1::zcce_window_v1::Event,
5340 _data: &(),
5341 _conn: &Connection,
5342 _qh: &QueueHandle<Self>,
5343 ) {}
5344 }
5345
5346 impl<A: Application> wayland_client::Dispatch<crate::protocol::cce_window_management_v1::zcce_output_v1::ZcceOutputV1, ()> for EngineState<A> {
5347 fn event(
5348 _state: &mut Self,
5349 _proxy: &crate::protocol::cce_window_management_v1::zcce_output_v1::ZcceOutputV1,
5350 _event: crate::protocol::cce_window_management_v1::zcce_output_v1::Event,
5351 _data: &(),
5352 _conn: &Connection,
5353 _qh: &QueueHandle<Self>,
5354 ) {}
5355 }
5356
5357 impl<A: Application> wayland_client::Dispatch<crate::protocol::cce_window_management_v1::zcce_seat_v1::ZcceSeatV1, ()> for EngineState<A> {
5358 fn event(
5359 _state: &mut Self,
5360 _proxy: &crate::protocol::cce_window_management_v1::zcce_seat_v1::ZcceSeatV1,
5361 _event: crate::protocol::cce_window_management_v1::zcce_seat_v1::Event,
5362 _data: &(),
5363 _conn: &Connection,
5364 _qh: &QueueHandle<Self>,
5365 ) {}
5366 }
5367
5368 impl<A: Application> wayland_client::Dispatch<crate::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1, ()> for EngineState<A> {
5369 fn event(
5370 state: &mut Self,
5371 _proxy: &crate::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1,
5372 event: crate::protocol::cce_window_management_v1::zcce_toplevel_v1::Event,
5373 _data: &(),
5374 _conn: &Connection,
5375 _qh: &QueueHandle<Self>,
5376 ) {
5377 use crate::protocol::cce_window_management_v1::zcce_toplevel_v1::Event;
5378 if let Event::GridPatch { serial, x, y, width, height, scale } = event {
5379 // A newer patch supersedes an unconsumed older one.
5380 state.pending_grid_patch = Some((serial, x, y, width, height, scale));
5381 state.redraw = true;
5382 }
5383 }
5384 }
5385
5386 delegate_compositor!(@<A: Application> EngineState<A>);
5387 delegate_xdg_shell!(@<A: Application> EngineState<A>);
5388 delegate_xdg_window!(@<A: Application> EngineState<A>);
5389 delegate_layer!(@<A: Application> EngineState<A>);
5390 delegate_shm!(@<A: Application> EngineState<A>);
5391 delegate_seat!(@<A: Application> EngineState<A>);
5392 delegate_pointer!(@<A: Application> EngineState<A>);
5393 delegate_keyboard!(@<A: Application> EngineState<A>);
5394 delegate_registry!(@<A: Application> EngineState<A>);
5395 delegate_output!(@<A: Application> EngineState<A>);
5396
5397 impl<A: Application> wayland_client::Dispatch<wl_region::WlRegion, ()> for EngineState<A> {
5398 fn event(
5399 _state: &mut Self,
5400 _proxy: &wl_region::WlRegion,
5401 _event: wl_region::Event,
5402 _data: &(),
5403 _conn: &Connection,
5404 _qh: &QueueHandle<Self>,
5405 ) {}
5406 }
5407
5408 impl<A: Application> wayland_client::Dispatch<wl_callback::WlCallback, ()> for EngineState<A> {
5409 fn event(
5410 state: &mut Self,
5411 _proxy: &wl_callback::WlCallback,
5412 event: wl_callback::Event,
5413 _data: &(),
5414 _conn: &Connection,
5415 _qh: &QueueHandle<Self>,
5416 ) {
5417 if let wl_callback::Event::Done { .. } = event {
5418 state.frame_callback_pending = false;
5419 if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
5420 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
5421 let waited = state.frame_callback_armed_at.map(|a| a.elapsed().as_millis()).unwrap_or(0);
5422 eprintln!("[vk] t={} frame-done (waited {}ms)", t, waited);
5423 }
5424 }
5425 }
5426 }
5427
5428 impl<A: Application> wayland_client::Dispatch<ZwpPointerGesturesV1, ()> for EngineState<A> {
5429 fn event(
5430 _state: &mut Self,
5431 _proxy: &ZwpPointerGesturesV1,
5432 _event: zwp_pointer_gestures::Event,
5433 _data: &(),
5434 _conn: &Connection,
5435 _qh: &QueueHandle<Self>,
5436 ) {}
5437 }
5438
5439 impl<A: Application> wayland_client::Dispatch<ZwpPointerGesturePinchV1, ()> for EngineState<A> {
5440 fn event(
5441 state: &mut Self,
5442 _proxy: &ZwpPointerGesturePinchV1,
5443 event: zwp_pointer_gesture_pinch_v1::Event,
5444 _data: &(),
5445 _conn: &Connection,
5446 _qh: &QueueHandle<Self>,
5447 ) {
5448 match event {
5449 zwp_pointer_gesture_pinch_v1::Event::Begin { .. } => {
5450 state.last_pinch_scale = 1.0;
5451 }
5452 zwp_pointer_gesture_pinch_v1::Event::Update { scale, .. } => {
5453 let scale_f32 = scale as f32;
5454 let factor = scale_f32 / state.last_pinch_scale;
5455 state.last_pinch_scale = scale_f32;
5456
5457 let (px, py) = state.cursor_pos;
5458 let mut rebuild = false;
5459
5460 // First offer the gesture as-is: apps with true pinch
5461 // surfaces (the designer's 3D viewport) consume it here at
5462 // 1:1 scale instead of through the wheel synthesis below.
5463 if state.inner.as_mut().unwrap().handle_pinch(factor, LogicalPosition::new(px, py), &mut rebuild) {
5464 if rebuild {
5465 state.redraw = true;
5466 }
5467 return;
5468 }
5469
5470 // Calculate the y_delta for PixelDelta mapping.
5471 // Since cce-graph interprets factor = 1.0 + y_delta * 0.015, we reverse it:
5472 let y_delta = (factor - 1.0) / 0.015;
5473 let delta = MouseScrollDelta::PixelDelta(Position {
5474 x: 0.0,
5475 y: y_delta as f64,
5476 });
5477
5478 if let Some(ctx) = state.inner.as_mut().unwrap().ui_context_mut() {
5479 ctx.ctrl_pressed = true; // Force ctrl_pressed = true for the pinch event
5480 }
5481 // A synthesized delta, not a scroll gesture: no glide, no fling.
5482 crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Wheel);
5483
5484 state.inner.as_mut().unwrap().handle_mouse_wheel(&delta, LogicalPosition::new(px, py), &mut rebuild);
5485
5486 if let Some(ctx) = state.inner.as_mut().unwrap().ui_context_mut() {
5487 ctx.ctrl_pressed = state.ctrl_pressed; // Restore original state
5488 }
5489
5490 if rebuild {
5491 state.redraw = true;
5492 }
5493 }
5494 zwp_pointer_gesture_pinch_v1::Event::End { .. } => {
5495 state.last_pinch_scale = 1.0;
5496 }
5497 _ => {}
5498 }
5499 }
5500 }
5501
5502 /// Why a session's event loop stopped.
5503 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5504 enum SessionEnd {
5505 /// The app asked to exit.
5506 AppExit,
5507 /// The compositor connection died while the compositor itself may well be
5508 /// alive — a broken transport. The `Application` is intact and can be
5509 /// re-attached to a fresh connection.
5510 ConnectionLost,
5511 /// Nothing answered at the display socket: the compositor this app
5512 /// belonged to is gone. A deliberate exit unlinks the socket and a crash
5513 /// leaves it refusing; either way there is no session left to rejoin.
5514 NoCompositor,
5515 }
5516
5517 /// What [`run`] does once a session has ended.
5518 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5519 enum AfterSession {
5520 /// Leave the process-lifetime loop: run `on_exit` and quit.
5521 Exit,
5522 /// Sleep this long, then open a fresh session on the same `Application`.
5523 Reconnect(std::time::Duration),
5524 }
5525
5526 /// How many consecutive failed reconnects before giving up. Reset once a
5527 /// session has survived [`RECONNECT_RESET`], so a long-lived window that loses
5528 /// its connection twice in a day still gets a full budget the second time.
5529 const RECONNECT_ATTEMPTS: u32 = 8;
5530 const RECONNECT_RESET: std::time::Duration = std::time::Duration::from_secs(10);
5531
5532 /// Decide whether a finished session is followed by another.
5533 ///
5534 /// `lived` is how long the session that just ended lasted, `has_app` whether
5535 /// an `Application` exists to carry over, and `attempt` the running count of
5536 /// consecutive reconnects (reset here once a session outlives
5537 /// [`RECONNECT_RESET`]).
5538 ///
5539 /// Only a lost connection is retried, and only while the compositor is still
5540 /// there to reconnect to. A reconnect is a repair of THIS session's transport
5541 /// — the fd-exhaustion break `raise_fd_limit` documents — not a way to outlive
5542 /// the compositor. When the connect itself fails the compositor has exited,
5543 /// and it has already saved this window for restore: the next compositor
5544 /// respawns the app from `state.json` on its own. A client that kept
5545 /// retrying instead (the backoff below spans ~25s) reattached to that
5546 /// successor beside the respawned copy, and every restore after a forced
5547 /// exit or a crash came up with two of each cce-ui window. So the process
5548 /// exits, as a Wayland client whose display went away always has.
5549 fn after_session(
5550 end: SessionEnd,
5551 has_app: bool,
5552 lived: std::time::Duration,
5553 attempt: &mut u32,
5554 ) -> AfterSession {
5555 match end {
5556 SessionEnd::AppExit | SessionEnd::NoCompositor => AfterSession::Exit,
5557 SessionEnd::ConnectionLost => {
5558 // Nothing to preserve if we never got as far as building the
5559 // app — that is a failure to start, not a lost window.
5560 if !has_app {
5561 return AfterSession::Exit;
5562 }
5563 if lived > RECONNECT_RESET {
5564 *attempt = 0;
5565 }
5566 *attempt += 1;
5567 if *attempt > RECONNECT_ATTEMPTS {
5568 return AfterSession::Exit;
5569 }
5570 AfterSession::Reconnect(std::time::Duration::from_millis(
5571 100 * (1 << (*attempt).min(6)),
5572 ))
5573 }
5574 }
5575 }
5576
5577 /// Raise this process's file-descriptor soft limit toward its hard limit.
5578 ///
5579 /// A cce-ui client's fd usage is not bounded by anything the app controls.
5580 /// Every dmabuf-feedback event the compositor sends carries a format-table
5581 /// fd, and those arrive per surface whenever scanout candidacy changes —
5582 /// entering the overview re-sends one for every window at once. Long-lived
5583 /// windows sit at 700+ open fds in normal use, against a soft limit of 1024.
5584 ///
5585 /// Crossing that limit does not fail politely. `recvmsg` drops the SCM_RIGHTS
5586 /// payload when it cannot allocate descriptors, while still delivering the
5587 /// message body — so libwayland hits a message whose fd never arrived,
5588 /// reports "file descriptor expected", and the connection dies. That is
5589 /// precisely the transport break [`run`] reconnects from below, at the cost
5590 /// of a rebuilt window.
5591 ///
5592 /// The compositor raises itself to 65536 for the same reason and then
5593 /// deliberately restores the inherited limit for the programs it spawns
5594 /// (cce-compositor `process.rs::cleanup_child`) — right for an arbitrary
5595 /// child, far too low for a dmabuf-heavy Wayland client. So each client
5596 /// raises its own, to the same ceiling.
5597 fn raise_fd_limit() {
5598 unsafe {
5599 let mut lim: libc::rlimit = std::mem::zeroed();
5600 if libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) != 0 {
5601 return;
5602 }
5603 let want = std::cmp::min(65536, lim.rlim_max);
5604 if lim.rlim_cur >= want {
5605 return;
5606 }
5607 let raised = libc::rlimit { rlim_cur: want, rlim_max: lim.rlim_max };
5608 if libc::setrlimit(libc::RLIMIT_NOFILE, &raised) == 0 {
5609 log::info!("[window_runner] fd limit raised {} -> {}", lim.rlim_cur, want);
5610 } else {
5611 log::warn!("[window_runner] could not raise fd limit from {}", lim.rlim_cur);
5612 }
5613 }
5614 }
5615
5616 /// Run an [`Application`] to completion, surviving loss of the compositor
5617 /// connection.
5618 ///
5619 /// A Wayland connection cannot be repaired once its transport state breaks — a
5620 /// single dropped file descriptor on a dmabuf-feedback event is enough, and
5621 /// libwayland then fails every dispatch with `EINVAL`. Exiting the process on
5622 /// that error (the old behavior) threw away everything the window held: a
5623 /// terminal's shell and scrollback, an editor's unsaved buffer.
5624 ///
5625 /// So a connection is one *session*. Objects that belong to the connection —
5626 /// the Wayland globals, the surface, the swapchain, the renderer — are rebuilt
5627 /// per session. The things that carry user state outlive it: the `Application`
5628 /// itself, the calloop loop, and the message channel. Keeping the **same
5629 /// channel** matters as much as keeping the app: worker threads hold clones of
5630 /// its `Sender` (cce-terminal's pty reader is the canonical case), and a fresh
5631 /// channel would orphan them into a live-but-deaf process.
5632 ///
5633 /// What is repaired is the transport, never the compositor: a reconnect only
5634 /// goes through while the compositor that owned the lost session is still
5635 /// listening. If the connect itself fails the compositor has exited, and the
5636 /// process exits with it — see [`after_session`] for why staying alive there
5637 /// duplicated every window on the next session restore.
5638 ///
5639 /// Caveat: GPU resources belong to the renderer, so a rebuild re-runs
5640 /// [`Application::renderer_init`]. Images uploaded outside it (e.g. in
5641 /// [`Application::new`]) are not replayed into the new renderer — upload from
5642 /// `renderer_init` if they must survive a reconnect.
5643 pub fn run<A: Application>() {
5644 raise_fd_limit();
5645
5646 // Outlives every session: worker threads hold this Sender, and the app's
5647 // own event sources are registered on this loop once.
5648 let (sender, channel) = calloop::channel::channel::<A::Message>();
5649 // Drop payloads come back from the per-drop reader threads (see
5650 // `backend::dnd`); registered once, like the app channel, because the
5651 // loop outlives a reconnect while the EngineState does not.
5652 let (drop_tx, drop_rx) =
5653 calloop::channel::channel::<crate::backend::dnd::DroppedData>();
5654 let mut event_loop = match EventLoop::try_new() {
5655 Ok(l) => l,
5656 Err(e) => {
5657 log::error!("[window_runner] cannot create event loop: {e}");
5658 return;
5659 }
5660 };
5661 event_loop
5662 .handle()
5663 .insert_source(channel, |event, _metadata, app_state: &mut EngineState<A>| {
5664 if let calloop::channel::Event::Msg(msg) = event {
5665 let mut rebuild = false;
5666 app_state.inner.as_mut().unwrap().update(msg, &mut rebuild, &mut app_state.exit);
5667 if rebuild {
5668 app_state.redraw = true;
5669 }
5670 }
5671 })
5672 .unwrap();
5673 event_loop
5674 .handle()
5675 .insert_source(drop_rx, |event, _metadata, app_state: &mut EngineState<A>| {
5676 if let calloop::channel::Event::Msg(drop) = event {
5677 // The transfer is complete, so the source can be released now
5678 // — doing it any earlier costs the payload.
5679 if let Some(offer) = app_state.pending_drop_offer.take() {
5680 offer.finish();
5681 offer.destroy();
5682 }
5683 let mut rebuild = false;
5684 if let Some(app) = app_state.inner.as_mut() {
5685 app.handle_drop(&drop.mime, &drop.bytes, drop.pos, &mut rebuild);
5686 }
5687 if rebuild {
5688 app_state.redraw = true;
5689 }
5690 }
5691 })
5692 .unwrap();
5693
5694 let mut app: Option<A> = None;
5695 let mut sources_registered = false;
5696 let mut attempt: u32 = 0;
5697
5698 loop {
5699 let started = std::time::Instant::now();
5700 let (returned_app, end) =
5701 run_session(&mut event_loop, sender.clone(), drop_tx.clone(), app.take(), !sources_registered);
5702 app = returned_app;
5703 sources_registered = true;
5704
5705 match after_session(end, app.is_some(), started.elapsed(), &mut attempt) {
5706 AfterSession::Exit => {
5707 match end {
5708 SessionEnd::AppExit => {}
5709 SessionEnd::NoCompositor if app.is_some() => log::warn!(
5710 "[window_runner] compositor is gone; exiting (its successor restores the session itself)"
5711 ),
5712 SessionEnd::NoCompositor => {
5713 log::error!("[window_runner] no compositor connection; giving up")
5714 }
5715 SessionEnd::ConnectionLost if app.is_some() => log::error!(
5716 "[window_runner] connection lost; giving up after {} attempts",
5717 attempt - 1
5718 ),
5719 SessionEnd::ConnectionLost => {
5720 log::error!("[window_runner] no compositor connection; giving up")
5721 }
5722 }
5723 break;
5724 }
5725 AfterSession::Reconnect(backoff) => {
5726 log::warn!(
5727 "[window_runner] compositor connection lost; reconnecting in {backoff:?} (attempt {attempt})"
5728 );
5729 std::thread::sleep(backoff);
5730 }
5731 }
5732 }
5733
5734 if let Some(mut app) = app {
5735 app.on_exit();
5736 }
5737 crate::process::cleanup_spawned_processes();
5738 }
5739
5740 /// One connection's lifetime: connect, build the surface and renderer, pump
5741 /// events until the app exits or the connection dies. Returns the
5742 /// `Application` so the caller can hand it to the next session.
5743 fn run_session<'l, A: Application>(
5744 event_loop: &mut EventLoop<'l, EngineState<A>>,
5745 sender: calloop::channel::Sender<A::Message>,
5746 drop_tx: calloop::channel::Sender<crate::backend::dnd::DroppedData>,
5747 existing_app: Option<A>,
5748 register_app_sources: bool,
5749 ) -> (Option<A>, SessionEnd) {
5750 let conn = match Connection::connect_to_env() {
5751 Ok(c) => c,
5752 Err(e) => {
5753 log::error!("[window_runner] cannot connect to compositor: {e}");
5754 return (existing_app, SessionEnd::NoCompositor);
5755 }
5756 };
5757 let (globals, mut event_queue) = match registry_queue_init(&conn) {
5758 Ok(v) => v,
5759 Err(e) => {
5760 log::error!("[window_runner] registry init failed: {e}");
5761 return (existing_app, SessionEnd::ConnectionLost);
5762 }
5763 };
5764 let qh = event_queue.handle();
5765
5766 let compositor_state = CompositorState::bind(&globals, &qh).unwrap();
5767 let xdg_shell_state = XdgShell::bind(&globals, &qh).unwrap();
5768 let layer_shell_state = LayerShell::bind(&globals, &qh).ok();
5769 let shm_state = Shm::bind(&globals, &qh).unwrap();
5770 let seat_state = SeatState::new(&globals, &qh);
5771 let output_state = OutputState::new(&globals, &qh);
5772
5773 let pointer_gestures: Option<ZwpPointerGesturesV1> = globals.bind(&qh, 1..=3, ()).ok();
5774
5775 let mut engine_state = EngineState {
5776 data_device_manager: DataDeviceManagerState::bind(&globals, &qh).ok(),
5777 data_devices: Vec::new(),
5778 drag_mime: None,
5779 drag_pos: LogicalPosition::new(0.0, 0.0),
5780 drop_tx: Some(drop_tx),
5781 pending_drop_offer: None,
5782 applied_input_regions: None,
5783 registry_state: RegistryState::new(&globals),
5784 compositor_state,
5785 xdg_shell_state,
5786 layer_shell_state,
5787 shm_state,
5788 seat_state,
5789 output_state,
5790 seats: Vec::new(),
5791 pointer: None,
5792 keyboard: None,
5793 window: None,
5794 layer_surface: None,
5795 surface: None,
5796 inner: None,
5797 renderer: None,
5798 font_system: None,
5799 swash_cache: cosmic_text::SwashCache::new(),
5800 scale_factor: 1.0,
5801 committed_buffer_scale: 1,
5802 entered_outputs: Vec::new(),
5803 logical_width: 0.0,
5804 logical_height: 0.0,
5805 frame_logical: (0.0, 0.0),
5806 applied_margin: 0.0,
5807 overflow_was_active: false,
5808 sent_popover_region: None,
5809 exit: false,
5810 redraw: false,
5811 frame_callback_pending: false,
5812 frame_callback_armed_at: None,
5813 warm_until: None,
5814 extent_gate_skips: 0,
5815 first_configure_received: false,
5816 ctrl_pressed: false,
5817 undo_chord: crate::input::app_chord("undo", "ctrl+z"),
5818 redo_chord: crate::input::app_chord("redo", "ctrl+shift+z"),
5819 group_next_chord: crate::input::app_chord("focus_next_group", "ctrl+tab"),
5820 group_prev_chord: crate::input::app_chord("focus_prev_group", "ctrl+shift+tab"),
5821 shift_pressed: false,
5822 alt_pressed: false,
5823 logo_pressed: false,
5824 pressed_key: None,
5825 sender,
5826 current_cursor_icon: None,
5827 qh: qh.clone(),
5828 just_configured: false,
5829 pointer_gestures,
5830 pinch_gesture: None,
5831 cce_toplevel: None,
5832 pending_grid_patch: None,
5833 last_pinch_scale: 1.0,
5834 cursor_pos: (0.0, 0.0),
5835 last_press_serial: None,
5836 buttons_down: 0,
5837 dl_text_items: Vec::new(),
5838 };
5839
5840 if let Err(e) = event_queue.roundtrip(&mut engine_state) {
5841 log::error!("[window_runner] initial roundtrip failed: {e}");
5842 return (existing_app, SessionEnd::ConnectionLost);
5843 }
5844
5845 let scale = detect_scale_factor(&engine_state.output_state);
5846 engine_state.scale_factor = scale;
5847 crate::scale::set_scale_factor(scale as f32);
5848 crate::units::set_metric(crate::wayland::detect_metric(&engine_state.output_state, scale));
5849
5850 // A reconnect re-attaches the SAME app: its state is the thing worth
5851 // saving, and `A::new` would both discard it and hand a fresh Sender to
5852 // worker threads that are still holding the original.
5853 let inner = match existing_app {
5854 Some(app) => app,
5855 None => A::new(&qh, engine_state.sender.clone()),
5856 };
5857 let settings = inner.settings();
5858 crate::scale::set_app_id(settings.app_id.clone());
5859 engine_state.logical_width = settings.width as f32;
5860 engine_state.logical_height = settings.height as f32;
5861 engine_state.inner = Some(inner);
5862
5863 let surface = engine_state.compositor_state.create_surface(&qh);
5864 // A grid app's surface is pinned to scale 1: the patch's `scale` is
5865 // BUFFER px per virtual unit and already carries the output scale (the
5866 // patch manager folds it in), so adopting the output scale here would
5867 // square it — the client renders a doubled buffer and the compositor
5868 // downsamples it straight back into blur.
5869 if engine_state.inner.as_ref().unwrap().grid() {
5870 engine_state.scale_factor = 1.0;
5871 }
5872 // Forced-scale mode renders scaled-up into a buffer_scale-1 surface.
5873 let buffer_scale = if crate::scale::forced_scale().is_some()
5874 || engine_state.inner.as_ref().unwrap().grid()
5875 {
5876 1
5877 } else {
5878 scale as i32
5879 };
5880 surface.set_buffer_scale(buffer_scale);
5881 engine_state.committed_buffer_scale = buffer_scale;
5882
5883 if settings.app_id.starts_with("cce-status") {
5884 let compositor = engine_state.compositor_state.wl_compositor();
5885 let region = compositor.create_region(&qh, ());
5886 region.add(0, 0, settings.width as i32, settings.height as i32);
5887 surface.set_input_region(Some(®ion));
5888 region.destroy();
5889 }
5890
5891 let layer_settings = engine_state.inner.as_ref().unwrap().layer();
5892 if let Some(ls) = layer_settings {
5893 let layer_shell = engine_state
5894 .layer_shell_state
5895 .as_ref()
5896 .expect("compositor does not support wlr-layer-shell");
5897 let layer_surface = layer_shell.create_layer_surface(
5898 &qh,
5899 surface.clone(),
5900 ls.layer,
5901 Some(ls.namespace.clone()),
5902 None,
5903 );
5904 layer_surface.set_anchor(ls.anchor);
5905 layer_surface.set_exclusive_zone(ls.exclusive_zone);
5906 layer_surface.set_keyboard_interactivity(ls.keyboard_interactivity);
5907 let (t, r, b, l) = ls.margin;
5908 layer_surface.set_margin(t, r, b, l);
5909 layer_surface.set_size(settings.width, settings.height);
5910 layer_surface.commit();
5911 engine_state.layer_surface = Some(layer_surface);
5912 } else {
5913 let window = engine_state.xdg_shell_state.create_window(surface.clone(), WindowDecorations::None, &qh);
5914 window.set_title(&settings.title);
5915 window.set_app_id(&settings.app_id);
5916 if settings.fullscreen {
5917 window.set_fullscreen(None);
5918 }
5919 if let Some((min_w, min_h)) = settings.min_size {
5920 window.set_min_size(Some((min_w, min_h)));
5921 }
5922 let wants_utility = engine_state.inner.as_ref().unwrap().utility();
5923 let wants_grid = engine_state.inner.as_ref().unwrap().grid();
5924 {
5925 // Bound for EVERY app now, not just utility/grid ones: the
5926 // toplevel also carries the popover-region hint (manager v7),
5927 // which any app with a dropdown wants. Role declarations go
5928 // BEFORE the initial commit so the mode is set by the time the
5929 // compositor maps the window. Version floors: set_utility
5930 // appeared at manager 5, the grid role at 6; the range tops at 7
5931 // so a newer compositor grants the hint and an older one simply
5932 // yields a lower-versioned toplevel — the hint send is gated on
5933 // version() >= 7 (send_popover_region), and on a pre-5
5934 // compositor the bind fails and the app runs plain.
5935 let version = if wants_grid { 6..=7 } else { 5..=7 };
5936 match globals.bind::<crate::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1, _, _>(&qh, version, ()) {
5937 Ok(cce_wm) => {
5938 let toplevel = cce_wm.get_cce_toplevel(&surface, &qh, ());
5939 if wants_utility {
5940 toplevel.set_utility();
5941 }
5942 if wants_grid {
5943 toplevel.set_grid();
5944 }
5945 engine_state.cce_toplevel = Some(toplevel);
5946 }
5947 Err(e) => {
5948 log::warn!("[window_runner] cce window-management declaration unavailable: {e}");
5949 }
5950 }
5951 }
5952 window.commit();
5953 engine_state.window = Some(window);
5954 }
5955 engine_state.surface = Some(surface);
5956
5957 // Overflow-margin mode: the surface (and so the GPU swapchain) is a rim
5958 // larger than the window frame on every side; geometry/input-region are
5959 // published per-resize.
5960 let rim = 2.0 * engine_state.inner.as_ref().unwrap().overflow_margin() as f32;
5961 engine_state.init_gpu(&conn, settings.width as f32 + rim, settings.height as f32 + rim);
5962 engine_state
5963 .inner
5964 .as_mut()
5965 .unwrap()
5966 .renderer_init(engine_state.renderer.as_mut().unwrap());
5967
5968 let loop_handle = event_loop.handle();
5969 let wayland_token = match WaylandSource::new(conn.clone(), event_queue).insert(loop_handle.clone())
5970 {
5971 Ok(token) => token,
5972 Err(e) => {
5973 log::error!("[window_runner] cannot register the wayland source: {e}");
5974 return (engine_state.inner.take(), SessionEnd::ConnectionLost);
5975 }
5976 };
5977
5978 // The app's own sources live on the persistent loop, so they are registered
5979 // once for the process — re-registering per session would double-deliver
5980 // every event on them.
5981 if register_app_sources {
5982 engine_state.inner.as_mut().unwrap().register_sources(&loop_handle);
5983 }
5984
5985 const KEY_REPEAT_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
5986 const KEY_REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
5987
5988 /// Same switch as the renderer's present tracer, resolved once — this sits
5989 /// in the per-iteration path, so a `std::env::var` call here would be I/O
5990 /// on the loop that is under measurement.
5991 fn loop_debug() -> bool {
5992 static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
5993 *FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
5994 }
5995
5996 /// Loop cadence while something is in motion: one tick per frame.
5997 const ACTIVE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(16);
5998
5999 /// Upper bound on an idle sleep. The loop is woken early by any Wayland
6000 /// event or calloop-channel message, so this only caps how long an
6001 /// app-side poll that bypasses both (see `Application::idle_poll_interval`)
6002 /// can wait. `CCE_UI_IDLE_MS` overrides it — `16` restores the old
6003 /// always-ticking loop for a bisect.
6004 fn idle_dispatch() -> std::time::Duration {
6005 static IDLE: std::sync::OnceLock<std::time::Duration> = std::sync::OnceLock::new();
6006 *IDLE.get_or_init(|| {
6007 std::env::var("CCE_UI_IDLE_MS")
6008 .ok()
6009 .and_then(|v| v.parse::<u64>().ok())
6010 .map(std::time::Duration::from_millis)
6011 .unwrap_or(IDLE_DISPATCH)
6012 })
6013 }
6014
6015 /// Seconds after session start at which to inject a simulated connection
6016 /// loss, from `CCE_UI_FAULT_RECONNECT`. Resolved once: this is read from
6017 /// the per-iteration path.
6018 fn fault_reconnect_after() -> Option<std::time::Duration> {
6019 static AFTER: std::sync::OnceLock<Option<std::time::Duration>> =
6020 std::sync::OnceLock::new();
6021 *AFTER.get_or_init(|| {
6022 std::env::var("CCE_UI_FAULT_RECONNECT")
6023 .ok()
6024 .and_then(|v| v.parse::<f32>().ok())
6025 .map(std::time::Duration::from_secs_f32)
6026 })
6027 }
6028
6029 let mut last_title = settings.title.clone();
6030 let mut last_tick = std::time::Instant::now();
6031 let mut end = SessionEnd::AppExit;
6032 let session_start = std::time::Instant::now();
6033 // The loop's cadence. ACTIVE while anything is in motion (a redraw
6034 // pending or just done, an animation, a held key, the post-activity
6035 // warm-down); otherwise the app's own poll interval or IDLE_DISPATCH.
6036 // Before 2026-09-11 this was a flat 16 ms whatever the state: every
6037 // cce-ui client woke 60 times a second forever — ~1200 wakeups/s across
6038 // a session's twenty clients — and each wake ran tick, desired_size,
6039 // title and margin checks for nothing.
6040 let mut next_timeout = ACTIVE_DISPATCH;
6041 let mut slept_idle = false;
6042 loop {
6043 // Frame callbacks arrive with a p50 of 0ms but a ~0.5s tail, while the
6044 // compositor's own trace shows it firing them within one or two vsyncs
6045 // of the arm. Tracing each iteration bisects that: if this loop keeps
6046 // turning at ~16ms all through a long wait, the event was not there to
6047 // read, and the delay is upstream rather than in dispatching it.
6048 let iter_start = if loop_debug() {
6049 Some(std::time::Instant::now())
6050 } else {
6051 None
6052 };
6053 if let Err(e) = event_loop.dispatch(next_timeout, &mut engine_state) {
6054 log::error!("[window_runner] event loop error, ending session: {e:?}");
6055 end = SessionEnd::ConnectionLost;
6056 break;
6057 }
6058 if let Some(start) = iter_start {
6059 let t = std::time::SystemTime::now()
6060 .duration_since(std::time::UNIX_EPOCH)
6061 .unwrap()
6062 .as_millis()
6063 % 100000;
6064 eprintln!(
6065 "[vk] t={} loop dispatch={}us pending_cb={}",
6066 t,
6067 start.elapsed().as_micros(),
6068 engine_state.frame_callback_pending
6069 );
6070 }
6071 // A protocol error kills the connection permanently, but it surfaces
6072 // through queue flushes whose errors calloop's WaylandSource swallows
6073 // (it only treats Io errors as fatal) — without this check the loop
6074 // spins forever on a dead display while wayland-backend re-prints the
6075 // error on every flush attempt.
6076 if let Some(perr) = conn.protocol_error() {
6077 log::error!("[window_runner] wayland protocol error, ending session: {perr}");
6078 end = SessionEnd::ConnectionLost;
6079 break;
6080 }
6081 // Fault injection for the reconnect path (`CCE_UI_FAULT_RECONNECT=<secs>`):
6082 // real connection loss is a rare race that cannot be provoked on demand,
6083 // so this drops the session exactly as a transport error would. One-shot
6084 // per process, so the app reconnects and then stays up.
6085 if let Some(after) = fault_reconnect_after() {
6086 static FIRED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
6087 if session_start.elapsed() >= after
6088 && !FIRED.swap(true, std::sync::atomic::Ordering::Relaxed)
6089 {
6090 log::warn!("[window_runner] CCE_UI_FAULT_RECONNECT: dropping the session");
6091 end = SessionEnd::ConnectionLost;
6092 break;
6093 }
6094 }
6095 if engine_state.exit {
6096 // The close dissolve. It is the COMPOSITOR that fades us — it
6097 // ramps our scene subtree's opacity, which takes the backdrop
6098 // blur, drop shadow and bevel down with the window; all this side
6099 // has to do is not vanish before it finishes. So keep the surface
6100 // mapped and the loop turning for exactly as long as the
6101 // compositor asked for, then leave. Dispatching (rather than
6102 // sleeping) keeps the connection pumped and lets any last
6103 // animation finish on screen while the window dissolves.
6104 let fade = crate::ipc::request_close_fade();
6105 if !fade.is_zero() {
6106 let until = std::time::Instant::now() + fade;
6107 loop {
6108 let left = until.saturating_duration_since(std::time::Instant::now());
6109 if left.is_zero() {
6110 break;
6111 }
6112 if event_loop.dispatch(left.min(ACTIVE_DISPATCH), &mut engine_state).is_err() {
6113 break;
6114 }
6115 }
6116 }
6117 break;
6118 }
6119
6120 let now = std::time::Instant::now();
6121 let mut dt = now.duration_since(last_tick).as_secs_f32();
6122 last_tick = now;
6123 if dt > 0.1 {
6124 dt = 0.1;
6125 }
6126 // Waking from an idle sleep: the interval is not animation time. An
6127 // animation an event just started must take its first step at frame
6128 // size, not leap 100 ms in one tick.
6129 if slept_idle {
6130 dt = dt.min(1.0 / 60.0);
6131 }
6132
6133 let mut rebuild = false;
6134 let roster_ticks_before =
6135 engine_state.inner.as_mut().unwrap().ui_context_mut().map(|ctx| ctx.tick_count());
6136 engine_state.inner.as_mut().unwrap().tick(dt, &mut rebuild);
6137 if rebuild {
6138 engine_state.redraw = true;
6139 }
6140 // Tick the app's retained UiContext (widget tick_receivers — e.g. an
6141 // animating Dropdown popover) for apps that expose it — but only when
6142 // the app's own tick did not already do so this frame. Receivers
6143 // integrate `dt` (scroll glides, slider inertia), so the old
6144 // "double-ticking is harmless" assumption ran every glide at twice
6145 // its configured rate in apps that tick the context themselves.
6146 if let Some(ctx) = engine_state.inner.as_mut().unwrap().ui_context_mut() {
6147 if Some(ctx.tick_count()) == roster_ticks_before {
6148 if ctx.tick(dt) {
6149 engine_state.redraw = true;
6150 }
6151 }
6152 }
6153
6154 let just_configured = engine_state.just_configured;
6155 engine_state.just_configured = false;
6156
6157 if !just_configured {
6158 if let Some((w, h)) = engine_state.inner.as_ref().unwrap().desired_size() {
6159 // desired_size is a window-frame size; the surface adds the
6160 // right/bottom overflow rim (0 for margin-less apps).
6161 let m = engine_state.inner.as_ref().unwrap().overflow_margin() as f32;
6162 let (sw, sh) = (w as f32 + m, h as f32 + m);
6163 if (engine_state.logical_width - sw).abs() > 0.001 || (engine_state.logical_height - sh).abs() > 0.001 {
6164 engine_state.frame_logical = (w as f32, h as f32);
6165 engine_state.applied_margin = m;
6166 engine_state.resize(sw, sh);
6167 engine_state.redraw = true;
6168 }
6169 }
6170 }
6171
6172 // Overflow-margin drift (configure-sized apps): the rim can change at
6173 // runtime — a popover overhanging the window frame — so re-derive the
6174 // surface from the stored frame whenever the app's answer moves. While
6175 // the rim is live, re-publish geometry every loop: the input region
6176 // tracks the animating popover rects.
6177 {
6178 let m_now = engine_state.inner.as_ref().unwrap().overflow_margin() as f32;
6179 if (m_now - engine_state.applied_margin).abs() > 0.001 && engine_state.frame_logical.0 > 0.0 {
6180 engine_state.applied_margin = m_now;
6181 let (fw, fh) = engine_state.frame_logical;
6182 engine_state.resize(fw + m_now, fh + m_now);
6183 engine_state.redraw = true;
6184 }
6185 if engine_state.applied_margin > 0.0 || engine_state.overflow_was_active {
6186 engine_state.publish_window_geometry();
6187 engine_state.overflow_was_active = engine_state.applied_margin > 0.0;
6188 }
6189 engine_state.send_popover_region();
6190 }
6191
6192 if let Some(ref mut pk) = engine_state.pressed_key {
6193 let now = std::time::Instant::now();
6194 if now.duration_since(pk.first_pressed) >= KEY_REPEAT_DELAY {
6195 if now.duration_since(pk.last_repeated) >= KEY_REPEAT_INTERVAL {
6196 pk.last_repeated = now;
6197 let custom_event = KeyEvent {
6198 state: ElementState::Pressed,
6199 logical_key: pk.logical_key.clone(),
6200 text: pk.text.clone(),
6201 repeat: true,
6202 ctrl: engine_state.ctrl_pressed,
6203 shift: engine_state.shift_pressed,
6204 alt: engine_state.alt_pressed,
6205 };
6206
6207 if let Some(ctx) = engine_state.inner.as_mut().unwrap().ui_context_mut() {
6208 ctx.ctrl_pressed = engine_state.ctrl_pressed;
6209 ctx.shift_pressed = engine_state.shift_pressed;
6210 ctx.alt_pressed = engine_state.alt_pressed;
6211 ctx.logo_pressed = engine_state.logo_pressed;
6212 }
6213
6214 let mut key_rebuild = false;
6215 if engine_state.route_history_chord(&custom_event, &mut key_rebuild)
6216 || engine_state.route_plate_navigation(&custom_event, &mut key_rebuild)
6217 {
6218 engine_state.redraw = true;
6219 } else if let Some(msg) = engine_state.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut key_rebuild) {
6220 let mut update_rebuild = false;
6221 engine_state.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut engine_state.exit);
6222 if update_rebuild {
6223 key_rebuild = true;
6224 }
6225 }
6226 if key_rebuild {
6227 engine_state.redraw = true;
6228 }
6229 }
6230 }
6231 }
6232 let current_title = engine_state.inner.as_ref().unwrap().settings().title;
6233 if current_title != last_title {
6234 if let Some(ref window) = engine_state.window {
6235 window.set_title(¤t_title);
6236 window.commit();
6237 }
6238 last_title = current_title;
6239 }
6240
6241 // Frame-callback starvation fallback: the compositor only sends
6242 // frame-done for surfaces it actually renders, so a callback armed
6243 // while the window sat off-viewport (or the scene went static) may
6244 // never fire — and the vsync gate below then freezes the app forever
6245 // with a perfectly live event loop (input processes, state changes,
6246 // nothing repaints). If a redraw has been waiting on a callback well
6247 // past any real vsync interval, stop waiting and draw.
6248 //
6249 // Gated on the renderer's present mode: forcing a present past a
6250 // dead callback is only safe under MAILBOX (the present replaces the
6251 // queued buffer). Under FIFO the driver's throttle waits on the
6252 // previous present's frame event, so the forced present itself
6253 // blocks forever inside the driver — the exact freeze this fallback
6254 // exists to prevent. There the gate stays closed: pixels may stale
6255 // until the next frame-done/configure, but the loop stays alive.
6256 if engine_state.redraw
6257 && engine_state.frame_callback_pending
6258 && engine_state
6259 .renderer
6260 .as_ref()
6261 .is_some_and(|r| r.forced_present_safe())
6262 && engine_state
6263 .frame_callback_armed_at
6264 .is_none_or(|t| t.elapsed().as_millis() > 250)
6265 {
6266 engine_state.frame_callback_pending = false;
6267 if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
6268 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
6269 eprintln!("[vk] t={} starvation fallback fired (callback never came)", t);
6270 }
6271 }
6272
6273 if engine_state.redraw {
6274 // Genuine dirt (input, app state, animation) extends the warm window;
6275 // warm-down renders below do NOT, so idle decays in one window.
6276 engine_state.warm_until =
6277 Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
6278 }
6279 let mut rendered = false;
6280 if engine_state.redraw && !engine_state.frame_callback_pending {
6281 engine_state.redraw = false;
6282 if engine_state.first_configure_received {
6283 engine_state.render();
6284 rendered = true;
6285 }
6286 } else if !engine_state.redraw
6287 && !engine_state.frame_callback_pending
6288 && engine_state
6289 .warm_until
6290 .is_some_and(|t| std::time::Instant::now() < t)
6291 {
6292 // Warm-down re-render of the cached frame, paced by frame callbacks.
6293 if engine_state.first_configure_received {
6294 engine_state.render();
6295 rendered = true;
6296 }
6297 }
6298
6299 // Anything still moving keeps the frame cadence; a frame callback
6300 // outstanding on its own does not (it arrives as an event) unless a
6301 // redraw is queued behind it, which is what the starvation fallback
6302 // above times. `redraw` still set here means the frame was withheld
6303 // (callback pending, or no configure yet) and must be retried soon.
6304 let warm = engine_state
6305 .warm_until
6306 .is_some_and(|t| std::time::Instant::now() < t);
6307 let busy = engine_state.redraw || rendered || warm || engine_state.pressed_key.is_some();
6308 next_timeout = if busy {
6309 ACTIVE_DISPATCH
6310 } else {
6311 let app_poll = engine_state.inner.as_ref().unwrap().idle_poll_interval();
6312 app_poll.map_or(idle_dispatch(), |d| d.min(idle_dispatch()))
6313 };
6314 slept_idle = !busy;
6315 }
6316
6317 // Tear the session down: drop its Wayland source from the persistent loop
6318 // (leaving it would leak a dead source per reconnect), then hand the app
6319 // back before `engine_state` drops the renderer and the surface with it.
6320 // `on_exit` and process cleanup belong to the app's real exit, in `run`.
6321 loop_handle.remove(wayland_token);
6322 let app = engine_state.inner.take();
6323 drop(engine_state);
6324 (app, end)
6325 }
6326
6327 // `all(test, debug_assertions)`: the function under test only exists in
6328 // debug builds, so a `cargo test --release` must compile the module out too.
6329 #[cfg(all(test, debug_assertions))]
6330 mod near_roll_fallback_tests {
6331 use super::near_roll_fallback_reason;
6332 use crate::scene::layout::Rect;
6333
6334 fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
6335 Rect { x, y, width: w, height: h }
6336 }
6337
6338 const HOST: Rect = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
6339 const ROLL: f32 = 8.0;
6340
6341 #[test]
6342 fn interior_carve_is_quiet() {
6343 // Well inside the deflated host: the overlay fallback is exact there.
6344 let carve = r(100.0, 100.0, 200.0, 100.0);
6345 assert_eq!(near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false), None);
6346 }
6347
6348 #[test]
6349 fn shaded_region_reaching_the_roll_is_loud() {
6350 // Carve rect stops 3px short of the roll band, but its shaded region
6351 // (depth*0.5 + 2 = 5px) crosses in — the inflation must count.
6352 let carve = r(ROLL + 3.0, 100.0, 200.0, 100.0);
6353 assert_eq!(
6354 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[], false),
6355 Some("the host's feature run is closed (another plate appended features since)")
6356 );
6357 }
6358
6359 #[test]
6360 fn occlusion_is_named_before_run_contiguity() {
6361 let carve = r(2.0, 100.0, 200.0, 100.0);
6362 let occluder = r(150.0, 150.0, 100.0, 100.0);
6363 assert_eq!(
6364 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], false),
6365 Some("a later plate overlaps the carve's shaded region")
6366 );
6367 }
6368
6369 #[test]
6370 fn non_overlapping_later_plate_is_not_occlusion() {
6371 let carve = r(2.0, 100.0, 200.0, 100.0);
6372 let elsewhere = r(500.0, 400.0, 100.0, 100.0);
6373 assert_eq!(
6374 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[elsewhere], false),
6375 Some("the host's feature run is closed (another plate appended features since)")
6376 );
6377 }
6378
6379 #[test]
6380 fn budget_wins_over_every_other_reason() {
6381 let carve = r(2.0, 100.0, 200.0, 100.0);
6382 let occluder = r(150.0, 150.0, 100.0, 100.0);
6383 assert_eq!(
6384 near_roll_fallback_reason(&carve, 6.0, &HOST, ROLL, &[occluder], true),
6385 Some("the feature budget is full")
6386 );
6387 }
6388 }
6389
6390 #[cfg(test)]
6391 mod reconnect_tests {
6392 use super::{after_session, AfterSession, SessionEnd, RECONNECT_ATTEMPTS, RECONNECT_RESET};
6393 use std::time::Duration;
6394
6395 const LONG: Duration = Duration::from_secs(60);
6396 const SHORT: Duration = Duration::from_millis(50);
6397
6398 #[test]
6399 fn app_exit_ends_the_process() {
6400 let mut attempt = 0;
6401 assert_eq!(after_session(SessionEnd::AppExit, true, LONG, &mut attempt), AfterSession::Exit);
6402 assert_eq!(attempt, 0);
6403 }
6404
6405 #[test]
6406 fn lost_transport_reconnects_with_backoff() {
6407 let mut attempt = 0;
6408 assert_eq!(
6409 after_session(SessionEnd::ConnectionLost, true, LONG, &mut attempt),
6410 AfterSession::Reconnect(Duration::from_millis(200))
6411 );
6412 assert_eq!(attempt, 1);
6413 assert_eq!(
6414 after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
6415 AfterSession::Reconnect(Duration::from_millis(400))
6416 );
6417 assert_eq!(attempt, 2);
6418 }
6419
6420 /// The compositor exited (its socket is unlinked, or refusing after a
6421 /// crash). It saved this window for restore, so the successor respawns
6422 /// the app itself; a client that waited for it reattached beside the
6423 /// respawned copy, and the restore came up with two of every window.
6424 #[test]
6425 fn compositor_gone_exits_instead_of_waiting_for_a_successor() {
6426 let mut attempt = 0;
6427 assert_eq!(
6428 after_session(SessionEnd::NoCompositor, true, LONG, &mut attempt),
6429 AfterSession::Exit
6430 );
6431 // Even mid-budget: a reconnect that finds nobody listening is the
6432 // compositor leaving, not another transport break.
6433 let mut attempt = 3;
6434 assert_eq!(
6435 after_session(SessionEnd::NoCompositor, true, SHORT, &mut attempt),
6436 AfterSession::Exit
6437 );
6438 }
6439
6440 #[test]
6441 fn nothing_to_carry_over_gives_up() {
6442 let mut attempt = 0;
6443 assert_eq!(
6444 after_session(SessionEnd::ConnectionLost, false, SHORT, &mut attempt),
6445 AfterSession::Exit
6446 );
6447 assert_eq!(
6448 after_session(SessionEnd::NoCompositor, false, SHORT, &mut attempt),
6449 AfterSession::Exit
6450 );
6451 }
6452
6453 #[test]
6454 fn budget_is_bounded_and_resets_after_a_long_session() {
6455 let mut attempt = 0;
6456 for _ in 0..RECONNECT_ATTEMPTS {
6457 assert!(matches!(
6458 after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
6459 AfterSession::Reconnect(_)
6460 ));
6461 }
6462 assert_eq!(
6463 after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
6464 AfterSession::Exit
6465 );
6466 // A session that outlived the reset window earns a fresh budget.
6467 assert_eq!(
6468 after_session(SessionEnd::ConnectionLost, true, RECONNECT_RESET + SHORT, &mut attempt),
6469 AfterSession::Reconnect(Duration::from_millis(200))
6470 );
6471 assert_eq!(attempt, 1);
6472 }
6473
6474 #[test]
6475 fn backoff_caps_at_six_point_four_seconds() {
6476 let mut attempt = 6;
6477 assert_eq!(
6478 after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
6479 AfterSession::Reconnect(Duration::from_millis(6400))
6480 );
6481 assert_eq!(
6482 after_session(SessionEnd::ConnectionLost, true, SHORT, &mut attempt),
6483 AfterSession::Reconnect(Duration::from_millis(6400))
6484 );
6485 }
6486 }