Skip to main content

rux_shell/
lib.rs

1//! Rux runtime shell, milestone M3.
2//!
3//! Opens a native window (winit), manages the GPU via vello's `RenderContext`,
4//! loads a `.rux` document each frame's tree from `rux-runtime`, and paints it
5//! (`rux-paint`). A `notify` file watcher wakes the event loop through an
6//! `EventLoopProxy` on every save, so edits to the `.rux` file repaint live,
7//! the hot-reload path from `docs/04-architecture.md`.
8
9use std::num::NonZeroUsize;
10use std::path::Path;
11#[cfg(not(target_arch = "wasm32"))]
12use std::path::PathBuf;
13use std::sync::Arc;
14// `web_time` re-exports `std::time` verbatim on native, so this is the std type
15// everywhere except wasm, where `std::time::Instant` panics on construction and
16// `ControlFlow::WaitUntil` wants the browser clock's instant instead. One import
17// covers both; there is no cfg and no behavioural difference off the web.
18use web_time::{Duration, Instant};
19
20#[cfg(target_arch = "wasm32")]
21use std::cell::RefCell;
22#[cfg(target_arch = "wasm32")]
23use std::rc::Rc;
24
25#[cfg(not(target_arch = "wasm32"))]
26use notify::{EventKind, RecursiveMode, Watcher};
27use rux_layout::{
28    Background, Cursor, FocusItem, FocusKind, FocusRegion, HitRegion,
29    Offset, Paint, PaintRect, PaintText, Rgba, ScrollRegion, SelectRegion, StateRegion, TextAlign,
30    TextContent, TextWrap,
31};
32use rux_runtime::{Document, Focus, InteractionState, Viewport};
33use vello::kurbo::Affine;
34use vello::peniko::Color;
35use vello::util::{RenderContext, RenderSurface};
36use vello::wgpu;
37use vello::wgpu::CurrentSurfaceTexture;
38use vello::{AaConfig, AaSupport, Renderer, RendererOptions, RenderParams, Scene};
39#[cfg(not(target_arch = "wasm32"))]
40use accesskit::{Node as AccessKitNode, NodeId, Role, Toggled, Tree, TreeUpdate};
41// Only the accessibility tree uses these, so they are gated with it rather
42// than sitting unused in the wasm build.
43#[cfg(not(target_arch = "wasm32"))]
44use rux_layout::{AccessNode, AccessRole};
45use winit::application::ApplicationHandler;
46use winit::event::{ElementState, Ime, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
47use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
48use winit::keyboard::{Key, NamedKey};
49use winit::window::{CursorIcon, Window, WindowId};
50
51/// Events delivered to the winit loop from outside it.
52#[derive(Debug)]
53enum RuxEvent {
54    /// The `.rux` file changed on disk.
55    #[cfg(not(target_arch = "wasm32"))]
56    Reload,
57    /// The GPU surface finished initialising. Web only: `create_surface` is
58    /// async and `resumed` is not, so setup runs as a task and wakes the loop
59    /// here. The payload is parked in `App::pending` rather than carried in the
60    /// event, because wgpu's types are not `Send` on wasm and the proxy requires
61    /// that they be.
62    #[cfg(target_arch = "wasm32")]
63    SurfaceReady,
64    /// New source text from the host page, the playground's replacement for a
65    /// file watcher. `String` is `Send`, so this one can travel in the event.
66    #[cfg(target_arch = "wasm32")]
67    SetSource(String),
68    /// The host page's canvas container changed size, in logical pixels. Web
69    /// only: on a desktop the window manager drives this, but in a page the
70    /// layout does, and the canvas has to be told rather than asked.
71    #[cfg(target_arch = "wasm32")]
72    Resize(f64, f64),
73    /// The browser's soft keyboard edited the focused field. Web only: on a
74    /// phone the text does not arrive as key presses at all, it arrives as the
75    /// new contents of the hidden `<input>` the shell keeps focused, so this
76    /// carries the whole value rather than a keystroke.
77    ///
78    /// `composing` is the byte length of any in-progress composition at the end
79    /// of the caret, `0` when there is none. The browser runs the composition
80    /// itself here; the shell only needs to know which tail of the text is still
81    /// provisional so it can underline it, exactly as it does natively.
82    ///
83    /// `anchor` is the other end of the selection, equal to `caret` when nothing
84    /// is selected. It is carried because the browser's own copy, cut and
85    /// select-all act on the hidden input's selection, so the two have to agree
86    /// about what is selected or the phone's clipboard operates on the wrong
87    /// text (before v0.5.1, on no text at all).
88    #[cfg(target_arch = "wasm32")]
89    WebText { value: String, caret: usize, anchor: usize, composing: usize },
90    /// The browser's clipboard resolved, carrying what it held. Web only: the
91    /// Clipboard API is a promise, so a paste cannot be finished inside the tap
92    /// or key press that asked for it.
93    #[cfg(target_arch = "wasm32")]
94    WebPaste(String),
95    /// Assistive technology asked us something (it attached, it wants the
96    /// tree, it moved focus). Delivered through the same proxy as hot-reload.
97    #[cfg(not(target_arch = "wasm32"))]
98    Access(accesskit_winit::Event),
99}
100
101#[cfg(not(target_arch = "wasm32"))]
102impl From<accesskit_winit::Event> for RuxEvent {
103    fn from(event: accesskit_winit::Event) -> Self {
104        Self::Access(event)
105    }
106}
107
108/// Taps closer than this (in physical pixels) between press and release still
109/// count as a tap rather than a drag.
110const TAP_SLOP: f64 = 6.0;
111
112/// How long a finger must rest on text before the press takes the word under it.
113///
114/// This is the gesture a phone uses to start selecting, and it is why a drag is
115/// free to mean something else (moving the caret). Roughly the platform
116/// convention: much shorter and an ordinary tap starts selecting text, much
117/// longer and the field feels unresponsive.
118const LONG_PRESS: Duration = Duration::from_millis(500);
119
120/// The selection toolbar's height and the padding around its labels, in logical
121/// px.
122const TOOLBAR_H: f32 = 34.0;
123const TOOLBAR_PAD: f32 = 12.0;
124/// Gap between the toolbar and the field it belongs to.
125const TOOLBAR_GAP: f32 = 6.0;
126
127/// What the selection toolbar offers, left to right.
128///
129/// A phone has no Ctrl+C, and a browser has no system clipboard for Rux to
130/// reach, so without this there is no way at all to get text out of a field on
131/// either. The desktop app keeps its shortcuts; this is the same four actions
132/// with somewhere to tap.
133#[derive(Clone, Copy, Debug, PartialEq)]
134enum TextAction {
135    Copy,
136    Cut,
137    Paste,
138    SelectAll,
139}
140
141impl TextAction {
142    const ALL: [TextAction; 4] =
143        [TextAction::Copy, TextAction::Cut, TextAction::Paste, TextAction::SelectAll];
144
145    fn label(self) -> &'static str {
146        match self {
147            TextAction::Copy => "Copy",
148            TextAction::Cut => "Cut",
149            TextAction::Paste => "Paste",
150            TextAction::SelectAll => "Select all",
151        }
152    }
153
154    /// Button width from the label's length.
155    ///
156    /// Estimated rather than measured because the geometry is needed for hit
157    /// testing as well as painting, and threading the text engine into a hit
158    /// test to agree with the painter is how the two end up disagreeing. The
159    /// estimate is deliberately generous, so a label sits inside its button
160    /// rather than against its edge.
161    fn width(self) -> f32 {
162        (self.label().chars().count() as f32 * 7.8).round() + TOOLBAR_PAD * 2.0
163    }
164}
165
166/// Where the toolbar sits for a field at `(x, y, w, h)`, and the box of each
167/// button, in logical px.
168///
169/// Above the field when there is room, below it when there is not, and never off
170/// the left edge. One function so the painter and the hit test cannot drift.
171fn toolbar_layout(
172    field: (f32, f32, f32, f32),
173    viewport: (f32, f32),
174) -> ((f32, f32, f32, f32), Vec<(TextAction, f32, f32, f32, f32)>) {
175    let total: f32 = TextAction::ALL.iter().map(|a| a.width()).sum();
176    let (fx, fy, _, fh) = field;
177    let x = fx.min(viewport.0 - total).max(0.0);
178    // Above by preference: a finger selecting text is usually below the line it
179    // is selecting, and a toolbar under the finger is one you cannot read.
180    let above = fy - TOOLBAR_H - TOOLBAR_GAP;
181    let y = if above >= 0.0 { above } else { fy + fh + TOOLBAR_GAP };
182
183    let mut buttons = Vec::with_capacity(TextAction::ALL.len());
184    let mut bx = x;
185    for action in TextAction::ALL {
186        let w = action.width();
187        buttons.push((action, bx, y, w, TOOLBAR_H));
188        bx += w;
189    }
190    ((x, y, total, TOOLBAR_H), buttons)
191}
192
193/// What the finger currently down is doing to a text field.
194///
195/// Touch used to share the mouse's press/drag/release path, which meant a drag
196/// selected, because that is what a mouse does. A phone expects the three
197/// gestures below instead, so touch needs its own small state machine: the same
198/// finger movement means different things depending on whether the press has had
199/// time to become a long one.
200#[derive(Clone, Copy, Debug, PartialEq)]
201enum TouchText {
202    /// Down on text and not yet resolved. Still becomes `Selecting` if the
203    /// finger rests until `deadline`, or `Caret` if it moves first.
204    Pending { at: (f64, f64), deadline: Instant },
205    /// Moved before the deadline: the caret follows the finger and nothing is
206    /// selected.
207    Caret,
208    /// The long press took a word: further movement extends the selection from
209    /// it, which is the only gesture that selects.
210    Selecting,
211}
212
213/// What a finger `distance` px from where it went down means, given what the
214/// press was already doing.
215///
216/// The whole gesture model is this one decision, so it is a plain function
217/// rather than inline in the event arm: a press that moves before it is old
218/// enough is a caret drag and can never become a selection afterwards, and one
219/// that has already taken a word keeps extending it however far it travels.
220fn touch_text_after_move(state: TouchText, distance: f64) -> TouchText {
221    match state {
222        TouchText::Pending { .. } if distance > TAP_SLOP => TouchText::Caret,
223        other => other,
224    }
225}
226
227/// Half the caret blink period: the caret is shown for this long, then hidden
228/// for this long. ~530ms matches the platform norm.
229const BLINK: Duration = Duration::from_millis(530);
230
231/// Two clicks closer together than this (and within `TAP_SLOP`) are a
232/// double-click, which selects a word.
233const DOUBLE_CLICK: Duration = Duration::from_millis(500);
234
235/// Rux screen background `#11111b`.
236const BG: Color = Color::from_rgb8(0x11, 0x11, 0x1b);
237
238/// Height of one option row in an open `select` dropdown, in logical px.
239const DROPDOWN_ROW_H: f32 = 30.0;
240/// Gap between the select box and the top of its dropdown panel, in logical px.
241const DROPDOWN_GAP: f32 = 4.0;
242
243/// The nth option row of an open dropdown as `(x, y, w, h)` in logical px. Rows
244/// stack below the select box (after a small gap). Shared by paint and
245/// hit-testing so the dropdown looks and behaves consistently.
246fn dropdown_row(sel: &SelectRegion, i: usize) -> (f32, f32, f32, f32) {
247    (
248        sel.x,
249        sel.y + sel.height + DROPDOWN_GAP + i as f32 * DROPDOWN_ROW_H,
250        sel.width,
251        DROPDOWN_ROW_H,
252    )
253}
254
255/// Thickness of a scrollbar, in logical px.
256const BAR_W: f32 = 8.0;
257/// Shortest a thumb may get, however long the content is.
258const BAR_MIN_THUMB: f32 = 24.0;
259/// One line of scroll travel, the wheel's unit, and the arrow keys'.
260const LINE: f32 = 24.0;
261
262/// Which axis a scrollbar (or a drag on one) belongs to.
263#[derive(Clone, Copy, Debug, PartialEq)]
264enum Axis2 {
265    X,
266    Y,
267}
268
269/// An in-progress drag of a scrollbar thumb.
270#[derive(Clone, Copy, Debug)]
271struct BarDrag {
272    /// The `ScrollRegion::id` being dragged.
273    id: usize,
274    axis: Axis2,
275    /// Pointer position (logical px, on `axis`) when the thumb was grabbed.
276    grab: f32,
277    /// The region's scroll offset (on `axis`) when the thumb was grabbed.
278    start: f32,
279}
280
281/// The track a scrollbar runs in, as `(x, y, w, h)` in logical px, an overlay
282/// inset along the box's trailing edge. When a box scrolls both ways the tracks
283/// stop short of the corner so they never overlap.
284fn bar_track(r: &ScrollRegion, axis: Axis2) -> (f32, f32, f32, f32) {
285    let corner = if r.max.x > 0.0 && r.max.y > 0.0 { BAR_W } else { 0.0 };
286    match axis {
287        Axis2::Y => (r.x + r.width - BAR_W, r.y, BAR_W, r.height - corner),
288        Axis2::X => (r.x, r.y + r.height - BAR_W, r.width - corner, BAR_W),
289    }
290}
291
292/// The thumb inside `bar_track`, as `(x, y, w, h)`. `None` when the box doesn't
293/// scroll on this axis, so there's nothing to show or grab.
294fn bar_thumb(r: &ScrollRegion, offset: Offset, axis: Axis2) -> Option<(f32, f32, f32, f32)> {
295    let (max, visible, content) = match axis {
296        Axis2::Y => (r.max.y, r.height, r.content_height),
297        Axis2::X => (r.max.x, r.width, r.content_width),
298    };
299    if max <= 0.0 {
300        return None;
301    }
302    let (tx, ty, tw, th) = bar_track(r, axis);
303    let track_len = if axis == Axis2::Y { th } else { tw };
304    // The thumb is as long a fraction of the track as the box is of the content
305    //, the standard proportion, but never so short it can't be grabbed.
306    let thumb_len = (track_len * visible / content.max(1.0)).clamp(BAR_MIN_THUMB.min(track_len), track_len);
307    let travel = (track_len - thumb_len).max(0.0);
308    let pos = match axis {
309        Axis2::Y => offset.y,
310        Axis2::X => offset.x,
311    };
312    let along = travel * (pos / max).clamp(0.0, 1.0);
313    // The track tuple is (x, y, w, h): its thickness is `tw` on the vertical bar
314    // and `th` on the horizontal one, the length is the other component.
315    Some(match axis {
316        Axis2::Y => (tx, ty + along, tw, thumb_len),
317        Axis2::X => (tx + along, ty, thumb_len, th),
318    })
319}
320
321/// Paint items for every visible scrollbar: a faint track with a lighter thumb,
322/// drawn over the content so a scroller's own clip can't eat them.
323fn scrollbar_paints(scrolls: &[ScrollRegion], offsets: &[Offset]) -> Vec<Paint> {
324    let track_bg = Rgba::new(1.0, 1.0, 1.0, 0.05);
325    let thumb_bg = Rgba::new(0.80, 0.84, 0.96, 0.35); // #cdd6f4 at 35%
326    let mut out = Vec::new();
327    for r in scrolls {
328        let offset = offsets.get(r.id).copied().unwrap_or_default();
329        for axis in [Axis2::Y, Axis2::X] {
330            let Some((thx, thy, thw, thh)) = bar_thumb(r, offset, axis) else {
331                continue;
332            };
333            let (tx, ty, tw, th) = bar_track(r, axis);
334            out.push(Paint::Rect(PaintRect {
335                x: tx,
336                y: ty,
337                width: tw,
338                height: th,
339                background: Some(Background::Color(track_bg)),
340                radius: [BAR_W / 2.0; 4],
341                border_width: 0.0,
342                border_color: None,
343            }));
344            out.push(Paint::Rect(PaintRect {
345                x: thx,
346                y: thy,
347                width: thw,
348                height: thh,
349                background: Some(Background::Color(thumb_bg)),
350                radius: [BAR_W / 2.0; 4],
351                border_width: 0.0,
352                border_color: None,
353            }));
354        }
355    }
356    out
357}
358
359/// A 2px focus ring just outside the focused element's box.
360fn focus_ring(item: &FocusItem) -> Vec<Paint> {
361    vec![Paint::Rect(PaintRect {
362        x: item.x - 2.0,
363        y: item.y - 2.0,
364        width: item.width + 4.0,
365        height: item.height + 4.0,
366        background: None,
367        radius: [7.0; 4],
368        border_width: 2.0,
369        border_color: Some(Rgba::new(0.54, 0.71, 0.98, 1.0)), // #89b4fa
370    })]
371}
372
373/// Paint items for an open dropdown: a single floating panel with a shadow, the
374/// selected value picked out as a pill, and thin separators between options.
375/// The selection toolbar: one rounded strip of actions above (or below) the
376/// focused field. Same palette as the dropdown, so the two read as one system.
377fn toolbar_paints(field: (f32, f32, f32, f32), viewport: (f32, f32)) -> Vec<Paint> {
378    let panel_bg = Rgba::new(0.19, 0.20, 0.27, 1.0); // #313244
379    let border = Rgba::new(0.27, 0.28, 0.35, 1.0); // #45475a
380    let ink = Rgba::new(0.80, 0.84, 0.96, 1.0); // #cdd6f4
381    let divider = Rgba::new(0.35, 0.36, 0.44, 1.0); // #585b70
382
383    let ((x, y, w, h), buttons) = toolbar_layout(field, viewport);
384    let mut out = Vec::with_capacity(buttons.len() * 2 + 2);
385    out.push(Paint::Shadow {
386        x,
387        y: y + 3.0,
388        width: w,
389        height: h,
390        radius: 8.0,
391        blur: 16.0,
392        color: Rgba::new(0.0, 0.0, 0.0, 0.45),
393    });
394    out.push(Paint::Rect(PaintRect {
395        x,
396        y,
397        width: w,
398        height: h,
399        background: Some(Background::Color(panel_bg)),
400        radius: [8.0; 4],
401        border_width: 1.0,
402        border_color: Some(border),
403    }));
404
405    for (i, (action, bx, by, bw, bh)) in buttons.iter().enumerate() {
406        // A hairline between buttons, so the strip reads as separate targets
407        // rather than one wide button.
408        if i > 0 {
409            out.push(Paint::Rect(PaintRect {
410                x: *bx,
411                y: by + 7.0,
412                width: 1.0,
413                height: bh - 14.0,
414                background: Some(Background::Color(divider)),
415                radius: [0.0; 4],
416                border_width: 0.0,
417                border_color: None,
418            }));
419        }
420        out.push(Paint::Text(PaintText {
421            x: *bx,
422            y: by + (bh - 17.0) / 2.0,
423            width: *bw,
424            height: 17.0,
425            content: TextContent {
426                align: TextAlign::Center,
427                ..overlay_text(action.label().to_string(), 14.0, 500, ink)
428            },
429        }));
430    }
431    out
432}
433
434fn dropdown_paints(sel: &SelectRegion, value: &str) -> Vec<Paint> {
435    let panel_bg = Rgba::new(0.19, 0.20, 0.27, 1.0); // #313244
436    let border = Rgba::new(0.27, 0.28, 0.35, 1.0); // #45475a
437    let selected = Rgba::new(0.35, 0.36, 0.44, 1.0); // #585b70
438    let ink = Rgba::new(0.80, 0.84, 0.96, 1.0); // #cdd6f4
439
440    let (px, py, pw, _) = dropdown_row(sel, 0);
441    let ph = sel.options.len() as f32 * DROPDOWN_ROW_H;
442
443    let mut out = Vec::with_capacity(sel.options.len() * 2 + 2);
444    // A soft shadow so the panel reads as floating above the page.
445    out.push(Paint::Shadow {
446        x: px,
447        y: py + 3.0,
448        width: pw,
449        height: ph,
450        radius: 8.0,
451        blur: 16.0,
452        color: Rgba::new(0.0, 0.0, 0.0, 0.45),
453    });
454    // The panel itself: one rounded rect behind all the rows.
455    out.push(Paint::Rect(PaintRect {
456        x: px,
457        y: py,
458        width: pw,
459        height: ph,
460        background: Some(Background::Color(panel_bg)),
461        radius: [8.0; 4],
462        border_width: 1.0,
463        border_color: Some(border),
464    }));
465
466    for (i, option) in sel.options.iter().enumerate() {
467        let y = py + i as f32 * DROPDOWN_ROW_H;
468        if option == value {
469            // A rounded pill marks the current choice, inset from the panel edge.
470            out.push(Paint::Rect(PaintRect {
471                x: px + 4.0,
472                y: y + 3.0,
473                width: pw - 8.0,
474                height: DROPDOWN_ROW_H - 6.0,
475                background: Some(Background::Color(selected)),
476                radius: [5.0; 4],
477                border_width: 0.0,
478                border_color: None,
479            }));
480        } else if i > 0 {
481            // A hairline separator between unselected rows.
482            out.push(Paint::Rect(PaintRect {
483                x: px + 10.0,
484                y,
485                width: pw - 20.0,
486                height: 1.0,
487                background: Some(Background::Color(border)),
488                radius: [0.0; 4],
489                border_width: 0.0,
490                border_color: None,
491            }));
492        }
493        out.push(Paint::Text(PaintText {
494            x: px + 12.0,
495            y: y + (DROPDOWN_ROW_H - 15.0) / 2.0,
496            width: pw - 24.0,
497            height: DROPDOWN_ROW_H,
498            content: TextContent {
499                text: option.clone(),
500                font_size: 15.0,
501                weight: 400,
502                color: ink,
503                align: TextAlign::Start,
504                wrap: TextWrap::Normal,
505                font_family: None,
506                letter_spacing: None,
507                word_spacing: None,
508                line_height: None,
509                italic: false,
510                underline: false,
511                strikethrough: false,
512                nowrap: true,
513                caret: None,
514                selection: None,
515                preedit: None,
516            },
517        }));
518    }
519    out
520}
521
522// ── Accessibility ───────────────────────────────────────────────────────────
523
524/// The accessibility tree's root. Element ids follow it, offset by one, so an
525/// element's id is stable for a given position in document order.
526#[cfg(not(target_arch = "wasm32"))]
527const ACCESS_ROOT: NodeId = NodeId(0);
528
529#[cfg(not(target_arch = "wasm32"))]
530fn to_accesskit_role(role: AccessRole) -> Role {
531    match role {
532        AccessRole::Label => Role::Label,
533        AccessRole::Heading => Role::Heading,
534        AccessRole::Button => Role::Button,
535        AccessRole::CheckBox => Role::CheckBox,
536        AccessRole::RadioButton => Role::RadioButton,
537        AccessRole::TextInput => Role::TextInput,
538        AccessRole::MultilineTextInput => Role::MultilineTextInput,
539        AccessRole::ComboBox => Role::ComboBox,
540        AccessRole::Image => Role::Image,
541        AccessRole::ScrollView => Role::ScrollView,
542        // A grouping the author marked with `role=`, and the unreachable None.
543        AccessRole::Group | AccessRole::None => Role::Group,
544    }
545}
546
547/// Build the accessibility tree for the current frame: a window root with one
548/// child per meaningful element, carrying its role, name, value, checked state
549/// and on-screen bounds.
550///
551/// Rebuilt per frame rather than diffed, at these tree sizes it is cheap, and
552/// the alternative (tracking node identity across reconciles) is exactly the kind
553/// of parallel bookkeeping that goes stale. Geometry is in *physical* pixels,
554/// which is what the platform expects.
555#[cfg(not(target_arch = "wasm32"))]
556fn access_tree(nodes: &[AccessNode], focused_model: Option<&str>, scale: f64, title: &str) -> TreeUpdate {
557    let mut root = AccessKitNode::new(Role::Window);
558    root.set_label(title.to_string());
559
560    let mut updates = Vec::with_capacity(nodes.len() + 1);
561    let mut children = Vec::with_capacity(nodes.len());
562    let mut focus = ACCESS_ROOT;
563
564    for (i, node) in nodes.iter().enumerate() {
565        let id = NodeId(i as u64 + 1);
566        children.push(id);
567
568        let mut ak = AccessKitNode::new(to_accesskit_role(node.access.role));
569        if let Some(label) = node.access.name() {
570            // Static text is the exception: accesskit reads a `Role::Label`'s
571            // name from its *value* (`label_comes_from_value`), so setting the
572            // label there leaves it nameless, which is what a UIA client saw
573            // before this line existed.
574            if node.access.role == AccessRole::Label {
575                ak.set_value(label.to_string());
576            } else {
577                ak.set_label(label.to_string());
578            }
579        }
580        if let Some(value) = &node.access.value {
581            ak.set_value(value.clone());
582        }
583        if let Some(checked) = node.access.checked {
584            ak.set_toggled(if checked { Toggled::True } else { Toggled::False });
585        }
586        // Bounds let a screen reader's cursor track the element on screen.
587        ak.set_bounds(accesskit::Rect {
588            x0: node.x as f64 * scale,
589            y0: node.y as f64 * scale,
590            x1: (node.x + node.width) as f64 * scale,
591            y1: (node.y + node.height) as f64 * scale,
592        });
593        // Anything a user can operate is reachable; static text is not a stop.
594        if matches!(
595            node.access.role,
596            AccessRole::Button
597                | AccessRole::CheckBox
598                | AccessRole::RadioButton
599                | AccessRole::TextInput
600                | AccessRole::MultilineTextInput
601                | AccessRole::ComboBox
602        ) {
603            ak.add_action(accesskit::Action::Focus);
604            ak.add_action(accesskit::Action::Click);
605        }
606        // Keep the platform's focus in step with ours, so a screen reader follows
607        // the caret instead of announcing a stale element.
608        if let (Some(model), Some(focused)) = (&node.model, focused_model) {
609            if model == focused {
610                focus = id;
611            }
612        }
613        updates.push((id, ak));
614    }
615
616    root.set_children(children);
617    let mut tree = Tree::new(ACCESS_ROOT);
618    tree.toolkit_name = Some("Rux".into());
619    tree.toolkit_version = Some(env!("CARGO_PKG_VERSION").into());
620    let mut tree_update = TreeUpdate {
621        nodes: vec![(ACCESS_ROOT, root)],
622        tree: Some(tree),
623        // We publish one window-level tree, never a subtree graft.
624        tree_id: accesskit::TreeId::ROOT,
625        focus,
626    };
627    tree_update.nodes.extend(updates);
628    tree_update
629}
630
631// ── Dev overlay ─────────────────────────────────────────────────────────────
632
633const OVERLAY_PAD: f32 = 16.0;
634const OVERLAY_LINE_H: f32 = 20.0;
635const OVERLAY_TITLE_H: f32 = 26.0;
636/// Warnings listed before the panel stops and says how many are left.
637const OVERLAY_MAX_WARNINGS: usize = 6;
638
639/// Paint items for the dev overlay: what is wrong with the document, drawn over
640/// the app.
641///
642/// This is the whole point of the feature, a broken `.rux` file used to show an
643/// empty window with one line on a stderr nobody running a GUI is watching. An
644/// error takes a red panel and says the screen is stale; warnings take a quieter
645/// amber one, since the app underneath is fine.
646/// The painted overlay, and where it ended up.
647struct Overlay {
648    paints: Vec<Paint>,
649    /// The panel's box in logical px, so a tap on it can dismiss it. Kept beside
650    /// the paints rather than recomputed, since a hit region that disagrees with
651    /// what was drawn is the kind of bug that only shows up under a resize.
652    rect: (f32, f32, f32, f32),
653}
654
655fn overlay_paints(diag: &rux_runtime::Diagnostics, path: &Path, width: f32) -> Option<Overlay> {
656    if diag.is_empty() {
657        return None;
658    }
659    let error_bg = Rgba::new(0.24, 0.09, 0.13, 0.97); // deep red
660    let error_edge = Rgba::new(0.95, 0.35, 0.42, 1.0); // #f38ba8-ish
661    let warn_bg = Rgba::new(0.20, 0.17, 0.10, 0.97); // deep amber
662    let warn_edge = Rgba::new(0.98, 0.70, 0.35, 1.0); // #fab387-ish
663    let ink = Rgba::new(0.95, 0.95, 0.97, 1.0);
664    let muted = Rgba::new(0.78, 0.78, 0.84, 1.0);
665
666    let is_error = diag.error.is_some();
667    let (bg, edge) = if is_error { (error_bg, error_edge) } else { (warn_bg, warn_edge) };
668
669    // Wrap the message text to the panel width so a long error is readable
670    // rather than clipped at the edge.
671    let panel_w = (width - OVERLAY_PAD * 2.0).max(120.0);
672    let text_w = panel_w - OVERLAY_PAD * 2.0;
673    let mut lines: Vec<(String, Rgba)> = Vec::new();
674    if let Some(error) = &diag.error {
675        lines.extend(wrap_overlay(error, text_w).into_iter().map(|l| (l, ink)));
676        if diag.stale {
677            lines.push((
678                "showing the last version that loaded, fix the file and save".to_string(),
679                muted,
680            ));
681        }
682    }
683    // A document can easily have a dozen unhonored properties; an unbounded panel
684    // would grow past the window and hide the app it is describing.
685    let shown = diag.warnings.len().min(OVERLAY_MAX_WARNINGS);
686    for warning in &diag.warnings[..shown] {
687        lines.extend(
688            wrap_overlay(&format!("• {warning}"), text_w)
689                .into_iter()
690                .map(|l| (l, if is_error { muted } else { ink })),
691        );
692    }
693    if diag.warnings.len() > shown {
694        lines.push((
695            format!("… and {} more (full list on stderr)", diag.warnings.len() - shown),
696            muted,
697        ));
698    }
699
700    let title = match (&diag.error, diag.warnings.len()) {
701        (Some(_), 0) => format!("rux: {} failed to load", file_name(path)),
702        (Some(_), n) => format!("rux: {} failed to load  ·  {n} warning(s)", file_name(path)),
703        (None, n) => format!("rux: {n} warning(s) in {}", file_name(path)),
704    };
705    // The panel covers the app it is describing, and there was no way to move it
706    // out of the way. It says so rather than leaving the gesture to be guessed
707    // at, and it comes back by itself the moment the diagnostics change.
708    lines.push(("tap this panel to dismiss it".to_string(), muted));
709
710    let panel_h = OVERLAY_TITLE_H + lines.len() as f32 * OVERLAY_LINE_H + OVERLAY_PAD * 1.5;
711    let x = OVERLAY_PAD;
712    let y = OVERLAY_PAD;
713
714    let mut out = Vec::with_capacity(lines.len() + 3);
715    out.push(Paint::Shadow {
716        x,
717        y: y + 3.0,
718        width: panel_w,
719        height: panel_h,
720        radius: 10.0,
721        blur: 20.0,
722        color: Rgba::new(0.0, 0.0, 0.0, 0.5),
723    });
724    out.push(Paint::Rect(PaintRect {
725        x,
726        y,
727        width: panel_w,
728        height: panel_h,
729        background: Some(Background::Color(bg)),
730        radius: [10.0; 4],
731        border_width: 2.0,
732        border_color: Some(edge),
733    }));
734    out.push(Paint::Text(PaintText {
735        x: x + OVERLAY_PAD,
736        y: y + OVERLAY_PAD * 0.6,
737        width: text_w,
738        height: OVERLAY_TITLE_H,
739        content: overlay_text(title, 15.0, 700, edge),
740    }));
741    for (i, (line, color)) in lines.into_iter().enumerate() {
742        out.push(Paint::Text(PaintText {
743            x: x + OVERLAY_PAD,
744            y: y + OVERLAY_TITLE_H + OVERLAY_PAD * 0.4 + i as f32 * OVERLAY_LINE_H,
745            width: text_w,
746            height: OVERLAY_LINE_H,
747            content: overlay_text(line, 14.0, 400, color),
748        }));
749    }
750    Some(Overlay { paints: out, rect: (x, y, panel_w, panel_h) })
751}
752
753/// Whether the overlay should be on screen: there is something to say, and it
754/// has not been dismissed *for these particular diagnostics*.
755///
756/// Comparing the whole `Diagnostics` rather than holding a flag is what makes
757/// the panel come back on its own. Dismissing "3 warnings" and then introducing
758/// a parse error must not leave the window silent about it, which a boolean
759/// would do until the next restart.
760fn overlay_visible(
761    diag: &rux_runtime::Diagnostics,
762    dismissed: Option<&rux_runtime::Diagnostics>,
763) -> bool {
764    !diag.is_empty() && dismissed != Some(diag)
765}
766
767fn file_name(path: &Path) -> String {
768    path.file_name()
769        .map(|n| n.to_string_lossy().into_owned())
770        .unwrap_or_else(|| path.display().to_string())
771}
772
773/// Break `text` into lines that fit `width`, by character estimate. The overlay
774/// paints each line itself (rather than handing one block to the text engine)
775/// so the panel's height is known before it is drawn.
776fn wrap_overlay(text: &str, width: f32) -> Vec<String> {
777    // ~0.52em per character at this size, a deliberate under-estimate, since a
778    // slightly short line is invisible and an overlong one is clipped.
779    let max_chars = ((width / 7.3) as usize).max(20);
780    let mut lines = Vec::new();
781    for paragraph in text.split('\n') {
782        let mut line = String::new();
783        for word in paragraph.split_whitespace() {
784            if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > max_chars {
785                lines.push(std::mem::take(&mut line));
786            }
787            if !line.is_empty() {
788                line.push(' ');
789            }
790            line.push_str(word);
791        }
792        lines.push(line);
793    }
794    lines
795}
796
797fn overlay_text(text: String, font_size: f32, weight: u16, color: Rgba) -> TextContent {
798    TextContent {
799        text,
800        font_size,
801        weight,
802        color,
803        align: TextAlign::Start,
804        wrap: TextWrap::Normal,
805        font_family: None,
806        letter_spacing: None,
807        word_spacing: None,
808        line_height: None,
809        italic: false,
810        underline: false,
811        strikethrough: false,
812        nowrap: true,
813        caret: None,
814        selection: None,
815        preedit: None,
816    }
817}
818
819/// Load a `.rux` document. On failure the window still opens, but now it opens
820/// showing the error, instead of a blank screen with a line on stderr.
821#[cfg(not(target_arch = "wasm32"))]
822fn load_document(path: &PathBuf) -> Document {
823    match Document::load(path) {
824        Ok(doc) => doc,
825        Err(err) => {
826            eprintln!("rux: failed to load {}: {err}", path.display());
827            let mut doc = Document::from_source("<template><screen></screen></template>")
828                .expect("empty document");
829            doc.set_load_error(err);
830            // Nothing was ever shown, so the empty screen isn't "stale", it is
831            // simply all there is.
832            doc.clear_stale();
833            doc
834        }
835    }
836}
837
838/// Per-window render state.
839struct RenderState {
840    window: Arc<Window>,
841    surface: RenderSurface<'static>,
842    renderer: Renderer,
843    scene: Scene,
844    /// Publishes the accessibility tree to the platform (UI Automation on
845    /// Windows, AT-SPI on Linux, NSAccessibility on macOS). It only does work
846    /// while assistive technology is actually attached, so this costs nothing in
847    /// the common case.
848    #[cfg(not(target_arch = "wasm32"))]
849    access: accesskit_winit::Adapter,
850}
851
852/// An IME composition in flight: the text between pressing a dead key (or
853/// starting to spell a CJK word) and choosing what it becomes.
854///
855/// The composed text is written straight into the bound signal, so it renders
856/// through the ordinary text path and needs no second string that the layout and
857/// painter would have to be taught about. A browser does the same thing to an
858/// `<input>`'s value while you compose, so an `@input` handler seeing provisional
859/// text is the behaviour people already expect.
860///
861/// What must be remembered separately is how to take it back out again, because
862/// a composition can be abandoned as well as committed.
863#[derive(Clone, Debug)]
864struct Preedit {
865    /// Byte offset in the value where the composition starts.
866    at: usize,
867    /// Byte length of the composed text currently sitting in the value.
868    len: usize,
869    /// Whatever the composition replaced when it began (composing over a
870    /// selection is allowed), put back if it is cancelled rather than committed.
871    replaced: String,
872}
873
874/// The application: owns the vello render context, the document, the text
875/// engine, input state, and (once resumed) one window.
876/// Where the surface-setup task leaves its result for `user_event` to collect.
877/// A shared cell rather than an event payload because wgpu's handles are `!Send`
878/// on wasm while `EventLoopProxy` requires `Send`.
879#[cfg(target_arch = "wasm32")]
880type Pending = Rc<RefCell<Option<(RenderContext, RenderState)>>>;
881
882struct App {
883    context: RenderContext,
884    state: Option<RenderState>,
885    /// Proxy for events raised outside the loop: the file watcher and the
886    /// accessibility adapter both deliver through it.
887    #[cfg(not(target_arch = "wasm32"))]
888    proxy: winit::event_loop::EventLoopProxy<RuxEvent>,
889    /// Set while the async surface setup is in flight, so `resumed` firing twice
890    /// doesn't start a second one.
891    #[cfg(target_arch = "wasm32")]
892    pending: Pending,
893    #[cfg(target_arch = "wasm32")]
894    starting: bool,
895    /// The file behind the document. Native only, it titles the window and is
896    /// what the watcher re-reads; on the web there is no file, and new source
897    /// arrives as text.
898    #[cfg(not(target_arch = "wasm32"))]
899    path: PathBuf,
900    document: Document,
901    text: rux_text::TextEngine,
902    images: rux_paint::ImageCache,
903    /// Hit regions from the most recent layout, for tap dispatch.
904    hits: Vec<HitRegion>,
905    /// Focusable input regions from the most recent layout.
906    focuses: Vec<FocusRegion>,
907    /// `type="select"` regions from the most recent layout.
908    selects: Vec<SelectRegion>,
909    /// Keyboard-focusable elements in Tab order, from the most recent layout.
910    focusables: Vec<FocusItem>,
911    /// Index into `focusables` of the keyboard-focused element, if any.
912    focus_index: Option<usize>,
913    /// Whether Shift is held (Shift+Tab reverse traversal; Shift+arrows extend a
914    /// selection; Shift+wheel scrolls sideways).
915    shift_held: bool,
916    /// Whether Ctrl is held (Ctrl+A/C/X/V).
917    ctrl_held: bool,
918    /// Scrollable regions from the most recent layout.
919    scrolls: Vec<ScrollRegion>,
920    /// Boxes styled by `:hover`/`:active`, from the most recent layout. Empty
921    /// unless the document actually uses a pointer-state rule.
922    states: Vec<StateRegion>,
923    /// Scroll offset per scrollable box, in tree order. Survives the rebuild
924    /// that follows every state change, so a list doesn't jump back to the top
925    /// when you tap something in it.
926    offsets: Vec<Offset>,
927    /// The scrollbar thumb being dragged, if any.
928    bar_drag: Option<BarDrag>,
929    /// Where the finger last was during a touch drag, in logical px.
930    touch: Option<(f32, f32)>,
931    /// The `r-model` of the currently focused input, if any.
932    focused: Option<String>,
933    /// Whether the focused input is a `type="textarea"` (Enter → newline).
934    focused_multiline: bool,
935    /// The `r-model` of the currently open `select` dropdown, if any. Survives
936    /// the rebuild after a state change, like scroll offsets.
937    open_select: Option<String>,
938    /// Caret position in the focused input, as a byte index into its value.
939    caret: usize,
940    /// Where the current selection started, as a byte index. Equal to `caret`
941    /// when nothing is selected, the selection is the range between them.
942    anchor: usize,
943    /// The diagnostics whose overlay has been dismissed, if any. Held as the
944    /// diagnostics themselves rather than a flag so that the panel reappears the
945    /// moment what is wrong with the document changes: dismissing "3 warnings"
946    /// must not also hide the error you introduce next.
947    overlay_dismissed: Option<rux_runtime::Diagnostics>,
948    /// Where the overlay was drawn last frame, in logical px, for hit testing.
949    /// `None` when it is not on screen.
950    overlay_rect: Option<(f32, f32, f32, f32)>,
951    /// The IME composition in flight, if any. `None` covers every keyboard that
952    /// commits directly, which is most of them most of the time.
953    preedit: Option<Preedit>,
954    /// Whether the pointer is selecting text by dragging inside an input.
955    text_drag: bool,
956    /// The touch text gesture in progress, if a finger is down on a field. The
957    /// mouse does not use this: it keeps `text_drag`, since drag-to-select is
958    /// the right model with a pointer.
959    touch_text: Option<TouchText>,
960    /// How far the focused *single-line* input's text is scrolled left, in
961    /// logical px.
962    ///
963    /// A textarea is `overflow: scroll` and gets a real scroll region, which is
964    /// what `scroll_caret_into_view` moves. An input is `overflow: clip`: it has
965    /// no scroll region, so nothing ever kept its caret inside the box and the
966    /// caret was simply clipped away past the right edge. This is the offset
967    /// that was missing. Held for the focused field only, and reset when focus
968    /// moves.
969    text_scroll: f32,
970    /// When and where the last click landed, for double-click word-select.
971    last_click: Option<(Instant, f64, f64)>,
972    /// The system clipboard. `None` if the platform wouldn't give us one, the
973    /// app still runs, copy/paste just does nothing. Absent on the web, where
974    /// the clipboard is async and permission-gated; same "copy/paste does
975    /// nothing" outcome, reached without a field.
976    #[cfg(not(target_arch = "wasm32"))]
977    clipboard: Option<arboard::Clipboard>,
978    /// Whether the caret is in the visible half of its blink cycle.
979    caret_visible: bool,
980    /// When the caret next toggles. `None` when no input is focused, so an idle
981    /// window stays fully event-driven with no timer.
982    blink_deadline: Option<Instant>,
983    /// Current pointer position (physical pixels).
984    pointer: (f64, f64),
985    /// Where the left button was pressed, if it is currently down.
986    press: Option<(f64, f64)>,
987    /// The cursor icon currently set on the window, so a mouse-move only calls
988    /// `set_cursor` when the shape actually changes.
989    cursor: CursorIcon,
990}
991
992impl App {
993    /// Build the app. Native loads the document from `path`; the web is handed
994    /// one already parsed, because it has no filesystem to load it from.
995    fn new(
996        #[cfg(not(target_arch = "wasm32"))] path: PathBuf,
997        #[cfg(not(target_arch = "wasm32"))] proxy: winit::event_loop::EventLoopProxy<RuxEvent>,
998        #[cfg(target_arch = "wasm32")] document: Document,
999    ) -> Self {
1000        #[cfg(not(target_arch = "wasm32"))]
1001        let document = load_document(&path);
1002        Self {
1003            context: RenderContext::new(),
1004            state: None,
1005            #[cfg(not(target_arch = "wasm32"))]
1006            proxy,
1007            #[cfg(target_arch = "wasm32")]
1008            pending: Rc::new(RefCell::new(None)),
1009            #[cfg(target_arch = "wasm32")]
1010            starting: false,
1011            #[cfg(not(target_arch = "wasm32"))]
1012            path,
1013            document,
1014            text: rux_text::TextEngine::new(),
1015            images: rux_paint::ImageCache::new(),
1016            hits: Vec::new(),
1017            focuses: Vec::new(),
1018            selects: Vec::new(),
1019            focusables: Vec::new(),
1020            focus_index: None,
1021            shift_held: false,
1022            ctrl_held: false,
1023            scrolls: Vec::new(),
1024            offsets: Vec::new(),
1025            bar_drag: None,
1026            touch: None,
1027            focused: None,
1028            focused_multiline: false,
1029            open_select: None,
1030            caret: 0,
1031            anchor: 0,
1032            overlay_dismissed: None,
1033            overlay_rect: None,
1034            preedit: None,
1035            text_drag: false,
1036            touch_text: None,
1037            text_scroll: 0.0,
1038            last_click: None,
1039            #[cfg(not(target_arch = "wasm32"))]
1040            clipboard: arboard::Clipboard::new()
1041                .map_err(|e| eprintln!("rux: no clipboard ({e}), so copy/paste is disabled"))
1042                .ok(),
1043            caret_visible: true,
1044            blink_deadline: None,
1045            pointer: (0.0, 0.0),
1046            press: None,
1047            cursor: CursorIcon::Default,
1048            states: Vec::new(),
1049        }
1050    }
1051
1052    /// Re-load the document after a file change. On a parse/load error the last
1053    /// good tree stays on screen and the dev overlay reports the error, so a typo
1054    /// mid-edit neither blanks the window nor passes unnoticed.
1055    #[cfg(not(target_arch = "wasm32"))]
1056    fn reload(&mut self) {
1057        match Document::load(&self.path) {
1058            Ok(doc) => {
1059                // Keeps the window's own state (viewport, hover) and drops the
1060                // previous error, so fixing the file clears the overlay.
1061                self.document.replace_with(doc);
1062                eprintln!("reloaded {}", self.path.display());
1063            }
1064            Err(err) => {
1065                eprintln!("rux: reload failed for {}: {err}", self.path.display());
1066                // The last good tree stays on screen; the overlay explains why it
1067                // is no longer what the file says.
1068                self.document.set_load_error(err);
1069            }
1070        }
1071    }
1072
1073    /// Rebuild from new source text, the web's equivalent of a file save.
1074    ///
1075    /// A parse error keeps the previous document on screen rather than blanking
1076    /// the canvas, which matters in a playground where the source is mid-edit
1077    /// most of the time. The error goes to the console for now; surfacing it in
1078    /// the page is what v0.4's dev overlay is for.
1079    #[cfg(target_arch = "wasm32")]
1080    fn set_source(&mut self, source: String) {
1081        match Document::from_source(&source) {
1082            Ok(doc) => {
1083                self.document = doc;
1084                self.focused = None;
1085                self.focus_index = None;
1086                self.open_select = None;
1087            }
1088            Err(err) => web_sys::console::error_1(&format!("rux: {err}").into()),
1089        }
1090    }
1091
1092    /// The window's DPI scale. Layout and hit regions are in logical pixels; the
1093    /// surface is physical, so the scene is scaled up at paint time.
1094    fn scale(&self) -> f64 {
1095        self.state
1096            .as_ref()
1097            .map(|s| s.window.scale_factor())
1098            .unwrap_or(1.0)
1099    }
1100
1101    /// The pointer in logical pixels (layout, hit regions and scrollbars all live
1102    /// in logical space; winit reports physical).
1103    fn logical(&self, p: (f64, f64)) -> (f32, f32) {
1104        let scale = self.scale();
1105        ((p.0 / scale) as f32, (p.1 / scale) as f32)
1106    }
1107
1108    /// Scroll the innermost scrollable box under the pointer by `(dx, dy)`
1109    /// logical pixels. Nothing under the pointer scrolls (or it's already at the
1110    /// end) → nothing happens, and no repaint is queued.
1111    fn scroll_at(&mut self, pointer: (f64, f64), dx: f32, dy: f32) {
1112        let (px, py) = self.logical(pointer);
1113        // Innermost wins: scrollers are pushed parent-first, so search backwards.
1114        let Some(region) = self
1115            .scrolls
1116            .iter()
1117            .rev()
1118            .find(|s| s.contains(px, py) && s.scrollable())
1119        else {
1120            return;
1121        };
1122        let (id, max) = (region.id, region.max);
1123        self.scroll_to(
1124            id,
1125            Offset {
1126                x: self.offsets[id].x + dx,
1127                y: self.offsets[id].y + dy,
1128            }
1129            .clamp_to(max),
1130        );
1131    }
1132
1133    /// Move scroller `id` to `next`, repainting only if it actually moved.
1134    fn scroll_to(&mut self, id: usize, next: Offset) {
1135        if self.offsets.get(id) != Some(&next) {
1136            if let Some(slot) = self.offsets.get_mut(id) {
1137                *slot = next;
1138                self.request_redraw();
1139            }
1140        }
1141    }
1142
1143    /// Start a scrollbar drag if the press landed on a thumb. Returns whether it
1144    /// did, in which case the press is the bar's, not a tap's.
1145    fn press_scrollbar(&mut self, pointer: (f64, f64)) -> bool {
1146        let (px, py) = self.logical(pointer);
1147        // Topmost (innermost) bar wins, as with the wheel.
1148        for r in self.scrolls.iter().rev() {
1149            let offset = self.offsets.get(r.id).copied().unwrap_or_default();
1150            for axis in [Axis2::Y, Axis2::X] {
1151                let Some((tx, ty, tw, th)) = bar_thumb(r, offset, axis) else {
1152                    continue;
1153                };
1154                if px >= tx && px <= tx + tw && py >= ty && py <= ty + th {
1155                    self.bar_drag = Some(BarDrag {
1156                        id: r.id,
1157                        axis,
1158                        grab: if axis == Axis2::Y { py } else { px },
1159                        start: if axis == Axis2::Y { offset.y } else { offset.x },
1160                    });
1161                    return true;
1162                }
1163            }
1164        }
1165        false
1166    }
1167
1168    /// Follow a scrollbar thumb drag: the pointer's travel down the *track* maps
1169    /// to the content's travel through its full scroll range.
1170    fn drag_scrollbar(&mut self, pointer: (f64, f64)) {
1171        let Some(drag) = self.bar_drag else { return };
1172        let Some(r) = self.scrolls.iter().find(|s| s.id == drag.id).cloned() else {
1173            return;
1174        };
1175        let Some((_, _, tw, th)) = bar_thumb(&r, self.offsets[drag.id], drag.axis) else {
1176            return;
1177        };
1178        let (_, _, track_w, track_h) = bar_track(&r, drag.axis);
1179        let (px, py) = self.logical(pointer);
1180        let (pos, track_len, thumb_len, max) = match drag.axis {
1181            Axis2::Y => (py, track_h, th, r.max.y),
1182            Axis2::X => (px, track_w, tw, r.max.x),
1183        };
1184        let travel = (track_len - thumb_len).max(0.0);
1185        if travel <= 0.0 {
1186            return;
1187        }
1188        let moved = drag.start + (pos - drag.grab) * max / travel;
1189        let next = match drag.axis {
1190            Axis2::Y => Offset { x: self.offsets[drag.id].x, y: moved },
1191            Axis2::X => Offset { x: moved, y: self.offsets[drag.id].y },
1192        };
1193        self.scroll_to(drag.id, next.clamp_to(r.max));
1194    }
1195
1196    /// Scroll the box under the pointer with the keyboard. Only reached when no
1197    /// input has focus, so it can't steal a caret key. Returns whether it acted.
1198    fn scroll_key(&mut self, key: &Key) -> bool {
1199        let (px, py) = self.logical(self.pointer);
1200        let Some(r) = self
1201            .scrolls
1202            .iter()
1203            .rev()
1204            .find(|s| s.contains(px, py) && s.scrollable())
1205            .cloned()
1206        else {
1207            return false;
1208        };
1209        // A page is just short of the box, so a landmark stays on screen.
1210        let page = (r.height * 0.9).max(LINE);
1211        let here = self.offsets[r.id];
1212        let next = match key {
1213            Key::Named(NamedKey::ArrowDown) => Offset { y: here.y + LINE, ..here },
1214            Key::Named(NamedKey::ArrowUp) => Offset { y: here.y - LINE, ..here },
1215            Key::Named(NamedKey::ArrowRight) => Offset { x: here.x + LINE, ..here },
1216            Key::Named(NamedKey::ArrowLeft) => Offset { x: here.x - LINE, ..here },
1217            Key::Named(NamedKey::PageDown) => Offset { y: here.y + page, ..here },
1218            Key::Named(NamedKey::PageUp) => Offset { y: here.y - page, ..here },
1219            Key::Named(NamedKey::Home) => Offset { y: 0.0, ..here },
1220            Key::Named(NamedKey::End) => Offset { y: r.max.y, ..here },
1221            _ => return false,
1222        };
1223        self.scroll_to(r.id, next.clamp_to(r.max));
1224        true
1225    }
1226
1227    /// Bring the keyboard-focused element into view: if it sits outside a
1228    /// scroller it belongs to, nudge that scroller just far enough. Tabbing to
1229    /// something below the fold is otherwise a focus ring you can't see.
1230    ///
1231    /// Geometry here is the *painted* (already-shifted) position from the last
1232    /// layout, so the adjustment is a plain delta; the next layout re-clamps it.
1233    fn scroll_focus_into_view(&mut self) {
1234        let Some(item) = self.focus_index.and_then(|i| self.focusables.get(i)).cloned() else {
1235            return;
1236        };
1237        // Outermost first: scrolling an ancestor moves the box inside it, so the
1238        // inner scroller's own correction must be computed after.
1239        for r in self.scrolls.clone() {
1240            if !r.scrollable() {
1241                continue;
1242            }
1243            // Only a scroller the item is horizontally within can own it, a
1244            // cheap stand-in for a real ancestor test (we don't carry parentage).
1245            if item.x + item.width < r.x || item.x > r.x + r.width {
1246                continue;
1247            }
1248            let here = self.offsets[r.id];
1249            let mut next = here;
1250            if item.y < r.y {
1251                next.y = here.y - (r.y - item.y);
1252            } else if item.y + item.height > r.y + r.height {
1253                next.y = here.y + (item.y + item.height - (r.y + r.height));
1254            }
1255            if item.x < r.x {
1256                next.x = here.x - (r.x - item.x);
1257            } else if item.x + item.width > r.x + r.width {
1258                next.x = here.x + (item.x + item.width - (r.x + r.width));
1259            }
1260            self.scroll_to(r.id, next.clamp_to(r.max));
1261        }
1262    }
1263
1264    /// The byte index in `region`'s text nearest a point, in logical px. An empty
1265    /// input is showing its placeholder, not a value, so its caret belongs at 0.
1266    fn index_in(&mut self, region: &FocusRegion, px: f32, py: f32) -> usize {
1267        let value = self.document.engine_mut().get_string(&region.model);
1268        match region.text.as_ref() {
1269            Some(t) if !value.is_empty() => {
1270                let (tx, ty) = self.text_point(region, t, px, py);
1271                self.text.index_at_point(
1272                    &value,
1273                    &rux_paint::text_style(&t.content),
1274                    Some(t.width),
1275                    tx,
1276                    ty,
1277                )
1278            }
1279            _ => 0,
1280        }
1281    }
1282
1283    /// A pointer position in the text's own coordinates, with the field's
1284    /// horizontal scroll applied.
1285    ///
1286    /// Every mapping from a pointer onto a string goes through here: a caret in
1287    /// [`index_in`](Self::index_in), a word in
1288    /// [`select_word_at`](Self::select_word_at). They were originally written
1289    /// separately and one of them missed the scroll, so a long press in a
1290    /// scrolled field took the word one scroll-distance behind the finger. A
1291    /// single conversion cannot disagree with itself.
1292    fn text_point(
1293        &self,
1294        region: &FocusRegion,
1295        t: &rux_layout::PaintText,
1296        px: f32,
1297        py: f32,
1298    ) -> (f32, f32) {
1299        (px - t.x + self.text_scroll_for(region), py - t.y)
1300    }
1301
1302    /// Update the focused single-line input's horizontal offset so its caret is
1303    /// inside the visible box, and return the offset to paint with.
1304    ///
1305    /// The offset only moves when the caret would otherwise fall outside, which
1306    /// is what stops the text sliding under a caret that is already visible. It
1307    /// is also clamped so the field never scrolls past the start, and never
1308    /// leaves blank space after the end once the text is short enough to fit.
1309    fn track_caret_x(
1310        layout: &rux_layout::Layout,
1311        focused: Option<&str>,
1312        caret: usize,
1313        scroll: &mut f32,
1314        text: &mut rux_text::TextEngine,
1315        document: &mut rux_runtime::Document,
1316    ) -> f32 {
1317        let Some(model) = focused else {
1318            *scroll = 0.0;
1319            return 0.0;
1320        };
1321        let Some(region) = layout.focuses.iter().find(|f| f.model == model) else {
1322            return *scroll;
1323        };
1324        // A textarea has a real scroll region and is handled by
1325        // `scroll_caret_into_view`; this is only for the clipped single line.
1326        let (false, Some(t)) = (region.multiline, region.text.as_ref()) else {
1327            *scroll = 0.0;
1328            return 0.0;
1329        };
1330        let value = document.engine_mut().get_string(model);
1331        let style = rux_paint::text_style(&t.content);
1332        let (cx, _, _) = text.caret_geometry(&value, &style, Some(t.width), caret.min(value.len()));
1333
1334        // The text starts inset from the box by its padding and border. Mirroring
1335        // that inset on the right gives the span actually visible, without the
1336        // layout having to report a content box it does not currently carry.
1337        let inset = (t.x - region.x).max(0.0);
1338        let visible = (region.width - inset * 2.0).max(1.0);
1339
1340        if cx < *scroll {
1341            *scroll = cx;
1342        } else if cx > *scroll + visible {
1343            *scroll = cx - visible;
1344        }
1345        // `None` for the width: the caret is tracked against the text's true
1346        // length, not a re-wrap at the box width.
1347        let full = text.measure(&value, &style, None).0;
1348        *scroll = scroll.clamp(0.0, (full - visible).max(0.0));
1349        *scroll
1350    }
1351
1352    /// The focused field's box, when it has a selection worth offering actions
1353    /// on. `None` means no toolbar: nothing focused, or nothing selected.
1354    ///
1355    /// Tied to the selection rather than to focus so the strip is not sitting
1356    /// over the page the whole time an input has a caret in it.
1357    fn toolbar_field(&self) -> Option<(f32, f32, f32, f32)> {
1358        if self.caret == self.anchor {
1359            return None;
1360        }
1361        let model = self.focused.as_deref()?;
1362        let region = self.focuses.iter().find(|f| f.model == model)?;
1363        Some((region.x, region.y, region.width, region.height))
1364    }
1365
1366    /// The action under `(fx, fy)` in logical px, if the toolbar is up and the
1367    /// point is on one of its buttons.
1368    fn toolbar_action_at(&self, fx: f32, fy: f32) -> Option<TextAction> {
1369        let field = self.toolbar_field()?;
1370        let (_, buttons) = toolbar_layout(field, self.logical_size());
1371        buttons
1372            .into_iter()
1373            .find(|(_, bx, by, bw, bh)| fx >= *bx && fx <= bx + bw && fy >= *by && fy <= by + bh)
1374            .map(|(action, ..)| action)
1375    }
1376
1377    /// Whether the toolbar covers `(fx, fy)`, so a press there is not also a
1378    /// press on whatever is underneath. The same rule the dev overlay follows.
1379    fn toolbar_covers(&self, fx: f32, fy: f32) -> bool {
1380        let Some(field) = self.toolbar_field() else { return false };
1381        let ((x, y, w, h), _) = toolbar_layout(field, self.logical_size());
1382        fx >= x && fx <= x + w && fy >= y && fy <= y + h
1383    }
1384
1385    /// Run a toolbar action against the focused field.
1386    fn run_text_action(&mut self, action: TextAction) {
1387        let Some(model) = self.focused.clone() else { return };
1388        match action {
1389            TextAction::Copy => self.copy_selection(),
1390            TextAction::Cut => self.cut_selection(&model),
1391            TextAction::Paste => self.request_paste(&model),
1392            TextAction::SelectAll => self.select_all_text(&model),
1393        }
1394        // Copy leaves the selection up, which is what every platform does: you
1395        // may want to cut what you just copied. The others change it themselves.
1396        self.request_redraw();
1397    }
1398
1399    /// The window in logical px, which the toolbar is kept inside.
1400    fn logical_size(&self) -> (f32, f32) {
1401        let Some(state) = self.state.as_ref() else { return (0.0, 0.0) };
1402        let scale = state.window.scale_factor();
1403        let size = state.window.inner_size();
1404        ((size.width as f64 / scale) as f32, (size.height as f64 / scale) as f32)
1405    }
1406
1407    /// The horizontal offset in force for `region`, which is zero for anything
1408    /// but the focused single-line input. A textarea scrolls through its own
1409    /// scroll region instead, and an unfocused field is never scrolled.
1410    fn text_scroll_for(&self, region: &FocusRegion) -> f32 {
1411        let focused = self.focused.as_deref() == Some(region.model.as_str());
1412        if focused && !region.multiline { self.text_scroll } else { 0.0 }
1413    }
1414
1415    /// A press inside an input starts a text selection: it drops the caret (and
1416    /// the anchor) where you clicked, and a drag from there extends it. A second
1417    /// click in the same spot selects the word instead.
1418    ///
1419    /// Returns whether the press was ours, if so it is *not* also dispatched as a
1420    /// tap on release, since focusing already happened here.
1421    fn press_text(&mut self, pointer: (f64, f64)) -> bool {
1422        // An open dropdown floats over everything and gets first refusal.
1423        if self.open_select.is_some() {
1424            return false;
1425        }
1426        // A press on the toolbar must not move the caret: collapsing the
1427        // selection is exactly what the button is about to act on. The tap is
1428        // handled on release, in `dispatch_tap`.
1429        let (fx, fy) = self.logical(pointer);
1430        if self.toolbar_covers(fx, fy) {
1431            return false;
1432        }
1433        let Some(region) = self.focuses.iter().rev().find(|f| f.contains(fx, fy)).cloned() else {
1434            return false;
1435        };
1436
1437        // A tap also moves keyboard focus, so Tab continues from what you clicked.
1438        self.focus_index = self.focusables.iter().rposition(|f| f.contains(fx, fy));
1439        self.focused_multiline = region.multiline;
1440
1441        let double = self
1442            .last_click
1443            .is_some_and(|(at, x, y)| {
1444                at.elapsed() < DOUBLE_CLICK && (pointer.0 - x).hypot(pointer.1 - y) <= TAP_SLOP
1445            });
1446        self.last_click = Some((Instant::now(), pointer.0, pointer.1));
1447
1448        // Double-click, and double-tap, select the word under the pointer.
1449        if double && self.select_word_at(pointer) {
1450            return true;
1451        }
1452
1453        let caret = self.index_in(&region, fx, fy);
1454        self.text_drag = true;
1455        self.set_focus(Some((region.model, caret)));
1456        true
1457    }
1458
1459    /// Select the word under `pointer`, in whichever field it lands in.
1460    ///
1461    /// Shared by double-click and by the touch long press: both mean "take the
1462    /// word here", and having one implementation is what keeps them agreeing
1463    /// about where a word ends. Returns whether a word was actually taken, which
1464    /// is false for an empty field or a press outside any text.
1465    fn select_word_at(&mut self, pointer: (f64, f64)) -> bool {
1466        let (fx, fy) = self.logical(pointer);
1467        let Some(region) = self.focuses.iter().rev().find(|f| f.contains(fx, fy)).cloned() else {
1468            return false;
1469        };
1470        let value = self.document.engine_mut().get_string(&region.model);
1471        let (Some(t), false) = (&region.text, value.is_empty()) else {
1472            return false;
1473        };
1474        let (tx, ty) = self.text_point(&region, t, fx, fy);
1475        let (start, end) = self.text.word_at_point(
1476            &value,
1477            &rux_paint::text_style(&t.content),
1478            Some(t.width),
1479            tx,
1480            ty,
1481        );
1482        self.set_focus_range(Some(Focus {
1483            model: region.model,
1484            caret: end,
1485            anchor: start,
1486            preedit: None,
1487        }));
1488        true
1489    }
1490
1491    /// Press on text from a *finger*. Unlike the mouse, this does not start a
1492    /// selection: it moves the caret and arms the long press, so that what the
1493    /// finger does next decides between dragging the caret and selecting.
1494    fn press_text_touch(&mut self, pointer: (f64, f64)) -> bool {
1495        if !self.press_text(pointer) {
1496            return false;
1497        }
1498        // `press_text` set this for the mouse's model; touch resolves the drag
1499        // itself and must not also be dragging a selection.
1500        self.text_drag = false;
1501        // A double-tap has already taken a word, so there is nothing pending.
1502        self.touch_text = Some(if self.anchor == self.caret {
1503            TouchText::Pending { at: pointer, deadline: Instant::now() + LONG_PRESS }
1504        } else {
1505            TouchText::Selecting
1506        });
1507        true
1508    }
1509
1510    /// Move the caret to the pointer *without* selecting: the anchor follows it,
1511    /// so the range stays empty. This is what a finger dragging on text does on
1512    /// a phone, where selecting is what the long press is for.
1513    fn drag_caret(&mut self, pointer: (f64, f64)) {
1514        let Some(model) = self.focused.clone() else { return };
1515        let Some(region) = self.focuses.iter().find(|f| f.model == model).cloned() else {
1516            return;
1517        };
1518        let (fx, fy) = self.logical(pointer);
1519        let caret = self.index_in(&region, fx, fy);
1520        if caret != self.caret || self.anchor != caret {
1521            self.set_focus_range(Some(Focus { model, caret, anchor: caret, preedit: None }));
1522        }
1523    }
1524
1525    /// Extend the selection to the pointer while dragging inside an input: the
1526    /// anchor stays where the press landed, the caret follows the pointer.
1527    fn drag_text(&mut self, pointer: (f64, f64)) {
1528        let Some(model) = self.focused.clone() else { return };
1529        let Some(region) = self.focuses.iter().find(|f| f.model == model).cloned() else {
1530            return;
1531        };
1532        let (fx, fy) = self.logical(pointer);
1533        let caret = self.index_in(&region, fx, fy);
1534        if caret != self.caret {
1535            let anchor = self.anchor;
1536            self.set_focus_range(Some(Focus { model, caret, anchor, preedit: None }));
1537        }
1538    }
1539
1540    /// Set the window's cursor from whatever tappable region is under the
1541    /// pointer (topmost wins, as with tap dispatch). Only touches the window when
1542    /// the shape changes, so it's cheap to call on every mouse move.
1543    fn update_cursor(&mut self) {
1544        let scale = self.scale();
1545        let (px, py) = ((self.pointer.0 / scale) as f32, (self.pointer.1 / scale) as f32);
1546        let want = self
1547            .hits
1548            .iter()
1549            .rev()
1550            .find(|h| h.contains(px, py))
1551            .map(|h| match h.cursor {
1552                Cursor::Pointer => CursorIcon::Pointer,
1553                Cursor::Default => CursorIcon::Default,
1554            })
1555            .unwrap_or(CursorIcon::Default);
1556        if want != self.cursor {
1557            self.cursor = want;
1558            if let Some(state) = &self.state {
1559                state.window.set_cursor(want);
1560            }
1561        }
1562    }
1563
1564    /// Push the current pointer state into the document so `:hover` and `:active`
1565    /// restyle. The topmost state region under the pointer wins, as with tap
1566    /// dispatch; `:active` additionally requires the button to be down on it.
1567    ///
1568    /// Cheap to call on every mouse move: with no pointer-state rules in the
1569    /// document there are no regions, and the document declines any state it is
1570    /// already in without touching the tree.
1571    fn update_pointer_state(&mut self) {
1572        if self.states.is_empty() && self.document.interaction().hovered.is_none() {
1573            return;
1574        }
1575        let scale = self.scale();
1576        let (px, py) = ((self.pointer.0 / scale) as f32, (self.pointer.1 / scale) as f32);
1577        let hovered = self
1578            .states
1579            .iter()
1580            .rev()
1581            .find(|r| r.contains(px, py))
1582            .map(|r| r.path.clone());
1583        // Pressing and then dragging off the element drops `:active`, the way a
1584        // button un-presses when the pointer leaves it.
1585        let active = self.press.is_some().then(|| hovered.clone()).flatten();
1586        let next = InteractionState {
1587            hovered,
1588            active,
1589            focused_model: self.document.interaction().focused_model.clone(),
1590        };
1591        if self.document.set_interaction(next) {
1592            self.request_redraw();
1593        }
1594    }
1595
1596    /// Tell the document the window's *logical* size, so `@media` queries are
1597    /// evaluated against the same units the stylesheet is written in. The document
1598    /// only re-cascades if a query actually changed answer, so calling this on
1599    /// every resize event is cheap.
1600    fn update_viewport(&mut self) {
1601        let Some(state) = self.state.as_ref() else { return };
1602        let scale = state.window.scale_factor();
1603        let viewport = Viewport {
1604            width: (state.surface.config.width as f64 / scale) as f32,
1605            height: (state.surface.config.height as f64 / scale) as f32,
1606        };
1607        if self.document.set_viewport(viewport) {
1608            self.request_redraw();
1609        }
1610    }
1611
1612    /// The pointer left the window: nothing is hovered or pressed any more.
1613    ///
1614    /// This needs its own event because the pointer leaving produces `CursorLeft`,
1615    /// not a `CursorMoved` to somewhere outside, so without it a `:hover` style
1616    /// stays lit after the pointer is long gone.
1617    fn clear_pointer_state(&mut self) {
1618        let mut next = self.document.interaction().clone();
1619        if next.hovered.is_none() && next.active.is_none() {
1620            return;
1621        }
1622        next.hovered = None;
1623        next.active = None;
1624        if self.document.set_interaction(next) {
1625            self.request_redraw();
1626        }
1627    }
1628
1629    /// Tell the document which input has focus, so `:focus` rules match it.
1630    fn update_focus_state(&mut self, model: Option<String>) {
1631        let mut next = self.document.interaction().clone();
1632        if next.focused_model == model {
1633            return;
1634        }
1635        next.focused_model = model;
1636        if self.document.set_interaction(next) {
1637            self.request_redraw();
1638        }
1639    }
1640
1641    /// Handle a completed tap at `(px, py)`, in physical pixels: focus an input
1642    /// Hide the dev overlay if `(fx, fy)` in logical px is on it. Returns whether
1643    /// it acted, so the tap is not also delivered to the app underneath.
1644    ///
1645    /// The dismissal is remembered against the current diagnostics, so it lasts
1646    /// exactly as long as the document's problems are the same ones.
1647    fn dismiss_overlay_at(&mut self, fx: f32, fy: f32) -> bool {
1648        if !self.overlay_covers(fx, fy) {
1649            return false;
1650        }
1651        self.overlay_dismissed = Some(self.document.diagnostics().clone());
1652        self.overlay_rect = None;
1653        self.request_redraw();
1654        true
1655    }
1656
1657    /// Whether the overlay is on screen and covers `(fx, fy)` in logical px.
1658    fn overlay_covers(&self, fx: f32, fy: f32) -> bool {
1659        self.overlay_rect
1660            .is_some_and(|(x, y, w, h)| fx >= x && fx <= x + w && fy >= y && fy <= y + h)
1661    }
1662
1663    /// The same test against a physical-pixel pointer position, which is what
1664    /// the press handlers have. A press landing on the panel must not reach the
1665    /// app underneath: starting a text selection inside a field you cannot see
1666    /// is exactly the confusion the panel is there to prevent.
1667    fn overlay_covers_physical(&self, (px, py): (f64, f64)) -> bool {
1668        let scale = self.scale();
1669        self.overlay_covers((px / scale) as f32, (py / scale) as f32)
1670    }
1671
1672    /// if one is under the pointer, otherwise run the topmost `@tap` handler.
1673    fn dispatch_tap(&mut self, px: f64, py: f64) {
1674        let scale = self.scale();
1675        let (px, py) = (px / scale, py / scale);
1676        let (fx, fy) = (px as f32, py as f32);
1677
1678        // The dev overlay is painted above everything, including a dropdown, so
1679        // it takes the tap first. Anything else would have the panel swallow
1680        // taps meant for it while passing them to whatever it is covering.
1681        if self.dismiss_overlay_at(fx, fy) {
1682            return;
1683        }
1684
1685        // The selection toolbar sits above the page like the dropdown, so it
1686        // takes the tap before anything under it. Checked before the dropdown
1687        // because the two are never up together: opening a select drops focus.
1688        if let Some(action) = self.toolbar_action_at(fx, fy) {
1689            self.run_text_action(action);
1690            return;
1691        }
1692
1693        // An open dropdown is on top of everything, so it intercepts taps first:
1694        // a tap on an option selects it; any other tap just closes the dropdown.
1695        if let Some(model) = self.open_select.take() {
1696            if let Some(sel) = self.selects.iter().find(|s| s.model == model).cloned() {
1697                for (i, option) in sel.options.iter().enumerate() {
1698                    let (rx, ry, rw, rh) = dropdown_row(&sel, i);
1699                    if fx >= rx && fx <= rx + rw && fy >= ry && fy <= ry + rh {
1700                        self.document.apply_edit(&model, option);
1701                        self.request_redraw();
1702                        return;
1703                    }
1704                }
1705            }
1706            // Closed by taking `open_select`; repaint without the dropdown.
1707            self.request_redraw();
1708            return;
1709        }
1710
1711        // A tap also moves keyboard focus, so Tab continues from what you clicked
1712        // (topmost focusable under the pointer, or nothing on empty space).
1713        self.focus_index = self.focusables.iter().rposition(|f| f.contains(fx, fy));
1714
1715        // A tap on a closed select opens its dropdown.
1716        if let Some(sel) = self.selects.iter().find(|s| s.contains(fx, fy)) {
1717            self.open_select = Some(sel.model.clone());
1718            self.set_focus(None);
1719            self.request_redraw();
1720            return;
1721        }
1722
1723        // Inputs are handled at press time (`press_text`), which is where a
1724        // selection drag has to start, so by here the tap is on something else.
1725        // Tapping elsewhere drops focus.
1726        self.set_focus(None);
1727
1728        // Topmost hit region wins (later in list = drawn on top).
1729        let handler = self
1730            .hits
1731            .iter()
1732            .rev()
1733            .find(|h| h.contains(px as f32, py as f32))
1734            .map(|h| h.on_tap.clone());
1735
1736        if let Some(src) = handler {
1737            // Patch in place when the change is display-only; rebuild only when it
1738            // touches structure/attributes/input values. Either way, repaint.
1739            if self.document.apply_handler(&src) {
1740                self.request_redraw();
1741            }
1742        }
1743    }
1744
1745    /// Apply a key to the focused input's bound signal, then rebuild + repaint.
1746    ///
1747    /// Indices are byte offsets into the value, always on a char boundary (we
1748    /// only ever step by whole characters, and parley returns boundaries), so
1749    /// slicing is safe.
1750    ///
1751    /// Selection rules, which are the platform's everywhere: **Shift** + a
1752    /// movement extends (the anchor stays put); a movement without it collapses;
1753    /// and anything that inserts or deletes replaces the selection first.
1754    fn edit_focused(&mut self, key: &Key) {
1755        let Some(model) = self.focused.clone() else {
1756            return;
1757        };
1758        // Ctrl chords are select-all / copy / cut / paste, not text.
1759        if self.ctrl_held && self.text_shortcut(key, &model) {
1760            return;
1761        }
1762
1763        let mut value = self.document.engine_mut().get_string(&model);
1764        let caret = self.caret.min(value.len());
1765        let (sel_start, sel_end) = {
1766            let (s, e) = self.selection();
1767            (s.min(value.len()), e.min(value.len()))
1768        };
1769        let has_selection = sel_start != sel_end;
1770        let extend = self.shift_held;
1771
1772        // How far the previous / next character is, in bytes.
1773        let prev = value[..caret].chars().next_back().map(char::len_utf8);
1774        let next = value[caret..].chars().next().map(char::len_utf8);
1775
1776        let mut edited = false;
1777        let mut moved = false;
1778        let mut new_caret = caret;
1779        // Replace whatever is selected with `text`, leaving the caret after it.
1780        let replace_selection = |value: &mut String, text: &str| {
1781            value.replace_range(sel_start..sel_end, text);
1782            sel_start + text.len()
1783        };
1784
1785        match key {
1786            Key::Named(NamedKey::Backspace) => {
1787                if has_selection {
1788                    new_caret = replace_selection(&mut value, "");
1789                    edited = true;
1790                } else if let Some(len) = prev {
1791                    value.replace_range(caret - len..caret, "");
1792                    new_caret = caret - len;
1793                    edited = true;
1794                }
1795            }
1796            Key::Named(NamedKey::Delete) => {
1797                if has_selection {
1798                    new_caret = replace_selection(&mut value, "");
1799                    edited = true;
1800                } else if let Some(len) = next {
1801                    value.replace_range(caret..caret + len, "");
1802                    edited = true;
1803                }
1804            }
1805            // A plain arrow with a selection collapses to its near edge rather
1806            // than moving, that's what every text field does.
1807            Key::Named(NamedKey::ArrowLeft) => {
1808                if has_selection && !extend {
1809                    new_caret = sel_start;
1810                    moved = true;
1811                } else if let Some(len) = prev {
1812                    new_caret = caret - len;
1813                    moved = true;
1814                }
1815            }
1816            Key::Named(NamedKey::ArrowRight) => {
1817                if has_selection && !extend {
1818                    new_caret = sel_end;
1819                    moved = true;
1820                } else if let Some(len) = next {
1821                    new_caret = caret + len;
1822                    moved = true;
1823                }
1824            }
1825            // Up/Down move the caret between lines of a textarea: find the byte
1826            // index at the same x on the line above/below the current caret.
1827            Key::Named(NamedKey::ArrowUp | NamedKey::ArrowDown) if self.focused_multiline => {
1828                if let Some(t) = self
1829                    .focuses
1830                    .iter()
1831                    .find(|f| f.model == model)
1832                    .and_then(|f| f.text.clone())
1833                {
1834                    let style = rux_paint::text_style(&t.content);
1835                    let (cx, cy, ch) = self.text.caret_geometry(&value, &style, Some(t.width), caret);
1836                    let dir = if matches!(key, Key::Named(NamedKey::ArrowUp)) { -1.0 } else { 1.0 };
1837                    let target_y = cy + ch / 2.0 + dir * ch;
1838                    new_caret = self.text.index_at_point(&value, &style, Some(t.width), cx, target_y);
1839                    moved = new_caret != caret;
1840                }
1841            }
1842            Key::Named(NamedKey::Home) => {
1843                new_caret = 0;
1844                moved = true;
1845            }
1846            Key::Named(NamedKey::End) => {
1847                new_caret = value.len();
1848                moved = true;
1849            }
1850            Key::Named(NamedKey::Escape) => {
1851                self.set_focus(None);
1852                return;
1853            }
1854            Key::Named(NamedKey::Space) => {
1855                new_caret = replace_selection(&mut value, " ");
1856                edited = true;
1857            }
1858            // Enter inserts a newline in a textarea; single-line inputs ignore it.
1859            Key::Named(NamedKey::Enter) if self.focused_multiline => {
1860                new_caret = replace_selection(&mut value, "\n");
1861                edited = true;
1862            }
1863            Key::Character(s) => {
1864                let typed: String = s.chars().filter(|c| !c.is_control()).collect();
1865                if !typed.is_empty() {
1866                    new_caret = replace_selection(&mut value, &typed);
1867                    edited = true;
1868                }
1869            }
1870            _ => {}
1871        }
1872
1873        if edited || moved {
1874            // Shift+movement keeps the anchor, extending the selection; anything
1875            // else collapses it to the caret.
1876            let new_anchor = if moved && extend { self.anchor } else { new_caret };
1877            self.scroll_caret_into_view(&model, &value, new_caret);
1878            // Patch the input's value in place (no rebuild) unless `model` is also
1879            // structural; then set the caret on the resulting tree.
1880            if edited {
1881                self.document.apply_edit(&model, &value);
1882            }
1883            self.set_focus_range(Some(Focus {
1884                model,
1885                caret: new_caret,
1886                anchor: new_anchor,
1887                preedit: None,
1888            }));
1889        }
1890    }
1891
1892    /// Ctrl chords inside a focused input: select all, copy, cut, paste. Returns
1893    /// whether the key was one of them, so it isn't also typed as a character:
1894    /// Ctrl+V arrives as `Key::Character("v")`.
1895    fn text_shortcut(&mut self, key: &Key, model: &str) -> bool {
1896        let Key::Character(s) = key else { return false };
1897        // The bodies live in named methods because the selection toolbar runs
1898        // the same four actions from a tap. Two implementations of "cut" would
1899        // drift the moment one of them learned about something the other did
1900        // not.
1901        match s.to_lowercase().as_str() {
1902            "a" => self.select_all_text(model),
1903            "c" => self.copy_selection(),
1904            "x" => self.cut_selection(model),
1905            "v" => self.request_paste(model),
1906            _ => return false,
1907        }
1908        true
1909    }
1910
1911    fn select_all_text(&mut self, model: &str) {
1912        let value = self.document.engine_mut().get_string(model);
1913        self.set_focus_range(Some(Focus {
1914            model: model.to_string(),
1915            caret: value.len(),
1916            anchor: 0,
1917            preedit: None,
1918        }));
1919    }
1920
1921    fn copy_selection(&mut self) {
1922        if let Some(text) = self.selected_text() {
1923            self.clipboard_write(&text);
1924        }
1925    }
1926
1927    fn cut_selection(&mut self, model: &str) {
1928        let Some(text) = self.selected_text() else { return };
1929        self.clipboard_write(&text);
1930        let value = self.document.engine_mut().get_string(model);
1931        let (start, end) = self.selection();
1932        let mut value = value;
1933        value.replace_range(start.min(value.len())..end.min(value.len()), "");
1934        self.document.apply_edit(model, &value);
1935        self.set_focus_range(Some(Focus::at(model, start)));
1936    }
1937
1938    /// Ask for the clipboard's contents and paste them.
1939    ///
1940    /// Native reads it here and pastes immediately. The web cannot: the Clipboard
1941    /// API is a promise, and permission may even be prompted for, so the read is
1942    /// started here and the paste happens later, when [`RuxEvent::WebPaste`]
1943    /// arrives. Both ends meet in [`apply_paste`](Self::apply_paste).
1944    #[cfg(not(target_arch = "wasm32"))]
1945    fn request_paste(&mut self, model: &str) {
1946        if let Some(pasted) = self.clipboard_read() {
1947            self.apply_paste(model, &pasted);
1948        }
1949    }
1950
1951    #[cfg(target_arch = "wasm32")]
1952    fn request_paste(&mut self, _model: &str) {
1953        use wasm_bindgen_futures::JsFuture;
1954
1955        let Some(clipboard) = web_clipboard() else { return };
1956        let promise = clipboard.read_text();
1957        wasm_bindgen_futures::spawn_local(async move {
1958            // A rejection is the ordinary case when the user declines the
1959            // permission prompt, so it is silent rather than a warning: refusing
1960            // to paste is not an error in the document.
1961            let Ok(value) = JsFuture::from(promise).await else { return };
1962            let Some(text) = value.as_string() else { return };
1963            WEB_PROXY.with(|p| {
1964                if let Some(proxy) = p.borrow().as_ref() {
1965                    let _ = proxy.send_event(RuxEvent::WebPaste(text));
1966                }
1967            });
1968        });
1969    }
1970
1971    /// Replace the selection with `pasted`, or insert it at the caret.
1972    fn apply_paste(&mut self, model: &str, pasted: &str) {
1973        // A single-line input takes the first line only, pasting a block
1974        // of text into a one-line field shouldn't smuggle newlines in.
1975        let pasted = if self.focused_multiline {
1976            pasted.replace("\r\n", "\n")
1977        } else {
1978            pasted.lines().next().unwrap_or("").to_string()
1979        };
1980        let value = self.document.engine_mut().get_string(model);
1981        let (start, end) = self.selection();
1982        let mut value = value;
1983        let (start, end) = (start.min(value.len()), end.min(value.len()));
1984        value.replace_range(start..end, &pasted);
1985        let caret = start + pasted.len();
1986        self.document.apply_edit(model, &value);
1987        self.scroll_caret_into_view(model, &value, caret);
1988        self.set_focus_range(Some(Focus::at(model, caret)));
1989    }
1990
1991    /// Keep the caret visible in a scrolling textarea: adjust its scroll offset
1992    /// so the caret *line* sits inside the box. No-op for a single-line input,
1993    /// which has no scroll region; its horizontal equivalent is
1994    /// [`track_caret_x`](Self::track_caret_x), applied once per frame.
1995    fn scroll_caret_into_view(&mut self, model: &str, value: &str, caret: usize) {
1996        let Some(region) = self.focuses.iter().find(|f| f.model == model).cloned() else {
1997            return;
1998        };
1999        let (Some(sid), Some(t)) = (region.scroll_id, &region.text) else {
2000            return;
2001        };
2002        let style = rux_paint::text_style(&t.content);
2003        let (_, cy, ch) = self.text.caret_geometry(value, &style, Some(t.width), caret);
2004        let visible = region.height;
2005        let mut off = self.offsets.get(sid).copied().unwrap_or_default();
2006        if cy < off.y {
2007            off.y = cy;
2008        } else if cy + ch > off.y + visible {
2009            off.y = cy + ch - visible;
2010        }
2011        // The next layout re-clamps this to the content's real max offset.
2012        if let Some(slot) = self.offsets.get_mut(sid) {
2013            slot.y = off.y.max(0.0);
2014        }
2015    }
2016
2017    /// Route a key press. Tab always moves keyboard focus; otherwise a focused
2018    /// text input edits, and a focused button/checkbox/radio/select activates on
2019    /// Space/Enter.
2020    fn on_key(&mut self, key: &Key) {
2021        if let Key::Named(NamedKey::Tab) = key {
2022            self.move_focus(self.shift_held);
2023            return;
2024        }
2025        if self.focused.is_some() {
2026            self.edit_focused(key);
2027            return;
2028        }
2029        if let Some(idx) = self.focus_index {
2030            match key {
2031                Key::Named(NamedKey::Space | NamedKey::Enter) => {
2032                    self.activate_focused(idx);
2033                    return;
2034                }
2035                Key::Named(NamedKey::Escape) => {
2036                    self.focus_index = None;
2037                    self.request_redraw();
2038                    return;
2039                }
2040                _ => {}
2041            }
2042        }
2043        // Nothing focused wants this key: let it scroll the box under the pointer.
2044        self.scroll_key(key);
2045    }
2046
2047    /// Move keyboard focus to the next (or previous) focusable, wrapping around.
2048    fn move_focus(&mut self, backward: bool) {
2049        let n = self.focusables.len();
2050        if n == 0 {
2051            return;
2052        }
2053        let next = match self.focus_index {
2054            Some(i) if backward => (i + n - 1) % n,
2055            Some(i) => (i + 1) % n,
2056            None if backward => n - 1,
2057            None => 0,
2058        };
2059        self.set_keyboard_focus(Some(next));
2060    }
2061
2062    /// Point keyboard focus at `index`. A text input also gets caret editing (with
2063    /// the caret at the end); anything else just gets the focus ring.
2064    fn set_keyboard_focus(&mut self, index: Option<usize>) {
2065        self.focus_index = index;
2066        match index.and_then(|i| self.focusables.get(i)).map(|f| f.kind.clone()) {
2067            Some(FocusKind::Text { model, multiline, .. }) => {
2068                let caret = self.document.engine_mut().get_string(&model).len();
2069                self.focused_multiline = multiline;
2070                self.set_focus(Some((model, caret)));
2071            }
2072            _ => self.set_focus(None),
2073        }
2074        // Tabbing to something below the fold must bring it into view.
2075        self.scroll_focus_into_view();
2076        self.request_redraw();
2077    }
2078
2079    /// Activate the focused element by keyboard: run a button/toggle's handler, or
2080    /// open a select's dropdown.
2081    fn activate_focused(&mut self, index: usize) {
2082        match self.focusables.get(index).map(|f| f.kind.clone()) {
2083            Some(FocusKind::Activate { on_tap }) => {
2084                self.document.apply_handler(&on_tap);
2085                self.request_redraw();
2086            }
2087            Some(FocusKind::Select { model, .. }) => {
2088                self.open_select = Some(model);
2089                self.request_redraw();
2090            }
2091            _ => {}
2092        }
2093    }
2094
2095    /// Focus an input (or clear focus) and tell the document, so the caret and
2096    /// selection paint. Collapses the selection to the caret.
2097    fn set_focus(&mut self, focus: Option<(String, usize)>) {
2098        match focus {
2099            Some((model, caret)) => self.set_focus_range(Some(Focus::at(model, caret))),
2100            None => self.set_focus_range(None),
2101        }
2102    }
2103
2104    /// The full-fidelity focus setter: caret, selection anchor *and* composition.
2105    ///
2106    /// Any caller that is not the IME leaves `preedit` at `None`, which is taken
2107    /// as "whatever was being composed is abandoned": clicking into another
2108    /// field, tabbing away or pressing Escape mid-composition all put the field
2109    /// back the way it was, rather than stranding half-typed text nobody chose.
2110    fn set_focus_range(&mut self, focus: Option<Focus>) {
2111        if focus.as_ref().and_then(|f| f.preedit).is_none() {
2112            self.cancel_preedit();
2113        }
2114        // A different field starts unscrolled: the offset belongs to the text
2115        // being edited, and carrying it over would show the new field's value
2116        // already scrolled to somewhere the caret is not.
2117        let next = focus.as_ref().map(|f| f.model.as_str());
2118        if next != self.focused.as_deref() {
2119            self.text_scroll = 0.0;
2120        }
2121        self.focused = focus.as_ref().map(|f| f.model.clone());
2122        self.caret = focus.as_ref().map(|f| f.caret).unwrap_or(0);
2123        self.anchor = focus.as_ref().map(|f| f.anchor).unwrap_or(0);
2124        self.document.set_focus(focus);
2125        // `:focus` matches on the focused model, so the document needs it too.
2126        let model = self.focused.clone();
2127        self.update_focus_state(model);
2128        self.set_ime_enabled(self.focused.is_some());
2129        self.reset_blink();
2130        self.request_redraw();
2131    }
2132
2133    /// Tell the platform whether to route composition at us.
2134    ///
2135    /// Off by default in winit, which is why Rux had no dead keys and no CJK
2136    /// input on any desktop: the events exist, nothing had ever asked for them.
2137    /// It is toggled with focus rather than left on, because while it is on the
2138    /// compositor may swallow plain keystrokes that the rest of the UI wants.
2139    fn set_ime_enabled(&mut self, on: bool) {
2140        let Some(state) = self.state.as_ref() else { return };
2141        state.window.set_ime_allowed(on);
2142        if on {
2143            self.update_ime_area();
2144        }
2145        #[cfg(target_arch = "wasm32")]
2146        self.sync_web_ime();
2147    }
2148
2149    /// Keep the hidden `<input>` in step with the focused field, and focus or
2150    /// blur it so the phone's keyboard opens and closes with the caret.
2151    ///
2152    /// Only on a touch device: see [`web_is_touch`]. Focusing it has to happen
2153    /// while the browser still considers a user gesture to be in progress, which
2154    /// is why this hangs off the focus change a tap causes rather than off a
2155    /// later frame.
2156    #[cfg(target_arch = "wasm32")]
2157    fn sync_web_ime(&mut self) {
2158        if !web_is_touch() {
2159            return;
2160        }
2161        let Some(el) = web_ime_element() else { return };
2162        let Some(model) = self.focused.clone() else {
2163            let _ = el.blur();
2164            return;
2165        };
2166        let value = self.document.engine_mut().get_string(&model);
2167        // Only touch it when it has actually drifted, which means the change
2168        // came from Rux (a handler, a tap moving the caret) rather than from the
2169        // keyboard. Writing the value or the selection back on every edit would
2170        // fight the browser for the caret mid-word, and the browser is the one
2171        // holding the composition.
2172        let caret16 = byte_to_utf16_index(&value, self.caret.min(value.len())) as u32;
2173        let anchor16 = byte_to_utf16_index(&value, self.anchor.min(value.len())) as u32;
2174        let (start, end, direction) = browser_selection(anchor16, caret16);
2175        if el.value() != value {
2176            el.set_value(&value);
2177            let _ = el.set_selection_range_with_direction(start, end, direction);
2178        } else if el.selection_start().ok().flatten() != Some(start)
2179            || el.selection_end().ok().flatten() != Some(end)
2180        {
2181            // The text is unchanged but the selection moved on our side: a drag
2182            // across the canvas, a double-tap on a word, a handler selecting
2183            // all. The browser has to be told, because its own copy, cut and
2184            // select-all read the hidden input's selection and nothing else.
2185            // Leaving this out is what made copy on a phone act on no text.
2186            let _ = el.set_selection_range_with_direction(start, end, direction);
2187        }
2188        let _ = el.focus();
2189        self.position_web_ime();
2190    }
2191
2192    /// Lay the hidden input over the field it is editing, so that when the
2193    /// keyboard opens the browser scrolls to the right place and any native UI
2194    /// it anchors (the composition popup, the selection handles) lands on the
2195    /// text rather than in the corner of the page.
2196    #[cfg(target_arch = "wasm32")]
2197    fn position_web_ime(&mut self) {
2198        let Some(el) = WEB_IME.with(|c| c.borrow().clone()) else { return };
2199        let Some(canvas) = WEB_CANVAS.with(|c| c.borrow().clone()) else { return };
2200        let Some(model) = self.focused.clone() else { return };
2201        let Some(region) = self.focuses.iter().find(|f| f.model == model) else { return };
2202        // Rux's logical pixels are CSS pixels, and the input is the canvas's
2203        // sibling, so the field's box offsets straight off the canvas's own.
2204        let (ox, oy) = (canvas.offset_left() as f32, canvas.offset_top() as f32);
2205        let style = el.style();
2206        let _ = style.set_property("left", &format!("{}px", ox + region.x));
2207        let _ = style.set_property("top", &format!("{}px", oy + region.y));
2208        let _ = style.set_property("width", &format!("{}px", region.width.max(1.0)));
2209        let _ = style.set_property("height", &format!("{}px", region.height.max(1.0)));
2210    }
2211
2212    /// Apply an edit the browser's soft keyboard made.
2213    ///
2214    /// On a phone the text never arrives as key presses: the browser owns the
2215    /// editing, the composition and the autocorrect, and reports the result as
2216    /// the hidden input's new contents. So this replaces the field's value
2217    /// outright rather than applying a keystroke to it.
2218    #[cfg(target_arch = "wasm32")]
2219    fn apply_web_text(&mut self, value: String, caret: usize, anchor: usize, composing: usize) {
2220        let Some(model) = self.focused.clone() else { return };
2221        // A one-line field never takes a newline, the rule paste already follows.
2222        let value = if self.focused_multiline {
2223            value.replace("\r\n", "\n")
2224        } else {
2225            value.replace(['\n', '\r'], "")
2226        };
2227        let caret = floor_char_boundary(&value, caret.min(value.len()));
2228        let anchor = floor_char_boundary(&value, anchor.min(value.len()));
2229        let preedit = (composing > 0 && composing <= caret)
2230            .then(|| (floor_char_boundary(&value, caret - composing), caret));
2231        // The browser is running the composition, so the shell's own
2232        // composition state stays empty and must not be restored over this.
2233        self.preedit = None;
2234        self.document.apply_edit(&model, &value);
2235        self.scroll_caret_into_view(&model, &value, caret);
2236        self.set_focus_range(Some(Focus { model, caret, anchor, preedit }));
2237    }
2238
2239    /// Park the candidate window under the caret instead of at the window's
2240    /// top-left, so the list of characters to choose from does not cover the text
2241    /// it is being chosen for.
2242    fn update_ime_area(&mut self) {
2243        let Some(window) = self.state.as_ref().map(|s| s.window.clone()) else { return };
2244        let scale = window.scale_factor();
2245        let Some(model) = self.focused.clone() else { return };
2246        let Some(region) = self.focuses.iter().find(|f| f.model == model).cloned() else {
2247            return;
2248        };
2249        let Some(t) = region.text.as_ref() else { return };
2250        let value = self.document.engine_mut().get_string(&model);
2251        let style = rux_paint::text_style(&t.content);
2252        let caret = self.caret.min(value.len());
2253        let (cx, cy, ch) = self.text.caret_geometry(&value, &style, Some(t.width), caret);
2254        window.set_ime_cursor_area(
2255            winit::dpi::LogicalPosition::new((t.x + cx) as f64, (t.y + cy) as f64)
2256                .to_physical::<f64>(scale),
2257            winit::dpi::LogicalSize::new(rux_text::CARET_WIDTH as f64, ch as f64)
2258                .to_physical::<f64>(scale),
2259        );
2260    }
2261
2262    /// Route a composition event from the platform's input method.
2263    ///
2264    /// This is the path that makes dead keys, accents and CJK work. Before it
2265    /// existed the shell read `KeyboardInput` only, so `´` then `e` produced two
2266    /// characters instead of `é`, and there was no way at all to type a language
2267    /// that spells one character out of several keystrokes.
2268    fn on_ime(&mut self, ime: &Ime) {
2269        match ime {
2270            // The method is attached. Nothing to do until text arrives.
2271            Ime::Enabled => {}
2272            Ime::Preedit(text, cursor) => self.set_preedit(text, *cursor),
2273            Ime::Commit(text) => self.commit_text(text),
2274            // The method detached (the window lost focus, the user switched
2275            // keyboards). Half-composed text was never chosen, so it goes back.
2276            Ime::Disabled => {
2277                self.cancel_preedit();
2278                self.request_redraw();
2279            }
2280        }
2281    }
2282
2283    /// Show the text being composed, replacing whatever the last preedit showed.
2284    ///
2285    /// `cursor` is the platform's caret *within* the composition, as a byte
2286    /// range; we take its start, which is where compositors put the insertion
2287    /// point. `None` means it wants the caret after the whole thing.
2288    fn set_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) {
2289        let Some(model) = self.focused.clone() else { return };
2290        let mut value = self.document.engine_mut().get_string(&model);
2291
2292        // Starting a composition lifts out whatever it is going to sit on top
2293        // of, so that abandoning it can put that back.
2294        let composing = match self.preedit.clone() {
2295            Some(p) => p,
2296            None => {
2297                let (start, end) = self.selection();
2298                let (start, end) = (start.min(value.len()), end.min(value.len()));
2299                let replaced = value[start..end].to_string();
2300                value.replace_range(start..end, "");
2301                Preedit { at: start, len: 0, replaced }
2302            }
2303        };
2304
2305        let at = composing.at.min(value.len());
2306        let end = (at + composing.len).min(value.len());
2307        value.replace_range(at..end, text);
2308
2309        // An empty preedit is how a compositor says the composition ended with
2310        // nothing chosen, which is a cancel, not a commit of "".
2311        if text.is_empty() {
2312            value.insert_str(at, &composing.replaced);
2313            let caret = at + composing.replaced.len();
2314            self.preedit = None;
2315            self.document.apply_edit(&model, &value);
2316            self.set_focus_range(Some(Focus::at(model, caret)));
2317            return;
2318        }
2319
2320        let caret = at + cursor.map(|(s, _)| s.min(text.len())).unwrap_or(text.len());
2321        self.preedit = Some(Preedit { at, len: text.len(), replaced: composing.replaced });
2322        self.document.apply_edit(&model, &value);
2323        self.scroll_caret_into_view(&model, &value, caret);
2324        self.set_focus_range(Some(Focus {
2325            model,
2326            caret,
2327            anchor: caret,
2328            preedit: Some((at, at + text.len())),
2329        }));
2330        self.update_ime_area();
2331    }
2332
2333    /// Accept composed text into the field for good.
2334    ///
2335    /// Also the path a plain keystroke takes on platforms whose input method
2336    /// stays in the loop even when nothing is being composed, so it has to
2337    /// behave like typing when there is no composition to replace.
2338    fn commit_text(&mut self, text: &str) {
2339        let Some(model) = self.focused.clone() else { return };
2340        let mut value = self.document.engine_mut().get_string(&model);
2341        let (start, end) = match self.preedit.take() {
2342            Some(p) => {
2343                let at = p.at.min(value.len());
2344                (at, (at + p.len).min(value.len()))
2345            }
2346            None => {
2347                let (s, e) = self.selection();
2348                (s.min(value.len()), e.min(value.len()))
2349            }
2350        };
2351        // A one-line input never takes a newline, the rule paste already follows.
2352        let text = if self.focused_multiline {
2353            text.replace("\r\n", "\n")
2354        } else {
2355            text.lines().next().unwrap_or("").to_string()
2356        };
2357        value.replace_range(start..end, &text);
2358        let caret = start + text.len();
2359        self.document.apply_edit(&model, &value);
2360        self.scroll_caret_into_view(&model, &value, caret);
2361        self.set_focus_range(Some(Focus::at(model, caret)));
2362        self.update_ime_area();
2363    }
2364
2365    /// Abandon a composition, putting the field back exactly as it was before it
2366    /// started. A no-op when nothing is being composed, which is the usual case.
2367    fn cancel_preedit(&mut self) {
2368        let Some(p) = self.preedit.take() else { return };
2369        let Some(model) = self.focused.clone() else { return };
2370        let mut value = self.document.engine_mut().get_string(&model);
2371        let at = p.at.min(value.len());
2372        let end = (at + p.len).min(value.len());
2373        value.replace_range(at..end, &p.replaced);
2374        self.document.apply_edit(&model, &value);
2375    }
2376
2377    /// The focused input's selected byte range, low to high. Empty when there's
2378    /// no selection (`start == end`).
2379    fn selection(&self) -> (usize, usize) {
2380        (self.caret.min(self.anchor), self.caret.max(self.anchor))
2381    }
2382
2383    /// The focused input's selected text, if any.
2384    fn selected_text(&mut self) -> Option<String> {
2385        let model = self.focused.clone()?;
2386        let (start, end) = self.selection();
2387        if start == end {
2388            return None;
2389        }
2390        let value = self.document.engine_mut().get_string(&model);
2391        value.get(start.min(value.len())..end.min(value.len())).map(str::to_string)
2392    }
2393
2394    /// Put `text` on the system clipboard.
2395    #[cfg(not(target_arch = "wasm32"))]
2396    fn clipboard_write(&mut self, text: &str) {
2397        if let Some(cb) = self.clipboard.as_mut() {
2398            if let Err(e) = cb.set_text(text.to_string()) {
2399                eprintln!("rux: clipboard copy failed: {e}");
2400            }
2401        }
2402    }
2403
2404    /// Read the system clipboard. `None` when it's empty, holds non-text, or
2405    /// there's no clipboard at all.
2406    #[cfg(not(target_arch = "wasm32"))]
2407    fn clipboard_read(&mut self) -> Option<String> {
2408        self.clipboard.as_mut()?.get_text().ok()
2409    }
2410
2411    // On the web the clipboard is asynchronous and permission-gated. Writing can
2412    // be fired and forgotten; reading cannot, so it does not go through
2413    // `clipboard_read` at all: see `request_paste`.
2414    #[cfg(target_arch = "wasm32")]
2415    fn clipboard_write(&mut self, text: &str) {
2416        let Some(clipboard) = web_clipboard() else { return };
2417        // The promise is deliberately dropped. A rejection (no permission, not a
2418        // secure context) means the copy did not happen, and there is nothing
2419        // useful to do about it in a UI with no place to report it.
2420        let _ = clipboard.write_text(text);
2421    }
2422
2423    #[cfg(target_arch = "wasm32")]
2424    fn clipboard_read(&mut self) -> Option<String> {
2425        // Unreachable in practice: `request_paste` takes the async path on the
2426        // web. Kept so the native and web shells present the same surface.
2427        None
2428    }
2429
2430    /// Show the caret solid and (re)start the blink cycle. Called on focus and on
2431    /// every edit, so the caret is steady while you type and only blinks at rest.
2432    /// Clearing focus stops the timer entirely, an idle window stays event-driven.
2433    fn reset_blink(&mut self) {
2434        self.caret_visible = true;
2435        self.blink_deadline = self.focused.is_some().then(|| Instant::now() + BLINK);
2436    }
2437
2438    fn request_redraw(&self) {
2439        if let Some(state) = self.state.as_ref() {
2440            state.window.request_redraw();
2441        }
2442    }
2443
2444    fn render(&mut self) {
2445        // Catches the first frame and any resize that arrived without an event
2446        // (hot-reload, scale change); a no-op unless a breakpoint moved.
2447        self.update_viewport();
2448        let caret_visible = self.caret_visible;
2449        // Split borrows so the text engine (used both to measure during layout
2450        // and to draw during paint) doesn't conflict with the render state.
2451        let App {
2452            context,
2453            state,
2454            document,
2455            text,
2456            images,
2457            hits,
2458            focuses,
2459            selects,
2460            focusables,
2461            focus_index,
2462            open_select,
2463            scrolls,
2464            offsets,
2465            states,
2466            overlay_dismissed,
2467            overlay_rect,
2468            caret,
2469            anchor,
2470            text_scroll,
2471            focused,
2472            #[cfg(not(target_arch = "wasm32"))]
2473            path,
2474            ..
2475        } = self;
2476        let Some(state) = state.as_mut() else {
2477            return;
2478        };
2479        let width = state.surface.config.width;
2480        let height = state.surface.config.height;
2481
2482        // Lay out in *logical* pixels so a `16px` font is the same physical size
2483        // on every display, then scale the scene up to the physical surface.
2484        // Without this, everything renders half-size on a 2x screen.
2485        let scale = state.window.scale_factor();
2486        let logical = (width as f64 / scale, height as f64 / scale);
2487
2488        // Layout (text sized via the engine's measure), then paint. Cache the
2489        // hit regions for tap dispatch.
2490        let mut layout = {
2491            let mut measure = |tc: &rux_layout::TextContent, mw: Option<f32>| {
2492                text.measure(&tc.text, &rux_paint::text_style(tc), mw)
2493            };
2494            rux_layout::layout_scrolled(
2495                &document.root,
2496                logical.0 as f32,
2497                logical.1 as f32,
2498                offsets,
2499                &mut measure,
2500            )
2501        };
2502        // Keep offsets in step with the scrollers the new layout actually has, and
2503        // re-clamp them (the content may have shrunk under us). `collect` clamps
2504        // the shift it applies the same way, so doing this before the scrollbars
2505        // are drawn is what keeps a thumb where its content actually is.
2506        offsets.resize(layout.scrolls.len(), Offset::default());
2507        for region in &layout.scrolls {
2508            offsets[region.id] = offsets[region.id].clamp_to(region.max);
2509        }
2510
2511        // Keep the focused single-line input's caret inside its box.
2512        //
2513        // Done here, once per frame, rather than at each place the caret moves:
2514        // typing, arrows, Home/End, a tap, a drag, an IME commit and the
2515        // browser's own keyboard all end up here, and one rule covers them all
2516        // where six call sites would eventually disagree.
2517        let shift = Self::track_caret_x(&layout, focused.as_deref(), *caret, text_scroll, text, document);
2518        if shift != 0.0 {
2519            // Only the focused input has a caret, so this finds exactly one text
2520            // paint. Everything the painter draws for it (glyphs, caret,
2521            // selection, preedit) is placed from this single x, so moving it
2522            // moves them together, and the box's own clip hides the rest.
2523            for paint in layout.paints.iter_mut() {
2524                if let Paint::Text(t) = paint {
2525                    if t.content.caret.is_some() {
2526                        t.x -= shift;
2527                    }
2528                }
2529            }
2530        }
2531
2532        let content = rux_paint::build_scene(&layout.paints, text, images, caret_visible);
2533        state.scene.reset();
2534        state
2535            .scene
2536            .append(&content, Some(Affine::scale(scale)));
2537
2538        // Scrollbars go over the content: they're an overlay on the box's own
2539        // trailing edge, and a scroller clips its children, so they can't be
2540        // painted as part of the subtree.
2541        let bars = scrollbar_paints(&layout.scrolls, offsets);
2542        if !bars.is_empty() {
2543            let scene = rux_paint::build_scene(&bars, text, images, false);
2544            state.scene.append(&scene, Some(Affine::scale(scale)));
2545        }
2546
2547        // A keyboard focus ring, drawn over the content (but under a dropdown).
2548        if let Some(item) = focus_index.and_then(|i| layout.focusables.get(i)) {
2549            let ring = rux_paint::build_scene(&focus_ring(item), text, images, false);
2550            state.scene.append(&ring, Some(Affine::scale(scale)));
2551        }
2552
2553        // The selection toolbar, over the content while something is selected.
2554        // It is the only route to copy and paste on a phone, and on the web at
2555        // all, so it is drawn above the page rather than inside it.
2556        if *caret != *anchor {
2557            if let Some(r) = focused
2558                .as_deref()
2559                .and_then(|m| layout.focuses.iter().find(|f| f.model == m))
2560            {
2561                let strip = toolbar_paints(
2562                    (r.x, r.y, r.width, r.height),
2563                    (logical.0 as f32, logical.1 as f32),
2564                );
2565                let scene = rux_paint::build_scene(&strip, text, images, false);
2566                state.scene.append(&scene, Some(Affine::scale(scale)));
2567            }
2568        }
2569
2570        // An open `select` draws its dropdown on top of everything else.
2571        if let Some(model) = open_select.clone() {
2572            if let Some(sel) = layout.selects.iter().find(|s| s.model == model) {
2573                let value = document.engine_mut().get_string(&model);
2574                let overlay = dropdown_paints(sel, &value);
2575                let scene = rux_paint::build_scene(&overlay, text, images, false);
2576                state.scene.append(&scene, Some(Affine::scale(scale)));
2577            }
2578        }
2579
2580        // The dev overlay goes last, above everything including a dropdown: if the
2581        // document is broken, that is the most important thing on screen.
2582        let diagnostics = document.diagnostics();
2583        // Dismissal is remembered against the diagnostics it was for, so fixing
2584        // one thing and breaking another brings the panel straight back rather
2585        // than leaving it hidden until restart.
2586        *overlay_rect = None;
2587        if overlay_visible(diagnostics, overlay_dismissed.as_ref()) {
2588            #[cfg(not(target_arch = "wasm32"))]
2589            let panel = overlay_paints(diagnostics, path, logical.0 as f32);
2590            // No file on the web, so the overlay titles itself after the editor.
2591            #[cfg(target_arch = "wasm32")]
2592            let panel =
2593                overlay_paints(diagnostics, Path::new("playground.rux"), logical.0 as f32);
2594            if let Some(panel) = panel {
2595                let scene = rux_paint::build_scene(&panel.paints, text, images, false);
2596                state.scene.append(&scene, Some(Affine::scale(scale)));
2597                *overlay_rect = Some(panel.rect);
2598            }
2599        }
2600
2601        // Publish the accessibility tree for this frame. `update_if_active` skips
2602        // the work entirely unless assistive technology is attached, so the common
2603        // case pays only for the (already computed) node list.
2604        // Native only: the web already has an accessibility tree of its own, and
2605        // accesskit_winit has no adapter for it.
2606        #[cfg(not(target_arch = "wasm32"))]
2607        {
2608            let window_title = state.window.title();
2609            state.access.update_if_active(|| {
2610                access_tree(&layout.access, focused.as_deref(), scale, &window_title)
2611            });
2612        }
2613
2614        *hits = layout.hits;
2615        *focuses = layout.focuses;
2616        *selects = layout.selects;
2617        // Keep the focus index in range if the new layout has fewer focusables.
2618        if focus_index.map(|i| i >= layout.focusables.len()).unwrap_or(false) {
2619            *focus_index = None;
2620        }
2621        *focusables = layout.focusables;
2622        *scrolls = layout.scrolls;
2623        *states = layout.states;
2624
2625        let device_handle = &context.devices[state.surface.dev_id];
2626        // wgpu 29 reports acquisition as a status enum. A timeout/occluded frame
2627        // is normal (minimized window, compositor hiccup), skip it and repaint
2628        // on the next event rather than tearing the app down.
2629        let surface_texture = match state.surface.surface.get_current_texture() {
2630            CurrentSurfaceTexture::Success(t) | CurrentSurfaceTexture::Suboptimal(t) => t,
2631            other => {
2632                eprintln!("rux: skipping frame ({other:?})");
2633                return;
2634            }
2635        };
2636        // vello renders with a compute shader, so it can't write the surface
2637        // texture directly (the surface is Bgra8, the storage target Rgba8).
2638        // render_to_surface used to hide this; in 0.9 we render into the
2639        // RenderSurface's intermediate target and blit that onto the surface.
2640        state
2641            .renderer
2642            .render_to_texture(
2643                &device_handle.device,
2644                &device_handle.queue,
2645                &state.scene,
2646                &state.surface.target_view,
2647                &RenderParams {
2648                    base_color: BG,
2649                    width,
2650                    height,
2651                    antialiasing_method: AaConfig::Area,
2652                },
2653            )
2654            .expect("render to texture");
2655
2656        let mut encoder = device_handle
2657            .device
2658            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2659                label: Some("rux: blit to surface"),
2660            });
2661        let view = surface_texture
2662            .texture
2663            .create_view(&wgpu::TextureViewDescriptor::default());
2664        state
2665            .surface
2666            .blitter
2667            .copy(&device_handle.device, &mut encoder, &state.surface.target_view, &view);
2668        device_handle.queue.submit([encoder.finish()]);
2669
2670        surface_texture.present();
2671
2672        // The hidden input is placed from `self.focuses`, which only becomes the
2673        // *current* layout here. Placing it during the focus change instead
2674        // would use the previous frame's geometry, so it sat one edit behind
2675        // whenever an edit moved the field it covers.
2676        #[cfg(target_arch = "wasm32")]
2677        self.position_web_ime();
2678    }
2679}
2680
2681/// Build the vello renderer for a freshly created surface. Shared by both
2682/// platforms so they cannot drift in their renderer options.
2683fn make_renderer(context: &RenderContext, surface: &RenderSurface<'static>) -> Renderer {
2684    Renderer::new(
2685        &context.devices[surface.dev_id].device,
2686        RendererOptions {
2687            use_cpu: false,
2688            antialiasing_support: AaSupport::area_only(),
2689            num_init_threads: NonZeroUsize::new(1),
2690            pipeline_cache: None,
2691        },
2692    )
2693    .expect("create renderer")
2694}
2695
2696impl ApplicationHandler<RuxEvent> for App {
2697    #[cfg(not(target_arch = "wasm32"))]
2698    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
2699        if self.state.is_some() {
2700            return;
2701        }
2702
2703        let title = format!(
2704            "Rux · {}",
2705            self.path
2706                .file_name()
2707                .map(|n| n.to_string_lossy().into_owned())
2708                .unwrap_or_else(|| "M2".into())
2709        );
2710        // Created hidden: the accessibility adapter must exist before the window
2711        // is first shown, or it panics. Revealed again once the adapter is up.
2712        let attributes = Window::default_attributes()
2713            .with_title(title)
2714            .with_visible(false)
2715            .with_inner_size(winit::dpi::LogicalSize::new(420.0, 640.0));
2716        let window = Arc::new(event_loop.create_window(attributes).expect("create window"));
2717        let access = accesskit_winit::Adapter::with_event_loop_proxy(
2718            event_loop,
2719            &window,
2720            self.proxy.clone(),
2721        );
2722        window.set_visible(true);
2723
2724        let size = window.inner_size();
2725        let surface = pollster::block_on(self.context.create_surface(
2726            window.clone(),
2727            size.width.max(1),
2728            size.height.max(1),
2729            wgpu::PresentMode::AutoVsync,
2730        ))
2731        .expect("create surface");
2732
2733        let renderer = make_renderer(&self.context, &surface);
2734        self.state = Some(RenderState {
2735            window,
2736            surface,
2737            renderer,
2738            scene: Scene::new(),
2739            access,
2740        });
2741        self.request_redraw();
2742    }
2743
2744    /// The web version of the same thing. `create_surface` is async and there is
2745    /// no blocking on a browser's main thread, so setup runs as a task: it builds
2746    /// its own `RenderContext` (cheap, and sidesteps borrowing `self` across an
2747    /// await), parks the result in `self.pending`, and wakes the loop with
2748    /// `SurfaceReady`.
2749    #[cfg(target_arch = "wasm32")]
2750    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
2751        use winit::platform::web::WindowAttributesExtWebSys;
2752
2753        if self.state.is_some() || self.starting {
2754            return;
2755        }
2756        self.starting = true;
2757
2758        let canvas = WEB_CANVAS.with(|c| c.borrow().clone());
2759        let (lw, lh) = WEB_SIZE.with(|s| *s.borrow());
2760        let attributes = Window::default_attributes()
2761            .with_canvas(canvas)
2762            .with_inner_size(winit::dpi::LogicalSize::new(lw, lh));
2763        let window = Arc::new(event_loop.create_window(attributes).expect("create window"));
2764
2765        let pending = self.pending.clone();
2766        let proxy = WEB_PROXY.with(|p| p.borrow().clone()).expect("event loop proxy");
2767
2768        // `inner_size()` is 0×0 until the resize observer has fired at least
2769        // once, which has usually not happened yet. Fall back to the size we
2770        // just asked for rather than configuring a 1×1 surface.
2771        let mut size = window.inner_size();
2772        if size.width == 0 || size.height == 0 {
2773            size = winit::dpi::LogicalSize::new(lw, lh).to_physical(window.scale_factor());
2774        }
2775        web_sys::console::log_1(
2776            &format!(
2777                "rux: canvas {lw}x{lh} css, surface {}x{} physical, dpr {}",
2778                size.width,
2779                size.height,
2780                window.scale_factor()
2781            )
2782            .into(),
2783        );
2784
2785        wasm_bindgen_futures::spawn_local(async move {
2786            let mut context = RenderContext::new();
2787            let surface = context
2788                .create_surface(
2789                    window.clone(),
2790                    size.width.max(1),
2791                    size.height.max(1),
2792                    wgpu::PresentMode::AutoVsync,
2793                )
2794                .await
2795                .expect("create surface");
2796            let renderer = make_renderer(&context, &surface);
2797
2798            *pending.borrow_mut() = Some((
2799                context,
2800                RenderState { window, surface, renderer, scene: Scene::new() },
2801            ));
2802            let _ = proxy.send_event(RuxEvent::SurfaceReady);
2803        });
2804    }
2805
2806    fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: RuxEvent) {
2807        match event {
2808            #[cfg(not(target_arch = "wasm32"))]
2809            RuxEvent::Reload => self.reload(),
2810
2811            // The device that owns the surface lives in the context the task
2812            // built, so that context replaces the placeholder one here.
2813            #[cfg(target_arch = "wasm32")]
2814            RuxEvent::SurfaceReady => {
2815                if let Some((context, state)) = self.pending.borrow_mut().take() {
2816                    self.context = context;
2817                    self.state = Some(state);
2818                    self.starting = false;
2819                }
2820            }
2821
2822            #[cfg(target_arch = "wasm32")]
2823            RuxEvent::SetSource(source) => self.set_source(source),
2824
2825            #[cfg(target_arch = "wasm32")]
2826            RuxEvent::WebText { value, caret, anchor, composing } => {
2827                self.apply_web_text(value, caret, anchor, composing)
2828            }
2829
2830            // The clipboard read started by a paste has come back. The field may
2831            // have lost focus in the meantime, in which case there is nowhere to
2832            // put it and dropping it is right.
2833            #[cfg(target_arch = "wasm32")]
2834            RuxEvent::WebPaste(text) => {
2835                if let Some(model) = self.focused.clone() {
2836                    self.apply_paste(&model, &text);
2837                    self.sync_web_ime();
2838                    self.request_redraw();
2839                }
2840            }
2841
2842            // Asking winit to resize restyles the canvas and then reports a
2843            // `Resized`, which reconfigures the surface through the same path a
2844            // desktop window resize takes. Going through winit rather than
2845            // setting CSS directly is what keeps the canvas's displayed size and
2846            // its surface size equal: taps are hit-tested against that geometry,
2847            // so any divergence misaligns every tap by the ratio.
2848            #[cfg(target_arch = "wasm32")]
2849            RuxEvent::Resize(w, h) => {
2850                if let Some(state) = self.state.as_ref() {
2851                    let _ = state
2852                        .window
2853                        .request_inner_size(winit::dpi::LogicalSize::new(w.max(1.0), h.max(1.0)));
2854                }
2855            }
2856
2857            #[cfg(not(target_arch = "wasm32"))]
2858            RuxEvent::Access(event) => {
2859                match event.window_event {
2860                    // Assistive technology just attached: it needs the whole tree,
2861                    // which the next frame publishes.
2862                    accesskit_winit::WindowEvent::InitialTreeRequested => {}
2863                    // It asked to focus or activate something. Our own focus model
2864                    // drives the app, so the tree is simply re-published; wiring
2865                    // these to real actions is the next slice.
2866                    accesskit_winit::WindowEvent::ActionRequested(_) => {}
2867                    accesskit_winit::WindowEvent::AccessibilityDeactivated => {}
2868                }
2869                self.request_redraw();
2870                return;
2871            }
2872        }
2873        self.request_redraw();
2874    }
2875
2876    fn window_event(
2877        &mut self,
2878        event_loop: &ActiveEventLoop,
2879        _id: WindowId,
2880        event: WindowEvent,
2881    ) {
2882        // The adapter needs to see window events (focus, resize) to keep the
2883        // platform's view of the window in step. It observes; we still handle
2884        // every event ourselves below.
2885        #[cfg(not(target_arch = "wasm32"))]
2886        if let Some(state) = self.state.as_mut() {
2887            state.access.process_event(&state.window, &event);
2888        }
2889        match event {
2890            WindowEvent::CloseRequested => event_loop.exit(),
2891            WindowEvent::Resized(size) => {
2892                if let Some(state) = self.state.as_mut() {
2893                    self.context.resize_surface(
2894                        &mut state.surface,
2895                        size.width.max(1),
2896                        size.height.max(1),
2897                    );
2898                }
2899                self.update_viewport();
2900                self.request_redraw();
2901            }
2902            WindowEvent::MouseWheel { delta, .. } => {
2903                // A line of wheel travel is ~ one line of text.
2904                let (dx, dy) = match delta {
2905                    MouseScrollDelta::LineDelta(x, y) => (x * LINE, y * LINE),
2906                    MouseScrollDelta::PixelDelta(p) => {
2907                        let scale = self.scale();
2908                        ((p.x / scale) as f32, (p.y / scale) as f32)
2909                    }
2910                };
2911                // Shift+wheel scrolls horizontally, the platform convention for a
2912                // wheel with only one axis.
2913                let (dx, dy) = if self.shift_held && dx == 0.0 { (dy, 0.0) } else { (dx, dy) };
2914                self.scroll_at(self.pointer, -dx, -dy);
2915            }
2916            WindowEvent::CursorMoved { position, .. } => {
2917                self.pointer = (position.x, position.y);
2918                if self.bar_drag.is_some() {
2919                    self.drag_scrollbar(self.pointer);
2920                } else if self.text_drag {
2921                    self.drag_text(self.pointer);
2922                } else {
2923                    self.update_cursor();
2924                    self.update_pointer_state();
2925                }
2926            }
2927            // The pointer left the window entirely, no CursorMoved follows, so
2928            // hover/active have to be dropped here or they stay lit.
2929            WindowEvent::CursorLeft { .. } => self.clear_pointer_state(),
2930            // Touch follows the same path as the mouse: press, drag, release.
2931            // It used to only scroll, which meant a finger could never tap
2932            // anything. That went unnoticed because there was no touch hardware
2933            // to try it on, and it is the first thing someone on a phone does.
2934            //
2935            // The one behaviour touch does *not* share: dragging on content that
2936            // is neither a scrollbar nor text scrolls that content directly. The
2937            // finger stays on the pixel it grabbed, so the content follows it and
2938            // the offset moves the other way.
2939            WindowEvent::Touch(touch) => {
2940                let at = (touch.location.x, touch.location.y);
2941                let scale = self.scale();
2942                let here = ((at.0 / scale) as f32, (at.1 / scale) as f32);
2943                match touch.phase {
2944                    TouchPhase::Started => {
2945                        // There is no hover on a touchscreen, so the pointer only
2946                        // exists while a finger is down and has to be set here.
2947                        // Every helper below reads it.
2948                        self.pointer = at;
2949                        self.touch = Some(here);
2950                        // Same order as the mouse: the dev overlay is above
2951                        // everything, so a finger on it arms a dismiss rather
2952                        // than reaching the app it is covering. The short-circuit
2953                        // is load-bearing, `press_scrollbar` and `press_text`
2954                        // start a drag as a side effect and must not run when the
2955                        // panel took the press.
2956                        if self.overlay_covers_physical(at)
2957                            || (!self.press_scrollbar(at) && !self.press_text_touch(at))
2958                        {
2959                            self.press = Some(at);
2960                        }
2961                    }
2962                    TouchPhase::Moved => {
2963                        self.pointer = at;
2964                        if self.bar_drag.is_some() {
2965                            self.drag_scrollbar(at);
2966                        } else if let Some(state) = self.touch_text {
2967                            // The finger is on text. Which of the three gestures
2968                            // this is depends on whether the press had time to
2969                            // become a long one before it moved.
2970                            let from = match state {
2971                                TouchText::Pending { at, .. } => at,
2972                                _ => at,
2973                            };
2974                            let moved = (at.0 - from.0).hypot(at.1 - from.1);
2975                            let next = touch_text_after_move(state, moved);
2976                            self.touch_text = Some(next);
2977                            match next {
2978                                TouchText::Selecting => self.drag_text(at),
2979                                TouchText::Caret => self.drag_caret(at),
2980                                // Still resting inside the slop: the press has
2981                                // not decided yet, so nothing moves.
2982                                TouchText::Pending { .. } => {}
2983                            }
2984                        } else if let Some((lx, ly)) = self.touch.replace(here) {
2985                            self.scroll_at(at, lx - here.0, ly - here.1);
2986                        }
2987                    }
2988                    TouchPhase::Ended => {
2989                        self.pointer = at;
2990                        self.touch = None;
2991                        if self.bar_drag.take().is_some() {
2992                            return;
2993                        }
2994                        if std::mem::take(&mut self.text_drag) {
2995                            return;
2996                        }
2997                        // A finger lifting off text has already had its effect,
2998                        // whichever gesture it turned out to be, and must not
2999                        // also reach the app as a tap.
3000                        if self.touch_text.take().is_some() {
3001                            return;
3002                        }
3003                        // A finger wanders more than a mouse, but the slop that
3004                        // separates a tap from a drag is the same idea.
3005                        if let Some((sx, sy)) = self.press.take() {
3006                            if (at.0 - sx).hypot(at.1 - sy) <= TAP_SLOP {
3007                                self.dispatch_tap(at.0, at.1);
3008                            }
3009                        }
3010                    }
3011                    TouchPhase::Cancelled => {
3012                        self.touch = None;
3013                        self.press = None;
3014                        self.bar_drag = None;
3015                        self.text_drag = false;
3016                        // Dropping this also disarms a pending long press, so a
3017                        // cancelled touch cannot select a word after the fact.
3018                        self.touch_text = None;
3019                    }
3020                }
3021            }
3022            WindowEvent::ModifiersChanged(mods) => {
3023                self.shift_held = mods.state().shift_key();
3024                self.ctrl_held = mods.state().control_key();
3025            }
3026            WindowEvent::Ime(ime) => self.on_ime(&ime),
3027            WindowEvent::KeyboardInput { event, .. } => {
3028                // While a composition is running the input method owns the
3029                // keyboard: the same keystrokes also arrive here, and acting on
3030                // them would type the letters twice, once raw and once composed.
3031                if event.state == ElementState::Pressed && self.preedit.is_none() {
3032                    self.on_key(&event.logical_key);
3033                }
3034            }
3035            WindowEvent::MouseInput {
3036                state: ElementState::Pressed,
3037                button: MouseButton::Left,
3038                ..
3039            } => {
3040                // A press on a scrollbar thumb belongs to the bar, and a press in
3041                // an input starts a text selection: neither becomes a tap on the
3042                // content under it. A press on the dev overlay is none of those,
3043                // it just arms the tap that dismisses it.
3044                if self.overlay_covers_physical(self.pointer) {
3045                    self.press = Some(self.pointer);
3046                } else if !self.press_scrollbar(self.pointer) && !self.press_text(self.pointer) {
3047                    self.press = Some(self.pointer);
3048                    // `:active` holds from press to release.
3049                    self.update_pointer_state();
3050                }
3051            }
3052            WindowEvent::MouseInput {
3053                state: ElementState::Released,
3054                button: MouseButton::Left,
3055                ..
3056            } => {
3057                if self.bar_drag.take().is_some() {
3058                    self.update_cursor();
3059                    return;
3060                }
3061                if std::mem::take(&mut self.text_drag) {
3062                    return;
3063                }
3064                if let Some((sx, sy)) = self.press.take() {
3065                    // Release ends `:active`, before the tap runs, so a handler
3066                    // that restructures the tree doesn't leave a pressed node behind.
3067                    self.update_pointer_state();
3068                    let (px, py) = self.pointer;
3069                    if (px - sx).hypot(py - sy) <= TAP_SLOP {
3070                        self.dispatch_tap(px, py);
3071                    }
3072                }
3073            }
3074            // Event-driven: we only paint in response to a redraw request, which
3075            // is issued on resume, resize, reload, and tap, not every frame.
3076            WindowEvent::RedrawRequested => self.render(),
3077            _ => {}
3078        }
3079    }
3080
3081    /// The only clock in an otherwise event-driven loop: while an input is
3082    /// focused, wake every `BLINK` to toggle the caret. With no focus the
3083    /// deadline is `None`, so we wait indefinitely for the next real event.
3084    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
3085        // A resting finger is the second clock, and the reason this is not just
3086        // the blink any more: nothing arrives to say a press has gone on long
3087        // enough, so the deadline has to be waited on and checked here.
3088        if let Some(TouchText::Pending { at, deadline }) = self.touch_text {
3089            if Instant::now() >= deadline {
3090                // Whether or not a word was there to take, the press has
3091                // resolved: it must not stay pending and fire again later.
3092                self.touch_text = Some(TouchText::Selecting);
3093                if self.select_word_at(at) {
3094                    self.request_redraw();
3095                }
3096            }
3097        }
3098
3099        if let Some(deadline) = self.blink_deadline {
3100            if Instant::now() >= deadline {
3101                self.caret_visible = !self.caret_visible;
3102                self.blink_deadline = Some(Instant::now() + BLINK);
3103                self.request_redraw();
3104            }
3105        }
3106
3107        // Wake for whichever clock is due first. With neither running, wait
3108        // indefinitely for a real event, as before.
3109        let long_press = match self.touch_text {
3110            Some(TouchText::Pending { deadline, .. }) => Some(deadline),
3111            _ => None,
3112        };
3113        match [self.blink_deadline, long_press].into_iter().flatten().min() {
3114            Some(next) => event_loop.set_control_flow(ControlFlow::WaitUntil(next)),
3115            None => event_loop.set_control_flow(ControlFlow::Wait),
3116        }
3117    }
3118}
3119
3120// ── Web entry point ──────────────────────────────────────────────────────────
3121//
3122// The browser drives the same `App` as the desktop: same input handling, same
3123// focus and caret logic, same painter. Only the three things a browser does not
3124// have are different, no file watcher (the host page pushes source instead), no
3125// blocking on the main thread (surface setup is a task), and no OS clipboard.
3126//
3127// Two values have to outlive the call that creates them and be reachable from
3128// inside `resumed` and from later JS calls, so they live in thread-locals. That
3129// is sound here in a way it would not be natively: wasm is single-threaded, and
3130// `spawn_app` hands the loop to the browser rather than returning.
3131
3132#[cfg(target_arch = "wasm32")]
3133thread_local! {
3134    /// The canvas the host page gave us, taken by `resumed`.
3135    static WEB_CANVAS: RefCell<Option<web_sys::HtmlCanvasElement>> = const { RefCell::new(None) };
3136    /// Kept so the surface task, and `set_source`, can wake the event loop.
3137    static WEB_PROXY: RefCell<Option<winit::event_loop::EventLoopProxy<RuxEvent>>> =
3138        const { RefCell::new(None) };
3139    /// The canvas's CSS size at boot, in logical pixels.
3140    ///
3141    /// Not a convenience, it is load-bearing. winit's web backend leaves a
3142    /// window's `current_size` at **zero** until a `ResizeObserver` fires, and it
3143    /// only styles the canvas at all when `inner_size` was requested. Ask a
3144    /// freshly created window for its size and you get 0×0, configure a surface
3145    /// at that, and wgpu sets the canvas backing store to 1×1, which collapses
3146    /// the element to a one-pixel strip that then never resizes, because there is
3147    /// no longer any size change to observe. So the size is captured from the DOM
3148    /// up front and used for both the window attributes and the first surface.
3149    static WEB_SIZE: RefCell<(f64, f64)> = const { RefCell::new((420.0, 640.0)) };
3150    /// The hidden `<input>` that exists purely to be focusable.
3151    ///
3152    /// A browser raises a phone's on-screen keyboard for a focused editable DOM
3153    /// element and for nothing else. Rux's fields are painted inside a
3154    /// `<canvas>`, which the browser knows nothing about, so before this there
3155    /// was no way to type into one on a phone at all: tapping a field focused it
3156    /// inside the runtime and the keyboard never came up.
3157    ///
3158    /// It is a real input holding the real text rather than a bare event sink,
3159    /// because that hands composition, autocorrect, dictation and the keyboard's
3160    /// own backspace to the browser, which already does all of it properly. The
3161    /// shell reads the value back out and copies it into the bound signal.
3162    static WEB_IME: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
3163    /// Byte length of the composition in flight in that input, `0` when none.
3164    static WEB_COMPOSING: RefCell<usize> = const { RefCell::new(0) };
3165}
3166
3167/// Whether this is a touch-first device, where the keyboard has to be summoned.
3168///
3169/// The hidden input is deliberately *not* used on a pointer-driven browser: it
3170/// takes DOM focus away from the canvas, and winit's web backend listens for
3171/// keys on the canvas, so focusing it there would trade a working desktop
3172/// keyboard for one that is not needed.
3173#[cfg(target_arch = "wasm32")]
3174fn web_is_touch() -> bool {
3175    web_sys::window()
3176        .and_then(|w| w.match_media("(pointer: coarse)").ok().flatten())
3177        .map(|m| m.matches())
3178        .unwrap_or(false)
3179}
3180
3181/// The browser's clipboard, when there is one.
3182///
3183/// Absent outside a secure context, which is also where WebGPU is absent, so in
3184/// practice this only fails on a page that could not have rendered anyway.
3185#[cfg(target_arch = "wasm32")]
3186fn web_clipboard() -> Option<web_sys::Clipboard> {
3187    Some(web_sys::window()?.navigator().clipboard())
3188}
3189
3190/// The hidden input, created and wired on first use.
3191#[cfg(target_arch = "wasm32")]
3192fn web_ime_element() -> Option<web_sys::HtmlInputElement> {
3193    use wasm_bindgen::JsCast;
3194    use wasm_bindgen::prelude::Closure;
3195
3196    if let Some(el) = WEB_IME.with(|c| c.borrow().clone()) {
3197        return Some(el);
3198    }
3199    let canvas = WEB_CANVAS.with(|c| c.borrow().clone())?;
3200    let document = web_sys::window()?.document()?;
3201    let el: web_sys::HtmlInputElement =
3202        document.create_element("input").ok()?.dyn_into().ok()?;
3203
3204    el.set_type("text");
3205    // Turn off every helper that would rewrite what is typed behind our back.
3206    // Autocorrect on a phone is welcome inside a text field, but capitalising
3207    // the first letter of a password or a code is not, and Rux has no way yet
3208    // to say which a field is.
3209    let _ = el.set_attribute("autocomplete", "off");
3210    let _ = el.set_attribute("autocapitalize", "off");
3211    let _ = el.set_attribute("autocorrect", "off");
3212    let _ = el.set_attribute("spellcheck", "false");
3213    let _ = el.set_attribute("aria-hidden", "true");
3214    // Invisible, but genuinely present and laid out over the field it is
3215    // editing: `display: none` or `visibility: hidden` cannot take focus, and an
3216    // element parked off-screen makes the browser scroll to it when the keyboard
3217    // opens. `pointer-events: none` keeps taps going to the canvas, so tapping
3218    // to move the caret still works; focus is only ever set programmatically.
3219    // The 16px floor is what stops iOS Safari zooming the page in on focus.
3220    let _ = el.set_attribute(
3221        "style",
3222        "position: absolute; opacity: 0; pointer-events: none; z-index: 1; \
3223         border: 0; padding: 0; margin: 0; background: transparent; \
3224         color: transparent; caret-color: transparent; font-size: 16px; \
3225         width: 1px; height: 1px; left: 0; top: 0;",
3226    );
3227
3228    // The canvas's parent is the positioned box the canvas itself sits in, so
3229    // placing the input there lets both be positioned in the same coordinates.
3230    let parent = canvas.parent_element()?;
3231    parent.append_child(&el).ok()?;
3232
3233    // Every path that changes the text ends in an `input` event, including
3234    // composition, dictation, autocorrect and the keyboard's own backspace, so
3235    // one listener covers all of them and no key mapping is needed.
3236    let on_input = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
3237        if let Some(target) = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()) {
3238            web_send_text(&target);
3239        }
3240    });
3241    let _ = el.add_event_listener_with_callback("input", on_input.as_ref().unchecked_ref());
3242    on_input.forget();
3243
3244    // Composition needs its own listeners only to know how much of the tail is
3245    // still provisional, so the runtime can underline it the way the desktop
3246    // does. The text itself already arrives through `input`.
3247    let on_comp = Closure::<dyn FnMut(web_sys::CompositionEvent)>::new(
3248        move |event: web_sys::CompositionEvent| {
3249            let composing = match event.type_().as_str() {
3250                "compositionend" => 0,
3251                _ => event.data().unwrap_or_default().len(),
3252            };
3253            WEB_COMPOSING.with(|c| *c.borrow_mut() = composing);
3254            if let Some(target) =
3255                event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
3256            {
3257                web_send_text(&target);
3258            }
3259        },
3260    );
3261    for name in ["compositionstart", "compositionupdate", "compositionend"] {
3262        let _ = el.add_event_listener_with_callback(name, on_comp.as_ref().unchecked_ref());
3263    }
3264    on_comp.forget();
3265
3266    WEB_IME.with(|c| *c.borrow_mut() = Some(el.clone()));
3267    Some(el)
3268}
3269
3270/// Push the hidden input's contents at the event loop.
3271#[cfg(target_arch = "wasm32")]
3272fn web_send_text(el: &web_sys::HtmlInputElement) {
3273    let value = el.value();
3274    // `selection_start` is in UTF-16 code units, which is not where Rux counts
3275    // from: it indexes strings by byte. Converting through the prefix keeps a
3276    // caret after an emoji or a CJK character in the right place instead of
3277    // several bytes short.
3278    let start16 = el.selection_start().ok().flatten().unwrap_or(0) as usize;
3279    let end16 = el.selection_end().ok().flatten().map_or(start16, |v| v as usize);
3280    // `selectionStart`/`End` are ordered, so on their own they cannot say which
3281    // end the caret is at. `selectionDirection` is what distinguishes a
3282    // selection dragged leftwards from the same range dragged rightwards, and
3283    // getting it wrong makes Shift+arrow extend from the wrong end afterwards.
3284    let backward = el.selection_direction().ok().flatten().as_deref() == Some("backward");
3285    let (anchor16, caret16) = rux_selection(start16, end16, backward);
3286    let caret = utf16_to_byte_index(&value, caret16);
3287    let anchor = utf16_to_byte_index(&value, anchor16);
3288    let composing = WEB_COMPOSING.with(|c| *c.borrow()).min(caret);
3289    WEB_PROXY.with(|p| {
3290        if let Some(proxy) = p.borrow().as_ref() {
3291            let _ = proxy.send_event(RuxEvent::WebText { value, caret, anchor, composing });
3292        }
3293    });
3294}
3295
3296// The caret arithmetic between a browser and Rux, kept out of the wasm cfg so
3297// it can be tested on any target. A browser counts a caret in UTF-16 code units
3298// and Rux indexes strings by bytes, and the two only agree on pure ASCII: an
3299// emoji is 4 bytes and 2 code units, a CJK character 3 bytes and 1. Getting this
3300// wrong does not misplace the caret slightly, it panics on the first slice that
3301// lands inside a character, so it is worth testing directly.
3302//
3303// Compiled for the web, which is the only caller, and for tests, which are the
3304// reason it is not simply inside the wasm module.
3305
3306/// Rux's `(anchor, caret)` as the browser's `(start, end, direction)`.
3307///
3308/// Rux stores a selection as two ends where the caret is the moving one. A DOM
3309/// input stores an ordered range plus a direction, so the caret's end is only
3310/// recoverable from `selectionDirection`. Mapping the two is pure arithmetic and
3311/// lives here so it can be tested without a browser.
3312#[cfg(any(target_arch = "wasm32", test))]
3313fn browser_selection(anchor: u32, caret: u32) -> (u32, u32, &'static str) {
3314    if anchor <= caret {
3315        (anchor, caret, "forward")
3316    } else {
3317        (caret, anchor, "backward")
3318    }
3319}
3320
3321/// The inverse: the browser's ordered range and direction as Rux's ends.
3322///
3323/// A collapsed range is reported `"none"` rather than a direction, which lands
3324/// on the forward arm and gives `anchor == caret`, meaning nothing selected.
3325/// That is the same thing Rux means by it.
3326#[cfg(any(target_arch = "wasm32", test))]
3327fn rux_selection(start: usize, end: usize, backward: bool) -> (usize, usize) {
3328    if backward {
3329        (end, start)
3330    } else {
3331        (start, end)
3332    }
3333}
3334
3335/// Byte index of the character boundary at or before `units` UTF-16 code units
3336/// into `s`.
3337///
3338/// "At or before" matters for the one index that has no byte equivalent: the
3339/// middle of a surrogate pair. Rounding down puts the caret in front of the
3340/// character, which is the same direction [`floor_char_boundary`] rounds, so a
3341/// caret can never appear to jump over an emoji depending on which conversion it
3342/// happened to go through.
3343#[cfg(any(target_arch = "wasm32", test))]
3344fn utf16_to_byte_index(s: &str, units: usize) -> usize {
3345    let mut seen = 0;
3346    for (byte, ch) in s.char_indices() {
3347        if seen >= units {
3348            return byte;
3349        }
3350        let next = seen + ch.len_utf16();
3351        if next > units {
3352            return byte;
3353        }
3354        seen = next;
3355    }
3356    s.len()
3357}
3358
3359/// The inverse: how many UTF-16 code units precede byte index `byte` in `s`.
3360#[cfg(any(target_arch = "wasm32", test))]
3361fn byte_to_utf16_index(s: &str, byte: usize) -> usize {
3362    s[..floor_char_boundary(s, byte)].chars().map(char::len_utf16).sum()
3363}
3364
3365/// Round `index` down to a character boundary, so a caret that arrives inside a
3366/// character is pulled back to its start rather than left to panic a later slice.
3367#[cfg(any(target_arch = "wasm32", test))]
3368fn floor_char_boundary(s: &str, mut index: usize) -> usize {
3369    index = index.min(s.len());
3370    while index > 0 && !s.is_char_boundary(index) {
3371        index -= 1;
3372    }
3373    index
3374}
3375
3376#[cfg(test)]
3377mod caret_index {
3378    use super::{
3379        Instant, TAP_SLOP, TouchText, browser_selection, byte_to_utf16_index, toolbar_layout,
3380        floor_char_boundary, rux_selection, touch_text_after_move, utf16_to_byte_index,
3381    };
3382
3383    /// ASCII is the case where the two agree, and the one every other case is
3384    /// measured against.
3385    #[test]
3386    fn ascii_indices_are_the_same_in_both_counts() {
3387        let s = "hello";
3388        for i in 0..=s.len() {
3389            assert_eq!(utf16_to_byte_index(s, i), i);
3390            assert_eq!(byte_to_utf16_index(s, i), i);
3391        }
3392    }
3393
3394    /// A caret after a CJK character: 1 code unit, 3 bytes.
3395    #[test]
3396    fn a_cjk_caret_converts_both_ways() {
3397        let s = "日本語";
3398        assert_eq!(utf16_to_byte_index(s, 0), 0);
3399        assert_eq!(utf16_to_byte_index(s, 1), 3);
3400        assert_eq!(utf16_to_byte_index(s, 3), 9);
3401        assert_eq!(byte_to_utf16_index(s, 3), 1);
3402        assert_eq!(byte_to_utf16_index(s, 9), 3);
3403    }
3404
3405    /// An emoji is a surrogate pair: 2 code units, 4 bytes. A caret between the
3406    /// two halves is not a position Rux can represent, so it comes back as the
3407    /// start of the character rather than as an index inside it.
3408    #[test]
3409    fn a_surrogate_pair_never_yields_an_index_inside_a_character() {
3410        let s = "a🙂b";
3411        assert_eq!(utf16_to_byte_index(s, 1), 1);
3412        assert_eq!(utf16_to_byte_index(s, 2), 1, "mid-surrogate falls back to the start");
3413        assert_eq!(utf16_to_byte_index(s, 3), 5);
3414        assert_eq!(byte_to_utf16_index(s, 5), 3);
3415        for i in 0..=s.len() {
3416            assert!(s.is_char_boundary(utf16_to_byte_index(s, i)));
3417        }
3418    }
3419
3420    /// Past the end clamps rather than panicking: a stale caret can outlive the
3421    /// text it pointed into, because the value is replaced wholesale.
3422    #[test]
3423    fn indices_past_the_end_clamp() {
3424        let s = "ab";
3425        assert_eq!(utf16_to_byte_index(s, 99), 2);
3426        assert_eq!(byte_to_utf16_index(s, 99), 2);
3427        assert_eq!(floor_char_boundary(s, 99), 2);
3428        assert_eq!(floor_char_boundary("é", 1), 0);
3429    }
3430
3431    /// A DOM input stores an ordered range and a direction; Rux stores two ends
3432    /// with the caret as the moving one. A selection dragged leftwards is the
3433    /// same range as one dragged rightwards, so the direction is the only thing
3434    /// carrying which end the caret is at.
3435    #[test]
3436    fn a_selection_keeps_which_end_the_caret_is_at() {
3437        assert_eq!(browser_selection(2, 7), (2, 7, "forward"));
3438        assert_eq!(browser_selection(7, 2), (2, 7, "backward"), "dragged leftwards");
3439        assert_eq!(browser_selection(4, 4), (4, 4, "forward"), "collapsed");
3440
3441        assert_eq!(rux_selection(2, 7, false), (2, 7));
3442        assert_eq!(rux_selection(2, 7, true), (7, 2), "caret at the left end");
3443        // A collapsed range reports "none", which is not "backward", so it takes
3444        // the forward arm and means nothing is selected.
3445        assert_eq!(rux_selection(4, 4, false), (4, 4));
3446    }
3447
3448    /// The painter draws the toolbar from this and the hit test reads it, so a
3449    /// button's box must be exactly where it is painted, and the strip must stay
3450    /// on screen for a field at either edge.
3451    #[test]
3452    fn the_toolbar_sits_where_its_buttons_are_hit() {
3453        let viewport = (400.0, 800.0);
3454        let ((x, y, w, h), buttons) = toolbar_layout((20.0, 300.0, 200.0, 40.0), viewport);
3455
3456        // Buttons tile the strip exactly: no gap to fall through, no overlap.
3457        assert_eq!(buttons.len(), 4);
3458        assert!((buttons[0].1 - x).abs() < f32::EPSILON, "first starts at the panel");
3459        let mut edge = x;
3460        for (_, bx, by, bw, bh) in &buttons {
3461            assert!((bx - edge).abs() < 0.001, "buttons are contiguous");
3462            assert_eq!((*by, *bh), (y, h), "all share the strip's line");
3463            edge += bw;
3464        }
3465        assert!((edge - (x + w)).abs() < 0.001, "and fill it exactly");
3466
3467        // Above the field, since there is room above it.
3468        assert!(y + h < 300.0, "sits above the field: {y}");
3469
3470        // A field at the top has no room above, so the strip goes below it.
3471        let ((_, below_y, _, _), _) = toolbar_layout((20.0, 0.0, 200.0, 40.0), viewport);
3472        assert!(below_y >= 40.0, "drops below the field instead: {below_y}");
3473
3474        // A field against the right edge must not push the strip off screen.
3475        let ((right_x, _, right_w, _), _) = toolbar_layout((380.0, 300.0, 200.0, 40.0), viewport);
3476        assert!(right_x >= 0.0, "never off the left edge");
3477        assert!(right_x + right_w <= viewport.0 + 0.001, "nor off the right: {right_x}");
3478    }
3479
3480    /// A finger drag on text moved the caret on a phone only after v0.5.1;
3481    /// before that it selected, because touch was routed down the mouse's path.
3482    /// These are the transitions that separate the two.
3483    #[test]
3484    fn a_finger_that_moves_before_the_long_press_drags_the_caret() {
3485        let pending = TouchText::Pending { at: (0.0, 0.0), deadline: Instant::now() };
3486
3487        // Inside the slop the press has not decided: it can still become a
3488        // selection if the finger stays put.
3489        assert_eq!(touch_text_after_move(pending, 0.0), pending);
3490        assert_eq!(touch_text_after_move(pending, TAP_SLOP), pending);
3491
3492        // Past it, the gesture is a caret drag, and cannot become a selection
3493        // later however long the finger then rests.
3494        assert_eq!(touch_text_after_move(pending, TAP_SLOP + 0.1), TouchText::Caret);
3495        assert_eq!(touch_text_after_move(TouchText::Caret, 0.0), TouchText::Caret);
3496        assert_eq!(touch_text_after_move(TouchText::Caret, 500.0), TouchText::Caret);
3497
3498        // Once a word has been taken, every further movement extends it. This
3499        // is the only path that selects.
3500        assert_eq!(touch_text_after_move(TouchText::Selecting, 0.0), TouchText::Selecting);
3501        assert_eq!(touch_text_after_move(TouchText::Selecting, 500.0), TouchText::Selecting);
3502    }
3503
3504    /// The two directions are inverses. Round-tripping is what catches a
3505    /// direction bug: pushing a backward selection to the browser and reading it
3506    /// straight back must not silently flip the caret to the other end, which is
3507    /// what makes a later Shift+arrow extend the wrong way.
3508    #[test]
3509    fn pushing_a_selection_and_reading_it_back_is_lossless() {
3510        for (anchor, caret) in [(0u32, 0u32), (0, 5), (5, 0), (3, 9), (9, 3), (4, 4)] {
3511            let (start, end, direction) = browser_selection(anchor, caret);
3512            let backward = direction == "backward";
3513            let (back_anchor, back_caret) = rux_selection(start as usize, end as usize, backward);
3514            assert_eq!(
3515                (back_anchor as u32, back_caret as u32),
3516                (anchor, caret),
3517                "round trip changed ({anchor}, {caret})"
3518            );
3519        }
3520    }
3521}
3522
3523/// Boot Rux onto an existing `<canvas>`, rendering `source`.
3524///
3525/// `font` is a font file's bytes, and is not optional in practice: a browser
3526/// exposes no system font source, so without it every family query misses and
3527/// the app renders as silent blank boxes. See `TextEngine::register_font`.
3528///
3529/// Returns immediately: `spawn_app` gives the event loop to the browser instead
3530/// of blocking, so the caller keeps running. Errors in `source` are reported and
3531/// replaced with an empty document, matching what the native loader does with an
3532/// unreadable file.
3533#[cfg(target_arch = "wasm32")]
3534pub fn start_web(canvas: web_sys::HtmlCanvasElement, source: String, font: Vec<u8>) {
3535    use winit::platform::web::EventLoopExtWebSys;
3536
3537    let document = match Document::from_source(&source) {
3538        Ok(doc) => doc,
3539        Err(err) => {
3540            web_sys::console::error_1(&format!("rux: {err}").into());
3541            Document::from_source("<template><screen></screen></template>").expect("empty document")
3542        }
3543    };
3544
3545    let event_loop = EventLoop::<RuxEvent>::with_user_event()
3546        .build()
3547        .expect("create event loop");
3548    event_loop.set_control_flow(ControlFlow::Wait);
3549
3550    // Prefer the laid-out CSS size; fall back to the element's width/height
3551    // attributes, then to a phone-ish default. See WEB_SIZE for why this cannot
3552    // be left to winit.
3553    let (mut lw, mut lh) = (canvas.client_width() as f64, canvas.client_height() as f64);
3554    if lw <= 0.0 || lh <= 0.0 {
3555        lw = canvas.width() as f64;
3556        lh = canvas.height() as f64;
3557    }
3558    if lw > 0.0 && lh > 0.0 {
3559        WEB_SIZE.with(|s| *s.borrow_mut() = (lw, lh));
3560    }
3561
3562    WEB_CANVAS.with(|c| *c.borrow_mut() = Some(canvas));
3563    WEB_PROXY.with(|p| *p.borrow_mut() = Some(event_loop.create_proxy()));
3564
3565    let mut app = App::new(document);
3566    if !app.text.register_font(font) {
3567        web_sys::console::error_1(&"rux: the supplied font had no usable faces, so text will not render".into());
3568    }
3569    event_loop.spawn_app(app);
3570}
3571
3572/// Resize the canvas to `w` x `h` logical pixels. No-op before `start_web`.
3573///
3574/// The host page owns the layout, so it has to push the size in. Everything
3575/// downstream (canvas styling, surface reconfigure, re-layout at the new
3576/// viewport, `@media` re-evaluation once v0.4 lands) follows from winit's
3577/// `Resized`.
3578#[cfg(target_arch = "wasm32")]
3579pub fn resize_web(w: f64, h: f64) {
3580    WEB_SIZE.with(|s| *s.borrow_mut() = (w, h));
3581    WEB_PROXY.with(|p| {
3582        if let Some(proxy) = p.borrow().as_ref() {
3583            let _ = proxy.send_event(RuxEvent::Resize(w, h));
3584        }
3585    });
3586}
3587
3588/// Replace the running document's source, returning a parse error if the source
3589/// is not loadable. No-op before `start_web`.
3590///
3591/// The source is checked here rather than in the event handler so the caller
3592/// gets a *synchronous* answer it can put on screen. The running app parses it
3593/// again when the event arrives; parsing is cheap next to a frame, and the
3594/// alternative, plumbing a result back out through the event loop, would be
3595/// far more machinery for the same outcome.
3596#[cfg(target_arch = "wasm32")]
3597pub fn set_web_source(source: String) -> Option<String> {
3598    if let Err(err) = Document::from_source(&source) {
3599        return Some(err.to_string());
3600    }
3601    WEB_PROXY.with(|p| {
3602        if let Some(proxy) = p.borrow().as_ref() {
3603            let _ = proxy.send_event(RuxEvent::SetSource(source));
3604        }
3605    });
3606    None
3607}
3608
3609/// Replace the running document and report **everything** wrong with it, as
3610/// JSON: `{"error": {"message", "line", "column"} | null, "warnings": [...]}`.
3611///
3612/// [`set_web_source`] returns only an error message, which is all the playground
3613/// could ever show: no line to jump to, and no warnings at all, while the
3614/// desktop window had both. This is the same call with the diagnostics the
3615/// runtime already computes actually handed over.
3616///
3617/// The document is built twice, once here to inspect and once on the event loop
3618/// to display. That is not new and not avoidable cheaply: a `Document` is not
3619/// `Send`, and the proxy that wakes the loop requires that it be, so the source
3620/// text is what travels. Both builds run the same code over the same input, so
3621/// the diagnostics reported are the diagnostics shown.
3622#[cfg(target_arch = "wasm32")]
3623pub fn diagnose_web_source(source: String) -> String {
3624    let (error, warnings) = match Document::from_source_checked(&source) {
3625        Err(err) => {
3626            let line = err.line.map(|l| l.to_string()).unwrap_or_else(|| "null".into());
3627            let column = err.column.map(|c| c.to_string()).unwrap_or_else(|| "null".into());
3628            let error = format!(
3629                "{{\"message\": {}, \"line\": {line}, \"column\": {column}}}",
3630                rux_runtime::json_string(&err.message)
3631            );
3632            (error, String::from("[]"))
3633        }
3634        Ok(doc) => {
3635            let warnings: Vec<String> =
3636                doc.diagnostics().warnings.iter().map(|w| w.to_json()).collect();
3637            // Only a document that builds gets displayed: a broken one leaves the
3638            // last good tree on screen, which is what the desktop does too.
3639            WEB_PROXY.with(|p| {
3640                if let Some(proxy) = p.borrow().as_ref() {
3641                    let _ = proxy.send_event(RuxEvent::SetSource(source));
3642                }
3643            });
3644            (String::from("null"), format!("[{}]", warnings.join(", ")))
3645        }
3646    };
3647    format!("{{\"error\": {error}, \"warnings\": {warnings}}}")
3648}
3649
3650/// Open the Rux window for the given `.rux` file and run the frame loop until the
3651/// window closes. Watches the file and repaints on change.
3652///
3653/// Native only: it takes a filesystem path and installs a file watcher, neither
3654/// of which a browser has. The web build drives the same `App` from source text
3655/// supplied by the playground editor.
3656#[cfg(not(target_arch = "wasm32"))]
3657pub fn run(path: PathBuf) {
3658    let event_loop = EventLoop::<RuxEvent>::with_user_event()
3659        .build()
3660        .expect("create event loop");
3661    event_loop.set_control_flow(ControlFlow::Wait);
3662
3663    // Watch the file's directory *recursively* so edits to imported components
3664    // (which live in subdirectories) also trigger a reload. Reload on any `.rux`
3665    // change, `Document::load` re-reads the main file and its components.
3666    let proxy = event_loop.create_proxy();
3667    let watch_dir = path
3668        .parent()
3669        .filter(|p| !p.as_os_str().is_empty())
3670        .map(|p| p.to_path_buf())
3671        .unwrap_or_else(|| PathBuf::from("."));
3672
3673    let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
3674        let Ok(event) = res else { return };
3675        if !matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
3676            return;
3677        }
3678        let touches_rux = event
3679            .paths
3680            .iter()
3681            .any(|p| p.extension().is_some_and(|e| e == "rux"));
3682        if touches_rux {
3683            let _ = proxy.send_event(RuxEvent::Reload);
3684        }
3685    })
3686    .expect("create watcher");
3687    watcher
3688        .watch(&watch_dir, RecursiveMode::Recursive)
3689        .expect("watch directory");
3690
3691    let mut app = App::new(path, event_loop.create_proxy());
3692    event_loop.run_app(&mut app).expect("run app");
3693
3694    drop(watcher); // keep the watcher alive for the loop's lifetime
3695}
3696
3697#[cfg(test)]
3698mod tests {
3699    use super::*;
3700    use rux_runtime::{Diagnostics, Warning};
3701
3702    fn warned(message: &str) -> Diagnostics {
3703        Diagnostics { warnings: vec![Warning::new(message)], ..Diagnostics::default() }
3704    }
3705
3706    /// The overlay covers the app it is describing, so it has to be dismissable.
3707    #[test]
3708    fn dismissing_the_overlay_hides_it() {
3709        let diag = warned("float does nothing");
3710        assert!(overlay_visible(&diag, None), "shown before it is dismissed");
3711        assert!(!overlay_visible(&diag, Some(&diag)), "hidden after");
3712    }
3713
3714    /// And it must come back on its own when what is wrong changes, or
3715    /// dismissing a warning would silence the error you write next.
3716    #[test]
3717    fn a_dismissed_overlay_returns_when_the_diagnostics_change() {
3718        let dismissed = warned("float does nothing");
3719
3720        let another_warning = warned("`:nope` is not supported");
3721        assert!(overlay_visible(&another_warning, Some(&dismissed)));
3722
3723        let now_broken = Diagnostics {
3724            error: Some("parse error".into()),
3725            stale: true,
3726            warnings: dismissed.warnings.clone(),
3727        };
3728        assert!(
3729            overlay_visible(&now_broken, Some(&dismissed)),
3730            "an error arriving after a dismissed warning must show"
3731        );
3732    }
3733
3734    /// Fixing everything hides the panel whether or not it was dismissed, and a
3735    /// stale dismissal must not make an empty document look dismissed-into-silence.
3736    #[test]
3737    fn nothing_wrong_means_no_overlay() {
3738        let clean = Diagnostics::default();
3739        assert!(!overlay_visible(&clean, None));
3740        assert!(!overlay_visible(&clean, Some(&warned("old"))));
3741    }
3742
3743    /// A 200x200 box holding 500px-tall content: it scrolls down, not sideways.
3744    fn tall() -> ScrollRegion {
3745        ScrollRegion {
3746            id: 0,
3747            x: 0.0,
3748            y: 0.0,
3749            width: 200.0,
3750            height: 200.0,
3751            content_width: 200.0,
3752            content_height: 500.0,
3753            max: Offset { x: 0.0, y: 300.0 },
3754        }
3755    }
3756
3757    /// The thumb is the box's fraction of the content, and sits at the top when
3758    /// unscrolled.
3759    #[test]
3760    fn thumb_is_proportional_to_the_content() {
3761        let (x, y, w, h) = bar_thumb(&tall(), Offset::default(), Axis2::Y).expect("a thumb");
3762        assert_eq!(h, 80.0, "200/500 of a 200px track");
3763        assert_eq!(y, 0.0, "unscrolled thumb starts at the top of the track");
3764        assert_eq!(w, BAR_W);
3765        assert_eq!(x, 200.0 - BAR_W, "the bar hugs the box's right edge");
3766    }
3767
3768    /// The horizontal thumb is the mirror of the vertical one: it runs *along* the
3769    /// bottom edge and is only `BAR_W` thick. (Getting the track tuple's length
3770    /// and thickness the wrong way round here painted a thumb as tall as the whole
3771    /// box, invisible to every test that only looked at the vertical bar.)
3772    #[test]
3773    fn horizontal_thumb_lies_along_the_bottom_edge() {
3774        let mut wide = tall();
3775        wide.content_height = 200.0;
3776        wide.content_width = 500.0;
3777        wide.max = Offset { x: 300.0, y: 0.0 };
3778
3779        let (x, y, w, h) = bar_thumb(&wide, Offset::default(), Axis2::X).expect("a thumb");
3780        assert_eq!(h, BAR_W, "a horizontal thumb is BAR_W *thick*, not BAR_W long");
3781        assert_eq!(w, 80.0, "200/500 of a 200px track");
3782        assert_eq!(x, 0.0);
3783        assert_eq!(y, 200.0 - BAR_W, "it sits on the box's bottom edge");
3784    }
3785
3786    /// At the end of the content the thumb is at the end of its track, the
3787    /// bottom of the thumb meets the bottom of the box.
3788    #[test]
3789    fn thumb_reaches_the_end_of_the_track() {
3790        let r = tall();
3791        let (_, y, _, h) = bar_thumb(&r, Offset { x: 0.0, y: 300.0 }, Axis2::Y).expect("a thumb");
3792        assert_eq!(y + h, r.height);
3793    }
3794
3795    /// The negative case: an axis with no travel has no thumb, nothing to draw,
3796    /// and nothing to grab. (A bar you can drag on a box that can't scroll was the
3797    /// easy bug here.)
3798    #[test]
3799    fn no_thumb_on_an_axis_that_does_not_scroll() {
3800        assert!(bar_thumb(&tall(), Offset::default(), Axis2::X).is_none());
3801
3802        let mut fits = tall();
3803        fits.content_height = 200.0;
3804        fits.max = Offset::default();
3805        assert!(bar_thumb(&fits, Offset::default(), Axis2::Y).is_none());
3806        assert!(!fits.scrollable());
3807    }
3808
3809    /// However long the content, the thumb stays big enough to grab.
3810    #[test]
3811    fn thumb_has_a_floor() {
3812        let mut huge = tall();
3813        huge.content_height = 100_000.0;
3814        huge.max = Offset { x: 0.0, y: 99_800.0 };
3815        let (_, _, _, h) = bar_thumb(&huge, Offset::default(), Axis2::Y).expect("a thumb");
3816        assert_eq!(h, BAR_MIN_THUMB);
3817    }
3818
3819    /// When both axes scroll, the tracks stop short of the corner so they don't
3820    /// cross each other.
3821    #[test]
3822    fn tracks_leave_the_corner_free() {
3823        let mut both = tall();
3824        both.content_width = 500.0;
3825        both.max.x = 300.0;
3826
3827        let (_, _, _, vh) = bar_track(&both, Axis2::Y);
3828        let (_, _, hw, _) = bar_track(&both, Axis2::X);
3829        assert_eq!(vh, both.height - BAR_W);
3830        assert_eq!(hw, both.width - BAR_W);
3831
3832        // …and with one axis only, the track runs the full length.
3833        let (_, _, _, full) = bar_track(&tall(), Axis2::Y);
3834        assert_eq!(full, 200.0);
3835    }
3836}