GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Merge main: touch input, the shared text cache, CI
main's six commits, with the three that touched window_runner moved to
where this branch keeps their code:
- The text cache (f46e8b0): Rc<Buffer> entries keyed text -> variants,
shared_text_buffer / shared_laid_out_buffer, boxed text cached by its
box, DlText for the frame — in backend/text.rs, with its tests. The
frame, the browser shell and the AppKit shell hold DlText; DlText is
public (fields crate-private) since build_frame is, for probe_web.
debug_clock_ms and present_debug stay in the Wayland shell.
- The status bar asked once per session: a OnceCell in the Driver, which
owns the CSD checks it serves.
- Touch (13976a5): TouchTracker stays in backend/touch.rs and is now
portable; what its actions do is Driver::touch (routing, as every other
input), reusing the pointer's outside-press popover close; only the
wl_touch binding is Wayland's. New test:
a_finger_is_the_left_button_and_never_the_windows.
- motion.rs keeps web_time::Instant in main's moved cache.
Verified with CI's own steps (RUSTFLAGS=-D warnings): build --all-targets;
test 560 passed; test --all-features 599 passed; the vk:: tests ran on
lavapipe, none skipped. Plate golden identical; check-wasm and check-mac
ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
.github/workflows/ci.yml | 60 ++++++
CLAUDE.md | 43 +++-
src/backend/driver.rs | 155 +++++++++++++--
src/backend/frame.rs | 6 +-
src/backend/mod.rs | 1 +
src/backend/text.rs | 357 ++++++++++++++++++++++++++--------
src/backend/touch.rs | 327 +++++++++++++++++++++++++++++++
src/backend/window_runner.rs | 63 ++++--
src/color.rs | 11 +-
src/config.rs | 1 -
src/input.rs | 11 ++
src/layout.rs | 14 +-
src/mac/mod.rs | 5 +-
src/motion.rs | 7 +-
src/scene/material.rs | 39 ++--
src/scene/paint.rs | 8 +-
src/vk/mod.rs | 1 +
src/vk/renderer.rs | 2 +-
src/web/shell.rs | 5 +-
src/widget/container/parameters_bg.rs | 51 ++++-
src/widget/display/graph.rs | 2 +-
src/widget/input/button.rs | 12 +-
src/widget/shaping.rs | 4 +-
23 files changed, 1026 insertions(+), 159 deletions(-)
diff --cc CLAUDE.md
index b2579f6,a8c675a..b501fbc
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@@ -57,336 -56,14 +57,344 @@@ scattering of widgets (`text_box`, `sli
engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
anything else.
+**The library also builds for the browser** (`wasm32-unknown-unknown`, since 2026-10-04):
+`scripts/check-wasm` type-checks it, with and without the optional features. The native
+shell and renderer — `vk`, the Wayland shell (`backend::{window_runner, menu_popup, dnd}`),
+`wayland`, `protocol`, `ipc`, `mcp`, `file_dialog` — and their crates (ash, smithay, calloop,
+wayland-*, libc, rfd) are `cfg(not(target_arch = "wasm32"))`; so are the Wayland-typed parts
+of the client contract: `Application::new(qh, …)`, `layer()` and `LayerSettings`,
+`register_sources`, and `renderer_init` / `stage_renderer`, which take a `VkRenderer`.
+`WindowAction::Resize` takes `app::WindowEdge` — xdg's `ResizeEdge` on Linux, as before.
+Since macOS joined (2026-10-05) "native" is two things: the Vulkan renderer and the native
+services (`vk`, `ipc`, `file_dialog`; ash, libc, rfd) are `cfg(not(target_arch = "wasm32"))`,
+and the WAYLAND shell — `backend::{window_runner, menu_popup, dnd}`, `wayland`, `protocol`,
+`mcp`, the crates smithay / calloop / wayland-* / xkeysym, and the contract's `new(qh, …)`,
+`layer()`, `LayerSettings` and `register_sources` — is
+`cfg(not(any(target_arch = "wasm32", target_os = "macos")))`. `renderer_init` /
+`stage_renderer` are on macOS too, since it has the Vulkan renderer.
+Portable code keeps time with `web_time::Instant` (std's own type natively; std's panics in
+the browser), and reaches what a renderer draws through `crate::draw`, not `crate::vk`. A
+change that makes portable code call into a native module fails `check-wasm` first — put
+the native half behind the cfg, as `color_selector::place_picker_at_pointer` does.
+
+**It draws in the browser too** (`src/web`, `WebRenderer`, since 2026-10-04): the Vulkan
+renderer's 2D path on WebGPU through web-sys, from the same `Frame2D`, shaders, glyph atlas
+and image queue. web-sys still ships its WebGPU bindings behind `--cfg=web_sys_unstable_apis`;
+`.cargo/config.toml` sets it for the wasm target, and **cargo reads that file from the
+directory it is run in** — so build the browser half from inside `cce-ui` (as
+`check-wasm` does), and a client crate that builds cce-ui for the browser needs the same line
+in its own config.
+
+**And an `Application` runs in a page** (`web::run::<App>(canvas, fonts, sizing).await`, since
+2026-10-05): the browser shell (`src/web/shell.rs`) is the Wayland shell's counterpart over a
+`<canvas>`, on the same `Driver` and `Pacer`. What it does in the page's terms:
+
+- **Events**: pointer (captured on press, so a drag outside the canvas still ends), wheel,
+ key and focus events on the canvas, mapped by `backend::dom` — `map_key` gives a key the
+ TEXT xkb's `utf8` would (Tab "\t", Enter "\r", Ctrl+letter its control code: the Wayland
+ shell hands widgets exactly that, and a focused text box inserts Tab's), `wheel_frame`
+ reads a whole notch-sized pixel delta (Chromium's 100 px) or a line / page delta as a wheel
+ notch and anything else as a finger, and a finger gesture's lift is synthesized after
+ 120 ms without a frame (`FINGER_LIFT`), since a page reports none. The browser's own key
+ repeats are dropped: the driver repeats, as on Wayland. On a Mac, Command is the shortcut
+ key (⌘Z is undo). A page has no grabs, so a press on a CSD border is the app's.
+- **Pacing**: a turn per animation frame at the pacer's ACTIVE cadence, a timer at its idle
+ one; any event, and any `AppSender::send` (through `backend::app::set_wake`), wakes the
+ loop for the next frame. Measured idle: 5 turns in 5 s, the native count.
+- **Size**: `Sizing::App` sizes the canvas from `WindowSettings` and `desired_size` (CSS px),
+ as a window; `Sizing::Page` leaves it to the page's CSS and ignores size requests (a tiling
+ compositor's answer); a ResizeObserver wakes a turn on relayout, and `devicePixelRatio` is
+ the scale. The context menu is drawn in the canvas and kept inside it, as on a layer surface.
+- **Fonts** (`web::Fonts`): the files, and the generic serif / sans / mono families — a page
+ has no font directory and no fontconfig, so it says both. `lib::page_fonts` holds them, and
+ on wasm EVERY font database the toolkit builds loads them: the shell's, the widget-geometry
+ one (`geometry_font_system`) and the text-measurement one (`widget::input::get_font_db`,
+ resvg's — the toggle's label is centred by it). cosmic-text has no family fallback list on
+ wasm (`fallback/other.rs` is empty), where on Linux it walks Noto Sans → DejaVu Sans → …,
+ so `page_fonts::stand_in_for_missing` gives the families the toolkit names (the configured
+ fonts, "Berkeley Mono") the faces of the first family of that Linux list the set has; a
+ family an app names itself must be in the set. Order matters: the measuring fallback is the
+ first face with the glyph.
+- **`web::capture().await`**: the next frame, read back from the GPU. Headless Chromium
+ composites in software and leaves a WebGPU canvas out of its screenshots and `toDataURL`.
+- **The clipboard** (since 2026-10-05): `widget::clipboard` is one synchronous text pair
+ (`copy_to_clipboard` / `read_from_clipboard`, every widget's copy, cut and paste) with a
+ backend per platform — `wl-copy` / `wl-paste` (`xclip`) on Wayland, `NSPasteboard` on
+ macOS, and in a page the page's own clipboard events, because a page may read the
+ clipboard only inside a `paste` event. So the canvas lets ⌘/Ctrl+C, X and V keep their
+ defaults (`dom::clipboard_key`), and a ⌘/Ctrl+V is HELD from the app until its `paste`
+ event has handed over the text (then a read answers it) — or, if none comes, until a
+ zero timer, when a read answers the page's own last copy or paste; a release never
+ overtakes it. A copy writes through `navigator.clipboard.writeText` where the page has it
+ (a secure context; checked first, since calling into undefined throws through the wasm
+ frames), and the `copy` / `cut` event the key raises carries it too, which needs no
+ secure context. Until then a copy in a page panicked (`std::thread::spawn`).
+ `scripts/web-probe/clipboard` is the check: copy, paste, copy with `writeText` refused,
+ and paste with the `paste` event swallowed, each read back from the system clipboard —
+ all four pass in headless Chromium (2026-10-05). (Five since the IME: a `paste`
+ swallowed with its default kept pastes into the keyboard sink, an `input` of type
+ `insertFromPaste`, which is the paste too.)
+- **The keyboard is a hidden `<textarea>`'s** (the keyboard sink, since 2026-10-05), not
+ the canvas's: a page composes input-method text only into an editable element. It takes
+ the focus a press on the canvas gave the canvas (a canvas focused another way hands it
+ over, and a blur over to the canvas is not a focus loss); its keys are the app's as the
+ canvas's were, except one the input method takes (`isComposing`, keyCode 229); its
+ `input` events while composing are the composition (`Driver::preedit`, the cursor from
+ its selection via `dom::utf16_range_to_bytes`), `compositionend` the commit, and text
+ with no composition (an emoji panel, dictation) a commit as it comes. After each frame
+ it is moved to the editing widget's caret (`ime::caret`), where the candidates open; a
+ composition a widget dropped (`ime::take_reset`) is cancelled by blurring and refocusing
+ it INSIDE the turn, where the events that raises reach no handler.
+ `scripts/web-probe/ime` is the check, through Chromium's own IME path (CDP
+ `Input.imeSetComposition` / `insertText`): a composition shown with the sink at its
+ caret, the commit replacing it, a cancelled one leaving the box, a no-composition
+ insert, and plain keys — all six pass (2026-10-05), and the 24-step demo replay is
+ identical to the pixel to the run before the sink through step 18.
+
+Not there yet: drag and drop, file dialogs, and an app whose
+text is not the display list's (`display_list_text` false — it stages its own through the
+native-only `stage_renderer`, so draws no text here).
+
+**Compute jobs run in the browser too** (`web::ComputeDevice`, since 2026-10-05). What a job
+IS moved out of `vk` into the portable `crate::compute` — `Kernel`, `Binding`, `BindKind`,
+`workgroups`, `MAX_BINDINGS`, and the rules a job is held to before any device sees it
+(`check_job`, the ping-pong `slot_for` / `result_slot`, `parse_kernel`: naga's WGSL
+frontend, now a dependency on every target, validates a kernel and reads its
+`@workgroup_size` — WebGPU can report neither) — and `vk::compute` re-exports every one at
+its old path. The browser device takes the same jobs and answers them the same way, with
+one difference the platform makes: readback is a promise, so its `run`, `run_over`,
+`run_passes`, `run_passes_over` and `workgroup_size` are `async`. Two things WebGPU does
+differently underneath: its layouts tell read-only storage from read-write (the module
+says which, `ParsedKernel::read_only_storage`), and a device starts at the spec's default
+of eight storage buffers a stage, so it asks for the adapter's own (SwiftShader offers
+ten; a job past the adapter's ceiling is an `Err` naming the limit). What WebGPU rejects
+is caught in a validation error scope and returned. `examples/compute_probe/jobs.rs` is
+the check — a map, a uniform, a 33- and a 34-pass ping-pong, a 2D dispatch, ten bindings,
+a bad kernel and a missing entry, each exact against a CPU reference in f32 — run by
+`compute_native` and by `scripts/web-probe/compute`: the two outputs are identical to the
+bit (lavapipe vs SwiftShader, 2026-10-05). A reference written for a length that is not
+a multiple of four floats must know that `arrayLength` counts the 16-byte padding, on
+both devices.
+
+**3D scenes draw in the browser too** (`Stage3D`, since 2026-10-05). What an app stages a
+scene through is a trait, `draw::scene::Stage3D` (`create_mesh`, `update_mesh`,
+`stage_scene`, `stage_scene_images`, `set_scene_light`), implemented by `VkRenderer` (each
+method its inherent one) and `WebRenderer`; the scene's types (`Vertex3D`, `MeshId`,
+`SceneDraw`, `SceneImage`), its uniform blocks (`scene_uniforms`), image quads and the
+wire-base depth bias (`wire_base_bias`) moved to `draw::scene`, and `scene3d.wgsl` /
+`scene3d_image.wgsl` to `draw/`, shared by both renderers; `vk` and `engine` re-export them.
+Two portable `Application` hooks take a `&mut dyn Stage3D`: **`init_3d`** (once per
+renderer — make meshes) and **`stage_3d`** (every frame, just before the draw; true asks
+for another frame). Natively `renderer_init` and `stage_renderer` forward to them by
+default, as `new` forwards to `create`, so an app that overrides the native hooks (the
+designer) is untouched, and one that moves to the portable pair runs on both shells. The
+WebGPU pass (`web/scene.rs`) is the Vulkan `SceneStage`'s port: a full-size backdrop in the
+canvas's sRGB view format with a depth32 buffer, copied into the canvas under a UI pass
+that LOADS it and samples it for blur — and kept, as on Vulkan, until the next staged
+scene. Depth bias is pipeline state in WebGPU, and a WebGPU line is one pixel (no
+wideLines), so the biased fill is a pipeline of its own at `wire_base_bias(1.0)` — what a
+Vulkan device without wideLines uses. `scene3d.wgsl` takes its derivatives at the top of
+`fs_main` (WebGPU rejects one under a branch on a varying); natively pixel-identical.
+`examples/probe3d/scene.rs` is the check, on the portable hooks — background quad, flat and
+prelit fills, a wire-carrying fill and its wires, a see-through fill and its edges, an
+image in the scene before the translucent draw, a host light, frost over the pane — run by
+`probe3d_native` and `scripts/web-probe/probe3d`: 2026-10-05, lavapipe vs SwiftShader,
+195 px differ by more than 8 levels, all on 1 px wires (where along its length a line
+steps a row is the rasterizer's), everything else within 2.
+
+**And so does the path tracer** (since 2026-10-05). `Stage3D` carries the tracer's half
+too — `set_rt_scene` / `set_rt_scene_with_image`, `set_rt_environment`,
+`set_rt_background`, `stage_rt`, `rt_accumulating` — and what it traces from moved to
+`draw::rt`: the schema (`RtTriangle`, `RtMaterial`, `RtImage`, `RtCamera`,
+`RtEnvironment`), the binned-SAH BVH and its tests, the buffers (`pack_scene`) and
+parameter blocks (`rt_params`, `denoise_params`) as the shaders read them, and the
+constants; the shaders (`rt_common` / `rt_bvh` / `rt_query` / `rt_denoise`) moved to `draw/`.
+`vk::rt` re-exports the schema and keeps its own state (frames in flight, the ray-query
+tier, `RtOffscreen`) — it now packs and lays out through `draw::rt`, verified by its
+GPU tests (`cargo test --lib rt -- --ignored`, lavapipe). `web/rt.rs` is the compute tier on
+WebGPU: the same shaders and packing, the same rules for restarting the accumulation, one
+sample a frame plus the three à-trous iterations in one compute pass. Two differences:
+WebGPU has no ray tracing, so there is no ray-query tier (the compute tier is what every
+Vulkan device without RT cores runs too); and Vulkan BLITS the tracer's `rgba8unorm`
+image into the sRGB backdrop, converting as it copies, which WebGPU's copies cannot — a
+small render pass loads each texel and writes it through the backdrop's sRGB view, the
+same conversion. The probe's traced mode (`Probe3d<true>`, `PROBE3D_TRACE=1` natively,
+`scripts/web-probe/probe3d <out> traced`) stages exactly eight frames and stops, so both
+are compared at eight samples: 2026-10-05, Vulkan compute tier (`CCE_VK_RT=compute`) on
+lavapipe vs SwiftShader, the traced pane's mean differs by 0.10 of a level, every pixel
+within 8, 47 channels in the frame past 8 — a few paths that diverged.
+
+**The reference app runs on both, through one input script.** `examples/demo_web.rs` is
+`src/main.rs`'s `DemoApp` (included by `#[path]`, hence `pub(crate)`) in a page;
+`scripts/web-probe/demo <dir>` builds it, serves it with the machine's fonts and replays the
+native harness's 24 steps (`drive.mjs`: moves, clicks, a drag, a wheel, typing, undo, the
+menu, Tab, held keys, the CSD bands), one captured frame per step, which `compare.py` diffs
+against the native run's screenshots (`--mask` the cursor's box; never sway's
+`hide_cursor`, which clears pointer focus, so the native app drops its hover). Measured
+2026-10-05 against lavapipe: steps 00–18 differ only at the slider band's two pointed tips,
+1 px of rasterizer tie-break (≤ 63 channels beyond 8 levels; everything else within 2),
+with `DEMO_FAMILIES=FreeSerif,FreeSans,FreeMono` — what native's fontdb made of this
+machine's fontconfig, not `fc-match`'s DejaVu. Steps 19–20 hold a key, and the driver
+repeats once per turn: SwiftShader takes ~250 ms a frame, so the page gets fewer repeats
+than native in the same 1.5 s. Timing, not routing.
+
+**The renderer probe holds the two renderers to each other.** `examples/probe/scene.rs`
+is one 1280x800 frame of nearly every prim — root, pane and frosted plates, every control
+stance, fields, carves, bevel, sphere, grooves, vector caps, text at four sizes in two
+families, an image at two sizes. `cargo run --example probe_native` draws it through
+Vulkan (a Wayland session; screenshot it); `scripts/web-probe/run <out.rgba>` builds
+`probe_web` for wasm, binds it with wasm-bindgen-cli (the version in Cargo.lock) and draws
+it in headless Chromium on SwiftShader, reading the frame back from the GPU; and
+`scripts/web-probe/compare.py native.png out.rgba 1280 800` diffs them. Both halves must
+have the same fonts — the native one with `CCE_LOAD_SYSTEM_FONTS=1`, the web one handed the
+DejaVu files (`$PROBE_FONTS_DIR`) — and the screenshot must not carry a cursor (sway:
+`seat * hide_cursor 200`), which is a difference the diff cannot tell from the renderer's.
+Lavapipe against SwiftShader, 2026-10-04: 96.8% of channels equal, every other within 2
+levels but ONE at 3 — rounding at antialiased edges and in the blur, no shading difference.
+Chromium needs `--use-angle=swiftshader --enable-unsafe-swiftshader
+--disable-gpu-compositing` beside the WebGPU flags (`browser.mjs`): headless, with GPU
+compositing it has no shared-image backing for a WebGPU canvas and loses the device on the
+first present ("A valid external Instance reference no longer exists").
+
+**And on a Mac, type-checked only** (`src/mac`, since 2026-10-05). The fourth shell is an
+AppKit window over the same `Driver`, `Pacer`, `build_frame` and **Vulkan renderer, on
+Metal through MoltenVK**: `vk::SurfaceTarget` names what a window's `VkSurfaceKHR` is made
+from — `Wayland { display, surface }` or `Metal { layer }` (a `CAMetalLayer`) —
+`VkRenderer::try_new_for` / `attach_surface_to` and `VkCore::new_for_surface` /
+`create_surface` take one, and the Wayland-pointer forms forward to them unchanged. The
+instance enables VK_EXT_metal_surface when the loader offers it, and on macOS only
+VK_KHR_portability_enumeration (the loader lists MoltenVK to no instance that does not ask);
+a device that offers VK_KHR_portability_subset gets it enabled, as the spec requires, and
+no Linux driver offers it — so a Linux instance and device are the ones they always were.
+`engine::run::<App>()` is the AppKit shell's `run` on macOS, so a client's `main` does not
+change. What the shell does, in AppKit's terms (module doc in `src/mac/mod.rs`):
+
+- **The window**: transparent, its titlebar transparent over full-size content, so the root
+ plate fills it with the traffic lights on its corner. AppKit resizes from its own window
+ edges, so the driver's CSD resize band is the app's (`PressSite::own_edges`, new; false
+ on the other shells); a press the driver reads as a move drags the window
+ (`performWindowDragWithEvent:`).
+- **Events**: a flipped, layer-hosting `NSView` maps mouse, scroll, magnify and keys through
+ `backend::appkit` (portable, tested on Linux like `dom`): named keys from the hardware key
+ code (AppKit spells them in private-use characters, and Backspace as DEL), the rest from
+ `characters`, or with ⌘/Ctrl from `charactersIgnoringModifiers` so ⌘Z is z typing ^Z;
+ Command reads as `ctrl`, as in a page on a Mac; a Control-click is a right click. AppKit
+ sends NO keyUp for a ⌘-combination, so the shell releases one as it presses it (else the
+ driver repeats ⌘Z until focus is lost). Its key repeats are dropped (the driver repeats)
+ and so is the system's scroll MOMENTUM (the toolkit coasts a flick itself; both would
+ coast twice). The system has applied natural scrolling to the wheel too, where a Linux
+ compositor applies it to the trackpad alone, so a wheel notch is turned back to the
+ wheel's own direction, and each trackpad event's `isDirectionInvertedFromDevice` sets
+ `input::force_natural_scroll` on the main thread: the system setting rules, not input.kdl's.
+- **Pacing**: main-queue dispatches (`dispatch2`); an event or any `AppSender::send`, from
+ any thread (`app::set_wake`, process-wide on macOS, per thread in a page), asks for a turn
+ at most one ACTIVE frame after the last; between, the pacer's sleep. A superseded turn is
+ dropped by its generation. Quit (⌘Q) and the close button ask the app to exit as a
+ compositor's close does; the run loop is stopped once it has.
+- **Fonts**: the system set is always loaded on macOS (`build_font_system`) — it is what
+ cosmic-text's macOS fallback list names.
+- **Clipboard**: the general `NSPasteboard`'s plain-text type, behind the same
+ `widget::clipboard` pair every widget uses; ⌘C / ⌘X / ⌘V reach the widgets as Ctrl+C /
+ X / V do on Linux, since Command reads as `ctrl`.
+- **Input methods**: the view is an `NSTextInputClient`. While a widget is editing text
+ (`ime::caret` is set) a key press without ⌘ goes through `interpretKeyEvents:` first:
+ `setMarkedText:` is the composition, `insertText:` the commit — unless it is a plain
+ key typing its own characters with nothing marked, which is left to the key path so it
+ keeps its named key and the driver's repeat — and `doCommandBySelector:` leaves the key
+ to the key path. `firstRectForCharacterRange:` is the caret in screen coordinates. With
+ nothing editing, keys skip the input method, so one left on does not eat an app's
+ single-key commands. After each frame a dropped composition is discarded through the
+ input context (the marked text cleared first, so the `unmarkText` that may call commits
+ nothing) and a moved caret invalidates the character coordinates.
+
+Not there yet: drag and drop, the context menu
+in a popup window (it is drawn in the window, as on a layer surface), blur behind the window,
+a menu bar beyond Quit. **None of it has run**: this is Linux, where an Apple target can be
+type-checked but not linked. `scripts/check-mac` type-checks the library, the demo, every
+example and the tests for `aarch64-apple-darwin` (`rustup target add aarch64-apple-darwin`);
+the four examples that still used the legacy `new(qh, …)` moved to `create`, and
+`plate_probe` / two integration tests reach the tessellator at `backend::tessellate` rather
+than through `window_runner`. On a Mac, MoltenVK and the Vulkan loader must be installed
+(the LunarG SDK, or Homebrew's `molten-vk` and `vulkan-loader`); `cargo run` is the test.
+
+**Input-method composition is one model for every shell** (`crate::ime`, since
+2026-10-05). Three things cross between the text widget and the shell's input method:
+the COMMIT is delivered as typed text (`Driver::commit_text`: a press of a key whose text
+it is, then its release — never a shortcut, never repeated, past the chords), so every
+widget that inserts a key's text takes it unchanged (`TextBox`, `LineEdit`, the
+`DocEditor`, an app's own field); the COMPOSITION (`ime::Preedit`: text and the input
+method's cursor as a byte range) is shared per thread, set through `Driver::preedit`; and
+the CARET goes back — a widget editing text reports it as it paints
+(`ime::report_caret`, in window px with the `PaintCtx` offset), `build_frame` brackets
+the frame (`begin_frame` / `end_frame`), and `ime::caret()` is where the candidates go and
+whether text is wanted at all. **`TextBox` shows a composition as a PROVISIONAL run** in
+`edit_buffer` (`composing`: its char start and length), so wrap, scroll, caret and the
+glyph advances draw it as typed text, and `selection_quads` underlines it; it is never
+held (`committed_buffer`, which `take_change` publishes under `update_on_type`), never in
+the history, and the box takes no key while it composes. It is applied in `prepare_text`
+and at the top of `handle_key`, against `ime::generation`; a press, or editing ending,
+drops it and asks the input method to cancel (`ime::request_reset`). A composition begun
+over a selection replaces it, as typing would. `a_composition_is_shown_in_place_and_the_
+commit_is_typed` is the test.
+
+**`LineEdit` and the `DocEditor` show it too** (since 2026-10-05), each without letting it
+into what it holds — a host reads `LineEdit::text` directly and saves the `DocEditor`'s
+buffer, so neither ever contains it. `LineEdit` splices it into `display()` at the caret,
+and `display_index` / `text_index` map across it (the caret lands where the input method
+has its cursor, a point inside the composition is the caret, one after it is the text it is
+drawn after); `composition_range` is the span to underline, a masked field shows bullets.
+The app, which draws the field, calls `sync_ime` each frame the field has the keyboard,
+reports the caret it draws (`ime::report_caret` — also what tells the shell text is
+wanted), and `drop_composition` when the field loses it. The `DocEditor` lays out the
+caret's line WITH the composition (an active line, raw anyway) and maps every column read
+off that layout across it (`laid_col` / `source_col`: the caret, `caret_rect`, `pos_at`);
+it underlines it, and reports its caret itself while painted focused; a host calls
+`drop_composition` when the editor loses the keyboard. Both take no key while a
+composition is up, take the commit as typed, and treat a press as dropping the
+composition (cancelled in the input method) and placing the caret — the `DocEditor`'s read
+through the line as drawn. `a_composition_is_shown_at_the_caret_and_never_held` and
+`a_composition_is_laid_out_in_place_and_never_held` are the tests; cce-notes, built against
+this tree, was driven under the headless sway with the stand-in input method (2026-10-05):
+the composition underlined at the caret, the commit typed, a second composition left up
+through the editor's autosave and then dropped by a click — and the note on disk held the
- commit and never the composition. No `LineEdit` host calls the three methods yet
- (cce-browser's URL bar and dialog fields; it cannot be built here, for WPE).
++commit and never the composition. cce-browser's URL bar, bookmarks search and dialog
++fields are the `LineEdit` hosts (its `keyboard_field` / `sync_ime`, lsgalante/cce-browser#1).
+
+Not there yet: no shell sends surrounding text, so an input
+method's `delete_surrounding_text` (text-input-v3) is not applied; and a password box is
+announced with the normal content purpose.
+
+**On Wayland it is `text-input-v3`** (`backend/text_input.rs`, since 2026-10-05), relayed
+by the compositor to an `input-method-v2` client (fcitx5, IBus's Wayland frontend). The
+text input is the first keyboard seat's, made with the keyboard. After each render
+(`EngineState::sync_text_input`) it is ENABLED while the seat's text-input focus is on our
+surface (`enter`) and a widget is editing (`ime::caret`), with a normal content type and
+the caret as the cursor rectangle (surface px — the app's logical px times a forced scale,
+as pointer input is divided), re-sent when the caret moves; DISABLED when nothing is
+editing; and disabled-then-enabled for a composition a widget dropped (`ime::take_reset`),
+which resets the input method. Every change is one `commit`, counted (`TextInput::commits`,
+what a current `done`'s serial is). `preedit_string` / `commit_string` /
+`delete_surrounding_text` are double-buffered and applied on `done` in the protocol's
+order (`Batch::apply_order`: the old composition out, the commit typed, the new one in;
+a batch with no `preedit_string` ends the composition, a cursor of -1 hides it); `leave`
+drops the composition. The decisions are pure (`TextInput::plan`, `Batch`) and tested
+with no compositor (`backend::text_input::tests`). Verified end to end under the headless
+sway with a scriptable `input-method-v2` client standing in for fcitx5 (2026-10-05): no
+activation until a box is clicked into; a composition shown underlined at the caret; the
+commit replacing it; a cancel; a press mid-composition dropping it with a
+disable-and-enable; Escape disabling — and under `WAYLAND_DEBUG` the cursor rectangle
+following the caret through every step, each `done`'s serial equal to the commits sent.
+Sway routes text-input focus only while an input method is bound, so with none (the
+24-step harness) nothing changes: 0 px.
+
+ CI (`.github/workflows/ci.yml`, every push and PR) builds and tests on Ubuntu 24.04 with
+ default and with all features, warnings as errors. It installs `libwayland-dev` and
+ `libxkbcommon-dev` (the two native libraries the build links, through pkg-config) and Mesa's lavapipe, a software Vulkan device,
+ so the GPU tests (`vk::compute`, `vk::plate_probe`) RUN there rather than skip — and a
+ last step fails the job if they printed a skip note, since a skipped test passes. To match
+ it locally: `apt install libwayland-dev libxkbcommon-dev mesa-vulkan-drivers`, then
+ `RUSTFLAGS="-D warnings" cargo test --all-features`.
+
Wayland protocol bindings are generated **inline at compile time** by `wayland-scanner` macros in
`src/protocol.rs` from `protocol/*.xml` (`cce-inspector-v1`, `cce-window-management-v1`) — there is
no `build.rs` and no codegen step to run.
@@@ -1853,6 -1454,36 +1861,37 @@@ deliberately left alone: a slider's an
over them, which changes a VALUE after the hand has stopped.
`the_animations_switch_stops_the_glide_and_not_the_coast` is the test.
+ ### A finger scrolls (touchscreens, since 2026-10-05)
+
+ The runner binds `wl_touch` when the seat offers it, and `backend/touch.rs`
+ turns the first finger into pointer input by what it does: a **tap** clicks
+ where it landed, a finger that **moves** past `SLOP` (10 px) scrolls — a
+ `PixelDelta` equal to the finger's travel, `ScrollPhase::Finger`, dispatched
+ at the down point, then `FingerEnd` at the lift so a flick coasts through
+ `ScrollMotion` like a trackpad's — and a finger **held** `HOLD_MS` (400 ms)
+ before moving is a held left button (a slider thumb, a text selection, a
+ scrollbar). The hold needs no timer: nothing is sent while the finger rests
+ inside the slop, so the choice is made at the first motion past it. Other
+ fingers are ignored until the first lifts. `TouchTracker` is the pure state
-machine (tested in that file); the `TouchHandler` impl and the dispatch are
-beside it, so `window_runner.rs` carries only the fields and the capability
-hook.
++machine (tested in that file, and portable); what its actions do is the
++driver's (`Driver::touch`, routing like every other input), and only the
++`TouchHandler` impl beside the tracker is Wayland's, so `window_runner.rs`
++carries only the fields and the capability hook.
+
+ A finger is always natural — the content goes where it is pushed — so the
+ dispatch runs inside `input::with_natural_scroll(true, …)` and a value
+ control's `value_notches_y` reads the finger's real direction whatever the
+ trackpad's setting. No per-app trackpad factor either: 1:1 keeps the content
+ under the finger. Not by finger: CSD moves/resizes and
+ `Application::take_window_action`, since the compositor checks those serials
+ against a pointer grab; the runner drains a queued action after a touch so it
+ cannot fire on the next pointer press. Binding `wl_touch` is also what moves a
+ cce-ui window off the compositor's emulated-pointer route
+ (`cce-compositor`'s `cursor::TouchRoute`), where a finger drag was a held
+ button and selected rather than scrolled. `CCE_SCROLL_DEBUG=1` logs each
+ touch scroll (`[scroll] touch: …`); in a shadow, `ccectl touch down|motion|up`
+ drives it.
+
### A host may name the phase; a test may pin the settings (2026-09-30)
The phase a wheel event belongs to (`Finger`, `FingerEnd`, `Wheel`) is a
diff --cc src/backend/driver.rs
index 320d0ff,0000000..bda611f
mode 100644,000000..100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@@ -1,1031 -1,0 +1,1154 @@@
+//! The runner's platform-neutral half: what happens to input once a shell has
+//! it in cce-ui's own terms. A shell (the Wayland runner today) translates its
+//! window system's events — evdev buttons, xkb keysyms, `wl_pointer` axis
+//! frames — into the calls here, and carries out what they ask back of the
+//! window (a cursor, an interactive move or resize). Everything between —
+//! modifier tracking, the undo/redo and plate-navigation chords, key repeat,
+//! the CSD hit zones, the outside-press popover close, held-button release on
+//! a lost pointer, the scroll phase, the pinch fallback — lives here once, so
+//! a second shell routes exactly as the first does.
+//!
+//! The app is never owned here: each call takes a [`Turn`], the app plus the
+//! runner's `redraw` / `exit` flags, borrowed from wherever the shell keeps
+//! them (the Wayland runner keeps them on `EngineState`, where clients'
+//! `register_sources` callbacks reach `inner` and `redraw` directly).
+
+use web_time::Instant;
+
+use super::app::{Application, LogicalPosition, LogicalSize};
+use cursor_icon::CursorIcon;
- use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position};
++use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position, ScrollPhase};
+
+/// A key held down, for the runner's own key repeat.
+pub struct PressedKey {
+ pub logical_key: Key,
+ pub text: Option<String>,
+ pub first_pressed: Instant,
+ pub last_repeated: Instant,
+}
+
+/// Held this long before the first repeat…
+pub const KEY_REPEAT_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
+/// …then one repeat per this.
+pub const KEY_REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
+
+fn is_repeatable_key(key: &Key) -> bool {
+ match key {
+ Key::Named(NamedKey::Backspace) |
+ Key::Named(NamedKey::Delete) |
+ Key::Named(NamedKey::ArrowLeft) |
+ Key::Named(NamedKey::ArrowRight) |
+ Key::Named(NamedKey::ArrowUp) |
+ Key::Named(NamedKey::ArrowDown) |
+ Key::Named(NamedKey::Home) |
+ Key::Named(NamedKey::End) |
+ Key::Character(_) => true,
+ _ => false,
+ }
+}
+
+/// The modifier keys as the keyboard last reported them.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct Modifiers {
+ pub ctrl: bool,
+ pub shift: bool,
+ pub alt: bool,
+ pub logo: bool,
+}
+
+/// A window edge an interactive resize grabs: the window system's own enum,
+/// spelled without it. The Wayland shell maps it onto `xdg_toplevel`'s.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ResizeEdge {
+ Top,
+ Bottom,
+ Left,
+ Right,
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight,
+}
+
+/// What a press turned out to be: the app's, or a grab of the window itself
+/// that the shell carries out (and the app never hears about).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Press {
+ Dispatched,
+ Resize(ResizeEdge),
+ Move,
+}
+
+/// Where a press landed, in the window's terms.
+#[derive(Debug, Clone, Copy)]
+pub struct PressSite {
+ /// The surface's logical size: the CSD borders are measured from it.
+ pub size: LogicalSize,
+ /// The press came through the context menu's popup surface: it is the
+ /// menu's, never a border or a movable plate of the window.
+ pub on_popup: bool,
+ /// The shell can start an interactive move / resize right now (a window
+ /// and a seat to grab with). When it cannot, a press that would have been
+ /// a grab is the app's instead — which is what the Wayland runner did on
+ /// a layer surface, and what a shell with no grabs at all always does.
+ pub can_grab: bool,
+ /// The window system resizes the window from edges of its own (AppKit's
+ /// window frame), and offers no way to start a resize from a press: the
+ /// CSD resize band is then no grab, and a press there goes on to the
+ /// move checks and the app.
+ pub own_edges: bool,
+}
+
+/// Where a scroll came from, as far as the phase cares.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ScrollSource {
+ Wheel,
+ Finger,
+ Continuous,
+ WheelTilt,
+}
+
+/// One frame of scroll input, coalesced: the shell sums a frame's axis events
+/// (in its own window system's units — `wl_pointer` axis values here) and
+/// hands the total over once.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ScrollFrame {
+ pub h: f64,
+ pub v: f64,
+ pub discrete_h: i32,
+ pub discrete_v: i32,
+ /// The frame's source, if it named one.
+ pub source: Option<ScrollSource>,
+ /// A finger lifted (an axis stop) in this frame.
+ pub stop: bool,
+}
+
+/// The app and the runner's two flags, for the length of one dispatch.
+pub struct Turn<'a, A: Application> {
+ pub app: &'a mut A,
+ pub redraw: &'a mut bool,
+ pub exit: &'a mut bool,
+}
+
+impl<A: Application> Turn<'_, A> {
+ /// Hand a message from an input handler to `update`, and fold the two
+ /// rebuild requests into the redraw flag — the shape every dispatch had.
+ fn deliver(&mut self, msg: Option<A::Message>, mut rebuild: bool) {
+ if let Some(msg) = msg {
+ let mut update_rebuild = false;
+ self.app.update(msg, &mut update_rebuild, self.exit);
+ if update_rebuild {
+ rebuild = true;
+ }
+ }
+ if rebuild {
+ *self.redraw = true;
+ }
+ }
+}
+
+/// The input state a session carries, and the routing over it.
+pub struct Driver {
+ pub mods: Modifiers,
+ pub pressed_key: Option<PressedKey>,
+ /// The pointer's last position, window-logical (popup events translated).
+ pub cursor_pos: (f32, f32),
+ /// Mouse buttons held, as a bitmask (1 Left / 2 Right / 4 Middle). On a
+ /// lost pointer the real release goes to whatever surface takes the
+ /// pointer next (fullscreen switches, layout animations), so
+ /// [`pointer_leave`](Self::pointer_leave) synthesizes releases for the
+ /// held set — a drag must end, not stay armed and steered by later
+ /// motion — and only then runs the off-screen hover-clear (which would
+ /// otherwise corrupt the drag: a ramp key snapped to the graph corner).
+ pub buttons_down: u32,
+ pub last_pinch_scale: f32,
+ /// The `undo` / `redo` chords, resolved from `input.kdl` when the
+ /// session starts.
+ pub undo_chord: String,
+ pub redo_chord: String,
+ /// `focus_next_group` / `focus_prev_group` (input.kdl, cce-ui domain):
+ /// the plate-navigation group jump, for apps that opt in.
+ pub group_next_chord: String,
+ pub group_prev_chord: String,
++ /// Whether the app is the status bar, asked once (see
++ /// [`is_status_bar`](Self::is_status_bar)).
++ status_bar: std::cell::OnceCell<bool>,
+}
+
+impl Default for Driver {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// The CSD border's width, in logical px.
+const CSD_BORDER: f32 = 8.0;
+
++/// Close the open popovers a left press at (lx, ly) misses, BEFORE the app's
++/// dispatch: apps commonly region-gate their routing, so an open menu's owner
++/// may never hear about a press elsewhere.
++fn close_popovers_missed_by<A: Application>(app: &mut A, lx: f32, ly: f32) {
++ let offsets: Vec<_> = app
++ .ui_context()
++ .map(|ctx| ctx.popover_owners())
++ .unwrap_or_default()
++ .into_iter()
++ .map(|id| (id, app.popover_offset(id)))
++ .collect();
++ if let Some(ctx) = app.ui_context_mut() {
++ ctx.close_popovers_missed_by_press_with(lx, ly, |id| {
++ offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
++ });
++ }
++}
++
+fn button_bit(btn: MouseButton) -> u32 {
+ match btn {
+ MouseButton::Left => 1,
+ MouseButton::Right => 2,
+ _ => 4,
+ }
+}
+
+impl Driver {
+ pub fn new() -> Self {
+ Self {
+ mods: Modifiers::default(),
+ pressed_key: None,
+ cursor_pos: (0.0, 0.0),
+ buttons_down: 0,
+ last_pinch_scale: 1.0,
+ undo_chord: crate::input::app_chord("undo", "ctrl+z"),
+ redo_chord: crate::input::app_chord("redo", "ctrl+shift+z"),
+ group_next_chord: crate::input::app_chord("focus_next_group", "ctrl+tab"),
+ group_prev_chord: crate::input::app_chord("focus_prev_group", "ctrl+shift+tab"),
++ status_bar: std::cell::OnceCell::new(),
+ }
+ }
+
++ /// The app is the status bar (`app_id` `cce-status…`): no CSD move,
++ /// resize or resize cursors. Read from `settings()` once per session —
++ /// the checks it serves run on every pointer motion and press, and
++ /// `settings()` builds two `String`s each call.
++ fn is_status_bar<A: Application>(&self, app: &A) -> bool {
++ *self.status_bar.get_or_init(|| app.settings().app_id.starts_with("cce-status"))
++ }
++
+ /// Copy the modifiers into the app's widget context, where widgets read them.
+ fn sync_mods<A: Application>(&self, app: &mut A) {
+ if let Some(ctx) = app.ui_context_mut() {
+ ctx.ctrl_pressed = self.mods.ctrl;
+ ctx.shift_pressed = self.mods.shift;
+ ctx.alt_pressed = self.mods.alt;
+ ctx.logo_pressed = self.mods.logo;
+ }
+ }
+
+ /// The cursor for the pointer at (lx, ly): the app's
+ /// [`Application::cursor_icon`] override, else the standard-CSD edge
+ /// cursors (status bars and non-standard-CSD apps fall back to Default).
+ pub fn cursor_icon_at<A: Application>(&self, app: &A, lx: f32, ly: f32, size: LogicalSize) -> CursorIcon {
+ // Over the context menu the pointer is the menu's,
+ // whatever of the app lies at that place under it (a splitter, a
+ // resize border) — and in their popups that place may be outside
+ // the window altogether.
+ if crate::widget::context_menu::is_visible() && crate::widget::context_menu::hit_test(lx, ly) {
+ return CursorIcon::Default;
+ }
+ if let Some(icon) = app.cursor_icon(lx, ly) {
+ return icon;
+ }
- if app.settings().app_id.starts_with("cce-status")
++ if self.is_status_bar(app)
+ || !app.standard_csd()
+ || !app.csd_resize_borders()
+ {
+ return CursorIcon::Default;
+ }
+ match csd_edge(lx, ly, size) {
+ Some(ResizeEdge::TopLeft) => CursorIcon::NwResize,
+ Some(ResizeEdge::TopRight) => CursorIcon::NeResize,
+ Some(ResizeEdge::Top) => CursorIcon::NResize,
+ Some(ResizeEdge::BottomLeft) => CursorIcon::SwResize,
+ Some(ResizeEdge::BottomRight) => CursorIcon::SeResize,
+ Some(ResizeEdge::Bottom) => CursorIcon::SResize,
+ Some(ResizeEdge::Left) => CursorIcon::WResize,
+ Some(ResizeEdge::Right) => CursorIcon::EResize,
+ None => CursorIcon::Default,
+ }
+ }
+
+ /// The pointer entered at `pos`. Enter carries the pointer's position but
+ /// no motion follows until it actually moves — without this the app's
+ /// hover state is stale from enter to first move, and a press in that
+ /// window can misroute (e.g. a divider press falling through to the
+ /// movable-root plate window drag).
+ pub fn pointer_enter<A: Application>(&mut self, t: Turn<'_, A>, pos: LogicalPosition) {
+ self.pointer_motion(t, pos);
+ }
+
+ /// The pointer moved to `pos`.
+ pub fn pointer_motion<A: Application>(&mut self, t: Turn<'_, A>, pos: LogicalPosition) {
+ let mut rebuild = false;
+ t.app.handle_pointer_move(pos, &mut rebuild);
+ if rebuild {
+ *t.redraw = true;
+ }
+ }
+
+ /// The pointer left the window. Focus can move mid-gesture (a fullscreen
+ /// switch, a relayout sliding the window away): the real release then
+ /// lands on another surface, and an armed drag would live forever. End
+ /// held gestures with synthetic releases at the last known position
+ /// first; then clear hover with an off-screen move — safe now that no
+ /// drag is held.
+ pub fn pointer_leave<A: Application>(&mut self, mut t: Turn<'_, A>) {
+ if self.buttons_down != 0 {
+ let (px, py) = self.cursor_pos;
+ for btn in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
+ if self.buttons_down & button_bit(btn) == 0 {
+ continue;
+ }
+ let mut rebuild = false;
+ let msg = t.app.handle_mouse_input(
+ btn,
+ ElementState::Released,
+ LogicalPosition::new(px, py),
+ &mut rebuild,
+ );
+ t.deliver(msg, rebuild);
+ }
+ self.buttons_down = 0;
+ }
+ let mut rebuild = false;
+ t.app.handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
+ if rebuild {
+ *t.redraw = true;
+ }
+ }
+
+ /// A button went down at `pos`. Returns a grab for the shell to start
+ /// when the press is the window's (a CSD border, the titlebar band, a
+ /// movable root plate); otherwise the press has been dispatched.
+ pub fn pointer_press<A: Application>(
+ &mut self,
+ mut t: Turn<'_, A>,
+ btn: MouseButton,
+ pos: LogicalPosition,
+ site: PressSite,
+ ) -> Press {
+ self.buttons_down |= button_bit(btn);
+ let (lx, ly) = (pos.x, pos.y);
+
+ // Client-side decorations: drag and resize. Never on the menu popup:
+ // its presses are the menu's, and its coordinates, translated into the
+ // window's, would otherwise read as a resize border or a movable plate.
+ if site.can_grab
+ && btn == MouseButton::Left
+ && !site.on_popup
- && !t.app.settings().app_id.starts_with("cce-status")
++ && !self.is_status_bar(t.app)
+ && t.app.standard_csd()
+ {
+ // Resize borders off: the compositor's own band outside the
+ // window handles it; the move checks still run, so drag-to-move
+ // still works.
+ if t.app.csd_resize_borders() && !site.own_edges {
+ if let Some(edge) = csd_edge(lx, ly, site.size) {
+ return Press::Resize(edge);
+ }
+ }
+ // The titlebar band: y in [8, 32), clear of the top-right buttons.
+ let is_widget = t.app.ui_context().is_some_and(|ctx| ctx.is_widget_at(lx, ly));
+ if (!is_widget
+ && t.app.csd_titlebar_move()
+ && ly >= CSD_BORDER
+ && ly < 32.0
+ && lx < site.size.width - 70.0)
+ || t.app.is_movable_root_plate_at(lx, ly)
+ {
+ return Press::Move;
+ }
+ }
+
+ // Outside-press close for open popovers, BEFORE the app's dispatch:
+ // apps commonly region-gate their routing, so an open menu's owner may
+ // never hear about a press elsewhere.
+ if btn == MouseButton::Left {
- let app = &mut *t.app;
- let offsets: Vec<_> = app
- .ui_context()
- .map(|ctx| ctx.popover_owners())
- .unwrap_or_default()
- .into_iter()
- .map(|id| (id, app.popover_offset(id)))
- .collect();
- if let Some(ctx) = app.ui_context_mut() {
- ctx.close_popovers_missed_by_press_with(lx, ly, |id| {
- offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
- });
- }
++ close_popovers_missed_by(t.app, lx, ly);
+ }
+
+ let mut rebuild = false;
+ let msg = t.app.handle_mouse_input(btn, ElementState::Pressed, pos, &mut rebuild);
+ t.deliver(msg, rebuild);
+ Press::Dispatched
+ }
+
+ /// A button came up at `pos`.
+ pub fn pointer_release<A: Application>(&mut self, mut t: Turn<'_, A>, btn: MouseButton, pos: LogicalPosition) {
+ self.buttons_down &= !button_bit(btn);
+ let mut rebuild = false;
+ let msg = t.app.handle_mouse_input(btn, ElementState::Released, pos, &mut rebuild);
+ t.deliver(msg, rebuild);
+ }
+
+ /// One coalesced frame of scrolling at `pos`. Publishes the frame's
+ /// phase (`scroll_motion::set_scroll_phase`) before the app sees it.
+ pub fn scroll<A: Application>(&mut self, t: Turn<'_, A>, frame: ScrollFrame, pos: LogicalPosition) {
+ let ScrollFrame { h, v, discrete_h, discrete_v, source, stop } = frame;
+ // Per-app scroll factors from input.kdl (`<app>`/`cce-ui` domain
+ // `input { }` blocks); the compositor's global device scaling has
+ // already been applied at the source.
+ let factors = crate::input::scroll_factors();
+ let (phase, delta) = scroll_delta(&frame, factors);
+ crate::widget::scroll_motion::set_scroll_phase(phase);
+ if crate::scroll_debug() {
+ static T0: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
+ let ms = T0.get_or_init(Instant::now).elapsed().as_millis();
+ eprintln!(
+ "[scroll {ms}ms] runner: coalesced=({h:.2},{v:.2}) discrete=({discrete_h},{discrete_v}) source={source:?} stop={stop} phase={phase:?} factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({:.0},{:.0})",
+ factors.trackpad, factors.mouse, pos.x, pos.y
+ );
+ }
+ let mut rebuild = false;
+ self.sync_mods(t.app);
+ t.app.handle_mouse_wheel(&delta, pos, &mut rebuild);
+ if rebuild {
+ *t.redraw = true;
+ }
+ }
+
++ /// What a touchscreen finger did, as pointer input (the shell's
++ /// [`TouchTracker`](super::touch::TouchTracker) decides which): a hover
++ /// or drag moves the pointer, a press and release are the left button's,
++ /// and a scroll is a trackpad finger scroll dispatched at `scroll_at`,
++ /// where the finger went down — the gesture belongs to what it began on,
++ /// however far the content moves.
++ ///
++ /// Never a CSD move or resize, and never an app's window action: a touch
++ /// serial does not satisfy the compositor's pointer-grab check, and an
++ /// action left queued would run on the next pointer press with that
++ /// press's serial, so one a touch queued is dropped.
++ pub fn touch<A: Application>(
++ &mut self,
++ mut t: Turn<'_, A>,
++ actions: Vec<super::touch::TouchAction>,
++ scroll_at: Option<(f32, f32)>,
++ ) {
++ use super::touch::TouchAction;
++ for action in actions {
++ let mut rebuild = false;
++ let mut msg = None;
++ match action {
++ TouchAction::Hover(x, y) | TouchAction::Drag(x, y) => {
++ t.app.handle_pointer_move(LogicalPosition::new(x, y), &mut rebuild);
++ }
++ TouchAction::Leave => {
++ t.app.handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
++ }
++ TouchAction::Press(x, y) => {
++ // Outside-press close for open popovers, as the pointer's
++ // press does before the app's own dispatch.
++ close_popovers_missed_by(t.app, x, y);
++ let pos = LogicalPosition::new(x, y);
++ msg = t.app.handle_mouse_input(MouseButton::Left, ElementState::Pressed, pos, &mut rebuild);
++ }
++ TouchAction::Release(x, y) => {
++ let pos = LogicalPosition::new(x, y);
++ msg = t.app.handle_mouse_input(MouseButton::Left, ElementState::Released, pos, &mut rebuild);
++ }
++ TouchAction::Scroll(dx, dy) => self.touch_scroll(t.app, scroll_at, ScrollPhase::Finger, dx, dy, &mut rebuild),
++ TouchAction::ScrollEnd => {
++ self.touch_scroll(t.app, scroll_at, ScrollPhase::FingerEnd, 0.0, 0.0, &mut rebuild)
++ }
++ }
++ t.deliver(msg, rebuild);
++ }
++ let _ = t.app.take_window_action();
++ }
++
++ /// Finger travel as a trackpad pixel scroll. A finger is always
++ /// "natural" — the content goes where it is pushed — so the dispatch runs
++ /// with natural scrolling on whatever the trackpad's setting, and a value
++ /// control (`MouseScrollDelta::value_notches_y`) reads the finger's real
++ /// direction. 1:1, without the trackpad's per-app factor: the content
++ /// stays under the finger.
++ fn touch_scroll<A: Application>(
++ &self,
++ app: &mut A,
++ scroll_at: Option<(f32, f32)>,
++ phase: ScrollPhase,
++ dx: f32,
++ dy: f32,
++ rebuild: &mut bool,
++ ) {
++ let Some((x, y)) = scroll_at else { return };
++ crate::widget::scroll_motion::set_scroll_phase(phase);
++ let delta = MouseScrollDelta::PixelDelta(Position { x: dx as f64, y: dy as f64 });
++ if crate::scroll_debug() {
++ eprintln!("[scroll] touch: phase={phase:?} -> {delta:?} at ({x:.0},{y:.0})");
++ }
++ self.sync_mods(app);
++ crate::input::with_natural_scroll(true, || {
++ app.handle_mouse_wheel(&delta, LogicalPosition::new(x, y), rebuild);
++ });
++ }
++
+ /// A pinch gesture began.
+ pub fn pinch_begin(&mut self) {
+ self.last_pinch_scale = 1.0;
+ }
+
+ /// A pinch gesture ended.
+ pub fn pinch_end(&mut self) {
+ self.last_pinch_scale = 1.0;
+ }
+
+ /// The pinch's cumulative `scale` moved, with the pointer where it last was.
+ pub fn pinch_update<A: Application>(&mut self, t: Turn<'_, A>, scale: f32) {
+ let factor = scale / self.last_pinch_scale;
+ self.last_pinch_scale = scale;
+
+ let (px, py) = self.cursor_pos;
+ let mut rebuild = false;
+
+ // First offer the gesture as-is: apps with true pinch surfaces (the
+ // designer's 3D viewport) consume it here at 1:1 scale instead of
+ // through the wheel synthesis below.
+ if t.app.handle_pinch(factor, LogicalPosition::new(px, py), &mut rebuild) {
+ if rebuild {
+ *t.redraw = true;
+ }
+ return;
+ }
+
+ // Calculate the y_delta for PixelDelta mapping.
+ // Since cce-graph interprets factor = 1.0 + y_delta * 0.015, we reverse it:
+ let y_delta = (factor - 1.0) / 0.015;
+ let delta = MouseScrollDelta::PixelDelta(Position {
+ x: 0.0,
+ y: y_delta as f64,
+ });
+
+ if let Some(ctx) = t.app.ui_context_mut() {
+ ctx.ctrl_pressed = true; // Force ctrl_pressed = true for the pinch event
+ }
+ // A synthesized delta, not a scroll gesture: no glide, no fling.
+ crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Wheel);
+
+ t.app.handle_mouse_wheel(&delta, LogicalPosition::new(px, py), &mut rebuild);
+
+ if let Some(ctx) = t.app.ui_context_mut() {
+ ctx.ctrl_pressed = self.mods.ctrl; // Restore original state
+ }
+
+ if rebuild {
+ *t.redraw = true;
+ }
+ }
+
+ /// The keyboard reported new modifier state.
+ pub fn set_modifiers<A: Application>(&mut self, app: &mut A, mods: Modifiers) {
+ self.mods = mods;
+ self.sync_mods(app);
+ }
+
+ /// The window gained (`true`) or lost keyboard focus. Losing it drops a
+ /// held key and the held modifiers, whose releases go elsewhere.
+ pub fn keyboard_focus<A: Application>(&mut self, t: Turn<'_, A>, focused: bool) {
+ if !focused {
+ self.pressed_key = None;
+ self.mods.ctrl = false;
+ self.mods.shift = false;
+ self.mods.alt = false;
+ }
+ let mut rebuild = false;
+ t.app.handle_focus_change(focused, &mut rebuild);
+ if rebuild {
+ *t.redraw = true;
+ }
+ }
+
+ /// A key went down or up, already in cce-ui's terms: the shell maps its
+ /// window system's key (an xkb keysym here) to `logical_key`, and passes
+ /// the text the key types, if any.
+ pub fn key<A: Application>(
+ &mut self,
+ mut t: Turn<'_, A>,
+ logical_key: Key,
+ text: Option<String>,
+ state: ElementState,
+ ) {
+ let event = KeyEvent {
+ state,
+ logical_key,
+ text,
+ repeat: false,
+ ctrl: self.mods.ctrl,
+ shift: self.mods.shift,
+ alt: self.mods.alt,
+ };
+
+ if state == ElementState::Pressed {
+ if is_repeatable_key(&event.logical_key) {
+ self.pressed_key = Some(PressedKey {
+ logical_key: event.logical_key.clone(),
+ text: event.text.clone(),
+ first_pressed: Instant::now(),
+ last_repeated: Instant::now(),
+ });
+ } else {
+ self.pressed_key = None;
+ }
+ } else if state == ElementState::Released {
+ if let Some(ref pk) = self.pressed_key {
+ if pk.logical_key == event.logical_key {
+ self.pressed_key = None;
+ }
+ }
+ }
+
+ self.sync_mods(t.app);
+
+ // Escape dismisses the shared context menu before app dispatch — the
+ // toolkit-wide default, mirroring the click-outside dismissal. Consumed:
+ // while a menu is open, Escape means "close it", nothing else.
+ if state == ElementState::Pressed
+ && event.logical_key == Key::Named(NamedKey::Escape)
+ && crate::widget::context_menu::is_visible()
+ {
+ crate::widget::context_menu::hide();
+ *t.redraw = true;
+ return;
+ }
+
+ let mut rebuild = false;
+ if self.route_history_chord(t.app, &event, &mut rebuild)
+ || self.route_plate_navigation(t.app, &event, &mut rebuild)
+ {
+ *t.redraw = true;
+ return;
+ }
+ let msg = t.app.handle_key_input(&event, &mut rebuild);
+ t.deliver(msg, rebuild);
+ }
+
+ /// Text an input method committed. It is delivered as TYPED — a press
+ /// of a key whose text it is, then that key's release — so a widget
+ /// that inserts what a key types takes it as it is (see `crate::ime`).
+ /// Never a shortcut (no Ctrl, no Alt, whatever is held: an input method
+ /// commits on its own keys), never repeated, and past the chords: a
+ /// commit of "z" is a "z", not half of an undo.
+ pub fn commit_text<A: Application>(&mut self, t: Turn<'_, A>, text: String) {
+ if text.is_empty() {
+ return;
+ }
+ let mut rebuild = false;
+ for state in [ElementState::Pressed, ElementState::Released] {
+ let event = KeyEvent {
+ state,
+ logical_key: Key::Character(text.clone()),
+ text: (state == ElementState::Pressed).then(|| text.clone()),
+ repeat: false,
+ ctrl: false,
+ shift: self.mods.shift,
+ alt: false,
+ };
+ let msg = t.app.handle_key_input(&event, &mut rebuild);
+ if let Some(msg) = msg {
+ let mut update_rebuild = false;
+ t.app.update(msg, &mut update_rebuild, t.exit);
+ rebuild |= update_rebuild;
+ }
+ }
+ *t.redraw |= rebuild;
+ // The editing widget shows the text it now holds.
+ *t.redraw = true;
+ }
+
+ /// The input method's composition changed (`None`: it ended without a
+ /// commit, or the commit follows). The editing widget shows it from the
+ /// next frame.
+ pub fn preedit<A: Application>(&mut self, t: Turn<'_, A>, preedit: Option<crate::ime::Preedit>) {
+ crate::ime::set_preedit(preedit);
+ *t.redraw = true;
+ }
+
+ /// The runner's key repeat: once a held key has been down
+ /// [`KEY_REPEAT_DELAY`], deliver it again every [`KEY_REPEAT_INTERVAL`].
+ /// Called once per loop turn.
+ pub fn repeat_keys<A: Application>(&mut self, mut t: Turn<'_, A>) {
+ let Some(ref mut pk) = self.pressed_key else { return };
+ let now = Instant::now();
+ if now.duration_since(pk.first_pressed) < KEY_REPEAT_DELAY
+ || now.duration_since(pk.last_repeated) < KEY_REPEAT_INTERVAL
+ {
+ return;
+ }
+ pk.last_repeated = now;
+ let event = KeyEvent {
+ state: ElementState::Pressed,
+ logical_key: pk.logical_key.clone(),
+ text: pk.text.clone(),
+ repeat: true,
+ ctrl: self.mods.ctrl,
+ shift: self.mods.shift,
+ alt: self.mods.alt,
+ };
+
+ self.sync_mods(t.app);
+
+ let mut rebuild = false;
+ if self.route_history_chord(t.app, &event, &mut rebuild)
+ || self.route_plate_navigation(t.app, &event, &mut rebuild)
+ {
+ *t.redraw = true;
+ } else {
+ let msg = t.app.handle_key_input(&event, &mut rebuild);
+ t.deliver(msg, false);
+ }
+ if rebuild {
+ *t.redraw = true;
+ }
+ }
+
+ /// Advance the app by `dt` seconds: its own `tick`, then its retained
+ /// `UiContext`'s (widget tick receivers — e.g. an animating Dropdown
+ /// popover) — but only when the app's own tick did not already tick the
+ /// context this turn. Receivers integrate `dt` (scroll glides, slider
+ /// inertia), so the old "double-ticking is harmless" assumption ran
+ /// every glide at twice its configured rate in apps that tick the
+ /// context themselves.
+ pub fn tick<A: Application>(&mut self, t: Turn<'_, A>, dt: f32) {
+ let mut rebuild = false;
+ let roster_ticks_before = t.app.ui_context_mut().map(|ctx| ctx.tick_count());
+ t.app.tick(dt, &mut rebuild);
+ if rebuild {
+ *t.redraw = true;
+ }
+ if let Some(ctx) = t.app.ui_context_mut() {
+ if Some(ctx.tick_count()) == roster_ticks_before && ctx.tick(dt) {
+ *t.redraw = true;
+ }
+ }
+ }
+
+ /// The toolkit's Tab traversal, for apps that opt in
+ /// (`Application::plate_navigation`): a bare Tab / Shift+Tab press moves
+ /// keyboard focus to the next / previous plate or well. Returns whether it
+ /// moved; otherwise the key is dispatched as usual.
+ fn route_plate_navigation<A: Application>(&self, app: &mut A, event: &KeyEvent, rebuild: &mut bool) -> bool {
+ if event.state != ElementState::Pressed {
+ return false;
+ }
+ // The group jump first (its chords carry ctrl); then a bare Tab.
+ let group_next = crate::widget::match_key_shortcut(event, &self.group_next_chord);
+ let group_prev = !group_next && crate::widget::match_key_shortcut(event, &self.group_prev_chord);
+ let bare_tab = event.logical_key == Key::Named(NamedKey::Tab)
+ && !self.mods.ctrl
+ && !self.mods.alt
+ && !self.mods.logo;
+ if !group_next && !group_prev && !bare_tab {
+ return false;
+ }
+ let reverse = if bare_tab { self.mods.shift } else { group_prev };
+ if !app.plate_navigation() {
+ return false;
+ }
+ let moved = app
+ .ui_context_mut()
+ .is_some_and(|ctx| if bare_tab { ctx.focus_step(reverse) } else { ctx.focus_step_group(reverse) });
+ if moved {
+ app.focus_stepped();
+ *rebuild = true;
+ }
+ moved
+ }
+
+ /// The toolkit-wide undo/redo routing: a press matching the `undo` /
+ /// `redo` chord goes to the focused widget first (`ContextAction::Undo`
+ /// / `Redo` — a text box that is editing steps its own typing), then to
+ /// the app's `Application::undo` / `redo`. Returns whether either took
+ /// it; otherwise the key is dispatched as usual, so an app with its own
+ /// scheme is undisturbed. Runs for repeats too — holding the chord walks
+ /// the history like holding Backspace walks the text.
+ fn route_history_chord<A: Application>(&self, app: &mut A, event: &KeyEvent, rebuild: &mut bool) -> bool {
+ if event.state != ElementState::Pressed {
+ return false;
+ }
+ let undo = crate::widget::match_key_shortcut(event, &self.undo_chord);
+ let redo = !undo && crate::widget::match_key_shortcut(event, &self.redo_chord);
+ if !undo && !redo {
+ return false;
+ }
+ let action = if undo { crate::widget::ContextAction::Undo } else { crate::widget::ContextAction::Redo };
+ if let Some(ctx) = app.ui_context_mut() {
+ if ctx.focused_context_action(action) {
+ *rebuild = true;
+ return true;
+ }
+ }
+ let taken = if undo { app.undo(rebuild) } else { app.redo(rebuild) };
+ if taken {
+ *rebuild = true;
+ }
+ taken
+ }
+}
+
+/// A coalesced scroll frame as the delta the app is handed, and the phase it
+/// belongs to. Smooth-scroll phase: a finger lift is a stop frame (no
+/// delta); finger/continuous sources track 1:1 and may fling on the lift;
+/// everything else is a wheel notch that glides.
+fn scroll_delta(frame: &ScrollFrame, factors: crate::input::ScrollFactors) -> (crate::widget::ScrollPhase, MouseScrollDelta) {
+ let ScrollFrame { h, v, discrete_h, discrete_v, source, stop } = *frame;
+ let no_delta = h == 0.0 && v == 0.0 && discrete_h == 0 && discrete_v == 0;
+ let phase = if stop && no_delta {
+ crate::widget::ScrollPhase::FingerEnd
+ } else if discrete_h == 0
+ && discrete_v == 0
+ && matches!(source, None | Some(ScrollSource::Finger) | Some(ScrollSource::Continuous))
+ {
+ crate::widget::ScrollPhase::Finger
+ } else {
+ crate::widget::ScrollPhase::Wheel
+ };
+ let delta = if discrete_h == 0 && discrete_v == 0 {
+ // Pixel scroll event from touchpad / smooth mouse
+ MouseScrollDelta::PixelDelta(Position {
+ x: -h * factors.trackpad,
+ y: -v * factors.trackpad,
+ })
+ } else {
+ // Discrete scroll event (e.g. wheel clicks)
+ let h_lines = if discrete_h != 0 { discrete_h as f32 } else { h as f32 / 10.0 };
+ let v_lines = if discrete_v != 0 { discrete_v as f32 } else { v as f32 / 10.0 };
+ MouseScrollDelta::LineDelta(-h_lines * factors.mouse as f32, -v_lines * factors.mouse as f32)
+ };
+ (phase, delta)
+}
+
+/// The CSD resize edge under (lx, ly), if the point is within the border.
+fn csd_edge(lx: f32, ly: f32, size: LogicalSize) -> Option<ResizeEdge> {
+ let b = CSD_BORDER;
+ if ly < b {
+ Some(if lx < b {
+ ResizeEdge::TopLeft
+ } else if lx > size.width - b {
+ ResizeEdge::TopRight
+ } else {
+ ResizeEdge::Top
+ })
+ } else if ly > size.height - b {
+ Some(if lx < b {
+ ResizeEdge::BottomLeft
+ } else if lx > size.width - b {
+ ResizeEdge::BottomRight
+ } else {
+ ResizeEdge::Bottom
+ })
+ } else if lx < b {
+ Some(ResizeEdge::Left)
+ } else if lx > size.width - b {
+ Some(ResizeEdge::Right)
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ //! The routing, driven with no window system: a mock app records what
+ //! reaches it.
+ use super::*;
+ use crate::backend::app::{AppSender, WindowSettings};
+
+ #[derive(Debug, Clone, PartialEq)]
+ enum Seen {
+ Move(f32, f32),
+ Button(MouseButton, ElementState, f32, f32),
+ Wheel(MouseScrollDelta),
+ Key(Key, bool),
+ Focus(bool),
+ Undo,
+ Update(u32),
+ }
+
+ struct Mock {
+ seen: Vec<Seen>,
+ csd: bool,
+ takes_undo: bool,
+ /// A press makes this message, which `update` records.
+ press_msg: Option<u32>,
+ }
+
+ impl Application for Mock {
+ type Message = u32;
+ fn create(_: AppSender<u32>) -> Self {
+ unreachable!("built directly")
+ }
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: String::new(),
+ app_id: "mock".into(),
+ width: 400,
+ height: 300,
+ fullscreen: false,
+ min_size: None,
+ }
+ }
+ fn update(&mut self, msg: u32, needs_rebuild: &mut bool, _exit: &mut bool) {
+ self.seen.push(Seen::Update(msg));
+ *needs_rebuild = true;
+ }
+ fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, _: &mut bool) {
+ self.seen.push(Seen::Move(pos.x, pos.y));
+ }
+ fn handle_mouse_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ pos: LogicalPosition,
+ _: &mut bool,
+ ) -> Option<u32> {
+ self.seen.push(Seen::Button(button, state, pos.x, pos.y));
+ if state == ElementState::Pressed { self.press_msg } else { None }
+ }
+ fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {
+ self.seen.push(Seen::Wheel(delta.clone()));
+ }
+ fn handle_key_input(&mut self, event: &KeyEvent, _: &mut bool) -> Option<u32> {
+ self.seen.push(Seen::Key(event.logical_key.clone(), event.repeat));
+ None
+ }
+ fn handle_focus_change(&mut self, focused: bool, _: &mut bool) {
+ self.seen.push(Seen::Focus(focused));
+ }
+ fn undo(&mut self, _: &mut bool) -> bool {
+ self.seen.push(Seen::Undo);
+ self.takes_undo
+ }
+ fn csd_resize_borders(&self) -> bool {
+ self.csd
+ }
+ fn csd_titlebar_move(&self) -> bool {
+ self.csd
+ }
+ }
+
+ fn mock() -> Mock {
+ Mock { seen: Vec::new(), csd: false, takes_undo: false, press_msg: None }
+ }
+
+ /// A driver with known chords, whatever the machine's input.kdl says.
+ fn driver() -> Driver {
+ Driver {
+ undo_chord: "ctrl+z".into(),
+ redo_chord: "ctrl+shift+z".into(),
+ group_next_chord: "ctrl+tab".into(),
+ group_prev_chord: "ctrl+shift+tab".into(),
+ ..Driver::new()
+ }
+ }
+
+ struct Flags {
+ redraw: bool,
+ exit: bool,
+ }
+
+ fn turn<'a>(app: &'a mut Mock, f: &'a mut Flags) -> Turn<'a, Mock> {
+ Turn { app, redraw: &mut f.redraw, exit: &mut f.exit }
+ }
+
+ const SIZE: LogicalSize = LogicalSize { width: 400.0, height: 300.0 };
+
+ fn site(can_grab: bool) -> PressSite {
+ PressSite { size: SIZE, on_popup: false, can_grab, own_edges: false }
+ }
+
+ #[test]
+ fn a_lost_pointer_releases_what_was_held_then_clears_hover() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ let at = LogicalPosition::new(50.0, 60.0);
+ d.cursor_pos = (50.0, 60.0);
+ d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, at, site(false));
+ d.pointer_press(turn(&mut app, &mut f), MouseButton::Middle, at, site(false));
+ assert_eq!(d.buttons_down, 1 | 4);
+ app.seen.clear();
+
+ d.pointer_leave(turn(&mut app, &mut f));
+ assert_eq!(
+ app.seen,
+ vec![
+ Seen::Button(MouseButton::Left, ElementState::Released, 50.0, 60.0),
+ Seen::Button(MouseButton::Middle, ElementState::Released, 50.0, 60.0),
+ Seen::Move(-10000.0, -10000.0),
+ ]
+ );
+ assert_eq!(d.buttons_down, 0);
+ }
+
+ #[test]
+ fn a_press_on_the_border_is_the_windows_only_when_the_shell_can_grab() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ app.csd = true;
+ let corner = LogicalPosition::new(2.0, 2.0);
+ let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, site(true));
+ assert_eq!(r, Press::Resize(ResizeEdge::TopLeft));
+ assert!(app.seen.is_empty(), "a grab never reaches the app: {:?}", app.seen);
+
+ let band = LogicalPosition::new(100.0, 20.0);
+ let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, band, site(true));
+ assert_eq!(r, Press::Move);
+
+ // No grab to start (a layer surface, a shell without grabs): the app's.
+ let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, site(false));
+ assert_eq!(r, Press::Dispatched);
+ assert_eq!(app.seen, vec![Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0)]);
+
+ // A window system that resizes from its own edges: the band is no
+ // grab, and the press is the app's.
+ app.seen.clear();
+ let own = PressSite { own_edges: true, ..site(true) };
+ assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, own), Press::Dispatched);
+ assert_eq!(app.seen, vec![Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0)]);
+ // But the titlebar band still moves the window.
+ assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, band, own), Press::Move);
+
+ // Nor is a press through the menu popup ever a grab.
+ app.seen.clear();
+ let popup = PressSite { on_popup: true, ..site(true) };
+ assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, popup), Press::Dispatched);
+ assert_eq!(app.seen.len(), 1);
+ }
+
+ #[test]
+ fn a_pressed_message_reaches_update_and_asks_for_a_frame() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ app.press_msg = Some(7);
+ d.pointer_press(turn(&mut app, &mut f), MouseButton::Right, LogicalPosition::new(9.0, 9.0), site(true));
+ assert_eq!(app.seen.last(), Some(&Seen::Update(7)));
+ assert!(f.redraw);
+ }
+
+ #[test]
+ fn a_commit_is_typed_text_and_never_a_shortcut_or_a_held_key() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ app.takes_undo = true;
+ // Ctrl held as the input method commits "z": typed, not an undo.
+ d.set_modifiers(&mut app, Modifiers { ctrl: true, ..Modifiers::default() });
+ d.commit_text(turn(&mut app, &mut f), "z".into());
+ d.commit_text(turn(&mut app, &mut f), "日本".into());
+ d.commit_text(turn(&mut app, &mut f), String::new());
+ let typed = |t: &str| Seen::Key(Key::Character(t.into()), false);
+ assert_eq!(app.seen, vec![typed("z"), typed("z"), typed("日本"), typed("日本")]);
+ assert!(d.pressed_key.is_none(), "nothing left held to repeat");
+ assert!(f.redraw);
+ }
+
+ #[test]
+ fn the_undo_chord_goes_to_the_app_hook_before_key_dispatch() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ d.set_modifiers(&mut app, Modifiers { ctrl: true, ..Modifiers::default() });
+ app.takes_undo = true;
+ d.key(turn(&mut app, &mut f), Key::Character("z".into()), None, ElementState::Pressed);
+ assert_eq!(app.seen, vec![Seen::Undo]);
+ assert!(f.redraw);
+
+ // Declined by the app, the key is dispatched as usual.
+ app.seen.clear();
+ app.takes_undo = false;
+ d.key(turn(&mut app, &mut f), Key::Character("z".into()), None, ElementState::Pressed);
+ assert_eq!(app.seen, vec![Seen::Undo, Seen::Key(Key::Character("z".into()), false)]);
+ }
+
+ #[test]
+ fn a_held_key_repeats_after_the_delay_and_stops_on_release() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ let bs = Key::Named(NamedKey::Backspace);
+ d.key(turn(&mut app, &mut f), bs.clone(), None, ElementState::Pressed);
+ app.seen.clear();
+
+ // Inside the delay: nothing.
+ d.repeat_keys(turn(&mut app, &mut f));
+ assert!(app.seen.is_empty());
+
+ // Past it: one repeat per turn that is an interval on.
+ let pk = d.pressed_key.as_mut().unwrap();
+ pk.first_pressed -= KEY_REPEAT_DELAY;
+ pk.last_repeated -= KEY_REPEAT_INTERVAL;
+ d.repeat_keys(turn(&mut app, &mut f));
+ d.repeat_keys(turn(&mut app, &mut f));
+ assert_eq!(app.seen, vec![Seen::Key(bs.clone(), true)]);
+
+ d.key(turn(&mut app, &mut f), bs, None, ElementState::Released);
+ assert!(d.pressed_key.is_none());
+ }
+
+ #[test]
+ fn losing_focus_drops_the_held_key_and_modifiers() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ d.set_modifiers(&mut app, Modifiers { ctrl: true, shift: true, alt: true, logo: true });
+ d.key(turn(&mut app, &mut f), Key::Character("a".into()), Some("a".into()), ElementState::Pressed);
+ assert!(d.pressed_key.is_some());
+
+ d.keyboard_focus(turn(&mut app, &mut f), false);
+ assert!(d.pressed_key.is_none());
+ // Logo is left as it was, as the runner always left it.
+ assert_eq!(d.mods, Modifiers { ctrl: false, shift: false, alt: false, logo: true });
+ assert_eq!(app.seen.last(), Some(&Seen::Focus(false)));
+ }
+
++ #[test]
++ fn a_finger_is_the_left_button_and_never_the_windows() {
++ // Only the tap and the hold: a touch scroll publishes the scroll
++ // phase process-wide, which a parallel suite must not race.
++ use crate::backend::touch::TouchAction::*;
++ let (mut app, mut d) = (mock(), driver());
++ app.csd = true;
++ app.press_msg = Some(7);
++ let mut f = Flags { redraw: false, exit: false };
++ // A tap on the resize border: a click there, not a resize.
++ d.touch(turn(&mut app, &mut f), vec![Hover(2.0, 2.0), Press(2.0, 2.0), Release(2.0, 2.0), Leave], None);
++ assert_eq!(
++ app.seen,
++ vec![
++ Seen::Move(2.0, 2.0),
++ Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0),
++ Seen::Update(7),
++ Seen::Button(MouseButton::Left, ElementState::Released, 2.0, 2.0),
++ Seen::Move(-10000.0, -10000.0),
++ ]
++ );
++ assert!(f.redraw, "the press's message asked for a frame");
++ // A hold, then a drag: the held left button's motion.
++ app.seen.clear();
++ d.touch(turn(&mut app, &mut f), vec![Press(10.0, 10.0), Drag(40.0, 10.0), Release(40.0, 10.0)], None);
++ assert_eq!(app.seen[2], Seen::Move(40.0, 10.0));
++ assert_eq!(d.buttons_down, 0, "a finger holds no pointer button");
++ }
++
+ #[test]
+ fn a_scroll_frame_is_its_phase_and_delta() {
+ // The pure half of `scroll`: the dispatch itself publishes the phase
+ // process-wide, which a parallel suite must not race.
+ use crate::input::ScrollFactors;
+ use crate::widget::ScrollPhase;
+ let unit = ScrollFactors { mouse: 1.0, trackpad: 1.0 };
+ let notch = ScrollFrame { v: 15.0, discrete_v: 1, source: Some(ScrollSource::Wheel), ..ScrollFrame::default() };
+ assert_eq!(scroll_delta(¬ch, unit), (ScrollPhase::Wheel, MouseScrollDelta::LineDelta(-0.0, -1.0)));
+
+ let finger = ScrollFrame { v: 4.0, source: Some(ScrollSource::Finger), ..ScrollFrame::default() };
+ assert_eq!(
+ scroll_delta(&finger, ScrollFactors { mouse: 1.0, trackpad: 2.0 }),
+ (ScrollPhase::Finger, MouseScrollDelta::PixelDelta(Position { x: -0.0, y: -8.0 }))
+ );
+ // No source named is a finger too; a lift with no delta ends it.
+ let unnamed = ScrollFrame { h: 3.0, ..ScrollFrame::default() };
+ assert_eq!(scroll_delta(&unnamed, unit).0, ScrollPhase::Finger);
+ let lift = ScrollFrame { stop: true, source: Some(ScrollSource::Finger), ..ScrollFrame::default() };
+ assert_eq!(scroll_delta(&lift, unit).0, ScrollPhase::FingerEnd);
+ // A tilt wheel, or anything newer, glides like a wheel.
+ let tilt = ScrollFrame { h: 5.0, source: Some(ScrollSource::WheelTilt), ..ScrollFrame::default() };
+ assert_eq!(scroll_delta(&tilt, unit).0, ScrollPhase::Wheel);
+ }
+}
diff --cc src/backend/frame.rs
index 8076adf,0000000..5cedaf9
mode 100644,000000..100644
--- a/src/backend/frame.rs
+++ b/src/backend/frame.rs
@@@ -1,366 -1,0 +1,366 @@@
+//! Building a frame: everything between "the window needs a frame" and "hand
+//! the renderer its data" that knows nothing of the window system. The app is
+//! asked for its display list, damage, custom vertices and overlays; widgets
+//! are shaped against the glyph pass's font system; the list is tessellated
+//! and its text gathered; popovers are collected for the text-occlusion
+//! clamp. What comes out is a [`BuiltFrame`] — plain data the renderer draws
+//! — and a shell presents it however its window system presents.
+//!
+//! The Wayland shell's half (`EngineState::render`) is what is left: the
+//! grid patch and input region, uploading the glyphs, the extent gate and
+//! buffer scale, the frame callback, `stage_renderer` and the draw. Order
+//! between the two halves is the order the frame always had — the app's
+//! damage is taken here even when the shell then skips the present.
+
+use cosmic_text::FontSystem;
+
+use super::app::{Application, LogicalSize};
+use super::tessellate::{quad_vertices, tessellate_display_list, DlBatch, Vertex};
+use super::text::{collect_dl_text, dl_text_spans, TextBounds};
+use crate::draw::{Batch2D, Frame2D, ImageQuad, TextSpan};
- use crate::widget::TextItem;
++use super::text::DlText;
+
+/// One frame, built and owned: the renderer's [`Frame2D`] is a borrow of it
+/// ([`frame2d`](Self::frame2d)). Its display-list text is shaped into the
+/// `text_items` that [`build_frame`] was handed, and lent to the glyph pass
+/// through [`text_spans`](Self::text_spans).
+pub struct BuiltFrame {
+ pub verts: Vec<Vertex>,
+ pub batches: Vec<Batch2D>,
+ /// Drawn after the text pass.
+ pub overlay_verts: Vec<Vertex>,
+ pub images: Vec<ImageQuad>,
+ pub plate_features: Vec<[f32; 12]>,
+ /// Linear, alpha as given.
+ pub clear_color: [f32; 4],
+ /// The part of the surface that changed, physical px; `None` = all of it.
+ pub damage: Option<(u32, u32, u32, u32)>,
+ /// The app's text is the display list's (`Application::display_list_text`).
+ /// When false the app stages the renderer's text itself and the glyph
+ /// pass must be left alone.
+ pub dl_text: bool,
+ /// Popover rects (and the in-window context menu) that page text is
+ /// clamped away from, logical px, in stacking order.
+ overlay_rects: Vec<(f32, f32, f32, f32)>,
+ scale: f32,
+ physical: (u32, u32),
+}
+
+impl BuiltFrame {
+ /// The display-list text as glyph spans, borrowing `items` — the vector
+ /// [`build_frame`] shaped this frame's text into.
- pub fn text_spans<'a>(&self, items: &'a [TextItem]) -> Vec<TextSpan<'a>> {
++ pub fn text_spans<'a>(&self, items: &'a [DlText]) -> Vec<TextSpan<'a>> {
+ let (pw, ph) = self.physical;
+ let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
+ // All text is display-list text: each Text prim with the default
+ // mapping (scale + surface clamp) plus the popover-occlusion clamp
+ // against the app's registered popovers.
+ dl_text_spans(items, self.scale, bounds, &self.overlay_rects)
+ }
+
+ /// The frame as the renderer takes it.
+ pub fn frame2d(&self) -> Frame2D<'_> {
+ Frame2D {
+ verts: &self.verts,
+ batches: &self.batches,
+ overlay_verts: &self.overlay_verts,
+ images: &self.images,
+ plate_features: &self.plate_features,
+ clear_color: self.clear_color,
+ damage: self.damage,
+ }
+ }
+}
+
+/// Build the app's frame at `size` and `scale`.
+///
+/// `damage_owed` is the shell's: true when the last built frame was not
+/// presented, so this one repaints everything. It is set true here; the
+/// shell clears it once a frame is actually presented. `text_items` is where
+/// the display-list text is shaped and held, for [`BuiltFrame::text_spans`].
+pub fn build_frame<A: Application>(
+ app: &mut A,
+ fs: &mut FontSystem,
+ size: LogicalSize,
+ scale: f64,
+ damage_owed: &mut bool,
- text_items: &mut Vec<TextItem>,
++ text_items: &mut Vec<DlText>,
+) -> BuiltFrame {
+ let (logical_w, logical_h) = (size.width, size.height);
+
+ // The editing widget reports its caret as it paints (`ime::report_caret`):
+ // where an input method's candidates go, and whether text is wanted.
+ crate::ime::begin_frame();
+
+ // 0. Shape every registered widget against the SAME FontSystem the glyph pass draws
+ // with, before the app builds its frame. A widget's caret/selection/click→index math
+ // reads per-glyph advances its `prepare_text` records; nothing else calls it on the
+ // display-list path (the paint walk is `&dyn`, and apps were left to remember —
+ // cce-list, cce-secrets, and the reference DemoApp all forgot, so their carets fell
+ // back to `measure_text_width("M")`, an inked extent that drifts off the glyphs).
+ // The flat path shapes in `layout::render_widget`; apps that hand-shape still work —
+ // their call and this one hit the same shaped-buffer cache. Pointers are collected
+ // first so the registry borrow ends before any widget is mutated (the missed-press
+ // walk dereferences the same registry the same way).
+ {
+ let ptrs: Vec<*mut (dyn crate::widget::WidgetHost + 'static)> = app
+ .ui_context()
+ .map(|ctx| ctx.tree.iter_registered().map(|(_, p)| p).collect())
+ .unwrap_or_default();
+ for ptr in ptrs {
+ unsafe {
+ if let Some(w) = ptr.as_mut() {
+ w.prepare_text(fs);
+ }
+ }
+ }
+ }
+
+ // 1. The frame's geometry IS the app's display list — the single paint path. Tessellated
+ // below as one batched, GPU-scissor-clipped pass. An app that draws nothing returns
+ // `None`, giving an empty frame (the legacy view*/tuple-wrapping path is gone).
+ let dl = app
+ .display_list(size, scale)
+ .unwrap_or_else(|| crate::scene::paint::PaintCtx::new().finish());
+ crate::ime::end_frame();
+ // Taken with the display list it describes. A frame that took the
+ // app's damage and then was not presented owes those pixels, so the
+ // next one that is presented repaints everything.
+ let app_damage = app.take_damage(size, scale);
+ let damage = match (app_damage, *damage_owed) {
+ (Some((x, y, w, h)), false) => {
+ // Outward to whole physical pixels, plus one for an
+ // antialiased edge.
+ let s = scale as f32;
+ let x0 = ((x * s).floor() - 1.0).max(0.0);
+ let y0 = ((y * s).floor() - 1.0).max(0.0);
+ let x1 = ((x + w) * s).ceil() + 1.0;
+ let y1 = ((y + h) * s).ceil() + 1.0;
+ Some((x0 as u32, y0 as u32, (x1 - x0).max(0.0) as u32, (y1 - y0).max(0.0) as u32))
+ }
+ _ => None,
+ };
+ *damage_owed = true;
+
+ // 1a. Phase 6 display-list text: shape the list's Text prims through the shared buffer
+ // cache and hold them for the glyph pass (the TextSpans the shell builds borrow these).
+ // Clip = the paint walk's item clip ∩ the prim's own bounds, in logical space.
+ text_items.clear();
+ let dl_text = app.display_list_text();
+ if dl_text {
+ collect_dl_text(fs, &dl, text_items);
+ }
+
+ let (mut verts, mut dl_batches, dl_images, plate_features) =
+ tessellate_display_list(&dl, logical_w, logical_h, scale as f32);
+ // A pending height-field export (`CCE_HEIGHTMAP`, or an app's
+ // `scene::heightfield::request`): the plates of THIS frame, sampled
+ // as the geometry the shader is about to shade.
+ if let Some(req) = crate::scene::heightfield::take_request() {
+ let s = scale as f32;
+ let (pw, ph) = ((logical_w * s).round() as usize, (logical_h * s).round() as usize);
+ let hf = crate::scene::heightfield::HeightField::from_frame(&dl_batches, &plate_features, pw, ph, s);
+ let (lo, hi) = hf.range_px();
+ match crate::scene::heightfield::export_png(&hf, &req.path, req.mm_per_sample) {
+ Ok(()) => log::info!(
+ "[heightfield] wrote {} ({}x{} px, {:.3}..{:.3} mm, metric {})",
+ req.path.display(), pw, ph, lo / hf.px_per_mm, hi / hf.px_per_mm, hf.source.as_str()
+ ),
+ Err(e) => log::warn!("[heightfield] export to {} failed: {e}", req.path.display()),
+ }
+ }
+ // custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
+ let pre_custom = verts.len() as u32;
+ app.custom_vertices(&mut verts, size, scale);
+ if (verts.len() as u32) > pre_custom {
+ dl_batches.push(DlBatch {
+ scissor: None,
+ clip_rrect: None,
+ start: pre_custom,
+ end: verts.len() as u32,
+ plate: None,
+ blur_behind: false,
+ });
+ }
+
+ // 1b. Overlay quads (drawn after the text pass).
+ let mut overlay_quads = Vec::new();
+ app.overlay_quads(&mut overlay_quads, size, scale);
+ let mut overlay_verts = Vec::new();
+ for &(qx, qy, qw, qh, qc) in &overlay_quads {
+ overlay_verts.extend(quad_vertices(qx, qy, qw, qh, logical_w, logical_h, qc));
+ }
+
+ // 2. What page text is clamped away from: the app's open popovers…
+ let scale_f32 = scale as f32;
+ let mut overlay_rects: Vec<(f32, f32, f32, f32)> = Vec::new();
+ if let Some(ctx) = app.ui_context() {
+ for &pop_id in &ctx.active_popovers {
+ if let Some(ptr) = ctx.tree.get_ptr(pop_id) {
+ unsafe {
+ if let Some((x, y, w, h)) = (*ptr).popover_rect() {
+ let (dx, dy) = app.popover_offset(pop_id);
+ overlay_rects.push((x + dx, y + dy, w, h));
+ }
+ }
+ }
+ }
+ }
+ // …and the global context menu, which draws into the app's display list,
+ // so it gets the same occlusion: the menu rect clamps list text beneath,
+ // and the menu's own labels are exempt because they carry bounds equal to
+ // the rect. Hosted in its popup surface, the menu covers the window from
+ // above and nothing of it is in the list.
+ if crate::widget::context_menu::is_visible() && !crate::widget::context_menu::is_hosted() {
+ overlay_rects.push((
+ crate::widget::context_menu::x(),
+ crate::widget::context_menu::y(),
+ crate::widget::context_menu::w(),
+ crate::widget::context_menu::h(),
+ ));
+ }
+
+ // 3. Images and batches in physical px.
+ let images: Vec<ImageQuad> = dl_images
+ .iter()
+ .map(|di| ImageQuad {
+ image: di.image,
+ rect: (
+ di.rect.x * scale_f32,
+ di.rect.y * scale_f32,
+ di.rect.width * scale_f32,
+ di.rect.height * scale_f32,
+ ),
+ alpha: di.alpha,
+ z_before: di.at,
+ clip: di.clip.map(|c| {
+ (
+ (c.x * scale_f32).max(0.0) as u32,
+ (c.y * scale_f32).max(0.0) as u32,
+ (c.width * scale_f32) as u32,
+ (c.height * scale_f32) as u32,
+ )
+ }),
+ })
+ .collect();
+
+ let batches = super::tessellate::dl_batches_2d(&dl_batches, scale_f32);
+
+ let cc = app.clear_color();
+ let clear_color = [cc[0].powf(2.2), cc[1].powf(2.2), cc[2].powf(2.2), cc[3]];
+
+ BuiltFrame {
+ verts,
+ batches,
+ overlay_verts,
+ images,
+ plate_features,
+ clear_color,
+ damage,
+ dl_text,
+ overlay_rects,
+ scale: scale_f32,
+ physical: ((logical_w * scale_f32) as u32, (logical_h * scale_f32) as u32),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ //! The builder with no window and no GPU: a mock app, an empty font
+ //! database, and the frame's data read back.
+ use super::*;
+ use crate::backend::app::{AppSender, LogicalPosition, WindowSettings};
+ use crate::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+
+ #[derive(Default)]
+ struct Mock {
+ damage: Option<(f32, f32, f32, f32)>,
+ custom: usize,
+ overlay: bool,
+ }
+
+ impl Application for Mock {
+ type Message = ();
+ fn create(_: AppSender<()>) -> Self {
+ unreachable!("built directly")
+ }
+ fn settings(&self) -> WindowSettings {
+ WindowSettings { title: String::new(), app_id: "mock".into(), width: 100, height: 50, fullscreen: false, min_size: None }
+ }
+ fn update(&mut self, _: (), _: &mut bool, _: &mut bool) {}
+ fn tick(&mut self, _: f32, _: &mut bool) {}
+ fn handle_pointer_move(&mut self, _: LogicalPosition, _: &mut bool) {}
+ fn handle_mouse_input(&mut self, _: MouseButton, _: ElementState, _: LogicalPosition, _: &mut bool) -> Option<()> {
+ None
+ }
+ fn handle_mouse_wheel(&mut self, _: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {}
+ fn handle_key_input(&mut self, _: &KeyEvent, _: &mut bool) -> Option<()> {
+ None
+ }
+ fn take_damage(&mut self, _: LogicalSize, _: f64) -> Option<(f32, f32, f32, f32)> {
+ self.damage
+ }
+ fn custom_vertices(&mut self, verts: &mut Vec<Vertex>, size: LogicalSize, _: f64) {
+ for _ in 0..self.custom {
+ verts.extend(quad_vertices(0.0, 0.0, 1.0, 1.0, size.width, size.height, [1.0; 4]));
+ }
+ }
+ fn overlay_quads(&mut self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _: LogicalSize, _: f64) {
+ if self.overlay {
+ quads.push((1.0, 2.0, 3.0, 4.0, [1.0; 4]));
+ }
+ }
+ fn clear_color(&self) -> [f32; 4] {
+ [0.5, 0.0, 1.0, 0.25]
+ }
+ }
+
+ fn fonts() -> FontSystem {
+ FontSystem::new_with_locale_and_db("en-US".into(), cosmic_text::fontdb::Database::new())
+ }
+
+ const SIZE: LogicalSize = LogicalSize { width: 100.0, height: 50.0 };
+
+ #[test]
+ fn damage_is_physical_and_owed_until_a_present_clears_it() {
+ let mut app = Mock { damage: Some((10.2, 4.0, 5.0, 3.0)), ..Mock::default() };
+ let (mut fs, mut items) = (fonts(), Vec::new());
+
+ // Nothing owed: the app's rect, outward to whole pixels at 2x plus one.
+ let mut owed = false;
+ let f = build_frame(&mut app, &mut fs, SIZE, 2.0, &mut owed, &mut items);
+ assert_eq!(f.damage, Some((19, 7, 13, 8)));
+ assert!(owed, "a built frame owes its pixels until the shell presents it");
+
+ // The last frame was never presented: this one repaints everything.
+ let f = build_frame(&mut app, &mut fs, SIZE, 2.0, &mut owed, &mut items);
+ assert_eq!(f.damage, None);
+ }
+
+ #[test]
+ fn custom_vertices_are_a_last_unclipped_batch_and_overlays_their_own() {
+ let mut app = Mock { custom: 2, overlay: true, ..Mock::default() };
+ let (mut fs, mut items, mut owed) = (fonts(), Vec::new(), false);
+ let f = build_frame(&mut app, &mut fs, SIZE, 1.0, &mut owed, &mut items);
+ // An empty display list: the custom quads are the whole vertex list,
+ // in one batch on top with no scissor.
+ assert_eq!(f.verts.len(), 12);
+ let last = f.batches.last().expect("a batch for the custom vertices");
+ assert_eq!((last.start, last.end, last.scissor), (0, 12, None));
+ assert_eq!(f.overlay_verts.len(), 6);
+
+ let none = build_frame(&mut Mock::default(), &mut fs, SIZE, 1.0, &mut owed, &mut items);
+ assert!(none.verts.is_empty() && none.batches.is_empty());
+ }
+
+ #[test]
+ fn the_clear_colour_is_linearized_and_its_alpha_kept() {
+ let (mut fs, mut items, mut owed) = (fonts(), Vec::new(), false);
+ let f = build_frame(&mut Mock::default(), &mut fs, SIZE, 1.0, &mut owed, &mut items);
+ let [r, g, b, a] = f.clear_color;
+ assert!((r - 0.5f32.powf(2.2)).abs() < 1e-6 && g == 0.0 && b == 1.0 && a == 0.25);
+ // And the frame lends the renderer exactly what it built.
+ let f2 = f.frame2d();
+ assert_eq!((f2.clear_color, f2.damage), (f.clear_color, f.damage));
+ }
+}
diff --cc src/backend/mod.rs
index 7d0c870,1e1c764..9462179
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@@ -1,27 -1,9 +1,28 @@@
+pub mod app;
+pub mod appkit;
+pub mod dom;
+pub mod driver;
+pub mod frame;
+pub mod shell;
+pub mod tessellate;
+pub mod text;
++pub mod touch;
+// The Wayland shell: native, but for macOS.
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod dnd;
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod menu_popup;
-pub mod touch;
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+pub mod text_input;
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod window_runner;
-pub use window_runner::{
- EngineState, WindowSettings, LogicalPosition, LogicalSize, Application, run,
- Vertex, LineCap, PressedKey, get_text_buffer,
-};
+
+pub use app::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
+pub use driver::PressedKey;
+pub use tessellate::{LineCap, Vertex};
+pub use text::get_text_buffer;
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+pub use window_runner::{run, EngineState};
+#[cfg(target_os = "macos")]
+pub use crate::mac::run;
diff --cc src/backend/text.rs
index be03db4,0000000..0a1fb66
mode 100644,000000..100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@@ -1,528 -1,0 +1,727 @@@
+//! Text shaping for the runner: the shaped-buffer cache, family resolution,
+//! the display list's text prims gathered for the glyph pass, and the
+//! popover-occlusion clamp. Platform-neutral — nothing here knows the window
+//! system; moved out of `window_runner` so another shell can share it.
+
+use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
- use crate::widget::TextItem;
++use std::rc::Rc;
+use crate::draw::TextSpan;
+
- #[derive(Hash, PartialEq, Eq, Clone)]
- struct BufferCacheKey {
- text: String,
++/// One shaped buffer in the text cache, keyed by everything that shapes it
++/// beyond its text (the text is the outer map's key, so a lookup borrows it
++/// rather than allocating).
++struct CachedBuffer {
++ /// Physical font size, in thousandths of a px.
+ size_milli: u32,
++ /// The family as the font string names it, size stripped.
+ font: Option<String>,
+ is_vertical: bool,
+ attrs: crate::scene::paint::TextAttrs,
++ /// The scale factor's bits: a laid-out buffer's wrap width and box are
++ /// logical px turned physical by it.
++ scale_bits: u32,
++ /// `Some` for a boxed [`Prim::Text`](crate::scene::paint::Prim::Text) laid
++ /// out by [`get_text_buffer_laid_out`]; `None` for a single run.
++ layout: Option<crate::scene::paint::TextLayout>,
++ /// The laid-out buffer's vertical offset in its box (0 for a single run).
++ voff: f32,
++ /// Shared, not cloned, on a hit: a `Buffer` owns every shaped line and
++ /// glyph, and the frame used to deep-copy one per text prim per frame.
++ buffer: Rc<Buffer>,
++ /// [`BUFFER_TICK`] at the last hit, for least-recently-used eviction.
++ last_used: u64,
+}
+
- #[derive(Clone)]
- struct CachedBuffer {
- buffer: Buffer,
- last_accessed: web_time::Instant,
++impl CachedBuffer {
++ fn matches(&self, k: &BufferKey<'_>) -> bool {
++ self.size_milli == k.size_milli
++ && self.font.as_deref() == k.font
++ && self.is_vertical == k.is_vertical
++ && self.attrs == k.attrs
++ && self.scale_bits == k.scale_bits
++ && self.layout == k.layout
++ }
++}
++
++/// A cache lookup's key, borrowed from the caller.
++struct BufferKey<'a> {
++ size_milli: u32,
++ font: Option<&'a str>,
++ is_vertical: bool,
++ attrs: crate::scene::paint::TextAttrs,
++ scale_bits: u32,
++ layout: Option<crate::scene::paint::TextLayout>,
+}
+
++/// The text cache holds at most this many buffers; past it the least
++/// recently used [`BUFFER_EVICT`] go.
++const BUFFER_CAP: usize = 2000;
++const BUFFER_EVICT: usize = 100;
++
+std::thread_local! {
- static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<BufferCacheKey, CachedBuffer>> = std::cell::RefCell::new(std::collections::HashMap::new());
++ /// Text → every shaped variant of it. Variants per text are few (a size,
++ /// a weight, a box), so they are scanned rather than hashed.
++ static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<CachedBuffer>>> =
++ std::cell::RefCell::new(std::collections::HashMap::new());
++ static BUFFER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
++ static BUFFER_TICK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
++}
++
++fn buffer_tick() -> u64 {
++ BUFFER_TICK.with(|t| {
++ let n = t.get() + 1;
++ t.set(n);
++ n
++ })
++}
++
++/// The cached buffer for `text` under `key`, and its vertical offset.
++fn buffer_cache_get(text: &str, key: &BufferKey<'_>) -> Option<(Rc<Buffer>, f32)> {
++ BUFFER_CACHE.with(|cache| {
++ let mut cache = cache.borrow_mut();
++ let hit = cache.get_mut(text)?.iter_mut().find(|e| e.matches(key))?;
++ hit.last_used = buffer_tick();
++ Some((Rc::clone(&hit.buffer), hit.voff))
++ })
++}
++
++fn buffer_cache_put(text: &str, key: &BufferKey<'_>, buffer: Rc<Buffer>, voff: f32) {
++ BUFFER_CACHE.with(|cache| {
++ let mut cache = cache.borrow_mut();
++ if BUFFER_COUNT.with(|c| c.get()) >= BUFFER_CAP {
++ let mut ticks: Vec<u64> = cache.values().flatten().map(|e| e.last_used).collect();
++ ticks.sort_unstable();
++ let cutoff = ticks[BUFFER_EVICT.min(ticks.len()) - 1];
++ cache.retain(|_, v| {
++ v.retain(|e| e.last_used > cutoff);
++ !v.is_empty()
++ });
++ BUFFER_COUNT.with(|c| c.set(cache.values().map(Vec::len).sum()));
++ }
++ let entry = CachedBuffer {
++ size_milli: key.size_milli,
++ font: key.font.map(str::to_owned),
++ is_vertical: key.is_vertical,
++ attrs: key.attrs,
++ scale_bits: key.scale_bits,
++ layout: key.layout,
++ voff,
++ buffer,
++ last_used: buffer_tick(),
++ };
++ match cache.get_mut(text) {
++ Some(v) => v.push(entry),
++ None => {
++ cache.insert(text.to_owned(), vec![entry]);
++ }
++ }
++ BUFFER_COUNT.with(|c| c.set(c.get() + 1));
++ });
+}
+
+fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
+ let lower_name = name.to_lowercase();
+ for face in fs.db().faces() {
+ for (family, _) in &face.families {
+ if family.to_lowercase() == lower_name {
+ return Some(family.clone());
+ }
+ }
+ }
+ None
+}
+
+thread_local! {
+ /// Family name → is-monospaced, resolved once per family from fontdb's
+ /// face metadata (the post table's isFixedPitch, as fontdb records it).
+ static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
+ std::cell::RefCell::new(std::collections::HashMap::new());
+}
+
+fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
+ MONO_FAMILY_CACHE.with(|cache| {
+ if let Some(&mono) = cache.borrow().get(name) {
+ return mono;
+ }
+ let lower = name.to_lowercase();
+ let mono = fs
+ .db()
+ .faces()
+ .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
+ .map(|face| face.monospaced)
+ .unwrap_or(false);
+ cache.borrow_mut().insert(name.to_string(), mono);
+ mono
+ })
+}
+
+/// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
+/// shapes `Basic`, everything else `Advanced`.
+///
+/// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
+/// mono face that is exactly right: a mono font's ligatures are the one thing
+/// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
+/// squeezes f+i into a single-advance fi glyph, which is why the bar's window
+/// titles rendered "file" with a cramped fi — while mono faces carry no
+/// kerning to lose. Proportional faces keep `Advanced` (their kerning and
+/// ligatures are wanted — a font preview must not misrepresent the face), and
+/// any non-ASCII text keeps real shaping (combining marks, emoji, complex
+/// scripts) whatever the face.
+pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
+ if text.is_ascii() {
+ if let cosmic_text::Family::Name(name) = family {
+ if family_is_monospaced(fs, name) {
+ return cosmic_text::Shaping::Basic;
+ }
+ }
+ }
+ cosmic_text::Shaping::Advanced
+}
+
+pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
+ get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
+}
+
+/// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
+/// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
+pub fn get_text_buffer_attrs(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+ text_attrs: crate::scene::paint::TextAttrs,
+) -> Buffer {
- let scale = crate::scale::scale_factor();
- let mut font_size = size;
- let mut family_name = None;
++ Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs))
++}
+
- if let Some(font_str) = font {
- let (parsed_family, parsed_size) = crate::layout::parse_font_string(font_str);
- if let Some(ps) = parsed_size {
- font_size = ps;
++/// [`get_text_buffer_attrs`] without the copy: the cached buffer itself,
++/// shared. What the frame and the toolkit's own measuring use — a `Buffer`
++/// owns every shaped line and glyph, so the clone the public functions hand
++/// out costs as much as the text is long.
++pub(crate) fn shared_text_buffer(
++ fs: &mut FontSystem,
++ text: &str,
++ size: f32,
++ font: Option<&str>,
++ text_attrs: crate::scene::paint::TextAttrs,
++) -> Rc<Buffer> {
++ let scale = crate::scale::scale_factor();
++ let (family_name, font_size) = match font {
++ Some(font_str) => {
++ let (family, parsed_size) = crate::layout::split_font_string(font_str);
++ (Some(family), parsed_size.unwrap_or(size))
+ }
- family_name = Some(parsed_family);
- }
++ None => (None, size),
++ };
+
+ let physical_size = font_size * scale;
- let size_key = (physical_size * 1000.0).round() as u32;
+ let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
- let key = BufferCacheKey {
- text: text.to_string(),
- size_milli: size_key,
- font: family_name.clone(),
++ let key = BufferKey {
++ size_milli: (physical_size * 1000.0).round() as u32,
++ font: family_name,
+ is_vertical,
+ attrs: text_attrs,
++ scale_bits: scale.to_bits(),
++ layout: None,
+ };
-
- let cached = BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- if let Some(cached_item) = cache.get_mut(&key) {
- cached_item.last_accessed = web_time::Instant::now();
- Some(cached_item.buffer.clone())
- } else {
- None
- }
- });
-
- if let Some(buf) = cached {
++ if let Some((buf, _)) = buffer_cache_get(text, &key) {
+ return buf;
+ }
+
+ let line_height = if is_vertical {
+ physical_size * 1.05
+ } else {
+ physical_size * 1.0
+ };
+ let metrics = Metrics::new(physical_size, line_height);
+ let mut buf = Buffer::new(fs, metrics);
+ let mut attrs = Attrs::new();
+
+ let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
+
- let resolved_storage = family_name.as_deref().and_then(|font_name| match font_name {
++ let resolved_storage = family_name.and_then(|font_name| match font_name {
+ "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
+ "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
+ "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
+ _ => None,
+ });
+
+ let resolved_sans = if !sans_fallback.is_empty() {
+ find_cased_family(fs, &sans_fallback)
+ } else {
+ None
+ };
+
- let family = if let Some(ref font_family) = family_name {
- match font_family.as_str() {
++ let family = if let Some(font_family) = family_name {
++ match font_family {
+ "monospace" => {
+ if !mono_fallback.is_empty() {
+ if let Some(ref cased) = resolved_storage {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
+ }
+ } else {
+ cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
+ }
+ }
+ "sans-serif" => {
+ if !sans_fallback.is_empty() {
+ if let Some(ref cased) = resolved_storage {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ }
+ "serif" => {
+ if !serif_fallback.is_empty() {
+ if let Some(ref cased) = resolved_storage {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::Serif
+ }
+ } else {
+ cosmic_text::Family::Serif
+ }
+ }
+ name => cosmic_text::Family::Name(name),
+ }
+ } else {
+ if !sans_fallback.is_empty() {
+ if let Some(ref cased) = resolved_sans {
+ cosmic_text::Family::Name(cased)
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ } else {
+ cosmic_text::Family::SansSerif
+ }
+ };
+ attrs = attrs.family(family);
+ if text_attrs.italic {
+ attrs = attrs.style(cosmic_text::Style::Italic);
+ }
+ if let Some(w) = text_attrs.weight {
+ attrs = attrs.weight(cosmic_text::Weight(w));
+ }
+ let shaping = shaping_for(fs, text, &family);
+ buf.set_text(fs, text, attrs, shaping);
+ buf.shape_until_scroll(fs, true);
+
- BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- if cache.len() >= 2000 {
- let mut items: Vec<(BufferCacheKey, web_time::Instant)> = cache
- .iter()
- .map(|(k, v)| (k.clone(), v.last_accessed))
- .collect();
- items.sort_by_key(|&(_, time)| time);
- for (k, _) in items.iter().take(100) {
- cache.remove(k);
- }
- }
- cache.insert(key, CachedBuffer {
- buffer: buf.clone(),
- last_accessed: web_time::Instant::now(),
- });
- });
-
++ let buf = Rc::new(buf);
++ buffer_cache_put(text, &key, Rc::clone(&buf), 0.0);
+ buf
+}
+
+/// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
+/// same buffer cache as the draw, so this is a lookup when the text is already on screen.
+/// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
+/// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
+/// source for caret placement and click→cursor mapping in hand-rolled text fields:
+/// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
+/// resolution, which disagrees with cosmic-text's advance and can even resolve a
+/// different face — a caret placed with it drifts off the drawn glyphs.
+pub fn shaped_cluster_offsets(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+) -> Vec<(usize, f32)> {
+ let scale = crate::scale::scale_factor().max(1.0);
- let buffer = get_text_buffer(fs, text, size, font);
++ let buffer = shared_text_buffer(fs, text, size, font, crate::scene::paint::TextAttrs::default());
+ let mut out: Vec<(usize, f32)> = Vec::new();
+ let mut total: f32 = 0.0;
+ for (start, x, w) in normalized_glyph_starts(&buffer, text) {
+ if out.last().map_or(true, |&(b, _)| b != start) {
+ out.push((start, x / scale));
+ }
+ total = total.max((x + w) / scale);
+ }
+ out.push((text.len(), total));
+ out
+}
+
+/// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
+/// with `start` normalized to be text-relative.
+///
+/// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
+/// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
+/// word — while the Advanced path emits line-relative starts. `shaping_for`
+/// picks Basic exactly for ASCII text in a monospace family (the DE's default
+/// control font), so any multi-word value hit the bug: offsets keyed by those
+/// starts collide on the low columns and the caret/selection walk off the
+/// glyphs. A reset can ONLY come from that path, which shapes strictly one
+/// glyph per char in logical order — so when one is seen, byte starts are
+/// rebuilt by walking the text's chars. `text` must be the single line the
+/// buffer was shaped from.
+pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
+ let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
+ let mut monotonic = true;
+ let mut prev = 0usize;
+ for run in buffer.layout_runs() {
+ for g in run.glyphs {
+ if g.start < prev {
+ monotonic = false;
+ }
+ prev = g.start;
+ glyphs.push((g.start, g.x, g.w));
+ }
+ }
+ if !monotonic {
+ let mut starts = text.char_indices().map(|(i, _)| i);
+ for g in glyphs.iter_mut() {
+ g.0 = starts.next().unwrap_or(text.len());
+ }
+ }
+ glyphs
+}
+
+/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
- /// Reuses [`get_text_buffer_attrs`] for all the family resolution — that returns a *clone* of the
- /// cached single-run buffer, so re-applying metrics/size/align here does not touch the cache — then
- /// re-lays-it-out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
- /// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside the
- /// box per `align_v`. Uncached by construction (each box may differ in width/align).
++/// Starts from [`get_text_buffer_attrs`]'s single run for all the family resolution, then
++/// re-lays it out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
++/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside
++/// the box per `align_v`. Cached beside the single runs, keyed by the box as well.
+pub fn get_text_buffer_laid_out(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+ text_attrs: crate::scene::paint::TextAttrs,
+ layout: crate::scene::paint::TextLayout,
+) -> (Buffer, f32) {
++ let (buf, voff) = shared_laid_out_buffer(fs, text, size, font, text_attrs, layout);
++ (Buffer::clone(&buf), voff)
++}
++
++/// [`get_text_buffer_laid_out`] without the copy, as [`shared_text_buffer`] is to
++/// [`get_text_buffer_attrs`]. Until 2026-10-04 a boxed text was re-shaped from scratch
++/// every frame it was drawn; the box is part of the key now.
++pub(crate) fn shared_laid_out_buffer(
++ fs: &mut FontSystem,
++ text: &str,
++ size: f32,
++ font: Option<&str>,
++ text_attrs: crate::scene::paint::TextAttrs,
++ layout: crate::scene::paint::TextLayout,
++) -> (Rc<Buffer>, f32) {
+ use crate::scene::paint::{AlignH, AlignV};
+ let scale = crate::scale::scale_factor();
+
- // Resolved family + attrs come for free (a cache clone we are free to mutate).
- let mut buf = get_text_buffer_attrs(fs, text, size, font, text_attrs);
-
- // The font string may override the size ("family:size") — mirror get_text_buffer_attrs.
- let mut font_size = size;
- if let Some(font_str) = font {
- if let (_, Some(ps)) = crate::layout::parse_font_string(font_str) {
- font_size = ps;
++ // The font string may override the size ("family:size") — mirror shared_text_buffer.
++ let (family, font_size) = match font {
++ Some(font_str) => {
++ let (family, parsed_size) = crate::layout::split_font_string(font_str);
++ (Some(family), parsed_size.unwrap_or(size))
+ }
- }
++ None => (None, size),
++ };
+ let physical_size = font_size * scale;
++ let key = BufferKey {
++ size_milli: (physical_size * 1000.0).round() as u32,
++ font: family,
++ is_vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
++ attrs: text_attrs,
++ scale_bits: scale.to_bits(),
++ layout: Some(layout),
++ };
++ if let Some(hit) = buffer_cache_get(text, &key) {
++ return hit;
++ }
++
++ // Resolved family + attrs come from the single run; this copy is ours to re-lay-out.
++ let mut buf = Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs));
+ let line_height = physical_size * 1.4;
+ buf.set_metrics(fs, Metrics::new(physical_size, line_height));
+ buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
+
+ let align = match layout.align_h {
+ AlignH::Left => cosmic_text::Align::Left,
+ AlignH::Center => cosmic_text::Align::Center,
+ AlignH::Right => cosmic_text::Align::Right,
+ };
+ for line in &mut buf.lines {
+ line.set_align(Some(align));
+ }
+ buf.shape_until_scroll(fs, true);
+
+ // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
+ let runs = buf.layout_runs().count();
+ let total_h = runs as f32 * font_size * 1.4;
+ let voff = match layout.align_v {
+ AlignV::Top => 0.0,
+ AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
+ AlignV::Bottom => (layout.box_height - total_h).max(0.0),
+ };
++ let buf = Rc::new(buf);
++ buffer_cache_put(text, &key, Rc::clone(&buf), voff);
+ (buf, voff)
+}
+
+/// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
+/// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
+/// it is defined here now that the dependency is cosmic-text directly. Same plain
+/// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TextBounds {
+ pub left: i32,
+ pub top: i32,
+ pub right: i32,
+ pub bottom: i32,
+}
+
++/// A display-list text prim ready for the glyph pass: a [`TextItem`](crate::widget::TextItem) whose
++/// buffer is the cache's own, shared rather than copied. Public so a shell
++/// outside the crate can hold what [`build_frame`](super::frame::build_frame)
++/// fills; its fields are the frame's own.
++pub struct DlText {
++ pub(crate) buffer: Rc<Buffer>,
++ pub(crate) x: f32,
++ pub(crate) y: f32,
++ pub(crate) color: cosmic_text::Color,
++ pub(crate) bounds: Option<[f32; 4]>,
++ pub(crate) clip_circle: Option<[f32; 3]>,
++ pub(crate) clip_rrect: Option<[f32; 5]>,
++}
++
+/// The display list's Text prims, shaped through the shared buffer cache and
+/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
+/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
+/// logical space. Shared by the window's frame and the context-menu popup's.
- pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<TextItem>) {
++pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<DlText>) {
+ for item in &dl.items {
+ if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
+ let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
+ let merged = match (clip, *bounds) {
+ (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
+ (Some(a), None) => Some(a),
+ (None, b) => b,
+ };
- // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
- // offset; ordinary labels take the shared cached buffer.
++ // Boxed text (wrap/align) is laid out in its box and shifts down by the
++ // vertical offset; ordinary labels are a single run. Both cached.
+ let (buffer, y_off) = match layout {
- Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
- None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
++ Some(l) => shared_laid_out_buffer(fs, text, *font_size, font.as_deref(), *attrs, *l),
++ None => (shared_text_buffer(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
+ };
- out.push(TextItem {
++ out.push(DlText {
+ buffer,
+ x: *x,
+ y: *y + y_off,
+ color: cosmic_text::Color::rgba(
+ color[0],
+ color[1],
+ color[2],
+ (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
+ ),
+ bounds: merged,
+ clip_circle: item.clip_circle,
+ clip_rrect: item.clip_rrect,
+ });
+ }
+ }
+}
+
+/// The glyph pass's spans for `items`: each clamped to the surface and its
+/// own bounds, then by the popover-occlusion clamp against `overlays`.
+pub(crate) fn dl_text_spans<'a>(
- items: &'a [TextItem],
++ items: &'a [DlText],
+ scale_f32: f32,
+ bounds: TextBounds,
+ overlays: &[(f32, f32, f32, f32)],
+) -> Vec<TextSpan<'a>> {
+ let mut spans: Vec<TextSpan<'a>> = Vec::new();
+ for ti in items {
+ let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
+ TextBounds {
+ left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
+ top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
+ right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
+ bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
+ }
+ } else {
+ bounds
+ };
+ popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
+ spans.push(TextSpan {
+ buffer: &ti.buffer,
+ left: (ti.x * scale_f32).round(),
+ top: (ti.y * scale_f32).round(),
+ // Buffers are shaped at physical size (get_text_buffer_attrs).
+ scale: 1.0,
+ bounds: Some([
+ item_bounds.left,
+ item_bounds.top,
+ item_bounds.right,
+ item_bounds.bottom,
+ ]),
+ default_color: [
+ ti.color.r() as f32 / 255.0,
+ ti.color.g() as f32 / 255.0,
+ ti.color.b() as f32 / 255.0,
+ ti.color.a() as f32 / 255.0,
+ ],
+ rotation: None,
+ // Circle wins when both are set (the circular pane's innermost clip);
+ // otherwise a rounded-rect clip rides as center+radius with extents.
+ clip_circle: match (ti.clip_circle, ti.clip_rrect) {
+ (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
+ (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
+ (None, None) => [0.0; 3],
+ },
+ clip_extents: match (ti.clip_circle, ti.clip_rrect) {
+ (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
+ _ => [0.0; 2],
+ },
+ });
+ }
+ spans
+}
+
+/// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
+/// the display-list text path: clip a text item's bounds so it does not bleed through an open
+/// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
+/// text and is left alone; anything else that intersects gets clamped horizontally toward
+/// whichever side of the popover it starts on.
+/// Clamp a text item's bounds away from the registered popover rects it
+/// runs under, so page text does not bleed through a floating plate.
+///
+/// A text item BELONGS to a popover when it carries exactly that popover's
+/// rect as its bounds (the convention every popover's own labels follow),
+/// and it is then clamped only against the popovers registered AFTER its
+/// own — `overlay_rects` is in stacking order, the shared context menu
+/// last. Before 2026-09-22 a popover's text was exempt from its own rect
+/// alone and clamped against every other, so a context menu opened over a
+/// modal dialog had its labels clipped by the dialog it was drawn on top
+/// of, and showed as a plate with no legible entries.
+fn popover_occlusion_clamp(
+ overlay_rects: &[(f32, f32, f32, f32)],
- ti: &TextItem,
++ ti: &DlText,
+ scale_f32: f32,
+ item_bounds: &mut TextBounds,
+) {
+ let owner = ti.bounds.and_then(|[l, t, r, b]| {
+ overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
+ (l - ox).abs() < 1.0
+ && (t - oy).abs() < 1.0
+ && (r - (ox + ow)).abs() < 1.0
+ && (b - (oy + oh)).abs() < 1.0
+ })
+ });
+ let first_above = owner.map_or(0, |k| k + 1);
+ for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
+ let ol = (ox * scale_f32).round() as i32;
+ let ot = (oy * scale_f32).round() as i32;
+ let or = ((ox + ow) * scale_f32).round() as i32;
+ let ob = ((oy + oh) * scale_f32).round() as i32;
+
+ let tx_pixel = ti.x * scale_f32;
+ let ty_pixel = ti.y * scale_f32;
+
+ let mut text_w = 0.0f32;
+ let mut run_count = 0;
+ for run in ti.buffer.layout_runs() {
+ text_w = text_w.max(run.line_w);
+ run_count += 1;
+ }
+ let text_h = run_count as f32 * ti.buffer.metrics().line_height;
+
+ let actual_left = tx_pixel;
+ let actual_right = tx_pixel + text_w;
+ let actual_top = ty_pixel;
+ let actual_bottom = ty_pixel + text_h;
+
+ if actual_left < or as f32
+ && actual_right > ol as f32
+ && actual_top < ob as f32
+ && actual_bottom > ot as f32
+ {
+ if tx_pixel < ol as f32 {
+ item_bounds.right = item_bounds.right.min(ol);
+ } else {
+ item_bounds.left = item_bounds.left.max(or);
+ }
+ }
+ }
+}
++
++#[cfg(test)]
++mod text_cache_tests {
++ use super::*;
++ use crate::scene::paint::{AlignH, AlignV, TextAttrs, TextLayout};
++
++ fn boxed(wrap: f32) -> TextLayout {
++ TextLayout { wrap_width: Some(wrap), box_height: 80.0, align_h: AlignH::Center, align_v: AlignV::Middle }
++ }
++
++ /// A hit hands back the cached buffer itself: the frame used to deep-copy
++ /// every text prim's shaped buffer, every frame.
++ #[test]
++ fn a_hit_shares_the_buffer_rather_than_copying_it() {
++ let mut fs = crate::geometry_font_system().lock().unwrap();
++ let attrs = TextAttrs::default();
++ let a = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
++ let b = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
++ assert!(Rc::ptr_eq(&a, &b));
++ let bold = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), TextAttrs { weight: Some(700), ..attrs });
++ assert!(!Rc::ptr_eq(&a, &bold), "attrs are part of the key");
++ let sized = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace 18"), attrs);
++ assert!(!Rc::ptr_eq(&a, &sized), "a size in the font string is part of the key");
++ }
++
++ /// Boxed text is cached by its box, and a hit is what a fresh layout of
++ /// the same box would be.
++ #[test]
++ fn a_laid_out_buffer_is_cached_by_its_box() {
++ let mut fs = crate::geometry_font_system().lock().unwrap();
++ let text = "a line long enough to wrap inside a narrow box";
++ let attrs = TextAttrs::default();
++ let (a, va) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
++ let (b, vb) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
++ assert!(Rc::ptr_eq(&a, &b));
++ assert_eq!(va, vb);
++ let (wide, _) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(400.0));
++ assert!(!Rc::ptr_eq(&a, &wide), "a different box is a different layout");
++ let single = shared_text_buffer(&mut fs, text, 14.0, None, attrs);
++ assert!(!Rc::ptr_eq(&a, &single), "a box never answers for the single run");
++
++ // The cached layout against one shaped from nothing.
++ BUFFER_CACHE.with(|c| c.borrow_mut().clear());
++ BUFFER_COUNT.with(|c| c.set(0));
++ let (fresh, vf) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
++ assert!(!Rc::ptr_eq(&a, &fresh));
++ assert_eq!(va, vf);
++ let runs = |b: &Buffer| b.layout_runs().map(|r| (r.line_y, r.line_w, r.glyphs.len())).collect::<Vec<_>>();
++ assert_eq!(runs(&a), runs(&fresh));
++ }
++
++ /// The cache holds at most `BUFFER_CAP` buffers, and eviction takes the
++ /// least recently used, not the oldest inserted.
++ #[test]
++ fn eviction_keeps_the_cap_and_the_recently_used() {
++ BUFFER_CACHE.with(|c| c.borrow_mut().clear());
++ BUFFER_COUNT.with(|c| c.set(0));
++ let key = |size_milli| BufferKey {
++ size_milli,
++ font: None,
++ is_vertical: false,
++ attrs: TextAttrs::default(),
++ scale_bits: 1.0f32.to_bits(),
++ layout: None,
++ };
++ let empty = || Rc::new(Buffer::new_empty(Metrics::new(10.0, 10.0)));
++ for i in 0..BUFFER_CAP as u32 {
++ buffer_cache_put(&format!("t{i}"), &key(i), empty(), 0.0);
++ }
++ // The first inserted is touched, so it is no longer the least recent.
++ assert!(buffer_cache_get("t0", &key(0)).is_some());
++ buffer_cache_put("over", &key(0), empty(), 0.0);
++ let count = BUFFER_CACHE.with(|c| c.borrow().values().map(Vec::len).sum::<usize>());
++ assert_eq!(count, BUFFER_CAP - BUFFER_EVICT + 1);
++ assert_eq!(BUFFER_COUNT.with(|c| c.get()), count);
++ assert!(buffer_cache_get("t0", &key(0)).is_some(), "recently used survives");
++ assert!(buffer_cache_get("t1", &key(1)).is_none(), "least recently used goes");
++ assert!(buffer_cache_get("over", &key(0)).is_some());
++ }
++}
diff --cc src/backend/touch.rs
index 0000000,06042d2..78ba7b4
mode 000000,100644..100644
--- a/src/backend/touch.rs
+++ b/src/backend/touch.rs
@@@ -1,0 -1,390 +1,327 @@@
+ // Touchscreen input (wl_touch).
+ //
+ // A cce-ui app has no touch widgets: everything it knows is a pointer that
+ // hovers, presses, drags and scrolls. So a finger is translated into those,
+ // and which one it becomes is decided by what the finger does, the way every
+ // touch surface decides it:
+ //
+ // - a TAP (down and up without leaving the slop) is a click where it landed;
+ // - a finger that MOVES past the slop scrolls whatever is under it, content
+ // following the finger, and the lift is a trackpad lift — a flick coasts
+ // through the same `ScrollMotion` a two-finger trackpad swipe does;
+ // - a finger HELD still for `HOLD_MS` and then moved is a held left button:
+ // a slider's thumb, a text selection, a drag-and-drop source.
+ //
+ // No timer is needed for the hold: nothing is sent while the finger rests
+ // inside the slop, so the decision is only taken at the first motion past
+ // it, from how long the finger had been down by then.
+ //
+ // Only the first finger is followed; others are ignored until it lifts.
+ // Before this module the compositor drove every cce-ui window by touch with
+ // an emulated pointer, so a finger drag was a held button and selected
+ // rather than scrolled. Binding wl_touch is what moves cce-ui windows onto
+ // the compositor's touch route (cce-compositor's `cursor::TouchRoute`).
+ //
+ // Not here: window moves and CSD resizes by finger. A touch serial does not
+ // satisfy the compositor's pointer-grab check, so `xdg_toplevel.move` from a
+ // touch would be refused. Overview and the Super-held adjust mode, where the
+ // compositor drives the pointer itself, move windows by finger instead.
-
-use smithay_client_toolkit::reexports::client::protocol::{wl_surface::WlSurface, wl_touch::WlTouch};
-use smithay_client_toolkit::reexports::client::{Connection, QueueHandle};
-use smithay_client_toolkit::seat::touch::TouchHandler;
-
-use super::window_runner::{Application, EngineState, LogicalPosition};
-use crate::widget::{ElementState, MouseButton, MouseScrollDelta, Position, ScrollPhase};
++//
++// The tracker is platform-neutral, and what its actions do is the driver's
++// (`Driver::touch`); only the `wl_touch` binding below is Wayland's.
+
+ /// Travel (logical px) a finger may wander and still be a tap or a hold.
+ pub const SLOP: f32 = 10.0;
+ /// How long a finger must rest before moving for the motion to be a drag
+ /// rather than a scroll.
+ pub const HOLD_MS: u32 = 400;
+
+ /// What a touch asks of the app, in pointer terms.
+ #[derive(Debug, Clone, Copy, PartialEq)]
+ pub enum TouchAction {
+ /// Move the hover to here (the finger arrived; nothing pressed yet).
+ Hover(f32, f32),
+ Press(f32, f32),
+ /// Motion with the button held.
+ Drag(f32, f32),
+ Release(f32, f32),
+ /// Scroll by this much finger travel, content following the finger.
+ Scroll(f32, f32),
+ /// The scrolling finger lifted: may coast.
+ ScrollEnd,
+ /// The finger is gone; no hover is left behind.
+ Leave,
+ }
+
+ #[derive(Debug, Clone, Copy, PartialEq)]
+ enum Phase {
+ /// Down, inside the slop: still a tap or a hold.
+ Pending,
+ Scrolling,
+ Dragging,
+ }
+
+ #[derive(Debug, Clone, Copy)]
+ struct Finger {
+ id: i32,
+ phase: Phase,
+ start: (f32, f32),
+ last: (f32, f32),
+ down_ms: u32,
+ }
+
+ /// The gesture of the one finger being followed. Pure: it maps touch events
+ /// to `TouchAction`s and holds no Wayland state, so it is tested directly.
+ #[derive(Debug, Default)]
+ pub struct TouchTracker {
+ finger: Option<Finger>,
+ }
+
+ impl TouchTracker {
+ pub fn down(&mut self, id: i32, x: f32, y: f32, time_ms: u32) -> Vec<TouchAction> {
+ if self.finger.is_some() {
+ return Vec::new();
+ }
+ self.finger = Some(Finger { id, phase: Phase::Pending, start: (x, y), last: (x, y), down_ms: time_ms });
+ vec![TouchAction::Hover(x, y)]
+ }
+
+ pub fn motion(&mut self, id: i32, x: f32, y: f32, time_ms: u32) -> Vec<TouchAction> {
+ let Some(f) = self.finger.as_mut().filter(|f| f.id == id) else { return Vec::new() };
+ let prev = f.last;
+ f.last = (x, y);
+ match f.phase {
+ Phase::Pending => {
+ let (dx, dy) = (x - f.start.0, y - f.start.1);
+ if dx.hypot(dy) < SLOP {
+ return Vec::new();
+ }
+ if time_ms.wrapping_sub(f.down_ms) >= HOLD_MS {
+ f.phase = Phase::Dragging;
+ vec![TouchAction::Press(f.start.0, f.start.1), TouchAction::Drag(x, y)]
+ } else {
+ // The whole travel from the down point, not just past the
+ // slop: the content stays under the finger.
+ f.phase = Phase::Scrolling;
+ vec![TouchAction::Scroll(dx, dy)]
+ }
+ }
+ Phase::Scrolling => {
+ let (dx, dy) = (x - prev.0, y - prev.1);
+ if dx == 0.0 && dy == 0.0 {
+ Vec::new()
+ } else {
+ vec![TouchAction::Scroll(dx, dy)]
+ }
+ }
+ Phase::Dragging => vec![TouchAction::Drag(x, y)],
+ }
+ }
+
+ pub fn up(&mut self, id: i32) -> Vec<TouchAction> {
+ if self.finger.map_or(true, |f| f.id != id) {
+ return Vec::new();
+ }
+ let f = self.finger.take().unwrap();
+ match f.phase {
+ Phase::Pending => vec![
+ TouchAction::Press(f.start.0, f.start.1),
+ TouchAction::Release(f.start.0, f.start.1),
+ TouchAction::Leave,
+ ],
+ Phase::Scrolling => vec![TouchAction::ScrollEnd, TouchAction::Leave],
+ Phase::Dragging => vec![TouchAction::Release(f.last.0, f.last.1), TouchAction::Leave],
+ }
+ }
+
+ /// The compositor took the sequence back (`wl_touch.cancel`). A tap that
+ /// never became anything clicks nothing; a held button is released where
+ /// it is, as a pointer leaving mid-drag is (`PointerEventKind::Leave`).
+ pub fn cancel(&mut self) -> Vec<TouchAction> {
+ let Some(f) = self.finger.take() else { return Vec::new() };
+ match f.phase {
+ Phase::Pending => vec![TouchAction::Leave],
+ Phase::Scrolling => vec![TouchAction::ScrollEnd, TouchAction::Leave],
+ Phase::Dragging => vec![TouchAction::Release(f.last.0, f.last.1), TouchAction::Leave],
+ }
+ }
+
+ pub fn id(&self) -> Option<i32> {
+ self.finger.map(|f| f.id)
+ }
+ }
+
-impl<A: Application> EngineState<A> {
- /// A surface-local touch position in the app's window coordinates: the
- /// forced-scale divide and the menu popup's offset, as the pointer path
- /// applies them.
- fn touch_pos(&self, (x, y): (f64, f64)) -> (f32, f32) {
- let forced = crate::scale::forced_scale().unwrap_or(1.0);
- let (ox, oy) = self.touch_offset;
- (x as f32 / forced + ox, y as f32 / forced + oy)
- }
++/// The `wl_touch` binding: a finger's events, in the window's coordinates,
++/// through the tracker and on to the driver.
++#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
++mod wayland {
++ use smithay_client_toolkit::reexports::client::protocol::{wl_surface::WlSurface, wl_touch::WlTouch};
++ use smithay_client_toolkit::reexports::client::{Connection, QueueHandle};
++ use smithay_client_toolkit::seat::touch::TouchHandler;
+
- /// The seat stopped offering touch: end the finger's gesture as a
- /// cancel would, and drop the object.
- pub(crate) fn touch_lost(&mut self) {
- if let Some(touch) = self.touch.take() {
- touch.release();
- }
- self.cancel_touch();
- }
++ use super::TouchAction;
++ use crate::backend::shell::Shell;
++ use crate::backend::window_runner::{Application, EngineState};
+
- fn cancel_touch(&mut self) {
- let actions = self.touch_tracker.cancel();
- self.run_touch_actions(actions);
- self.touch_scroll_at = None;
- }
++ impl<A: Application> EngineState<A> {
++ /// A surface-local touch position in the app's window coordinates: the
++ /// forced-scale divide and the menu popup's offset, as the pointer path
++ /// applies them.
++ fn touch_pos(&self, (x, y): (f64, f64)) -> (f32, f32) {
++ let forced = crate::scale::forced_scale().unwrap_or(1.0);
++ let (ox, oy) = self.touch_offset;
++ (x as f32 / forced + ox, y as f32 / forced + oy)
++ }
+
- fn run_touch_actions(&mut self, actions: Vec<TouchAction>) {
- for action in actions {
- let mut rebuild = false;
- match action {
- TouchAction::Hover(x, y) | TouchAction::Drag(x, y) => {
- self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(x, y), &mut rebuild);
- }
- TouchAction::Leave => {
- self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
- }
- TouchAction::Press(x, y) => {
- // Outside-press close for open popovers, as the pointer's
- // press does before the app's own dispatch.
- let app = self.inner.as_mut().unwrap();
- let offsets: Vec<_> = app
- .ui_context()
- .map(|ctx| ctx.popover_owners())
- .unwrap_or_default()
- .into_iter()
- .map(|id| (id, app.popover_offset(id)))
- .collect();
- if let Some(ctx) = app.ui_context_mut() {
- ctx.close_popovers_missed_by_press_with(x, y, |id| {
- offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
- });
- }
- self.touch_button(ElementState::Pressed, x, y, &mut rebuild);
- }
- TouchAction::Release(x, y) => self.touch_button(ElementState::Released, x, y, &mut rebuild),
- TouchAction::Scroll(dx, dy) => {
- self.touch_wheel(ScrollPhase::Finger, dx, dy, &mut rebuild);
- }
- TouchAction::ScrollEnd => self.touch_wheel(ScrollPhase::FingerEnd, 0.0, 0.0, &mut rebuild),
- }
- if rebuild {
- self.redraw = true;
++ /// The seat stopped offering touch: end the finger's gesture as a
++ /// cancel would, and drop the object.
++ pub(crate) fn touch_lost(&mut self) {
++ if let Some(touch) = self.touch.take() {
++ touch.release();
+ }
++ self.cancel_touch();
+ }
- // A press may queue an app-driven window move; it cannot be honoured
- // from a touch (see the module comment), and left queued it would
- // run on the next pointer press with that press's serial.
- let _ = self.inner.as_mut().unwrap().take_window_action();
- }
+
- fn touch_button(&mut self, state: ElementState, x: f32, y: f32, rebuild: &mut bool) {
- let app = self.inner.as_mut().unwrap();
- if let Some(msg) = app.handle_mouse_input(MouseButton::Left, state, LogicalPosition::new(x, y), rebuild) {
- let mut update_rebuild = false;
- app.update(msg, &mut update_rebuild, &mut self.exit);
- *rebuild |= update_rebuild;
++ fn cancel_touch(&mut self) {
++ let actions = self.touch_tracker.cancel();
++ self.run_touch_actions(actions);
++ self.touch_scroll_at = None;
+ }
- }
+
- /// Finger travel as a trackpad pixel scroll. A finger is always
- /// "natural" — the content goes where it is pushed — so the dispatch runs
- /// with natural scrolling on whatever the trackpad's setting, and a value
- /// control (`MouseScrollDelta::value_notches_y`) reads the finger's real
- /// direction. 1:1, without the trackpad's per-app factor: the content
- /// stays under the finger.
- fn touch_wheel(&mut self, phase: ScrollPhase, dx: f32, dy: f32, rebuild: &mut bool) {
- let Some((x, y)) = self.touch_scroll_at else { return };
- crate::widget::scroll_motion::set_scroll_phase(phase);
- let delta = MouseScrollDelta::PixelDelta(Position { x: dx as f64, y: dy as f64 });
- if crate::scroll_debug() {
- eprintln!("[scroll] touch: phase={phase:?} -> {delta:?} at ({x:.0},{y:.0})");
++ fn run_touch_actions(&mut self, actions: Vec<TouchAction>) {
++ let scroll_at = self.touch_scroll_at;
++ let (driver, t) = self.turn();
++ driver.touch(t, actions, scroll_at);
+ }
- if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
- ctx.ctrl_pressed = self.ctrl_pressed;
- ctx.shift_pressed = self.shift_pressed;
- ctx.alt_pressed = self.alt_pressed;
- ctx.logo_pressed = self.logo_pressed;
- }
- let app = self.inner.as_mut().unwrap();
- crate::input::with_natural_scroll(true, || {
- app.handle_mouse_wheel(&delta, LogicalPosition::new(x, y), rebuild);
- });
+ }
-}
+
-impl<A: Application> TouchHandler for EngineState<A> {
- fn down(
- &mut self,
- _conn: &Connection,
- _qh: &QueueHandle<Self>,
- _touch: &WlTouch,
- _serial: u32,
- time: u32,
- surface: WlSurface,
- id: i32,
- position: (f64, f64),
- ) {
- if self.touch_tracker.id().is_some() {
- return;
++ impl<A: Application> TouchHandler for EngineState<A> {
++ fn down(
++ &mut self,
++ _conn: &Connection,
++ _qh: &QueueHandle<Self>,
++ _touch: &WlTouch,
++ _serial: u32,
++ time: u32,
++ surface: WlSurface,
++ id: i32,
++ position: (f64, f64),
++ ) {
++ if self.touch_tracker.id().is_some() {
++ return;
++ }
++ // An event on the context menu's popup surface is the app's too, at
++ // the popup's offset from the window; fixed for the finger's life,
++ // since every later event of it is about the same surface.
++ self.touch_offset = self.menu_popup_offset(&surface).unwrap_or((0.0, 0.0));
++ let (x, y) = self.touch_pos(position);
++ // A scroll is dispatched where the finger went down: the gesture
++ // belongs to what it began on, as a trackpad scroll does
++ // (`scroll_initiate_widget_id`), however far the content moves.
++ self.touch_scroll_at = Some((x, y));
++ let actions = self.touch_tracker.down(id, x, y, time);
++ self.run_touch_actions(actions);
+ }
- // An event on the context menu's popup surface is the app's too, at
- // the popup's offset from the window; fixed for the finger's life,
- // since every later event of it is about the same surface.
- self.touch_offset = self.menu_popup_offset(&surface).unwrap_or((0.0, 0.0));
- let (x, y) = self.touch_pos(position);
- // A scroll is dispatched where the finger went down: the gesture
- // belongs to what it began on, as a trackpad scroll does
- // (`scroll_initiate_widget_id`), however far the content moves.
- self.touch_scroll_at = Some((x, y));
- let actions = self.touch_tracker.down(id, x, y, time);
- self.run_touch_actions(actions);
- }
+
- fn up(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, _serial: u32, _time: u32, id: i32) {
- let actions = self.touch_tracker.up(id);
- self.run_touch_actions(actions);
- if self.touch_tracker.id().is_none() {
- self.touch_scroll_at = None;
++ fn up(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, _serial: u32, _time: u32, id: i32) {
++ let actions = self.touch_tracker.up(id);
++ self.run_touch_actions(actions);
++ if self.touch_tracker.id().is_none() {
++ self.touch_scroll_at = None;
++ }
+ }
- }
+
- fn motion(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, time: u32, id: i32, position: (f64, f64)) {
- if self.touch_tracker.id() != Some(id) {
- return;
++ fn motion(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, time: u32, id: i32, position: (f64, f64)) {
++ if self.touch_tracker.id() != Some(id) {
++ return;
++ }
++ let (x, y) = self.touch_pos(position);
++ let actions = self.touch_tracker.motion(id, x, y, time);
++ self.run_touch_actions(actions);
+ }
- let (x, y) = self.touch_pos(position);
- let actions = self.touch_tracker.motion(id, x, y, time);
- self.run_touch_actions(actions);
- }
+
- fn shape(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64, _: f64) {}
++ fn shape(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64, _: f64) {}
+
- fn orientation(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64) {}
++ fn orientation(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64) {}
+
- fn cancel(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch) {
- self.cancel_touch();
++ fn cancel(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch) {
++ self.cancel_touch();
++ }
+ }
-}
+
-smithay_client_toolkit::delegate_touch!(@<A: Application> EngineState<A>);
++ smithay_client_toolkit::delegate_touch!(@<A: Application> EngineState<A>);
++}
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+ use TouchAction::*;
+
+ #[test]
+ fn a_tap_clicks_where_it_landed() {
+ let mut t = TouchTracker::default();
+ assert_eq!(t.down(1, 50.0, 60.0, 1000), vec![Hover(50.0, 60.0)]);
+ // Jitter inside the slop is nothing yet.
+ assert!(t.motion(1, 53.0, 62.0, 1050).is_empty());
+ assert_eq!(t.up(1), vec![Press(50.0, 60.0), Release(50.0, 60.0), Leave]);
+ assert_eq!(t.id(), None);
+ }
+
+ #[test]
+ fn a_quick_drag_scrolls_with_the_finger_and_coasts_on_the_lift() {
+ let mut t = TouchTracker::default();
+ t.down(1, 100.0, 300.0, 0);
+ // Past the slop: the whole travel from the down point.
+ assert_eq!(t.motion(1, 100.0, 280.0, 80), vec![Scroll(0.0, -20.0)]);
+ assert_eq!(t.motion(1, 95.0, 250.0, 100), vec![Scroll(-5.0, -30.0)]);
+ // A repeated position is not an empty scroll.
+ assert!(t.motion(1, 95.0, 250.0, 110).is_empty());
+ assert_eq!(t.up(1), vec![ScrollEnd, Leave]);
+ }
+
+ #[test]
+ fn a_hold_then_move_is_a_held_button_drag() {
+ let mut t = TouchTracker::default();
+ t.down(1, 10.0, 10.0, 5000);
+ assert!(t.motion(1, 12.0, 10.0, 5200).is_empty());
+ assert_eq!(t.motion(1, 40.0, 10.0, 5000 + HOLD_MS), vec![Press(10.0, 10.0), Drag(40.0, 10.0)]);
+ assert_eq!(t.motion(1, 60.0, 15.0, 5600), vec![Drag(60.0, 15.0)]);
+ assert_eq!(t.up(1), vec![Release(60.0, 15.0), Leave]);
+ }
+
+ #[test]
+ fn the_hold_survives_the_clock_wrapping() {
+ let mut t = TouchTracker::default();
+ t.down(1, 0.0, 0.0, u32::MAX - 100);
+ assert_eq!(t.motion(1, 30.0, 0.0, HOLD_MS)[0], Press(0.0, 0.0));
+ }
+
+ #[test]
+ fn only_the_first_finger_is_followed() {
+ let mut t = TouchTracker::default();
+ t.down(1, 0.0, 0.0, 0);
+ assert!(t.down(2, 100.0, 100.0, 10).is_empty());
+ assert!(t.motion(2, 300.0, 300.0, 20).is_empty());
+ assert!(t.up(2).is_empty());
+ assert_eq!(t.id(), Some(1));
+ // Once it lifts, a new finger is followed.
+ t.up(1);
+ assert_eq!(t.down(2, 5.0, 5.0, 30), vec![Hover(5.0, 5.0)]);
+ }
+
+ #[test]
+ fn a_cancel_clicks_nothing_and_releases_a_held_button() {
+ let mut t = TouchTracker::default();
+ t.down(1, 0.0, 0.0, 0);
+ assert_eq!(t.cancel(), vec![Leave]);
+
+ t.down(1, 0.0, 0.0, 0);
+ t.motion(1, 0.0, 50.0, 10);
+ assert_eq!(t.cancel(), vec![ScrollEnd, Leave]);
+
+ t.down(1, 0.0, 0.0, 0);
+ t.motion(1, 50.0, 0.0, HOLD_MS);
+ assert_eq!(t.cancel(), vec![Release(50.0, 0.0), Leave]);
+ assert!(t.cancel().is_empty());
+ }
+ }
diff --cc src/backend/window_runner.rs
index 4f558e1,0d72b4b..6c22cdf
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@@ -27,33 -28,4090 +27,41 @@@ use wayland_client::
Connection, QueueHandle, Proxy,
};
-use wayland_protocols::wp::pointer_gestures::zv1::client::{
- zwp_pointer_gesture_pinch_v1::{self, ZwpPointerGesturePinchV1},
- zwp_pointer_gestures_v1::{self as zwp_pointer_gestures, ZwpPointerGesturesV1},
+use wayland_protocols::wp::text_input::zv3::client::{
+ zwp_text_input_manager_v3::ZwpTextInputManagerV3,
+ zwp_text_input_v3::{self, ZwpTextInputV3},
};
-pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel;
-pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
-use calloop::EventLoop;
-use calloop_wayland_source::WaylandSource;
-use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
-use std::rc::Rc;
-use crate::widget::{WidgetHost, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
-use crate::wayland::detect_scale_factor;
-use crate::vk::{Batch2D, Frame2D, TextSpan, VkRenderer};
-
-/// One shaped buffer in the text cache, keyed by everything that shapes it
-/// beyond its text (the text is the outer map's key, so a lookup borrows it
-/// rather than allocating).
-struct CachedBuffer {
- /// Physical font size, in thousandths of a px.
- size_milli: u32,
- /// The family as the font string names it, size stripped.
- font: Option<String>,
- is_vertical: bool,
- attrs: crate::scene::paint::TextAttrs,
- /// The scale factor's bits: a laid-out buffer's wrap width and box are
- /// logical px turned physical by it.
- scale_bits: u32,
- /// `Some` for a boxed [`Prim::Text`](crate::scene::paint::Prim::Text) laid
- /// out by [`get_text_buffer_laid_out`]; `None` for a single run.
- layout: Option<crate::scene::paint::TextLayout>,
- /// The laid-out buffer's vertical offset in its box (0 for a single run).
- voff: f32,
- /// Shared, not cloned, on a hit: a `Buffer` owns every shaped line and
- /// glyph, and the frame used to deep-copy one per text prim per frame.
- buffer: Rc<Buffer>,
- /// [`BUFFER_TICK`] at the last hit, for least-recently-used eviction.
- last_used: u64,
-}
-
-impl CachedBuffer {
- fn matches(&self, k: &BufferKey<'_>) -> bool {
- self.size_milli == k.size_milli
- && self.font.as_deref() == k.font
- && self.is_vertical == k.is_vertical
- && self.attrs == k.attrs
- && self.scale_bits == k.scale_bits
- && self.layout == k.layout
- }
-}
-
-/// A cache lookup's key, borrowed from the caller.
-struct BufferKey<'a> {
- size_milli: u32,
- font: Option<&'a str>,
- is_vertical: bool,
- attrs: crate::scene::paint::TextAttrs,
- scale_bits: u32,
- layout: Option<crate::scene::paint::TextLayout>,
-}
-
-/// The text cache holds at most this many buffers; past it the least
-/// recently used [`BUFFER_EVICT`] go.
-const BUFFER_CAP: usize = 2000;
-const BUFFER_EVICT: usize = 100;
-
-std::thread_local! {
- /// Text → every shaped variant of it. Variants per text are few (a size,
- /// a weight, a box), so they are scanned rather than hashed.
- static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<CachedBuffer>>> =
- std::cell::RefCell::new(std::collections::HashMap::new());
- static BUFFER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
- static BUFFER_TICK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
-}
-
-fn buffer_tick() -> u64 {
- BUFFER_TICK.with(|t| {
- let n = t.get() + 1;
- t.set(n);
- n
- })
-}
-
-/// The cached buffer for `text` under `key`, and its vertical offset.
-fn buffer_cache_get(text: &str, key: &BufferKey<'_>) -> Option<(Rc<Buffer>, f32)> {
- BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- let hit = cache.get_mut(text)?.iter_mut().find(|e| e.matches(key))?;
- hit.last_used = buffer_tick();
- Some((Rc::clone(&hit.buffer), hit.voff))
- })
-}
-
-fn buffer_cache_put(text: &str, key: &BufferKey<'_>, buffer: Rc<Buffer>, voff: f32) {
- BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- if BUFFER_COUNT.with(|c| c.get()) >= BUFFER_CAP {
- let mut ticks: Vec<u64> = cache.values().flatten().map(|e| e.last_used).collect();
- ticks.sort_unstable();
- let cutoff = ticks[BUFFER_EVICT.min(ticks.len()) - 1];
- cache.retain(|_, v| {
- v.retain(|e| e.last_used > cutoff);
- !v.is_empty()
- });
- BUFFER_COUNT.with(|c| c.set(cache.values().map(Vec::len).sum()));
- }
- let entry = CachedBuffer {
- size_milli: key.size_milli,
- font: key.font.map(str::to_owned),
- is_vertical: key.is_vertical,
- attrs: key.attrs,
- scale_bits: key.scale_bits,
- layout: key.layout,
- voff,
- buffer,
- last_used: buffer_tick(),
- };
- match cache.get_mut(text) {
- Some(v) => v.push(entry),
- None => {
- cache.insert(text.to_owned(), vec![entry]);
- }
- }
- BUFFER_COUNT.with(|c| c.set(c.get() + 1));
- });
-}
-
-/// The `CCE_PRESENT_DEBUG` traces' timestamp: wall-clock milliseconds, mod
-/// 100 s, short enough to read down a column of lines.
-fn debug_clock_ms() -> u128 {
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |d| d.as_millis() % 100_000)
-}
-
-/// A droplet spec resolved against a concrete rect: the push-constant fields
-/// that define its SILHOUETTE, in logical px.
-///
-/// Shared by [`crate::scene::paint::Prim::Droplet`] and
-/// [`crate::scene::paint::Prim::DropletScrim`] so the lit drop and the vignette
-/// drawn inside it can never disagree about the shape — the whole reason the
-/// scrim rides the droplet's shader path instead of approximating the outline
-/// with a rounded rect.
-struct DropletGeom {
- hx: f32,
- hy: f32,
- sag: f32,
- br: f32,
- bw: f32,
- k: f32,
- sr: f32,
- ar: f32,
- band: f32,
- bow: f32,
- /// How far the contact shadow reaches below/beside the box (0 when the
- /// spec has no shadow). The lit drop's cover quad grows by this; a scrim
- /// never draws outside the silhouette and ignores it.
- sh_reach: f32,
-}
-
-fn droplet_geom(rect: &crate::scene::layout::Rect, spec: &crate::scene::paint::DropletSpec) -> DropletGeom {
- let hx = rect.width * 0.5;
- let hy = rect.height * 0.5;
- let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
- // belly <= 0 disables the belly outright (the oval-dewdrop default) — the
- // shader skips the smin when the radius is 0.
- let (br, bw) = if spec.belly > 0.0 {
- let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
- (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
- } else {
- (0.0, 0.0)
- };
- let k = (spec.blend.max(0.0) * rect.height).max(1.0);
- let sheet_hy = hy - sag * 0.5;
- // Bottom (sheet_r) and top (attach) corner radii: when the pair overfills
- // the sheet height, scale both down proportionally — 0.5 + 0.5 is the
- // fully continuous egg.
- let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
- let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
- let sheet_h = (2.0 * sheet_hy).max(0.0);
- if sr + ar > sheet_h && sr + ar > 0.0 {
- let f = sheet_h / (sr + ar);
- sr *= f;
- ar *= f;
- }
- let band = (spec.band.max(0.05) * rect.height).max(1.0);
- // Bottom-bow edge rise; the shader derives the arc radius from it per drop
- // (R = hx^2/2*rise).
- let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
- let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
- DropletGeom { hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach }
-}
-
-fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
- let lower_name = name.to_lowercase();
- for face in fs.db().faces() {
- for (family, _) in &face.families {
- if family.to_lowercase() == lower_name {
- return Some(family.clone());
- }
- }
- }
- None
-}
-
-thread_local! {
- /// Family name → is-monospaced, resolved once per family from fontdb's
- /// face metadata (the post table's isFixedPitch, as fontdb records it).
- static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
- std::cell::RefCell::new(std::collections::HashMap::new());
-}
-
-fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
- MONO_FAMILY_CACHE.with(|cache| {
- if let Some(&mono) = cache.borrow().get(name) {
- return mono;
- }
- let lower = name.to_lowercase();
- let mono = fs
- .db()
- .faces()
- .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
- .map(|face| face.monospaced)
- .unwrap_or(false);
- cache.borrow_mut().insert(name.to_string(), mono);
- mono
- })
-}
-
-/// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
-/// shapes `Basic`, everything else `Advanced`.
-///
-/// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
-/// mono face that is exactly right: a mono font's ligatures are the one thing
-/// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
-/// squeezes f+i into a single-advance fi glyph, which is why the bar's window
-/// titles rendered "file" with a cramped fi — while mono faces carry no
-/// kerning to lose. Proportional faces keep `Advanced` (their kerning and
-/// ligatures are wanted — a font preview must not misrepresent the face), and
-/// any non-ASCII text keeps real shaping (combining marks, emoji, complex
-/// scripts) whatever the face.
-pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
- if text.is_ascii() {
- if let cosmic_text::Family::Name(name) = family {
- if family_is_monospaced(fs, name) {
- return cosmic_text::Shaping::Basic;
- }
- }
- }
- cosmic_text::Shaping::Advanced
-}
-
-pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
- get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
-}
-
-/// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
-/// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
-pub fn get_text_buffer_attrs(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
- text_attrs: crate::scene::paint::TextAttrs,
-) -> Buffer {
- Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs))
-}
-
-/// [`get_text_buffer_attrs`] without the copy: the cached buffer itself,
-/// shared. What the frame and the toolkit's own measuring use — a `Buffer`
-/// owns every shaped line and glyph, so the clone the public functions hand
-/// out costs as much as the text is long.
-pub(crate) fn shared_text_buffer(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
- text_attrs: crate::scene::paint::TextAttrs,
-) -> Rc<Buffer> {
- let scale = crate::scale::scale_factor();
- let (family_name, font_size) = match font {
- Some(font_str) => {
- let (family, parsed_size) = crate::layout::split_font_string(font_str);
- (Some(family), parsed_size.unwrap_or(size))
- }
- None => (None, size),
- };
-
- let physical_size = font_size * scale;
- let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
- let key = BufferKey {
- size_milli: (physical_size * 1000.0).round() as u32,
- font: family_name,
- is_vertical,
- attrs: text_attrs,
- scale_bits: scale.to_bits(),
- layout: None,
- };
- if let Some((buf, _)) = buffer_cache_get(text, &key) {
- return buf;
- }
-
- let line_height = if is_vertical {
- physical_size * 1.05
- } else {
- physical_size * 1.0
- };
- let metrics = Metrics::new(physical_size, line_height);
- let mut buf = Buffer::new(fs, metrics);
- let mut attrs = Attrs::new();
-
- let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
-
- let resolved_storage = family_name.and_then(|font_name| match font_name {
- "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
- "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
- "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
- _ => None,
- });
-
- let resolved_sans = if !sans_fallback.is_empty() {
- find_cased_family(fs, &sans_fallback)
- } else {
- None
- };
-
- let family = if let Some(font_family) = family_name {
- match font_family {
- "monospace" => {
- if !mono_fallback.is_empty() {
- if let Some(ref cased) = resolved_storage {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
- }
- } else {
- cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
- }
- }
- "sans-serif" => {
- if !sans_fallback.is_empty() {
- if let Some(ref cased) = resolved_storage {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::SansSerif
- }
- } else {
- cosmic_text::Family::SansSerif
- }
- }
- "serif" => {
- if !serif_fallback.is_empty() {
- if let Some(ref cased) = resolved_storage {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::Serif
- }
- } else {
- cosmic_text::Family::Serif
- }
- }
- name => cosmic_text::Family::Name(name),
- }
- } else {
- if !sans_fallback.is_empty() {
- if let Some(ref cased) = resolved_sans {
- cosmic_text::Family::Name(cased)
- } else {
- cosmic_text::Family::SansSerif
- }
- } else {
- cosmic_text::Family::SansSerif
- }
- };
- attrs = attrs.family(family);
- if text_attrs.italic {
- attrs = attrs.style(cosmic_text::Style::Italic);
- }
- if let Some(w) = text_attrs.weight {
- attrs = attrs.weight(cosmic_text::Weight(w));
- }
- let shaping = shaping_for(fs, text, &family);
- buf.set_text(fs, text, attrs, shaping);
- buf.shape_until_scroll(fs, true);
-
- let buf = Rc::new(buf);
- buffer_cache_put(text, &key, Rc::clone(&buf), 0.0);
- buf
-}
-
-/// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
-/// same buffer cache as the draw, so this is a lookup when the text is already on screen.
-/// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
-/// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
-/// source for caret placement and click→cursor mapping in hand-rolled text fields:
-/// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
-/// resolution, which disagrees with cosmic-text's advance and can even resolve a
-/// different face — a caret placed with it drifts off the drawn glyphs.
-pub fn shaped_cluster_offsets(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
-) -> Vec<(usize, f32)> {
- let scale = crate::scale::scale_factor().max(1.0);
- let buffer = shared_text_buffer(fs, text, size, font, crate::scene::paint::TextAttrs::default());
- let mut out: Vec<(usize, f32)> = Vec::new();
- let mut total: f32 = 0.0;
- for (start, x, w) in normalized_glyph_starts(&buffer, text) {
- if out.last().map_or(true, |&(b, _)| b != start) {
- out.push((start, x / scale));
- }
- total = total.max((x + w) / scale);
- }
- out.push((text.len(), total));
- out
-}
-
-/// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
-/// with `start` normalized to be text-relative.
-///
-/// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
-/// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
-/// word — while the Advanced path emits line-relative starts. `shaping_for`
-/// picks Basic exactly for ASCII text in a monospace family (the DE's default
-/// control font), so any multi-word value hit the bug: offsets keyed by those
-/// starts collide on the low columns and the caret/selection walk off the
-/// glyphs. A reset can ONLY come from that path, which shapes strictly one
-/// glyph per char in logical order — so when one is seen, byte starts are
-/// rebuilt by walking the text's chars. `text` must be the single line the
-/// buffer was shaped from.
-pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
- let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
- let mut monotonic = true;
- let mut prev = 0usize;
- for run in buffer.layout_runs() {
- for g in run.glyphs {
- if g.start < prev {
- monotonic = false;
- }
- prev = g.start;
- glyphs.push((g.start, g.x, g.w));
- }
- }
- if !monotonic {
- let mut starts = text.char_indices().map(|(i, _)| i);
- for g in glyphs.iter_mut() {
- g.0 = starts.next().unwrap_or(text.len());
- }
- }
- glyphs
-}
-
-/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
-/// Starts from [`get_text_buffer_attrs`]'s single run for all the family resolution, then
-/// re-lays it out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
-/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside
-/// the box per `align_v`. Cached beside the single runs, keyed by the box as well.
-pub fn get_text_buffer_laid_out(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
- text_attrs: crate::scene::paint::TextAttrs,
- layout: crate::scene::paint::TextLayout,
-) -> (Buffer, f32) {
- let (buf, voff) = shared_laid_out_buffer(fs, text, size, font, text_attrs, layout);
- (Buffer::clone(&buf), voff)
-}
-
-/// [`get_text_buffer_laid_out`] without the copy, as [`shared_text_buffer`] is to
-/// [`get_text_buffer_attrs`]. Until 2026-10-04 a boxed text was re-shaped from scratch
-/// every frame it was drawn; the box is part of the key now.
-pub(crate) fn shared_laid_out_buffer(
- fs: &mut FontSystem,
- text: &str,
- size: f32,
- font: Option<&str>,
- text_attrs: crate::scene::paint::TextAttrs,
- layout: crate::scene::paint::TextLayout,
-) -> (Rc<Buffer>, f32) {
- use crate::scene::paint::{AlignH, AlignV};
- let scale = crate::scale::scale_factor();
-
- // The font string may override the size ("family:size") — mirror shared_text_buffer.
- let (family, font_size) = match font {
- Some(font_str) => {
- let (family, parsed_size) = crate::layout::split_font_string(font_str);
- (Some(family), parsed_size.unwrap_or(size))
- }
- None => (None, size),
- };
- let physical_size = font_size * scale;
- let key = BufferKey {
- size_milli: (physical_size * 1000.0).round() as u32,
- font: family,
- is_vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
- attrs: text_attrs,
- scale_bits: scale.to_bits(),
- layout: Some(layout),
- };
- if let Some(hit) = buffer_cache_get(text, &key) {
- return hit;
- }
-
- // Resolved family + attrs come from the single run; this copy is ours to re-lay-out.
- let mut buf = Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs));
- let line_height = physical_size * 1.4;
- buf.set_metrics(fs, Metrics::new(physical_size, line_height));
- buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
-
- let align = match layout.align_h {
- AlignH::Left => cosmic_text::Align::Left,
- AlignH::Center => cosmic_text::Align::Center,
- AlignH::Right => cosmic_text::Align::Right,
- };
- for line in &mut buf.lines {
- line.set_align(Some(align));
- }
- buf.shape_until_scroll(fs, true);
-
- // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
- let runs = buf.layout_runs().count();
- let total_h = runs as f32 * font_size * 1.4;
- let voff = match layout.align_v {
- AlignV::Top => 0.0,
- AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
- AlignV::Bottom => (layout.box_height - total_h).max(0.0),
- };
- let buf = Rc::new(buf);
- buffer_cache_put(text, &key, Rc::clone(&buf), voff);
- (buf, voff)
-}
-
-/// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
-/// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
-/// it is defined here now that the dependency is cosmic-text directly. Same plain
-/// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct TextBounds {
- pub left: i32,
- pub top: i32,
- pub right: i32,
- pub bottom: i32,
-}
-
-/// A display-list text prim ready for the glyph pass: a [`TextItem`](crate::widget::TextItem) whose
-/// buffer is the cache's own, shared rather than copied.
-pub(crate) struct DlText {
- pub(crate) buffer: Rc<Buffer>,
- pub(crate) x: f32,
- pub(crate) y: f32,
- pub(crate) color: cosmic_text::Color,
- pub(crate) bounds: Option<[f32; 4]>,
- pub(crate) clip_circle: Option<[f32; 3]>,
- pub(crate) clip_rrect: Option<[f32; 5]>,
-}
-
-/// The display list's Text prims, shaped through the shared buffer cache and
-/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
-/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
-/// logical space. Shared by the window's frame and the context-menu popup's.
-pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<DlText>) {
- for item in &dl.items {
- if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
- let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
- let merged = match (clip, *bounds) {
- (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
- (Some(a), None) => Some(a),
- (None, b) => b,
- };
- // Boxed text (wrap/align) is laid out in its box and shifts down by the
- // vertical offset; ordinary labels are a single run. Both cached.
- let (buffer, y_off) = match layout {
- Some(l) => shared_laid_out_buffer(fs, text, *font_size, font.as_deref(), *attrs, *l),
- None => (shared_text_buffer(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
- };
- out.push(DlText {
- buffer,
- x: *x,
- y: *y + y_off,
- color: cosmic_text::Color::rgba(
- color[0],
- color[1],
- color[2],
- (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
- ),
- bounds: merged,
- clip_circle: item.clip_circle,
- clip_rrect: item.clip_rrect,
- });
- }
- }
-}
-
-/// The glyph pass's spans for `items`: each clamped to the surface and its
-/// own bounds, then by the popover-occlusion clamp against `overlays`.
-pub(crate) fn dl_text_spans<'a>(
- items: &'a [DlText],
- scale_f32: f32,
- bounds: TextBounds,
- overlays: &[(f32, f32, f32, f32)],
-) -> Vec<TextSpan<'a>> {
- let mut spans: Vec<TextSpan<'a>> = Vec::new();
- for ti in items {
- let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
- TextBounds {
- left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
- top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
- right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
- bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
- }
- } else {
- bounds
- };
- popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
- spans.push(TextSpan {
- buffer: &ti.buffer,
- left: (ti.x * scale_f32).round(),
- top: (ti.y * scale_f32).round(),
- // Buffers are shaped at physical size (get_text_buffer_attrs).
- scale: 1.0,
- bounds: Some([
- item_bounds.left,
- item_bounds.top,
- item_bounds.right,
- item_bounds.bottom,
- ]),
- default_color: [
- ti.color.r() as f32 / 255.0,
- ti.color.g() as f32 / 255.0,
- ti.color.b() as f32 / 255.0,
- ti.color.a() as f32 / 255.0,
- ],
- rotation: None,
- // Circle wins when both are set (the circular pane's innermost clip);
- // otherwise a rounded-rect clip rides as center+radius with extents.
- clip_circle: match (ti.clip_circle, ti.clip_rrect) {
- (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
- (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
- (None, None) => [0.0; 3],
- },
- clip_extents: match (ti.clip_circle, ti.clip_rrect) {
- (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
- _ => [0.0; 2],
- },
- });
- }
- spans
-}
-
-/// The tessellated display list's batches, scissors and rounded clips scaled
-/// to physical px.
-pub(crate) fn dl_batches_2d(dl_batches: &[DlBatch], scale_f32: f32) -> Vec<Batch2D> {
- dl_batches
- .iter()
- .map(|batch| Batch2D {
- scissor: batch.scissor.map(|clip| {
- (
- (clip.x * scale_f32).max(0.0) as u32,
- (clip.y * scale_f32).max(0.0) as u32,
- (clip.width * scale_f32) as u32,
- (clip.height * scale_f32) as u32,
- )
- }),
- clip_rrect: batch
- .clip_rrect
- .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
- start: batch.start,
- end: batch.end,
- plate: batch.plate,
- blur_behind: batch.blur_behind,
- })
- .collect()
-}
-
-/// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
-/// the display-list text path: clip a text item's bounds so it does not bleed through an open
-/// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
-/// text and is left alone; anything else that intersects gets clamped horizontally toward
-/// whichever side of the popover it starts on.
-/// Clamp a text item's bounds away from the registered popover rects it
-/// runs under, so page text does not bleed through a floating plate.
-///
-/// A text item BELONGS to a popover when it carries exactly that popover's
-/// rect as its bounds (the convention every popover's own labels follow),
-/// and it is then clamped only against the popovers registered AFTER its
-/// own — `overlay_rects` is in stacking order, the shared context menu
-/// last. Before 2026-09-22 a popover's text was exempt from its own rect
-/// alone and clamped against every other, so a context menu opened over a
-/// modal dialog had its labels clipped by the dialog it was drawn on top
-/// of, and showed as a plate with no legible entries.
-fn popover_occlusion_clamp(
- overlay_rects: &[(f32, f32, f32, f32)],
- ti: &DlText,
- scale_f32: f32,
- item_bounds: &mut TextBounds,
-) {
- let owner = ti.bounds.and_then(|[l, t, r, b]| {
- overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
- (l - ox).abs() < 1.0
- && (t - oy).abs() < 1.0
- && (r - (ox + ow)).abs() < 1.0
- && (b - (oy + oh)).abs() < 1.0
- })
- });
- let first_above = owner.map_or(0, |k| k + 1);
- for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
- let ol = (ox * scale_f32).round() as i32;
- let ot = (oy * scale_f32).round() as i32;
- let or = ((ox + ow) * scale_f32).round() as i32;
- let ob = ((oy + oh) * scale_f32).round() as i32;
-
- let tx_pixel = ti.x * scale_f32;
- let ty_pixel = ti.y * scale_f32;
-
- let mut text_w = 0.0f32;
- let mut run_count = 0;
- for run in ti.buffer.layout_runs() {
- text_w = text_w.max(run.line_w);
- run_count += 1;
- }
- let text_h = run_count as f32 * ti.buffer.metrics().line_height;
-
- let actual_left = tx_pixel;
- let actual_right = tx_pixel + text_w;
- let actual_top = ty_pixel;
- let actual_bottom = ty_pixel + text_h;
-
- if actual_left < or as f32
- && actual_right > ol as f32
- && actual_top < ob as f32
- && actual_bottom > ot as f32
- {
- if tx_pixel < ol as f32 {
- item_bounds.right = item_bounds.right.min(ol);
- } else {
- item_bounds.left = item_bounds.left.max(or);
- }
- }
- }
-}
-
-#[repr(C)]
-#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-pub struct Vertex {
- pub position: [f32; 2],
- pub color: [f32; 4],
- pub clip_circle: [f32; 3], // [cx, cy, r]
-}
-
-pub fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [f32; 4]) -> [Vertex; 6] {
- let x0 = (x / sw) * 2.0 - 1.0;
- let y0 = 1.0 - (y / sh) * 2.0;
- let x1 = ((x + w) / sw) * 2.0 - 1.0;
- let y1 = 1.0 - ((y + h) / sh) * 2.0;
- [
- Vertex { position: [x0, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x1, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
- Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
- ]
-}
-
-pub fn quad_vertices_with_clip(
- x: f32, y: f32, w: f32, h: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
-) -> [Vertex; 6] {
- let x0 = (x / sw) * 2.0 - 1.0;
- let y0 = 1.0 - (y / sh) * 2.0;
- let x1 = ((x + w) / sw) * 2.0 - 1.0;
- let y1 = 1.0 - ((y + h) / sh) * 2.0;
- [
- Vertex { position: [x0, y0], color, clip_circle },
- Vertex { position: [x1, y0], color, clip_circle },
- Vertex { position: [x0, y1], color, clip_circle },
- Vertex { position: [x1, y0], color, clip_circle },
- Vertex { position: [x1, y1], color, clip_circle },
- Vertex { position: [x0, y1], color, clip_circle },
- ]
-}
-
-/// A quad whose four corners each carry their own color, Gouraud-interpolated across both
-/// triangles by the shader (`@location(0) color` has no `flat` qualifier). Corner order is
-/// TL, TR, BR, BL. Keep the alpha equal on all four: negative alpha is the blur sentinel,
-/// so a gradient that crossed zero would tear the triangle in half.
-pub fn quad_vertices_shaded(
- x: f32, y: f32, w: f32, h: f32,
- sw: f32, sh: f32,
- c_tl: [f32; 4], c_tr: [f32; 4], c_br: [f32; 4], c_bl: [f32; 4],
- clip_circle: [f32; 3],
-) -> [Vertex; 6] {
- let x0 = (x / sw) * 2.0 - 1.0;
- let y0 = 1.0 - (y / sh) * 2.0;
- let x1 = ((x + w) / sw) * 2.0 - 1.0;
- let y1 = 1.0 - ((y + h) / sh) * 2.0;
- [
- Vertex { position: [x0, y0], color: c_tl, clip_circle },
- Vertex { position: [x1, y0], color: c_tr, clip_circle },
- Vertex { position: [x0, y1], color: c_bl, clip_circle },
- Vertex { position: [x1, y0], color: c_tr, clip_circle },
- Vertex { position: [x1, y1], color: c_br, clip_circle },
- Vertex { position: [x0, y1], color: c_bl, clip_circle },
- ]
-}
-
-pub fn quad_vertices_clipped(
- x: f32, y: f32, w: f32, h: f32,
- surface_w: f32, surface_h: f32,
- color: [f32; 4],
- clip: (f32, f32, f32, f32),
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let (cx0, cy0, cx1, cy1) = clip;
- let ix0 = x.max(cx0);
- let iy0 = y.max(cy0);
- let ix1 = (x + w).min(cx1);
- let iy1 = (y + h).min(cy1);
- if ix1 <= ix0 || iy1 <= iy0 {
- return Vec::new();
- }
- quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, surface_w, surface_h, color, clip_circle).to_vec()
-}
-
-pub fn line_vertices(
- x1: f32, y1: f32, x2: f32, y2: f32,
- thickness: f32,
- sw: f32, sh: f32,
- c: [f32; 4]
-) -> [Vertex; 6] {
- let dx = x2 - x1;
- let dy = y2 - y1;
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 {
- return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c);
- }
- let ux = dx / len;
- let uy = dy / len;
- let nx = -uy;
- let ny = ux;
-
- let half_t = thickness * 0.5;
- let p0x = x1 + nx * half_t;
- let p0y = y1 + ny * half_t;
- let p1x = x1 - nx * half_t;
- let p1y = y1 - ny * half_t;
- let p2x = x2 - nx * half_t;
- let p2y = y2 - ny * half_t;
- let p3x = x2 + nx * half_t;
- let p3y = y2 + ny * half_t;
-
- let ndc_p0x = (p0x / sw) * 2.0 - 1.0;
- let ndc_p0y = 1.0 - (p0y / sh) * 2.0;
- let ndc_p1x = (p1x / sw) * 2.0 - 1.0;
- let ndc_p1y = 1.0 - (p1y / sh) * 2.0;
- let ndc_p2x = (p2x / sw) * 2.0 - 1.0;
- let ndc_p2y = 1.0 - (p2y / sh) * 2.0;
- let ndc_p3x = (p3x / sw) * 2.0 - 1.0;
- let ndc_p3y = 1.0 - (p3y / sh) * 2.0;
-
- let clip_circle = [0.0, 0.0, 0.0];
- [
- Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
- Vertex { position: [ndc_p1x, ndc_p1y], color: c, clip_circle },
- Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
- Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
- Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
- Vertex { position: [ndc_p3x, ndc_p3y], color: c, clip_circle },
- ]
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
-pub enum LineCap {
- Arrow,
- Round,
- Flat,
-}
-
-pub fn vector_vertices(
- x1: f32, y1: f32, x2: f32, y2: f32,
- thickness: f32,
- sw: f32, sh: f32,
- c: [f32; 4],
- line_cap: LineCap,
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- let dx = x2 - x1;
- let dy = y2 - y1;
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 {
- return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c).to_vec();
- }
-
- match line_cap {
- LineCap::Arrow => {
- let ux = dx / len;
- let uy = dy / len;
- let nx = -uy;
- let ny = ux;
-
- let arrow_len = (thickness * 3.0).max(10.0).min(len);
- let arrow_width = (thickness * 2.5).max(8.0);
-
- let line_x2 = x2 - ux * arrow_len;
- let line_y2 = y2 - uy * arrow_len;
-
- if len > arrow_len {
- verts.extend_from_slice(&line_vertices(x1, y1, line_x2, line_y2, thickness, sw, sh, c));
- }
-
- let bx = line_x2;
- let by = line_y2;
-
- let w1x = bx + nx * (arrow_width * 0.5);
- let w1y = by + ny * (arrow_width * 0.5);
- let w2x = bx - nx * (arrow_width * 0.5);
- let w2y = by - ny * (arrow_width * 0.5);
-
- let ndc_tip_x = (x2 / sw) * 2.0 - 1.0;
- let ndc_tip_y = 1.0 - (y2 / sh) * 2.0;
- let ndc_w1x = (w1x / sw) * 2.0 - 1.0;
- let ndc_w1y = 1.0 - (w1y / sh) * 2.0;
- let ndc_w2x = (w2x / sw) * 2.0 - 1.0;
- let ndc_w2y = 1.0 - (w2y / sh) * 2.0;
-
- let clip_circle = [0.0, 0.0, 0.0];
- verts.push(Vertex { position: [ndc_tip_x, ndc_tip_y], color: c, clip_circle });
- verts.push(Vertex { position: [ndc_w1x, ndc_w1y], color: c, clip_circle });
- verts.push(Vertex { position: [ndc_w2x, ndc_w2y], color: c, clip_circle });
- }
- LineCap::Round => {
- push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
- let clip_circle = [0.0, 0.0, 0.0];
- verts.extend(circle_vertices(x2, y2, thickness / 2.0, sw, sh, c, 16, clip_circle));
- }
- LineCap::Flat => {
- push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
- }
- }
-
- verts
-}
-
-/// `line_vertices` with a half-px alpha ramp along each long edge (the arc
-/// tessellator's poor-man's AA) — diagonal strokes resolve smoothly instead of
-/// stair-stepping. Axis-aligned strokes keep the crisp single-quad path:
-/// feathering a pixel-snapped hairline would only blur it.
-fn push_feathered_line_vertices(
- x1: f32, y1: f32, x2: f32, y2: f32,
- thickness: f32,
- sw: f32, sh: f32,
- c: [f32; 4],
- out: &mut Vec<Vertex>,
-) {
- let dx = x2 - x1;
- let dy = y2 - y1;
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 || dx.abs() < 0.01 || dy.abs() < 0.01 {
- out.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
- return;
- }
- let (nx, ny) = (-dy / len, dx / len);
- let f = 0.5f32.min(thickness * 0.25);
- let half = thickness * 0.5;
- // (offset at band start, offset at band end, alpha at start, alpha at end)
- let bands = [
- (-half - f, -half + f, 0.0, c[3]),
- (-half + f, half - f, c[3], c[3]),
- (half - f, half + f, c[3], 0.0),
- ];
- for &(oa, ob, aa, ab) in &bands {
- let ca = [c[0], c[1], c[2], aa];
- let cb = [c[0], c[1], c[2], ab];
- let p = |x: f32, y: f32, o: f32| -> [f32; 2] {
- [((x + nx * o) / sw) * 2.0 - 1.0, 1.0 - ((y + ny * o) / sh) * 2.0]
- };
- let clip_circle = [0.0, 0.0, 0.0];
- let (a1, b1) = (p(x1, y1, oa), p(x1, y1, ob));
- let (a2, b2) = (p(x2, y2, oa), p(x2, y2, ob));
- out.push(Vertex { position: a1, color: ca, clip_circle });
- out.push(Vertex { position: b1, color: cb, clip_circle });
- out.push(Vertex { position: b2, color: cb, clip_circle });
- out.push(Vertex { position: a1, color: ca, clip_circle });
- out.push(Vertex { position: b2, color: cb, clip_circle });
- out.push(Vertex { position: a2, color: ca, clip_circle });
- }
-}
-
-pub fn rounded_rect_vertices_corners(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- corners: (bool, bool, bool, bool),
- clip_rect: Option<(f32, f32, f32, f32)>,
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- let radii = crate::widget::CornerRadii::new(
- if corners.0 { r } else { 0.0 },
- if corners.1 { r } else { 0.0 },
- if corners.2 { r } else { 0.0 },
- if corners.3 { r } else { 0.0 },
- );
- push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, color, clip_circle, clip_rect, &mut verts);
- verts
-}
-
-/// Sample of the unit superellipse |x|^n + |y|^n = 1 at circle parameter θ —
-/// the (cos θ, sin θ) replacement the corner fans use. Exactly the circle at
-/// n = 2; higher `corner_shape` exponents give the DE's continuous-curvature
-/// corners, so widget silhouettes follow the same corner family as the
-/// SDF-lit plates. `e` is 2/n, hoisted by callers. Tangent points at the
-/// quadrant ends are pinned to the exact axis points (see below), so fans
-/// tile exactly against the body rects and edge strips.
-#[inline]
-fn superellipse_pt(theta: f32, e: f32) -> (f32, f32) {
- let (s, c) = theta.sin_cos();
- // f32 sin/cos are not exactly 0 at a quadrant end (sin(PI) is -8.7e-8),
- // and the fractional power magnifies that noise by orders of magnitude:
- // at corner_shape 4.5 it is 7e-4, which on the desktop grid's 138 px
- // cell corners put the fan's tangent vertex 0.1 px short of the body
- // quad's edge. The fan's edge then tilts away from the quad's, and the
- // row of pixel centres between them is covered by neither: a stray
- // gap-coloured line 32 px long at every cell's left edge, and a lone
- // gap pixel on the bottom row where the arc meets the body. Snap the
- // ends to the exact axis points so fans tile against the rects.
- let axis = |v: f32| -> f32 {
- if v.abs() < 1e-6 {
- 0.0
- } else if v.abs() > 1.0 - 1e-6 {
- v.signum()
- } else {
- v.signum() * v.abs().powf(e)
- }
- };
- (axis(c), axis(s))
-}
-
-/// Feathered glow ([`Prim::Glow`]): the rounded rect's interior fills at the
-/// color's alpha and concentric outline rings fade it to zero across `reach`
-/// px outside the boundary. Alpha rides the VERTICES, so the GPU interpolates
-/// a per-pixel-smooth falloff between rings — stacked translucent layers band
-/// visibly; this cannot. Ring alphas sit on a quadratic ease-out, giving the
-/// vignette profile piecewise-linearly with kinks below visibility at glow
-/// alphas. Corners sample [`superellipse_pt`], so a glow's silhouette sits in
-/// the same corner family as the cells, nodes, and plates it highlights.
-pub fn push_glow_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- radius: f32, reach: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- if ww <= 0.0 || h <= 0.0 || color[3].abs() <= 0.0005 || sw <= 0.0 || sh <= 0.0 {
- return;
- }
- let r0 = radius.clamp(0.0, ww.min(h) * 0.5);
- let ctl = (x + r0, y + r0);
- let ctr = (x + ww - r0, y + r0);
- let cbr = (x + ww - r0, y + h - r0);
- let cbl = (x + r0, y + h - r0);
- const K: usize = 10;
- use std::f32::consts::PI;
- let corner_e = 2.0 / crate::layout::corner_shape();
- // One outline ring `off` px outside the boundary, clockwise from the
- // top-left arc; every ring shares the layout, so strips never twist.
- let ring = |off: f32| -> Vec<[f32; 2]> {
- let r = (r0 + off).max(0.0);
- let mut pts = Vec::with_capacity(4 * (K + 1));
- let corners = [
- (ctl, PI, 1.5 * PI),
- (ctr, 1.5 * PI, 2.0 * PI),
- (cbr, 0.0, 0.5 * PI),
- (cbl, 0.5 * PI, PI),
- ];
- for ((cx, cy), a0, a1) in corners {
- for k in 0..=K {
- let a = a0 + (a1 - a0) * (k as f32 / K as f32);
- let (ux, uy) = superellipse_pt(a, corner_e);
- pts.push([cx + r * ux, cy + r * uy]);
- }
- }
- pts
- };
- let to_v = |p: [f32; 2], a: f32| Vertex {
- position: [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0],
- color: [color[0], color[1], color[2], a],
- clip_circle,
- };
-
- let rings: Vec<(Vec<[f32; 2]>, f32)> = [0.0f32, 0.35, 0.7, 1.0]
- .iter()
- .map(|&t| (ring(reach * t), color[3] * (1.0 - t) * (1.0 - t)))
- .collect();
- let n = rings[0].0.len();
-
- // Interior: a fan from the rect center over the innermost ring (a rounded
- // rect is convex, so the fan covers it exactly), uniform core alpha.
- let center = [x + ww * 0.5, y + h * 0.5];
- for i in 0..n {
- let p1 = rings[0].0[i];
- let p2 = rings[0].0[(i + 1) % n];
- out.push(to_v(center, color[3]));
- out.push(to_v(p1, color[3]));
- out.push(to_v(p2, color[3]));
- }
- // The feather: strips between consecutive rings, each vertex carrying its
- // ring's alpha.
- for w in rings.windows(2) {
- let (inner, ia) = (&w[0].0, w[0].1);
- let (outer, oa) = (&w[1].0, w[1].1);
- for i in 0..n {
- let a1 = inner[i];
- let a2 = inner[(i + 1) % n];
- let b1 = outer[i];
- let b2 = outer[(i + 1) % n];
- out.push(to_v(a1, ia));
- out.push(to_v(b1, oa));
- out.push(to_v(a2, ia));
- out.push(to_v(a2, ia));
- out.push(to_v(b1, oa));
- out.push(to_v(b2, oa));
- }
- }
-}
-
-pub fn push_rounded_rect_vertices_corners(
- x: f32, y: f32, ww: f32, h: f32,
- radii: crate::widget::CornerRadii,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- clip_rect: Option<(f32, f32, f32, f32)>,
- out: &mut Vec<Vertex>,
-) {
- let corner_e = 2.0 / crate::layout::corner_shape();
- let mut r_tl = radii.top_left.max(0.0);
- let mut r_tr = radii.top_right.max(0.0);
- let mut r_br = radii.bottom_right.max(0.0);
- let mut r_bl = radii.bottom_left.max(0.0);
-
- // Simple scale clamping
- let sum_top = r_tl + r_tr;
- if sum_top > ww {
- let f = ww / sum_top;
- r_tl *= f;
- r_tr *= f;
- }
- let sum_bottom = r_bl + r_br;
- if sum_bottom > ww {
- let f = ww / sum_bottom;
- r_bl *= f;
- r_br *= f;
- }
- let sum_left = r_tl + r_bl;
- if sum_left > h {
- let f = h / sum_left;
- r_tl *= f;
- r_bl *= f;
- }
- let sum_right = r_tr + r_br;
- if sum_right > h {
- let f = h / sum_right;
- r_tr *= f;
- r_br *= f;
- }
-
- let clamp_x = |val: f32| -> f32 {
- if let Some((cx0, _, cx1, _)) = clip_rect {
- val.max(cx0).min(cx1)
- } else {
- val
- }
- };
- let clamp_y = |val: f32| -> f32 {
- if let Some((_, cy0, _, cy1)) = clip_rect {
- val.max(cy0).min(cy1)
- } else {
- val
- }
- };
-
- let push_quad = |verts: &mut Vec<Vertex>, qx: f32, qy: f32, qw: f32, qh: f32| {
- let x0 = clamp_x(qx);
- let y0 = clamp_y(qy);
- let x1 = clamp_x(qx + qw);
- let y1 = clamp_y(qy + qh);
-
- if x1 <= x0 || y1 <= y0 {
- return;
- }
-
- let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
- let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
- let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
- let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
- };
-
- let has_corners = r_tl > 0.1 || r_tr > 0.1 || r_br > 0.1 || r_bl > 0.1;
- if !has_corners {
- push_quad(out, x, y, ww, h);
- return;
- }
-
- // Body rectangles
- let mid_x0 = r_tl.max(r_bl);
- let mid_x1 = ww - r_tr.max(r_br);
- if mid_x1 > mid_x0 {
- push_quad(out, x + mid_x0, y, mid_x1 - mid_x0, h);
- }
- if h > r_tl + r_bl {
- push_quad(out, x, y + r_tl, mid_x0, h - r_tl - r_bl);
- }
- if h > r_tr + r_br {
- push_quad(out, x + mid_x1, y + r_tr, ww - mid_x1, h - r_tr - r_br);
- }
-
- // Corner rendering. The fans are FEATHERED: the fan body stops half a
- // pixel short of the silhouette and a strip fades from opaque at
- // silhouette-0.5 to transparent at silhouette+0.5, so the arc
- // anti-aliases instead of rasterizing a hard staircase — invisible on
- // HiDPI widget buffers, glaring on the desktop grid's world-scale
- // cells. Perceived size is unchanged (the 50%-coverage line stays on
- // the exact silhouette). Radii too small to feather keep the hard fan.
- let segments = 16;
- let fade = [color[0], color[1], color[2], 0.0];
- let to_ndc = |px: f32, py: f32| -> [f32; 2] {
- [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0]
- };
- let push_corner = |out: &mut Vec<Vertex>, cx: f32, cy: f32, r: f32, start: f32, end: f32| {
- let feather = r > 1.5;
- let r_fan = if feather { r - 0.5 } else { r };
- let r_out = r + 0.5;
- for i in 0..segments {
- let theta1 = start + (i as f32) * (end - start) / (segments as f32);
- let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
-
- let (c1, s1) = superellipse_pt(theta1, corner_e);
- let (c2, s2) = superellipse_pt(theta2, corner_e);
- let p0 = to_ndc(clamp_x(cx), clamp_y(cy));
- let p1 = to_ndc(clamp_x(cx + r_fan * c1), clamp_y(cy + r_fan * s1));
- let p2 = to_ndc(clamp_x(cx + r_fan * c2), clamp_y(cy + r_fan * s2));
-
- out.push(Vertex { position: p0, color, clip_circle });
- out.push(Vertex { position: p1, color, clip_circle });
- out.push(Vertex { position: p2, color, clip_circle });
-
- if feather {
- let q1 = to_ndc(clamp_x(cx + r_out * c1), clamp_y(cy + r_out * s1));
- let q2 = to_ndc(clamp_x(cx + r_out * c2), clamp_y(cy + r_out * s2));
- out.push(Vertex { position: p1, color, clip_circle });
- out.push(Vertex { position: q1, color: fade, clip_circle });
- out.push(Vertex { position: q2, color: fade, clip_circle });
- out.push(Vertex { position: p1, color, clip_circle });
- out.push(Vertex { position: q2, color: fade, clip_circle });
- out.push(Vertex { position: p2, color, clip_circle });
- }
- }
- };
-
- // Top-Left
- if r_tl > 0.1 {
- push_corner(out, x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI);
- if mid_x0 > r_tl {
- push_quad(out, x + r_tl, y, mid_x0 - r_tl, r_tl);
- }
- }
-
- // Top-Right
- if r_tr > 0.1 {
- push_corner(out, x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI);
- if ww - mid_x1 > r_tr {
- push_quad(out, x + mid_x1, y, ww - mid_x1 - r_tr, r_tr);
- }
- }
-
- // Bottom-Right
- if r_br > 0.1 {
- push_corner(out, x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI);
- if ww - mid_x1 > r_br {
- push_quad(out, x + mid_x1, y + h - r_br, ww - mid_x1 - r_br, r_br);
- }
- }
-
- // Bottom-Left
- if r_bl > 0.1 {
- push_corner(out, x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI);
- if mid_x0 > r_bl {
- push_quad(out, x + r_bl, y + h - r_bl, mid_x0 - r_bl, r_bl);
- }
- }
-}
-
-pub fn rounded_rect_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, &mut verts);
- verts
-}
-
-pub fn push_rounded_rect_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, out);
-}
-
-pub fn plate_bevel_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_plate_bevel_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, &mut verts);
- verts
-}
-
-pub fn push_plate_bevel_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- push_bevel_edge_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, 1.0, out);
-}
-
-/// The bevel edge shading, with the light direction selectable: `light_sign` is `1.0`
-/// for a raised plate (edges facing `light_source_position` are lit) and `-1.0` for a
-/// recess (those same edges fall into shadow instead, and the far edges catch the
-/// light). Negating the whole light vector flips every edge and every corner segment
-/// consistently, because both the flat-edge factors and the arc-normal dot product
-/// below are linear in it.
-pub fn push_bevel_edge_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- light_sign: f32,
- out: &mut Vec<Vertex>,
-) {
- push_bevel_edge_vertices_radii(
- x, y, ww, h, (r, r, r, r), t, sw, sh, base_color, clip_circle, light_sign, out,
- );
-}
-
-/// As [`push_bevel_edge_vertices`], but with a per-corner radius (TL, TR, BR, BL) so the
-/// lip can follow a shape whose corners differ — a recess carved along the top of a
-/// rounded plate needs the plate's radius on its top corners and square ones where it
-/// meets the content below. A uniform radius there would either square off the plate's
-/// arc (painting a notch outside it) or wrongly round the inner corners.
-pub fn push_bevel_edge_vertices_radii(
- x: f32, y: f32, ww: f32, h: f32,
- radii: (f32, f32, f32, f32),
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- light_sign: f32,
- out: &mut Vec<Vertex>,
-) {
- push_bevel_edge_vertices_banded(
- x, y, ww, h, radii, t, sw, sh, base_color, clip_circle, light_sign,
- default_bevel_bands(t), (true, true, true, true), EdgeKind::Rim, out,
- );
-}
-
-/// What kind of height change an edge represents. The two shade differently because they
-/// are different shapes, and using one where the other belongs is what makes a bevel read
-/// as a drawn line instead of a surface.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum EdgeKind {
- /// The surface *ends* here: a quarter-round rolling from face-on at the inner edge of
- /// the lip to fully in-plane at the outer boundary, where it drops away. The shading
- /// therefore peaks exactly at the boundary and dies inward. This is a plate's outer
- /// perimeter.
- Rim,
- /// The surface *continues* at a different height: one plateau steps down to another.
- /// A height field that falls monotonically across the transition has its normal tilted
- /// toward the low side the whole way, steepest in the middle and flat at both ends —
- /// so the shading is a bump straddling the boundary, not a band butted against it.
- /// Hanging the band on one side instead leaves the seam the eye reads as a drawn line.
- Step,
-}
-
-/// Shading across an edge at signed distance `d` from the boundary (positive = toward the
-/// shape's interior), for a transition of width `t`. Returns the light term as a fraction
-/// of full tilt.
-#[inline]
-fn bevel_profile(kind: EdgeKind, d: f32, t: f32) -> f32 {
- if t <= 0.0 {
- return 0.0;
- }
- match kind {
- // Normal rotates from in-plane (d = 0) to face-on (d = t): sine of what tilt is
- // left. A linear ramp here reads as a flat 45° chamfer instead of a roll.
- EdgeKind::Rim => ((1.0 - (d / t).clamp(0.0, 1.0)) * std::f32::consts::FRAC_PI_2).sin(),
- // Symmetric bump over [-t/2, +t/2], zero at both ends so the transition blends into
- // both plateaus with no seam.
- EdgeKind::Step => {
- let s = (d / t + 0.5).clamp(0.0, 1.0);
- (s * std::f32::consts::PI).sin()
- }
- }
-}
-
-/// The light-independent curvature term at signed distance `d` — the second depth cue,
-/// on top of the directional one. Curvature shading is what ambient light does: convex
-/// surface catches it from everywhere (bright), concave is self-occluded (dark). Because
-/// it does not rotate with the light, it survives exactly where the directional term
-/// dies — walls parallel to the light vector — so no edge ever vanishes entirely.
-///
-/// `high_sign` is +1 when the rect interior is the HIGH side of the transition and -1
-/// when it is the low side (a recess). Geometry, not lighting: it does not flip with
-/// `light_sign`... except that for these 2.5D shapes the two are the same number, since
-/// a raised shape is lit like a plateau and shaded like one.
-#[inline]
-fn bevel_curvature(kind: EdgeKind, d: f32, t: f32, high_sign: f32) -> f32 {
- if t <= 0.0 {
- return 0.0;
- }
- match kind {
- // A rim is convex everywhere, tightest right at the silhouette: a bright crest
- // line hugging the boundary and dying fast inward. This is the line that makes
- // glass read as glass — the edge catches ambient light all the way around, even
- // (dimmer, via the gain asymmetry below) on the side facing away from the light.
- EdgeKind::Rim => {
- let u = (d / t).clamp(0.0, 1.0);
- let f = 1.0 - u;
- CREST_RATIO * f * f * f
- }
- // An S-curve step is convex on its high half (the shoulder) and concave on its
- // low half (the fillet, where the wall meets the floor): antisymmetric, zero at
- // the ends (no seam against either plateau) and at the midpoint.
- EdgeKind::Step => {
- let s = (d / t + 0.5).clamp(0.0, 1.0);
- let outer_is_high = -high_sign; // d < 0 is outside the rect
- // sin(2πs) is positive on the outer half — the shoulder when the outside is
- // the high side — and negative on the inner (fillet) half.
- AO_RATIO * outer_is_high * (s * std::f32::consts::TAU).sin()
- }
- }
-}
-
-/// Crest amplitude as a fraction of `bevel_depth` — how much brighter a rim's silhouette
-/// line is than flat surface under even light. Must stay clearly below ~0.7 (the
-/// projection of a 135° light onto an axis edge), or it cancels the directional shadow
-/// on the dark side and the rim goes flat there instead of showing a faint bright line
-/// over a shadowed roll.
-const CREST_RATIO: f32 = 0.4;
-/// Shoulder/fillet amplitude as a fraction of `bevel_depth`.
-const AO_RATIO: f32 = 0.6;
-/// Per-sign overlay gains. These are asymmetric the opposite way from intuition: on the
-/// dark bases this DE runs, white-over blending (`b + a(1-b)`) moves the pixel far more
-/// per unit alpha than black-over (`b(1-a)`) — a dark surface has little brightness for
-/// black to take away. The old subtractive shading effectively crushed shadow sides to
-/// black in linear space; the black overlay needs a high gain to keep shadows reading
-/// at all, while white needs damping to keep highlights from blowing out.
-const LIGHT_GAIN: f32 = 0.7;
-const DARK_GAIN: f32 = 3.0;
-
-/// A shading value (already scaled by `bevel_depth`) as the two overlay passes: the lit
-/// pass is translucent white, the shadow pass translucent black. Painting the
-/// *modulation* instead of a resolved surface color is what lets relief primitives compose — a step
-/// crossing a rim shades the rim's gradient instead of stamping a flat band over it, a
-/// lip on a translucent plate no longer doubles its opacity, and a recess needs no
-/// knowledge of the surface color it carves.
-///
-/// Why two passes with fixed RGB rather than one signed color: a primitive whose value
-/// crosses zero inside a band would interpolate white→black through mid-gray at
-/// non-negligible alpha — on a dark base a *brightening* artifact right where the
-/// shading should vanish. With per-pass alphas clamped at the crossing, each pass fades
-/// to zero there and the hue can never be wrong. Alphas also stay non-negative on every
-/// vertex, which the renderer requires (negative alpha is the blur sentinel).
-#[inline]
-fn overlay_light(v: f32) -> [f32; 4] {
- [1.0, 1.0, 1.0, (v.max(0.0) * LIGHT_GAIN).min(1.0)]
-}
-#[inline]
-fn overlay_dark(v: f32) -> [f32; 4] {
- [0.0, 0.0, 0.0, ((-v).max(0.0) * DARK_GAIN).min(1.0)]
-}
-
-/// The signed distance range an edge's shading occupies, relative to the boundary.
-#[inline]
-fn bevel_span(kind: EdgeKind, t: f32) -> (f32, f32) {
- match kind {
- EdgeKind::Rim => (0.0, t),
- EdgeKind::Step => (-0.5 * t, 0.5 * t),
- }
-}
-
-/// How many gradient bands to slice a lip of thickness `t` into. Vertex colors interpolate
-/// linearly, so each band is a chord of the shading curve; one band per ~1.25px keeps the
-/// error under a shade step without emitting geometry finer than the display resolves.
-/// The cap rose with the curvature term: a step now has two features across its width
-/// (shoulder and fillet), so it needs double the samples a single bump did.
-fn default_bevel_bands(t: f32) -> usize {
- ((t / 1.25).ceil() as usize).clamp(1, 12)
-}
-
-/// As [`push_bevel_edge_vertices_radii`], with the band count forced and the walls
-/// selectable — for callers that want a coarser or finer roll-off than thickness alone
-/// implies, or that are shading a step rather than a closed shape.
-///
-/// `edges` is (top, right, bottom, left). Suppressing a wall matters for a region that
-/// runs flush to the surface's own edge: a full-width menubar sunk into the top of a plate
-/// is a *plateau one step down*, not a trough, so its only real wall is the one facing the
-/// content. Drawing the other three would carve a lip along the plate's outer edge, where
-/// the plate's own roll already lives, and the two would fight.
-pub fn push_bevel_edge_vertices_banded(
- x: f32, y: f32, ww: f32, h: f32,
- radii: (f32, f32, f32, f32),
- t: f32,
- sw: f32, sh: f32,
- base_color: [f32; 4],
- clip_circle: [f32; 3],
- light_sign: f32,
- bands: usize,
- edges: (bool, bool, bool, bool),
- kind: EdgeKind,
- out: &mut Vec<Vertex>,
-) {
- // Floored for the same reason as `plate_push_raised`'s cap: a negative
- // extent must degrade to no ring, not panic in `clamp`.
- let cap = (ww.min(h) * 0.5).max(0.0);
- let (tl, tr, br, bl) = (
- radii.0.clamp(0.0, cap),
- radii.1.clamp(0.0, cap),
- radii.2.clamp(0.0, cap),
- radii.3.clamp(0.0, cap),
- );
- let t = t.clamp(0.0, cap);
- if t <= 0.0 {
- return;
- }
- let bands = bands.max(1);
-
- let rad = crate::layout::light_source_position();
- let lx = rad.cos() * light_sign;
- let ly = -rad.sin() * light_sign;
- let depth = crate::layout::bevel_depth();
-
- // `base_color` is no longer painted: shading is an overlay (see `overlay_color`), so
- // the surface below shows through with its own gradients and translucency intact.
- let _ = base_color;
- // Shading (directional + curvature, scaled by bevel_depth) at signed distance `d`,
- // for an edge whose outward flat normal is `dir`. A `Step` band runs negative — it
- // straddles the boundary into the plateau outside the rect, which is exactly what
- // removes the seam.
- let value = |dot: f32, d: f32| {
- depth * (bevel_profile(kind, d, t) * dot + bevel_curvature(kind, d, t, light_sign))
- };
- // The (up to two) overlay color pairs for a band running from value `v0` to `v1`:
- // one white pair and/or one black pair, each pass fading to zero alpha wherever the
- // value has the other sign. Both fire only when the band straddles the terminator.
- let passes = |v0: f32, v1: f32| -> [Option<([f32; 4], [f32; 4])>; 2] {
- [
- (v0 > 0.0 || v1 > 0.0).then(|| (overlay_light(v0), overlay_light(v1))),
- (v0 < 0.0 || v1 < 0.0).then(|| (overlay_dark(v0), overlay_dark(v1))),
- ]
- };
- let (span_lo, span_hi) = bevel_span(kind, t);
-
- // Each flat edge spans between its two adjoining corner radii, not a single uniform
- // inset — that is what lets the corners differ. At a square corner there is no arc to
- // cover the t×t patch where two edges meet, so the horizontal edges claim it (they run
- // the full span) and the vertical ones inset by `t`; overlapping them instead would
- // double-blend that patch, which shows as a dark notch on a translucent surface.
- let (left_top, left_bot) = (if tl > 0.0 { tl } else { t }, if bl > 0.0 { bl } else { t });
- let (right_top, right_bot) = (if tr > 0.0 { tr } else { t }, if br > 0.0 { br } else { t });
- let top_w = ww - tl - tr;
- let bottom_w = ww - bl - br;
- let left_h = h - left_top - left_bot;
- let right_h = h - right_top - right_bot;
-
- for k in 0..bands {
- let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
- let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
- let bw = d1 - d0;
-
- // Top: outward normal (0,-1); the gradient runs downward, into the surface.
- if top_w > 0.0 && edges.0 {
- let (v0, v1) = (value(-ly, d0), value(-ly, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + tl, y + d0, top_w, bw, sw, sh, c0, c0, c1, c1, clip_circle,
- ));
- }
- }
- // Bottom: outward normal (0,1); gradient runs upward.
- if bottom_w > 0.0 && edges.2 {
- let (v0, v1) = (value(ly, d0), value(ly, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + bl, y + h - d1, bottom_w, bw, sw, sh, c1, c1, c0, c0, clip_circle,
- ));
- }
- }
- // Left: outward normal (-1,0); gradient runs rightward.
- if left_h > 0.0 && edges.3 {
- let (v0, v1) = (value(-lx, d0), value(-lx, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + d0, y + left_top, bw, left_h, sw, sh, c0, c1, c1, c0, clip_circle,
- ));
- }
- }
- // Right: outward normal (1,0); gradient runs leftward.
- if right_h > 0.0 && edges.1 {
- let (v0, v1) = (value(lx, d0), value(lx, d1));
- for (c0, c1) in passes(v0, v1).into_iter().flatten() {
- out.extend_from_slice(&quad_vertices_shaded(
- x + ww - d1, y + right_top, bw, right_h, sw, sh, c1, c0, c0, c1, clip_circle,
- ));
- }
- }
- }
-
- // A corner arc belongs to both of its adjoining walls, so it is drawn only when both
- // are — otherwise a suppressed wall would still get a quarter of a lip.
- let corners = [
- (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, edges.0 && edges.3), // Top-Left
- (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, edges.0 && edges.1), // Top-Right
- (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI, edges.2 && edges.1), // Bottom-Right
- (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, edges.2 && edges.3), // Bottom-Left
- ];
-
- for &(cx, cy, r, start_angle, end_angle, enabled) in &corners {
- // A square corner has no arc to sweep — the flat edges already met there.
- if r <= 0.0 || !enabled {
- continue;
- }
- // The corner is a quarter of a torus: shading varies along the sweep (the normal
- // swings through 90° of the light) *and* across the lip (the roll-off). Both come
- // out of the vertex colors, so one quad per (segment × band) cell is enough — no
- // faceting, unlike the 16 flat wedges this replaced.
- let segments = ((r * 0.75) as usize).clamp(8, 48);
- let ct = t.min(r);
- for j in 0..segments {
- let theta0 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
- let theta1 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
- let (cos0, sin0) = (theta0.cos(), theta0.sin());
- let (cos1, sin1) = (theta1.cos(), theta1.sin());
- for k in 0..bands {
- let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
- let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
- // Inward along the corner's radius is the same signed distance as inward
- // from a flat edge, so the arc scales the span the same way.
- let (r0, r1) = (r - ct * (d0 / t), r - ct * (d1 / t));
- let p = |rho: f32, c: f32, s: f32| -> [f32; 2] {
- [
- ((cx + rho * c) / sw) * 2.0 - 1.0,
- 1.0 - ((cy + rho * s) / sh) * 2.0,
- ]
- };
- // Outer/inner × the two sweep ends; each vertex gets its own value, and
- // the cell is drawn once per overlay pass that has any coverage.
- let vals = [
- value(cos0 * lx + sin0 * ly, d0),
- value(cos1 * lx + sin1 * ly, d0),
- value(cos1 * lx + sin1 * ly, d1),
- value(cos0 * lx + sin0 * ly, d1),
- ];
- let geo = [
- p(r0, cos0, sin0),
- p(r0, cos1, sin1),
- p(r1, cos1, sin1),
- p(r1, cos0, sin0),
- ];
- let mut cells: [Option<fn(f32) -> [f32; 4]>; 2] = [None, None];
- if vals.iter().any(|&v| v > 0.0) {
- cells[0] = Some(overlay_light);
- }
- if vals.iter().any(|&v| v < 0.0) {
- cells[1] = Some(overlay_dark);
- }
- for f in cells.into_iter().flatten() {
- let c: Vec<Vertex> = (0..4)
- .map(|i| Vertex { position: geo[i], color: f(vals[i]), clip_circle })
- .collect();
- out.extend_from_slice(&[c[0], c[1], c[2], c[0], c[2], c[3]]);
- }
- }
- }
- }
-}
-
-/// How strong the face gradient is, as a fraction of `bevel_depth` at the corner nearest
-/// the light. Deliberately well below the edge amplitude: the face is a plane, not a
-/// roll — it only *leans* toward the light.
-const FACE_RATIO: f32 = 0.35;
-
-/// The face lighting of a plate: a single diagonal luminance gradient across the whole
-/// surface, brightest at the corner facing `light_source_position` and darkest at the
-/// opposite one. This is the difference between an object and a sticker: a real surface
-/// under directional light is never uniform, and a perfectly flat fill makes the eye
-/// read the (much smaller) edge shading as frame decoration rather than shape.
-///
-/// Emitted as the same two-pass white/black overlays as the relief primitives (see
-/// [`overlay_light`]/[`overlay_dark`]): fixed RGB per pass, per-corner alphas clamped at
-/// the terminator, bilinear across the quad. The quad is square — its corners poke past
-/// a rounded plate's arcs — but the compositor clips the window surface to the same
-/// radius, so the overhang never reaches the screen.
-pub fn push_plate_face_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- sw: f32, sh: f32,
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let rad = crate::layout::light_source_position();
- let (lx, ly) = (rad.cos(), -rad.sin());
- let amp = crate::layout::bevel_depth() * FACE_RATIO;
- // Corner value = how much its outward diagonal faces the light.
- let inv = std::f32::consts::FRAC_1_SQRT_2;
- let v_tl = amp * inv * (-lx - ly);
- let v_tr = amp * inv * (lx - ly);
- let v_br = amp * inv * (lx + ly);
- let v_bl = amp * inv * (-lx + ly);
- let vs = [v_tl, v_tr, v_br, v_bl];
- if vs.iter().any(|&v| v > 0.0) {
- out.extend_from_slice(&quad_vertices_shaded(
- x, y, ww, h, sw, sh,
- overlay_light(v_tl), overlay_light(v_tr), overlay_light(v_br), overlay_light(v_bl),
- clip_circle,
- ));
- }
- if vs.iter().any(|&v| v < 0.0) {
- out.extend_from_slice(&quad_vertices_shaded(
- x, y, ww, h, sw, sh,
- overlay_dark(v_tl), overlay_dark(v_tr), overlay_dark(v_br), overlay_dark(v_bl),
- clip_circle,
- ));
- }
-}
-
-pub fn push_plate_solid_border_vertices(
- x: f32, y: f32, ww: f32, h: f32,
- radii: crate::widget::CornerRadii,
- t: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let mut r_tl = radii.top_left.max(0.0);
- let mut r_tr = radii.top_right.max(0.0);
- let mut r_br = radii.bottom_right.max(0.0);
- let mut r_bl = radii.bottom_left.max(0.0);
-
- // Simple scale clamping
- let sum_top = r_tl + r_tr;
- if sum_top > ww {
- let f = ww / sum_top;
- r_tl *= f;
- r_tr *= f;
- }
- let sum_bottom = r_bl + r_br;
- if sum_bottom > ww {
- let f = ww / sum_bottom;
- r_bl *= f;
- r_br *= f;
- }
- let sum_left = r_tl + r_bl;
- if sum_left > h {
- let f = h / sum_left;
- r_tl *= f;
- r_bl *= f;
- }
- let sum_right = r_tr + r_br;
- if sum_right > h {
- let f = h / sum_right;
- r_tr *= f;
- r_br *= f;
- }
-
- out.extend_from_slice(&quad_vertices_with_clip(x + r_tl, y, ww - r_tl - r_tr, t, sw, sh, color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x, y + r_tl, t, h - r_tl - r_bl, sw, sh, color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x + r_bl, y + h - t, ww - r_bl - r_br, t, sw, sh, color, clip_circle));
- out.extend_from_slice(&quad_vertices_with_clip(x + ww - t, y + r_tr, t, h - r_tr - r_br, sw, sh, color, clip_circle));
-
- let segments = 16;
- let corner_e = 2.0 / crate::layout::corner_shape();
-
- // Corner strokes as annulus strips between the outer superellipse (radius
- // r) and its inner scaled copy (r - t): at 1px thickness the scaled inner
- // curve is indistinguishable from the true parallel curve, and at
- // corner_shape 2 this is exactly the circular arc annulus. NOT
- // push_arc_background_vertices — that stays circular for genuine arcs.
- //
- // Both edges of the annulus are FEATHERED, like the fill fan's corners
- // (push_rounded_rect_vertices_corners) and push_feathered_line_vertices:
- // each fades over `f` either side of its true curve, so the 50%-coverage
- // lines stay on the exact silhouette and the stroke reads at the same
- // weight, but the arc anti-aliases instead of rasterizing a staircase
- // beside the SDF-smooth face it outlines. The straight edges stay crisp
- // quads (a pixel-snapped hairline would only blur). A corner too tight to
- // fit the inner fade keeps the hard annulus.
- let f = 0.5f32.min(t * 0.25);
- let fade = [color[0], color[1], color[2], 0.0];
- let corner = |cx: f32, cy: f32, r: f32, start: f32, end: f32, out: &mut Vec<Vertex>| {
- let r_in = (r - t).max(0.0);
- // (inner radius, outer radius, inner alpha colour, outer alpha colour)
- let bands: &[(f32, f32, [f32; 4], [f32; 4])] = if r_in - f > 0.0 {
- &[
- (r_in - f, r_in + f, fade, color),
- (r_in + f, r - f, color, color),
- (r - f, r + f, color, fade),
- ]
- } else {
- &[(r_in, r, color, color)]
- };
- let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
- for i in 0..segments {
- let t1 = start + (i as f32) * (end - start) / segments as f32;
- let t2 = start + ((i + 1) as f32) * (end - start) / segments as f32;
- let (c1, s1) = superellipse_pt(t1, corner_e);
- let (c2, s2) = superellipse_pt(t2, corner_e);
- for &(ra, rb, ca, cb) in bands {
- if rb - ra <= 0.0 {
- continue;
- }
- let o1 = ndc(cx + rb * c1, cy + rb * s1);
- let o2 = ndc(cx + rb * c2, cy + rb * s2);
- let i1 = ndc(cx + ra * c1, cy + ra * s1);
- let i2 = ndc(cx + ra * c2, cy + ra * s2);
- out.push(Vertex { position: o1, color: cb, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i2, color: ca, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- }
- }
- };
-
- if r_tl > 0.1 {
- corner(x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, out);
- }
- if r_tr > 0.1 {
- corner(x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, out);
- }
- if r_br > 0.1 {
- corner(x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI, out);
- }
- if r_bl > 0.1 {
- corner(x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, out);
- }
-}
-
-pub fn push_plate_solid_border_vertices_legacy(
- x: f32, y: f32, ww: f32, h: f32,
- r: f32,
- t: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let radii = crate::widget::CornerRadii::uniform(r);
- push_plate_solid_border_vertices(x, y, ww, h, radii, t, sw, sh, color, clip_circle, out);
-}
-
-pub fn widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3]) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_widget_vertices(w, sw, sh, clip_circle, &mut verts);
- verts
-}
-
-pub fn push_widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3], out: &mut Vec<Vertex>) {
- let (x, y, ww, h) = w.rect();
- let radii = w.corner_radii();
- if let Some(thickness) = w.plate_bevel() {
- let t = thickness;
- // Full-size fill: the bevel lip is a shading overlay now, not a paint of the
- // outer ring, so an inset fill would leave the ring unfilled.
- push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
- push_plate_bevel_vertices(x, y, ww, h, radii.top_left, t, sw, sh, w.color(), clip_circle, out);
- } else {
- push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
- if let Some((color, thickness)) = w.solid_border() {
- push_plate_solid_border_vertices(x, y, ww, h, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
-
- for (cx, cy, r, t, start, end, qc) in w.extra_arcs() {
- push_arc_background_vertices(cx, cy, r, t, start, end, sw, sh, qc, 16, clip_circle, out);
- }
-}
-
-/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
-/// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
-/// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
-/// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
-pub struct DlBatch {
- pub scissor: Option<crate::scene::layout::Rect>,
- pub clip_rrect: Option<[f32; 5]>,
- pub start: u32,
- pub end: u32,
- /// When set, this batch is one SDF-lit plate cover quad (see
- /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
- pub plate: Option<crate::vk::PlatePush>,
- /// A blur-behind plate (negative-alpha color): before drawing this batch
- /// the renderer snapshots the swapchain-so-far into its snapshot image, so
- /// the blur samples everything painted beneath the plate — not just the 3D
- /// scene backdrop. Never merged.
- pub blur_behind: bool,
-}
-
-/// An image draw from the display list: `at` is the vertex index it sorts
-/// before (its position in the tessellated stream); `clip` is the item's
-/// paint-walk clip. Logical coordinates throughout.
-pub struct DlImage {
- pub image: u32,
- pub rect: crate::scene::layout::Rect,
- pub alpha: f32,
- pub at: u32,
- pub clip: Option<crate::scene::layout::Rect>,
-}
-
-/// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
-/// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
-/// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
-/// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
-/// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
-/// merged into one batch (the circle clip is per-vertex, so it never splits batches).
-/// `CCE_PLATE_DEBUG=1` — trace which carves group into their host plate as exact
-/// CSG features and which fall back to the standalone overlay shading.
-///
-/// The two paths do NOT look the same: a grouped carve is part of the plate's
-/// single height field, so its wall meets the plate's rolled perimeter as a real
-/// junction, while the fallback approximates that with the host-box fade. Six
-/// conditions decide it, three of them dynamic (draw order, neighbouring plates,
-/// whether another carve already claimed the host's feature run), so the SAME
-/// widget can render either way depending on what is around it — and it does so
-/// silently. That has already shipped as a bug once: a hovered button's opaque
-/// fill used to sever every later button from the root plate they carve into,
-/// which is why `plate_stack` is a stack (see its comment below).
-///
-/// Off by default and read once; the classification below runs only when set.
-/// Prim discriminant name, for `CCE_PLATE_DEBUG` reporting only.
-fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
- use crate::scene::paint::Prim as P;
- match p {
- P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
- P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
- P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
- P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Field { .. } => "Field", P::Plate { .. } => "Plate",
- P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
- P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
- P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
- P::DropletScrim { .. } => "DropletScrim",
- P::ConcaveFillet { .. } => "ConcaveFillet",
- P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice", P::Grout { .. } => "Grout", P::Fill { .. } => "Fill",
- P::CarveUnion { .. } => "CarveUnion", P::Glow { .. } => "Glow",
- P::Text { .. } => "Text", P::Image { .. } => "Image",
- }
-}
-
-fn plate_debug() -> bool {
- static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
- *ON.get_or_init(|| std::env::var("CCE_PLATE_DEBUG").is_ok_and(|v| v != "0"))
-}
-
-/// Debug builds make one kind of fallback LOUD without `CCE_PLATE_DEBUG`: a
-/// carve that could group (full ring, untinted) failing to while a still-open
-/// plate encloses it and the carve's shaded region reaches that plate's
-/// perimeter roll. There the grouped and overlay paths shade the junction
-/// differently, and the rejection is one of the dynamic rules — so the SAME
-/// widget can flip looks frame to frame with nothing on stderr. Not an
-/// assert/panic: every rejection is conservative-CORRECT (the audit that
-/// shipped CCE_PLATE_DEBUG found no misgrouping; a later plate overlapping the
-/// carve genuinely must be shaded over, not under) — it is the frame-to-frame
-/// LOOK that flips, so the right loudness is an unmissable warning, not a
-/// crash. The ubiquitous quiet case stays quiet by construction: ordinary
-/// geometry closing every grouping window empties `plate_stack`, so no
-/// enclosing OPEN plate exists and this never runs — that is draw-order
-/// design, not a flip.
-///
-/// Returns the dynamic rule to report, or `None` when the fallback is not the
-/// loud case. Pure so the classification is unit-testable; `later_plates` are
-/// the open plates emitted after the enclosing host.
-#[cfg(debug_assertions)]
-fn near_roll_fallback_reason(
- carve: &crate::scene::layout::Rect,
- depth: f32,
- host: &crate::scene::layout::Rect,
- roll: f32,
- later_plates: &[crate::scene::layout::Rect],
- budget_full: bool,
-) -> Option<&'static str> {
- // The carve's shaded region — the overlay path's cover-quad inflation.
- let infl = depth * 0.5 + 2.0;
- let (sx0, sy0) = (carve.x - infl, carve.y - infl);
- let (sx1, sy1) = (carve.x + carve.width + infl, carve.y + carve.height + infl);
- // "Near the roll" = the shaded region leaves the host rect deflated by the
- // host's own roll width on any side.
- let near = sx0 < host.x + roll
- || sy0 < host.y + roll
- || sx1 > host.x + host.width - roll
- || sy1 > host.y + host.height - roll;
- if !near {
- return None;
- }
- // The dynamic rules, in the order the grouping guard tests them.
- if budget_full {
- return Some("the feature budget is full");
- }
- if later_plates
- .iter()
- .any(|o| sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y)
- {
- return Some("a later plate overlaps the carve's shaded region");
- }
- Some("the host's feature run is closed (another plate appended features since)")
-}
-
-/// Print a near-roll fallback warning once per distinct message — a carve in a
-/// steady layout would otherwise repeat it every frame.
-#[cfg(debug_assertions)]
-fn plate_carve_warn_once(msg: String) {
- use std::sync::{Mutex, OnceLock};
- static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
- let seen = SEEN.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
- if seen.lock().unwrap().insert(msg.clone()) {
- eprintln!("{msg}");
- }
-}
-
-pub fn tessellate_display_list(
- dl: &crate::scene::paint::DisplayList,
- sw: f32,
- sh: f32,
- scale: f32,
-) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
- use crate::scene::material::PlateRole;
- use crate::scene::paint::{Cap, Prim};
- let mut verts: Vec<Vertex> = Vec::new();
- let mut batches: Vec<DlBatch> = Vec::new();
- let mut images: Vec<DlImage> = Vec::new();
- // Carves CSG'd into plates (see Frame2D::plate_features), plus the plate
- // they group into: the most recent Plate/Bevel batch, provided only Text
- // and Image prims (which draw through separate paths anyway) intervene.
- let mut features: Vec<[f32; 12]> = Vec::new();
- // Open carve-host plates, in emission order (innermost candidates last).
- // A STACK, not a single slot: a sibling plate emitted between a root plate
- // and its later carves (a hovered button's opaque fill among transparent
- // ones) must not sever those carves from the root plate they are carved
- // into — that severing rendered every button after the hovered one
- // through the visually-different overlay fallback. Ordinary geometry
- // still closes every open plate (the draw-order rule below).
- let mut plate_stack: Vec<(usize, crate::scene::layout::Rect)> = Vec::new();
- // Which plate last appended a carve feature: a plate's features are
- // addressed as one contiguous [offset, count] run (PlatePush::host), so a
- // plate may only receive MORE features while no other plate has appended
- // any since.
- let mut last_feature_plate: Option<usize> = None;
- // `CCE_PLATE_DEBUG` bookkeeping — see `plate_debug`.
- let dbg_plates = plate_debug();
- let mut dbg_grouped = 0usize;
- let mut dbg_fell_back: Vec<String> = Vec::new();
- let mut dbg_opened = 0usize;
- // Which prim kind closed a still-open grouping window, and how many plates
- // it closed — the answer to "why was there no enclosing plate?".
- let mut dbg_closed_by: std::collections::BTreeMap<&'static str, usize> =
- std::collections::BTreeMap::new();
-
- // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
- // shading, plus the frame-constant lighting inputs it pushes per plate.
- let shader_plates = crate::layout::bevel_shader();
- // Light and material come from `scene::relief_shade`, which is also what
- // cce-relief predicts pixels with — one definition, so the editor cannot
- // draw a different material than the renderer applies.
- let plate_light = crate::scene::relief_shade::light_vector();
- // [shading strength (1.0 at the default bevel_depth), specular strength,
- // shininess, curvature/AO strength] — the DE's finish, for the CARVES,
- // which shade whatever is beneath them and so take the host's. A prim
- // that carries a Material (Plate, Bevel, Sphere, Droplet) pushes its own
- // `material.finish` instead. Curvature is kept near the raised path's
- // crest amplitude: the recess shoulder's brightening lands on the same
- // pixels as its specular line, and the two stack — at 0.5 the step read
- // several times hotter than a plate roll.
- let plate_mat = crate::scene::material::Finish::from_style().to_array();
-
- for item in &dl.items {
- let mut start = verts.len() as u32;
- let mut plate: Option<crate::vk::PlatePush> = None;
- // A frosted flat fill promoted to a zero-depth plate batch (below):
- // it carries a recipe like any plate, but it is ordinary geometry to
- // the carve grouping — it opens no host and closes the open ones.
- let mut promoted = false;
- let mut made_plate: Option<crate::scene::layout::Rect> = None;
- // Blur-behind marker: a prim whose FILL alpha is negative asks the
- // renderer to snapshot the frame-so-far before it draws. Every
- // fill-bearing prim counts — the shader's a<0 branch runs for all of
- // them, and a variant missing here still frosts, but against the
- // stale scene backdrop instead of the frame: a flat tint with no
- // content and no blur, which is how the Dropdown popover (Border)
- // and the menubar panels (Quad) shipped visibly unfrosted while the
- // context menu (Plate) worked.
- let mut blur_behind = matches!(
- &item.prim,
- crate::scene::paint::Prim::Quad { color, .. }
- | crate::scene::paint::Prim::RoundedRect { color, .. } if color[3] < 0.0
- ) || matches!(
- &item.prim,
- crate::scene::paint::Prim::Bevel { material, .. }
- | crate::scene::paint::Prim::Plate { material, .. }
- | crate::scene::paint::Prim::Droplet { material, .. }
- if material.fill(PlateRole::Nested)[3] < 0.0
- ) || matches!(
- &item.prim,
- crate::scene::paint::Prim::Border { fill, .. } if fill[3] < 0.0
- ) || matches!(
- &item.prim,
- crate::scene::paint::Prim::Fill { material, .. } if material.fill(PlateRole::Nested)[3] < 0.0
- );
- // Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
- let no = item
- .clip_circle
- .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
- .unwrap_or([0.0f32, 0.0, 0.0]);
- // Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
- // the fan with the PHYSICAL radius (capped — beyond 128 the chord error is
- // subpixel even on HiDPI).
- let segs = |radius: f32| -> usize { ((radius * scale) as usize).clamp(16, 128) };
- match &item.prim {
- Prim::Text { .. } => continue, // text goes through the glyph/text-span path
- Prim::Image { image, rect, alpha } => {
- images.push(DlImage {
- image: *image,
- rect: *rect,
- alpha: *alpha,
- at: verts.len() as u32,
- clip: item.clip,
- });
- continue;
- }
- // A frosted FLAT fill — a `Flat` control face, a menu panel, a
- // popover, an inset plate's face — is a zero-depth plate batch
- // (RFC material § 6.2): the same shader path as every plate, so
- // it carries its own frost recipe instead of a window-wide one,
- // with the configured `corner_shape` and no roll, which is what the
- // tessellated fill drew. The display list is untouched, so the
- // legacy bridges that extract RoundedRects still see one.
- Prim::Quad { rect, color } if shader_plates && color[3] < 0.0 => {
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
- plate = Some(flat_frost_push(rect, (0.0, 0.0, 0.0, 0.0), *color, scale, plate_light, plate_mat));
- promoted = true;
- }
- Prim::RoundedRect { rect, radius, corners, color } if shader_plates && color[3] < 0.0 => {
- let radii = (
- if corners.0 { *radius } else { 0.0 },
- if corners.1 { *radius } else { 0.0 },
- if corners.2 { *radius } else { 0.0 },
- if corners.3 { *radius } else { 0.0 },
- );
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
- plate = Some(flat_frost_push(rect, radii, *color, scale, plate_light, plate_mat));
- promoted = true;
- }
- Prim::Fill { rect, radii, material } if shader_plates && material.frost.is_frosted() => {
- // A material's flat fill: the frosted promotion above with
- // the MATERIAL's recipe (compression, refraction, radius)
- // instead of the DE default's. Zero depth, the configured
- // corner shape, no host — exactly a promoted RoundedRect.
- let color = material.fill(PlateRole::Nested);
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- let mut p = plate_push_raised(rect, *radii, 0.0, scale, plate_light, plate_mat, false, None);
- let [fz, fw] = material.frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- plate = Some(p);
- promoted = true;
- }
- Prim::Fill { rect, radii, material } => {
- // Opaque (or the legacy path): a plain rounded fill.
- let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, material.fill(PlateRole::Nested), no, None, &mut verts);
- }
- Prim::Border { rect, radii, fill, border, thickness } if shader_plates && fill[3] < 0.0 => {
- // The fill as its own plate batch, closed here; the stroke
- // follows as ordinary geometry in the batch the tail makes.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *fill));
- let p = flat_frost_push(rect, *radii, *fill, scale, plate_light, plate_mat);
- let end = verts.len() as u32;
- plate_stack.clear();
- batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate: Some(p), blur_behind: true });
- start = end;
- blur_behind = false;
- let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
- push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
- }
- Prim::Quad { rect, color } => {
- // Quads honor an active circle clip like circles/arcs do (the
- // Ramp's foam-cell fills draw as clipped strips).
- verts.extend(quad_vertices_with_clip(rect.x, rect.y, rect.width, rect.height, sw, sh, *color, no));
- }
- Prim::RoundedRect { rect, radius, corners, color } => {
- let radii = crate::widget::CornerRadii::new(
- if corners.0 { *radius } else { 0.0 },
- if corners.1 { *radius } else { 0.0 },
- if corners.2 { *radius } else { 0.0 },
- if corners.3 { *radius } else { 0.0 },
- );
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
- }
- Prim::Border { rect, radii, fill, border, thickness } => {
- let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, *fill, no, None, &mut verts);
- push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
- }
- Prim::Glow { rect, radius, reach, color } => {
- push_glow_vertices(rect.x, rect.y, rect.width, rect.height, *radius, *reach, sw, sh, *color, no, &mut verts);
- }
- Prim::Bevel { rect, radii, material, depth, tint } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // SDF-lit raised plate: one cover quad; the shader owns fill,
- // roll shading, corners, and silhouette AA. Nominal corner
- // radii (scale_corners false): a Bevel is a WIDGET-scale plate
- // whose silhouette must match the nominal-radius squircles of
- // the controls around it — only window-scale `Plate`s get the
- // curvature-matched span.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, false, None);
- // The plate's own frost recipe rides host.zw (see PlatePush).
- let [fz, fw] = material.frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- // w = 1 marks an accent-tinted plate (the focused-pane
- // treatment): the shader keeps the roll's light and shadow
- // and recolours them — light toward the tint, shadow toward
- // a dark tint — matching the free-carve path's tinted-well
- // convention. Neutral white keeps w = 0 (a no-op multiply).
- let full = if *tint == [1.0, 1.0, 1.0] { 0.0 } else { 1.0 };
- p.specular_tint = [tint[0], tint[1], tint[2], full];
- plate = Some(p);
- made_plate = Some(*rect);
- }
- Prim::Plate { rect, radii, material, depth, shape } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- if *depth < 0.0 {
- // Negative depth = fill-less roll overlay (MODE_ROLL): the
- // window-edge roll shading alone, screened over whatever is
- // beneath — for a root plate whose face is not a fill (the
- // designer's 3D canvas). The cover quad carries no color,
- // and the batch is NOT opened as a carve host: an overlay
- // owns no surface for a CSG feature to cut into.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
- let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, mat, true, *shape);
- p.mode = 11.0; // MODE_ROLL
- plate = Some(p);
- } else {
- // Same lit-plate branch; the cover quad is the exact rect so the
- // silhouette and the compositor's rounded window corners agree.
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, true, *shape);
- let [fz, fw] = material.frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- plate = Some(p);
- made_plate = Some(*rect);
- }
- }
- // A sunken well ending in a flush run (see `Prim::Field`): one
- // outline, one overlay — never grouped, its profile is not a
- // monotonic step. The cover quad inflates by half the wall, as a
- // free carve's does; the host-box slot carries the run's two
- // ends (physical px), since nothing fades against a host here.
- Prim::Field { rect, radii, depth, split, end, tint } if shader_plates => {
- let infl = *depth * 0.5 + 2.0;
- verts.extend(quad_vertices(
- rect.x - infl, rect.y - infl,
- rect.width + 2.0 * infl, rect.height + 2.0 * infl,
- sw, sh, [0.0; 4],
- ));
- let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
- p.mode = 16.0; // MODE_FIELD
- if let Some(t) = tint {
- p.specular_tint = [t[0], t[1], t[2], 1.0];
- }
- p.host = [*split * scale, *end * scale, 0.0, 0.0];
- plate = Some(p);
- }
- Prim::Recess { rect, radii, depth, edges, .. }
- | Prim::Boss { rect, radii, depth, edges, .. }
- | Prim::Ridge { rect, radii, depth, edges }
- | Prim::Trough { rect, radii, depth, edges, .. }
- if shader_plates =>
- {
- let tint = match &item.prim {
- Prim::Recess { tint, .. } => *tint,
- Prim::Boss { tint, .. } => *tint,
- Prim::Trough { tint, .. } => *tint,
- _ => None,
- };
- // Recess carves down into the surface; Boss raises a plateau out
- // of it (same machinery, depth sign flipped); Ridge is a raised
- // rim straddling the boundary and Trough the sunken valley twin
- // (their own overlay profiles — never grouped, the CSG features
- // only model monotonic steps).
- let mode = match &item.prim {
- Prim::Boss { .. } => 3.0f32,
- Prim::Ridge { .. } => 4.0,
- Prim::Trough { .. } => 9.0,
- _ => 2.0,
- };
- let raised = mode > 2.5;
- // Grouped into the enclosing plate whenever one is live: the
- // carve becomes a CSG feature of that plate's single draw —
- // exact composite shading, real junctions at the plate's rolled
- // perimeter — instead of a shading overlay (the fallback below).
- //
- // Edge-suppressed carves NEVER group: a suppressed wall's rect
- // extends past the carve (below), relying on the overlay cover
- // quad to keep that shading out of the drawn pixels — a clip
- // the plate's whole-surface draw does not have, so grouped it
- // smears the extended walls across the plate. Union pieces
- // (section wells, a spinbox's field and button run) are
- // exactly these.
- // A tinted carve also never groups: a CSG feature is geometry only,
- // so the tint could only land on the whole plate's specular.
- let full_ring = *edges == (true, true, true, true);
- let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::vk::MAX_PLATE_FEATURES {
- // The carve's shaded region, for the occlusion test below
- // (the overlay path's cover-quad inflation).
- let infl = *depth * 0.5 + 2.0;
- let (sx0, sy0) = (rect.x - infl, rect.y - infl);
- let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
- plate_stack
- .iter()
- .enumerate()
- .rev()
- .find(|(si, (bi, prect))| {
- let inside = rect.x >= prect.x - 0.5
- && rect.y >= prect.y - 0.5
- && rect.x + rect.width <= prect.x + prect.width + 0.5
- && rect.y + rect.height <= prect.y + prect.height + 0.5;
- if !inside {
- return false;
- }
- // Pixels drawn since this plate (a LATER plate in the
- // stack) must not overlap the carve — its shading would
- // land beneath them in this plate's earlier draw.
- if plate_stack[si + 1..].iter().any(|(_, orect)| {
- sx0 < orect.x + orect.width
- && sx1 > orect.x
- && sy0 < orect.y + orect.height
- && sy1 > orect.y
- }) {
- return false;
- }
- // Contiguity: only the last feature-receiving plate (or
- // one with no features yet) may take another.
- batches[*bi].plate.as_ref().map_or(false, |p| p.host[1] == 0.0)
- || last_feature_plate == Some(*bi)
- })
- .map(|(_, &(bi, _))| bi)
- } else {
- None
- };
- // Debug-build loudness for the silent grouped→overlay flip —
- // see `near_roll_fallback_reason` on what qualifies and why
- // this warns instead of panicking.
- #[cfg(debug_assertions)]
- if host_plate.is_none() && mode < 3.5 && full_ring && tint.is_none() {
- let enclosing = plate_stack.iter().enumerate().rev().find(|(_, (_, p))| {
- rect.x >= p.x - 0.5
- && rect.y >= p.y - 0.5
- && rect.x + rect.width <= p.x + p.width + 0.5
- && rect.y + rect.height <= p.y + p.height + 0.5
- });
- if let Some((si, &(bi, prect))) = enclosing {
- // Host roll width rides the push's light.w (physical px).
- let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
- let later: Vec<crate::scene::layout::Rect> =
- plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
- let budget_full = features.len() >= crate::vk::MAX_PLATE_FEATURES;
- if let Some(why) =
- near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
- {
- let kind = if mode > 2.5 { "boss" } else { "recess" };
- plate_carve_warn_once(format!(
- "plate-carve: near-roll {kind} ({:.0},{:.0} {:.0}x{:.0}) lost grouping — {why}; \
- its junction with the host plate's roll shades through the overlay fallback, \
- visually different from grouped frames (CCE_PLATE_DEBUG=1 traces verdicts) \
- [debug-build warning, printed once]",
- rect.x, rect.y, rect.width, rect.height
- ));
- }
- }
- }
- if dbg_plates {
- match host_plate {
- Some(_) => dbg_grouped += 1,
- None => {
- // Re-derive WHY, in the same order the guard tests
- // them. Debug-only: the hot path above is untouched.
- let kind = match &item.prim {
- Prim::Boss { .. } => "boss",
- Prim::Ridge { .. } => "ridge",
- Prim::Trough { .. } => "trough",
- _ => "recess",
- };
- let infl = *depth * 0.5 + 2.0;
- let (sx0, sy0) = (rect.x - infl, rect.y - infl);
- let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
- let enclosing: Vec<usize> = plate_stack
- .iter()
- .enumerate()
- .filter(|(_, (_, p))| {
- rect.x >= p.x - 0.5
- && rect.y >= p.y - 0.5
- && rect.x + rect.width <= p.x + p.width + 0.5
- && rect.y + rect.height <= p.y + p.height + 0.5
- })
- .map(|(si, _)| si)
- .collect();
- let occluded = |si: usize| {
- plate_stack[si + 1..].iter().any(|(_, o)| {
- sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y
- })
- };
- let why = if mode >= 3.5 {
- "ridge — never groups (its bump profile is not a monotonic step)".into()
- } else if !full_ring {
- format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
- } else if tint.is_some() {
- "tinted — a CSG feature is geometry only, it carries no color".into()
- } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
- format!("feature budget full ({} used)", features.len())
- } else if enclosing.is_empty() {
- format!("no enclosing plate ({} open)", plate_stack.len())
- } else if enclosing.iter().all(|&si| occluded(si)) {
- "a later plate overlaps this carve's shaded region".into()
- } else {
- "host plate's feature run is closed (another carve appended since)".into()
- };
- dbg_fell_back.push(format!(
- " overlay: {kind} ({:.0},{:.0} {:.0}x{:.0}) — {why}",
- rect.x, rect.y, rect.width, rect.height
- ));
- }
- }
- }
- if let Some(bi) = host_plate {
- {
- // A wall the carve shares with the plate's edge extends
- // past the plate, so the carve has no wall there.
- let ext = *depth + 4.0;
- let (mut x0, mut y0) = (rect.x, rect.y);
- let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
- if !edges.0 { y0 -= ext; }
- if !edges.1 { x1 += ext; }
- if !edges.2 { y1 += ext; }
- if !edges.3 { x0 -= ext; }
- let t_px = *depth * scale;
- // The carve's drop: the material's pinned height, else
- // the analytic ratio of the wall saturating at the DE's
- // roll width (`layout::carve_depth_px` states the rule
- // once for this path and the shader's free carves).
- let k_mag = crate::layout::carve_depth_px(*depth) * scale;
- // Negative depth = raised (Boss); the shader's summed
- // slope vectors and curvature sign follow it.
- let k_px = if raised { -k_mag } else { k_mag };
- if let Some(p) = batches[bi].plate.as_mut() {
- if p.host[1] == 0.0 {
- p.host[0] = features.len() as f32;
- }
- p.host[1] += 1.0;
- }
- last_feature_plate = Some(bi);
- features.push([
- (x0 + x1) * 0.5 * scale,
- (y0 + y1) * 0.5 * scale,
- (x1 - x0) * 0.5 * scale,
- (y1 - y0) * 0.5 * scale,
- radii.0 * scale,
- radii.1 * scale,
- radii.2 * scale,
- radii.3 * scale,
- t_px,
- k_px,
- 0.0,
- 0.0,
- ]);
- continue;
- }
- }
- // Overlay-only carve: the cover quad inflates by half the roll
- // width (the step straddles the boundary) and carries no color —
- // the shader emits translucent white/black over what's beneath.
- let infl = *depth * 0.5 + 2.0;
- verts.extend(quad_vertices(
- rect.x - infl, rect.y - infl,
- rect.width + 2.0 * infl, rect.height + 2.0 * infl,
- sw, sh, [0.0; 4],
- ));
- // A suppressed wall is pushed past the cover quad, so its
- // shading falls outside the drawn pixels (see Prim::Recess on
- // why a flush region is a step, not a trough).
- let ext = *depth + 4.0;
- let (mut x0, mut y0) = (rect.x, rect.y);
- let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
- if !edges.0 { y0 -= ext; }
- if !edges.1 { x1 += ext; }
- if !edges.2 { y1 += ext; }
- if !edges.3 { x0 -= ext; }
- let sdf_rect = crate::scene::layout::Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
- let mut p = plate_push_raised(&sdf_rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
- p.mode = mode;
- // w = 1.0 flags the free-carve shader path to composite its
- // light in the tint and its shadow in a dark tint instead of
- // white and black (plates leave w at 0.0).
- if let Some(t) = tint {
- p.specular_tint = [t[0], t[1], t[2], 1.0];
- }
- // Host-plate box for the roll fade: a suppressed wall means the
- // recess runs flush to the host's edge there, so that side of
- // the box sits at the original rect edge; enabled walls face
- // host interior, pushed to ±1e5 so no fade applies.
- const FAR: f32 = 1e5;
- let (hx0, hy0) = (
- if edges.3 { rect.x - FAR } else { rect.x },
- if edges.0 { rect.y - FAR } else { rect.y },
- );
- let (hx1, hy1) = (
- if edges.1 { rect.x + rect.width + FAR } else { rect.x + rect.width },
- if edges.2 { rect.y + rect.height + FAR } else { rect.y + rect.height },
- );
- p.host = [
- (hx0 + hx1) * 0.5 * scale,
- (hy0 + hy1) * 0.5 * scale,
- (hx1 - hx0) * 0.5 * scale,
- (hy1 - hy0) * 0.5 * scale,
- ];
- plate = Some(p);
- }
- Prim::Bevel { rect, radii, material, depth, tint: _ } => {
- let color = material.fill(PlateRole::Nested);
- // Full-size fill: the lip is now a shading overlay, not a paint of the
- // outer ring, so the fill must cover the whole rect (the old inset fill
- // would leave the ring showing whatever lay beneath).
- let corners = crate::widget::CornerRadii {
- top_left: radii.0, top_right: radii.1,
- bottom_right: radii.2, bottom_left: radii.3,
- };
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts);
- push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, color, no, &mut verts);
- }
- Prim::Plate { rect, radii, material, depth, .. } => {
- let color = material.fill(PlateRole::Nested);
- if *depth < 0.0 {
- // Fill-less roll overlay (negative-depth sentinel): the banded
- // legacy tessellation has no overlay compositing, so the roll
- // is simply absent here — the A/B path draws nothing rather
- // than a wrong fill.
- continue;
- }
- // Fill at full size (no inset — see Prim::Plate), then light the face,
- // then roll the perimeter. The lip rides on top of the fill's outer band
- // rather than replacing it, so the plate's silhouette and the
- // compositor's rounded window corners still agree exactly.
- let corners = crate::widget::CornerRadii {
- top_left: radii.0, top_right: radii.1,
- bottom_right: radii.2, bottom_left: radii.3,
- };
- push_rounded_rect_vertices_corners(
- rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts,
- );
- push_plate_face_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, no, &mut verts);
- push_bevel_edge_vertices_radii(
- rect.x, rect.y, rect.width, rect.height, *radii, *depth,
- sw, sh, color, no, 1.0, &mut verts,
- );
- }
- Prim::Recess { rect, radii, depth, edges, .. } => {
- // Edges only — no fill: the shading is an overlay, so whatever is painted
- // below (fill, rim gradient, blur) shows through the carve modulated
- // rather than repainted. `light_sign = -1.0` shadows the lit-facing edges,
- // which is the raised->recessed inversion.
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, *depth,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Boss { rect, radii, depth, edges, .. } => {
- // Legacy raised step: the recess overlay with the light sign upright.
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, *depth,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(*depth), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Ridge { rect, radii, depth, edges } => {
- // Legacy approximation: a raised step up at the boundary plus a
- // recessed step down half a width in (the banded machinery has no
- // bump profile; the double-pass hot crest is accepted here — the
- // legacy path exists only for A/B comparison).
- let half = *depth * 0.5;
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, half,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- let ir = (radii.0 - half).max(0.0);
- push_bevel_edge_vertices_banded(
- rect.x + half, rect.y + half,
- rect.width - *depth, rect.height - *depth,
- (ir, ir, ir, ir), half,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Trough { rect, radii, depth, edges, .. } => {
- // Legacy approximation, the Ridge arm's two steps with the light
- // signs swapped: down at the boundary, back up half a width in.
- // The banded machinery has no valley profile, so this is the old
- // stacked look — accepted here, as the legacy path exists only
- // for A/B comparison against the SDF one.
- let half = *depth * 0.5;
- push_bevel_edge_vertices_banded(
- rect.x, rect.y, rect.width, rect.height, *radii, half,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- let ir = (radii.0 - half).max(0.0);
- push_bevel_edge_vertices_banded(
- rect.x + half, rect.y + half,
- rect.width - *depth, rect.height - *depth,
- (ir, ir, ir, ir), half,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Field { rect, radii, depth, split, end, .. } => {
- // Legacy approximation: the two-box form `Prim::Field`
- // replaced — a well either side of the run a step down, the
- // run the Trough arm's down-then-up stack. The banded
- // machinery has no blended outline, and the legacy path
- // exists only for A/B comparison.
- let all = (true, true, true, true);
- let (fl, fr) = (rect.x, rect.x + rect.width);
- let (well_l, well_r) = (*split > fl, *end < fr);
- let rx = split.max(fl);
- let rw = (end.min(fr) - rx).max(0.0);
- if well_l {
- push_bevel_edge_vertices_banded(
- fl, rect.y, rx - fl, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
- EdgeKind::Step, &mut verts,
- );
- }
- if well_r {
- push_bevel_edge_vertices_banded(
- rx + rw, rect.y, fr - rx - rw, rect.height, (0.0, radii.1, radii.2, 0.0), *depth,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
- EdgeKind::Step, &mut verts,
- );
- }
- // A run's own corners where it reaches the outline; square at a seam.
- let (l0, l3) = if well_l { (0.0, 0.0) } else { (radii.0, radii.3) };
- let (r1, r2) = if well_r { (0.0, 0.0) } else { (radii.1, radii.2) };
- let half = *depth * 0.5;
- push_bevel_edge_vertices_banded(
- rx, rect.y, rw, rect.height, (l0, r1, r2, l3), half,
- sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), all,
- EdgeKind::Step, &mut verts,
- );
- let ir = if well_r { 0.0 } else { (radii.1 - half).max(0.0) };
- let il = if well_l { 0.0 } else { (radii.0 - half).max(0.0) };
- push_bevel_edge_vertices_banded(
- rx + half, rect.y + half, rw - *depth, rect.height - *depth, (il, ir, ir, il), half,
- sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
- EdgeKind::Step, &mut verts,
- );
- }
- Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
- push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
- }
- Prim::ArcShaded { cx, cy, radius, thickness, start: sa, end: ea, inner, crest, outer } => {
- push_arc_shaded_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *inner, *crest, *outer, segs(*radius), no, &mut verts);
- }
- Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
- let lc = match cap {
- Cap::Flat => LineCap::Flat,
- Cap::Round => LineCap::Round,
- Cap::Arrow => LineCap::Arrow,
- };
- verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
- }
- Prim::Circle { cx, cy, radius, color } => {
- if item.clip_circle.is_none() && *radius > 1.5 {
- // Cover quad with the disc itself as the (feathered) circle
- // clip: a per-pixel smooth silhouette instead of a hard-edged
- // fan. The quad overhangs by 1px for the feather. Only when
- // no ancestor clip holds the slot — then it's the fan path.
- let own = [cx * scale, cy * scale, radius * scale];
- let d = *radius + 1.0;
- verts.extend(quad_vertices_with_clip(
- cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color, own,
- ));
- } else {
- verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
- }
- }
- Prim::Sphere { cx, cy, radius, material } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // A hemisphere lit per pixel by the plate branch (mode 5): one
- // cover quad, its own never-merged batch. The quad overhangs
- // the disc by 1px for the shader's silhouette anti-aliasing.
- let d = *radius + 1.0;
- verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
- plate = Some(crate::vk::PlatePush {
- // Center + radius in physical px; the SDF box machinery is
- // unused in this mode, so .w is free.
- rect: [cx * scale, cy * scale, radius * scale, 0.0],
- radii: [0.0; 4],
- light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
- material: mat,
- host: [0.0; 4],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 5.0,
- shape: 2.0,
- });
- }
- Prim::Sphere { cx, cy, radius, material } => {
- let color = material.fill(PlateRole::Nested);
- // Legacy path: the flat disc, exactly a Circle.
- verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, color, segs(*radius), no));
- }
- Prim::DropletScrim { rect, material, spec, feather } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // Shader mode 12: the droplet's own SDF, filled flat and
- // feathered inward. No contact shadow, so unlike the lit drop
- // the cover quad is exactly the box — a scrim never draws
- // outside the silhouette.
- let g = droplet_geom(rect, spec);
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
- plate = Some(crate::vk::PlatePush {
- rect: [
- (rect.x + rect.width * 0.5) * scale,
- (rect.y + rect.height * 0.5) * scale,
- g.hx * scale,
- g.hy * scale,
- ],
- radii: [g.sag * scale, g.br * scale, g.bw * scale, g.k * scale],
- // p_light.w carries the FEATHER here; mode 12 returns
- // before the shading band it otherwise holds is read.
- light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
- material: [mat[0], 0.0, 0.0, 0.0],
- host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
- specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
- mode: 12.0,
- shape: spec.curve.clamp(2.0, 6.0),
- });
- }
- Prim::Droplet { rect, material, spec } if shader_plates => {
- let color = material.fill(PlateRole::Nested);
- let mat = material.finish.to_array();
- // A water droplet lit by shader mode 10: one cover quad; the
- // shader owns silhouette (sheet ∪smin belly), dome shading,
- // fresnel rim and thin-edge clarity. The spec's height
- // fractions resolve against the concrete rect here, clamped so
- // small or narrow boxes stay well-formed (a belly wider than
- // the box would turn the SDF interior inside out).
- // The cover quad grows sideways and BELOW the box by the
- // contact shadow's reach — shadow fragments live outside the
- // silhouette, so they need covered pixels to shade.
- let g = droplet_geom(rect, spec);
- let (hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach) =
- (g.hx, g.hy, g.sag, g.br, g.bw, g.k, g.sr, g.ar, g.band, g.bow, g.sh_reach);
- verts.extend(quad_vertices(
- rect.x - sh_reach,
- rect.y,
- rect.width + 2.0 * sh_reach,
- rect.height + sh_reach,
- sw, sh, color,
- ));
- plate = Some(crate::vk::PlatePush {
- rect: [
- (rect.x + rect.width * 0.5) * scale,
- (rect.y + rect.height * 0.5) * scale,
- hx * scale,
- hy * scale,
- ],
- radii: [sag * scale, br * scale, bw * scale, k * scale],
- light: [plate_light[0], plate_light[1], plate_light[2], band * scale],
- // Slots y/z/w feed roll_spec and the rim term directly:
- // a droplet's material carries its own gleam/shine/rim
- // there (`DropletSpec::finish`; a drop is wetter than the
- // DE's plates), so this is the material's finish like any
- // plate's.
- material: mat,
- host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
- // Droplet glints are always white, so the tint RGB slots
- // carry droplet params instead: x = core density,
- // y = contact-shadow reach px, z = shadow strength.
- specular_tint: [
- spec.core.clamp(0.0, 2.0),
- sh_reach * scale,
- spec.shadow.clamp(0.0, 1.0),
- bow * scale,
- ],
- mode: 10.0,
- shape: spec.curve.clamp(2.0, 6.0),
- });
- }
- Prim::DropletScrim { rect, material, spec, .. } => {
- let color = material.fill(PlateRole::Nested);
- // Legacy banded path: no SDF to feather against, so the scrim
- // degrades to the same flat outline the drop itself does —
- // hard-edged, but present. A prim with no arm here VANISHES.
- let cap = (rect.height * 0.5).min(rect.width * 0.5);
- let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
- let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
- let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
- }
- Prim::Droplet { rect, material, spec } => {
- let color = material.fill(PlateRole::Nested);
- // Legacy banded path: the flat drop outline — attach-tapered
- // top, round bottom. Degrades the material but keeps the
- // silhouette (a prim with no arm here VANISHES, it doesn't
- // degrade — see Ridge/Groove above).
- let cap = (rect.height * 0.5).min(rect.width * 0.5);
- let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
- let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
- let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
- push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
- }
- Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
- // A quarter-arc carve wall (shader mode 6/7): one cover quad
- // over the wedge's reach; the wall straddles the arc by ±t/2
- // like every carve boundary. p_rect carries centre + radius,
- // p_radii.x the wedge start angle. Host box pushed far out —
- // an inside-corner fillet never fades.
- let m = *depth * 0.5 + 2.0;
- let r = *radius + m;
- verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
- plate = Some(crate::vk::PlatePush {
- rect: [cx * scale, cy * scale, *radius * scale, 0.0],
- radii: [*a0, 0.0, 0.0, 0.0],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- material: plate_mat,
- host: [0.0, 0.0, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: if *raised { 7.0 } else { 6.0 },
- shape: crate::layout::corner_shape(),
- });
- }
- // Legacy banded path has no radial wall — the composed corner
- // stays square there (A/B comparison path only).
- Prim::ConcaveFillet { .. } => {}
- Prim::Groove { a, b, width, depth, host, strength } if shader_plates => {
- // A slab carve about the line a–b (shader mode 8): the cover
- // quad is the segment's bounding box grown by the groove's own
- // half-width plus the wall's reach. Off-band corners of that
- // box sit at u = 1 (plateau), so the box overhang shades
- // nothing — the slab is what bounds the mark, not the quad.
- let m = *width * 0.5 + *depth * 0.5 + 2.0;
- let (x0, x1) = (a.0.min(b.0) - m, a.0.max(b.0) + m);
- let (y0, y1) = (a.1.min(b.1) - m, a.1.max(b.1) + m);
- verts.extend(quad_vertices(x0, y0, x1 - x0, y1 - y0, sw, sh, [0.0; 4]));
- // Unit normal of the line — the direction the slab's distance is
- // measured along. A degenerate segment falls back to vertical so
- // a zero-length groove is a no-op wall rather than a NaN.
- let (dx, dy) = (b.0 - a.0, b.1 - a.1);
- let len = (dx * dx + dy * dy).sqrt();
- let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
- plate = Some(crate::vk::PlatePush {
- // Centre + slab half-width in physical px; .w unused.
- rect: [
- (a.0 + b.0) * 0.5 * scale,
- (a.1 + b.1) * 0.5 * scale,
- *width * 0.5 * scale,
- 0.0,
- ],
- radii: [n.0, n.1, 0.0, 0.0],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- // The finish's shading, specular and AO, at the groove's
- // strength; shininess is a shape, not an amount.
- material: {
- let s = strength.clamp(0.0, 1.0);
- [plate_mat[0] * s, plate_mat[1] * s, plate_mat[2], plate_mat[3] * s]
- },
- host: [
- (host.x + host.width * 0.5) * scale,
- (host.y + host.height * 0.5) * scale,
- host.width * 0.5 * scale,
- host.height * 0.5 * scale,
- ],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 8.0,
- shape: crate::layout::corner_shape(),
- });
- }
- Prim::Groove { a, b, width, depth, host: _, strength } => {
- // Legacy approximation. The banded tessellators walk BOX edges —
- // exactly the axis-aligned assumption a groove exists to escape —
- // so the walls are drawn directly as two feathered lines meeting
- // at the centerline: the engraved-line fake, one half in shadow
- // and one lit. Coarser than the SDF (no profile curve, no host
- // fade), but this path exists for A/B comparison, and drawing
- // NOTHING would silently delete the mark rather than degrade it
- // — see `Prim::Ridge` above, which accepts a hot crest for the
- // same reason.
- let (dx, dy) = (b.0 - a.0, b.1 - a.1);
- let len = (dx * dx + dy * dy).sqrt();
- if len < 0.001 {
- continue;
- }
- let n = (-dy / len, dx / len);
- // Same convention as `push_bevel_edge_vertices_banded`: the
- // light folded through `light_sign` (-1.0 — a groove is a
- // carve), dotted with each wall's OUTWARD normal, amplitude on
- // `bevel_depth`. So a groove re-lights with the DE's light
- // instead of hardcoding which side is dark.
- let rad = crate::layout::light_source_position();
- let (lx, ly) = (-rad.cos(), rad.sin());
- let v = crate::layout::bevel_depth() * (n.0 * lx + n.1 * ly);
- // Each wall covers its own half, centreline to outer edge —
- // abutting rather than overlapping. The SDF gets away with
- // walls that overlap across a sub-pixel floor because it is one
- // evaluation of |distance|; two opposite-signed overlays would
- // just blend to mud.
- let half = (*width * 0.5 + *depth * 0.5).max(0.5);
- for side in [1.0f32, -1.0] {
- let sv = v * side;
- let mut c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
- c[3] *= strength.clamp(0.0, 1.0);
- if c[3] <= 0.0 {
- continue;
- }
- let off = side * half * 0.5;
- push_feathered_line_vertices(
- a.0 + n.0 * off, a.1 + n.1 * off,
- b.0 + n.0 * off, b.1 + n.1 * off,
- half, sw, sh, c, &mut verts,
- );
- }
- }
- Prim::Lattice { rect, period, origin, cell, radius, depth } if shader_plates => {
- // A periodic well field (shader mode 13): one cover quad over
- // `rect`; the shader folds each pixel into the period and
- // measures the nearest cell, so the whole lattice is a single
- // evaluation. p_rect = one cell's centre + half-extents,
- // p_radii = the corner radius, p_host.xy = the period; the
- // host-box fade sides are pushed far out (a lattice never
- // fades against a host — its own rect bounds it).
- let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
- plate = Some(crate::vk::PlatePush {
- rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
- radii: [*radius * scale; 4],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- material: plate_mat,
- host: [pw * scale, ph * scale, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 13.0,
- shape: crate::layout::corner_shape(),
- });
- }
- Prim::Grout { rect, period, origin, cell, radius, color } if shader_plates => {
- // The lattice's fold, painted flat (shader mode 15): one cover
- // quad in the grout colour; the shader keeps it outside the
- // cells. Same push layout as the lattice; light/material are
- // carried but unread.
- let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
- verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
- plate = Some(crate::vk::PlatePush {
- rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
- radii: [*radius * scale; 4],
- light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
- material: plate_mat,
- host: [pw * scale, ph * scale, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 15.0,
- shape: crate::layout::corner_shape(),
- });
- }
- // Legacy banded path: no periodic wall — the lattice and the grout
- // draw nothing there, like the fillet (A/B comparison path only).
- Prim::Lattice { .. } | Prim::Grout { .. } => {}
- Prim::CarveUnion { boxes, depth, raised } if shader_plates => {
- // The union of several boxes as ONE wall (shader mode 14): the
- // boxes go into the frame's feature buffer as a contiguous run
- // and the shader takes the nearest one per pixel. The cover
- // quad is the union's bounding box grown by the wall's reach;
- // off-shape corners of it sit at the plateau and shade nothing.
- let budget = crate::vk::MAX_PLATE_FEATURES.saturating_sub(features.len());
- let take = boxes.len().min(budget);
- if take < boxes.len() && plate_debug() {
- eprintln!(
- "plate-carve: union of {} boxes gets {} — feature budget full ({} used)",
- boxes.len(), take, features.len()
- );
- }
- if take == 0 {
- continue;
- }
- let kept = &boxes[..take];
- let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
- for (r, _) in kept {
- x0 = x0.min(r.x);
- y0 = y0.min(r.y);
- x1 = x1.max(r.x + r.width);
- y1 = y1.max(r.y + r.height);
- }
- let infl = *depth * 0.5 + 2.0;
- verts.extend(quad_vertices(
- x0 - infl, y0 - infl,
- (x1 - x0) + 2.0 * infl, (y1 - y0) + 2.0 * infl,
- sw, sh, [0.0; 4],
- ));
- let off = features.len() as f32;
- for (r, radii) in kept {
- features.push([
- (r.x + r.width * 0.5) * scale,
- (r.y + r.height * 0.5) * scale,
- r.width * 0.5 * scale,
- r.height * 0.5 * scale,
- radii.0 * scale,
- radii.1 * scale,
- radii.2 * scale,
- radii.3 * scale,
- *depth * scale,
- 0.0,
- 0.0,
- 0.0,
- ]);
- }
- // The run is complete: a plate with an open feature run must
- // not append past it (its features would no longer be
- // contiguous), so it is closed here like any other appender.
- last_feature_plate = None;
- plate = Some(crate::vk::PlatePush {
- rect: [
- (x0 + x1) * 0.5 * scale,
- (y0 + y1) * 0.5 * scale,
- (x1 - x0) * 0.5 * scale,
- (y1 - y0) * 0.5 * scale,
- ],
- // x: the raised flag; the shader reads nothing else here.
- radii: [if *raised { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
- light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
- material: plate_mat,
- // Feature run [offset, count] (the renderer rebases the
- // offset onto the frame slot, as for mode 1); zw far out
- // so the host-box fade never applies.
- host: [off, take as f32, 1e6, 1e6],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 14.0,
- shape: crate::layout::corner_shape(),
- });
- }
- // Legacy banded path: no union — nothing is drawn there, like the
- // fillet and the lattice (A/B comparison path only).
- Prim::CarveUnion { .. } => {}
- }
- let end = verts.len() as u32;
- if end == start {
- continue;
- }
- // Some tessellators (quad_vertices, vector_vertices) don't thread the circle clip —
- // stamp the whole emitted range so every prim kind honors it uniformly.
- if item.clip_circle.is_some() {
- for v in verts[start as usize..].iter_mut() {
- v.clip_circle = no;
- }
- }
- // Merge into the previous batch if it shares this clip pair and is contiguous.
- // Plate batches carry per-draw push constants, and blur-behind batches
- // trigger the renderer's snapshot copy, so neither ever merges.
- if plate.is_none() && !blur_behind {
- // Ordinary geometry painted after a plate ends its carve-grouping
- // window: a recess emitted later must overlay this geometry (the
- // fallback path), not shade beneath it inside the plate's draw.
- if dbg_plates && !plate_stack.is_empty() {
- *dbg_closed_by.entry(prim_kind(&item.prim)).or_insert(0) += plate_stack.len();
- }
- plate_stack.clear();
- if let Some(last) = batches.last_mut() {
- if last.plate.is_none()
- && last.scissor == item.clip
- && last.clip_rrect == item.clip_rrect
- && last.end == start
- {
- last.end = end;
- continue;
- }
- }
- }
- if promoted {
- plate_stack.clear();
- }
- batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate, blur_behind });
- if let Some(prect) = made_plate {
- plate_stack.push((batches.len() - 1, prect));
- if dbg_plates {
- dbg_opened += 1;
- }
- }
- }
-
- if dbg_plates && (dbg_grouped > 0 || !dbg_fell_back.is_empty()) {
- eprintln!(
- "plate-dbg: {} carves — {dbg_grouped} grouped (exact CSG), {} overlay fallback",
- dbg_grouped + dbg_fell_back.len(),
- dbg_fell_back.len(),
- );
- eprintln!(
- "plate-dbg: {dbg_opened} grouping window(s) opened by a filled plate; closed early by {}",
- if dbg_closed_by.is_empty() {
- "nothing".to_string()
- } else {
- dbg_closed_by
- .iter()
- .map(|(k, n)| format!("{k}x{n}"))
- .collect::<Vec<_>>()
- .join(", ")
- }
- );
- for line in &dbg_fell_back {
- eprintln!("plate-dbg: {line}");
- }
- }
-
- (verts, batches, images, features)
-}
-
-/// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
-/// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
-///
-/// `shape` is a per-plate corner exponent (`Prim::Plate`'s override); `None`
-/// follows the DE-wide `layout::corner_shape`. The span factor follows the
-/// exponent actually used, so a circular override (2.0) spans nothing and a
-/// half-extent radius lands on a true circle.
-#[allow(clippy::too_many_arguments)]
-/// The push block of a frosted flat fill promoted to a zero-depth plate: a
-/// mode-1 plate with no roll (`t` = 0.001, so the face is exactly the fill),
-/// corners at the nominal radii in the configured `corner_shape`, and the
-/// fill's own frost recipe in `host.zw` (`Material::from_fill` decodes the
-/// sentinel).
-///
-/// The shape must be `corner_shape`, not a fixed circle: a frosted `Border`
-/// draws its stroke as `push_plate_solid_border_vertices` geometry in that
-/// shape, and the unfrosted fill fan uses it too. A circular face under a
-/// squircle stroke left the stroke cutting inside the face's corners.
-fn flat_frost_push(
- rect: &crate::scene::layout::Rect,
- radii: (f32, f32, f32, f32),
- fill: [f32; 4],
- scale: f32,
- light: [f32; 3],
- material: [f32; 4],
-) -> crate::vk::PlatePush {
- let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, None);
- let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
- p.host[2] = fz;
- p.host[3] = fw;
- p
-}
-
-fn plate_push_raised(
- rect: &crate::scene::layout::Rect,
- radii: (f32, f32, f32, f32),
- width: f32,
- scale: f32,
- light: [f32; 3],
- material: [f32; 4],
- scale_corners: bool,
- shape: Option<f32>,
-) -> crate::vk::PlatePush {
- // Floored: a rect already shrunk past its padding (a window dragged
- // below what its layout can hold) has a NEGATIVE extent here, and
- // `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
- let cap = (rect.width.min(rect.height) * 0.5).max(0.0);
- let shape = shape.map_or_else(crate::layout::corner_shape, |n| n.clamp(2.0, 16.0));
- // For PLATES (`scale_corners`), widen the corner span by the
- // curvature-match factor (see `layout::corner_span_factor`): the diagonal
- // curvature radius equals the configured radius, the corner reads as the
- // same size as a circular one, and every roll inset ≤ r stays crease-free
- // (past the diagonal curvature radius the offset curve the specular band
- // follows creases into a visible square corner). Widget-scale overlay
- // reliefs (recess/boss/ridge fallbacks) pass false: their radii must MATCH
- // the nominal-radius squircles of the widget silhouettes around them, and
- // at their few-px roll widths the offset crease is subpixel.
- let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
- crate::vk::PlatePush {
- rect: [
- (rect.x + rect.width * 0.5) * scale,
- (rect.y + rect.height * 0.5) * scale,
- rect.width * 0.5 * scale,
- rect.height * 0.5 * scale,
- ],
- radii: [
- (radii.0 * rscale).clamp(0.0, cap) * scale,
- (radii.1 * rscale).clamp(0.0, cap) * scale,
- (radii.2 * rscale).clamp(0.0, cap) * scale,
- (radii.3 * rscale).clamp(0.0, cap) * scale,
- ],
- light: [light[0], light[1], light[2], width * scale],
- material,
- // Mode-1 semantics: [feature offset, feature count] — no carves yet;
- // the tessellator fills these in as recesses group into this plate.
- host: [0.0, 0.0, 0.0, 0.0],
- specular_tint: [1.0, 1.0, 1.0, 0.0],
- mode: 1.0,
- shape,
- }
-}
-
-pub fn extra_quad_vertices(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_extra_quad_vertices(w, qx, qy, qw, qh, sw, sh, qc, clip_circle, &mut verts);
- verts
-}
-
-fn get_child_widget_for_quad<'a>(
- w: &'a dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
-) -> &'a dyn crate::widget::WidgetHost {
- if let Some(pbg) = w.as_any().downcast_ref::<crate::widget::ParametersBg>() {
- for s_opt in &pbg.sliders {
- if let Some(s) = s_opt {
- let (sx, sy, sww, shh) = s.rect();
- if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
- return s;
- }
- }
- }
- for f_opt in &pbg.float3s {
- if let Some(f) = f_opt {
- let (fx, fy, fww, fhh) = f.rect();
- if qx >= fx - 0.1 && qx + qw <= fx + fww + 0.1 && qy >= fy - 0.1 && qy + qh <= fy + fhh + 0.1 {
- return f;
- }
- }
- }
- for sb_opt in &pbg.spinboxes {
- if let Some(sb) = sb_opt {
- let (sx, sy, sww, shh) = sb.rect();
- if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
- return sb;
- }
- }
- }
- for btn_opt in &pbg.buttons {
- if let Some(btn) = btn_opt {
- let (bx, by, bww, bhh) = btn.rect();
- if qx >= bx - 0.1 && qx + qw <= bx + bww + 0.1 && qy >= by - 0.1 && qy + qh <= by + bhh + 0.1 {
- return btn;
- }
- }
- }
- for ch_opt in &pbg.choices {
- if let Some(ch) = ch_opt {
- let (cx, cy, cww, chh) = ch.rect();
- if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
- return ch;
- }
- }
- }
- for t_opt in &pbg.texts {
- if let Some(t) = t_opt {
- let (tx, ty, tww, thh) = t.rect();
- if qx >= tx - 0.1 && qx + qw <= tx + tww + 0.1 && qy >= ty - 0.1 && qy + qh <= ty + thh + 0.1 {
- return t;
- }
- }
- }
- for cb_opt in &pbg.toggles {
- if let Some(cb) = cb_opt {
- let (cx, cy, cww, chh) = cb.rect();
- if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
- return cb;
- }
- }
- }
- for c_opt in &pbg.colors {
- if let Some(c) = c_opt {
- let (cx, cy, cww, chh) = c.rect();
- if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
- return c;
- }
- }
- }
- }
- w
-}
-
-pub fn push_extra_quad_vertices(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
- if graph.is_node_rect(qx, qy, qw, qh) {
- let r = crate::layout::graph_node_corner_radius();
- let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
- return;
- }
- }
-
- let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
- let radii = target_w.corner_radii();
- if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
- out.extend_from_slice(&quad_vertices_with_clip(qx, qy, qw, qh, sw, sh, qc, clip_circle));
- if let Some((color, thickness)) = target_w.solid_border() {
- let (wx, wy, ww, wh) = target_w.rect();
- if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
- return;
- }
-
- let (wx, mut wy, ww, mut wh) = target_w.rect();
- let top_room = target_w.label_strip();
- wy += top_room;
- wh -= top_room;
- let extra_radii = crate::widget::CornerRadii::new(
- if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
- if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
- );
-
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
-
- if let Some((color, thickness)) = target_w.solid_border() {
- let (rx, mut ry, rw, mut rh) = target_w.rect();
- let top = target_w.label_strip();
- ry += top;
- rh -= top;
- if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
-}
-
-pub fn extra_quad_vertices_clipped(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip: (f32, f32, f32, f32),
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_extra_quad_vertices_clipped(w, qx, qy, qw, qh, sw, sh, qc, clip, clip_circle, &mut verts);
- verts
-}
-
-pub fn push_extra_quad_vertices_clipped(
- w: &dyn crate::widget::WidgetHost,
- qx: f32, qy: f32, qw: f32, qh: f32,
- sw: f32, sh: f32,
- qc: [f32; 4],
- clip: (f32, f32, f32, f32),
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
- if graph.is_node_rect(qx, qy, qw, qh) {
- let r = crate::layout::graph_node_corner_radius();
- let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
- return;
- }
- }
-
- let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
- let radii = target_w.corner_radii();
- if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
- let (cx0, cy0, cx1, cy1) = clip;
- let ix0 = qx.max(cx0);
- let iy0 = qy.max(cy0);
- let ix1 = (qx + qw).min(cx1);
- let iy1 = (qy + qh).min(cy1);
- if ix1 <= ix0 || iy1 <= iy0 {
- return;
- }
- out.extend_from_slice(&quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, sw, sh, qc, clip_circle));
- if let Some((color, thickness)) = target_w.solid_border() {
- let (wx, wy, ww, wh) = target_w.rect();
- if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
- return;
- }
-
- let (wx, mut wy, ww, mut wh) = target_w.rect();
- let top_room = target_w.label_strip();
- wy += top_room;
- wh -= top_room;
- let extra_radii = crate::widget::CornerRadii::new(
- if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
- if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
- if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
- );
-
- push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
-
- if let Some((color, thickness)) = target_w.solid_border() {
- let (rx, mut ry, rw, mut rh) = target_w.rect();
- let top = target_w.label_strip();
- ry += top;
- rh -= top;
- if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
- push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
- }
- }
-}
-
-pub fn circle_vertices(
- cx: f32, cy: f32, r: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- for i in 0..segments {
- let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
- let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
- let x0 = cx;
- let y0 = cy;
- let x1 = cx + r * theta1.cos();
- let y1 = cy + r * theta1.sin();
- let x2 = cx + r * theta2.cos();
- let y2 = cy + r * theta2.sin();
-
- let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
- let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
- let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
- let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
- let ndc_x2 = (x2 / sw) * 2.0 - 1.0;
- let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
- }
- verts
-}
-
-pub fn circle_border_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- for i in 0..segments {
- let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
- let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
-
- let x0 = cx + (r - thickness) * theta1.cos();
- let y0 = cy + (r - thickness) * theta1.sin();
- let x1 = cx + r * theta1.cos();
- let y1 = cy + r * theta1.sin();
-
- let x2 = cx + r * theta2.cos();
- let y2 = cy + r * theta2.sin();
- let x3 = cx + (r - thickness) * theta2.cos();
- let y3 = cy + (r - thickness) * theta2.sin();
-
- let ndc_x0 = (x0 / sw) * 2.0 - 1.0; let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
- let ndc_x1 = (x1 / sw) * 2.0 - 1.0; let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
- let ndc_x2 = (x2 / sw) * 2.0 - 1.0; let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
- let ndc_x3 = (x3 / sw) * 2.0 - 1.0; let ndc_y3 = 1.0 - (y3 / sh) * 2.0;
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
- verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
-
- verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
- verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
- verts.push(Vertex { position: [ndc_x3, ndc_y3], color, clip_circle });
- }
- verts
-}
-
-pub fn arc_background_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- start_angle: f32, end_angle: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
-) -> Vec<Vertex> {
- let mut verts = Vec::new();
- push_arc_background_vertices(cx, cy, r, thickness, start_angle, end_angle, sw, sh, color, segments, clip_circle, &mut verts);
- verts
-}
-
-/// A ring band with radial Gouraud shading: two sub-bands (inner rim → crest
-/// centerline, crest → outer rim) whose vertex colors interpolate across the
-/// stroke — the rounded-bevel profile — plus the half-px alpha feathers at
-/// both true rims (colors matched to the adjacent band, so no seams).
-#[allow(clippy::too_many_arguments)]
-pub fn push_arc_shaded_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- start_angle: f32, end_angle: f32,
- sw: f32, sh: f32,
- inner: [f32; 4], crest: [f32; 4], outer: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- let f = 0.5f32.min(thickness * 0.25);
- let r_out = r;
- let r_in = (r - thickness).max(0.0);
- let r_mid = (r_in + r_out) / 2.0;
- let fade_in = [inner[0], inner[1], inner[2], 0.0];
- let fade_out = [outer[0], outer[1], outer[2], 0.0];
- // (inner radius, outer radius, color at inner edge, color at outer edge)
- let bands = [
- ((r_in - f).max(0.0), r_in + f, fade_in, inner),
- (r_in + f, r_mid, inner, crest),
- (r_mid, r_out - f, crest, outer),
- (r_out - f, r_out + f, outer, fade_out),
- ];
- for i in 0..segments {
- let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
- let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
- let (c1, s1) = (theta1.cos(), theta1.sin());
- let (c2, s2) = (theta2.cos(), theta2.sin());
- for &(ra, rb, ca, cb) in &bands {
- if rb <= ra {
- continue;
- }
- let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
- [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
- };
- let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
- let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o1, color: cb, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i2, color: ca, clip_circle });
- }
- }
-}
-
-pub fn push_arc_background_vertices(
- cx: f32, cy: f32, r: f32,
- thickness: f32,
- start_angle: f32, end_angle: f32,
- sw: f32, sh: f32,
- color: [f32; 4],
- segments: usize,
- clip_circle: [f32; 3],
- out: &mut Vec<Vertex>,
-) {
- // The stroke band [r - thickness, r], with a half-px alpha ramp on each rim
- // (Gouraud across thin edge bands) so curved edges resolve smoothly instead
- // of hard-stepping — the poor-man's AA the flat pipeline doesn't provide.
- let f = 0.5f32.min(thickness * 0.25);
- let r_in = (r - thickness).max(0.0);
- // (inner radius, outer radius, alpha at inner rim, alpha at outer rim)
- let bands = [
- ((r_in - f).max(0.0), r_in + f, 0.0, color[3]),
- (r_in + f, r - f, color[3], color[3]),
- (r - f, r + f, color[3], 0.0),
- ];
- for i in 0..segments {
- let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
- let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
- let (c1, s1) = (theta1.cos(), theta1.sin());
- let (c2, s2) = (theta2.cos(), theta2.sin());
- for &(ra, rb, aa, ab) in &bands {
- if rb <= ra {
- continue;
- }
- let ca = [color[0], color[1], color[2], aa];
- let cb = [color[0], color[1], color[2], ab];
- let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
- [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
- };
- let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
- let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o1, color: cb, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i1, color: ca, clip_circle });
- out.push(Vertex { position: o2, color: cb, clip_circle });
- out.push(Vertex { position: i2, color: ca, clip_circle });
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct WindowSettings {
- pub title: String,
- pub app_id: String,
- pub width: u32,
- pub height: u32,
- pub fullscreen: bool,
- pub min_size: Option<(u32, u32)>,
-}
-
-/// A compositor-side window operation requested by the app: an interactive
-/// move or resize grab. Returned from [`Application::take_window_action`];
-/// the runner executes it with the serial of the most recent pointer press.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum WindowAction {
- Move,
- Resize(xdg_toplevel::ResizeEdge),
-}
-
-// Re-export the wlr-layer-shell types apps need to describe a layer surface.
-pub use smithay_client_toolkit::shell::wlr_layer::{
- Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
+use wayland_protocols::wp::pointer_gestures::zv1::client::{
+ zwp_pointer_gesture_pinch_v1::{self, ZwpPointerGesturePinchV1},
+ zwp_pointer_gestures_v1::{self as zwp_pointer_gestures, ZwpPointerGesturesV1},
};
+pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel;
+pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
+use calloop::EventLoop;
+use calloop_wayland_source::WaylandSource;
+use cosmic_text::FontSystem;
- use crate::widget::{TextItem, MouseButton, ElementState, Key, NamedKey};
++use crate::widget::{MouseButton, ElementState, Key, NamedKey};
+use crate::wayland::detect_scale_factor;
+use crate::vk::VkRenderer;
-/// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
-/// surface (panels, overlays, notifications) instead of an xdg toplevel.
-/// Return one from [`Application::layer`] to select layer-shell.
-#[derive(Debug, Clone)]
-pub struct LayerSettings {
- pub layer: LayerKind,
- pub anchor: LayerAnchor,
- pub exclusive_zone: i32,
- pub keyboard_interactivity: LayerKeyboardInteractivity,
- /// (top, right, bottom, left) margins in logical pixels.
- pub margin: (i32, i32, i32, i32),
- pub namespace: String,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct LogicalPosition {
- pub x: f32,
- pub y: f32,
-}
-
-impl LogicalPosition {
- pub fn new(x: f32, y: f32) -> Self {
- Self { x, y }
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct LogicalSize {
- pub width: f32,
- pub height: f32,
-}
-
-impl LogicalSize {
- pub fn new(width: f32, height: f32) -> Self {
- Self { width, height }
- }
-}
-
-pub struct RenderContext<'a> {
- pub font_system: &'a mut FontSystem,
-}
-
-pub trait Application: Sized + 'static {
- type Message: Send + Clone + 'static;
-
- fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self;
- fn settings(&self) -> WindowSettings;
- /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
- /// instead of an xdg toplevel. Defaults to `None` (a normal window).
- fn layer(&self) -> Option<LayerSettings> {
- None
- }
- /// Declare the window a UTILITY window: a tool whose shape is decided by
- /// its contents. The compositor then never dictates a size to it (every
- /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
- /// become the surface's own initial size), offers no resize affordance
- /// (the whole border band moves the window), and never saves geometry
- /// for it, so a stale remembered size can't be restored over what the
- /// app asks for. Declared over the cce window-management protocol at
- /// window creation; on a compositor too old to know the request this is
- /// silently a plain floating window. Defaults to `false`.
- fn utility(&self) -> bool {
- false
- }
- /// Declare the window the DESKTOP-GRID layer (zcce set_grid): the
- /// compositor world-anchors the surface to the virtual desktop and
- /// pans/zooms it per frame like window content; the app renders only
- /// when handed a patch (see [`Application::grid_patch`]). The surface
- /// becomes input-transparent and lives behind all windows. Needs
- /// manager v6; on an older compositor the declaration is skipped.
- /// Defaults to `false`.
- fn grid(&self) -> bool {
- false
- }
- /// A grid patch to render (grid apps only): virtual origin (`x`, `y`),
- /// virtual size (`w`, `h`), and `scale` surface px per virtual unit.
- /// Called right before the frame that must show it; the runner has
- /// already resized the surface to `(w*scale, h*scale)` and acks the
- /// patch so the coming commit is latched at the new anchor.
- fn grid_patch(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _scale: f64) {}
- fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
- fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
- /// How long the runner may sleep between `tick`s while the window is
- /// idle — nothing to draw, no animation, no key held, no frame callback
- /// outstanding. `None` (the default) lets it sleep until a Wayland
- /// event or a message on the app's calloop `Sender` arrives, bounded by
- /// [`IDLE_DISPATCH`]. Override with `Some` ONLY if your `tick` polls
- /// something the loop cannot see — a `std::sync::mpsc` receiver drained
- /// in `tick`, say — because with the default that poll waits for the
- /// next unrelated event. The better fix is to send through the calloop
- /// `Sender` handed to `new`, which wakes the loop by itself.
- fn idle_poll_interval(&self) -> Option<std::time::Duration> {
- None
- }
- /// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
- /// tray-hover highlights). Deliberately separate from the single paint path.
- fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
- /// The part of the surface that changed since the last frame this app
- /// painted, as (x, y, w, h) in logical px, taken (and reset) once per
- /// rendered frame right after `display_list`. `None` — the default —
- /// means all of it. Returning a rect makes the frame a partial one: only
- /// that rect is repainted and only it is reported to the compositor as
- /// damage, which is what keeps a small change on a very large surface
- /// (an image dragged across the desktop grid) from costing a full
- /// repaint on both sides. The app vouches for the rect: anything that
- /// changed outside it keeps its old pixels. A frame that was skipped is
- /// the runner's to make up — the next one is painted in full.
- fn take_damage(&mut self, _size: LogicalSize, _scale: f64) -> Option<(f32, f32, f32, f32)> {
- None
- }
- fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
- None
- }
-
- /// Transparent overflow rim, in logical px, on the RIGHT and BOTTOM of
- /// the window. Non-zero opts into buffer-larger-than-geometry mode: the
- /// runner sizes the surface `margin` wider/taller than the configured
- /// window size, publishes the top-left rect as the xdg window geometry
- /// (what the compositor tiles, borders, and snaps) and an input region of
- /// the frame plus any open popover rects — an overhanging menu stays
- /// clickable while empty rim falls through to whatever is behind.
- ///
- /// Right/bottom ONLY, deliberately: the surface grows away from its
- /// origin, so the frame never moves relative to the surface and pointer
- /// coordinates stay valid across the resize (a leading rim shifts the
- /// surface under an unmoved cursor, and the compositor's stale pointer
- /// state then drops the very next click). Frame coords == surface coords:
- /// no input translation, no paint shift — the app's only obligation is to
- /// lay out against the frame (`display_list`'s `size` minus the margin);
- /// content emitted past the frame edge renders in the rim instead of
- /// clipping at the buffer edge.
- ///
- /// The value may change at runtime (return the popover overhang while a
- /// menu is open, 0 otherwise): the engine re-derives the surface from the
- /// stored frame and resizes on drift. Quantize the answer (e.g. 64px
- /// steps) so an animating popover doesn't resize the surface per frame.
- /// xdg toplevels only (layer surfaces ignore it).
- fn overflow_margin(&self) -> u32 {
- 0
- }
-
- fn desired_size(&self) -> Option<(u32, u32)> {
- None
- }
-
- fn ui_context(&self) -> Option<&crate::context::UiContext> {
- None
- }
-
- fn ui_context_mut(&mut self) -> Option<&mut crate::context::UiContext> {
- None
- }
-
- /// Where a widget's open popover is DRAWN, as an offset from the rect it
- /// reports (`popover_rect`). A widget reports in the coordinates it was
- /// laid out in; an app that lays its page out unscrolled and shifts what
- /// it emits draws the popover `scroll` px away from there, and returns
- /// `(0.0, -scroll_y)` here for the page's widgets. Everything the engine
- /// derives from a popover rect reads it through this: the text-occlusion
- /// clamp, the overflow input region, and the region sent to the
- /// compositor. Without it a menu opened on a scrolled page had the page's
- /// text drawn over it, and cut a menu-shaped hole in the text one scroll
- /// offset away.
- fn popover_offset(&self, _id: crate::widget::WidgetId) -> (f32, f32) {
- (0.0, 0.0)
- }
-
- /// Whether a left-press at (px, py) should start a compositor window drag. Every root
- /// root plate container is dissolved (Phase 6), so the default is "no" — apps that want
- /// drag-anywhere override this with `ctx.drag_allowed_at(px, py)`.
- fn is_movable_root_plate_at(&self, _px: f32, _py: f32) -> bool {
- false
- }
-
- fn clear_color(&self) -> [f32; 4] {
- [0.0, 0.0, 0.0, 0.0]
- }
-
- fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
-
- fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
- (width, height)
- }
-
- /// Mime types this app accepts from a drag, in the app's own preference
- /// order (the source's order is ignored — a browser lists `text/html`
- /// before `text/uri-list` and which is more useful is the app's call).
- /// The default is empty: the app accepts nothing and drags over it read
- /// as "can't drop here", which is what every client did before drops
- /// existed. Opting in also requires [`Application::handle_drop`].
- fn drop_mimes(&self) -> &'static [&'static str] {
- &[]
- }
-
- /// A completed drop: `data` is everything the source wrote for `mime`,
- /// and `pos` is where it was released in the app's logical coordinates.
- /// Runs on the main loop, after the transfer finished — this is not the
- /// place to block, since the compositor is waiting on the next frame.
- fn handle_drop(
- &mut self,
- _mime: &str,
- _data: &[u8],
- _pos: LogicalPosition,
- _needs_rebuild: &mut bool,
- ) {
- }
-
- fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool);
- fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message>;
- fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool);
- /// Trackpad pinch (zwp_pointer_gestures pinch). `factor` is the scale
- /// change SINCE THE LAST update (1.0 = no change, >1 = fingers spreading),
- /// so direct-manipulation zoom is `content_scale *= factor`. Return true
- /// to consume; returning false falls back to the engine's legacy
- /// synthesis — a ctrl+wheel PixelDelta sized for the graph's zoom mapping
- /// (`y = (factor-1)/0.015`) — so ctrl-scroll-zoom surfaces keep working
- /// without implementing this.
- fn handle_pinch(&mut self, _factor: f32, _pos: LogicalPosition, _needs_rebuild: &mut bool) -> bool {
- false
- }
- fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message>;
-
- /// Undo, after the focused widget declined the chord (a text box that is
- /// editing takes it for its own typing). Return true when something was
- /// undone; false lets the key fall through to `handle_key_input` like any
- /// other. The chords are `undo` / `redo` in `input.kdl` (cce-ui domain
- /// defaults `ctrl+z` / `ctrl+shift+z`), resolved once at startup. Build
- /// the history on `cce_ui::history::History`.
- fn undo(&mut self, _needs_rebuild: &mut bool) -> bool {
- false
- }
-
- /// Redo — see [`undo`](Self::undo).
- fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
- false
- }
-
- /// Opt into the toolkit's keyboard navigation in plate terms: Tab and
- /// Shift+Tab move focus to the next / previous plate or well in reading
- /// order (`UiContext::focus_step`), a press (Enter / Space) acts on the
- /// focused plate, a well opens for typing when focused. Default false: an
- /// app that routes Tab itself (a terminal, a web view, its own field
- /// order) is undisturbed. See "Plates, wells and seams" in `CLAUDE.md`.
- fn plate_navigation(&self) -> bool {
- false
- }
-
- /// Wait for the NEXT compositor when this one goes away, instead of
- /// exiting. Default false, which is right for any window the compositor
- /// saves and restores: its successor respawns the app itself, and a
- /// client that rejoined too came up beside its own copy (see
- /// [`after_session`]). Return true from a process the compositor does NOT
- /// restore and that must outlive it — a systemd user service like the
- /// status bar or the notifier, whose D-Bus names (the tray's
- /// StatusNotifierWatcher, org.freedesktop.Notifications) other programs
- /// depend on. Exiting took those names down at every logout and
- /// compositor restart, and Dropbox, starting into the gap, found no tray.
- fn outlives_compositor(&self) -> bool {
- false
- }
-
- /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
- /// caches its geometry until its own rebuild flag (relief carves collected
- /// in a view pass, widget lists built on layout) raises that flag here, so
- /// the new ring is drawn; an app that paints fresh every frame needs
- /// nothing. Default: nothing.
- fn focus_stepped(&mut self) {}
- /// Keyboard focus entered/left the window (the compositor keyboard-focuses
- /// the focused window, so this is the "am I the focused window" signal —
- /// e.g. for focus-dependent chrome). Default: ignore.
- fn handle_focus_change(&mut self, _focused: bool, _needs_rebuild: &mut bool) {}
-
- fn custom_vertices(&mut self, _verts: &mut Vec<Vertex>, _size: LogicalSize, _scale: f64) {}
-
- /// The frame's geometry, drawn via one batched, GPU-scissor-clipped pass (the single
- /// paint path). Every rendering app implements this — the legacy `view*` sinks are gone;
- /// `None` yields an empty frame. Overlays ([`overlay_quads`](Application::overlay_quads))
- /// and [`custom_vertices`](Application::custom_vertices) still go through their own paths;
- /// text renders from the list when [`display_list_text`](Application::display_list_text)
- /// opts in. Receives the frame's logical size and HiDPI scale. Typically implemented as
- /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, &self.root))`.
- fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<crate::scene::paint::DisplayList> {
- None
- }
-
- /// Opt in to render the display list's `Prim::Text` items through the glyph pass
- /// (shaped via the shared buffer cache, clipped to the item clip ∩ the prim bounds). An
- /// app's ENTIRE frame — geometry and text — is then one
- /// [`display_list`](Application::display_list). Default `false` draws no text (an app that
- /// only draws geometry, or none at all).
- ///
- /// Display-list text gets the same popover-occlusion clamp as the legacy `text_areas`
- /// mapping (`popover_occlusion_clamp`, driven by `ui_context().active_popovers`), so an
- /// open popover's plate clips list text beneath it on both paths.
- fn display_list_text(&self) -> bool {
- false
- }
-
- /// Opt into system fonts in the ENGINE's render `FontSystem` (the one that shapes
- /// display-list text and rasterizes every glyph at prepare time). Default `false`: the
- /// render FontSystem loads only the bundled CCE fonts, and text asking for a family that
- /// exists only among installed system fonts is silently invisible — buffers shaped
- /// app-side against a system-fonts `FontSystem` carry fontdb face IDs the engine's
- /// database doesn't have (the cce-colors Phase 6e bug). An app whose UI must render
- /// arbitrary installed families (the font picker) returns `true`; its own `FontSystem`,
- /// if it keeps one for measurement, should be `create_font_system_with_system_fonts()`
- /// so both databases load identically. Consulted once, at GPU init.
- fn load_system_fonts(&self) -> bool {
- false
- }
-
- /// Called once, right after the renderer is created and before the first
- /// frame: create persistent renderer resources here (3D meshes via
- /// [`VkRenderer::create_mesh`]). Most 2D apps never need this.
- fn renderer_init(&mut self, _renderer: &mut VkRenderer) {}
-
- /// Direct renderer staging, called every frame after the engine's own text
- /// prep and immediately before the frame is drawn: stage 3D scene panes
- /// (`stage_scene`), path-traced panes (`stage_rt`), flush mesh updates, or
- /// prepare app-shaped text (`prepare_text` — an app that returns `false`
- /// from [`display_list_text`](Application::display_list_text) fully owns
- /// the renderer's text state, the engine never touches it). Return `true`
- /// to request another frame immediately (e.g. while a path tracer is still
- /// accumulating samples).
- fn stage_renderer(&mut self, _renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
- false
- }
-
- /// The surface was resized (or the scale factor changed): `width`/`height`
- /// are the new logical size. The renderer has already been resized; use
- /// this for stateful relayout that can't wait for the next paint callback.
- fn handle_resize(&mut self, _width: f32, _height: f32, _scale: f64) {}
-
- /// Whether the runner's built-in client-side decorations apply: the
- /// titlebar move band, the movable-root plate drag regions, and — when
- /// [`csd_resize_borders`](Application::csd_resize_borders) is also on —
- /// the rect-edge resize grabs and their edge cursors. Return `false` for a
- /// window whose chrome doesn't follow its rect (e.g. a circular pane) and
- /// drive moves/resizes yourself via
- /// [`take_window_action`](Application::take_window_action).
- fn standard_csd(&self) -> bool {
- true
- }
-
- /// Whether the standard CSD claims the outer 8px of the surface as resize
- /// grabs (with matching edge cursors). Off by default: under the cce
- /// compositor the server already provides a resize band just *outside* the
- /// window, so enabling this gives a window two adjacent 8px gutters driven
- /// by different code paths — and only the compositor's snaps to the
- /// desktop grid. It also costs the app clicks, since a press inside the
- /// band starts a grab and never reaches the widgets underneath.
- ///
- /// Turn it on for a window that must be resizable by its own edges under a
- /// compositor that provides no such affordance. Only consulted when
- /// [`standard_csd`](Application::standard_csd) is on.
- fn csd_resize_borders(&self) -> bool {
- false
- }
-
- /// Whether the standard CSD reserves an implicit title-bar strip (`y` in `[8, 32)`) as a
- /// drag-to-move handle. Opt-in: off by default, so a window has no title bar and is moved
- /// through the compositor (or via explicitly-declared handles —
- /// [`is_movable_root_plate_at`](Application::is_movable_root_plate_at)); nothing is
- /// implicitly draggable. An app with an actual title bar returns `true`. Separate from
- /// [`standard_csd`](Application::standard_csd), which also gates the resize borders, and
- /// only consulted when `standard_csd()` is on.
- fn csd_titlebar_move(&self) -> bool {
- false
- }
-
- /// Override the pointer cursor at (x, y). `None` falls back to the
- /// runner's standard CSD edge cursors (or `Default` when
- /// [`standard_csd`](Application::standard_csd) is off).
- fn cursor_icon(&self, _x: f32, _y: f32) -> Option<CursorIcon> {
- None
- }
-
- /// Polled after each pointer frame is dispatched: return a
- /// [`WindowAction`] to start an interactive move/resize grab with the
- /// serial of the most recent pointer press. This is take-semantics — the
- /// implementation should clear its pending action when returning it.
- fn take_window_action(&mut self) -> Option<WindowAction> {
- None
- }
-
- /// Called once when the event loop ends (window closed, app-requested
- /// exit): last-chance work like autosave. The surface is still alive.
- fn on_exit(&mut self) {}
-}
+pub use super::app::*;
+pub use super::driver::PressedKey;
+use super::frame::build_frame;
+use super::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
+use super::driver::{Driver, Modifiers, Press, PressSite, ResizeEdge, ScrollFrame, ScrollSource, Turn};
+pub use super::tessellate::*;
+pub use super::text::*;
-pub struct PressedKey {
- pub logical_key: Key,
- pub text: Option<String>,
- pub first_pressed: Instant,
- pub last_repeated: Instant,
-}
+pub use super::shell::IDLE_DISPATCH;
-fn is_repeatable_key(key: &Key) -> bool {
- match key {
- Key::Named(NamedKey::Backspace) |
- Key::Named(NamedKey::Delete) |
- Key::Named(NamedKey::ArrowLeft) |
- Key::Named(NamedKey::ArrowRight) |
- Key::Named(NamedKey::ArrowUp) |
- Key::Named(NamedKey::ArrowDown) |
- Key::Named(NamedKey::Home) |
- Key::Named(NamedKey::End) |
- Key::Character(_) => true,
- _ => false,
- }
++/// The `CCE_PRESENT_DEBUG` traces' timestamp: wall-clock milliseconds, mod
++/// 100 s, short enough to read down a column of lines.
++fn debug_clock_ms() -> u128 {
++ std::time::SystemTime::now()
++ .duration_since(std::time::UNIX_EPOCH)
++ .map_or(0, |d| d.as_millis() % 100_000)
+ }
+
-/// Default cap on the runner's idle sleep — see `Application::idle_poll_interval`.
-pub const IDLE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(1000);
-
pub struct EngineState<A: Application> {
pub registry_state: RegistryState,
pub compositor_state: CompositorState,
@@@ -156,6 -4224,24 +164,14 @@@
/// Serial of the most recent pointer press, kept for
/// [`Application::take_window_action`] move/resize grabs.
pub last_press_serial: Option<u32>,
- /// Mouse buttons currently held, as a bitmask (1 Left / 2 Right /
- /// 4 Middle). On pointer Leave mid-gesture the real Release goes to
- /// whatever surface takes the pointer next (fullscreen switches, layout
- /// animations), so Leave synthesizes releases for the held set — a drag
- /// must end, not stay armed and steered by later motion — and only then
- /// runs the off-screen hover-clear (which would otherwise corrupt the
- /// drag: a ramp key snapped to the graph corner).
- pub buttons_down: u32,
+ /// The touchscreen, once the seat offers one; see `backend/touch.rs`.
+ pub touch: Option<wl_touch::WlTouch>,
+ pub touch_tracker: super::touch::TouchTracker,
+ /// The followed finger's surface offset into window coordinates (the
+ /// menu popup's, or none), fixed at its down.
+ pub touch_offset: (f32, f32),
+ /// Where a touch scroll is dispatched: the finger's down point.
+ pub touch_scroll_at: Option<(f32, f32)>,
/// This frame's display-list text, shaped and held here so the `TextSpan`s built
/// in the render pass can borrow the buffers (Phase 6 —
/// [`Application::display_list_text`]).
@@@ -501,162 -4793,6 +523,162 @@@ impl<A: Application> EngineState<A>
} else {
self.damage_owed = false;
}
+ self.sync_text_input();
+ }
+
+ /// Bring the text input in step with the frame just built: enabled at
+ /// the editing widget's caret, disabled with nothing editing, reset for
+ /// a composition a widget dropped (`backend::text_input`).
+ fn sync_text_input(&mut self) {
+ use crate::backend::text_input::Send;
+ use zwp_text_input_v3::{ContentHint, ContentPurpose};
+ let reset = crate::ime::take_reset();
+ let Some(ti) = self.text_input.clone() else { return };
+ // Forced mode: the surface is the compositor's scale-1 space.
+ let surface_scale = crate::scale::forced_scale().unwrap_or(1.0);
+ match self.text_input_state.plan(crate::ime::caret(), surface_scale, reset) {
+ Send::Nothing => {}
+ Send::Enable { rect: [x, y, w, h], reset } => {
+ if reset {
+ ti.disable();
+ ti.commit();
+ }
+ ti.enable();
+ ti.set_content_type(ContentHint::None, ContentPurpose::Normal);
+ ti.set_cursor_rectangle(x, y, w, h);
+ ti.commit();
+ }
+ Send::Move { rect: [x, y, w, h] } => {
+ ti.set_cursor_rectangle(x, y, w, h);
+ ti.commit();
+ }
+ Send::Disable => {
+ ti.disable();
+ ti.commit();
+ }
+ }
+ }
+}
+
+impl<A: Application> Shell for EngineState<A> {
+ type App = A;
+
+ /// The driver and the app's turn, borrowed apart: every input dispatch
+ /// is `let (driver, t) = self.turn(); driver.<event>(t, ..)`.
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
+ (
+ &mut self.driver,
+ Turn { app: self.inner.as_mut().unwrap(), redraw: &mut self.redraw, exit: &mut self.exit },
+ )
+ }
+
+ fn app(&self) -> &A {
+ self.inner.as_ref().unwrap()
+ }
+
+ fn redraw(&mut self) -> &mut bool {
+ &mut self.redraw
+ }
+
+ fn exit_requested(&self) -> bool {
+ self.exit
+ }
+
+ fn take_just_configured(&mut self) -> bool {
+ std::mem::replace(&mut self.just_configured, false)
+ }
+
+ fn request_size(&mut self, w: u32, h: u32) {
+ // desired_size is a window-frame size; the surface adds the
+ // right/bottom overflow rim (0 for margin-less apps).
+ let m = self.inner.as_ref().unwrap().overflow_margin() as f32;
+ let (sw, sh) = (w as f32 + m, h as f32 + m);
+ if (self.logical_width - sw).abs() > 0.001 || (self.logical_height - sh).abs() > 0.001 {
+ self.frame_logical = (w as f32, h as f32);
+ self.applied_margin = m;
+ self.resize(sw, sh);
+ self.redraw = true;
+ }
+ }
+
+ fn sync(&mut self) {
+ // Overflow-margin drift (configure-sized apps): the rim can change at
+ // runtime — a popover overhanging the window frame — so re-derive the
+ // surface from the stored frame whenever the app's answer moves. While
+ // the rim is live, re-publish geometry every loop: the input region
+ // tracks the animating popover rects.
+ let m_now = self.inner.as_ref().unwrap().overflow_margin() as f32;
+ if (m_now - self.applied_margin).abs() > 0.001 && self.frame_logical.0 > 0.0 {
+ self.applied_margin = m_now;
+ let (fw, fh) = self.frame_logical;
+ self.resize(fw + m_now, fh + m_now);
+ self.redraw = true;
+ }
+ if self.applied_margin > 0.0 || self.overflow_was_active {
+ self.publish_window_geometry();
+ self.overflow_was_active = self.applied_margin > 0.0;
+ }
+ self.send_popover_region();
+ self.sync_menu_popup();
+ }
+
+ fn set_title(&mut self, title: &str) {
+ if let Some(ref window) = self.window {
+ window.set_title(title);
+ window.commit();
+ }
+ }
+
+ fn frame_pending(&mut self) -> bool {
+ // Frame-callback starvation fallback: the compositor only sends
+ // frame-done for surfaces it actually renders, so a callback armed
+ // while the window sat off-viewport (or the scene went static) may
+ // never fire — and the vsync gate then freezes the app forever
+ // with a perfectly live event loop (input processes, state changes,
+ // nothing repaints). If a redraw has been waiting on a callback well
+ // past any real vsync interval, stop waiting and draw.
+ //
+ // Gated on the renderer's present mode: forcing a present past a
+ // dead callback is only safe under MAILBOX (the present replaces the
+ // queued buffer). Under FIFO the driver's throttle waits on the
+ // previous present's frame event, so the forced present itself
+ // blocks forever inside the driver — the exact freeze this fallback
+ // exists to prevent. There the gate stays closed: pixels may stale
+ // until the next frame-done/configure, but the loop stays alive.
+ if self.redraw
+ && self.frame_callback_pending
+ && self.renderer.as_ref().is_some_and(|r| r.forced_present_safe())
+ && self.frame_callback_armed_at.is_none_or(|t| t.elapsed().as_millis() > 250)
+ {
+ self.frame_callback_pending = false;
- if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
- let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
++ if crate::vk::present_debug() {
++ let t = debug_clock_ms();
+ eprintln!("[vk] t={} starvation fallback fired (callback never came)", t);
+ }
+ }
+ self.frame_callback_pending
+ }
+
+ fn configured(&self) -> bool {
+ self.first_configure_received
+ }
+
+ fn present(&mut self, fresh: bool) {
+ if !fresh {
+ // A warm-down re-render: the window alone.
+ self.render();
+ return;
+ }
+ // A menu handed over from the window commits first, so
+ // there is no moment with neither (`take_menu_popup_lead`).
+ let lead = self.take_menu_popup_lead();
+ if lead {
+ self.render_menu_popup();
+ }
+ self.render();
+ if !lead {
+ self.render_menu_popup();
+ }
}
}
@@@ -920,10 -5056,10 +942,13 @@@ impl<A: Application> SeatHandler for En
if capability == Capability::Keyboard && self.keyboard.is_none() {
let keyboard = self.seat_state.get_keyboard(qh, &seat, None).unwrap();
self.keyboard = Some(keyboard);
+ if let (Some(m), None) = (&self.text_input_manager, &self.text_input) {
+ self.text_input = Some(m.get_text_input(&seat, qh, ()));
+ }
}
+ if capability == Capability::Touch && self.touch.is_none() {
+ self.touch = self.seat_state.get_touch(qh, &seat).ok();
+ }
}
fn remove_capability(
@@@ -1865,8 -6311,16 +1893,13 @@@ fn run_session<'l, A: Application>
pinch_gesture: None,
cce_toplevel: None,
pending_grid_patch: None,
- last_pinch_scale: 1.0,
- cursor_pos: (0.0, 0.0),
last_press_serial: None,
- buttons_down: 0,
+ touch: None,
+ touch_tracker: Default::default(),
+ touch_offset: (0.0, 0.0),
+ touch_scroll_at: None,
dl_text_items: Vec::new(),
+ is_status_bar: false,
};
if let Err(e) = event_queue.roundtrip(&mut engine_state) {
@@@ -2021,10 -6479,28 +2055,9 @@@
/// in the per-iteration path, so a `std::env::var` call here would be I/O
/// on the loop that is under measurement.
fn loop_debug() -> bool {
- static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
- *FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
+ crate::vk::present_debug()
}
- /// Loop cadence while something is in motion: one tick per frame.
- const ACTIVE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(16);
-
- /// Upper bound on an idle sleep. The loop is woken early by any Wayland
- /// event or calloop-channel message, so this only caps how long an
- /// app-side poll that bypasses both (see `Application::idle_poll_interval`)
- /// can wait. `CCE_UI_IDLE_MS` overrides it — `16` restores the old
- /// always-ticking loop for a bisect.
- fn idle_dispatch() -> std::time::Duration {
- static IDLE: std::sync::OnceLock<std::time::Duration> = std::sync::OnceLock::new();
- *IDLE.get_or_init(|| {
- std::env::var("CCE_UI_IDLE_MS")
- .ok()
- .and_then(|v| v.parse::<u64>().ok())
- .map(std::time::Duration::from_millis)
- .unwrap_or(IDLE_DISPATCH)
- })
- }
-
/// Seconds after session start at which to inject a simulated connection
/// loss, from `CCE_UI_FAULT_RECONNECT`. Resolved once: this is read from
/// the per-iteration path.
diff --cc src/mac/mod.rs
index 19ca7a5,0000000..22532a9
mode 100644,000000..100644
--- a/src/mac/mod.rs
+++ b/src/mac/mod.rs
@@@ -1,1064 -1,0 +1,1065 @@@
+//! The macOS shell: an [`Application`] run in an AppKit window, the way the
+//! Wayland shell runs one on a surface and the browser shell on a canvas. It
+//! is the third shell over the shared pieces — [`Driver`] routes what input
+//! means, [`Pacer::turn`] decides what a turn does, [`build_frame`] builds
+//! the frame — and the renderer is the Vulkan one, on Metal through
+//! MoltenVK (a `CAMetalLayer` is a [`SurfaceTarget::Metal`]). What is left
+//! here is AppKit's side:
+//!
+//! - **The window.** An `NSWindow` whose content is the app's root plate:
+//! transparent, its titlebar transparent over full-size content, so the
+//! plate fills the window and the traffic lights stand on its corner. The
+//! window keeps AppKit's own resize edges, so the driver's CSD resize band
+//! is the app's (`PressSite::own_edges`); a press it reads as the
+//! window's (the titlebar band, a movable root plate) drags the window
+//! (`performWindowDragWithEvent:`).
+//! - **Events in.** A layer-hosting `NSView` (flipped, so y runs down as
+//! everywhere else in the toolkit) takes the mouse, scroll, magnify and
+//! key events and hands them, mapped by `backend::appkit`, to the driver.
+//! Command is the shortcut key (⌘Z is undo). AppKit's own key repeats
+//! and scroll momentum are dropped: the driver repeats a held key and the
+//! toolkit coasts a flick, as on Wayland.
+//! - **Turns.** Main-queue dispatches (`dispatch2`): any event, and any
+//! `AppSender::send` from any thread (`set_wake`), asks for a turn at most
+//! one ACTIVE frame after the last; between, the loop sleeps for whatever
+//! the pacer said. A turn superseded by an earlier one is dropped by its
+//! generation.
+//! - **Frames out.** [`build_frame`] at the view's size in points and the
+//! window's backing scale, drawn by the [`VkRenderer`]; `stage_renderer`
+//! (and so the portable `stage_3d`) runs before each draw, `renderer_init`
+//! once.
+//!
+//! The menu is a minimal one (Quit, ⌘Q), whose quit — like the close
+//! button — asks the app to exit the way a compositor's close does.
+//!
+//! - **Input methods.** The view is an `NSTextInputClient`: while a widget
+//! is editing text, a key press goes through `interpretKeyEvents:`, whose
+//! marked text is the composition and whose inserted text the commit
+//! (`crate::ime`); the candidate window is put under the caret the
+//! widget reported. The clipboard is the general `NSPasteboard`
+//! (`widget::clipboard`).
+//!
+//! Not there yet: drag and drop, the context menu in a popup window (it is
+//! drawn in the window and kept inside it, as on a layer surface), and blur
+//! behind the window (`NSVisualEffectView`).
+//!
+//! **This module has never been run.** It is written against objc2's
+//! AppKit bindings and type-checked for `aarch64-apple-darwin`
+//! (`scripts/check-mac`) on Linux, where nothing can be linked or run: it
+//! needs a Mac, with MoltenVK installed (the Vulkan SDK, or Homebrew's
+//! `molten-vk` and `vulkan-loader`).
+
+use std::cell::{Cell, RefCell};
+use std::sync::mpsc::Receiver;
+use std::time::Duration;
+
+use cursor_icon::CursorIcon;
+use dispatch2::{DispatchQueue, DispatchTime};
+use objc2::rc::Retained;
+use objc2::runtime::{AnyObject, NSObject, ProtocolObject, Sel};
+use objc2::{define_class, msg_send, sel, AnyThread, DefinedClass, MainThreadOnly};
+use objc2_app_kit::{
+ NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSApplicationTerminateReply,
+ NSBackingStoreType, NSColor, NSCursor, NSEvent, NSEventModifierFlags, NSEventType, NSMenu, NSMenuItem,
+ NSTextInputClient, NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow, NSWindowDelegate, NSWindowStyleMask,
+ NSWindowTitleVisibility,
+};
+use objc2_foundation::{
+ ns_string, MainThreadMarker, NSArray, NSAttributedString, NSAttributedStringKey, NSNotFound, NSNotification,
+ NSObjectProtocol, NSPoint, NSRange, NSRangePointer, NSRect, NSSize, NSString, NSUInteger,
+};
+use objc2_quartz_core::CAMetalLayer;
+use web_time::Instant;
+
+use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
+use crate::backend::appkit;
+use crate::backend::driver::{Driver, Press, PressSite, Turn};
+use crate::backend::frame::build_frame;
+use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
+use crate::vk::{SurfaceTarget, VkRenderer};
- use crate::widget::{context_menu, ElementState, Key, TextItem};
++use crate::widget::{context_menu, ElementState, Key};
++use crate::backend::text::DlText;
+
+/// Run `A` in a window until it exits. Must be called on the main thread
+/// (a process's `main`), as AppKit requires.
+pub fn run<A: Application>() {
+ let mtm = MainThreadMarker::new().expect("cce-ui's macOS shell runs on the main thread");
+ let ns_app = NSApplication::sharedApplication(mtm);
+ ns_app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
+
+ let (tx, rx) = std::sync::mpsc::channel();
+ // The scale is known before the app is built, as on the other shells: a
+ // widget may read it as it is made.
+ let main_scale = objc2_app_kit::NSScreen::mainScreen(mtm).map_or(2.0, |s| s.backingScaleFactor());
+ crate::scale::set_scale_factor(main_scale as f32);
+ let mut app = A::create(AppSender::from(tx));
+ let settings = app.settings();
+ crate::scale::set_app_id(settings.app_id.clone());
+
+ let delegate = Delegate::new(mtm);
+ ns_app.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
+ ns_app.setMainMenu(Some(&main_menu(mtm, &settings.title)));
+
+ let style = NSWindowStyleMask::Titled
+ | NSWindowStyleMask::Closable
+ | NSWindowStyleMask::Miniaturizable
+ | NSWindowStyleMask::Resizable
+ | NSWindowStyleMask::FullSizeContentView;
+ let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(settings.width as f64, settings.height as f64));
+ // SAFETY: released-when-closed is turned off at once, as a window made
+ // outside a window controller must be.
+ let window = unsafe {
+ NSWindow::initWithContentRect_styleMask_backing_defer(
+ NSWindow::alloc(mtm),
+ rect,
+ style,
+ NSBackingStoreType::Buffered,
+ false,
+ )
+ };
+ unsafe { window.setReleasedWhenClosed(false) };
+ window.setTitle(&NSString::from_str(&settings.title));
+ window.setTitlebarAppearsTransparent(true);
+ window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
+ window.setOpaque(false);
+ window.setBackgroundColor(Some(&NSColor::clearColor()));
+ window.setAcceptsMouseMovedEvents(true);
+ if let Some((w, h)) = settings.min_size {
+ window.setContentMinSize(NSSize::new(w as f64, h as f64));
+ }
+ window.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
+
+ let view = CceView::new(mtm, rect);
+ let layer = CAMetalLayer::new();
+ layer.setOpaque(false);
+ let scale = window.backingScaleFactor();
+ layer.setContentsScale(scale);
+ // A layer-HOSTING view: the layer is set before the view wants one.
+ view.setLayer(Some(&layer));
+ view.setWantsLayer(true);
+ window.setContentView(Some(&view));
+ let _ = window.makeFirstResponder(Some(&view));
+ window.center();
+
+ let fs = if app.load_system_fonts() {
+ crate::create_font_system_with_system_fonts()
+ } else {
+ crate::create_font_system()
+ };
+ let (pw, ph) = physical((settings.width as f32, settings.height as f32), scale);
+ let layer_ptr: *const std::ffi::c_void = Retained::as_ptr(&layer).cast();
+ // SAFETY: the layer is held by the shell, which drops the renderer first.
+ let mut renderer = match unsafe { VkRenderer::try_new_for(SurfaceTarget::Metal { layer: layer_ptr }, pw, ph, 0.0) } {
+ Ok(r) => r,
+ Err(e) => {
+ log::error!("[mac] no renderer for the window: {e}");
+ return;
+ }
+ };
+ app.renderer_init(&mut renderer);
+
+ let shell = MacShell {
+ renderer,
+ app,
+ driver: Driver::new(),
+ redraw: true,
+ exit: false,
+ rx,
+ fs,
+ swash: cosmic_text::SwashCache::new(),
+ items: Vec::new(),
+ damage_owed: true,
+ logical: (0.0, 0.0),
+ scale,
+ just_configured: true,
+ cursor: CursorIcon::Default,
+ pinch: 1.0,
+ pacer: Pacer::new(settings.title),
+ generation: 0,
+ due: None,
+ last_turn: Instant::now(),
+ stopped: false,
+ ime_caret: None,
+ mtm,
+ layer,
+ view: view.clone(),
+ window: window.clone(),
+ };
+ let shell = RefCell::new(shell);
+ shell.borrow_mut().measure();
+ SINK.with(|s| *s.borrow_mut() = Some(Box::new(move |ev| shell.borrow_mut().event(ev))));
+ // A send from any thread hops to the main queue and asks for a turn.
+ set_wake(Some(std::sync::Arc::new(|| DispatchQueue::main().exec_async(|| {
+ send(Ev::Wake);
+ }))));
+
+ window.makeKeyAndOrderFront(None);
+ #[allow(deprecated)]
+ ns_app.activateIgnoringOtherApps(true);
+ send(Ev::Wake);
+ ns_app.run();
+
+ // `stop:` returned the run loop: the app has exited. The shell — the
+ // renderer before the layer it presents to — goes with the sink.
+ set_wake(None);
+ SINK.with(|s| s.borrow_mut().take());
+ drop(view);
+ drop(window);
+}
+
+/// What the view, the delegate and the loop's dispatches hand the shell, in
+/// plain values: the shell is generic over the app and the AppKit classes
+/// cannot be, so they reach it through [`SINK`].
+enum Ev {
+ Moved { x: f32, y: f32 },
+ Entered { x: f32, y: f32 },
+ Exited,
+ /// Answers true when the press is a window drag the view must start.
+ Pressed { number: i64, flags: u64, x: f32, y: f32 },
+ Released { number: i64, flags: u64, x: f32, y: f32 },
+ Scrolled { dx: f64, dy: f64, precise: bool, inverted: bool, phase: u64, momentum: u64, flags: u64, x: f32, y: f32 },
+ Magnified { phase: u64, magnification: f64 },
+ Keyed { code: u16, chars: String, unmodified: String, flags: u64, down: bool },
+ /// The input method's composition changed (marked text).
+ Preedit(Option<crate::ime::Preedit>),
+ /// The input method committed text.
+ Commit(String),
+ FlagsChanged { code: u16, flags: u64 },
+ Focused(bool),
+ /// The window's size or backing scale changed.
+ Resized,
+ /// The close button, or Quit: the app is asked to exit.
+ CloseRequested,
+ /// A turn scheduled under this generation is due.
+ Turn(u64),
+ /// Turn soon.
+ Wake,
+}
+
+thread_local! {
+ static SINK: RefCell<Option<Box<dyn FnMut(Ev) -> bool>>> = const { RefCell::new(None) };
+}
+
+/// Hand `ev` to the shell. An event raised from inside a dispatch (AppKit
+/// calling back while the app runs) finds the shell busy and is dropped,
+/// as the browser shell drops one; so is everything after the exit.
+fn send(ev: Ev) -> bool {
+ SINK.with(|s| match s.try_borrow_mut() {
+ Ok(mut sink) => sink.as_mut().is_some_and(|f| f(ev)),
+ Err(_) => false,
+ })
+}
+
+/// The window's side of the run loop, as the Wayland shell's `EngineState`
+/// is the compositor's. Fields drop in order: the renderer before the layer
+/// it presents to.
+struct MacShell<A: Application> {
+ renderer: VkRenderer,
+ app: A,
+ driver: Driver,
+ redraw: bool,
+ exit: bool,
+ rx: Receiver<A::Message>,
+ fs: cosmic_text::FontSystem,
+ swash: cosmic_text::SwashCache,
- items: Vec<TextItem>,
++ items: Vec<DlText>,
+ damage_owed: bool,
+ /// The view's size in points, and the window's backing scale.
+ logical: (f32, f32),
+ scale: f64,
+ just_configured: bool,
+ cursor: CursorIcon,
+ /// The pinch's cumulative scale (AppKit reports each step's change).
+ pinch: f32,
+ pacer: Pacer,
+ /// The generation of the turn that is due; older dispatches are stale.
+ generation: u64,
+ due: Option<Instant>,
+ last_turn: Instant,
+ stopped: bool,
+ /// The caret the input method was last told of.
+ ime_caret: Option<[f32; 4]>,
+ mtm: MainThreadMarker,
+ layer: Retained<CAMetalLayer>,
+ view: Retained<CceView>,
+ window: Retained<NSWindow>,
+}
+
+impl<A: Application> MacShell<A> {
+ fn event(&mut self, ev: Ev) -> bool {
+ if self.stopped {
+ return false;
+ }
+ let mut drag = false;
+ match ev {
+ Ev::Turn(g) => {
+ if g == self.generation {
+ self.take_turn();
+ }
+ return false;
+ }
+ Ev::Wake => {}
+ Ev::Moved { x, y } => {
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.pointer_motion(t, pos);
+ self.update_cursor();
+ }
+ Ev::Entered { x, y } => {
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.pointer_enter(t, pos);
+ self.update_cursor();
+ }
+ Ev::Exited => {
+ let (driver, t) = self.turn();
+ driver.pointer_leave(t);
+ }
+ Ev::Pressed { number, flags, x, y } => {
+ let Some(btn) = appkit::button(number, flags) else { return false };
+ self.sync_mods(flags);
+ let pos = self.at(x, y);
+ // AppKit resizes from its own window edges; a move is a drag.
+ let site = PressSite { size: self.size(), on_popup: false, can_grab: true, own_edges: true };
+ let (driver, t) = self.turn();
+ drag = driver.pointer_press(t, btn, pos, site) == Press::Move;
+ }
+ Ev::Released { number, flags, x, y } => {
+ let Some(btn) = appkit::button(number, flags) else { return false };
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.pointer_release(t, btn, pos);
+ self.update_cursor();
+ }
+ Ev::Scrolled { dx, dy, precise, inverted, phase, momentum, flags, x, y } => {
+ // The system's natural-scrolling setting is the one in force
+ // here, not input.kdl's: value controls read it through this.
+ if precise {
+ crate::input::force_natural_scroll(Some(inverted));
+ }
+ let Some(frame) = appkit::scroll_frame(dx, dy, precise, inverted, phase, momentum) else { return false };
+ self.sync_mods(flags);
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.scroll(t, frame, pos);
+ }
+ Ev::Magnified { phase, magnification } => {
+ if phase & appkit::phase::BEGAN != 0 {
+ self.pinch = 1.0;
+ self.driver.pinch_begin();
+ }
+ if magnification != 0.0 {
+ self.pinch *= 1.0 + magnification as f32;
+ let pinch = self.pinch;
+ let (driver, t) = self.turn();
+ driver.pinch_update(t, pinch);
+ }
+ if phase & (appkit::phase::ENDED | appkit::phase::CANCELLED) != 0 {
+ self.driver.pinch_end();
+ }
+ }
+ Ev::Keyed { code, chars, unmodified, flags, down } => {
+ let accel = flags & (appkit::flags::COMMAND | appkit::flags::CONTROL) != 0;
+ let Some((key, text)) = appkit::map_key(code, &chars, &unmodified, accel) else { return false };
+ self.sync_mods(flags);
+ let state = if down { ElementState::Pressed } else { ElementState::Released };
+ let (driver, t) = self.turn();
+ driver.key(t, key, text, state);
+ }
+ Ev::Preedit(preedit) => {
+ let (driver, t) = self.turn();
+ driver.preedit(t, preedit);
+ }
+ Ev::Commit(text) => {
+ let (driver, t) = self.turn();
+ driver.preedit(t, None);
+ let (driver, t) = self.turn();
+ driver.commit_text(t, text);
+ }
+ Ev::FlagsChanged { code, flags } => {
+ // A modifier key went down or up: the new state, and the key
+ // itself as the other shells report it.
+ self.sync_mods(flags);
+ let bit = match code {
+ 0x37 | 0x36 => appkit::flags::COMMAND,
+ 0x38 | 0x3C => appkit::flags::SHIFT,
+ 0x3A | 0x3D => appkit::flags::OPTION,
+ 0x3B | 0x3E => appkit::flags::CONTROL,
+ _ => 0,
+ };
+ if let (true, Some((key @ Key::Named(_), _))) = (bit != 0, appkit::map_key(code, "", "", false)) {
+ let state = if flags & bit != 0 { ElementState::Pressed } else { ElementState::Released };
+ let (driver, t) = self.turn();
+ driver.key(t, key, None, state);
+ }
+ }
+ Ev::Focused(focused) => {
+ let (driver, t) = self.turn();
+ driver.keyboard_focus(t, focused);
+ }
+ Ev::Resized => {
+ if self.measure() {
+ self.just_configured = true;
+ }
+ }
+ Ev::CloseRequested => self.exit = true,
+ }
+ self.wake();
+ drag
+ }
+
+ /// Ask for a turn one ACTIVE frame after the last at the latest, unless
+ /// one is due sooner already.
+ fn wake(&mut self) {
+ let at = (self.last_turn + ACTIVE_DISPATCH).max(Instant::now());
+ if self.due.is_some_and(|d| d <= at) {
+ return;
+ }
+ self.schedule(at.saturating_duration_since(Instant::now()));
+ }
+
+ fn schedule(&mut self, after: Duration) {
+ self.generation += 1;
+ self.due = Some(Instant::now() + after);
+ let g = self.generation;
+ let when = DispatchTime::try_from(after).unwrap_or(DispatchTime::NOW);
+ let _ = DispatchQueue::main().after(when, move || {
+ send(Ev::Turn(g));
+ });
+ }
+
+ fn take_turn(&mut self) {
+ self.due = None;
+ self.last_turn = Instant::now();
+ while let Ok(msg) = self.rx.try_recv() {
+ let mut rebuild = false;
+ self.app.update(msg, &mut rebuild, &mut self.exit);
+ self.redraw |= rebuild;
+ }
+ let mut pacer = std::mem::replace(&mut self.pacer, Pacer::new(String::new()));
+ let step = pacer.turn(self);
+ self.pacer = pacer;
+ match step {
+ Step::Exit => self.finish(),
+ Step::Sleep(d) => {
+ if self.due.is_none() {
+ self.schedule(d);
+ }
+ }
+ }
+ }
+
+ /// The app exited: let it take its leave, then return the run loop.
+ fn finish(&mut self) {
+ self.stopped = true;
+ self.app.on_exit();
+ let ns_app = NSApplication::sharedApplication(self.mtm);
+ ns_app.stop(None);
+ // `stop:` takes effect after the next event; this is it.
+ if let Some(ev) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
+ NSEventType::ApplicationDefined,
+ NSPoint::new(0.0, 0.0),
+ NSEventModifierFlags::empty(),
+ 0.0,
+ 0,
+ None,
+ 0,
+ 0,
+ 0,
+ ) {
+ ns_app.postEvent_atStart(&ev, true);
+ }
+ }
+
+ /// Read the view's size and the backing scale, and size the drawable to
+ /// them. Whether either changed.
+ fn measure(&mut self) -> bool {
+ let bounds = self.view.bounds();
+ let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
+ let scale = self.window.backingScaleFactor();
+ if (w, h) == self.logical && scale == self.scale {
+ return false;
+ }
+ self.logical = (w, h);
+ if scale != self.scale {
+ self.scale = scale;
+ crate::scale::set_scale_factor(scale as f32);
+ }
+ // MoltenVK reports the layer's bounds times its contents scale as
+ // the surface's extent.
+ self.layer.setContentsScale(scale);
+ let (pw, ph) = physical(self.logical, self.scale);
+ self.renderer.resize(pw, ph);
+ self.redraw = true;
+ true
+ }
+
+ /// Keep the input method in step with the frame just drawn: a
+ /// composition a widget dropped, or one with no widget editing any more,
+ /// is discarded (the marked text cleared first, so the `unmarkText` this
+ /// may call commits nothing); a caret that moved has the candidate
+ /// window follow it.
+ fn sync_input_method(&mut self) {
+ let caret = crate::ime::caret();
+ let drop = crate::ime::take_reset() || (caret.is_none() && !self.view.ivars().marked.borrow().is_empty());
+ let context = self.view.inputContext();
+ if drop {
+ self.view.ivars().marked.borrow_mut().clear();
+ crate::ime::set_preedit(None);
+ if let Some(c) = &context {
+ c.discardMarkedText();
+ }
+ }
+ if caret != self.ime_caret {
+ self.ime_caret = caret;
+ if let Some(c) = &context {
+ c.invalidateCharacterCoordinates();
+ }
+ }
+ }
+
+ fn size(&self) -> LogicalSize {
+ LogicalSize::new(self.logical.0, self.logical.1)
+ }
+
+ fn at(&mut self, x: f32, y: f32) -> LogicalPosition {
+ self.driver.cursor_pos = (x, y);
+ LogicalPosition::new(x, y)
+ }
+
+ fn sync_mods(&mut self, flags: u64) {
+ let mods = appkit::modifiers(flags);
+ if mods != self.driver.mods {
+ self.driver.set_modifiers(&mut self.app, mods);
+ }
+ }
+
+ fn update_cursor(&mut self) {
+ let (x, y) = self.driver.cursor_pos;
+ let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
+ if icon != self.cursor {
+ self.cursor = icon;
+ ns_cursor(icon).set();
+ }
+ }
+}
+
+impl<A: Application> Shell for MacShell<A> {
+ type App = A;
+
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
+ (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
+ }
+
+ fn app(&self) -> &A {
+ &self.app
+ }
+
+ fn redraw(&mut self) -> &mut bool {
+ &mut self.redraw
+ }
+
+ fn exit_requested(&self) -> bool {
+ self.exit
+ }
+
+ fn take_just_configured(&mut self) -> bool {
+ std::mem::replace(&mut self.just_configured, false)
+ }
+
+ fn request_size(&mut self, w: u32, h: u32) {
+ if (w as f32, h as f32) != self.logical {
+ self.window.setContentSize(NSSize::new(w as f64, h as f64));
+ self.measure();
+ }
+ }
+
+ fn sync(&mut self) {
+ // No popup window to host the menu yet: it is drawn in the window
+ // and kept inside it, frames asked for while a page turn animates.
+ if context_menu::is_visible() {
+ if context_menu::is_turning() {
+ self.redraw = true;
+ }
+ context_menu::set_hosted(false);
+ context_menu::constrain_to(0.0, 0.0, self.logical.0, self.logical.1);
+ }
+ }
+
+ fn set_title(&mut self, title: &str) {
+ self.window.setTitle(&NSString::from_str(title));
+ }
+
+ fn frame_pending(&mut self) -> bool {
+ false
+ }
+
+ fn configured(&self) -> bool {
+ self.logical.0 > 0.0 && self.logical.1 > 0.0
+ }
+
+ fn present(&mut self, _fresh: bool) {
+ let size = self.size();
+ let frame = build_frame(&mut self.app, &mut self.fs, size, self.scale, &mut self.damage_owed, &mut self.items);
+ if frame.dl_text {
+ let spans = frame.text_spans(&self.items);
+ self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
+ }
+ let (pw, ph) = physical(self.logical, self.scale);
+ let e = self.renderer.pending_extent();
+ if (e.width, e.height) != (pw, ph) {
+ self.renderer.resize(pw, ph);
+ }
+ if self.app.stage_renderer(&mut self.renderer, size, self.scale) {
+ self.redraw = true;
+ }
+ if self.renderer.draw_frame_2d(frame.frame2d()) {
+ self.damage_owed = false;
+ } else {
+ self.redraw = true;
+ }
+ self.sync_input_method();
+ }
+}
+
+fn physical(logical: (f32, f32), scale: f64) -> (u32, u32) {
+ let s = scale as f32;
+ ((logical.0 * s).round().max(1.0) as u32, (logical.1 * s).round().max(1.0) as u32)
+}
+
+/// The toolkit's cursor as AppKit's. AppKit has no public diagonal resize
+/// cursors before macOS 15, so those are the arrow; the two straight ones
+/// are deprecated there, for the directional cursors macOS 15 added, and
+/// still what every earlier release has.
+#[allow(deprecated)]
+fn ns_cursor(icon: CursorIcon) -> Retained<NSCursor> {
+ match icon {
+ CursorIcon::Pointer => NSCursor::pointingHandCursor(),
+ CursorIcon::Text | CursorIcon::VerticalText => NSCursor::IBeamCursor(),
+ CursorIcon::Crosshair | CursorIcon::Cell => NSCursor::crosshairCursor(),
+ CursorIcon::Grab => NSCursor::openHandCursor(),
+ CursorIcon::Grabbing | CursorIcon::Move | CursorIcon::AllScroll => NSCursor::closedHandCursor(),
+ CursorIcon::NotAllowed | CursorIcon::NoDrop => NSCursor::operationNotAllowedCursor(),
+ CursorIcon::EResize | CursorIcon::WResize | CursorIcon::EwResize | CursorIcon::ColResize => {
+ NSCursor::resizeLeftRightCursor()
+ }
+ CursorIcon::NResize | CursorIcon::SResize | CursorIcon::NsResize | CursorIcon::RowResize => {
+ NSCursor::resizeUpDownCursor()
+ }
+ CursorIcon::ContextMenu => NSCursor::contextualMenuCursor(),
+ CursorIcon::Copy => NSCursor::dragCopyCursor(),
+ CursorIcon::Alias => NSCursor::dragLinkCursor(),
+ _ => NSCursor::arrowCursor(),
+ }
+}
+
+/// An application menu with Quit (⌘Q) in it: every Mac app has one, and
+/// without it ⌘Q does nothing.
+fn main_menu(mtm: MainThreadMarker, title: &str) -> Retained<NSMenu> {
+ let bar = NSMenu::new(mtm);
+ let app_item = NSMenuItem::new(mtm);
+ let app_menu = NSMenu::new(mtm);
+ // SAFETY: `terminate:` is NSApplication's, which answers it through the
+ // delegate's `applicationShouldTerminate:` below.
+ let quit = unsafe {
+ NSMenuItem::initWithTitle_action_keyEquivalent(
+ NSMenuItem::alloc(mtm),
+ &NSString::from_str(&format!("Quit {title}")),
+ Some(sel!(terminate:)),
+ ns_string!("q"),
+ )
+ };
+ app_menu.addItem(&quit);
+ app_item.setSubmenu(Some(&app_menu));
+ bar.addItem(&app_item);
+ bar
+}
+
+/// Where an event's pointer is, in the view's (flipped) coordinates.
+fn location(view: &NSView, event: &NSEvent) -> (f32, f32) {
+ let p = view.convertPoint_fromView(event.locationInWindow(), None);
+ (p.x as f32, p.y as f32)
+}
+
+/// The view's input-method state, for the `NSTextInputClient` methods.
+#[derive(Default)]
+struct ViewIme {
+ /// The marked text (the composition) as the input method last set it.
+ marked: RefCell<String>,
+ /// Inside `interpretKeyEvents:` for a key press, whose characters are
+ /// these; and whether the input method took the press.
+ in_key: Cell<bool>,
+ consumed: Cell<bool>,
+ key_chars: RefCell<String>,
+}
+
+/// The text of what an input method hands over: an `NSString`, or an
+/// `NSAttributedString` around one.
+fn ns_text(obj: &AnyObject) -> String {
+ if let Some(a) = obj.downcast_ref::<NSAttributedString>() {
+ a.string().to_string()
+ } else if let Some(s) = obj.downcast_ref::<NSString>() {
+ s.to_string()
+ } else {
+ String::new()
+ }
+}
+
+define_class!(
+ // SAFETY: NSView has no subclassing requirements, and CceView no Drop.
+ #[unsafe(super(NSView, objc2_app_kit::NSResponder, NSObject))]
+ #[thread_kind = MainThreadOnly]
+ #[name = "CceUiView"]
+ #[ivars = ViewIme]
+ struct CceView;
+
+ unsafe impl NSObjectProtocol for CceView {}
+
+ // The input method's side of the view: AppKit calls these from inside
+ // `interpretKeyEvents:` (see `key`), and the candidate window asks where
+ // the caret is.
+ unsafe impl NSTextInputClient for CceView {
+ #[unsafe(method(insertText:replacementRange:))]
+ unsafe fn insert_text(&self, string: &AnyObject, _replacement: NSRange) {
+ let text = ns_text(string);
+ let ime = self.ivars();
+ let was_marked = !std::mem::take(&mut *ime.marked.borrow_mut()).is_empty();
+ // A plain key typing its own character is left to the key path,
+ // which keeps its named keys and the driver's repeat.
+ if ime.in_key.get() && !was_marked && text == *ime.key_chars.borrow() {
+ return;
+ }
+ ime.consumed.set(true);
+ send(Ev::Commit(text));
+ }
+
+ #[unsafe(method(doCommandBySelector:))]
+ unsafe fn do_command(&self, _selector: Sel) {
+ // A key the input method does not take (Return, Backspace, an
+ // arrow): left to the key path.
+ }
+
+ #[unsafe(method(setMarkedText:selectedRange:replacementRange:))]
+ unsafe fn set_marked_text(&self, string: &AnyObject, selected: NSRange, _replacement: NSRange) {
+ let text = ns_text(string);
+ let ime = self.ivars();
+ ime.consumed.set(true);
+ *ime.marked.borrow_mut() = text.clone();
+ let cursor = crate::backend::dom::utf16_range_to_bytes(
+ &text,
+ Some(selected.location as u32),
+ Some((selected.location + selected.length) as u32),
+ );
+ send(Ev::Preedit((!text.is_empty()).then(|| crate::ime::Preedit { text, cursor })));
+ }
+
+ #[unsafe(method(unmarkText))]
+ fn unmark_text(&self) {
+ // Accept the composition as it stands.
+ let text = std::mem::take(&mut *self.ivars().marked.borrow_mut());
+ if !text.is_empty() {
+ send(Ev::Commit(text));
+ }
+ }
+
+ #[unsafe(method(selectedRange))]
+ fn selected_range(&self) -> NSRange {
+ NSRange::new(NSNotFound as usize, 0)
+ }
+
+ #[unsafe(method(markedRange))]
+ fn marked_range(&self) -> NSRange {
+ let marked = self.ivars().marked.borrow();
+ if marked.is_empty() {
+ NSRange::new(NSNotFound as usize, 0)
+ } else {
+ NSRange::new(0, marked.encode_utf16().count())
+ }
+ }
+
+ #[unsafe(method(hasMarkedText))]
+ fn has_marked_text(&self) -> bool {
+ !self.ivars().marked.borrow().is_empty()
+ }
+
+ #[unsafe(method_id(attributedSubstringForProposedRange:actualRange:))]
+ unsafe fn attributed_substring(&self, _range: NSRange, _actual: NSRangePointer) -> Option<Retained<NSAttributedString>> {
+ None
+ }
+
+ #[unsafe(method_id(validAttributesForMarkedText))]
+ fn valid_attributes(&self) -> Retained<NSArray<NSAttributedStringKey>> {
+ NSArray::new()
+ }
+
+ /// Where the candidate window goes: under the editing widget's caret,
+ /// in screen coordinates.
+ #[unsafe(method(firstRectForCharacterRange:actualRange:))]
+ unsafe fn first_rect(&self, _range: NSRange, _actual: NSRangePointer) -> NSRect {
+ let [x, y, w, h] = crate::ime::caret().unwrap_or([0.0, 0.0, 1.0, 16.0]);
+ let caret = NSRect::new(NSPoint::new(x as f64, y as f64), NSSize::new(w.max(1.0) as f64, h as f64));
+ let in_window = self.convertRect_toView(caret, None);
+ self.window().map_or(in_window, |w| w.convertRectToScreen(in_window))
+ }
+
+ #[unsafe(method(characterIndexForPoint:))]
+ fn character_index(&self, _point: NSPoint) -> NSUInteger {
+ NSNotFound as NSUInteger
+ }
+ }
+
+ impl CceView {
+ #[unsafe(method(isFlipped))]
+ fn is_flipped(&self) -> bool {
+ true
+ }
+
+ #[unsafe(method(acceptsFirstResponder))]
+ fn accepts_first_responder(&self) -> bool {
+ true
+ }
+
+ #[unsafe(method(acceptsFirstMouse:))]
+ fn accepts_first_mouse(&self, _event: Option<&NSEvent>) -> bool {
+ true
+ }
+
+ #[unsafe(method(mouseMoved:))]
+ fn mouse_moved(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(mouseDragged:))]
+ fn mouse_dragged(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(rightMouseDragged:))]
+ fn right_mouse_dragged(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(otherMouseDragged:))]
+ fn other_mouse_dragged(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(mouseEntered:))]
+ fn mouse_entered(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Entered { x, y });
+ }
+
+ #[unsafe(method(mouseExited:))]
+ fn mouse_exited(&self, _event: &NSEvent) {
+ send(Ev::Exited);
+ }
+
+ #[unsafe(method(mouseDown:))]
+ fn mouse_down(&self, event: &NSEvent) {
+ self.press(event);
+ }
+
+ #[unsafe(method(rightMouseDown:))]
+ fn right_mouse_down(&self, event: &NSEvent) {
+ self.press(event);
+ }
+
+ #[unsafe(method(otherMouseDown:))]
+ fn other_mouse_down(&self, event: &NSEvent) {
+ self.press(event);
+ }
+
+ #[unsafe(method(mouseUp:))]
+ fn mouse_up(&self, event: &NSEvent) {
+ self.release(event);
+ }
+
+ #[unsafe(method(rightMouseUp:))]
+ fn right_mouse_up(&self, event: &NSEvent) {
+ self.release(event);
+ }
+
+ #[unsafe(method(otherMouseUp:))]
+ fn other_mouse_up(&self, event: &NSEvent) {
+ self.release(event);
+ }
+
+ #[unsafe(method(scrollWheel:))]
+ fn scroll_wheel(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Scrolled {
+ dx: event.scrollingDeltaX(),
+ dy: event.scrollingDeltaY(),
+ precise: event.hasPreciseScrollingDeltas(),
+ inverted: event.isDirectionInvertedFromDevice(),
+ phase: event.phase().0 as u64,
+ momentum: event.momentumPhase().0 as u64,
+ flags: event.modifierFlags().0 as u64,
+ x,
+ y,
+ });
+ }
+
+ #[unsafe(method(magnifyWithEvent:))]
+ fn magnify(&self, event: &NSEvent) {
+ send(Ev::Magnified { phase: event.phase().0 as u64, magnification: event.magnification() });
+ }
+
+ #[unsafe(method(keyDown:))]
+ fn key_down(&self, event: &NSEvent) {
+ self.key(event, true);
+ }
+
+ #[unsafe(method(keyUp:))]
+ fn key_up(&self, event: &NSEvent) {
+ self.key(event, false);
+ }
+
+ #[unsafe(method(flagsChanged:))]
+ fn flags_changed(&self, event: &NSEvent) {
+ send(Ev::FlagsChanged { code: event.keyCode(), flags: event.modifierFlags().0 as u64 });
+ }
+ }
+);
+
+impl CceView {
+ fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
+ let this = Self::alloc(mtm).set_ivars(ViewIme::default());
+ // SAFETY: NSView's designated initializer.
+ let view: Retained<Self> = unsafe { msg_send![super(this), initWithFrame: frame] };
+ // Enter, exit and motion wherever the view is, window key or not.
+ let options = NSTrackingAreaOptions::MouseEnteredAndExited
+ | NSTrackingAreaOptions::MouseMoved
+ | NSTrackingAreaOptions::ActiveAlways
+ | NSTrackingAreaOptions::InVisibleRect;
+ // SAFETY: the view owns the area and outlives it; no user info.
+ let area = unsafe {
+ NSTrackingArea::initWithRect_options_owner_userInfo(
+ NSTrackingArea::alloc(),
+ NSRect::ZERO,
+ options,
+ Some(&view),
+ None,
+ )
+ };
+ view.addTrackingArea(&area);
+ view
+ }
+
+ fn press(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ let ev = Ev::Pressed { number: event.buttonNumber() as i64, flags: event.modifierFlags().0 as u64, x, y };
+ if send(ev) {
+ if let Some(window) = self.window() {
+ window.performWindowDragWithEvent(event);
+ }
+ }
+ }
+
+ fn release(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Released { number: event.buttonNumber() as i64, flags: event.modifierFlags().0 as u64, x, y });
+ }
+
+ fn key(&self, event: &NSEvent, down: bool) {
+ let text = |s: Option<Retained<NSString>>| s.map(|s| s.to_string()).unwrap_or_default();
+ let flags = event.modifierFlags().0 as u64;
+ // While a widget is editing text, a key press goes to the input
+ // method first (`interpretKeyEvents:`, which calls back into the
+ // `NSTextInputClient` methods above); what it takes is composition
+ // or a commit, what it leaves takes the key path below. A ⌘
+ // shortcut never goes, and nothing does while nothing is editing,
+ // so an input method left on does not eat an app's single-key
+ // commands.
+ let ime = self.ivars();
+ if down && flags & appkit::flags::COMMAND == 0 && crate::ime::caret().is_some() {
+ ime.in_key.set(true);
+ ime.consumed.set(false);
+ *ime.key_chars.borrow_mut() = text(event.characters());
+ self.interpretKeyEvents(&NSArray::from_slice(&[event]));
+ ime.in_key.set(false);
+ if ime.consumed.get() {
+ return;
+ }
+ }
+ // The driver repeats a held key itself, as it does on Wayland.
+ if event.isARepeat() {
+ return;
+ }
+ let keyed = |down| Ev::Keyed {
+ code: event.keyCode(),
+ chars: text(event.characters()),
+ unmodified: text(event.charactersIgnoringModifiers()),
+ flags,
+ down,
+ };
+ send(keyed(down));
+ // AppKit sends no keyUp for a key pressed with Command held, so a ⌘
+ // shortcut is released as it is pressed: otherwise the driver would
+ // take the key as held, and repeat ⌘Z until the window lost focus.
+ if down && flags & appkit::flags::COMMAND != 0 {
+ send(keyed(false));
+ }
+ }
+}
+
+define_class!(
+ // SAFETY: NSObject has no subclassing requirements, and Delegate no Drop.
+ #[unsafe(super(NSObject))]
+ #[thread_kind = MainThreadOnly]
+ #[name = "CceUiDelegate"]
+ struct Delegate;
+
+ unsafe impl NSObjectProtocol for Delegate {}
+
+ unsafe impl NSApplicationDelegate for Delegate {
+ /// Quit asks the app to exit, as a close does; the shell stops the
+ /// run loop once it has.
+ #[unsafe(method(applicationShouldTerminate:))]
+ fn should_terminate(&self, _sender: &NSApplication) -> NSApplicationTerminateReply {
+ send(Ev::CloseRequested);
+ NSApplicationTerminateReply::TerminateCancel
+ }
+ }
+
+ unsafe impl NSWindowDelegate for Delegate {
+ #[unsafe(method(windowShouldClose:))]
+ fn window_should_close(&self, _sender: &NSWindow) -> bool {
+ send(Ev::CloseRequested);
+ false
+ }
+
+ #[unsafe(method(windowDidResize:))]
+ fn window_did_resize(&self, _notification: &NSNotification) {
+ send(Ev::Resized);
+ }
+
+ #[unsafe(method(windowDidChangeBackingProperties:))]
+ fn window_did_change_backing(&self, _notification: &NSNotification) {
+ send(Ev::Resized);
+ }
+
+ #[unsafe(method(windowDidBecomeKey:))]
+ fn window_did_become_key(&self, _notification: &NSNotification) {
+ send(Ev::Focused(true));
+ }
+
+ #[unsafe(method(windowDidResignKey:))]
+ fn window_did_resign_key(&self, _notification: &NSNotification) {
+ send(Ev::Focused(false));
+ }
+ }
+);
+
+impl Delegate {
+ fn new(mtm: MainThreadMarker) -> Retained<Self> {
+ let this = Self::alloc(mtm).set_ivars(());
+ // SAFETY: NSObject's `init`.
+ unsafe { msg_send![super(this), init] }
+ }
+}
diff --cc src/motion.rs
index de298e6,aedaead..cbf6e35
--- a/src/motion.rs
+++ b/src/motion.rs
@@@ -88,6 -84,9 +84,9 @@@ fn enabled_on_this_machine() -> bool
if let Some(forced) = *ENV.get_or_init(|| std::env::var("CCE_ANIMATIONS").ok().and_then(|v| parse(&v))) {
return forced;
}
+ use std::sync::Mutex;
- use std::time::Instant;
++ use web_time::Instant;
+ static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
match *cache {
diff --cc src/web/shell.rs
index df53633,0000000..37536bc
mode 100644,000000..100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@@ -1,929 -1,0 +1,930 @@@
+//! The browser shell: an [`Application`] run on a `<canvas>`, the way the
+//! Wayland shell runs one on a surface. It is the third piece of the run
+//! loop over the shared two — [`Driver`] routes what the page's events mean,
+//! [`Pacer::turn`] decides what a turn does — and what is left here is the
+//! page's side of both:
+//!
+//! - **Events in.** Pointer, wheel, key and focus events on the canvas,
+//! mapped into driver calls in cce-ui's terms (`map_key`, `wheel_frame`).
+//! A page has no grabs, so a press on what would be a CSD border is the
+//! app's ([`PressSite::can_grab`] false).
+//! - **Turns.** One turn per animation frame while anything moves (the
+//! pacer's ACTIVE cadence), a timer while idle; any event, and any message
+//! on the app's [`AppSender`](crate::engine::AppSender), wakes the loop
+//! for the next frame. Presenting happens inside an animation-frame
+//! callback, which is the browser's own frame pacing — there is no
+//! outstanding frame to wait on ([`Shell::frame_pending`] is false).
+//! - **Frames out.** [`build_frame`] at the canvas's CSS size and the page's
+//! `devicePixelRatio`, drawn by the [`WebRenderer`].
+//!
+//! **The keyboard is a hidden `<textarea>`'s**, not the canvas's: a page can
+//! compose input-method text (Japanese, Chinese, Korean, a dead key, an
+//! emoji panel) only into an editable element. It takes the focus a press
+//! on the canvas would have given the canvas (and the canvas, focused some
+//! other way, hands it over), its key events are the app's as the canvas's
+//! were, and its composition is the input method's: a key the input method
+//! takes (`Process`, keyCode 229, or one sent mid-composition) is left to
+//! it, `input` events while composing are the composition
+//! (`Driver::preedit`), `compositionend` its commit (`Driver::commit_text`),
+//! and text that arrives with no composition (an emoji panel, dictation) is
+//! committed as it comes. After each frame it is moved to the editing
+//! widget's caret (`ime::caret`), where the input method puts its
+//! candidates. A widget that drops a composition (`ime::take_reset`) has it
+//! cancelled by taking the focus off the textarea and back, inside the turn,
+//! where the events that raises are not the app's.
+//!
+//! **The clipboard** comes through the page's clipboard events, since a page
+//! may read the clipboard only inside a `paste` event: a ⌘/Ctrl+V is held
+//! back from the app until its `paste` event has handed over the text (or,
+//! if none comes, until the task after), so the widget that pastes on it
+//! reads that text (`widget::clipboard`). A ⌘/Ctrl+C or X reaches the app at
+//! once, and the `copy` / `cut` event it raises carries whatever the app
+//! copied. The three keys' defaults are the only ones the canvas lets the
+//! page have.
+//!
+//! The page owns the canvas's place in it; [`Sizing`] says who owns its
+//! size. There is no context-menu popup surface here: the menu is drawn in
+//! the canvas and kept inside it (`context_menu::constrain_to`), as on a
+//! layer surface.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+use std::sync::mpsc::Receiver;
+use std::time::Duration;
+
+use cursor_icon::CursorIcon;
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{
+ AddEventListenerOptions, ClipboardEvent, CompositionEvent, FocusEvent, HtmlCanvasElement, HtmlTextAreaElement, InputEvent,
+ KeyboardEvent, PointerEvent, WheelEvent,
+};
+
+use super::renderer::{Capture, WebRenderer};
+use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
+use crate::backend::dom::{clipboard_key, map_key, utf16_range_to_bytes, wheel_frame, ClipKey};
+use crate::backend::driver::{Driver, Modifiers, PressSite, ScrollFrame, ScrollSource, Turn};
+use crate::backend::frame::build_frame;
+use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
- use crate::widget::{clipboard, context_menu, ElementState, Key, MouseButton, TextItem};
++use crate::widget::{clipboard, context_menu, ElementState, Key, MouseButton};
++use crate::backend::text::DlText;
+
+/// Who decides the canvas's size.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Sizing {
+ /// The app does, as it sizes a window: the canvas is given the app's
+ /// `WindowSettings` size at start and its `desired_size` after (CSS px).
+ App,
+ /// The page does: the canvas is wherever its CSS box puts it, the app's
+ /// size requests are not honoured (a tiling compositor's answer), and a
+ /// change of the box is a resize.
+ Page,
+}
+
+/// The fonts an app is run with: a page has no font directory and no
+/// fontconfig, so it says both what there is and what the generic families
+/// are. A generic left `None` is the first family the set has of a list of
+/// well-known ones (DejaVu, Noto, FreeFont, …).
+#[derive(Debug, Clone, Default)]
+pub struct Fonts {
+ /// Font files' bytes (TrueType, OpenType, collections), in the order a
+ /// directory scan would find them: where text falls back to the first
+ /// face holding a glyph, order decides it.
+ pub files: Vec<Vec<u8>>,
+ pub serif: Option<String>,
+ pub sans_serif: Option<String>,
+ pub monospace: Option<String>,
+}
+
+impl Fonts {
+ pub fn new(files: Vec<Vec<u8>>) -> Self {
+ Self { files, ..Default::default() }
+ }
+}
+
+/// Run `A` on `canvas`, its text set in `fonts`. Returns once the app is
+/// up; the loop runs on the page's callbacks from then on, until the app
+/// asks to exit.
+///
+/// One app per page: the toolkit's context menu is one per thread, and so is
+/// [`set_wake`].
+pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing: Sizing) -> Result<(), JsValue> {
+ // Every font database the toolkit builds from here on — this one, its
+ // own for widget geometry, and the measuring one — loads the page's.
+ crate::page_fonts::provide(fonts.files, fonts.serif, fonts.sans_serif, fonts.monospace);
+ let fs = crate::create_font_system();
+
+ let mut renderer = WebRenderer::new(canvas.clone()).await?;
+ // The scale is known before the app is built, as the Wayland shell sets
+ // it before `create`: a widget may read it as it is made.
+ let dpr = device_pixel_ratio();
+ crate::scale::set_scale_factor(dpr as f32);
+ let (tx, rx) = std::sync::mpsc::channel();
+ let mut app = A::create(AppSender::from(tx));
+ // The app's persistent GPU resources, as the Wayland shell's
+ // `renderer_init` makes them: once, before the first frame.
+ app.init_3d(&mut renderer);
+ let settings = app.settings();
+ crate::scale::set_app_id(settings.app_id.clone());
+ if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
+ doc.set_title(&settings.title);
+ }
+
+ let style = canvas.style();
+ if sizing == Sizing::App {
+ set_css_size(&canvas, settings.width, settings.height);
+ }
+ // The canvas can be focused (it hands the focus to the keyboard sink),
+ // draws no focus outline of its own, and keeps touches for the app
+ // rather than panning the page.
+ canvas.set_tab_index(0);
+ let _ = style.set_property("outline", "none");
+ let _ = style.set_property("touch-action", "none");
+ let sink = keyboard_sink()?;
+
+ let shell = WebShell {
+ app,
+ driver: Driver::new(),
+ redraw: true,
+ exit: false,
+ rx,
+ renderer,
+ fs,
+ swash: cosmic_text::SwashCache::new(),
+ items: Vec::new(),
+ damage_owed: true,
+ canvas: canvas.clone(),
+ sizing,
+ logical: (0.0, 0.0),
+ scale: dpr,
+ just_configured: false,
+ cursor: CursorIcon::Default,
+ mac: is_mac(),
+ sink: sink.clone(),
+ sink_at: None,
+ };
+ let lp = Rc::new(Loop {
+ shell: RefCell::new(shell),
+ pacer: RefCell::new(Pacer::new(settings.title)),
+ sched: RefCell::new(Sched::default()),
+ frame_cb: RefCell::new(None),
+ timer_cb: RefCell::new(None),
+ finger_end_cb: RefCell::new(None),
+ finger_timer: Cell::new(None),
+ held_paste: RefCell::new(None),
+ paste_cb: RefCell::new(None),
+ });
+ lp.shell.borrow_mut().measure();
+ lp.shell.borrow_mut().just_configured = true;
+ Loop::install(&lp, &canvas, &sink)?;
+ lp.wake();
+ Ok(())
+}
+
+thread_local! {
+ /// Callers of [`capture`] waiting on the next frame: each a promise's resolve.
+ static CAPTURES: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
+}
+
+/// The next frame the shell draws, read back from the GPU. A fresh frame is
+/// asked for, as an input event would ask, and this resolves once it is
+/// drawn. For screenshot tests: a headless browser compositing in software
+/// leaves a WebGPU canvas out of its page screenshots (and `toDataURL`).
+pub async fn capture() -> Result<Capture, JsValue> {
+ let promise = js_sys::Promise::new(&mut |resolve, _reject| CAPTURES.with(|c| c.borrow_mut().push(resolve)));
+ crate::backend::app::wake();
+ let got = wasm_bindgen_futures::JsFuture::from(promise).await?;
+ let got: js_sys::Array = got.dyn_into().map_err(|_| JsValue::from_str("cce-ui: the frame was not captured"))?;
+ Ok(Capture {
+ width: got.get(0).as_f64().unwrap_or(0.0) as u32,
+ height: got.get(1).as_f64().unwrap_or(0.0) as u32,
+ rgba: js_sys::Uint8Array::new(&got.get(2)).to_vec(),
+ })
+}
+
+/// The page's side of the run loop, as the Wayland shell's `EngineState` is
+/// the compositor's.
+struct WebShell<A: Application> {
+ app: A,
+ driver: Driver,
+ redraw: bool,
+ exit: bool,
+ rx: Receiver<A::Message>,
+ renderer: WebRenderer,
+ fs: cosmic_text::FontSystem,
+ swash: cosmic_text::SwashCache,
+ /// The frame's display-list text, shaped by [`build_frame`].
- items: Vec<TextItem>,
++ items: Vec<DlText>,
+ damage_owed: bool,
+ canvas: HtmlCanvasElement,
+ sizing: Sizing,
+ /// The canvas's CSS size, and `devicePixelRatio`, as last measured.
+ logical: (f32, f32),
+ scale: f64,
+ just_configured: bool,
+ cursor: CursorIcon,
+ /// Command is the shortcut key here: ⌘Z is undo, as every Mac app has it.
+ mac: bool,
+ /// The hidden textarea that holds the keyboard (see the module doc), and
+ /// where it was last put (page px: left, top, height).
+ sink: HtmlTextAreaElement,
+ sink_at: Option<(f64, f64, f64)>,
+}
+
+impl<A: Application> WebShell<A> {
+ /// Read the canvas's box and the pixel ratio, and size the drawing
+ /// buffer to them. Whether either changed.
+ fn measure(&mut self) -> bool {
+ let dpr = device_pixel_ratio();
+ let w = self.canvas.client_width().max(0) as f32;
+ let h = self.canvas.client_height().max(0) as f32;
+ if (w, h) == self.logical && dpr == self.scale {
+ return false;
+ }
+ self.logical = (w, h);
+ if dpr != self.scale {
+ self.scale = dpr;
+ crate::scale::set_scale_factor(dpr as f32);
+ }
+ let (pw, ph) = self.physical();
+ if self.renderer.size() != (pw, ph) {
+ self.renderer.resize(pw, ph);
+ }
+ self.redraw = true;
+ true
+ }
+
+ fn physical(&self) -> (u32, u32) {
+ let s = self.scale as f32;
+ ((self.logical.0 * s).round().max(1.0) as u32, (self.logical.1 * s).round().max(1.0) as u32)
+ }
+
+ fn size(&self) -> LogicalSize {
+ LogicalSize::new(self.logical.0, self.logical.1)
+ }
+
+ /// Hand the messages posted since the last turn to `update`, as the
+ /// Wayland loop's channel source does between dispatches.
+ fn drain_messages(&mut self) {
+ while let Ok(msg) = self.rx.try_recv() {
+ let mut rebuild = false;
+ self.app.update(msg, &mut rebuild, &mut self.exit);
+ self.redraw |= rebuild;
+ }
+ }
+
+ fn mods_from(&self, ctrl: bool, shift: bool, alt: bool, meta: bool) -> Modifiers {
+ if self.mac {
+ Modifiers { ctrl: ctrl || meta, shift, alt, logo: false }
+ } else {
+ Modifiers { ctrl, shift, alt, logo: meta }
+ }
+ }
+
+ fn sync_mods(&mut self, mods: Modifiers) {
+ if mods != self.driver.mods {
+ self.driver.set_modifiers(&mut self.app, mods);
+ }
+ }
+
+ fn pointer_pos(&mut self, e: &PointerEvent) -> LogicalPosition {
+ let (x, y) = (e.offset_x() as f32, e.offset_y() as f32);
+ self.driver.cursor_pos = (x, y);
+ LogicalPosition::new(x, y)
+ }
+
+ /// Put the keyboard sink at the editing widget's caret (the canvas's
+ /// corner when nothing is editing), so the input method's candidates
+ /// open there; and cancel a composition a widget dropped. Inside the
+ /// turn: the shell is borrowed, so the blur and focus this raises (and
+ /// the composition's end) reach no handler.
+ fn place_sink(&mut self) {
+ let r = self.canvas.get_bounding_client_rect();
+ let (left, top, h) = match crate::ime::caret() {
+ Some([x, y, _, h]) => (r.left() + x as f64, r.top() + y as f64, (h as f64).max(8.0)),
+ None => (r.left(), r.top(), 16.0),
+ };
+ if self.sink_at != Some((left, top, h)) {
+ self.sink_at = Some((left, top, h));
+ let st = self.sink.style();
+ let _ = st.set_property("left", &format!("{left}px"));
+ let _ = st.set_property("top", &format!("{top}px"));
+ let _ = st.set_property("height", &format!("{h}px"));
+ let _ = st.set_property("font-size", &format!("{}px", (h * 0.8).round()));
+ let _ = st.set_property("line-height", &format!("{h}px"));
+ }
+ if crate::ime::take_reset() {
+ let focused = web_sys::window()
+ .and_then(|w| w.document())
+ .and_then(|d| d.active_element())
+ .is_some_and(|a| a == *self.sink.unchecked_ref::<web_sys::Element>());
+ self.sink.set_value("");
+ if focused {
+ let _ = self.sink.blur();
+ let _ = self.sink.focus();
+ }
+ }
+ }
+
+ fn update_cursor(&mut self) {
+ let (x, y) = self.driver.cursor_pos;
+ let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
+ if icon != self.cursor {
+ self.cursor = icon;
+ let _ = self.canvas.style().set_property("cursor", icon.name());
+ }
+ }
+}
+
+impl<A: Application> Shell for WebShell<A> {
+ type App = A;
+
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
+ (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
+ }
+
+ fn app(&self) -> &A {
+ &self.app
+ }
+
+ fn redraw(&mut self) -> &mut bool {
+ &mut self.redraw
+ }
+
+ fn exit_requested(&self) -> bool {
+ self.exit
+ }
+
+ fn take_just_configured(&mut self) -> bool {
+ std::mem::replace(&mut self.just_configured, false)
+ }
+
+ fn request_size(&mut self, w: u32, h: u32) {
+ if self.sizing == Sizing::App && (w as f32, h as f32) != self.logical {
+ set_css_size(&self.canvas, w, h);
+ self.measure();
+ }
+ }
+
+ fn sync(&mut self) {
+ // The page laid the canvas out anew, or moved it to another
+ // display: a configure, which wins the next turn over the app's size.
+ if self.measure() {
+ self.just_configured = true;
+ }
+ if CAPTURES.with(|c| !c.borrow().is_empty()) {
+ self.redraw = true;
+ }
+ // No popup surface to host the menu: it is drawn in the canvas and
+ // kept inside it, frames asked for while a page turn animates.
+ if context_menu::is_visible() {
+ if context_menu::is_turning() {
+ self.redraw = true;
+ }
+ context_menu::set_hosted(false);
+ context_menu::constrain_to(0.0, 0.0, self.logical.0, self.logical.1);
+ }
+ }
+
+ fn set_title(&mut self, title: &str) {
+ if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
+ doc.set_title(title);
+ }
+ }
+
+ fn frame_pending(&mut self) -> bool {
+ false
+ }
+
+ fn configured(&self) -> bool {
+ self.logical.0 > 0.0 && self.logical.1 > 0.0
+ }
+
+ fn present(&mut self, _fresh: bool) {
+ let size = self.size();
+ let frame = build_frame(&mut self.app, &mut self.fs, size, self.scale, &mut self.damage_owed, &mut self.items);
+ if frame.dl_text {
+ let spans = frame.text_spans(&self.items);
+ self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
+ }
+ // The app's 3D scene, staged last, as the Wayland shell's
+ // `stage_renderer`; true asks for another frame.
+ if self.app.stage_3d(&mut self.renderer, size, self.scale) {
+ self.redraw = true;
+ }
+ let waiting = CAPTURES.with(|c| std::mem::take(&mut *c.borrow_mut()));
+ if !waiting.is_empty() {
+ self.renderer.capture_next_frame();
+ }
+ match self.renderer.draw_frame_2d(frame.frame2d()) {
+ Ok(()) => self.damage_owed = false,
+ Err(e) => {
+ web_sys::console::error_2(&"cce-ui: frame not drawn:".into(), &e);
+ self.redraw = true;
+ }
+ }
+ self.place_sink();
+ if waiting.is_empty() {
+ return;
+ }
+ let pending = self.renderer.take_pending_capture();
+ wasm_bindgen_futures::spawn_local(async move {
+ let got = match pending {
+ Some(p) => p.read().await.ok(),
+ None => None,
+ };
+ let value: JsValue = match got {
+ Some(c) => js_sys::Array::of3(&c.width.into(), &c.height.into(), &js_sys::Uint8Array::from(&c.rgba[..])).into(),
+ None => JsValue::NULL,
+ };
+ for resolve in waiting {
+ let _ = resolve.call1(&JsValue::NULL, &value);
+ }
+ });
+ }
+}
+
+/// When the next turn is due.
+#[derive(Default)]
+struct Sched {
+ /// An animation frame is requested: the next turn is in it.
+ frame: Option<i32>,
+ /// An idle timer is set; it requests the frame when it fires.
+ timer: Option<i32>,
+ /// The app exited: nothing turns again.
+ stopped: bool,
+}
+
+/// The shell, the pacer and the schedule, shared by the page's callbacks.
+/// The schedule is its own cell, borrowed only briefly and never across a
+/// call into the app, so a message the app sends mid-turn can wake the loop.
+struct Loop<A: Application> {
+ shell: RefCell<WebShell<A>>,
+ pacer: RefCell<Pacer>,
+ sched: RefCell<Sched>,
+ frame_cb: RefCell<Option<Closure<dyn FnMut(f64)>>>,
+ timer_cb: RefCell<Option<Closure<dyn FnMut()>>>,
+ finger_end_cb: RefCell<Option<Closure<dyn FnMut()>>>,
+ /// The lift timer the last finger frame set; the next frame cancels it.
+ finger_timer: Cell<Option<i32>>,
+ /// A ⌘/Ctrl+V held back until its `paste` event, and the timer that
+ /// lets it through if no event comes.
+ held_paste: RefCell<Option<HeldKey>>,
+ paste_cb: RefCell<Option<Closure<dyn FnMut()>>>,
+}
+
+/// A key press, kept to dispatch later: the key, its text and the
+/// modifiers it came with (ctrl, shift, alt, meta).
+struct HeldKey {
+ key: Key,
+ text: Option<String>,
+ mods: (bool, bool, bool, bool),
+}
+
+/// A browser reports no lift for a two-finger scroll: this long without a
+/// finger frame is one, and ends the gesture (`ScrollPhase::FingerEnd`) so a
+/// flick coasts and a side swipe readies its next turn.
+const FINGER_LIFT: Duration = Duration::from_millis(120);
+
+impl<A: Application> Loop<A> {
+ /// Turn the loop at the next animation frame.
+ fn wake(&self) {
+ let mut s = self.sched.borrow_mut();
+ if s.stopped || s.frame.is_some() {
+ return;
+ }
+ let win = window();
+ if let Some(t) = s.timer.take() {
+ win.clear_timeout_with_handle(t);
+ }
+ let cb = self.frame_cb.borrow();
+ if let Some(cb) = cb.as_ref() {
+ s.frame = win.request_animation_frame(cb.as_ref().unchecked_ref()).ok();
+ }
+ }
+
+ /// One turn, in an animation frame.
+ fn on_frame(&self) {
+ self.sched.borrow_mut().frame = None;
+ let step = {
+ let mut shell = self.shell.borrow_mut();
+ shell.drain_messages();
+ self.pacer.borrow_mut().turn(&mut *shell)
+ };
+ match step {
+ Step::Exit => {
+ self.sched.borrow_mut().stopped = true;
+ set_wake(None);
+ self.shell.borrow_mut().app.on_exit();
+ }
+ Step::Sleep(d) if d <= ACTIVE_DISPATCH => self.wake(),
+ Step::Sleep(d) => {
+ let mut s = self.sched.borrow_mut();
+ let cb = self.timer_cb.borrow();
+ // A frame already requested (the app posted itself a message
+ // mid-turn) turns sooner than any timer would.
+ if let (None, None, Some(cb)) = (s.frame, s.timer, cb.as_ref()) {
+ s.timer = window()
+ .set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), d.as_millis() as i32)
+ .ok();
+ }
+ }
+ }
+ }
+
+ /// Dispatch an event to the shell, then turn soon. Input after the exit
+ /// is dropped.
+ fn event(&self, f: impl FnOnce(&mut WebShell<A>)) {
+ if self.sched.borrow().stopped {
+ return;
+ }
+ // A page event fired from inside a turn (a focus change the app's
+ // own DOM call caused) finds the shell borrowed: it is dropped
+ // rather than panicking the page.
+ let Ok(mut shell) = self.shell.try_borrow_mut() else { return };
+ f(&mut shell);
+ drop(shell);
+ self.wake();
+ }
+
+ /// The callbacks: the loop's own two, the wake hook, and the canvas's events.
+ fn install(lp: &Rc<Self>, canvas: &HtmlCanvasElement, sink: &HtmlTextAreaElement) -> Result<(), JsValue> {
+ let l = lp.clone();
+ *lp.frame_cb.borrow_mut() = Some(Closure::new(move |_t: f64| l.on_frame()));
+ let l = lp.clone();
+ *lp.timer_cb.borrow_mut() = Some(Closure::new(move || {
+ l.sched.borrow_mut().timer = None;
+ l.wake();
+ }));
+ let l = lp.clone();
+ *lp.finger_end_cb.borrow_mut() = Some(Closure::new(move || l.finger_lift()));
+ let l = lp.clone();
+ *lp.paste_cb.borrow_mut() = Some(Closure::new(move || l.release_paste()));
+ let l = Rc::downgrade(lp);
+ set_wake(Some(Box::new(move || {
+ if let Some(l) = l.upgrade() {
+ l.wake();
+ }
+ })));
+
+ let target: &web_sys::EventTarget = canvas.as_ref();
+ let l = lp.clone();
+ listen(target, "pointermove", false, move |e: PointerEvent| {
+ l.event(|s| {
+ let pos = s.pointer_pos(&e);
+ let (driver, t) = s.turn();
+ driver.pointer_motion(t, pos);
+ s.update_cursor();
+ })
+ })?;
+ let l = lp.clone();
+ listen(target, "pointerenter", false, move |e: PointerEvent| {
+ l.event(|s| {
+ let pos = s.pointer_pos(&e);
+ let (driver, t) = s.turn();
+ driver.pointer_enter(t, pos);
+ s.update_cursor();
+ })
+ })?;
+ let l = lp.clone();
+ listen(target, "pointerleave", false, move |_e: PointerEvent| {
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.pointer_leave(t);
+ })
+ })?;
+ let l = lp.clone();
+ let c = canvas.clone();
+ let k = sink.clone();
+ listen(target, "pointerdown", true, move |e: PointerEvent| {
+ let Some(btn) = dom_button(e.button()) else { return };
+ // Take the keyboard first: the focus event this fires is
+ // dispatched now, before the shell is borrowed below. Capture
+ // keeps a drag's moves and its release coming to the canvas when
+ // the pointer leaves it, as a Wayland implicit grab does.
+ let _ = k.focus();
+ let _ = c.set_pointer_capture(e.pointer_id());
+ e.prevent_default();
+ l.event(|s| {
+ let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ s.sync_mods(mods);
+ let pos = s.pointer_pos(&e);
+ let site = PressSite { size: s.size(), on_popup: false, can_grab: false, own_edges: false };
+ let (driver, t) = s.turn();
+ driver.pointer_press(t, btn, pos, site);
+ })
+ })?;
+ let l = lp.clone();
+ listen(target, "pointerup", false, move |e: PointerEvent| {
+ let Some(btn) = dom_button(e.button()) else { return };
+ l.event(|s| {
+ let pos = s.pointer_pos(&e);
+ let (driver, t) = s.turn();
+ driver.pointer_release(t, btn, pos);
+ s.update_cursor();
+ })
+ })?;
+ // The right button is the app's, not the page's menu.
+ listen(target, "contextmenu", true, |e: web_sys::Event| e.prevent_default())?;
+ let l = lp.clone();
+ listen(target, "wheel", true, move |e: WheelEvent| {
+ e.prevent_default();
+ let frame = wheel_frame(e.delta_mode(), e.delta_x(), e.delta_y());
+ l.event(|s| {
+ let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ s.sync_mods(mods);
+ let pos = LogicalPosition::new(e.offset_x() as f32, e.offset_y() as f32);
+ s.driver.cursor_pos = (pos.x, pos.y);
+ let (driver, t) = s.turn();
+ driver.scroll(t, frame, pos);
+ });
+ if frame.source == Some(ScrollSource::Finger) {
+ l.arm_finger_lift();
+ }
+ })?;
+ // The canvas focused some other way (Tab, the page's script) hands
+ // the keyboard to the sink.
+ let k = sink.clone();
+ listen(target, "focus", false, move |_e: FocusEvent| {
+ let _ = k.focus();
+ })?;
+ let keys: &web_sys::EventTarget = sink.as_ref();
+ let l = lp.clone();
+ listen(keys, "keydown", true, move |e: KeyboardEvent| l.key(&e, ElementState::Pressed))?;
+ let l = lp.clone();
+ listen(keys, "keyup", true, move |e: KeyboardEvent| l.key(&e, ElementState::Released))?;
+ for (name, focused) in [("focus", true), ("blur", false)] {
+ let l = lp.clone();
+ let c = canvas.clone();
+ listen(keys, name, false, move |e: FocusEvent| {
+ // Over to the canvas is on its way back.
+ let to_canvas = e.related_target().is_some_and(|r| AsRef::<JsValue>::as_ref(&r) == AsRef::<JsValue>::as_ref(&c));
+ if !focused && to_canvas {
+ return;
+ }
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.keyboard_focus(t, focused);
+ })
+ })?;
+ }
+ // The input method's half.
+ let l = lp.clone();
+ let k = sink.clone();
+ listen(keys, "input", false, move |e: InputEvent| {
+ if e.is_composing() {
+ let text = k.value();
+ let cursor = utf16_range_to_bytes(&text, k.selection_start().ok().flatten(), k.selection_end().ok().flatten());
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.preedit(t, Some(crate::ime::Preedit { text, cursor }));
+ });
+ } else if e.input_type() == "insertFromPaste" {
+ // A paste that reached the sink by the browser's default (a
+ // handler of the page's swallowed its `paste` event): the
+ // clipboard's text all the same, for the held ⌘/Ctrl+V.
+ clipboard::pasted(k.value());
+ k.set_value("");
+ l.release_paste();
+ } else {
+ // Text with no composition: an emoji panel, dictation.
+ let text = k.value();
+ k.set_value("");
+ if !text.is_empty() {
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.commit_text(t, text);
+ });
+ }
+ }
+ })?;
+ let l = lp.clone();
+ let k = sink.clone();
+ listen(keys, "compositionend", false, move |e: CompositionEvent| {
+ let text = e.data().unwrap_or_default();
+ k.set_value("");
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.preedit(t, None);
+ let (driver, t) = s.turn();
+ driver.commit_text(t, text);
+ });
+ })?;
+ // The clipboard's events, raised by the three keys `key` lets
+ // through. They go to the focused element or the body, so they are
+ // heard on the document.
+ if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
+ let doc: &web_sys::EventTarget = doc.as_ref();
+ let l = lp.clone();
+ listen(doc, "paste", true, move |e: ClipboardEvent| {
+ if let Some(text) = e.clipboard_data().and_then(|d| d.get_data("text/plain").ok()) {
+ clipboard::pasted(text);
+ }
+ e.prevent_default();
+ l.release_paste();
+ })?;
+ for name in ["copy", "cut"] {
+ listen(doc, name, true, move |e: ClipboardEvent| {
+ if let (Some(text), Some(data)) = (clipboard::take_copied(), e.clipboard_data()) {
+ if data.set_data("text/plain", &text).is_ok() {
+ e.prevent_default();
+ }
+ }
+ })?;
+ }
+ }
+ // The page laying the canvas out anew is a configure; the turn it
+ // wakes measures the box (`sync`).
+ let l = lp.clone();
+ let observer = Closure::<dyn FnMut()>::new(move || l.wake());
+ let ro = web_sys::ResizeObserver::new(observer.as_ref().unchecked_ref())?;
+ ro.observe(canvas);
+ observer.forget();
+ std::mem::forget(ro);
+ Ok(())
+ }
+
+ fn key(&self, e: &KeyboardEvent, state: ElementState) {
+ // The input method's key: it composes with it (and a browser that
+ // sends the key confirming a composition after its end still marks
+ // it 229).
+ if e.is_composing() || e.key_code() == 229 {
+ return;
+ }
+ let accel = e.ctrl_key() || e.meta_key();
+ let Some((key, text)) = map_key(&e.key(), accel) else { return };
+ let clip = clipboard_key(&e.key(), accel, e.alt_key());
+ // A clipboard key's default is its clipboard event: the page keeps it.
+ if clip.is_none() && !passes_to_page(e) {
+ e.prevent_default();
+ }
+ // The driver repeats a held key itself, at the toolkit's own rate
+ // (`KEY_REPEAT_DELAY` / `_INTERVAL`), as it does on Wayland, where
+ // the compositor sends one press: the browser's repeats are dropped.
+ if e.repeat() {
+ return;
+ }
+ let mods = (e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ if state == ElementState::Pressed {
+ match clip {
+ // Held until the `paste` event has handed over the text: it
+ // is raised after this listener returns, in the same task,
+ // so a zero timer is the fallback for a browser that raises
+ // none (the clipboard then reads what it read before).
+ Some(ClipKey::Paste) => {
+ self.release_paste();
+ *self.held_paste.borrow_mut() = Some(HeldKey { key, text, mods });
+ if let Some(cb) = self.paste_cb.borrow().as_ref() {
+ let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 0);
+ }
+ return;
+ }
+ // A copy left over from a menu click is not this key's.
+ Some(ClipKey::Copy | ClipKey::Cut) => {
+ clipboard::take_copied();
+ }
+ None => {}
+ }
+ } else {
+ // A release never overtakes the press it ends.
+ self.release_paste();
+ }
+ self.dispatch_key(HeldKey { key, text, mods }, state);
+ }
+
+ fn dispatch_key(&self, k: HeldKey, state: ElementState) {
+ let (ctrl, shift, alt, meta) = k.mods;
+ self.event(|s| {
+ let mods = s.mods_from(ctrl, shift, alt, meta);
+ s.sync_mods(mods);
+ let (driver, t) = s.turn();
+ driver.key(t, k.key, k.text, state);
+ });
+ }
+
+ /// Hand a held ⌘/Ctrl+V to the app, if one is held: its `paste` event
+ /// has come, or will not.
+ fn release_paste(&self) {
+ let held = self.held_paste.borrow_mut().take();
+ if let Some(k) = held {
+ self.dispatch_key(k, ElementState::Pressed);
+ }
+ }
+
+ fn arm_finger_lift(&self) {
+ let win = window();
+ if let Some(t) = self.finger_timer.take() {
+ win.clear_timeout_with_handle(t);
+ }
+ if let Some(cb) = self.finger_end_cb.borrow().as_ref() {
+ self.finger_timer.set(
+ win.set_timeout_with_callback_and_timeout_and_arguments_0(
+ cb.as_ref().unchecked_ref(),
+ FINGER_LIFT.as_millis() as i32,
+ )
+ .ok(),
+ );
+ }
+ }
+
+ /// No finger frame for [`FINGER_LIFT`]: the gesture ended.
+ fn finger_lift(&self) {
+ self.finger_timer.set(None);
+ let frame = ScrollFrame { source: Some(ScrollSource::Finger), stop: true, ..Default::default() };
+ self.event(|s| {
+ let (x, y) = s.driver.cursor_pos;
+ let (driver, t) = s.turn();
+ driver.scroll(t, frame, LogicalPosition::new(x, y));
+ });
+ }
+}
+
+/// The hidden textarea the keyboard goes to (see the module doc): fixed in
+/// the page, invisible, never hit, and no help offered on what is typed.
+fn keyboard_sink() -> Result<HtmlTextAreaElement, JsValue> {
+ let doc = window().document().ok_or_else(|| JsValue::from_str("cce-ui: no document"))?;
+ let sink: HtmlTextAreaElement = doc.create_element("textarea")?.dyn_into()?;
+ for (k, v) in [("autocomplete", "off"), ("autocorrect", "off"), ("autocapitalize", "off"), ("spellcheck", "false"), ("aria-hidden", "true")] {
+ sink.set_attribute(k, v)?;
+ }
+ let st = sink.style();
+ for (k, v) in [
+ ("position", "fixed"),
+ ("left", "0px"),
+ ("top", "0px"),
+ ("width", "1px"),
+ ("height", "16px"),
+ ("padding", "0"),
+ ("border", "0"),
+ ("margin", "0"),
+ ("outline", "none"),
+ ("resize", "none"),
+ ("overflow", "hidden"),
+ ("white-space", "pre"),
+ ("opacity", "0"),
+ ("pointer-events", "none"),
+ ("caret-color", "transparent"),
+ ("color", "transparent"),
+ ("background", "transparent"),
+ ] {
+ st.set_property(k, v)?;
+ }
+ doc.body().ok_or_else(|| JsValue::from_str("cce-ui: no body"))?.append_child(&sink)?;
+ Ok(sink)
+}
+
+fn window() -> web_sys::Window {
+ web_sys::window().expect("cce-ui's browser shell runs in a window")
+}
+
+fn device_pixel_ratio() -> f64 {
+ web_sys::window().map_or(1.0, |w| w.device_pixel_ratio())
+}
+
+fn is_mac() -> bool {
+ web_sys::window()
+ .and_then(|w| w.navigator().platform().ok())
+ .is_some_and(|p| p.starts_with("Mac") || p.starts_with("iP"))
+}
+
+fn set_css_size(canvas: &HtmlCanvasElement, w: u32, h: u32) {
+ let style = canvas.style();
+ let _ = style.set_property("width", &format!("{w}px"));
+ let _ = style.set_property("height", &format!("{h}px"));
+}
+
+/// Add `f` as the target's `name` listener for the page's lifetime. A
+/// `cancelable` one is added `passive: false`, so it may cancel the event's
+/// default (the page's scroll, menu, focus move or shortcut).
+fn listen<E: JsCast + 'static>(
+ target: &web_sys::EventTarget,
+ name: &str,
+ cancelable: bool,
+ mut f: impl FnMut(E) + 'static,
+) -> Result<(), JsValue> {
+ let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |e: web_sys::Event| f(e.unchecked_into()));
+ let opts = AddEventListenerOptions::new();
+ opts.set_passive(!cancelable);
+ target.add_event_listener_with_callback_and_add_event_listener_options(name, cb.as_ref().unchecked_ref(), &opts)?;
+ cb.forget();
+ Ok(())
+}
+
+/// A DOM `button` as the driver's. The back and forward buttons are not
+/// buttons the toolkit has.
+fn dom_button(b: i16) -> Option<MouseButton> {
+ match b {
+ 0 => Some(MouseButton::Left),
+ 1 => Some(MouseButton::Middle),
+ 2 => Some(MouseButton::Right),
+ _ => None,
+ }
+}
+
+/// Keys the page keeps however the app routes keys: reload and the
+/// developer tools.
+fn passes_to_page(e: &KeyboardEvent) -> bool {
+ let k = e.key();
+ let accel = e.ctrl_key() || e.meta_key();
+ k == "F5" || k == "F12" || (accel && (k == "r" || k == "R")) || (accel && e.shift_key() && (k == "I" || k == "J"))
+}
diff --cc src/widget/shaping.rs
index c4062c4,8658b10..4210e68
--- a/src/widget/shaping.rs
+++ b/src/widget/shaping.rs
@@@ -36,7 -36,7 +36,7 @@@ impl Measure for ShapingMeasure
if let Some(w) = self.cache.get(&key) {
return *w;
}
- let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
- let buf = crate::backend::window_runner::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
++ let buf = crate::backend::text::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
// The glyphs' extent, trailing spaces included — the same measure
// `offsets` ends on. (A layout run's `line_w` leaves trailing
// whitespace out, so a width taken from it disagreed with where
@@@ -58,8 -58,8 +58,8 @@@ impl ShapingMeasure
/// combining mark) takes the cluster's start.
pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
let scale = crate::scale::scale_factor().max(0.01);
- let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
- let buf = crate::backend::window_runner::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
- let glyphs = crate::backend::window_runner::normalized_glyph_starts(&buf, text);
++ let buf = crate::backend::text::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
+ let glyphs = crate::backend::text::normalized_glyph_starts(&buf, text);
let mut starts: Vec<(usize, f32)> = Vec::with_capacity(glyphs.len() + 1);
let mut width = 0.0f32;
for (start, x, w) in glyphs {