use velox_dom::VNode;
use velox_style::Stylesheet;
pub fn style_vnode_with_hover<F>(vnode: &VNode, author: &Stylesheet, is_hovered: &F) -> VNode
where
F: Fn(&str, &velox_dom::Props) -> bool,
{
velox_style::apply_with_cascade_with_hover(vnode, author, is_hovered)
}
#[allow(dead_code)]
pub struct LifecycleCleanupGuard;
impl Drop for LifecycleCleanupGuard {
fn drop(&mut self) {
let _ = std::panic::catch_unwind(velox_core::lifecycle::run_all_destroy_hooks);
}
}
#[allow(dead_code)]
pub fn ensure_mounted(flag: &mut bool) {
if !*flag {
*flag = true;
velox_core::lifecycle::run_all_mounted_hooks();
}
}
pub fn find_node_at_path<'a>(node: &'a VNode, path: &[usize]) -> Option<&'a VNode> {
let mut cur = node;
for &idx in path {
match cur {
VNode::Element { children, .. } => cur = children.get(idx)?,
_ => return None,
}
}
Some(cur)
}
#[cfg(any(feature = "skia-native", test))]
fn viewport_logical_dimensions(width: i32, height: i32, scale_factor: f32) -> (u32, u32) {
Viewport::from_i32(width, height, scale_factor).logical_size()
}
#[cfg(feature = "skia-native")]
pub fn logical_size(width: i32, height: i32, scale_factor: f32) -> (u32, u32) {
viewport_logical_dimensions(width, height, scale_factor)
}
#[cfg(any(feature = "skia-native", test))]
fn dispatch_resize_if_changed(
committed: bool,
logical: (u32, u32),
last_resize_size: &mut Option<(u32, u32)>,
) {
if committed && *last_resize_size != Some(logical) {
velox_core::lifecycle::run_resize_hooks(logical.0, logical.1);
*last_resize_size = Some(logical);
}
}
#[cfg(any(feature = "skia-native", test))]
fn frame_logical_size(
width: i32,
height: i32,
scale_factor: f32,
committed: bool,
last_resize_size: &mut Option<(u32, u32)>,
) -> (u32, u32) {
let logical = viewport_logical_dimensions(width, height, scale_factor);
dispatch_resize_if_changed(committed, logical, last_resize_size);
logical
}
#[cfg(any(feature = "skia-native", test))]
struct ResizeState {
pending_resize: Option<(u32, u32)>,
last_resize_size: Option<(u32, u32)>,
}
#[cfg(any(feature = "skia-native", test))]
impl ResizeState {
fn new() -> Self {
Self {
pending_resize: None,
last_resize_size: None,
}
}
fn queue(&mut self, physical: (u32, u32)) {
self.pending_resize = Some(physical);
}
fn take_pending(&mut self) -> Option<(u32, u32)> {
self.pending_resize.take()
}
fn record_initial(&mut self, width: i32, height: i32, scale_factor: f32) -> (u32, u32) {
let logical = viewport_logical_dimensions(width, height, scale_factor);
self.last_resize_size = Some(logical);
logical
}
fn frame_logical_size(
&mut self,
width: i32,
height: i32,
scale_factor: f32,
committed: bool,
) -> (u32, u32) {
frame_logical_size(
width,
height,
scale_factor,
committed,
&mut self.last_resize_size,
)
}
}
#[cfg(feature = "skia-native")]
fn recompute_targets(
vnode: &velox_dom::VNode,
layout: &velox_dom::layout::LayoutNode,
click_targets: &mut Vec<crate::events::ClickTarget>,
hover_targets: &mut Vec<crate::events::HoverTarget>,
input_targets: &mut Vec<crate::events::InputTarget>,
) {
click_targets.clear();
let mut order = 0;
crate::events::collect_click_targets(
vnode,
layout,
None,
crate::events::StackCtx::ROOT,
&mut order,
click_targets,
);
hover_targets.clear();
let mut order = 0;
crate::events::collect_hover_targets(
vnode,
layout,
None,
crate::events::StackCtx::ROOT,
&mut order,
hover_targets,
);
let previous = std::mem::take(input_targets);
let mut order = 0;
let mut path = Vec::new();
crate::events::collect_input_targets(
vnode,
layout,
None,
crate::events::StackCtx::ROOT,
&mut path,
&mut order,
input_targets,
);
crate::events::preserve_input_state(input_targets, &previous, &|p| {
input_value_char_len(vnode, p)
});
}
#[cfg(feature = "skia-native")]
fn with_hover_ids(vnode: &velox_dom::VNode, next_id: &mut u32) -> velox_dom::VNode {
match vnode {
velox_dom::VNode::Text(_) => vnode.clone(),
velox_dom::VNode::Element {
tag,
props,
children,
} => {
let mut new_props = props.clone();
if crate::events::is_hoverable(tag, props) {
let id = *next_id;
*next_id += 1;
new_props = new_props.set("data-hover-id", id.to_string());
}
let new_children = children
.iter()
.map(|c| with_hover_ids(c, next_id))
.collect();
velox_dom::VNode::Element {
tag: tag.clone(),
props: new_props,
children: new_children,
}
}
}
}
#[cfg(feature = "skia-native")]
const CARET_BLINK_MS: u64 = 530;
#[cfg(feature = "skia-native")]
struct CaretBlinkTicker<T: Clone + Send + 'static> {
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
_tick: std::marker::PhantomData<fn() -> T>,
}
#[cfg(feature = "skia-native")]
impl<T: Clone + Send + 'static> CaretBlinkTicker<T> {
fn start(proxy: winit::event_loop::EventLoopProxy<T>, tick: T) -> Self {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_thread = std::sync::Arc::clone(&stop);
let period = std::time::Duration::from_millis(CARET_BLINK_MS);
let handle = std::thread::Builder::new()
.name("velox-caret-blink".into())
.spawn(move || {
while !stop_thread.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(period);
if stop_thread.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
if proxy.send_event(tick.clone()).is_err() {
break;
}
}
})
.ok();
Self {
stop,
handle,
_tick: std::marker::PhantomData,
}
}
}
#[cfg(feature = "skia-native")]
impl<T: Clone + Send + 'static> Drop for CaretBlinkTicker<T> {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
#[cfg(feature = "skia-native")]
fn on_caret_blink_tick(
input_targets: &mut [crate::events::InputTarget],
blink_deadline: &mut Option<std::time::Instant>,
) -> bool {
let Some(idx) = crate::events::focused_input_index(input_targets) else {
return false;
};
let now = std::time::Instant::now();
if let Some(d) = *blink_deadline
&& now < d
{
return false;
}
input_targets[idx].blink_on = !input_targets[idx].blink_on;
*blink_deadline = Some(now + std::time::Duration::from_millis(CARET_BLINK_MS));
true
}
#[cfg(feature = "skia-native")]
fn arm_blink_deadline(blink_deadline: &mut Option<std::time::Instant>) {
*blink_deadline =
Some(std::time::Instant::now() + std::time::Duration::from_millis(CARET_BLINK_MS));
}
#[cfg(feature = "skia-native")]
fn style_decl<'s>(style: &'s str, key: &str) -> Option<&'s str> {
style
.split(';')
.filter_map(|d| d.split_once(':'))
.find(|(k, _)| k.trim() == key)
.map(|(_, v)| v.trim())
}
#[cfg(feature = "skia-native")]
fn resolve_text_metrics(
props: &velox_dom::Props,
rect: velox_dom::layout::Rect,
viewport: (f32, f32),
) -> crate::input_metrics::InputTextMetrics {
crate::input_metrics::input_text_metrics(
props.attrs.get("style").map(|s| s.as_str()),
rect,
viewport,
crate::skia_render::PAINT_ROOT_FONT_SIZE,
)
}
#[cfg(feature = "skia-native")]
fn input_value_at(vnode: &VNode, path: &[usize]) -> Option<String> {
match find_node_at_path(vnode, path) {
Some(VNode::Element { props, .. }) => {
Some(props.attrs.get("value").cloned().unwrap_or_default())
}
_ => None,
}
}
#[cfg(feature = "skia-native")]
fn input_value_char_len(vnode: &VNode, path: &[usize]) -> usize {
input_value_at(vnode, path)
.map(|s| s.chars().count())
.unwrap_or(0)
}
#[cfg(feature = "skia-native")]
fn dispatch_input_value(
last_vnode: &Option<VNode>,
path: &[usize],
new_value: &str,
on_event: &mut impl FnMut(&str, Option<&str>),
) {
let Some(vnode) = last_vnode else { return };
let Some(node) = find_node_at_path(vnode, path) else {
return;
};
let VNode::Element { props, .. } = node else {
return;
};
let Some(handler) = props.attrs.get("on:input").cloned() else {
return;
};
on_event(&handler, Some(new_value));
}
#[cfg(feature = "skia-native")]
pub fn apply_edit_to_focused(
input_targets: &mut [crate::events::InputTarget],
action: crate::events::EditAction,
last_vnode: &Option<VNode>,
focused_input: &mut Option<Vec<usize>>,
on_event: &mut impl FnMut(&str, Option<&str>),
) -> bool {
let Some(idx) = crate::events::focused_input_index(input_targets) else {
return false;
};
let path = input_targets[idx].path.clone();
let value = last_vnode
.as_ref()
.and_then(|v| input_value_at(v, &path))
.unwrap_or_default();
let res = crate::events::apply_edit(&mut input_targets[idx], &value, action);
if res.submit {
let v = res.value.clone().unwrap_or_else(|| value.clone());
dispatch_input_value(last_vnode, &path, &v, on_event);
} else if let Some(new_value) = &res.value {
dispatch_input_value(last_vnode, &path, new_value, on_event);
}
if matches!(action, crate::events::EditAction::Blur) {
*focused_input = None;
}
res.needs_repaint()
}
#[cfg(feature = "skia-native")]
pub fn edit_action_for_key(
keycode: winit::event::VirtualKeyCode,
shift: bool,
) -> Option<crate::events::EditAction> {
use crate::events::EditAction;
use winit::event::VirtualKeyCode as K;
Some(match keycode {
K::Left => EditAction::MoveLeft { shift },
K::Right => EditAction::MoveRight { shift },
K::Home => EditAction::Home { shift },
K::End => EditAction::End { shift },
K::Back => EditAction::Backspace,
K::Delete => EditAction::Delete,
K::Return => EditAction::Submit,
K::Escape => EditAction::Blur,
_ => return None,
})
}
#[cfg(feature = "skia-native")]
pub fn key_name(keycode: winit::event::VirtualKeyCode) -> &'static str {
use winit::event::VirtualKeyCode as K;
match keycode {
K::A => "A",
K::B => "B",
K::C => "C",
K::D => "D",
K::E => "E",
K::F => "F",
K::G => "G",
K::H => "H",
K::I => "I",
K::J => "J",
K::K => "K",
K::L => "L",
K::M => "M",
K::N => "N",
K::O => "O",
K::P => "P",
K::Q => "Q",
K::R => "R",
K::S => "S",
K::T => "T",
K::U => "U",
K::V => "V",
K::W => "W",
K::X => "X",
K::Y => "Y",
K::Z => "Z",
K::Key0 => "0",
K::Key1 => "1",
K::Key2 => "2",
K::Key3 => "3",
K::Key4 => "4",
K::Key5 => "5",
K::Key6 => "6",
K::Key7 => "7",
K::Key8 => "8",
K::Key9 => "9",
K::F1 => "F1",
K::F2 => "F2",
K::F3 => "F3",
K::F4 => "F4",
K::F5 => "F5",
K::F6 => "F6",
K::F7 => "F7",
K::F8 => "F8",
K::F9 => "F9",
K::F10 => "F10",
K::F11 => "F11",
K::F12 => "F12",
K::Up => "ArrowUp",
K::Down => "ArrowDown",
K::Left => "ArrowLeft",
K::Right => "ArrowRight",
K::Back => "Backspace",
K::Delete => "Delete",
K::Insert => "Insert",
K::Home => "Home",
K::End => "End",
K::PageUp => "PageUp",
K::PageDown => "PageDown",
K::Return | K::NumpadEnter => "Enter",
K::Escape => "Escape",
K::Tab => "Tab",
K::Space => "Space",
K::LShift | K::RShift => "Shift",
K::LControl | K::RControl => "Control",
K::LAlt | K::RAlt => "Alt",
K::LWin | K::RWin => "Meta",
_ => "Unidentified",
}
}
#[cfg(feature = "skia-native")]
pub fn focus_input_by_id(
input_targets: &mut [crate::events::InputTarget],
last_vnode: &Option<VNode>,
focused_input: &mut Option<Vec<usize>>,
id: &str,
) -> bool {
let Some(vnode) = last_vnode.as_ref() else {
return false;
};
let Some(path) = crate::events::find_focus_id_path(vnode, &[], id) else {
return false;
};
let plan = crate::events::KeydownPlan {
focus_paths: vec![path],
..Default::default()
};
crate::events::apply_keydown(
&plan,
"",
input_targets,
focused_input,
&|p: &[usize]| input_value_char_len(vnode, p),
&mut (|_: &str, _: Option<&str>| {}),
)
}
#[cfg(feature = "skia-native")]
pub fn dispatch_keydown(
keycode: winit::event::VirtualKeyCode,
input_targets: &mut [crate::events::InputTarget],
last_vnode: &Option<VNode>,
focused_input: &mut Option<Vec<usize>>,
on_event: &mut impl FnMut(&str, Option<&str>),
) -> bool {
let name = crate::key_name(keycode);
let Some(vnode) = last_vnode.as_ref() else {
return false;
};
let plan = crate::events::plan_keydown(vnode, name);
crate::events::apply_keydown(
&plan,
name,
input_targets,
focused_input,
&|p: &[usize]| input_value_char_len(vnode, p),
on_event,
)
}
#[cfg(feature = "skia-native")]
fn inject_input_caret_attrs(vnode: &VNode, focused: Option<&crate::events::InputTarget>) -> VNode {
fn walk(
node: &VNode,
focused_path: Option<&[usize]>,
on_path: bool,
focused: Option<&crate::events::InputTarget>,
) -> VNode {
match node {
VNode::Text(_) => node.clone(),
VNode::Element {
tag,
props,
children,
} => {
let mut new_props = props.clone();
if crate::events::is_text_input(tag, props) {
let (is_focused, cursor, blink, sel_start, sel_end) = match focused {
Some(t) if on_path => {
let (a, b) = t.selection().unwrap_or((t.cursor, t.cursor));
(true, t.cursor, t.blink_on, a, b)
}
_ => (false, 0, true, 0, 0),
};
new_props = new_props
.set("focused", if is_focused { "true" } else { "false" })
.set("caret", cursor.to_string())
.set("caret_blink", if blink { "true" } else { "false" })
.set("sel_start", sel_start.to_string())
.set("sel_end", sel_end.to_string());
}
let new_children = children
.iter()
.enumerate()
.map(|(i, child)| {
let child_on_path = on_path
&& focused_path
.and_then(|p| p.first())
.map(|first| *first == i)
.unwrap_or(false);
let rest = if child_on_path {
focused_path.map(|p| &p[1..])
} else {
None
};
walk(child, rest, child_on_path, focused)
})
.collect();
VNode::Element {
tag: tag.clone(),
props: new_props,
children: new_children,
}
}
}
}
let focused_path = focused.map(|t| t.path.as_slice());
walk(vnode, focused_path, true, focused)
}
#[cfg(feature = "skia-native")]
fn resolve_font_family(props: &velox_dom::Props) -> String {
props
.attrs
.get("style")
.and_then(|s| style_decl(s, "font-family"))
.filter(|f| !f.is_empty())
.unwrap_or("system-ui, sans-serif")
.to_string()
}
#[cfg(feature = "skia-native")]
fn surface_logical_viewport(
renderer: &skia_backend::SkiaRenderer,
scale_factor: f32,
) -> (f32, f32) {
match &renderer.surface {
Some(s) => {
let (w, h) = logical_size(s.width, s.height, scale_factor);
(w as f32, h as f32)
}
None => (0.0, 0.0),
}
}
#[cfg(feature = "skia-native")]
fn apply_click_focus(
input_targets: &mut [crate::events::InputTarget],
last_vnode: &Option<VNode>,
focused_input: &mut Option<Vec<usize>>,
x: f32,
y: f32,
scale_factor: f32,
viewport: (f32, f32),
) -> bool {
let Some(idx) = crate::events::hit_test_input_index(input_targets, x, y) else {
let changed = crate::events::blur_focused_input(input_targets);
return changed || focused_input.take().is_some();
};
let was_focused = input_targets[idx].focused;
let rect = input_targets[idx].rect;
let path = input_targets[idx].path.clone();
let mut caret: Option<usize> = None;
let mut value_len: Option<usize> = None;
if let Some(vnode) = last_vnode.as_ref()
&& let Some(VNode::Element { props, .. }) = find_node_at_path(vnode, &path)
{
let value = props.attrs.get("value").cloned().unwrap_or_default();
value_len = Some(value.chars().count());
let metrics = resolve_text_metrics(props, rect, viewport);
let font_size = metrics.font_size;
let config = crate::text::TextRenderConfig::new(&resolve_font_family(props), font_size);
let measure =
|s: &str| crate::text::TextMeasurer::measure_with_scale(s, &config, scale_factor).0;
caret = Some(crate::events::click_to_char_index(
&value,
rect,
x,
metrics.text_left,
font_size,
&measure,
));
}
crate::events::focus_input(input_targets, idx);
input_targets[idx].blink_on = true;
input_targets[idx].anchor = None;
input_targets[idx].cursor = if was_focused {
caret.unwrap_or(input_targets[idx].cursor)
} else {
value_len.unwrap_or(input_targets[idx].cursor)
};
*focused_input = Some(path);
true
}
pub mod event_binding;
pub mod events;
pub mod hmr;
pub mod input_metrics;
pub mod text;
pub mod viewport;
pub use hmr::{DEFAULT_HMR_PORT, HmrMessage, hmr_config, run_hmr_client};
pub use viewport::{LogicalSize, PhysicalSize, Viewport};
#[cfg(feature = "skia-native")]
mod skia_gl;
#[cfg(feature = "skia-native")]
mod skia_render;
#[cfg(feature = "skia-native")]
pub mod skia_surface;
#[cfg(feature = "skia-native")]
mod presenter;
#[cfg(feature = "skia-native")]
pub use skia_render::skia_impl::render_vnode_to_rgba;
#[cfg(feature = "skia-native")]
pub use skia_render::skia_impl::{image_decode_count, reset_image_decode_count};
#[cfg(feature = "skia-native")]
pub use skia_render::{render_vnode_to_raster_png, render_vnode_to_raster_png_with_scale};
pub struct RenderTree {
pub root: VNode,
pub node_count: usize,
pub text_count: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct A11yNode {
pub id: usize,
pub role: String,
pub name: String,
pub rect: velox_dom::layout::Rect,
pub children: Vec<A11yNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct A11yTree {
pub root: A11yNode,
}
pub trait HmrRenderer {
fn init() -> Result<(), String>
where
Self: Sized;
fn mount(&mut self, vnode: VNode) -> Result<(), String>;
fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String>;
fn get_window_handle(&self) -> *mut std::ffi::c_void;
}
pub trait VeloxRenderer {
fn new() -> Result<Self, String>
where
Self: Sized;
fn mount(&mut self, vnode: VNode) -> Result<(), String>;
fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String>;
}
fn summarize(v: &VNode, counts: &mut (usize, usize)) {
match v {
VNode::Text(_) => {
counts.0 += 1;
counts.1 += 1;
}
VNode::Element { children, .. } => {
counts.0 += 1;
for c in children {
summarize(c, counts);
}
}
}
}
fn build_render_tree(v: &VNode) -> RenderTree {
let mut counts = (0, 0);
summarize(v, &mut counts);
RenderTree {
root: v.clone(),
node_count: counts.0,
text_count: counts.1,
}
}
fn vnode_text_content(node: &VNode) -> String {
match node {
VNode::Text(t) => t.clone(),
VNode::Element { children, .. } => {
let mut out = String::new();
for ch in children {
let s = vnode_text_content(ch);
if !s.is_empty() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(&s);
}
}
out
}
}
}
fn a11y_role_for(tag: &str, props: &velox_dom::Props) -> String {
if let Some(role) = props.attrs.get("role") {
return role.clone();
}
match tag {
"button" => "button",
"img" => "image",
"input" => "textbox",
"label" => "label",
"a" => "link",
_ => "group",
}
.to_string()
}
fn a11y_name_for(tag: &str, props: &velox_dom::Props, node: &VNode) -> String {
if let Some(label) = props.attrs.get("aria-label") {
return label.clone();
}
if tag == "img"
&& let Some(alt) = props.attrs.get("alt")
{
return alt.clone();
}
vnode_text_content(node)
}
fn build_a11y_tree_with_layout(
vnode: &VNode,
layout: &velox_dom::layout::LayoutNode,
next_id: &mut usize,
) -> A11yNode {
let id = *next_id;
*next_id += 1;
match vnode {
VNode::Text(t) => A11yNode {
id,
role: "text".to_string(),
name: t.clone(),
rect: layout.rect,
children: Vec::new(),
},
VNode::Element {
tag,
props,
children,
..
} => {
let mut child_nodes = Vec::new();
for ch_layout in &layout.children {
if ch_layout.display_none {
continue;
}
if let Some(src_idx) = ch_layout.source_index
&& let Some(ch) = children.get(src_idx)
{
child_nodes.push(build_a11y_tree_with_layout(ch, ch_layout, next_id));
}
}
A11yNode {
id,
role: a11y_role_for(tag, props),
name: a11y_name_for(tag, props, vnode),
rect: layout.rect,
children: child_nodes,
}
}
}
}
pub fn build_a11y_tree(vnode: &VNode, width: i32, height: i32) -> A11yTree {
let layout = velox_dom::layout::compute_layout(vnode, width, height);
let mut next_id = 1;
let root = build_a11y_tree_with_layout(vnode, &layout, &mut next_id);
A11yTree { root }
}
pub trait Renderer {
fn backend_name(&self) -> &'static str;
fn mount(&self, vnode: &VNode) -> Result<RenderTree, String>;
}
#[cfg(feature = "skia-native")]
pub mod skia_backend {
use crate::HmrRenderer;
use crate::VeloxRenderer;
#[cfg(feature = "skia-native")]
use crate::skia_gl;
#[cfg(feature = "skia-native")]
use crate::skia_surface;
#[cfg(feature = "skia-native")]
use raw_window_handle::HasRawWindowHandle;
use velox_dom::VNode;
pub fn init() -> Result<(), String> {
match skia_gl::create_context() {
Ok(gl_ctx) => match gl_ctx.into_direct_context() {
Some(_dctx) => {
log::info!("skia backend: init OK (DirectContext created)");
Ok(())
}
None => Err("skia backend: init failed: couldn't create DirectContext".to_string()),
},
Err(e) => Err(format!("skia backend: init failed: {}", e)),
}
}
pub struct SkiaRenderer {
pub surface: Option<skia_surface::SkiaSurface>,
pub vnode: Option<VNode>,
}
impl SkiaRenderer {
pub fn with_window(
window: &impl HasRawWindowHandle,
width: i32,
height: i32,
) -> Result<Self, String> {
match skia_surface::create_window_surface_from_handle(window, width, height) {
Ok(s) => Ok(SkiaRenderer {
surface: Some(s),
vnode: None,
}),
Err(e) => Err(e),
}
}
pub fn present(&mut self) -> Result<(), String> {
if let Some(s) = &mut self.surface {
s.present()
} else {
Ok(())
}
}
pub fn resize(&mut self, width: i32, height: i32) -> Result<(), String> {
if let Some(s) = &mut self.surface {
s.resize(width, height)
} else {
Ok(())
}
}
}
impl crate::Renderer for SkiaRenderer {
fn backend_name(&self) -> &'static str {
"skia"
}
fn mount(&self, vnode: &velox_dom::VNode) -> Result<crate::RenderTree, String> {
Ok(crate::build_render_tree(vnode))
}
}
impl HmrRenderer for SkiaRenderer {
fn init() -> Result<(), String> {
init()
}
fn mount(&mut self, vnode: VNode) -> Result<(), String> {
self.vnode = Some(vnode);
Ok(())
}
fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String> {
self.vnode = Some(new_vnode);
Ok(())
}
fn get_window_handle(&self) -> *mut std::ffi::c_void {
std::ptr::null_mut()
}
}
impl VeloxRenderer for SkiaRenderer {
fn new() -> Result<Self, String> {
Ok(SkiaRenderer {
surface: None,
vnode: None,
})
}
fn mount(&mut self, vnode: VNode) -> Result<(), String> {
self.vnode = Some(vnode);
Ok(())
}
fn hot_update(&mut self, new_vnode: VNode) -> Result<(), String> {
self.vnode = Some(new_vnode);
Ok(())
}
}
}
#[cfg(all(feature = "skia", not(feature = "skia-native")))]
pub mod skia_backend {
pub fn init() -> Result<(), String> {
Ok(())
}
pub struct SkiaRenderer;
impl crate::Renderer for SkiaRenderer {
fn backend_name(&self) -> &'static str {
"skia"
}
fn mount(&self, vnode: &velox_dom::VNode) -> Result<crate::RenderTree, String> {
Ok(crate::build_render_tree(vnode))
}
}
impl crate::HmrRenderer for SkiaRenderer {
fn init() -> Result<(), String> {
Ok(())
}
fn mount(&mut self, _vnode: velox_dom::VNode) -> Result<(), String> {
Ok(())
}
fn hot_update(&mut self, _new_vnode: velox_dom::VNode) -> Result<(), String> {
Ok(())
}
fn get_window_handle(&self) -> *mut std::ffi::c_void {
std::ptr::null_mut()
}
}
impl crate::VeloxRenderer for SkiaRenderer {
fn new() -> Result<Self, String> {
Ok(SkiaRenderer)
}
fn mount(&mut self, _vnode: velox_dom::VNode) -> Result<(), String> {
Ok(())
}
fn hot_update(&mut self, _new_vnode: velox_dom::VNode) -> Result<(), String> {
Ok(())
}
}
}
#[cfg(not(feature = "skia"))]
pub fn init() -> Result<(), String> {
Ok(())
}
#[cfg(feature = "skia")]
pub const BACKEND: &str = "skia";
#[cfg(not(feature = "skia"))]
pub const BACKEND: &str = "stub";
pub fn backend_name() -> &'static str {
BACKEND
}
#[cfg(any(feature = "skia", feature = "skia-native"))]
pub type SelectedRenderer = skia_backend::SkiaRenderer;
#[cfg(not(feature = "skia"))]
pub struct StubRenderer;
#[cfg(not(feature = "skia"))]
pub type SelectedRenderer = StubRenderer;
#[cfg(not(feature = "skia"))]
impl Renderer for StubRenderer {
fn backend_name(&self) -> &'static str {
"stub"
}
fn mount(&self, vnode: &VNode) -> Result<RenderTree, String> {
Ok(build_render_tree(vnode))
}
}
#[cfg(not(feature = "skia"))]
impl HmrRenderer for StubRenderer {
fn init() -> Result<(), String> {
Ok(())
}
fn mount(&mut self, _vnode: VNode) -> Result<(), String> {
Ok(())
}
fn hot_update(&mut self, _new_vnode: VNode) -> Result<(), String> {
Ok(())
}
fn get_window_handle(&self) -> *mut std::ffi::c_void {
std::ptr::null_mut()
}
}
#[cfg(not(feature = "skia"))]
impl VeloxRenderer for StubRenderer {
fn new() -> Result<Self, String> {
Ok(StubRenderer)
}
fn mount(&mut self, _vnode: VNode) -> Result<(), String> {
Ok(())
}
fn hot_update(&mut self, _new_vnode: VNode) -> Result<(), String> {
Ok(())
}
}
pub fn new_selected_renderer() -> SelectedRenderer {
#[cfg(feature = "skia-native")]
{
skia_backend::SkiaRenderer {
surface: None,
vnode: None,
}
}
#[cfg(all(feature = "skia", not(feature = "skia-native")))]
{
skia_backend::SkiaRenderer
}
#[cfg(not(feature = "skia"))]
{
StubRenderer
}
}
pub use events::Runtime as EventRuntime;
#[cfg(all(feature = "skia-native", unix))]
pub fn skia_draw_test_frame() -> Result<(), String> {
crate::skia_gl::draw_gpu_test_frame(256, 256)
}
#[cfg(all(feature = "skia-native", unix))]
pub fn create_direct_context() -> Result<crate::skia_gl::GlDirectContext, String> {
crate::skia_gl::create_direct_context()
}
#[cfg(feature = "skia-native")]
fn panic_detail(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic payload".to_string()
}
}
#[cfg(feature = "skia-native")]
fn window_close_requested() -> bool {
velox_core::lifecycle::run_all_destroy_hooks();
true
}
#[cfg(feature = "skia-native")]
fn window_resized(
resize_state: &mut ResizeState,
window_opt: &Option<winit::window::Window>,
size: (u32, u32),
) {
resize_state.queue(size);
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
#[cfg(feature = "skia-native")]
fn window_scale_factor_changed(
scale_factor: &mut f32,
mouse_pos: &mut (f32, f32),
resize_state: &mut ResizeState,
surface: &mut Option<crate::skia_surface::SkiaSurface>,
window_opt: &Option<winit::window::Window>,
new_scale: f64,
new_inner_size: (u32, u32),
) {
let old_scale = *scale_factor;
*scale_factor = new_scale as f32;
if old_scale.is_finite() && old_scale > 0.0 && scale_factor.is_finite() && *scale_factor > 0.0 {
mouse_pos.0 = mouse_pos.0 * old_scale / *scale_factor;
mouse_pos.1 = mouse_pos.1 * old_scale / *scale_factor;
}
resize_state.queue(new_inner_size);
if let Some(s) = surface.as_mut() {
s.set_scale_factor(*scale_factor);
}
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
#[cfg(feature = "skia-native")]
fn window_cursor_moved(
scale_factor: f32,
mouse_pos: &mut (f32, f32),
hover_targets: &[crate::events::HoverTarget],
hovered_id: &mut Option<u32>,
window_opt: &Option<winit::window::Window>,
position: (f32, f32),
) {
*mouse_pos = (position.0 / scale_factor, position.1 / scale_factor);
let now_hovered = crate::events::hit_test_hover(hover_targets, mouse_pos.0, mouse_pos.1);
if now_hovered != *hovered_id {
*hovered_id = now_hovered;
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
}
#[cfg(feature = "skia-native")]
fn window_modifiers_changed(shift_held: &mut bool, shift: bool) {
*shift_held = shift;
}
#[cfg(feature = "skia-native")]
fn window_wheel_delta_y(delta: winit::event::MouseScrollDelta, scale_factor: f32) -> f32 {
match delta {
winit::event::MouseScrollDelta::LineDelta(_, y) => -y * 40.0,
winit::event::MouseScrollDelta::PixelDelta(pos) => -(pos.y as f32) / scale_factor,
}
}
#[cfg(feature = "skia-native")]
fn window_mouse_wheel(
scale_factor: f32,
mouse_pos: (f32, f32),
last_layout: &Option<velox_dom::layout::LayoutNode>,
scroll_offsets: &mut std::collections::HashMap<Vec<usize>, f32>,
window_opt: &Option<winit::window::Window>,
delta: winit::event::MouseScrollDelta,
) {
let delta_y = window_wheel_delta_y(delta, scale_factor);
if let Some(layout) = last_layout.as_ref()
&& crate::events::apply_wheel_scroll(
layout,
mouse_pos.0,
mouse_pos.1,
scroll_offsets,
delta_y,
)
&& let Some(w) = window_opt.as_ref()
{
w.request_redraw();
}
}
#[cfg(all(test, feature = "skia-native"))]
mod window_event_shared_tests {
use super::*;
use std::collections::HashMap;
use winit::dpi::PhysicalPosition;
use winit::event::MouseScrollDelta;
fn no_window() -> Option<winit::window::Window> {
None
}
fn hover_target(x: i32, y: i32, w: i32, h: i32, id: u32) -> crate::events::HoverTarget {
crate::events::HoverTarget {
rect: velox_dom::layout::Rect { x, y, w, h },
id,
z_index: 0,
order: 0,
clip: None,
sc: crate::events::StackCtx::ROOT,
}
}
#[test]
fn close_requested_tells_the_caller_to_exit() {
assert!(window_close_requested());
}
#[test]
fn resized_keeps_only_the_last_size_of_a_drag() {
let mut state = ResizeState::new();
let window = no_window();
window_resized(&mut state, &window, (800, 600));
window_resized(&mut state, &window, (1024, 768));
assert_eq!(state.take_pending(), Some((1024, 768)));
assert_eq!(state.take_pending(), None);
}
#[test]
fn scale_factor_change_rescales_the_cursor_so_the_physical_point_is_stable() {
let mut state = ResizeState::new();
let window = no_window();
let mut surface: Option<crate::skia_surface::SkiaSurface> = None;
let mut scale_factor = 1.0f32;
let mut mouse_pos = (100.0f32, 200.0f32);
window_scale_factor_changed(
&mut scale_factor,
&mut mouse_pos,
&mut state,
&mut surface,
&window,
2.0,
(1920, 1080),
);
assert_eq!(scale_factor, 2.0);
assert_eq!(mouse_pos, (50.0, 100.0));
assert_eq!(state.take_pending(), Some((1920, 1080)));
}
#[test]
fn scale_factor_change_never_divides_by_a_degenerate_old_scale() {
for degenerate in [0.0f32, -1.0, f32::NAN, f32::INFINITY] {
let mut state = ResizeState::new();
let window = no_window();
let mut surface: Option<crate::skia_surface::SkiaSurface> = None;
let mut scale_factor = degenerate;
let mut mouse_pos = (10.0f32, 20.0f32);
window_scale_factor_changed(
&mut scale_factor,
&mut mouse_pos,
&mut state,
&mut surface,
&window,
2.0,
(800, 600),
);
assert_eq!(
scale_factor, 2.0,
"old_scale={degenerate}: new scale adopted"
);
assert_eq!(
mouse_pos,
(10.0, 20.0),
"old_scale={degenerate}: cursor left alone"
);
assert_eq!(state.take_pending(), Some((800, 600)));
}
}
#[test]
fn cursor_moved_converts_physical_pixels_to_logical_units() {
let window = no_window();
let mut mouse_pos = (0.0f32, 0.0f32);
let mut hovered_id: Option<u32> = None;
window_cursor_moved(
2.0,
&mut mouse_pos,
&[],
&mut hovered_id,
&window,
(300.0, 200.0),
);
assert_eq!(mouse_pos, (150.0, 100.0));
}
#[test]
fn cursor_moved_latches_and_clears_the_hover_target_under_the_logical_cursor() {
let window = no_window();
let targets = [hover_target(0, 0, 200, 200, 7)];
let mut mouse_pos = (0.0f32, 0.0f32);
let mut hovered_id: Option<u32> = None;
window_cursor_moved(
2.0,
&mut mouse_pos,
&targets,
&mut hovered_id,
&window,
(300.0, 200.0),
);
assert_eq!(hovered_id, Some(7));
window_cursor_moved(
2.0,
&mut mouse_pos,
&targets,
&mut hovered_id,
&window,
(900.0, 800.0),
);
assert_eq!(hovered_id, None);
}
#[test]
fn modifiers_changed_latches_the_shift_state_the_caret_reads() {
let mut shift_held = false;
window_modifiers_changed(&mut shift_held, true);
assert!(shift_held);
window_modifiers_changed(&mut shift_held, false);
assert!(!shift_held);
}
#[test]
fn wheel_deltas_become_logical_pixels_in_the_natural_direction() {
assert_eq!(
window_wheel_delta_y(MouseScrollDelta::LineDelta(0.0, 1.0), 1.0),
-40.0
);
assert_eq!(
window_wheel_delta_y(MouseScrollDelta::LineDelta(0.0, -1.0), 1.0),
40.0
);
assert_eq!(
window_wheel_delta_y(MouseScrollDelta::LineDelta(7.0, 1.0), 1.0),
-40.0
);
assert_eq!(
window_wheel_delta_y(
MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, -100.0)),
2.0,
),
50.0
);
}
#[test]
fn a_wheel_event_before_the_first_frame_is_a_no_op() {
let window = no_window();
let mut scroll_offsets: HashMap<Vec<usize>, f32> = HashMap::new();
window_mouse_wheel(
1.0,
(10.0, 10.0),
&None,
&mut scroll_offsets,
&window,
MouseScrollDelta::LineDelta(0.0, 3.0),
);
assert!(scroll_offsets.is_empty());
}
}
#[cfg(feature = "skia-native")]
pub fn run_window_vnode_skia<F, G, H>(
title: &str,
mut make_view: F,
mut on_event: G,
mut get_title: H,
) -> Result<(), String>
where
F: FnMut(u32, u32) -> (velox_dom::VNode, Stylesheet) + 'static,
G: FnMut(&str, Option<&str>) + 'static,
H: FnMut() -> String + 'static,
{
use winit::dpi::PhysicalSize;
use winit::event::{ElementState, Event, MouseButton, StartCause, WindowEvent};
use winit::event_loop::{ControlFlow, EventLoop};
use winit::window::WindowBuilder;
let headless_env = std::env::var("VELOX_HEADLESS").as_deref() == Ok("1")
|| !crate::presenter::is_compositor_available();
let mut last_vnode: Option<velox_dom::VNode> = None;
let mut _hmr_pending = false;
let _headless_check = crate::presenter::prepare_backend();
type WindowOutcome = Result<
(
Option<winit::event_loop::EventLoop<()>>,
Option<winit::window::Window>,
PhysicalSize<u32>,
f32,
),
String,
>;
let window_outcome: WindowOutcome =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let event_loop = EventLoop::new();
let window_result = WindowBuilder::new()
.with_title(title)
.with_inner_size(PhysicalSize::new(800, 600))
.build(&event_loop);
match window_result {
Ok(w) => {
let size = w.inner_size();
let sf = w.scale_factor() as f32;
Ok((Some(event_loop), Some(w), size, sf))
}
Err(e) => {
let msg = e.to_string();
let lower = msg.to_ascii_lowercase();
let is_display_err = lower.contains("broken pipe")
|| lower.contains("os error 32")
|| lower.contains("no compositor")
|| lower.contains("no display server")
|| lower.contains("failed to connect");
if headless_env || is_display_err {
log::warn!("window creation failed — continuing in headless mode: {msg}");
Ok((Some(event_loop), None, PhysicalSize::new(800, 600), 1.0))
} else {
Err(
velox_dom::VeloxError::Window(format!("failed to create window: {e}"))
.to_string(),
)
}
}
}
}))
.unwrap_or_else(|payload| {
eprintln!(
"[velox] window/event loop creation panicked — continuing in headless mode: {}",
panic_detail(payload)
);
Ok((None, None, PhysicalSize::new(800, 600), 1.0))
});
let (event_loop_opt, window, window_size, scale_factor) = window_outcome?;
let window_opt: Option<winit::window::Window> = window;
let mut renderer = match crate::skia_surface::SkiaSurface::new_raster(
window_size.width as i32,
window_size.height as i32,
) {
Ok(surface) => skia_backend::SkiaRenderer {
surface: Some(surface),
vnode: None,
},
Err(e) => {
return Err(format!("failed to create SkiaSurface: {e}"));
}
};
let mut presenter: Option<crate::presenter::SoftbufferPresenter> = None;
if let Some(w) = window_opt.as_ref() {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::presenter::SoftbufferPresenter::new(w, window_size.width, window_size.height)
})) {
Ok(Ok(p)) => presenter = Some(p),
Ok(Err(e)) => {
if headless_env {
log::warn!(
"softbuffer presenter unavailable — continuing in headless mode: {e}"
);
} else {
return Err(format!("failed to create softbuffer presenter: {e}"));
}
}
Err(payload) => {
eprintln!(
"[velox] softbuffer presenter creation panicked — continuing in headless mode: {}",
panic_detail(payload)
);
}
}
}
let mut scale_factor = scale_factor;
let mut mouse_pos = (0.0f32, 0.0f32);
let mut hovered_id: Option<u32> = None;
let mut click_targets: Vec<crate::events::ClickTarget> = Vec::new();
let mut hover_targets: Vec<crate::events::HoverTarget> = Vec::new();
let mut input_targets: Vec<crate::events::InputTarget> = Vec::new();
let mut focused_input: Option<Vec<usize>> = None;
let mut blink_deadline: Option<std::time::Instant> = None;
let mut shift_held = false;
let mut scroll_offsets: std::collections::HashMap<Vec<usize>, f32> =
std::collections::HashMap::new();
let mut last_layout: Option<velox_dom::layout::LayoutNode> = None;
let _lifecycle_guard = LifecycleCleanupGuard;
let mut did_mount = false;
let mut resize_state = ResizeState::new();
if let Some(s) = &mut renderer.surface {
s.set_scale_factor(scale_factor);
let (vw, vh) = resize_state.record_initial(s.width, s.height, scale_factor);
let (vnode_raw, sheet) = make_view(vw, vh);
let mut next_id = 1u32;
let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
let vnode = crate::style_vnode_with_hover(&vnode_tagged, &sheet, &|_tag, props| {
props
.attrs
.get("data-hover-id")
.and_then(|v| v.parse::<u32>().ok())
.map(|id| Some(id) == hovered_id)
.unwrap_or(false)
});
let vnode = crate::inject_input_caret_attrs(&vnode, None);
last_vnode = Some(vnode.clone());
let mut layout = velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
{
let mut path = Vec::new();
crate::events::apply_scroll_offsets(&mut layout, &scroll_offsets, &mut path);
}
last_layout = Some(layout.clone());
recompute_targets(
&vnode,
&layout,
&mut click_targets,
&mut hover_targets,
&mut input_targets,
);
if let Err(e) = crate::skia_render::skia_impl::render_frame(s, &vnode, &layout, &sheet) {
log::error!("skia initial render error: {}", e);
}
if let Some(presenter) = presenter.as_mut()
&& let Err(e) = presenter.present(s)
{
log::error!("skia initial present error: {}", e);
}
}
if let Some(event_loop) = event_loop_opt {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _blink_ticker = crate::CaretBlinkTicker::start(event_loop.create_proxy(), ());
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::NewEvents(StartCause::Init) => {
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
if window_close_requested() {
*control_flow = ControlFlow::Exit;
}
}
Event::WindowEvent {
event: WindowEvent::Resized(new_size),
..
} => {
window_resized(
&mut resize_state,
&window_opt,
(new_size.width, new_size.height),
);
}
Event::WindowEvent {
event:
WindowEvent::ScaleFactorChanged {
scale_factor: new_scale,
new_inner_size,
..
},
..
} => {
window_scale_factor_changed(
&mut scale_factor,
&mut mouse_pos,
&mut resize_state,
&mut renderer.surface,
&window_opt,
new_scale,
(new_inner_size.width, new_inner_size.height),
);
}
Event::WindowEvent {
event: WindowEvent::CursorMoved { position, .. },
..
} => {
window_cursor_moved(
scale_factor,
&mut mouse_pos,
&hover_targets,
&mut hovered_id,
&window_opt,
(position.x as f32, position.y as f32),
);
}
Event::WindowEvent {
event:
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
},
..
} => {
crate::apply_click_focus(
&mut input_targets,
&last_vnode,
&mut focused_input,
mouse_pos.0,
mouse_pos.1,
scale_factor,
surface_logical_viewport(&renderer, scale_factor),
);
let mut handled_click = false;
if let Some((handler, payload_opt)) =
crate::events::hit_test_click(&click_targets, mouse_pos.0, mouse_pos.1)
{
let payload_owned =
payload_opt.map(|p| p.to_string()).unwrap_or_else(|| {
format!("{{\"x\":{},\"y\":{}}}", mouse_pos.0, mouse_pos.1)
});
on_event(handler, Some(&payload_owned));
velox_core::lifecycle::run_all_updated_hooks();
if let Some(s) = &mut renderer.surface {
let (vw, vh) = logical_size(s.width, s.height, scale_factor);
let (vnode_raw, sheet) = make_view(vw, vh);
let mut next_id = 1u32;
let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
let vnode = crate::style_vnode_with_hover(
&vnode_tagged,
&sheet,
&|_tag, props| {
props
.attrs
.get("data-hover-id")
.and_then(|v| v.parse::<u32>().ok())
.map(|id| Some(id) == hovered_id)
.unwrap_or(false)
},
);
let vnode = crate::inject_input_caret_attrs(
&vnode,
crate::events::focused_input_index(&input_targets)
.and_then(|i| input_targets.get(i)),
);
let mut layout =
velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
{
let mut path = Vec::new();
crate::events::apply_scroll_offsets(
&mut layout,
&scroll_offsets,
&mut path,
);
}
last_layout = Some(layout.clone());
recompute_targets(
&vnode,
&layout,
&mut click_targets,
&mut hover_targets,
&mut input_targets,
);
}
handled_click = true;
}
if let Some(w) = window_opt.as_ref() {
if handled_click {
w.set_title(&get_title());
}
w.request_redraw();
}
}
Event::WindowEvent {
event: WindowEvent::ModifiersChanged(mods),
..
} => {
window_modifiers_changed(&mut shift_held, mods.shift());
}
Event::WindowEvent {
event: WindowEvent::KeyboardInput { input, .. },
..
} => {
use winit::event::VirtualKeyCode;
if let Some(keycode) = input.virtual_keycode
&& input.state == ElementState::Pressed
{
if crate::dispatch_keydown(
keycode,
&mut input_targets,
&last_vnode,
&mut focused_input,
&mut on_event,
) {
arm_blink_deadline(&mut blink_deadline);
velox_core::lifecycle::run_all_updated_hooks();
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
match keycode {
VirtualKeyCode::R | VirtualKeyCode::Q
if !crate::events::any_input_focused(&input_targets) =>
{
velox_core::lifecycle::run_all_destroy_hooks();
*control_flow = ControlFlow::Exit;
}
_ => {
if let Some(action) =
crate::edit_action_for_key(keycode, shift_held)
{
let changed = crate::apply_edit_to_focused(
&mut input_targets,
action,
&last_vnode,
&mut focused_input,
&mut on_event,
);
if changed {
arm_blink_deadline(&mut blink_deadline);
velox_core::lifecycle::run_all_updated_hooks();
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
}
}
}
}
}
Event::UserEvent(()) => {
if crate::on_caret_blink_tick(&mut input_targets, &mut blink_deadline)
&& let Some(w) = window_opt.as_ref()
{
w.request_redraw();
}
}
Event::WindowEvent {
event: WindowEvent::ReceivedCharacter(c),
..
} => {
if !c.is_control()
&& c != '\u{7f}'
&& crate::apply_edit_to_focused(
&mut input_targets,
crate::events::EditAction::Insert(c),
&last_vnode,
&mut focused_input,
&mut on_event,
)
{
arm_blink_deadline(&mut blink_deadline);
velox_core::lifecycle::run_all_updated_hooks();
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
}
Event::WindowEvent {
event: WindowEvent::MouseWheel { delta, .. },
..
} => {
window_mouse_wheel(
scale_factor,
mouse_pos,
&last_layout,
&mut scroll_offsets,
&window_opt,
delta,
);
}
Event::RedrawRequested(_) => {
let mut committed_resize = false;
if let Some((pw, ph)) = resize_state.take_pending() {
match renderer.resize(pw as i32, ph as i32) {
Ok(()) => committed_resize = true,
Err(e) => {
log::warn!("renderer resize failed ({}x{}): {}", pw, ph, e);
}
}
if let Some(presenter) = presenter.as_mut()
&& let Err(e) = presenter.resize(pw, ph)
{
log::warn!("presenter resize failed: {}", e);
}
}
ensure_mounted(&mut did_mount);
if let Some(s) = &mut renderer.surface {
s.set_scale_factor(scale_factor);
let (vw, vh) = resize_state.frame_logical_size(
s.width,
s.height,
scale_factor,
committed_resize,
);
let (vnode_raw, sheet) = make_view(vw, vh);
let mut next_id = 1u32;
let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
let vnode = crate::style_vnode_with_hover(
&vnode_tagged,
&sheet,
&|_tag, props| {
props
.attrs
.get("data-hover-id")
.and_then(|v| v.parse::<u32>().ok())
.map(|id| Some(id) == hovered_id)
.unwrap_or(false)
},
);
let vnode = crate::inject_input_caret_attrs(
&vnode,
crate::events::focused_input_index(&input_targets)
.and_then(|i| input_targets.get(i)),
);
last_vnode = Some(vnode.clone());
let mut layout =
velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
{
let mut path = Vec::new();
crate::events::apply_scroll_offsets(
&mut layout,
&scroll_offsets,
&mut path,
);
}
last_layout = Some(layout.clone());
recompute_targets(
&vnode,
&layout,
&mut click_targets,
&mut hover_targets,
&mut input_targets,
);
if let Err(e) = crate::skia_render::skia_impl::render_frame(
s, &vnode, &layout, &sheet,
) {
log::error!("skia render error: {}", e);
}
if let Some(presenter) = presenter.as_mut()
&& let Err(e) = presenter.present(s)
{
log::error!("skia present error: {}", e);
}
}
}
_ => {}
}
});
}));
} else {
log::info!("running in headless mode (no event loop)");
velox_core::lifecycle::run_all_destroy_hooks();
}
Ok(())
}
#[cfg(feature = "skia-native")]
type WindowBootstrap = (
Option<winit::event_loop::EventLoop<HmrMessage>>,
Option<winit::event_loop::EventLoopProxy<HmrMessage>>,
Option<winit::window::Window>,
winit::dpi::PhysicalSize<u32>,
f32,
);
#[cfg(feature = "skia-native")]
pub fn run_window_vnode_skia_with_hmr<F, G, H>(
title: &str,
mut make_view: F,
mut on_event: G,
mut get_title: H,
hmr_rx: std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<HmrMessage>>>,
) -> Result<(), String>
where
F: FnMut(u32, u32) -> (velox_dom::VNode, Stylesheet) + 'static,
G: FnMut(&str, Option<&str>) + 'static,
H: FnMut() -> String + 'static,
{
use winit::dpi::PhysicalSize;
use winit::event::{ElementState, Event, MouseButton, StartCause, WindowEvent};
use winit::event_loop::{ControlFlow, EventLoopBuilder};
use winit::window::WindowBuilder;
let headless_env_hmr = std::env::var("VELOX_HEADLESS").as_deref() == Ok("1")
|| !crate::presenter::is_compositor_available();
let _headless_check_hmr = crate::presenter::prepare_backend();
let window_outcome_hmr: Result<WindowBootstrap, String> =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let event_loop = EventLoopBuilder::<HmrMessage>::with_user_event().build();
let proxy = event_loop.create_proxy();
let window_result = WindowBuilder::new()
.with_title(title)
.with_inner_size(PhysicalSize::new(800, 600))
.build(&event_loop);
match window_result {
Ok(w) => {
let size = w.inner_size();
let sf = w.scale_factor() as f32;
Ok((Some(event_loop), Some(proxy), Some(w), size, sf))
}
Err(e) => {
let msg = e.to_string();
let lower = msg.to_ascii_lowercase();
let is_display_err = lower.contains("broken pipe")
|| lower.contains("os error 32")
|| lower.contains("no compositor")
|| lower.contains("no display server")
|| lower.contains("failed to connect");
if headless_env_hmr || is_display_err {
log::warn!("window creation failed — continuing in headless mode: {msg}");
Ok((
Some(event_loop),
Some(proxy),
None,
PhysicalSize::new(800, 600),
1.0,
))
} else {
Err(
velox_dom::VeloxError::Window(format!("failed to create window: {e}"))
.to_string(),
)
}
}
}
}))
.unwrap_or_else(|payload| {
eprintln!(
"[velox] window/event loop creation panicked — continuing in headless mode: {}",
panic_detail(payload)
);
Ok((None, None, None, PhysicalSize::new(800, 600), 1.0))
});
let (event_loop_opt, proxy_opt, window, window_size, scale_factor) = window_outcome_hmr?;
if let Some(proxy) = proxy_opt {
match std::sync::Arc::try_unwrap(hmr_rx) {
Ok(mutex) => {
let rx = mutex
.into_inner()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
while let Ok(msg) = rx.recv() {
if proxy.send_event(msg).is_err() {
break;
}
}
}));
});
}
Err(shared) => {
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
loop {
let res = {
let guard = shared
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
guard.try_recv()
};
match res {
Ok(msg) => {
if proxy.send_event(msg).is_err() {
break;
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
std::thread::sleep(std::time::Duration::from_millis(16));
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
}
}));
});
}
}
}
let window_opt: Option<winit::window::Window> = window;
let mut scale_factor = scale_factor;
let mut renderer = match crate::skia_surface::SkiaSurface::new_raster(
window_size.width as i32,
window_size.height as i32,
) {
Ok(surface) => skia_backend::SkiaRenderer {
surface: Some(surface),
vnode: None,
},
Err(e) => {
return Err(format!("failed to create SkiaSurface: {e}"));
}
};
let mut presenter: Option<crate::presenter::SoftbufferPresenter> = None;
if let Some(w) = window_opt.as_ref() {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::presenter::SoftbufferPresenter::new(w, window_size.width, window_size.height)
})) {
Ok(Ok(p)) => presenter = Some(p),
Ok(Err(e)) => {
if headless_env_hmr || !crate::presenter::is_compositor_available() {
log::warn!(
"softbuffer presenter unavailable — continuing in headless mode: {e}"
);
} else {
return Err(format!("failed to create softbuffer presenter: {e}"));
}
}
Err(payload) => {
eprintln!(
"[velox] softbuffer presenter creation panicked — continuing in headless mode: {}",
panic_detail(payload)
);
}
}
}
let mut mouse_pos = (0.0f32, 0.0f32);
let mut hovered_id: Option<u32> = None;
let mut click_targets: Vec<crate::events::ClickTarget> = Vec::new();
let mut hover_targets: Vec<crate::events::HoverTarget> = Vec::new();
let mut input_targets: Vec<crate::events::InputTarget> = Vec::new();
let mut last_vnode: Option<velox_dom::VNode> = None;
let mut focused_input: Option<Vec<usize>> = None;
let mut blink_deadline: Option<std::time::Instant> = None;
let mut shift_held = false;
let mut scroll_offsets_hmr: std::collections::HashMap<Vec<usize>, f32> =
std::collections::HashMap::new();
let mut last_layout_hmr: Option<velox_dom::layout::LayoutNode> = None;
let _lifecycle_guard_hmr = LifecycleCleanupGuard;
let mut did_mount_hmr = false;
let mut resize_state = ResizeState::new();
if let Some(s) = &mut renderer.surface {
s.set_scale_factor(scale_factor);
let (vw, vh) = resize_state.record_initial(s.width, s.height, scale_factor);
let (vnode_raw, sheet) = make_view(vw, vh);
let mut next_id = 1u32;
let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
let vnode = crate::style_vnode_with_hover(&vnode_tagged, &sheet, &|_tag, props| {
props
.attrs
.get("data-hover-id")
.and_then(|v| v.parse::<u32>().ok())
.map(|id| Some(id) == hovered_id)
.unwrap_or(false)
});
let vnode = crate::inject_input_caret_attrs(&vnode, None);
last_vnode = Some(vnode.clone());
let mut layout = velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
{
let mut path = Vec::new();
crate::events::apply_scroll_offsets(&mut layout, &scroll_offsets_hmr, &mut path);
}
last_layout_hmr = Some(layout.clone());
recompute_targets(
&vnode,
&layout,
&mut click_targets,
&mut hover_targets,
&mut input_targets,
);
if let Err(e) = crate::skia_render::skia_impl::render_frame(s, &vnode, &layout, &sheet) {
log::error!("skia initial render error: {}", e);
}
if let Some(presenter) = presenter.as_mut()
&& let Err(e) = presenter.present(s)
{
log::error!("skia initial present error: {}", e);
}
}
if let Some(event_loop) = event_loop_opt {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _blink_ticker =
crate::CaretBlinkTicker::start(event_loop.create_proxy(), HmrMessage::KeepWindow);
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::UserEvent(msg) => match msg {
HmrMessage::FullReload => {
velox_core::lifecycle::run_all_destroy_hooks();
*control_flow = ControlFlow::Exit;
}
HmrMessage::HotReload { module_path: _ } => {
if let Some(s) = renderer.surface.as_ref() {
let (vw, vh) = logical_size(s.width, s.height, scale_factor);
let (vnode_raw, _) = make_view(vw, vh);
if let Err(e) = HmrRenderer::hot_update(&mut renderer, vnode_raw) {
log::error!("hot_update failed: {}", e);
}
if let Some(s2) = renderer.surface.as_ref() {
let (vw2, vh2) =
logical_size(s2.width, s2.height, scale_factor);
let (vnode2_raw, sheet2) = make_view(vw2, vh2);
let mut nid = 1u32;
let tagged = with_hover_ids(&vnode2_raw, &mut nid);
let vnode2 = crate::style_vnode_with_hover(
&tagged,
&sheet2,
&|_tag, props| {
props
.attrs
.get("data-hover-id")
.and_then(|v| v.parse::<u32>().ok())
.map(|id| Some(id) == hovered_id)
.unwrap_or(false)
},
);
let vnode2 = crate::inject_input_caret_attrs(
&vnode2,
crate::events::focused_input_index(&input_targets)
.and_then(|i| input_targets.get(i)),
);
last_vnode = Some(vnode2.clone());
let mut layout = velox_dom::layout::compute_layout(
&vnode2, vw2 as i32, vh2 as i32,
);
{
let mut path = Vec::new();
crate::events::apply_scroll_offsets(
&mut layout,
&scroll_offsets_hmr,
&mut path,
);
}
last_layout_hmr = Some(layout.clone());
recompute_targets(
&vnode2,
&layout,
&mut click_targets,
&mut hover_targets,
&mut input_targets,
);
}
}
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
HmrMessage::KeepWindow => {
if crate::on_caret_blink_tick(&mut input_targets, &mut blink_deadline)
&& let Some(w) = window_opt.as_ref()
{
w.request_redraw();
}
}
},
Event::NewEvents(StartCause::Init) => {
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
if window_close_requested() {
*control_flow = ControlFlow::Exit;
}
}
Event::WindowEvent {
event: WindowEvent::Resized(new_size),
..
} => {
window_resized(
&mut resize_state,
&window_opt,
(new_size.width, new_size.height),
);
}
Event::WindowEvent {
event:
WindowEvent::ScaleFactorChanged {
scale_factor: new_scale,
new_inner_size,
..
},
..
} => {
window_scale_factor_changed(
&mut scale_factor,
&mut mouse_pos,
&mut resize_state,
&mut renderer.surface,
&window_opt,
new_scale,
(new_inner_size.width, new_inner_size.height),
);
}
Event::WindowEvent {
event: WindowEvent::CursorMoved { position, .. },
..
} => {
window_cursor_moved(
scale_factor,
&mut mouse_pos,
&hover_targets,
&mut hovered_id,
&window_opt,
(position.x as f32, position.y as f32),
);
}
Event::WindowEvent {
event:
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
},
..
} => {
crate::apply_click_focus(
&mut input_targets,
&last_vnode,
&mut focused_input,
mouse_pos.0,
mouse_pos.1,
scale_factor,
surface_logical_viewport(&renderer, scale_factor),
);
let mut handled_click = false;
if let Some((handler, payload_opt)) =
crate::events::hit_test_click(&click_targets, mouse_pos.0, mouse_pos.1)
{
let payload_owned =
payload_opt.map(|p| p.to_string()).unwrap_or_else(|| {
format!("{{\"x\":{},\"y\":{}}}", mouse_pos.0, mouse_pos.1)
});
on_event(handler, Some(&payload_owned));
velox_core::lifecycle::run_all_updated_hooks();
if let Some(s) = &mut renderer.surface {
let (vw, vh) = logical_size(s.width, s.height, scale_factor);
let (vnode_raw, sheet) = make_view(vw, vh);
let mut next_id = 1u32;
let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
let vnode = crate::style_vnode_with_hover(
&vnode_tagged,
&sheet,
&|_tag, props| {
props
.attrs
.get("data-hover-id")
.and_then(|v| v.parse::<u32>().ok())
.map(|id| Some(id) == hovered_id)
.unwrap_or(false)
},
);
let vnode = crate::inject_input_caret_attrs(
&vnode,
crate::events::focused_input_index(&input_targets)
.and_then(|i| input_targets.get(i)),
);
last_vnode = Some(vnode.clone());
let mut layout =
velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
{
let mut path = Vec::new();
crate::events::apply_scroll_offsets(
&mut layout,
&scroll_offsets_hmr,
&mut path,
);
}
last_layout_hmr = Some(layout.clone());
recompute_targets(
&vnode,
&layout,
&mut click_targets,
&mut hover_targets,
&mut input_targets,
);
}
handled_click = true;
}
if let Some(w) = window_opt.as_ref() {
if handled_click {
w.set_title(&get_title());
}
w.request_redraw();
}
}
Event::WindowEvent {
event: WindowEvent::ModifiersChanged(mods),
..
} => {
window_modifiers_changed(&mut shift_held, mods.shift());
}
Event::WindowEvent {
event: WindowEvent::KeyboardInput { input, .. },
..
} => {
use winit::event::VirtualKeyCode;
if let Some(keycode) = input.virtual_keycode
&& input.state == ElementState::Pressed
{
if crate::dispatch_keydown(
keycode,
&mut input_targets,
&last_vnode,
&mut focused_input,
&mut on_event,
) {
crate::arm_blink_deadline(&mut blink_deadline);
velox_core::lifecycle::run_all_updated_hooks();
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
match keycode {
VirtualKeyCode::R | VirtualKeyCode::Q
if !crate::events::any_input_focused(&input_targets) =>
{
velox_core::lifecycle::run_all_destroy_hooks();
*control_flow = ControlFlow::Exit;
}
_ => {
if let Some(action) =
crate::edit_action_for_key(keycode, shift_held)
{
let changed = crate::apply_edit_to_focused(
&mut input_targets,
action,
&last_vnode,
&mut focused_input,
&mut on_event,
);
if changed {
crate::arm_blink_deadline(&mut blink_deadline);
velox_core::lifecycle::run_all_updated_hooks();
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
}
}
}
}
}
Event::WindowEvent {
event: WindowEvent::ReceivedCharacter(c),
..
} => {
if !c.is_control()
&& c != '\u{7f}'
&& crate::apply_edit_to_focused(
&mut input_targets,
crate::events::EditAction::Insert(c),
&last_vnode,
&mut focused_input,
&mut on_event,
)
{
crate::arm_blink_deadline(&mut blink_deadline);
velox_core::lifecycle::run_all_updated_hooks();
if let Some(w) = window_opt.as_ref() {
w.request_redraw();
}
}
}
Event::WindowEvent {
event: WindowEvent::MouseWheel { delta, .. },
..
} => {
window_mouse_wheel(
scale_factor,
mouse_pos,
&last_layout_hmr,
&mut scroll_offsets_hmr,
&window_opt,
delta,
);
}
Event::RedrawRequested(_) => {
let mut committed_resize = false;
if let Some((pw, ph)) = resize_state.take_pending() {
match renderer.resize(pw as i32, ph as i32) {
Ok(()) => committed_resize = true,
Err(e) => {
log::warn!("renderer resize failed ({}x{}): {}", pw, ph, e);
}
}
if let Some(presenter) = presenter.as_mut()
&& let Err(e) = presenter.resize(pw, ph)
{
log::warn!("presenter resize failed: {}", e);
}
}
ensure_mounted(&mut did_mount_hmr);
if let Some(s) = &mut renderer.surface {
s.set_scale_factor(scale_factor);
let (vw, vh) = resize_state.frame_logical_size(
s.width,
s.height,
scale_factor,
committed_resize,
);
let (vnode_raw, sheet) = make_view(vw, vh);
let mut next_id = 1u32;
let vnode_tagged = with_hover_ids(&vnode_raw, &mut next_id);
let vnode = crate::style_vnode_with_hover(
&vnode_tagged,
&sheet,
&|_tag, props| {
props
.attrs
.get("data-hover-id")
.and_then(|v| v.parse::<u32>().ok())
.map(|id| Some(id) == hovered_id)
.unwrap_or(false)
},
);
let vnode = crate::inject_input_caret_attrs(
&vnode,
crate::events::focused_input_index(&input_targets)
.and_then(|i| input_targets.get(i)),
);
last_vnode = Some(vnode.clone());
let mut layout =
velox_dom::layout::compute_layout(&vnode, vw as i32, vh as i32);
{
let mut path = Vec::new();
crate::events::apply_scroll_offsets(
&mut layout,
&scroll_offsets_hmr,
&mut path,
);
}
last_layout_hmr = Some(layout.clone());
recompute_targets(
&vnode,
&layout,
&mut click_targets,
&mut hover_targets,
&mut input_targets,
);
if let Err(e) = crate::skia_render::skia_impl::render_frame(
s, &vnode, &layout, &sheet,
) {
log::error!("skia render error: {}", e);
}
if let Some(presenter) = presenter.as_mut()
&& let Err(e) = presenter.present(s)
{
log::error!("softbuffer present error: {}", e);
}
}
}
_ => {}
}
});
}));
} else {
log::info!("running in headless mode (no event loop)");
velox_core::lifecycle::run_all_destroy_hooks();
}
Ok(())
}
#[cfg(test)]
mod resize_state_tests {
use super::ResizeState;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Mutex, MutexGuard, OnceLock};
use velox_core::lifecycle::{
cleanup_component, clear_current_component, generate_component_id, on_resize,
set_current_component,
};
use velox_core::signal::Signal;
fn resize_test_guard() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn register_recorder(calls: Rc<RefCell<Vec<(u32, u32)>>>) -> usize {
let id = generate_component_id();
set_current_component(id);
on_resize(move |width, height| calls.borrow_mut().push((width, height)));
clear_current_component();
id
}
#[test]
fn renderer_resize_state_coalesces_raw_events_and_dispatches_once() {
let _guard = resize_test_guard();
let calls = Rc::new(RefCell::new(Vec::new()));
let id = register_recorder(calls.clone());
let mut state = ResizeState::new();
assert_eq!(state.record_initial(640, 480, 1.0), (640, 480));
state.queue((800, 600));
state.queue((900, 700));
assert!(calls.borrow().is_empty());
assert_eq!(state.take_pending(), Some((900, 700)));
assert_eq!(state.frame_logical_size(900, 700, 1.0, true), (900, 700));
assert_eq!(state.take_pending(), None);
assert_eq!(state.frame_logical_size(900, 700, 1.0, true), (900, 700));
assert_eq!(&*calls.borrow(), &[(900, 700)]);
cleanup_component(id);
}
#[test]
fn renderer_resize_state_initial_logical_size_suppresses_notification() {
let _guard = resize_test_guard();
let calls = Rc::new(RefCell::new(Vec::new()));
let id = register_recorder(calls.clone());
let mut state = ResizeState::new();
assert_eq!(state.record_initial(800, 600, 1.0), (800, 600));
assert_eq!(state.frame_logical_size(800, 600, 1.0, true), (800, 600));
assert!(calls.borrow().is_empty());
cleanup_component(id);
}
#[test]
fn renderer_resize_state_uses_viewport_logical_pixels_for_dpi() {
let _guard = resize_test_guard();
let calls = Rc::new(RefCell::new(Vec::new()));
let id = register_recorder(calls.clone());
let mut state = ResizeState::new();
assert_eq!(state.record_initial(1600, 1200, 2.0), (800, 600));
state.queue((2000, 1000));
assert_eq!(state.take_pending(), Some((2000, 1000)));
assert_eq!(state.frame_logical_size(2000, 1000, 2.0, true), (1000, 500));
assert_eq!(&*calls.borrow(), &[(1000, 500)]);
cleanup_component(id);
}
#[test]
fn two_windows_with_equal_committed_sizes_both_dispatch() {
let _guard = resize_test_guard();
let calls = Rc::new(RefCell::new(Vec::new()));
let id = register_recorder(calls.clone());
let mut first_window = ResizeState::new();
let mut second_window = ResizeState::new();
first_window.record_initial(800, 600, 1.0);
second_window.record_initial(700, 600, 1.0);
first_window.queue((900, 600));
second_window.queue((900, 600));
first_window.frame_logical_size(900, 600, 1.0, true);
second_window.frame_logical_size(900, 600, 1.0, true);
assert_eq!(&*calls.borrow(), &[(900, 600), (900, 600)]);
cleanup_component(id);
}
#[test]
fn renderer_resize_state_runs_all_hooks_and_allows_signal_mutation() {
let _guard = resize_test_guard();
let first_calls = Rc::new(RefCell::new(Vec::new()));
let second_calls = Rc::new(RefCell::new(Vec::new()));
let signal = Rc::new(Signal::new(0u32));
let id = generate_component_id();
set_current_component(id);
{
let calls = first_calls.clone();
on_resize(move |width, height| calls.borrow_mut().push((width, height)));
}
{
let calls = second_calls.clone();
let signal = signal.clone();
on_resize(move |width, height| {
calls.borrow_mut().push((width, height));
signal.set(width + height);
});
}
clear_current_component();
let mut state = ResizeState::new();
state.record_initial(100, 100, 1.0);
state.queue((200, 150));
assert_eq!(state.take_pending(), Some((200, 150)));
assert_eq!(state.frame_logical_size(200, 150, 1.0, true), (200, 150));
assert_eq!(&*first_calls.borrow(), &[(200, 150)]);
assert_eq!(&*second_calls.borrow(), &[(200, 150)]);
assert_eq!(signal.get(), 350);
cleanup_component(id);
}
#[test]
fn cleanup_component_removes_only_its_resize_hook() {
let _guard = resize_test_guard();
let removed_calls = Rc::new(RefCell::new(Vec::new()));
let retained_calls = Rc::new(RefCell::new(Vec::new()));
let removed_id = register_recorder(removed_calls.clone());
let retained_id = register_recorder(retained_calls.clone());
cleanup_component(removed_id);
let mut state = ResizeState::new();
state.record_initial(100, 100, 1.0);
state.frame_logical_size(200, 150, 1.0, true);
assert!(removed_calls.borrow().is_empty());
assert_eq!(&*retained_calls.borrow(), &[(200, 150)]);
cleanup_component(retained_id);
}
}
#[cfg(all(test, feature = "skia-native"))]
mod edit_focus_tests {
use super::{
apply_click_focus, apply_edit_to_focused, edit_action_for_key, inject_input_caret_attrs,
recompute_targets, style_vnode_with_hover,
};
use crate::events::{EditAction, InputTarget};
use velox_dom::{Props, VNode};
use winit::event::VirtualKeyCode as K;
const W: i32 = 400;
const H: i32 = 300;
const FIELD_STYLE: &str = "position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:15px;padding-left:2px";
const VALUE: &str = "hello";
const HANDLER: &str = "set_value";
const TEXT_X: f32 = 22.0;
const TEXT_Y: f32 = 40.0;
const EMPTY_X: f32 = 350.0;
const EMPTY_Y: f32 = 250.0;
type Dispatches = Vec<(String, Option<String>)>;
struct Scene {
vnode: Option<VNode>,
inputs: Vec<InputTarget>,
focused: Option<Vec<usize>>,
}
fn scene(attrs: &[(&str, &str)]) -> Scene {
let mut p = Props::new()
.set("type", "text")
.set("style", FIELD_STYLE)
.set("value", VALUE)
.set("on:input", HANDLER);
for (k, v) in attrs.iter().copied() {
p = p.set(k, v);
}
let raw = VNode::Element {
tag: "div".into(),
props: Props::new().set("style", "background:#000000"),
children: vec![VNode::Element {
tag: "input".into(),
props: p,
children: vec![],
}],
};
let sheet = velox_style::Stylesheet::default();
let styled = style_vnode_with_hover(&raw, &sheet, &|_tag: &str, _p: &Props| false);
let vnode = inject_input_caret_attrs(&styled, None);
let layout = velox_dom::layout::compute_layout(&vnode, W, H);
let mut clicks: Vec<crate::events::ClickTarget> = Vec::new();
let mut hovers: Vec<crate::events::HoverTarget> = Vec::new();
let mut inputs: Vec<InputTarget> = Vec::new();
recompute_targets(&vnode, &layout, &mut clicks, &mut hovers, &mut inputs);
assert_eq!(inputs.len(), 1, "fixture must yield exactly one text input");
Scene {
vnode: Some(vnode),
inputs,
focused: None,
}
}
impl Scene {
fn focused_at(mut self, cursor: usize) -> Self {
self.inputs[0].focused = true;
self.inputs[0].cursor = cursor;
self
}
}
fn run_edit(s: &mut Scene, action: EditAction) -> (bool, Dispatches) {
let mut log: Dispatches = Vec::new();
let repaint = {
let mut sink = |name: &str, data: Option<&str>| {
log.push((name.to_string(), data.map(str::to_string)))
};
apply_edit_to_focused(&mut s.inputs, action, &s.vnode, &mut s.focused, &mut sink)
};
(repaint, log)
}
fn run_click(s: &mut Scene, x: f32, y: f32) -> bool {
apply_click_focus(
&mut s.inputs,
&s.vnode,
&mut s.focused,
x,
y,
1.0,
(W as f32, H as f32),
)
}
#[test]
fn insert_at_caret_dispatches_new_value_and_advances_caret() {
let mut s = scene(&[]).focused_at(5);
let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
assert!(repaint, "an inserted char must repaint");
assert_eq!(log, vec![(HANDLER.to_string(), Some("helloX".into()))]);
assert_eq!(s.inputs[0].cursor, 6);
}
#[test]
fn insert_lands_at_the_caret_not_at_the_end() {
let mut s = scene(&[]).focused_at(0);
let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
assert!(repaint);
assert_eq!(log, vec![(HANDLER.to_string(), Some("Xhello".into()))]);
assert_eq!(s.inputs[0].cursor, 1);
}
#[test]
fn backspace_removes_the_char_before_the_caret() {
let mut s = scene(&[]).focused_at(5);
let (repaint, log) = run_edit(&mut s, EditAction::Backspace);
assert!(repaint);
assert_eq!(log, vec![(HANDLER.to_string(), Some("hell".into()))]);
assert_eq!(s.inputs[0].cursor, 4);
}
#[test]
fn submit_dispatches_the_unchanged_value() {
let mut s = scene(&[]).focused_at(5);
let (repaint, log) = run_edit(&mut s, EditAction::Submit);
assert!(
repaint,
"submit must repaint even though the text is unchanged"
);
assert_eq!(
log,
vec![(HANDLER.to_string(), Some("hello".into()))],
"a submit with no selection must still reach the handler"
);
assert_eq!(s.inputs[0].cursor, 5);
}
#[test]
fn caret_move_repaints_without_announcing_a_text_change() {
let mut s = scene(&[]).focused_at(3);
let (repaint, log) = run_edit(&mut s, EditAction::MoveLeft { shift: false });
assert!(repaint, "a caret move is a visual change and must repaint");
assert!(
log.is_empty(),
"a caret move must not dispatch a value change"
);
assert_eq!(s.inputs[0].cursor, 2);
}
#[test]
fn shift_caret_move_anchors_a_selection() {
let mut s = scene(&[]).focused_at(3);
let (repaint, log) = run_edit(&mut s, EditAction::MoveLeft { shift: true });
assert!(repaint);
assert!(log.is_empty());
assert_eq!(s.inputs[0].cursor, 2);
assert_eq!(
s.inputs[0].anchor,
Some(3),
"a shift-move must anchor at the caret's old position"
);
}
#[test]
fn no_focused_input_is_a_no_op() {
let mut s = scene(&[]);
assert!(!s.inputs[0].focused);
let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
assert!(!repaint, "an edit with nothing focused must not repaint");
assert!(log.is_empty());
}
#[test]
fn backspace_at_caret_zero_changes_nothing() {
let mut s = scene(&[]).focused_at(0);
let (repaint, log) = run_edit(&mut s, EditAction::Backspace);
assert!(!repaint, "backspace at index 0 is a no-op, not a repaint");
assert!(log.is_empty(), "backspace at index 0 must not dispatch");
assert_eq!(s.inputs[0].cursor, 0);
}
#[test]
fn dispatch_uses_the_value_in_the_tree_not_the_target() {
let mut s = scene(&[("value", "world")]).focused_at(5);
let (repaint, log) = run_edit(&mut s, EditAction::Backspace);
assert!(repaint);
assert_eq!(log, vec![(HANDLER.to_string(), Some("worl".into()))]);
}
#[test]
fn editing_keys_map_to_their_actions() {
use EditAction::*;
let cases: &[(K, bool, EditAction)] = &[
(K::Left, false, MoveLeft { shift: false }),
(K::Left, true, MoveLeft { shift: true }),
(K::Right, false, MoveRight { shift: false }),
(K::Right, true, MoveRight { shift: true }),
(K::Home, false, Home { shift: false }),
(K::Home, true, Home { shift: true }),
(K::End, false, End { shift: false }),
(K::End, true, End { shift: true }),
(K::Back, false, Backspace),
(K::Delete, false, Delete),
(K::Return, false, Submit),
(K::Escape, false, Blur),
];
for (key, shift, want) in cases {
assert_eq!(
edit_action_for_key(*key, *shift),
Some(*want),
"{key:?} with shift={shift}"
);
}
}
#[test]
fn non_editing_keys_produce_no_action() {
for key in [K::A, K::R, K::Key1, K::Space, K::Tab, K::Up, K::Down] {
assert_eq!(
edit_action_for_key(key, false),
None,
"{key:?} is not an editing key"
);
}
}
#[test]
fn escape_on_a_focused_field_blurs_it_and_drops_its_caret_and_selection() {
let mut s = scene(&[]).focused_at(4);
s.inputs[0].anchor = Some(1);
assert_eq!(s.inputs[0].selection(), Some((1, 4)));
let (repaint, log) = run_edit(&mut s, EditAction::Blur);
assert!(
repaint,
"the focus ring and highlight disappear, so it repaints"
);
assert!(!s.inputs[0].focused, "Esc must leave the field");
assert_eq!(
s.inputs[0].anchor, None,
"a selection cannot outlive the focus"
);
assert_eq!(s.inputs[0].cursor, 0, "Esc clears the caret");
assert!(
s.inputs[0].blink_on,
"the caret comes back solid, not blinking"
);
assert!(
log.is_empty(),
"Esc changes no text, so nothing is dispatched"
);
assert_eq!(
s.focused, None,
"the loop's mirror of the focus must follow the blur, or it names a \
field that is no longer focused"
);
}
#[test]
fn escape_with_nothing_focused_is_a_no_op() {
let mut s = scene(&[]);
assert!(!s.inputs[0].focused);
let (repaint, log) = run_edit(&mut s, EditAction::Blur);
assert!(
!repaint,
"there was no focus to drop, so there is nothing to redraw"
);
assert!(log.is_empty());
assert_eq!(s.focused, None);
}
#[test]
fn escape_is_routed_to_the_blur_action() {
assert_eq!(
edit_action_for_key(K::Escape, false),
Some(EditAction::Blur)
);
assert_eq!(
edit_action_for_key(K::Escape, true),
Some(EditAction::Blur),
"Shift does not change what Esc means"
);
}
#[test]
fn focus_gain_puts_the_caret_at_the_end_of_the_value() {
let mut s = scene(&[]);
assert_eq!(s.inputs[0].cursor, 0);
assert!(run_click(&mut s, TEXT_X + 1.0, TEXT_Y));
assert!(s.inputs[0].focused);
assert_eq!(
s.inputs[0].cursor, 5,
"focus gain must put the caret at the end, so typing appends"
);
}
#[test]
fn click_into_a_focused_field_repositions_the_caret() {
let mut s = scene(&[]).focused_at(5);
assert!(run_click(&mut s, TEXT_X + 1.0, TEXT_Y));
assert!(s.inputs[0].focused);
assert_eq!(
s.inputs[0].cursor, 0,
"clicking the first glyph must move the caret there, not leave it at the end"
);
}
#[test]
fn shorthand_padding_places_the_caret_on_the_painted_glyph() {
const SHORTHAND_STYLE: &str = "position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:15px;padding:6px 30px";
let mut s = scene(&[("style", SHORTHAND_STYLE)]).focused_at(5);
let rect = s.inputs[0].rect;
assert_eq!(rect.x, 20, "fixture must stay absolutely positioned");
let text_left = rect.x as f32 + 30.0;
assert!(run_click(&mut s, text_left + 1.0, TEXT_Y));
assert_eq!(
s.inputs[0].cursor, 0,
"a click on the first glyph must put the caret before it, whatever \
syntax the author used for the padding"
);
}
#[test]
fn text_origin_agrees_with_the_paint_authority_for_every_padding_spelling() {
use velox_dom::Props;
let rect = velox_dom::layout::Rect {
x: 20,
y: 20,
w: 200,
h: 40,
};
let viewport = (W as f32, H as f32);
let authored = [
"padding: 6px 30px",
"padding-left: 30px",
"padding: 6px 30px; padding-left: 30px",
"padding-left: 30px; padding: 6px 30px",
"padding: 30px 6px 6px 30px",
];
for decl in authored {
let props = Props::new().set("style", decl);
assert_eq!(
super::resolve_text_metrics(&props, rect, viewport).text_left,
50.0,
"`{decl}` must resolve the same first-glyph x as the paint lane"
);
let authority = crate::input_metrics::input_text_metrics(
props.attrs.get("style").map(|s| s.as_str()),
rect,
viewport,
velox_dom::layout::DEFAULT_ROOT_FONT_SIZE,
);
assert_eq!(
super::resolve_text_metrics(&props, rect, viewport).text_left,
authority.text_left,
"`{decl}`: the hit-test lane must not re-derive what paint computed"
);
}
let props = Props::new().set("style", "padding-left: 5vw");
assert_eq!(
super::resolve_text_metrics(&props, rect, viewport).text_left,
rect.x as f32 + 0.05 * W as f32
);
}
fn caret_from_click(style: &str, value: &str, click_x: f32) -> usize {
let mut s = scene(&[("style", style), ("value", value)]).focused_at(0);
assert!(
run_click(&mut s, click_x, TEXT_Y),
"the click at x={click_x} must land on the field, not on empty space"
);
s.inputs[0].cursor
}
fn assert_carets_agree_across_field(equal: (&str, &str), value: &str) {
let (a, b) = equal;
let mut profile: Vec<(f32, usize, usize)> = Vec::new();
let mut indices: Vec<usize> = Vec::new();
let mut x = 20.0;
while x <= 219.0 {
let ca = caret_from_click(a, value, x);
let cb = caret_from_click(b, value, x);
if ca != cb {
profile.push((x, ca, cb));
}
indices.push(ca);
x += 1.0;
}
let worst = profile
.iter()
.map(|&(_, u, v)| (u as i64 - v as i64).abs())
.max()
.unwrap_or(0);
assert!(
profile.is_empty(),
"`{a}` and `{b}` paint identically, so the caret must land on the same \
glyph at every click x. They disagree at {} of 200 coordinates, by up \
to {worst} character(s); first divergence: {:?}",
profile.len(),
profile.first()
);
let len = value.chars().count();
assert!(
indices.iter().any(|&i| i > 0 && i < len),
"every click resolved to an end-of-value caret, so this compared \
nothing: {indices:?}"
);
}
#[test]
fn the_caret_lane_measures_at_the_size_an_undeclared_font_size_paints_at() {
assert_carets_agree_across_field(
(
"position:absolute;left:20px;top:20px;width:200px;height:40px",
"position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:14px",
),
&"M".repeat(20),
);
}
#[test]
fn em_padding_resolves_against_the_same_inherited_size_in_both_lanes() {
assert_carets_agree_across_field(
(
"position:absolute;left:20px;top:20px;width:200px;height:40px;padding:0 4em",
"position:absolute;left:20px;top:20px;width:200px;height:40px;padding:0 4em;font-size:14px",
),
&"M".repeat(20),
);
}
#[test]
fn a_relative_font_size_reaches_the_caret_lane_at_its_resolved_size() {
assert_carets_agree_across_field(
(
"position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:2em",
"position:absolute;left:20px;top:20px;width:200px;height:40px;font-size:28px",
),
&"M".repeat(20),
);
}
#[test]
fn clicking_empty_space_blurs_clears_the_selection_and_keeps_the_caret() {
let mut s = scene(&[]).focused_at(5);
s.inputs[0].anchor = Some(1);
assert!(
run_click(&mut s, EMPTY_X, EMPTY_Y),
"a blur is a visual change"
);
assert!(!s.inputs[0].focused);
assert_eq!(
s.inputs[0].anchor, None,
"a stale highlight must not survive a blur"
);
assert_eq!(
s.inputs[0].cursor, 5,
"blur keeps the caret, so re-entering the field restores position"
);
}
#[test]
fn clicking_empty_space_with_nothing_focused_changes_nothing() {
let mut s = scene(&[]);
assert!(
!run_click(&mut s, EMPTY_X, EMPTY_Y),
"a click on empty space with nothing focused is not a change"
);
}
#[test]
fn focus_gain_with_no_published_frame_puts_the_caret_at_zero() {
let mut s = scene(&[]);
s.vnode = None;
assert!(run_click(&mut s, TEXT_X + 1.0, TEXT_Y));
assert!(s.inputs[0].focused);
assert_eq!(
s.inputs[0].cursor, 0,
"with no frame to measure, focus gain leaves the caret at 0 and typing prepends"
);
}
#[test]
fn insert_with_no_published_frame_is_silently_dropped() {
let mut s = scene(&[]).focused_at(5);
s.vnode = None;
let (repaint, log) = run_edit(&mut s, EditAction::Insert('X'));
assert!(
repaint,
"the edit still reports a repaint, so the loop schedules a frame for nothing"
);
assert!(
log.is_empty(),
"no dispatch at all: the app never sees the keystroke, yet the caret moved"
);
assert_eq!(
s.inputs[0].cursor, 1,
"the caret is clamped to an empty value and corrupted, not advanced to 6"
);
}
#[test]
fn both_loops_publish_their_pre_loop_frame_before_they_start() {
let src = include_str!("lib.rs");
let decl = format!("last_vnode: Option<{}::VNode> = None;", "velox_dom");
let mut loops = 0usize;
let mut rest = src;
while let Some(at) = rest.find(decl.as_str()) {
loops += 1;
let body = &rest[at..];
let run_at = body
.find("event_loop.run(")
.unwrap_or_else(|| panic!("loop {loops} has no `event_loop.run(`"));
assert!(
body[..run_at].contains("last_vnode = Some("),
"loop {loops} must assign last_vnode in its pre-loop frame; until it does, every \
click and keystroke before the first redraw is handled against an empty value"
);
rest = &body[run_at..];
}
assert_eq!(loops, 2, "expected the plain loop and the HMR loop");
}
}