Skip to content
Merged
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
194 changes: 127 additions & 67 deletions packages/iocraft/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::{
future::Future,
hash::Hash,
io::{self, stderr, stdout, IsTerminal, Write},
pin::Pin,
sync::Arc,
};

Expand Down Expand Up @@ -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<Output = io::Result<()>>;
///
/// 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.
Expand Down Expand Up @@ -259,14 +266,126 @@ pub trait ElementExt: private::Sealed + Sized {
fn mock_terminal_render_loop(
&mut self,
config: MockTerminalConfig,
) -> impl Stream<Item = Canvas>;
) -> impl Stream<Item = Canvas> {
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<Output = io::Result<()>>;
///
/// This is equivalent to `self.render_loop().fullscreen()`.
fn fullscreen(&mut self) -> impl Future<Output = io::Result<()>> {
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<Box<dyn Future<Output = io::Result<()>> + '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<Self::Output> {
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<'_> {
Expand All @@ -286,21 +405,6 @@ impl ElementExt for AnyElement<'_> {
fn render(&mut self, max_width: Option<usize>) -> 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<Item = Canvas> {
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<'_> {
Expand All @@ -320,21 +424,6 @@ impl ElementExt for &mut AnyElement<'_> {
fn render(&mut self, max_width: Option<usize>) -> 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<Item = Canvas> {
mock_terminal_render_loop(&mut **self, config)
}

async fn fullscreen(&mut self) -> io::Result<()> {
terminal_render_loop(&mut **self, Terminal::fullscreen()?).await
}
}

impl<T> ElementExt for Element<'_, T>
Expand All @@ -357,20 +446,6 @@ where
fn render(&mut self, max_width: Option<usize>) -> 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<Item = Canvas> {
mock_terminal_render_loop(self, config)
}
async fn fullscreen(&mut self) -> io::Result<()> {
terminal_render_loop(self, Terminal::fullscreen()?).await
}
}

impl<T> ElementExt for &mut Element<'_, T>
Expand All @@ -393,21 +468,6 @@ where
fn render(&mut self, max_width: Option<usize>) -> 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<Item = Canvas> {
mock_terminal_render_loop(&mut **self, config)
}

async fn fullscreen(&mut self) -> io::Result<()> {
terminal_render_loop(&mut **self, Terminal::fullscreen()?).await
}
}

#[cfg(test)]
Expand Down
10 changes: 5 additions & 5 deletions packages/iocraft/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,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>(mut e: E, term: Terminal) -> io::Result<()>
pub(crate) async fn terminal_render_loop<E>(e: &mut E, term: Terminal) -> io::Result<()>
where
E: ElementExt,
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<Vec<_>>();
Expand All @@ -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]
Expand Down Expand Up @@ -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()
Expand Down
28 changes: 18 additions & 10 deletions packages/iocraft/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ pub(crate) struct Terminal {
event_stream: Option<BoxStream<'static, TerminalEvent>>,
subscribers: Vec<Weak<Mutex<TerminalEventsInner>>>,
received_ctrl_c: bool,
ignore_ctrl_c: bool,
}

impl Terminal {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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() {
Expand Down