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(®ion.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(®ion, 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(®ion.model, region.row.as_deref());
1635 let (Some(t), false) = (®ion.text, value.is_empty()) else {
1636 return false;
1637 };
1638 let (tx, ty) = self.text_point(®ion, 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(®ion, 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(®ion, 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, ®ion.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}