use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use blinc_animation::AnimationScheduler;
use blinc_core::context_state::{BlincContextState, HookState};
use blinc_core::reactive::{ReactiveGraph, SignalId};
use blinc_layout::div::Div;
use blinc_layout::renderer::RenderTree;
use blinc_layout::selector::ElementRegistry;
use blinc_layout::widgets::overlay::overlay_manager;
use blinc_layout::widgets::OverlayManagerExt;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use crate::app::{BlincApp, BlincConfig};
use crate::error::{BlincError, Result};
use crate::windowed::{
RefDirtyFlag, SharedAnimationScheduler, SharedElementRegistry, SharedReactiveGraph,
SharedReadyCallbacks, WindowedContext,
};
fn convert_layout_button(
b: blinc_platform::MouseButton,
) -> blinc_layout::event_router::MouseButton {
match b {
blinc_platform::MouseButton::Left => blinc_layout::event_router::MouseButton::Left,
blinc_platform::MouseButton::Right => blinc_layout::event_router::MouseButton::Right,
blinc_platform::MouseButton::Middle => blinc_layout::event_router::MouseButton::Middle,
blinc_platform::MouseButton::Back => blinc_layout::event_router::MouseButton::Back,
blinc_platform::MouseButton::Forward => blinc_layout::event_router::MouseButton::Forward,
blinc_platform::MouseButton::Other(n) => blinc_layout::event_router::MouseButton::Other(n),
}
}
#[derive(Clone, Copy)]
enum EditAction {
Cut,
Copy,
Paste,
SelectAll,
}
fn synthesize_edit_action(action: EditAction) {
use blinc_core::events::event_types;
blinc_layout::widgets::text_input::set_touch_input(false);
if let Ok(mut slot) = PENDING_EDIT_ACTION.lock() {
*slot = Some(action);
}
blinc_layout::request_redraw();
let _ = event_types::KEY_DOWN; }
static PENDING_EDIT_ACTION: std::sync::Mutex<Option<EditAction>> = std::sync::Mutex::new(None);
fn drain_pending_edit_action(tree: &mut blinc_layout::renderer::RenderTree) {
use blinc_core::events::event_types;
let Some(action) = PENDING_EDIT_ACTION.lock().ok().and_then(|mut s| s.take()) else {
return;
};
let (key_code, meta) = match action {
EditAction::Cut => (88, true), EditAction::Copy => (67, true), EditAction::SelectAll => (65, true), EditAction::Paste => {
spawn_paste_from_clipboard();
return;
}
};
tree.broadcast_key_event(event_types::KEY_DOWN, key_code, false, false, false, meta);
}
fn spawn_paste_from_clipboard() {
let Some(window) = web_sys::window() else {
return;
};
let clipboard = window.navigator().clipboard();
let promise = clipboard.read_text();
wasm_bindgen_futures::spawn_local(async move {
let result = wasm_bindgen_futures::JsFuture::from(promise).await;
let Ok(value) = result else {
return;
};
let Some(text) = value.as_string() else {
return;
};
if text.is_empty() {
return;
}
if let Ok(mut slot) = PENDING_PASTE_TEXT.lock() {
*slot = Some(text);
}
blinc_layout::request_redraw();
});
}
static PENDING_PASTE_TEXT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
fn drain_pending_paste_text(tree: &mut blinc_layout::renderer::RenderTree) {
let Some(text) = PENDING_PASTE_TEXT.lock().ok().and_then(|mut s| s.take()) else {
return;
};
for ch in text.chars() {
tree.broadcast_text_input_event(ch, false, false, false, false);
}
}
fn cursor_style_to_css(cursor: blinc_layout::element::CursorStyle) -> &'static str {
use blinc_layout::element::CursorStyle;
match cursor {
CursorStyle::Default => "default",
CursorStyle::Pointer => "pointer",
CursorStyle::Text => "text",
CursorStyle::Crosshair => "crosshair",
CursorStyle::Move => "move",
CursorStyle::NotAllowed => "not-allowed",
CursorStyle::ResizeNS => "ns-resize",
CursorStyle::ResizeEW => "ew-resize",
CursorStyle::ResizeNESW => "nesw-resize",
CursorStyle::ResizeNWSE => "nwse-resize",
CursorStyle::Grab => "grab",
CursorStyle::Grabbing => "grabbing",
CursorStyle::Wait => "wait",
CursorStyle::Progress => "progress",
CursorStyle::None => "none",
}
}
trait UiBuilderFn: 'static {
fn build_from_scratch(
&mut self,
ctx: &mut WindowedContext,
registry: std::sync::Arc<blinc_layout::selector::ElementRegistry>,
) -> blinc_layout::renderer::RenderTree;
fn build_and_update(
&mut self,
ctx: &mut WindowedContext,
tree: &mut blinc_layout::renderer::RenderTree,
) -> blinc_layout::UpdateResult;
}
impl<F, E> UiBuilderFn for F
where
F: FnMut(&mut WindowedContext) -> E + 'static,
E: blinc_layout::ElementBuilder + 'static,
{
fn build_from_scratch(
&mut self,
ctx: &mut WindowedContext,
registry: std::sync::Arc<blinc_layout::selector::ElementRegistry>,
) -> blinc_layout::renderer::RenderTree {
let user_ui = self(ctx);
let overlay_layer = ctx.overlay_manager.build_overlay_layer();
let composed = Div::new()
.w(ctx.width)
.h(ctx.height)
.relative()
.child(user_ui)
.child(overlay_layer);
blinc_layout::renderer::RenderTree::from_element_with_registry(&composed, registry)
}
fn build_and_update(
&mut self,
ctx: &mut WindowedContext,
tree: &mut blinc_layout::renderer::RenderTree,
) -> blinc_layout::UpdateResult {
let user_ui = self(ctx);
let overlay_layer = ctx.overlay_manager.build_overlay_layer();
let composed = Div::new()
.w(ctx.width)
.h(ctx.height)
.relative()
.child(user_ui)
.child(overlay_layer);
tree.incremental_update(&composed)
}
}
type UiBuilder = Box<dyn UiBuilderFn>;
fn now_ms() -> u64 {
use std::sync::OnceLock;
static START: OnceLock<web_time::Instant> = OnceLock::new();
let start = START.get_or_init(web_time::Instant::now);
start.elapsed().as_millis() as u64
}
pub struct WebApp {
#[allow(dead_code)]
canvas: web_sys::HtmlCanvasElement,
surface: wgpu::Surface<'static>,
surface_config: wgpu::SurfaceConfiguration,
blinc_app: BlincApp,
ctx: WindowedContext,
ui_builder: Option<UiBuilder>,
current_tree: Option<RenderTree>,
needs_rebuild: bool,
needs_full_rebuild: bool,
last_logical_size: (f32, f32),
last_touch_pos: Option<(f32, f32)>,
last_cursor: &'static str,
last_wheel_time_ms: Option<u64>,
pending_wheel_delta: (f32, f32),
render_state: blinc_layout::RenderState,
css_anim_store: Arc<Mutex<blinc_layout::CssAnimationStore>>,
last_frame_time_ms: u64,
asset_loader: std::sync::Arc<blinc_platform_web::WebAssetLoader>,
}
impl WebApp {
fn init_theme() {
use blinc_theme::{
detect_system_color_scheme, platform_theme_bundle, set_redraw_callback, ThemeState,
};
if ThemeState::try_get().is_none() {
let bundle = platform_theme_bundle();
let scheme = detect_system_color_scheme();
ThemeState::init(bundle, scheme);
}
set_redraw_callback(|| {
tracing::debug!("Theme changed - requesting full rebuild + CSS reparse");
blinc_layout::widgets::request_css_reparse();
blinc_layout::widgets::request_full_rebuild();
});
}
pub async fn new(canvas_id: &str) -> Result<Self> {
blinc_noto_emoji::register();
blinc_noto_symbols::register();
let window = web_sys::window().ok_or_else(|| {
BlincError::Platform("WebApp::new called without a global `window` object".to_string())
})?;
let document = window.document().ok_or_else(|| {
BlincError::Platform("WebApp::new called without a `document` object".to_string())
})?;
let canvas: web_sys::HtmlCanvasElement = document
.get_element_by_id(canvas_id)
.ok_or_else(|| {
BlincError::Platform(format!("No element with id `{canvas_id}` in document"))
})?
.dyn_into()
.map_err(|_| {
BlincError::Platform(format!("Element `{canvas_id}` is not an HtmlCanvasElement"))
})?;
let data_attr = |name: &str| -> Option<f64> {
canvas
.get_attribute(name)
.and_then(|v| v.parse::<f64>().ok())
};
let logical_width = data_attr("data-width")
.map(|v| v as f32)
.unwrap_or_else(|| canvas.client_width() as f32);
let logical_height = data_attr("data-height")
.map(|v| v as f32)
.unwrap_or_else(|| canvas.client_height() as f32);
let scale_factor = data_attr("data-dpr").unwrap_or_else(|| window.device_pixel_ratio());
let physical_width = (logical_width * scale_factor as f32).round().max(1.0);
let physical_height = (logical_height * scale_factor as f32).round().max(1.0);
canvas.set_width(physical_width as u32);
canvas.set_height(physical_height as u32);
let config = BlincConfig {
max_primitives: 20_000,
sample_count: 1, ..Default::default()
};
let (blinc_app, surface) = BlincApp::with_canvas(canvas.clone(), Some(config)).await?;
crate::text_measurer::init_text_measurer_with_registry(blinc_app.font_registry());
let texture_format = blinc_app.texture_format();
let mut surface_usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
if blinc_app.has_storage_buffers() {
surface_usage |= wgpu::TextureUsages::COPY_SRC;
}
let surface_config = wgpu::SurfaceConfiguration {
usage: surface_usage,
format: texture_format,
width: physical_width as u32,
height: physical_height as u32,
present_mode: wgpu::PresentMode::Fifo,
desired_maximum_frame_latency: 2,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
};
surface.configure(blinc_app.device(), &surface_config);
let scheduler = AnimationScheduler::new();
let animations: SharedAnimationScheduler = Arc::new(Mutex::new(scheduler));
{
let handle = animations.lock().unwrap().handle();
blinc_animation::set_global_scheduler(handle);
}
let ref_dirty_flag: RefDirtyFlag = Arc::new(AtomicBool::new(false));
let reactive: SharedReactiveGraph = Arc::new(Mutex::new(ReactiveGraph::new()));
let hooks = Arc::new(Mutex::new(HookState::new()));
if !BlincContextState::is_initialized() {
#[allow(clippy::type_complexity)]
let stateful_callback: Arc<dyn Fn(&[SignalId]) + Send + Sync> =
Arc::new(|signal_ids| {
blinc_layout::check_stateful_deps(signal_ids);
});
BlincContextState::init_with_callback(
Arc::clone(&reactive),
Arc::clone(&hooks),
Arc::clone(&ref_dirty_flag),
stateful_callback,
);
}
Self::init_theme();
let asset_loader = std::sync::Arc::new(blinc_platform_web::WebAssetLoader::new());
let shared = blinc_platform_web::assets::SharedWebAssetLoader(asset_loader.clone());
let _ = blinc_platform::assets::set_global_asset_loader(Box::new(shared));
let overlay_mgr = overlay_manager();
if !blinc_layout::overlay_state::OverlayContext::is_initialized() {
blinc_layout::overlay_state::OverlayContext::init(Arc::clone(&overlay_mgr));
}
let element_registry: SharedElementRegistry = Arc::new(ElementRegistry::new());
let ready_callbacks: SharedReadyCallbacks = Arc::new(Mutex::new(Vec::new()));
let ctx = WindowedContext::new_web(
logical_width,
logical_height,
scale_factor,
physical_width,
physical_height,
true, animations,
ref_dirty_flag,
reactive,
hooks,
overlay_mgr,
element_registry,
ready_callbacks,
);
let render_state = blinc_layout::RenderState::new(Arc::clone(&ctx.animations));
let css_anim_store = Arc::new(Mutex::new(blinc_layout::CssAnimationStore::new()));
Ok(Self {
canvas,
surface,
surface_config,
blinc_app,
ctx,
ui_builder: None,
current_tree: None,
needs_rebuild: true,
needs_full_rebuild: false,
last_logical_size: (logical_width, logical_height),
last_touch_pos: None,
last_cursor: "default",
last_wheel_time_ms: None,
pending_wheel_delta: (0.0, 0.0),
render_state,
css_anim_store,
last_frame_time_ms: 0,
asset_loader,
})
}
pub async fn run<F, E>(canvas_id: &str, ui_builder: F) -> Result<()>
where
F: FnMut(&mut WindowedContext) -> E + 'static,
E: blinc_layout::ElementBuilder + 'static,
{
Self::run_with_setup(canvas_id, |_| {}, ui_builder).await
}
pub async fn run_with_setup<S, F, E>(canvas_id: &str, setup: S, ui_builder: F) -> Result<()>
where
S: FnOnce(&mut Self) + 'static,
F: FnMut(&mut WindowedContext) -> E + 'static,
E: blinc_layout::ElementBuilder + 'static,
{
Self::run_with_setup_inner(
canvas_id,
move |app| {
Box::pin(async move {
setup(app);
Ok(())
})
},
ui_builder,
)
.await
}
pub async fn run_with_async_setup<S, F, E>(
canvas_id: &str,
setup: S,
ui_builder: F,
) -> Result<()>
where
S: for<'a> FnOnce(
&'a mut Self,
)
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>>,
F: FnMut(&mut WindowedContext) -> E + 'static,
E: blinc_layout::ElementBuilder + 'static,
{
Self::run_with_setup_inner(canvas_id, setup, ui_builder).await
}
async fn run_with_setup_inner<S, F, E>(canvas_id: &str, setup: S, ui_builder: F) -> Result<()>
where
S: for<'a> FnOnce(
&'a mut Self,
)
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>>,
F: FnMut(&mut WindowedContext) -> E + 'static,
E: blinc_layout::ElementBuilder + 'static,
{
let mut app = Self::new(canvas_id).await?;
setup(&mut app).await?;
app.set_ui_builder(ui_builder);
if let Err(e) = app.run_one_frame() {
tracing::error!("WebApp::run initial frame failed: {e}");
}
let app_rc = Rc::new(RefCell::new(app));
let app_for_wake = Rc::clone(&app_rc);
let wake = move || {
if let Ok(mut app) = app_for_wake.try_borrow_mut() {
if let Err(e) = app.run_one_frame() {
tracing::error!("WebApp wake-frame failed: {e}");
}
}
};
Self::install_input_listeners(Rc::clone(&app_rc))?;
let scheduler_arc = Arc::clone(&app_rc.borrow().ctx.animations);
if let Ok(mut scheduler) = scheduler_arc.lock() {
scheduler.set_wake_callback(wake);
scheduler.set_continuous_redraw(true);
}
if let Ok(scheduler) = scheduler_arc.lock() {
scheduler.start_raf();
}
Ok(())
}
pub fn load_font_data(&mut self, bytes: Vec<u8>) -> usize {
let faces = self.blinc_app.load_font_data_to_registry(bytes);
self.blinc_app.refresh_generic_font_styles();
faces
}
pub fn insert_asset(&self, key: impl Into<String>, bytes: Vec<u8>) {
self.asset_loader.insert_raw(key, bytes);
}
#[cfg(target_arch = "wasm32")]
pub async fn preload_assets(&self, urls: &[&str]) -> crate::error::Result<()> {
self.asset_loader
.preload(urls)
.await
.map_err(|e| crate::error::BlincError::Platform(format!("preload_assets: {e}")))
}
fn install_input_listeners(app_rc: Rc<RefCell<Self>>) -> Result<()> {
let window = web_sys::window().ok_or_else(|| {
BlincError::Platform(
"WebApp::install_input_listeners called without a global `window` object"
.to_string(),
)
})?;
let document = window.document().ok_or_else(|| {
BlincError::Platform(
"WebApp::install_input_listeners called without a `document` object".to_string(),
)
})?;
let canvas = app_rc.borrow().canvas.clone();
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::MouseEvent| {
if let Ok(mut app) = app_rc.try_borrow_mut() {
let x = evt.offset_x() as f32;
let y = evt.offset_y() as f32;
Self::dispatch_mouse_move(&mut app, x, y);
}
});
canvas
.add_event_listener_with_callback("mousemove", closure.as_ref().unchecked_ref())
.map_err(|e| {
BlincError::Platform(format!("add mousemove listener failed: {e:?}"))
})?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::MouseEvent| {
if evt.button() != 0 {
return;
}
if let Ok(mut app) = app_rc.try_borrow_mut() {
let x = evt.offset_x() as f32;
let y = evt.offset_y() as f32;
let button = blinc_platform_web::input::convert_mouse_button(evt.button());
Self::dispatch_mouse_down(&mut app, x, y, button);
}
});
canvas
.add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())
.map_err(|e| {
BlincError::Platform(format!("add mousedown listener failed: {e:?}"))
})?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::MouseEvent| {
if evt.button() != 0 {
return;
}
if let Ok(mut app) = app_rc.try_borrow_mut() {
let x = evt.offset_x() as f32;
let y = evt.offset_y() as f32;
let button = blinc_platform_web::input::convert_mouse_button(evt.button());
Self::dispatch_mouse_up(&mut app, x, y, button);
}
});
canvas
.add_event_listener_with_callback("mouseup", closure.as_ref().unchecked_ref())
.map_err(|e| BlincError::Platform(format!("add mouseup listener failed: {e:?}")))?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::WheelEvent| {
evt.prevent_default();
if let Ok(mut app) = app_rc.try_borrow_mut() {
let multiplier: f32 = match evt.delta_mode() {
0 => 1.0, 1 => 16.0, 2 => app.ctx.height, _ => 1.0,
};
let raw_dx = -(evt.delta_x() as f32) * multiplier;
let raw_dy = -(evt.delta_y() as f32) * multiplier;
app.pending_wheel_delta.0 += raw_dx;
app.pending_wheel_delta.1 += raw_dy;
}
});
canvas
.add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref())
.map_err(|e| BlincError::Platform(format!("add wheel listener failed: {e:?}")))?;
closure.forget();
}
{
let canvas_for_touch = canvas.clone();
let touch_local_pos =
move |touch_list: web_sys::TouchList| -> Option<(f32, f32, usize)> {
let len = touch_list.length() as usize;
if len == 0 {
return None;
}
let touch = touch_list.get(0)?;
let rect = canvas_for_touch.get_bounding_client_rect();
let x = touch.client_x() as f32 - rect.left() as f32;
let y = touch.client_y() as f32 - rect.top() as f32;
Some((x, y, len))
};
{
let app_rc = Rc::clone(&app_rc);
let touch_local_pos = touch_local_pos.clone();
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::TouchEvent| {
evt.prevent_default();
if let Ok(mut app) = app_rc.try_borrow_mut() {
if let Some((x, y, len)) = touch_local_pos(evt.touches()) {
blinc_layout::widgets::text_input::set_touch_input(true);
if len == 1 {
app.last_touch_pos = Some((x, y));
Self::dispatch_mouse_down(
&mut app,
x,
y,
blinc_platform::MouseButton::Left,
);
} else {
app.last_touch_pos = None;
}
}
}
});
canvas
.add_event_listener_with_callback(
"touchstart",
closure.as_ref().unchecked_ref(),
)
.map_err(|e| {
BlincError::Platform(format!("add touchstart listener failed: {e:?}"))
})?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let touch_local_pos = touch_local_pos.clone();
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::TouchEvent| {
evt.prevent_default();
if let Ok(mut app) = app_rc.try_borrow_mut() {
if let Some((x, y, len)) = touch_local_pos(evt.touches()) {
if len == 1 {
if let Some((px, py)) = app.last_touch_pos {
let dx = x - px;
let dy = y - py;
if dx.abs() > 0.5 || dy.abs() > 0.5 {
Self::dispatch_scroll(&mut app, dx, dy);
}
}
app.last_touch_pos = Some((x, y));
Self::dispatch_mouse_move(&mut app, x, y);
} else {
app.last_touch_pos = None;
}
}
}
});
canvas
.add_event_listener_with_callback("touchmove", closure.as_ref().unchecked_ref())
.map_err(|e| {
BlincError::Platform(format!("add touchmove listener failed: {e:?}"))
})?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let touch_local_pos = touch_local_pos.clone();
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::TouchEvent| {
evt.prevent_default();
if let Ok(mut app) = app_rc.try_borrow_mut() {
let pos = touch_local_pos(evt.changed_touches())
.or(app.last_touch_pos.map(|(x, y)| (x, y, 1)));
if let Some((x, y, _)) = pos {
Self::dispatch_mouse_up(
&mut app,
x,
y,
blinc_platform::MouseButton::Left,
);
}
if let Some(ref tree) = app.current_tree {
tree.on_gesture_end();
}
blinc_layout::widgets::text_input::cancel_long_press_timer();
app.last_touch_pos = None;
}
});
canvas
.add_event_listener_with_callback("touchend", closure.as_ref().unchecked_ref())
.map_err(|e| {
BlincError::Platform(format!("add touchend listener failed: {e:?}"))
})?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::TouchEvent| {
evt.prevent_default();
if let Ok(mut app) = app_rc.try_borrow_mut() {
if let Some((x, y)) = app.last_touch_pos {
Self::dispatch_mouse_up(
&mut app,
x,
y,
blinc_platform::MouseButton::Left,
);
}
blinc_layout::widgets::text_input::cancel_long_press_timer();
app.last_touch_pos = None;
}
});
canvas
.add_event_listener_with_callback(
"touchcancel",
closure.as_ref().unchecked_ref(),
)
.map_err(|e| {
BlincError::Platform(format!("add touchcancel listener failed: {e:?}"))
})?;
closure.forget();
}
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::KeyboardEvent| {
if let Ok(mut app) = app_rc.try_borrow_mut() {
let key_code = evt.key_code();
let shift = evt.shift_key();
let ctrl = evt.ctrl_key();
let alt = evt.alt_key();
let meta = evt.meta_key();
Self::dispatch_key_down(&mut app, key_code, shift, ctrl, alt, meta);
let key_str = evt.key();
let mut chars = key_str.chars();
if let (Some(ch), None) = (chars.next(), chars.next()) {
if !ch.is_control() && !ctrl && !meta {
evt.prevent_default();
Self::dispatch_text_input(&mut app, ch, shift, ctrl, alt, meta);
}
}
}
});
document
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())
.map_err(|e| BlincError::Platform(format!("add keydown listener failed: {e:?}")))?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::KeyboardEvent| {
if let Ok(mut app) = app_rc.try_borrow_mut() {
let key_code = evt.key_code();
let shift = evt.shift_key();
let ctrl = evt.ctrl_key();
let alt = evt.alt_key();
let meta = evt.meta_key();
Self::dispatch_key_up(&mut app, key_code, shift, ctrl, alt, meta);
}
});
document
.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())
.map_err(|e| BlincError::Platform(format!("add keyup listener failed: {e:?}")))?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::ClipboardEvent| {
if let Ok(mut app) = app_rc.try_borrow_mut() {
let Some(data) = evt.clipboard_data() else {
return;
};
let text = data.get_data("text/plain").unwrap_or_default();
if text.is_empty() {
return;
}
evt.prevent_default();
if let Some(tree) = app.current_tree.as_mut() {
for ch in text.chars() {
tree.broadcast_text_input_event(ch, false, false, false, false);
}
}
}
});
document
.add_event_listener_with_callback("paste", closure.as_ref().unchecked_ref())
.map_err(|e| BlincError::Platform(format!("add paste listener failed: {e:?}")))?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::MouseEvent| {
evt.prevent_default();
if let Ok(mut app) = app_rc.try_borrow_mut() {
let x = evt.offset_x() as f32;
let y = evt.offset_y() as f32;
let page_x = evt.client_x() as f32;
let page_y = evt.client_y() as f32;
Self::handle_context_menu(&mut app, x, y, page_x, page_y);
}
});
canvas
.add_event_listener_with_callback("contextmenu", closure.as_ref().unchecked_ref())
.map_err(|e| {
BlincError::Platform(format!("add contextmenu listener failed: {e:?}"))
})?;
closure.forget();
}
{
let app_rc = Rc::clone(&app_rc);
let closure = Closure::<dyn FnMut(_)>::new(move |_evt: web_sys::Event| {
if let Ok(mut app) = app_rc.try_borrow_mut() {
Self::handle_resize(&mut app);
}
});
window
.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref())
.map_err(|e| BlincError::Platform(format!("add resize listener failed: {e:?}")))?;
closure.forget();
}
Ok(())
}
fn dispatch_mouse_move(app: &mut Self, x: f32, y: f32) {
let tree = match app.current_tree.as_ref() {
Some(t) => t,
None => return,
};
let pending = app.ctx.event_router.on_mouse_move(tree, x, y);
let cursor_style = tree
.get_cursor_at(&app.ctx.event_router, x, y)
.unwrap_or(blinc_layout::element::CursorStyle::Default);
let css_cursor = cursor_style_to_css(cursor_style);
if css_cursor != app.last_cursor {
if let Some(html_canvas) = app.canvas.dyn_ref::<web_sys::HtmlElement>() {
let _ = html_canvas.style().set_property("cursor", css_cursor);
}
app.last_cursor = css_cursor;
}
Self::dispatch_pending(app, pending);
}
fn dispatch_mouse_down(app: &mut Self, x: f32, y: f32, button: blinc_platform::MouseButton) {
blinc_layout::widgets::text_input::set_touch_input(false);
blinc_layout::widgets::blur_all_text_inputs();
let tree = match app.current_tree.as_ref() {
Some(t) => t,
None => return,
};
let pending = app
.ctx
.event_router
.on_mouse_down(tree, x, y, convert_layout_button(button));
Self::dispatch_pending(app, pending);
}
fn dispatch_mouse_up(app: &mut Self, x: f32, y: f32, button: blinc_platform::MouseButton) {
let tree = match app.current_tree.as_ref() {
Some(t) => t,
None => return,
};
let pending = app
.ctx
.event_router
.on_mouse_up(tree, x, y, convert_layout_button(button));
Self::dispatch_pending(app, pending);
}
fn dispatch_scroll(app: &mut Self, delta_x: f32, delta_y: f32) {
if let Some(tree) = app.current_tree.as_ref() {
if tree.has_bouncing_scroll() {
return;
}
}
let hit = {
let tree = match app.current_tree.as_ref() {
Some(t) => t,
None => return,
};
app.ctx
.event_router
.on_scroll_nested(tree, delta_x, delta_y)
};
let Some(hit) = hit else {
return;
};
let (mx, my) = app.ctx.event_router.mouse_position();
if let Some(tree) = app.current_tree.as_mut() {
tree.dispatch_scroll_chain(hit.node, &hit.ancestors, mx, my, delta_x, delta_y);
}
app.last_wheel_time_ms = Some(now_ms());
}
fn dispatch_key_down(
app: &mut Self,
key_code: u32,
shift: bool,
ctrl: bool,
alt: bool,
meta: bool,
) {
let _ = app.ctx.event_router.on_key_down(key_code);
if let Some(tree) = app.current_tree.as_mut() {
tree.broadcast_key_event(
blinc_core::events::event_types::KEY_DOWN,
key_code,
shift,
ctrl,
alt,
meta,
);
}
}
fn dispatch_key_up(
app: &mut Self,
key_code: u32,
shift: bool,
ctrl: bool,
alt: bool,
meta: bool,
) {
let _ = app.ctx.event_router.on_key_up(key_code);
if let Some(tree) = app.current_tree.as_mut() {
tree.broadcast_key_event(
blinc_core::events::event_types::KEY_UP,
key_code,
shift,
ctrl,
alt,
meta,
);
}
}
fn dispatch_text_input(
app: &mut Self,
ch: char,
shift: bool,
ctrl: bool,
alt: bool,
meta: bool,
) {
if let Some(tree) = app.current_tree.as_mut() {
tree.broadcast_text_input_event(ch, shift, ctrl, alt, meta);
}
}
fn handle_context_menu(app: &mut Self, canvas_x: f32, canvas_y: f32, page_x: f32, page_y: f32) {
let focused = blinc_layout::widgets::text_input::focused_editable_node_id();
if focused.is_none() {
return;
}
if let Some(tree) = app.current_tree.as_ref() {
let _ = app.ctx.event_router.on_mouse_move(tree, canvas_x, canvas_y);
}
let Some(window) = web_sys::window() else {
return;
};
let Some(document) = window.document() else {
return;
};
const MENU_ID: &str = "blinc-context-menu";
if let Some(existing) = document.get_element_by_id(MENU_ID) {
existing.remove();
}
let Ok(menu) = document.create_element("div") else {
return;
};
let _ = menu.set_attribute("id", MENU_ID);
let style = format!(
"position:fixed;left:{}px;top:{}px;\
background:#1e1e2e;color:#cdd6f4;\
border:1px solid #45475a;border-radius:8px;\
padding:4px 0;\
font:13px -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;\
box-shadow:0 8px 24px rgba(0,0,0,0.4);\
z-index:9999;\
min-width:160px;",
page_x, page_y,
);
let _ = menu.set_attribute("style", &style);
for (label, action) in [
("Cut", EditAction::Cut),
("Copy", EditAction::Copy),
("Paste", EditAction::Paste),
("Select All", EditAction::SelectAll),
] {
let Ok(item) = document.create_element("div") else {
continue;
};
let _ =
item.set_attribute("style", "padding:6px 16px;cursor:pointer;user-select:none;");
item.set_text_content(Some(label));
if let Some(html_item) = item.dyn_ref::<web_sys::HtmlElement>() {
let html_item_clone = html_item.clone();
let on_over = Closure::<dyn FnMut(_)>::new(move |_evt: web_sys::MouseEvent| {
let _ = html_item_clone
.style()
.set_property("background", "#313244");
});
let _ = html_item.add_event_listener_with_callback(
"mouseover",
on_over.as_ref().unchecked_ref(),
);
on_over.forget();
let html_item_clone = html_item.clone();
let on_out = Closure::<dyn FnMut(_)>::new(move |_evt: web_sys::MouseEvent| {
let _ = html_item_clone.style().set_property("background", "");
});
let _ = html_item
.add_event_listener_with_callback("mouseout", on_out.as_ref().unchecked_ref());
on_out.forget();
}
let on_action = Closure::<dyn FnMut(_)>::new(move |evt: web_sys::MouseEvent| {
evt.stop_propagation();
evt.prevent_default();
synthesize_edit_action(action);
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(menu) = doc.get_element_by_id(MENU_ID) {
menu.remove();
}
}
});
let _ = item
.add_event_listener_with_callback("mousedown", on_action.as_ref().unchecked_ref());
on_action.forget();
let _ = menu.append_child(&item);
}
let dismiss = Closure::<dyn FnMut(_)>::new(move |_evt: web_sys::MouseEvent| {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(menu) = doc.get_element_by_id(MENU_ID) {
menu.remove();
}
}
});
let document_clone = document.clone();
let dismiss_attach = Closure::<dyn FnMut()>::new(move || {
let _ = document_clone.add_event_listener_with_callback_and_add_event_listener_options(
"mousedown",
dismiss.as_ref().unchecked_ref(),
web_sys::AddEventListenerOptions::new().once(true),
);
});
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
dismiss_attach.as_ref().unchecked_ref(),
0,
);
dismiss_attach.forget();
if let Some(body) = document.body() {
let _ = body.append_child(&menu);
}
}
fn dispatch_pending(app: &mut Self, pending: Vec<(blinc_layout::tree::LayoutNodeId, u32)>) {
if pending.is_empty() {
return;
}
let (mx, my) = app.ctx.event_router.mouse_position();
let (drag_dx, drag_dy) = app.ctx.event_router.drag_delta();
struct DispatchEntry {
node: blinc_layout::tree::LayoutNodeId,
event_type: u32,
bounds: (f32, f32, f32, f32),
}
let entries: Vec<DispatchEntry> = pending
.iter()
.map(|&(node, event_type)| DispatchEntry {
node,
event_type,
bounds: app
.ctx
.event_router
.get_node_bounds(node)
.unwrap_or((0.0, 0.0, 0.0, 0.0)),
})
.collect();
if let Some(tree) = app.current_tree.as_mut() {
for entry in entries {
let DispatchEntry {
node,
event_type,
bounds: (bx, by, bw, bh),
} = entry;
let local_x = mx - bx;
let local_y = my - by;
let (dx, dy) = if event_type == blinc_core::events::event_types::DRAG
|| event_type == blinc_core::events::event_types::DRAG_END
{
(drag_dx, drag_dy)
} else {
(0.0, 0.0)
};
tree.dispatch_event_full(
node, event_type, mx, my, local_x, local_y, bx, by, bw, bh, dx, dy,
1.0,
);
}
}
}
fn handle_resize(app: &mut Self) {
let window = match web_sys::window() {
Some(w) => w,
None => return,
};
if app.canvas.get_attribute("data-width").is_some()
|| app.canvas.get_attribute("data-height").is_some()
{
return;
}
let logical_width = app.canvas.client_width() as f32;
let logical_height = app.canvas.client_height() as f32;
if logical_width <= 0.0 || logical_height <= 0.0 {
return;
}
let scale_factor = app
.canvas
.get_attribute("data-dpr")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or_else(|| window.device_pixel_ratio());
let (last_w, last_h) = app.last_logical_size;
let last_dpr = app.ctx.scale_factor;
if (last_w - logical_width).abs() < 0.5
&& (last_h - logical_height).abs() < 0.5
&& (last_dpr - scale_factor).abs() < 0.001
{
return;
}
let physical_width = (logical_width * scale_factor as f32).round().max(1.0);
let physical_height = (logical_height * scale_factor as f32).round().max(1.0);
app.canvas.set_width(physical_width as u32);
app.canvas.set_height(physical_height as u32);
app.surface_config.width = physical_width as u32;
app.surface_config.height = physical_height as u32;
app.surface
.configure(app.blinc_app.device(), &app.surface_config);
app.ctx.width = logical_width;
app.ctx.height = logical_height;
app.ctx.scale_factor = scale_factor;
app.ctx.physical_width = physical_width;
app.ctx.physical_height = physical_height;
app.last_logical_size = (logical_width, logical_height);
app.needs_rebuild = true;
app.needs_full_rebuild = true;
}
pub fn set_ui_builder<F, E>(&mut self, builder: F)
where
F: FnMut(&mut WindowedContext) -> E + 'static,
E: blinc_layout::ElementBuilder + 'static,
{
self.ui_builder = Some(Box::new(builder));
self.needs_rebuild = true;
}
pub fn request_rebuild(&mut self) {
self.needs_rebuild = true;
}
pub fn run_one_frame(&mut self) -> Result<()> {
let now = now_ms();
self.render_state.clear_overlays();
{
let ctx_state = blinc_core::BlincContextState::get();
for pass in ctx_state.drain_custom_passes() {
if let Ok(typed) =
pass.downcast::<Box<dyn blinc_gpu::custom_pass::CustomRenderPass>>()
{
self.blinc_app.context().register_custom_pass(*typed);
}
}
}
let (pending_dx, pending_dy) = self.pending_wheel_delta;
self.pending_wheel_delta = (0.0, 0.0);
if pending_dx != 0.0 || pending_dy != 0.0 {
const DAMP_EXPONENT: f32 = 0.7;
let damp = |d: f32| -> f32 {
if d == 0.0 {
0.0
} else {
d.signum() * d.abs().powf(DAMP_EXPONENT)
}
};
Self::dispatch_scroll(self, damp(pending_dx), damp(pending_dy));
}
if let Some(ref mut tree) = self.current_tree {
tree.tick_scroll_physics(now);
tree.process_pending_scroll_refs();
if let Some(last) = self.last_wheel_time_ms {
let elapsed = now.saturating_sub(last);
let overscrolling = tree.has_overscrolling_scroll();
let debounce_ms = if overscrolling { 32 } else { 120 };
if elapsed >= debounce_ms {
tree.on_scroll_end();
self.last_wheel_time_ms = None;
}
}
drain_pending_edit_action(tree);
drain_pending_paste_text(tree);
}
self.render_state.process_global_motion_exit_starts();
self.render_state.process_global_motion_exit_cancels();
self.render_state.process_global_motion_starts();
self.render_state.sync_shared_motion_states();
self.ctx.overlay_manager.set_viewport_with_scale(
self.ctx.width,
self.ctx.height,
self.ctx.scale_factor as f32,
);
self.ctx.overlay_manager.update(now);
if self.ctx.overlay_manager.is_dirty() {
let registry = self.ctx.element_registry().clone();
if let Some(overlay_node_id) =
registry.get(blinc_layout::widgets::overlay::OVERLAY_LAYER_ID)
{
let overlay_content = self.ctx.overlay_manager.build_overlay_layer();
blinc_layout::queue_subtree_rebuild(overlay_node_id, overlay_content);
}
self.ctx.overlay_manager.take_dirty();
}
if let Some(ref tree) = self.current_tree {
if tree.needs_rebuild() {
self.needs_rebuild = true;
}
}
if blinc_layout::widgets::take_needs_rebuild() {
self.needs_rebuild = true;
}
if self
.ctx
.dirty_flag()
.swap(false, std::sync::atomic::Ordering::SeqCst)
{
self.needs_rebuild = true;
}
if blinc_layout::widgets::take_needs_relayout() {
self.needs_full_rebuild = true;
}
if blinc_layout::widgets::take_needs_css_reparse() {
self.ctx.reparse_css();
}
let has_stateful_updates = blinc_layout::take_needs_redraw();
let has_pending_rebuilds = blinc_layout::has_pending_subtree_rebuilds();
if has_stateful_updates || has_pending_rebuilds {
let prop_updates = blinc_layout::take_pending_prop_updates();
if let Some(ref mut tree) = self.current_tree {
for (node_id, props) in &prop_updates {
tree.update_render_props(*node_id, |p| *p = props.clone());
}
}
let mut needs_relayout = false;
if let Some(ref mut tree) = self.current_tree {
needs_relayout = tree.process_pending_subtree_rebuilds();
}
if needs_relayout {
if let Some(ref mut tree) = self.current_tree {
tree.apply_stylesheet_layout_overrides();
tree.compute_layout(self.ctx.width, self.ctx.height);
tree.apply_flip_transitions();
tree.update_flip_bounds();
tree.initialize_motion_animations(&mut self.render_state);
self.render_state.end_stable_motion_frame();
self.render_state.process_global_motion_replays();
tree.start_all_css_animations();
}
}
}
if self.needs_rebuild {
let builder = match self.ui_builder.as_mut() {
Some(b) => b,
None => return Ok(()),
};
blinc_layout::reset_call_counters();
blinc_layout::clear_stateful_base_updaters();
blinc_layout::click_outside::clear_click_outside_handlers();
if self.needs_full_rebuild {
self.current_tree = None;
self.needs_full_rebuild = false;
}
if let Some(ref mut existing_tree) = self.current_tree {
use blinc_layout::UpdateResult;
match builder.build_and_update(&mut self.ctx, existing_tree) {
UpdateResult::NoChanges | UpdateResult::VisualOnly => {}
UpdateResult::LayoutChanged | UpdateResult::ChildrenChanged => {
existing_tree.apply_stylesheet_base_styles();
existing_tree.apply_stylesheet_layout_overrides();
existing_tree.compute_layout(self.ctx.width, self.ctx.height);
existing_tree.apply_flip_transitions();
existing_tree.update_flip_bounds();
if let Some(ref stylesheet) = self.ctx.stylesheet {
self.ctx.pointer_query.register_from_stylesheet(stylesheet);
}
existing_tree.initialize_motion_animations(&mut self.render_state);
self.render_state.end_stable_motion_frame();
self.render_state.process_global_motion_replays();
existing_tree.start_all_css_animations();
}
}
existing_tree.clear_dirty();
} else {
let registry = Arc::clone(self.ctx.element_registry());
let mut tree = builder.build_from_scratch(&mut self.ctx, registry);
tree.set_animations(&self.ctx.animations);
tree.set_scale_factor(self.ctx.scale_factor as f32);
tree.set_css_anim_store(Arc::clone(&self.css_anim_store));
if let Some(ref stylesheet) = self.ctx.stylesheet {
tree.set_stylesheet_arc(stylesheet.clone());
}
tree.apply_all_stylesheet_styles();
if let Some(ref stylesheet) = self.ctx.stylesheet {
self.ctx.pointer_query.register_from_stylesheet(stylesheet);
}
tree.compute_layout(self.ctx.width, self.ctx.height);
tree.update_flip_bounds();
tree.initialize_motion_animations(&mut self.render_state);
self.render_state.end_stable_motion_frame();
self.render_state.process_global_motion_replays();
tree.start_all_css_animations();
self.current_tree = Some(tree);
}
self.needs_rebuild = false;
self.ctx.rebuild_count = self.ctx.rebuild_count.saturating_add(1);
}
self.render_state.process_global_motion_exit_cancels();
self.render_state.process_global_motion_exit_starts();
self.render_state.process_global_motion_starts();
let _animations_active = self.render_state.tick(now);
let dt_ms = if self.last_frame_time_ms > 0 {
now.saturating_sub(self.last_frame_time_ms) as f32
} else {
16.0
};
let css_active = if let Some(ref mut tree) = self.current_tree {
let store = tree.css_anim_store();
let (anim, trans) = store.lock().unwrap().tick(dt_ms);
let flip = tree.tick_flip_animations(dt_ms);
anim || trans || flip || tree.css_has_active()
} else {
false
};
self.last_frame_time_ms = now;
self.render_state.sync_shared_motion_states();
let _theme_animating = blinc_theme::ThemeState::get().tick();
{
let needs_animation_redraw = self.ctx.animations.lock().unwrap().take_needs_redraw();
if needs_animation_redraw && blinc_layout::has_animating_statefuls() {
blinc_layout::check_stateful_animations();
}
}
{
let prop_updates = blinc_layout::take_pending_prop_updates();
if let Some(ref mut tree) = self.current_tree {
for (node_id, props) in &prop_updates {
tree.update_render_props(*node_id, |p| *p = props.clone());
}
}
if blinc_layout::has_pending_subtree_rebuilds() {
let mut needs_relayout = false;
if let Some(ref mut tree) = self.current_tree {
needs_relayout = tree.process_pending_subtree_rebuilds();
}
if needs_relayout {
if let Some(ref mut tree) = self.current_tree {
tree.compute_layout(self.ctx.width, self.ctx.height);
}
}
}
}
{
let text_focus = blinc_layout::widgets::text_input::focused_text_input_node_id()
.or_else(blinc_layout::widgets::text_input::focused_text_area_node_id);
let current_focus = self.ctx.event_router.focused();
if text_focus != current_focus {
self.ctx.event_router.set_focus(text_focus);
}
}
if let Some(ref mut tree) = self.current_tree {
if tree.stylesheet().is_some() {
let state_changed = tree.apply_stylesheet_state_styles(&self.ctx.event_router);
if state_changed {
tree.compute_layout(self.ctx.width, self.ctx.height);
tree.update_flip_bounds();
}
}
}
if css_active
|| !self
.current_tree
.as_ref()
.map_or(true, |t| t.css_transitions_empty())
{
if let Some(ref mut tree) = self.current_tree {
tree.apply_all_css_animation_props();
tree.apply_all_css_transition_props();
tree.apply_flip_animation_props();
if tree.apply_animated_layout_props() {
tree.compute_layout(self.ctx.width, self.ctx.height);
tree.update_flip_bounds();
}
}
}
if !self.ctx.pointer_query.is_empty() {
let (mx, my) = self.ctx.event_router.mouse_position();
let is_pressed = self.ctx.event_router.pressed_target().is_some();
let dt_sec = dt_ms / 1000.0;
let time_sec = now as f64 / 1000.0;
let registry = Arc::clone(self.ctx.element_registry());
let router = &self.ctx.event_router;
self.ctx
.pointer_query
.update(mx, my, is_pressed, dt_sec, time_sec, |id| {
let node = registry.get(id)?;
if router.is_hovered(node) {
router.get_node_bounds(node)
} else {
None
}
});
if let Some(ref mut tree) = self.current_tree {
tree.apply_pointer_styles(&self.ctx.pointer_query, &self.ctx.event_router);
}
}
let tree = match self.current_tree.as_ref() {
Some(t) => t,
None => return Ok(()),
};
let frame = self
.surface
.get_current_texture()
.map_err(|e| BlincError::Render(format!("get_current_texture failed: {e}")))?;
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let physical_w = self.surface_config.width;
let physical_h = self.surface_config.height;
let (mx, my) = self.ctx.event_router.mouse_position();
let sf = self.ctx.scale_factor as f32;
self.blinc_app.set_cursor_position(mx * sf, my * sf);
self.render_state
.set_viewport_size(self.ctx.width, self.ctx.height);
self.blinc_app.set_blend_target(&frame.texture);
self.blinc_app.render_tree_with_motion(
tree,
&self.render_state,
&view,
physical_w,
physical_h,
)?;
frame.present();
let _content_dirty = self.ctx.overlay_manager.take_dirty();
let _animation_dirty = self.ctx.overlay_manager.take_animation_dirty();
self.ctx.had_visible_overlays = self.ctx.overlay_manager.has_visible_overlays();
Ok(())
}
pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
&self.canvas
}
pub fn blinc_app(&self) -> &BlincApp {
&self.blinc_app
}
pub fn context(&self) -> &WindowedContext {
&self.ctx
}
pub fn context_mut(&mut self) -> &mut WindowedContext {
&mut self.ctx
}
pub fn surface(&self) -> &wgpu::Surface<'static> {
&self.surface
}
pub fn surface_config(&self) -> &wgpu::SurfaceConfiguration {
&self.surface_config
}
pub fn scheduler(&self) -> &crate::windowed::SharedAnimationScheduler {
&self.ctx.animations
}
pub fn start_frame_loop(&self) {
if let Ok(scheduler) = self.ctx.animations.lock() {
scheduler.start_raf();
}
}
}