use std::collections::HashSet;
use std::time::{Duration, Instant};
use crossterm::event::{Event, EventStream};
use futures::{FutureExt, StreamExt};
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
use crate::backend::inline::{InlineSink, buffer_rows};
use crate::backend::{Backend, FullscreenBackend, InlineBackend};
use crate::buffer::{Buffer, Cell};
use crate::element::Element;
use crate::error::Error;
use crate::fiber::{FiberId, FiberTree};
use crate::hooks::{RuntimeHandle, Wake};
use crate::layout::compute_layout;
use crate::paint::paint;
const FRAME: Duration = Duration::from_millis(16);
const MAX_EVENT_BURST: usize = 128;
const MAX_UPDATE_PASSES: usize = 64;
pub(crate) struct AppCore {
pub tree: FiberTree,
pub rt: RuntimeHandle,
wake_rx: UnboundedReceiver<Wake>,
pending: Vec<FiberId>,
prev: Option<Buffer>,
scratch: Option<Buffer>,
force_full: bool,
pub size: (u16, u16),
pub exited: bool,
pending_clipboard: Vec<String>,
live_size: Option<(u16, u16)>,
live_scratch: Option<Buffer>,
}
impl AppCore {
pub fn new(el: Element, size: (u16, u16)) -> Self {
let (wake, wake_rx) = unbounded_channel();
let rt = RuntimeHandle {
wake,
size: std::sync::Arc::new(std::sync::Mutex::new(size)),
scrollback: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
};
let mut tree = FiberTree::new();
tree.mount_root(el, &rt);
tree.flush_effects();
AppCore {
tree,
rt,
wake_rx,
pending: Vec::new(),
prev: None,
scratch: None,
force_full: false,
size,
exited: false,
pending_clipboard: Vec::new(),
live_size: None,
live_scratch: None,
}
}
pub fn apply_wake(&mut self, w: Wake) {
match w {
Wake::Dirty(id) => self.pending.push(id),
Wake::Redraw => {
if let Some(root) = self.tree.root {
self.pending.push(root);
}
self.prev = None; }
Wake::Exit => self.exited = true,
Wake::CopyToClipboard(text) => self.pending_clipboard.push(text),
}
}
pub fn take_pending_clipboard(&mut self) -> Vec<String> {
std::mem::take(&mut self.pending_clipboard)
}
pub fn process_wakes(&mut self) {
while let Ok(w) = self.wake_rx.try_recv() {
self.apply_wake(w);
}
let mut passes = 0;
while !self.pending.is_empty() {
passes += 1;
if passes > MAX_UPDATE_PASSES {
panic!(
"ntui: maximum update depth exceeded ({MAX_UPDATE_PASSES} passes) — a use_effect that sets state unconditionally, or a state update during every render, is preventing the UI from reaching a stable state"
);
}
let mut dirty = std::mem::take(&mut self.pending);
let mut seen = HashSet::new();
dirty.retain(|id| seen.insert(*id));
dirty.sort_by_key(|id| self.depth(*id));
let rt = self.rt.clone();
for id in dirty {
self.tree.render_fiber(id, &rt); }
self.tree.flush_effects();
while let Ok(w) = self.wake_rx.try_recv() {
self.apply_wake(w);
}
}
}
pub fn dispatch_key(&mut self, ev: crossterm::event::KeyEvent) {
if ev.kind == crossterm::event::KeyEventKind::Release {
return;
}
let handlers = self.tree.collect_input_handlers();
let mut ctx = crate::hooks::input::InputCtx { stopped: false };
for h in handlers {
h.borrow_mut()(ev, &mut ctx);
if ctx.stopped {
break;
}
}
}
pub fn dispatch_paste(&mut self, text: &str) {
let handlers = self.tree.collect_paste_handlers();
let mut ctx = crate::hooks::input::InputCtx { stopped: false };
for h in handlers {
h.borrow_mut()(text, &mut ctx);
if ctx.stopped {
break;
}
}
}
fn depth(&self, mut id: FiberId) -> usize {
let mut d = 0;
while self.tree.contains(id) {
match self.tree.get(id).parent {
Some(p) => {
d += 1;
id = p;
}
None => break,
}
}
d
}
pub fn draw(&mut self, backend: &mut dyn Backend) -> Result<(), Error> {
if self.tree.layout_dirty || self.prev.is_none() {
compute_layout(&mut self.tree, self.size.0, self.size.1);
}
let mut buf = self
.scratch
.take()
.unwrap_or_else(|| Buffer::new(self.size.0, self.size.1));
buf.resize_and_clear(self.size.0, self.size.1);
paint(&self.tree, &mut buf);
let baseline;
let prev: &Buffer = if self.force_full {
baseline = Buffer::new(0, 0);
&baseline
} else if let Some(p) = &self.prev {
p
} else {
baseline = Buffer::new(self.size.0, self.size.1);
&baseline
};
backend.flush(&buf.diff(prev))?;
self.force_full = false;
self.scratch = self.prev.take();
self.prev = Some(buf);
Ok(())
}
pub fn live_rows(&mut self, width: u16, max_height: u16) -> Vec<Vec<Cell>> {
let size_changed = self.live_size != Some((width, max_height));
if self.tree.layout_dirty || size_changed {
compute_layout(&mut self.tree, width, max_height);
self.live_size = Some((width, max_height));
}
let mut buf = self
.live_scratch
.take()
.unwrap_or_else(|| Buffer::new(width, max_height));
buf.resize_and_clear(width, max_height);
paint(&self.tree, &mut buf);
let rows = trim_rows(buffer_rows(&buf));
self.live_scratch = Some(buf);
rows
}
pub fn take_committed(&mut self, width: u16, max_height: u16) -> Vec<Vec<Cell>> {
let queued: Vec<Element> = std::mem::take(&mut *self.rt.scrollback.borrow_mut());
let mut rows = Vec::new();
for el in queued {
let mut tree = FiberTree::new();
tree.mount_root(el, &self.rt);
compute_layout(&mut tree, width, max_height);
let mut buf = Buffer::new(width, max_height);
paint(&tree, &mut buf);
rows.extend(trim_rows(buffer_rows(&buf)));
}
rows
}
#[cfg_attr(coverage_nightly, coverage(off))]
pub async fn wait_wake(&mut self) -> Option<Wake> {
self.wake_rx.recv().await
}
pub fn resize(&mut self, w: u16, h: u16) {
self.size = (w, h);
*self
.rt
.size
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = (w, h);
self.tree.layout_dirty = true;
self.force_full = true;
if let Some(root) = self.tree.root {
self.pending.push(root);
}
}
}
pub(crate) struct RestoreGuard<'a, B: Backend + ?Sized> {
pub backend: &'a mut B,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl<'a, B: Backend + ?Sized> Drop for RestoreGuard<'a, B> {
fn drop(&mut self) {
let _ = self.backend.leave();
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn install_panic_hook() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::cursor::Show,
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableBracketedPaste
);
prev(info);
}));
});
}
#[cfg_attr(coverage_nightly, coverage(off))]
pub async fn render(el: Element) -> Result<(), Error> {
install_panic_hook();
let mut backend = FullscreenBackend::new();
backend.enter()?;
let guard = RestoreGuard {
backend: &mut backend,
};
run_loop(el, guard).await
}
#[cfg_attr(coverage_nightly, coverage(off))]
async fn run_loop<B: Backend>(el: Element, guard: RestoreGuard<'_, B>) -> Result<(), Error> {
let size = guard.backend.size()?;
let mut core = AppCore::new(el, size);
core.process_wakes();
flush_clipboard(&mut core, guard.backend);
core.draw(guard.backend)?;
let mut events = EventStream::new();
let mut last_frame = Instant::now();
while !core.exited {
tokio::select! {
ev = events.next() => match ev {
Some(Ok(Event::Key(k))) => core.dispatch_key(k),
Some(Ok(Event::Paste(s))) => core.dispatch_paste(&s),
Some(Ok(Event::Resize(w, h))) => core.resize(w, h),
Some(Err(e)) => return Err(e.into()),
None => break, _ => {}
},
w = core.wait_wake() => match w {
Some(w) => core.apply_wake(w),
None => break,
},
}
for _ in 0..MAX_EVENT_BURST {
match events.next().now_or_never() {
Some(Some(Ok(Event::Key(k)))) => core.dispatch_key(k),
Some(Some(Ok(Event::Paste(s)))) => core.dispatch_paste(&s),
Some(Some(Ok(Event::Resize(w, h)))) => core.resize(w, h),
Some(Some(Ok(_))) => {}
Some(Some(Err(e))) => return Err(e.into()),
Some(None) | None => break,
}
}
core.process_wakes();
flush_clipboard(&mut core, guard.backend);
if core.exited {
break;
}
let elapsed = last_frame.elapsed();
if elapsed < FRAME {
tokio::time::sleep(FRAME - elapsed).await;
core.process_wakes();
flush_clipboard(&mut core, guard.backend);
if core.exited {
break;
}
}
core.rt.scrollback.borrow_mut().clear();
flush_clipboard(&mut core, guard.backend);
core.draw(guard.backend)?;
last_frame = Instant::now();
}
Ok(()) }
fn flush_clipboard<B: Backend + ?Sized>(core: &mut AppCore, backend: &mut B) {
for text in core.take_pending_clipboard() {
let _ = backend.copy_to_clipboard(&text);
}
}
fn trim_rows(mut rows: Vec<Vec<Cell>>) -> Vec<Vec<Cell>> {
while rows
.last()
.is_some_and(|r| r.iter().all(|c| *c == Cell::default()))
{
rows.pop();
}
rows
}
pub(crate) struct InlineRestoreGuard<'a, S: InlineSink + ?Sized> {
pub backend: &'a mut S,
}
#[cfg_attr(coverage_nightly, coverage(off))]
impl<'a, S: InlineSink + ?Sized> Drop for InlineRestoreGuard<'a, S> {
fn drop(&mut self) {
let _ = self.backend.leave();
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
pub async fn render_inline(el: Element) -> Result<(), Error> {
install_panic_hook();
let mut backend = InlineBackend::new();
backend.enter()?;
let guard = InlineRestoreGuard {
backend: &mut backend,
};
run_inline_loop(el, guard).await
}
#[cfg_attr(coverage_nightly, coverage(off))]
async fn run_inline_loop<S: InlineSink>(
el: Element,
guard: InlineRestoreGuard<'_, S>,
) -> Result<(), Error> {
let (w, h) = guard.backend.size()?;
let mut core = AppCore::new(el, (w, h));
core.process_wakes();
commit_and_present(&mut core, guard.backend)?;
let mut events = EventStream::new();
let mut last_frame = Instant::now();
while !core.exited {
tokio::select! {
ev = events.next() => match ev {
Some(Ok(Event::Key(k))) => core.dispatch_key(k),
Some(Ok(Event::Paste(s))) => core.dispatch_paste(&s),
Some(Ok(Event::Resize(nw, nh))) => core.resize(nw, nh),
Some(Err(e)) => return Err(e.into()),
None => break,
_ => {}
},
wk = core.wait_wake() => match wk {
Some(wk) => core.apply_wake(wk),
None => break,
},
}
for _ in 0..MAX_EVENT_BURST {
match events.next().now_or_never() {
Some(Some(Ok(Event::Key(k)))) => core.dispatch_key(k),
Some(Some(Ok(Event::Paste(s)))) => core.dispatch_paste(&s),
Some(Some(Ok(Event::Resize(nw, nh)))) => core.resize(nw, nh),
Some(Some(Ok(_))) => {}
Some(Some(Err(e))) => return Err(e.into()),
Some(None) | None => break,
}
}
core.process_wakes();
flush_clipboard_inline(&mut core, guard.backend);
if core.exited {
break;
}
let elapsed = last_frame.elapsed();
if elapsed < FRAME {
tokio::time::sleep(FRAME - elapsed).await;
core.process_wakes();
flush_clipboard_inline(&mut core, guard.backend);
if core.exited {
break;
}
}
commit_and_present(&mut core, guard.backend)?;
last_frame = Instant::now();
}
Ok(())
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn commit_and_present<S: InlineSink>(core: &mut AppCore, backend: &mut S) -> Result<(), Error> {
flush_clipboard_inline(core, backend);
let (w, h) = core.size;
let committed = core.take_committed(w, h);
if !committed.is_empty() {
backend.commit(&committed)?;
}
let live = core.live_rows(w, h);
backend.present(&live)?;
Ok(())
}
fn flush_clipboard_inline<S: InlineSink>(core: &mut AppCore, backend: &mut S) {
for text in core.take_pending_clipboard() {
let _ = backend.copy_to_clipboard(&text);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::TestBackend;
use crate::backend::inline::{InlineSink, RecordingSink};
use crate::component::Component;
use crate::hooks::Hooks;
use crate::props::{Dimension, FlexDirection, TextProps, ViewProps};
#[test]
fn restore_guard_leaves_on_drop() {
let mut be = TestBackend::new(4, 2);
be.enter().unwrap();
{
let _guard = RestoreGuard { backend: &mut be };
}
assert_eq!(be.lifecycle, vec!["enter", "leave"]);
}
struct Inline;
impl Component for Inline {
type Props = ();
fn render(_: &(), hooks: &mut Hooks) -> Element {
let sb = hooks.use_scrollback();
hooks.use_effect((), move || {
sb.commit(Element::text(TextProps {
content: "committed turn".into(),
..Default::default()
}));
});
Element::view(
ViewProps::default(),
vec![Element::text(TextProps {
content: "> live prompt".into(),
..Default::default()
})],
)
}
}
#[test]
fn inline_splits_committed_scrollback_from_live_region() {
let mut core = AppCore::new(Element::component::<Inline>(()), (20, 6));
core.process_wakes();
let mut sink = RecordingSink::new(20, 6);
commit_and_present_into(&mut core, &mut sink);
assert!(
sink.committed.iter().any(|l| l.contains("committed turn")),
"committed: {:?}",
sink.committed
);
assert!(
sink.live.iter().any(|l| l.contains("> live prompt")),
"live: {:?}",
sink.live
);
assert!(
!sink.live.iter().any(|l| l.contains("committed turn")),
"committed content must not remain in the live region"
);
}
#[test]
fn appcore_wakes_dispatch_release_and_resize_with_depth() {
use crate::hooks::input::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::hooks::state::State;
use crate::test_util::Shared;
#[derive(Clone, PartialEq, Default)]
struct CP {
handle: Shared<Option<State<i32>>>,
seen: Shared<i32>,
}
struct Child;
impl Component for Child {
type Props = CP;
fn render(props: &CP, hooks: &mut Hooks) -> Element {
let n = hooks.use_state(|| 0);
*props.handle.lock() = Some(n.clone());
*props.seen.lock() = n.get();
Element::text(TextProps {
content: n.get().to_string(),
..Default::default()
})
}
}
#[derive(Clone, PartialEq, Default)]
struct PP {
child: CP,
}
struct Parent;
impl Component for Parent {
type Props = PP;
fn render(props: &PP, _hooks: &mut Hooks) -> Element {
Element::view(
ViewProps::default(),
vec![Element::component::<Child>(props.child.clone())],
)
}
}
let props = PP::default();
let mut core = AppCore::new(Element::component::<Parent>(props.clone()), (10, 5));
core.process_wakes();
let st = props.child.handle.lock().clone().unwrap();
st.set(7);
core.process_wakes();
assert_eq!(*props.child.seen.lock(), 7);
core.apply_wake(Wake::Redraw);
core.resize(20, 8);
core.process_wakes();
assert_eq!(core.size, (20, 8));
core.dispatch_key(KeyEvent::new_with_kind(
KeyCode::Char('a'),
KeyModifiers::NONE,
KeyEventKind::Release,
));
core.apply_wake(Wake::Exit);
assert!(core.exited);
}
#[test]
fn recording_sink_lifecycle() {
let mut s = crate::backend::inline::RecordingSink::new(4, 2);
assert_eq!(s.size().unwrap(), (4, 2));
s.enter().unwrap();
s.leave().unwrap();
assert_eq!(s.lifecycle, vec!["enter", "leave"]);
}
fn commit_and_present_into<S: InlineSink>(core: &mut AppCore, sink: &mut S) {
let (w, h) = core.size;
let committed = core.take_committed(w, h);
sink.commit(&committed).unwrap();
let live = core.live_rows(w, h);
sink.present(&live).unwrap();
}
struct SizeLabel;
impl Component for SizeLabel {
type Props = ();
fn render(_: &(), hooks: &mut Hooks) -> Element {
let (w, h) = hooks.use_terminal_size();
Element::text(TextProps {
content: format!("{w}x{h}"),
..Default::default()
})
}
}
#[test]
fn draw_double_buffer_reflects_new_dimensions_after_resize() {
let mut core = AppCore::new(Element::component::<SizeLabel>(()), (6, 2));
core.process_wakes();
let mut be = TestBackend::new(10, 3);
core.draw(&mut be).unwrap();
let row0 = be.to_text().lines().next().unwrap().to_string();
assert!(
row0.starts_with("6x2"),
"expected first frame to show 6x2, got {row0:?}"
);
assert_eq!(
core.prev.as_ref().map(|b| (b.width(), b.height())),
Some((6, 2))
);
core.resize(10, 3);
core.process_wakes();
core.draw(&mut be).unwrap();
let row0 = be.to_text().lines().next().unwrap().to_string();
assert!(
row0.starts_with("10x3"),
"expected second frame to show 10x3, got {row0:?}"
);
assert_eq!(
core.prev.as_ref().map(|b| (b.width(), b.height())),
Some((10, 3))
);
}
struct Label;
#[derive(Clone, PartialEq, Default)]
struct LabelProps {
text: crate::test_util::Shared<Option<crate::hooks::state::State<String>>>,
}
impl Component for Label {
type Props = LabelProps;
fn render(props: &LabelProps, hooks: &mut Hooks) -> Element {
let s = hooks.use_state(|| "XXXXXX".to_string());
*props.text.lock() = Some(s.clone());
Element::text(TextProps {
content: s.get(),
..Default::default()
})
}
}
#[test]
fn resize_repaints_cells_that_became_blank() {
let props = LabelProps::default();
let mut core = AppCore::new(Element::component::<Label>(props.clone()), (10, 2));
core.process_wakes();
let mut be = TestBackend::new(10, 2);
core.draw(&mut be).unwrap();
assert!(be.to_text().contains("XXXXXX"));
props.text.lock().clone().unwrap().set("Y".into());
core.resize(10, 2);
core.process_wakes();
core.draw(&mut be).unwrap();
let out = be.to_text();
assert!(out.contains('Y'), "{out:?}");
assert!(
!out.contains('X'),
"stale cells from before the resize were never cleared: {out:?}"
);
}
struct Wrapping;
impl Component for Wrapping {
type Props = ();
fn render(_: &(), _hooks: &mut Hooks) -> Element {
Element::view(
ViewProps {
flex_direction: FlexDirection::Column,
width: Dimension::Percent(100.0),
..Default::default()
},
vec![Element::text(TextProps {
content: "hello brave new world foo bar".into(),
..Default::default()
})],
)
}
}
#[test]
fn live_rows_gate_skips_recompute_unless_size_changes() {
let mut core = AppCore::new(Element::component::<Wrapping>(()), (20, 10));
core.process_wakes();
let first = core.live_rows(10, 10);
let second = core.live_rows(10, 10);
assert_eq!(
first, second,
"calling live_rows twice with the same size must be stable"
);
assert_eq!(core.live_size, Some((10, 10)));
let wider = core.live_rows(20, 10);
assert_eq!(core.live_size, Some((20, 10)));
assert_ne!(
wider.len(),
first.len(),
"a genuine width change must change how the text wraps"
);
}
#[test]
fn depth_orders_dirty_fibers_shallowest_first() {
use crate::hooks::state::State;
use crate::test_util::Shared;
#[derive(Clone, PartialEq, Default)]
struct Ctx {
root_state: Shared<Option<State<i32>>>,
mid_state: Shared<Option<State<i32>>>,
log: Shared<Vec<char>>,
}
struct Mid;
impl Component for Mid {
type Props = Ctx;
fn render(ctx: &Ctx, hooks: &mut Hooks) -> Element {
let n = hooks.use_state(|| 0);
*ctx.mid_state.lock() = Some(n.clone());
ctx.log.lock().push('M');
Element::text(TextProps {
content: n.get().to_string(),
..Default::default()
})
}
}
struct Root;
impl Component for Root {
type Props = Ctx;
fn render(ctx: &Ctx, hooks: &mut Hooks) -> Element {
let n = hooks.use_state(|| 0);
*ctx.root_state.lock() = Some(n.clone());
ctx.log.lock().push('R');
let _ = n.get();
Element::view(
ViewProps::default(),
vec![Element::component::<Mid>(ctx.clone())],
)
}
}
let ctx = Ctx::default();
let mut core = AppCore::new(Element::component::<Root>(ctx.clone()), (10, 5));
core.process_wakes();
ctx.log.lock().clear();
ctx.root_state.lock().clone().unwrap().set(1);
ctx.mid_state.lock().clone().unwrap().set(1);
core.process_wakes();
assert_eq!(*ctx.log.lock(), vec!['R', 'M']);
}
}