From 6c428fb21ce1187bb845b72caf66332e5ffcff75 Mon Sep 17 00:00:00 2001 From: Chris Brown <1731074+ccbrown@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:02:22 -0400 Subject: [PATCH] add fullscreen example --- Cargo.toml | 1 + examples/fullscreen.rs | 65 +++++++++++++ packages/iocraft/Cargo.toml | 3 - packages/iocraft/src/canvas.rs | 30 ++++-- packages/iocraft/src/element.rs | 29 +++++- packages/iocraft/src/hooks/mod.rs | 2 + .../iocraft/src/hooks/use_terminal_size.rs | 23 +++++ packages/iocraft/src/render.rs | 75 ++++----------- packages/iocraft/src/style.rs | 15 +++ packages/iocraft/src/terminal.rs | 93 ++++++++++++++----- 10 files changed, 243 insertions(+), 93 deletions(-) create mode 100644 examples/fullscreen.rs create mode 100644 packages/iocraft/src/hooks/use_terminal_size.rs diff --git a/Cargo.toml b/Cargo.toml index e030b22..4eb0d59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ license = "MIT OR Apache-2.0" iocraft = { path = "packages/iocraft" } futures = "0.3.30" smol = "2.0.1" +chrono = "0.4.38" [dependencies] unicode-width = "0.1.13" diff --git a/examples/fullscreen.rs b/examples/fullscreen.rs new file mode 100644 index 0000000..abd27fc --- /dev/null +++ b/examples/fullscreen.rs @@ -0,0 +1,65 @@ +use chrono::Local; +use iocraft::prelude::*; +use std::time::Duration; + +#[component] +fn Example(mut hooks: Hooks) -> impl Into> { + let (width, height) = hooks.use_terminal_size(); + let mut system = hooks.use_context_mut::(); + let time = hooks.use_state(|| Local::now()); + let should_exit = hooks.use_state(|| false); + + hooks.use_future(async move { + loop { + smol::Timer::after(Duration::from_secs(1)).await; + time.set(Local::now()); + } + }); + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => { + match code { + KeyCode::Char('q') => should_exit.set(true), + _ => {} + } + } + _ => {} + } + }); + + if should_exit.get() { + system.exit(); + } + + element! { + Box( + // subtract one in case there's a scrollbar + width: width - 1, + height, + background_color: Color::DarkGrey, + border_style: BorderStyle::Double, + border_color: Color::Blue, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ) { + Box( + border_style: BorderStyle::Round, + border_color: Color::Blue, + margin_bottom: 2, + padding_top: 2, + padding_bottom: 2, + padding_left: 8, + padding_right: 8, + ) { + Text(content: format!("Current Time: {}", time.get().format("%r"))) + } + Text(content: "Press \"q\" to quit.") + } + } +} + +fn main() { + smol::block_on(element!(Example).fullscreen()).unwrap(); +} diff --git a/packages/iocraft/Cargo.toml b/packages/iocraft/Cargo.toml index 4b09878..e9e25ce 100644 --- a/packages/iocraft/Cargo.toml +++ b/packages/iocraft/Cargo.toml @@ -18,6 +18,3 @@ generational-box = "0.5.6" [dev-dependencies] indoc = "2" -smol = "2.0.1" -smol-macros = "0.1.1" -macro_rules_attribute = "0.2.0" diff --git a/packages/iocraft/src/canvas.rs b/packages/iocraft/src/canvas.rs index 8528456..c79bc7e 100644 --- a/packages/iocraft/src/canvas.rs +++ b/packages/iocraft/src/canvas.rs @@ -112,7 +112,12 @@ impl Canvas { } } - fn write_impl(&self, mut w: W, ansi: bool) -> io::Result<()> { + fn write_impl( + &self, + mut w: W, + ansi: bool, + omit_final_newline: bool, + ) -> io::Result<()> { if ansi { write!(w, csi!("0m"))?; } @@ -120,7 +125,8 @@ impl Canvas { let mut background_color = None; let mut text_style = CanvasTextStyle::default(); - for row in &self.cells { + for y in 0..self.cells.len() { + let row = &self.cells[y]; let last_non_empty = row.iter().rposition(|cell| !cell.is_empty()); let row = &row[..last_non_empty.map_or(0, |i| i + 1)]; let mut col = 0; @@ -196,10 +202,14 @@ impl Canvas { } // clear until end of line write!(w, csi!("K"))?; - // add a carriage return in case we're in raw mode - w.write_all(b"\r\n")?; - } else { - w.write_all(b"\n")?; + } + if !omit_final_newline || y < self.cells.len() - 1 { + if ansi { + // add a carriage return in case we're in raw mode + w.write_all(b"\r\n")?; + } else { + w.write_all(b"\n")?; + } } } if ansi { @@ -211,12 +221,16 @@ impl Canvas { /// Writes the canvas to the given writer with ANSI escape codes. pub fn write_ansi(&self, w: W) -> io::Result<()> { - self.write_impl(w, true) + self.write_impl(w, true, false) + } + + pub(crate) fn write_ansi_without_final_newline(&self, w: W) -> io::Result<()> { + self.write_impl(w, true, true) } /// Writes the canvas to the given writer as unstyled text, without ANSI escape codes. pub fn write(&self, w: W) -> io::Result<()> { - self.write_impl(w, false) + self.write_impl(w, false, false) } } diff --git a/packages/iocraft/src/element.rs b/packages/iocraft/src/element.rs index bc6d22b..2e7f437 100644 --- a/packages/iocraft/src/element.rs +++ b/packages/iocraft/src/element.rs @@ -1,7 +1,7 @@ use crate::{ component::{Component, ComponentHelper, ComponentHelperExt}, props::AnyProps, - render, terminal_render_loop, Canvas, + render, terminal_render_loop, Canvas, Terminal, }; use crossterm::{terminal, tty::IsTty}; use std::{ @@ -195,6 +195,9 @@ pub trait ElementExt: private::Sealed + Sized { /// Renders the element in a loop, allowing it to be dynamic and interactive. fn render_loop(&mut self) -> impl Future>; + + /// Renders the element as fullscreen in a loop, allowing it to be dynamic and interactive. + fn fullscreen(&mut self) -> impl Future>; } impl<'a> ElementExt for AnyElement<'a> { @@ -216,7 +219,11 @@ impl<'a> ElementExt for AnyElement<'a> { } async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(self, stdout()).await + terminal_render_loop(self, Terminal::new()?).await + } + + async fn fullscreen(&mut self) -> io::Result<()> { + terminal_render_loop(self, Terminal::fullscreen()?).await } } @@ -239,7 +246,11 @@ impl<'a> ElementExt for &mut AnyElement<'a> { } async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(&mut **self, stdout()).await + terminal_render_loop(&mut **self, Terminal::new()?).await + } + + async fn fullscreen(&mut self) -> io::Result<()> { + terminal_render_loop(&mut **self, Terminal::fullscreen()?).await } } @@ -265,7 +276,11 @@ where } async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(self, stdout()).await + terminal_render_loop(self, Terminal::new()?).await + } + + async fn fullscreen(&mut self) -> io::Result<()> { + terminal_render_loop(self, Terminal::fullscreen()?).await } } @@ -291,7 +306,11 @@ where } async fn render_loop(&mut self) -> io::Result<()> { - terminal_render_loop(&mut **self, stdout()).await + terminal_render_loop(&mut **self, Terminal::new()?).await + } + + async fn fullscreen(&mut self) -> io::Result<()> { + terminal_render_loop(&mut **self, Terminal::fullscreen()?).await } } diff --git a/packages/iocraft/src/hooks/mod.rs b/packages/iocraft/src/hooks/mod.rs index d8e849b..cf4e08d 100644 --- a/packages/iocraft/src/hooks/mod.rs +++ b/packages/iocraft/src/hooks/mod.rs @@ -6,3 +6,5 @@ mod use_state; pub use use_state::*; mod use_terminal_events; pub use use_terminal_events::*; +mod use_terminal_size; +pub use use_terminal_size::*; diff --git a/packages/iocraft/src/hooks/use_terminal_size.rs b/packages/iocraft/src/hooks/use_terminal_size.rs new file mode 100644 index 0000000..94f6ce2 --- /dev/null +++ b/packages/iocraft/src/hooks/use_terminal_size.rs @@ -0,0 +1,23 @@ +use crate::{ + hooks::{UseState, UseTerminalEvents}, + Hooks, TerminalEvent, +}; +use crossterm::terminal; + +/// `UseTerminalSize` is a hook that returns the current terminal size. +pub trait UseTerminalSize { + /// Returns the current terminal size as a tuple of `(width, height)`. + fn use_terminal_size(&mut self) -> (u16, u16); +} + +impl UseTerminalSize for Hooks<'_, '_> { + fn use_terminal_size(&mut self) -> (u16, u16) { + let size = self.use_state(|| terminal::size().unwrap_or((0, 0))); + self.use_terminal_events(move |event| { + if let TerminalEvent::Resize(width, height) = event { + size.set((width, height)); + } + }); + size.get() + } +} diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs index 684568e..df65231 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -354,38 +354,40 @@ impl<'a> Tree<'a> { } } - async fn terminal_render_loop(&mut self, mut w: W) -> io::Result<()> - where - W: Write, - { - let mut terminal = Terminal::new()?; + async fn terminal_render_loop(&mut self, mut term: Terminal) -> io::Result<()> { let mut prev_canvas: Option = None; loop { - let width = terminal.width().ok().map(|w| w as usize); - execute!(w, terminal::BeginSynchronizedUpdate,)?; - let lines_to_rewind_to_clear = prev_canvas.as_ref().map_or(0, |c| c.height()); - let output = self.render(width, Some(&mut terminal), lines_to_rewind_to_clear); + let width = term.width().ok().map(|w| w as usize); + execute!(term, terminal::BeginSynchronizedUpdate,)?; + let lines_to_rewind_to_clear = prev_canvas + .as_ref() + .map_or(0, |c| c.height() - if term.is_fullscreen() { 1 } else { 0 }); + let output = self.render(width, Some(&mut term), lines_to_rewind_to_clear); if output.did_clear_terminal_output || prev_canvas.as_ref() != Some(&output.canvas) { if !output.did_clear_terminal_output { - terminal.rewind_lines(lines_to_rewind_to_clear as _)?; + term.rewind_lines(lines_to_rewind_to_clear as _)?; + } + if term.is_fullscreen() { + output.canvas.write_ansi_without_final_newline(&mut term)?; + } else { + output.canvas.write_ansi(&mut term)?; } - output.canvas.write_ansi(&mut w)?; } prev_canvas = Some(output.canvas); - execute!(w, terminal::EndSynchronizedUpdate)?; - if self.system_context.should_exit() || terminal.received_ctrl_c() { + execute!(term, terminal::EndSynchronizedUpdate)?; + if self.system_context.should_exit() || term.received_ctrl_c() { break; } select( self.root_component.wait().boxed_local(), - terminal.wait().boxed_local(), + term.wait().boxed_local(), ) .await; - if terminal.received_ctrl_c() { + if term.received_ctrl_c() { break; } } - mem::drop(terminal); + write!(term, "\r\n")?; Ok(()) } } @@ -396,48 +398,11 @@ pub(crate) fn render(mut e: E, max_width: Option) -> Canva tree.render(max_width, None, 0).canvas } -pub(crate) async fn terminal_render_loop(mut e: E, dest: W) -> io::Result<()> +pub(crate) async fn terminal_render_loop(mut e: E, term: Terminal) -> io::Result<()> where E: ElementExt, - W: Write, { let h = e.helper(); let mut tree = Tree::new(e.props_mut(), h); - tree.terminal_render_loop(dest).await -} - -#[cfg(test)] -mod tests { - use crate::{hooks::UseFuture, prelude::*}; - use macro_rules_attribute::apply; - use smol_macros::test; - - #[component] - fn MyComponent(mut hooks: Hooks) -> impl Into> { - let mut system = hooks.use_context_mut::(); - let mut counter = hooks.use_state(|| 0); - - hooks.use_future(async move { - counter += 1; - }); - - if counter == 1 { - system.exit(); - } - - element! { - Text(content: format!("count: {}", counter)) - } - } - - #[apply(test!)] - async fn test_terminal_render_loop() { - let mut buf = Vec::new(); - terminal_render_loop(element!(MyComponent), &mut buf) - .await - .unwrap(); - let output = String::from_utf8_lossy(&buf); - assert!(output.contains("count: 0")); - assert!(output.contains("count: 1")); - } + tree.terminal_render_loop(term).await } diff --git a/packages/iocraft/src/style.rs b/packages/iocraft/src/style.rs index d1912cf..b9f3623 100644 --- a/packages/iocraft/src/style.rs +++ b/packages/iocraft/src/style.rs @@ -53,6 +53,21 @@ impl From for LengthPercentage { macro_rules! impl_from_length { ($name:ident) => { + impl From for $name { + fn from(l: i16) -> Self { + $name::Length(l as _) + } + } + impl From for $name { + fn from(l: i32) -> Self { + $name::Length(l as _) + } + } + impl From for $name { + fn from(l: u16) -> Self { + $name::Length(l as _) + } + } impl From for $name { fn from(l: u32) -> Self { $name::Length(l) diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index f3f5902..7323f53 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -9,7 +9,7 @@ use futures::{ }; use std::{ collections::VecDeque, - io::{self, stdout}, + io::{self, stdout, Write}, pin::Pin, sync::{Arc, Mutex, Weak}, task::{Context, Poll, Waker}, @@ -37,6 +37,8 @@ pub struct KeyEvent { pub enum TerminalEvent { /// A key event, fired when a key is pressed. Key(KeyEvent), + /// A resize event, fired when the terminal is resized. + Resize(u16, u16), } struct TerminalEventsInner { @@ -63,43 +65,46 @@ impl Stream for TerminalEvents { } } -trait TerminalImpl { - fn new() -> io::Result - where - Self: Sized; - +trait TerminalImpl: Write { fn width(&self) -> io::Result; + fn is_fullscreen(&self) -> bool; fn is_raw_mode_enabled(&self) -> bool; fn rewind_lines(&mut self, lines: u16) -> io::Result<()>; fn event_stream(&mut self) -> io::Result>; } struct StdTerminal { + dest: io::Stdout, + fullscreen: bool, raw_mode_enabled: bool, } -impl TerminalImpl for StdTerminal { - fn new() -> io::Result - where - Self: Sized, - { - queue!(stdout(), cursor::Hide)?; - Ok(Self { - raw_mode_enabled: false, - }) +impl Write for StdTerminal { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.dest.write(buf) } + fn flush(&mut self) -> io::Result<()> { + self.dest.flush() + } +} + +impl TerminalImpl for StdTerminal { fn width(&self) -> io::Result { terminal::size().map(|(w, _)| w) } + fn is_fullscreen(&self) -> bool { + self.fullscreen + } + fn is_raw_mode_enabled(&self) -> bool { self.raw_mode_enabled } fn rewind_lines(&mut self, lines: u16) -> io::Result<()> { queue!( - stdout(), + self.dest, cursor::MoveToPreviousLine(lines as _), terminal::Clear(terminal::ClearType::FromCursorDown) ) @@ -116,6 +121,7 @@ impl TerminalImpl for StdTerminal { modifiers: event.modifiers, kind: event.kind, })), + Ok(Event::Resize(width, height)) => Some(TerminalEvent::Resize(width, height)), _ => None, } }) @@ -124,19 +130,41 @@ impl TerminalImpl for StdTerminal { } impl StdTerminal { + fn new(fullscreen: bool) -> io::Result + where + Self: Sized, + { + let mut dest = stdout(); + queue!(dest, cursor::Hide)?; + if fullscreen { + queue!(dest, terminal::EnterAlternateScreen)?; + } + Ok(Self { + dest, + fullscreen, + raw_mode_enabled: false, + }) + } + 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 { execute!( - stdout(), + self.dest, event::PushKeyboardEnhancementFlags( event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES ) )?; + if self.fullscreen { + execute!(self.dest, event::EnableMouseCapture)?; + } terminal::enable_raw_mode()?; } else { terminal::disable_raw_mode()?; - execute!(stdout(), event::PopKeyboardEnhancementFlags)?; + if self.fullscreen { + execute!(self.dest, event::DisableMouseCapture)?; + } + execute!(self.dest, event::PopKeyboardEnhancementFlags)?; } self.raw_mode_enabled = raw_mode_enabled; } @@ -147,7 +175,10 @@ impl StdTerminal { impl Drop for StdTerminal { fn drop(&mut self) { let _ = self.set_raw_mode_enabled(false); - let _ = execute!(stdout(), cursor::Show); + if self.fullscreen { + let _ = queue!(self.dest, terminal::LeaveAlternateScreen); + } + let _ = execute!(self.dest, cursor::Show); } } @@ -160,18 +191,26 @@ pub(crate) struct Terminal { impl Terminal { pub fn new() -> io::Result { - Self::new_with_impl::() + Self::new_with_impl(StdTerminal::new(false)?) } - fn new_with_impl() -> io::Result { + pub fn fullscreen() -> io::Result { + Self::new_with_impl(StdTerminal::new(true)?) + } + + fn new_with_impl(inner: T) -> io::Result { Ok(Self { - inner: Box::new(T::new()?), + inner: Box::new(inner), event_stream: None, subscribers: Vec::new(), received_ctrl_c: false, }) } + pub fn is_fullscreen(&self) -> bool { + self.inner.is_fullscreen() + } + pub fn is_raw_mode_enabled(&self) -> bool { self.inner.is_raw_mode_enabled() } @@ -238,6 +277,16 @@ impl Terminal { } } +impl Write for Terminal { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + #[cfg(test)] mod tests { use crate::prelude::*;