From 91f6e4aaf490cb40903e2895c001643a4c585364 Mon Sep 17 00:00:00 2001 From: Chris Brown <1731074+ccbrown@users.noreply.github.com> Date: Sat, 1 Nov 2025 20:06:35 -0400 Subject: [PATCH] feat: allow disabling ctrl-c handling --- packages/iocraft/src/element.rs | 194 ++++++++++++++++++++----------- packages/iocraft/src/render.rs | 10 +- packages/iocraft/src/terminal.rs | 28 +++-- 3 files changed, 150 insertions(+), 82 deletions(-) diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index 0cfbe38..daaa3f1 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -12,6 +12,7 @@ use std::{ future::Future, hash::Hash, io::{self, stderr, stdout, IsTerminal, Write}, + pin::Pin, sync::Arc, }; @@ -217,12 +218,18 @@ pub trait ElementExt: private::Sealed + Sized { } } - /// Renders the element in a loop, allowing it to be dynamic and interactive. + /// Returns a future which renders the element in a loop, allowing it to be dynamic and + /// interactive. /// - /// This method should only be used if when stdio is a TTY terminal. If for example, stdout is - /// a file, this will probably not produce the desired result. You can determine whether stdout + /// This method should only be used when stdio is a TTY terminal. If for example, stdout is a + /// file, this will probably not produce the desired result. You can determine whether stdout /// is a terminal with [`IsTerminal`](std::io::IsTerminal). - fn render_loop(&mut self) -> impl Future>; + /// + /// The behavior of the render loop can be configured via the methods on the returned future + /// before awaiting it. + fn render_loop(&mut self) -> RenderLoopFuture<'_, Self> { + RenderLoopFuture::new(self) + } /// Renders the element in a loop using a mock terminal, allowing you to simulate terminal /// events for testing purposes. @@ -259,14 +266,126 @@ pub trait ElementExt: private::Sealed + Sized { fn mock_terminal_render_loop( &mut self, config: MockTerminalConfig, - ) -> impl Stream; + ) -> impl Stream { + mock_terminal_render_loop(self, config) + } /// Renders the element as fullscreen in a loop, allowing it to be dynamic and interactive. /// - /// This method should only be used if when stdio is a TTY terminal. If for example, stdout is - /// a file, this will probably not produce the desired result. You can determine whether stdout + /// This method should only be used when stdio is a TTY terminal. If for example, stdout is a + /// file, this will probably not produce the desired result. You can determine whether stdout /// is a terminal with [`IsTerminal`](std::io::IsTerminal). - fn fullscreen(&mut self) -> impl Future>; + /// + /// This is equivalent to `self.render_loop().fullscreen()`. + fn fullscreen(&mut self) -> impl Future> { + self.render_loop().fullscreen() + } +} + +#[derive(Default)] +enum RenderLoopFutureState<'a, E: ElementExt> { + #[default] + Empty, + Init { + fullscreen: bool, + ignore_ctrl_c: bool, + element: &'a mut E, + }, + Running(Pin> + 'a>>), +} + +/// A future that renders an element in a loop, allowing it to be dynamic and interactive. +/// +/// This is created by the [`ElementExt::render_loop`] method. +/// +/// Before awaiting the future, you can use its methods to configure its behavior. +pub struct RenderLoopFuture<'a, E: ElementExt + 'a> { + state: RenderLoopFutureState<'a, E>, +} + +impl<'a, E: ElementExt + 'a> RenderLoopFuture<'a, E> { + pub(crate) fn new(element: &'a mut E) -> Self { + Self { + state: RenderLoopFutureState::Init { + fullscreen: false, + ignore_ctrl_c: false, + element, + }, + } + } + + /// Renders the element as fullscreen in a loop, allowing it to be dynamic and interactive. + /// + /// This method should only be used when stdio is a TTY terminal. If for example, stdout is a + /// file, this will probably not produce the desired result. You can determine whether stdout + /// is a terminal with [`IsTerminal`](std::io::IsTerminal). + pub fn fullscreen(mut self) -> Self { + match &mut self.state { + RenderLoopFutureState::Init { fullscreen, .. } => { + *fullscreen = true; + } + _ => panic!("fullscreen() must be called before polling the future"), + } + self + } + + /// If the terminal is in raw mode, Ctrl-C presses will not trigger the usual interrupt + /// signals. By default, if the terminal is in raw mode for any reason, iocraft will listen for + /// Ctrl-C and stop the render loop in response. If you would like to prevent this behavior and + /// implement your own handling for Ctrl-C, you can call this method. + pub fn ignore_ctrl_c(mut self) -> Self { + match &mut self.state { + RenderLoopFutureState::Init { ignore_ctrl_c, .. } => { + *ignore_ctrl_c = true; + } + _ => panic!("ignore_ctrl_c() must be called before polling the future"), + } + self + } +} + +impl<'a, E: ElementExt + 'a> Future for RenderLoopFuture<'a, E> { + type Output = io::Result<()>; + + fn poll( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + loop { + match &mut self.state { + RenderLoopFutureState::Init { .. } => { + let (fullscreen, ignore_ctrl_c, element) = + match std::mem::replace(&mut self.state, RenderLoopFutureState::Empty) { + RenderLoopFutureState::Init { + fullscreen, + ignore_ctrl_c, + element, + } => (fullscreen, ignore_ctrl_c, 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)), + }; + if ignore_ctrl_c { + terminal.ignore_ctrl_c(); + } + let fut = Box::pin(terminal_render_loop(element, terminal)); + self.state = RenderLoopFutureState::Running(fut); + } + RenderLoopFutureState::Running(fut) => { + return fut.as_mut().poll(cx); + } + RenderLoopFutureState::Empty => { + panic!("polled after completion"); + } + } + } + } } impl ElementExt for AnyElement<'_> { @@ -286,21 +405,6 @@ impl ElementExt for AnyElement<'_> { fn render(&mut self, max_width: Option) -> Canvas { render(self, max_width) } - - async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(self, Terminal::new()?).await - } - - fn mock_terminal_render_loop( - &mut self, - config: MockTerminalConfig, - ) -> impl Stream { - mock_terminal_render_loop(self, config) - } - - async fn fullscreen(&mut self) -> io::Result<()> { - terminal_render_loop(self, Terminal::fullscreen()?).await - } } impl ElementExt for &mut AnyElement<'_> { @@ -320,21 +424,6 @@ impl ElementExt for &mut AnyElement<'_> { fn render(&mut self, max_width: Option) -> Canvas { render(&mut **self, max_width) } - - async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(&mut **self, Terminal::new()?).await - } - - fn mock_terminal_render_loop( - &mut self, - config: MockTerminalConfig, - ) -> impl Stream { - mock_terminal_render_loop(&mut **self, config) - } - - async fn fullscreen(&mut self) -> io::Result<()> { - terminal_render_loop(&mut **self, Terminal::fullscreen()?).await - } } impl ElementExt for Element<'_, T> @@ -357,20 +446,6 @@ where fn render(&mut self, max_width: Option) -> Canvas { render(self, max_width) } - - async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(self, Terminal::new()?).await - } - - fn mock_terminal_render_loop( - &mut self, - config: MockTerminalConfig, - ) -> impl Stream { - mock_terminal_render_loop(self, config) - } - async fn fullscreen(&mut self) -> io::Result<()> { - terminal_render_loop(self, Terminal::fullscreen()?).await - } } impl ElementExt for &mut Element<'_, T> @@ -393,21 +468,6 @@ where fn render(&mut self, max_width: Option) -> Canvas { render(&mut **self, max_width) } - - async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(&mut **self, Terminal::new()?).await - } - - fn mock_terminal_render_loop( - &mut self, - config: MockTerminalConfig, - ) -> impl Stream { - mock_terminal_render_loop(&mut **self, config) - } - - async fn fullscreen(&mut self) -> io::Result<()> { - terminal_render_loop(&mut **self, Terminal::fullscreen()?).await - } } #[cfg(test)] diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs index b6efc5e..9f4813e 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -490,7 +490,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(mut e: E, term: Terminal) -> io::Result<()> +pub(crate) async fn terminal_render_loop(e: &mut E, term: Terminal) -> io::Result<()> where E: ElementExt, { @@ -520,7 +520,7 @@ impl Stream for MockTerminalRenderLoop<'_> { } pub(crate) fn mock_terminal_render_loop<'a, E>( - e: E, + e: &'a mut E, config: MockTerminalConfig, ) -> MockTerminalRenderLoop<'a> where @@ -585,7 +585,7 @@ mod tests { #[apply(test!)] async fn test_terminal_render_loop() { let canvases: Vec<_> = - mock_terminal_render_loop(element!(MyComponent), MockTerminalConfig::default()) + mock_terminal_render_loop(&mut element!(MyComponent), MockTerminalConfig::default()) .collect() .await; let actual = canvases.iter().map(|c| c.to_string()).collect::>(); @@ -604,7 +604,7 @@ mod tests { #[apply(test!)] async fn test_terminal_render_loop_send() { let (term, _output) = Terminal::mock(MockTerminalConfig::default()); - await_send_future(terminal_render_loop(element!(MyComponent), term)).await; + await_send_future(terminal_render_loop(&mut element!(MyComponent), term)).await; } #[component] @@ -679,7 +679,7 @@ mod tests { #[apply(test!)] async fn test_async_ticker_container() { let canvases: Vec<_> = mock_terminal_render_loop( - element!(AsyncTickerContainer), + &mut element!(AsyncTickerContainer), MockTerminalConfig::default(), ) .collect() diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 55be0cf..259fee8 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -379,6 +379,7 @@ pub(crate) struct Terminal { event_stream: Option>, subscribers: Vec>>, received_ctrl_c: bool, + ignore_ctrl_c: bool, } impl Terminal { @@ -401,9 +402,14 @@ impl Terminal { event_stream: None, subscribers: Vec::new(), received_ctrl_c: false, + ignore_ctrl_c: false, } } + pub fn ignore_ctrl_c(&mut self) { + self.ignore_ctrl_c = true; + } + pub fn is_raw_mode_enabled(&self) -> bool { self.inner.is_raw_mode_enabled() } @@ -442,16 +448,18 @@ impl Terminal { match &mut self.event_stream { Some(event_stream) => { while let Some(event) = event_stream.next().await { - if let TerminalEvent::Key(KeyEvent { - code: KeyCode::Char('c'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::CONTROL, - }) = event - { - self.received_ctrl_c = true; - } - if self.received_ctrl_c { - return; + if !self.ignore_ctrl_c { + if let TerminalEvent::Key(KeyEvent { + code: KeyCode::Char('c'), + kind: KeyEventKind::Press, + modifiers: KeyModifiers::CONTROL, + }) = event + { + self.received_ctrl_c = true; + } + if self.received_ctrl_c { + return; + } } self.subscribers.retain(|subscriber| { if let Some(subscriber) = subscriber.upgrade() {