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