pub mod context;
pub mod handle;
pub mod panic_hook;
#[cfg(test)]
mod tests;
use std::io::{self, Stdout};
use std::panic::{self, AssertUnwindSafe};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crossterm::event::{self, Event as CtEvent, KeyCode, KeyModifiers};
use std::rc::Rc;
use crate::render::backend::Backend;
use crate::render::backend_crossterm::{CrosstermBackend, enter_tui_mode, leave_tui_mode};
use crate::render::{LayoutExt, PaintExt, Terminal, TerminalGuard};
use crate::runtime::router::Router;
use crate::runtime::selection::clipboard::{Clipboard, SystemClipboard};
use crate::runtime::url_opener::{SystemUrlOpener, UrlOpener};
use crate::style::{CascadeExt, DirtyTracker, Stylesheet};
use crate::{TuiDispatchExt, TuiDom, TuiEvent};
pub use context::{AppContext, ControlFlow};
pub use handle::AppHandle;
use handle::AppShared;
type TickCallback = Box<dyn FnMut(&mut AppContext<'_>) -> ControlFlow + 'static>;
#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)]
pub struct StylesheetId(u64);
pub struct App<B: Backend = CrosstermBackend<Stdout>> {
dom: TuiDom,
stylesheets: Vec<(StylesheetId, Stylesheet)>,
next_stylesheet_id: u64,
terminal: Terminal<B>,
tracker: DirtyTracker,
router: Router,
tick_rate: Duration,
animation_frame_ms: u32,
on_tick: Option<TickCallback>,
pub(crate) scheduler: crate::runtime::timers::Scheduler,
pub(crate) animations: crate::runtime::animation::AnimationRegistry,
needs_redraw: bool,
should_quit: bool,
guard: Option<TerminalGuard>,
shared: Arc<AppShared>,
clipboard: Box<dyn Clipboard>,
url_opener: crate::runtime::builtins::a_href::SharedOpener,
}
impl App<CrosstermBackend<Stdout>> {
pub fn new(dom: TuiDom, stylesheet: Stylesheet) -> io::Result<Self> {
panic_hook::install();
let mut stdout = io::stdout();
enter_tui_mode(&mut stdout)?;
let guard = TerminalGuard::new();
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
let mut app = Self::build(dom, stylesheet, terminal)?;
app.guard = Some(guard);
Ok(app)
}
pub fn run(mut self) -> io::Result<()> {
self.needs_redraw = true;
let loop_result = panic::catch_unwind(AssertUnwindSafe(|| -> io::Result<()> {
self.draw_if_dirty()?;
loop {
self.drain_handle_signals();
if self.should_quit {
break;
}
let poll_timeout = self.compute_poll_timeout();
let has_event = event::poll(poll_timeout).unwrap_or(false);
if has_event {
loop {
match event::read() {
Ok(ev) => {
crate::rdom_trace!("event::read() -> Ok({ev:?})");
self.handle_event(ev);
}
Err(e) => {
crate::rdom_trace!("event::read() -> Err({e:?})");
break;
}
}
if !event::poll(std::time::Duration::ZERO).unwrap_or(false) {
break;
}
}
} else {
self.tick();
}
self.pump_scheduler();
self.drain_handle_injections();
self.draw_if_dirty()?;
}
Ok(())
}));
let mut stdout = io::stdout();
let _ = leave_tui_mode(&mut stdout);
match loop_result {
Ok(inner_result) => inner_result,
Err(payload) => panic::resume_unwind(payload),
}
}
}
impl<B: Backend> App<B> {
pub fn with_backend(
dom: TuiDom,
stylesheet: Stylesheet,
terminal: Terminal<B>,
) -> io::Result<Self> {
Self::build(dom, stylesheet, terminal)
}
fn build(mut dom: TuiDom, stylesheet: Stylesheet, terminal: Terminal<B>) -> io::Result<Self> {
let tracker = DirtyTracker::install(&mut dom);
crate::cssom::install_default_observers(&mut dom);
let default_opener: Rc<dyn UrlOpener> = Rc::new(SystemUrlOpener);
let url_opener: crate::runtime::builtins::a_href::SharedOpener =
Rc::new(std::cell::RefCell::new(default_opener));
crate::runtime::builtins::a_href::install(&mut dom, url_opener.clone());
crate::runtime::builtins::button::install(&mut dom);
crate::runtime::builtins::label::install(&mut dom);
crate::runtime::builtins::details::install(&mut dom);
crate::runtime::builtins::toggle::install(&mut dom);
crate::runtime::builtins::number::install(&mut dom);
crate::runtime::builtins::form::install(&mut dom);
crate::runtime::builtins::dialog::install(&mut dom);
crate::runtime::builtins::select::install(&mut dom);
crate::runtime::builtins::range::install(&mut dom);
crate::runtime::builtins::tree::install(&mut dom);
crate::runtime::builtins::input::seed_all(&mut dom);
crate::runtime::builtins::range::attach_all(&mut dom);
crate::runtime::builtins::table::size_all_tables(&mut dom);
crate::runtime::implicit_events::install(&mut dom);
crate::runtime::autofocus::focus_first_autofocus(&mut dom);
Ok(Self {
dom,
stylesheets: vec![(StylesheetId(0), stylesheet)],
next_stylesheet_id: 1,
terminal,
tracker,
router: Router::new(),
tick_rate: Duration::from_millis(50),
animation_frame_ms: 16,
on_tick: None,
scheduler: crate::runtime::timers::Scheduler::new(std::time::Instant::now()),
animations: crate::runtime::animation::AnimationRegistry::new(),
needs_redraw: true,
should_quit: false,
guard: None,
shared: AppShared::new(),
clipboard: Box::new(SystemClipboard::new()),
url_opener,
})
}
pub fn set_animation_frame_rate(&mut self, fps: u16) {
let fps = fps.clamp(1, 120);
self.animation_frame_ms = (1000 / fps as u32).max(1);
}
pub fn with_clipboard(mut self, clipboard: Box<dyn Clipboard>) -> Self {
self.clipboard = clipboard;
self
}
pub fn with_url_opener(self, opener: Rc<dyn UrlOpener>) -> Self {
*self.url_opener.borrow_mut() = opener;
self
}
pub fn handle(&self) -> AppHandle {
AppHandle::from_shared(Arc::clone(&self.shared))
}
fn compute_poll_timeout(&self) -> Duration {
let now = std::time::Instant::now();
let to_deadline = self
.scheduler
.next_deadline()
.map(|d| d.saturating_duration_since(now))
.unwrap_or(self.tick_rate);
let frame_floor = if self.scheduler.has_active_raf() {
Duration::from_millis(self.animation_frame_ms as u64)
} else {
self.tick_rate
};
to_deadline.min(frame_floor).min(self.tick_rate)
}
fn pump_scheduler(&mut self) {
use crate::runtime::timers as t;
self.scheduler.set_now(std::time::Instant::now());
t::drain_microtasks(&mut self.scheduler, &mut self.dom);
t::pump_timeouts(&mut self.scheduler, &mut self.dom);
let due = self.scheduler.drain_expired_interval_ids();
t::pump_intervals(&mut self.scheduler, &mut self.dom, &due);
t::drain_microtasks(&mut self.scheduler, &mut self.dom);
t::pump_raf(&mut self.scheduler, &mut self.dom);
t::drain_microtasks(&mut self.scheduler, &mut self.dom);
}
pub fn set_stylesheet(&mut self, sheet: Stylesheet) -> StylesheetId {
let id = StylesheetId(self.next_stylesheet_id);
self.next_stylesheet_id += 1;
self.stylesheets.clear();
self.stylesheets.push((id, sheet));
self.invalidate_cascade();
id
}
pub fn push_stylesheet(&mut self, sheet: Stylesheet) -> StylesheetId {
let id = StylesheetId(self.next_stylesheet_id);
self.next_stylesheet_id += 1;
self.stylesheets.push((id, sheet));
self.invalidate_cascade();
id
}
pub fn remove_stylesheet(&mut self, id: StylesheetId) {
if let Some(pos) = self.stylesheets.iter().position(|(sid, _)| *sid == id) {
self.stylesheets.remove(pos);
self.invalidate_cascade();
}
}
fn invalidate_cascade(&mut self) {
self.tracker.take_roots();
self.needs_redraw = true;
}
pub fn style_sheets(&self) -> Vec<&Stylesheet> {
self.stylesheets.iter().map(|(_, s)| s).collect()
}
pub fn dom_mut(&mut self) -> &mut TuiDom {
&mut self.dom
}
pub fn dom(&self) -> &TuiDom {
&self.dom
}
pub fn terminal(&self) -> &Terminal<B> {
&self.terminal
}
pub fn terminal_mut(&mut self) -> &mut Terminal<B> {
&mut self.terminal
}
pub fn tick_rate(mut self, d: Duration) -> Self {
self.tick_rate = d;
self
}
pub fn on_tick<F>(mut self, f: F) -> Self
where
F: FnMut(&mut AppContext<'_>) -> ControlFlow + 'static,
{
self.on_tick = Some(Box::new(f));
self
}
pub fn handle_event(&mut self, event: CtEvent) {
let _scheduler_guard = crate::runtime::timers::SchedulerGuard::install(&mut self.scheduler);
crate::rdom_trace!("App::handle_event RAW: {event:?}");
match &event {
CtEvent::Key(key) => {
use crossterm::event::KeyEventKind;
if key.kind == KeyEventKind::Release {
let target = self.dom.focused().unwrap_or_else(|| self.dom.root());
let mut tui = TuiEvent::keyup(*key);
let _ = self.dom.dispatch_tui_event(target, &mut tui);
self.needs_redraw |= tui.event.redraw_requested();
self.needs_redraw |= !self.tracker.roots_snapshot().is_empty();
self.needs_redraw |= self.tracker.take_paint_dirty();
return;
}
if try_handle_clipboard_key(self, *key) {
return;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.should_quit = true;
return;
}
let target = self.dom.focused().unwrap_or_else(|| self.dom.root());
let mut tui = TuiEvent::keydown(*key);
let _ = self.dom.dispatch_tui_event(target, &mut tui);
if key.code == KeyCode::F(10) && key.modifiers.contains(KeyModifiers::SHIFT) {
let mut cm = TuiEvent::new("contextmenu");
cm.event = cm.event.clone().with_synthetic(true);
let _ = self.dom.dispatch_tui_event(target, &mut cm);
self.needs_redraw |= cm.event.redraw_requested();
}
if !tui.event.default_prevented() {
if crate::runtime::selection::keyboard::try_handle_key(&mut self.dom, *key)
|| try_handle_editable_key(&mut self.dom, *key)
{
self.needs_redraw = true;
} else {
match key.code {
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
crate::runtime::focus::tabindex::focus_prev(&mut self.dom);
self.needs_redraw = true;
}
KeyCode::Tab => {
crate::runtime::focus::tabindex::focus_next(&mut self.dom);
self.needs_redraw = true;
}
KeyCode::BackTab => {
crate::runtime::focus::tabindex::focus_prev(&mut self.dom);
self.needs_redraw = true;
}
_ => {}
}
}
}
self.needs_redraw |= tui.event.redraw_requested();
self.needs_redraw |= !self.tracker.roots_snapshot().is_empty();
self.needs_redraw |= self.tracker.take_paint_dirty();
}
CtEvent::Mouse(_) => {
let outcome = self.router.route(&mut self.dom, event);
self.needs_redraw |= outcome.redraw_requested;
self.should_quit |= outcome.quit_requested;
self.needs_redraw |= self.tracker.take_paint_dirty();
}
CtEvent::Resize(_, _) => {
let root = self.dom.root();
let mut tui = TuiEvent::new("resize");
tui.event.cancelable = false;
let _ = self.dom.dispatch_tui_event(root, &mut tui);
self.needs_redraw = true;
let mut stdout = std::io::stdout();
if let Err(e) = crate::render::backend_crossterm::enter_mouse_capture(&mut stdout) {
crate::rdom_trace!("Resize: failed to re-arm mouse capture: {e}");
} else {
crate::rdom_trace!("Resize: re-armed mouse capture");
}
}
CtEvent::FocusGained => {
crate::rdom_trace!(
"App::handle_event: FocusGained (capture={:?}, hovered={:?})",
self.dom.pointer_capture(),
self.dom.hovered()
);
}
CtEvent::FocusLost => {
crate::rdom_trace!(
"App::handle_event: FocusLost (capture={:?}, hovered={:?})",
self.dom.pointer_capture(),
self.dom.hovered()
);
}
_ => {
}
}
}
pub(crate) fn tick(&mut self) {
let Some(mut cb) = self.on_tick.take() else {
return;
};
let _scheduler_guard = crate::runtime::timers::SchedulerGuard::install(&mut self.scheduler);
let queued = {
let mut ctx = AppContext::new(&mut self.dom);
let flow = cb(&mut ctx);
self.needs_redraw |= ctx.redraw_requested;
self.should_quit |= ctx.quit_requested || flow == ControlFlow::Quit;
std::mem::take(&mut ctx.queued_dispatches)
};
self.on_tick = Some(cb);
for (target, mut event) in queued {
let _ = self.dom.dispatch_event(target, &mut event);
}
}
pub(crate) fn drain_handle_signals(&mut self) {
if self.shared.redraw_requested.swap(false, Ordering::Relaxed) {
self.needs_redraw = true;
}
if self.shared.quit_requested.load(Ordering::Relaxed) {
self.should_quit = true;
}
}
pub(crate) fn drain_handle_injections(&mut self) {
let injections = self.shared.drain_injections();
if injections.is_empty() {
return;
}
let mut queued = Vec::new();
for f in injections {
let mut ctx = AppContext::new(&mut self.dom);
f(&mut ctx);
self.needs_redraw |= ctx.redraw_requested;
self.should_quit |= ctx.quit_requested;
queued.extend(std::mem::take(&mut ctx.queued_dispatches));
}
for (target, mut event) in queued {
let _ = self.dom.dispatch_event(target, &mut event);
}
}
pub fn draw_if_dirty(&mut self) -> io::Result<()> {
let mut dirty_roots = self.tracker.roots_snapshot();
if !dirty_roots.is_empty() {
self.tracker.take_roots();
}
if !self.needs_redraw && dirty_roots.is_empty() {
crate::rdom_trace!("draw_if_dirty: SKIP (needs_redraw=false, dirty_roots empty)");
return Ok(());
}
crate::rdom_trace!(
"draw_if_dirty: DRAW (needs_redraw={}, dirty_roots={:?})",
self.needs_redraw,
dirty_roots
);
dirty_roots.sort_unstable();
dirty_roots.dedup();
let sheets: Vec<&Stylesheet> = self.stylesheets.iter().map(|(_, s)| s).collect();
let dom = &mut self.dom;
let terminal = &mut self.terminal;
let animations = &mut self.animations;
let now = std::time::Instant::now();
terminal.draw(|buf| {
if dirty_roots.is_empty() {
dom.cascade_all(&sheets);
} else {
dom.cascade_subtrees_all(&sheets, &dirty_roots);
}
crate::runtime::animation::diff_and_register(dom, animations, now);
animations.advance(dom, now);
dom.layout_dom(buf.area);
dom.paint_dom(buf, buf.area);
Ok(())
})?;
self.dispatch_animation_events();
self.needs_redraw = !self.animations.is_empty();
Ok(())
}
fn dispatch_animation_events(&mut self) {
use crate::runtime::animation::{PendingEvent, TransitionEventKind};
let pending = self.animations.take_pending_events();
for PendingEvent {
node,
kind,
property,
elapsed_seconds,
} in pending
{
let event_name = match kind {
TransitionEventKind::Start => "transitionstart",
TransitionEventKind::End => "transitionend",
TransitionEventKind::Cancel => "transitioncancel",
};
let mut ev = rdom_core::Event::new(event_name);
ev.detail = rdom_core::EventDetail::Transition(Box::new(rdom_core::TransitionDetail {
property_name: property.css_name().to_string(),
elapsed: elapsed_seconds.into(),
pseudo_element: None,
}));
let _ = self.dom.dispatch_event(node, &mut ev);
}
}
#[cfg(test)]
pub(crate) fn dirty_roots_snapshot(&self) -> Vec<rdom_core::NodeId> {
self.tracker.roots_snapshot()
}
#[cfg(test)]
pub(crate) fn needs_redraw(&self) -> bool {
self.needs_redraw
}
#[cfg(test)]
pub(crate) fn should_quit(&self) -> bool {
self.should_quit
}
#[cfg(test)]
pub(crate) fn animations_mut_for_test(
&mut self,
) -> &mut crate::runtime::animation::AnimationRegistry {
&mut self.animations
}
#[cfg(test)]
pub(crate) fn dispatch_animation_events_for_test(&mut self) {
self.dispatch_animation_events();
}
}
fn try_handle_clipboard_key<B: Backend>(app: &mut App<B>, key: crossterm::event::KeyEvent) -> bool {
let ctrl_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::SUPER);
if !ctrl_or_super {
return false;
}
match key.code {
KeyCode::Char('c') | KeyCode::Char('C') => do_copy(app),
KeyCode::Char('x') | KeyCode::Char('X') => do_cut(app),
KeyCode::Char('v') | KeyCode::Char('V') => do_paste(app),
_ => false,
}
}
fn do_copy<B: Backend>(app: &mut App<B>) -> bool {
let Some((text, _range)) =
crate::runtime::selection::clipboard::current_selection_text(&app.dom)
else {
return false;
};
let target = crate::runtime::selection::clipboard::copy_target(&app.dom)
.unwrap_or_else(|| app.dom.root());
let mut tui = TuiEvent::copy(text.clone());
let _ = app.dom.dispatch_tui_event(target, &mut tui);
if !tui.event.default_prevented() {
app.clipboard.write_text(text);
}
true
}
fn do_cut<B: Backend>(app: &mut App<B>) -> bool {
let Some((text, _range)) =
crate::runtime::selection::clipboard::current_selection_text(&app.dom)
else {
return false;
};
let target = crate::runtime::selection::clipboard::copy_target(&app.dom)
.unwrap_or_else(|| app.dom.root());
let mut tui = TuiEvent::cut(text.clone());
let _ = app.dom.dispatch_tui_event(target, &mut tui);
if !tui.event.default_prevented() {
app.clipboard.write_text(text);
if crate::node::nearest_editable_ancestor(&app.dom, target).is_some() {
let _ = crate::runtime::editing::insert_at_selection(&mut app.dom, "");
}
}
true
}
fn try_handle_history_key(dom: &mut TuiDom, key: crossterm::event::KeyEvent) -> bool {
let ctrl_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::SUPER);
if !ctrl_or_super {
return false;
}
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let is_undo = matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z')) && !shift;
let is_redo = matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
|| (matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z')) && shift);
if is_undo {
matches!(
crate::runtime::editing::undo_last(dom),
crate::runtime::editing::UndoOutcome::Applied
)
} else if is_redo {
matches!(
crate::runtime::editing::redo_last(dom),
crate::runtime::editing::UndoOutcome::Applied
)
} else {
false
}
}
fn do_paste<B: Backend>(app: &mut App<B>) -> bool {
let text = app.clipboard.read_text().unwrap_or_default();
let target = app.dom.focused().unwrap_or_else(|| app.dom.root());
let mut tui = TuiEvent::paste(text.clone());
let _ = app.dom.dispatch_tui_event(target, &mut tui);
if !tui.event.default_prevented()
&& crate::node::nearest_editable_ancestor(&app.dom, target).is_some()
{
let _ = crate::runtime::editing::insert_at_selection(&mut app.dom, &text);
}
true
}
fn try_handle_editable_key(dom: &mut TuiDom, key: crossterm::event::KeyEvent) -> bool {
let Some(focused) = dom.focused() else {
return false;
};
if crate::node::nearest_editable_ancestor(dom, focused).is_none() {
return false;
};
if try_handle_history_key(dom, key) {
return true;
}
if crate::runtime::editing::movement::try_handle_movement_key(dom, key) {
return true;
}
if matches!(key.code, KeyCode::Enter)
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER | KeyModifiers::ALT)
{
let editable = crate::node::nearest_editable_ancestor(dom, focused);
let is_input = editable
.map(|id| dom.node(id).tag_name() == Some("input"))
.unwrap_or(false);
if is_input {
return true;
}
let outcome = crate::runtime::editing::insert_at_selection(dom, "\n");
return matches!(
outcome,
crate::runtime::editing::EditOutcome::Applied
| crate::runtime::editing::EditOutcome::Prevented
);
}
if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER)
{
return false;
}
let ch = match key.code {
KeyCode::Char(c) if !c.is_control() => c,
_ => return false,
};
let outcome = crate::runtime::editing::insert_at_selection(dom, &ch.to_string());
matches!(
outcome,
crate::runtime::editing::EditOutcome::Applied
| crate::runtime::editing::EditOutcome::Prevented
)
}