pub mod actions;
pub mod anchored;
mod caps;
mod driver;
mod driver_images;
mod driver_suspend;
mod events;
pub mod keymap_help;
pub mod keys;
mod notices;
pub mod overlays;
pub mod popups;
pub mod push_to_talk;
mod redraw;
pub mod select;
pub mod selection;
mod theme;
mod viewport;
#[cfg(test)]
mod acceptance;
pub use actions::{ActionInfo, Actions};
pub use caps::{current_caps, use_caps};
pub use driver::{Driver, RunConfig, Turn};
pub use keymap_help::KeymapHelp;
pub use notices::use_startup_notices;
pub use overlays::{ImageHandle, LayerHandle, Overlays};
pub use popups::{Modal, Toast, MODAL_Z, TOAST_Z};
pub use select::{Combobox, MultiSelect, Select, SelectOption};
pub use theme::{current_theme, set_theme, set_theme_by_id, use_theme};
pub use viewport::{current_viewport, use_viewport};
pub use keys::{hold_gesture_label, key_state, use_key_state, KeyFidelity, KeyState};
pub use push_to_talk::{CaptureState, PttMode, PushToTalk, StopReason};
pub use redraw::{redraw_on_focus_gained, request_full_redraw, set_redraw_on_focus_gained};
use std::cell::Cell;
use std::rc::Rc;
use crate::base::{Result, Size};
use crate::reactive::{
self, create_root, drain_posted, flush_effects, take_frame_request, RootScope, Scope,
WakeHandle,
};
use crate::term::Terminal;
use crate::theme::TokenId;
use crate::ui::{StyledCanvas, UiTree, View};
#[derive(Clone)]
pub struct Quitter(Rc<Cell<bool>>);
impl Quitter {
pub fn quit(&self) {
self.0.set(true);
}
}
pub struct App {
root: Option<RootScope>,
tree: UiTree,
viewport: Size,
quit: Rc<Cell<bool>>,
overlays: Overlays,
actions: Actions,
notices: Vec<String>,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct PumpReport {
pub posted_jobs: usize,
pub frame_requested: bool,
}
impl App {
pub fn simple(component: impl FnOnce(Scope) -> View) -> Result<()> {
let mut app = App::new(Size::new(80, 24));
app.mount(component)?;
app.run()
}
pub fn new(viewport: Size) -> App {
App {
root: None,
tree: UiTree::new(viewport),
viewport,
quit: Rc::new(Cell::new(false)),
overlays: Overlays::new(),
actions: Actions::new(),
notices: Vec::new(),
}
}
pub fn push_startup_notice(&mut self, notice: impl Into<String>) {
let notice = notice.into();
self.notices.push(notice.clone());
notices::publish_notice(notice);
}
pub fn startup_notices(&self) -> &[String] {
&self.notices
}
pub fn overlays(&self) -> Overlays {
self.overlays.clone()
}
pub fn actions(&self) -> Actions {
self.actions.clone()
}
pub fn mount(&mut self, component: impl FnOnce(Scope) -> View) -> Result<()> {
if self.root.is_some() {
return Err(crate::base::Error::App("App::mount called twice".into()));
}
let tree = &mut self.tree;
let overlays = self.overlays.clone();
let (root, ()) = create_root(|cx| {
let theme_sig = use_theme(cx);
cx.provide_context(theme_sig);
cx.provide_context(overlays);
let invalidate = tree.invalidator();
cx.effect_labeled("app-theme-watcher", move || {
let _ = theme_sig.get(); invalidate();
});
let view = component(cx);
tree.mount(cx, view);
});
self.root = Some(root);
Ok(())
}
pub fn quitter(&self) -> Quitter {
Quitter(self.quit.clone())
}
pub fn quit_requested(&self) -> bool {
self.quit.get()
}
pub fn pump(&mut self) -> PumpReport {
let posted_jobs = drain_posted();
flush_effects();
PumpReport {
posted_jobs,
frame_requested: take_frame_request(),
}
}
pub fn draw(&mut self, canvas: &mut dyn StyledCanvas) {
self.tree
.set_text_fg(current_theme().tokens.get(TokenId::Text));
self.tree.layout();
self.tree.draw(canvas);
}
pub fn tree(&mut self) -> &mut UiTree {
&mut self.tree
}
pub fn viewport(&self) -> Size {
self.viewport
}
pub fn set_viewport(&mut self, size: Size) {
self.viewport = size;
self.tree.set_viewport(size);
viewport::publish_viewport(size);
}
pub fn wake_handle(&self) -> WakeHandle {
reactive::wake_handle()
}
pub fn run(mut self) -> Result<()> {
if !crate::term::have_tty() {
return Err(crate::base::Error::Unsupported(
"App::run needs a tty (headless callers use mount + pump + draw, \
or Driver against their own Terminal)"
.into(),
));
}
install_panic_hook();
let mut term = new_platform_terminal()?;
self.run_prepared(&mut term, RunConfig::default())
}
#[cfg(unix)]
fn run_prepared(&mut self, term: &mut crate::term::UnixTerminal, cfg: RunConfig) -> Result<()> {
let mut driver = Driver::new(self, term, cfg)?;
if let Some(label) = term.degraded() {
self.push_startup_notice(format!("input: degraded ({label})"));
}
self.push_startup_notice(caps_summary(driver.caps()));
self.drive_loop(&mut driver, term)
}
#[cfg(windows)]
fn run_prepared(
&mut self,
term: &mut crate::term::WindowsTerminal,
cfg: RunConfig,
) -> Result<()> {
let mut driver = Driver::new(self, term, cfg)?;
self.push_startup_notice(caps_summary(driver.caps()));
self.drive_loop(&mut driver, term)
}
pub fn run_on(&mut self, term: &mut dyn Terminal, cfg: RunConfig) -> Result<()> {
let mut driver = Driver::new(self, term, cfg)?;
self.push_startup_notice(caps_summary(driver.caps()));
self.drive_loop(&mut driver, term)
}
fn drive_loop(&mut self, driver: &mut Driver, term: &mut dyn Terminal) -> Result<()> {
const FRAME_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16);
let result = loop {
match driver.turn(self, term) {
Ok(turn) if turn.quit => break Ok(()),
Ok(_) if reactive::frame_tasks_pending() > 0 => {
let deadline = std::time::Instant::now() + FRAME_INTERVAL;
if let Err(e) = driver.wait_until(term, deadline) {
break Err(e);
}
}
Ok(turn) if turn.idle => {
let result = match reactive::next_timer_deadline() {
Some(deadline) => driver.wait_until(term, deadline),
None => driver.wait_for_activity(term),
};
if let Err(e) = result {
break Err(e);
}
}
Ok(_) => {}
Err(e) => break Err(e),
}
};
let _ = driver.finish(term); for note in driver.collapse_log() {
eprintln!("{note}");
}
result
}
pub fn shutdown(&mut self) {
if let Some(root) = self.root.take() {
root.dispose();
}
}
}
impl Drop for App {
fn drop(&mut self) {
self.shutdown();
}
}
fn caps_summary(caps: &crate::term::Capabilities) -> String {
let mut on: Vec<&str> = Vec::new();
if caps.truecolor {
on.push("truecolor");
} else if caps.colors_256 {
on.push("256color");
} else {
on.push("16color");
}
if caps.kitty_keyboard {
on.push("kitty-kbd");
}
if caps.sync_output_2026 {
on.push("sync");
}
if caps.kitty_graphics {
on.push("kitty-gfx");
} else if caps.iterm2_images {
on.push("iterm2-gfx");
} else if caps.sixel {
on.push("sixel");
}
if caps.in_tmux {
on.push("tmux");
}
format!("caps: {}", on.join("+"))
}
#[cfg(unix)]
fn new_platform_terminal() -> Result<crate::term::UnixTerminal> {
crate::term::UnixTerminal::new()
}
#[cfg(windows)]
fn new_platform_terminal() -> Result<crate::term::WindowsTerminal> {
crate::term::WindowsTerminal::new()
}
fn install_panic_hook() {
use std::sync::Once;
static HOOK: Once = Once::new();
HOOK.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
crate::term::emergency_restore();
prev(info);
}));
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Point;
use crate::layout::Style;
use crate::ui::{dyn_view, text, BufferCanvas, Element};
#[test]
fn headless_mount_pump_draw_cycle() {
let mut app = App::new(Size::new(16, 2));
let handle = app.wake_handle();
app.mount(|cx| {
let n = cx.signal(1i64);
std::thread::spawn(move || handle.post(move || n.set(42)))
.join()
.expect("join");
Element::new()
.child(dyn_view(Style::default(), move || {
text(format!("v={}", n.get()))
}))
.build()
})
.expect("mount");
let report = app.pump();
assert_eq!(report.posted_jobs, 1);
assert!(report.frame_requested);
let mut canvas = BufferCanvas::new(Size::new(16, 2));
app.draw(&mut canvas);
assert_eq!(canvas.row_text(0).trim_end(), "v=42");
assert_eq!(canvas.cell(Point::new(0, 0)).expect("cell").0, 'v');
let idle = app.pump();
assert_eq!(
idle,
PumpReport {
posted_jobs: 0,
frame_requested: false
}
);
app.shutdown();
assert_eq!(
app.tree().instance_count(),
0,
"shutdown unmounts everything"
);
}
#[test]
fn quitter_sets_the_flag() {
let app = App::new(Size::new(4, 4));
assert!(!app.quit_requested());
app.quitter().quit();
assert!(app.quit_requested());
}
#[test]
fn theme_switch_invalidates_the_tree() {
let mut app = App::new(Size::new(8, 2));
app.mount(|_cx| Element::new().child(text("hi")).build())
.expect("mount");
app.pump();
let _ = app.tree().take_damage();
let prev = set_theme(crate::theme::get("nord").expect("nord"));
assert!(
!app.tree().take_damage().is_empty(),
"theme watcher must damage the tree on switch"
);
set_theme(prev); }
}