fatui 0.1.0

a silly little framework for terminal user interfaces
Documentation
//! terminal input and output, using ansi escape codes

mod input;

use std::panic;
use std::{
	io::{self, Write},
	time::{Duration, Instant},
};

use crossterm::{
	cursor,
	event::{self, Event as CtEvent},
	execute, style, terminal,
};
use input::InputQueue;

use crate::grid::Grid;
use crate::output::StyledChar;
use crate::{Event, Frame, pos::Size};

use super::Backend;

/// implements a backend with [`crossterm`], and on startup:
/// - enters the [alternate screen][terminal::EnterAlternateScreen]
/// - enables [mouse events][event::EnableMouseCapture],
///   [bracketed paste][event::EnableBracketedPaste],
///   and [focus change events][event::EnableFocusChange]
/// - [hides the cursor][cursor::Hide]
/// - [enables raw mode][terminal::enable_raw_mode]
///
/// on `Drop`, it then undoes all that, in reverse order.
pub struct Term<W: io::Write> {
	output: W,
	prev: Instant,
	pending: InputQueue,
	title: Option<String>,
	chars: Grid<StyledChar>,
	need_render: bool,
}

impl Term<io::StdoutLock<'_>> {
	/// create a new ansi backend with exclusive use of stdio
	///
	/// note that this takes a stdout lock, so any `println!` **will deadlock**!
	/// (`eprintln`/ `write` to other files are fine, though.)
	///
	/// since it's the only thing using stdio,
	/// it'll also set a panic hook to (try to) clean up the console before
	/// calling the old panic hook,
	/// but it **will not** restore that old hook on drop!
	pub fn excl() -> io::Result<Self> {
		let old = panic::take_hook();
		panic::set_hook(Box::new(move |phi| {
			let _ = Self::cleanup(&mut io::stderr());
			old(phi)
		}));
		Self::new(io::stdout().lock())
	}
}

impl<W: io::Write> Term<W> {
	// TODO: customizable
	const FRAME_PD: Duration = Duration::from_nanos(1_000_000_000 / 60);

	/// create a new ansi backend writing to the given output
	pub fn new(o: W) -> io::Result<Self> {
		let (x, y) = terminal::size()?;
		Self {
			output: o,
			prev: Instant::now(),
			pending: InputQueue::default(),
			title: None,
			chars: Grid::<StyledChar>::new(Size::rnew(x as _, y as _)),
			need_render: false,
		}
		.setup()
	}

	fn setup(mut self) -> io::Result<Self> {
		// TODO: make this toggleable... somehow...
		terminal::enable_raw_mode()?;
		execute!(
			&mut self.output,
			terminal::EnterAlternateScreen,
			event::EnableBracketedPaste,
			event::EnableMouseCapture,
			event::EnableFocusChange,
			event::PushKeyboardEnhancementFlags(
				event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES
					| event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
					| event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
			),
			cursor::Hide,
		)?;
		Ok(self)
	}

	fn cleanup(mut w: impl Write) -> io::Result<()> {
		crossterm::execute!(
			w,
			cursor::Show,
			event::PopKeyboardEnhancementFlags,
			event::DisableMouseCapture,
			event::DisableFocusChange,
			event::DisableBracketedPaste,
			terminal::LeaveAlternateScreen,
		)?;
		terminal::disable_raw_mode()?;
		Ok(())
	}
}

impl<W: io::Write> Drop for Term<W> {
	fn drop(&mut self) {
		let _ = Self::cleanup(&mut self.output);
	}
}

impl<W: io::Write> Term<W> {
	fn raster(&mut self, buf: &mut Vec<u8>) {
		buf.clear();
		let _ = crossterm::queue!(buf, terminal::Clear(terminal::ClearType::All),);
		if self.chars.is_empty() {
			return;
		}
		if let Some(title) = self.title.take() {
			let _ = crossterm::queue!(buf, terminal::SetTitle(title));
		}
		for (i, line) in self.chars.rows().enumerate() {
			let _ = crossterm::queue!(buf, cursor::MoveTo(0, i as u16), style::ResetColor,);
			// TODO: minify harder (how?)
			let (head, rest) = line.split_first().unwrap();
			let _ = write!(buf, "{}{}", head.style.prefix_formatter(), head.char);
			let mut style = head.style;
			for ch in rest {
				if ch.style != style {
					let _ = write!(buf, "{}", ch.style.prefix_formatter());
					style = ch.style;
				}
				let _ = write!(buf, "{}", ch.char);
			}
		}
	}

	fn render(&mut self) -> io::Result<()> {
		let buf = &mut vec![];
		self.raster(buf);
		self.output.write_all(&*buf)?;
		buf.clear();
		Ok(())
	}
}

impl<W: io::Write> Backend for Term<W> {
	// we want to:
	// - yield inputs as fast as the receiver can take them
	// - reduce their workload if they're lagging
	//   by compressing inputs (and not requesting redraws?)
	// - request a redraw after FRAME_PD
	// - render the redrawn frame immediately after sending it
	fn step(&mut self) -> io::Result<Frame<'_, Event>> {
		if self.need_render {
			self.render()?;
			self.need_render = false;
		}
		let deadline = self.prev + Self::FRAME_PD;
		let (input, redraw) = loop {
			let rem = deadline.checked_duration_since(Instant::now());
			if let Some(p) = self.pending.pop() {
				break (p, rem.is_none());
			};
			let Some(rem) = rem else {
				break (Event::Redraw, true);
			};
			if event::poll(rem)? {
				self.pending.push_ct(event::read()?);
			} else {
				break (Event::Redraw, true);
			}
		};
		if redraw {
			self.need_render = true;
			self.prev = Instant::now();
		}
		if let Some(sz) = self.pending.new_sz {
			self.chars.resize(sz);
		}
		// TODO: send null frames when we can (how?)
		Ok(Frame::new(input, &mut self.chars))
	}

	fn title(&mut self, title: &str) -> io::Result<()> {
		self.title = Some(title.to_owned());
		Ok(())
	}
}