use devtools_core::{DevAction, DevPlugin};
use platform_core::{Event, EventHandler, Window, WindowCommand};
use reactive_core::{FlushNotifyHandle, begin_batch, end_batch, set_flush_notify};
use renderer_core::{RenderBackend, RendererError};
use renderer_hardware::{HardwareRenderer, HardwareRendererConfig};
use renderer_software::SoftwareRenderer;
use services_core::AppPathsProvider;
use ui_core::EventResult;
use crate::app::App;
use crate::config::{self, RendererBackend};
use crate::prefs::UserPrefs;
use crate::window_signals::WindowSignals;
use super::COMMAND_BUF_POOL_CAP;
use super::font_config::{
SystemFonts, build_font_config, build_hardware_font_config, build_software_renderer_config,
hardware_cache_path,
};
use super::hot_host::{FrameMsg, spawn_render_thread};
use super::{FRAME_BUDGET, HW_KEEPALIVE_GRACE, HW_KEEPALIVE_INTERVAL};
pub(super) struct AppHandler<W, D: DevPlugin>
where
W: Window + Clone + Send + Sync + 'static,
{
pub(super) app: Box<dyn App>,
// This handler's surface world, activated around every lifecycle call so its build/event/frame resolve
// layout/overlay/focus against the right surface. `None` for a single-window app: its ambient thread-local
// world IS its one surface, and entering it is a no-op. The multi-surface runner injects `Some` per window.
pub(super) surface: Option<std::rc::Rc<ui_core::Surface>>,
// This window's OS command queue (Close/Drag/SetTitle/…), entered alongside `surface` so a title-bar
// action pushed by one window's widgets targets that window — never a sibling sharing the M3 UI thread.
// Only entered when `surface` is `Some`; a single-window app keeps using the ambient thread-local queue.
pub(super) window_commands: platform_core::WindowCommandContext,
// The app's mounted UI — obtained from the app rather than built here, because under hot reload the tree has
// to live in the dylib's runtime for its segments to subscribe to anything (see `crate::tree`).
pub(super) tree: Option<Box<dyn crate::tree::UiTree>>,
pub(super) renderer: Option<Box<dyn RenderBackend>>,
pub(super) renderer_is_hardware: bool,
// The transparency the live renderer was built for. Only `remount` reads it: an app whose surface changes
// from opaque to transparent between two mounts needs the renderer built again, and asking the app after
// the rebuild would compare the new answer with itself.
pub(super) renderer_transparent: bool,
/// Added to the tree's own compose generation on its way to a renderer, and stepped past everything already
/// drawn whenever the tree is replaced.
///
/// A renderer skips its whole pipeline and re-presents the texture it retained when the generation it is
/// handed matches the last one it rendered — the invariant being that equal generations mean identical draw
/// commands. A remount breaks that on its own: the counter lives on the tree, so a new tree starts over, and
/// a surface whose content never changes hands out the very same number it did before. Static surfaces are
/// then exactly the ones that keep showing the frame they had before the rebuild, while anything with a
/// clock in it repaints because its counter had already climbed past the collision.
pub(super) generation_base: u64,
/// The highest generation handed to a renderer for this surface, so the next tree can start past it.
pub(super) last_generation: u64,
/// How many frames have gone out under a live continuous region. See [`frame_generation`](Self::frame_generation).
pub(super) continuous_frames: u64,
pub(super) backend: RendererBackend,
pub(super) prefs: UserPrefs,
pub(super) paths: Box<dyn AppPathsProvider>,
pub(super) pending_restart: bool,
pub(super) pending_renderer:
Option<std::thread::JoinHandle<Result<HardwareRenderer<W>, RendererError>>>,
pub(super) _flush_notify: Option<FlushNotifyHandle>,
pub(super) scale_factor: f32,
// Set when the app pushed WindowCommand::Close (a custom title-bar close button); polled by the platform
// via take_exit_request to leave the run loop.
pub(super) exit_requested: bool,
// A Send/Sync handle that wakes this window's loop, built from the window at resume and handed to app code
// via AppCtx so background threads can request a redraw when their results are ready.
pub(super) redraw_waker: Option<crate::app_context::RedrawWaker>,
// Reused across frames so the SW/HiDPI command scaling allocates neither a fresh Vec nor redundant per-command style Arcs.
pub(super) scale_scratch: renderer_core::ScaleScratch,
pub(super) window_signals: Option<WindowSignals>,
pub(super) app_name: String,
pub(super) last_frame: std::time::Instant,
// When the frame pass last ran, as opposed to `last_frame`, which only advances on a frame carrying new content; `on_redraw` paces itself against this so the pass costs the same however often the platform calls it.
pub(super) last_tick: std::time::Instant,
// When a frame was last submitted, content or keepalive; paces the keepalive blit.
pub(super) last_submit: std::time::Instant,
pub(super) dev: D,
pub(super) font_paths: Vec<std::path::PathBuf>,
pub(super) font_data: Vec<Vec<u8>>,
pub(super) _window: std::marker::PhantomData<W>,
pub(super) render_tx: Option<std::sync::mpsc::SyncSender<FrameMsg>>,
pub(super) render_join: Option<RenderJoin<W>>,
// F2: command buffers recycled back from the render thread, plus a small free-list the send path
// refills instead of allocating a fresh Vec each frame.
pub(super) render_ret_rx: Option<std::sync::mpsc::Receiver<Vec<renderer_core::DrawCommand>>>,
pub(super) command_buf_pool: Vec<Vec<renderer_core::DrawCommand>>,
pub(super) hw_renderer: Option<HardwareRenderer<W>>,
#[cfg(all(feature = "dev", not(target_os = "android")))]
pub(super) hot_reload_rx: Option<std::sync::mpsc::Receiver<crate::hot::HotEvent>>,
}
/// A join handle for the render thread, kept typed per backend rather than boxed: joining hardware has to
/// hand back a concrete `HardwareRenderer` for [`AppHandler::hw_renderer`] to rebind on the next resume, and
/// a `Box<dyn RenderBackend>` could not.
pub(super) enum RenderJoin<W: Window + Clone + Send + Sync + 'static> {
Hardware(std::thread::JoinHandle<HardwareRenderer<W>>),
Software(std::thread::JoinHandle<SoftwareRenderer<W, W>>),
}
impl<W: Window + Clone + Send + Sync + 'static> RenderJoin<W> {
/// Waits for the thread to finish, yielding the hardware renderer when there is one to keep warm.
/// Software has no device or pipeline cache worth carrying across a suspend, so it is simply dropped.
fn join(self) -> Option<HardwareRenderer<W>> {
match self {
RenderJoin::Hardware(join) => match join.join() {
Ok(hw) => Some(hw),
Err(_) => {
tracing::warn!("render thread panicked on suspend, renderer lost");
None
}
},
RenderJoin::Software(join) => {
let _ = join.join();
None
}
}
}
}
// Assembles an `AppHandler` from its ready inputs — the one place the large field literal lives, shared by the
// single-surface `run_with_platform` and the per-surface handler factory in `run_multi_with_platform`. Builds
// no renderer and touches no thread-local state, so it is safe to call on whatever thread will later drive the
// handler (e.g. a per-surface worker thread).
#[allow(clippy::too_many_arguments)]
pub(super) fn build_app_handler<W, D>(
app: Box<dyn App>,
paths: Box<dyn AppPathsProvider>,
font_paths: Vec<std::path::PathBuf>,
font_data: Vec<Vec<u8>>,
backend: crate::config::RendererBackend,
prefs: UserPrefs,
app_name: String,
) -> AppHandler<W, D>
where
W: Window + Clone + Send + Sync + 'static,
D: DevPlugin,
{
AppHandler::<W, D> {
app,
surface: None,
window_commands: platform_core::WindowCommandContext::new(),
tree: None,
renderer: None,
renderer_is_hardware: false,
renderer_transparent: false,
generation_base: 0,
last_generation: 0,
continuous_frames: 0,
backend,
prefs,
pending_restart: false,
pending_renderer: None,
_flush_notify: None,
scale_factor: 1.0,
exit_requested: false,
redraw_waker: None,
scale_scratch: renderer_core::ScaleScratch::new(),
window_signals: None,
app_name,
last_frame: std::time::Instant::now(),
// Backdated so the first `on_redraw` after resume composes immediately instead of waiting out a frame it has nothing to pace against yet.
last_tick: std::time::Instant::now()
.checked_sub(FRAME_BUDGET)
.unwrap_or_else(std::time::Instant::now),
last_submit: std::time::Instant::now()
.checked_sub(HW_KEEPALIVE_INTERVAL)
.unwrap_or_else(std::time::Instant::now),
dev: D::default(),
paths,
font_paths,
font_data,
_window: std::marker::PhantomData,
render_tx: None,
render_ret_rx: None,
command_buf_pool: Vec::new(),
render_join: None,
hw_renderer: None,
#[cfg(all(feature = "dev", not(target_os = "android")))]
hot_reload_rx: None,
}
}
// Holds a surface's reactive/layout world and its OS command queue active together for one lifecycle call.
// Dropped fields restore the previous surface and queue; the two restores are independent, so drop order is
// irrelevant.
struct LifecycleGuard {
_surface: ui_core::SurfaceGuard,
_commands: platform_core::WindowCommandGuard,
}
impl<W, D> AppHandler<W, D>
where
W: Window + Clone + Send + Sync + 'static,
D: DevPlugin,
{
/// Enters this handler's surface world for the duration of a lifecycle call, so its build/event/frame
/// resolve layout/overlay/focus (and the reactive current-surface) against the right surface. Returns
/// `None` for a single-window app — its ambient world is its one surface — making this a zero-cost no-op.
/// The returned guard owns the restore state and does not borrow `self`, so callers can mutate `self`
/// while it is held (`let _surface = self.enter_surface();`).
// Drains and applies the window-management commands a handler enqueued — from a title-bar control during
// event dispatch, or from `on_frame` (e.g. raising this window on a routed handoff). Returns whether any
// applied. Routed through the App bridge so the dylib-backed `HotApp` drains the dylib's own queue.
fn apply_window_commands(&mut self, window: &W) -> bool {
let mut applied = false;
for cmd in self.app.drain_window_commands() {
applied = true;
match cmd {
WindowCommand::Drag => window.drag_window(),
WindowCommand::Minimize => window.set_minimized(true),
WindowCommand::ToggleMaximize => window.set_maximized(!window.is_maximized()),
WindowCommand::SetMaximized(v) => window.set_maximized(v),
WindowCommand::SetTitle(title) => window.set_title(&title),
WindowCommand::Focus => window.focus_window(),
WindowCommand::Close => self.exit_requested = true,
}
}
applied
}
fn enter_surface(&self) -> Option<LifecycleGuard> {
self.surface.as_ref().map(|s| LifecycleGuard {
_surface: s.enter(),
_commands: self.window_commands.enter(),
})
}
/// The generation this frame's commands go out under: the tree's own, offset past every tree this surface
/// has had before it. See [`generation_base`](Self::generation_base) for why the offset has to exist.
fn frame_generation(&mut self) -> u64 {
let composed = self.tree.as_ref().map(|t| t.generation()).unwrap_or(0);
// A continuous region breaks the invariant this number stands for: its commands are identical every frame while the picture they point at is not, so equal generations would stop meaning an equal frame and the renderer would re-present the texture it retained. Stepping it per frame while one is alive restores what the invariant is actually for — skip work only when the output cannot have changed. Monotonic, so `generation_base` still steps past every tree this surface has had.
if self.app.motion_has_continuous() {
self.continuous_frames = self.continuous_frames.saturating_add(1);
}
let generation = self
.generation_base
.saturating_add(composed)
.saturating_add(self.continuous_frames);
self.last_generation = self.last_generation.max(generation);
generation
}
/// Whether the app asked for a transparent surface (`WindowConfig::is_transparent`). Read at each renderer creation so hardware picks a premultiplied-alpha composite mode and software presents an alpha-preserving buffer.
fn is_transparent(&self) -> bool {
self.app
.window_config()
.map(|c| c.is_transparent)
.unwrap_or(false)
}
// Builds the configured on-screen renderer (software, or hardware with an auto→software fallback) and wires
// up the render thread for the hardware path. Returns false if renderer creation failed. Split out of
// on_resume so the offscreen/headless path (which needs no surface) can bypass it entirely.
fn init_windowed_renderer(&mut self, window: &W, system_fonts: &SystemFonts) -> bool {
let android = cfg!(target_os = "android");
let cache_path = hardware_cache_path(&self.app_name, self.paths.as_ref());
match self.backend {
RendererBackend::Software => {
if let Err(e) = self.start_software_render_thread(window, system_fonts) {
tracing::error!("SW renderer failed: {e}");
return false;
}
}
RendererBackend::Hardware | RendererBackend::Auto => {
let font_config = build_hardware_font_config(
self.font_paths.clone(),
self.font_data.clone(),
system_fonts,
);
// Reuse the renderer saved on suspend (keeps device/pipelines/caches warm); only the surface is rebound. Otherwise build a fresh one.
let hw_result = if let Some(mut existing) = self.hw_renderer.take() {
existing
.rebind_surface(std::sync::Arc::new(window.clone()))
.map(|()| existing)
} else {
HardwareRenderer::new(
window.clone(),
cache_path.as_deref(),
android,
font_config,
HardwareRendererConfig {
transparent: self.is_transparent(),
..HardwareRendererConfig::default()
},
)
};
match hw_result {
Ok(hw) => self.start_hardware_render_thread(hw),
Err(e) if matches!(self.backend, RendererBackend::Auto) => {
tracing::warn!("HW renderer unavailable ({e}), falling back to SW");
if let Err(e2) = self.start_software_render_thread(window, system_fonts) {
tracing::error!("SW fallback also failed: {e2}");
return false;
}
}
Err(e) => {
tracing::error!("HW renderer failed: {e}");
return false;
}
}
}
}
true
}
/// Puts a freshly built hardware renderer on its own thread and wires the frame channels to it.
fn start_hardware_render_thread(&mut self, renderer: HardwareRenderer<W>) {
let (tx, ret_rx, join) = spawn_render_thread(renderer);
self.render_tx = Some(tx);
self.render_ret_rx = Some(ret_rx);
self.render_join = Some(RenderJoin::Hardware(join));
self.renderer_is_hardware = true;
}
/// Builds the software rasteriser and puts it on its own thread, exactly as hardware gets. The surface is
/// created *here*, on the UI thread, because macOS/iOS refuse to hand out Core Graphics handles anywhere
/// else; only the built renderer moves.
fn start_software_render_thread(
&mut self,
window: &W,
system_fonts: &SystemFonts,
) -> Result<(), RendererError> {
let budget = build_software_renderer_config(
self.font_paths.clone(),
self.font_data.clone(),
system_fonts,
self.is_transparent(),
);
let renderer = SoftwareRenderer::new(window.clone(), window.clone(), budget)?;
let (tx, ret_rx, join) = spawn_render_thread(renderer);
self.render_tx = Some(tx);
self.render_ret_rx = Some(ret_rx);
self.render_join = Some(RenderJoin::Software(join));
self.renderer_is_hardware = false;
Ok(())
}
/// Tears the render thread down and waits for it, so the next one starts against a surface this one is
/// provably no longer touching. Returns the hardware renderer when there was one worth keeping warm.
fn stop_render_thread(&mut self) -> Option<HardwareRenderer<W>> {
drop(self.render_tx.take());
self.render_ret_rx = None;
self.render_join.take().and_then(RenderJoin::join)
}
/// Rasterises a frame inline, for the offscreen renderer that has no thread of its own.
///
/// Headless runs, `[preview]` captures and `cargo telar test` all read the pixels back with
/// `last_frame_rgba` in the same call that asked for them, so this one has to stay synchronous — and it
/// deliberately does *not* wrap the render in `catch_unwind`, because a panic here is a test failure to
/// surface rather than a dropped frame to recover from.
fn render_offscreen(&mut self, msg: FrameMsg) {
let Some(renderer) = &mut self.renderer else {
return;
};
if let Err(e) =
renderer.begin_frame(msg.width, msg.height, msg.scale_factor, msg.generation)
{
tracing::error!("begin_frame failed: {e}");
return;
}
let gpu_start = renderer_core::perf::now_if_enabled();
let commands: &[renderer_core::DrawCommand] =
if renderer.applies_scale_factor() || msg.scale_factor == 1.0 {
&msg.commands
} else {
self.scale_scratch
.scale_into(&msg.commands, msg.scale_factor)
};
if let Err(e) = renderer.as_mut().render_frame(commands, msg.clear) {
tracing::error!("render_frame failed: {e}");
}
renderer_core::perf::record_since(renderer_core::perf::Phase::Gpu, gpu_start);
if self.command_buf_pool.len() < COMMAND_BUF_POOL_CAP {
self.command_buf_pool.push(msg.commands);
}
}
}
impl<W, D> EventHandler<W> for AppHandler<W, D>
where
W: Window + Clone + Send + Sync + 'static,
D: DevPlugin,
{
fn on_resume(&mut self, window: &W) -> bool {
let _surface = self.enter_surface();
let system_fonts = SystemFonts::from_provider(self.paths.as_ref());
// Point the layout-time text measurer at the same fonts as the renderer, on this (the layout) thread, before any layout runs. Otherwise it falls back to system defaults and aborts on Android ("no default font found").
renderer_text::set_measure_font_config(build_font_config(
self.font_paths.clone(),
self.font_data.clone(),
&system_fonts,
));
// Offscreen/headless windows have no surface, so a windowed renderer can't create one: rasterize into a
// CPU pixmap (read back via `last_frame_rgba`), forced regardless of the configured backend so the
// headless path needs no GPU adapter. On-screen windows build the configured renderer.
let renderer_ok = if window.is_offscreen() {
let budget = build_software_renderer_config(
self.font_paths.clone(),
self.font_data.clone(),
&system_fonts,
self.is_transparent(),
);
self.renderer = Some(Box::new(SoftwareRenderer::<W, W>::new_headless(
window.width(),
window.height(),
budget,
)));
true
} else {
self.init_windowed_renderer(window, &system_fonts)
};
if !renderer_ok {
return false;
}
self.renderer_transparent = self.is_transparent();
let sf = window.scale_factor() as f32;
self.scale_factor = sf;
self.window_signals = Some(WindowSignals::new(
window.width() as f32 / sf,
window.height() as f32 / sf,
));
// Prefer the process-global loop wake (installed by the platform): it wakes the loop — redrawing every
// surface — without holding any window, so an app can cache this waker or hand it to a worker thread
// and, if its content is later moved to another window, the original still closes and wakeups still
// reach it. Fall back to a window-cloning wake on backends that install no loop waker.
self.redraw_waker = Some(match platform_core::loop_waker() {
Some(wake) => crate::app_context::RedrawWaker::new(move || wake()),
None => {
let window = window.clone();
crate::app_context::RedrawWaker::new(move || window.request_redraw())
}
});
// Hand the same wake to the app's reactive runtime so `spawn_task` needs no waker ceremony from the
// app. Under the per-window fallback above this points at whichever surface resumed last, which is
// enough: any redraw runs a frame, and every surface's frame drains the whole task queue.
if let Some(waker) = self.redraw_waker.clone() {
self.app.install_task_waker(waker);
}
self.tree = Some(self.app.mount());
#[cfg(all(feature = "dev", not(target_os = "android")))]
if let Some(rx) = self.hot_reload_rx.take() {
let (relay_tx, relay_rx) = std::sync::mpsc::channel::<crate::hot::HotEvent>();
let window_clone = window.clone();
std::thread::Builder::new()
.name("telar-hot-relay".to_string())
.spawn(move || {
while let Ok(event) = rx.recv() {
if relay_tx.send(event).is_err() {
break;
}
window_clone.request_redraw();
}
})
.ok();
self.hot_reload_rx = Some(relay_rx);
}
// Synthesize an initial WindowResized so apps that initialize layout from that event start with the correct logical dimensions instead of their hardcoded defaults.
let initial_resize = platform_core::Event::WindowResized {
width: (window.width() as f32 / sf) as u32,
height: (window.height() as f32 / sf) as u32,
};
if let Some(ref mut tree) = self.tree {
tree.on_event(&initial_resize);
}
let w = window.clone();
self._flush_notify = Some(set_flush_notify(move || w.request_redraw()));
window.request_redraw();
true
}
/// Builds the app's UI again on the surface it is already running on, dropping the previous tree first so
/// its effects and their subscriptions go with it.
///
/// The window, the renderer and the surface's place on screen are untouched — this is a re-render, not a
/// restart. The one exception is transparency, which a renderer is *built* with: an app that now asks for
/// the other kind gets its renderer rebuilt on the next frame, which is the same path a backend switch
/// takes.
fn remount(&mut self, window: &W) {
let _surface = self.enter_surface();
// Dropped before the new one is built: an effect from the outgoing tree that re-runs while its
// replacement is being assembled would write into widgets nothing is drawing any more.
self.tree = None;
self.tree = Some(self.app.mount());
// Past every generation this surface has already been drawn at, so the renderer cannot mistake the new
// tree's fresh counter for content it is already showing. See `generation_base`.
self.generation_base = self.last_generation + 1;
if self.is_transparent() != self.renderer_transparent {
self.pending_restart = true;
}
// The same synthetic resize a fresh mount gets: a tree starts at its 0×0 defaults and learns the
// surface's real size from this event, exactly as it would from a monitor's own resize.
let resize = Event::WindowResized {
width: (window.width() as f32 / self.scale_factor) as u32,
height: (window.height() as f32 / self.scale_factor) as u32,
};
if let Some(ref mut tree) = self.tree {
tree.on_event(&resize);
}
window.request_redraw();
}
fn on_event(&mut self, event: Event, window: &W) {
let _surface = self.enter_surface();
// Before dispatch, so a handler running on this very event already sees the state it establishes: a `Shift`-click's press handler has to read the modifiers the click arrived under.
ui_core::observe_keyboard(&event);
if let Event::ScaleFactorChanged { scale_factor } = &event {
self.scale_factor = *scale_factor as f32;
}
if let Event::WindowResized { width, height } = &event {
if let Some(ref signals) = self.window_signals {
signals.update(*width as f32, *height as f32);
}
}
if let Event::ColorSchemeChanged { dark } = &event {
// Drives the follow_system effect (which writes the theme signal); batch the app's runtime so the
// re-render flushes cleanly across the hot-reload boundary. No widget consumes this event.
self.app.begin_event_batch();
self.app.set_system_dark(*dark);
self.app.end_event_batch();
window.request_redraw();
return;
}
if let Event::KeyPressed { key, modifiers } = &event {
match self.dev.on_key(key, *modifiers) {
DevAction::Redraw => {
window.request_redraw();
}
DevAction::ToggleBackend => {
let next = match self.prefs.backend.unwrap_or(RendererBackend::Auto) {
RendererBackend::Hardware => RendererBackend::Software,
_ => RendererBackend::Hardware,
};
self.prefs.backend = Some(next);
if let Err(e) = self.prefs.save(&self.app_name, self.paths.as_ref()) {
tracing::warn!("Could not save preferences: {e}");
}
match next {
RendererBackend::Software => {
self.pending_restart = true;
}
_ => {
let window_clone = window.clone();
let cache_path =
hardware_cache_path(&self.app_name, self.paths.as_ref());
let font_paths = self.font_paths.clone();
let font_data = self.font_data.clone();
let android = cfg!(target_os = "android");
let system_fonts = SystemFonts::from_provider(self.paths.as_ref());
// Computed before the closure: `self` is not `Send`, so its transparency must be captured by value, not read across the spawn.
let transparent = self.is_transparent();
let handle = std::thread::spawn(move || {
let font_config = build_hardware_font_config(
font_paths,
font_data,
&system_fonts,
);
HardwareRenderer::new(
window_clone,
cache_path.as_deref(),
android,
font_config,
HardwareRendererConfig {
transparent,
..HardwareRendererConfig::default()
},
)
});
self.pending_renderer = Some(handle);
}
}
}
DevAction::None => {}
}
}
if let Event::PointerPressed { x, y, .. } = &event {
if self.dev.on_pointer_pressed(*x as f32, *y as f32) {
window.request_redraw();
return;
}
}
// Batch the app's OWN reactive runtime across dispatch. In hot-reload the app dylib links its own
// reactive-core copy (separate runtime), which the host's begin/end_batch cannot reach; a handler's
// signal write would then flush immediately and re-run a segment's effect while its widget is still
// borrowed for on_event, silently dropping that segment's subscriptions. Closing the batch after
// dispatch (every borrow released) makes the deferred effects flush safely. No-op for a normal app.
self.app.begin_event_batch();
// Overlays (modals/dropdowns) paint on top, so a positioned pointer event over one must reach it
// FIRST and be blocked from the content behind. The overlay registry lives on the app's side of the
// hot-reload boundary (where `overlay` widgets register), so consult it via the App bridge before
// the tree walk; when an overlay consumes the event, skip the walk entirely (this is the block).
let handled = if self.app.dispatch_overlays(&event) {
EventResult::Handled
} else {
self.tree
.as_mut()
.map(|tree| tree.on_event(&event))
.unwrap_or(EventResult::Ignored)
};
self.app.end_event_batch();
// Apply any window-management commands a handler enqueued this dispatch (custom title-bar controls).
// Drag must run inside the pointer-press dispatch it originated from, so this sits right after the walk.
let window_command_applied = self.apply_window_commands(window);
if handled == EventResult::Handled || window_command_applied {
#[cfg(feature = "dev")]
if let Some(tree) = &self.tree {
tree.bump_force_ticks();
}
// Flush reactive effects immediately so on_redraw() in the same cycle finds tree_dirty=true rather than deferring to the next cycle.
end_batch();
begin_batch();
window.request_redraw();
}
}
fn on_redraw(&mut self, window: &W) {
let _surface = self.enter_surface();
#[cfg(all(feature = "dev", not(target_os = "android")))]
if let Some(rx) = &self.hot_reload_rx {
if let Ok(event) = rx.try_recv() {
match event {
crate::hot::HotEvent::Reload(new_path) => {
match crate::hot::load_hot_app(&new_path) {
Ok(new_app) => {
// Carry serializable hot state into the incoming dylib while the old tree (and its signals) is still alive; hot_signal consumes it as components remount.
if let Some(blob) = self.app.hot_snapshot() {
new_app.hot_restore(&blob);
}
// Drop the old tree first so effect closures (which contain code from the old dylib) are destroyed while the old lib is still mapped. Only then replace self.app, which dlcloses the old dylib.
self.tree = None;
self.app = Box::new(new_app);
self.tree = Some(self.app.mount());
// A successful reload clears any banner from the previous failed build.
self.dev.set_build_error(None);
// Synthesize WindowResized so the new tree's layout starts with the correct logical dimensions instead of its 0×0 defaults.
let resize = platform_core::Event::WindowResized {
width: (window.width() as f32 / self.scale_factor) as u32,
height: (window.height() as f32 / self.scale_factor) as u32,
};
if let Some(ref mut tree) = self.tree {
tree.on_event(&resize);
tree.bump_force_ticks();
}
tracing::info!("hot reloaded: {}", new_path.display());
window.request_redraw();
return;
}
Err(e) => tracing::error!("hot reload failed: {e}"),
}
}
crate::hot::HotEvent::BuildError(msg) => {
self.dev.set_build_error(Some(msg));
window.request_redraw();
}
}
}
}
// Drive the motion engine before tree_dirty is read below: tick()'s .set() calls only enqueue effects while a batch is open (new_events already opened one), so force a flush here to re-run any segment reading an animated value now, not on the next cycle. This is what makes an animation-only frame (no user event, tree otherwise clean) observe interpolated values in this same frame's tree.commands(). The tree is mounted in the app's own runtime (`crate::tree`), so those values reach the segments that read them even under hot reload — a host-mounted tree would instead re-send the commands composed for the animation's first value (a page stuck at opacity 0) until some event forced a re-render.
// Everything below composes a frame, so pace the whole pass rather than only capping the render at the end. A platform may call `on_redraw` on every loop turn, and the pass then schedules its own next call — the motion tick's `.set()` writes flush, and the flush notifies the platform to redraw — so the loop free-runs instead of sleeping.
let now = std::time::Instant::now();
if now.duration_since(self.last_tick) < FRAME_BUDGET {
return;
}
self.last_tick = now;
// Deliver finished background work before the tick: the batch open here defers its flush to the
// `end_batch` below, so a task that dirties layout is picked up by the `relayout` that follows
// instead of waiting a frame.
self.app.drain_tasks();
self.app.motion_tick(now);
end_batch();
// Runtime-driven relayout: a reactive change (e.g. a reactive list adding/removing items) mutated
// the layout tree during the flush above but the app shell only recomputes layout on resize/route
// changes. Re-lay out any dirtied root here — outside a batch, so the rect updates it produces flush
// their segment effects before tree_dirty is read below and the frame is composed. Routed through
// the app so the dylib-backed `HotApp` relayouts the dylib's runtime (where the tree lives), not the
// host's empty one.
self.app.relayout();
begin_batch();
let mut redraw_requested = false;
{
let mut ctx = crate::app_context::AppCtx {
app_name: &self.app_name,
prefs: &mut self.prefs,
paths: self.paths.as_ref(),
pending_restart: &mut self.pending_restart,
redraw_requested: &mut redraw_requested,
window_signals: self.window_signals.as_ref(),
redraw_waker: self.redraw_waker.as_ref(),
raw_window_handle: raw_window_handle::HasWindowHandle::window_handle(window)
.ok()
.map(|h| h.as_raw()),
raw_display_handle: raw_window_handle::HasDisplayHandle::display_handle(window)
.ok()
.map(|h| h.as_raw()),
};
self.app.on_frame(&mut ctx);
}
// After `on_frame`, which is where an app reads them: a press has to answer true for the whole frame it arrived in, and stop answering in the next.
ui_core::end_keyboard_frame();
// Apply commands enqueued during on_frame (e.g. raising this window on a routed handoff): on_event's
// drain only runs on input events, so a frame-driven command would otherwise wait for the next one.
self.apply_window_commands(window);
if redraw_requested {
window.request_redraw();
}
if let Some(handle) = self.pending_renderer.take() {
if handle.is_finished() {
match handle.join().unwrap_or_else(|_| {
Err(RendererError::Backend(
"renderer thread panicked".to_string(),
))
}) {
Ok(new_renderer) => {
drop(self.renderer.take());
// Retire the old render thread — whichever backend it was driving — before the new
// one takes the surface.
self.stop_render_thread();
#[cfg(target_os = "linux")]
unsafe {
libc::malloc_trim(0);
}
self.start_hardware_render_thread(new_renderer);
}
Err(e) => tracing::error!("Background HW renderer creation failed: {e}"),
}
window.request_redraw();
} else {
self.pending_renderer = Some(handle);
}
}
if self.pending_restart {
self.pending_restart = false;
self.renderer_transparent = self.is_transparent();
self.backend = self
.prefs
.backend
.unwrap_or_else(config::compile_time_backend);
let cache_path = hardware_cache_path(&self.app_name, self.paths.as_ref());
let android = cfg!(target_os = "android");
let system_fonts = SystemFonts::from_provider(self.paths.as_ref());
// Drop old renderer and render thread before creating new one to avoid peak memory overlap.
drop(self.renderer.take());
self.stop_render_thread();
#[cfg(target_os = "linux")]
unsafe {
libc::malloc_trim(0);
}
match self.backend {
RendererBackend::Software => {
if let Err(e) = self.start_software_render_thread(window, &system_fonts) {
tracing::error!("Failed to switch to SW renderer: {e}");
}
}
RendererBackend::Hardware | RendererBackend::Auto => {
let font_config = build_hardware_font_config(
self.font_paths.clone(),
self.font_data.clone(),
&system_fonts,
);
match HardwareRenderer::new(
window.clone(),
cache_path.as_deref(),
android,
font_config,
HardwareRendererConfig {
transparent: self.is_transparent(),
..HardwareRendererConfig::default()
},
) {
Ok(hw) => self.start_hardware_render_thread(hw),
Err(e) => tracing::error!("Failed to switch to HW renderer: {e}"),
}
}
}
}
// A continuous region carries new content the tree cannot report: the application repaints its own texture and the draw commands naming it never change. Counting only `is_dirty` here drops such a frame through to the 1 fps keepalive below, which shows a region refilled at sixty once a second.
let has_content = self.tree.as_ref().map(|t| t.is_dirty()).unwrap_or(false)
|| self.app.motion_has_continuous();
// Keepalive is a GPU power policy, not a property of running on a render thread: hardware keeps
// taking frames while idle so an idle blit holds the device in an active state (see `about_to_wait`),
// whereas re-rasterising an unchanged frame on the CPU buys nothing. Both backends now have a render
// thread, so this must key off the backend, not off `render_tx`.
let needs_keepalive = self.renderer_is_hardware || self.dev.keepalive_interval().is_some();
if !has_content && !needs_keepalive {
return;
}
// A keepalive blit carries no new content, so it runs at the keepalive cadence rather than the frame rate. Enforced here instead of left to `about_to_wait`'s reported interval because a submitted frame is itself a wakeup: its commit makes the compositor fd readable, returning the next dispatch immediately to compose another one.
let keepalive_interval = self
.dev
.keepalive_interval()
.unwrap_or(HW_KEEPALIVE_INTERVAL);
if !has_content && now.duration_since(self.last_submit) < keepalive_interval {
return;
}
self.last_submit = now;
// Only update last_frame for content frames; keepalive blits must not reset the budget clock (would delay next content render by up to 16ms).
if has_content {
self.last_frame = now;
}
let (w, h) = (window.width(), window.height());
let generation = self.frame_generation();
tracing::debug!(
"on_redraw: window {}x{} scale={} has_content={}",
w,
h,
self.scale_factor,
has_content
);
// Flush reactive effects so clear_color and draw commands come from the same reactive pass. Without
// this, a RedrawRequested that fires before about_to_wait (e.g. a keepalive blit) reads clear_color
// from the new signal value while commands still reflect the previous view() call.
end_batch();
begin_batch();
renderer_core::perf::tick();
// F2: reclaim buffers the render thread finished with, capped so the free-list stays tiny.
if let Some(rx) = &self.render_ret_rx {
while let Ok(buf) = rx.try_recv() {
if self.command_buf_pool.len() < COMMAND_BUF_POOL_CAP {
self.command_buf_pool.push(buf);
}
}
}
let build_start = renderer_core::perf::now_if_enabled();
let clear = self.app.clear_color();
let commands_ref = self.tree.as_ref().map(|t| t.frame());
let base_slice: &[renderer_core::DrawCommand] = commands_ref.as_deref().unwrap_or(&[]);
if let Some(tree) = &self.tree {
self.dev.on_tree(&crate::tree::TreeView(tree.as_ref()));
}
let logical_w = w as f32 / self.scale_factor;
let logical_h = h as f32 / self.scale_factor;
let frame_commands = self
.dev
.on_frame(base_slice, logical_w, logical_h, has_content);
renderer_core::perf::record_since(renderer_core::perf::Phase::Build, build_start);
let clone_start = renderer_core::perf::now_if_enabled();
// F2: refill a recycled buffer instead of allocating a fresh Vec every frame.
let mut commands = self.command_buf_pool.pop().unwrap_or_default();
commands.clear();
commands.extend_from_slice(&frame_commands);
renderer_core::perf::record_since(renderer_core::perf::Phase::Clone, clone_start);
// The message owns its commands from here on; release the borrows of the tree and the dev plugin so
// the offscreen branch below can take `&mut self`.
drop(frame_commands);
drop(commands_ref);
let msg = FrameMsg {
width: w,
height: h,
scale_factor: self.scale_factor,
generation,
commands,
clear,
timestamp: std::time::Instant::now(),
};
// On-screen: hand the frame to the render thread and return. Drop it if that thread is still busy,
// which is what keeps input handling off the rasteriser's critical path. On a dropped or disconnected
// send, recover the buffer for the free-list instead of freeing it.
if let Some(tx) = &self.render_tx {
if let Err(e) = tx.try_send(msg) {
let recovered = match e {
std::sync::mpsc::TrySendError::Full(m)
| std::sync::mpsc::TrySendError::Disconnected(m) => m.commands,
};
if self.command_buf_pool.len() < COMMAND_BUF_POOL_CAP {
self.command_buf_pool.push(recovered);
}
}
return;
}
self.render_offscreen(msg);
}
fn on_suspend(&mut self) {
let _surface = self.enter_surface();
// Reclaim the renderer so the next resume can rebind the surface instead of rebuilding
// device/pipelines/caches. Only hardware has anything worth carrying over.
self.hw_renderer = self.stop_render_thread();
}
fn new_events(&mut self) {
begin_batch();
}
fn take_exit_request(&mut self) -> bool {
std::mem::take(&mut self.exit_requested)
}
fn about_to_wait(&mut self) -> Option<std::time::Duration> {
end_batch();
let tree_dirty = self.tree.as_ref().map(|t| t.is_dirty()).unwrap_or(false);
// An unsettled animation must keep the loop scheduling frames even while the tree itself is momentarily clean (e.g. the tick that only established t0); once it settles, has_active() drops out and this falls through to the existing idle/keepalive branch below.
if tree_dirty || self.app.motion_has_active() || self.app.motion_has_continuous() {
// Against `last_tick`, the clock `on_redraw` actually gates on: reporting a deadline the pass would decline to act on wakes the loop early and it spins re-asking. The two disagree whenever a tick leaves the tree clean, advancing `last_tick` but not `last_frame`.
Some(FRAME_BUDGET.saturating_sub(self.last_tick.elapsed()))
} else {
let dev_keepalive = self.dev.keepalive_interval();
if let Some(interval) = dev_keepalive {
// Dev plugin drives its own cadence (e.g. FPS counter tick-down).
Some(interval)
} else if self.renderer_is_hardware && self.last_frame.elapsed() < HW_KEEPALIVE_GRACE {
// F4: hold the GPU in an active power state at 1fps for a short grace window after the
// last content frame (covers interactive bursts), then let it sleep — real input/redraw
// events still wake the loop. `last_frame` isn't reset by keepalive blits, so its
// elapsed measures true inactivity. Saves ~1 idle GPU wake/sec on battery.
Some(HW_KEEPALIVE_INTERVAL)
} else {
None
}
}
}
// Hands back the offscreen renderer's last frame so a headless platform can read pixels. Only the
// windowless software renderer holds a readable pixmap; the HW/windowed paths present and return None.
fn last_frame_rgba(&self) -> Option<Vec<u8>> {
self.renderer.as_ref().and_then(|r| r.read_rgba())
}
// Entering the surface is the whole point: the registry is one of its per-surface worlds, so a caller outside this handler reads the ambient (always empty) one instead.
fn interactive_rects(&self) -> Vec<geometry_core::Rect> {
let _surface = self.enter_surface();
ui_core::interactive_rects()
}
}
#[cfg(test)]
mod tests {
use super::*;
use platform_headless::HeadlessWindow;
struct NullPaths;
impl AppPathsProvider for NullPaths {
fn config_dir(&self) -> Option<std::path::PathBuf> {
None
}
fn data_dir(&self) -> Option<std::path::PathBuf> {
None
}
fn cache_dir(&self) -> Option<std::path::PathBuf> {
None
}
}
/// An app whose content never changes — a shell's frame ring, a wallpaper, a static diagram. Its tree's
/// own generation is fixed for the life of the tree, which is what makes the collision below reachable.
struct Unchanging;
impl App for Unchanging {
fn root(&self) -> Box<dyn ui_tree::Component> {
ui_core::reset_layout_runtime();
Box::new(
ui_core::Rectangle::new(
layout_core::LayoutStyle::new().width(10.0).height(10.0),
|| renderer_core::RectStyle::filled(renderer_core::Color::BLACK, 0.0),
)
.expect("a rectangle builds"),
)
}
}
fn handler() -> AppHandler<HeadlessWindow, ()> {
build_app_handler::<HeadlessWindow, ()>(
Box::new(Unchanging),
Box::new(NullPaths),
Vec::new(),
Vec::new(),
RendererBackend::Software,
UserPrefs::default(),
"generation-test".to_string(),
)
}
/// A rebuilt tree must never hand the renderer a generation it has already drawn — see
/// [`AppHandler::generation_base`] for why one otherwise would.
///
/// What it looked like: a shell's config reload moved the space its bars reserved but left the frame ring
/// and the wallpaper exactly as they were, until the process was restarted. The bars followed the edit,
/// because a ticking clock had already carried their counter past the collision.
#[test]
fn a_remounted_tree_never_reuses_a_generation_the_renderer_has_drawn() {
let mut handler = handler();
let window = HeadlessWindow::new(120, 80);
handler.tree = Some(handler.app.mount());
let first = handler.frame_generation();
// The collision, stated: the fresh tree's own counter is back where the outgoing one started.
handler.remount(&window);
let composed = handler.tree.as_ref().map(|t| t.generation()).unwrap_or(0);
let second = handler.frame_generation();
assert!(
second > first,
"a rebuilt tree reported generation {second} after {first} was already drawn, so the renderer \
would blit the frame from before the rebuild"
);
assert!(
composed <= first,
"this test proves nothing unless the new tree's own counter really is back in drawn territory: \
it reported {composed} against {first}"
);
// A tree that is *not* rebuilt keeps its generation, which is what lets the renderer skip idle frames.
assert_eq!(
handler.frame_generation(),
second,
"an unchanged tree must keep reporting the same generation, or every idle frame re-renders"
);
}
/// A region filled from outside must not be caught by the idle-frame fast path.
///
/// The trap is that everything looks right: the application renders into its texture at its own pace and
/// Telar schedules frames for it, but the draw commands pointing at that texture are identical every time
/// — the id addresses the view, not its contents, deliberately. Equal generations then tell the renderer
/// it may re-present what it retained, and the window shows one frozen frame while the application keeps
/// repainting behind it at full speed.
#[test]
fn a_continuous_region_moves_the_generation_though_its_commands_never_change() {
let mut handler = handler();
let window = HeadlessWindow::new(120, 80);
handler.tree = Some(handler.app.mount());
let at_rest = handler.frame_generation();
assert_eq!(
handler.frame_generation(),
at_rest,
"this app's commands are fixed, so without a region nothing should move"
);
let region = motion_core::Continuous::new();
let first = handler.frame_generation();
let second = handler.frame_generation();
assert!(
first > at_rest && second > first,
"the generation stalled at {at_rest}/{first}/{second}, so the renderer would blit a stale frame"
);
drop(region);
let after = handler.frame_generation();
assert_eq!(
handler.frame_generation(),
after,
"with the region gone the surface must go back to skipping idle frames"
);
let _ = window;
}
/// A continuous region has to survive **three** gates, and the third is the one that bites hardest.
///
/// `about_to_wait` must keep scheduling frames, `frame_generation` must keep moving so the renderer
/// cannot re-present what it retained — and this one: a frame whose tree is clean falls through to the
/// keepalive branch, which runs at **1 fps**. A region that cleared the first two and not this one is
/// composed once a second while the application refills it at sixty, which reads as a renderer that is
/// merely slow.
#[test]
fn a_clean_tree_with_a_continuous_region_is_still_worth_a_frame() {
let mut handler = handler();
let window = HeadlessWindow::new(120, 80);
assert!(handler.on_resume(&window), "a headless resume builds one");
// The platform opens a batch before dispatching, and `on_redraw` closes and reopens it; without one already open it closes a batch that was never begun.
begin_batch();
// Forced open before each pass: this is about what counts as content, not about the frame clock.
let opened = || std::time::Instant::now() - FRAME_BUDGET * 2;
handler.last_tick = opened();
handler.on_redraw(&window);
let first = handler.last_submit;
handler.last_tick = opened();
handler.on_redraw(&window);
assert_eq!(
handler.last_submit, first,
"a clean tree with nothing else to say must not submit a frame"
);
let _awake = motion_core::Continuous::new();
handler.last_tick = opened();
handler.on_redraw(&window);
assert!(
handler.last_submit > first,
"the region says the picture changed even though the tree cannot, so this frame had to go out"
);
}
/// The keyboard registry is wired into the runner, not just into `ui-core`.
///
/// Its own unit tests drive `observe` directly, so they would pass just as well with the runner never
/// calling it — and a modifier state nobody feeds is worse than none, because it answers confidently
/// with whatever it last saw.
#[test]
fn the_runner_feeds_the_keyboard_registry() {
ui_core::reset_keyboard();
let mut handler = handler();
let window = HeadlessWindow::new(120, 80);
assert!(!ui_core::modifiers().is_shift);
handler.on_event(
Event::ModifiersChanged {
modifiers: platform_core::ModifiersState {
is_shift: true,
..Default::default()
},
},
&window,
);
assert!(
ui_core::modifiers().is_shift,
"a bare Shift must reach the registry, since it maps to no Key at all"
);
handler.on_event(
Event::KeyPressed {
key: platform_core::Key::Named(platform_core::NamedKey::ArrowUp),
modifiers: Default::default(),
},
&window,
);
let up = platform_core::Key::Named(platform_core::NamedKey::ArrowUp);
assert!(ui_core::key_held(&up));
assert!(ui_core::key_pressed(&up));
}
}