diff options
-rw-r--r-- | .github/workflows/docs.yml | 50 | ||||
-rw-r--r-- | .github/workflows/rust.yml | 24 | ||||
-rw-r--r-- | Cargo.toml | 14 | ||||
-rw-r--r-- | README.md | 16 | ||||
-rw-r--r-- | src/display/components.rs (renamed from src/components.rs) | 35 | ||||
-rw-r--r-- | src/display/mod.rs | 8 | ||||
-rw-r--r-- | src/display/resources.rs | 43 | ||||
-rw-r--r-- | src/display/systems.rs (renamed from src/systems.rs) | 87 | ||||
-rw-r--r-- | src/input/events.rs (renamed from src/events.rs) | 1 | ||||
-rw-r--r-- | src/input/mod.rs | 8 | ||||
-rw-r--r-- | src/input/resources.rs | 48 | ||||
-rw-r--r-- | src/input/systems.rs | 46 | ||||
-rw-r--r-- | src/lib.rs | 85 | ||||
-rw-r--r-- | src/resources.rs | 109 | ||||
-rw-r--r-- | src/widgets/components.rs | 14 | ||||
-rw-r--r-- | src/widgets/mod.rs | 21 | ||||
-rw-r--r-- | src/widgets/systems.rs | 35 |
17 files changed, 388 insertions, 256 deletions
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e0044de --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +name: Docs +on: + push: + branches: [master] +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: deploy + cancel-in-progress: false +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Dependencies + run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Configure cache + uses: Swatinem/rust-cache@v2 + - name: Setup pages + id: pages + uses: actions/configure-pages@v4 + - name: Clean docs folder + run: cargo clean --doc + - name: Build docs + run: cargo doc --no-deps + - name: Add redirect + run: echo '<meta http-equiv="refresh" content="0;url=bevy_terminal_display/index.html">' > target/doc/index.html + - name: Remove lock file + run: rm target/doc/.lock + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: target/doc + deploy: + name: Deploy + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..e38739d --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,24 @@ +name: Rust + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install Dependencies + run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose @@ -1,24 +1,24 @@ [package] -name = "grex_terminal_display" -version = "0.1.5" +name = "bevy_terminal_display" +version = "0.2.0" edition = "2021" [dependencies] crossterm = "0.27.0" +downcast-rs = "1.2.1" once_cell = "1.19.0" [dependencies.bevy] version = "0.13" -[dependencies.grex_framebuffer_extract] -git = "https://github.com/exvacuum/grex_framebuffer_extract" +[dependencies.bevy_framebuffer_extract] +git = "https://github.com/exvacuum/bevy_framebuffer_extract" tag = "v0.1.1" -[dependencies.grex_dither_post_process] -git = "https://github.com/exvacuum/grex_dither_post_process" +[dependencies.bevy_dither_post_process] +git = "https://github.com/exvacuum/bevy_dither_post_process" tag = "v0.1.3" [dependencies.ratatui] version = "0.26.2" -features = ["unstable-widget-ref"] @@ -1,4 +1,4 @@ -# grex_terminal_display +# bevy_terminal_display A (very experimental) plugin for the [Bevy](https://bevyengine.org) engine which allows for rendering to a terminal window. @@ -10,7 +10,7 @@ Features Include: - Post-process dithers colors to pure black and white, which are then printed as braille characters to the terminal - Responsiveness to terminal window resizing - `TerminalInput` resource which keeps track of pressed & released keys -- `TerminalUI` resource for rendering ratatui TUI widgets +- `Widget` component for rendering ratatui TUI widgets - `TerminalWidget` trait for creating custom TUI widget components - Logging redirected to `output.log` @@ -20,14 +20,14 @@ Features Include: | Crate Version | Bevy Version | |--- |--- | -| 0.1 | 0.13 | +| 0.2 | 0.13 | ## Installation ### Using git URL in Cargo.toml ```toml -[dependencies.grex_terminal_display] -git = "https://github.com/exvacuum/grex_terminal_display.git" +[dependencies.bevy_terminal_display] +git = "https://github.com/exvacuum/bevy_terminal_display.git" ``` ## Example Usage @@ -35,14 +35,14 @@ git = "https://github.com/exvacuum/grex_terminal_display.git" In `main.rs`: ```rs use bevy::prelude::*; -use grex_terminal_display; +use bevy_terminal_display; fn main() { App::new() .add_plugins(( DefaultPlugins.build().disable::<WinitPlugin>().disable::<LogPlugin>, ScheduleRunnerPlugin::run_loop(Duration::from_secs_f32(1.0 / 60.0)), - grex_terminal_display::TerminalDisplayPlugin, + bevy_terminal_display::TerminalDisplayPlugin, )) .insert_resource(Msaa::Off) // For post-process .run(); @@ -51,7 +51,7 @@ fn main() { When spawning a camera: ```rs -let terminal_display_bundle = grex_terminal_display::components::TerminalDisplayBundle::new(3, &asset_server); +let terminal_display_bundle = bevy_terminal_display::display::components::TerminalDisplayBundle::new(3, &asset_server); commands.spawn(( Camera3dBundle { diff --git a/src/components.rs b/src/display/components.rs index 3814cc7..4459325 100644 --- a/src/components.rs +++ b/src/display/components.rs @@ -1,21 +1,13 @@ +use bevy::{prelude::*, render::render_resource::{Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages}}; +use bevy_dither_post_process::components::DitherPostProcessSettings; +use bevy_framebuffer_extract::{components::{ExtractFramebufferBundle, FramebufferExtractDestination}, render_assets::FramebufferExtractSource}; -use bevy::{ - prelude::*, - render::render_resource::{ - Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, - }, -}; -use grex_dither_post_process::components::DitherPostProcessSettings; -use grex_framebuffer_extract::{ - components::{ExtractFramebufferBundle, FramebufferExtractDestination}, - render_assets::FramebufferExtractSource, -}; - -use crate::resources::TerminalWidget; - +/// Marker component for terminal display #[derive(Component)] pub struct TerminalDisplay; +/// Bundle for terminal display, contains a handle to an image to be used as a render target to +/// render to the terminal #[derive(Bundle)] pub struct TerminalDisplayBundle { _terminal_display: TerminalDisplay, @@ -25,6 +17,9 @@ pub struct TerminalDisplayBundle { } impl TerminalDisplayBundle { + /// Create a new terminal display with the given dither level. A higher level exponentially + /// increases the size of the bayer matrix used in the ordered dithering calculations. If in + /// doubt, 3 is a good starting value to test with. pub fn new(dither_level: u32, asset_server: &AssetServer) -> Self { let terminal_size = crossterm::terminal::size().unwrap(); let size = Extent3d { @@ -69,17 +64,9 @@ impl TerminalDisplayBundle { } } + /// Retrieves the handle to this display's target image. Anything written here will be + /// displayed. pub fn image_handle(&self) -> Handle<Image> { self.image_handle.clone() } } - -#[derive(Component)] -pub struct Widget { - pub widget: Box<dyn TerminalWidget + Send + Sync>, - pub depth: u32, - pub enabled: bool, -} - -#[derive(Component)] -pub struct Tooltip(pub String); diff --git a/src/display/mod.rs b/src/display/mod.rs new file mode 100644 index 0000000..ae45445 --- /dev/null +++ b/src/display/mod.rs @@ -0,0 +1,8 @@ +/// Components for this module +pub mod components; + +/// Resources for this module +pub mod resources; + +/// Systems for this module +pub(crate) mod systems; diff --git a/src/display/resources.rs b/src/display/resources.rs new file mode 100644 index 0000000..7321aea --- /dev/null +++ b/src/display/resources.rs @@ -0,0 +1,43 @@ +use std::io::{stdout, Stdout}; + +use bevy::prelude::*; +use crossterm::{ + event::{ + DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::backend::CrosstermBackend; + +/// Ratatui terminal instance. Enters alternate screen when constructed, and exits once dropped. +#[derive(Resource)] +pub struct Terminal(pub ratatui::Terminal<CrosstermBackend<Stdout>>); + +impl Default for Terminal { + fn default() -> Self { + stdout().execute(EnterAlternateScreen).unwrap(); + stdout().execute(EnableMouseCapture).unwrap(); + stdout() + .execute(PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::REPORT_EVENT_TYPES, + )) + .unwrap(); + enable_raw_mode().unwrap(); + let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(stdout())) + .expect("Failed to create terminal"); + terminal.clear().expect("Failed to clear terminal"); + Self(terminal) + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + let mut stdout = stdout(); + let _ = stdout.execute(PopKeyboardEnhancementFlags); + let _ = stdout.execute(DisableMouseCapture); + let _ = stdout.execute(LeaveAlternateScreen); + let _ = disable_raw_mode(); + } +} diff --git a/src/systems.rs b/src/display/systems.rs index dedef65..a768af5 100644 --- a/src/systems.rs +++ b/src/display/systems.rs @@ -2,44 +2,31 @@ use bevy::{ prelude::*, render::render_resource::{Extent3d, TextureFormat}, }; -use crossterm::event::{read, Event, KeyEventKind}; -use grex_framebuffer_extract::{ +use crossterm::event::Event; +use bevy_framebuffer_extract::{ components::FramebufferExtractDestination, render_assets::FramebufferExtractSource, }; - -use crate::{ - components::Widget, events::TerminalInputEvent, resources::{EventQueue, Terminal, TerminalInput} -}; - use ratatui::{ - prelude::*, + style::Stylize, widgets::{Paragraph, Wrap}, }; +use crate::input::events::TerminalInputEvent; + +use super::resources::Terminal; + const BRAILLE_CODE_MIN: u16 = 0x2800; const BRAILLE_CODE_MAX: u16 = 0x28FF; -const BRAILLE_DOT_BIT_POSITIONS: [u8; 8] = [0, 1, 2, 6, 3, 4, 5, 7]; -pub fn setup(event_queue: Res<EventQueue>) { - let event_queue = event_queue.0.clone(); - std::thread::spawn(move || { - loop { - // `read()` blocks until an `Event` is available - match read() { - Ok(event) => { - event_queue.lock().unwrap().push(event); - } - Err(err) => { - panic!("Error reading input events: {:?}", err); - } - } - } - }); -} +/// 0 3 +/// 1 4 +/// 2 5 +/// 6 7 +const BRAILLE_DOT_BIT_POSITIONS: [u8; 8] = [0, 1, 2, 6, 3, 4, 5, 7]; +/// Prints out the contents of a render image to the terminal as braille characters pub fn print_to_terminal( mut terminal: ResMut<Terminal>, - mut widgets: Query<&mut Widget>, image_exports: Query<&FramebufferExtractDestination>, ) { for image_export in image_exports.iter() { @@ -47,10 +34,6 @@ pub fn print_to_terminal( .0 .lock() .expect("Failed to get lock on output texture"); - //TODO: Find a better way of preventing first frame - if image.size() == UVec2::ONE { - continue; - } if image.texture_descriptor.format != TextureFormat::R8Unorm { warn_once!("Extracted framebuffer texture is not R8Unorm format. Will attempt conversion, but consider changing your render texture's format."); info_once!("{:?}", image); @@ -87,24 +70,19 @@ pub fn print_to_terminal( terminal .0 .draw(|frame| { - let area = frame.size(); frame.render_widget( Paragraph::new(string) .white() .bold() .wrap(Wrap { trim: true }), - area, + frame.size(), ); - let mut active_widgets = widgets.iter_mut().filter(|widget| widget.enabled).collect::<Vec<_>>(); - active_widgets.sort_by(|a, b| a.depth.cmp(&b.depth)); - for mut widget in active_widgets { - widget.widget.render(frame, area); - } }) .expect("Failed to draw terminal frame"); } } +/// Utility function to convert a u8 into the corresponding braille character fn braille_char(mask: u8) -> char { match char::from_u32((BRAILLE_CODE_MIN + mask as u16) as u32) { Some(character) => { @@ -117,40 +95,7 @@ fn braille_char(mask: u8) -> char { } } -pub fn widget_input_handling( - mut widgets: Query<&mut Widget>, - mut event_reader: EventReader<TerminalInputEvent>, - mut commands: Commands, -) { - for event in event_reader.read() { - for mut widget in widgets.iter_mut().filter(|widget| widget.enabled) { - widget.widget.handle_events(event, &mut commands); - } - } -} - -pub fn input_handling( - event_queue: Res<EventQueue>, - mut input: ResMut<TerminalInput>, - mut event_writer: EventWriter<TerminalInputEvent>, -) { - let mut event_queue = event_queue.0.lock().unwrap(); - while let Some(event) = event_queue.pop() { - if let Event::Key(event) = event { - match event.kind { - KeyEventKind::Press => { - input.press(event.code); - } - KeyEventKind::Release => { - input.release(event.code); - } - _ => (), - } - } - event_writer.send(TerminalInputEvent(event)); - } -} - +/// Watches for terminal resize events and resizes the render image accordingly pub fn resize_handling( mut images: ResMut<Assets<Image>>, mut sources: ResMut<Assets<FramebufferExtractSource>>, diff --git a/src/events.rs b/src/input/events.rs index 3f7057b..cf46445 100644 --- a/src/events.rs +++ b/src/input/events.rs @@ -1,5 +1,6 @@ use bevy::prelude::*; use crossterm::event::Event; +/// An event triggered when a crossterm input event is received #[derive(Event)] pub struct TerminalInputEvent(pub Event); diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..80c36bc --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,8 @@ +/// Events for this module +pub mod events; + +/// Resources for this module +pub mod resources; + +/// Systems for this module +pub(crate) mod systems; diff --git a/src/input/resources.rs b/src/input/resources.rs new file mode 100644 index 0000000..afe6353 --- /dev/null +++ b/src/input/resources.rs @@ -0,0 +1,48 @@ +use bevy::{prelude::*, utils::HashSet}; +use crossterm::event::{Event, KeyCode}; +use std::sync::{Arc, Mutex}; + +/// Resource containing currently pressed and released keys +#[derive(Resource, Default)] +pub struct TerminalInput { + pressed_keys: HashSet<KeyCode>, + released_keys: HashSet<KeyCode>, +} + +impl TerminalInput { + /// Gets whether the given key is pressed + pub fn is_pressed(&self, code: KeyCode) -> bool { + self.pressed_keys.contains(&code) + } + + /// Gets whether the given key is released + pub fn is_released(&self, code: KeyCode) -> bool { + self.released_keys.contains(&code) + } + + /// Sets given key to pressed + pub(super) fn press(&mut self, code: KeyCode) { + if !self.is_pressed(code) { + self.pressed_keys.insert(code); + } + } + + /// Sets given key to released and removes pressed state + pub(super) fn release(&mut self, code: KeyCode) { + if self.is_pressed(code) { + self.pressed_keys.remove(&code); + } + if !self.is_released(code) { + self.released_keys.insert(code); + } + } + + /// Clears all released keys + pub(super) fn clear_released(&mut self) { + self.released_keys.clear(); + } +} + +/// Event queue for crossterm input event thread +#[derive(Resource, Default)] +pub(crate) struct EventQueue(pub(super) Arc<Mutex<Vec<Event>>>); diff --git a/src/input/systems.rs b/src/input/systems.rs new file mode 100644 index 0000000..3b7f11d --- /dev/null +++ b/src/input/systems.rs @@ -0,0 +1,46 @@ +use bevy::prelude::*; +use crossterm::event::{read, Event, KeyEventKind}; + +use super::{events::TerminalInputEvent, resources::{EventQueue, TerminalInput}}; + +/// Initializes event queue and thread +pub fn setup_input(event_queue: Res<EventQueue>) { + let event_queue = event_queue.0.clone(); + std::thread::spawn(move || { + loop { + // `read()` blocks until an `Event` is available + match read() { + Ok(event) => { + event_queue.lock().unwrap().push(event); + } + Err(err) => { + panic!("Error reading input events: {:?}", err); + } + } + } + }); +} + +/// Reads events from queue and broadcasts corresponding `TerminalInputEvent`s +pub fn input_handling( + event_queue: Res<EventQueue>, + mut input: ResMut<TerminalInput>, + mut event_writer: EventWriter<TerminalInputEvent>, +) { + input.clear_released(); + let mut event_queue = event_queue.0.lock().unwrap(); + while let Some(event) = event_queue.pop() { + if let Event::Key(event) = event { + match event.kind { + KeyEventKind::Press => { + input.press(event.code); + } + KeyEventKind::Release => { + input.release(event.code); + } + _ => (), + } + } + event_writer.send(TerminalInputEvent(event)); + } +} @@ -1,46 +1,58 @@ -use std::{fs::OpenOptions, io::stdout, path::PathBuf, sync::{Arc, Mutex}}; +#![warn(missing_docs)] + +//! Bevy plugin which allows a camera to render to a terminal window. + +use std::{ + fs::OpenOptions, + path::PathBuf, + sync::{Arc, Mutex}, +}; use bevy::{ log::{ - tracing_subscriber::{self, Registry, prelude::*}, - LogPlugin, Level, + tracing_subscriber::{self, prelude::*, Registry}, + Level, LogPlugin, }, - prelude::*, utils::tracing::level_filters::LevelFilter, + prelude::*, + utils::tracing::level_filters::LevelFilter, }; -use crossterm::{ - event::{DisableMouseCapture, PopKeyboardEnhancementFlags}, - terminal::{disable_raw_mode, LeaveAlternateScreen}, - ExecutableCommand, -}; -use grex_dither_post_process::DitherPostProcessPlugin; -use grex_framebuffer_extract::FramebufferExtractPlugin; +use bevy_dither_post_process::DitherPostProcessPlugin; +use bevy_framebuffer_extract::FramebufferExtractPlugin; pub use crossterm; use once_cell::sync::Lazy; pub use ratatui; -pub mod components; -pub mod events; -pub mod resources; -mod systems; +/// Functions and types related to capture and display of world to terminal +pub mod display; + +/// Functions and types related to capturing and processing user keyboard input +pub mod input; + +/// Functions and types related to constructing and rendering TUI widgets +pub mod widgets; static LOG_PATH: Lazy<Arc<Mutex<PathBuf>>> = Lazy::new(|| Arc::new(Mutex::new(PathBuf::default()))); +/// Plugin providing terminal display functionality pub struct TerminalDisplayPlugin { + /// Path to redirect tracing logs to. Defaults to "debug.log" pub log_path: PathBuf, } impl Default for TerminalDisplayPlugin { fn default() -> Self { Self { - log_path: "debug.log".into() + log_path: "debug.log".into(), } } } impl Plugin for TerminalDisplayPlugin { fn build(&self, app: &mut App) { - *LOG_PATH.lock().expect("Failed to get lock on log path mutex") = self.log_path.clone(); + *LOG_PATH + .lock() + .expect("Failed to get lock on log path mutex") = self.log_path.clone(); app.add_plugins(( DitherPostProcessPlugin, FramebufferExtractPlugin, @@ -50,7 +62,12 @@ impl Plugin for TerminalDisplayPlugin { .write(true) .create(true) .truncate(true) - .open(LOG_PATH.lock().expect("Failed to get lock on log path mutex").clone()) + .open( + LOG_PATH + .lock() + .expect("Failed to get lock on log path mutex") + .clone(), + ) .unwrap(); let file_layer = tracing_subscriber::fmt::Layer::new() .with_writer(log_file) @@ -60,29 +77,23 @@ impl Plugin for TerminalDisplayPlugin { ..Default::default() }, )) - .add_systems(Startup, systems::setup) + .add_systems(Startup, input::systems::setup_input) .add_systems( Update, ( - systems::input_handling, - systems::resize_handling, - systems::print_to_terminal, - systems::widget_input_handling, + input::systems::input_handling, + display::systems::resize_handling, + ( + display::systems::print_to_terminal, + widgets::systems::draw_widgets, + ) + .chain(), + widgets::systems::widget_input_handling, ), ) - .insert_resource(resources::Terminal::default()) - .insert_resource(resources::EventQueue::default()) - .insert_resource(resources::TerminalInput::default()) - .add_event::<events::TerminalInputEvent>(); - } -} - -impl Drop for TerminalDisplayPlugin { - fn drop(&mut self) { - let mut stdout = stdout(); - let _ = stdout.execute(PopKeyboardEnhancementFlags); - let _ = stdout.execute(DisableMouseCapture); - let _ = stdout.execute(LeaveAlternateScreen); - let _ = disable_raw_mode(); + .insert_resource(display::resources::Terminal::default()) + .insert_resource(input::resources::EventQueue::default()) + .insert_resource(input::resources::TerminalInput::default()) + .add_event::<input::events::TerminalInputEvent>(); } } diff --git a/src/resources.rs b/src/resources.rs deleted file mode 100644 index a373a80..0000000 --- a/src/resources.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::{ - any::Any, io::{stdout, Stdout}, sync::{Arc, Mutex} -}; - -use bevy::{ - prelude::*, - utils::HashSet, -}; -use crossterm::{ - event::{ - EnableMouseCapture, Event, KeyCode, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, - }, - terminal::{enable_raw_mode, EnterAlternateScreen}, - ExecutableCommand, -}; -use ratatui::{backend::CrosstermBackend, layout::Rect, Frame}; - -use crate::events::TerminalInputEvent; - -#[derive(Resource, Default)] -pub struct TerminalInput { - pressed_keys: HashSet<KeyCode>, - released_keys: HashSet<KeyCode>, -} - -impl TerminalInput { - pub fn is_pressed(&self, code: KeyCode) -> bool { - self.pressed_keys.contains(&code) - } - - pub fn is_released(&self, code: KeyCode) -> bool { - self.released_keys.contains(&code) - } - - pub(super) fn press(&mut self, code: KeyCode) { - if !self.is_pressed(code) { - self.pressed_keys.insert(code); - } - } - - pub(super) fn release(&mut self, code: KeyCode) { - if self.is_pressed(code) { - self.pressed_keys.remove(&code); - } - if !self.is_released(code) { - self.released_keys.insert(code); - } - } -} - -#[derive(Resource, Default)] -pub(super) struct EventQueue(pub(super) Arc<Mutex<Vec<Event>>>); - -#[derive(Resource)] -pub struct Terminal(pub ratatui::Terminal<CrosstermBackend<Stdout>>); - -impl Default for Terminal { - fn default() -> Self { - stdout().execute(EnterAlternateScreen).unwrap(); - stdout().execute(EnableMouseCapture).unwrap(); - stdout() - .execute(PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::REPORT_EVENT_TYPES, - )) - .unwrap(); - enable_raw_mode().unwrap(); - let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(stdout())) - .expect("Failed to create terminal"); - terminal.clear().expect("Failed to clear terminal"); - Self(terminal) - } -} - -// #[derive(Resource, Default)] -// pub struct TerminalUI { -// widgets: HashMap<Uuid, Box<dyn TerminalWidget + Sync + Send>>, -// } -// -// impl TerminalUI { -// pub fn insert_widget(&mut self, widget: Box<dyn TerminalWidget + Sync + Send>) -> Uuid { -// let id = Uuid::new_v4(); -// self.widgets.insert(id, widget); -// id -// } -// -// pub fn get_widget(&mut self, id: Uuid) -> Option<&mut Box<dyn TerminalWidget + Sync + Send>> { -// self.widgets.get_mut(&id) -// } -// -// pub fn destroy_widget(&mut self, id: Uuid) { -// self.widgets.remove(&id); -// } -// -// pub fn widgets(&mut self) -> Vec<&mut Box<dyn TerminalWidget + Sync + Send>> { -// let mut vec = self.widgets.values_mut().collect::<Vec<_>>(); -// vec.sort_by(|a, b| a.depth().cmp(&b.depth()).reverse()); -// vec -// } -// } - -pub trait TerminalWidget: Any { - fn init(&mut self) {} - fn update(&mut self) {} - fn render(&mut self, frame: &mut Frame, rect: Rect); - fn handle_events(&mut self, _event: &TerminalInputEvent, _commands: &mut Commands) {} - fn depth(&self) -> u32 { - 0 - } -} diff --git a/src/widgets/components.rs b/src/widgets/components.rs new file mode 100644 index 0000000..b6ff925 --- /dev/null +++ b/src/widgets/components.rs @@ -0,0 +1,14 @@ +use bevy::prelude::*; + +use super::TerminalWidget; + +/// Component representing a terminal widget. +#[derive(Component)] +pub struct Widget { + /// The widget instance itself, containing rendering and input logic + pub widget: Box<dyn TerminalWidget + Send + Sync>, + /// Depth to render widget at + pub depth: u32, + /// Whether this widget is currently enabled or should be hidden + pub enabled: bool, +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..46bfa20 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,21 @@ +use bevy::prelude::*; +use downcast_rs::{impl_downcast, DowncastSync}; +use ratatui::{layout::Rect, Frame}; + +use crate::input::events::TerminalInputEvent; + +/// Components for this module +pub mod components; + +/// Systems for this module +pub(crate) mod systems; + +/// Trait which defines an interface for terminal widgets +pub trait TerminalWidget: DowncastSync { + /// Called every frame to render the widget + fn render(&mut self, frame: &mut Frame, rect: Rect); + + /// Called when a terminal input event is invoked to update any state accordingly + fn handle_events(&mut self, _event: &TerminalInputEvent, _commands: &mut Commands) {} +} +impl_downcast!(sync TerminalWidget); diff --git a/src/widgets/systems.rs b/src/widgets/systems.rs new file mode 100644 index 0000000..69a84e3 --- /dev/null +++ b/src/widgets/systems.rs @@ -0,0 +1,35 @@ +use bevy::prelude::*; + +use crate::{display::resources::Terminal, input::events::TerminalInputEvent}; + +use super::components::Widget; + +/// Invokes every enabled widget's `render` method +pub fn draw_widgets(mut terminal: ResMut<Terminal>, mut widgets: Query<&mut Widget>) { + terminal + .0 + .draw(|frame| { + let mut active_widgets = widgets + .iter_mut() + .filter(|widget| widget.enabled) + .collect::<Vec<_>>(); + active_widgets.sort_by(|a, b| a.depth.cmp(&b.depth)); + for mut widget in active_widgets { + widget.widget.render(frame, frame.size()); + } + }) + .unwrap(); +} + +/// Invokes every enabled widget's `handle_events` methods for each incoming input event +pub fn widget_input_handling( + mut widgets: Query<&mut Widget>, + mut event_reader: EventReader<TerminalInputEvent>, + mut commands: Commands, +) { + for event in event_reader.read() { + for mut widget in widgets.iter_mut().filter(|widget| widget.enabled) { + widget.widget.handle_events(event, &mut commands); + } + } +} |