GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Merge main: the text-input claim, the focus-shadow fix, the checkbox
main gained its own text-input-v3 the same day this branch did. The two
are now one:
- crate::text_input::claim stays, as ime::report_caret by its first
name, so the claims main added (Spinbox, Slider's readout,
ColorSelector, the params pane's code rows, the TextBox field and the
DocEditor viewport) feed ime::caret like every other report. They now
add the PaintCtx offset, as the caret reports do; a widget's caret
report, made after its field claim, wins.
- backend/text_input.rs is this branch's (preedit, commits counted,
reset), with main's two findings taken over: enter applies the last
frame's caret at once, and leave disables an enabled text input, since
wlroots keeps the enabled state across a leave. New test:
a_leave_while_enabled_owes_a_disable_and_enter_enables_again.
- The focus-shadow shader fix followed the shader to src/draw; its
PlatePush doc comment moved to draw/mod.rs.
Verified with CI's steps (RUSTFLAGS=-D warnings): build --all-targets;
test 562 passed; --all-features 601 passed; vk:: tests ran on lavapipe.
check-wasm and check-mac ok. Under the headless sway with the stand-in
input method: activation on click, a composition underlined at the
caret, the commit, a cancel, a press resetting, Escape disabling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 23 ++++++
src/backend/text_input.rs | 35 ++++++++--
src/backend/window_runner.rs | 13 +++-
src/draw/mod.rs | 6 +-
src/draw/shader2d.wgsl | 61 ++++++++++------
src/lib.rs | 1 +
src/text_input.rs | 45 ++++++++++++
src/widget/container/parameters_bg.rs | 5 ++
src/widget/doc_editor/mod.rs | 7 ++
src/widget/input/checkbox.rs | 127 +++++++++++++++++++---------------
src/widget/input/color_selector.rs | 6 ++
src/widget/input/slider.rs | 6 ++
src/widget/input/spinbox.rs | 5 ++
src/widget/input/text_box.rs | 8 +++
14 files changed, 261 insertions(+), 87 deletions(-)
diff --cc CLAUDE.md
index b501fbc,e1607f1..9bb6456
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@@ -1892,6 -1484,24 +1892,29 @@@ button and selected rather than scrolle
touch scroll (`[scroll] touch: …`); in a shadow, `ccectl touch down|motion|up`
drives it.
+ ### A field being edited says so (text-input-v3, since 2026-10-05)
+
+ A widget open for typing calls `cce_ui::text_input::claim(x, y, w, h)` from
-its paint, every frame; once the display list is built the runner takes the
-frame's claim and `backend/text_input.rs` enables the seat's
-`zwp_text_input_v3` with that rectangle, or disables it when nobody claimed.
-The compositor raises the on-screen keyboard on an enable that follows a touch
-(`cce-compositor`'s `osk.rs`). A claim per frame, not an enable/disable pair,
-because a field leaves editing on many paths (Enter, Escape, a click
-elsewhere, focus loss, its page dropped) and a widget that stops painting has
-stopped claiming. `TextBox`, `Spinbox`, `Slider`'s readout, `ColorSelector`,
-the params pane's code rows and a focused `DocEditor` claim; an app that draws
-its own text (a `LineEdit`, a terminal, an editor) must call `claim` itself
-from `display_list` while it has a caret, or the board will not follow it.
-Keys still come over `wl_keyboard`; an input method's `commit_string` is
-delivered as one typed `Key::Character` (`EngineState::type_text`). Preedit
-and surrounding text are not implemented.
++its paint, every frame (window px, the `PaintCtx` offset added). A claim per
++frame, not an enable/disable pair, because a field leaves editing on many
++paths (Enter, Escape, a click elsewhere, focus loss, its page dropped) and a
++widget that stops painting has stopped claiming. `claim` is
++`ime::report_caret` by its first name — the two were written the same day on
++two branches and merged into one: the frame's last claim is `ime::caret()`,
++which every shell reads (see "On Wayland it is `text-input-v3`" above for the
++Wayland half, `backend/text_input.rs`). The compositor raises the on-screen
++keyboard on an enable that follows a touch (`cce-compositor`'s `osk.rs`).
++`Spinbox`, `Slider`'s readout, `ColorSelector`, the params pane's code rows
++claim their field; `TextBox` and a focused `DocEditor` claim their field (the
++viewport) and then report the caret once it is drawn, which wins; an app that
++draws its own text (a `LineEdit`, a terminal, an editor) must claim from
++`display_list` while it has a caret, or the board will not follow it. Keys
++still come over `wl_keyboard`; an input method's commit is typed through
++`Driver::commit_text`. On `enter` the last frame's claim is applied at once,
++and `leave` disables an enabled text input: wlroots keeps the enabled state
++across a leave, and a stale "enabled" turns the next enable into a plain
++commit the compositor ignores.
+
### 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/text_input.rs
index d1c2e3d,71009d4..f6d3b6f
--- a/src/backend/text_input.rs
+++ b/src/backend/text_input.rs
@@@ -1,68 -1,95 +1,72 @@@
-// text-input-v3 (zwp_text_input_v3): the seat's text input follows the
-// frame's claim (`crate::text_input`).
-//
-// While some widget claims a field, the text input is enabled and carries
-// the caret's rectangle; the first frame nobody claims, it is disabled. The
-// compositor only listens while this client holds keyboard focus (between
-// `enter` and `leave`), so the last frame's claim is kept and replayed on
-// `enter`, and `leave` disables: wlroots keeps a text input's enabled state
-// across a leave, and a stale "enabled" would turn the next enable into a
-// plain commit the compositor ignores.
-//
-// Nothing here types. Keys arrive over wl_keyboard as before (the on-screen
-// keyboard is a virtual keyboard); a `commit_string` from an input method is
-// delivered to the app as one typed key (`EngineState::type_text`).
-
-use smithay_client_toolkit::reexports::client::{Connection, Dispatch, QueueHandle};
-use wayland_protocols::wp::text_input::zv3::client::{
- zwp_text_input_manager_v3::ZwpTextInputManagerV3,
- zwp_text_input_v3::{self, ZwpTextInputV3},
-};
-
-use super::window_runner::{Application, EngineState};
-
-/// A request the sync wants sent, in order. `Commit` closes each batch.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum Request {
- Enable,
- Disable,
- CursorRectangle([i32; 4]),
- Commit,
+//! `text-input-unstable-v3`: the Wayland shell's half of input-method
+//! composition (`crate::ime`), as the hidden textarea is the browser's and
+//! `NSTextInputClient` the AppKit shell's.
+//!
+//! The compositor relays between this object and the input method (an
+//! `input-method-v2` client: fcitx5, IBus through its Wayland frontend, …):
+//!
+//! - **What we tell it.** While a widget is editing text (`ime::caret` is
+//! set) and the seat's text-input focus is on our surface (`enter`), the
+//! text input is ENABLED, with a normal content type and the caret as the
+//! cursor rectangle (surface px), re-sent when the caret moves; otherwise
+//! it is disabled. A composition a widget dropped (`ime::take_reset`) is
+//! cancelled by disabling and enabling again, which resets the input
+//! method's state. Every state change is one `commit`, counted.
+//! - **What it tells us.** `preedit_string`, `commit_string` and
+//! `delete_surrounding_text` are double-buffered and applied on `done`, in
+//! the protocol's order: the old composition out, the commit typed
+//! (`Driver::commit_text`), the new composition in (`Driver::preedit`).
+//! A batch with no `preedit_string` ends the composition. We send no
+//! surrounding text, so a deletion has nothing to count its bytes in and
+//! is not applied. A `done` whose serial is behind our commits still
+//! applies its text — the protocol asks only that it not change our state.
- //! - `leave` drops any composition, as the protocol asks; the compositor
- //! ignores our requests until the next `enter`.
++//! - `enter` applies the last frame's caret at once (an idle window builds
++//! no frame to do it); `leave` drops any composition, as the protocol
++//! asks, and an enabled text input is disabled there and then: wlroots
++//! keeps a text input's enabled state across a leave, and a stale
++//! "enabled" turns the next enable into a plain commit the compositor
++//! ignores.
+//!
+//! The pure part — what a batch does, and what state to send — is
+//! [`Batch::apply_order`] and [`TextInput::plan`], tested with no
+//! compositor.
+
+use crate::ime::Preedit;
+
+/// The double-buffered text a `done` applies.
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct Batch {
+ pub preedit: Option<Preedit>,
+ pub commit: Option<String>,
+ pub delete: Option<(u32, u32)>,
}
-/// What the compositor has been told, and what the app wants. Pure, so the
-/// enter/leave/claim interplay is tested without a compositor.
-#[derive(Debug, Default)]
-pub struct Sync {
- entered: bool,
- enabled: bool,
- sent_rect: Option<[i32; 4]>,
- wanted: Option<[i32; 4]>,
+/// One step of applying a batch.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Apply {
+ Preedit(Option<Preedit>),
+ Commit(String),
}
-impl Sync {
- /// A frame was built; `claim` is what it claimed.
- pub fn frame(&mut self, claim: Option<[f32; 4]>) -> Vec<Request> {
- self.wanted = claim.map(|[x, y, w, h]| [x.round() as i32, y.round() as i32, w.round().max(1.0) as i32, h.round().max(1.0) as i32]);
- self.apply()
- }
-
- pub fn enter(&mut self) -> Vec<Request> {
- self.entered = true;
- self.apply()
- }
-
- pub fn leave(&mut self) -> Vec<Request> {
- let out = if self.enabled { vec![Request::Disable, Request::Commit] } else { Vec::new() };
- self.entered = false;
- self.enabled = false;
- self.sent_rect = None;
- out
+impl Batch {
+ /// A `preedit_string` event: the protocol's cursor (bytes, -1 for
+ /// hidden) as `Preedit`'s.
+ pub fn set_preedit(&mut self, text: Option<String>, begin: i32, end: i32) {
+ let text = text.unwrap_or_default();
+ let cursor = (begin >= 0 && end >= 0).then(|| (begin as usize, end as usize));
+ self.preedit = (!text.is_empty()).then(|| Preedit::new(text, cursor));
}
- fn apply(&mut self) -> Vec<Request> {
- if !self.entered {
- return Vec::new();
+ /// The batch as the protocol's `done` orders it: the old composition
+ /// out, the commit typed, the new composition in.
+ pub fn apply_order(self) -> Vec<Apply> {
+ let mut steps = Vec::new();
+ if let Some(text) = self.commit.filter(|t| !t.is_empty()) {
+ steps.push(Apply::Preedit(None));
+ steps.push(Apply::Commit(text));
}
- let mut out = Vec::new();
- match self.wanted {
- Some(rect) => {
- if !self.enabled {
- // `enable` resets the state, so the rectangle follows it.
- out.push(Request::Enable);
- self.enabled = true;
- self.sent_rect = None;
- }
- if self.sent_rect != Some(rect) {
- out.push(Request::CursorRectangle(rect));
- self.sent_rect = Some(rect);
- }
- }
- None => {
- if self.enabled {
- out.push(Request::Disable);
- self.enabled = false;
- self.sent_rect = None;
- }
- }
- }
- if !out.is_empty() {
- out.push(Request::Commit);
- }
- out
+ steps.push(Apply::Preedit(self.preedit));
+ steps
}
}
@@@ -96,63 -102,87 +100,70 @@@ pub struct TextInput
}
impl TextInput {
- pub fn new<A: Application>(
- manager: &ZwpTextInputManagerV3,
- seat: &smithay_client_toolkit::reexports::client::protocol::wl_seat::WlSeat,
- qh: &QueueHandle<EngineState<A>>,
- ) -> Self {
- TextInput { proxy: manager.get_text_input(seat, qh, ()), sync: Sync::default(), pending_commit: None }
- }
-
- pub fn frame(&mut self, claim: Option<[f32; 4]>) {
- let requests = self.sync.frame(claim);
- self.send(&requests);
- }
-
- fn send(&self, requests: &[Request]) {
- for r in requests {
- match *r {
- Request::Enable => {
- self.proxy.enable();
- self.proxy.set_content_type(
- zwp_text_input_v3::ContentHint::empty(),
- zwp_text_input_v3::ContentPurpose::Normal,
- );
- }
- Request::Disable => self.proxy.disable(),
- Request::CursorRectangle([x, y, w, h]) => self.proxy.set_cursor_rectangle(x, y, w, h),
- Request::Commit => self.proxy.commit(),
+ /// What the state should become, given the editing widget's caret
+ /// (`ime::caret`, in the app's logical px), the surface's px per
+ /// logical px, and whether a widget asked for its composition to be
+ /// cancelled. Records the new state as sent.
+ pub fn plan(&mut self, caret: Option<[f32; 4]>, surface_scale: f32, reset: bool) -> Send {
+ let rect = caret.map(|[x, y, w, h]| {
+ let s = surface_scale;
+ [(x * s).round() as i32, (y * s).round() as i32, ((w * s).round() as i32).max(1), ((h * s).round() as i32).max(1)]
+ });
+ let send = match (self.entered, rect) {
+ (true, Some(rect)) if !self.enabled || reset => Send::Enable { rect, reset: self.enabled && reset },
+ (true, Some(rect)) if self.sent_rect != Some(rect) => Send::Move { rect },
+ (true, Some(_)) => Send::Nothing,
+ (true, None) if self.enabled => Send::Disable,
+ _ => Send::Nothing,
+ };
+ match send {
+ Send::Enable { rect, reset } => {
+ self.enabled = true;
+ self.sent_rect = Some(rect);
+ // A reset commits its disable before the enable.
+ self.commits += if reset { 2 } else { 1 };
}
+ Send::Move { rect } => {
+ self.sent_rect = Some(rect);
+ self.commits += 1;
+ }
+ Send::Disable => {
+ self.enabled = false;
+ self.sent_rect = None;
+ self.commits += 1;
+ }
+ Send::Nothing => {}
}
+ send
}
-}
-impl Drop for TextInput {
- fn drop(&mut self) {
- self.proxy.destroy();
+ /// `enter`: our surface has the text-input focus; the next plan enables
+ /// if a widget is editing.
+ pub fn enter(&mut self) {
+ self.entered = true;
+ self.enabled = false;
+ self.sent_rect = None;
}
-}
- /// `leave`: the focus went; the compositor disabled us, and ignores us
- /// until the next `enter`.
- pub fn leave(&mut self) {
-impl<A: Application> Dispatch<ZwpTextInputManagerV3, ()> for EngineState<A> {
- fn event(
- _state: &mut Self,
- _proxy: &ZwpTextInputManagerV3,
- _event: <ZwpTextInputManagerV3 as smithay_client_toolkit::reexports::client::Proxy>::Event,
- _data: &(),
- _conn: &Connection,
- _qh: &QueueHandle<Self>,
- ) {
++ /// `leave`: the focus went. True when we were enabled, and so owe a
++ /// `disable` and `commit` (counted here): wlroots keeps the enabled
++ /// state across a leave, and the next `enter`'s enable would otherwise
++ /// be a plain commit it ignores.
++ pub fn leave(&mut self) -> bool {
++ let owed = self.enabled;
++ if owed {
++ self.commits += 1;
++ }
+ self.entered = false;
+ self.enabled = false;
+ self.sent_rect = None;
+ self.pending = Batch::default();
++ owed
}
-}
-impl<A: Application> Dispatch<ZwpTextInputV3, ()> for EngineState<A> {
- fn event(
- state: &mut Self,
- _proxy: &ZwpTextInputV3,
- event: zwp_text_input_v3::Event,
- _data: &(),
- _conn: &Connection,
- _qh: &QueueHandle<Self>,
- ) {
- let Some(ti) = state.text_input.as_mut() else { return };
- match event {
- zwp_text_input_v3::Event::Enter { .. } => {
- let requests = ti.sync.enter();
- ti.send(&requests);
- }
- zwp_text_input_v3::Event::Leave { .. } => {
- let requests = ti.sync.leave();
- ti.send(&requests);
- ti.pending_commit = None;
- }
- zwp_text_input_v3::Event::CommitString { text } => {
- ti.pending_commit = text;
- }
- zwp_text_input_v3::Event::Done { .. } => {
- if let Some(text) = ti.pending_commit.take().filter(|t| !t.is_empty()) {
- state.type_text(text);
- }
- }
- // No preedit display and no surrounding text: an input method
- // composing in place is not supported, only its committed text.
- _ => {}
- }
+ /// `done`: the batch to apply, the pending state back to initial.
+ pub fn done(&mut self) -> Batch {
+ std::mem::take(&mut self.pending)
}
}
@@@ -182,33 -202,31 +193,45 @@@ mod tests
}
#[test]
- fn a_claim_enables_once_and_follows_the_caret() {
- let mut s = Sync::default();
- s.enter();
- assert_eq!(s.frame(Some(R)), vec![Enable, CursorRectangle(RI), Commit]);
- assert!(s.frame(Some(R)).is_empty(), "an unchanged frame sends nothing");
- let moved = [12.0, 20.0, 100.0, 30.0];
- assert_eq!(s.frame(Some(moved)), vec![CursorRectangle([12, 20, 100, 30]), Commit]);
- assert_eq!(s.frame(None), vec![Disable, Commit]);
- assert!(s.frame(None).is_empty());
+ fn enabled_only_while_focused_and_editing_and_every_change_one_commit() {
+ let mut ti = TextInput::default();
+ let caret = Some([10.0, 20.0, 1.5, 16.0]);
+ // Editing, but not yet entered: nothing to say.
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Nothing);
+ ti.enter();
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Enable { rect: [10, 20, 2, 16], reset: false });
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Nothing);
+ // The caret moves; at a forced scale of 2 the surface is twice the app.
+ assert_eq!(ti.plan(Some([30.0, 20.0, 1.5, 16.0]), 2.0, false), Send::Move { rect: [60, 40, 3, 32] });
+ // A widget dropped its composition: disable and enable again.
+ assert_eq!(ti.plan(Some([30.0, 20.0, 1.5, 16.0]), 2.0, true), Send::Enable { rect: [60, 40, 3, 32], reset: true });
+ // Nothing editing.
+ assert_eq!(ti.plan(None, 1.0, false), Send::Disable);
+ assert_eq!(ti.plan(None, 1.0, false), Send::Nothing);
+ assert_eq!(ti.commits, 5, "enable, move, disable + enable, disable");
+ // The focus leaves; editing again enables nothing until it is back.
- ti.leave();
++ assert!(!ti.leave(), "nothing enabled, nothing owed");
+ assert_eq!(ti.plan(caret, 1.0, false), Send::Nothing);
+ ti.enter();
+ assert!(matches!(ti.plan(caret, 1.0, false), Send::Enable { reset: false, .. }));
}
+ #[test]
- fn leave_disables_and_enter_restores() {
- let mut s = Sync::default();
- s.enter();
- s.frame(Some(R));
- assert_eq!(s.leave(), vec![Disable, Commit]);
- assert!(s.frame(Some(R)).is_empty(), "unfocused: the compositor is not listening");
- assert_eq!(s.enter(), vec![Enable, CursorRectangle(RI), Commit]);
++ fn a_leave_while_enabled_owes_a_disable_and_enter_enables_again() {
++ let mut ti = TextInput::default();
++ let caret = Some([10.0, 20.0, 1.5, 16.0]);
++ ti.enter();
++ ti.plan(caret, 1.0, false);
++ assert!(ti.leave(), "wlroots keeps the enabled state across a leave");
++ assert_eq!(ti.commits, 2, "the enable, then the disable");
++ ti.enter();
++ assert_eq!(ti.plan(caret, 1.0, false), Send::Enable { rect: [10, 20, 2, 16], reset: false });
+ }
+
#[test]
- fn leave_without_a_field_sends_nothing() {
- let mut s = Sync::default();
- s.enter();
- assert!(s.leave().is_empty());
+ fn a_reset_with_nothing_enabled_is_an_ordinary_enable() {
+ let mut ti = TextInput::default();
+ ti.enter();
+ assert_eq!(ti.plan(Some([0.0, 0.0, 1.0, 10.0]), 1.0, true), Send::Enable { rect: [0, 0, 1, 10], reset: false });
}
}
diff --cc src/backend/window_runner.rs
index 6c22cdf,d2b83b4..72df970
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@@ -1425,64 -5892,6 +1426,70 @@@ impl<A: Application> wayland_client::Di
}
}
+impl<A: Application> wayland_client::Dispatch<ZwpTextInputManagerV3, ()> for EngineState<A> {
+ fn event(
+ _state: &mut Self,
+ _proxy: &ZwpTextInputManagerV3,
+ _event: <ZwpTextInputManagerV3 as wayland_client::Proxy>::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+/// The input method's events (see `backend::text_input`): the focus, and
+/// the double-buffered composition, commit and deletion, applied on `done`.
+impl<A: Application> wayland_client::Dispatch<ZwpTextInputV3, ()> for EngineState<A> {
+ fn event(
+ state: &mut Self,
+ _proxy: &ZwpTextInputV3,
+ event: zwp_text_input_v3::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ use crate::backend::text_input::Apply;
+ match event {
+ zwp_text_input_v3::Event::Enter { .. } => {
+ state.text_input_state.enter();
- // A frame, so the sync after it enables for an editing widget.
- state.redraw = true;
++ // At once, at the last frame's caret: an idle window builds
++ // no frame to do it.
++ state.sync_text_input();
+ }
+ zwp_text_input_v3::Event::Leave { .. } => {
- state.text_input_state.leave();
++ if state.text_input_state.leave() {
++ if let Some(ti) = &state.text_input {
++ ti.disable();
++ ti.commit();
++ }
++ }
+ let (driver, t) = state.turn();
+ driver.preedit(t, None);
+ }
+ zwp_text_input_v3::Event::PreeditString { text, cursor_begin, cursor_end } => {
+ state.text_input_state.pending.set_preedit(text, cursor_begin, cursor_end);
+ }
+ zwp_text_input_v3::Event::CommitString { text } => {
+ state.text_input_state.pending.commit = text;
+ }
+ zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } => {
+ state.text_input_state.pending.delete = Some((before_length, after_length));
+ }
+ zwp_text_input_v3::Event::Done { .. } => {
+ for step in state.text_input_state.done().apply_order() {
+ let (driver, t) = state.turn();
+ match step {
+ Apply::Preedit(p) => driver.preedit(t, p),
+ Apply::Commit(text) => driver.commit_text(t, text),
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
impl<A: Application> wayland_client::Dispatch<ZwpPointerGesturesV1, ()> for EngineState<A> {
fn event(
_state: &mut Self,
diff --cc src/draw/mod.rs
index b096ce1,0000000..f7f4afd
mode 100644,000000..100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@@ -1,219 -1,0 +1,221 @@@
+//! What a renderer draws, with no renderer in it: the frame
+//! ([`Frame2D`] and its [`Batch2D`]s, [`PlatePush`] parameter blocks and the
+//! one way to lay a batch's block out, [`batch_push_constants`]), text runs
+//! ([`TextSpan`]), image draws ([`ImageQuad`]) and the image-id queue apps
+//! upload through ([`images`]).
+//!
+//! These lived in `vk/` because the Vulkan renderer was the only one. They
+//! are plain data, and `backend::frame` builds them for any renderer — the
+//! Vulkan one on Linux, a WebGPU one in the browser — so they live here, and
+//! `vk` re-exports every one at its old path.
+
+use crate::backend::tessellate::Vertex;
+use cosmic_text::Buffer as TextBuffer;
+
+pub mod glyphs;
+pub mod images;
+pub mod rt;
+pub mod scene;
+pub mod shaders;
+
+pub use images::{
+ free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
+ upload_rgba_mipmapped, ImageQuad, PixelFormat,
+};
+
+/// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
+/// physical pixels; None draws with the full-surface scissor. `clip_rrect` is an
+/// optional rounded-rect clip `[cx, cy, bx, by, r]` (center, SDF half-extents, corner
+/// radius; physical px) applied via push constants — fragments outside it discard, so a
+/// plate's children cut off at its rounded corners.
+pub struct Batch2D {
+ pub scissor: Option<(u32, u32, u32, u32)>,
+ pub clip_rrect: Option<[f32; 5]>,
+ pub start: u32,
+ pub end: u32,
+ /// When set, this batch is a single SDF-lit plate cover quad: the params go
+ /// out as push constants and shader2d's plate branch lights it per pixel.
+ pub plate: Option<PlatePush>,
+ /// A blur-behind plate (negative-alpha color): the renderer suspends the UI
+ /// pass, copies the swapchain-so-far into its snapshot image, and resumes —
+ /// so the plate's blur samples everything painted beneath it (background,
+ /// widgets, wires), not just the 3D scene backdrop.
+ pub blur_behind: bool,
+}
+
+/// Floats in the fragment push-constant block: the rounded-rect clip (`rect0`,
+/// `rect1` — 6 clip/flag floats plus the plate mode and corner shape) followed
+/// by [`PlatePush`]'s six vec4s. Field for field, this is shader2d's `RRectClip`.
+pub const PUSH_CONSTANT_FLOATS: usize = 32;
+
+/// The fragment push-constant block for one batch: its rounded-rect clip,
+/// and its plate block when it is a plate cover quad. `feature_base` is the
+/// frame slot's first entry in the feature UBO, added to a plate's (or a
+/// union carve's) feature offset. Shared by every renderer and the offscreen
+/// test harness (`vk::plate_probe`), so none can push a different block. (Vulkan
+/// carries it as push constants; a renderer without them puts it in a uniform.)
+pub fn batch_push_constants(batch: &Batch2D, clip_shape: f32, feature_base: usize) -> [f32; PUSH_CONSTANT_FLOATS] {
+ let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
+ let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
+ let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
+ pc[..5].copy_from_slice(&rr);
+ pc[5] = enabled;
+ pc[7] = clip_shape;
+ if let Some(p) = &batch.plate {
+ pc[6] = p.mode;
+ pc[7] = p.shape;
+ pc[8..12].copy_from_slice(&p.rect);
+ pc[12..16].copy_from_slice(&p.radii);
+ pc[16..20].copy_from_slice(&p.light);
+ pc[20..24].copy_from_slice(&p.material);
+ pc[24..28].copy_from_slice(&p.host);
+ pc[28..32].copy_from_slice(&p.specular_tint);
+ if p.mode == 1.0 || p.mode == 14.0 {
+ // Rebase the feature offset onto this frame's UBO slot (a plate's
+ // CSG carves, or a union carve's boxes).
+ pc[24] += feature_base as f32;
+ }
+ }
+ pc
+}
+
+/// Push-constant block for one SDF-lit plate batch (physical px throughout).
+/// Mirrors the `p_*` fields of shader2d's `RRectClip`.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub struct PlatePush {
+ /// SDF box: center + half-extents. May extend past the cover quad — that is
+ /// how a recess suppresses a wall.
+ pub rect: [f32; 4],
+ /// Per-corner radii [tl, tr, br, bl].
+ pub radii: [f32; 4],
+ /// xyz = unit vector toward the light (+z out of the screen), w = roll width px.
+ pub light: [f32; 4],
+ /// [shading strength, specular strength, shininess, curvature/AO strength].
+ pub material: [f32; 4],
+ /// Mode 1: `[feature offset, feature count, frost z, frost w]` — xy into
+ /// the frame's `plate_features`, the carves CSG'd out of this plate (the
+ /// renderer adds the frame slot's base offset at record time); zw the
+ /// plate's frost recipe, `scene::material::Frost::pack` (compression and
+ /// refraction packed in z, the blur sigma in physical px in w). Mode 14 uses the same
+ /// `[offset, count]` for the union's boxes. Mode 2: the host-plate box
+ /// (center + half-extents) a free recess fades out against; far-away sides
+ /// (±1e5) disable the fade.
+ pub host: [f32; 4],
- /// RGB multiplies the roll's specular color (w unused). Neutral white
- /// normally; the focused-pane bevel carries the highlight color here.
++ /// RGB multiplies the roll's specular color. Neutral white normally; the
++ /// focused-pane bevel carries the highlight color here, with w = 1 marking
++ /// the plate (or carve) as accent-tinted — the shader's focus branch,
++ /// which recolours the light AND the shadow (see shader2d's FOCUS_*).
+ pub specular_tint: [f32; 4],
+ /// 1.0 = raised lit plate, 2.0 = recess overlay, 3.0 = boss, 4.0 = ridge,
+ /// 5.0 = sphere, 6.0/7.0 = concave fillet (recessed/raised), 8.0 = groove
+ /// (slab carve about a line: `rect` = [cx, cy, half-width, _], `radii.xy` =
+ /// the line's unit normal, `host` = the surface it is engraved into),
+ /// 9.0 = trough, 10.0 = droplet (`radii` = [sag, belly r, belly half-w,
+ /// blend k] px, `host` = [sheet corner r px, clarity, dome amplitude,
+ /// attach r px], `material.w` = fresnel rim, `specular_tint` = [core
+ /// density, _, _, bottom-bow rise px] — droplet glints are always white,
+ /// so the tint RGB is repurposed; see shader2d's MODE_DROPLET).
+ pub mode: f32,
+ /// Corner shape exponent: 2.0 = circular arcs, > 2 = superellipse
+ /// (continuous-curvature) corners — see shader2d's `plate_sdf_grad`.
+ pub shape: f32,
+}
+
+/// A full 2D frame: the display-list vertices (optionally split into scissored
+/// batches), overlay vertices drawn after text, and the clear color (linear;
+/// only used on frames without a backdrop copy).
+pub struct Frame2D<'a> {
+ pub verts: &'a [Vertex],
+ pub batches: &'a [Batch2D],
+ pub overlay_verts: &'a [Vertex],
+ /// User images drawn interleaved with `verts` by each quad's `z_before`.
+ pub images: &'a [ImageQuad],
+ /// Carves CSG'd into this frame's SDF-lit plates, 12 floats each (rect
+ /// center+half-extents, per-corner radii, [width px, depth px, 0, 0]).
+ /// Plate batches reference them by offset+count in `PlatePush::host`.
+ pub plate_features: &'a [[f32; 12]],
+ pub clear_color: [f32; 4],
+ /// The only part of the surface that differs from the previous frame,
+ /// (x, y, w, h) in physical pixels; None = all of it. With a rect the
+ /// renderer keeps the pixels outside it (Vulkan's `ImageAge`) and tells the
+ /// compositor that only the rect changed. The caller vouches for it: a
+ /// pixel that changed outside the rect stays as it was.
+ pub damage: Option<(u32, u32, u32, u32)>,
+}
+
+/// Max plate-carve features per frame; the shader's UBO holds one slot of this
+/// size per frame in flight.
+pub const MAX_PLATE_FEATURES: usize = 64;
+
+/// One shaped text run to draw. `left`/`top` are physical pixels and `scale`
+/// multiplies the shaped (logical) glyph positions — the same contract as
+/// the old glyphon::TextArea, where callers pass `label.x * scale`.
+pub struct TextSpan<'a> {
+ pub buffer: &'a TextBuffer,
+ pub left: f32,
+ pub top: f32,
+ pub scale: f32,
+ /// Physical-pixel clip rect (left, top, right, bottom); None = whole surface.
+ pub bounds: Option<[i32; 4]>,
+ /// 0..=1 sRGB + alpha, applied to glyphs without their own color.
+ pub default_color: [f32; 4],
+ /// Rotate the span's glyph quads by (radians, center_x, center_y) in
+ /// physical pixels — the circular network pane's curved rim labels.
+ pub rotation: Option<(f32, f32, f32)>,
+ /// Fragment circle clip (center_x, center_y, radius) in physical pixels;
+ /// zero radius disables (matches shader.wgsl's clip_circle).
+ pub clip_circle: [f32; 3],
+ /// Rounded-rect clip half-extents (physical px). Zero keeps `clip_circle` a plain
+ /// circle; non-zero reinterprets it as a rounded-rect SDF clip — center
+ /// `clip_circle.xy`, corner radius `clip_circle.z`, inner box half-size
+ /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
+ pub clip_extents: [f32; 2],
+}
+
+/// The bytes of one plate feature (a carve CSG'd into a plate): three vec4s,
+/// `[f32; 12]` in `Frame2D::plate_features`.
+pub const PLATE_FEATURE_BYTES: usize = 48;
+
+/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
+/// [8 vec4 of profile slope samples].
+// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
+// [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
+// [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
+// END — every offset above is addressed by index from both sides.
+pub const WINDOW_INFO_BYTES: usize = 320;
+
+/// The pinned relief heights (carve, roll) in physical px at `scale`, 0 =
+/// follow the width.
+pub fn relief_px_at(scale: f32) -> (f32, f32) {
+ let s = scale.max(0.001);
+ (
+ crate::layout::bevel_height().map_or(0.0, |h| h * s),
+ crate::layout::roll_height().map_or(0.0, |h| h * s),
+ )
+}
+
+/// shader2d's `WindowInfo` block for a `width` x `height` target whose corners clip
+/// at `clip_corner_radius` (physical px), with the pinned relief heights
+/// `relief` (carve, roll; physical px, 0 = unpinned) — the profiles and the
+/// corner shape from the live style. Shared by every renderer and the offscreen
+/// test harness.
+pub fn window_info_data(width: u32, height: u32, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; WINDOW_INFO_BYTES / 4] {
+ let mut data = [0.0f32; WINDOW_INFO_BYTES / 4];
+ data[0] = width as f32;
+ data[1] = height as f32;
+ data[2] = clip_corner_radius;
+ data[3] = crate::layout::corner_shape();
+ if let Some(slopes) = crate::layout::bevel_profile_slopes() {
+ data[4] = 1.0;
+ data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ data[8..8 + slopes.len()].copy_from_slice(&slopes);
+ }
+ if let Some(slopes) = crate::layout::roll_profile_slopes() {
+ data[40] = 1.0;
+ data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ data[44..44 + slopes.len()].copy_from_slice(&slopes);
+ }
+ data[76] = relief.0;
+ data[77] = relief.1;
+ data
+}
diff --cc src/lib.rs
index 6859d1a,cead6a9..6a92977
--- a/src/lib.rs
+++ b/src/lib.rs
@@@ -22,22 -12,14 +22,23 @@@ pub mod scale
pub mod units;
pub mod backend;
pub mod context;
+pub mod draw;
pub mod scene;
+#[cfg(not(target_arch = "wasm32"))]
pub mod file_dialog;
pub mod icon;
+#[cfg(not(target_arch = "wasm32"))]
pub mod ipc;
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod mcp;
pub mod motion;
+ pub mod text_input;
+#[cfg(not(target_arch = "wasm32"))]
pub mod vk;
+#[cfg(target_arch = "wasm32")]
+pub mod web;
+#[cfg(target_os = "macos")]
+pub mod mac;
pub mod colors {
pub use crate::color::*;
diff --cc src/text_input.rs
index 0000000,f283c9b..b8b02f1
mode 000000,100644..100644
--- a/src/text_input.rs
+++ b/src/text_input.rs
@@@ -1,0 -1,49 +1,45 @@@
-//! "A text field is being edited": the announcement behind text-input-v3.
++//! "A text field is being edited": the announcement behind text input.
+ //!
-//! cce-ui types through `wl_keyboard`; the compositor still needs to know when
-//! a field is open for typing, because that is what raises the on-screen
-//! keyboard after a touch (cce-compositor's `osk.rs`) and what activates an
-//! input method. A widget says so by calling [`claim`] from its paint, every
-//! frame it is editing; the runner reads the frame's claim once the display
-//! list is built and enables the seat's text input, or disables it when no
-//! widget claimed (`backend::text_input`).
++//! The compositor needs to know when a field is open for typing, because that
++//! is what raises the on-screen keyboard after a touch (cce-compositor's
++//! `osk.rs`) and what activates an input method. A widget says so by calling
++//! [`claim`] from its paint, every frame it is editing; a widget that stops
++//! painting has stopped claiming, so there is no enable/disable pair to close
++//! on every path out of editing (Enter, Escape, a click elsewhere, a focus
++//! change, its page dropped).
+ //!
-//! A claim per frame rather than an enable/disable pair is deliberate: a text
-//! box leaves editing by Enter, Escape, a click elsewhere, a focus change or
-//! being dropped with its page, and a pair would have to be closed on every
-//! one of those paths. A widget that stops painting has stopped claiming.
++//! A claim is the caret the input method sees: it is [`crate::ime::report_caret`]
++//! by its older name, and the shells read it through [`crate::ime::caret`]
++//! once the frame is built (Wayland's `text-input-v3` in
++//! `backend::text_input`, the browser's keyboard sink, the AppKit shell's
++//! `NSTextInputClient`). A widget that knows its caret claims the caret; one
++//! that does not claims its field. The last claim of a frame wins, so a
++//! widget may claim its field first and its caret once it has drawn it.
+ //!
-//! `TextBox` claims on its own. An app drawing its own text surface (an
-//! editor, a terminal) calls `claim` from its `display_list` while it has a
-//! caret.
-
-use std::cell::Cell;
-
-thread_local! {
- static CLAIM: Cell<Option<[f32; 4]>> = const { Cell::new(None) };
-}
++//! `TextBox`, `Spinbox`, `Slider`'s readout, `ColorSelector`, the params
++//! pane's code rows and a focused `DocEditor` claim on their own. An app
++//! drawing its own text (a `LineEdit`, a terminal, an editor) claims from its
++//! `display_list` while it has a caret.
+
+ /// A field is editing this frame, its caret (or the field, when the caret is
-/// not known) at `x, y, w, h` in logical surface coordinates. The last claim
-/// of a frame wins.
++/// not known) at `x, y, w, h` in the window's logical px. The last claim of a
++/// frame wins.
+ pub fn claim(x: f32, y: f32, w: f32, h: f32) {
- CLAIM.with(|c| c.set(Some([x, y, w, h])));
-}
-
-/// The frame's claim, clearing it for the next frame.
-pub(crate) fn take() -> Option<[f32; 4]> {
- CLAIM.with(|c| c.take())
++ crate::ime::report_caret(x, y, w, h);
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+
+ #[test]
+ fn a_claim_lasts_one_frame() {
- let _ = take();
++ crate::ime::begin_frame();
+ claim(1.0, 2.0, 3.0, 4.0);
- assert_eq!(take(), Some([1.0, 2.0, 3.0, 4.0]));
- assert_eq!(take(), None, "a frame nobody claimed disables the text input");
++ crate::ime::end_frame();
++ assert_eq!(crate::ime::caret(), Some([1.0, 2.0, 3.0, 4.0]));
++ crate::ime::begin_frame();
++ crate::ime::end_frame();
++ assert_eq!(crate::ime::caret(), None, "a frame nobody claimed disables the text input");
+ }
+ }
diff --cc src/widget/container/parameters_bg.rs
index bc5212c,ed9738f..3836ed5
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@@ -2137,6 -2137,10 +2137,11 @@@ impl Paint for ParametersBg
/// runs): the flat subset plus the scrollbar, kept for direct callers only. The background
/// plate stays out — see [`color`](Paint::color).
fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
+ // A code row open for typing (its fields claim for themselves).
+ if self.code_editing() {
- crate::text_input::claim(self.rect.x, self.rect.y, self.rect.width, self.rect.height);
++ let (ox, oy) = ctx.offset();
++ crate::text_input::claim(self.rect.x + ox, self.rect.y + oy, self.rect.width, self.rect.height);
+ }
for (qx, qy, qw, qh, qc) in self.plain_quads() {
ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
}
diff --cc src/widget/doc_editor/mod.rs
index 6d0bec0,c9ebfd5..3bb37af
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@@ -1000,9 -886,13 +1000,16 @@@ impl DocEditor
let (ox, oy) = (self.origin.0, self.origin.1 - self.scroll);
let sel = self.buf.selection();
let caret = self.buf.caret;
+ let caret_col = self.laid_col(caret.line, caret.col, true);
+ let composition = self.composition_on(caret.line);
let th = self.theme.clone();
+ // Focused is typing: the on-screen keyboard follows
+ // (`crate::text_input`). The viewport stands in until the caret's
+ // line is drawn below, and stays when it is scrolled out of view.
+ if focused {
- crate::text_input::claim(rect.x, rect.y, rect.width, rect.height);
++ let (dx, dy) = pc.offset();
++ crate::text_input::claim(rect.x + dx, rect.y + dy, rect.width, rect.height);
+ }
let first = self.line_at_y(self.scroll - self.pad);
pc.clip(rect, |pc| {
let mut i = first;
diff --cc src/widget/input/color_selector.rs
index b088d6c,3df983c..e96b03f
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@@ -245,6 -245,11 +245,12 @@@ impl Paint for ColorSelector
let visual_h = rect.height;
let pick_x = rect.x + rect.width * 0.65;
let pick_w = rect.width * 0.35;
+ // Typing a hex value: the on-screen keyboard follows
+ // (`crate::text_input`).
+ if self.editing {
- crate::text_input::claim(rect.x, rect.y, pick_x - rect.x, visual_h);
++ let (ox, oy) = ctx.offset();
++ crate::text_input::claim(rect.x + ox, rect.y + oy, pick_x - rect.x, visual_h);
+ }
// The text field has NO face of its own — a frame over the host plate,
// like a relief TextBox well (transparent fill, the outline defines
diff --cc src/widget/input/slider.rs
index c580a1a,7b88e6f..8e1829f
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@@ -356,6 -356,11 +356,12 @@@ impl Paint for Slider
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
let g = self.geom(rect);
+ // Typing into the readout: the on-screen keyboard follows
+ // (`crate::text_input`).
+ if self.editing {
- crate::text_input::claim(g.x, g.y, g.w, g.h);
++ let (ox, oy) = ctx.offset();
++ crate::text_input::claim(g.x + ox, g.y + oy, g.w, g.h);
+ }
let radius = crate::layout::slider_corner_radius();
let rounded = radius > 0.0;
let rc = (rounded, rounded, rounded, rounded);
diff --cc src/widget/input/spinbox.rs
index 7c1a1a1,1950016..874b028
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@@ -357,6 -357,10 +357,11 @@@ impl Paint for Spinbox
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
let g = self.geom(rect);
+ // Typing a value: the on-screen keyboard follows (`crate::text_input`).
+ if self.editing {
- crate::text_input::claim(g.x, g.y, g.w, g.h);
++ let (ox, oy) = ctx.offset();
++ crate::text_input::claim(g.x + ox, g.y + oy, g.w, g.h);
+ }
let radius = crate::layout::spinbox_corner_radius();
let rounded = radius > 0.0;
let display_bg = if self.editing { [0.06, 0.10, 0.18, 1.0] } else { colors::spinbox_display() };
diff --cc src/widget/input/text_box.rs
index 3b4c556,f0d8263..552b2f7
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@@ -1649,6 -1513,12 +1649,14 @@@ impl Paint for TextBox
let radius = crate::layout::textbox_corner_radius();
let border_w = self.border_width();
+ // Open for typing: say so to the compositor this frame (the
- // on-screen keyboard follows it). The field stands in for the caret.
++ // on-screen keyboard follows it). The field stands in for the caret
++ // until the caret is drawn below, which reports itself.
+ if self.editing && !self.disabled {
- crate::text_input::claim(self.rect.x, self.rect.y + top, self.rect.width, visual_h);
++ let (ox, oy) = ctx.offset();
++ crate::text_input::claim(self.rect.x + ox, self.rect.y + top + oy, self.rect.width, visual_h);
+ }
+
// Keep the model's cached rect and the paint rect consistent: paint receives the
// content rect derived from the same base the cache holds, so the bodies below read
// `self.rect` (the legacy `self.base`) exactly as legacy did. `rect` is used only to