From 17cc06c56e03435afe62275d4db99d472ffa8c8d Mon Sep 17 00:00:00 2001 From: Sander Date: Tue, 9 Dec 2025 16:15:19 +0100 Subject: [PATCH 1/6] feat: allow configuring render output and stdout/stderr handles Example usage: ``` element.render_loop() .output(Output::Stderr) .stdout(custom_writer) .stderr(custom_writer) .await ``` --- packages/iocraft/src/element.rs | 98 +++++++++++++++++++++--- packages/iocraft/src/hooks/use_output.rs | 81 ++++++++++++++------ packages/iocraft/src/render.rs | 9 ++- packages/iocraft/src/terminal.rs | 87 +++++++++++++++++---- 4 files changed, 225 insertions(+), 50 deletions(-) diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index d35c8fa..c36f273 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -3,7 +3,9 @@ use crate::{ component::{Component, ComponentHelper, ComponentHelperExt}, mock_terminal_render_loop, props::AnyProps, - render, terminal_render_loop, Canvas, MockTerminalConfig, Terminal, + render, + terminal::TerminalConfig, + terminal_render_loop, Canvas, MockTerminalConfig, Terminal, }; use crossterm::terminal; use futures::Stream; @@ -11,9 +13,9 @@ 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, + sync::{Arc, Mutex}, }; /// Used by the `element!` macro to extend a collection with elements. @@ -282,6 +284,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 +301,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 +324,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 +360,49 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { } self } + + /// Set the stdout handle for hook output. + /// + /// 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. + /// + /// 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,20 +415,37 @@ 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() - } { + let terminal_config = TerminalConfig { + stdout: Arc::new(Mutex::new( + stdout_writer.unwrap_or_else(|| Box::new(stdout())), + )), + // Unlike stdout, stderr is unbuffered by default in the standard library + stderr: Arc::new(Mutex::new( + stderr_writer.unwrap_or_else(|| Box::new(LineWriter::new(stderr()))), + )), + render_to: output, + }; + let terminal = Terminal::with_terminal_config(terminal_config, fullscreen); + let mut terminal = match terminal { Ok(t) => t, Err(e) => return std::task::Poll::Ready(Err(e)), }; diff --git a/packages/iocraft/src/hooks/use_output.rs b/packages/iocraft/src/hooks/use_output.rs index 074d6fd..d79c581 100644 --- a/packages/iocraft/src/hooks/use_output.rs +++ b/packages/iocraft/src/hooks/use_output.rs @@ -1,10 +1,13 @@ -use crate::{ComponentUpdater, Hook, Hooks}; +use crate::{element::Output, ComponentUpdater, Hook, Hooks}; use core::{ pin::Pin, task::{Context, Poll, Waker}, }; use crossterm::{cursor, queue}; -use std::sync::{Arc, Mutex}; +use std::{ + io::Write, + sync::{Arc, Mutex}, +}; mod private { pub trait Sealed {} @@ -76,41 +79,78 @@ impl UseOutputState { return; } - updater.clear_terminal_output(); - if let Some(col) = self.appended_newline { - let _ = queue!(std::io::stdout(), cursor::MoveUp(1), cursor::MoveRight(col)); + // Check if we have a terminal - if not, messages stay queued + if updater.terminal_config().is_none() { + return; } + updater.clear_terminal_output(); let needs_carriage_returns = updater.is_terminal_raw_mode_enabled(); + + let terminal_config = updater.terminal_config().unwrap(); + let stdout = &terminal_config.stdout; + let stderr = &terminal_config.stderr; + let render_to = terminal_config.render_to; + + let render_handle = match render_to { + Output::Stdout => stdout, + Output::Stderr => stderr, + }; + + if let Some(col) = self.appended_newline { + let _ = queue!( + render_handle.lock().unwrap(), + cursor::MoveUp(1), + cursor::MoveRight(col) + ); + } let mut needs_extra_newline = self.appended_newline.is_some(); for msg in self.queue.drain(..) { + // Cursor manipulation only works when message output matches the render target + let msg_matches_render = matches!( + (&msg, render_to), + ( + Message::Stdout(_) | Message::StdoutNoNewline(_), + Output::Stdout + ) | ( + Message::Stderr(_) | Message::StderrNoNewline(_), + Output::Stderr + ) + ); + 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 _ = stdout.lock().unwrap().write_all(formatted.as_bytes()); + if msg_matches_render { + needs_extra_newline = false; } - needs_extra_newline = false; } Message::StdoutNoNewline(msg) => { - print!("{}", msg); - if !msg.is_empty() { + let _ = stdout.lock().unwrap().write_all(msg.as_bytes()); + if msg_matches_render && !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 _ = stderr.lock().unwrap().write_all(formatted.as_bytes()); + if msg_matches_render { + needs_extra_newline = false; } - needs_extra_newline = false; } Message::StderrNoNewline(msg) => { - eprint!("{}", msg); - if !msg.is_empty() { + let _ = stderr.lock().unwrap().write_all(msg.as_bytes()); + if msg_matches_render && !msg.is_empty() { needs_extra_newline = !msg.ends_with('\n'); } } @@ -120,11 +160,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 _ = render_handle.lock().unwrap().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..0d1572e 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -5,7 +5,9 @@ use crate::{ element::ElementExt, multimap::AppendOnlyMultimap, props::AnyProps, - terminal::{MockTerminalConfig, MockTerminalOutputStream, Terminal, TerminalEvents}, + terminal::{ + MockTerminalConfig, MockTerminalOutputStream, TerminalConfig, Terminal, TerminalEvents, + }, }; use core::{ any::Any, @@ -83,6 +85,11 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { } } + /// Returns the output configuration, if running in a terminal render loop. + pub fn terminal_config(&self) -> Option<&TerminalConfig> { + self.context.terminal.as_ref().map(|t| t.terminal_config()) + } + #[doc(hidden)] pub fn component_context_stack(&self) -> &ContextStack<'c> { self.component_context_stack diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 259fee8..d499670 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -1,4 +1,4 @@ -use crate::canvas::Canvas; +use crate::{canvas::Canvas, element::Output}; use crossterm::{ cursor, event::{self, Event, EventStream}, @@ -11,13 +11,48 @@ use futures::{ }; use std::{ collections::VecDeque, - io::{self, stdin, stdout, IsTerminal, Write}, + io::{self, stderr, stdin, stdout, IsTerminal, LineWriter, Write}, mem, pin::Pin, sync::{Arc, Mutex, Weak}, task::{Context, Poll, Waker}, }; +/// Configuration for output handles used by the render loop. +pub struct TerminalConfig { + /// The stdout handle for hook output. + pub stdout: Arc>>, + /// The stderr handle for hook output. + pub stderr: Arc>>, + /// Which handle to render the TUI to. + pub render_to: Output, +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { + stdout: Arc::new(Mutex::new(Box::new(stdout()))), + stderr: Arc::new(Mutex::new(Box::new(LineWriter::new(stderr())))), + render_to: Output::default(), + } + } +} + +/// A writer that delegates to a shared handle. +struct SharedWriter { + inner: Arc>>, +} + +impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.lock().unwrap().flush() + } +} + // Re-exports for basic types. pub use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers, MouseEventKind}; @@ -123,9 +158,9 @@ trait TerminalImpl: Write + Send { fn event_stream(&mut self) -> io::Result>; } -struct StdTerminal { +struct StdTerminal { input_is_terminal: bool, - dest: io::Stdout, + dest: W, fullscreen: bool, raw_mode_enabled: bool, enabled_keyboard_enhancement: bool, @@ -133,7 +168,7 @@ 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) } @@ -143,7 +178,7 @@ impl Write for StdTerminal { } } -impl TerminalImpl for StdTerminal { +impl TerminalImpl for StdTerminal { fn refresh_size(&mut self) { self.size = terminal::size().ok() } @@ -224,12 +259,8 @@ impl TerminalImpl for StdTerminal { } } -impl StdTerminal { - fn new(fullscreen: bool) -> io::Result - where - Self: Sized, - { - let mut dest = stdout(); +impl StdTerminal { + fn new(mut dest: W, fullscreen: bool) -> io::Result { queue!(dest, cursor::Hide)?; if fullscreen { queue!(dest, terminal::EnterAlternateScreen)?; @@ -276,7 +307,7 @@ impl StdTerminal { } } -impl Drop for StdTerminal { +impl Drop for StdTerminal { fn drop(&mut self) { let _ = self.set_raw_mode_enabled(false); if self.fullscreen { @@ -380,32 +411,54 @@ pub(crate) struct Terminal { subscribers: Vec>>, received_ctrl_c: bool, ignore_ctrl_c: bool, + terminal_config: TerminalConfig, } impl Terminal { pub fn new() -> io::Result { - Ok(Self::new_with_impl(StdTerminal::new(false)?)) + Self::with_terminal_config(TerminalConfig::default(), false) } pub fn fullscreen() -> io::Result { - Ok(Self::new_with_impl(StdTerminal::new(true)?)) + Self::with_terminal_config(TerminalConfig::default(), true) + } + + pub fn with_terminal_config( + terminal_config: TerminalConfig, + fullscreen: bool, + ) -> io::Result { + let writer = SharedWriter { + inner: match terminal_config.render_to { + Output::Stdout => terminal_config.stdout.clone(), + Output::Stderr => terminal_config.stderr.clone(), + }, + }; + Ok(Self::new_with_impl( + StdTerminal::new(writer, fullscreen)?, + terminal_config, + )) } pub fn mock(config: MockTerminalConfig) -> (Self, MockTerminalOutputStream) { let (term, output) = MockTerminal::new(config); - (Self::new_with_impl(term), output) + (Self::new_with_impl(term, TerminalConfig::default()), output) } - fn new_with_impl(inner: T) -> Self { + fn new_with_impl(inner: T, terminal_config: TerminalConfig) -> Self { Self { inner: Box::new(inner), event_stream: None, subscribers: Vec::new(), received_ctrl_c: false, ignore_ctrl_c: false, + terminal_config, } } + pub fn terminal_config(&self) -> &TerminalConfig { + &self.terminal_config + } + pub fn ignore_ctrl_c(&mut self) { self.ignore_ctrl_c = true; } From f4f81381a0d5f3d6b40918a6c55ef3a69956feb8 Mon Sep 17 00:00:00 2001 From: Sander Date: Fri, 23 Jan 2026 12:11:36 +0100 Subject: [PATCH 2/6] refactor: move terminal handles to SystemContext --- packages/iocraft/src/context.rs | 44 ++++++++++++++++- packages/iocraft/src/element.rs | 33 +++++++------ packages/iocraft/src/hooks/use_output.rs | 16 +++---- packages/iocraft/src/render.rs | 52 +++++++++++++++----- packages/iocraft/src/terminal.rs | 61 +++++------------------- 5 files changed, 120 insertions(+), 86 deletions(-) diff --git a/packages/iocraft/src/context.rs b/packages/iocraft/src/context.rs index a8f0a88..c6a6e60 100644 --- a/packages/iocraft/src/context.rs +++ b/packages/iocraft/src/context.rs @@ -1,17 +1,42 @@ +use crate::element::Output; use core::{ any::Any, cell::{Ref, RefCell, RefMut}, mem, }; +use std::{ + io::{self, stderr, stdout, LineWriter, Write}, + sync::{Arc, Mutex}, +}; /// The system context, which is always available to all components. pub struct SystemContext { should_exit: bool, + stdout: Arc>>, + stderr: Arc>>, + render_to: Output, } impl SystemContext { - pub(crate) fn new() -> Self { - Self { should_exit: false } + pub(crate) fn new( + stdout: Arc>>, + stderr: Arc>>, + render_to: Output, + ) -> Self { + Self { + should_exit: false, + stdout, + stderr, + render_to, + } + } + + pub(crate) fn new_default() -> Self { + Self::new( + Arc::new(Mutex::new(Box::new(stdout()))), + Arc::new(Mutex::new(Box::new(LineWriter::new(stderr())))), + Output::default(), + ) } /// If called from a component that is being dynamically rendered, this will cause the render @@ -23,6 +48,21 @@ impl SystemContext { pub(crate) fn should_exit(&self) -> bool { self.should_exit } + + /// Returns the stdout handle. + pub fn stdout(&self) -> Arc>> { + self.stdout.clone() + } + + /// Returns the stderr handle. + pub fn stderr(&self) -> Arc>> { + self.stderr.clone() + } + + /// Returns which handle the TUI is being rendered to. + pub fn render_to(&self) -> Output { + self.render_to + } } /// A context that can be passed to components. diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index c36f273..5d8d705 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -3,9 +3,7 @@ use crate::{ component::{Component, ComponentHelper, ComponentHelperExt}, mock_terminal_render_loop, props::AnyProps, - render, - terminal::TerminalConfig, - terminal_render_loop, Canvas, MockTerminalConfig, Terminal, + render, terminal_render_loop, Canvas, MockTerminalConfig, Terminal, }; use crossterm::terminal; use futures::Stream; @@ -434,17 +432,18 @@ impl<'a, E: ElementExt + Send + 'a> Future for RenderLoopFuture<'a, E> { ), _ => unreachable!(), }; - let terminal_config = TerminalConfig { - stdout: Arc::new(Mutex::new( - stdout_writer.unwrap_or_else(|| Box::new(stdout())), - )), - // Unlike stdout, stderr is unbuffered by default in the standard library - stderr: Arc::new(Mutex::new( - stderr_writer.unwrap_or_else(|| Box::new(LineWriter::new(stderr()))), - )), - render_to: output, + let stdout_handle = Arc::new(Mutex::new( + stdout_writer.unwrap_or_else(|| Box::new(stdout())), + )); + // Unlike stdout, stderr is unbuffered by default in the standard library + let stderr_handle = Arc::new(Mutex::new( + stderr_writer.unwrap_or_else(|| Box::new(LineWriter::new(stderr()))), + )); + let render_handle = match output { + Output::Stdout => stdout_handle.clone(), + Output::Stderr => stderr_handle.clone(), }; - let terminal = Terminal::with_terminal_config(terminal_config, fullscreen); + let terminal = Terminal::with_render_handle(render_handle, fullscreen); let mut terminal = match terminal { Ok(t) => t, Err(e) => return std::task::Poll::Ready(Err(e)), @@ -452,7 +451,13 @@ impl<'a, E: ElementExt + Send + 'a> Future for RenderLoopFuture<'a, E> { if ignore_ctrl_c { terminal.ignore_ctrl_c(); } - let fut = Box::pin(terminal_render_loop(element, terminal)); + let fut = Box::pin(terminal_render_loop( + element, + terminal, + stdout_handle, + stderr_handle, + output, + )); self.state = RenderLoopFutureState::Running(fut); } RenderLoopFutureState::Running(fut) => { diff --git a/packages/iocraft/src/hooks/use_output.rs b/packages/iocraft/src/hooks/use_output.rs index d79c581..7135fa4 100644 --- a/packages/iocraft/src/hooks/use_output.rs +++ b/packages/iocraft/src/hooks/use_output.rs @@ -1,4 +1,4 @@ -use crate::{element::Output, ComponentUpdater, Hook, Hooks}; +use crate::{context::SystemContext, element::Output, ComponentUpdater, Hook, Hooks}; use core::{ pin::Pin, task::{Context, Poll, Waker}, @@ -80,21 +80,21 @@ impl UseOutputState { } // Check if we have a terminal - if not, messages stay queued - if updater.terminal_config().is_none() { + if !updater.is_terminal_render_loop() { return; } updater.clear_terminal_output(); let needs_carriage_returns = updater.is_terminal_raw_mode_enabled(); - let terminal_config = updater.terminal_config().unwrap(); - let stdout = &terminal_config.stdout; - let stderr = &terminal_config.stderr; - let render_to = terminal_config.render_to; + let system = updater.get_context::().unwrap(); + let stdout = system.stdout(); + let stderr = system.stderr(); + let render_to = system.render_to(); let render_handle = match render_to { - Output::Stdout => stdout, - Output::Stderr => stderr, + Output::Stdout => &stdout, + Output::Stderr => &stderr, }; if let Some(col) = self.appended_newline { diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs index 0d1572e..7e0dc0c 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -2,12 +2,10 @@ use crate::{ canvas::{Canvas, CanvasSubviewMut}, component::{ComponentHelperExt, Components, InstantiatedComponent}, context::{Context, ContextStack, SystemContext}, - element::ElementExt, + element::{ElementExt, Output}, multimap::AppendOnlyMultimap, props::AnyProps, - terminal::{ - MockTerminalConfig, MockTerminalOutputStream, TerminalConfig, Terminal, TerminalEvents, - }, + terminal::{MockTerminalConfig, MockTerminalOutputStream, Terminal, TerminalEvents}, }; use core::{ any::Any, @@ -20,6 +18,7 @@ use futures::{ stream::{Stream, StreamExt}, }; use std::io; +use std::sync::{Arc, Mutex}; use taffy::{ AvailableSpace, Display, Layout, NodeId, Overflow, Point, Rect, Size, Style, TaffyTree, }; @@ -85,9 +84,9 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { } } - /// Returns the output configuration, if running in a terminal render loop. - pub fn terminal_config(&self) -> Option<&TerminalConfig> { - self.context.terminal.as_ref().map(|t| t.terminal_config()) + /// Returns whether we're running in a terminal render loop. + pub(crate) fn is_terminal_render_loop(&self) -> bool { + self.context.terminal.is_some() } #[doc(hidden)] @@ -362,7 +361,13 @@ struct RenderOutput { } impl<'a> Tree<'a> { - fn new(mut props: AnyProps<'a>, helper: Box) -> Self { + fn new( + mut props: AnyProps<'a>, + helper: Box, + stdout: Arc>>, + stderr: Arc>>, + render_to: Output, + ) -> Self { let mut layout_engine = TaffyTree::new(); let root_node_id = layout_engine .new_leaf_with_context(Style::default(), LayoutEngineNodeContext::default()) @@ -375,7 +380,7 @@ impl<'a> Tree<'a> { wrapper_node_id, root_component: InstantiatedComponent::new(root_node_id, props.borrow(), helper), root_component_props: props, - system_context: SystemContext::new(), + system_context: SystemContext::new(stdout, stderr, render_to), } } @@ -493,16 +498,29 @@ impl<'a> Tree<'a> { pub(crate) fn render(mut e: E, max_width: Option) -> Canvas { let h = e.helper(); - let mut tree = Tree::new(e.props_mut(), h); + let system_context = SystemContext::new_default(); + let mut tree = Tree::new( + e.props_mut(), + h, + system_context.stdout(), + system_context.stderr(), + system_context.render_to(), + ); 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( + e: &mut E, + term: Terminal, + stdout: Arc>>, + stderr: Arc>>, + render_to: Output, +) -> io::Result<()> where E: ElementExt, { let h = e.helper(); - let mut tree = Tree::new(e.props_mut(), h); + let mut tree = Tree::new(e.props_mut(), h, stdout, stderr, render_to); tree.terminal_render_loop(term).await } @@ -534,8 +552,16 @@ where E: ElementExt + 'a, { let (term, output) = Terminal::mock(config); + let system_context = SystemContext::new_default(); MockTerminalRenderLoop { - render_loop: terminal_render_loop(e, term).boxed_local(), + render_loop: terminal_render_loop( + e, + term, + system_context.stdout(), + system_context.stderr(), + system_context.render_to(), + ) + .boxed_local(), render_loop_is_done: false, output, } diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index d499670..8384847 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -1,4 +1,4 @@ -use crate::{canvas::Canvas, element::Output}; +use crate::canvas::Canvas; use crossterm::{ cursor, event::{self, Event, EventStream}, @@ -11,33 +11,13 @@ use futures::{ }; use std::{ collections::VecDeque, - io::{self, stderr, stdin, stdout, IsTerminal, LineWriter, Write}, + io::{self, stdin, stdout, IsTerminal, Write}, mem, pin::Pin, sync::{Arc, Mutex, Weak}, task::{Context, Poll, Waker}, }; -/// Configuration for output handles used by the render loop. -pub struct TerminalConfig { - /// The stdout handle for hook output. - pub stdout: Arc>>, - /// The stderr handle for hook output. - pub stderr: Arc>>, - /// Which handle to render the TUI to. - pub render_to: Output, -} - -impl Default for TerminalConfig { - fn default() -> Self { - Self { - stdout: Arc::new(Mutex::new(Box::new(stdout()))), - stderr: Arc::new(Mutex::new(Box::new(LineWriter::new(stderr())))), - render_to: Output::default(), - } - } -} - /// A writer that delegates to a shared handle. struct SharedWriter { inner: Arc>>, @@ -411,54 +391,34 @@ pub(crate) struct Terminal { subscribers: Vec>>, received_ctrl_c: bool, ignore_ctrl_c: bool, - terminal_config: TerminalConfig, } impl Terminal { - pub fn new() -> io::Result { - Self::with_terminal_config(TerminalConfig::default(), false) - } - - pub fn fullscreen() -> io::Result { - Self::with_terminal_config(TerminalConfig::default(), true) - } - - pub fn with_terminal_config( - terminal_config: TerminalConfig, + pub fn with_render_handle( + render_handle: Arc>>, fullscreen: bool, ) -> io::Result { let writer = SharedWriter { - inner: match terminal_config.render_to { - Output::Stdout => terminal_config.stdout.clone(), - Output::Stderr => terminal_config.stderr.clone(), - }, + inner: render_handle, }; - Ok(Self::new_with_impl( - StdTerminal::new(writer, fullscreen)?, - terminal_config, - )) + Ok(Self::new_with_impl(StdTerminal::new(writer, fullscreen)?)) } pub fn mock(config: MockTerminalConfig) -> (Self, MockTerminalOutputStream) { let (term, output) = MockTerminal::new(config); - (Self::new_with_impl(term, TerminalConfig::default()), output) + (Self::new_with_impl(term), output) } - fn new_with_impl(inner: T, terminal_config: TerminalConfig) -> Self { + fn new_with_impl(inner: T) -> Self { Self { inner: Box::new(inner), event_stream: None, subscribers: Vec::new(), received_ctrl_c: false, ignore_ctrl_c: false, - terminal_config, } } - pub fn terminal_config(&self) -> &TerminalConfig { - &self.terminal_config - } - pub fn ignore_ctrl_c(&mut self) { self.ignore_ctrl_c = true; } @@ -577,12 +537,15 @@ impl Drop for SynchronizedUpdate<'_> { #[cfg(test)] mod tests { use crate::prelude::*; + use std::sync::{Arc, Mutex}; #[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 render_handle: Arc>> = + Arc::new(Mutex::new(Box::new(std::io::stdout()))); + let mut terminal = Terminal::with_render_handle(render_handle, false).unwrap(); assert!(!terminal.is_raw_mode_enabled()); assert!(!terminal.received_ctrl_c()); assert!(!terminal.is_raw_mode_enabled()); From 1215852b737b39ad262a22400075ba52c4d96989 Mon Sep 17 00:00:00 2001 From: sandydoo Date: Sat, 24 Jan 2026 13:45:02 +0100 Subject: [PATCH 3/6] refactor: make terminal own the stdout/stderr handles Both StdTerminal and output hooks need to write to the handles. We have two options: 1. use Arc + Mutex to share the handles and control write access 2. make the terminal own the handles and provide write access via methods This implements option 2. --- packages/iocraft/src/context.rs | 44 +---- packages/iocraft/src/element.rs | 38 ++--- packages/iocraft/src/hooks/use_output.rs | 46 ++--- packages/iocraft/src/render.rs | 48 ++---- packages/iocraft/src/terminal.rs | 207 ++++++++++++++--------- 5 files changed, 172 insertions(+), 211 deletions(-) diff --git a/packages/iocraft/src/context.rs b/packages/iocraft/src/context.rs index c6a6e60..a8f0a88 100644 --- a/packages/iocraft/src/context.rs +++ b/packages/iocraft/src/context.rs @@ -1,42 +1,17 @@ -use crate::element::Output; use core::{ any::Any, cell::{Ref, RefCell, RefMut}, mem, }; -use std::{ - io::{self, stderr, stdout, LineWriter, Write}, - sync::{Arc, Mutex}, -}; /// The system context, which is always available to all components. pub struct SystemContext { should_exit: bool, - stdout: Arc>>, - stderr: Arc>>, - render_to: Output, } impl SystemContext { - pub(crate) fn new( - stdout: Arc>>, - stderr: Arc>>, - render_to: Output, - ) -> Self { - Self { - should_exit: false, - stdout, - stderr, - render_to, - } - } - - pub(crate) fn new_default() -> Self { - Self::new( - Arc::new(Mutex::new(Box::new(stdout()))), - Arc::new(Mutex::new(Box::new(LineWriter::new(stderr())))), - Output::default(), - ) + pub(crate) fn new() -> Self { + Self { should_exit: false } } /// If called from a component that is being dynamically rendered, this will cause the render @@ -48,21 +23,6 @@ impl SystemContext { pub(crate) fn should_exit(&self) -> bool { self.should_exit } - - /// Returns the stdout handle. - pub fn stdout(&self) -> Arc>> { - self.stdout.clone() - } - - /// Returns the stderr handle. - pub fn stderr(&self) -> Arc>> { - self.stderr.clone() - } - - /// Returns which handle the TUI is being rendered to. - pub fn render_to(&self) -> Output { - self.render_to - } } /// A context that can be passed to components. diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index 5d8d705..2217b50 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -13,7 +13,7 @@ use std::{ hash::Hash, io::{self, stderr, stdout, IsTerminal, LineWriter, Write}, pin::Pin, - sync::{Arc, Mutex}, + sync::Arc, }; /// Used by the `element!` macro to extend a collection with elements. @@ -359,7 +359,7 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { self } - /// Set the stdout handle for hook output. + /// 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 { @@ -372,7 +372,7 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { self } - /// Set the stderr handle for hook output. + /// 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 { @@ -432,32 +432,20 @@ impl<'a, E: ElementExt + Send + 'a> Future for RenderLoopFuture<'a, E> { ), _ => unreachable!(), }; - let stdout_handle = Arc::new(Mutex::new( - stdout_writer.unwrap_or_else(|| Box::new(stdout())), - )); + 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 = Arc::new(Mutex::new( - stderr_writer.unwrap_or_else(|| Box::new(LineWriter::new(stderr()))), - )); - let render_handle = match output { - Output::Stdout => stdout_handle.clone(), - Output::Stderr => stderr_handle.clone(), - }; - let terminal = Terminal::with_render_handle(render_handle, fullscreen); - let mut terminal = match terminal { - Ok(t) => t, - Err(e) => return std::task::Poll::Ready(Err(e)), - }; + 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(); } - let fut = Box::pin(terminal_render_loop( - element, - terminal, - stdout_handle, - stderr_handle, - output, - )); + let fut = Box::pin(terminal_render_loop(element, terminal)); self.state = RenderLoopFutureState::Running(fut); } RenderLoopFutureState::Running(fut) => { diff --git a/packages/iocraft/src/hooks/use_output.rs b/packages/iocraft/src/hooks/use_output.rs index 7135fa4..7dd029a 100644 --- a/packages/iocraft/src/hooks/use_output.rs +++ b/packages/iocraft/src/hooks/use_output.rs @@ -1,13 +1,10 @@ -use crate::{context::SystemContext, element::Output, ComponentUpdater, Hook, Hooks}; +use crate::{element::Output, ComponentUpdater, Hook, Hooks}; use core::{ pin::Pin, task::{Context, Poll, Waker}, }; -use crossterm::{cursor, queue}; -use std::{ - io::Write, - sync::{Arc, Mutex}, -}; +use crossterm::{cursor, QueueableCommand}; +use std::sync::{Arc, Mutex}; mod private { pub trait Sealed {} @@ -80,36 +77,27 @@ impl UseOutputState { } // Check if we have a terminal - if not, messages stay queued - if !updater.is_terminal_render_loop() { + if updater.terminal_mut().is_none() { return; } updater.clear_terminal_output(); - let needs_carriage_returns = updater.is_terminal_raw_mode_enabled(); - - let system = updater.get_context::().unwrap(); - let stdout = system.stdout(); - let stderr = system.stderr(); - let render_to = system.render_to(); - - let render_handle = match render_to { - Output::Stdout => &stdout, - Output::Stderr => &stderr, - }; + let terminal = updater.terminal_mut().unwrap(); + let needs_carriage_returns = terminal.is_raw_mode_enabled(); + let output = terminal.output(); if let Some(col) = self.appended_newline { - let _ = queue!( - render_handle.lock().unwrap(), - cursor::MoveUp(1), - cursor::MoveRight(col) - ); + let _ = terminal + .render_output() + .queue(cursor::MoveUp(1)) + .and_then(|w| w.queue(cursor::MoveRight(col))); } let mut needs_extra_newline = self.appended_newline.is_some(); for msg in self.queue.drain(..) { // Cursor manipulation only works when message output matches the render target let msg_matches_render = matches!( - (&msg, render_to), + (&msg, output), ( Message::Stdout(_) | Message::StdoutNoNewline(_), Output::Stdout @@ -126,13 +114,13 @@ impl UseOutputState { } else { format!("{}\n", msg) }; - let _ = stdout.lock().unwrap().write_all(formatted.as_bytes()); + let _ = terminal.stdout().write_all(formatted.as_bytes()); if msg_matches_render { needs_extra_newline = false; } } Message::StdoutNoNewline(msg) => { - let _ = stdout.lock().unwrap().write_all(msg.as_bytes()); + let _ = terminal.stdout().write_all(msg.as_bytes()); if msg_matches_render && !msg.is_empty() { needs_extra_newline = !msg.ends_with('\n'); } @@ -143,13 +131,13 @@ impl UseOutputState { } else { format!("{}\n", msg) }; - let _ = stderr.lock().unwrap().write_all(formatted.as_bytes()); + let _ = terminal.stderr().write_all(formatted.as_bytes()); if msg_matches_render { needs_extra_newline = false; } } Message::StderrNoNewline(msg) => { - let _ = stderr.lock().unwrap().write_all(msg.as_bytes()); + let _ = terminal.stderr().write_all(msg.as_bytes()); if msg_matches_render && !msg.is_empty() { needs_extra_newline = !msg.ends_with('\n'); } @@ -161,7 +149,7 @@ impl UseOutputState { if let Ok(pos) = cursor::position() { self.appended_newline = Some(pos.0); let newline = if needs_carriage_returns { "\r\n" } else { "\n" }; - let _ = render_handle.lock().unwrap().write_all(newline.as_bytes()); + 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 7e0dc0c..7c8829c 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -2,7 +2,7 @@ use crate::{ canvas::{Canvas, CanvasSubviewMut}, component::{ComponentHelperExt, Components, InstantiatedComponent}, context::{Context, ContextStack, SystemContext}, - element::{ElementExt, Output}, + element::ElementExt, multimap::AppendOnlyMultimap, props::AnyProps, terminal::{MockTerminalConfig, MockTerminalOutputStream, Terminal, TerminalEvents}, @@ -18,7 +18,6 @@ use futures::{ stream::{Stream, StreamExt}, }; use std::io; -use std::sync::{Arc, Mutex}; use taffy::{ AvailableSpace, Display, Layout, NodeId, Overflow, Point, Rect, Size, Style, TaffyTree, }; @@ -84,9 +83,9 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> { } } - /// Returns whether we're running in a terminal render loop. - pub(crate) fn is_terminal_render_loop(&self) -> bool { - self.context.terminal.is_some() + /// 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> { + self.context.terminal.as_deref_mut() } #[doc(hidden)] @@ -361,13 +360,7 @@ struct RenderOutput { } impl<'a> Tree<'a> { - fn new( - mut props: AnyProps<'a>, - helper: Box, - stdout: Arc>>, - stderr: Arc>>, - render_to: Output, - ) -> Self { + fn new(mut props: AnyProps<'a>, helper: Box) -> Self { let mut layout_engine = TaffyTree::new(); let root_node_id = layout_engine .new_leaf_with_context(Style::default(), LayoutEngineNodeContext::default()) @@ -380,7 +373,7 @@ impl<'a> Tree<'a> { wrapper_node_id, root_component: InstantiatedComponent::new(root_node_id, props.borrow(), helper), root_component_props: props, - system_context: SystemContext::new(stdout, stderr, render_to), + system_context: SystemContext::new(), } } @@ -498,29 +491,16 @@ impl<'a> Tree<'a> { pub(crate) fn render(mut e: E, max_width: Option) -> Canvas { let h = e.helper(); - let system_context = SystemContext::new_default(); - let mut tree = Tree::new( - e.props_mut(), - h, - system_context.stdout(), - system_context.stderr(), - system_context.render_to(), - ); + let mut tree = Tree::new(e.props_mut(), h); tree.render(max_width, None).canvas } -pub(crate) async fn terminal_render_loop( - e: &mut E, - term: Terminal, - stdout: Arc>>, - stderr: Arc>>, - render_to: Output, -) -> io::Result<()> +pub(crate) async fn terminal_render_loop(e: &mut E, term: Terminal) -> io::Result<()> where E: ElementExt, { let h = e.helper(); - let mut tree = Tree::new(e.props_mut(), h, stdout, stderr, render_to); + let mut tree = Tree::new(e.props_mut(), h); tree.terminal_render_loop(term).await } @@ -552,16 +532,8 @@ where E: ElementExt + 'a, { let (term, output) = Terminal::mock(config); - let system_context = SystemContext::new_default(); MockTerminalRenderLoop { - render_loop: terminal_render_loop( - e, - term, - system_context.stdout(), - system_context.stderr(), - system_context.render_to(), - ) - .boxed_local(), + render_loop: terminal_render_loop(e, term).boxed_local(), render_loop_is_done: false, output, } diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 8384847..0efb95d 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,28 +11,13 @@ use futures::{ }; use std::{ collections::VecDeque, - io::{self, stdin, stdout, IsTerminal, Write}, + io::{self, stdin, IsTerminal, Write}, mem, pin::Pin, sync::{Arc, Mutex, Weak}, task::{Context, Poll, Waker}, }; -/// A writer that delegates to a shared handle. -struct SharedWriter { - inner: Arc>>, -} - -impl Write for SharedWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.inner.lock().unwrap().write(buf) - } - - fn flush(&mut self) -> io::Result<()> { - self.inner.lock().unwrap().flush() - } -} - // Re-exports for basic types. pub use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers, MouseEventKind}; @@ -136,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 { input_is_terminal: bool, - dest: W, + stdout: Box, + stderr: Box, fullscreen: bool, raw_mode_enabled: bool, enabled_keyboard_enhancement: bool, @@ -148,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() } @@ -181,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<()> { @@ -237,48 +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(mut dest: W, fullscreen: bool) -> io::Result { - queue!(dest, cursor::Hide)?; - if fullscreen { - queue!(dest, terminal::EnterAlternateScreen)?; - } - Ok(Self { - dest, +impl StdTerminal { + 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; @@ -287,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); } } @@ -338,6 +335,8 @@ impl Default for MockTerminalConfig { struct MockTerminal { config: MockTerminalConfig, output: mpsc::UnboundedSender, + dummy_stdout: io::Sink, + dummy_stderr: io::Sink, } impl MockTerminal { @@ -348,6 +347,8 @@ impl MockTerminal { Self { config, output: output_tx, + dummy_stdout: io::sink(), + dummy_stderr: io::sink(), }, output, ) @@ -383,10 +384,19 @@ 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, + output: Output, event_stream: Option>, subscribers: Vec>>, received_ctrl_c: bool, @@ -394,29 +404,40 @@ pub(crate) struct Terminal { } impl Terminal { - pub fn with_render_handle( - render_handle: Arc>>, + pub fn new( + stdout: Box, + stderr: Box, + output: Output, fullscreen: bool, ) -> io::Result { - let writer = SharedWriter { - inner: render_handle, + // 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::new_with_impl(StdTerminal::new(writer, fullscreen)?)) - } - - 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), + 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 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, + ) } pub fn ignore_ctrl_c(&mut self) { @@ -447,6 +468,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<()> @@ -523,29 +572,33 @@ pub(crate) struct SynchronizedUpdate<'a> { impl<'a> SynchronizedUpdate<'a> { pub fn begin(terminal: &'a mut Terminal) -> io::Result { - execute!(terminal, terminal::BeginSynchronizedUpdate)?; + terminal.execute(terminal::BeginSynchronizedUpdate)?; Ok(Self { inner: terminal }) } } 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::*; - use std::sync::{Arc, Mutex}; #[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 render_handle: Arc>> = - Arc::new(Mutex::new(Box::new(std::io::stdout()))); - let mut terminal = Terminal::with_render_handle(render_handle, false).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()); From 77b538ee877fa8f869f9fee9457431d0be562bb8 Mon Sep 17 00:00:00 2001 From: Sander Date: Sat, 24 Jan 2026 22:05:34 +0100 Subject: [PATCH 4/6] feat: allow using borrowed stdout/stderr writers --- packages/iocraft/src/component.rs | 2 +- packages/iocraft/src/element.rs | 8 +-- packages/iocraft/src/render.rs | 22 +++---- packages/iocraft/src/terminal.rs | 95 +++++++++++++++++++------------ 4 files changed, 76 insertions(+), 51 deletions(-) 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 2217b50..5b07f76 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -300,8 +300,8 @@ enum RenderLoopFutureState<'a, E: ElementExt> { fullscreen: bool, ignore_ctrl_c: bool, output: Output, - stdout_writer: Option>, - stderr_writer: Option>, + stdout_writer: Option>, + stderr_writer: Option>, element: &'a mut E, }, Running(Pin> + Send + 'a>>), @@ -362,7 +362,7 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { /// 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 { + pub fn stdout(mut self, writer: W) -> Self { match &mut self.state { RenderLoopFutureState::Init { stdout_writer, .. } => { *stdout_writer = Some(Box::new(writer)); @@ -375,7 +375,7 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { /// 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 { + pub fn stderr(mut self, writer: W) -> Self { match &mut self.state { RenderLoopFutureState::Init { stderr_writer, .. } => { *stderr_writer = Some(Box::new(writer)); diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs index 7c8829c..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 { @@ -84,7 +84,7 @@ 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> { + pub(crate) fn terminal_mut(&mut self) -> Option<&mut Terminal<'w>> { self.context.terminal.as_deref_mut() } @@ -377,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 = { @@ -460,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(); @@ -495,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 0efb95d..0b728e3 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -125,10 +125,10 @@ trait TerminalImpl: Write + Send { fn stderr(&mut self) -> &mut dyn Write; } -struct StdTerminal { +struct StdTerminal<'a> { input_is_terminal: bool, - stdout: Box, - stderr: Box, + stdout: Box, + stderr: Box, fullscreen: bool, raw_mode_enabled: bool, enabled_keyboard_enhancement: bool, @@ -136,7 +136,7 @@ struct StdTerminal { size: Option<(u16, u16)>, } -impl Write for StdTerminal { +impl Write for StdTerminal<'_> { fn write(&mut self, buf: &[u8]) -> io::Result { self.stdout.write(buf) } @@ -146,7 +146,7 @@ impl Write for StdTerminal { } } -impl TerminalImpl for StdTerminal { +impl TerminalImpl for StdTerminal<'_> { fn refresh_size(&mut self) { self.size = terminal::size().ok() } @@ -233,10 +233,10 @@ impl TerminalImpl for StdTerminal { } } -impl StdTerminal { +impl<'a> StdTerminal<'a> { fn new( - stdout: Box, - stderr: Box, + stdout: Box, + stderr: Box, fullscreen: bool, ) -> io::Result { let mut term = Self { @@ -284,7 +284,7 @@ impl StdTerminal { } } -impl Drop for StdTerminal { +impl Drop for StdTerminal<'_> { fn drop(&mut self) { let _ = self.set_raw_mode_enabled(false); if self.fullscreen { @@ -394,8 +394,8 @@ impl TerminalImpl for MockTerminal { } } -pub(crate) struct Terminal { - inner: Box, +pub(crate) struct Terminal<'a> { + inner: Box, output: Output, event_stream: Option>, subscribers: Vec>>, @@ -403,10 +403,10 @@ pub(crate) struct Terminal { ignore_ctrl_c: bool, } -impl Terminal { +impl<'a> Terminal<'a> { pub fn new( - stdout: Box, - stderr: Box, + stdout: Box, + stderr: Box, output: Output, fullscreen: bool, ) -> io::Result { @@ -425,21 +425,6 @@ impl Terminal { }) } - 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, - ) - } - pub fn ignore_ctrl_c(&mut self) { self.ignore_ctrl_c = true; } @@ -554,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) } @@ -566,18 +568,18 @@ 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 { +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 _ = self.inner.execute(terminal::EndSynchronizedUpdate); } @@ -605,4 +607,27 @@ mod tests { 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()); + } } From 9f14ffdf1d3e955fa9a5204d50f6a8a91a6e01d1 Mon Sep 17 00:00:00 2001 From: Sander Date: Tue, 27 Jan 2026 14:42:59 +0100 Subject: [PATCH 5/6] fix: handle cursor in use_output on writes to both stderr and stdout --- packages/iocraft/src/hooks/use_output.rs | 32 +++++++----------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/iocraft/src/hooks/use_output.rs b/packages/iocraft/src/hooks/use_output.rs index 7dd029a..36fe534 100644 --- a/packages/iocraft/src/hooks/use_output.rs +++ b/packages/iocraft/src/hooks/use_output.rs @@ -1,10 +1,13 @@ -use crate::{element::Output, ComponentUpdater, Hook, Hooks}; +use crate::{ComponentUpdater, Hook, Hooks}; use core::{ pin::Pin, task::{Context, Poll, Waker}, }; use crossterm::{cursor, QueueableCommand}; -use std::sync::{Arc, Mutex}; +use std::{ + io::Write, + sync::{Arc, Mutex}, +}; mod private { pub trait Sealed {} @@ -84,7 +87,6 @@ impl UseOutputState { updater.clear_terminal_output(); let terminal = updater.terminal_mut().unwrap(); let needs_carriage_returns = terminal.is_raw_mode_enabled(); - let output = terminal.output(); if let Some(col) = self.appended_newline { let _ = terminal @@ -95,18 +97,6 @@ impl UseOutputState { let mut needs_extra_newline = self.appended_newline.is_some(); for msg in self.queue.drain(..) { - // Cursor manipulation only works when message output matches the render target - let msg_matches_render = matches!( - (&msg, output), - ( - Message::Stdout(_) | Message::StdoutNoNewline(_), - Output::Stdout - ) | ( - Message::Stderr(_) | Message::StderrNoNewline(_), - Output::Stderr - ) - ); - match msg { Message::Stdout(msg) => { let formatted = if needs_carriage_returns { @@ -115,13 +105,11 @@ impl UseOutputState { format!("{}\n", msg) }; let _ = terminal.stdout().write_all(formatted.as_bytes()); - if msg_matches_render { - needs_extra_newline = false; - } + needs_extra_newline = false; } Message::StdoutNoNewline(msg) => { let _ = terminal.stdout().write_all(msg.as_bytes()); - if msg_matches_render && !msg.is_empty() { + if !msg.is_empty() { needs_extra_newline = !msg.ends_with('\n'); } } @@ -132,13 +120,11 @@ impl UseOutputState { format!("{}\n", msg) }; let _ = terminal.stderr().write_all(formatted.as_bytes()); - if msg_matches_render { - needs_extra_newline = false; - } + needs_extra_newline = false; } Message::StderrNoNewline(msg) => { let _ = terminal.stderr().write_all(msg.as_bytes()); - if msg_matches_render && !msg.is_empty() { + if !msg.is_empty() { needs_extra_newline = !msg.ends_with('\n'); } } From 45f1c3db581bd420df37febe3cd0c2cbf64d424a Mon Sep 17 00:00:00 2001 From: Sander Date: Tue, 27 Jan 2026 15:11:39 +0100 Subject: [PATCH 6/6] fix: flush output before outputting cross-stream logs --- packages/iocraft/src/hooks/use_output.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/iocraft/src/hooks/use_output.rs b/packages/iocraft/src/hooks/use_output.rs index 36fe534..1871cee 100644 --- a/packages/iocraft/src/hooks/use_output.rs +++ b/packages/iocraft/src/hooks/use_output.rs @@ -94,6 +94,10 @@ impl UseOutputState { .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 mut needs_extra_newline = self.appended_newline.is_some(); for msg in self.queue.drain(..) {