Skip to content

feat: allow configuring render output and stdout/stderr handles#157

Merged
ccbrown merged 9 commits intoccbrown:mainfrom
sandydoo:feat-configure-output
Mar 2, 2026
Merged

feat: allow configuring render output and stdout/stderr handles#157
ccbrown merged 9 commits intoccbrown:mainfrom
sandydoo:feat-configure-output

Conversation

@sandydoo
Copy link
Contributor

@sandydoo sandydoo commented Jan 20, 2026

What It Does

Adds the following render loop methods:

  • .stdout(writer): sets the handle to use when writing to stdout.
  • .stderr(writer): sets the handle to use when writing to stderr.
  • .output(<Output>): configures which handle to use to render to: stderr or stdout.

The use_output hooks also take into account the above options.

Related Issues

Fixes #156.

Comment on lines +82 to 85
// Check if we have a terminal - if not, messages stay queued
if updater.terminal_config().is_none() {
return;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook messages are no longer printed outside of the render loop, since we don't have a terminal instance in that case.

These messages are side-effects of the render, so skipping over them seems to make sense.

@ccbrown ccbrown self-requested a review January 21, 2026 21:11
@ccbrown ccbrown added the enhancement New feature or request label Jan 21, 2026
/// Set the stdout handle for hook output.
///
/// Default: `std::io::stdout()`
pub fn stdout<W: Write + Send + 'static>(mut self, writer: W) -> Self {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory I think it should be possible to eliminate the 'static requirement.

If it ends up being too ugly to implement the required lifetimes, that's fine, but I don't see any reason it shouldn't be possible for users to do something like...

foo.render_loop().stdout(&w).await;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave this a try. If the handles live on SystemContext, then we have to do a transmute to make the compiler happy.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I see now that it would be extremely difficult to give SystemContext (or any context) a lifetime since Any requires 'static.

Unfortunately what you have here I believe isn't quite sound: A user could clone hooks.use_context::<SystemContext>().stdout() and then keep it around past its lifetime.

However, if we pass the writers around in a slightly different way, it's possible to do this soundly and without any unsafe code (and without Arc or Mutex).

This patch (based off of main/a3385b2fbcb7424a3a98f403e82e49dc562d3eea) demonstrates the idea I think. I haven't tested it, but it type-checks at least.

diff --git a/packages/iocraft/src/context.rs b/packages/iocraft/src/context.rs
index a8f0a88..141e50a 100644
--- a/packages/iocraft/src/context.rs
+++ b/packages/iocraft/src/context.rs
@@ -3,6 +3,7 @@ use core::{
     cell::{Ref, RefCell, RefMut},
     mem,
 };
+use std::io::Write;
 
 /// The system context, which is always available to all components.
 pub struct SystemContext {
@@ -25,6 +26,30 @@ impl SystemContext {
     }
 }
 
+/// Ideally this would be part of `SystemContext`, but since since context relies on `Any` and
+/// `Any` requires `'static`, we can't use regular context for objects with lifetimes. So we'll
+/// pass this around separately alongside the other context.
+pub(crate) struct SystemContextInternal<'a> {
+    pub stdout: Box<dyn Write + Send + Sync + 'a>,
+    pub stderr: Box<dyn Write + Send + Sync + 'a>,
+}
+
+impl<'a> SystemContextInternal<'a> {
+    pub fn new(
+        stdout: Box<dyn Write + Send + Sync + 'a>,
+        stderr: Box<dyn Write + Send + Sync + 'a>,
+    ) -> Self {
+        Self { stdout, stderr }
+    }
+
+    pub fn reborrow(&mut self) -> SystemContextInternal<'_> {
+        SystemContextInternal {
+            stdout: Box::new(&mut *self.stdout),
+            stderr: Box::new(&mut *self.stderr),
+        }
+    }
+}
+
 /// A context that can be passed to components.
 pub enum Context<'a> {
     /// Provides the context via a mutable reference. Children will be able to get mutable or
@@ -87,13 +112,18 @@ impl<'a> Context<'a> {
 
 #[doc(hidden)]
 pub struct ContextStack<'a> {
+    system_context_internal: SystemContextInternal<'a>,
     contexts: Vec<RefCell<Context<'a>>>,
 }
 
 impl<'a> ContextStack<'a> {
-    pub(crate) fn root(root_context: &'a mut (dyn Any + Send + Sync)) -> Self {
+    pub(crate) fn root(
+        system_context: &'a SystemContext,
+        system_context_internal: SystemContextInternal<'a>,
+    ) -> Self {
         Self {
-            contexts: vec![RefCell::new(Context::Mut(root_context))],
+            system_context_internal,
+            contexts: vec![RefCell::new(Context::from_ref(system_context))],
         }
     }
 
@@ -139,4 +169,8 @@ impl<'a> ContextStack<'a> {
         }
         None
     }
+
+    pub(crate) fn system_context_internal_mut(&mut self) -> &mut SystemContextInternal<'a> {
+        &mut self.system_context_internal
+    }
 }
diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs
index d35c8fa..7a0838f 100644
--- a/packages/iocraft/src/element.rs
+++ b/packages/iocraft/src/element.rs
@@ -289,6 +289,8 @@ enum RenderLoopFutureState<'a, E: ElementExt> {
     Init {
         fullscreen: bool,
         ignore_ctrl_c: bool,
+        stdout: Box<dyn Write + Send + Sync + 'a>,
+        stderr: Box<dyn Write + Send + Sync + 'a>,
         element: &'a mut E,
     },
     Running(Pin<Box<dyn Future<Output = io::Result<()>> + Send + 'a>>),
@@ -309,6 +311,8 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> {
             state: RenderLoopFutureState::Init {
                 fullscreen: false,
                 ignore_ctrl_c: false,
+                stdout: Box::new(stdout()),
+                stderr: Box::new(stderr()),
                 element,
             },
         }
@@ -342,6 +346,26 @@ impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> {
         }
         self
     }
+
+    pub fn stdout<W: Write + Send + Sync + 'a>(mut self, stdout: W) -> Self {
+        match &mut self.state {
+            RenderLoopFutureState::Init { stdout: out, .. } => {
+                *out = Box::new(stdout);
+            }
+            _ => panic!("stdout() must be called before polling the future"),
+        }
+        self
+    }
+
+    pub fn stderr<W: Write + Send + Sync + 'a>(mut self, stderr: W) -> Self {
+        match &mut self.state {
+            RenderLoopFutureState::Init { stderr: err, .. } => {
+                *err = Box::new(stderr);
+            }
+            _ => panic!("stderr() must be called before polling the future"),
+        }
+        self
+    }
 }
 
 impl<'a, E: ElementExt + Send + 'a> Future for RenderLoopFuture<'a, E> {
@@ -354,13 +378,15 @@ 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, element, stdout, stderr) =
                         match std::mem::replace(&mut self.state, RenderLoopFutureState::Empty) {
                             RenderLoopFutureState::Init {
                                 fullscreen,
                                 ignore_ctrl_c,
                                 element,
-                            } => (fullscreen, ignore_ctrl_c, element),
+                                stdout,
+                                stderr,
+                            } => (fullscreen, ignore_ctrl_c, element, stdout, stderr),
                             _ => unreachable!(),
                         };
                     let mut terminal = match if fullscreen {
@@ -374,7 +400,7 @@ 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, stderr));
                     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 074d6fd..119f003 100644
--- a/packages/iocraft/src/hooks/use_output.rs
+++ b/packages/iocraft/src/hooks/use_output.rs
@@ -76,6 +76,13 @@ impl UseOutputState {
             return;
         }
 
+        let stdout = &mut updater
+            .component_context_stack_mut()
+            .system_context_internal_mut()
+            .stdout;
+        stdout.write(b"foo\n").unwrap();
+        // TODO: actually use this and the configured stderr for the output
+
         updater.clear_terminal_output();
         if let Some(col) = self.appended_newline {
             let _ = queue!(std::io::stdout(), cursor::MoveUp(1), cursor::MoveRight(col));
diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs
index 9f4813e..8552afa 100644
--- a/packages/iocraft/src/render.rs
+++ b/packages/iocraft/src/render.rs
@@ -1,7 +1,7 @@
 use crate::{
     canvas::{Canvas, CanvasSubviewMut},
     component::{ComponentHelperExt, Components, InstantiatedComponent},
-    context::{Context, ContextStack, SystemContext},
+    context::{Context, ContextStack, SystemContext, SystemContextInternal},
     element::ElementExt,
     multimap::AppendOnlyMultimap,
     props::AnyProps,
@@ -17,7 +17,7 @@ use futures::{
     future::{select, FutureExt, LocalBoxFuture},
     stream::{Stream, StreamExt},
 };
-use std::io;
+use std::io::{self, Write};
 use taffy::{
     AvailableSpace, Display, Layout, NodeId, Overflow, Point, Rect, Size, Style, TaffyTree,
 };
@@ -88,6 +88,11 @@ impl<'a, 'b, 'c> ComponentUpdater<'a, 'b, 'c> {
         self.component_context_stack
     }
 
+    #[doc(hidden)]
+    pub fn component_context_stack_mut(&mut self) -> &mut ContextStack<'c> {
+        self.component_context_stack
+    }
+
     /// Gets an immutable reference to context of the given type.
     pub fn get_context<T: Any>(&self) -> Option<Ref<T>> {
         self.component_context_stack.get_context()
@@ -347,6 +352,7 @@ struct Tree<'a> {
     root_component: InstantiatedComponent,
     root_component_props: AnyProps<'a>,
     system_context: SystemContext,
+    system_context_internal: SystemContextInternal<'a>,
 }
 
 struct RenderOutput {
@@ -355,7 +361,12 @@ struct RenderOutput {
 }
 
 impl<'a> Tree<'a> {
-    fn new(mut props: AnyProps<'a>, helper: Box<dyn ComponentHelperExt>) -> Self {
+    fn new(
+        mut props: AnyProps<'a>,
+        helper: Box<dyn ComponentHelperExt>,
+        stdout: Box<dyn Write + Send + Sync + 'a>,
+        stderr: Box<dyn Write + Send + Sync + 'a>,
+    ) -> Self {
         let mut layout_engine = TaffyTree::new();
         let root_node_id = layout_engine
             .new_leaf_with_context(Style::default(), LayoutEngineNodeContext::default())
@@ -369,6 +380,7 @@ impl<'a> Tree<'a> {
             root_component: InstantiatedComponent::new(root_node_id, props.borrow(), helper),
             root_component_props: props,
             system_context: SystemContext::new(),
+            system_context_internal: SystemContextInternal::new(stdout, stderr),
         }
     }
 
@@ -384,7 +396,10 @@ impl<'a> Tree<'a> {
                 layout_engine: &mut self.layout_engine,
                 did_clear_terminal_output: false,
             };
-            let mut component_context_stack = ContextStack::root(&mut self.system_context);
+            let mut component_context_stack = ContextStack::root(
+                &self.system_context,
+                self.system_context_internal.reborrow(),
+            );
             self.root_component.update(
                 &mut context,
                 &mut wrapper_child_node_ids,
@@ -486,16 +501,26 @@ impl<'a> Tree<'a> {
 
 pub(crate) fn render<E: ElementExt>(mut e: E, max_width: Option<usize>) -> Canvas {
     let h = e.helper();
-    let mut tree = Tree::new(e.props_mut(), h);
+    let mut tree = Tree::new(
+        e.props_mut(),
+        h,
+        Box::new(io::stdout()),
+        Box::new(io::stderr()),
+    );
     tree.render(max_width, None).canvas
 }
 
-pub(crate) async fn terminal_render_loop<E>(e: &mut E, term: Terminal) -> io::Result<()>
+pub(crate) async fn terminal_render_loop<E>(
+    e: &mut E,
+    term: Terminal,
+    stdout: Box<dyn Write + Send + Sync + '_>,
+    stderr: Box<dyn Write + Send + Sync + '_>,
+) -> 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);
     tree.terminal_render_loop(term).await
 }
 
@@ -528,7 +553,8 @@ where
 {
     let (term, output) = Terminal::mock(config);
     MockTerminalRenderLoop {
-        render_loop: terminal_render_loop(e, term).boxed_local(),
+        render_loop: terminal_render_loop(e, term, Box::new(io::stdout()), Box::new(io::stderr()))
+            .boxed_local(),
         render_loop_is_done: false,
         output,
     }
@@ -604,7 +630,13 @@ mod tests {
     #[apply(test!)]
     async fn test_terminal_render_loop_send() {
         let (term, _output) = Terminal::mock(MockTerminalConfig::default());
-        await_send_future(terminal_render_loop(&mut element!(MyComponent), term)).await;
+        await_send_future(terminal_render_loop(
+            &mut element!(MyComponent),
+            term,
+            Box::new(io::stdout()),
+            Box::new(io::stderr()),
+        ))
+        .await;
     }
 
     #[component]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately what you have here I believe isn't quite sound: A user could clone hooks.use_context::().stdout() and then keep it around past its lifetime.

Ah, good point!

However, if we pass the writers around in a slightly different way, it's possible to do this soundly and without any unsafe code (and without Arc or Mutex).

SystemContextInternal would work, but we'd still need an Arc + Mutex to share the handle with StdTerminal. So we're left with another ownership conflict: both the terminal and the output hooks want to write to the same handles.

Which got me thinking, why don't we store both handles on StdTerminal? Then the output hooks borrow them to write their messages.
This also avoids having to drill the handles through Tree, which feels off to me.

I've pushed two commits:

  • 1215852: stores the handles in StdTerminal.
  • 77b538e: allows using borrowed writers. This requires sprinkling lifetimes in a few places, but looks manageable.

@sandydoo sandydoo force-pushed the feat-configure-output branch from 9fea6ac to f4f8138 Compare January 23, 2026 11:27
@sandydoo
Copy link
Contributor Author

crossterm's keyboard enhancement check writes to stdout as fallback: https://github.com/crossterm-rs/crossterm/blob/4f08595ef4477de2d504dcced24060ed9e3d582a/src/terminal/sys/unix.rs#L237-L241

I'll open an issue upstream. We can add a guard against this in the meantime.

@sandydoo sandydoo force-pushed the feat-configure-output branch 6 times, most recently from e9c5096 to 1215852 Compare January 24, 2026 20:59
Copy link
Owner

@ccbrown ccbrown left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The general approach and the API lgtm!

I'm just trying to convince myself that all of the writes are happening in the correct place. I think the flipping that Terminal does makes it kind of hard to follow.

To test this out, I tried making a simple modification to the use_output example:

diff --git a/examples/use_output.rs b/examples/use_output.rs
index 4a968e7..6506889 100644
--- a/examples/use_output.rs
+++ b/examples/use_output.rs
@@ -25,5 +25,5 @@ fn Example(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
 }
 
 fn main() {
-    smol::block_on(element!(Example).render_loop()).unwrap();
+    smol::block_on(element!(Example).render_loop().output(Output::Stderr)).unwrap();
 }

I expected the output to look the same, but somehow the output was corrupted:

Image

Any idea why this didn't work?

@sandydoo
Copy link
Contributor Author

I think the flipping that Terminal does makes it kind of hard to follow.

One option would be to call them something like main/alternate?

I expected the output to look the same, but somehow the output was corrupted:
Any idea why this didn't work?

This should now work.

The last blockers here have to do with crossterm. For example, cursor position is hardcoded to use stdout. This is fine when both stdout and stderr are TTYs, but breaks when stdout is piped. Related issue: crossterm-rs/crossterm#652

@ccbrown
Copy link
Owner

ccbrown commented Feb 3, 2026

I think the flipping that Terminal does makes it kind of hard to follow.

One option would be to call them something like main/alternate?

That would probably be at least a little bit better.

The last blockers here have to do with crossterm. For example, cursor position is hardcoded to use stdout. This is fine when both stdout and stderr are TTYs, but breaks when stdout is piped. Related issue: crossterm-rs/crossterm#652

Hmm... unfortunately it doesn't look like crossterm is actively reviewing and merging PRs. And it looks like crossterm is using some private, global state internally, so we can't just use modified portions of the relevant code from it.

Any ideas other than forking the entire crate?

@sandydoo
Copy link
Contributor Author

sandydoo commented Feb 6, 2026

That would probably be at least a little bit better.
👍

Any ideas other than forking the entire crate?

Yeah, the situation with crossterm is a little unfortunate. We'll continue pushing the necessary upstream fixes.

As for alternatives, termion seems to have the better API for cursor positioning that doesn't hardcode stdout. No support for Windows though.

@domenkozar
Copy link
Contributor

@ccbrown any chance to get this merged (once rebased)? We're using it in https://github.com/cachix/devenv so that we can render our TUI to stderr while direnv is reloading (which surpresses stdout).

@ccbrown
Copy link
Owner

ccbrown commented Feb 13, 2026

@ccbrown any chance to get this merged (once rebased)? We're using it in https://github.com/cachix/devenv so that we can render our TUI to stderr while direnv is reloading (which surpresses stdout).

Of course! My only hesitation is with the crossterm caveats. Could we either...

a. Restrict the public API in a way that only allows for usage that we know to work properly. I.e. maybe just removing the stderr / stdout functions or marking them as #[doc(hidden)] for now.

Or...

b. Document the known issues clearly so that if users read the docs, they're not surprised by true stdout being used for cursor position regardless of their configuration.

If we could do one of those I'd be happy to merge this.

domenkozar added a commit to domenkozar/iocraft that referenced this pull request Feb 25, 2026
Add the ability to configure which handle (stdout/stderr) the TUI
renders to, and allow custom stdout/stderr writers. This is useful for
CLI tools that pipe stdout while displaying a TUI on stderr.

Changes:
- Add `Output` enum (Stdout/Stderr) to select render destination
- Add `.output()`, `.stdout()`, `.stderr()` methods on RenderLoopFuture
- Terminal now accepts custom writer boxes with borrowed lifetime support
- use_output hook writes through terminal handles instead of print!/eprint!
- Flush render output before cross-stream writes to avoid corruption

Based on PR ccbrown#157 by @sandydoo, rebased on main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sandydoo sandydoo force-pushed the feat-configure-output branch 2 times, most recently from eaabb0a to 90013f0 Compare February 25, 2026 14:26
Copy link
Owner

@ccbrown ccbrown left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the renaming! That makes the code much easier to follow for me.

All of the code looks good to me! But as mentioned in the other comment, let's either...

a. Restrict the public API in a way that only allows for usage that we know to work properly. I.e. maybe just removing the stderr / stdout functions or marking them as #[doc(hidden)] for now.

Or...

b. Document the known issues clearly so that if users read the docs, they're not surprised by true stdout being used for cursor position regardless of their configuration.

@sandydoo sandydoo force-pushed the feat-configure-output branch from e2a868f to 2f30e73 Compare March 1, 2026 14:26
sandydoo and others added 2 commits March 2, 2026 15:43
Places the output stream APIs behind a `unstable-output-streams` feature flag.
crossterm currently has a number of bugs that affect the use of these APIs.
@ccbrown ccbrown merged commit 2d17c98 into ccbrown:main Mar 2, 2026
3 checks passed
@ccbrown
Copy link
Owner

ccbrown commented Mar 2, 2026

Thanks!

@codecov
Copy link

codecov bot commented Mar 2, 2026

Codecov Report

❌ Patch coverage is 78.35052% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.81%. Comparing base (d7b66e8) to head (0b2bf36).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
packages/iocraft/src/terminal.rs 73.98% 25 Missing and 7 partials ⚠️
packages/iocraft/src/hooks/use_output.rs 62.50% 9 Missing ⚠️
packages/iocraft/src/element.rs 97.43% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #157      +/-   ##
==========================================
- Coverage   89.00%   88.81%   -0.20%     
==========================================
  Files          33       34       +1     
  Lines        5785     5945     +160     
  Branches     5785     5945     +160     
==========================================
+ Hits         5149     5280     +131     
- Misses        525      550      +25     
- Partials      111      115       +4     
Files with missing lines Coverage Δ
packages/iocraft/src/component.rs 90.84% <100.00%> (ø)
packages/iocraft/src/render.rs 93.50% <100.00%> (-1.44%) ⬇️
packages/iocraft/src/element.rs 76.13% <97.43%> (+3.25%) ⬆️
packages/iocraft/src/hooks/use_output.rs 82.38% <62.50%> (+0.51%) ⬆️
packages/iocraft/src/terminal.rs 75.66% <73.98%> (+2.06%) ⬆️

... and 2 files with indirect coverage changes

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow rendering to stderr or custom writers

3 participants