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}