Skip to main content

velox_renderer/
lib.rs

1//! Renderer crate with optional backends.
2//! No features enabled => stub, compiles fast.
3
4use velox_dom::VNode;
5use velox_style::Stylesheet;
6
7/// Apply the renderer-wide UA < author < inline cascade before layout or paint.
8///
9/// The renderer always calls this once on a freshly-built VNode. Keeping the
10/// composition here prevents a backend from accidentally dropping the UA layer.
11pub fn style_vnode_with_hover<F>(vnode: &VNode, author: &Stylesheet, is_hovered: &F) -> VNode
12where
13    F: Fn(&str, &velox_dom::Props) -> bool,
14{
15    velox_style::apply_with_cascade_with_hover(vnode, author, is_hovered)
16}
17
18/// Lifecycle wiring helper — ensures `on_unmounted` / `before_destroy` hooks
19/// fire even if the event loop exits via `Drop` rather than `CloseRequested`.
20#[allow(dead_code)]
21pub struct LifecycleCleanupGuard;
22
23impl Drop for LifecycleCleanupGuard {
24    fn drop(&mut self) {
25        let _ = std::panic::catch_unwind(velox_core::lifecycle::run_all_destroy_hooks);
26    }
27}
28
29#[allow(dead_code)]
30pub fn ensure_mounted(flag: &mut bool) {
31    if !*flag {
32        *flag = true;
33        velox_core::lifecycle::run_all_mounted_hooks();
34    }
35}
36
37/// Return the VNode at a child source-index path, if it exists.
38pub fn find_node_at_path<'a>(node: &'a VNode, path: &[usize]) -> Option<&'a VNode> {
39    let mut cur = node;
40    for &idx in path {
41        match cur {
42            VNode::Element { children, .. } => cur = children.get(idx)?,
43            _ => return None,
44        }
45    }
46    Some(cur)
47}
48
49/// Unified logical size helper — single rounding point for all frame paths.
50/// Delegates to Viewport::from_i32 for consistent clamping; kept as free function for compat.
51#[cfg(any(feature = "skia-native", test))]
52fn viewport_logical_dimensions(width: i32, height: i32, scale_factor: f32) -> (u32, u32) {
53    Viewport::from_i32(width, height, scale_factor).logical_size()
54}
55
56#[cfg(feature = "skia-native")]
57pub fn logical_size(width: i32, height: i32, scale_factor: f32) -> (u32, u32) {
58    viewport_logical_dimensions(width, height, scale_factor)
59}
60
61/// Notify resize hooks only after a coalesced physical resize has committed and
62/// its logical viewport differs from the last committed size.
63#[cfg(any(feature = "skia-native", test))]
64fn dispatch_resize_if_changed(
65    committed: bool,
66    logical: (u32, u32),
67    last_resize_size: &mut Option<(u32, u32)>,
68) {
69    if committed && *last_resize_size != Some(logical) {
70        velox_core::lifecycle::run_resize_hooks(logical.0, logical.1);
71        *last_resize_size = Some(logical);
72    }
73}
74
75#[cfg(any(feature = "skia-native", test))]
76fn frame_logical_size(
77    width: i32,
78    height: i32,
79    scale_factor: f32,
80    committed: bool,
81    last_resize_size: &mut Option<(u32, u32)>,
82) -> (u32, u32) {
83    let logical = viewport_logical_dimensions(width, height, scale_factor);
84    dispatch_resize_if_changed(committed, logical, last_resize_size);
85    logical
86}
87
88/// Per-renderer resize state. Physical events are coalesced here and the
89/// committed logical baseline is kept locally to this window/event loop.
90#[cfg(any(feature = "skia-native", test))]
91struct ResizeState {
92    pending_resize: Option<(u32, u32)>,
93    last_resize_size: Option<(u32, u32)>,
94}
95
96#[cfg(any(feature = "skia-native", test))]
97impl ResizeState {
98    fn new() -> Self {
99        Self {
100            pending_resize: None,
101            last_resize_size: None,
102        }
103    }
104
105    fn queue(&mut self, physical: (u32, u32)) {
106        self.pending_resize = Some(physical);
107    }
108
109    fn take_pending(&mut self) -> Option<(u32, u32)> {
110        self.pending_resize.take()
111    }
112
113    fn record_initial(&mut self, width: i32, height: i32, scale_factor: f32) -> (u32, u32) {
114        let logical = viewport_logical_dimensions(width, height, scale_factor);
115        self.last_resize_size = Some(logical);
116        logical
117    }
118
119    fn frame_logical_size(
120        &mut self,
121        width: i32,
122        height: i32,
123        scale_factor: f32,
124        committed: bool,
125    ) -> (u32, u32) {
126        frame_logical_size(
127            width,
128            height,
129            scale_factor,
130            committed,
131            &mut self.last_resize_size,
132        )
133    }
134}
135
136/// Build hit-test targets from a precomputed layout. No layout recompute here.
137#[cfg(feature = "skia-native")]
138fn recompute_targets(
139    vnode: &velox_dom::VNode,
140    layout: &velox_dom::layout::LayoutNode,
141    click_targets: &mut Vec<crate::events::ClickTarget>,
142    hover_targets: &mut Vec<crate::events::HoverTarget>,
143    input_targets: &mut Vec<crate::events::InputTarget>,
144) {
145    click_targets.clear();
146    let mut order = 0;
147    crate::events::collect_click_targets(
148        vnode,
149        layout,
150        None,
151        crate::events::StackCtx::ROOT,
152        &mut order,
153        click_targets,
154    );
155    hover_targets.clear();
156    let mut order = 0;
157    crate::events::collect_hover_targets(
158        vnode,
159        layout,
160        None,
161        crate::events::StackCtx::ROOT,
162        &mut order,
163        hover_targets,
164    );
165    // Edit state (focus / caret / selection / blink phase) lives on the targets,
166    // and a target rebuild throws it all away. Move the old vector aside and
167    // re-apply it onto the freshly collected targets by tree path, so a
168    // re-render does not silently drop the caret. `std::mem::take` is only a
169    // move — the fresh collection below reuses the same allocation.
170    let previous = std::mem::take(input_targets);
171    let mut order = 0;
172    let mut path = Vec::new();
173    crate::events::collect_input_targets(
174        vnode,
175        layout,
176        None,
177        crate::events::StackCtx::ROOT,
178        &mut path,
179        &mut order,
180        input_targets,
181    );
182    crate::events::preserve_input_state(input_targets, &previous, &|p| {
183        input_value_char_len(vnode, p)
184    });
185}
186
187#[cfg(feature = "skia-native")]
188fn with_hover_ids(vnode: &velox_dom::VNode, next_id: &mut u32) -> velox_dom::VNode {
189    match vnode {
190        velox_dom::VNode::Text(_) => vnode.clone(),
191        velox_dom::VNode::Element {
192            tag,
193            props,
194            children,
195        } => {
196            let mut new_props = props.clone();
197            if crate::events::is_hoverable(tag, props) {
198                let id = *next_id;
199                *next_id += 1;
200                new_props = new_props.set("data-hover-id", id.to_string());
201            }
202            let new_children = children
203                .iter()
204                .map(|c| with_hover_ids(c, next_id))
205                .collect();
206            velox_dom::VNode::Element {
207                tag: tag.clone(),
208                props: new_props,
209                children: new_children,
210            }
211        }
212    }
213}
214
215/// Caret blink half-period, in milliseconds.
216///
217/// 530 ms is the conventional caret cadence. It is a named const rather than a
218/// literal at the call site because the tick interval, the "solid while
219/// typing" grace window, and the post-key re-arm deadline must all agree.
220#[cfg(feature = "skia-native")]
221const CARET_BLINK_MS: u64 = 530;
222
223/// Posts a recurring `UserEvent` to the winit event loop so the caret blink has
224/// a clock.
225///
226/// Why there is a thread at all: the loops run with `ControlFlow::Wait`, so they
227/// only wake for real OS events. A bare `Instant::now()` deadline check inside
228/// the event closure would therefore never fire while the user is idle, and the
229/// caret would freeze solid. winit 0.28 has no timer API, so the tick has to
230/// come in as a user event.
231///
232/// Why this is not a shared-state race: the thread owns nothing but an
233/// `EventLoopProxy`, a cloned copy of the tick payload, and an `AtomicBool`
234/// stop flag. It has no reference to `input_targets`, the VNode, the renderer,
235/// or the presenter — it cannot read or write renderer state at all. Every
236/// `blink_on` mutation happens on the event-loop thread, inside the event
237/// closure, where the renderer is already single-threaded. This is exactly the
238/// property a `Mutex<InputState>` design would have had to give up.
239///
240/// The thread is joined in `Drop`, so closing the window does not leave a
241/// detached ticker posting events at a dead loop.
242#[cfg(feature = "skia-native")]
243struct CaretBlinkTicker<T: Clone + Send + 'static> {
244    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
245    handle: Option<std::thread::JoinHandle<()>>,
246    /// The tick payload is moved into the thread, so the struct itself does
247    /// not otherwise mention `T`. This marker is what ties the type parameter
248    /// to the type; it is zero-sized and owns nothing.
249    _tick: std::marker::PhantomData<fn() -> T>,
250}
251
252#[cfg(feature = "skia-native")]
253impl<T: Clone + Send + 'static> CaretBlinkTicker<T> {
254    /// Start ticking. `tick` is the payload posted on every interval — `()` for
255    /// the plain loop, [`HmrMessage::KeepWindow`] for the HMR loop, whose
256    /// channel is typed.
257    fn start(proxy: winit::event_loop::EventLoopProxy<T>, tick: T) -> Self {
258        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
259        let stop_thread = std::sync::Arc::clone(&stop);
260        let period = std::time::Duration::from_millis(CARET_BLINK_MS);
261        let handle = std::thread::Builder::new()
262            .name("velox-caret-blink".into())
263            .spawn(move || {
264                while !stop_thread.load(std::sync::atomic::Ordering::Relaxed) {
265                    std::thread::sleep(period);
266                    if stop_thread.load(std::sync::atomic::Ordering::Relaxed) {
267                        break;
268                    }
269                    // winit 0.28's `EventLoopProxy<T>::send_event` takes the
270                    // user-event payload `T` directly — the platform layer wraps
271                    // it into `Event::UserEvent(..)` on delivery. Sending fails
272                    // once the loop is gone, which is the exit condition.
273                    if proxy.send_event(tick.clone()).is_err() {
274                        break;
275                    }
276                }
277            })
278            .ok();
279        Self {
280            stop,
281            handle,
282            _tick: std::marker::PhantomData,
283        }
284    }
285}
286
287#[cfg(feature = "skia-native")]
288impl<T: Clone + Send + 'static> Drop for CaretBlinkTicker<T> {
289    fn drop(&mut self) {
290        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
291        if let Some(h) = self.handle.take() {
292            let _ = h.join();
293        }
294    }
295}
296
297/// Advance the caret blink phase on a ticker event.
298///
299/// Returns `true` when the phase actually flipped and the frame must be
300/// repainted. A tick that arrives inside the "solid while typing" window set by
301/// a keypress or a focus change does *not* flip: it only re-arms the deadline,
302/// which is what stops the caret from blinking off milliseconds after a
303/// keystroke.
304#[cfg(feature = "skia-native")]
305fn on_caret_blink_tick(
306    input_targets: &mut [crate::events::InputTarget],
307    blink_deadline: &mut Option<std::time::Instant>,
308) -> bool {
309    let Some(idx) = crate::events::focused_input_index(input_targets) else {
310        return false;
311    };
312    let now = std::time::Instant::now();
313    if let Some(d) = *blink_deadline
314        && now < d
315    {
316        return false;
317    }
318    input_targets[idx].blink_on = !input_targets[idx].blink_on;
319    *blink_deadline = Some(now + std::time::Duration::from_millis(CARET_BLINK_MS));
320    true
321}
322
323/// Re-arm the blink grace window after a keypress or focus change.
324///
325/// This is the "caret goes solid while you type" rule: any editing action
326/// defers the next phase flip by one cadence period. Without it a keystroke
327/// landing just before a tick would be blinked off a few milliseconds later,
328/// which reads as flicker rather than as a caret.
329#[cfg(feature = "skia-native")]
330fn arm_blink_deadline(blink_deadline: &mut Option<std::time::Instant>) {
331    *blink_deadline =
332        Some(std::time::Instant::now() + std::time::Duration::from_millis(CARET_BLINK_MS));
333}
334
335/// Read a declaration out of a cascaded `style` attribute (`"a: b; c: d"`).
336#[cfg(feature = "skia-native")]
337fn style_decl<'s>(style: &'s str, key: &str) -> Option<&'s str> {
338    style
339        .split(';')
340        .filter_map(|d| d.split_once(':'))
341        .find(|(k, _)| k.trim() == key)
342        .map(|(_, v)| v.trim())
343}
344
345/// The painter's own geometry for a text input's value text: content edges
346/// **and** the font size its advances are measured at.
347///
348/// The click-to-caret mapping is relative to `text_left`, so padding shifts
349/// the caret to match the painted glyphs.
350///
351/// This delegates to [`crate::input_metrics::input_text_metrics`] rather than
352/// reading the authored string. It used to look up the literal key
353/// `padding-left`, which is never present: the cascade does not expand
354/// shorthands, so `padding: 6px 10px` survives under the key `padding` and the
355/// lookup missed it, returning `rect.x + 0` while the paint lane drew glyphs at
356/// `text_left`. Click-to-caret therefore disagreed with the painted glyphs by
357/// the whole left padding, and only an inline `padding-left` happened to work.
358/// Reading the *computed* side is the fix; the paint lane already calls the
359/// same function, so the two lanes can no longer drift.
360///
361/// Returning the whole struct — rather than just `.text_left` — is what makes
362/// the second half of the bug unrepresentable. The origin used to be shared
363/// while the font size was re-derived by a lane-local `parse_px` that accepted
364/// only a literal `px` suffix, so `em`/`%`/`rem` and bare inheritance all
365/// collapsed to a fallback and every advance came out `16/14` too wide: a
366/// click at the far end of the value landed the caret about one character
367/// short for every seven clicked. One call, both fields.
368///
369/// `viewport` is the **logical** `(w, h)` the paint lane resolves `vw`/`vh`
370/// against, so `padding-left: 2vw` lands the caret on the glyph in both lanes.
371#[cfg(feature = "skia-native")]
372fn resolve_text_metrics(
373    props: &velox_dom::Props,
374    rect: velox_dom::layout::Rect,
375    viewport: (f32, f32),
376) -> crate::input_metrics::InputTextMetrics {
377    crate::input_metrics::input_text_metrics(
378        props.attrs.get("style").map(|s| s.as_str()),
379        rect,
380        viewport,
381        // This lane has no inheritance context, so it cannot read the parent's
382        // size. The painter's root `TextStyle` IS that context — the size an
383        // element that declares no `font-size` of its own paints at — so its
384        // constant is the stand-in, and it must be the same number in both
385        // lanes. Passing `DEFAULT_ROOT_FONT_SIZE` (16) here instead meant a field
386        // with no `font-size` was measured 16/14 too wide and its `em` padding
387        // resolved against 16 while paint used 14.
388        crate::skia_render::PAINT_ROOT_FONT_SIZE,
389    )
390}
391
392/// The `value` attribute of the element at `path`, if it is an element.
393#[cfg(feature = "skia-native")]
394fn input_value_at(vnode: &VNode, path: &[usize]) -> Option<String> {
395    match find_node_at_path(vnode, path) {
396        Some(VNode::Element { props, .. }) => {
397            Some(props.attrs.get("value").cloned().unwrap_or_default())
398        }
399        _ => None,
400    }
401}
402
403/// Char length of the value at `path`, 0 when there is no such input.
404#[cfg(feature = "skia-native")]
405fn input_value_char_len(vnode: &VNode, path: &[usize]) -> usize {
406    input_value_at(vnode, path)
407        .map(|s| s.chars().count())
408        .unwrap_or(0)
409}
410
411/// Dispatch an explicit new value to the input at `path` via its `on:input`
412/// handler, so the app's state and the next `make_view` see the edit.
413#[cfg(feature = "skia-native")]
414fn dispatch_input_value(
415    last_vnode: &Option<VNode>,
416    path: &[usize],
417    new_value: &str,
418    on_event: &mut impl FnMut(&str, Option<&str>),
419) {
420    let Some(vnode) = last_vnode else { return };
421    let Some(node) = find_node_at_path(vnode, path) else {
422        return;
423    };
424    let VNode::Element { props, .. } = node else {
425        return;
426    };
427    let Some(handler) = props.attrs.get("on:input").cloned() else {
428        return;
429    };
430    on_event(&handler, Some(new_value));
431}
432
433/// Apply one editing action to the focused input and dispatch any text change.
434///
435/// Shared by both event loops — that shared call is what keeps the HMR loop
436/// from drifting away from the plain one again. Returns `true` when the caller
437/// must repaint.
438///
439/// Public because a host that drives its own key handling needs the same
440/// sequence the loops run, and because "is a keypress additive?" is only
441/// testable if both halves of a keypress can be driven in one test.
442#[cfg(feature = "skia-native")]
443pub fn apply_edit_to_focused(
444    input_targets: &mut [crate::events::InputTarget],
445    action: crate::events::EditAction,
446    last_vnode: &Option<VNode>,
447    focused_input: &mut Option<Vec<usize>>,
448    on_event: &mut impl FnMut(&str, Option<&str>),
449) -> bool {
450    let Some(idx) = crate::events::focused_input_index(input_targets) else {
451        return false;
452    };
453    let path = input_targets[idx].path.clone();
454    let value = last_vnode
455        .as_ref()
456        .and_then(|v| input_value_at(v, &path))
457        .unwrap_or_default();
458    let res = crate::events::apply_edit(&mut input_targets[idx], &value, action);
459    if res.submit {
460        // Submit always dispatches: with no selection the text is unchanged, and
461        // the handler still has to see the submission.
462        let v = res.value.clone().unwrap_or_else(|| value.clone());
463        dispatch_input_value(last_vnode, &path, &v, on_event);
464    } else if let Some(new_value) = &res.value {
465        dispatch_input_value(last_vnode, &path, new_value, on_event);
466    }
467    // `InputTarget::focused` is the single source of truth for focus; the
468    // loop's `focused_input` is its index-free mirror, so it has to follow
469    // whatever the action did to the flag rather than drift one keypress
470    // behind it. Every action except `Blur` leaves focus alone.
471    if matches!(action, crate::events::EditAction::Blur) {
472        *focused_input = None;
473    }
474    res.needs_repaint()
475}
476
477/// Resolve the VirtualKeyCode of a key event into an editing action, honouring
478/// the Shift modifier. Returns `None` for keys that are not editing commands.
479///
480/// The match is closed on purpose: it is the *editor's* vocabulary, and a key
481/// that is not an editing command has no business acquiring one just because an
482/// app also listens for it. The author-facing listening vocabulary is
483/// [`key_name`], and the two are peers rather than a chain — see
484/// [`dispatch_keydown`], which runs both on the same press.
485#[cfg(feature = "skia-native")]
486pub fn edit_action_for_key(
487    keycode: winit::event::VirtualKeyCode,
488    shift: bool,
489) -> Option<crate::events::EditAction> {
490    use crate::events::EditAction;
491    use winit::event::VirtualKeyCode as K;
492    Some(match keycode {
493        K::Left => EditAction::MoveLeft { shift },
494        K::Right => EditAction::MoveRight { shift },
495        K::Home => EditAction::Home { shift },
496        K::End => EditAction::End { shift },
497        K::Back => EditAction::Backspace,
498        K::Delete => EditAction::Delete,
499        K::Return => EditAction::Submit,
500        // Esc ends the editing session: focus, selection and caret go, the
501        // value stays. This match is closed — there is no fallthrough to user
502        // code — so Esc belongs here or the user has no way out of a field.
503        K::Escape => EditAction::Blur,
504        _ => return None,
505    })
506}
507
508/// Stable, author-facing name for a key press.
509///
510/// This is the whole payload contract of `@keydown`, and it is a *string*, not
511/// the winit enum, for three reasons:
512///
513/// - the enum is a dependency's type, so putting it in a `.vx` handler's
514///   signature would leak `winit` (and an optional feature) into every app;
515/// - winit spells its variants `Key1`, `Numpad0`, `LWin`; an author writes
516///   `"1"`, `"0"`, `"MetaLeft"`-or-`"Super"`, never the crate's spelling;
517/// - a name is a *value*, so it can be compared, matched, printed and stored,
518///   which is what an `if key == "F2"` branch needs.
519///
520/// The vocabulary is the DOM `KeyboardEvent.key` convention where the two
521/// disagree (`"Enter"` not `"Return"`, `" "`-as-`"Space"`, `"Escape"`), and
522/// upper-case bare letters so a shifted key is the *same* name as an unshifted
523/// one — `shift` is not reported, and folding it into the name would mean an
524/// author cannot tell `a` from `A` when they cannot tell them apart either.
525/// Anything outside the table becomes `"Unidentified"`.
526#[cfg(feature = "skia-native")]
527pub fn key_name(keycode: winit::event::VirtualKeyCode) -> &'static str {
528    use winit::event::VirtualKeyCode as K;
529    match keycode {
530        K::A => "A",
531        K::B => "B",
532        K::C => "C",
533        K::D => "D",
534        K::E => "E",
535        K::F => "F",
536        K::G => "G",
537        K::H => "H",
538        K::I => "I",
539        K::J => "J",
540        K::K => "K",
541        K::L => "L",
542        K::M => "M",
543        K::N => "N",
544        K::O => "O",
545        K::P => "P",
546        K::Q => "Q",
547        K::R => "R",
548        K::S => "S",
549        K::T => "T",
550        K::U => "U",
551        K::V => "V",
552        K::W => "W",
553        K::X => "X",
554        K::Y => "Y",
555        K::Z => "Z",
556        K::Key0 => "0",
557        K::Key1 => "1",
558        K::Key2 => "2",
559        K::Key3 => "3",
560        K::Key4 => "4",
561        K::Key5 => "5",
562        K::Key6 => "6",
563        K::Key7 => "7",
564        K::Key8 => "8",
565        K::Key9 => "9",
566        K::F1 => "F1",
567        K::F2 => "F2",
568        K::F3 => "F3",
569        K::F4 => "F4",
570        K::F5 => "F5",
571        K::F6 => "F6",
572        K::F7 => "F7",
573        K::F8 => "F8",
574        K::F9 => "F9",
575        K::F10 => "F10",
576        K::F11 => "F11",
577        K::F12 => "F12",
578        K::Up => "ArrowUp",
579        K::Down => "ArrowDown",
580        K::Left => "ArrowLeft",
581        K::Right => "ArrowRight",
582        K::Back => "Backspace",
583        K::Delete => "Delete",
584        K::Insert => "Insert",
585        K::Home => "Home",
586        K::End => "End",
587        K::PageUp => "PageUp",
588        K::PageDown => "PageDown",
589        K::Return | K::NumpadEnter => "Enter",
590        K::Escape => "Escape",
591        K::Tab => "Tab",
592        K::Space => "Space",
593        K::LShift | K::RShift => "Shift",
594        K::LControl | K::RControl => "Control",
595        K::LAlt | K::RAlt => "Alt",
596        K::LWin | K::RWin => "Meta",
597        _ => "Unidentified",
598    }
599}
600
601/// Focus the input the author named `data-focus-id="id"`.
602///
603/// The stable-name entry point, for a caller that already knows *which* field it
604/// means — a host embedding the renderer that drives focus itself, or a test.
605/// Resolution runs through the same [`crate::events::find_focus_id_path`] the
606/// `@keydown` grant uses, so "focus the composer" means one thing in this
607/// codebase no matter which door it came through.
608///
609/// Returns `true` when focus actually moved, so an already-focused field is not
610/// mistaken for a change and does not have its caret reset.
611#[cfg(feature = "skia-native")]
612pub fn focus_input_by_id(
613    input_targets: &mut [crate::events::InputTarget],
614    last_vnode: &Option<VNode>,
615    focused_input: &mut Option<Vec<usize>>,
616    id: &str,
617) -> bool {
618    let Some(vnode) = last_vnode.as_ref() else {
619        return false;
620    };
621    let Some(path) = crate::events::find_focus_id_path(vnode, &[], id) else {
622        return false;
623    };
624    // Expressed as a one-focus `KeydownPlan` so this and the key path run the
625    // same grant code — the flag, the caret, the anchor, the blink and the
626    // index-free mirror are all written in exactly one place.
627    let plan = crate::events::KeydownPlan {
628        focus_paths: vec![path],
629        ..Default::default()
630    };
631    crate::events::apply_keydown(
632        &plan,
633        "",
634        input_targets,
635        focused_input,
636        &|p: &[usize]| input_value_char_len(vnode, p),
637        &mut (|_: &str, _: Option<&str>| {}),
638    )
639}
640
641/// Route one key press to the app: declarative focus grants plus every
642/// `on:keydown` handler, with the key's [`key_name`] as the payload.
643///
644/// Shared by both event loops, for the same reason `apply_edit_to_focused` is:
645/// the HMR loop drifting from the plain one is not a theoretical risk, it is the
646/// specific bug that indirection exists to prevent. Returns `true` when the
647/// caller must repaint.
648///
649/// Both loops call this from the key arm *before* the reload/quit guard and
650/// outside the `match` that routes editing keys, and it returns no instruction
651/// to consume the key. That placement is the additive contract made structural
652/// instead of documented: `dispatch_keydown` and the editing branch are
653/// siblings, so swallowing a keystroke would take deleting the adjacency to do.
654#[cfg(feature = "skia-native")]
655pub fn dispatch_keydown(
656    keycode: winit::event::VirtualKeyCode,
657    input_targets: &mut [crate::events::InputTarget],
658    last_vnode: &Option<VNode>,
659    focused_input: &mut Option<Vec<usize>>,
660    on_event: &mut impl FnMut(&str, Option<&str>),
661) -> bool {
662    let name = crate::key_name(keycode);
663    let Some(vnode) = last_vnode.as_ref() else {
664        return false;
665    };
666    let plan = crate::events::plan_keydown(vnode, name);
667    crate::events::apply_keydown(
668        &plan,
669        name,
670        input_targets,
671        focused_input,
672        &|p: &[usize]| input_value_char_len(vnode, p),
673        on_event,
674    )
675}
676
677/// Inject the caret/selection/focus contract onto every text input in the tree.
678///
679/// Paint never reads live event state. It reads these five attrs off the styled
680/// VNode, which is what makes the caret headless-testable — a golden can be
681/// produced from a VNode alone, with no event loop running:
682///
683/// - `caret`       — caret position as a char index into the value
684/// - `caret_blink` — `"true"` when the caret bar is visible this frame
685/// - `sel_start`   — selection start char index (== `caret` when collapsed)
686/// - `sel_end`     — selection end char index
687/// - `focused`     — `"true"` when the input holds keyboard focus
688///
689/// Runs *after* `style_vnode_with_hover` (the single style-cascade application
690/// site) and before layout, so it adds no second cascade and no second
691/// rounding. Every text input always carries all five attrs, so paint never has
692/// to invent a default for a missing one.
693#[cfg(feature = "skia-native")]
694fn inject_input_caret_attrs(vnode: &VNode, focused: Option<&crate::events::InputTarget>) -> VNode {
695    /// Descend one level. `on_path` means "this node is an ancestor of (or is)
696    /// the focused input"; `focused_path` is the remainder to match.
697    fn walk(
698        node: &VNode,
699        focused_path: Option<&[usize]>,
700        on_path: bool,
701        focused: Option<&crate::events::InputTarget>,
702    ) -> VNode {
703        match node {
704            VNode::Text(_) => node.clone(),
705            VNode::Element {
706                tag,
707                props,
708                children,
709            } => {
710                let mut new_props = props.clone();
711                if crate::events::is_text_input(tag, props) {
712                    let (is_focused, cursor, blink, sel_start, sel_end) = match focused {
713                        Some(t) if on_path => {
714                            let (a, b) = t.selection().unwrap_or((t.cursor, t.cursor));
715                            (true, t.cursor, t.blink_on, a, b)
716                        }
717                        // Unfocused inputs still get the attrs, so paint reads
718                        // a total contract rather than guessing at absence.
719                        _ => (false, 0, true, 0, 0),
720                    };
721                    new_props = new_props
722                        .set("focused", if is_focused { "true" } else { "false" })
723                        .set("caret", cursor.to_string())
724                        .set("caret_blink", if blink { "true" } else { "false" })
725                        .set("sel_start", sel_start.to_string())
726                        .set("sel_end", sel_end.to_string());
727                }
728                let new_children = children
729                    .iter()
730                    .enumerate()
731                    .map(|(i, child)| {
732                        let child_on_path = on_path
733                            && focused_path
734                                .and_then(|p| p.first())
735                                .map(|first| *first == i)
736                                .unwrap_or(false);
737                        let rest = if child_on_path {
738                            focused_path.map(|p| &p[1..])
739                        } else {
740                            None
741                        };
742                        walk(child, rest, child_on_path, focused)
743                    })
744                    .collect();
745                VNode::Element {
746                    tag: tag.clone(),
747                    props: new_props,
748                    children: new_children,
749                }
750            }
751        }
752    }
753
754    let focused_path = focused.map(|t| t.path.as_slice());
755    walk(vnode, focused_path, true, focused)
756}
757
758/// Effective font family of a styled element, for caret hit testing.
759#[cfg(feature = "skia-native")]
760fn resolve_font_family(props: &velox_dom::Props) -> String {
761    props
762        .attrs
763        .get("style")
764        .and_then(|s| style_decl(s, "font-family"))
765        .filter(|f| !f.is_empty())
766        .unwrap_or("system-ui, sans-serif")
767        .to_string()
768}
769
770/// The **logical** `(w, h)` the paint lane will resolve `vw`/`vh` against this
771/// frame, read from the live surface.
772///
773/// Read from the surface, not a constant, precisely so it cannot disagree with
774/// paint: `render_frame` resolves viewport units against the same surface
775/// dimensions through the same [`logical_size`] rounding. A surface-less
776/// (headless) renderer yields `(0.0, 0.0)`, which only matters for
777/// viewport-unit padding — and there is nothing painted to disagree with.
778#[cfg(feature = "skia-native")]
779fn surface_logical_viewport(
780    renderer: &skia_backend::SkiaRenderer,
781    scale_factor: f32,
782) -> (f32, f32) {
783    match &renderer.surface {
784        Some(s) => {
785            let (w, h) = logical_size(s.width, s.height, scale_factor);
786            (w as f32, h as f32)
787        }
788        None => (0.0, 0.0),
789    }
790}
791
792/// Move keyboard focus to the text input under the cursor (or drop focus),
793/// and place the caret.
794///
795/// Shared by both event loops for the same reason `apply_edit_to_focused` is:
796/// the HMR loop used to have no focus handling at all, and a second copy of
797/// this is how the drift happened.
798///
799/// Caret placement differs by case, and the difference is deliberate:
800/// - clicking an input that did **not** have focus is a focus *gain*, so the
801///   caret goes to the end of the value (the conventional behaviour, and what
802///   makes "click a field and start typing" replace rather than prepend);
803/// - clicking an input that already had focus is a reposition, so the caret
804///   goes to the clicked glyph via [`crate::events::click_to_char_index`].
805///
806/// Returns `true` when the caller must repaint — i.e. focus actually changed or
807/// the caret moved.
808///
809/// `viewport` is the **logical** `(w, h)` the paint lane uses this frame (see
810/// [`surface_logical_viewport`]). It is threaded in rather than reconstructed
811/// here because the caret's origin is computed from the same lengths paint
812/// resolves: pass a different viewport and a `vw`/`vh` field puts its caret in
813/// one place and its glyphs in another.
814#[cfg(feature = "skia-native")]
815fn apply_click_focus(
816    input_targets: &mut [crate::events::InputTarget],
817    last_vnode: &Option<VNode>,
818    focused_input: &mut Option<Vec<usize>>,
819    x: f32,
820    y: f32,
821    scale_factor: f32,
822    viewport: (f32, f32),
823) -> bool {
824    let Some(idx) = crate::events::hit_test_input_index(input_targets, x, y) else {
825        // Clicked empty space: blur. Clearing the selection is what stops a
826        // stale highlight from surviving on a field the user has left. The
827        // caret position is deliberately kept, so re-entering the field
828        // restores where the user was.
829        let changed = crate::events::blur_focused_input(input_targets);
830        return changed || focused_input.take().is_some();
831    };
832
833    let was_focused = input_targets[idx].focused;
834    let rect = input_targets[idx].rect;
835    let path = input_targets[idx].path.clone();
836    let mut caret: Option<usize> = None;
837    let mut value_len: Option<usize> = None;
838    if let Some(vnode) = last_vnode.as_ref()
839        && let Some(VNode::Element { props, .. }) = find_node_at_path(vnode, &path)
840    {
841        let value = props.attrs.get("value").cloned().unwrap_or_default();
842        value_len = Some(value.chars().count());
843        // ONE call for both the origin and the font size. Taking the origin
844        // from here and the size from anywhere else is the defect this shape
845        // removes: two lanes that must agree, each holding its own constant.
846        let metrics = resolve_text_metrics(props, rect, viewport);
847        let font_size = metrics.font_size;
848        let config = crate::text::TextRenderConfig::new(&resolve_font_family(props), font_size);
849        // Measure through the renderer's own text stack (single measure path,
850        // skia-aware under `skia-native`) rather than a local heuristic, so the
851        // caret lands on the glyph the paint lane will actually draw.
852        let measure =
853            |s: &str| crate::text::TextMeasurer::measure_with_scale(s, &config, scale_factor).0;
854        caret = Some(crate::events::click_to_char_index(
855            &value,
856            rect,
857            x,
858            metrics.text_left,
859            font_size,
860            &measure,
861        ));
862    }
863
864    // The one place focus is granted. `focus_input` also blurs any other field
865    // that held focus, so a click straight from one field to another cannot
866    // leave two of them flagged.
867    crate::events::focus_input(input_targets, idx);
868    input_targets[idx].blink_on = true;
869    // A plain click is never a selection-extension; it collapses one.
870    input_targets[idx].anchor = None;
871    // `cursor` is a char index, so "the end" is the char count, not `len()`.
872    input_targets[idx].cursor = if was_focused {
873        caret.unwrap_or(input_targets[idx].cursor)
874    } else {
875        // Focus gain: caret to the end of the value.
876        value_len.unwrap_or(input_targets[idx].cursor)
877    };
878    *focused_input = Some(path);
879    true
880}
881
882pub mod event_binding;
883pub mod events;
884pub mod hmr;
885// The single authority on an `<input>`'s content-box geometry. Public so both
886// `skia_render`'s paint lane and `lib.rs`'s hit-test lane reach the SAME
887// function instead of each growing an inset of its own, and so a test can
888// assert against it without re-deriving the painter's arithmetic.
889pub mod input_metrics;
890pub mod text;
891pub mod viewport;
892
893pub use hmr::{DEFAULT_HMR_PORT, HmrMessage, hmr_config, run_hmr_client};
894pub use viewport::{LogicalSize, PhysicalSize, Viewport};
895
896// Native Skia GL helper module (feature-gated)
897#[cfg(feature = "skia-native")]
898mod skia_gl;
899// Skia surface and renderer helpers (feature-gated)
900#[cfg(feature = "skia-native")]
901mod skia_render;
902#[cfg(feature = "skia-native")]
903pub mod skia_surface;
904// Softbuffer presenter for window rendering (feature-gated)
905#[cfg(feature = "skia-native")]
906mod presenter;
907#[cfg(feature = "skia-native")]
908pub use skia_render::skia_impl::render_vnode_to_rgba;
909// Test/diagnostic only, and public only because `tests/` is a separate crate
910// that cannot see a private module. The decode counter is the honest way to
911// assert the persistent `<img src>` cache works; a timing assertion would
912// measure the machine instead of the cache. See `IMAGE_DECODE_COUNT`.
913#[cfg(feature = "skia-native")]
914pub use skia_render::skia_impl::{image_decode_count, reset_image_decode_count};
915#[cfg(feature = "skia-native")]
916pub use skia_render::{render_vnode_to_raster_png, render_vnode_to_raster_png_with_scale};
917
918/// In-memory representation of a mounted tree (stubbed for now).
919pub struct RenderTree {
920    pub root: VNode,
921    pub node_count: usize,
922    pub text_count: usize,
923}
924
925#[derive(Debug, Clone, PartialEq)]
926pub struct A11yNode {
927    pub id: usize,
928    pub role: String,
929    pub name: String,
930    pub rect: velox_dom::layout::Rect,
931    pub children: Vec<A11yNode>,
932}
933
934#[derive(Debug, Clone, PartialEq)]
935pub struct A11yTree {
936    pub root: A11yNode,
937}
938
939pub trait HmrRenderer {
940    /// Initialize the rendering backend (e.g. create DirectContext, verify GPU).
941    fn init() -> Result<(), String>
942    where
943        Self: Sized;
944
945    /// Mount a VNode into the renderer for display.
946    fn mount(&mut self, vnode: VNode) -> Result<(), String>;
947
948    /// Hot-update the rendered VNode.
949    fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String>;
950
951    /// Get the raw window handle for platform integration.
952    fn get_window_handle(&self) -> *mut std::ffi::c_void;
953}
954
955/// High-level renderer lifecycle trait. Backends implement this to expose
956/// a consistent `new -> mount -> hot_update` workflow that returns `Result`
957/// instead of panicking on failure.
958pub trait VeloxRenderer {
959    /// Construct a new renderer instance.
960    fn new() -> Result<Self, String>
961    where
962        Self: Sized;
963
964    /// Mount a VNode tree for rendering, returning an error on failure.
965    fn mount(&mut self, vnode: VNode) -> Result<(), String>;
966
967    /// Hot-replace the rendered VNode tree, returning an error on failure.
968    fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String>;
969}
970
971fn summarize(v: &VNode, counts: &mut (usize, usize)) {
972    match v {
973        VNode::Text(_) => {
974            counts.0 += 1;
975            counts.1 += 1;
976        }
977        VNode::Element { children, .. } => {
978            counts.0 += 1;
979            for c in children {
980                summarize(c, counts);
981            }
982        }
983    }
984}
985
986fn build_render_tree(v: &VNode) -> RenderTree {
987    let mut counts = (0, 0);
988    summarize(v, &mut counts);
989    RenderTree {
990        root: v.clone(),
991        node_count: counts.0,
992        text_count: counts.1,
993    }
994}
995
996fn vnode_text_content(node: &VNode) -> String {
997    match node {
998        VNode::Text(t) => t.clone(),
999        VNode::Element { children, .. } => {
1000            let mut out = String::new();
1001            for ch in children {
1002                let s = vnode_text_content(ch);
1003                if !s.is_empty() {
1004                    if !out.is_empty() {
1005                        out.push(' ');
1006                    }
1007                    out.push_str(&s);
1008                }
1009            }
1010            out
1011        }
1012    }
1013}
1014
1015fn a11y_role_for(tag: &str, props: &velox_dom::Props) -> String {
1016    if let Some(role) = props.attrs.get("role") {
1017        return role.clone();
1018    }
1019    match tag {
1020        "button" => "button",
1021        "img" => "image",
1022        "input" => "textbox",
1023        "label" => "label",
1024        "a" => "link",
1025        _ => "group",
1026    }
1027    .to_string()
1028}
1029
1030fn a11y_name_for(tag: &str, props: &velox_dom::Props, node: &VNode) -> String {
1031    if let Some(label) = props.attrs.get("aria-label") {
1032        return label.clone();
1033    }
1034    if tag == "img"
1035        && let Some(alt) = props.attrs.get("alt")
1036    {
1037        return alt.clone();
1038    }
1039    vnode_text_content(node)
1040}
1041
1042fn build_a11y_tree_with_layout(
1043    vnode: &VNode,
1044    layout: &velox_dom::layout::LayoutNode,
1045    next_id: &mut usize,
1046) -> A11yNode {
1047    let id = *next_id;
1048    *next_id += 1;
1049    match vnode {
1050        VNode::Text(t) => A11yNode {
1051            id,
1052            role: "text".to_string(),
1053            name: t.clone(),
1054            rect: layout.rect,
1055            children: Vec::new(),
1056        },
1057        VNode::Element {
1058            tag,
1059            props,
1060            children,
1061            ..
1062        } => {
1063            let mut child_nodes = Vec::new();
1064            for ch_layout in &layout.children {
1065                if ch_layout.display_none {
1066                    continue;
1067                }
1068                if let Some(src_idx) = ch_layout.source_index
1069                    && let Some(ch) = children.get(src_idx)
1070                {
1071                    child_nodes.push(build_a11y_tree_with_layout(ch, ch_layout, next_id));
1072                }
1073            }
1074            A11yNode {
1075                id,
1076                role: a11y_role_for(tag, props),
1077                name: a11y_name_for(tag, props, vnode),
1078                rect: layout.rect,
1079                children: child_nodes,
1080            }
1081        }
1082    }
1083}
1084
1085/// Build the accessibility tree for `vnode` laid out at `width` x `height`.
1086///
1087/// DELIBERATE EXCEPTION to R-8's single-funnel invariant: this function runs its
1088/// own `compute_layout` and does not go through the renderer's `prepare_frame`.
1089///
1090/// The reason is that this is not a render path. It takes no `Stylesheet`, paints
1091/// nothing, and is given no `SkiaSurface`; its only output is a tree of roles,
1092/// names and rects. Funnelling it would mean inventing a stylesheet and a surface
1093/// for a caller that has neither, and then applying a style cascade to a tree
1094/// whose geometry — not its appearance — is what is being reported. It is named
1095/// here so the exception is on the record rather than discovered later as a
1096/// "missing" funnel site.
1097pub fn build_a11y_tree(vnode: &VNode, width: i32, height: i32) -> A11yTree {
1098    let layout = velox_dom::layout::compute_layout(vnode, width, height);
1099    let mut next_id = 1;
1100    let root = build_a11y_tree_with_layout(vnode, &layout, &mut next_id);
1101    A11yTree { root }
1102}
1103
1104/// Minimal renderer trait. Backends implement this to expose a consistent API.
1105pub trait Renderer {
1106    fn backend_name(&self) -> &'static str;
1107    fn mount(&self, vnode: &VNode) -> Result<RenderTree, String>;
1108}
1109
1110// Real Skia backend only when `skia-native` is enabled.
1111#[cfg(feature = "skia-native")]
1112pub mod skia_backend {
1113    use crate::HmrRenderer;
1114    use crate::VeloxRenderer;
1115    #[cfg(feature = "skia-native")]
1116    use crate::skia_gl;
1117    #[cfg(feature = "skia-native")]
1118    use crate::skia_surface;
1119    #[cfg(feature = "skia-native")]
1120    use raw_window_handle::HasRawWindowHandle;
1121    use velox_dom::VNode;
1122
1123    pub fn init() -> Result<(), String> {
1124        match skia_gl::create_context() {
1125            Ok(gl_ctx) => match gl_ctx.into_direct_context() {
1126                Some(_dctx) => {
1127                    log::info!("skia backend: init OK (DirectContext created)");
1128                    Ok(())
1129                }
1130                None => Err("skia backend: init failed: couldn't create DirectContext".to_string()),
1131            },
1132            Err(e) => Err(format!("skia backend: init failed: {}", e)),
1133        }
1134    }
1135
1136    pub struct SkiaRenderer {
1137        pub surface: Option<skia_surface::SkiaSurface>,
1138        pub vnode: Option<VNode>,
1139    }
1140
1141    impl SkiaRenderer {
1142        pub fn with_window(
1143            window: &impl HasRawWindowHandle,
1144            width: i32,
1145            height: i32,
1146        ) -> Result<Self, String> {
1147            match skia_surface::create_window_surface_from_handle(window, width, height) {
1148                Ok(s) => Ok(SkiaRenderer {
1149                    surface: Some(s),
1150                    vnode: None,
1151                }),
1152                Err(e) => Err(e),
1153            }
1154        }
1155
1156        pub fn present(&mut self) -> Result<(), String> {
1157            if let Some(s) = &mut self.surface {
1158                s.present()
1159            } else {
1160                Ok(())
1161            }
1162        }
1163
1164        pub fn resize(&mut self, width: i32, height: i32) -> Result<(), String> {
1165            if let Some(s) = &mut self.surface {
1166                s.resize(width, height)
1167            } else {
1168                Ok(())
1169            }
1170        }
1171    }
1172
1173    impl crate::Renderer for SkiaRenderer {
1174        fn backend_name(&self) -> &'static str {
1175            "skia"
1176        }
1177        fn mount(&self, vnode: &velox_dom::VNode) -> Result<crate::RenderTree, String> {
1178            Ok(crate::build_render_tree(vnode))
1179        }
1180    }
1181
1182    impl HmrRenderer for SkiaRenderer {
1183        fn init() -> Result<(), String> {
1184            init()
1185        }
1186
1187        fn mount(&mut self, vnode: VNode) -> Result<(), String> {
1188            self.vnode = Some(vnode);
1189            Ok(())
1190        }
1191
1192        fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String> {
1193            self.vnode = Some(new_vnode);
1194            Ok(())
1195        }
1196
1197        fn get_window_handle(&self) -> *mut std::ffi::c_void {
1198            std::ptr::null_mut()
1199        }
1200    }
1201
1202    impl VeloxRenderer for SkiaRenderer {
1203        fn new() -> Result<Self, String> {
1204            Ok(SkiaRenderer {
1205                surface: None,
1206                vnode: None,
1207            })
1208        }
1209
1210        fn mount(&mut self, vnode: VNode) -> Result<(), String> {
1211            self.vnode = Some(vnode);
1212            Ok(())
1213        }
1214
1215        fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String> {
1216            self.vnode = Some(new_vnode);
1217            Ok(())
1218        }
1219    }
1220}
1221
1222// Skia stub backend to allow compiling with `--features skia` without native deps.
1223#[cfg(all(feature = "skia", not(feature = "skia-native")))]
1224pub mod skia_backend {
1225    pub fn init() -> Result<(), String> {
1226        Ok(())
1227    }
1228
1229    pub struct SkiaRenderer;
1230    impl crate::Renderer for SkiaRenderer {
1231        fn backend_name(&self) -> &'static str {
1232            "skia"
1233        }
1234        fn mount(&self, vnode: &velox_dom::VNode) -> Result<crate::RenderTree, String> {
1235            Ok(crate::build_render_tree(vnode))
1236        }
1237    }
1238    impl crate::HmrRenderer for SkiaRenderer {
1239        fn init() -> Result<(), String> {
1240            Ok(())
1241        }
1242        fn mount(&mut self, _vnode: velox_dom::VNode) -> Result<(), String> {
1243            Ok(())
1244        }
1245        fn hot_update(&mut self, _new_vnode: velox_dom::VNode) -> Result<(), String> {
1246            Ok(())
1247        }
1248        fn get_window_handle(&self) -> *mut std::ffi::c_void {
1249            std::ptr::null_mut()
1250        }
1251    }
1252    impl crate::VeloxRenderer for SkiaRenderer {
1253        fn new() -> Result<Self, String> {
1254            Ok(SkiaRenderer)
1255        }
1256        fn mount(&mut self, _vnode: velox_dom::VNode) -> Result<(), String> {
1257            Ok(())
1258        }
1259        fn hot_update(&mut self, _new_vnode: velox_dom::VNode) -> Result<(), String> {
1260            Ok(())
1261        }
1262    }
1263}
1264
1265/// Stub init used when no backend features are enabled.
1266#[cfg(not(feature = "skia"))]
1267pub fn init() -> Result<(), String> {
1268    // Intentionally empty — no backend enabled
1269    Ok(())
1270}
1271
1272// Simple identifier of the selected backend, useful for tests.
1273#[cfg(feature = "skia")]
1274pub const BACKEND: &str = "skia";
1275#[cfg(not(feature = "skia"))]
1276pub const BACKEND: &str = "stub";
1277
1278pub fn backend_name() -> &'static str {
1279    BACKEND
1280}
1281
1282/// Feature-selected renderer type and constructor for tests and examples.
1283#[cfg(any(feature = "skia", feature = "skia-native"))]
1284pub type SelectedRenderer = skia_backend::SkiaRenderer;
1285#[cfg(not(feature = "skia"))]
1286pub struct StubRenderer;
1287#[cfg(not(feature = "skia"))]
1288pub type SelectedRenderer = StubRenderer;
1289#[cfg(not(feature = "skia"))]
1290impl Renderer for StubRenderer {
1291    fn backend_name(&self) -> &'static str {
1292        "stub"
1293    }
1294    fn mount(&self, vnode: &VNode) -> Result<RenderTree, String> {
1295        Ok(build_render_tree(vnode))
1296    }
1297}
1298#[cfg(not(feature = "skia"))]
1299impl HmrRenderer for StubRenderer {
1300    fn init() -> Result<(), String> {
1301        Ok(())
1302    }
1303    fn mount(&mut self, _vnode: VNode) -> Result<(), String> {
1304        Ok(())
1305    }
1306    fn hot_update(&mut self, _new_vnode: VNode) -> Result<(), String> {
1307        Ok(())
1308    }
1309    fn get_window_handle(&self) -> *mut std::ffi::c_void {
1310        std::ptr::null_mut()
1311    }
1312}
1313#[cfg(not(feature = "skia"))]
1314impl VeloxRenderer for StubRenderer {
1315    fn new() -> Result<Self, String> {
1316        Ok(StubRenderer)
1317    }
1318    fn mount(&mut self, _vnode: VNode) -> Result<(), String> {
1319        Ok(())
1320    }
1321    fn hot_update(&mut self, _new_vnode: VNode) -> Result<(), String> {
1322        Ok(())
1323    }
1324}
1325
1326/// Construct the feature-selected renderer.
1327pub fn new_selected_renderer() -> SelectedRenderer {
1328    #[cfg(feature = "skia-native")]
1329    {
1330        // Construct SkiaRenderer with no surface for the default selected renderer.
1331        skia_backend::SkiaRenderer {
1332            surface: None,
1333            vnode: None,
1334        }
1335    }
1336    #[cfg(all(feature = "skia", not(feature = "skia-native")))]
1337    {
1338        skia_backend::SkiaRenderer
1339    }
1340    #[cfg(not(feature = "skia"))]
1341    {
1342        StubRenderer
1343    }
1344}
1345
1346pub use events::Runtime as EventRuntime;
1347
1348/// Test helper: exercise a small Skia draw path (native-only).
1349#[cfg(all(feature = "skia-native", unix))]
1350pub fn skia_draw_test_frame() -> Result<(), String> {
1351    crate::skia_gl::draw_gpu_test_frame(256, 256)
1352}
1353
1354/// Convenience wrapper to create a Skia `DirectContext` from the crate root.
1355///
1356/// Returns the owning `GlDirectContext` pair, which holds the headless EGL
1357/// context alongside the `DirectContext`: handing back a bare `DirectContext`
1358/// would return a handle whose GL context had already been destroyed.
1359#[cfg(all(feature = "skia-native", unix))]
1360pub fn create_direct_context() -> Result<crate::skia_gl::GlDirectContext, String> {
1361    crate::skia_gl::create_direct_context()
1362}
1363
1364/// Extracts the human-readable message from a caught panic payload
1365/// (`panic!("literal")` stores `&'static str`, `format!`-based panics store
1366/// `String`). Used so compositor failures caught by `catch_unwind` are
1367/// surfaced instead of silently discarded (CX-13 / F-23).
1368#[cfg(feature = "skia-native")]
1369fn panic_detail(payload: Box<dyn std::any::Any + Send>) -> String {
1370    if let Some(s) = payload.downcast_ref::<&'static str>() {
1371        (*s).to_string()
1372    } else if let Some(s) = payload.downcast_ref::<String>() {
1373        s.clone()
1374    } else {
1375        "unknown panic payload".to_string()
1376    }
1377}
1378
1379// ---------------------------------------------------------------------------
1380// Shared window-event arms (Task 5.6b)
1381// ---------------------------------------------------------------------------
1382//
1383// `run_window_vnode_skia` and `run_window_vnode_skia_with_hmr` are the same loop
1384// written twice, and six `WindowEvent` arms were copy-pasted between them. Two
1385// copies means every fix has to be made twice, and this repo has already paid
1386// for that: the HMR keyboard arm shipped as a bare `_ => {}` stub, and the HMR
1387// loop had no `ReceivedCharacter` arm at all, so typing silently did nothing
1388// under HMR. Each function below is the single implementation of one arm, and
1389// both loops call it.
1390//
1391// Two rules, both load-bearing:
1392//
1393//   * These functions never touch `ControlFlow`. The loops keep every
1394//     `*control_flow = ...` assignment, because parking on `Wait` (not `Poll`,
1395//     not `WaitUntil`) is right for a non-game application: `Poll` would burn a
1396//     core while idle and `WaitUntil` would swallow the events these arms
1397//     handle. `tests/event_loop_arms.rs` pins that, and it is easier to keep
1398//     pinned when the control flow never leaves the loop.
1399//   * Shared state is passed as arguments instead of moving into a struct, so
1400//     the three arms that are *not* here keep reading the loop's own locals
1401//     under their own names — see the note on why those three stayed behind.
1402//
1403// Why six of the nine, and not nine:
1404//
1405//   * Extracted: `CloseRequested`, `Resized`, `ScaleFactorChanged`,
1406//     `CursorMoved`, `ModifiersChanged`, `MouseWheel`.
1407//   * Left in place: the left-click, `KeyboardInput` and `ReceivedCharacter`
1408//     arms. `tests/event_loop_arms.rs` pins their bodies as *literal source
1409//     text* inside each loop — `apply_click_focus(`, `&mut focused_input`,
1410//     `hit_test_click`, `edit_action_for_key(keycode, shift_held)`,
1411//     `arm_blink_deadline(&mut blink_deadline)`, `EditAction::Insert(c)` — and
1412//     `the_duplicated_arms_of_both_loops_make_the_same_calls` compares their
1413//     callee sequences so a second divergence cannot appear beside the one
1414//     known HMR-only `last_vnode` republish. Moving the bodies out of the
1415//     loops deletes that guard along with the duplicate: the drift test would
1416//     compare two identical one-line call sites and never see the arms again.
1417//     Those three are the arms the caret and typing regressions both lived in,
1418//     so they keep a live drift test instead of a second copy. Task 5.6 step 3
1419//     is what actually finishes the job — see the report for why it needs
1420//     production-code change before it can exist.
1421
1422/// `WindowEvent::CloseRequested` — shared by both loops.
1423///
1424/// Returns `true` so the *caller* performs the exit. `ControlFlow` belongs to
1425/// the loop, and keeping the assignment at the call site keeps it visible to
1426/// the structural test.
1427#[cfg(feature = "skia-native")]
1428fn window_close_requested() -> bool {
1429    velox_core::lifecycle::run_all_destroy_hooks();
1430    true
1431}
1432
1433/// `WindowEvent::Resized` — shared by both loops.
1434///
1435/// R-L3: coalesce. A drag produces many `Resized` events and only the last one
1436/// per frame may materialise, so this records the pending size and asks for a
1437/// redraw. It must not touch the raster surface; surface recreation belongs to
1438/// `RedrawRequested`.
1439#[cfg(feature = "skia-native")]
1440fn window_resized(
1441    resize_state: &mut ResizeState,
1442    window_opt: &Option<winit::window::Window>,
1443    size: (u32, u32),
1444) {
1445    // `ResizeState::queue` is the production `pending_resize = Some(..)` path.
1446    resize_state.queue(size);
1447    if let Some(w) = window_opt.as_ref() {
1448        w.request_redraw();
1449    }
1450}
1451
1452/// `WindowEvent::ScaleFactorChanged` — shared by both loops.
1453///
1454/// R-M1: the physical cursor must not move when the scale factor changes, so
1455/// the logical `mouse_pos` is rescaled by `old / new` in the same step that
1456/// adopts the new factor. R-L3: the pending resize and the surface scale are
1457/// updated here, but nothing is recreated — layout and hit-test refresh wait
1458/// for `RedrawRequested` (single layout/frame, R-H5).
1459#[cfg(feature = "skia-native")]
1460fn window_scale_factor_changed(
1461    scale_factor: &mut f32,
1462    mouse_pos: &mut (f32, f32),
1463    resize_state: &mut ResizeState,
1464    surface: &mut Option<crate::skia_surface::SkiaSurface>,
1465    window_opt: &Option<winit::window::Window>,
1466    new_scale: f64,
1467    new_inner_size: (u32, u32),
1468) {
1469    let old_scale = *scale_factor;
1470    *scale_factor = new_scale as f32;
1471    if old_scale.is_finite() && old_scale > 0.0 && scale_factor.is_finite() && *scale_factor > 0.0 {
1472        mouse_pos.0 = mouse_pos.0 * old_scale / *scale_factor;
1473        mouse_pos.1 = mouse_pos.1 * old_scale / *scale_factor;
1474    }
1475    resize_state.queue(new_inner_size);
1476    if let Some(s) = surface.as_mut() {
1477        s.set_scale_factor(*scale_factor);
1478    }
1479    if let Some(w) = window_opt.as_ref() {
1480        w.request_redraw();
1481    }
1482}
1483
1484/// `WindowEvent::CursorMoved` — shared by both loops.
1485///
1486/// winit reports physical pixels while hit-testing and layout are logical, so
1487/// the position is divided by the scale factor. Only a *change* of hover asks
1488/// for a redraw: a cursor move within the same target paints identically.
1489#[cfg(feature = "skia-native")]
1490fn window_cursor_moved(
1491    scale_factor: f32,
1492    mouse_pos: &mut (f32, f32),
1493    hover_targets: &[crate::events::HoverTarget],
1494    hovered_id: &mut Option<u32>,
1495    window_opt: &Option<winit::window::Window>,
1496    position: (f32, f32),
1497) {
1498    *mouse_pos = (position.0 / scale_factor, position.1 / scale_factor);
1499    let now_hovered = crate::events::hit_test_hover(hover_targets, mouse_pos.0, mouse_pos.1);
1500    if now_hovered != *hovered_id {
1501        *hovered_id = now_hovered;
1502        if let Some(w) = window_opt.as_ref() {
1503            w.request_redraw();
1504        }
1505    }
1506}
1507
1508/// `WindowEvent::ModifiersChanged` — shared by both loops.
1509///
1510/// Latches Shift for Shift-extends-selection. winit 0.28 deprecates
1511/// `KeyboardInput::modifiers` in favour of this event, so the caret reads the
1512/// latched value instead of the deprecated per-key field.
1513#[cfg(feature = "skia-native")]
1514fn window_modifiers_changed(shift_held: &mut bool, shift: bool) {
1515    *shift_held = shift;
1516}
1517
1518/// Convert a winit scroll delta into logical pixels, negated so that wheel-down
1519/// increases the scroll offset and reveals content below.
1520///
1521/// winit reports positive `y` for wheel-up; one line is 40 logical px, and
1522/// `PixelDelta` is physical so it needs the scale factor.
1523#[cfg(feature = "skia-native")]
1524fn window_wheel_delta_y(delta: winit::event::MouseScrollDelta, scale_factor: f32) -> f32 {
1525    match delta {
1526        winit::event::MouseScrollDelta::LineDelta(_, y) => -y * 40.0,
1527        winit::event::MouseScrollDelta::PixelDelta(pos) => -(pos.y as f32) / scale_factor,
1528    }
1529}
1530
1531/// `WindowEvent::MouseWheel` — shared by both loops.
1532///
1533/// Scrollable overflow: the deepest scrollable under the cursor, clamped by
1534/// `apply_wheel_scroll`, which returns whether the offset actually moved. A
1535/// wheel event that arrives before the first frame has no layout to scroll and
1536/// must be a no-op rather than a panic.
1537#[cfg(feature = "skia-native")]
1538fn window_mouse_wheel(
1539    scale_factor: f32,
1540    mouse_pos: (f32, f32),
1541    last_layout: &Option<velox_dom::layout::LayoutNode>,
1542    scroll_offsets: &mut std::collections::HashMap<Vec<usize>, f32>,
1543    window_opt: &Option<winit::window::Window>,
1544    delta: winit::event::MouseScrollDelta,
1545) {
1546    let delta_y = window_wheel_delta_y(delta, scale_factor);
1547    if let Some(layout) = last_layout.as_ref()
1548        && crate::events::apply_wheel_scroll(
1549            layout,
1550            mouse_pos.0,
1551            mouse_pos.1,
1552            scroll_offsets,
1553            delta_y,
1554        )
1555        && let Some(w) = window_opt.as_ref()
1556    {
1557        w.request_redraw();
1558    }
1559}
1560
1561// Behavioural tests for the shared arms.
1562//
1563// The loops themselves can only run inside a live winit window, and winit is an
1564// optional dependency rather than a dev-dependency, so nothing under `tests/`
1565// can name a `WindowEvent` and reach these functions. `tests/event_loop_arms.rs`
1566// pins the *source text* of what is left in the loops, which catches the two
1567// copies drifting apart from each other but says nothing about whether either
1568// one is correct. These tests are that missing check: each one states an
1569// observable property of a shared arm, so deleting or breaking the shared
1570// implementation turns them red.
1571//
1572// They run under `cargo test --features velox-renderer/skia-native`; a plain
1573// `cargo test -p velox-renderer` does not enable `skia-native` and does not
1574// compile the loops or these tests at all.
1575#[cfg(all(test, feature = "skia-native"))]
1576mod window_event_shared_tests {
1577    use super::*;
1578    use std::collections::HashMap;
1579    use winit::dpi::PhysicalPosition;
1580    use winit::event::MouseScrollDelta;
1581
1582    /// Stand-in for the loop's `window_opt`. A `None` window is the case that
1583    /// matters for these assertions: every shared arm must still do its state
1584    /// work, and must simply skip the redraw request.
1585    fn no_window() -> Option<winit::window::Window> {
1586        None
1587    }
1588
1589    fn hover_target(x: i32, y: i32, w: i32, h: i32, id: u32) -> crate::events::HoverTarget {
1590        crate::events::HoverTarget {
1591            rect: velox_dom::layout::Rect { x, y, w, h },
1592            id,
1593            z_index: 0,
1594            order: 0,
1595            clip: None,
1596            sc: crate::events::StackCtx::ROOT,
1597        }
1598    }
1599
1600    #[test]
1601    fn close_requested_tells_the_caller_to_exit() {
1602        // `ControlFlow` is the loop's to own, so the shared arm signals instead
1603        // of setting it. A false here would leave the window running.
1604        assert!(window_close_requested());
1605    }
1606
1607    #[test]
1608    fn resized_keeps_only_the_last_size_of_a_drag() {
1609        // R-L3 coalescing: a drag emits many `Resized` events and only the
1610        // final one may be committed, so a resize storm cannot queue a backlog.
1611        let mut state = ResizeState::new();
1612        let window = no_window();
1613        window_resized(&mut state, &window, (800, 600));
1614        window_resized(&mut state, &window, (1024, 768));
1615        assert_eq!(state.take_pending(), Some((1024, 768)));
1616        assert_eq!(state.take_pending(), None);
1617    }
1618
1619    #[test]
1620    fn scale_factor_change_rescales_the_cursor_so_the_physical_point_is_stable() {
1621        // R-M1: the physical cursor does not move when the scale factor does,
1622        // so the logical position has to be divided by the same ratio.
1623        let mut state = ResizeState::new();
1624        let window = no_window();
1625        let mut surface: Option<crate::skia_surface::SkiaSurface> = None;
1626        let mut scale_factor = 1.0f32;
1627        let mut mouse_pos = (100.0f32, 200.0f32);
1628
1629        window_scale_factor_changed(
1630            &mut scale_factor,
1631            &mut mouse_pos,
1632            &mut state,
1633            &mut surface,
1634            &window,
1635            2.0,
1636            (1920, 1080),
1637        );
1638
1639        assert_eq!(scale_factor, 2.0);
1640        assert_eq!(mouse_pos, (50.0, 100.0));
1641        // The pending resize is queued by the same arm (R-L3).
1642        assert_eq!(state.take_pending(), Some((1920, 1080)));
1643    }
1644
1645    #[test]
1646    fn scale_factor_change_never_divides_by_a_degenerate_old_scale() {
1647        // A 0, negative or NaN old scale cannot produce a meaningful ratio.
1648        // Rescaling by it anyway would poison `mouse_pos` with NaN or infinity,
1649        // after which every hover hit-test silently misses forever.
1650        for degenerate in [0.0f32, -1.0, f32::NAN, f32::INFINITY] {
1651            let mut state = ResizeState::new();
1652            let window = no_window();
1653            let mut surface: Option<crate::skia_surface::SkiaSurface> = None;
1654            let mut scale_factor = degenerate;
1655            let mut mouse_pos = (10.0f32, 20.0f32);
1656
1657            window_scale_factor_changed(
1658                &mut scale_factor,
1659                &mut mouse_pos,
1660                &mut state,
1661                &mut surface,
1662                &window,
1663                2.0,
1664                (800, 600),
1665            );
1666
1667            assert_eq!(
1668                scale_factor, 2.0,
1669                "old_scale={degenerate}: new scale adopted"
1670            );
1671            assert_eq!(
1672                mouse_pos,
1673                (10.0, 20.0),
1674                "old_scale={degenerate}: cursor left alone"
1675            );
1676            // The pending resize is not conditional on the cursor arithmetic.
1677            assert_eq!(state.take_pending(), Some((800, 600)));
1678        }
1679    }
1680
1681    #[test]
1682    fn cursor_moved_converts_physical_pixels_to_logical_units() {
1683        // winit reports physical pixels; layout and hit-testing are logical.
1684        // Skipping this division puts the cursor half a screen away at 2x.
1685        let window = no_window();
1686        let mut mouse_pos = (0.0f32, 0.0f32);
1687        let mut hovered_id: Option<u32> = None;
1688
1689        window_cursor_moved(
1690            2.0,
1691            &mut mouse_pos,
1692            &[],
1693            &mut hovered_id,
1694            &window,
1695            (300.0, 200.0),
1696        );
1697
1698        assert_eq!(mouse_pos, (150.0, 100.0));
1699    }
1700
1701    #[test]
1702    fn cursor_moved_latches_and_clears_the_hover_target_under_the_logical_cursor() {
1703        let window = no_window();
1704        let targets = [hover_target(0, 0, 200, 200, 7)];
1705        let mut mouse_pos = (0.0f32, 0.0f32);
1706        let mut hovered_id: Option<u32> = None;
1707
1708        // 300 physical px at 2.0 is 150 logical, inside the 0..200 target.
1709        window_cursor_moved(
1710            2.0,
1711            &mut mouse_pos,
1712            &targets,
1713            &mut hovered_id,
1714            &window,
1715            (300.0, 200.0),
1716        );
1717        assert_eq!(hovered_id, Some(7));
1718
1719        // Leaving the target has to clear the latch, or the hover style sticks.
1720        window_cursor_moved(
1721            2.0,
1722            &mut mouse_pos,
1723            &targets,
1724            &mut hovered_id,
1725            &window,
1726            (900.0, 800.0),
1727        );
1728        assert_eq!(hovered_id, None);
1729    }
1730
1731    #[test]
1732    fn modifiers_changed_latches_the_shift_state_the_caret_reads() {
1733        // winit 0.28 deprecated the per-key `KeyboardInput::modifiers` field, so
1734        // Shift-extends-selection depends on this latch being the only writer.
1735        let mut shift_held = false;
1736        window_modifiers_changed(&mut shift_held, true);
1737        assert!(shift_held);
1738        window_modifiers_changed(&mut shift_held, false);
1739        assert!(!shift_held);
1740    }
1741
1742    #[test]
1743    fn wheel_deltas_become_logical_pixels_in_the_natural_direction() {
1744        // winit reports positive y for wheel-up; the offset must grow when the
1745        // wheel is pushed down, and one line is 40 logical px.
1746        assert_eq!(
1747            window_wheel_delta_y(MouseScrollDelta::LineDelta(0.0, 1.0), 1.0),
1748            -40.0
1749        );
1750        assert_eq!(
1751            window_wheel_delta_y(MouseScrollDelta::LineDelta(0.0, -1.0), 1.0),
1752            40.0
1753        );
1754        // The horizontal component is not a vertical scroll.
1755        assert_eq!(
1756            window_wheel_delta_y(MouseScrollDelta::LineDelta(7.0, 1.0), 1.0),
1757            -40.0
1758        );
1759        // `PixelDelta` is physical, so it needs the scale factor.
1760        assert_eq!(
1761            window_wheel_delta_y(
1762                MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, -100.0)),
1763                2.0,
1764            ),
1765            50.0
1766        );
1767    }
1768
1769    #[test]
1770    fn a_wheel_event_before_the_first_frame_is_a_no_op() {
1771        // A wheel can arrive before any `RedrawRequested`, when there is no
1772        // layout to scroll. That must be inert, not a panic.
1773        let window = no_window();
1774        let mut scroll_offsets: HashMap<Vec<usize>, f32> = HashMap::new();
1775        window_mouse_wheel(
1776            1.0,
1777            (10.0, 10.0),
1778            &None,
1779            &mut scroll_offsets,
1780            &window,
1781            MouseScrollDelta::LineDelta(0.0, 3.0),
1782        );
1783        assert!(scroll_offsets.is_empty());
1784    }
1785}
1786
1787/// Run a Skia window whose contents are produced by `make_view`.
1788///
1789/// # Viewport contract (1A / X-H1) — Viewport Root Normalization (CX-04)
1790///
1791/// `make_view` is `FnMut(w: u32, h: u32) -> (VNode, Stylesheet)` where
1792/// `(w, h)` are **logical viewport dimensions** — `Viewport::from_i32(physical, scale).logical_size()`.
1793/// The renderer calls it:
1794/// * once on startup,
1795/// * on every `RedrawRequested` / resize / DPI change with the *current* logical `w,h`.
1796///   Templates must not ignore `(w,h)` (no `|_w, _h|`). The canonical
1797///   responsive pattern is a viewport-filling root:
1798///   `width: 100%; min-height: 100vh` on `.app` (see `veloxc/templates/project/src/App.vx`).
1799///   With `width:100%` and `min-height:100vh`, `compute_layout(vnode, w as i32, h as i32)`
1800///   reflows visibly on every window resize — no element hidden when it should be visible
1801///   (Flutter invariant). Callers may also thread `(w,h)` into style/layout decisions if needed.
1802///
1803/// ## Root normalization
1804///
1805/// `velox_dom::layout::root_is_viewport_filling` ensures the *first* VNode (index 0 / `None`)
1806/// always fills the logical viewport — even without explicit `width:100%`. The expanded
1807/// predicate `is_viewport_filling(style, is_root_index)` additionally treats `100% | 100vw | 100dvw | 100vh | 100dvh | min-height:100%|vh|dvh`
1808/// as viewport-filling, validated via `compute_layout` percent chains (`height:100%` fills
1809/// when parent is definite) and `vw/vh/dvh` units that resolve against `Viewport::logical_size`.
1810#[cfg(feature = "skia-native")]
1811pub fn run_window_vnode_skia<F, G, H>(
1812    title: &str,
1813    mut make_view: F,
1814    mut on_event: G,
1815    mut get_title: H,
1816) -> Result<(), String>
1817where
1818    F: FnMut(u32, u32) -> (velox_dom::VNode, Stylesheet) + 'static,
1819    G: FnMut(&str, Option<&str>) + 'static,
1820    H: FnMut() -> String + 'static,
1821{
1822    use winit::dpi::PhysicalSize;
1823    use winit::event::{ElementState, Event, MouseButton, StartCause, WindowEvent};
1824    use winit::event_loop::{ControlFlow, EventLoop};
1825    use winit::window::WindowBuilder;
1826
1827    // Headless mode: when no compositor is available (CI, containers, SSH)
1828    // we still create the window + run the event loop but skip softbuffer
1829    // presentation, rendering offscreen only. Enable with VELOX_HEADLESS=1
1830    // or it is auto-detected via presenter::is_compositor_available().
1831    let headless_env = std::env::var("VELOX_HEADLESS").as_deref() == Ok("1")
1832        || !crate::presenter::is_compositor_available();
1833
1834    let mut last_vnode: Option<velox_dom::VNode> = None;
1835    let mut _hmr_pending = false;
1836
1837    // Prepare the winit backend: force X11 if Wayland socket is stale to
1838    // avoid winit's Wayland backend calling process::exit() on EPIPE.
1839    let _headless_check = crate::presenter::prepare_backend();
1840
1841    // Try to create a winit event loop and window. In headless mode or when
1842    // no compositor is available, both can fail (winit may panic instead of
1843    // returning Err) — we catch this and proceed with headless rendering.
1844    //
1845    // A NON-display failure is not headless-eligible: it is returned as a
1846    // typed `VeloxError::Window`, never panicked. The old `panic!` here was
1847    // caught by the `catch_unwind` below and silently converted into the same
1848    // headless fallback as a broken pipe, which masked real configuration
1849    // errors (bad size, closed display fd) as "running headless".
1850    // Factored out of the `let` below: clippy::type_complexity fires on the
1851    // inline annotation. Behaviour-identical alias.
1852    type WindowOutcome = Result<
1853        (
1854            Option<winit::event_loop::EventLoop<()>>,
1855            Option<winit::window::Window>,
1856            PhysicalSize<u32>,
1857            f32,
1858        ),
1859        String,
1860    >;
1861    let window_outcome: WindowOutcome =
1862        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1863            let event_loop = EventLoop::new();
1864            let window_result = WindowBuilder::new()
1865                .with_title(title)
1866                .with_inner_size(PhysicalSize::new(800, 600))
1867                .build(&event_loop);
1868            match window_result {
1869                Ok(w) => {
1870                    let size = w.inner_size();
1871                    let sf = w.scale_factor() as f32;
1872                    Ok((Some(event_loop), Some(w), size, sf))
1873                }
1874                Err(e) => {
1875                    let msg = e.to_string();
1876                    let lower = msg.to_ascii_lowercase();
1877                    let is_display_err = lower.contains("broken pipe")
1878                        || lower.contains("os error 32")
1879                        || lower.contains("no compositor")
1880                        || lower.contains("no display server")
1881                        || lower.contains("failed to connect");
1882                    if headless_env || is_display_err {
1883                        log::warn!("window creation failed — continuing in headless mode: {msg}");
1884                        Ok((Some(event_loop), None, PhysicalSize::new(800, 600), 1.0))
1885                    } else {
1886                        Err(
1887                            velox_dom::VeloxError::Window(format!("failed to create window: {e}"))
1888                                .to_string(),
1889                        )
1890                    }
1891                }
1892            }
1893        }))
1894        .unwrap_or_else(|payload| {
1895            // Surface the panic payload (usually the compositor error, e.g.
1896            // broken pipe) instead of silently masking it — log::warn is
1897            // invisible without an initialized logger (CX-13 / F-23).
1898            eprintln!(
1899                "[velox] window/event loop creation panicked — continuing in headless mode: {}",
1900                panic_detail(payload)
1901            );
1902            Ok((None, None, PhysicalSize::new(800, 600), 1.0))
1903        });
1904    let (event_loop_opt, window, window_size, scale_factor) = window_outcome?;
1905
1906    let window_opt: Option<winit::window::Window> = window;
1907    let mut renderer = match crate::skia_surface::SkiaSurface::new_raster(
1908        window_size.width as i32,
1909        window_size.height as i32,
1910    ) {
1911        Ok(surface) => skia_backend::SkiaRenderer {
1912            surface: Some(surface),
1913            vnode: None,
1914        },
1915        Err(e) => {
1916            return Err(format!("failed to create SkiaSurface: {e}"));
1917        }
1918    };
1919    let mut presenter: Option<crate::presenter::SoftbufferPresenter> = None;
1920    if let Some(w) = window_opt.as_ref() {
1921        // softbuffer::Context::new() can panic when the display server is
1922        // unreachable even though DISPLAY/WAYLAND_DISPLAY are set (broken pipe).
1923        // Catch such panics and degrade to headless rendering.
1924        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1925            crate::presenter::SoftbufferPresenter::new(w, window_size.width, window_size.height)
1926        })) {
1927            Ok(Ok(p)) => presenter = Some(p),
1928            Ok(Err(e)) => {
1929                if headless_env {
1930                    log::warn!(
1931                        "softbuffer presenter unavailable — continuing in headless mode: {e}"
1932                    );
1933                } else {
1934                    return Err(format!("failed to create softbuffer presenter: {e}"));
1935                }
1936            }
1937            Err(payload) => {
1938                // Surface the panic payload (softbuffer panics with a
1939                // broken pipe when the display is unreachable) instead of
1940                // silently masking it (CX-13 / F-23).
1941                eprintln!(
1942                    "[velox] softbuffer presenter creation panicked — continuing in headless mode: {}",
1943                    panic_detail(payload)
1944                );
1945            }
1946        }
1947    }
1948    let mut scale_factor = scale_factor;
1949    let mut mouse_pos = (0.0f32, 0.0f32);
1950    let mut hovered_id: Option<u32> = None;
1951    let mut click_targets: Vec<crate::events::ClickTarget> = Vec::new();
1952    let mut hover_targets: Vec<crate::events::HoverTarget> = Vec::new();
1953    let mut input_targets: Vec<crate::events::InputTarget> = Vec::new();
1954    // Path (child source indices) to the focused text input, if any.
1955    let mut focused_input: Option<Vec<usize>> = None;
1956    // Instant before which the caret must stay solid regardless of tick
1957    // timing. Armed by every editing key and by focus changes.
1958    let mut blink_deadline: Option<std::time::Instant> = None;
1959    // Shift state, latched by the shared `window_modifiers_changed` below.
1960    // winit 0.28 deprecates `KeyboardInput::modifiers` in favour of the
1961    // `ModifiersChanged` event, so the caret reads this latched value instead
1962    // of the deprecated per-key field.
1963    let mut shift_held = false;
1964    // Scrollable overflow model: wheel clamping + deepest hit_test
1965    let mut scroll_offsets: std::collections::HashMap<Vec<usize>, f32> =
1966        std::collections::HashMap::new();
1967    let mut last_layout: Option<velox_dom::layout::LayoutNode> = None;
1968    // Lifecycle: ensure on_mounted fires once on first RedrawRequested and
1969    // on_unmounted/before_destroy fire on CloseRequested or drop.
1970    let _lifecycle_guard = LifecycleCleanupGuard;
1971    let mut did_mount = false;
1972    // R-L3: coalesce rapid resize drags — only last size per frame materializes.
1973    // Surface recreation (raster_n32_premul) is deferred to RedrawRequested.
1974    let mut resize_state = ResizeState::new();
1975
1976    // Render first frame immediately before entering the event loop.
1977    // This ensures the window has content even on platforms where
1978    // request_redraw() from NewEvents(StartCause::Init) may not trigger
1979    // a RedrawRequested event (e.g. certain Wayland/X11 compositors).
1980
1981    if let Some(s) = &mut renderer.surface {
1982        s.set_scale_factor(scale_factor);
1983        let (vw, vh) = resize_state.record_initial(s.width, s.height, scale_factor);
1984        let (vnode_raw, sheet) = make_view(vw, vh);
1985        let mut next_id = 1u32;
1986        let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
1987        let vnode = crate::style_vnode_with_hover(&vnode_tagged, &sheet, &|_tag, props| {
1988            props
1989                .attrs
1990                .get("data-hover-id")
1991                .and_then(|v| v.parse::<u32>().ok())
1992                .map(|id| Some(id) == hovered_id)
1993                .unwrap_or(false)
1994        });
1995        // First frame: no input can be focused yet, so every text input gets
1996        // the unfocused defaults. Injected anyway so the attr contract holds on
1997        // frame one and paint never has to special-case absence.
1998        let vnode = crate::inject_input_caret_attrs(&vnode, None);
1999        // Publish the pre-loop frame so a click or keystroke that arrives before
2000        // the first RedrawRequested is handled against real layout/value data
2001        // rather than an empty `last_vnode`. Without this, `apply_click_focus`
2002        // and `apply_edit_to_focused` see `None` and either drop the edit or put
2003        // the caret at 0 — the regression the HMR loop already guards against
2004        // by assigning `last_vnode` here.
2005        last_vnode = Some(vnode.clone());
2006        let mut layout = velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
2007        {
2008            let mut path = Vec::new();
2009            crate::events::apply_scroll_offsets(&mut layout, &scroll_offsets, &mut path);
2010        }
2011        last_layout = Some(layout.clone());
2012        recompute_targets(
2013            &vnode,
2014            &layout,
2015            &mut click_targets,
2016            &mut hover_targets,
2017            &mut input_targets,
2018        );
2019        // Render and present the initial frame so the window has immediate content.
2020        if let Err(e) = crate::skia_render::skia_impl::render_frame(s, &vnode, &layout, &sheet) {
2021            log::error!("skia initial render error: {}", e);
2022        }
2023        if let Some(presenter) = presenter.as_mut()
2024            && let Err(e) = presenter.present(s)
2025        {
2026            log::error!("skia initial present error: {}", e);
2027        }
2028    }
2029
2030    if let Some(event_loop) = event_loop_opt {
2031        // The event loop can panic if the display server becomes unreachable
2032        // (e.g. "Io error: Broken pipe") — catch that and degrade gracefully.
2033        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2034            // Caret blink clock. `EventLoop::new()` is an alias for
2035            // `EventLoopBuilder::new().build()`, and that constructor calls
2036            // `with_user_event()`, so the user-event channel is already enabled
2037            // here and `create_proxy()` needs no builder change. Held in a local
2038            // inside the unwind closure so its `Drop` joins the thread when the
2039            // loop ends.
2040            let _blink_ticker = crate::CaretBlinkTicker::start(event_loop.create_proxy(), ());
2041            event_loop.run(move |event, _, control_flow| {
2042                *control_flow = ControlFlow::Wait;
2043                match event {
2044                    Event::NewEvents(StartCause::Init) => {
2045                        if let Some(w) = window_opt.as_ref() {
2046                            w.request_redraw();
2047                        }
2048                    }
2049                    Event::WindowEvent {
2050                        event: WindowEvent::CloseRequested,
2051                        ..
2052                    } => {
2053                        // Destroy hooks run inside the shared arm; the exit stays
2054                        // here so `ControlFlow` never leaves the loop.
2055                        if window_close_requested() {
2056                            *control_flow = ControlFlow::Exit;
2057                        }
2058                    }
2059                    Event::WindowEvent {
2060                        event: WindowEvent::Resized(new_size),
2061                        ..
2062                    } => {
2063                        window_resized(
2064                            &mut resize_state,
2065                            &window_opt,
2066                            (new_size.width, new_size.height),
2067                        );
2068                    }
2069                    Event::WindowEvent {
2070                        event:
2071                            WindowEvent::ScaleFactorChanged {
2072                                scale_factor: new_scale,
2073                                new_inner_size,
2074                                ..
2075                            },
2076                        ..
2077                    } => {
2078                        window_scale_factor_changed(
2079                            &mut scale_factor,
2080                            &mut mouse_pos,
2081                            &mut resize_state,
2082                            &mut renderer.surface,
2083                            &window_opt,
2084                            new_scale,
2085                            (new_inner_size.width, new_inner_size.height),
2086                        );
2087                    }
2088                    Event::WindowEvent {
2089                        event: WindowEvent::CursorMoved { position, .. },
2090                        ..
2091                    } => {
2092                        window_cursor_moved(
2093                            scale_factor,
2094                            &mut mouse_pos,
2095                            &hover_targets,
2096                            &mut hovered_id,
2097                            &window_opt,
2098                            (position.x as f32, position.y as f32),
2099                        );
2100                    }
2101                    Event::WindowEvent {
2102                        event:
2103                            WindowEvent::MouseInput {
2104                                state: ElementState::Pressed,
2105                                button: MouseButton::Left,
2106                                ..
2107                            },
2108                        ..
2109                    } => {
2110                        // Text-input focus: clicking a text field focuses it and
2111                        // places the caret; clicking anywhere else drops focus and
2112                        // clears the selection. This runs BEFORE the click-handler
2113                        // test and is deliberately independent of it — see the
2114                        // redraw hoist at the bottom of this arm.
2115                        crate::apply_click_focus(
2116                            &mut input_targets,
2117                            &last_vnode,
2118                            &mut focused_input,
2119                            mouse_pos.0,
2120                            mouse_pos.1,
2121                            scale_factor,
2122                            surface_logical_viewport(&renderer, scale_factor),
2123                        );
2124                        let mut handled_click = false;
2125                        if let Some((handler, payload_opt)) =
2126                            crate::events::hit_test_click(&click_targets, mouse_pos.0, mouse_pos.1)
2127                        {
2128                            let payload_owned =
2129                                payload_opt.map(|p| p.to_string()).unwrap_or_else(|| {
2130                                    format!("{{\"x\":{},\"y\":{}}}", mouse_pos.0, mouse_pos.1)
2131                                });
2132                            on_event(handler, Some(&payload_owned));
2133                            velox_core::lifecycle::run_all_updated_hooks();
2134                            if let Some(s) = &mut renderer.surface {
2135                                let (vw, vh) = logical_size(s.width, s.height, scale_factor);
2136                                let (vnode_raw, sheet) = make_view(vw, vh);
2137                                let mut next_id = 1u32;
2138                                let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
2139                                let vnode = crate::style_vnode_with_hover(
2140                                    &vnode_tagged,
2141                                    &sheet,
2142                                    &|_tag, props| {
2143                                        props
2144                                            .attrs
2145                                            .get("data-hover-id")
2146                                            .and_then(|v| v.parse::<u32>().ok())
2147                                            .map(|id| Some(id) == hovered_id)
2148                                            .unwrap_or(false)
2149                                    },
2150                                );
2151                                let vnode = crate::inject_input_caret_attrs(
2152                                    &vnode,
2153                                    crate::events::focused_input_index(&input_targets)
2154                                        .and_then(|i| input_targets.get(i)),
2155                                );
2156                                let mut layout =
2157                                    velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
2158                                {
2159                                    let mut path = Vec::new();
2160                                    crate::events::apply_scroll_offsets(
2161                                        &mut layout,
2162                                        &scroll_offsets,
2163                                        &mut path,
2164                                    );
2165                                }
2166                                last_layout = Some(layout.clone());
2167                                recompute_targets(
2168                                    &vnode,
2169                                    &layout,
2170                                    &mut click_targets,
2171                                    &mut hover_targets,
2172                                    &mut input_targets,
2173                                );
2174                            }
2175                            handled_click = true;
2176                        }
2177                        if let Some(w) = window_opt.as_ref() {
2178                            if handled_click {
2179                                w.set_title(&get_title());
2180                            }
2181                            // HOISTED OUT of the `hit_test_click` arm on purpose.
2182                            // This used to live inside it, so a click that hit
2183                            // empty space changed focus and then never repainted:
2184                            // the focus ring and caret were stuck at their old
2185                            // values. Focus is now state the frame depends on, so
2186                            // any change to it must schedule a redraw.
2187                            w.request_redraw();
2188                        }
2189                    }
2190                    Event::WindowEvent {
2191                        event: WindowEvent::ModifiersChanged(mods),
2192                        ..
2193                    } => {
2194                        window_modifiers_changed(&mut shift_held, mods.shift());
2195                    }
2196                    Event::WindowEvent {
2197                        event: WindowEvent::KeyboardInput { input, .. },
2198                        ..
2199                    } => {
2200                        // Handle keyboard shortcuts and text-input editing keys.
2201                        use winit::event::VirtualKeyCode;
2202                        if let Some(keycode) = input.virtual_keycode
2203                            && input.state == ElementState::Pressed
2204                        {
2205                            // `@keydown` bindings first, and additively: this
2206                            // grants focus and calls handlers, it never
2207                            // consumes the key, so every branch below still
2208                            // runs exactly as it did before any keydown
2209                            // binding existed.
2210                            if crate::dispatch_keydown(
2211                                keycode,
2212                                &mut input_targets,
2213                                &last_vnode,
2214                                &mut focused_input,
2215                                &mut on_event,
2216                            ) {
2217                                arm_blink_deadline(&mut blink_deadline);
2218                                velox_core::lifecycle::run_all_updated_hooks();
2219                                if let Some(w) = window_opt.as_ref() {
2220                                    w.request_redraw();
2221                                }
2222                            }
2223                            match keycode {
2224                                // Reload (app exits, dev server restarts it) and quit.
2225                                // Both are withheld while a text field has focus: with a
2226                                // field focused these keys are *text*, so an unguarded
2227                                // shortcut means typing "r" into a search box kills the
2228                                // application. With nothing focused they are the only way
2229                                // out of the window. When the guard fails the key falls
2230                                // through to the editor below, which is what lets the
2231                                // character still reach the field.
2232                                VirtualKeyCode::R | VirtualKeyCode::Q
2233                                    if !crate::events::any_input_focused(&input_targets) =>
2234                                {
2235                                    velox_core::lifecycle::run_all_destroy_hooks();
2236                                    *control_flow = ControlFlow::Exit;
2237                                }
2238                                _ => {
2239                                    // Every remaining editing key routes through the
2240                                    // one shared call, so the HMR loop cannot drift
2241                                    // from this one again.
2242                                    if let Some(action) =
2243                                        crate::edit_action_for_key(keycode, shift_held)
2244                                    {
2245                                        let changed = crate::apply_edit_to_focused(
2246                                            &mut input_targets,
2247                                            action,
2248                                            &last_vnode,
2249                                            &mut focused_input,
2250                                            &mut on_event,
2251                                        );
2252                                        if changed {
2253                                            // Caret goes solid while you type.
2254                                            arm_blink_deadline(&mut blink_deadline);
2255                                            velox_core::lifecycle::run_all_updated_hooks();
2256                                            if let Some(w) = window_opt.as_ref() {
2257                                                w.request_redraw();
2258                                            }
2259                                        }
2260                                    }
2261                                }
2262                            }
2263                        }
2264                    }
2265                    Event::UserEvent(()) => {
2266                        // Caret blink tick from `CaretBlinkTicker`. See that
2267                        // type for why a thread is involved and why it is not a
2268                        // shared-state race.
2269                        if crate::on_caret_blink_tick(&mut input_targets, &mut blink_deadline)
2270                            && let Some(w) = window_opt.as_ref()
2271                        {
2272                            w.request_redraw();
2273                        }
2274                    }
2275                    Event::WindowEvent {
2276                        event: WindowEvent::ReceivedCharacter(c),
2277                        ..
2278                    } => {
2279                        // Printable characters go to the focused text input.
2280                        if !c.is_control()
2281                            && c != '\u{7f}'
2282                            && crate::apply_edit_to_focused(
2283                                &mut input_targets,
2284                                crate::events::EditAction::Insert(c),
2285                                &last_vnode,
2286                                &mut focused_input,
2287                                &mut on_event,
2288                            )
2289                        {
2290                            // Caret goes solid while you type.
2291                            arm_blink_deadline(&mut blink_deadline);
2292                            velox_core::lifecycle::run_all_updated_hooks();
2293                            if let Some(w) = window_opt.as_ref() {
2294                                w.request_redraw();
2295                            }
2296                        }
2297                    }
2298                    Event::WindowEvent {
2299                        event: WindowEvent::MouseWheel { delta, .. },
2300                        ..
2301                    } => {
2302                        window_mouse_wheel(
2303                            scale_factor,
2304                            mouse_pos,
2305                            &last_layout,
2306                            &mut scroll_offsets,
2307                            &window_opt,
2308                            delta,
2309                        );
2310                    }
2311                    Event::RedrawRequested(_) => {
2312                        // R-L3: materialize any coalesced resize exactly once per frame.
2313                        let mut committed_resize = false;
2314                        if let Some((pw, ph)) = resize_state.take_pending() {
2315                            // `ResizeState::take_pending` is the production `pending_resize.take()` path.
2316                            match renderer.resize(pw as i32, ph as i32) {
2317                                Ok(()) => committed_resize = true,
2318                                Err(e) => {
2319                                    log::warn!("renderer resize failed ({}x{}): {}", pw, ph, e);
2320                                }
2321                            }
2322                            if let Some(presenter) = presenter.as_mut()
2323                                && let Err(e) = presenter.resize(pw, ph)
2324                            {
2325                                log::warn!("presenter resize failed: {}", e);
2326                            }
2327                        }
2328                        // First mount: fire on_mounted once.
2329                        ensure_mounted(&mut did_mount);
2330                        // Render VNode -> Skia frame and present.
2331                        if let Some(s) = &mut renderer.surface {
2332                            s.set_scale_factor(scale_factor);
2333                            let (vw, vh) = resize_state.frame_logical_size(
2334                                s.width,
2335                                s.height,
2336                                scale_factor,
2337                                committed_resize,
2338                            );
2339                            let (vnode_raw, sheet) = make_view(vw, vh);
2340                            let mut next_id = 1u32;
2341                            let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
2342                            let vnode = crate::style_vnode_with_hover(
2343                                &vnode_tagged,
2344                                &sheet,
2345                                &|_tag, props| {
2346                                    props
2347                                        .attrs
2348                                        .get("data-hover-id")
2349                                        .and_then(|v| v.parse::<u32>().ok())
2350                                        .map(|id| Some(id) == hovered_id)
2351                                        .unwrap_or(false)
2352                                },
2353                            );
2354                            // Caret/selection/focus contract for the paint lane.
2355                            // After the style cascade, before layout: it adds no
2356                            // second cascade and no second rounding site.
2357                            let vnode = crate::inject_input_caret_attrs(
2358                                &vnode,
2359                                crate::events::focused_input_index(&input_targets)
2360                                    .and_then(|i| input_targets.get(i)),
2361                            );
2362                            last_vnode = Some(vnode.clone());
2363                            let mut layout =
2364                                velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
2365                            {
2366                                let mut path = Vec::new();
2367                                crate::events::apply_scroll_offsets(
2368                                    &mut layout,
2369                                    &scroll_offsets,
2370                                    &mut path,
2371                                );
2372                            }
2373                            last_layout = Some(layout.clone());
2374                            recompute_targets(
2375                                &vnode,
2376                                &layout,
2377                                &mut click_targets,
2378                                &mut hover_targets,
2379                                &mut input_targets,
2380                            );
2381                            if let Err(e) = crate::skia_render::skia_impl::render_frame(
2382                                s, &vnode, &layout, &sheet,
2383                            ) {
2384                                log::error!("skia render error: {}", e);
2385                            }
2386                            if let Some(presenter) = presenter.as_mut()
2387                                && let Err(e) = presenter.present(s)
2388                            {
2389                                log::error!("skia present error: {}", e);
2390                            }
2391                        }
2392                    }
2393                    _ => {}
2394                }
2395            });
2396        }));
2397    } else {
2398        // Headless mode — no event loop, just run the initial render and return.
2399        log::info!("running in headless mode (no event loop)");
2400        velox_core::lifecycle::run_all_destroy_hooks();
2401    }
2402    Ok(())
2403}
2404
2405/// What the caught window/loop construction yields: the event loop and its HMR
2406/// proxy (both survive even when the window does not), the window itself, its
2407/// physical size, and the DPI scale factor.
2408#[cfg(feature = "skia-native")]
2409type WindowBootstrap = (
2410    Option<winit::event_loop::EventLoop<HmrMessage>>,
2411    Option<winit::event_loop::EventLoopProxy<HmrMessage>>,
2412    Option<winit::window::Window>,
2413    winit::dpi::PhysicalSize<u32>,
2414    f32,
2415);
2416
2417/// HMR variant — same viewport contract as [`run_window_vnode_skia`] (see its docs).
2418/// `make_view` is called with logical `(w, h)` on every frame/resize; templates use
2419/// `width:100%` / `min-height:100vh` so the viewport change is visibly reflected.
2420#[cfg(feature = "skia-native")]
2421pub fn run_window_vnode_skia_with_hmr<F, G, H>(
2422    title: &str,
2423    mut make_view: F,
2424    mut on_event: G,
2425    mut get_title: H,
2426    hmr_rx: std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<HmrMessage>>>,
2427) -> Result<(), String>
2428where
2429    F: FnMut(u32, u32) -> (velox_dom::VNode, Stylesheet) + 'static,
2430    G: FnMut(&str, Option<&str>) + 'static,
2431    H: FnMut() -> String + 'static,
2432{
2433    use winit::dpi::PhysicalSize;
2434    use winit::event::{ElementState, Event, MouseButton, StartCause, WindowEvent};
2435    use winit::event_loop::{ControlFlow, EventLoopBuilder};
2436    use winit::window::WindowBuilder;
2437
2438    let headless_env_hmr = std::env::var("VELOX_HEADLESS").as_deref() == Ok("1")
2439        || !crate::presenter::is_compositor_available();
2440
2441    // Prepare the winit backend: force X11 if Wayland socket is stale to
2442    // avoid winit's Wayland backend calling process::exit() on EPIPE.
2443    let _headless_check_hmr = crate::presenter::prepare_backend();
2444
2445    // Try to create a winit event loop and window. In headless mode or when
2446    // no compositor is available, both can fail (winit may panic instead of
2447    // returning Err) — we catch this and proceed with headless rendering.
2448    // Use EventLoop<HmrMessage> so the HMR thread can forward messages
2449    // directly via EventLoopProxy::send_event(HmrMessage) without any
2450    // shared Mutex<Receiver>.
2451    // Same contract as the plain loop above: a non-display window failure is
2452    // a typed `VeloxError::Window` returned as `Err`, never a `panic!` the
2453    // `catch_unwind` would launder into headless mode.
2454    let window_outcome_hmr: Result<WindowBootstrap, String> =
2455        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2456            let event_loop = EventLoopBuilder::<HmrMessage>::with_user_event().build();
2457            let proxy = event_loop.create_proxy();
2458            let window_result = WindowBuilder::new()
2459                .with_title(title)
2460                .with_inner_size(PhysicalSize::new(800, 600))
2461                .build(&event_loop);
2462            match window_result {
2463                Ok(w) => {
2464                    let size = w.inner_size();
2465                    let sf = w.scale_factor() as f32;
2466                    Ok((Some(event_loop), Some(proxy), Some(w), size, sf))
2467                }
2468                Err(e) => {
2469                    let msg = e.to_string();
2470                    let lower = msg.to_ascii_lowercase();
2471                    let is_display_err = lower.contains("broken pipe")
2472                        || lower.contains("os error 32")
2473                        || lower.contains("no compositor")
2474                        || lower.contains("no display server")
2475                        || lower.contains("failed to connect");
2476                    if headless_env_hmr || is_display_err {
2477                        log::warn!("window creation failed — continuing in headless mode: {msg}");
2478                        Ok((
2479                            Some(event_loop),
2480                            Some(proxy),
2481                            None,
2482                            PhysicalSize::new(800, 600),
2483                            1.0,
2484                        ))
2485                    } else {
2486                        Err(
2487                            velox_dom::VeloxError::Window(format!("failed to create window: {e}"))
2488                                .to_string(),
2489                        )
2490                    }
2491                }
2492            }
2493        }))
2494        .unwrap_or_else(|payload| {
2495            // Surface the panic payload (usually the compositor error, e.g.
2496            // broken pipe) instead of silently masking it — log::warn is
2497            // invisible without an initialized logger (CX-13 / F-23).
2498            eprintln!(
2499                "[velox] window/event loop creation panicked — continuing in headless mode: {}",
2500                panic_detail(payload)
2501            );
2502            Ok((None, None, None, PhysicalSize::new(800, 600), 1.0))
2503        });
2504    let (event_loop_opt, proxy_opt, window, window_size, scale_factor) = window_outcome_hmr?;
2505
2506    // Spawn the HMR forwarding thread. It takes sole ownership of the
2507    // Receiver (moved out of the Arc<Mutex>) and forwards each HmrMessage
2508    // directly via EventLoopProxy::send_event(HmrMessage). No Mutex is
2509    // held across blocking recv(). Wrap in catch_unwind so HMR panics
2510    // never bring down the app (acceptance: catch_unwind around HMR loop).
2511    if let Some(proxy) = proxy_opt {
2512        match std::sync::Arc::try_unwrap(hmr_rx) {
2513            Ok(mutex) => {
2514                // `into_inner`, never `expect`: a poisoned mutex still owns a
2515                // valid `Receiver`, and panicking here would kill the app over
2516                // a previous panic's side effect. The receiver is taken as-is.
2517                let rx = mutex
2518                    .into_inner()
2519                    .unwrap_or_else(|poisoned| poisoned.into_inner());
2520                std::thread::spawn(move || {
2521                    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2522                        while let Ok(msg) = rx.recv() {
2523                            if proxy.send_event(msg).is_err() {
2524                                break;
2525                            }
2526                        }
2527                    }));
2528                });
2529            }
2530            Err(shared) => {
2531                // Fallback: another Arc clone exists (unusual). Poll without
2532                // holding the lock across blocking recv.
2533                std::thread::spawn(move || {
2534                    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2535                        loop {
2536                            let res = {
2537                                // `into_inner`, never `expect`: poisoning must
2538                                // not panic the HMR thread (which would kill
2539                                // forwarding silently). The guard is dropped
2540                                // before `recv` either way — no lock is held
2541                                // across the blocking call.
2542                                let guard = shared
2543                                    .lock()
2544                                    .unwrap_or_else(|poisoned| poisoned.into_inner());
2545                                guard.try_recv()
2546                            };
2547                            match res {
2548                                Ok(msg) => {
2549                                    if proxy.send_event(msg).is_err() {
2550                                        break;
2551                                    }
2552                                }
2553                                Err(std::sync::mpsc::TryRecvError::Empty) => {
2554                                    std::thread::sleep(std::time::Duration::from_millis(16));
2555                                }
2556                                Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
2557                            }
2558                        }
2559                    }));
2560                });
2561            }
2562        }
2563    }
2564    // NOTE: hmr_rx has been moved into the forwarding thread. The event loop
2565    // now receives HmrMessage via Event::UserEvent(msg) without any Mutex.
2566
2567    let window_opt: Option<winit::window::Window> = window;
2568    let mut scale_factor = scale_factor;
2569    let mut renderer = match crate::skia_surface::SkiaSurface::new_raster(
2570        window_size.width as i32,
2571        window_size.height as i32,
2572    ) {
2573        Ok(surface) => skia_backend::SkiaRenderer {
2574            surface: Some(surface),
2575            vnode: None,
2576        },
2577        Err(e) => {
2578            return Err(format!("failed to create SkiaSurface: {e}"));
2579        }
2580    };
2581    let mut presenter: Option<crate::presenter::SoftbufferPresenter> = None;
2582    if let Some(w) = window_opt.as_ref() {
2583        // softbuffer::Context::new() can panic when the display server is
2584        // unreachable even though DISPLAY/WAYLAND_DISPLAY are set (broken pipe).
2585        // Catch such panics and degrade to headless rendering.
2586        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2587            crate::presenter::SoftbufferPresenter::new(w, window_size.width, window_size.height)
2588        })) {
2589            Ok(Ok(p)) => presenter = Some(p),
2590            Ok(Err(e)) => {
2591                if headless_env_hmr || !crate::presenter::is_compositor_available() {
2592                    log::warn!(
2593                        "softbuffer presenter unavailable — continuing in headless mode: {e}"
2594                    );
2595                } else {
2596                    return Err(format!("failed to create softbuffer presenter: {e}"));
2597                }
2598            }
2599            Err(payload) => {
2600                // Surface the panic payload (softbuffer panics with a
2601                // broken pipe when the display is unreachable) instead of
2602                // silently masking it (CX-13 / F-23).
2603                eprintln!(
2604                    "[velox] softbuffer presenter creation panicked — continuing in headless mode: {}",
2605                    panic_detail(payload)
2606                );
2607            }
2608        }
2609    }
2610    let mut mouse_pos = (0.0f32, 0.0f32);
2611    let mut hovered_id: Option<u32> = None;
2612    let mut click_targets: Vec<crate::events::ClickTarget> = Vec::new();
2613    let mut hover_targets: Vec<crate::events::HoverTarget> = Vec::new();
2614    let mut input_targets: Vec<crate::events::InputTarget> = Vec::new();
2615    // Was `_last_vnode` until 5cc4a4f, which renamed it and made it live. The
2616    // earlier HMR-deadlock fix (7585674) introduced this binding *unused*, so
2617    // typing, Backspace and Return stayed dead under HMR; 5cc4a4f is what
2618    // actually fixed them, by giving the value a real name and wiring it into
2619    // the ported input handling.
2620    let mut last_vnode: Option<velox_dom::VNode> = None;
2621    // Path (child source indices) to the focused text input, if any.
2622    // Mirrors the plain loop's state so the two loops stay behaviourally equal.
2623    let mut focused_input: Option<Vec<usize>> = None;
2624    // Instant before which the caret must stay solid regardless of tick timing.
2625    let mut blink_deadline: Option<std::time::Instant> = None;
2626    // Shift state, latched by the shared `window_modifiers_changed` below —
2627    // see the plain loop for why the per-key `modifiers` field is not used.
2628    let mut shift_held = false;
2629    let mut scroll_offsets_hmr: std::collections::HashMap<Vec<usize>, f32> =
2630        std::collections::HashMap::new();
2631    let mut last_layout_hmr: Option<velox_dom::layout::LayoutNode> = None;
2632    let _lifecycle_guard_hmr = LifecycleCleanupGuard;
2633    let mut did_mount_hmr = false;
2634    // R-L3: coalesce rapid resize drags in HMR loop too (only last size per frame).
2635    let mut resize_state = ResizeState::new();
2636
2637    // Render first frame immediately before entering the event loop.
2638    // This ensures the window has content even on platforms where
2639    // request_redraw() from NewEvents(StartCause::Init) may not trigger
2640    // a RedrawRequested event (e.g. certain Wayland/X11 compositors).
2641    if let Some(s) = &mut renderer.surface {
2642        s.set_scale_factor(scale_factor);
2643        let (vw, vh) = resize_state.record_initial(s.width, s.height, scale_factor);
2644        let (vnode_raw, sheet) = make_view(vw, vh);
2645        let mut next_id = 1u32;
2646        let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
2647        let vnode = crate::style_vnode_with_hover(&vnode_tagged, &sheet, &|_tag, props| {
2648            props
2649                .attrs
2650                .get("data-hover-id")
2651                .and_then(|v| v.parse::<u32>().ok())
2652                .map(|id| Some(id) == hovered_id)
2653                .unwrap_or(false)
2654        });
2655        // First frame: no input can be focused yet, so every text input gets the
2656        // unfocused defaults.
2657        let vnode = crate::inject_input_caret_attrs(&vnode, None);
2658        last_vnode = Some(vnode.clone());
2659        let mut layout = velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
2660        {
2661            let mut path = Vec::new();
2662            crate::events::apply_scroll_offsets(&mut layout, &scroll_offsets_hmr, &mut path);
2663        }
2664        last_layout_hmr = Some(layout.clone());
2665        recompute_targets(
2666            &vnode,
2667            &layout,
2668            &mut click_targets,
2669            &mut hover_targets,
2670            &mut input_targets,
2671        );
2672        // Render and present the initial frame so the window has immediate content.
2673        if let Err(e) = crate::skia_render::skia_impl::render_frame(s, &vnode, &layout, &sheet) {
2674            log::error!("skia initial render error: {}", e);
2675        }
2676        if let Some(presenter) = presenter.as_mut()
2677            && let Err(e) = presenter.present(s)
2678        {
2679            log::error!("skia initial present error: {}", e);
2680        }
2681    }
2682
2683    if let Some(event_loop) = event_loop_opt {
2684        // The event loop can panic if the display server becomes unreachable
2685        // (e.g. "Io error: Broken pipe") — catch that and degrade gracefully.
2686        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2687            // Caret blink clock for the HMR loop. `EventLoop::run` consumes
2688            // `self`, so the proxy has to be made before the call. This loop's
2689            // channel is typed `HmrMessage`, so the tick rides
2690            // `HmrMessage::KeepWindow` — see that arm for why.
2691            let _blink_ticker =
2692                crate::CaretBlinkTicker::start(event_loop.create_proxy(), HmrMessage::KeepWindow);
2693            event_loop.run(move |event, _, control_flow| {
2694                *control_flow = ControlFlow::Wait;
2695                match event {
2696                    Event::UserEvent(msg) => match msg {
2697                        HmrMessage::FullReload => {
2698                            velox_core::lifecycle::run_all_destroy_hooks();
2699                            *control_flow = ControlFlow::Exit;
2700                        }
2701                        HmrMessage::HotReload { module_path: _ } => {
2702                            // Rebuild view and refresh all hit-test targets including
2703                            // input_targets, then request a redraw so the new VNode
2704                            // is painted on the next frame.
2705                            if let Some(s) = renderer.surface.as_ref() {
2706                                let (vw, vh) = logical_size(s.width, s.height, scale_factor);
2707                                let (vnode_raw, _) = make_view(vw, vh);
2708                                if let Err(e) = HmrRenderer::hot_update(&mut renderer, vnode_raw) {
2709                                    log::error!("hot_update failed: {}", e);
2710                                }
2711                                // Recompute all targets from the updated view so
2712                                // input focus / hit-test stays in sync after HMR.
2713                                if let Some(s2) = renderer.surface.as_ref() {
2714                                    let (vw2, vh2) =
2715                                        logical_size(s2.width, s2.height, scale_factor);
2716                                    let (vnode2_raw, sheet2) = make_view(vw2, vh2);
2717                                    let mut nid = 1u32;
2718                                    let tagged = with_hover_ids(&vnode2_raw, &mut nid);
2719                                    let vnode2 = crate::style_vnode_with_hover(
2720                                        &tagged,
2721                                        &sheet2,
2722                                        &|_tag, props| {
2723                                            props
2724                                                .attrs
2725                                                .get("data-hover-id")
2726                                                .and_then(|v| v.parse::<u32>().ok())
2727                                                .map(|id| Some(id) == hovered_id)
2728                                                .unwrap_or(false)
2729                                        },
2730                                    );
2731                                    // Caret attrs survive a hot reload: the
2732                                    // focused path is matched by tree path, and
2733                                    // `recompute_targets` below re-applies the
2734                                    // edit state onto the new targets.
2735                                    let vnode2 = crate::inject_input_caret_attrs(
2736                                        &vnode2,
2737                                        crate::events::focused_input_index(&input_targets)
2738                                            .and_then(|i| input_targets.get(i)),
2739                                    );
2740                                    last_vnode = Some(vnode2.clone());
2741                                    let mut layout = velox_dom::layout::compute_layout(
2742                                        &vnode2, vw2 as i32, vh2 as i32,
2743                                    );
2744                                    {
2745                                        let mut path = Vec::new();
2746                                        crate::events::apply_scroll_offsets(
2747                                            &mut layout,
2748                                            &scroll_offsets_hmr,
2749                                            &mut path,
2750                                        );
2751                                    }
2752                                    last_layout_hmr = Some(layout.clone());
2753                                    recompute_targets(
2754                                        &vnode2,
2755                                        &layout,
2756                                        &mut click_targets,
2757                                        &mut hover_targets,
2758                                        &mut input_targets,
2759                                    );
2760                                }
2761                            }
2762                            if let Some(w) = window_opt.as_ref() {
2763                                w.request_redraw();
2764                            }
2765                        }
2766                        // `KeepWindow` is overloaded as the HMR loop's caret
2767                        // blink tick. The HMR channel is typed `HmrMessage`, and
2768                        // winit 0.28 has no timer API, so the ticker posts a
2769                        // no-op HMR message on the same channel rather than
2770                        // forcing the loop to be untyped. The dev server's real
2771                        // keep-alives land here too, and both are no-ops, so
2772                        // overloading costs nothing.
2773                        HmrMessage::KeepWindow => {
2774                            if crate::on_caret_blink_tick(&mut input_targets, &mut blink_deadline)
2775                                && let Some(w) = window_opt.as_ref()
2776                            {
2777                                w.request_redraw();
2778                            }
2779                        }
2780                    },
2781                    Event::NewEvents(StartCause::Init) => {
2782                        if let Some(w) = window_opt.as_ref() {
2783                            w.request_redraw();
2784                        }
2785                    }
2786                    Event::WindowEvent {
2787                        event: WindowEvent::CloseRequested,
2788                        ..
2789                    } => {
2790                        // Destroy hooks run inside the shared arm; the exit stays
2791                        // here so `ControlFlow` never leaves the loop.
2792                        if window_close_requested() {
2793                            *control_flow = ControlFlow::Exit;
2794                        }
2795                    }
2796                    Event::WindowEvent {
2797                        event: WindowEvent::Resized(new_size),
2798                        ..
2799                    } => {
2800                        window_resized(
2801                            &mut resize_state,
2802                            &window_opt,
2803                            (new_size.width, new_size.height),
2804                        );
2805                    }
2806                    Event::WindowEvent {
2807                        event:
2808                            WindowEvent::ScaleFactorChanged {
2809                                scale_factor: new_scale,
2810                                new_inner_size,
2811                                ..
2812                            },
2813                        ..
2814                    } => {
2815                        window_scale_factor_changed(
2816                            &mut scale_factor,
2817                            &mut mouse_pos,
2818                            &mut resize_state,
2819                            &mut renderer.surface,
2820                            &window_opt,
2821                            new_scale,
2822                            (new_inner_size.width, new_inner_size.height),
2823                        );
2824                    }
2825                    Event::WindowEvent {
2826                        event: WindowEvent::CursorMoved { position, .. },
2827                        ..
2828                    } => {
2829                        window_cursor_moved(
2830                            scale_factor,
2831                            &mut mouse_pos,
2832                            &hover_targets,
2833                            &mut hovered_id,
2834                            &window_opt,
2835                            (position.x as f32, position.y as f32),
2836                        );
2837                    }
2838                    Event::WindowEvent {
2839                        event:
2840                            WindowEvent::MouseInput {
2841                                state: ElementState::Pressed,
2842                                button: MouseButton::Left,
2843                                ..
2844                            },
2845                        ..
2846                    } => {
2847                        // Text-input focus: identical to the plain loop, and now
2848                        // actually present. This arm previously had NO focus
2849                        // handling at all, which is why the whole caret was
2850                        // unreachable under HMR.
2851                        crate::apply_click_focus(
2852                            &mut input_targets,
2853                            &last_vnode,
2854                            &mut focused_input,
2855                            mouse_pos.0,
2856                            mouse_pos.1,
2857                            scale_factor,
2858                            surface_logical_viewport(&renderer, scale_factor),
2859                        );
2860                        let mut handled_click = false;
2861                        if let Some((handler, payload_opt)) =
2862                            crate::events::hit_test_click(&click_targets, mouse_pos.0, mouse_pos.1)
2863                        {
2864                            let payload_owned =
2865                                payload_opt.map(|p| p.to_string()).unwrap_or_else(|| {
2866                                    format!("{{\"x\":{},\"y\":{}}}", mouse_pos.0, mouse_pos.1)
2867                                });
2868                            on_event(handler, Some(&payload_owned));
2869                            velox_core::lifecycle::run_all_updated_hooks();
2870                            if let Some(s) = &mut renderer.surface {
2871                                let (vw, vh) = logical_size(s.width, s.height, scale_factor);
2872                                let (vnode_raw, sheet) = make_view(vw, vh);
2873                                let mut next_id = 1u32;
2874                                let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
2875                                let vnode = crate::style_vnode_with_hover(
2876                                    &vnode_tagged,
2877                                    &sheet,
2878                                    &|_tag, props| {
2879                                        props
2880                                            .attrs
2881                                            .get("data-hover-id")
2882                                            .and_then(|v| v.parse::<u32>().ok())
2883                                            .map(|id| Some(id) == hovered_id)
2884                                            .unwrap_or(false)
2885                                    },
2886                                );
2887                                let vnode = crate::inject_input_caret_attrs(
2888                                    &vnode,
2889                                    crate::events::focused_input_index(&input_targets)
2890                                        .and_then(|i| input_targets.get(i)),
2891                                );
2892                                last_vnode = Some(vnode.clone());
2893                                let mut layout =
2894                                    velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
2895                                {
2896                                    let mut path = Vec::new();
2897                                    crate::events::apply_scroll_offsets(
2898                                        &mut layout,
2899                                        &scroll_offsets_hmr,
2900                                        &mut path,
2901                                    );
2902                                }
2903                                last_layout_hmr = Some(layout.clone());
2904                                recompute_targets(
2905                                    &vnode,
2906                                    &layout,
2907                                    &mut click_targets,
2908                                    &mut hover_targets,
2909                                    &mut input_targets,
2910                                );
2911                            }
2912                            handled_click = true;
2913                        }
2914                        if let Some(w) = window_opt.as_ref() {
2915                            if handled_click {
2916                                w.set_title(&get_title());
2917                            }
2918                            // HOISTED OUT of the `hit_test_click` arm, for the
2919                            // same reason as the plain loop: this arm had the
2920                            // identical nesting bug, so a click on empty space
2921                            // changed focus and never repainted.
2922                            w.request_redraw();
2923                        }
2924                    }
2925                    Event::WindowEvent {
2926                        event: WindowEvent::ModifiersChanged(mods),
2927                        ..
2928                    } => {
2929                        window_modifiers_changed(&mut shift_held, mods.shift());
2930                    }
2931                    Event::WindowEvent {
2932                        event: WindowEvent::KeyboardInput { input, .. },
2933                        ..
2934                    } => {
2935                        // Keyboard shortcuts and text-input editing keys.
2936                        use winit::event::VirtualKeyCode;
2937                        if let Some(keycode) = input.virtual_keycode
2938                            && input.state == ElementState::Pressed
2939                        {
2940                            // `@keydown` bindings, identical to the plain
2941                            // loop's — including the additive contract, which
2942                            // is what stops an HMR session from losing F2-focus
2943                            // the moment a rebuild is watched.
2944                            if crate::dispatch_keydown(
2945                                keycode,
2946                                &mut input_targets,
2947                                &last_vnode,
2948                                &mut focused_input,
2949                                &mut on_event,
2950                            ) {
2951                                crate::arm_blink_deadline(&mut blink_deadline);
2952                                velox_core::lifecycle::run_all_updated_hooks();
2953                                if let Some(w) = window_opt.as_ref() {
2954                                    w.request_redraw();
2955                                }
2956                            }
2957                            match keycode {
2958                                // Reload / quit. Identical to the plain loop's arm
2959                                // including the focus guard — an unguarded shortcut
2960                                // here would mean typing "r" into a field exits the
2961                                // process under HMR only, which is the kind of drift
2962                                // that survives review because it is "the other loop".
2963                                VirtualKeyCode::R | VirtualKeyCode::Q
2964                                    if !crate::events::any_input_focused(&input_targets) =>
2965                                {
2966                                    velox_core::lifecycle::run_all_destroy_hooks();
2967                                    *control_flow = ControlFlow::Exit;
2968                                }
2969                                // Ported from the plain loop. This arm used to be
2970                                // a bare `_ => {}` stub with no `focused_input` in
2971                                // scope at all, so typing did nothing under HMR.
2972                                _ => {
2973                                    if let Some(action) =
2974                                        crate::edit_action_for_key(keycode, shift_held)
2975                                    {
2976                                        let changed = crate::apply_edit_to_focused(
2977                                            &mut input_targets,
2978                                            action,
2979                                            &last_vnode,
2980                                            &mut focused_input,
2981                                            &mut on_event,
2982                                        );
2983                                        if changed {
2984                                            // Caret goes solid while you type.
2985                                            crate::arm_blink_deadline(&mut blink_deadline);
2986                                            velox_core::lifecycle::run_all_updated_hooks();
2987                                            if let Some(w) = window_opt.as_ref() {
2988                                                w.request_redraw();
2989                                            }
2990                                        }
2991                                    }
2992                                }
2993                            }
2994                        }
2995                    }
2996                    Event::WindowEvent {
2997                        event: WindowEvent::ReceivedCharacter(c),
2998                        ..
2999                    } => {
3000                        // Printable characters go to the focused text input.
3001                        // Ported from the plain loop; there was no
3002                        // `ReceivedCharacter` arm anywhere in this loop, so
3003                        // typing inserted nothing under HMR.
3004                        if !c.is_control()
3005                            && c != '\u{7f}'
3006                            && crate::apply_edit_to_focused(
3007                                &mut input_targets,
3008                                crate::events::EditAction::Insert(c),
3009                                &last_vnode,
3010                                &mut focused_input,
3011                                &mut on_event,
3012                            )
3013                        {
3014                            // Caret goes solid while you type.
3015                            crate::arm_blink_deadline(&mut blink_deadline);
3016                            velox_core::lifecycle::run_all_updated_hooks();
3017                            if let Some(w) = window_opt.as_ref() {
3018                                w.request_redraw();
3019                            }
3020                        }
3021                    }
3022                    Event::WindowEvent {
3023                        event: WindowEvent::MouseWheel { delta, .. },
3024                        ..
3025                    } => {
3026                        window_mouse_wheel(
3027                            scale_factor,
3028                            mouse_pos,
3029                            &last_layout_hmr,
3030                            &mut scroll_offsets_hmr,
3031                            &window_opt,
3032                            delta,
3033                        );
3034                    }
3035                    Event::RedrawRequested(_) => {
3036                        // R-L3: materialize any coalesced resize exactly once per frame.
3037                        let mut committed_resize = false;
3038                        if let Some((pw, ph)) = resize_state.take_pending() {
3039                            // `ResizeState::take_pending` is the production `pending_resize.take()` path.
3040                            match renderer.resize(pw as i32, ph as i32) {
3041                                Ok(()) => committed_resize = true,
3042                                Err(e) => {
3043                                    log::warn!("renderer resize failed ({}x{}): {}", pw, ph, e);
3044                                }
3045                            }
3046                            if let Some(presenter) = presenter.as_mut()
3047                                && let Err(e) = presenter.resize(pw, ph)
3048                            {
3049                                log::warn!("presenter resize failed: {}", e);
3050                            }
3051                        }
3052                        ensure_mounted(&mut did_mount_hmr);
3053                        // Render VNode -> Skia frame and present.
3054                        if let Some(s) = &mut renderer.surface {
3055                            s.set_scale_factor(scale_factor);
3056                            let (vw, vh) = resize_state.frame_logical_size(
3057                                s.width,
3058                                s.height,
3059                                scale_factor,
3060                                committed_resize,
3061                            );
3062                            let (vnode_raw, sheet) = make_view(vw, vh);
3063                            let mut next_id = 1u32;
3064                            let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
3065                            let vnode = crate::style_vnode_with_hover(
3066                                &vnode_tagged,
3067                                &sheet,
3068                                &|_tag, props| {
3069                                    props
3070                                        .attrs
3071                                        .get("data-hover-id")
3072                                        .and_then(|v| v.parse::<u32>().ok())
3073                                        .map(|id| Some(id) == hovered_id)
3074                                        .unwrap_or(false)
3075                                },
3076                            );
3077                            // Caret/selection/focus contract for the paint lane.
3078                            let vnode = crate::inject_input_caret_attrs(
3079                                &vnode,
3080                                crate::events::focused_input_index(&input_targets)
3081                                    .and_then(|i| input_targets.get(i)),
3082                            );
3083                            last_vnode = Some(vnode.clone());
3084                            let mut layout =
3085                                velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
3086                            {
3087                                let mut path = Vec::new();
3088                                crate::events::apply_scroll_offsets(
3089                                    &mut layout,
3090                                    &scroll_offsets_hmr,
3091                                    &mut path,
3092                                );
3093                            }
3094                            last_layout_hmr = Some(layout.clone());
3095                            recompute_targets(
3096                                &vnode,
3097                                &layout,
3098                                &mut click_targets,
3099                                &mut hover_targets,
3100                                &mut input_targets,
3101                            );
3102                            if let Err(e) = crate::skia_render::skia_impl::render_frame(
3103                                s, &vnode, &layout, &sheet,
3104                            ) {
3105                                log::error!("skia render error: {}", e);
3106                            }
3107                            if let Some(presenter) = presenter.as_mut()
3108                                && let Err(e) = presenter.present(s)
3109                            {
3110                                log::error!("softbuffer present error: {}", e);
3111                            }
3112                        }
3113                    }
3114                    _ => {}
3115                }
3116            });
3117        }));
3118    } else {
3119        // Headless mode — no event loop, just run the initial render and return.
3120        log::info!("running in headless mode (no event loop)");
3121        velox_core::lifecycle::run_all_destroy_hooks();
3122    }
3123    Ok(())
3124}
3125
3126#[cfg(test)]
3127mod resize_state_tests {
3128    use super::ResizeState;
3129    use std::cell::RefCell;
3130    use std::rc::Rc;
3131    use std::sync::{Mutex, MutexGuard, OnceLock};
3132    use velox_core::lifecycle::{
3133        cleanup_component, clear_current_component, generate_component_id, on_resize,
3134        set_current_component,
3135    };
3136    use velox_core::signal::Signal;
3137
3138    fn resize_test_guard() -> MutexGuard<'static, ()> {
3139        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3140        LOCK.get_or_init(|| Mutex::new(()))
3141            .lock()
3142            .unwrap_or_else(|poisoned| poisoned.into_inner())
3143    }
3144
3145    fn register_recorder(calls: Rc<RefCell<Vec<(u32, u32)>>>) -> usize {
3146        let id = generate_component_id();
3147        set_current_component(id);
3148        on_resize(move |width, height| calls.borrow_mut().push((width, height)));
3149        clear_current_component();
3150        id
3151    }
3152
3153    #[test]
3154    fn renderer_resize_state_coalesces_raw_events_and_dispatches_once() {
3155        let _guard = resize_test_guard();
3156        let calls = Rc::new(RefCell::new(Vec::new()));
3157        let id = register_recorder(calls.clone());
3158        let mut state = ResizeState::new();
3159
3160        assert_eq!(state.record_initial(640, 480, 1.0), (640, 480));
3161        state.queue((800, 600));
3162        state.queue((900, 700));
3163        // Raw events only update the pending size; they do not dispatch hooks.
3164        assert!(calls.borrow().is_empty());
3165        assert_eq!(state.take_pending(), Some((900, 700)));
3166        assert_eq!(state.frame_logical_size(900, 700, 1.0, true), (900, 700));
3167        assert_eq!(state.take_pending(), None);
3168        assert_eq!(state.frame_logical_size(900, 700, 1.0, true), (900, 700));
3169
3170        assert_eq!(&*calls.borrow(), &[(900, 700)]);
3171        cleanup_component(id);
3172    }
3173
3174    #[test]
3175    fn renderer_resize_state_initial_logical_size_suppresses_notification() {
3176        let _guard = resize_test_guard();
3177        let calls = Rc::new(RefCell::new(Vec::new()));
3178        let id = register_recorder(calls.clone());
3179        let mut state = ResizeState::new();
3180
3181        assert_eq!(state.record_initial(800, 600, 1.0), (800, 600));
3182        assert_eq!(state.frame_logical_size(800, 600, 1.0, true), (800, 600));
3183        assert!(calls.borrow().is_empty());
3184        cleanup_component(id);
3185    }
3186
3187    #[test]
3188    fn renderer_resize_state_uses_viewport_logical_pixels_for_dpi() {
3189        let _guard = resize_test_guard();
3190        let calls = Rc::new(RefCell::new(Vec::new()));
3191        let id = register_recorder(calls.clone());
3192        let mut state = ResizeState::new();
3193
3194        assert_eq!(state.record_initial(1600, 1200, 2.0), (800, 600));
3195        state.queue((2000, 1000));
3196        assert_eq!(state.take_pending(), Some((2000, 1000)));
3197        assert_eq!(state.frame_logical_size(2000, 1000, 2.0, true), (1000, 500));
3198        assert_eq!(&*calls.borrow(), &[(1000, 500)]);
3199        cleanup_component(id);
3200    }
3201
3202    #[test]
3203    fn two_windows_with_equal_committed_sizes_both_dispatch() {
3204        let _guard = resize_test_guard();
3205        let calls = Rc::new(RefCell::new(Vec::new()));
3206        let id = register_recorder(calls.clone());
3207        let mut first_window = ResizeState::new();
3208        let mut second_window = ResizeState::new();
3209
3210        // Each window owns its baseline; both legitimately commit 900x600.
3211        first_window.record_initial(800, 600, 1.0);
3212        second_window.record_initial(700, 600, 1.0);
3213        first_window.queue((900, 600));
3214        second_window.queue((900, 600));
3215
3216        first_window.frame_logical_size(900, 600, 1.0, true);
3217        second_window.frame_logical_size(900, 600, 1.0, true);
3218
3219        assert_eq!(&*calls.borrow(), &[(900, 600), (900, 600)]);
3220        cleanup_component(id);
3221    }
3222
3223    #[test]
3224    fn renderer_resize_state_runs_all_hooks_and_allows_signal_mutation() {
3225        let _guard = resize_test_guard();
3226        let first_calls = Rc::new(RefCell::new(Vec::new()));
3227        let second_calls = Rc::new(RefCell::new(Vec::new()));
3228        let signal = Rc::new(Signal::new(0u32));
3229        let id = generate_component_id();
3230
3231        set_current_component(id);
3232        {
3233            let calls = first_calls.clone();
3234            on_resize(move |width, height| calls.borrow_mut().push((width, height)));
3235        }
3236        {
3237            let calls = second_calls.clone();
3238            let signal = signal.clone();
3239            on_resize(move |width, height| {
3240                calls.borrow_mut().push((width, height));
3241                signal.set(width + height);
3242            });
3243        }
3244        clear_current_component();
3245
3246        let mut state = ResizeState::new();
3247        state.record_initial(100, 100, 1.0);
3248        state.queue((200, 150));
3249        assert_eq!(state.take_pending(), Some((200, 150)));
3250        assert_eq!(state.frame_logical_size(200, 150, 1.0, true), (200, 150));
3251
3252        assert_eq!(&*first_calls.borrow(), &[(200, 150)]);
3253        assert_eq!(&*second_calls.borrow(), &[(200, 150)]);
3254        assert_eq!(signal.get(), 350);
3255        cleanup_component(id);
3256    }
3257
3258    #[test]
3259    fn cleanup_component_removes_only_its_resize_hook() {
3260        let _guard = resize_test_guard();
3261        let removed_calls = Rc::new(RefCell::new(Vec::new()));
3262        let retained_calls = Rc::new(RefCell::new(Vec::new()));
3263        let removed_id = register_recorder(removed_calls.clone());
3264        let retained_id = register_recorder(retained_calls.clone());
3265
3266        cleanup_component(removed_id);
3267        let mut state = ResizeState::new();
3268        state.record_initial(100, 100, 1.0);
3269        state.frame_logical_size(200, 150, 1.0, true);
3270
3271        assert!(removed_calls.borrow().is_empty());
3272        assert_eq!(&*retained_calls.borrow(), &[(200, 150)]);
3273        cleanup_component(retained_id);
3274    }
3275}
3276
3277// ---------------------------------------------------------------------------
3278// Task 5.6a — characterisation tests for the text-input edit and focus path.
3279//
3280// `apply_click_focus`, `apply_edit_to_focused` and `edit_action_for_key` are the
3281// three functions both copies of the event loop call into, and until this
3282// module landed none of them had any test coverage at all. A dedup that dropped
3283// or re-pointed an arm would have shipped silently, which is the failure mode
3284// these tests exist to prevent.
3285//
3286// They live here rather than in `tests/` because all three are *private* `fn`s
3287// behind `#[cfg(feature = "skia-native")]`; an integration test can only reach
3288// the crate's public API.
3289#[cfg(all(test, feature = "skia-native"))]
3290mod edit_focus_tests {
3291    use super::{
3292        apply_click_focus, apply_edit_to_focused, edit_action_for_key, inject_input_caret_attrs,
3293        recompute_targets, style_vnode_with_hover,
3294    };
3295    use crate::events::{EditAction, InputTarget};
3296    use velox_dom::{Props, VNode};
3297    use winit::event::VirtualKeyCode as K;
3298
3299    const W: i32 = 400;
3300    const H: i32 = 300;
3301    /// Absolutely positioned so the field's rect is known exactly: the click
3302    /// tests below use literal coordinates and must not depend on layout order.
3303    const FIELD_STYLE: &str = "position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:15px;padding-left:2px";
3304    const VALUE: &str = "hello";
3305    const HANDLER: &str = "set_value";
3306    /// The field spans x 20..220, y 20..60; its text starts at x 20 + 2px padding.
3307    const TEXT_X: f32 = 22.0;
3308    const TEXT_Y: f32 = 40.0;
3309    const EMPTY_X: f32 = 350.0;
3310    const EMPTY_Y: f32 = 250.0;
3311
3312    /// `(handler, value)` pairs handed to the app, in dispatch order.
3313    type Dispatches = Vec<(String, Option<String>)>;
3314
3315    /// One text input on a page, built through the frame order the loops use:
3316    /// style cascade, caret-attr injection, layout, then target collection.
3317    /// Going through the real pipeline is the point — a hand-assembled target
3318    /// vector would not exercise the `path`/`rect` plumbing these functions read.
3319    struct Scene {
3320        /// `None` models the window before the first `RedrawRequested`, when the
3321        /// loop has painted a frame but has not published it as `last_vnode`.
3322        vnode: Option<VNode>,
3323        inputs: Vec<InputTarget>,
3324        /// The loops' index-free mirror of "which input is focused". It is
3325        /// asserted on below, because after Esc it has to follow the blur
3326        /// rather than keep naming a field that is no longer focused.
3327        focused: Option<Vec<usize>>,
3328    }
3329
3330    fn scene(attrs: &[(&str, &str)]) -> Scene {
3331        let mut p = Props::new()
3332            .set("type", "text")
3333            .set("style", FIELD_STYLE)
3334            .set("value", VALUE)
3335            .set("on:input", HANDLER);
3336        for (k, v) in attrs.iter().copied() {
3337            p = p.set(k, v);
3338        }
3339        let raw = VNode::Element {
3340            tag: "div".into(),
3341            props: Props::new().set("style", "background:#000000"),
3342            children: vec![VNode::Element {
3343                tag: "input".into(),
3344                props: p,
3345                children: vec![],
3346            }],
3347        };
3348        let sheet = velox_style::Stylesheet::default();
3349        let styled = style_vnode_with_hover(&raw, &sheet, &|_tag: &str, _p: &Props| false);
3350        let vnode = inject_input_caret_attrs(&styled, None);
3351        let layout = velox_dom::layout::compute_layout(&vnode, W, H);
3352        let mut clicks: Vec<crate::events::ClickTarget> = Vec::new();
3353        let mut hovers: Vec<crate::events::HoverTarget> = Vec::new();
3354        let mut inputs: Vec<InputTarget> = Vec::new();
3355        recompute_targets(&vnode, &layout, &mut clicks, &mut hovers, &mut inputs);
3356        assert_eq!(inputs.len(), 1, "fixture must yield exactly one text input");
3357        Scene {
3358            vnode: Some(vnode),
3359            inputs,
3360            focused: None,
3361        }
3362    }
3363
3364    impl Scene {
3365        /// Focus the field with the caret `cursor` chars in.
3366        fn focused_at(mut self, cursor: usize) -> Self {
3367            self.inputs[0].focused = true;
3368            self.inputs[0].cursor = cursor;
3369            self
3370        }
3371    }
3372
3373    /// Run one edit, returning whether the caller must repaint and everything
3374    /// dispatched to the app. Asserting on the dispatch log, not just the
3375    /// caret, is the point: a caret that moves while the handler is never
3376    /// called is precisely the silent-typing failure this suite must not miss.
3377    fn run_edit(s: &mut Scene, action: EditAction) -> (bool, Dispatches) {
3378        let mut log: Dispatches = Vec::new();
3379        let repaint = {
3380            let mut sink = |name: &str, data: Option<&str>| {
3381                log.push((name.to_string(), data.map(str::to_string)))
3382            };
3383            apply_edit_to_focused(&mut s.inputs, action, &s.vnode, &mut s.focused, &mut sink)
3384        };
3385        (repaint, log)
3386    }
3387
3388    /// Click at (x, y), returning whether the caller must repaint. `focused` is
3389    /// the loop's own mirror of the focus flag, kept in `Scene` so the blur tests
3390    /// can assert it alongside the flag itself.
3391    fn run_click(s: &mut Scene, x: f32, y: f32) -> bool {
3392        // The viewport the loops pass is the live surface's logical size; this
3393        // module has no surface, so it passes the page it laid out against
3394        // (`W` x `H`) at scale 1. Only viewport-unit lengths (`vw`/`vh`) can
3395        // tell the two apart, and none of the fixtures below declare one.
3396        apply_click_focus(
3397            &mut s.inputs,
3398            &s.vnode,
3399            &mut s.focused,
3400            x,
3401            y,
3402            1.0,
3403            (W as f32, H as f32),
3404        )
3405    }
3406
3407    // -- apply_edit_to_focused ------------------------------------------------
3408
3409    #[test]
3410    fn insert_at_caret_dispatches_new_value_and_advances_caret() {
3411        let mut s = scene(&[]).focused_at(5);
3412        let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
3413        assert!(repaint, "an inserted char must repaint");
3414        assert_eq!(log, vec![(HANDLER.to_string(), Some("helloX".into()))]);
3415        assert_eq!(s.inputs[0].cursor, 6);
3416    }
3417
3418    #[test]
3419    fn insert_lands_at_the_caret_not_at_the_end() {
3420        let mut s = scene(&[]).focused_at(0);
3421        let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
3422        assert!(repaint);
3423        assert_eq!(log, vec![(HANDLER.to_string(), Some("Xhello".into()))]);
3424        assert_eq!(s.inputs[0].cursor, 1);
3425    }
3426
3427    #[test]
3428    fn backspace_removes_the_char_before_the_caret() {
3429        let mut s = scene(&[]).focused_at(5);
3430        let (repaint, log) = run_edit(&mut s, EditAction::Backspace);
3431        assert!(repaint);
3432        assert_eq!(log, vec![(HANDLER.to_string(), Some("hell".into()))]);
3433        assert_eq!(s.inputs[0].cursor, 4);
3434    }
3435
3436    #[test]
3437    fn submit_dispatches_the_unchanged_value() {
3438        let mut s = scene(&[]).focused_at(5);
3439        let (repaint, log) = run_edit(&mut s, EditAction::Submit);
3440        assert!(
3441            repaint,
3442            "submit must repaint even though the text is unchanged"
3443        );
3444        assert_eq!(
3445            log,
3446            vec![(HANDLER.to_string(), Some("hello".into()))],
3447            "a submit with no selection must still reach the handler"
3448        );
3449        assert_eq!(s.inputs[0].cursor, 5);
3450    }
3451
3452    #[test]
3453    fn caret_move_repaints_without_announcing_a_text_change() {
3454        let mut s = scene(&[]).focused_at(3);
3455        let (repaint, log) = run_edit(&mut s, EditAction::MoveLeft { shift: false });
3456        assert!(repaint, "a caret move is a visual change and must repaint");
3457        assert!(
3458            log.is_empty(),
3459            "a caret move must not dispatch a value change"
3460        );
3461        assert_eq!(s.inputs[0].cursor, 2);
3462    }
3463
3464    #[test]
3465    fn shift_caret_move_anchors_a_selection() {
3466        let mut s = scene(&[]).focused_at(3);
3467        let (repaint, log) = run_edit(&mut s, EditAction::MoveLeft { shift: true });
3468        assert!(repaint);
3469        assert!(log.is_empty());
3470        assert_eq!(s.inputs[0].cursor, 2);
3471        assert_eq!(
3472            s.inputs[0].anchor,
3473            Some(3),
3474            "a shift-move must anchor at the caret's old position"
3475        );
3476    }
3477
3478    #[test]
3479    fn no_focused_input_is_a_no_op() {
3480        let mut s = scene(&[]);
3481        assert!(!s.inputs[0].focused);
3482        let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
3483        assert!(!repaint, "an edit with nothing focused must not repaint");
3484        assert!(log.is_empty());
3485    }
3486
3487    #[test]
3488    fn backspace_at_caret_zero_changes_nothing() {
3489        let mut s = scene(&[]).focused_at(0);
3490        let (repaint, log) = run_edit(&mut s, EditAction::Backspace);
3491        assert!(!repaint, "backspace at index 0 is a no-op, not a repaint");
3492        assert!(log.is_empty(), "backspace at index 0 must not dispatch");
3493        assert_eq!(s.inputs[0].cursor, 0);
3494    }
3495
3496    /// The target holds no value of its own — the tree does. This is the coupling
3497    /// that makes an unpublished frame (below) a correctness problem rather than
3498    /// a cosmetic one.
3499    #[test]
3500    fn dispatch_uses_the_value_in_the_tree_not_the_target() {
3501        let mut s = scene(&[("value", "world")]).focused_at(5);
3502        let (repaint, log) = run_edit(&mut s, EditAction::Backspace);
3503        assert!(repaint);
3504        assert_eq!(log, vec![(HANDLER.to_string(), Some("worl".into()))]);
3505    }
3506
3507    // -- edit_action_for_key --------------------------------------------------
3508
3509    /// Every key the two loops route to the editor. This is the mapping a dedup
3510    /// could silently transpose, and `Back`/`Delete`/`Return` are exactly the
3511    /// keys whose mis-wiring stays invisible until someone types.
3512    #[test]
3513    fn editing_keys_map_to_their_actions() {
3514        use EditAction::*;
3515        let cases: &[(K, bool, EditAction)] = &[
3516            (K::Left, false, MoveLeft { shift: false }),
3517            (K::Left, true, MoveLeft { shift: true }),
3518            (K::Right, false, MoveRight { shift: false }),
3519            (K::Right, true, MoveRight { shift: true }),
3520            (K::Home, false, Home { shift: false }),
3521            (K::Home, true, Home { shift: true }),
3522            (K::End, false, End { shift: false }),
3523            (K::End, true, End { shift: true }),
3524            (K::Back, false, Backspace),
3525            (K::Delete, false, Delete),
3526            (K::Return, false, Submit),
3527            // Esc is an editing key even though it changes no text: it is the
3528            // only way out of a field, and the match has no fallthrough to user
3529            // code, so anything absent here simply cannot happen.
3530            (K::Escape, false, Blur),
3531        ];
3532        for (key, shift, want) in cases {
3533            assert_eq!(
3534                edit_action_for_key(*key, *shift),
3535                Some(*want),
3536                "{key:?} with shift={shift}"
3537            );
3538        }
3539    }
3540
3541    /// A printable key must map to `None`: characters reach the editor through
3542    /// `ReceivedCharacter`, so claiming one here would insert it twice.
3543    ///
3544    /// `Escape` is deliberately *not* in this list. It used to be, and that
3545    /// assertion was what pinned the bug: with Esc mapping to `None` and no
3546    /// other code handling it, the key was inert and a focused field could not
3547    /// be left. Esc maps to [`EditAction::Blur`] and the blur behaviour is
3548    /// asserted below.
3549    #[test]
3550    fn non_editing_keys_produce_no_action() {
3551        for key in [K::A, K::R, K::Key1, K::Space, K::Tab, K::Up, K::Down] {
3552            assert_eq!(
3553                edit_action_for_key(key, false),
3554                None,
3555                "{key:?} is not an editing key"
3556            );
3557        }
3558    }
3559
3560    // -- Esc unfocuses --------------------------------------------------------
3561
3562    /// Esc on a focused field drops focus, the selection and the caret, and
3563    /// leaves the text alone.
3564    ///
3565    /// The caret goes because Esc is an explicit "I am done here" rather than a
3566    /// click that wandered off — that is the difference from the empty-space
3567    /// blur above, which deliberately keeps the caret. The value must not
3568    /// change: Esc is not an edit, so the app must not be woken with an
3569    /// `on:input` it did not cause.
3570    #[test]
3571    fn escape_on_a_focused_field_blurs_it_and_drops_its_caret_and_selection() {
3572        let mut s = scene(&[]).focused_at(4);
3573        s.inputs[0].anchor = Some(1);
3574        assert_eq!(s.inputs[0].selection(), Some((1, 4)));
3575
3576        let (repaint, log) = run_edit(&mut s, EditAction::Blur);
3577
3578        assert!(
3579            repaint,
3580            "the focus ring and highlight disappear, so it repaints"
3581        );
3582        assert!(!s.inputs[0].focused, "Esc must leave the field");
3583        assert_eq!(
3584            s.inputs[0].anchor, None,
3585            "a selection cannot outlive the focus"
3586        );
3587        assert_eq!(s.inputs[0].cursor, 0, "Esc clears the caret");
3588        assert!(
3589            s.inputs[0].blink_on,
3590            "the caret comes back solid, not blinking"
3591        );
3592        assert!(
3593            log.is_empty(),
3594            "Esc changes no text, so nothing is dispatched"
3595        );
3596        assert_eq!(
3597            s.focused, None,
3598            "the loop's mirror of the focus must follow the blur, or it names a \
3599             field that is no longer focused"
3600        );
3601    }
3602
3603    /// Esc with nothing focused is free — no repaint, no dispatch.
3604    ///
3605    /// Same contract as every other no-op keypress: the loops branch on
3606    /// `needs_repaint()`, and a wake-up here would repaint for nothing.
3607    #[test]
3608    fn escape_with_nothing_focused_is_a_no_op() {
3609        let mut s = scene(&[]);
3610        assert!(!s.inputs[0].focused);
3611        let (repaint, log) = run_edit(&mut s, EditAction::Blur);
3612        assert!(
3613            !repaint,
3614            "there was no focus to drop, so there is nothing to redraw"
3615        );
3616        assert!(log.is_empty());
3617        assert_eq!(s.focused, None);
3618    }
3619
3620    /// Esc reaches the editor at all only because the key map claims it, so pin
3621    /// the mapping through the same call the loops make.
3622    #[test]
3623    fn escape_is_routed_to_the_blur_action() {
3624        assert_eq!(
3625            edit_action_for_key(K::Escape, false),
3626            Some(EditAction::Blur)
3627        );
3628        assert_eq!(
3629            edit_action_for_key(K::Escape, true),
3630            Some(EditAction::Blur),
3631            "Shift does not change what Esc means"
3632        );
3633    }
3634
3635    // -- apply_click_focus ----------------------------------------------------
3636
3637    #[test]
3638    fn focus_gain_puts_the_caret_at_the_end_of_the_value() {
3639        let mut s = scene(&[]);
3640        assert_eq!(s.inputs[0].cursor, 0);
3641        assert!(run_click(&mut s, TEXT_X + 1.0, TEXT_Y));
3642        assert!(s.inputs[0].focused);
3643        assert_eq!(
3644            s.inputs[0].cursor, 5,
3645            "focus gain must put the caret at the end, so typing appends"
3646        );
3647    }
3648
3649    #[test]
3650    fn click_into_a_focused_field_repositions_the_caret() {
3651        let mut s = scene(&[]).focused_at(5);
3652        assert!(run_click(&mut s, TEXT_X + 1.0, TEXT_Y));
3653        assert!(s.inputs[0].focused);
3654        assert_eq!(
3655            s.inputs[0].cursor, 0,
3656            "clicking the first glyph must move the caret there, not leave it at the end"
3657        );
3658    }
3659
3660    /// The regression this lane had: the origin was computed from a lookup of
3661    /// the literal key `padding-left`, which the cascade never writes for an
3662    /// authored shorthand. `padding: 6px 30px` therefore resolved to
3663    /// `rect.x + 0` while the painter drew the first glyph at `rect.x + 30`, so
3664    /// every caret sat 30px left of the glyph it claimed to be on. With the
3665    /// field focused, a click on the *first* glyph was measured 30px further
3666    /// right than it is and landed past the end of the value.
3667    #[test]
3668    fn shorthand_padding_places_the_caret_on_the_painted_glyph() {
3669        const SHORTHAND_STYLE: &str = "position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:15px;padding:6px 30px";
3670        let mut s = scene(&[("style", SHORTHAND_STYLE)]).focused_at(5);
3671        let rect = s.inputs[0].rect;
3672        assert_eq!(rect.x, 20, "fixture must stay absolutely positioned");
3673        let text_left = rect.x as f32 + 30.0;
3674
3675        assert!(run_click(&mut s, text_left + 1.0, TEXT_Y));
3676        assert_eq!(
3677            s.inputs[0].cursor, 0,
3678            "a click on the first glyph must put the caret before it, whatever \
3679             syntax the author used for the padding"
3680        );
3681    }
3682
3683    /// The origin is not merely "shifted": it must equal the painter's
3684    /// `text_left` for every spelling of the same declaration, because both
3685    /// lanes now read the same computed value out of
3686    /// `input_metrics::input_text_metrics`.
3687    #[test]
3688    fn text_origin_agrees_with_the_paint_authority_for_every_padding_spelling() {
3689        use velox_dom::Props;
3690        let rect = velox_dom::layout::Rect {
3691            x: 20,
3692            y: 20,
3693            w: 200,
3694            h: 40,
3695        };
3696        let viewport = (W as f32, H as f32);
3697        let authored = [
3698            "padding: 6px 30px",
3699            "padding-left: 30px",
3700            "padding: 6px 30px; padding-left: 30px",
3701            "padding-left: 30px; padding: 6px 30px",
3702            "padding: 30px 6px 6px 30px",
3703        ];
3704        for decl in authored {
3705            let props = Props::new().set("style", decl);
3706            assert_eq!(
3707                super::resolve_text_metrics(&props, rect, viewport).text_left,
3708                50.0,
3709                "`{decl}` must resolve the same first-glyph x as the paint lane"
3710            );
3711            // …and the authority itself, not a local copy of it.
3712            let authority = crate::input_metrics::input_text_metrics(
3713                props.attrs.get("style").map(|s| s.as_str()),
3714                rect,
3715                viewport,
3716                velox_dom::layout::DEFAULT_ROOT_FONT_SIZE,
3717            );
3718            assert_eq!(
3719                super::resolve_text_metrics(&props, rect, viewport).text_left,
3720                authority.text_left,
3721                "`{decl}`: the hit-test lane must not re-derive what paint computed"
3722            );
3723        }
3724        // A viewport-unit left padding must resolve against the viewport the
3725        // caller threaded in — the same one paint resolves `vw` against.
3726        let props = Props::new().set("style", "padding-left: 5vw");
3727        assert_eq!(
3728            super::resolve_text_metrics(&props, rect, viewport).text_left,
3729            rect.x as f32 + 0.05 * W as f32
3730        );
3731    }
3732
3733    /// The caret index a click at `click_x` leaves on a field styled `style`.
3734    ///
3735    /// The field is pre-focused on purpose: `apply_click_focus` sends the caret
3736    /// to the END of the value on focus gain, so a never-focused fixture would
3737    /// report "end of value" for every click x and any comparison built on it
3738    /// would pass without ever running the code under test.
3739    ///
3740    /// Overriding `style` and `value` on top of `FIELD_STYLE` matters for the
3741    /// same reason: `FIELD_STYLE` pins `font-size:15px`, and every fixture that
3742    /// inherits it silently opts out of the undeclared-font-size case these
3743    /// tests exist to cover.
3744    fn caret_from_click(style: &str, value: &str, click_x: f32) -> usize {
3745        let mut s = scene(&[("style", style), ("value", value)]).focused_at(0);
3746        assert!(
3747            run_click(&mut s, click_x, TEXT_Y),
3748            "the click at x={click_x} must land on the field, not on empty space"
3749        );
3750        s.inputs[0].cursor
3751    }
3752
3753    /// Assert that two styles describing visually identical fields answer
3754    /// identically at every click x across the field, and that the answers are
3755    /// non-degenerate.
3756    ///
3757    /// `equal_windows` sweeps the whole field rather than probing one x on
3758    /// purpose. A single probe can land on a char boundary where two disagreeing
3759    /// advances happen to round to the same index, and the test would pass with
3760    /// the defect still in place; sweeping makes that unfalsifiable at one lucky
3761    /// coordinate.
3762    fn assert_carets_agree_across_field(equal: (&str, &str), value: &str) {
3763        let (a, b) = equal;
3764        // Collect the WHOLE profile before asserting, so a failure reports how
3765        // far apart the two lanes end up rather than only the first coordinate
3766        // where they parted.
3767        let mut profile: Vec<(f32, usize, usize)> = Vec::new();
3768        let mut indices: Vec<usize> = Vec::new();
3769        let mut x = 20.0;
3770        while x <= 219.0 {
3771            let ca = caret_from_click(a, value, x);
3772            let cb = caret_from_click(b, value, x);
3773            if ca != cb {
3774                profile.push((x, ca, cb));
3775            }
3776            indices.push(ca);
3777            x += 1.0;
3778        }
3779        let worst = profile
3780            .iter()
3781            .map(|&(_, u, v)| (u as i64 - v as i64).abs())
3782            .max()
3783            .unwrap_or(0);
3784        assert!(
3785            profile.is_empty(),
3786            "`{a}` and `{b}` paint identically, so the caret must land on the same \
3787             glyph at every click x. They disagree at {} of 200 coordinates, by up \
3788             to {worst} character(s); first divergence: {:?}",
3789            profile.len(),
3790            profile.first()
3791        );
3792        // Anti-vacuity: a lane that ignored the click entirely, or that always
3793        // answered "end of value", would satisfy the equality above trivially.
3794        let len = value.chars().count();
3795        assert!(
3796            indices.iter().any(|&i| i > 0 && i < len),
3797            "every click resolved to an end-of-value caret, so this compared \
3798             nothing: {indices:?}"
3799        );
3800    }
3801
3802    /// The click-to-caret lane must measure advances at the size the PAINT lane
3803    /// draws them at, and must not hold a second answer to "how big is this
3804    /// field".
3805    ///
3806    /// The proof is differential and needs no font metric, no geometry constant
3807    /// and no knowledge of which face is bundled: an input that declares no
3808    /// `font-size` paints at the painter's root size — that is what "declares
3809    /// none" means — which is by definition the same size an input declaring
3810    /// that size *explicitly* paints at. The two fixtures are therefore
3811    /// identical on screen, so a click at the same x must leave the caret on
3812    /// the same glyph, whatever each lane measured with.
3813    ///
3814    /// This is exactly the shipped-boilerplate-shaped hole: the UA sheet
3815    /// declares no `font-size` for `input`, so a field that never says it
3816    /// inherits 14px from the painter's root `TextStyle` while the hit-test lane
3817    /// fell back to its own 16px. Every advance came out `16/14` wide, so a
3818    /// click at the far end of the value landed the caret roughly one character
3819    /// short for every seven clicked.
3820    #[test]
3821    fn the_caret_lane_measures_at_the_size_an_undeclared_font_size_paints_at() {
3822        // Wide glyphs, and enough of them that a 14%-wide advance error cannot
3823        // hide inside one character's slack over the 200px field.
3824        assert_carets_agree_across_field(
3825            (
3826                "position:absolute;left:20px;top:20px;width:200px;height:40px",
3827                "position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:14px",
3828            ),
3829            &"M".repeat(20),
3830        );
3831    }
3832
3833    /// The origin half of the same defect, which survived after the origin was
3834    /// first routed through the shared authority: an `em` padding resolves
3835    /// against the ELEMENT's font size, so an element that inherits its size has
3836    /// to resolve `4em` against the inherited size in *both* lanes. The hit-test
3837    /// lane passed its own 16px where paint inherits 14px, so the caret sat a
3838    /// whole `4em` — 16px — to the right of the glyph it claimed to be on.
3839    #[test]
3840    fn em_padding_resolves_against_the_same_inherited_size_in_both_lanes() {
3841        // Vertical padding stays 0 so the only thing under test is the
3842        // horizontal origin, and the field's box cannot move out from under the
3843        // literal click coordinates the sweep uses.
3844        assert_carets_agree_across_field(
3845            (
3846                "position:absolute;left:20px;top:20px;width:200px;height:40px;padding:0 4em",
3847                "position:absolute;left:20px;top:20px;width:200px;height:40px;padding:0 4em;font-size:14px",
3848            ),
3849            &"M".repeat(20),
3850        );
3851    }
3852
3853    /// A relative `font-size` is resolved against the inherited size by the
3854    /// cascade, so `2em` on a field that inherits 14px paints at 28px — and
3855    /// nothing about that declaration contains a `px` suffix for a lane-local
3856    /// literal parser to find.
3857    #[test]
3858    fn a_relative_font_size_reaches_the_caret_lane_at_its_resolved_size() {
3859        assert_carets_agree_across_field(
3860            (
3861                "position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:2em",
3862                "position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:28px",
3863            ),
3864            &"M".repeat(20),
3865        );
3866    }
3867
3868    #[test]
3869    fn clicking_empty_space_blurs_clears_the_selection_and_keeps_the_caret() {
3870        let mut s = scene(&[]).focused_at(5);
3871        s.inputs[0].anchor = Some(1);
3872        assert!(
3873            run_click(&mut s, EMPTY_X, EMPTY_Y),
3874            "a blur is a visual change"
3875        );
3876        assert!(!s.inputs[0].focused);
3877        assert_eq!(
3878            s.inputs[0].anchor, None,
3879            "a stale highlight must not survive a blur"
3880        );
3881        assert_eq!(
3882            s.inputs[0].cursor, 5,
3883            "blur keeps the caret, so re-entering the field restores position"
3884        );
3885    }
3886
3887    #[test]
3888    fn clicking_empty_space_with_nothing_focused_changes_nothing() {
3889        let mut s = scene(&[]);
3890        assert!(
3891            !run_click(&mut s, EMPTY_X, EMPTY_Y),
3892            "a click on empty space with nothing focused is not a change"
3893        );
3894    }
3895
3896    // -- the `last_vnode` window ----------------------------------------------
3897    //
3898    // Both loops declare `last_vnode` as `None` and only assign it on
3899    // `RedrawRequested`, so before the loop's first redraw it is empty. The HMR
3900    // loop additionally publishes its pre-loop frame; these tests pin what the
3901    // plain loop must do to match, and what happens if it does not.
3902
3903    /// With no published frame there is nothing to measure or to read a value
3904    /// from, so focus gain leaves the caret at 0 — which turns "click a field
3905    /// and type" into a *prepend*.
3906    #[test]
3907    fn focus_gain_with_no_published_frame_puts_the_caret_at_zero() {
3908        let mut s = scene(&[]);
3909        s.vnode = None;
3910        assert!(run_click(&mut s, TEXT_X + 1.0, TEXT_Y));
3911        assert!(s.inputs[0].focused);
3912        assert_eq!(
3913            s.inputs[0].cursor, 0,
3914            "with no frame to measure, focus gain leaves the caret at 0 and typing prepends"
3915        );
3916    }
3917
3918    /// The other half of the same window: the edit mutates the caret, reports
3919    /// "repaint", and reaches the app not at all. The keystroke is lost and the
3920    /// caret is left corrupted — the value reads as `""`, so the caret is
3921    /// clamped to 0 before the insert and lands at 1 rather than 6.
3922    #[test]
3923    fn insert_with_no_published_frame_is_silently_dropped() {
3924        let mut s = scene(&[]).focused_at(5);
3925        s.vnode = None;
3926        let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
3927        assert!(
3928            repaint,
3929            "the edit still reports a repaint, so the loop schedules a frame for nothing"
3930        );
3931        assert!(
3932            log.is_empty(),
3933            "no dispatch at all: the app never sees the keystroke, yet the caret moved"
3934        );
3935        assert_eq!(
3936            s.inputs[0].cursor, 1,
3937            "the caret is clamped to an empty value and corrupted, not advanced to 6"
3938        );
3939    }
3940
3941    /// `last_vnode` is what the two functions above read, so both loops must
3942    /// publish the frame they render *before* they start dispatching input, not
3943    /// only once the first `RedrawRequested` arrives. Structural — the loops need
3944    /// a live window — but it is the only thing that binds the requirement to the
3945    /// loops themselves rather than to one call site.
3946    #[test]
3947    fn both_loops_publish_their_pre_loop_frame_before_they_start() {
3948        let src = include_str!("lib.rs");
3949        // Assembled from two halves on purpose: `include_str!` reads this test
3950        // module too, and a needle written out in full would match its own
3951        // source and look like a third loop.
3952        let decl = format!("last_vnode: Option<{}::VNode> = None;", "velox_dom");
3953        let mut loops = 0usize;
3954        let mut rest = src;
3955        while let Some(at) = rest.find(decl.as_str()) {
3956            loops += 1;
3957            let body = &rest[at..];
3958            let run_at = body
3959                .find("event_loop.run(")
3960                .unwrap_or_else(|| panic!("loop {loops} has no `event_loop.run(`"));
3961            assert!(
3962                body[..run_at].contains("last_vnode = Some("),
3963                "loop {loops} must assign last_vnode in its pre-loop frame; until it does, every \
3964                 click and keystroke before the first redraw is handled against an empty value"
3965            );
3966            rest = &body[run_at..];
3967        }
3968        assert_eq!(loops, 2, "expected the plain loop and the HMR loop");
3969    }
3970}