use crate::css::{AppRuntimePseudos, set_app_active, set_app_runtime_pseudos, set_style_context};
use crate::debug::{debug_input, debug_render, debug_timing, timing_enabled};
use crate::event::{
Action, AnimationEase, AnimationRequest, AnimationValueEvent, BlurEvent, Event, EventCtx,
FocusEvent, MountEvent, MouseDownEvent, MouseScrollEvent, MouseUpEvent, ReadyEvent,
StyleAnimationRequest, StyleValue, UnmountEvent,
};
use crate::keys::KeyEventData;
use crate::message::{Message, MessageEvent};
use crate::worker::{WorkerRegistry, WorkerRequest, process_worker_requests};
use crossterm::event::{
self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind,
};
use rich_rs::Renderable;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use super::App;
use super::devtools::DevtoolsCommand;
use super::dispatch_ctx::set_dispatch_recipient;
use super::helpers::{
any_widget_active_tree, call_on_mouse_move_tree, collect_focus_chain_tree,
generate_enter_leave_events, mouse_scroll_deltas, pointer_shape_for_hover_tree,
should_quit_key, tree_content_local_coords, widget_at_tree_layout,
};
use super::render::apply_layout_info_tree_from_layout_rects;
use super::routing::{
active_binding_hints_tree, dispatch_event_broadcast_tree, dispatch_event_to_target_tree,
dispatch_event_tree, dispatch_message_queue_tree, dispatch_mouse_scroll,
dispatch_mouse_scroll_to_target_tree, dispatch_scroll_action_tree, focused_help_metadata_tree,
focused_node_id_tree, is_priority_action, is_scroll_action, match_binding_tree,
};
use super::types::{DispatchOutcome, PendingInvalidation, StylesheetReload};
use crate::node_id::{NodeId, node_id_to_ffi};
use crate::reactive::RuntimeReactiveEntry;
use crate::widgets::{CommandPalette, Widget};
thread_local! {
static WORKER_REQUEST_ACC: RefCell<Vec<WorkerRequest>> = const { RefCell::new(Vec::new()) };
}
fn drain_accumulated_worker_requests() -> Vec<WorkerRequest> {
WORKER_REQUEST_ACC.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
fn accumulate_worker_requests(outcome: &mut DispatchOutcome) {
let requests = std::mem::take(&mut outcome.worker_requests);
if !requests.is_empty() {
WORKER_REQUEST_ACC.with(|cell| cell.borrow_mut().extend(requests));
}
}
fn should_dispatch_binding_hints(
last_hints: &[crate::event::BindingHint],
last_sources: &[NodeId],
current_hints: &[crate::event::BindingHint],
current_sources: &[NodeId],
) -> bool {
last_hints != current_hints || last_sources != current_sources
}
fn should_dispatch_focused_help(
last_source: Option<NodeId>,
last_markup: Option<&str>,
current_source: Option<NodeId>,
current_markup: Option<&str>,
) -> bool {
last_source != current_source || last_markup != current_markup
}
fn focused_help_message(current: Option<(NodeId, String)>) -> MessageEvent {
if let Some((source, markup)) = current {
MessageEvent {
sender: source,
message: Message::HelpPanelFocusedHelpChanged(
crate::message::HelpPanelFocusedHelpChanged { source, markup },
),
control: Some(source),
}
} else {
let sender = App::runtime_message_sender();
MessageEvent {
sender,
message: Message::HelpPanelFocusedHelpCleared(
crate::message::HelpPanelFocusedHelpCleared,
),
control: Some(sender),
}
}
}
fn parse_simulated_key(spec: &str) -> Option<KeyEventData> {
let spec = spec.trim().to_ascii_lowercase();
if spec.is_empty() {
return None;
}
let (modifiers, key_token) = if let Some(chord) = spec.strip_prefix('^') {
if chord.chars().count() == 1 {
(KeyModifiers::CONTROL, chord.to_string())
} else {
return None;
}
} else {
let mut modifiers = KeyModifiers::NONE;
let mut key_token = None::<String>;
for token in spec
.split('+')
.map(str::trim)
.filter(|token| !token.is_empty())
{
match token {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"alt" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
"super" | "meta" => modifiers |= KeyModifiers::SUPER,
other => key_token = Some(other.to_string()),
}
}
(modifiers, key_token?)
};
let code = match key_token.as_str() {
"enter" | "return" => KeyCode::Enter,
"tab" => KeyCode::Tab,
"backspace" => KeyCode::Backspace,
"delete" => KeyCode::Delete,
"escape" | "esc" => KeyCode::Esc,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "page_up" => KeyCode::PageUp,
"pagedown" | "page_down" => KeyCode::PageDown,
"insert" => KeyCode::Insert,
"space" => KeyCode::Char(' '),
token if token.starts_with('f') && token.len() > 1 => {
let number = token[1..].parse::<u8>().ok()?;
KeyCode::F(number)
}
token if token.chars().count() == 1 => KeyCode::Char(token.chars().next().unwrap()),
_ => return None,
};
Some(KeyEventData::from_crossterm(KeyEvent::new(code, modifiers)))
}
fn input_event_kind(event: &CrosstermEvent) -> &'static str {
match event {
CrosstermEvent::Key(_) => "key",
CrosstermEvent::Mouse(_) => "mouse",
CrosstermEvent::Resize(_, _) => "resize",
CrosstermEvent::FocusLost => "focus_lost",
CrosstermEvent::FocusGained => "focus_gained",
CrosstermEvent::Paste(_) => "paste",
}
}
fn scrollbar_drag_trace_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("TEXTUAL_DEBUG_SCROLLBAR_DRAG_TRACE")
.ok()
.map(|value| {
let normalized = value.trim().to_ascii_lowercase();
!(normalized.is_empty()
|| normalized == "0"
|| normalized == "false"
|| normalized == "off"
|| normalized == "no")
})
.unwrap_or(false)
})
}
fn merge_outcome_into_runtime_pass(pass: &mut RuntimeMessagePass, outcome: &mut DispatchOutcome) {
pass.repaint_requested |= outcome.repaint_requested;
pass.invalidation.merge(outcome.invalidation);
pass.stop_requested |= outcome.stop_requested;
pass.animation_requests
.append(&mut outcome.animation_requests);
pass.worker_requests.append(&mut outcome.worker_requests);
pass.recompose_nodes.append(&mut outcome.recompose_nodes);
pass.generated.append(&mut outcome.messages);
}
fn execute_action_with_dispatch_target(
widget: &mut dyn Widget,
action: &crate::action::ParsedAction,
ctx: &mut EventCtx,
target: NodeId,
) -> bool {
let _dispatch_guard = set_dispatch_recipient(target);
widget.execute_action(action, ctx)
}
fn dispatch_simulated_key_like_input(
app: &mut App,
root: &mut dyn Widget,
key: KeyEventData,
pass: &mut RuntimeMessagePass,
) {
let mut app_key_ctx = EventCtx::default();
root.on_app_key(app, &key, &mut app_key_ctx);
pass.repaint_requested |= app_key_ctx.repaint_requested();
pass.invalidation.merge(app_key_ctx.invalidation());
pass.stop_requested |= app_key_ctx.stop_requested();
pass.animation_requests
.extend(app_key_ctx.take_animation_requests());
pass.worker_requests
.extend(app_key_ctx.take_worker_requests());
pass.recompose_nodes
.extend(app_key_ctx.take_recompose_nodes());
pass.generated.extend(app_key_ctx.take_messages());
if pass.stop_requested || app_key_ctx.handled() {
return;
}
let bind = crate::event::KeyBind::from_event(&key);
let mapped_action = app.action_map.lookup(&bind);
if let Some(action) = mapped_action.filter(|a| is_priority_action(*a)) {
let mut outcome = app.dispatch_event_auto(root, Event::Action(action));
let handled = outcome.handled;
merge_outcome_into_runtime_pass(pass, &mut outcome);
if handled {
return;
}
}
if let Some(tree) = app.active_widget_tree()
&& let Some((_binding_node_id, action_str)) = match_binding_tree(tree, &key)
&& let Some(parsed) = crate::action::parse_action(&action_str)
{
if let Some(tree_mut) = app.active_widget_tree_mut() {
let focused = focused_node_id_tree(tree_mut);
let resolved = {
let tree_ref = &*tree_mut;
focused.and_then(|fid| {
crate::action::resolve_action(&parsed, tree_ref, fid, |nid| {
tree_ref
.get(nid)
.map(|n| (n.widget.action_namespace(), n.widget.action_registry()))
})
})
};
if let Some(ra) = resolved
&& let Some(node) = tree_mut.get_mut(ra.node)
{
let mut ctx = EventCtx::default();
if execute_action_with_dispatch_target(
&mut *node.widget,
&parsed,
&mut ctx,
ra.node,
) || ctx.handled()
{
pass.repaint_requested |= ctx.repaint_requested();
pass.invalidation.merge(ctx.invalidation());
pass.stop_requested |= ctx.stop_requested();
pass.animation_requests
.extend(ctx.take_animation_requests());
pass.worker_requests.extend(ctx.take_worker_requests());
pass.recompose_nodes.extend(ctx.take_recompose_nodes());
pass.generated.extend(ctx.take_messages());
return;
}
}
}
let mut root_ctx = EventCtx::default();
if execute_action_with_dispatch_target(root, &parsed, &mut root_ctx, NodeId::default())
|| root_ctx.handled()
{
pass.repaint_requested |= root_ctx.repaint_requested();
pass.invalidation.merge(root_ctx.invalidation());
pass.stop_requested |= root_ctx.stop_requested();
pass.animation_requests
.extend(root_ctx.take_animation_requests());
pass.worker_requests.extend(root_ctx.take_worker_requests());
pass.recompose_nodes.extend(root_ctx.take_recompose_nodes());
pass.generated.extend(root_ctx.take_messages());
return;
}
}
let mut key_outcome = app.dispatch_event_auto(root, Event::Key(key.clone()));
let key_handled = key_outcome.handled;
merge_outcome_into_runtime_pass(pass, &mut key_outcome);
if key_handled {
return;
}
if let Some(action) = mapped_action.filter(|a| !is_priority_action(*a)) {
if action == Action::CopySelectedText {
if let Some(text) = app.action_copy_selected_text() {
let sender = App::runtime_message_sender();
pass.generated.push(MessageEvent {
sender,
message: Message::TextEditClipboardCopyRequested(
crate::message::TextEditClipboardCopyRequested { text, cut: false },
),
control: Some(sender),
});
} else {
app.notify_help_quit();
pass.repaint_requested = true;
}
return;
}
if action == Action::HelpQuit {
app.notify_help_quit();
pass.repaint_requested = true;
return;
}
if matches!(action, Action::FocusNext | Action::FocusPrev) {
let mut focus_outcome = app.dispatch_event_auto(root, Event::Action(action));
let focus_handled = focus_outcome.handled;
merge_outcome_into_runtime_pass(pass, &mut focus_outcome);
if focus_handled {
return;
}
if app.move_focus_auto(action) {
pass.repaint_requested = true;
return;
}
}
let mut outcome = if is_scroll_action(action) {
app.dispatch_scroll_action_auto(root, action, app.hovered)
} else {
app.dispatch_event_auto(root, Event::Action(action))
};
merge_outcome_into_runtime_pass(pass, &mut outcome);
}
}
fn worker_state_runtime_messages(
registry: &WorkerRegistry,
changes: Vec<crate::worker::WorkerStateChanged>,
) -> Vec<MessageEvent> {
changes
.into_iter()
.map(|change| {
let sender = registry
.owner(change.worker_id)
.unwrap_or_else(App::runtime_message_sender);
MessageEvent {
sender,
message: Message::WorkerStateChanged(crate::message::WorkerStateChanged {
worker_id: change.worker_id,
state: change.state,
}),
control: Some(sender),
}
})
.collect()
}
fn hit_probe_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("TEXTUAL_DEBUG_HIT_TEST_VERBOSE")
.ok()
.map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
.unwrap_or(false)
})
}
fn point_direction(prev: Option<(u16, u16)>, curr: (u16, u16)) -> &'static str {
let Some((px, py)) = prev else {
return "start";
};
let dx = curr.0 as i32 - px as i32;
let dy = curr.1 as i32 - py as i32;
match (dx.signum(), dy.signum()) {
(0, -1) => "up",
(0, 1) => "down",
(-1, 0) => "left",
(1, 0) => "right",
(1, -1) => "up-right",
(-1, -1) => "up-left",
(1, 1) => "down-right",
(-1, 1) => "down-left",
_ => "still",
}
}
fn fmt_rect(rect: Option<crate::runtime::types::Rect>) -> String {
match rect {
Some(r) => format!("[{},{}..{},{}]", r.x0, r.y0, r.x1, r.y1),
None => "-".to_string(),
}
}
fn coalesce_mouse_motion_events(
mut mouse: crossterm::event::MouseEvent,
pending_event: &mut Option<CrosstermEvent>,
) -> crate::Result<crossterm::event::MouseEvent> {
loop {
if !event::poll(Duration::ZERO)? {
break;
}
match event::read()? {
CrosstermEvent::Mouse(next)
if matches!(next.kind, MouseEventKind::Moved | MouseEventKind::Drag(_)) =>
{
mouse = next;
}
other => {
*pending_event = Some(other);
break;
}
}
}
Ok(mouse)
}
fn collect_clipboard_runtime_messages(
clipboard: &mut Option<String>,
messages: &[MessageEvent],
) -> Vec<MessageEvent> {
let mut system_clipboard = SystemClipboardBackend;
collect_clipboard_runtime_messages_with_backend(clipboard, messages, &mut system_clipboard)
}
trait ClipboardBackend {
fn copy(&mut self, text: &str) -> bool;
fn paste(&mut self) -> Option<String>;
}
struct SystemClipboardBackend;
impl ClipboardBackend for SystemClipboardBackend {
fn copy(&mut self, text: &str) -> bool {
copy_to_system_clipboard(text)
}
fn paste(&mut self) -> Option<String> {
paste_from_system_clipboard()
}
}
fn collect_clipboard_runtime_messages_with_backend(
clipboard: &mut Option<String>,
messages: &[MessageEvent],
backend: &mut impl ClipboardBackend,
) -> Vec<MessageEvent> {
let mut generated = Vec::new();
for event in messages {
match &event.message {
Message::TextEditClipboardCopyRequested(
crate::message::TextEditClipboardCopyRequested { text, .. },
) => {
*clipboard = Some(text.clone());
if !backend.copy(text) {
debug_input("[clipboard] system copy unavailable; runtime fallback updated");
}
}
Message::TextEditClipboardPasteRequested(
crate::message::TextEditClipboardPasteRequested { target },
) => {
let text = if let Some(system_text) = backend.paste() {
*clipboard = Some(system_text.clone());
Some(system_text)
} else {
if clipboard.is_some() {
debug_input("[clipboard] system paste unavailable; using runtime fallback");
} else {
debug_input(
"[clipboard] paste requested with no system data and empty fallback",
);
}
clipboard.clone()
};
if let Some(text) = text {
generated.push(App::clipboard_message_event(*target, text));
}
}
_ => {}
}
}
generated
}
#[derive(Default)]
struct RuntimeMessagePass {
deliver: Vec<MessageEvent>,
generated: Vec<MessageEvent>,
repaint_requested: bool,
invalidation: crate::event::InvalidationFlags,
animation_requests: Vec<AnimationRequest>,
worker_requests: Vec<WorkerRequest>,
recompose_nodes: Vec<NodeId>,
stop_requested: bool,
}
fn set_overlay_modal_display_tree(
tree: &mut crate::widget_tree::WidgetTree,
overlay: NodeId,
visible: bool,
) -> bool {
let modal_root = match tree.children(overlay).get(1).copied() {
Some(id) => id,
None => return false,
};
let node_ids = tree.walk_depth_first(modal_root);
let mut changed = false;
for node_id in node_ids {
let before = tree.is_displayed(node_id);
tree.set_runtime_display(node_id, visible);
if before != tree.is_displayed(node_id) {
changed = true;
}
}
changed
}
fn sync_widget_controlled_child_display_tree(
tree: &mut crate::widget_tree::WidgetTree,
root_widget: &dyn Widget,
) -> bool {
let Some(root) = tree.root() else {
return false;
};
let mut updates: Vec<(NodeId, bool)> = Vec::new();
for (idx, child_id) in tree.children(root).iter().copied().enumerate() {
if let Some(display) = root_widget.child_display_for_tree(idx) {
updates.push((child_id, display));
}
}
for parent_id in tree.walk_depth_first(root) {
let child_ids = tree.children(parent_id).to_vec();
if child_ids.is_empty() {
continue;
}
let Some(parent) = tree.get(parent_id) else {
continue;
};
for (idx, child_id) in child_ids.into_iter().enumerate() {
if let Some(display) = parent.widget.child_display_for_tree(idx) {
updates.push((child_id, display));
}
}
}
let mut changed = false;
for (node_id, display) in updates {
let before = tree.is_displayed(node_id);
tree.set_runtime_display(node_id, display);
if !display && let Some(node) = tree.get_mut(node_id) {
node.widget.set_focus(false);
}
if before != tree.is_displayed(node_id) {
changed = true;
}
}
changed
}
fn recompose_node_subtree(tree: &mut crate::widget_tree::WidgetTree, node_id: NodeId) {
let Some(node) = tree.get_mut(node_id) else {
return;
};
let extracted = node.widget.take_composed_children();
let declarations = node.widget.compose();
tree.remove_children(node_id);
for child in extracted {
App::mount_extracted_recursive(tree, node_id, child);
}
if !declarations.is_empty() {
App::mount_declarations(tree, node_id, declarations);
}
}
fn split_runtime_control_messages(
app: &mut App,
root: &mut dyn Widget,
queue: Vec<MessageEvent>,
) -> RuntimeMessagePass {
let mut pass = RuntimeMessagePass::default();
for event in queue {
match event.message {
Message::AsyncTaskSpawn(crate::message::AsyncTaskSpawn {
task_id,
target,
request,
}) => {
if let Some(cancelled) = app.async_tasks.spawn(task_id, target, request) {
pass.generated.push(cancelled);
}
}
Message::AsyncTaskCancel(crate::message::AsyncTaskCancel { task_id }) => {
if let Some(cancelled) = app.async_tasks.cancel(task_id) {
pass.generated.push(cancelled);
}
}
Message::AsyncTaskCancelTarget(crate::message::AsyncTaskCancelTarget { target }) => {
pass.generated
.extend(app.async_tasks.cancel_for_target(target));
}
Message::TimerSchedule(crate::message::TimerSchedule {
timer_id,
target,
delay,
}) => {
if let Some(cancelled) = app.one_shot_timers.schedule(timer_id, target, delay) {
pass.generated.push(cancelled);
}
}
Message::TimerCancel(crate::message::TimerCancel { timer_id }) => {
if let Some(cancelled) = app.one_shot_timers.cancel(timer_id) {
pass.generated.push(cancelled);
}
}
Message::AppAddClass(crate::message::AppAddClass {
selector,
class_name,
}) => match app.action_add_class(&selector, &class_name) {
Ok(matched) if matched > 0 => {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
Ok(_) => {}
Err(err) => {
debug_input(&format!(
"[runtime] app.add_class ignored selector={selector:?} class={class_name:?} err={err:?}"
));
}
},
Message::AppRemoveClass(crate::message::AppRemoveClass {
selector,
class_name,
}) => match app.action_remove_class(&selector, &class_name) {
Ok(matched) if matched > 0 => {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
Ok(_) => {}
Err(err) => {
debug_input(&format!(
"[runtime] app.remove_class ignored selector={selector:?} class={class_name:?} err={err:?}"
));
}
},
Message::AppToggleClass(crate::message::AppToggleClass {
selector,
class_name,
}) => match app.action_toggle_class(&selector, &class_name) {
Ok(matched) if matched > 0 => {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
Ok(_) => {}
Err(err) => {
debug_input(&format!(
"[runtime] app.toggle_class ignored selector={selector:?} class={class_name:?} err={err:?}"
));
}
},
Message::AppSetDisabled(crate::message::AppSetDisabled { selector, disabled }) => {
match app.query_mut(&selector) {
Ok(query) => {
let matched = query.len();
query.set(None, None, Some(disabled), None);
if matched > 0 {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
}
Err(err) => {
debug_input(&format!(
"[runtime] app.set_disabled ignored selector={selector:?} disabled={disabled:?} err={err:?}"
));
}
}
}
Message::AppBack(crate::message::AppBack) => {
if app.action_back() {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
}
Message::AppBell(crate::message::AppBell) => {
let _ = app.action_bell();
}
Message::AppChangeTheme(crate::message::AppChangeTheme) => {
if app.action_change_theme() {
pass.repaint_requested = true;
}
}
Message::AppCommandPalette(crate::message::AppCommandPalette) => {
let mut outcome = if let Ok(target) = app.query_one("CommandPalette") {
app.dispatch_event_to_target_auto(
root,
target,
&Event::Action(Action::CommandPalette),
)
} else {
app.dispatch_event_auto(root, Event::Action(Action::CommandPalette))
};
merge_outcome_into_runtime_pass(&mut pass, &mut outcome);
}
Message::AppFocus(crate::message::AppFocus { widget_id }) => {
match app.action_focus(&widget_id) {
Ok(changed) => {
if changed {
pass.repaint_requested = true;
}
}
Err(err) => {
debug_input(&format!(
"[runtime] app.focus ignored widget_id={widget_id:?} err={err:?}"
));
}
}
}
Message::AppFocusNext(crate::message::AppFocusNext) => {
if app.action_focus_next() {
pass.repaint_requested = true;
}
}
Message::AppFocusPrevious(crate::message::AppFocusPrevious) => {
if app.action_focus_previous() {
pass.repaint_requested = true;
}
}
Message::AppHelpQuit(crate::message::AppHelpQuit) => {
app.action_help_quit();
pass.repaint_requested = true;
}
Message::AppCopySelectedText(crate::message::AppCopySelectedText) => {
if let Some(text) = app.action_copy_selected_text() {
let sender = App::runtime_message_sender();
pass.generated.push(MessageEvent {
sender,
message: Message::TextEditClipboardCopyRequested(
crate::message::TextEditClipboardCopyRequested { text, cut: false },
),
control: Some(sender),
});
} else {
app.notify_help_quit();
pass.repaint_requested = true;
}
}
Message::AppHideHelpPanel(crate::message::AppHideHelpPanel) => {
match app.action_hide_help_panel() {
Ok(changed) => {
if changed {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
}
Err(err) => {
debug_input(&format!(
"[runtime] app.hide_help_panel ignored err={err:?}"
));
}
}
pass.deliver.push(event);
}
Message::AppNotify(crate::message::AppNotify {
message,
title,
severity,
}) => {
app.action_notify(&message, &title, &severity);
pass.repaint_requested = true;
}
Message::AppPopScreen(crate::message::AppPopScreen) => {
if app.action_pop_screen() {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
}
Message::AppPushScreen(crate::message::AppPushScreen { screen }) => {
if app.action_push_screen(&screen) {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
} else {
debug_input(&format!(
"[runtime] app.push_screen ignored missing screen={screen:?}"
));
}
}
Message::AppScreenshot(crate::message::AppScreenshot { filename, path }) => {
pass.repaint_requested |=
app.action_screenshot(filename.as_deref(), path.as_deref());
}
Message::AppShowHelpPanel(crate::message::AppShowHelpPanel) => {
match app.action_show_help_panel() {
Ok(changed) => {
if changed {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
}
Err(err) => {
debug_input(&format!(
"[runtime] app.show_help_panel ignored err={err:?}"
));
}
}
pass.deliver.push(event);
}
Message::AppSimulateKey(crate::message::AppSimulateKey { key }) => {
if let Some(synthetic) = parse_simulated_key(&key) {
dispatch_simulated_key_like_input(app, root, synthetic, &mut pass);
} else {
debug_input(&format!(
"[runtime] app.simulate_key ignored invalid key spec {:?}",
key
));
}
}
Message::AppSuspendProcess(crate::message::AppSuspendProcess) => {
pass.repaint_requested |= app.action_suspend_process();
}
Message::AppSwitchMode(crate::message::AppSwitchMode { mode }) => {
if app.switch_mode(&mode) {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
} else {
debug_input(&format!("[runtime] app.switch_mode ignored mode={mode:?}"));
}
}
Message::AppSwitchScreen(crate::message::AppSwitchScreen { screen }) => {
if app.action_switch_screen(&screen) {
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
} else {
debug_input(&format!(
"[runtime] app.switch_screen ignored screen={screen:?}"
));
}
}
Message::AppToggleDark(crate::message::AppToggleDark) => {
if app.action_toggle_dark() {
pass.repaint_requested = true;
}
}
Message::ActionDispatchRequested(crate::message::ActionDispatchRequested {
action,
}) => {
let Some(parsed) = crate::action::parse_action(&action) else {
debug_input(&format!(
"[runtime] action dispatch ignored invalid action={action:?}"
));
continue;
};
let mut dispatched = false;
if let Some(tree_mut) = app.active_widget_tree_mut() {
let resolve_from = if tree_mut.contains(event.sender) {
Some(event.sender)
} else {
focused_node_id_tree(tree_mut).or_else(|| tree_mut.root())
};
let resolved = {
let tree_ref = &*tree_mut;
resolve_from.and_then(|start| {
crate::action::resolve_action(&parsed, tree_ref, start, |nid| {
tree_ref.get(nid).map(|node| {
(
node.widget.action_namespace(),
node.widget.action_registry(),
)
})
})
})
};
if let Some(ra) = resolved
&& let Some(node) = tree_mut.get_mut(ra.node)
{
let mut ctx = EventCtx::default();
let handled = execute_action_with_dispatch_target(
&mut *node.widget,
&parsed,
&mut ctx,
ra.node,
);
let mut outcome = DispatchOutcome {
handled: handled || ctx.handled(),
repaint_requested: ctx.repaint_requested(),
invalidation: ctx.invalidation(),
stop_requested: ctx.stop_requested(),
messages: ctx.take_messages(),
animation_requests: ctx.take_animation_requests(),
worker_requests: ctx.take_worker_requests(),
recompose_nodes: ctx.take_recompose_nodes(),
default_prevented: false,
};
merge_outcome_into_runtime_pass(&mut pass, &mut outcome);
dispatched = outcome.handled;
}
}
if !dispatched {
let mut ctx = EventCtx::default();
let handled = execute_action_with_dispatch_target(
root,
&parsed,
&mut ctx,
NodeId::default(),
);
let mut outcome = DispatchOutcome {
handled: handled || ctx.handled(),
repaint_requested: ctx.repaint_requested(),
invalidation: ctx.invalidation(),
stop_requested: ctx.stop_requested(),
messages: ctx.take_messages(),
animation_requests: ctx.take_animation_requests(),
worker_requests: ctx.take_worker_requests(),
recompose_nodes: ctx.take_recompose_nodes(),
default_prevented: false,
};
merge_outcome_into_runtime_pass(&mut pass, &mut outcome);
dispatched = outcome.handled;
}
if !dispatched {
debug_input(&format!(
"[runtime] action dispatch unresolved action={action:?} sender={}",
crate::node_id::node_id_to_ffi(event.sender)
));
}
}
Message::OverlayVisibilityChanged(crate::message::OverlayVisibilityChanged {
overlay,
visible,
}) => {
if let Some(tree) = app.active_widget_tree_mut()
&& set_overlay_modal_display_tree(tree, overlay, visible)
{
pass.repaint_requested = true;
pass.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
pass.deliver.push(event);
}
_ => pass.deliver.push(event),
}
}
pass.generated
.extend(app.one_shot_timers.drain_ready(Instant::now()));
pass.generated.extend(app.async_tasks.drain_completed());
pass
}
#[derive(Clone)]
struct SelectorSnapshot {
type_name: String,
style_id: Option<String>,
classes: Vec<String>,
disabled: bool,
focused: bool,
hovered: bool,
active: bool,
inline: bool,
ansi: bool,
nocolor: bool,
}
fn snapshot_for(
widget: &dyn Widget,
_node_id: NodeId,
app_active: bool,
app_pseudos: AppRuntimePseudos,
) -> SelectorSnapshot {
let is_screen = widget.style_type() == "Screen"
|| widget
.style_type_aliases()
.iter()
.any(|alias| *alias == "Screen");
SelectorSnapshot {
type_name: widget.style_type().to_string(),
style_id: widget.style_id().map(str::to_string),
classes: widget.style_classes().to_vec(),
disabled: widget.is_disabled(),
focused: (widget.has_focus() || is_screen) && app_active,
hovered: widget.is_hovered(),
active: widget.is_active(),
inline: app_pseudos.inline,
ansi: app_pseudos.ansi,
nocolor: app_pseudos.nocolor,
}
}
fn selector_matches_snapshot(
selector: &crate::css::StyleSelector,
meta: &SelectorSnapshot,
) -> bool {
if let Some(type_name) = selector.type_name() {
if meta.type_name != type_name {
return false;
}
}
if let Some(id) = selector.id_name() {
if meta.style_id.as_deref() != Some(id) {
return false;
}
}
if !selector.classes().is_empty() {
if !selector
.classes()
.iter()
.all(|class| meta.classes.iter().any(|value| value == class))
{
return false;
}
}
for pseudo in selector.pseudos() {
let ok = match pseudo {
crate::css::PseudoClass::Disabled => meta.disabled,
crate::css::PseudoClass::Focus => meta.focused,
crate::css::PseudoClass::Hover => meta.hovered,
crate::css::PseudoClass::Active => meta.active,
crate::css::PseudoClass::Blur => !meta.focused,
crate::css::PseudoClass::Inline => meta.inline,
crate::css::PseudoClass::Ansi => meta.ansi,
crate::css::PseudoClass::NoColor => meta.nocolor,
_ => false,
};
if !ok {
return false;
}
}
true
}
fn rule_matches_snapshot_chain(
rule: &crate::css::StyleRule,
current: &SelectorSnapshot,
ancestors: &[SelectorSnapshot],
) -> bool {
let chain = rule.selector_chain();
let parts = chain.parts();
if parts.is_empty() {
return false;
}
let last = parts.last().expect("parts not empty");
if !selector_matches_snapshot(last, current) {
return false;
}
if parts.len() == 1 {
return true;
}
let combinators = chain.combinators();
let mut idx = ancestors.len() as isize - 1;
if idx < 0 {
return false;
}
for (part_index, selector) in parts[..parts.len() - 1].iter().rev().enumerate() {
let combinator = combinators[combinators.len() - 1 - part_index];
match combinator {
crate::css::Combinator::Child => {
let meta = &ancestors[idx as usize];
if !selector_matches_snapshot(selector, meta) {
return false;
}
idx -= 1;
}
crate::css::Combinator::Descendant => {
let mut found = false;
let mut current_idx = idx;
while current_idx >= 0 {
let meta = &ancestors[current_idx as usize];
if selector_matches_snapshot(selector, meta) {
found = true;
idx = current_idx - 1;
break;
}
current_idx -= 1;
}
if !found {
return false;
}
}
}
}
true
}
fn collect_stylesheet_affected_widgets_root(
root: &dyn Widget,
changed_rules: &[crate::css::StyleRule],
app_active: bool,
app_pseudos: AppRuntimePseudos,
) -> Vec<NodeId> {
if changed_rules.is_empty() {
return Vec::new();
}
let current = snapshot_for(root, NodeId::default(), app_active, app_pseudos);
if changed_rules
.iter()
.any(|rule| rule_matches_snapshot_chain(rule, ¤t, &[]))
{
vec![NodeId::default()]
} else {
Vec::new()
}
}
fn collect_stylesheet_affected_widgets_tree(
tree: &crate::widget_tree::WidgetTree,
changed_rules: &[crate::css::StyleRule],
app_active: bool,
app_pseudos: AppRuntimePseudos,
) -> Vec<NodeId> {
if changed_rules.is_empty() {
return Vec::new();
}
let root = match tree.root() {
Some(r) => r,
None => return Vec::new(),
};
let mut affected = HashSet::new();
fn visit(
tree: &crate::widget_tree::WidgetTree,
node_id: NodeId,
rules: &[crate::css::StyleRule],
app_active: bool,
app_pseudos: AppRuntimePseudos,
ancestors: &mut Vec<SelectorSnapshot>,
affected: &mut HashSet<NodeId>,
) {
let Some(node) = tree.get(node_id) else {
return;
};
let current = snapshot_for(node.widget.as_ref(), node_id, app_active, app_pseudos);
if rules
.iter()
.any(|rule| rule_matches_snapshot_chain(rule, ¤t, ancestors))
{
affected.insert(node_id);
}
ancestors.push(current);
for &child_id in tree.children(node_id) {
visit(
tree,
child_id,
rules,
app_active,
app_pseudos,
ancestors,
affected,
);
}
ancestors.pop();
}
let mut ancestors = Vec::new();
visit(
tree,
root,
changed_rules,
app_active,
app_pseudos,
&mut ancestors,
&mut affected,
);
let mut out = affected.into_iter().collect::<Vec<_>>();
out.sort_by_key(|id| node_id_to_ffi(*id));
out
}
pub fn resolve_transition_for_property(
style: &crate::style::Style,
property: &str,
) -> Option<(Duration, Duration, AnimationEase)> {
if let Some(ref transitions) = style.transitions {
if let Some(pt) = transitions
.iter()
.find(|t| t.property == property)
.or_else(|| transitions.iter().find(|t| t.property == "all"))
{
if pt.duration.is_zero() {
return None;
}
let ease = transition_timing_to_ease(pt.timing);
return Some((pt.duration, pt.delay, ease));
}
}
let duration = style.transition_duration?;
if duration.is_zero() {
return None;
}
let delay = style.transition_delay.unwrap_or(Duration::ZERO);
let ease = style
.transition_timing
.map(transition_timing_to_ease)
.unwrap_or(AnimationEase::OutCubic);
Some((duration, delay, ease))
}
fn canonical_transition_property_name(property: &str) -> String {
property.trim().to_ascii_lowercase().replace('-', "_")
}
fn style_numeric_property(style: &crate::style::Style, property: &str) -> Option<f32> {
match canonical_transition_property_name(property).as_str() {
"opacity" => Some(style.opacity.unwrap_or(100) as f32),
"text_opacity" => Some(style.text_opacity.unwrap_or(100) as f32),
"offset_x" => style.offset.map(|offset| match offset.x {
crate::style::OffsetValue::Cells(v) => v as f32,
crate::style::OffsetValue::Percent(v) => v,
}),
"offset_y" => style.offset.map(|offset| match offset.y {
crate::style::OffsetValue::Cells(v) => v as f32,
crate::style::OffsetValue::Percent(v) => v,
}),
_ => None,
}
}
fn semantic_transition_alias(property: &str) -> Option<&'static str> {
match property {
"color" | "foreground" => Some("fg"),
"background" => Some("bg"),
"background_tint" | "background-tint" => Some("background_tint"),
_ => None,
}
}
fn resolve_transition_for_property_aliases(
style: &crate::style::Style,
property: &str,
) -> Option<(Duration, Duration, AnimationEase)> {
if let Some(found) = resolve_transition_for_property(style, property) {
return Some(found);
}
let canonical = canonical_transition_property_name(property);
if let Some(found) = resolve_transition_for_property(style, &canonical) {
return Some(found);
}
let dashed = canonical.replace('_', "-");
if dashed != canonical {
if let Some(found) = resolve_transition_for_property(style, &dashed) {
return Some(found);
}
}
if let Some(alias) = semantic_transition_alias(&canonical) {
return resolve_transition_for_property(style, alias);
}
None
}
fn transition_requests_for_style_change(
target: NodeId,
previous: &crate::style::Style,
current: &crate::style::Style,
) -> (Vec<AnimationRequest>, Vec<StyleAnimationRequest>) {
if previous == current {
return (Vec::new(), Vec::new());
}
const NUMERIC_ANIMATABLE: [&str; 4] = ["opacity", "text_opacity", "offset_x", "offset_y"];
let numeric: Vec<AnimationRequest> = NUMERIC_ANIMATABLE
.iter()
.filter_map(|property| {
let from = style_numeric_property(previous, property)?;
let to = style_numeric_property(current, property)?;
if (from - to).abs() < f32::EPSILON {
return None;
}
let (duration, delay, ease) =
resolve_transition_for_property_aliases(current, property)?;
Some(
AnimationRequest::new(target, *property, from, to, duration)
.with_delay(delay)
.with_ease(ease)
.with_level(crate::event::AnimationLevel::Basic),
)
})
.collect();
const STYLE_ANIMATABLE: [&str; 12] = [
"fg",
"bg",
"width",
"height",
"min_width",
"max_width",
"min_height",
"max_height",
"margin",
"padding",
"tint",
"background_tint",
];
let style: Vec<StyleAnimationRequest> = STYLE_ANIMATABLE
.iter()
.filter_map(|property| {
let from = style_property_as_style_value(previous, property)?;
let to = style_property_as_style_value(current, property)?;
if from == to {
return None;
}
let (duration, delay, ease) =
resolve_transition_for_property_aliases(current, property)?;
Some(
StyleAnimationRequest::new(target, *property, from, to, duration)
.with_delay(delay)
.with_ease(ease)
.with_level(crate::event::AnimationLevel::Full),
)
})
.collect();
(numeric, style)
}
fn style_property_as_style_value(
style: &crate::style::Style,
property: &str,
) -> Option<StyleValue> {
match property {
"fg" => Some(StyleValue::Color(style.fg?)),
"bg" => Some(StyleValue::Color(style.bg?)),
"width" => Some(StyleValue::Scalar(style.width?)),
"height" => Some(StyleValue::Scalar(style.height?)),
"min_width" => Some(StyleValue::Scalar(style.min_width?)),
"max_width" => Some(StyleValue::Scalar(style.max_width?)),
"min_height" => Some(StyleValue::Scalar(style.min_height?)),
"max_height" => Some(StyleValue::Scalar(style.max_height?)),
"margin" => Some(StyleValue::Spacing(*style.margin.as_ref()?)),
"padding" => Some(StyleValue::Spacing(*style.padding.as_ref()?)),
"tint" => Some(StyleValue::Tint(*style.tint.as_ref()?)),
"background_tint" => Some(StyleValue::Tint(*style.background_tint.as_ref()?)),
_ => None,
}
}
fn apply_style_value_to_property(
style: &mut crate::style::Style,
property: &str,
value: &StyleValue,
) {
match (property, value) {
("fg", StyleValue::Color(c)) => style.fg = Some(*c),
("bg", StyleValue::Color(c)) => style.bg = Some(*c),
("width", StyleValue::Scalar(s)) => style.width = Some(*s),
("height", StyleValue::Scalar(s)) => style.height = Some(*s),
("min_width", StyleValue::Scalar(s)) => style.min_width = Some(*s),
("max_width", StyleValue::Scalar(s)) => style.max_width = Some(*s),
("min_height", StyleValue::Scalar(s)) => style.min_height = Some(*s),
("max_height", StyleValue::Scalar(s)) => style.max_height = Some(*s),
("margin", StyleValue::Spacing(sp)) => style.margin = Some(*sp),
("padding", StyleValue::Spacing(sp)) => style.padding = Some(*sp),
("tint", StyleValue::Tint(t)) => style.tint = Some(*t),
("background_tint", StyleValue::Tint(t)) => style.background_tint = Some(*t),
_ => {}
}
}
fn transition_timing_to_ease(timing: crate::style::TransitionTiming) -> AnimationEase {
match timing {
crate::style::TransitionTiming::Linear => AnimationEase::Linear,
crate::style::TransitionTiming::InOutCubic => AnimationEase::InOutCubic,
crate::style::TransitionTiming::OutCubic => AnimationEase::OutCubic,
crate::style::TransitionTiming::Round => AnimationEase::Round,
crate::style::TransitionTiming::None => AnimationEase::None,
}
}
fn sanitize_snapshot_field(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'\n' | '\r' | '\t' | '|' => ' ',
_ => ch,
})
.collect()
}
fn bool_flag(value: bool) -> &'static str {
if value { "1" } else { "0" }
}
#[derive(Clone, Copy)]
enum InvalidationScope {
Global,
Widget(NodeId),
}
fn copy_to_system_clipboard(text: &str) -> bool {
#[cfg(target_os = "macos")]
{
return run_copy_command("pbcopy", &[], text);
}
#[cfg(target_os = "windows")]
{
return run_copy_command(
"powershell",
&[
"-NoProfile",
"-Command",
"Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
text,
) || run_copy_command("cmd", &["/C", "clip"], text);
}
#[cfg(all(unix, not(target_os = "macos")))]
{
return run_copy_command("wl-copy", &[], text)
|| run_copy_command("xclip", &["-selection", "clipboard"], text)
|| run_copy_command("xsel", &["--clipboard", "--input"], text);
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = text;
false
}
}
fn paste_from_system_clipboard() -> Option<String> {
#[cfg(target_os = "macos")]
{
return run_paste_command("pbpaste", &[]);
}
#[cfg(target_os = "windows")]
{
return run_paste_command(
"powershell",
&["-NoProfile", "-Command", "Get-Clipboard -Raw"],
)
.or_else(|| run_paste_command("powershell", &["-NoProfile", "-Command", "Get-Clipboard"]));
}
#[cfg(all(unix, not(target_os = "macos")))]
{
return run_paste_command("wl-paste", &["-n"])
.or_else(|| run_paste_command("xclip", &["-selection", "clipboard", "-o"]))
.or_else(|| run_paste_command("xsel", &["--clipboard", "--output"]));
}
#[cfg(not(any(unix, target_os = "windows")))]
{
None
}
}
fn run_copy_command(program: &str, args: &[&str], text: &str) -> bool {
let mut child = match Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => child,
Err(_) => return false,
};
let write_ok = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(text.as_bytes()).is_ok(),
None => false,
};
if !write_ok {
let _ = child.kill();
let _ = child.wait();
return false;
}
matches!(child.wait(), Ok(status) if status.success())
}
fn run_paste_command(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
if text.is_empty() {
return None;
}
Some(text)
}
impl App {
fn apply_app_blur_focus_state(&mut self) {
self.app_active = false;
let focused = self.active_widget_tree().and_then(focused_node_id_tree);
self.last_focused_on_app_blur = focused;
if let Some(focused_id) = focused
&& let Some(tree) = self.active_widget_tree_mut()
&& let Some(node) = tree.get_mut(focused_id)
{
node.widget.set_focus(false);
}
}
fn apply_app_focus_restore_state(&mut self) {
self.app_active = true;
if let Some(focused_id) = self.last_focused_on_app_blur.take()
&& let Some(tree) = self.active_widget_tree_mut()
&& focused_node_id_tree(tree).is_none()
&& tree.contains(focused_id)
&& tree.is_displayed(focused_id)
&& let Some(node) = tree.get_mut(focused_id)
{
node.widget.set_focus(true);
}
}
fn apply_devtools_commands(
&mut self,
_root: &mut dyn Widget,
pending_invalidation: &mut PendingInvalidation,
) -> bool {
let Some(devtools) = &self.devtools else {
return false;
};
let commands = devtools.drain_commands();
if commands.is_empty() {
return false;
}
for command in commands {
match command {
DevtoolsCommand::Focus(id) => {
if let Some(tree) = self.active_widget_tree_mut() {
if let Some(node) = tree.get_mut(id) {
node.widget.set_focus(true);
}
}
pending_invalidation.request_full_content();
}
DevtoolsCommand::SetDebugLayout(enabled) => {
self.enable_debug_layout(enabled);
pending_invalidation.request_full_content();
}
DevtoolsCommand::ToggleDisplay(id) => {
if let Some(tree) = self.active_widget_tree_mut() {
let current = tree.is_displayed(id);
tree.set_runtime_display(id, !current);
}
pending_invalidation.request_full_content();
}
DevtoolsCommand::Highlight(id) => {
if let Some(tree) = self.active_widget_tree_mut() {
tree.add_class(id, "-devtools-highlight");
}
pending_invalidation.request_full_content();
self.pending_highlight_clear = Some((
id,
std::time::Instant::now() + std::time::Duration::from_millis(500),
));
}
DevtoolsCommand::AddClass(id, class) => {
if let Some(tree) = self.active_widget_tree_mut() {
tree.add_class(id, &class);
}
pending_invalidation.request_full_content();
}
DevtoolsCommand::RemoveClass(id, class) => {
if let Some(tree) = self.active_widget_tree_mut() {
tree.remove_class(id, &class);
}
pending_invalidation.request_full_content();
}
DevtoolsCommand::Quit => {
return true;
}
}
}
if let Some((id, clear_at)) = self.pending_highlight_clear {
if std::time::Instant::now() >= clear_at {
if let Some(tree) = self.active_widget_tree_mut() {
tree.remove_class(id, "-devtools-highlight");
}
self.pending_highlight_clear = None;
pending_invalidation.request_full_content();
}
}
false
}
fn publish_devtools_snapshot(&mut self, root: &mut dyn Widget) {
let Some(devtools) = &self.devtools else {
return;
};
let mut widget_lines = Vec::new();
let mut focused = None;
if let Some(tree) = self.active_widget_tree() {
if let Some(root_id) = tree.root() {
let walk = tree.walk_depth_first(root_id);
for node_id in walk {
let Some(node) = tree.get(node_id) else {
continue;
};
let depth = tree.ancestors(node_id).len();
let widget = node.widget.as_ref();
let is_focused = widget.has_focus() && self.app_active;
let layout_rect = self.hit_test.rect(node_id);
let layout_rect_field = if let Some(r) = layout_rect {
format!("{},{},{},{}", r.x0, r.y0, r.x1, r.y1)
} else {
"-".to_string()
};
let cr = &node.content_rect;
let content_rect_field = if cr.x0 == 0 && cr.y0 == 0 && cr.x1 == 0 && cr.y1 == 0
{
"-".to_string()
} else {
format!("{},{},{},{}", cr.x0, cr.y0, cr.x1, cr.y1)
};
let style_id = widget
.style_id()
.map(sanitize_snapshot_field)
.unwrap_or_else(|| "-".to_string());
let mut classes: Vec<String> = widget
.style_classes()
.iter()
.map(|c| sanitize_snapshot_field(c))
.collect();
for c in &node.classes {
let sanitized = sanitize_snapshot_field(c);
if !classes.contains(&sanitized) {
classes.push(sanitized);
}
}
let classes_field = classes.join(",");
let parent_field = node
.parent
.map(|p| node_id_to_ffi(p).to_string())
.unwrap_or_else(|| "-".to_string());
let children_field = if node.children.is_empty() {
"-".to_string()
} else {
node.children
.iter()
.map(|c| node_id_to_ffi(*c).to_string())
.collect::<Vec<_>>()
.join(",")
};
let visibility_field = match node.visibility {
crate::style::Visibility::Visible => "visible",
crate::style::Visibility::Hidden => "hidden",
};
let line = format!(
"widget\t{depth}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
node_id_to_ffi(node_id),
sanitize_snapshot_field(widget.style_type()),
style_id,
classes_field,
bool_flag(is_focused),
bool_flag(self.hovered == Some(node_id)),
bool_flag(widget.is_active()),
bool_flag(widget.is_disabled()),
layout_rect_field,
content_rect_field,
bool_flag(node.display),
visibility_field,
bool_flag(node.css_display),
bool_flag(node.runtime_display),
bool_flag(node.mounted),
parent_field,
children_field,
);
widget_lines.push(line);
if is_focused {
focused = Some(node_id);
}
}
}
} else {
let widget = root as &dyn Widget;
let is_focused = widget.has_focus() && self.app_active;
let rect = self.hit_test.rect(NodeId::default());
let rect_field = if let Some(r) = rect {
format!("{},{},{},{}", r.x0, r.y0, r.x1, r.y1)
} else {
"-".to_string()
};
let style_id = widget
.style_id()
.map(sanitize_snapshot_field)
.unwrap_or_else(|| "-".to_string());
let classes = widget
.style_classes()
.iter()
.map(|c| sanitize_snapshot_field(c))
.collect::<Vec<_>>()
.join(",");
let line = format!(
"widget\t0\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t-\t1\tvisible\t1\t1\t1\t-\t-",
node_id_to_ffi(NodeId::default()),
sanitize_snapshot_field(widget.style_type()),
style_id,
classes,
bool_flag(is_focused),
bool_flag(self.hovered == Some(NodeId::default())),
bool_flag(widget.is_active()),
bool_flag(widget.is_disabled()),
rect_field,
);
widget_lines.push(line);
if is_focused {
focused = Some(NodeId::default());
}
}
let mut snapshot = String::new();
snapshot.push_str("version\t2\n");
snapshot.push_str(&format!("pid\t{}\n", std::process::id()));
snapshot.push_str(&format!("app_active\t{}\n", bool_flag(self.app_active)));
snapshot.push_str(&format!(
"debug_layout\t{}\n",
bool_flag(self.debug_layout.enabled)
));
snapshot.push_str(&format!(
"frame\t{}\t{}\n",
self.frame.width, self.frame.height
));
snapshot.push_str(&format!(
"hovered\t{}\n",
self.hovered
.map(|id| node_id_to_ffi(id).to_string())
.unwrap_or_else(|| "-".to_string())
));
snapshot.push_str(&format!(
"focused\t{}\n",
focused
.map(|id| node_id_to_ffi(id).to_string())
.unwrap_or_else(|| "-".to_string())
));
snapshot.push_str(&format!("widget_count\t{}\n", widget_lines.len()));
for hint in &self.last_binding_hints {
snapshot.push_str(&format!(
"hint\t{}\t{}\n",
sanitize_snapshot_field(&hint.key),
sanitize_snapshot_field(&hint.description)
));
}
for line in widget_lines {
snapshot.push_str(&line);
snapshot.push('\n');
}
for (node_id, style) in &self.style_snapshot_cache {
let ffi_id = node_id_to_ffi(*node_id);
for (prop, value) in style.debug_properties() {
snapshot.push_str(&format!(
"style\t{ffi_id}\t{}\t{}\n",
sanitize_snapshot_field(prop),
sanitize_snapshot_field(&value)
));
}
}
devtools.publish_snapshot(snapshot);
}
fn dispatch_message_queue_with_runtime(
&mut self,
root: &mut dyn Widget,
initial: Vec<MessageEvent>,
) -> DispatchOutcome {
let mut aggregate = DispatchOutcome::default();
let mut queue = initial;
loop {
let pass = split_runtime_control_messages(self, root, queue);
aggregate.repaint_requested |= pass.repaint_requested;
aggregate.invalidation.merge(pass.invalidation);
aggregate.stop_requested |= pass.stop_requested;
aggregate
.animation_requests
.extend(pass.animation_requests.into_iter());
aggregate
.worker_requests
.extend(pass.worker_requests.into_iter());
aggregate
.recompose_nodes
.extend(pass.recompose_nodes.into_iter());
let mut next_queue =
collect_clipboard_runtime_messages(&mut self.clipboard, &pass.deliver);
next_queue.extend(pass.generated);
let mut outcome = if pass.deliver.is_empty() {
DispatchOutcome::default()
} else {
self.dispatch_message_queue_auto(root, pass.deliver)
};
aggregate.handled |= outcome.handled;
aggregate.repaint_requested |= outcome.repaint_requested;
aggregate.invalidation.merge(outcome.invalidation);
aggregate.stop_requested |= outcome.stop_requested;
aggregate.default_prevented |= outcome.default_prevented;
aggregate
.animation_requests
.append(&mut outcome.animation_requests);
aggregate
.worker_requests
.append(&mut outcome.worker_requests);
aggregate
.recompose_nodes
.append(&mut outcome.recompose_nodes);
let emitted = std::mem::take(&mut outcome.messages);
if !emitted.is_empty() {
aggregate.messages.extend(emitted.iter().cloned());
next_queue.extend(emitted);
}
if aggregate.stop_requested || next_queue.is_empty() {
break;
}
queue = next_queue;
}
aggregate
}
fn dispatch_background_runtime_messages(&mut self, root: &mut dyn Widget) -> DispatchOutcome {
let mut queue = self.drain_pending_app_messages();
queue.extend(self.one_shot_timers.drain_ready(Instant::now()));
queue.extend(self.async_tasks.drain_completed());
self.dispatch_message_queue_with_runtime(root, queue)
}
pub async fn run_with<F, R>(&mut self, mut render: F) -> crate::Result<()>
where
F: FnMut(&mut App, u64) -> R,
R: Renderable,
{
if !self.running {
return Err(crate::Error::RuntimeStopped);
}
self.start()?;
let mut tick: u64 = 0;
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
match event::read()? {
CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => {
if matches!(
key.code,
crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ')
) {
debug_input(&format!("[input] key {:?}", key.code));
}
if should_quit_key(&key, &self.quit_keys) {
break;
}
}
CrosstermEvent::Resize(_, _) => {
self.refresh_size()?;
}
_ => {}
}
}
if last_tick.elapsed() >= tick_rate {
let _ = self.poll_stylesheet();
let renderable = render(self, tick);
self.render(&renderable)?;
tick += 1;
last_tick = Instant::now();
}
}
self.finish()?;
Ok(())
}
pub async fn run_widget_tree(&mut self, root: &mut dyn Widget) -> crate::Result<()> {
if !self.running {
return Err(crate::Error::RuntimeStopped);
}
self.start()?;
root.on_mount();
self.build_widget_tree(root);
if let Some(tree) = self.active_widget_tree_mut() {
let _ = sync_widget_controlled_child_display_tree(tree, root);
}
self.style_snapshot_cache.clear();
if let Some(tree) = self.active_widget_tree_mut() {
let focus_chain = collect_focus_chain_tree(tree);
if let Some(&first) = focus_chain.first() {
if let Some(node) = tree.get_mut(first) {
node.widget.set_focus(true);
}
}
}
{
let mut mount_ctx = crate::event::EventCtx::default();
root.on_app_mount(self, &mut mount_ctx);
}
self.publish_devtools_snapshot(root);
let initial_help_outcome = self.dispatch_focused_help_changed(root);
if initial_help_outcome.stop_requested {
root.on_unmount();
self.finish()?;
return Ok(());
}
let mut tick: u64 = 0;
let idle_tick_rate = Duration::from_millis(100);
let active_tick_rate = Duration::from_millis(16);
let mut worker_registry = WorkerRegistry::new();
let mut pending_invalidation = PendingInvalidation::default();
pending_invalidation.request_flags(initial_help_outcome.invalidation);
if initial_help_outcome.should_repaint() {
pending_invalidation.request_full_content();
}
let mut prev_any_active = false;
self.render_widget(root)?;
self.apply_layout_info_to_tree();
self.publish_devtools_snapshot(root);
pending_invalidation = PendingInvalidation::default();
let initial_mount_nodes: Vec<NodeId> = self
.active_widget_tree()
.and_then(|tree| tree.root().map(|r| tree.walk_depth_first(r)))
.unwrap_or_default();
for node_id in initial_mount_nodes {
let mut outcome = self.dispatch_event_to_target_auto(
root,
node_id,
&Event::Mount(MountEvent { node: node_id }),
);
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome = self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
}
{
let mut outcome = self.dispatch_event_auto(root, Event::Ready(ReadyEvent));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome = self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
}
self.dispatch_style_transition_requests(root);
let mut previous_focus: Option<NodeId> =
self.active_widget_tree().and_then(focused_node_id_tree);
let mut last_tick = Instant::now();
let mut pending_input_event: Option<CrosstermEvent> = None;
let mut last_mouse_pos: Option<(u16, u16)> = None;
'event_loop: loop {
let timing_on = timing_enabled();
let loop_started = Instant::now();
let mut input_kind = "none";
self.validate_active_selection_owner();
let mut input_dispatch_us: u128 = 0;
let mut background_us: Option<u128> = None;
let mut focused_help_us: Option<u128> = None;
let mut lifecycle_us: Option<u128> = None;
let mut reactive_us: Option<u128> = None;
let mut focus_transition_us: Option<u128> = None;
let mut binding_us: Option<u128> = None;
let mut animation_us: Option<u128> = None;
let mut worker_us: Option<u128> = None;
let mut style_transition_us: u128 = 0;
let mut immediate_render_us: u128 = 0;
let mut normal_render_us: u128 = 0;
let mut tick_render_us: u128 = 0;
if self.apply_devtools_commands(root, &mut pending_invalidation) {
break 'event_loop;
}
let now = Instant::now();
let has_runtime_animation = self.animator.has_animations();
let tick_rate = if has_runtime_animation || prev_any_active {
active_tick_rate
} else {
idle_tick_rate
};
let tick_timeout = tick_rate.saturating_sub(last_tick.elapsed());
let timeout = self
.animator
.next_timeout(now)
.map(|anim_timeout| tick_timeout.min(anim_timeout))
.unwrap_or(tick_timeout);
let timeout = self
.one_shot_timers
.next_timeout(now)
.map(|timer_timeout| timeout.min(timer_timeout))
.unwrap_or(timeout);
let poll_started = Instant::now();
let input_event = if let Some(pending) = pending_input_event.take() {
Some(pending)
} else if event::poll(timeout)? {
Some(event::read()?)
} else {
None
};
let poll_wait_us = poll_started.elapsed().as_micros();
if let Some(ref event) = input_event {
input_kind = input_event_kind(event);
}
if timing_on
&& input_event.is_none()
&& pending_invalidation.is_dirty()
&& poll_wait_us > 1_000
{
debug_timing(&format!(
"[timing] wait_for_input kind=none timeout_us={} waited_us={} dirty=true flags(c={} s={} l={})",
timeout.as_micros(),
poll_wait_us,
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
let mut handled_input_this_loop = false;
if let Some(input_event) = input_event {
let input_started = Instant::now();
handled_input_this_loop = true;
let mut sheet = self.default_stylesheet.clone();
sheet.extend(&self.stylesheet);
if let Some(screen_sheet) = self.active_screen_stylesheet() {
sheet.extend(screen_sheet);
}
let _active = set_app_active(self.app_active);
let _pseudo_state = set_app_runtime_pseudos(AppRuntimePseudos {
dark: self.dark_mode,
inline: self.app_inline,
ansi: self.app_ansi,
nocolor: self.app_nocolor,
});
let _guard = set_style_context(sheet);
match input_event {
CrosstermEvent::Key(key) => {
debug_input(&format!(
"[input] key code={:?} mods={:?} kind={:?}",
key.code, key.modifiers, key.kind
));
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=non_press_key input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
if should_quit_key(&key, &self.quit_keys) {
break;
}
let key = KeyEventData::from_crossterm(key);
let mut app_key_ctx = EventCtx::default();
root.on_app_key(self, &key, &mut app_key_ctx);
if app_key_ctx.repaint_requested() {
pending_invalidation.request_full_content();
}
pending_invalidation.request_flags(app_key_ctx.invalidation());
if app_key_ctx.stop_requested() {
break 'event_loop;
}
let app_key_handled = app_key_ctx.handled();
let app_key_messages = app_key_ctx.take_messages();
if !app_key_messages.is_empty() {
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, app_key_messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if msg_outcome.stop_requested {
break 'event_loop;
}
}
if app_key_handled {
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=app_key_handled input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
let bind = crate::event::KeyBind::from_event(&key);
let mapped_action = self.action_map.lookup(&bind);
if let Some(action) = mapped_action.filter(|a| is_priority_action(*a)) {
debug_input(&format!(
"[input] priority action-map {:?} -> {:?}",
bind, action
));
let mut outcome = self.dispatch_event_auto(root, Event::Action(action));
debug_input(&format!(
"[input] priority action dispatch action={:?} handled={} repaint={} messages={}",
action,
outcome.handled,
outcome.repaint_requested,
outcome.messages.len()
));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
if outcome.handled {
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=priority_action_handled input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
}
if let Some(palette_target) = self.open_command_palette_target() {
let mut key_outcome =
self.dispatch_event_auto(root, Event::Key(key.clone()));
debug_input(&format!(
"[input] palette-open key dispatch handled={} repaint={} messages={}",
key_outcome.handled,
key_outcome.repaint_requested,
key_outcome.messages.len()
));
let palette_scope = if key_outcome.messages.is_empty()
&& !key_outcome.invalidation.layout
&& !key_outcome.invalidation.style
{
InvalidationScope::Widget(palette_target)
} else {
InvalidationScope::Global
};
self.absorb_outcome(
&mut key_outcome,
&mut pending_invalidation,
palette_scope,
);
let mut msg_outcome = self
.dispatch_message_queue_with_runtime(root, key_outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if key_outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
if pending_invalidation.is_dirty() || self.resized_since_last_render {
let render_started = Instant::now();
let regions = pending_invalidation
.content_regions
.as_render_regions(self.frame.width, self.frame.height);
let layout_invalidation = pending_invalidation.flags.layout
|| pending_invalidation.flags.style
|| self.resized_since_last_render;
self.render_widget_with_regions(
root,
regions.as_deref(),
layout_invalidation,
)?;
self.apply_layout_info_to_tree();
self.publish_devtools_snapshot(root);
pending_invalidation = PendingInvalidation::default();
immediate_render_us = render_started.elapsed().as_micros();
}
if event::poll(Duration::ZERO)? {
pending_input_event = Some(event::read()?);
}
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=palette_key_path input={} dispatch_us={} immediate_render_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
immediate_render_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
let binding_match = self.active_widget_tree().and_then(|tree| {
match_binding_tree(tree, &key).map(|(node_id, action_str)| {
(node_id, action_str, tree.root().unwrap_or_default())
})
});
if let Some((_binding_node_id, action_str, root_target)) = binding_match
&& let Some(parsed) = crate::action::parse_action(&action_str)
{
if let Some(tree_mut) = self.active_widget_tree_mut() {
let focused = focused_node_id_tree(tree_mut);
let resolved = {
let tree_ref = &*tree_mut;
focused.and_then(|fid| {
crate::action::resolve_action(
&parsed,
tree_ref,
fid,
|nid| {
tree_ref.get(nid).map(|n| {
(
n.widget.action_namespace(),
n.widget.action_registry(),
)
})
},
)
})
};
if let Some(ra) = resolved
&& let Some(node) = tree_mut.get_mut(ra.node)
{
let mut ctx = EventCtx::default();
let handled = execute_action_with_dispatch_target(
&mut *node.widget,
&parsed,
&mut ctx,
ra.node,
);
debug_input(&format!(
"[input] binding action={action_str:?} handled={handled}"
));
if handled || ctx.handled() {
let mut binding_outcome = DispatchOutcome {
handled: handled || ctx.handled(),
repaint_requested: ctx.repaint_requested(),
invalidation: ctx.invalidation(),
stop_requested: ctx.stop_requested(),
messages: ctx.take_messages(),
animation_requests: ctx.take_animation_requests(),
worker_requests: ctx.take_worker_requests(),
recompose_nodes: ctx.take_recompose_nodes(),
default_prevented: false,
};
self.absorb_outcome(
&mut binding_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let messages = binding_outcome.messages;
if !messages.is_empty() {
let mut msg_outcome = self
.dispatch_message_queue_with_runtime(
root, messages,
);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if msg_outcome.stop_requested {
break 'event_loop;
}
}
if binding_outcome.stop_requested {
break 'event_loop;
}
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=binding_widget_action input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
}
}
let mut root_ctx = EventCtx::default();
let handled = execute_action_with_dispatch_target(
root,
&parsed,
&mut root_ctx,
root_target,
);
debug_input(&format!(
"[input] binding action={action_str:?} root_handled={handled}"
));
if handled || root_ctx.handled() {
let mut root_binding_outcome = DispatchOutcome {
handled: handled || root_ctx.handled(),
repaint_requested: root_ctx.repaint_requested(),
invalidation: root_ctx.invalidation(),
stop_requested: root_ctx.stop_requested(),
messages: root_ctx.take_messages(),
animation_requests: root_ctx.take_animation_requests(),
worker_requests: root_ctx.take_worker_requests(),
recompose_nodes: root_ctx.take_recompose_nodes(),
default_prevented: false,
};
self.absorb_outcome(
&mut root_binding_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let messages = root_binding_outcome.messages;
if !messages.is_empty() {
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if msg_outcome.stop_requested {
break 'event_loop;
}
}
if root_binding_outcome.stop_requested {
break 'event_loop;
}
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=binding_root_action input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
}
let mut key_outcome =
self.dispatch_event_auto(root, Event::Key(key.clone()));
debug_input(&format!(
"[input] key dispatch handled={} repaint={} messages={}",
key_outcome.handled,
key_outcome.repaint_requested,
key_outcome.messages.len()
));
self.absorb_outcome(
&mut key_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, key_outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if key_outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
if !key_outcome.handled {
if let Some(action) = mapped_action.filter(|a| !is_priority_action(*a))
{
if action == Action::CopySelectedText {
if let Some(text) = self.action_copy_selected_text() {
let sender = App::runtime_message_sender();
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(
root,
vec![MessageEvent {
sender,
message: Message::TextEditClipboardCopyRequested(
crate::message::TextEditClipboardCopyRequested {
text,
cut: false,
},
),
control: Some(sender),
}],
);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=copy_selected_text input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
} else {
self.notify_help_quit();
pending_invalidation.request_full_content();
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=help_quit input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
}
continue;
}
if action == Action::HelpQuit {
self.notify_help_quit();
pending_invalidation.request_full_content();
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=help_quit input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
if matches!(action, Action::FocusNext | Action::FocusPrev) {
let mut focus_outcome =
self.dispatch_event_auto(root, Event::Action(action));
self.absorb_outcome(
&mut focus_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut focus_msg_outcome = self
.dispatch_message_queue_with_runtime(
root,
focus_outcome.messages,
);
self.absorb_outcome(
&mut focus_msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if focus_outcome.stop_requested
|| focus_msg_outcome.stop_requested
{
break 'event_loop;
}
if focus_outcome.handled {
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=focus_action_handled input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
if self.move_focus_auto(action) {
pending_invalidation.request_full_content();
input_dispatch_us = input_started.elapsed().as_micros();
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=focus_moved input={} dispatch_us={} loop_us={} dirty={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
}
debug_input(&format!(
"[input] action-map {:?} -> {:?}",
bind, action
));
let mut outcome = if is_scroll_action(action) {
self.dispatch_scroll_action_auto(root, action, self.hovered)
} else {
self.dispatch_event_auto(root, Event::Action(action))
};
debug_input(&format!(
"[input] action dispatch action={:?} handled={} repaint={} messages={}",
action,
outcome.handled,
outcome.repaint_requested,
outcome.messages.len()
));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome = self
.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
} else {
debug_input(&format!("[input] action-map {:?} -> none", bind));
}
}
}
CrosstermEvent::Mouse(mouse) => {
let mouse = if matches!(
mouse.kind,
MouseEventKind::Moved | MouseEventKind::Drag(_)
) {
coalesce_mouse_motion_events(mouse, &mut pending_input_event)?
} else {
mouse
};
match mouse.kind {
MouseEventKind::Moved | MouseEventKind::Drag(_) => {
if hit_probe_enabled() {
let curr = (mouse.column, mouse.row);
let dir = point_direction(last_mouse_pos, curr);
let frame_target = self.widget_at(mouse.column, mouse.row);
let tree_target = self.active_widget_tree().and_then(|tree| {
widget_at_tree_layout(tree, mouse.column, mouse.row)
});
let chosen = self
.active_widget_tree()
.map(|tree| {
super::choose_deeper_target(
tree,
frame_target,
tree_target,
)
})
.unwrap_or(frame_target);
let relation = self
.active_widget_tree()
.and_then(|tree| match (frame_target, tree_target) {
(Some(frame), Some(tree_hit)) if frame != tree_hit => {
if super::is_ancestor_or_self(tree, frame, tree_hit)
{
Some("frame->ancestor(tree)")
} else if super::is_ancestor_or_self(
tree, tree_hit, frame,
) {
Some("tree->ancestor(frame)")
} else {
Some("unrelated")
}
}
(Some(_), Some(_)) => Some("same"),
_ => None,
})
.unwrap_or("-");
let frame_rect =
frame_target.and_then(|id| self.hit_test.rect(id));
let tree_rect =
tree_target.and_then(|id| self.hit_test.rect(id));
debug_input(&format!(
"[hit-probe] pos=({}, {}) dir={} frame={:?} frame_rect={} tree={:?} tree_rect={} relation={} chosen={:?}",
mouse.column,
mouse.row,
dir,
frame_target.map(node_id_to_ffi),
fmt_rect(frame_rect),
tree_target.map(node_id_to_ffi),
fmt_rect(tree_rect),
relation,
chosen.map(node_id_to_ffi)
));
}
last_mouse_pos = Some((mouse.column, mouse.row));
let before = self.hovered;
if self.update_hover_from_frame(mouse.column, mouse.row, root) {
if let Some(id) = before {
pending_invalidation
.request_widget_rect(&self.hit_test, id);
}
if let Some(id) = self.hovered {
pending_invalidation
.request_widget_rect(&self.hit_test, id);
} else {
pending_invalidation.request_full_content();
}
let enter_leave = generate_enter_leave_events(
before,
self.hovered,
mouse.column,
mouse.row,
mouse.column,
mouse.row,
);
for (target, event) in enter_leave {
let mut outcome = self
.dispatch_event_to_target_auto(root, target, &event);
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
}
}
if let Some(owner) = self.active_selection_owner
&& self.selection_drag_active
{
let (sx, sy) = self.content_local_coords_auto(
owner,
mouse.column,
mouse.row,
);
if self.update_selection_drag(owner, sx, sy).unwrap_or(false) {
pending_invalidation
.request_widget_rect(&self.hit_test, owner);
}
}
if self.update_hover_tooltip(mouse.column, mouse.row) {
pending_invalidation
.request_flags(crate::event::InvalidationFlags::layout());
pending_invalidation.request_full_content();
}
let is_drag = matches!(mouse.kind, MouseEventKind::Drag(_));
let down_target = self.click_tracker.down_target();
let move_target = if is_drag {
down_target
.or_else(|| self.widget_at_auto(mouse.column, mouse.row))
} else {
self.widget_at_auto(mouse.column, mouse.row)
};
if is_drag && scrollbar_drag_trace_enabled() {
debug_input(&format!(
"[scrollbar-drag] move screen=({}, {}) down_target={:?} hovered={:?} chosen_target={:?}",
mouse.column,
mouse.row,
down_target.map(node_id_to_ffi),
self.hovered.map(node_id_to_ffi),
move_target.map(node_id_to_ffi),
));
}
if let Some(target) = move_target {
let changed = self.call_on_mouse_move_auto(
root,
target,
mouse.column,
mouse.row,
is_drag && down_target.is_some(),
);
let (local_x, local_y) = self.content_local_coords_auto(
target,
mouse.column,
mouse.row,
);
let move_event =
Event::MouseMove(crate::event::MouseMoveEvent {
target,
screen_x: mouse.column,
screen_y: mouse.row,
x: local_x,
y: local_y,
});
let mut move_outcome = self.dispatch_event_to_target_auto(
root,
target,
&move_event,
);
self.absorb_outcome(
&mut move_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut move_msg_outcome = self
.dispatch_message_queue_with_runtime(
root,
move_outcome.messages,
);
self.absorb_outcome(
&mut move_msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if move_outcome.stop_requested
|| move_msg_outcome.stop_requested
{
break 'event_loop;
}
if changed {
if matches!(mouse.kind, MouseEventKind::Drag(_)) {
pending_invalidation.request_flags(
crate::event::InvalidationFlags::layout(),
);
pending_invalidation.request_full_content();
} else {
pending_invalidation.request_full_content();
}
}
}
}
MouseEventKind::Down(btn) => {
debug_input(&format!(
"[input] mouse down x={} y={} hovered={:?}",
mouse.column,
mouse.row,
self.hovered.map(|id| node_id_to_ffi(id))
));
if let Some(target) = self.widget_at_auto(mouse.column, mouse.row) {
let (x, y) = self.content_local_coords_auto(
target,
mouse.column,
mouse.row,
);
debug_input(&format!(
"[input] mouse target id={}",
node_id_to_ffi(target)
));
let button = match btn {
crossterm::event::MouseButton::Left => 0,
crossterm::event::MouseButton::Right => 2,
crossterm::event::MouseButton::Middle => 1,
};
self.click_tracker.on_mouse_down(
target,
x,
y,
mouse.column,
mouse.row,
button,
);
if scrollbar_drag_trace_enabled() {
debug_input(&format!(
"[scrollbar-drag] down target={} local=({}, {}) screen=({}, {}) button={}",
node_id_to_ffi(target),
x,
y,
mouse.column,
mouse.row,
button
));
}
let down_event = Event::MouseDown(MouseDownEvent {
target,
screen_x: mouse.column,
screen_y: mouse.row,
x,
y,
});
if matches!(
btn,
crossterm::event::MouseButton::Left
| crossterm::event::MouseButton::Right
) {
let previous_owner = self.active_selection_owner;
let click_count = self.register_selection_click(
target,
button,
mouse.column,
mouse.row,
);
let changed = match click_count {
1 => self
.begin_selection_drag(target, x, y)
.or_else(|| Some(self.clear_active_selection()))
.unwrap_or(false),
2 => self
.select_word_at(target, x, y)
.or_else(|| Some(self.clear_active_selection()))
.unwrap_or(false),
_ => self
.select_all_at_target(target)
.or_else(|| Some(self.clear_active_selection()))
.unwrap_or(false),
};
if changed {
if let Some(id) = previous_owner {
pending_invalidation
.request_widget_rect(&self.hit_test, id);
}
if let Some(id) = self.active_selection_owner {
pending_invalidation
.request_widget_rect(&self.hit_test, id);
}
}
} else {
self.clear_selection_click_streak();
}
let mut outcome = self.dispatch_event_to_target_auto(
root,
target,
&down_event,
);
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome = self.dispatch_message_queue_with_runtime(
root,
outcome.messages,
);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
} else {
if matches!(
btn,
crossterm::event::MouseButton::Left
| crossterm::event::MouseButton::Right
) {
self.clear_selection_click_streak();
let previous_owner = self.active_selection_owner;
if self.clear_active_selection() {
if let Some(id) = previous_owner {
pending_invalidation
.request_widget_rect(&self.hit_test, id);
} else {
pending_invalidation.request_full_content();
}
}
}
let down_event = Event::MouseDown(MouseDownEvent {
target: NodeId::default(),
screen_x: mouse.column,
screen_y: mouse.row,
x: mouse.column,
y: mouse.row,
});
let mut outcome = self.dispatch_event_auto(root, down_event);
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome = self.dispatch_message_queue_with_runtime(
root,
outcome.messages,
);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
}
MouseEventKind::Up(_) => {
self.end_selection_drag();
let down_target = self.click_tracker.down_target();
let target = self.widget_at_auto(mouse.column, mouse.row);
let (x, y) = target
.map(|id| {
self.content_local_coords_auto(id, mouse.column, mouse.row)
})
.unwrap_or((mouse.column, mouse.row));
let up_event = Event::MouseUp(MouseUpEvent {
target,
screen_x: mouse.column,
screen_y: mouse.row,
x,
y,
});
if scrollbar_drag_trace_enabled() {
debug_input(&format!(
"[scrollbar-drag] up target={:?} local=({}, {}) screen=({}, {}) down_target_before_clear={:?}",
target.map(node_id_to_ffi),
x,
y,
mouse.column,
mouse.row,
self.click_tracker.down_target().map(node_id_to_ffi)
));
}
if let Some(capture_target) =
down_target.filter(|id| Some(*id) != target)
{
let (cx, cy) = self.content_local_coords_auto(
capture_target,
mouse.column,
mouse.row,
);
let capture_up = Event::MouseUp(MouseUpEvent {
target: Some(capture_target),
screen_x: mouse.column,
screen_y: mouse.row,
x: cx,
y: cy,
});
let mut capture_outcome = self.dispatch_event_to_target_auto(
root,
capture_target,
&capture_up,
);
self.absorb_outcome(
&mut capture_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut capture_msg_outcome = self
.dispatch_message_queue_with_runtime(
root,
capture_outcome.messages,
);
self.absorb_outcome(
&mut capture_msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if capture_outcome.stop_requested
|| capture_msg_outcome.stop_requested
{
break 'event_loop;
}
}
let mut outcome = if let Some(target) = target {
self.dispatch_event_to_target_auto(root, target, &up_event)
} else {
self.dispatch_event_auto(root, up_event)
};
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if let Some((click_target, click_event)) = self
.click_tracker
.on_mouse_up(target, x, y, mouse.column, mouse.row)
{
let mut click_outcome = self.dispatch_event_to_target_auto(
root,
click_target,
&click_event,
);
self.absorb_outcome(
&mut click_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
}
let mut msg_outcome = self
.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => {
debug_input(&format!(
"[input] mouse scroll kind={:?} mods={:?} x={} y={}",
mouse.kind, mouse.modifiers, mouse.column, mouse.row
));
let before = self.hovered;
if self.update_hover_from_frame(mouse.column, mouse.row, root) {
if let Some(id) = before {
pending_invalidation
.request_widget_rect(&self.hit_test, id);
}
if let Some(id) = self.hovered {
pending_invalidation
.request_widget_rect(&self.hit_test, id);
} else {
pending_invalidation.request_full_content();
}
}
let (delta_x, delta_y) =
mouse_scroll_deltas(mouse.kind, mouse.modifiers);
let target = self.widget_at_auto(mouse.column, mouse.row);
let (local_x, local_y) = target
.map(|id| {
self.content_local_coords_auto(id, mouse.column, mouse.row)
})
.unwrap_or((0, 0));
debug_input(&format!(
"[input] mouse scroll route target={:?} dx={} dy={}",
target.map(node_id_to_ffi),
delta_x,
delta_y
));
let mut diag_outcome = if let Some(target) = target {
self.dispatch_event_to_target_auto(
root,
target,
&Event::MouseScroll(MouseScrollEvent {
target: Some(target),
screen_x: mouse.column,
screen_y: mouse.row,
x: local_x,
y: local_y,
delta_x,
delta_y,
modifiers: mouse.modifiers,
}),
)
} else {
self.dispatch_event_auto(
root,
Event::MouseScroll(MouseScrollEvent {
target: None,
screen_x: mouse.column,
screen_y: mouse.row,
x: local_x,
y: local_y,
delta_x,
delta_y,
modifiers: mouse.modifiers,
}),
)
};
self.absorb_outcome(
&mut diag_outcome,
&mut pending_invalidation,
target
.map(InvalidationScope::Widget)
.unwrap_or(InvalidationScope::Global),
);
let mut msg_outcome = self.dispatch_message_queue_with_runtime(
root,
diag_outcome.messages,
);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut outcome = if let Some(target) = target {
self.dispatch_mouse_scroll_to_target_auto(
root, target, delta_x, delta_y,
)
} else {
dispatch_mouse_scroll(root, delta_x, delta_y)
};
debug_input(&format!(
"[input] mouse scroll dispatch handled={} repaint={} messages={}",
outcome.handled,
outcome.repaint_requested,
outcome.messages.len()
));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome = self
.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if diag_outcome.stop_requested
|| outcome.stop_requested
|| msg_outcome.stop_requested
{
break 'event_loop;
}
}
}
}
CrosstermEvent::Resize(_, _) => {
let size = self.driver.size();
debug_render(&format!("[event] Resize({}x{})", size.width, size.height));
self.refresh_size()?;
let size = self.driver.size();
root.on_resize(size.width, size.height);
let mut outcome =
self.dispatch_event_auto(root, Event::Resize(size.width, size.height));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
CrosstermEvent::FocusLost => {
self.apply_app_blur_focus_state();
if self.clear_hover_tooltip() {
pending_invalidation
.request_flags(crate::event::InvalidationFlags::layout());
}
debug_input("[event] FocusLost");
let mut outcome = self.dispatch_event_auto(root, Event::AppFocus(false));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
pending_invalidation.request_full_content();
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
CrosstermEvent::FocusGained => {
self.apply_app_focus_restore_state();
debug_input("[event] FocusGained");
let mut outcome = self.dispatch_event_auto(root, Event::AppFocus(true));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
pending_invalidation.request_full_content();
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
_ => {}
}
if input_dispatch_us == 0 {
input_dispatch_us = input_started.elapsed().as_micros();
}
}
let phase_started = Instant::now();
let mut background_outcome = self.dispatch_background_runtime_messages(root);
background_us = Some(
background_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
self.absorb_outcome(
&mut background_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if background_outcome.stop_requested {
break 'event_loop;
}
let phase_started = Instant::now();
let mut focused_help_outcome = self.dispatch_focused_help_changed(root);
focused_help_us = Some(
focused_help_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
self.absorb_outcome(
&mut focused_help_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if focused_help_outcome.stop_requested {
break 'event_loop;
}
let phase_started = Instant::now();
let lifecycle_events: Vec<(NodeId, bool)> = self
.active_widget_tree_mut()
.map(|tree| {
tree.drain_lifecycle()
.into_iter()
.map(|evt| match evt {
crate::widget_tree::LifecycleEvent::Mount { node } => (node, true),
crate::widget_tree::LifecycleEvent::Unmount { node } => (node, false),
})
.collect()
})
.unwrap_or_default();
for (node_id, is_mount) in lifecycle_events {
let event = if is_mount {
Event::Mount(MountEvent { node: node_id })
} else {
Event::Unmount(UnmountEvent { node: node_id })
};
let mut outcome = self.dispatch_event_to_target_auto(root, node_id, &event);
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
lifecycle_us = Some(
lifecycle_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
let phase_started = Instant::now();
self.run_event_loop_reactive_phase(root, &mut pending_invalidation);
reactive_us = Some(
reactive_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
let phase_started = Instant::now();
let current_focus: Option<NodeId> =
self.active_widget_tree().and_then(focused_node_id_tree);
if current_focus != previous_focus {
if let Some(old_id) = previous_focus {
let mut blur_outcome = self.dispatch_event_to_target_auto(
root,
old_id,
&Event::Blur(BlurEvent { node: old_id }),
);
self.absorb_outcome(
&mut blur_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, blur_outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if blur_outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
if let Some(new_id) = current_focus {
let mut focus_outcome = self.dispatch_event_to_target_auto(
root,
new_id,
&Event::Focus(FocusEvent { node: new_id }),
);
self.absorb_outcome(
&mut focus_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, focus_outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if focus_outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
}
previous_focus = current_focus;
}
focus_transition_us = Some(
focus_transition_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
let mut rendered_immediately_for_input = false;
if handled_input_this_loop
&& (pending_invalidation.is_dirty() || self.resized_since_last_render)
{
let render_started = Instant::now();
let regions = pending_invalidation
.content_regions
.as_render_regions(self.frame.width, self.frame.height);
let layout_invalidation = pending_invalidation.flags.layout
|| pending_invalidation.flags.style
|| self.resized_since_last_render;
self.render_widget_with_regions(root, regions.as_deref(), layout_invalidation)?;
self.apply_layout_info_to_tree();
self.publish_devtools_snapshot(root);
pending_invalidation = PendingInvalidation::default();
rendered_immediately_for_input = true;
immediate_render_us = render_started.elapsed().as_micros();
}
if rendered_immediately_for_input && event::poll(Duration::ZERO)? {
pending_input_event = Some(event::read()?);
let tick_due = last_tick.elapsed() >= tick_rate;
if !tick_due {
if timing_on {
debug_timing(&format!(
"[timing] early_continue reason=input_priority_drain input={} dispatch_us={} immediate_render_us={} loop_us={} dirty_end={} flags(c={} s={} l={})",
input_kind,
input_dispatch_us,
immediate_render_us,
loop_started.elapsed().as_micros(),
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
continue;
}
if timing_on {
debug_timing(&format!(
"[timing] fairness_break reason=tick_due_after_input_drain input={} dispatch_us={} immediate_render_us={} loop_us={}",
input_kind,
input_dispatch_us,
immediate_render_us,
loop_started.elapsed().as_micros()
));
}
}
let phase_started = Instant::now();
let mut binding_outcome = self.dispatch_binding_hints_changed(root);
binding_us = Some(
binding_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
self.absorb_outcome(
&mut binding_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if binding_outcome.stop_requested {
break 'event_loop;
}
let phase_started = Instant::now();
let mut animation_outcome = self.dispatch_animation_frame(root);
animation_us = Some(
animation_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
self.absorb_outcome(
&mut animation_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if animation_outcome.stop_requested {
break 'event_loop;
}
let phase_started = Instant::now();
{
let pending_workers = drain_accumulated_worker_requests();
let changes = process_worker_requests(&mut worker_registry, pending_workers);
if !changes.is_empty() {
let worker_messages = worker_state_runtime_messages(&worker_registry, changes);
let mut worker_outcome =
self.dispatch_message_queue_with_runtime(root, worker_messages);
self.absorb_outcome(
&mut worker_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if worker_outcome.stop_requested {
break 'event_loop;
}
}
worker_registry.cleanup();
}
worker_us = Some(
worker_us
.unwrap_or(0)
.saturating_add(phase_started.elapsed().as_micros()),
);
if pending_invalidation.flags.style
|| pending_invalidation.flags.layout
|| self.style_snapshot_cache.is_empty()
{
let phase_started = Instant::now();
self.dispatch_style_transition_requests(root);
style_transition_us += phase_started.elapsed().as_micros();
}
if let Some(tree) = self.active_widget_tree_mut()
&& sync_widget_controlled_child_display_tree(tree, root)
{
pending_invalidation.request_flags(crate::event::InvalidationFlags::layout());
pending_invalidation.request_full_content();
}
self.absorb_pending_recompositions(&mut pending_invalidation);
self.absorb_pending_query_refreshes(&mut pending_invalidation);
if pending_invalidation.is_dirty() || self.resized_since_last_render {
let render_started = Instant::now();
let regions = pending_invalidation
.content_regions
.as_render_regions(self.frame.width, self.frame.height);
let layout_invalidation = pending_invalidation.flags.layout
|| pending_invalidation.flags.style
|| self.resized_since_last_render;
self.render_widget_with_regions(root, regions.as_deref(), layout_invalidation)?;
self.apply_layout_info_to_tree();
self.publish_devtools_snapshot(root);
pending_invalidation = PendingInvalidation::default();
normal_render_us = render_started.elapsed().as_micros();
}
if last_tick.elapsed() >= tick_rate {
let mut sheet = self.default_stylesheet.clone();
sheet.extend(&self.stylesheet);
if let Some(screen_sheet) = self.active_screen_stylesheet() {
sheet.extend(screen_sheet);
}
let _active = set_app_active(self.app_active);
let _pseudo_state = set_app_runtime_pseudos(AppRuntimePseudos {
dark: self.dark_mode,
inline: self.app_inline,
ansi: self.app_ansi,
nocolor: self.app_nocolor,
});
let _guard = set_style_context(sheet);
if let Some(reload) = self.poll_stylesheet() {
self.absorb_stylesheet_reload(root, reload, &mut pending_invalidation);
}
root.on_tick(tick);
let mut app_tick_ctx = EventCtx::default();
root.on_app_tick(self, tick, &mut app_tick_ctx);
let mut app_tick_outcome = DispatchOutcome {
handled: app_tick_ctx.handled(),
repaint_requested: app_tick_ctx.repaint_requested(),
invalidation: app_tick_ctx.invalidation(),
stop_requested: app_tick_ctx.stop_requested(),
messages: app_tick_ctx.take_messages(),
animation_requests: app_tick_ctx.take_animation_requests(),
worker_requests: app_tick_ctx.take_worker_requests(),
recompose_nodes: app_tick_ctx.take_recompose_nodes(),
default_prevented: false,
};
self.absorb_outcome(
&mut app_tick_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if app_tick_outcome.stop_requested {
break 'event_loop;
}
let mut app_tick_msg_outcome =
self.dispatch_message_queue_with_runtime(root, app_tick_outcome.messages);
self.absorb_outcome(
&mut app_tick_msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
if app_tick_msg_outcome.stop_requested {
break 'event_loop;
}
let mut outcome = self.dispatch_event_auto(root, Event::Tick(tick));
self.absorb_outcome(
&mut outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let mut msg_outcome =
self.dispatch_message_queue_with_runtime(root, outcome.messages);
self.absorb_outcome(
&mut msg_outcome,
&mut pending_invalidation,
InvalidationScope::Global,
);
let notifications_before = self.notifications.len();
let now = Instant::now();
self.notifications.retain(|note| note.expires_at > now);
if self.notifications.len() != notifications_before {
pending_invalidation.request_full_content();
}
if pending_invalidation.flags.style
|| pending_invalidation.flags.layout
|| self.style_snapshot_cache.is_empty()
{
let phase_started = Instant::now();
self.dispatch_style_transition_requests(root);
style_transition_us += phase_started.elapsed().as_micros();
}
if outcome.stop_requested || msg_outcome.stop_requested {
break 'event_loop;
}
let any_active = self.any_widget_active_auto(root);
if let Some(tree) = self.active_widget_tree_mut()
&& sync_widget_controlled_child_display_tree(tree, root)
{
pending_invalidation.request_flags(crate::event::InvalidationFlags::layout());
pending_invalidation.request_full_content();
}
self.absorb_pending_recompositions(&mut pending_invalidation);
self.absorb_pending_query_refreshes(&mut pending_invalidation);
if pending_invalidation.is_dirty()
|| self.resized_since_last_render
|| any_active
|| prev_any_active
{
let render_started = Instant::now();
let regions = pending_invalidation
.content_regions
.as_render_regions(self.frame.width, self.frame.height);
let layout_invalidation = pending_invalidation.flags.layout
|| pending_invalidation.flags.style
|| self.resized_since_last_render;
self.render_widget_with_regions(root, regions.as_deref(), layout_invalidation)?;
self.apply_layout_info_to_tree();
self.publish_devtools_snapshot(root);
pending_invalidation = PendingInvalidation::default();
tick_render_us = render_started.elapsed().as_micros();
}
prev_any_active = any_active;
last_tick = Instant::now();
tick += 1;
}
if timing_on {
let total_us = loop_started.elapsed().as_micros();
if handled_input_this_loop
|| immediate_render_us > 0
|| normal_render_us > 0
|| tick_render_us > 0
|| total_us > 2_000
{
debug_timing(&format!(
"[timing] loop input={} poll_wait_us={} input_dispatch_us={} phases_us(bg={} help={} lifecycle={} reactive={} focus={} binding={} anim={} worker={} style={}) render_us(immediate={} normal={} tick={}) total_us={} dirty_end={} flags_end(c={} s={} l={})",
input_kind,
poll_wait_us,
input_dispatch_us,
background_us.unwrap_or(0),
focused_help_us.unwrap_or(0),
lifecycle_us.unwrap_or(0),
reactive_us.unwrap_or(0),
focus_transition_us.unwrap_or(0),
binding_us.unwrap_or(0),
animation_us.unwrap_or(0),
worker_us.unwrap_or(0),
style_transition_us,
immediate_render_us,
normal_render_us,
tick_render_us,
total_us,
pending_invalidation.is_dirty(),
pending_invalidation.flags.content,
pending_invalidation.flags.style,
pending_invalidation.flags.layout
));
}
}
}
root.on_unmount();
self.finish()?;
Ok(())
}
pub(super) fn dispatch_binding_hints_changed(
&mut self,
root: &mut dyn Widget,
) -> DispatchOutcome {
let (widget_hints, current_sources) = self.active_binding_hints_auto(root);
let mut current = widget_hints;
current.extend(self.binding_hints());
self.apply_check_action(&mut current);
let current = self.normalize_binding_hints(current);
if !should_dispatch_binding_hints(
&self.last_binding_hints,
&self.last_binding_hint_sources,
¤t,
¤t_sources,
) {
return DispatchOutcome::default();
}
self.last_binding_hints = current.clone();
self.last_binding_hint_sources = current_sources;
let outcome = if let Some(tree) = self.active_widget_tree_mut() {
dispatch_event_broadcast_tree(tree, &Event::BindingsChanged(current))
} else {
self.dispatch_event_auto(root, Event::BindingsChanged(current))
};
let msg_outcome = self.dispatch_message_queue_with_runtime(root, outcome.messages);
let mut invalidation = outcome.invalidation;
invalidation.merge(msg_outcome.invalidation);
DispatchOutcome {
handled: outcome.handled || msg_outcome.handled,
repaint_requested: outcome.repaint_requested || msg_outcome.repaint_requested,
invalidation,
stop_requested: outcome.stop_requested || msg_outcome.stop_requested,
messages: msg_outcome.messages,
animation_requests: {
let mut requests = outcome.animation_requests;
requests.extend(msg_outcome.animation_requests);
requests
},
worker_requests: {
let mut requests = outcome.worker_requests;
requests.extend(msg_outcome.worker_requests);
requests
},
recompose_nodes: {
let mut nodes = outcome.recompose_nodes;
nodes.extend(msg_outcome.recompose_nodes);
nodes
},
default_prevented: outcome.default_prevented || msg_outcome.default_prevented,
}
}
pub(super) fn dispatch_focused_help_changed(
&mut self,
root: &mut dyn Widget,
) -> DispatchOutcome {
let current = self.focused_help_metadata_auto(root);
let current_source = current.as_ref().map(|(source, _)| *source);
let current_markup = current.as_ref().map(|(_, markup)| markup.as_str());
if !should_dispatch_focused_help(
self.last_focused_help_source,
self.last_focused_help_markup.as_deref(),
current_source,
current_markup,
) {
return DispatchOutcome::default();
}
self.last_focused_help_source = current_source;
self.last_focused_help_markup = current.as_ref().map(|(_, markup)| markup.clone());
let event = focused_help_message(current);
self.dispatch_message_queue_with_runtime(root, vec![event])
}
pub(super) fn enqueue_animation_requests(&mut self, requests: Vec<AnimationRequest>) {
if requests.is_empty() {
return;
}
self.animator.enqueue_many(requests, Instant::now());
}
pub(super) fn enqueue_style_animation_requests(
&mut self,
requests: Vec<StyleAnimationRequest>,
) {
if requests.is_empty() {
return;
}
self.animator.enqueue_style_many(requests, Instant::now());
}
fn absorb_outcome(
&mut self,
outcome: &mut DispatchOutcome,
pending: &mut PendingInvalidation,
scope: InvalidationScope,
) {
pending.request_flags(outcome.invalidation);
if outcome.should_repaint() {
match scope {
InvalidationScope::Global => pending.request_full_content(),
InvalidationScope::Widget(id) => pending.request_widget_rect(&self.hit_test, id),
}
}
let requests = std::mem::take(&mut outcome.animation_requests);
self.enqueue_animation_requests(requests);
let recompose_nodes = std::mem::take(&mut outcome.recompose_nodes);
if !recompose_nodes.is_empty() {
self.request_widget_recompose_nodes(&recompose_nodes);
}
accumulate_worker_requests(outcome);
}
fn absorb_stylesheet_reload(
&mut self,
_root: &mut dyn Widget,
reload: StylesheetReload,
pending: &mut PendingInvalidation,
) {
if reload.previous == reload.next {
return;
}
let affected = if let Some(tree) = self.active_widget_tree() {
collect_stylesheet_affected_widgets_tree(
tree,
&reload.changed_rules,
self.app_active,
AppRuntimePseudos {
dark: self.dark_mode,
inline: self.app_inline,
ansi: self.app_ansi,
nocolor: self.app_nocolor,
},
)
} else {
collect_stylesheet_affected_widgets_root(
_root,
&reload.changed_rules,
self.app_active,
AppRuntimePseudos {
dark: self.dark_mode,
inline: self.app_inline,
ansi: self.app_ansi,
nocolor: self.app_nocolor,
},
)
};
if affected.is_empty() {
return;
}
pending.request_flags(if reload.layout_affected {
crate::event::InvalidationFlags::layout()
} else {
crate::event::InvalidationFlags::style()
});
if reload.layout_affected || affected.len() > 128 {
pending.request_full_content();
return;
}
for id in affected {
pending.request_widget_rect(&self.hit_test, id);
}
}
fn absorb_pending_query_refreshes(&mut self, pending: &mut PendingInvalidation) {
let queued = self.take_pending_query_refresh_nodes();
if queued.is_empty() {
return;
}
let mut missing_rect = false;
for id in queued {
if self.hit_test.rect(id).is_some() {
pending.request_widget_rect(&self.hit_test, id);
} else {
missing_rect = true;
}
}
if missing_rect {
pending.request_full_content();
}
}
fn absorb_pending_recompositions(&mut self, pending: &mut PendingInvalidation) {
let queued = self.take_pending_recompose_nodes();
if queued.is_empty() {
return;
}
if let Some(tree) = self.active_widget_tree_mut() {
for node_id in queued {
if tree.contains(node_id) {
recompose_node_subtree(tree, node_id);
}
}
pending.request_flags(crate::event::InvalidationFlags::layout());
pending.request_full_content();
}
}
fn collect_current_resolved_styles(
&self,
root: &dyn Widget,
) -> HashMap<NodeId, crate::style::Style> {
let mut out = HashMap::new();
if let Some(tree) = self.active_widget_tree() {
if let Some(root_id) = tree.root() {
for node_id in tree.walk_depth_first(root_id) {
let Some(node) = tree.get(node_id) else {
continue;
};
let meta = crate::css::selector_meta_generic_with_classes(
node.widget.as_ref(),
node.classes.iter().cloned(),
);
out.insert(
node_id,
crate::css::resolve_style(node.widget.as_ref(), &meta),
);
}
}
return out;
}
let meta = crate::css::selector_meta_generic(root);
out.insert(NodeId::default(), crate::css::resolve_style(root, &meta));
out
}
fn dispatch_style_transition_requests(&mut self, root: &dyn Widget) {
let mut sheet = self.default_stylesheet.clone();
sheet.extend(&self.stylesheet);
if let Some(screen_sheet) = self.active_screen_stylesheet() {
sheet.extend(screen_sheet);
}
let _active = set_app_active(self.app_active);
let _pseudo_state = set_app_runtime_pseudos(AppRuntimePseudos {
dark: self.dark_mode,
inline: self.app_inline,
ansi: self.app_ansi,
nocolor: self.app_nocolor,
});
let _guard = set_style_context(sheet);
let current_styles = self.collect_current_resolved_styles(root);
let mut numeric_requests = Vec::new();
let mut style_requests = Vec::new();
for (node_id, current_style) in ¤t_styles {
if let Some(previous_style) = self.style_snapshot_cache.get(node_id) {
let (nr, sr) =
transition_requests_for_style_change(*node_id, previous_style, current_style);
numeric_requests.extend(nr);
style_requests.extend(sr);
}
}
self.style_snapshot_cache = current_styles;
self.enqueue_animation_requests(numeric_requests);
self.enqueue_style_animation_requests(style_requests);
}
pub(super) fn dispatch_animation_frame(&mut self, root: &mut dyn Widget) -> DispatchOutcome {
let now = Instant::now();
let updates = self.animator.step(now, self.animation_level);
let style_updates = self.animator.step_style(now, self.animation_level);
if updates.is_empty() && style_updates.is_empty() {
return DispatchOutcome::default();
}
for style_update in style_updates {
if let Some(tree) = self.active_widget_tree_mut() {
if let Some(node) = tree.get_mut(style_update.target) {
if let Some(styles) = node.widget.styles_mut() {
apply_style_value_to_property(
&mut styles.style,
&style_update.property,
&style_update.value,
);
}
}
}
}
if updates.is_empty() {
let mut aggregate = DispatchOutcome::default();
aggregate.repaint_requested = true;
aggregate
.invalidation
.merge(crate::event::InvalidationFlags::content());
return aggregate;
}
let mut aggregate = DispatchOutcome::default();
for update in updates {
let mut outcome = self.dispatch_event_to_target_auto(
root,
update.target,
&Event::AnimationValue(AnimationValueEvent {
target: update.target,
attribute: update.attribute,
value: update.value,
done: update.done,
}),
);
aggregate.handled |= outcome.handled;
aggregate.repaint_requested |= outcome.repaint_requested;
aggregate.invalidation.merge(outcome.invalidation);
let mut msg_outcome = self.dispatch_message_queue_with_runtime(root, outcome.messages);
aggregate.handled |= msg_outcome.handled;
aggregate.repaint_requested |= msg_outcome.repaint_requested;
aggregate.invalidation.merge(msg_outcome.invalidation);
let requests = std::mem::take(&mut outcome.animation_requests);
aggregate.animation_requests.extend(requests);
let msg_requests = std::mem::take(&mut msg_outcome.animation_requests);
aggregate.animation_requests.extend(msg_requests);
aggregate
.worker_requests
.extend(std::mem::take(&mut outcome.worker_requests));
aggregate
.worker_requests
.extend(std::mem::take(&mut msg_outcome.worker_requests));
aggregate.stop_requested |= outcome.stop_requested || msg_outcome.stop_requested;
aggregate.default_prevented |=
outcome.default_prevented || msg_outcome.default_prevented;
aggregate.messages.extend(msg_outcome.messages);
}
aggregate.repaint_requested = true;
aggregate
.invalidation
.merge(crate::event::InvalidationFlags::content());
aggregate
}
fn run_event_loop_reactive_phase(
&mut self,
_root: &mut dyn Widget,
pending: &mut PendingInvalidation,
) {
let queued = crate::reactive::take_runtime_reactive_entries();
if queued.is_empty() {
return;
}
let mut by_node: std::collections::HashMap<NodeId, Vec<RuntimeReactiveEntry>> =
std::collections::HashMap::new();
for entry in queued {
by_node.entry(entry.node_id()).or_default().push(entry);
}
if let Some(tree) = self.active_widget_tree() {
if let Some(root_id) = tree.root() {
for node_id in tree.walk_depth_first(root_id) {
if let Some(entries) = by_node.remove(&node_id) {
self.process_reactive_entries_for_node(node_id, entries, pending);
}
}
}
}
let mut remaining: Vec<(NodeId, Vec<RuntimeReactiveEntry>)> = by_node.into_iter().collect();
remaining.sort_by_key(|(node_id, _)| node_id_to_ffi(*node_id));
for (node_id, entries) in remaining {
self.process_reactive_entries_for_node(node_id, entries, pending);
}
}
fn process_reactive_entries_for_node(
&mut self,
node_id: NodeId,
entries: Vec<RuntimeReactiveEntry>,
pending: &mut PendingInvalidation,
) {
let mut repaint_requested = false;
let mut layout_requested = false;
for mut entry in entries {
let result = if let Some(tree) = self.active_widget_tree_mut() {
if let Some(node) = tree.get_mut(node_id) {
entry.run_with_dispatch(|changes, ctx| {
if let Some(reactive_widget) = node.widget.reactive_widget() {
reactive_widget.reactive_dispatch(changes, ctx);
}
})
} else {
entry.run_without_dispatch()
}
} else {
entry.run_without_dispatch()
};
repaint_requested |= result.needs_repaint;
layout_requested |= result.needs_layout;
}
if layout_requested {
pending.request_flags(crate::event::InvalidationFlags::layout());
}
if repaint_requested {
pending.request_widget_rect(&self.hit_test, node_id);
}
}
fn move_focus_auto(&mut self, action: Action) -> bool {
let Some(tree) = self.active_widget_tree_mut() else {
return false;
};
let focus_chain = collect_focus_chain_tree(tree);
if focus_chain.is_empty() {
return false;
}
let current = focused_node_id_tree(tree);
let current_index =
current.and_then(|id| focus_chain.iter().position(|candidate| *candidate == id));
let next_index = match (action, current_index) {
(Action::FocusNext, Some(idx)) => (idx + 1) % focus_chain.len(),
(Action::FocusPrev, Some(0)) => focus_chain.len() - 1,
(Action::FocusPrev, Some(idx)) => idx - 1,
(Action::FocusNext, None) => 0,
(Action::FocusPrev, None) => focus_chain.len() - 1,
_ => return false,
};
let next = focus_chain[next_index];
if current == Some(next) {
return false;
}
if let Some(current) = current {
if let Some(node) = tree.get_mut(current) {
node.widget.set_focus(false);
}
}
if let Some(node) = tree.get_mut(next) {
node.widget.set_focus(true);
return true;
}
false
}
fn open_command_palette_target(&mut self) -> Option<NodeId> {
let target = self.query_one("CommandPalette").ok()?;
let open = self
.with_widget_mut_as::<CommandPalette, _>(target, |palette| palette.is_open())
.unwrap_or(false);
if open { Some(target) } else { None }
}
fn ensure_runtime_tree(&mut self, root: &mut dyn Widget) {
if self.active_widget_tree().is_none() {
self.build_widget_tree(root);
}
}
fn dispatch_event_auto(&mut self, root: &mut dyn Widget, event: Event) -> DispatchOutcome {
self.ensure_runtime_tree(root);
let dismissed_tooltip = matches!(&event, Event::Action(Action::CommandPalette))
&& self.start_command_palette_tooltip_cooldown();
let palette_target = self.open_command_palette_target();
if matches!(&event, Event::Action(Action::CommandPalette))
&& let Ok(target) = self.query_one("CommandPalette")
{
let tree = self.active_widget_tree_mut().expect("tree should exist");
let mut direct = dispatch_event_to_target_tree(tree, target, &event);
if dismissed_tooltip {
direct.repaint_requested = true;
direct
.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
if direct.handled {
return direct;
}
}
if let Some(target) = palette_target
&& !matches!(&event, Event::Action(Action::CommandPalette))
&& matches!(
&event,
Event::Action(_)
| Event::Key(_)
| Event::MouseDown(_)
| Event::MouseUp(_)
| Event::MouseScroll(_)
| Event::Tick(_)
)
{
let tree = self.active_widget_tree_mut().expect("tree should exist");
let mut direct = dispatch_event_to_target_tree(tree, target, &event);
if dismissed_tooltip {
direct.repaint_requested = true;
direct
.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
return direct;
}
let mut root_capture_ctx = EventCtx::default();
if matches!(&event, Event::Key(..)) {
root.on_event_capture(&event, &mut root_capture_ctx);
if root_capture_ctx.handled() {
return DispatchOutcome {
handled: root_capture_ctx.handled(),
repaint_requested: root_capture_ctx.repaint_requested(),
invalidation: root_capture_ctx.invalidation(),
stop_requested: root_capture_ctx.stop_requested(),
messages: root_capture_ctx.take_messages(),
animation_requests: root_capture_ctx.take_animation_requests(),
worker_requests: root_capture_ctx.take_worker_requests(),
recompose_nodes: root_capture_ctx.take_recompose_nodes(),
default_prevented: false,
};
}
}
let mut outcome = {
let tree = self.active_widget_tree_mut().expect("tree should exist");
let focused = focused_node_id_tree(tree);
dispatch_event_tree(tree, focused, &event)
};
if matches!(&event, Event::Key(..)) {
outcome.handled |= root_capture_ctx.handled();
outcome.repaint_requested |= root_capture_ctx.repaint_requested();
outcome.invalidation.merge(root_capture_ctx.invalidation());
outcome.stop_requested |= root_capture_ctx.stop_requested();
let mut root_messages = root_capture_ctx.take_messages();
if !root_messages.is_empty() {
root_messages.extend(outcome.messages);
outcome.messages = root_messages;
}
let mut root_animation_requests = root_capture_ctx.take_animation_requests();
if !root_animation_requests.is_empty() {
root_animation_requests.extend(outcome.animation_requests);
outcome.animation_requests = root_animation_requests;
}
let mut root_worker_requests = root_capture_ctx.take_worker_requests();
if !root_worker_requests.is_empty() {
root_worker_requests.extend(outcome.worker_requests);
outcome.worker_requests = root_worker_requests;
}
let mut root_recompose_nodes = root_capture_ctx.take_recompose_nodes();
if !root_recompose_nodes.is_empty() {
root_recompose_nodes.extend(outcome.recompose_nodes);
outcome.recompose_nodes = root_recompose_nodes;
}
}
if !outcome.handled
&& matches!(
&event,
Event::Action(_)
| Event::Key(_)
| Event::MouseDown(_)
| Event::MouseUp(_)
| Event::MouseScroll(_)
| Event::AppFocus(_)
)
{
let mut root_event_ctx = EventCtx::default();
root.on_event(&event, &mut root_event_ctx);
outcome.handled |= root_event_ctx.handled();
outcome.repaint_requested |= root_event_ctx.repaint_requested();
outcome.invalidation.merge(root_event_ctx.invalidation());
outcome.stop_requested |= root_event_ctx.stop_requested();
outcome.messages.extend(root_event_ctx.take_messages());
outcome
.animation_requests
.extend(root_event_ctx.take_animation_requests());
outcome
.worker_requests
.extend(root_event_ctx.take_worker_requests());
outcome
.recompose_nodes
.extend(root_event_ctx.take_recompose_nodes());
}
if !outcome.handled
&& let Event::Action(action) = &event
{
let mut app_action_ctx = EventCtx::default();
root.on_app_action(self, *action, &mut app_action_ctx);
outcome.handled |= app_action_ctx.handled();
outcome.repaint_requested |= app_action_ctx.repaint_requested();
outcome.invalidation.merge(app_action_ctx.invalidation());
outcome.stop_requested |= app_action_ctx.stop_requested();
outcome.messages.extend(app_action_ctx.take_messages());
outcome
.animation_requests
.extend(app_action_ctx.take_animation_requests());
outcome
.worker_requests
.extend(app_action_ctx.take_worker_requests());
outcome
.recompose_nodes
.extend(app_action_ctx.take_recompose_nodes());
}
if dismissed_tooltip {
outcome.repaint_requested = true;
outcome
.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
outcome
}
fn dispatch_event_to_target_auto(
&mut self,
root: &mut dyn Widget,
_target: NodeId,
event: &Event,
) -> DispatchOutcome {
self.ensure_runtime_tree(root);
let dismissed_tooltip = matches!(event, Event::Action(Action::CommandPalette))
&& self.start_command_palette_tooltip_cooldown();
if matches!(event, Event::Action(Action::CommandPalette))
&& let Ok(target) = self.query_one("CommandPalette")
{
let tree = self.active_widget_tree_mut().expect("tree should exist");
let mut direct = dispatch_event_to_target_tree(tree, target, event);
if dismissed_tooltip {
direct.repaint_requested = true;
direct
.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
if direct.handled {
return direct;
}
}
let palette_target = self.open_command_palette_target();
let tree = self.active_widget_tree_mut().expect("tree should exist");
if let Some(palette_target) = palette_target
&& !matches!(event, Event::Action(Action::CommandPalette))
&& matches!(
event,
Event::Action(_)
| Event::Key(_)
| Event::MouseDown(_)
| Event::MouseUp(_)
| Event::MouseScroll(_)
| Event::Tick(_)
)
{
let mut direct = dispatch_event_to_target_tree(tree, palette_target, event);
if dismissed_tooltip {
direct.repaint_requested = true;
direct
.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
return direct;
}
let mut outcome = dispatch_event_to_target_tree(tree, _target, event);
if dismissed_tooltip {
outcome.repaint_requested = true;
outcome
.invalidation
.merge(crate::event::InvalidationFlags::layout());
}
outcome
}
fn dispatch_scroll_action_auto(
&mut self,
root: &mut dyn Widget,
action: Action,
hovered: Option<NodeId>,
) -> DispatchOutcome {
self.ensure_runtime_tree(root);
let tree = self.active_widget_tree_mut().expect("tree should exist");
dispatch_scroll_action_tree(tree, action, hovered)
}
fn dispatch_mouse_scroll_to_target_auto(
&mut self,
root: &mut dyn Widget,
_target: NodeId,
delta_x: i32,
delta_y: i32,
) -> DispatchOutcome {
self.ensure_runtime_tree(root);
let tree = self.active_widget_tree_mut().expect("tree should exist");
dispatch_mouse_scroll_to_target_tree(tree, _target, delta_x, delta_y)
}
fn dispatch_message_queue_auto(
&mut self,
root: &mut dyn Widget,
initial: Vec<MessageEvent>,
) -> DispatchOutcome {
self.ensure_runtime_tree(root);
let mut outcome = {
let tree = self.active_widget_tree_mut().expect("tree should exist");
dispatch_message_queue_tree(tree, initial.clone())
};
for message in initial {
let mut ctx = EventCtx::default();
root.on_message(&message, &mut ctx);
if !ctx.handled() {
root.on_app_message(self, &message, &mut ctx);
}
outcome.handled |= ctx.handled();
outcome.repaint_requested |= ctx.repaint_requested();
outcome.invalidation.merge(ctx.invalidation());
outcome.stop_requested |= ctx.stop_requested();
outcome.messages.extend(ctx.take_messages());
outcome
.animation_requests
.extend(ctx.take_animation_requests());
outcome.worker_requests.extend(ctx.take_worker_requests());
}
outcome
}
fn any_widget_active_auto(&mut self, root: &mut dyn Widget) -> bool {
self.ensure_runtime_tree(root);
if let Some(tree) = self.active_widget_tree() {
any_widget_active_tree(tree)
} else {
false
}
}
fn active_binding_hints_auto(
&mut self,
root: &mut dyn Widget,
) -> (Vec<crate::event::BindingHint>, Vec<NodeId>) {
self.ensure_runtime_tree(root);
if let Some(tree) = self.active_widget_tree() {
active_binding_hints_tree(tree)
} else {
(Vec::new(), Vec::new())
}
}
fn focused_help_metadata_auto(&mut self, root: &mut dyn Widget) -> Option<(NodeId, String)> {
self.ensure_runtime_tree(root);
if let Some(tree) = self.active_widget_tree() {
focused_help_metadata_tree(tree)
} else {
None
}
}
pub(super) fn call_on_mouse_move_auto(
&mut self,
root: &mut dyn Widget,
_target: NodeId,
x: u16,
y: u16,
capture_only: bool,
) -> bool {
self.ensure_runtime_tree(root);
if let Some(tree) = self.active_widget_tree_mut() {
if capture_only {
let (lx, ly) = tree_content_local_coords(tree, _target, x, y);
if let Some(node) = tree.get_mut(_target) {
let _dispatch_guard = set_dispatch_recipient(_target);
node.widget.on_mouse_move(lx, ly)
} else {
false
}
} else {
call_on_mouse_move_tree(tree, _target, x, y)
}
} else {
false
}
}
pub(super) fn pointer_shape_for_hover_auto(
&self,
_root: &mut dyn Widget,
hovered: Option<NodeId>,
) -> crate::driver::PointerShape {
if let Some(tree) = self.active_widget_tree() {
pointer_shape_for_hover_tree(tree, hovered)
} else {
crate::driver::PointerShape::Default
}
}
fn apply_layout_info_to_tree(&mut self) {
let mut sheet = self.default_stylesheet.clone();
sheet.extend(&self.stylesheet);
if let Some(screen_sheet) = self.active_screen_stylesheet() {
sheet.extend(screen_sheet);
}
let _active = set_app_active(self.app_active);
let _pseudo_state = set_app_runtime_pseudos(AppRuntimePseudos {
dark: self.dark_mode,
inline: self.app_inline,
ansi: self.app_ansi,
nocolor: self.app_nocolor,
});
let _guard = set_style_context(sheet);
if let Some(tree) = self.active_widget_tree_mut() {
apply_layout_info_tree_from_layout_rects(tree);
}
}
}
#[cfg(test)]
mod tests {
use super::{
ClipboardBackend, collect_clipboard_runtime_messages_with_backend,
collect_stylesheet_affected_widgets_root, focused_help_message, parse_simulated_key,
set_overlay_modal_display_tree, should_dispatch_binding_hints,
should_dispatch_focused_help, sync_widget_controlled_child_display_tree,
transition_requests_for_style_change,
};
use crate::App;
use crate::action::{ActionDecl, ParsedAction, parse_action, resolve_action};
use crate::css::StyleSheet;
use crate::event::{Action, BindingHint, Event, EventCtx, MountEvent};
use crate::keys::KeyEventData;
use crate::message::{Message, MessageEvent};
use crate::node_id::{NodeId, node_id_from_ffi};
use crate::reactive::{
ReactiveChange, ReactiveCtx, ReactiveFlags, ReactiveWidget, enqueue_runtime_reactive_entry,
take_runtime_reactive_entries,
};
use crate::style::{Offset, OffsetValue, PropertyTransition, Style, TransitionTiming};
use crate::widgets::{AppRoot, BindingDecl, Widget};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rich_rs::{Console, ConsoleOptions, Segments};
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
#[test]
fn parse_simulated_key_ctrl_chord() {
let key = parse_simulated_key("^p").expect("parse ^p");
assert_eq!(key.code, KeyCode::Char('p'));
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
assert_eq!(key.key, "ctrl+p");
}
#[test]
fn parse_simulated_key_shift_tab() {
let key = parse_simulated_key("shift+tab").expect("parse shift+tab");
assert_eq!(key.code, KeyCode::Tab);
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
}
#[derive(Default)]
struct StubClipboardBackend {
copy_results: VecDeque<bool>,
paste_results: VecDeque<Option<String>>,
copied: Vec<String>,
}
impl ClipboardBackend for StubClipboardBackend {
fn copy(&mut self, text: &str) -> bool {
self.copied.push(text.to_string());
self.copy_results.pop_front().unwrap_or(false)
}
fn paste(&mut self) -> Option<String> {
self.paste_results.pop_front().unwrap_or(None)
}
}
#[derive(Default)]
struct RootBindingsHost {
extracted: bool,
}
impl Widget for RootBindingsHost {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn bindings(&self) -> Vec<BindingDecl> {
vec![BindingDecl::new("l", "show_tab('leto')", "Leto")]
}
fn take_composed_children(&mut self) -> Vec<Box<dyn Widget>> {
if self.extracted {
Vec::new()
} else {
self.extracted = true;
vec![Box::new(FocusedProbe)]
}
}
}
struct FocusedProbe;
impl Widget for FocusedProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn focusable(&self) -> bool {
true
}
fn has_focus(&self) -> bool {
true
}
}
struct SimulatedKeyBindingHost {
hits_l: Arc<AtomicUsize>,
hits_j: Arc<AtomicUsize>,
hits_p: Arc<AtomicUsize>,
}
impl Widget for SimulatedKeyBindingHost {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn focusable(&self) -> bool {
true
}
fn has_focus(&self) -> bool {
true
}
fn bindings(&self) -> Vec<BindingDecl> {
vec![
BindingDecl::new("l", "select('l')", "Leto"),
BindingDecl::new("j", "select('j')", "Jessica"),
BindingDecl::new("p", "select('p')", "Paul"),
]
}
fn action_registry(&self) -> &[ActionDecl] {
const ACTIONS: &[ActionDecl] = &[ActionDecl {
name: "select",
namespace: "",
description: "select key",
default_binding: None,
}];
ACTIONS
}
fn execute_action(&mut self, action: &ParsedAction, ctx: &mut EventCtx) -> bool {
if action.name != "select" || action.arguments.len() != 1 {
return false;
}
match action.arguments[0].as_str() {
"l" => {
self.hits_l.fetch_add(1, Ordering::SeqCst);
ctx.set_handled();
true
}
"j" => {
self.hits_j.fetch_add(1, Ordering::SeqCst);
ctx.set_handled();
true
}
"p" => {
self.hits_p.fetch_add(1, Ordering::SeqCst);
ctx.set_handled();
true
}
_ => false,
}
}
}
struct RootHookProbe {
key_hits: Arc<AtomicUsize>,
action_hits: Arc<AtomicUsize>,
app_action_hits: Arc<AtomicUsize>,
message_hits: Arc<AtomicUsize>,
app_message_hits: Arc<AtomicUsize>,
app_tick_hits: Arc<AtomicUsize>,
handle_key: bool,
handle_action: bool,
handle_message: bool,
}
impl Widget for RootHookProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn on_event_capture(&mut self, event: &Event, ctx: &mut EventCtx) {
if matches!(event, Event::Key(..)) {
self.key_hits.fetch_add(1, Ordering::SeqCst);
if self.handle_key {
ctx.set_handled();
}
}
}
fn on_event(&mut self, event: &Event, ctx: &mut EventCtx) {
if matches!(event, Event::Action(..)) {
self.action_hits.fetch_add(1, Ordering::SeqCst);
if self.handle_action {
ctx.set_handled();
}
}
}
fn on_app_action(&mut self, _app: &mut App, _action: Action, ctx: &mut EventCtx) {
self.app_action_hits.fetch_add(1, Ordering::SeqCst);
ctx.set_handled();
}
fn on_message(&mut self, _message: &MessageEvent, ctx: &mut EventCtx) {
self.message_hits.fetch_add(1, Ordering::SeqCst);
if self.handle_message {
ctx.set_handled();
}
}
fn on_app_message(&mut self, _app: &mut App, _message: &MessageEvent, ctx: &mut EventCtx) {
self.app_message_hits.fetch_add(1, Ordering::SeqCst);
ctx.set_handled();
}
fn on_app_tick(&mut self, _app: &mut App, _tick: u64, _ctx: &mut EventCtx) {
self.app_tick_hits.fetch_add(1, Ordering::SeqCst);
}
}
struct TreeEventProbe {
focused: bool,
capture_hits: Arc<AtomicUsize>,
}
impl Widget for TreeEventProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn has_focus(&self) -> bool {
self.focused
}
fn on_event_capture(&mut self, event: &Event, _ctx: &mut EventCtx) {
if matches!(event, Event::Key(..)) {
self.capture_hits.fetch_add(1, Ordering::SeqCst);
}
}
}
#[test]
fn binding_hints_dispatch_when_hint_payload_changes() {
let last_hints = vec![BindingHint::new("tab", "next")];
let current_hints = vec![
BindingHint::new("tab", "next"),
BindingHint::new("q", "quit"),
];
let last_sources = vec![node_id_from_ffi(1)];
let current_sources = vec![node_id_from_ffi(1)];
assert!(should_dispatch_binding_hints(
&last_hints,
&last_sources,
¤t_hints,
¤t_sources,
));
}
#[test]
fn dispatch_event_auto_tree_runs_root_key_capture_and_tree_dispatch() {
let root_key_hits = Arc::new(AtomicUsize::new(0));
let root_action_hits = Arc::new(AtomicUsize::new(0));
let tree_root_capture_hits = Arc::new(AtomicUsize::new(0));
let tree_focused_capture_hits = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
let tree_root = tree.set_root(Box::new(TreeEventProbe {
focused: false,
capture_hits: Arc::clone(&tree_root_capture_hits),
}));
tree.mount(
tree_root,
Box::new(TreeEventProbe {
focused: true,
capture_hits: Arc::clone(&tree_focused_capture_hits),
}),
);
let mut app = test_app_with_tree(tree);
let mut runtime_root = RootHookProbe {
key_hits: Arc::clone(&root_key_hits),
action_hits: Arc::clone(&root_action_hits),
app_action_hits: Arc::new(AtomicUsize::new(0)),
message_hits: Arc::new(AtomicUsize::new(0)),
app_message_hits: Arc::new(AtomicUsize::new(0)),
app_tick_hits: Arc::new(AtomicUsize::new(0)),
handle_key: false,
handle_action: true,
handle_message: false,
};
let outcome = app.dispatch_event_auto(
&mut runtime_root,
Event::Key(KeyEventData::from_crossterm(KeyEvent::new(
KeyCode::Char('k'),
KeyModifiers::NONE,
))),
);
assert_eq!(root_key_hits.load(Ordering::SeqCst), 1);
assert_eq!(tree_root_capture_hits.load(Ordering::SeqCst), 1);
assert_eq!(tree_focused_capture_hits.load(Ordering::SeqCst), 1);
assert!(!outcome.handled);
assert_eq!(root_action_hits.load(Ordering::SeqCst), 0);
}
#[test]
fn dispatch_event_auto_tree_root_key_handle_short_circuits_tree_dispatch() {
let root_key_hits = Arc::new(AtomicUsize::new(0));
let root_action_hits = Arc::new(AtomicUsize::new(0));
let tree_root_capture_hits = Arc::new(AtomicUsize::new(0));
let tree_focused_capture_hits = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
let tree_root = tree.set_root(Box::new(TreeEventProbe {
focused: false,
capture_hits: Arc::clone(&tree_root_capture_hits),
}));
tree.mount(
tree_root,
Box::new(TreeEventProbe {
focused: true,
capture_hits: Arc::clone(&tree_focused_capture_hits),
}),
);
let mut app = test_app_with_tree(tree);
let mut runtime_root = RootHookProbe {
key_hits: Arc::clone(&root_key_hits),
action_hits: Arc::clone(&root_action_hits),
app_action_hits: Arc::new(AtomicUsize::new(0)),
message_hits: Arc::new(AtomicUsize::new(0)),
app_message_hits: Arc::new(AtomicUsize::new(0)),
app_tick_hits: Arc::new(AtomicUsize::new(0)),
handle_key: true,
handle_action: true,
handle_message: false,
};
let outcome = app.dispatch_event_auto(
&mut runtime_root,
Event::Key(KeyEventData::from_crossterm(KeyEvent::new(
KeyCode::Char('k'),
KeyModifiers::NONE,
))),
);
assert_eq!(root_key_hits.load(Ordering::SeqCst), 1);
assert_eq!(tree_root_capture_hits.load(Ordering::SeqCst), 0);
assert_eq!(tree_focused_capture_hits.load(Ordering::SeqCst), 0);
assert!(outcome.handled);
assert_eq!(root_action_hits.load(Ordering::SeqCst), 0);
}
#[test]
fn dispatch_event_auto_tree_runs_root_action_fallback_when_unhandled() {
let root_key_hits = Arc::new(AtomicUsize::new(0));
let root_action_hits = Arc::new(AtomicUsize::new(0));
let app_action_hits = Arc::new(AtomicUsize::new(0));
let tree_capture_hits = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
tree.set_root(Box::new(TreeEventProbe {
focused: true,
capture_hits: Arc::clone(&tree_capture_hits),
}));
let mut app = test_app_with_tree(tree);
let mut runtime_root = RootHookProbe {
key_hits: Arc::clone(&root_key_hits),
action_hits: Arc::clone(&root_action_hits),
app_action_hits: Arc::clone(&app_action_hits),
message_hits: Arc::new(AtomicUsize::new(0)),
app_message_hits: Arc::new(AtomicUsize::new(0)),
app_tick_hits: Arc::new(AtomicUsize::new(0)),
handle_key: false,
handle_action: true,
handle_message: false,
};
let outcome = app.dispatch_event_auto(&mut runtime_root, Event::Action(Action::HelpQuit));
assert_eq!(root_action_hits.load(Ordering::SeqCst), 1);
assert_eq!(app_action_hits.load(Ordering::SeqCst), 0);
assert!(outcome.handled);
assert_eq!(root_key_hits.load(Ordering::SeqCst), 0);
assert_eq!(tree_capture_hits.load(Ordering::SeqCst), 0);
}
#[test]
fn dispatch_event_auto_tree_uses_app_action_hook_when_root_fallback_unhandled() {
let root_action_hits = Arc::new(AtomicUsize::new(0));
let app_action_hits = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
tree.set_root(Box::new(TreeEventProbe {
focused: true,
capture_hits: Arc::new(AtomicUsize::new(0)),
}));
let mut app = test_app_with_tree(tree);
let mut runtime_root = RootHookProbe {
key_hits: Arc::new(AtomicUsize::new(0)),
action_hits: Arc::clone(&root_action_hits),
app_action_hits: Arc::clone(&app_action_hits),
message_hits: Arc::new(AtomicUsize::new(0)),
app_message_hits: Arc::new(AtomicUsize::new(0)),
app_tick_hits: Arc::new(AtomicUsize::new(0)),
handle_key: false,
handle_action: false,
handle_message: false,
};
let outcome = app.dispatch_event_auto(&mut runtime_root, Event::Action(Action::HelpQuit));
assert_eq!(root_action_hits.load(Ordering::SeqCst), 1);
assert_eq!(app_action_hits.load(Ordering::SeqCst), 1);
assert!(outcome.handled);
}
#[test]
fn dispatch_event_auto_tree_runs_root_key_fallback_when_unhandled() {
struct RootKeyFallbackProbe {
on_event_key_hits: Arc<AtomicUsize>,
}
impl Widget for RootKeyFallbackProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn on_event(&mut self, event: &Event, ctx: &mut EventCtx) {
if matches!(event, Event::Key(..)) {
self.on_event_key_hits.fetch_add(1, Ordering::SeqCst);
ctx.set_handled();
}
}
}
let mut tree = crate::widget_tree::WidgetTree::new();
tree.set_root(Box::new(TreeEventProbe {
focused: true,
capture_hits: Arc::new(AtomicUsize::new(0)),
}));
let mut app = test_app_with_tree(tree);
let key_hits = Arc::new(AtomicUsize::new(0));
let mut runtime_root = RootKeyFallbackProbe {
on_event_key_hits: Arc::clone(&key_hits),
};
let outcome = app.dispatch_event_auto(
&mut runtime_root,
Event::Key(KeyEventData::from_crossterm(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
))),
);
assert!(outcome.handled);
assert_eq!(key_hits.load(Ordering::SeqCst), 1);
}
#[test]
fn command_palette_renders_from_runtime_root_in_tree_mode() {
let mut app = super::App::new().expect("app should initialize");
let mut root = crate::widgets::CommandPalette::new(crate::widgets::Label::new("body"));
app.build_widget_tree(&mut root);
assert!(app.widget_tree.is_some(), "tree mode should be active");
if let Some(tree) = app.widget_tree.as_mut() {
let _ = sync_widget_controlled_child_display_tree(tree, &root);
}
let outcome = app.dispatch_event_auto(&mut root, Event::Action(Action::CommandPalette));
assert!(
outcome.handled,
"open action should be handled by runtime root"
);
let msg_outcome = app.dispatch_message_queue_with_runtime(&mut root, outcome.messages);
assert!(!msg_outcome.stop_requested);
if let Some(tree) = app.widget_tree.as_mut() {
let changed = sync_widget_controlled_child_display_tree(tree, &root);
assert!(
!changed,
"opening palette should preserve wrapped subtree display in tree mode"
);
}
app.render_widget(&mut root).expect("render should succeed");
let lines = app.frame.as_plain_lines().join("\n");
assert!(
lines.contains("Search for commands"),
"opened palette UI should render in tree mode"
);
}
#[test]
fn command_palette_action_dismisses_visible_system_tooltip_immediately() {
struct TooltipHost;
impl Widget for TooltipHost {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn tooltip(&self) -> Option<String> {
Some("Open command palette".to_string())
}
}
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let tooltip_owner = tree.mount(root_id, Box::new(TooltipHost));
tree.mount(
root_id,
Box::new(crate::widgets::CommandPalette::new(
crate::widgets::Label::new("body"),
)),
);
App::mount_system_tooltip(&mut tree, root_id);
if let Some(node) = tree.get_mut(tooltip_owner) {
node.layout_rect = crate::widget_tree::Rect {
x0: 0,
y0: 0,
x1: 8,
y1: 1,
};
node.content_rect = node.layout_rect;
}
let mut app = test_app_with_tree(tree);
app.options.size = (80, 24);
app.options.max_width = 80;
app.options.max_height = 24;
app.hovered = Some(tooltip_owner);
assert!(app.update_hover_tooltip(1, 0));
let tooltip_id = app
.get_widget_by_id(crate::widgets::SYSTEM_TOOLTIP_STYLE_ID)
.expect("system tooltip should exist");
let visible_before = app
.active_widget_tree()
.and_then(|tree| tree.get(tooltip_id))
.map(|node| node.runtime_display)
.unwrap_or(false);
assert!(visible_before, "precondition: tooltip should be visible");
let mut runtime_root = AppRoot::new();
let outcome =
app.dispatch_event_auto(&mut runtime_root, Event::Action(Action::CommandPalette));
assert!(
outcome.repaint_requested,
"opening command palette should request repaint when dismissing tooltip"
);
let visible_after = app
.active_widget_tree()
.and_then(|tree| tree.get(tooltip_id))
.map(|node| node.runtime_display)
.unwrap_or(true);
assert!(
!visible_after,
"command palette open should dismiss tooltip immediately"
);
assert!(
!app.update_hover_tooltip(1, 0),
"command palette open should start a cooldown that suppresses immediate tooltip re-show"
);
}
#[test]
fn open_command_palette_routes_tick_away_from_focused_tree_widget() {
struct TickSwallowProbe {
ticks: Arc<AtomicUsize>,
}
impl Widget for TickSwallowProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn on_event(&mut self, event: &Event, ctx: &mut EventCtx) {
if matches!(event, Event::Tick(_)) {
self.ticks.fetch_add(1, Ordering::SeqCst);
ctx.set_handled();
}
}
fn focusable(&self) -> bool {
true
}
}
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let tick_hits = Arc::new(AtomicUsize::new(0));
let probe_id = tree.mount(
root_id,
Box::new(TickSwallowProbe {
ticks: Arc::clone(&tick_hits),
}),
);
if let Some(node) = tree.get_mut(probe_id) {
node.widget.set_focus(true);
}
tree.mount(
root_id,
Box::new(crate::widgets::CommandPalette::new(
crate::widgets::Label::new("body"),
)),
);
let mut app = test_app_with_tree(tree);
let mut runtime_root = StyleNode::new("RuntimeRoot");
let open =
app.dispatch_event_auto(&mut runtime_root, Event::Action(Action::CommandPalette));
assert!(
open.handled,
"open action should be handled by command palette"
);
let msg_outcome = app.dispatch_message_queue_with_runtime(&mut runtime_root, open.messages);
assert!(!msg_outcome.stop_requested);
let tick = app.dispatch_event_auto(&mut runtime_root, Event::Tick(1));
assert!(tick.handled, "open palette should handle tick in tree mode");
assert_eq!(
tick_hits.load(Ordering::SeqCst),
0,
"focused underlay widget should not receive tick while palette is open"
);
}
#[test]
fn dispatch_message_queue_auto_calls_app_message_when_root_message_unhandled() {
let mut app = super::App::new().expect("app should initialize");
let mut runtime_root = RootHookProbe {
key_hits: Arc::new(AtomicUsize::new(0)),
action_hits: Arc::new(AtomicUsize::new(0)),
app_action_hits: Arc::new(AtomicUsize::new(0)),
message_hits: Arc::new(AtomicUsize::new(0)),
app_message_hits: Arc::new(AtomicUsize::new(0)),
app_tick_hits: Arc::new(AtomicUsize::new(0)),
handle_key: false,
handle_action: true,
handle_message: false,
};
let outcome = app.dispatch_message_queue_auto(
&mut runtime_root,
vec![MessageEvent {
sender: node_id_from_ffi(7),
message: Message::FooterBindingsUpdated(crate::message::FooterBindingsUpdated {
count: 0,
}),
control: None,
}],
);
assert_eq!(runtime_root.message_hits.load(Ordering::SeqCst), 1);
assert_eq!(runtime_root.app_message_hits.load(Ordering::SeqCst), 1);
assert!(outcome.handled);
}
#[test]
fn binding_hints_dispatch_when_sources_change_with_same_hints() {
let hints = vec![BindingHint::new("tab", "next")];
let last_sources = vec![node_id_from_ffi(1)];
let current_sources = vec![node_id_from_ffi(2)];
assert!(should_dispatch_binding_hints(
&hints,
&last_sources,
&hints,
¤t_sources,
));
}
#[test]
fn binding_hints_skip_when_hints_and_sources_are_stable() {
let hints = vec![BindingHint::new("tab", "next")];
let sources = vec![node_id_from_ffi(1)];
assert!(!should_dispatch_binding_hints(
&hints, &sources, &hints, &sources,
));
}
#[test]
fn dispatch_binding_hints_changed_includes_root_bindings_in_tree_mode() {
let mut app = App::new().expect("app should initialize");
let mut root = RootBindingsHost::default();
app.build_widget_tree(&mut root);
assert!(app.widget_tree.is_some(), "tree mode should be active");
let _ = app.dispatch_binding_hints_changed(&mut root);
assert!(
app.last_binding_hints
.iter()
.any(|hint| hint.key == "l" && hint.description == "Leto"),
"root-declared app bindings should be present in computed binding hints"
);
}
#[test]
fn app_simulate_key_uses_binding_pipeline_before_action_map_fallback() {
let hits_l = Arc::new(AtomicUsize::new(0));
let hits_j = Arc::new(AtomicUsize::new(0));
let hits_p = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
tree.set_root(Box::new(SimulatedKeyBindingHost {
hits_l: Arc::clone(&hits_l),
hits_j: Arc::clone(&hits_j),
hits_p: Arc::clone(&hits_p),
}));
let mut app = test_app_with_tree(tree);
let mut runtime_root = StyleNode::new("RuntimeRoot");
for key in ["j", "p", "l"] {
let _outcome = app.dispatch_message_queue_with_runtime(
&mut runtime_root,
vec![MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppSimulateKey(crate::message::AppSimulateKey {
key: key.to_string(),
}),
control: Some(node_id_from_ffi(1)),
}],
);
}
assert_eq!(hits_j.load(Ordering::SeqCst), 1, "j binding should fire");
assert_eq!(hits_p.load(Ordering::SeqCst), 1, "p binding should fire");
assert_eq!(hits_l.load(Ordering::SeqCst), 1, "l binding should fire");
}
#[test]
fn focused_help_dispatches_when_focus_source_changes() {
assert!(should_dispatch_focused_help(
Some(node_id_from_ffi(1)),
Some("## First"),
Some(node_id_from_ffi(2)),
Some("## Second"),
));
}
#[test]
fn focused_help_dispatches_when_help_clears() {
assert!(should_dispatch_focused_help(
Some(node_id_from_ffi(1)),
Some("## First"),
None,
None,
));
}
#[test]
fn focused_help_skips_when_source_and_markup_stable() {
assert!(!should_dispatch_focused_help(
Some(node_id_from_ffi(1)),
Some("## Stable"),
Some(node_id_from_ffi(1)),
Some("## Stable"),
));
}
#[test]
fn focused_help_message_emits_set_payload() {
let source = node_id_from_ffi(7);
let event = focused_help_message(Some((source, "## Source help".to_string())));
assert_eq!(event.sender, source);
assert!(matches!(
event.message,
Message::HelpPanelFocusedHelpChanged(crate::message::HelpPanelFocusedHelpChanged {
source: msg_source,
markup,
}) if msg_source == source && markup == "## Source help"
));
}
#[test]
fn focused_help_message_emits_clear_payload() {
let event = focused_help_message(None);
assert_eq!(event.sender, node_id_from_ffi(0));
assert!(matches!(
event.message,
Message::HelpPanelFocusedHelpCleared(_)
));
}
#[test]
fn clipboard_runtime_handles_copy_then_paste_request() {
let target = node_id_from_ffi(42);
let mut clipboard = None;
let mut backend = StubClipboardBackend {
copy_results: VecDeque::from(vec![true]),
paste_results: VecDeque::from(vec![None]),
copied: Vec::new(),
};
let generated = collect_clipboard_runtime_messages_with_backend(
&mut clipboard,
&[
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::TextEditClipboardCopyRequested(
crate::message::TextEditClipboardCopyRequested {
text: "hello".to_string(),
cut: false,
},
),
control: None,
},
MessageEvent {
sender: node_id_from_ffi(2),
message: Message::TextEditClipboardPasteRequested(
crate::message::TextEditClipboardPasteRequested { target },
),
control: None,
},
],
&mut backend,
);
assert_eq!(clipboard.as_deref(), Some("hello"));
assert_eq!(backend.copied, vec!["hello".to_string()]);
assert_eq!(generated.len(), 1);
assert!(matches!(
&generated[0].message,
Message::TextEditClipboardPaste(crate::message::TextEditClipboardPaste {
target: t,
text
}) if *t == target && text == "hello"
));
}
#[test]
fn clipboard_runtime_ignores_paste_without_buffered_text() {
let target = node_id_from_ffi(7);
let mut clipboard = None;
let mut backend = StubClipboardBackend::default();
let generated = collect_clipboard_runtime_messages_with_backend(
&mut clipboard,
&[MessageEvent {
sender: node_id_from_ffi(2),
message: Message::TextEditClipboardPasteRequested(
crate::message::TextEditClipboardPasteRequested { target },
),
control: None,
}],
&mut backend,
);
assert!(generated.is_empty());
}
#[test]
fn clipboard_runtime_prefers_system_clipboard_on_paste() {
let target = node_id_from_ffi(9);
let mut clipboard = Some("fallback".to_string());
let mut backend = StubClipboardBackend {
copy_results: VecDeque::new(),
paste_results: VecDeque::from(vec![Some("system".to_string())]),
copied: Vec::new(),
};
let generated = collect_clipboard_runtime_messages_with_backend(
&mut clipboard,
&[MessageEvent {
sender: node_id_from_ffi(2),
message: Message::TextEditClipboardPasteRequested(
crate::message::TextEditClipboardPasteRequested { target },
),
control: None,
}],
&mut backend,
);
assert_eq!(clipboard.as_deref(), Some("system"));
assert_eq!(generated.len(), 1);
assert!(matches!(
&generated[0].message,
Message::TextEditClipboardPaste(crate::message::TextEditClipboardPaste { target: t, text }) if *t == target && text == "system"
));
}
struct StyleNode {
_node_id: NodeId,
type_name: &'static str,
style_id: Option<String>,
classes: Vec<String>,
focused: bool,
children: Vec<StyleNode>,
}
impl StyleNode {
fn new(type_name: &'static str) -> Self {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Self {
_node_id: node_id_from_ffi(n),
type_name,
style_id: None,
classes: Vec::new(),
focused: false,
children: Vec::new(),
}
}
fn with_class(mut self, class: &str) -> Self {
self.classes.push(class.to_string());
self
}
fn with_focus(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
fn with_child(mut self, child: StyleNode) -> Self {
self.children.push(child);
self
}
}
impl Widget for StyleNode {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn style_type(&self) -> &'static str {
self.type_name
}
fn style_id(&self) -> Option<&str> {
self.style_id.as_deref()
}
fn style_classes(&self) -> &[String] {
&self.classes
}
fn has_focus(&self) -> bool {
self.focused
}
}
fn build_tree_from_style_node(node: StyleNode) -> (crate::widget_tree::WidgetTree, NodeId) {
let mut tree = crate::widget_tree::WidgetTree::new();
fn insert(
tree: &mut crate::widget_tree::WidgetTree,
mut node: StyleNode,
parent: Option<NodeId>,
) -> NodeId {
let children = std::mem::take(&mut node.children);
let id = if let Some(p) = parent {
tree.mount(p, Box::new(node))
} else {
tree.set_root(Box::new(node))
};
for child in children {
insert(tree, child, Some(id));
}
id
}
let root_id = insert(&mut tree, node, None);
(tree, root_id)
}
#[test]
fn stylesheet_invalidation_matches_selectively_via_tree() {
let button = StyleNode::new("Button").with_class("special");
let root_node = StyleNode::new("Container")
.with_class("panel")
.with_child(button)
.with_child(StyleNode::new("Label"));
let (tree, _root_id) = build_tree_from_style_node(root_node);
let changed = StyleSheet::parse("Container.panel > Button.special { bg: #334455; }");
let affected = collect_stylesheet_affected_widgets_root(
tree.get(_root_id).unwrap().widget.as_ref(),
changed.rules(),
true,
crate::css::AppRuntimePseudos::default(),
);
assert!(affected.is_empty());
let affected_tree = super::collect_stylesheet_affected_widgets_tree(
&tree,
changed.rules(),
true,
crate::css::AppRuntimePseudos::default(),
);
assert_eq!(affected_tree.len(), 1);
}
#[test]
fn stylesheet_invalidation_respects_focus_pseudo_state_via_tree() {
let button = StyleNode::new("Button").with_focus(true);
let root_node = StyleNode::new("Container").with_child(button);
let (tree, _root_id) = build_tree_from_style_node(root_node);
let changed = StyleSheet::parse("Button:focus { fg: #ffffff; }");
let affected_active = super::collect_stylesheet_affected_widgets_tree(
&tree,
changed.rules(),
true,
crate::css::AppRuntimePseudos::default(),
);
let affected_inactive = super::collect_stylesheet_affected_widgets_tree(
&tree,
changed.rules(),
false,
crate::css::AppRuntimePseudos::default(),
);
assert!(!affected_active.is_empty());
assert!(affected_inactive.is_empty());
}
#[test]
fn overlay_visibility_hides_modal_subtree_display_in_tree_mode() {
let root_node = StyleNode::new("Container").with_child(
StyleNode::new("Overlay")
.with_child(StyleNode::new("Base"))
.with_child(StyleNode::new("Modal").with_child(StyleNode::new("ModalBody"))),
);
let (mut tree, root_id) = build_tree_from_style_node(root_node);
let overlay_id = tree.children(root_id)[0];
let base_id = tree.children(overlay_id)[0];
let modal_id = tree.children(overlay_id)[1];
let modal_body_id = tree.children(modal_id)[0];
assert!(set_overlay_modal_display_tree(&mut tree, overlay_id, false));
assert!(
tree.get(base_id).unwrap().display,
"base child stays displayed"
);
assert!(!tree.get(modal_id).unwrap().display, "modal root hidden");
assert!(
!tree.get(modal_body_id).unwrap().display,
"modal descendants hidden"
);
}
#[test]
fn overlay_visibility_show_restores_modal_subtree_display_in_tree_mode() {
let root_node = StyleNode::new("Container").with_child(
StyleNode::new("Overlay")
.with_child(StyleNode::new("Base"))
.with_child(StyleNode::new("Modal").with_child(StyleNode::new("ModalBody"))),
);
let (mut tree, root_id) = build_tree_from_style_node(root_node);
let overlay_id = tree.children(root_id)[0];
let modal_id = tree.children(overlay_id)[1];
let modal_body_id = tree.children(modal_id)[0];
assert!(set_overlay_modal_display_tree(&mut tree, overlay_id, false));
assert!(set_overlay_modal_display_tree(&mut tree, overlay_id, true));
assert!(tree.get(modal_id).unwrap().display, "modal root shown");
assert!(
tree.get(modal_body_id).unwrap().display,
"modal descendants shown"
);
}
#[test]
fn p2g36_runtime_transition_dispatch_matches_changed_properties() {
let target = node_id_from_ffi(99);
let old = Style::new().opacity(10).text_opacity(30);
let mut new = Style::new().opacity(80).text_opacity(30);
new.transitions = Some(vec![
PropertyTransition {
property: "opacity".to_string(),
duration: std::time::Duration::from_millis(250),
timing: TransitionTiming::Linear,
delay: std::time::Duration::from_millis(20),
},
PropertyTransition {
property: "offset_y".to_string(),
duration: std::time::Duration::from_millis(500),
timing: TransitionTiming::InOutCubic,
delay: std::time::Duration::ZERO,
},
]);
let (requests, style_requests) = transition_requests_for_style_change(target, &old, &new);
assert!(style_requests.is_empty(), "opacity is a numeric property");
assert_eq!(
requests.len(),
1,
"only changed+transitioned property should animate"
);
assert_eq!(requests[0].target, target);
assert_eq!(requests[0].attribute, "opacity");
assert_eq!(requests[0].start, 10.0);
assert_eq!(requests[0].end, 80.0);
assert_eq!(requests[0].duration, std::time::Duration::from_millis(250));
assert_eq!(requests[0].delay, std::time::Duration::from_millis(20));
}
#[test]
fn p2g36_runtime_transition_dispatch_handles_css_hyphen_names() {
let target = node_id_from_ffi(101);
let mut old = Style::new();
old.offset = Some(Offset {
x: OffsetValue::Cells(0),
y: OffsetValue::Cells(0),
});
let mut new = Style::new();
new.offset = Some(Offset {
x: OffsetValue::Cells(0),
y: OffsetValue::Cells(6),
});
new.transitions = Some(vec![PropertyTransition {
property: "offset-y".to_string(),
duration: std::time::Duration::from_millis(120),
timing: TransitionTiming::OutCubic,
delay: std::time::Duration::ZERO,
}]);
let (requests, style_requests) = transition_requests_for_style_change(target, &old, &new);
assert!(style_requests.is_empty(), "offset_y is a numeric property");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].attribute, "offset_y");
assert_eq!(requests[0].start, 0.0);
assert_eq!(requests[0].end, 6.0);
assert_eq!(requests[0].duration, std::time::Duration::from_millis(120));
}
#[test]
fn p2g36_transition_emits_style_request_for_bg_change() {
use crate::event::{AnimationEase, StyleValue};
use crate::style::{Color, PropertyTransition, TransitionTiming};
let target = node_id_from_ffi(200);
let mut old = Style::new();
old.bg = Some(Color::rgb(0, 0, 0));
let mut new = Style::new();
new.bg = Some(Color::rgb(255, 0, 0));
new.transitions = Some(vec![PropertyTransition {
property: "bg".to_string(),
duration: std::time::Duration::from_millis(300),
timing: TransitionTiming::Linear,
delay: std::time::Duration::ZERO,
}]);
let (numeric, style) = transition_requests_for_style_change(target, &old, &new);
assert!(
numeric.is_empty(),
"bg is a style-value property, not numeric"
);
assert_eq!(
style.len(),
1,
"should emit one StyleAnimationRequest for bg"
);
assert_eq!(style[0].target, target);
assert_eq!(style[0].property, "bg");
assert_eq!(style[0].from, StyleValue::Color(Color::rgb(0, 0, 0)));
assert_eq!(style[0].to, StyleValue::Color(Color::rgb(255, 0, 0)));
assert_eq!(style[0].duration, std::time::Duration::from_millis(300));
assert_eq!(style[0].ease, AnimationEase::Linear);
}
#[test]
fn p2g36_transition_emits_style_request_for_fg_and_margin() {
use crate::event::StyleValue;
use crate::style::{Color, PropertyTransition, Spacing, TransitionTiming};
let target = node_id_from_ffi(201);
let mut old = Style::new();
old.fg = Some(Color::rgb(10, 20, 30));
old.margin = Some(Spacing::all(0));
let mut new = Style::new();
new.fg = Some(Color::rgb(100, 200, 255));
new.margin = Some(Spacing::all(4));
new.transitions = Some(vec![
PropertyTransition {
property: "fg".to_string(),
duration: std::time::Duration::from_millis(200),
timing: TransitionTiming::InOutCubic,
delay: std::time::Duration::ZERO,
},
PropertyTransition {
property: "margin".to_string(),
duration: std::time::Duration::from_millis(150),
timing: TransitionTiming::Linear,
delay: std::time::Duration::ZERO,
},
]);
let (numeric, style) = transition_requests_for_style_change(target, &old, &new);
assert!(numeric.is_empty());
assert_eq!(style.len(), 2);
let fg_req = style.iter().find(|r| r.property == "fg").unwrap();
assert_eq!(fg_req.from, StyleValue::Color(Color::rgb(10, 20, 30)));
assert_eq!(fg_req.to, StyleValue::Color(Color::rgb(100, 200, 255)));
let margin_req = style.iter().find(|r| r.property == "margin").unwrap();
assert_eq!(margin_req.from, StyleValue::Spacing(Spacing::all(0)));
assert_eq!(margin_req.to, StyleValue::Spacing(Spacing::all(4)));
}
#[test]
fn p2g36_transition_no_request_when_property_unchanged() {
use crate::style::{Color, PropertyTransition, TransitionTiming};
let target = node_id_from_ffi(202);
let mut old = Style::new();
old.bg = Some(Color::rgb(50, 50, 50));
let mut new = Style::new();
new.bg = Some(Color::rgb(50, 50, 50)); new.transitions = Some(vec![PropertyTransition {
property: "bg".to_string(),
duration: std::time::Duration::from_millis(300),
timing: TransitionTiming::Linear,
delay: std::time::Duration::ZERO,
}]);
let (numeric, style) = transition_requests_for_style_change(target, &old, &new);
assert!(numeric.is_empty());
assert!(
style.is_empty(),
"identical values should not produce animation requests"
);
}
#[test]
fn p2g36_apply_style_value_to_property_sets_correct_fields() {
use super::apply_style_value_to_property;
use crate::event::StyleValue;
use crate::style::{Color, Scalar, Spacing, Tint};
let mut style = Style::new();
apply_style_value_to_property(&mut style, "bg", &StyleValue::Color(Color::rgb(1, 2, 3)));
assert_eq!(style.bg, Some(Color::rgb(1, 2, 3)));
apply_style_value_to_property(&mut style, "fg", &StyleValue::Color(Color::rgb(4, 5, 6)));
assert_eq!(style.fg, Some(Color::rgb(4, 5, 6)));
apply_style_value_to_property(&mut style, "width", &StyleValue::Scalar(Scalar::Cells(42)));
assert_eq!(style.width, Some(Scalar::Cells(42)));
apply_style_value_to_property(
&mut style,
"height",
&StyleValue::Scalar(Scalar::Percent(75.0)),
);
assert_eq!(style.height, Some(Scalar::Percent(75.0)));
let sp = Spacing {
top: 2,
right: 4,
bottom: 2,
left: 4,
};
apply_style_value_to_property(&mut style, "margin", &StyleValue::Spacing(sp));
assert_eq!(style.margin, Some(sp));
apply_style_value_to_property(&mut style, "padding", &StyleValue::Spacing(sp));
assert_eq!(style.padding, Some(sp));
let tint = Tint::new(Color::rgb(255, 0, 0), 50);
apply_style_value_to_property(&mut style, "tint", &StyleValue::Tint(tint));
assert_eq!(style.tint, Some(tint));
apply_style_value_to_property(&mut style, "background_tint", &StyleValue::Tint(tint));
assert_eq!(style.background_tint, Some(tint));
}
#[test]
fn worker_accumulator_drain_empty() {
let _ = super::drain_accumulated_worker_requests();
let drained = super::drain_accumulated_worker_requests();
assert!(drained.is_empty());
}
#[test]
fn worker_accumulator_collects_from_outcome() {
use crate::worker::{WorkerRequest, WorkerRequestPayload};
let _ = super::drain_accumulated_worker_requests();
let mut outcome = super::DispatchOutcome {
worker_requests: vec![
WorkerRequest {
owner: node_id_from_ffi(1),
exclusive_key: None,
name: Some("w1".into()),
payload: WorkerRequestPayload::default(),
},
WorkerRequest {
owner: node_id_from_ffi(2),
exclusive_key: Some("exc".into()),
name: None,
payload: WorkerRequestPayload::default(),
},
],
..Default::default()
};
super::accumulate_worker_requests(&mut outcome);
assert!(
outcome.worker_requests.is_empty(),
"should drain from outcome"
);
let drained = super::drain_accumulated_worker_requests();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].name.as_deref(), Some("w1"));
assert_eq!(drained[1].exclusive_key.as_deref(), Some("exc"));
let drained2 = super::drain_accumulated_worker_requests();
assert!(drained2.is_empty());
}
#[test]
fn worker_accumulator_multiple_outcomes() {
use crate::worker::{WorkerRequest, WorkerRequestPayload};
let _ = super::drain_accumulated_worker_requests();
let mut o1 = super::DispatchOutcome {
worker_requests: vec![WorkerRequest {
owner: node_id_from_ffi(1),
exclusive_key: None,
name: Some("a".into()),
payload: WorkerRequestPayload::default(),
}],
..Default::default()
};
let mut o2 = super::DispatchOutcome {
worker_requests: vec![WorkerRequest {
owner: node_id_from_ffi(2),
exclusive_key: None,
name: Some("b".into()),
payload: WorkerRequestPayload::default(),
}],
..Default::default()
};
super::accumulate_worker_requests(&mut o1);
super::accumulate_worker_requests(&mut o2);
let drained = super::drain_accumulated_worker_requests();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].name.as_deref(), Some("a"));
assert_eq!(drained[1].name.as_deref(), Some("b"));
}
#[test]
fn worker_accumulator_empty_outcome_is_noop() {
let _ = super::drain_accumulated_worker_requests();
let mut outcome = super::DispatchOutcome::default();
super::accumulate_worker_requests(&mut outcome);
let drained = super::drain_accumulated_worker_requests();
assert!(drained.is_empty());
}
#[test]
fn worker_full_pipeline_ctx_to_registry() {
use crate::event::EventCtx;
use crate::worker::{WorkerRegistry, WorkerState, process_worker_requests};
let _ = super::drain_accumulated_worker_requests();
let owner = node_id_from_ffi(42);
let mut ctx = EventCtx::default();
ctx.set_node_id(owner);
ctx.request_worker(Some("bg-job"));
ctx.request_exclusive_worker("search", Some("searcher"));
let requests = ctx.take_worker_requests();
assert_eq!(requests.len(), 2);
let mut outcome = super::DispatchOutcome {
worker_requests: requests,
..Default::default()
};
super::accumulate_worker_requests(&mut outcome);
assert!(outcome.worker_requests.is_empty());
let pending = super::drain_accumulated_worker_requests();
assert_eq!(pending.len(), 2);
let mut registry = WorkerRegistry::new();
let mut changes = process_worker_requests(&mut registry, pending);
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(150);
while changes.len() < 2 && std::time::Instant::now() < deadline {
let mut batch = process_worker_requests(&mut registry, Vec::new());
if batch.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(1));
continue;
}
changes.append(&mut batch);
}
assert_eq!(changes.len(), 2);
assert_eq!(changes[0].state, WorkerState::Success);
assert_eq!(changes[1].state, WorkerState::Success);
registry.cleanup();
assert!(registry.active_workers().is_empty());
}
#[test]
fn worker_request_processing_in_runtime_hot_path_is_non_blocking() {
use crate::worker::{WorkerRegistry, WorkerRequest, WorkerRequestPayload, WorkerState};
let owner = node_id_from_ffi(90);
let mut registry = WorkerRegistry::new();
let delayed_request = WorkerRequest {
owner,
exclusive_key: None,
name: Some("delayed".into()),
payload: WorkerRequestPayload::ComputeDigest {
input: "payload".into(),
rounds: 1,
delay_per_round_ms: 80,
fail_with: None,
},
};
let start = std::time::Instant::now();
let first = crate::worker::process_worker_requests(&mut registry, vec![delayed_request]);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(40),
"worker processing should not block waiting for completion; elapsed={elapsed:?}"
);
assert!(
first.is_empty(),
"delayed worker completion should not be synchronously delivered"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(250);
let mut completed = Vec::new();
while completed.is_empty() && std::time::Instant::now() < deadline {
completed = crate::worker::process_worker_requests(&mut registry, Vec::new());
if completed.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
assert_eq!(completed.len(), 1);
assert_eq!(completed[0].state, WorkerState::Success);
}
struct WorkerDeliveryProbe {
success_hits: Arc<AtomicUsize>,
error_hits: Arc<AtomicUsize>,
}
impl Widget for WorkerDeliveryProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn on_message(&mut self, message: &MessageEvent, ctx: &mut EventCtx) {
if let Message::WorkerStateChanged(crate::message::WorkerStateChanged {
state, ..
}) = &message.message
{
match state {
crate::worker::WorkerState::Success => {
self.success_hits.fetch_add(1, Ordering::Relaxed);
ctx.set_handled();
}
crate::worker::WorkerState::Error(_) => {
self.error_hits.fetch_add(1, Ordering::Relaxed);
ctx.set_handled();
}
_ => {}
}
}
}
}
#[test]
fn worker_state_changes_route_to_owning_widgets_via_message_pipeline() {
use crate::worker::{
WorkerRegistry, WorkerRequest, WorkerRequestPayload, WorkerState,
process_worker_requests,
};
let success_hits = Arc::new(AtomicUsize::new(0));
let error_hits = Arc::new(AtomicUsize::new(0));
let bystander_hits = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let owner_success = tree.mount(
root_id,
Box::new(WorkerDeliveryProbe {
success_hits: Arc::clone(&success_hits),
error_hits: Arc::new(AtomicUsize::new(0)),
}),
);
let owner_error = tree.mount(
root_id,
Box::new(WorkerDeliveryProbe {
success_hits: Arc::new(AtomicUsize::new(0)),
error_hits: Arc::clone(&error_hits),
}),
);
let _bystander = tree.mount(
root_id,
Box::new(WorkerDeliveryProbe {
success_hits: Arc::clone(&bystander_hits),
error_hits: Arc::clone(&bystander_hits),
}),
);
let mut registry = WorkerRegistry::new();
let (errored_worker, _) = registry.register(owner_error, None, Some("errored".into()));
registry.set_running(errored_worker);
registry.complete(errored_worker, Err("boom".into()));
let requests = vec![WorkerRequest {
owner: owner_success,
exclusive_key: None,
name: Some("ok".into()),
payload: WorkerRequestPayload::default(),
}];
let mut changes = process_worker_requests(&mut registry, requests);
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(150);
while changes.len() < 2 && std::time::Instant::now() < deadline {
let mut batch = process_worker_requests(&mut registry, Vec::new());
if batch.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(1));
continue;
}
changes.append(&mut batch);
}
assert_eq!(changes.len(), 2);
assert!(
changes
.iter()
.any(|c| c.worker_id == errored_worker
&& c.state == WorkerState::Error("boom".into()))
);
assert!(changes.iter().any(|c| c.state == WorkerState::Success));
let messages = super::worker_state_runtime_messages(®istry, changes);
assert_eq!(messages.len(), 2);
assert!(
messages
.iter()
.all(|event| event.control == Some(event.sender))
);
assert!(messages.iter().any(|event| event.sender == owner_success
&& matches!(
event.message,
Message::WorkerStateChanged(crate::message::WorkerStateChanged {
state: WorkerState::Success,
..
})
)));
assert!(messages.iter().any(|event| event.sender == owner_error
&& matches!(
event.message,
Message::WorkerStateChanged(crate::message::WorkerStateChanged {
state: WorkerState::Error(ref err),
..
}) if err == "boom"
)));
let mut app = test_app_with_tree(tree);
let mut runtime_root = StyleNode::new("RuntimeRoot");
let routed = app.dispatch_message_queue_with_runtime(&mut runtime_root, messages);
assert!(routed.handled);
assert_eq!(success_hits.load(Ordering::Relaxed), 1);
assert_eq!(error_hits.load(Ordering::Relaxed), 1);
assert_eq!(bystander_hits.load(Ordering::Relaxed), 0);
}
#[test]
fn worker_state_runtime_messages_fallback_to_runtime_sender_when_owner_missing() {
let registry = crate::worker::WorkerRegistry::new();
let orphan_change = crate::worker::WorkerStateChanged {
worker_id: crate::worker::WorkerId::new(),
state: crate::worker::WorkerState::Cancelled,
};
let messages = super::worker_state_runtime_messages(®istry, vec![orphan_change]);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender, crate::node_id::node_id_from_ffi(0));
assert_eq!(
messages[0].control,
Some(crate::node_id::node_id_from_ffi(0))
);
assert!(matches!(
messages[0].message,
Message::WorkerStateChanged(crate::message::WorkerStateChanged {
state: crate::worker::WorkerState::Cancelled,
..
})
));
}
#[test]
fn runtime_app_selector_messages_mutate_tree_and_request_layout_invalidation() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
tree.mount(root_id, Box::new(crate::widgets::Button::new("go")));
let mut app = test_app_with_tree(tree);
let mut runtime_root = StyleNode::new("RuntimeRoot");
let messages = vec![MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppAddClass(crate::message::AppAddClass {
selector: "Button".to_string(),
class_name: "highlight".to_string(),
}),
control: Some(node_id_from_ffi(1)),
}];
let outcome = app.dispatch_message_queue_with_runtime(&mut runtime_root, messages);
assert!(outcome.repaint_requested);
assert!(outcome.invalidation.layout);
let highlighted = app.query(".highlight").expect("selector parses");
assert_eq!(highlighted.len(), 1);
}
struct ChainedAppSelectorEmitter;
impl Widget for ChainedAppSelectorEmitter {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn on_message(&mut self, message: &MessageEvent, ctx: &mut EventCtx) {
if matches!(message.message, Message::FooterBindingsUpdated(..)) {
ctx.post_message(Message::AppAddClass(crate::message::AppAddClass {
selector: "Button".to_string(),
class_name: "from-chained-message".to_string(),
}));
ctx.set_handled();
}
}
}
#[test]
fn runtime_dispatch_processes_messages_emitted_during_message_handling() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let emitter_id = tree.mount(root_id, Box::new(ChainedAppSelectorEmitter));
tree.mount(root_id, Box::new(crate::widgets::Button::new("go")));
let mut app = test_app_with_tree(tree);
let mut runtime_root = StyleNode::new("RuntimeRoot");
let initial = vec![MessageEvent {
sender: emitter_id,
message: Message::FooterBindingsUpdated(crate::message::FooterBindingsUpdated {
count: 0,
}),
control: Some(emitter_id),
}];
let outcome = app.dispatch_message_queue_with_runtime(&mut runtime_root, initial);
assert!(outcome.repaint_requested);
assert!(outcome.invalidation.layout);
let highlighted = app
.query(".from-chained-message")
.expect("selector should parse");
assert_eq!(
highlighted.len(),
1,
"messages emitted from on_message handlers must flow back through runtime control routing"
);
}
struct FocusIdProbe {
id: String,
focused: bool,
}
impl FocusIdProbe {
fn new(id: &str) -> Self {
Self {
id: id.to_string(),
focused: false,
}
}
}
impl Widget for FocusIdProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn style_id(&self) -> Option<&str> {
Some(&self.id)
}
fn focusable(&self) -> bool {
true
}
fn has_focus(&self) -> bool {
self.focused
}
fn set_focus(&mut self, focused: bool) {
self.focused = focused;
}
}
#[test]
fn app_blur_clears_tree_focus_and_remembers_last_focused_node() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let first = tree.mount(root_id, Box::new(FocusIdProbe::new("first")));
let _second = tree.mount(root_id, Box::new(FocusIdProbe::new("second")));
if let Some(node) = tree.get_mut(first) {
node.widget.set_focus(true);
}
let mut app = test_app_with_tree(tree);
app.apply_app_blur_focus_state();
assert!(!app.app_active);
assert_eq!(app.last_focused_on_app_blur, Some(first));
let first_focused = app
.with_widget_mut(first, |widget| widget.has_focus())
.expect("first widget should exist");
assert!(!first_focused);
}
#[test]
fn app_focus_restores_blurred_focus_when_no_new_focus_exists() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let first = tree.mount(root_id, Box::new(FocusIdProbe::new("first")));
let second = tree.mount(root_id, Box::new(FocusIdProbe::new("second")));
if let Some(node) = tree.get_mut(first) {
node.widget.set_focus(true);
}
let mut app = test_app_with_tree(tree);
app.apply_app_blur_focus_state();
app.apply_app_focus_restore_state();
assert!(app.app_active);
assert_eq!(app.last_focused_on_app_blur, None);
let first_focused = app
.with_widget_mut(first, |widget| widget.has_focus())
.expect("first widget should exist");
let second_focused = app
.with_widget_mut(second, |widget| widget.has_focus())
.expect("second widget should exist");
assert!(first_focused);
assert!(!second_focused);
}
struct RuntimeModeScreen;
impl crate::screen::Screen for RuntimeModeScreen {
fn compose(&self) -> Box<dyn Widget> {
Box::new(AppRoot::new())
}
}
#[derive(Default)]
struct HelpPanelMessageProbe {
show_messages: usize,
hide_messages: usize,
}
impl Widget for HelpPanelMessageProbe {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn on_message(&mut self, message: &MessageEvent, _ctx: &mut EventCtx) {
match message.message {
Message::AppShowHelpPanel(_) => self.show_messages += 1,
Message::AppHideHelpPanel(_) => self.hide_messages += 1,
_ => {}
}
}
}
#[test]
fn runtime_help_panel_control_messages_are_delivered_to_widgets() {
let mut app = App::new().expect("app runtime should initialize");
let mut root = HelpPanelMessageProbe::default();
let sender = node_id_from_ffi(1);
let messages = vec![
MessageEvent {
sender,
message: Message::AppShowHelpPanel(crate::message::AppShowHelpPanel),
control: Some(sender),
},
MessageEvent {
sender,
message: Message::AppHideHelpPanel(crate::message::AppHideHelpPanel),
control: Some(sender),
},
];
let _ = app.dispatch_message_queue_with_runtime(&mut root, messages);
assert_eq!(root.show_messages, 1);
assert_eq!(root.hide_messages, 1);
}
#[test]
fn app_copy_selected_text_falls_back_to_help_quit_notification() {
let mut app = App::new().expect("app runtime should initialize");
let mut root = StyleNode::new("RuntimeRoot");
let sender = node_id_from_ffi(1);
let outcome = app.dispatch_message_queue_with_runtime(
&mut root,
vec![MessageEvent {
sender,
message: Message::AppCopySelectedText(crate::message::AppCopySelectedText),
control: Some(sender),
}],
);
assert!(outcome.repaint_requested);
assert_eq!(app.notifications.len(), 1);
let note = app.notifications.last().expect("help quit notification");
assert_eq!(note.title, "Do you want to quit?");
assert!(
note.message.contains("Press"),
"help quit notification should include quit guidance"
);
}
#[test]
fn runtime_app_action_messages_cover_non_selector_paths() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let first = tree.mount(root_id, Box::new(FocusIdProbe::new("first")));
let _second = tree.mount(root_id, Box::new(FocusIdProbe::new("second")));
if let Some(node) = tree.get_mut(first) {
node.widget.set_focus(true);
}
let mut app = test_app_with_tree(tree);
app.set_suspend_process_impl_for_test(|| Ok(()));
app.add_mode("home", || Box::new(RuntimeModeScreen));
app.add_mode("main", || Box::new(RuntimeModeScreen));
let mut runtime_root = StyleNode::new("RuntimeRoot");
let screenshot_filename = format!(
"textual-rs-test-{}.svg",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time after epoch")
.as_nanos()
);
let screenshot_dir = std::env::temp_dir();
let screenshot_path = screenshot_dir.join(&screenshot_filename);
let screenshot_dir_str = screenshot_dir.to_string_lossy().to_string();
let messages = vec![
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppBell(crate::message::AppBell),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppChangeTheme(crate::message::AppChangeTheme),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppFocus(crate::message::AppFocus {
widget_id: "second".to_string(),
}),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppFocusNext(crate::message::AppFocusNext),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppFocusPrevious(crate::message::AppFocusPrevious),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppNotify(crate::message::AppNotify {
message: "hello".to_string(),
title: "title".to_string(),
severity: "warning".to_string(),
}),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppHelpQuit(crate::message::AppHelpQuit),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppCopySelectedText(crate::message::AppCopySelectedText),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppCommandPalette(crate::message::AppCommandPalette),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppShowHelpPanel(crate::message::AppShowHelpPanel),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppHideHelpPanel(crate::message::AppHideHelpPanel),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppPushScreen(crate::message::AppPushScreen {
screen: "home".to_string(),
}),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppPopScreen(crate::message::AppPopScreen),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppBack(crate::message::AppBack),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppSwitchMode(crate::message::AppSwitchMode {
mode: "home".to_string(),
}),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppSwitchScreen(crate::message::AppSwitchScreen {
screen: "main".to_string(),
}),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppScreenshot(crate::message::AppScreenshot {
filename: Some(screenshot_filename.clone()),
path: Some(screenshot_dir_str.clone()),
}),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppSimulateKey(crate::message::AppSimulateKey {
key: "tab".to_string(),
}),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppSuspendProcess(crate::message::AppSuspendProcess),
control: Some(node_id_from_ffi(1)),
},
MessageEvent {
sender: node_id_from_ffi(1),
message: Message::AppToggleDark(crate::message::AppToggleDark),
control: Some(node_id_from_ffi(1)),
},
];
let outcome = app.dispatch_message_queue_with_runtime(&mut runtime_root, messages);
assert!(outcome.repaint_requested);
assert!(app.notifications.len() >= 3);
let tree = app.widget_tree.as_ref().expect("tree should still exist");
assert!(
tree.walk_depth_first(tree.root().expect("root exists"))
.into_iter()
.any(|id| tree.get(id).is_some_and(|node| node.widget.has_focus())),
"one probe widget should stay focused"
);
assert!(app.screen_count() >= 1);
assert!(screenshot_path.exists());
let _ = std::fs::remove_file(&screenshot_path);
}
struct AppActionHost;
impl Widget for AppActionHost {
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn action_namespace(&self) -> &str {
"app"
}
fn action_registry(&self) -> &[ActionDecl] {
const ACTIONS: &[ActionDecl] = &[
ActionDecl {
name: "add_class",
namespace: "app",
description: "Add class",
default_binding: None,
},
ActionDecl {
name: "remove_class",
namespace: "app",
description: "Remove class",
default_binding: None,
},
ActionDecl {
name: "toggle_class",
namespace: "app",
description: "Toggle class",
default_binding: None,
},
];
ACTIONS
}
fn execute_action(&mut self, action: &ParsedAction, ctx: &mut EventCtx) -> bool {
if action.name != "add_class" || action.arguments.len() != 2 {
return false;
}
ctx.post_message(Message::AppAddClass(crate::message::AppAddClass {
selector: action.arguments[0].clone(),
class_name: action.arguments[1].clone(),
}));
ctx.set_handled();
true
}
}
#[test]
fn action_routing_app_add_class_uses_runtime_pipeline() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root = tree.set_root(Box::new(AppRoot::new()));
let app_node = tree.mount(root, Box::new(AppActionHost));
let button = tree.mount(app_node, Box::new(crate::widgets::Button::new("ok")));
if let Some(node) = tree.get_mut(button) {
node.widget.set_focus(true);
}
let mut app = test_app_with_tree(tree);
let parsed =
parse_action("app.add_class('Button', 'from-action')").expect("action should parse");
let resolved = {
let tree_ref = app.widget_tree.as_ref().expect("tree exists");
resolve_action(&parsed, tree_ref, button, |nid| {
tree_ref.get(nid).map(|node| {
(
node.widget.action_namespace(),
node.widget.action_registry(),
)
})
})
}
.expect("action should resolve");
assert_eq!(resolved.node, app_node);
let mut ctx = EventCtx::default();
if let Some(tree_mut) = app.widget_tree.as_mut()
&& let Some(node) = tree_mut.get_mut(resolved.node)
{
assert!(node.widget.execute_action(&parsed, &mut ctx));
}
let messages = ctx.take_messages();
assert_eq!(messages.len(), 1);
let mut runtime_root = StyleNode::new("RuntimeRoot");
let outcome = app.dispatch_message_queue_with_runtime(&mut runtime_root, messages);
assert!(outcome.repaint_requested);
assert!(outcome.invalidation.layout);
let mutated = app.query(".from-action").expect("selector should parse");
assert_eq!(mutated.len(), 1);
}
#[test]
fn action_dispatch_requested_message_executes_routed_action() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root = tree.set_root(Box::new(AppRoot::new()));
let app_node = tree.mount(root, Box::new(AppActionHost));
let button = tree.mount(app_node, Box::new(crate::widgets::Button::new("ok")));
if let Some(node) = tree.get_mut(button) {
node.widget.set_focus(true);
}
let mut app = test_app_with_tree(tree);
let mut runtime_root = StyleNode::new("RuntimeRoot");
let outcome = app.dispatch_message_queue_with_runtime(
&mut runtime_root,
vec![MessageEvent {
sender: button,
message: Message::ActionDispatchRequested(
crate::message::ActionDispatchRequested {
action: "app.add_class('Button', 'from-action')".to_string(),
},
),
control: Some(button),
}],
);
assert!(outcome.repaint_requested);
assert!(outcome.invalidation.layout);
let mutated = app.query(".from-action").expect("selector should parse");
assert_eq!(mutated.len(), 1);
}
#[test]
fn app_command_palette_message_dispatches_action_instead_of_synthetic_open_message() {
let mut app = super::App::new().expect("app should initialize");
let mut root = crate::widgets::CommandPalette::new(crate::widgets::Label::new("body"));
let sender = node_id_from_ffi(1);
let outcome = app.dispatch_message_queue_with_runtime(
&mut root,
vec![MessageEvent {
sender,
message: Message::AppCommandPalette(crate::message::AppCommandPalette),
control: Some(sender),
}],
);
let console = rich_rs::Console::new();
let mut options = console.options().clone();
options.size = (80, 20);
options.max_width = 80;
options.max_height = 20;
let buf = crate::render::FrameBuffer::from_renderable(&console, &options, &root, None);
let lines = buf.as_plain_lines().join("\n");
assert!(
lines.contains("Search for commands"),
"runtime should dispatch Action::CommandPalette and open the palette UI"
);
assert!(outcome.repaint_requested);
}
struct ReactivePhaseProbeWidget {
value: i32,
watch_calls: Arc<AtomicUsize>,
init_calls: Arc<AtomicUsize>,
emit_init: bool,
init_enabled: bool,
}
impl ReactivePhaseProbeWidget {
fn new(
watch_calls: Arc<AtomicUsize>,
init_calls: Arc<AtomicUsize>,
emit_init: bool,
init_enabled: bool,
) -> Self {
Self {
value: 0,
watch_calls,
init_calls,
emit_init,
init_enabled,
}
}
fn set_value(&mut self, value: i32) {
if self.value == value {
return;
}
let old = self.value;
self.value = value;
let node_id = self.node_id();
let mut rctx = ReactiveCtx::new(node_id);
rctx.record_change(
"value",
ReactiveFlags::reactive(),
Box::new(old),
Box::new(value),
);
enqueue_runtime_reactive_entry(crate::reactive::RuntimeReactiveEntry::new(
node_id, rctx,
));
}
fn enqueue_init_watcher(&mut self) {
if !self.emit_init || !self.init_enabled {
return;
}
let node_id = self.node_id();
let mut rctx = ReactiveCtx::new(node_id);
rctx.record_change(
"value",
ReactiveFlags::reactive(),
Box::new(self.value),
Box::new(self.value),
);
enqueue_runtime_reactive_entry(crate::reactive::RuntimeReactiveEntry::new(
node_id, rctx,
));
}
}
impl ReactiveWidget for ReactivePhaseProbeWidget {
fn reactive_dispatch(&mut self, changes: &[ReactiveChange], _ctx: &mut ReactiveCtx) {
for change in changes {
self.watch_calls.fetch_add(1, Ordering::SeqCst);
if let (Some(old), Some(new)) = (
change.old_value.downcast_ref::<i32>(),
change.new_value.downcast_ref::<i32>(),
) {
if old == new {
self.init_calls.fetch_add(1, Ordering::SeqCst);
}
}
}
}
}
impl Widget for ReactivePhaseProbeWidget {
fn reactive_widget(&mut self) -> Option<&mut dyn ReactiveWidget> {
Some(self)
}
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn on_event(&mut self, event: &Event, _ctx: &mut EventCtx) {
match event {
Event::Action(Action::Toggle) => self.set_value(1),
Event::Mount(_mount) => self.enqueue_init_watcher(),
_ => {}
}
}
}
fn test_app_with_tree(tree: crate::widget_tree::WidgetTree) -> crate::runtime::App {
let mut app = super::App::new().expect("app should initialize for runtime tests");
app.widget_tree = Some(tree);
app
}
#[test]
fn apply_layout_info_to_tree_uses_tree_geometry_not_hit_test_bounds() {
let mut tree = crate::widget_tree::WidgetTree::new();
let root_id = tree.set_root(Box::new(AppRoot::new()));
let log_id = tree.mount(
root_id,
Box::new(crate::widgets::Log::new().auto_scroll(false)),
);
if let Some(node) = tree.get_mut(root_id) {
node.layout_rect = crate::widget_tree::Rect {
x0: 0,
y0: 0,
x1: 80,
y1: 24,
};
node.content_rect = node.layout_rect;
}
if let Some(node) = tree.get_mut(log_id) {
node.layout_rect = crate::widget_tree::Rect {
x0: 0,
y0: 1,
x1: 69,
y1: 23,
};
node.content_rect = node.layout_rect;
}
let mut app = test_app_with_tree(tree);
app.hit_test.bounds.insert(
root_id,
crate::runtime::types::Rect {
x0: 69,
y0: 1,
x1: 79,
y1: 22,
},
);
app.hit_test.bounds.insert(
log_id,
crate::runtime::types::Rect {
x0: 0,
y0: 1,
x1: 11,
y1: 22,
},
);
app.apply_layout_info_to_tree();
let viewport = app
.active_widget_tree()
.and_then(|tree| tree.get(root_id))
.and_then(|node| node.widget.scroll_viewport_size())
.expect("AppRoot viewport should be available");
assert_eq!(
viewport,
(80, 24),
"viewport must follow solved layout rects, not painted hit-test bounds",
);
}
#[test]
fn reactive_phase_in_event_loop_runs_setter_watcher_and_repaint_invalidation() {
let _ = take_runtime_reactive_entries();
let watch_calls = Arc::new(AtomicUsize::new(0));
let init_calls = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
let target = tree.set_root(Box::new(ReactivePhaseProbeWidget::new(
Arc::clone(&watch_calls),
Arc::clone(&init_calls),
false,
false,
)));
let _ =
super::dispatch_event_to_target_tree(&mut tree, target, &Event::Action(Action::Toggle));
let mut app = test_app_with_tree(tree);
let mut pending = super::PendingInvalidation::default();
let mut root = StyleNode::new("Root");
app.run_event_loop_reactive_phase(&mut root, &mut pending);
assert_eq!(watch_calls.load(Ordering::SeqCst), 1);
assert!(pending.flags.content);
assert!(pending.is_dirty());
}
#[test]
fn reactive_phase_mount_init_watcher_respects_init_flag() {
let _ = take_runtime_reactive_entries();
let init_true_calls = Arc::new(AtomicUsize::new(0));
let init_false_calls = Arc::new(AtomicUsize::new(0));
let watch_calls = Arc::new(AtomicUsize::new(0));
let mut tree = crate::widget_tree::WidgetTree::new();
let root = tree.set_root(Box::new(StyleNode::new("Root")));
let init_true = tree.mount(
root,
Box::new(ReactivePhaseProbeWidget::new(
Arc::clone(&watch_calls),
Arc::clone(&init_true_calls),
true,
true,
)),
);
let init_false = tree.mount(
root,
Box::new(ReactivePhaseProbeWidget::new(
Arc::clone(&watch_calls),
Arc::clone(&init_false_calls),
true,
false,
)),
);
let _ = super::dispatch_event_to_target_tree(
&mut tree,
init_true,
&Event::Mount(MountEvent { node: init_true }),
);
let _ = super::dispatch_event_to_target_tree(
&mut tree,
init_false,
&Event::Mount(MountEvent { node: init_false }),
);
let mut app = test_app_with_tree(tree);
let mut pending = super::PendingInvalidation::default();
let mut runtime_root = StyleNode::new("RuntimeRoot");
app.run_event_loop_reactive_phase(&mut runtime_root, &mut pending);
assert_eq!(init_true_calls.load(Ordering::SeqCst), 1);
assert_eq!(init_false_calls.load(Ordering::SeqCst), 0);
assert!(pending.flags.content);
}
}