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