Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/iocraft/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ impl InstantiatedComponent {

pub fn update(
&mut self,
context: &mut UpdateContext<'_>,
context: &mut UpdateContext<'_, '_>,
unattached_child_node_ids: &mut Vec<NodeId>,
component_context_stack: &mut ContextStack<'_>,
props: AnyProps,
Expand Down
93 changes: 82 additions & 11 deletions packages/iocraft/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -282,13 +282,26 @@ 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]
Empty,
Init {
fullscreen: bool,
ignore_ctrl_c: bool,
output: Output,
stdout_writer: Option<Box<dyn Write + Send + 'a>>,
stderr_writer: Option<Box<dyn Write + Send + 'a>>,
element: &'a mut E,
},
Running(Pin<Box<dyn Future<Output = io::Result<()>> + Send + 'a>>),
Expand All @@ -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,
},
}
Expand Down Expand Up @@ -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<W: Write + Send + 'a>(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<W: Write + Send + 'a>(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> {
Expand All @@ -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();
}
Expand Down
53 changes: 34 additions & 19 deletions packages/iocraft/src/hooks/use_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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');
}
Expand All @@ -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;
}
Expand Down
25 changes: 15 additions & 10 deletions packages/iocraft/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeId>,
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<NodeId>,
context: &'a mut UpdateContext<'b>,
context: &'a mut UpdateContext<'b, 'w>,
component_context_stack: &'a mut ContextStack<'c>,
) -> Self {
Self {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -372,10 +377,10 @@ impl<'a> Tree<'a> {
}
}

fn render(
fn render<'w>(
&mut self,
max_width: Option<usize>,
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 = {
Expand Down Expand Up @@ -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<Canvas> = None;
loop {
term.refresh_size();
Expand Down Expand Up @@ -490,7 +495,7 @@ pub(crate) fn render<E: ElementExt>(mut e: E, max_width: Option<usize>) -> Canva
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<'a, E>(e: &mut E, term: Terminal<'a>) -> io::Result<()>
where
E: ElementExt,
{
Expand Down
Loading