use std::io::{self, Write};
use std::time::Duration;
use crate::app::{App, Ctx};
use crate::element::Element;
use crate::input::InputEvent;
use crate::task::{Effect, PersistTracker};
use crate::timeline::Timeline;
pub struct Runtime<A: App> {
app: A,
timeline: Timeline,
animate: Option<Duration>,
effects: Vec<Effect<A::Msg>>,
pending: Vec<u8>,
init_exit: Option<A::Output>,
persists: PersistTracker,
}
impl<A: App> Runtime<A>
where
A::Msg: Clone,
{
pub fn new(mut app: A, width: u16, terminal_height: u16) -> Self {
let mut timeline = Timeline::new(width, terminal_height);
let mut pending = Vec::new();
let mut effects = Vec::new();
let persists = PersistTracker::new();
let mut ctx = Ctx {
timeline: &mut timeline,
output: &mut pending,
effects: &mut effects,
persists: &persists,
exit: None,
};
app.init(&mut ctx);
let mut init_exit = ctx.exit;
if init_exit.is_none()
&& let Some(msg) = app.on_resize(width, terminal_height)
{
let mut ctx = Ctx {
timeline: &mut timeline,
output: &mut pending,
effects: &mut effects,
persists: &persists,
exit: None,
};
app.update(msg, &mut ctx);
init_exit = ctx.exit;
}
Self {
app,
timeline,
animate: None,
effects,
pending,
init_exit,
persists,
}
}
pub fn startup(&mut self) -> (Vec<u8>, Option<A::Output>) {
let mut bytes = self.present();
let exit = self.init_exit.take();
if exit.is_some() {
bytes.extend_from_slice(&self.timeline.finalize());
}
(bytes, exit)
}
pub fn handle(&mut self, event: InputEvent) -> (Vec<u8>, Option<A::Output>) {
self.handle_batch(std::iter::once(event))
}
pub fn handle_batch(
&mut self,
events: impl IntoIterator<Item = InputEvent>,
) -> (Vec<u8>, Option<A::Output>) {
let mut bytes: Option<Vec<u8>> = None;
let mut exit = None;
for event in events {
if let Some(msg) = self.app.keymap().dispatch(&event) {
let buf = bytes.get_or_insert_with(|| std::mem::take(&mut self.pending));
exit = self.apply_msg(msg, buf);
if exit.is_some() {
break;
}
}
}
let Some(mut bytes) = bytes else {
return (Vec::new(), None);
};
bytes.extend_from_slice(&self.present());
if exit.is_some() {
bytes.extend_from_slice(&self.timeline.finalize());
}
(bytes, exit)
}
pub fn process(&mut self, msg: A::Msg) -> (Vec<u8>, Option<A::Output>) {
self.process_batch(std::iter::once(msg))
}
pub fn process_batch(
&mut self,
msgs: impl IntoIterator<Item = A::Msg>,
) -> (Vec<u8>, Option<A::Output>) {
let mut bytes = std::mem::take(&mut self.pending);
let mut exit = None;
for msg in msgs {
exit = self.apply_msg(msg, &mut bytes);
if exit.is_some() {
break;
}
}
bytes.extend_from_slice(&self.present());
if exit.is_some() {
bytes.extend_from_slice(&self.timeline.finalize());
}
(bytes, exit)
}
fn apply_msg(&mut self, msg: A::Msg, bytes: &mut Vec<u8>) -> Option<A::Output> {
let mut ctx = Ctx {
timeline: &mut self.timeline,
output: bytes,
effects: &mut self.effects,
persists: &self.persists,
exit: None,
};
self.app.update(msg, &mut ctx);
ctx.exit
}
pub fn take_effects(&mut self) -> Vec<Effect<A::Msg>> {
std::mem::take(&mut self.effects)
}
pub fn persists(&self) -> PersistTracker {
self.persists.clone()
}
pub fn present(&mut self) -> Vec<u8> {
self.timeline.set_cursor_style(self.app.cursor_style());
let tail = self.app.tail();
self.animate = tail.animated();
let mut bytes = std::mem::take(&mut self.pending);
bytes.extend(self.timeline.present(&tail));
bytes
}
pub fn animation_interval(&self) -> Option<Duration> {
self.animate
}
pub fn resize(&mut self, width: u16, terminal_height: u16) -> Vec<u8> {
self.timeline.set_terminal_height(terminal_height);
let mut bytes = self.timeline.resize(width);
bytes.extend_from_slice(&self.present());
bytes
}
pub fn resize_anchored(
&mut self,
width: u16,
terminal_height: u16,
cursor: (u16, u16),
) -> Vec<u8> {
self.timeline.set_terminal_height(terminal_height);
let mut bytes = self.timeline.resize_anchored(width, cursor);
bytes.extend_from_slice(&self.present());
bytes
}
pub fn resize_msg(
&mut self,
width: u16,
terminal_height: u16,
cursor: Option<(u16, u16)>,
) -> (Vec<u8>, Option<A::Output>) {
self.timeline.set_terminal_height(terminal_height);
let mut bytes = match cursor {
Some(pos) => self.timeline.resize_anchored(width, pos),
None => self.timeline.resize(width),
};
let exit = self.deliver_resize(width, terminal_height, &mut bytes);
bytes.extend_from_slice(&self.present());
if exit.is_some() {
bytes.extend_from_slice(&self.timeline.finalize());
}
(bytes, exit)
}
pub fn resize_screen(
&mut self,
width: u16,
terminal_height: u16,
) -> (Vec<u8>, Option<A::Output>) {
self.timeline.set_terminal_height(terminal_height);
let mut bytes = self.timeline.reset_screen(width);
let exit = self.deliver_resize(width, terminal_height, &mut bytes);
bytes.extend_from_slice(&self.present());
if exit.is_some() {
bytes.extend_from_slice(&self.timeline.finalize());
}
(bytes, exit)
}
fn deliver_resize(
&mut self,
width: u16,
terminal_height: u16,
bytes: &mut Vec<u8>,
) -> Option<A::Output> {
let msg = self.app.on_resize(width, terminal_height)?;
self.apply_msg(msg, bytes)
}
pub fn finalize(&mut self) -> Vec<u8> {
self.timeline.finalize()
}
pub fn app(&self) -> &A {
&self.app
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KeyboardProtocol {
#[default]
Legacy,
Enhanced,
Custom {
flags: crossterm::event::KeyboardEnhancementFlags,
probe: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScreenMode {
#[default]
Inline,
AltScreen,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RunOptions {
pub keyboard: KeyboardProtocol,
pub screen: ScreenMode,
pub mouse_capture: bool,
pub persist_grace: Option<Duration>,
}
impl RunOptions {
pub fn keyboard(mut self, protocol: KeyboardProtocol) -> Self {
self.keyboard = protocol;
self
}
pub fn screen(mut self, screen: ScreenMode) -> Self {
self.screen = screen;
self
}
pub fn mouse_capture(mut self, capture: bool) -> Self {
self.mouse_capture = capture;
self
}
pub fn persist_grace(mut self, grace: Duration) -> Self {
self.persist_grace = Some(grace);
self
}
}
pub fn run<A: App>(app: A) -> io::Result<A::Output>
where
A::Msg: Clone,
{
run_with(app, RunOptions::default())
}
pub fn run_with<A: App>(app: A, options: RunOptions) -> io::Result<A::Output>
where
A::Msg: Clone,
{
let (width, height) = crossterm::terminal::size()?;
let mut runtime = Runtime::new(app, width, height);
let mut stdout = io::stdout().lock();
let _guard = RawModeGuard::enable(options.keyboard, options.screen, options.mouse_capture)?;
if options.screen != ScreenMode::AltScreen {
normalize_start_column();
}
let (bytes, init_exit) = runtime.startup();
stdout.write_all(&bytes)?;
stdout.flush()?;
if let Some(output) = init_exit {
return Ok(output);
}
reject_effects(&mut runtime)?;
loop {
let timeout = runtime
.animation_interval()
.unwrap_or(Duration::from_secs(3600));
let bytes = if crossterm::event::poll(timeout)? {
use crossterm::event::{Event, KeyEventKind};
enum Seg {
Inputs(Vec<InputEvent>),
Resize(u16, u16),
}
let mut segs: Vec<Seg> = Vec::new();
let mut drained = 0usize;
let mut first = true;
while first || (drained < 64 && crossterm::event::poll(Duration::ZERO)?) {
first = false;
drained += 1;
let input = match crossterm::event::read()? {
Event::Key(k)
if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
InputEvent::Key(k)
}
Event::Paste(s) => InputEvent::Paste(s),
Event::Mouse(m) => InputEvent::Mouse(m),
Event::Resize(w, h) => {
if let Some(Seg::Resize(rw, rh)) = segs.last_mut() {
(*rw, *rh) = (w, h);
} else {
segs.push(Seg::Resize(w, h));
}
continue;
}
_ => continue,
};
if let Some(Seg::Inputs(v)) = segs.last_mut() {
v.push(input);
} else {
segs.push(Seg::Inputs(vec![input]));
}
}
let mut bytes = Vec::new();
for seg in segs {
let (seg_bytes, seg_exit) = match seg {
Seg::Inputs(inputs) => runtime.handle_batch(inputs),
Seg::Resize(w, h) => resize_with_report(&mut runtime, w, h, options.screen),
};
reject_effects(&mut runtime)?;
bytes.extend_from_slice(&seg_bytes);
if let Some(output) = seg_exit {
stdout.write_all(&bytes)?;
stdout.flush()?;
return Ok(output);
}
}
bytes
} else {
runtime.present()
};
if !bytes.is_empty() {
stdout.write_all(&bytes)?;
stdout.flush()?;
}
}
}
pub(crate) fn read_cursor_position() -> std::io::Result<(u16, u16)> {
use std::sync::atomic::{AtomicUsize, Ordering};
static ORPHANS: AtomicUsize = AtomicUsize::new(0);
let discard = ORPHANS.load(Ordering::Relaxed) + 1;
for _ in 0..discard {
if let Err(e) = crossterm::cursor::position() {
ORPHANS.fetch_add(1, Ordering::Relaxed);
return Err(e);
}
}
match crossterm::cursor::position() {
Ok(v) => Ok(v),
Err(e) => {
ORPHANS.fetch_add(1, Ordering::Relaxed);
Err(e)
}
}
}
pub(crate) fn normalize_start_column() {
let Ok((col, _)) = read_cursor_position() else {
return;
};
if col != 0 {
let mut out = io::stdout().lock();
let _ = out.write_all(b"\r\n");
let _ = out.flush();
}
}
pub(crate) fn resize_with_report<A: App>(
runtime: &mut Runtime<A>,
w: u16,
h: u16,
screen: ScreenMode,
) -> (Vec<u8>, Option<A::Output>)
where
A::Msg: Clone,
{
let (w, h) = crossterm::terminal::size().unwrap_or((w, h));
match screen {
ScreenMode::AltScreen => runtime.resize_screen(w, h),
ScreenMode::Inline => {
let cursor = read_cursor_position().ok();
runtime.resize_msg(w, h, cursor)
}
}
}
fn reject_effects<A: App>(runtime: &mut Runtime<A>) -> io::Result<()>
where
A::Msg: Clone,
{
if !runtime.take_effects().is_empty() {
return Err(io::Error::other(
"app spawned async work (ctx.spawn/perform); drive it with the tokio runtime \
(eye_declare::driver_tokio::run) instead of the sync run()",
));
}
if !runtime.app().subscriptions().is_empty() {
return Err(io::Error::other(
"app declares subscriptions; drive it with the tokio runtime \
(eye_declare::driver_tokio::run) instead of the sync run()",
));
}
Ok(())
}
fn keyboard_flags_to_push(
keyboard: KeyboardProtocol,
terminal_supports: impl FnOnce() -> bool,
) -> Option<crossterm::event::KeyboardEnhancementFlags> {
match keyboard {
KeyboardProtocol::Legacy => None,
KeyboardProtocol::Enhanced => terminal_supports()
.then_some(crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES),
KeyboardProtocol::Custom { flags, probe } => {
(!probe || terminal_supports()).then_some(flags)
}
}
}
pub(crate) struct RawModeGuard {
keyboard_enhanced: bool,
alt_screen: bool,
mouse_capture: bool,
}
impl RawModeGuard {
pub(crate) fn take_mouse_capture(&mut self) -> bool {
std::mem::take(&mut self.mouse_capture)
}
pub(crate) fn enable(
keyboard: KeyboardProtocol,
screen: ScreenMode,
mouse_capture: bool,
) -> io::Result<Self> {
crossterm::terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
let alt_screen = screen == ScreenMode::AltScreen;
if alt_screen {
let _ = crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen);
let _ = stdout.write_all(b"\x1b[2J\x1b[H");
let _ = stdout.flush();
}
let _ = crossterm::execute!(stdout, crossterm::event::EnableBracketedPaste);
if mouse_capture {
let _ = crossterm::execute!(stdout, crossterm::event::EnableMouseCapture);
}
let flags = keyboard_flags_to_push(keyboard, || {
crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false)
});
let keyboard_enhanced = flags.is_some();
if let Some(flags) = flags {
let _ = crossterm::execute!(
stdout,
crossterm::event::PushKeyboardEnhancementFlags(flags)
);
}
Ok(Self {
keyboard_enhanced,
alt_screen,
mouse_capture,
})
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
let mut stdout = io::stdout();
if self.keyboard_enhanced {
let _ = crossterm::execute!(stdout, crossterm::event::PopKeyboardEnhancementFlags);
}
if self.mouse_capture {
let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(50);
while std::time::Instant::now() < deadline
&& matches!(
crossterm::event::poll(std::time::Duration::from_millis(5)),
Ok(true)
)
{
if !matches!(
crossterm::event::read(),
Ok(crossterm::event::Event::Mouse(_))
) {
break;
}
}
}
if self.alt_screen {
let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
}
let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
let _ = crossterm::terminal::disable_raw_mode();
let _ = stdout.write_all(b"\x1b[?25h");
let _ = stdout.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyboardEnhancementFlags as Flags;
#[test]
fn legacy_pushes_nothing_and_never_probes() {
let flags = keyboard_flags_to_push(KeyboardProtocol::Legacy, || {
panic!("legacy must not query the terminal")
});
assert_eq!(flags, None);
}
#[test]
fn enhanced_probes_and_falls_back() {
assert_eq!(
keyboard_flags_to_push(KeyboardProtocol::Enhanced, || true),
Some(Flags::DISAMBIGUATE_ESCAPE_CODES)
);
assert_eq!(
keyboard_flags_to_push(KeyboardProtocol::Enhanced, || false),
None
);
}
#[test]
fn custom_blind_push_skips_the_probe_round_trip() {
let flags = Flags::DISAMBIGUATE_ESCAPE_CODES
| Flags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
| Flags::REPORT_ALTERNATE_KEYS;
let pushed = keyboard_flags_to_push(
KeyboardProtocol::Custom {
flags,
probe: false,
},
|| panic!("blind push must not query the terminal"),
);
assert_eq!(pushed, Some(flags));
}
#[test]
fn custom_with_probe_respects_the_answer() {
let flags = Flags::REPORT_ALTERNATE_KEYS;
assert_eq!(
keyboard_flags_to_push(KeyboardProtocol::Custom { flags, probe: true }, || true),
Some(flags)
);
assert_eq!(
keyboard_flags_to_push(KeyboardProtocol::Custom { flags, probe: true }, || false),
None
);
}
}