diff --git a/packages/iocraft/src/component.rs b/packages/iocraft/src/component.rs index 578b640..93ef4b0 100644 --- a/packages/iocraft/src/component.rs +++ b/packages/iocraft/src/component.rs @@ -161,7 +161,7 @@ impl InstantiatedComponent { pub fn update( &mut self, - context: &mut UpdateContext<'_>, + context: &mut UpdateContext<'_, '_>, unattached_child_node_ids: &mut Vec, component_context_stack: &mut ContextStack<'_>, props: AnyProps, diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index d35c8fa..5b07f76 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -11,7 +11,7 @@ use std::{ fmt::Debug, future::Future, hash::Hash, - io::{self, stderr, stdout, IsTerminal, Write}, + io::{self, stderr, stdout, IsTerminal, LineWriter, Write}, pin::Pin, sync::Arc, }; @@ -282,6 +282,16 @@ pub trait ElementExt: private::Sealed + Sized { } } +/// Specifies which handle to render the TUI to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Output { + /// Render to the stdout handle (default). + #[default] + Stdout, + /// Render to the stderr handle. + Stderr, +} + #[derive(Default)] enum RenderLoopFutureState<'a, E: ElementExt> { #[default] @@ -289,6 +299,9 @@ enum RenderLoopFutureState<'a, E: ElementExt> { Init { fullscreen: bool, ignore_ctrl_c: bool, + output: Output, + stdout_writer: Option>, + stderr_writer: Option>, element: &'a mut E, }, Running(Pin> + Send + 'a>>), @@ -309,6 +322,9 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { state: RenderLoopFutureState::Init { fullscreen: false, ignore_ctrl_c: false, + output: Output::default(), + stdout_writer: None, + stderr_writer: None, element, }, } @@ -342,6 +358,49 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { } self } + + /// Set the stdout handle for hook output and TUI rendering (when output is Stdout). + /// + /// Default: `std::io::stdout()` + pub fn stdout(mut self, writer: W) -> Self { + match &mut self.state { + RenderLoopFutureState::Init { stdout_writer, .. } => { + *stdout_writer = Some(Box::new(writer)); + } + _ => panic!("stdout() must be called before polling the future"), + } + self + } + + /// Set the stderr handle for hook output and TUI rendering (when output is Stderr). + /// + /// Default: `LineWriter::new(std::io::stderr())` + pub fn stderr(mut self, writer: W) -> Self { + match &mut self.state { + RenderLoopFutureState::Init { stderr_writer, .. } => { + *stderr_writer = Some(Box::new(writer)); + } + _ => panic!("stderr() must be called before polling the future"), + } + self + } + + /// Choose which handle to render the TUI to. + /// + /// When set to [`Output::Stderr`], the TUI will be rendered to the stderr handle. + /// This is useful for CLI tools that need to pipe stdout to other programs + /// while still displaying a TUI to the user. + /// + /// Default: [`Output::Stdout`] + pub fn output(mut self, output: Output) -> Self { + match &mut self.state { + RenderLoopFutureState::Init { output: o, .. } => { + *o = output; + } + _ => panic!("output() must be called before polling the future"), + } + self + } } impl<'a, E: ElementExt + Send + 'a> Future for RenderLoopFuture<'a, E> { @@ -354,23 +413,35 @@ impl<'a, E: ElementExt + Send + 'a> Future for RenderLoopFuture<'a, E> { loop { match &mut self.state { RenderLoopFutureState::Init { .. } => { - let (fullscreen, ignore_ctrl_c, element) = + let (fullscreen, ignore_ctrl_c, output, stdout_writer, stderr_writer, element) = match std::mem::replace(&mut self.state, RenderLoopFutureState::Empty) { RenderLoopFutureState::Init { fullscreen, ignore_ctrl_c, + output, + stdout_writer, + stderr_writer, element, - } => (fullscreen, ignore_ctrl_c, element), + } => ( + fullscreen, + ignore_ctrl_c, + output, + stdout_writer, + stderr_writer, + element, + ), _ => unreachable!(), }; - let mut terminal = match if fullscreen { - Terminal::fullscreen() - } else { - Terminal::new() - } { - Ok(t) => t, - Err(e) => return std::task::Poll::Ready(Err(e)), - }; + let stdout_handle = stdout_writer.unwrap_or_else(|| Box::new(stdout())); + // Unlike stdout, stderr is unbuffered by default in the standard library + let stderr_handle = + stderr_writer.unwrap_or_else(|| Box::new(LineWriter::new(stderr()))); + + let mut terminal = + match Terminal::new(stdout_handle, stderr_handle, output, fullscreen) { + Ok(t) => t, + Err(e) => return std::task::Poll::Ready(Err(e)), + }; if ignore_ctrl_c { terminal.ignore_ctrl_c(); } diff --git a/packages/iocraft/src/hooks/use_output.rs b/packages/iocraft/src/hooks/use_output.rs index 074d6fd..1871cee 100644 --- a/packages/iocraft/src/hooks/use_output.rs +++ b/packages/iocraft/src/hooks/use_output.rs @@ -3,8 +3,11 @@ use core::{ pin::Pin, task::{Context, Poll, Waker}, }; -use crossterm::{cursor, queue}; -use std::sync::{Arc, Mutex}; +use crossterm::{cursor, QueueableCommand}; +use std::{ + io::Write, + sync::{Arc, Mutex}, +}; mod private { pub trait Sealed {} @@ -76,40 +79,55 @@ impl UseOutputState { return; } + // Check if we have a terminal - if not, messages stay queued + if updater.terminal_mut().is_none() { + return; + } + updater.clear_terminal_output(); + let terminal = updater.terminal_mut().unwrap(); + let needs_carriage_returns = terminal.is_raw_mode_enabled(); + if let Some(col) = self.appended_newline { - let _ = queue!(std::io::stdout(), cursor::MoveUp(1), cursor::MoveRight(col)); + let _ = terminal + .render_output() + .queue(cursor::MoveUp(1)) + .and_then(|w| w.queue(cursor::MoveRight(col))); } + // Flush render output to ensure escape sequences are sent before any + // cross-stream writes (e.g., stdout messages when rendering to stderr). + let _ = terminal.render_output().flush(); - let needs_carriage_returns = updater.is_terminal_raw_mode_enabled(); let mut needs_extra_newline = self.appended_newline.is_some(); for msg in self.queue.drain(..) { match msg { Message::Stdout(msg) => { - if needs_carriage_returns { - print!("{}\r\n", msg) + let formatted = if needs_carriage_returns { + format!("{}\r\n", msg) } else { - println!("{}", msg) - } + format!("{}\n", msg) + }; + let _ = terminal.stdout().write_all(formatted.as_bytes()); needs_extra_newline = false; } Message::StdoutNoNewline(msg) => { - print!("{}", msg); + let _ = terminal.stdout().write_all(msg.as_bytes()); if !msg.is_empty() { needs_extra_newline = !msg.ends_with('\n'); } } Message::Stderr(msg) => { - if needs_carriage_returns { - eprint!("{}\r\n", msg) + let formatted = if needs_carriage_returns { + format!("{}\r\n", msg) } else { - eprintln!("{}", msg) - } + format!("{}\n", msg) + }; + let _ = terminal.stderr().write_all(formatted.as_bytes()); needs_extra_newline = false; } Message::StderrNoNewline(msg) => { - eprint!("{}", msg); + let _ = terminal.stderr().write_all(msg.as_bytes()); if !msg.is_empty() { needs_extra_newline = !msg.ends_with('\n'); } @@ -120,11 +138,8 @@ impl UseOutputState { if needs_extra_newline { if let Ok(pos) = cursor::position() { self.appended_newline = Some(pos.0); - if needs_carriage_returns { - print!("\r\n"); - } else { - println!(); - } + let newline = if needs_carriage_returns { "\r\n" } else { "\n" }; + let _ = terminal.render_output().write_all(newline.as_bytes()); } else { self.appended_newline = None; } diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs index 9f4813e..fe68f1f 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -22,29 +22,29 @@ use taffy::{ AvailableSpace, Display, Layout, NodeId, Overflow, Point, Rect, Size, Style, TaffyTree, }; -pub(crate) struct UpdateContext<'a> { - terminal: Option<&'a mut Terminal>, +pub(crate) struct UpdateContext<'a, 'w> { + terminal: Option<&'a mut Terminal<'w>>, layout_engine: &'a mut LayoutEngine, did_clear_terminal_output: bool, } /// Provides information and operations that low level component implementations may need to /// utilize during the update phase. -pub struct ComponentUpdater<'a, 'b: 'a, 'c: 'a> { +pub struct ComponentUpdater<'a, 'b: 'a, 'c: 'a, 'w> { node_id: NodeId, transparent_layout: bool, children: &'a mut Components, unattached_child_node_ids: &'a mut Vec, - context: &'a mut UpdateContext<'b>, + context: &'a mut UpdateContext<'b, 'w>, component_context_stack: &'a mut ContextStack<'c>, } -impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { +impl<'a, 'b, 'c, 'w> ComponentUpdater<'a, 'b, 'c, 'w> { pub(crate) fn new( node_id: NodeId, children: &'a mut Components, unattached_child_node_ids: &'a mut Vec, - context: &'a mut UpdateContext<'b>, + context: &'a mut UpdateContext<'b, 'w>, component_context_stack: &'a mut ContextStack<'c>, ) -> Self { Self { @@ -83,6 +83,11 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { } } + /// Returns a mutable reference to the terminal, if we're in a terminal render loop. + pub(crate) fn terminal_mut(&mut self) -> Option<&mut Terminal<'w>> { + self.context.terminal.as_deref_mut() + } + #[doc(hidden)] pub fn component_context_stack(&self) -> &ContextStack<'c> { self.component_context_stack @@ -372,10 +377,10 @@ impl<'a> Tree<'a> { } } - fn render( + fn render<'w>( &mut self, max_width: Option, - terminal: Option<&mut Terminal>, + terminal: Option<&mut Terminal<'w>>, ) -> RenderOutput { let mut wrapper_child_node_ids = vec![self.root_component.node_id()]; let did_clear_terminal_output = { @@ -455,7 +460,7 @@ impl<'a> Tree<'a> { } } - async fn terminal_render_loop(&mut self, mut term: Terminal) -> io::Result<()> { + async fn terminal_render_loop(&mut self, mut term: Terminal<'_>) -> io::Result<()> { let mut prev_canvas: Option = None; loop { term.refresh_size(); @@ -490,7 +495,7 @@ pub(crate) fn render(mut e: E, max_width: Option) -> Canva tree.render(max_width, None).canvas } -pub(crate) async fn terminal_render_loop(e: &mut E, term: Terminal) -> io::Result<()> +pub(crate) async fn terminal_render_loop<'a, E>(e: &mut E, term: Terminal<'a>) -> io::Result<()> where E: ElementExt, { diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 259fee8..0b728e3 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -1,8 +1,8 @@ -use crate::canvas::Canvas; +use crate::{canvas::Canvas, element::Output}; use crossterm::{ cursor, event::{self, Event, EventStream}, - execute, queue, terminal, + terminal, ExecutableCommand, QueueableCommand, }; use futures::{ channel::mpsc, @@ -11,7 +11,7 @@ use futures::{ }; use std::{ collections::VecDeque, - io::{self, stdin, stdout, IsTerminal, Write}, + io::{self, stdin, IsTerminal, Write}, mem, pin::Pin, sync::{Arc, Mutex, Weak}, @@ -121,11 +121,14 @@ trait TerminalImpl: Write + Send { fn clear_canvas(&mut self) -> io::Result<()>; fn write_canvas(&mut self, canvas: &Canvas) -> io::Result<()>; fn event_stream(&mut self) -> io::Result>; + fn stdout(&mut self) -> &mut dyn Write; + fn stderr(&mut self) -> &mut dyn Write; } -struct StdTerminal { +struct StdTerminal<'a> { input_is_terminal: bool, - dest: io::Stdout, + stdout: Box, + stderr: Box, fullscreen: bool, raw_mode_enabled: bool, enabled_keyboard_enhancement: bool, @@ -133,17 +136,17 @@ struct StdTerminal { size: Option<(u16, u16)>, } -impl Write for StdTerminal { +impl Write for StdTerminal<'_> { fn write(&mut self, buf: &[u8]) -> io::Result { - self.dest.write(buf) + self.stdout.write(buf) } fn flush(&mut self) -> io::Result<()> { - self.dest.flush() + self.stdout.flush() } } -impl TerminalImpl for StdTerminal { +impl TerminalImpl for StdTerminal<'_> { fn refresh_size(&mut self) { self.size = terminal::size().ok() } @@ -166,21 +169,19 @@ impl TerminalImpl for StdTerminal { if self.prev_canvas_height >= size.1 { // We have to clear the entire terminal to avoid leaving artifacts. // See: https://github.com/ccbrown/iocraft/issues/118 - return queue!( - self.dest, - terminal::Clear(terminal::ClearType::Purge), - cursor::MoveTo(0, 0), - ); + self.stdout + .queue(terminal::Clear(terminal::ClearType::Purge))? + .queue(cursor::MoveTo(0, 0))?; + return Ok(()); } } } let lines_to_rewind = self.prev_canvas_height - if self.fullscreen { 1 } else { 0 }; - queue!( - self.dest, - cursor::MoveToPreviousLine(lines_to_rewind as _), - terminal::Clear(terminal::ClearType::FromCursorDown) - ) + self.stdout + .queue(cursor::MoveToPreviousLine(lines_to_rewind as _))? + .queue(terminal::Clear(terminal::ClearType::FromCursorDown))?; + Ok(()) } fn write_canvas(&mut self, canvas: &Canvas) -> io::Result<()> { @@ -222,52 +223,59 @@ impl TerminalImpl for StdTerminal { }) .boxed()) } + + fn stdout(&mut self) -> &mut dyn Write { + &mut *self.stdout + } + + fn stderr(&mut self) -> &mut dyn Write { + &mut *self.stderr + } } -impl StdTerminal { - fn new(fullscreen: bool) -> io::Result - where - Self: Sized, - { - let mut dest = stdout(); - queue!(dest, cursor::Hide)?; - if fullscreen { - queue!(dest, terminal::EnterAlternateScreen)?; - } - Ok(Self { - dest, +impl<'a> StdTerminal<'a> { + fn new( + stdout: Box, + stderr: Box, + fullscreen: bool, + ) -> io::Result { + let mut term = Self { + stdout, + stderr, input_is_terminal: stdin().is_terminal(), fullscreen, raw_mode_enabled: false, enabled_keyboard_enhancement: false, prev_canvas_height: 0, size: None, - }) + }; + term.stdout.queue(cursor::Hide)?; + if fullscreen { + term.stdout.queue(terminal::EnterAlternateScreen)?; + } + Ok(term) } fn set_raw_mode_enabled(&mut self, raw_mode_enabled: bool) -> io::Result<()> { if raw_mode_enabled != self.raw_mode_enabled { if raw_mode_enabled { if terminal::supports_keyboard_enhancement().unwrap_or(false) { - execute!( - self.dest, - event::PushKeyboardEnhancementFlags( - event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES - ) - )?; + self.stdout.execute(event::PushKeyboardEnhancementFlags( + event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES, + ))?; self.enabled_keyboard_enhancement = true; } if self.fullscreen { - execute!(self.dest, event::EnableMouseCapture)?; + self.stdout.execute(event::EnableMouseCapture)?; } terminal::enable_raw_mode()?; } else { terminal::disable_raw_mode()?; if self.fullscreen { - execute!(self.dest, event::DisableMouseCapture)?; + self.stdout.execute(event::DisableMouseCapture)?; } if self.enabled_keyboard_enhancement { - execute!(self.dest, event::PopKeyboardEnhancementFlags)?; + self.stdout.execute(event::PopKeyboardEnhancementFlags)?; } } self.raw_mode_enabled = raw_mode_enabled; @@ -276,13 +284,13 @@ impl StdTerminal { } } -impl Drop for StdTerminal { +impl Drop for StdTerminal<'_> { fn drop(&mut self) { let _ = self.set_raw_mode_enabled(false); if self.fullscreen { - let _ = queue!(self.dest, terminal::LeaveAlternateScreen); + let _ = self.stdout.queue(terminal::LeaveAlternateScreen); } - let _ = execute!(self.dest, cursor::Show); + let _ = self.stdout.execute(cursor::Show); } } @@ -327,6 +335,8 @@ impl Default for MockTerminalConfig { struct MockTerminal { config: MockTerminalConfig, output: mpsc::UnboundedSender, + dummy_stdout: io::Sink, + dummy_stderr: io::Sink, } impl MockTerminal { @@ -337,6 +347,8 @@ impl MockTerminal { Self { config, output: output_tx, + dummy_stdout: io::sink(), + dummy_stderr: io::sink(), }, output, ) @@ -372,38 +384,45 @@ impl TerminalImpl for MockTerminal { mem::swap(&mut events, &mut self.config.events); Ok(events.chain(stream::pending()).boxed()) } + + fn stdout(&mut self) -> &mut dyn Write { + &mut self.dummy_stdout + } + + fn stderr(&mut self) -> &mut dyn Write { + &mut self.dummy_stderr + } } -pub(crate) struct Terminal { - inner: Box, +pub(crate) struct Terminal<'a> { + inner: Box, + output: Output, event_stream: Option>, subscribers: Vec>>, received_ctrl_c: bool, ignore_ctrl_c: bool, } -impl Terminal { - pub fn new() -> io::Result { - Ok(Self::new_with_impl(StdTerminal::new(false)?)) - } - - pub fn fullscreen() -> io::Result { - Ok(Self::new_with_impl(StdTerminal::new(true)?)) - } - - pub fn mock(config: MockTerminalConfig) -> (Self, MockTerminalOutputStream) { - let (term, output) = MockTerminal::new(config); - (Self::new_with_impl(term), output) - } - - fn new_with_impl(inner: T) -> Self { - Self { - inner: Box::new(inner), +impl<'a> Terminal<'a> { + pub fn new( + stdout: Box, + stderr: Box, + output: Output, + fullscreen: bool, + ) -> io::Result { + // Flip handles so StdTerminal.stdout is always the render destination + let (stdout, stderr) = match output { + Output::Stdout => (stdout, stderr), + Output::Stderr => (stderr, stdout), + }; + Ok(Self { + inner: Box::new(StdTerminal::new(stdout, stderr, fullscreen)?), + output, event_stream: None, subscribers: Vec::new(), received_ctrl_c: false, ignore_ctrl_c: false, - } + }) } pub fn ignore_ctrl_c(&mut self) { @@ -434,6 +453,34 @@ impl Terminal { self.received_ctrl_c } + /// Returns which output handle is being used for TUI rendering. + pub fn output(&self) -> Output { + self.output + } + + /// Returns a mutable reference to the stdout handle. + pub fn stdout(&mut self) -> &mut dyn Write { + // Flip back: inner.stdout is render dest, inner.stderr is alternate + match self.output { + Output::Stdout => self.inner.stdout(), + Output::Stderr => self.inner.stderr(), + } + } + + /// Returns a mutable reference to the stderr handle. + pub fn stderr(&mut self) -> &mut dyn Write { + // Flip back: inner.stdout is render dest, inner.stderr is alternate + match self.output { + Output::Stdout => self.inner.stderr(), + Output::Stderr => self.inner.stdout(), + } + } + + /// Returns a mutable reference to the render output handle (stdout or stderr based on output setting). + pub fn render_output(&mut self) -> &mut dyn Write { + self.inner.stdout() + } + /// Wraps a series of terminal updates in a synchronized update block, making sure to end the /// synchronized update even if there is an error or panic. pub fn synchronized_update(&mut self, f: F) -> io::Result<()> @@ -492,7 +539,24 @@ impl Terminal { } } -impl Write for Terminal { +impl Terminal<'static> { + pub fn mock(config: MockTerminalConfig) -> (Self, MockTerminalOutputStream) { + let (term, output_stream) = MockTerminal::new(config); + ( + Self { + inner: Box::new(term), + output: Output::Stdout, + event_stream: None, + subscribers: Vec::new(), + received_ctrl_c: false, + ignore_ctrl_c: false, + }, + output_stream, + ) + } +} + +impl Write for Terminal<'_> { fn write(&mut self, buf: &[u8]) -> io::Result { self.inner.write(buf) } @@ -504,36 +568,66 @@ impl Write for Terminal { /// Synchronized update terminal guard. /// Enters synchronized update on creation, exits when dropped. -pub(crate) struct SynchronizedUpdate<'a> { - inner: &'a mut Terminal, +pub(crate) struct SynchronizedUpdate<'a, 'b> { + inner: &'a mut Terminal<'b>, } -impl<'a> SynchronizedUpdate<'a> { - pub fn begin(terminal: &'a mut Terminal) -> io::Result { - execute!(terminal, terminal::BeginSynchronizedUpdate)?; +impl<'a, 'b> SynchronizedUpdate<'a, 'b> { + pub fn begin(terminal: &'a mut Terminal<'b>) -> io::Result { + terminal.execute(terminal::BeginSynchronizedUpdate)?; Ok(Self { inner: terminal }) } } -impl Drop for SynchronizedUpdate<'_> { +impl Drop for SynchronizedUpdate<'_, '_> { fn drop(&mut self) { - let _ = execute!(self.inner, terminal::EndSynchronizedUpdate); + let _ = self.inner.execute(terminal::EndSynchronizedUpdate); } } #[cfg(test)] mod tests { + use super::*; use crate::prelude::*; #[test] fn test_std_terminal() { // There's unfortunately not much here we can really test, but we'll do our best. // TODO: Is there a library we can use to emulate terminal input/output? - let mut terminal = Terminal::new().unwrap(); + let mut terminal = Terminal::new( + Box::new(std::io::stdout()), + Box::new(std::io::stderr()), + Output::Stdout, + false, + ) + .unwrap(); assert!(!terminal.is_raw_mode_enabled()); assert!(!terminal.received_ctrl_c()); assert!(!terminal.is_raw_mode_enabled()); let canvas = Canvas::new(10, 1); terminal.write_canvas(&canvas).unwrap(); } + + #[test] + fn test_borrowed_writers() { + let mut stdout_buf: Vec = Vec::new(); + let mut stderr_buf: Vec = Vec::new(); + + { + // Test that borrowed writers compile and work. + // The lifetime of the terminal is tied to the borrowed writers. + let mut terminal = Terminal::new( + Box::new(&mut stdout_buf), + Box::new(&mut stderr_buf), + Output::Stdout, + false, + ) + .unwrap(); + let canvas = Canvas::new(10, 1); + terminal.write_canvas(&canvas).unwrap(); + } + + // Verify we can use the buffers after the terminal is dropped + assert!(!stdout_buf.is_empty()); + } }