git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitfd1b4ce9eddb74b28b38bcfdef9c638286f841f6
parentc702f40771 9afd4d8c63
authorClaude <noreply@anthropic.com>
date2026-10-06 00:41
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