diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e4b3b9f..71f30c6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -13,15 +13,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v2 - name: Setup rust toolchain - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@stable with: toolchain: nightly override: true - name: Run tests - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-fail-fast --lib --no-default-features --features derive,serialize,with-crossterm + run: cargo test --no-fail-fast --lib --no-default-features --features derive,serialize env: CARGO_INCREMENTAL: "0" RUSTFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests" diff --git a/.github/workflows/crossterm-windows.yml b/.github/workflows/crossterm-windows.yml index 188bd2c..c72804f 100644 --- a/.github/workflows/crossterm-windows.yml +++ b/.github/workflows/crossterm-windows.yml @@ -11,24 +11,16 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 + - uses: dtolnay/rust-toolchain@stable with: toolchain: stable override: true components: rustfmt, clippy - - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-fail-fast --no-default-features --features derive,serialize,tui,crossterm - - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-fail-fast --no-default-features --features derive,serialize,ratatui,crossterm + - name: Test + run: cargo test --no-fail-fast --lib --no-default-features --features derive,serialize,crossterm - name: Examples run: cargo build --all-targets --examples - name: Format run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --lib --no-default-features --features derive,serialize,tui,crossterm -- -Dwarnings - - name: Clippy - run: cargo clippy --lib --no-default-features --features derive,serialize,ratatui,crossterm -- -Dwarnings + run: cargo clippy --lib --no-default-features --features derive,serialize,crossterm -- -Dwarnings diff --git a/.github/workflows/ratatui_crossterm.yml b/.github/workflows/ratatui_crossterm.yml index beaccca..cadd4f5 100644 --- a/.github/workflows/ratatui_crossterm.yml +++ b/.github/workflows/ratatui_crossterm.yml @@ -3,26 +3,24 @@ name: Ratatui-Crossterm on: [push, pull_request] env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: always jobs: - build: - runs-on: ubuntu-latest + build: + runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - components: rustfmt, clippy - - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-fail-fast --features derive,serialize,ratatui,crossterm --no-default-features - - name: Examples - run: cargo build --all-targets --examples - - name: Format - run: cargo fmt --all -- --check - - name: Clippy - run: cargo clippy --lib --features derive,serialize,ratatui,crossterm --no-default-features -- -Dwarnings + steps: + - uses: actions/checkout@v2 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + override: true + components: rustfmt, clippy + - name: Test + run: cargo test --no-fail-fast --lib --features derive,serialize,crossterm --no-default-features + - name: Examples + run: cargo build --all-targets --examples + - name: Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --lib --features derive,serialize,crossterm --no-default-features -- -Dwarnings diff --git a/.github/workflows/ratatui_termion.yml b/.github/workflows/ratatui_termion.yml index 429a2e4..b5591c1 100644 --- a/.github/workflows/ratatui_termion.yml +++ b/.github/workflows/ratatui_termion.yml @@ -11,18 +11,15 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 + - uses: dtolnay/rust-toolchain@stable with: toolchain: stable - override: true components: rustfmt, clippy - - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-fail-fast --no-default-features --features derive,serialize,ratatui,termion + - name: Test + run: cargo test --no-fail-fast --lib --no-default-features --features derive,serialize,termion - name: Examples - run: cargo build --all-targets --no-default-features --features derive,serialize,ratatui,termion --examples + run: cargo build --all-targets --no-default-features --features derive,serialize,termion --examples - name: Format run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --lib --no-default-features --features derive,serialize,ratatui,termion -- -Dwarnings + run: cargo clippy --lib --no-default-features --features derive,serialize,termion -- -Dwarnings diff --git a/.github/workflows/tui_crossterm.yml b/.github/workflows/tui_crossterm.yml deleted file mode 100644 index e24c55d..0000000 --- a/.github/workflows/tui_crossterm.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Tui-Crossterm - -on: [push, pull_request] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - components: rustfmt, clippy - - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-fail-fast --features derive,serialize,tui,crossterm --no-default-features - - name: Examples - run: cargo build --all-targets --examples - - name: Format - run: cargo fmt --all -- --check - - name: Clippy - run: cargo clippy --lib --features derive,serialize,tui,crossterm --no-default-features -- -Dwarnings diff --git a/.github/workflows/tui_termion.yml b/.github/workflows/tui_termion.yml deleted file mode 100644 index 6a45f68..0000000 --- a/.github/workflows/tui_termion.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Tui-Termion - -on: [push, pull_request] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - components: rustfmt, clippy - - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-fail-fast --no-default-features --features derive,serialize,tui,termion - - name: Examples - run: cargo build --all-targets --no-default-features --features derive,serialize,tui,termion --examples - - name: Format - run: cargo fmt --all -- --check - - name: Clippy - run: cargo clippy --lib --no-default-features --features derive,serialize,tui,termion -- -Dwarnings diff --git a/CHANGELOG.md b/CHANGELOG.md index c21c61e..48abcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog - [Changelog](#changelog) + - [2.0.0](#200) - [1.9.2](#192) - [1.9.1](#191) - [1.9.0](#190) @@ -35,6 +36,34 @@ --- +## 2.0.0 + +Released on 13/10/2024 + +- Dropped support for `tui-rs`. Tui-rs was deprecated a long time ago, so it doesn't really makes sense to keep supporting it. +- Added new methods for `TerminalBridge` + - `init`: Initialize a terminal with reasonable defaults for most applications. + - Raw mode is enabled + - Alternate screen buffer enabled + - A panic hook is installed that restores the terminal before panicking. Ensure that this method is called after any other panic hooks that may be installed to ensure that the terminal is. + - `restore`: Restore the terminal to its original state + - `set_panic_hook`: Sets a panic hook that restores the terminal before panicking. + - Added `draw` to `TerminalBridge` +- `CmdResult::Custom(&'static str)` changed to `CmdResult::Custom(&'static str, State)` +- Added new `subclause_and!(Id::Foo, Id::Bar, Id::Baz)` and `subclause_or!(Id::Foo, Id::Bar, Id::Baz)` macros. +- Removed `InputListener`. Now use `CrosstermInputListener` or `TermionInputListener`. +- Added Event handling for Mouse Events + - Added `Mouse` in `SubEventClause`. +- Bump `ratatui` version to `0.28` +- Dont enable `MouseCapture` by default +- Add function `enable_mouse_capture` and `disable_mouse_capture` to `TerminalBridge` +- **Max poll for ports**: + - Add `Port::set_max_poll` to set the amount a `Port` is polled in a single `Port::should_poll`. + - Add `EventListenerCfg::port` to add a manually constructed `Port` + - Previous `EventListenerCfg::port` has been renamed to `EventListenerCfg::add_port` + +Huge thanks to [hasezoey](https://github.com/hasezoey) for the contributions. + ## 1.9.2 Released on 04/03/2023 diff --git a/Cargo.toml b/Cargo.toml index 17cd154..3b19e85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuirealm" -version = "1.9.2" +version = "2.0.0" authors = ["Christian Visintin"] edition = "2021" categories = ["command-line-utilities"] @@ -14,15 +14,14 @@ readme = "README.md" repository = "https://github.com/veeso/tui-realm" [dependencies] -bitflags = "2.4" -crossterm = { version = "0.27", optional = true } +bitflags = "2" +crossterm = { version = "0.28", optional = true } lazy-regex = "3" -ratatui = { version = "0.26", default-features = false, optional = true } +ratatui = { version = "0.28", default-features = false } serde = { version = "^1", features = ["derive"], optional = true } -termion = { version = "^2", optional = true } -thiserror = "^1.0.0" -tui = { version = "0.19", default-features = false, optional = true } -tuirealm_derive = { version = "^1.0.0", optional = true } +termion = { version = "^4", optional = true } +thiserror = "1" +tuirealm_derive = { version = "2", optional = true } [dev-dependencies] pretty_assertions = "^1" @@ -30,21 +29,22 @@ toml = "^0.8" tempfile = "^3" [features] -default = ["derive", "ratatui", "crossterm"] -derive = ["tuirealm_derive"] -serialize = ["serde", "bitflags/serde"] -tui = ["dep:tui"] -crossterm = ["dep:crossterm", "tui?/crossterm", "ratatui?/crossterm"] -termion = ["dep:termion", "tui?/termion", "ratatui?/termion"] -ratatui = ["dep:ratatui"] -# deprecated aliases for broken out backend and UI library features -with-crossterm = ["tui", "crossterm"] -with-termion = ["tui", "termion"] +default = ["derive", "crossterm"] +derive = ["dep:tuirealm_derive"] +serialize = ["dep:serde", "bitflags/serde"] +crossterm = ["dep:crossterm", "ratatui/crossterm"] +termion = ["dep:termion", "ratatui/termion"] [[example]] name = "demo" path = "examples/demo/demo.rs" +required-features = ["crossterm"] [[example]] name = "user-events" path = "examples/user_events/user_events.rs" +required-features = ["crossterm"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/README.md b/README.md index a7d559a..cacd669 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

Developed by @veeso

-

Current version: 1.9.2 (04/03/2024)

+

Current version: 2.0.0 (13/10/2024)

- Tui-Crossterm CI - Termion CI ⚠️ You can enable only one backend at the time and at least one must be enabled in order to build. -> ❗ You don't need tui as a dependency, since you can access to tui types via `use tuirealm::tui::` - #### Enabling other backends ⚠️ This library supports two backends: `crossterm` and `termion`, and two high @@ -169,18 +149,18 @@ level terminal TUI libraries: `tui` and `ratatui`. Whenever you explicitly declare any of the TUI library or backend feature sets you should disable the crate's default features. -> ❗ You can never have more than one backend and one UI library enabled at the same time +> ❗ The two features can co-exist, even if it doesn't make too much sense. -Example using the termion backend: +Example using crossterm: ```toml -tuirealm = { version = "^1.9.0", default-features = false, features = [ "termion", "derive", "tui" ] } +tuirealm = { version = "2", default-features = false, features = [ "derive", "crossterm" ]} ``` -Example using the ratatui UI library: +Example using the termion backend: ```toml -tuirealm = { version = "^1.9.0", default-features = false, features = [ "ratatui", "derive", "crossterm" ]} +tuirealm = { version = "2", default-features = false, features = [ "derive", "termion" ] } ``` ### Create a tui-realm application 🪂 @@ -191,7 +171,7 @@ View how to implement a tui-realm application in the [related guide](/docs/en/ge Still confused about how tui-realm works? Don't worry, try with the examples: -- [demo](/examples/demo.rs): a simple application which shows how tui-realm works +- [demo](/examples/demo/demo.rs): a simple application which shows how tui-realm works ```sh cargo run --example demo diff --git a/examples/demo/app/model.rs b/examples/demo/app/model.rs index 26b7905..aa9e317 100644 --- a/examples/demo/app/model.rs +++ b/examples/demo/app/model.rs @@ -6,8 +6,8 @@ use std::time::{Duration, SystemTime}; use tuirealm::event::NoUserEvent; use tuirealm::props::{Alignment, Color, TextModifiers}; -use tuirealm::terminal::TerminalBridge; -use tuirealm::tui::layout::{Constraint, Direction, Layout}; +use tuirealm::ratatui::layout::{Constraint, Direction, Layout}; +use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalAdapter, TerminalBridge}; use tuirealm::{ Application, AttrValue, Attribute, EventListenerCfg, Sub, SubClause, SubEventClause, Update, }; @@ -15,7 +15,10 @@ use tuirealm::{ use super::components::{Clock, DigitCounter, Label, LetterCounter}; use super::{Id, Msg}; -pub struct Model { +pub struct Model +where + T: TerminalAdapter, +{ /// Application pub app: Application, /// Indicates that the application must quit @@ -23,25 +26,27 @@ pub struct Model { /// Tells whether to redraw interface pub redraw: bool, /// Used to draw to terminal - pub terminal: TerminalBridge, + pub terminal: TerminalBridge, } -impl Default for Model { +impl Default for Model { fn default() -> Self { Self { app: Self::init_app(), quit: false, redraw: true, - terminal: TerminalBridge::new().expect("Cannot initialize terminal"), + terminal: TerminalBridge::init_crossterm().expect("Cannot initialize terminal"), } } } -impl Model { +impl Model +where + T: TerminalAdapter, +{ pub fn view(&mut self) { assert!(self .terminal - .raw_mut() .draw(|f| { let chunks = Layout::default() .direction(Direction::Vertical) @@ -55,7 +60,7 @@ impl Model { ] .as_ref(), ) - .split(f.size()); + .split(f.area()); self.app.view(&Id::Clock, f, chunks[0]); self.app.view(&Id::LetterCounter, f, chunks[1]); self.app.view(&Id::DigitCounter, f, chunks[2]); @@ -72,7 +77,7 @@ impl Model { let mut app: Application = Application::init( EventListenerCfg::default() - .default_input_listener(Duration::from_millis(20)) + .crossterm_input_listener(Duration::from_millis(20), 3) .poll_timeout(Duration::from_millis(10)) .tick_interval(Duration::from_secs(1)), ); @@ -128,7 +133,10 @@ impl Model { // Let's implement Update for model -impl Update for Model { +impl Update for Model +where + T: TerminalAdapter, +{ fn update(&mut self, msg: Option) -> Option { if let Some(msg) = msg { // Set redraw diff --git a/examples/demo/components/clock.rs b/examples/demo/components/clock.rs index 6d14e3d..86d6a8f 100644 --- a/examples/demo/components/clock.rs +++ b/examples/demo/components/clock.rs @@ -7,7 +7,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tuirealm::command::{Cmd, CmdResult}; use tuirealm::props::{Alignment, Color, TextModifiers}; -use tuirealm::tui::layout::Rect; +use tuirealm::ratatui::layout::Rect; use tuirealm::{ AttrValue, Attribute, Component, Event, Frame, MockComponent, NoUserEvent, State, StateValue, }; diff --git a/examples/demo/components/counter.rs b/examples/demo/components/counter.rs index f5a5eda..e9a218d 100644 --- a/examples/demo/components/counter.rs +++ b/examples/demo/components/counter.rs @@ -5,8 +5,8 @@ use tuirealm::command::{Cmd, CmdResult}; use tuirealm::event::{Key, KeyEvent, KeyModifiers}; use tuirealm::props::{Alignment, Borders, Color, Style, TextModifiers}; -use tuirealm::tui::layout::Rect; -use tuirealm::tui::widgets::{BorderType, Paragraph}; +use tuirealm::ratatui::layout::Rect; +use tuirealm::ratatui::widgets::{BorderType, Paragraph}; use tuirealm::{ AttrValue, Attribute, Component, Event, Frame, MockComponent, NoUserEvent, Props, State, StateValue, diff --git a/examples/demo/components/label.rs b/examples/demo/components/label.rs index c21ad7e..8bab2cd 100644 --- a/examples/demo/components/label.rs +++ b/examples/demo/components/label.rs @@ -4,8 +4,8 @@ use tuirealm::command::{Cmd, CmdResult}; use tuirealm::props::{Alignment, Color, Style, TextModifiers}; -use tuirealm::tui::layout::Rect; -use tuirealm::tui::widgets::Paragraph; +use tuirealm::ratatui::layout::Rect; +use tuirealm::ratatui::widgets::Paragraph; use tuirealm::{ AttrValue, Attribute, Component, Event, Frame, MockComponent, NoUserEvent, Props, State, }; diff --git a/examples/demo/components/mod.rs b/examples/demo/components/mod.rs index a45c807..794c432 100644 --- a/examples/demo/components/mod.rs +++ b/examples/demo/components/mod.rs @@ -3,7 +3,7 @@ //! demo example components use tuirealm::props::{Alignment, Borders, Color, Style}; -use tuirealm::tui::widgets::Block; +use tuirealm::ratatui::widgets::Block; use super::Msg; diff --git a/examples/user_events/components/label.rs b/examples/user_events/components/label.rs index b0a7d07..25fb8f4 100644 --- a/examples/user_events/components/label.rs +++ b/examples/user_events/components/label.rs @@ -7,8 +7,8 @@ use std::time::UNIX_EPOCH; use tuirealm::command::{Cmd, CmdResult}; use tuirealm::event::{Key, KeyEvent}; use tuirealm::props::{Alignment, Color, Style, TextModifiers}; -use tuirealm::tui::layout::Rect; -use tuirealm::tui::widgets::Paragraph; +use tuirealm::ratatui::layout::Rect; +use tuirealm::ratatui::widgets::Paragraph; use tuirealm::{AttrValue, Attribute, Component, Event, Frame, MockComponent, Props, State}; use super::{Msg, UserEvent}; diff --git a/examples/user_events/model.rs b/examples/user_events/model.rs index afcfba6..fd90f84 100644 --- a/examples/user_events/model.rs +++ b/examples/user_events/model.rs @@ -2,13 +2,16 @@ //! //! app model -use tuirealm::terminal::TerminalBridge; -use tuirealm::tui::layout::{Constraint, Direction, Layout}; +use tuirealm::ratatui::layout::{Constraint, Direction, Layout}; +use tuirealm::terminal::{TerminalAdapter, TerminalBridge}; use tuirealm::{Application, Update}; use super::{Id, Msg, UserEvent}; -pub struct Model { +pub struct Model +where + T: TerminalAdapter, +{ /// Application pub app: Application, /// Indicates that the application must quit @@ -16,23 +19,25 @@ pub struct Model { /// Tells whether to redraw interface pub redraw: bool, /// Used to draw to terminal - pub terminal: TerminalBridge, + pub terminal: TerminalBridge, } -impl Model { - pub fn new(app: Application) -> Self { +impl Model +where + T: TerminalAdapter, +{ + pub fn new(app: Application, adapter: T) -> Self { Self { app, quit: false, redraw: true, - terminal: TerminalBridge::new().expect("Cannot initialize terminal"), + terminal: TerminalBridge::init(adapter).expect("Cannot initialize terminal"), } } pub fn view(&mut self) { assert!(self .terminal - .raw_mut() .draw(|f| { let chunks = Layout::default() .direction(Direction::Vertical) @@ -44,7 +49,7 @@ impl Model { ] .as_ref(), ) - .split(f.size()); + .split(f.area()); self.app.view(&Id::Label, f, chunks[0]); self.app.view(&Id::Other, f, chunks[1]); }) @@ -54,7 +59,10 @@ impl Model { // Let's implement Update for model -impl Update for Model { +impl Update for Model +where + T: TerminalAdapter, +{ fn update(&mut self, msg: Option) -> Option { if let Some(msg) = msg { // Set redraw diff --git a/examples/user_events/user_events.rs b/examples/user_events/user_events.rs index 6a4ed21..8b5aa20 100644 --- a/examples/user_events/user_events.rs +++ b/examples/user_events/user_events.rs @@ -5,6 +5,7 @@ use std::time::{Duration, SystemTime}; use components::Label; use tuirealm::listener::{ListenerResult, Poll}; +use tuirealm::terminal::CrosstermTerminalAdapter; use tuirealm::{ Application, Event, EventListenerCfg, PollStrategy, Sub, SubClause, SubEventClause, Update, }; @@ -49,14 +50,15 @@ impl Poll for UserDataPort { } fn main() { - let mut app: Application = Application::init( - EventListenerCfg::default() - .default_input_listener(Duration::from_millis(10)) - .port( - Box::new(UserDataPort::default()), - Duration::from_millis(1000), - ), - ); + let event_listener = EventListenerCfg::default() + .crossterm_input_listener(Duration::from_millis(10), 3) + .add_port( + Box::new(UserDataPort::default()), + Duration::from_millis(1000), + 1, + ); + + let mut app: Application = Application::init(event_listener); // subscribe component to clause app.mount( @@ -80,9 +82,10 @@ fn main() { app.active(&Id::Label).expect("failed to active"); - let mut model = Model::new(app); - let _ = model.terminal.enter_alternate_screen(); - let _ = model.terminal.enable_raw_mode(); + let mut model = Model::new( + app, + CrosstermTerminalAdapter::new().expect("failed to create terminal"), + ); // Main loop // NOTE: loop until quit; quit is set in update if AppClose is received from counter while !model.quit { @@ -109,8 +112,9 @@ fn main() { model.redraw = false; } } - // Terminate terminal - let _ = model.terminal.leave_alternate_screen(); - let _ = model.terminal.disable_raw_mode(); - let _ = model.terminal.clear_screen(); + + model + .terminal + .restore() + .expect("failed to restore terminal"); } diff --git a/src/adapter/crossterm/listener.rs b/src/adapter/crossterm/listener.rs deleted file mode 100644 index f3f7068..0000000 --- a/src/adapter/crossterm/listener.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! ## Listener -//! -//! input listener adapter for crossterm - -use std::marker::PhantomData; -use std::time::Duration; - -use crossterm::event as xterm; - -use super::Event; -use crate::listener::{ListenerError, ListenerResult, Poll}; - -/// The input listener for crossterm. -/// If crossterm is enabled, this will already be exported as `InputEventListener` in the `adapter` module -/// or you can use it directly in the event listener, calling `default_input_listener()` in the `EventListenerCfg` -pub struct CrosstermInputListener -where - U: Eq + PartialEq + Clone + PartialOrd + Send, -{ - ghost: PhantomData, - interval: Duration, -} - -impl CrosstermInputListener -where - U: Eq + PartialEq + Clone + PartialOrd + Send, -{ - pub fn new(interval: Duration) -> Self { - Self { - ghost: PhantomData, - interval: interval / 2, - } - } -} - -impl Poll for CrosstermInputListener -where - U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, -{ - fn poll(&mut self) -> ListenerResult>> { - match xterm::poll(self.interval) { - Ok(true) => xterm::read() - .map(|x| Some(Event::from(x))) - .map_err(|_| ListenerError::PollFailed), - Ok(false) => Ok(None), - Err(_) => Err(ListenerError::PollFailed), - } - } -} diff --git a/src/adapter/crossterm/mod.rs b/src/adapter/crossterm/mod.rs deleted file mode 100644 index 06c8ac9..0000000 --- a/src/adapter/crossterm/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! ## crossterm -//! -//! this module contains the adapters for crossterm - -extern crate crossterm; - -mod event; -mod listener; -mod terminal; - -// -- export -use std::io::Stdout; - -pub use listener::CrosstermInputListener; - -use super::{Event, Key, KeyEvent, KeyModifiers, MediaKeyCode}; -use crate::tui::backend::CrosstermBackend; -use crate::tui::{Frame as TuiFrame, Terminal as TuiTerminal}; - -// -- Frame - -/// Frame represents the Frame where the view will be displayed in -#[cfg(feature = "ratatui")] -pub type Frame<'a> = TuiFrame<'a>; - -#[cfg(feature = "tui")] -pub type Frame<'a> = TuiFrame<'a, CrosstermBackend>; - -/// Terminal must be used to interact with the terminal in tui applications -pub type Terminal = TuiTerminal>; diff --git a/src/adapter/crossterm/terminal.rs b/src/adapter/crossterm/terminal.rs deleted file mode 100644 index 20c6d7d..0000000 --- a/src/adapter/crossterm/terminal.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! ## Terminal -//! -//! terminal bridge adapter for crossterm - -use std::io::stdout; - -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; - -use crate::terminal::{TerminalBridge, TerminalError, TerminalResult}; -use crate::tui::backend::CrosstermBackend; -use crate::Terminal; - -impl TerminalBridge { - pub(crate) fn adapt_new_terminal() -> TerminalResult { - Terminal::new(CrosstermBackend::new(stdout())) - .map_err(|_| TerminalError::CannotConnectStdout) - } - - pub(crate) fn adapt_enter_alternate_screen(&mut self) -> TerminalResult<()> { - execute!( - self.raw_mut().backend_mut(), - EnterAlternateScreen, - EnableMouseCapture - ) - .map_err(|_| TerminalError::CannotEnterAlternateMode) - } - - pub(crate) fn adapt_leave_alternate_screen(&mut self) -> TerminalResult<()> { - execute!( - self.raw_mut().backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .map_err(|_| TerminalError::CannotLeaveAlternateMode) - } - - pub(crate) fn adapt_clear_screen(&mut self) -> TerminalResult<()> { - self.raw_mut() - .clear() - .map_err(|_| TerminalError::CannotClear) - } - - pub(crate) fn adapt_enable_raw_mode(&mut self) -> TerminalResult<()> { - enable_raw_mode().map_err(|_| TerminalError::CannotToggleRawMode) - } - - pub(crate) fn adapt_disable_raw_mode(&mut self) -> TerminalResult<()> { - disable_raw_mode().map_err(|_| TerminalError::CannotToggleRawMode) - } -} diff --git a/src/adapter/mod.rs b/src/adapter/mod.rs deleted file mode 100644 index 33c479a..0000000 --- a/src/adapter/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! ## adapters -//! -//! this module contains the event converter for the different backends - -#[cfg(feature = "crossterm")] -use crate::core::event::MediaKeyCode; -use crate::core::event::{Event, Key, KeyEvent, KeyModifiers}; - -// -- crossterm -#[cfg(feature = "crossterm")] -pub mod crossterm; -#[cfg(feature = "crossterm")] -pub use self::crossterm::CrosstermInputListener as InputEventListener; -#[cfg(feature = "crossterm")] -pub use self::crossterm::{Frame, Terminal}; - -// -- termion -#[cfg(feature = "termion")] -pub mod termion; -#[cfg(feature = "termion")] -pub use self::termion::TermionInputListener as InputEventListener; -#[cfg(feature = "termion")] -pub use self::termion::{Frame, Terminal}; diff --git a/src/adapter/termion/listener.rs b/src/adapter/termion/listener.rs deleted file mode 100644 index 117b930..0000000 --- a/src/adapter/termion/listener.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! ## Listener -//! -//! input listener adapter for termion - -use std::io::stdin; -use std::marker::PhantomData; -use std::time::Duration; - -use termion::input::TermRead; - -use super::Event; -use crate::listener::{ListenerError, ListenerResult, Poll}; - -/// The input listener for termion. -/// If termion is enabled, this will already be exported as `InputEventListener` in the `adapter` module -/// or you can use it directly in the event listener, calling `default_input_listener()` in the `EventListenerCfg` -pub struct TermionInputListener -where - U: Eq + PartialEq + Clone + PartialOrd + Send, -{ - ghost: PhantomData, -} - -impl TermionInputListener -where - U: Eq + PartialEq + Clone + PartialOrd + Send, -{ - pub fn new(_interval: Duration) -> Self { - Self { ghost: PhantomData } - } -} - -impl Poll for TermionInputListener -where - U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, -{ - fn poll(&mut self) -> ListenerResult>> { - match stdin().events().next() { - Some(Ok(ev)) => Ok(Some(Event::from(ev))), - Some(Err(_)) => Err(ListenerError::PollFailed), - None => Ok(None), - } - } -} diff --git a/src/adapter/termion/mod.rs b/src/adapter/termion/mod.rs deleted file mode 100644 index 1d71968..0000000 --- a/src/adapter/termion/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! ## termion -//! -//! this module contains the adapters for termion - -extern crate termion; - -mod event; -mod listener; -mod terminal; - -// -- export -use std::io::Stdout; - -pub use listener::TermionInputListener; -use termion::input::MouseTerminal; -use termion::raw::RawTerminal; -use termion::screen::AlternateScreen; - -use super::{Event, Key, KeyEvent, KeyModifiers}; -use crate::tui::backend::TermionBackend; -use crate::tui::{Frame as TuiFrame, Terminal as TuiTerminal}; - -// -- Frame - -/// Frame represents the Frame where the view will be displayed in -#[cfg(feature = "ratatui")] -pub type Frame<'a> = TuiFrame<'a>; - -#[cfg(feature = "tui")] -pub type Frame<'a> = - TuiFrame<'a, TermionBackend>>>>; - -/// Terminal must be used to interact with the terminal in tui applications -pub type Terminal = - TuiTerminal>>>>; diff --git a/src/adapter/termion/terminal.rs b/src/adapter/termion/terminal.rs deleted file mode 100644 index f3439a8..0000000 --- a/src/adapter/termion/terminal.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! ## Terminal -//! -//! terminal bridge adapter for termion - -use std::io::stdout; - -use termion::input::MouseTerminal; -use termion::raw::IntoRawMode; -use termion::screen::IntoAlternateScreen; - -use crate::terminal::{TerminalBridge, TerminalError, TerminalResult}; -use crate::tui::backend::TermionBackend; -use crate::Terminal; - -impl TerminalBridge { - pub(crate) fn adapt_new_terminal() -> TerminalResult { - let stdout = stdout() - .into_raw_mode() - .map_err(|_| TerminalError::CannotConnectStdout)? - .into_alternate_screen() - .map_err(|_| TerminalError::CannotConnectStdout)?; - let stdout = MouseTerminal::from(stdout); - Terminal::new(TermionBackend::new(stdout)).map_err(|_| TerminalError::CannotConnectStdout) - } - - pub(crate) fn adapt_enter_alternate_screen(&mut self) -> TerminalResult<()> { - Err(TerminalError::Unsupported) - } - - pub(crate) fn adapt_leave_alternate_screen(&mut self) -> TerminalResult<()> { - Err(TerminalError::Unsupported) - } - - pub(crate) fn adapt_clear_screen(&mut self) -> TerminalResult<()> { - self.raw_mut() - .clear() - .map_err(|_| TerminalError::CannotClear) - } - - pub(crate) fn adapt_enable_raw_mode(&mut self) -> TerminalResult<()> { - Err(TerminalError::Unsupported) - } - - pub(crate) fn adapt_disable_raw_mode(&mut self) -> TerminalResult<()> { - Err(TerminalError::Unsupported) - } -} diff --git a/src/core/application.rs b/src/core/application.rs index c37fad8..999eb9e 100644 --- a/src/core/application.rs +++ b/src/core/application.rs @@ -5,22 +5,23 @@ use std::hash::Hash; use std::time::{Duration, Instant}; +use ratatui::Frame; use thiserror::Error; use super::{Subscription, View, WrappedComponent}; use crate::listener::{EventListener, EventListenerCfg, ListenerError}; -use crate::tui::layout::Rect; -use crate::{AttrValue, Attribute, Event, Frame, Injector, State, Sub, SubEventClause, ViewError}; +use crate::ratatui::layout::Rect; +use crate::{AttrValue, Attribute, Event, Injector, State, Sub, SubEventClause, ViewError}; -/// Result retuned by `Application`. +/// Result retuned by [`Application`]. /// Ok depends on method -/// Err is always `ApplicationError` +/// Err is always [`ApplicationError`] pub type ApplicationResult = Result; /// The application defines a tui-realm application. /// It will handle events, subscriptions and the view too. /// It provides functions to interact with the view (mount, umount, query, etc), but also -/// the main function: `tick()`. See [tick](#tick) +/// the main function: [`Application::tick`]. pub struct Application where ComponentId: Eq + PartialEq + Clone + Hash, @@ -40,7 +41,7 @@ where Msg: PartialEq, UserEvent: Eq + PartialEq + Clone + PartialOrd + Send + 'static, { - /// Initialize a new `Application`. + /// Initialize a new [`Application`]. /// The event listener is immediately created and started. pub fn init(listener_cfg: EventListenerCfg) -> Self { Self { @@ -53,7 +54,7 @@ where /// Restart listener in case the previous listener has died or if you want to start a new one with a new configuration. /// - /// > The listener has died if you received a `ApplicationError::Listener(ListenerError::ListenerDied))` + /// > The listener has died if you received a [`ApplicationError::Listener(ListenerError::ListenerDied))`] pub fn restart_listener( &mut self, listener_cfg: EventListenerCfg, @@ -77,14 +78,14 @@ where /// The tick method makes the application to run once. /// The workflow of the tick method is the following one: /// - /// 1. The event listener is fetched according to the provided `PollStrategy` + /// 1. The event listener is fetched according to the provided [`PollStrategy`] /// 2. All the received events are sent to the current active component /// 3. All the received events are forwarded to the subscribed components which satisfy the received events and conditions. /// 4. Returns messages to process /// - /// As soon as function returns, you should call the `view()` method. + /// As soon as function returns, you should call the [`Application::view`] method. /// - /// > You can also call `view` from the `update()` if you need it + /// > You can also call [`Application::view`] from the [`crate::Update`] if you need it pub fn tick(&mut self, strategy: PollStrategy) -> ApplicationResult> { // Poll event listener let events = self.poll(strategy)?; @@ -354,19 +355,19 @@ where } } -/// Poll strategy defines how to call `poll` on the event listener. +/// Poll strategy defines how to call [`Application::poll`] on the event listener. pub enum PollStrategy { - /// The poll() function will be called once + /// [`Application::poll`] function will be called once Once, /// The application will keep waiting for events for the provided duration TryFor(Duration), - /// The poll() function will be called up to `n` times, until it will return `None`. + /// [`Application::poll`] function will be called up to `n` times, until it will return [`Option::None`]. UpTo(usize), } // -- error -/// Error variants returned by `Application` +/// Error variants returned by [`Application`] #[derive(Debug, Error)] pub enum ApplicationError { #[error("already subscribed")] @@ -1034,9 +1035,10 @@ mod test { } fn listener_config() -> EventListenerCfg { - EventListenerCfg::default().port( + EventListenerCfg::default().add_port( Box::new(MockPoll::::default()), Duration::from_millis(100), + 1, ) } diff --git a/src/core/command.rs b/src/core/command.rs index 4421445..2ebcc83 100644 --- a/src/core/command.rs +++ b/src/core/command.rs @@ -61,9 +61,9 @@ pub enum Position { // -- Command result -/// A command result describes the output of a `Cmd` performed on a Component. +/// A command result describes the output of a [`Cmd`] performed on a Component. /// It reports a "logical" change on the `MockComponent`. -/// The `Component` then, must return a certain user defined `Msg` based on the value of the `CmdResult`. +/// The `Component` then, must return a certain user defined `Msg` based on the value of the [`CmdResult`]. #[derive(Debug, PartialEq, Clone)] #[allow(clippy::large_enum_variant)] pub enum CmdResult { @@ -75,7 +75,7 @@ pub enum CmdResult { /// The command could not be applied. Useful to report errors Invalid(Cmd), /// Custom cmd result - Custom(&'static str), + Custom(&'static str, State), /// An array of Command result Batch(Vec), /// No result to report diff --git a/src/core/component.rs b/src/core/component.rs index bb1f7bf..883ea8c 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -2,13 +2,15 @@ //! //! This module exposes the component traits +use ratatui::Frame; + use crate::command::{Cmd, CmdResult}; -use crate::tui::layout::Rect; -use crate::{AttrValue, Attribute, Event, Frame, State}; +use crate::ratatui::layout::Rect; +use crate::{AttrValue, Attribute, Event, State}; /// A Mock Component represents a component which defines all the properties and states it can handle and represent -/// and the way it should be rendered. It must also define how to behave in case of a `Cmd` (command). -/// Despite that, it won't define how to behave after an `Event` and it won't send any `Msg`. +/// and the way it should be rendered. It must also define how to behave in case of a [`Cmd`] (command). +/// Despite that, it won't define how to behave after an [`Event`] and it won't send any `Msg`. /// The MockComponent is intended to be used as a reusable component to implement your application component. /// /// ### In practice @@ -17,7 +19,7 @@ use crate::{AttrValue, Attribute, Event, Frame, State}; /// The mock component is represented by the `Input`, which will define the properties (e.g. max input length, input type, ...) /// and by its behaviour (e.g. when the user types 'a', 'a' char is added to input state). /// -/// In your application though, you may use a `IpAddressInput` which is the `Component` using the `Input` mock component. +/// In your application though, you may use a `IpAddressInput` which is the [`Component`] using the `Input` mock component. /// If you want more example, just dive into the `examples/` folder in the project root. pub trait MockComponent { /// Based on the current properties and states, renders the component in the provided area frame. @@ -40,12 +42,12 @@ pub trait MockComponent { fn perform(&mut self, cmd: Cmd) -> CmdResult; } -/// The component describes the application level component, which is a wrapper around the `MockComponent`, +/// The component describes the application level component, which is a wrapper around the [`MockComponent`], /// which, in addition to all the methods exposed by the mock, it will handle the event coming from the `View`. /// The Event are passed to the `on` method, which will eventually return a `Msg`, -/// which is defined in your application as an enum. (Don't forget to derive `PartialEq` for your enum). +/// which is defined in your application as an enum. (Don't forget to derive [`PartialEq`] for your enum). /// In your application you should have a Component for each element on your UI, but the logic to implement -/// is very tiny, since the most of the work should already be done into the `MockComponent` +/// is very tiny, since the most of the work should already be done into the [`MockComponent`] /// and many of them are available in the standard library at . /// /// Don't forget you can find an example in the `examples/` directory and you can discover many more information @@ -57,6 +59,6 @@ where { /// Handle input event and update internal states. /// Returns a Msg to the view. - /// If `None` is returned it means there's no message to return for the provided event. + /// If [`None`] is returned it means there's no message to return for the provided event. fn on(&mut self, ev: Event) -> Option; } diff --git a/src/core/event.rs b/src/core/event.rs index 4fab6b4..0d25c3c 100644 --- a/src/core/event.rs +++ b/src/core/event.rs @@ -3,7 +3,7 @@ //! `events` exposes the event raised by a user interaction or by the runtime use bitflags::bitflags; -#[cfg(feature = "serde")] +#[cfg(feature = "serialize")] use serde::{Deserialize, Serialize}; // -- event @@ -16,6 +16,8 @@ where { /// A keyboard event Keyboard(KeyEvent), + /// A Mouse event + Mouse(MouseEvent), /// This event is raised after the terminal window is resized WindowResize(u16, u16), /// Window focus gained @@ -45,6 +47,14 @@ where } } + pub(crate) fn is_mouse(&self) -> Option<&MouseEvent> { + if let Event::Mouse(m) = self { + Some(m) + } else { + None + } + } + pub(crate) fn is_window_resize(&self) -> bool { matches!(self, Self::WindowResize(_, _)) } @@ -142,6 +152,34 @@ pub enum Key { Media(MediaKeyCode), /// Escape key. Esc, + /// Shift left + ShiftLeft, + /// Alt left; warning: it is supported only on termion + AltLeft, + /// warning: it is supported only on termion + CtrlLeft, + /// warning: it is supported only on termion + ShiftRight, + /// warning: it is supported only on termion + AltRight, + /// warning: it is supported only on termion + CtrlRight, + /// warning: it is supported only on termion + ShiftUp, + /// warning: it is supported only on termion + AltUp, + /// warning: it is supported only on termion + CtrlUp, + /// warning: it is supported only on termion + ShiftDown, + /// warning: it is supported only on termion + AltDown, + /// warning: it is supported only on termion + CtrlDown, + /// warning: it is supported only on termion + CtrlHome, + /// warning: it is supported only on termion + CtrlEnd, } /// Defines special key states, such as shift, control, alt... @@ -206,6 +244,66 @@ pub enum MediaKeyCode { MuteVolume, } +/// A keyboard event +#[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] +#[cfg_attr( + feature = "serialize", + derive(Deserialize, Serialize), + serde(tag = "type") +)] +pub struct MouseEvent { + /// The kind of mouse event that was caused + pub kind: MouseEventKind, + /// The key modifiers active when the event occurred + pub modifiers: KeyModifiers, + /// The column that the event occurred on + pub column: u16, + /// The row that the event occurred on + pub row: u16, +} + +/// A Mouse event +#[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] +#[cfg_attr( + feature = "serialize", + derive(Deserialize, Serialize), + serde(tag = "type", content = "args") +)] +pub enum MouseEventKind { + /// Pressed mouse button. Contains the button that was pressed + Down(MouseButton), + /// Released mouse button. Contains the button that was released + Up(MouseButton), + /// Moved the mouse cursor while pressing the contained mouse button + Drag(MouseButton), + /// Moved / Hover changed without pressing any buttons + Moved, + /// Scrolled mouse wheel downwards + ScrollDown, + /// Scrolled mouse wheel upwards + ScrollUp, + /// Scrolled mouse wheel left + ScrollLeft, + /// Scrolled mouse wheel right + ScrollRight, +} + +/// A keyboard event +#[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd, Hash)] +#[cfg_attr( + feature = "serialize", + derive(Deserialize, Serialize), + serde(tag = "type", content = "args") +)] +pub enum MouseButton { + /// Left mouse button. + Left, + /// Right mouse button. + Right, + /// Middle mouse button. + Middle, +} + #[cfg(test)] mod test { @@ -234,7 +332,7 @@ mod test { assert!(e.is_keyboard().is_some()); assert_eq!(e.is_window_resize(), false); assert_eq!(e.is_tick(), false); - assert_eq!(e.is_tick(), false); + assert_eq!(e.is_mouse().is_some(), false); assert!(e.is_user().is_none()); let e: Event = Event::WindowResize(0, 24); assert!(e.is_window_resize()); @@ -243,6 +341,17 @@ mod test { assert!(e.is_tick()); let e: Event = Event::User(MockEvent::Bar); assert_eq!(e.is_user().unwrap(), &MockEvent::Bar); + + let e: Event = Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0, + row: 0, + }); + assert!(e.is_mouse().is_some()); + assert_eq!(e.is_keyboard().is_some(), false); + assert_eq!(e.is_tick(), false); + assert_eq!(e.is_window_resize(), false); } // -- serde diff --git a/src/core/injector.rs b/src/core/injector.rs index f8730db..48c940e 100644 --- a/src/core/injector.rs +++ b/src/core/injector.rs @@ -4,34 +4,11 @@ use std::hash::Hash; -/** - * MIT License - * - * tui-realm - Copyright (C) 2022 Christian Visintin - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ use super::props::{AttrValue, Attribute}; /// An injector is a trait object which can provide properties to inject to a certain component. /// The injector is called each time a component is mounted, providing the id of the mounted -/// component and may return a list of (`Attribute`, `AttrValue`) to inject. +/// component and may return a list of ([`Attribute`], [`AttrValue`]) to inject. pub trait Injector where ComponentId: Eq + PartialEq + Clone + Hash, diff --git a/src/core/props/borders.rs b/src/core/props/borders.rs index a97caf9..eb05791 100644 --- a/src/core/props/borders.rs +++ b/src/core/props/borders.rs @@ -4,7 +4,7 @@ use super::{Color, Style}; // Exports -pub use crate::tui::widgets::{BorderType, Borders as BorderSides}; +pub use crate::ratatui::widgets::{BorderType, Borders as BorderSides}; // -- Border diff --git a/src/core/props/dataset.rs b/src/core/props/dataset.rs index a742f72..b71d1b0 100644 --- a/src/core/props/dataset.rs +++ b/src/core/props/dataset.rs @@ -3,8 +3,8 @@ //! `Dataset` is a wrapper for tui dataset use super::Style; -use crate::tui::symbols::Marker; -use crate::tui::widgets::{Dataset as TuiDataset, GraphType}; +use crate::ratatui::symbols::Marker; +use crate::ratatui::widgets::{Dataset as TuiDataset, GraphType}; /// Dataset describes a set of data for a chart #[derive(Clone, Debug)] @@ -105,7 +105,7 @@ mod test { use pretty_assertions::assert_eq; use super::*; - use crate::tui::style::Color; + use crate::ratatui::style::Color; #[test] fn dataset() { diff --git a/src/core/props/layout.rs b/src/core/props/layout.rs index abe3bf6..8db3ef4 100644 --- a/src/core/props/layout.rs +++ b/src/core/props/layout.rs @@ -2,7 +2,7 @@ //! //! This module exposes the layout type -use crate::tui::layout::{Constraint, Direction, Layout as TuiLayout, Margin, Rect}; +use crate::ratatui::layout::{Constraint, Direction, Layout as TuiLayout, Margin, Rect}; /// Defines how a layout has to be rendered #[derive(Debug, PartialEq, Clone, Eq)] @@ -60,26 +60,13 @@ impl Layout { /// Split an `Area` into chunks using the current layout configuration pub fn chunks(&self, area: Rect) -> Vec { - #[cfg(feature = "tui")] - { - TuiLayout::default() - .direction(self.direction.clone()) - .horizontal_margin(self.margin.horizontal) - .vertical_margin(self.margin.vertical) - .constraints::<&[Constraint]>(self.constraints.as_ref()) - .split(area) - .to_vec() - } - #[cfg(feature = "ratatui")] - { - TuiLayout::default() - .direction(self.direction) - .horizontal_margin(self.margin.horizontal) - .vertical_margin(self.margin.vertical) - .constraints::<&[Constraint]>(self.constraints.as_ref()) - .split(area) - .to_vec() - } + TuiLayout::default() + .direction(self.direction) + .horizontal_margin(self.margin.horizontal) + .vertical_margin(self.margin.vertical) + .constraints::<&[Constraint]>(self.constraints.as_ref()) + .split(area) + .to_vec() } } diff --git a/src/core/props/mod.rs b/src/core/props/mod.rs index 5a38ee2..86029b5 100644 --- a/src/core/props/mod.rs +++ b/src/core/props/mod.rs @@ -24,8 +24,8 @@ pub use shape::Shape; pub use texts::{Table, TableBuilder, TextSpan}; pub use value::{PropPayload, PropValue}; -pub use crate::tui::layout::Alignment; -pub use crate::tui::style::{Color, Modifier as TextModifiers, Style}; +pub use crate::ratatui::layout::Alignment; +pub use crate::ratatui::style::{Color, Modifier as TextModifiers, Style}; /// The props struct holds all the attributes associated to the component. /// Properties have been designed to be versatile for all kind of components, but without introducing diff --git a/src/core/props/shape.rs b/src/core/props/shape.rs index b0667ca..c6ea41a 100644 --- a/src/core/props/shape.rs +++ b/src/core/props/shape.rs @@ -3,7 +3,7 @@ //! This module exposes the shape attribute type use super::Color; -use crate::tui::widgets::canvas::{Line, Map, Rectangle}; +use crate::ratatui::widgets::canvas::{Line, Map, Rectangle}; /// Describes the shape to draw on the canvas #[derive(Clone, Debug)] diff --git a/src/core/props/texts.rs b/src/core/props/texts.rs index b3035f8..d6b0f14 100644 --- a/src/core/props/texts.rs +++ b/src/core/props/texts.rs @@ -3,7 +3,7 @@ //! `Texts` is the module which defines the texts properties for components. //! It also provides some helpers and builders to facilitate the use of builders. -use crate::tui::style::{Color, Modifier}; +use crate::ratatui::style::{Color, Modifier}; // -- Text parts diff --git a/src/core/props/value.rs b/src/core/props/value.rs index 35f5d6e..fc21f78 100644 --- a/src/core/props/value.rs +++ b/src/core/props/value.rs @@ -315,7 +315,7 @@ mod tests { use std::collections::HashMap; use super::*; - use crate::tui::widgets::canvas::Map; + use crate::ratatui::widgets::canvas::Map; #[test] fn prop_values() { diff --git a/src/core/subscription.rs b/src/core/subscription.rs index c43113d..4865129 100644 --- a/src/core/subscription.rs +++ b/src/core/subscription.rs @@ -3,8 +3,9 @@ //! This module defines the model for the Subscriptions use std::hash::Hash; +use std::ops::Range; -use crate::event::KeyEvent; +use crate::event::{KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use crate::{AttrValue, Attribute, Event, State}; /// Public type to define a subscription. @@ -55,7 +56,7 @@ where K: Eq + PartialEq + Clone + Hash, U: Eq + PartialEq + Clone + PartialOrd + Send, { - /// Instantiates a new `Subscription` + /// Instantiates a new [`Subscription`] pub fn new(target: K, sub: Sub) -> Self { Self { target, @@ -91,6 +92,25 @@ where } } +/// A event clause for [`MouseEvent`]s +#[derive(Debug, PartialEq, Eq)] +pub struct MouseEventClause { + /// The kind of mouse event that was caused + pub kind: MouseEventKind, + /// The key modifiers active when the event occurred + pub modifiers: KeyModifiers, + /// The column that the event occurred on + pub column: Range, + /// The row that the event occurred on + pub row: Range, +} + +impl MouseEventClause { + fn is_in_range(&self, ev: &MouseEvent) -> bool { + self.column.contains(&ev.column) && self.row.contains(&ev.row) + } +} + #[derive(Debug, PartialEq, Eq)] /// An event clause indicates on which kind of event the event must be forwarded to the `target` component. @@ -102,12 +122,14 @@ where Any, /// Check whether a certain key has been pressed Keyboard(KeyEvent), + /// Check whether a certain key has been pressed + Mouse(MouseEventClause), /// Check whether window has been resized WindowResize, /// The event will be forwarded on a tick Tick, /// Event will be forwarded on this specific user event. - /// The way user event is matched, depends on its partialEq implementation + /// The way user event is matched, depends on its [`PartialEq`] implementation User(UserEvent), } @@ -121,14 +143,16 @@ where /// /// - Any: Forward, no matter what kind of event /// - Keyboard: everything must match + /// - Mouse: everything must match, column and row need to be within range /// - WindowResize: matches only event type, not sizes /// - Tick: matches tick event /// - None: matches None event - /// - UserEvent: depends on UserEvent PartialEq + /// - UserEvent: depends on UserEvent [`PartialEq`] fn forward(&self, ev: &Event) -> bool { match self { EventClause::Any => true, EventClause::Keyboard(k) => Some(k) == ev.is_keyboard(), + EventClause::Mouse(m) => ev.is_mouse().map(|ev| m.is_in_range(ev)).unwrap_or(false), EventClause::WindowResize => ev.is_window_resize(), EventClause::Tick => ev.is_tick(), EventClause::User(u) => Some(u) == ev.is_user(), @@ -139,9 +163,9 @@ where /// A subclause indicates the condition that must be satisfied in order to forward `ev` to `target`. /// Usually clauses are single conditions, but there are also some special condition, to create "ligatures", which are: /// -/// - `Not(SubClause)`: Negates inner condition -/// - `And(SubClause, SubClause)`: the AND of the two clauses must be `true` -/// - `Or(SubClause, SubClause)`: the OR of the two clauses must be `true` +/// - [`SubClause::Not`]: Negates inner condition +/// - [`SubClause::And`]: the AND of the two clauses must be `true` +/// - [`SubClause::Or`]: the OR of the two clauses must be `true` #[derive(Debug, PartialEq)] #[allow(clippy::large_enum_variant)] pub enum SubClause @@ -169,18 +193,18 @@ impl SubClause where Id: Eq + PartialEq + Clone + Hash, { - /// Shortcut for `SubClause::Not` without specifying `Box::new(...)` + /// Shortcut for [`SubClause::Not`] without specifying `Box::new(...)` #[allow(clippy::should_implement_trait)] pub fn not(clause: Self) -> Self { Self::Not(Box::new(clause)) } - /// Shortcut for `SubClause::And` without specifying `Box::new(...)` + /// Shortcut for [`SubClause::And`] without specifying `Box::new(...)` pub fn and(a: Self, b: Self) -> Self { Self::And(Box::new(a), Box::new(b)) } - /// Shortcut for `SubClause::Or` without specifying `Box::new(...)` + /// Shortcut for [`SubClause::Or`] without specifying `Box::new(...)` pub fn or(a: Self, b: Self) -> Self { Self::Or(Box::new(a), Box::new(b)) } @@ -296,7 +320,7 @@ mod test { use super::*; use crate::command::Cmd; - use crate::event::Key; + use crate::event::{Key, KeyModifiers, MouseEventKind}; use crate::mock::{MockComponentId, MockEvent, MockFooInput}; use crate::{MockComponent, StateValue}; @@ -389,6 +413,71 @@ mod test { EventClause::::Keyboard(KeyEvent::from(Key::Enter)).forward(&Event::Tick), false ); + assert_eq!( + EventClause::::Keyboard(KeyEvent::from(Key::Enter)).forward(&Event::Mouse( + MouseEvent { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0, + row: 0 + } + )), + false + ); + } + + #[test] + fn event_clause_mouse_should_forward() { + assert_eq!( + EventClause::::Mouse(MouseEventClause { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0..10, + row: 0..10 + }) + .forward(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0, + row: 0 + })), + true + ); + assert_eq!( + EventClause::::Mouse(MouseEventClause { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0..10, + row: 0..10 + }) + .forward(&Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 20, + row: 20 + })), + false + ); + assert_eq!( + EventClause::::Mouse(MouseEventClause { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0..10, + row: 0..10 + }) + .forward(&Event::Keyboard(KeyEvent::from(Key::Backspace))), + false + ); + assert_eq!( + EventClause::::Mouse(MouseEventClause { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0..10, + row: 0..10 + }) + .forward(&Event::Tick), + false + ); } #[test] diff --git a/src/core/view.rs b/src/core/view.rs index f925b12..d499809 100644 --- a/src/core/view.rs +++ b/src/core/view.rs @@ -6,10 +6,11 @@ use std::collections::HashMap; use std::hash::Hash; +use ratatui::Frame; use thiserror::Error; -use crate::tui::layout::Rect; -use crate::{AttrValue, Attribute, Component, Event, Frame, Injector, State}; +use crate::ratatui::layout::Rect; +use crate::{AttrValue, Attribute, Component, Event, Injector, State}; /// A boxed component. Shorthand for View components map pub(crate) type WrappedComponent = Box>; diff --git a/src/lib.rs b/src/lib.rs index add4379..a1342d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,11 @@ //! # tui-realm //! -//! tui-realm is a **framework** for [tui](https://github.com/fdehau/tui-rs) to simplify the implementation of terminal -//! user interfaces adding the possibility to work with re-usable components with properties and states, -//! as you'd do in React. -//! But that's not all: the components communicate with the ui engine via a system based on **Messages** and **Events**, +//! tui-realm is a **framework** for **[ratatui](https://github.com/ratatui-org/ratatui)** +//! to simplify the implementation of terminal user interfaces adding the possibility to work +//! with re-usable components with properties and states, as you'd do in React. But that's not all: +//! the components communicate with the ui engine via a system based on **Messages** and **Events**, //! providing you with the possibility to implement `update` routines as happens in Elm. +//! //! In addition, the components are organized inside the **View**, which manages mounting/umounting, //! focus and event forwarding for you. //! @@ -20,32 +21,31 @@ //! If you want the default features, just add tuirealm 1.x version: //! //! ```toml -//! tuirealm = "^1.9.0" +//! tuirealm = "2" //! ``` //! //! otherwise you can specify the features you want to add: //! //! ```toml -//! tuirealm = { version = "^1.9.0", default-features = false, features = [ "derive", "with-termion" ] } +//! tuirealm = { version = "2", default-features = false, features = [ "derive", "serialize", "termion" ] } //! ``` //! //! Supported features are: //! //! - `derive` (*default*): add the `#[derive(MockComponent)]` proc macro to automatically implement `MockComponent` for `Component`. [Read more](https://github.com/veeso/tuirealm_derive). -//! - `with-crossterm` (*default*): use [crossterm](https://github.com/crossterm-rs/crossterm) as backend for tui. -//! - `with-termion` (*default*): use [termion](https://github.com/redox-os/termion) as backend for tui. -//! -//! > ⚠️ You can enable only one backend at the time and at least one must be enabled in order to build. -//! > ❗ You don't need tui as a dependency, since you can access to tui types via `use tuirealm::tui::` +//! - `serialize`: add the serialize/deserialize trait implementation for `KeyEvent` and `Key`. +//! - `crossterm`: use the [crossterm](https://github.com/crossterm-rs/crossterm) terminal backend +//! - `termion`: use the [termion](https://github.com/redox-os/termion) terminal backend //! //! ### Create a tui-realm application 🪂 //! //! You can read the guide to get started with tui-realm on [Github](https://github.com/veeso/tui-realm/blob/main/docs/en/get-started.md) //! //! ### Run examples 🔍 -//!Still confused about how tui-realm works? Don't worry, try with the examples: //! -//!- [demo](https://github.com/veeso/tui-realm/blob/main/examples/demo.rs): a simple application which shows how tui-realm works +//! Still confused about how tui-realm works? Don't worry, try with the examples: +//! +//! - [demo](https://github.com/veeso/tui-realm/blob/main/examples/demo.rs): a simple application which shows how tui-realm works //! //! ```sh //! cargo run --example demo @@ -68,17 +68,14 @@ extern crate self as tuirealm; #[macro_use] extern crate tuirealm_derive; -// -- modules -pub mod adapter; mod core; pub mod listener; +pub mod macros; #[cfg(test)] pub mod mock; +pub mod ratatui; pub mod terminal; -pub mod tui; pub mod utils; -// -- export -pub use adapter::{Frame, Terminal}; pub use listener::{EventListenerCfg, ListenerError}; // -- derive #[cfg(feature = "derive")] @@ -91,3 +88,4 @@ pub use self::core::injector::Injector; pub use self::core::props::{self, AttrValue, Attribute, Props}; pub use self::core::subscription::{EventClause as SubEventClause, Sub, SubClause}; pub use self::core::{command, Component, MockComponent, State, StateValue, Update, ViewError}; +pub use self::ratatui::Frame; diff --git a/src/listener/builder.rs b/src/listener/builder.rs index e175421..c84d748 100644 --- a/src/listener/builder.rs +++ b/src/listener/builder.rs @@ -2,10 +2,10 @@ //! //! This module exposes the EventListenerCfg which is used to build the event listener -use super::{Duration, EventListener, InputEventListener, Poll, Port}; +use super::{Duration, EventListener, Poll, Port}; /// The event listener configurator is used to setup an event listener. -/// Once you're done with configuration just call `start()` and the event listener will start and the listener +/// Once you're done with configuration just call [`EventListenerCfg::start`] and the event listener will start and the listener /// will be returned. pub struct EventListenerCfg where @@ -59,15 +59,46 @@ where self } - /// Add a new Port (Poll, Interval) to the the event listener - pub fn port(mut self, poll: Box>, interval: Duration) -> Self { - self.ports.push(Port::new(poll, interval)); + /// Add a new [`Port`] (Poll, Interval) to the the event listener. + /// + /// The interval is the amount of time between each [`Poll::poll`] call. + /// The max_poll is the maximum amount of times the port should be polled in a single poll. + pub fn add_port(self, poll: Box>, interval: Duration, max_poll: usize) -> Self { + self.port(Port::new(poll, interval, max_poll)) + } + + /// Add a new [`Port`] to the the event listener + /// + /// The [`Port`] needs to be manually constructed, unlike [`Self::add_port`] + pub fn port(mut self, port: Port) -> Self { + self.ports.push(port); self } - /// Add to the event listener the default input event listener for the backend configured. - pub fn default_input_listener(self, interval: Duration) -> Self { - self.port(Box::new(InputEventListener::::new(interval)), interval) + #[cfg(feature = "crossterm")] + /// Add to the event listener the default crossterm input listener [`crate::terminal::CrosstermInputListener`] + /// + /// The interval is the amount of time between each [`Poll::poll`] call. + /// The max_poll is the maximum amount of times the port should be polled in a single poll. + pub fn crossterm_input_listener(self, interval: Duration, max_poll: usize) -> Self { + self.add_port( + Box::new(crate::terminal::CrosstermInputListener::::new(interval)), + interval, + max_poll, + ) + } + + #[cfg(feature = "termion")] + /// Add to the event listener the default termion input listener [`crate::terminal::TermionInputListener`] + /// + /// The interval is the amount of time between each [`Poll::poll`] call. + /// The max_poll is the maximum amount of times the port should be polled in a single poll. + pub fn termion_input_listener(self, interval: Duration, max_poll: usize) -> Self { + self.add_port( + Box::new(crate::terminal::TermionInputListener::::new(interval)), + interval, + max_poll, + ) } } @@ -80,7 +111,27 @@ mod test { use crate::mock::{MockEvent, MockPoll}; #[test] - fn should_configure_and_start_event_listener() { + #[cfg(feature = "crossterm")] + fn should_configure_and_start_event_listener_crossterm() { + let builder = EventListenerCfg::::default(); + assert!(builder.ports.is_empty()); + assert!(builder.tick_interval.is_none()); + assert_eq!(builder.poll_timeout, Duration::from_millis(10)); + let builder = builder.tick_interval(Duration::from_secs(10)); + assert_eq!(builder.tick_interval.unwrap(), Duration::from_secs(10)); + let builder = builder.poll_timeout(Duration::from_millis(50)); + assert_eq!(builder.poll_timeout, Duration::from_millis(50)); + let builder = builder + .crossterm_input_listener(Duration::from_millis(200), 1) + .add_port(Box::new(MockPoll::default()), Duration::from_secs(300), 1); + assert_eq!(builder.ports.len(), 2); + let mut listener = builder.start(); + assert!(listener.stop().is_ok()); + } + + #[test] + #[cfg(feature = "termion")] + fn should_configure_and_start_event_listener_termion() { let builder = EventListenerCfg::::default(); assert!(builder.ports.is_empty()); assert!(builder.tick_interval.is_none()); @@ -90,8 +141,8 @@ mod test { let builder = builder.poll_timeout(Duration::from_millis(50)); assert_eq!(builder.poll_timeout, Duration::from_millis(50)); let builder = builder - .default_input_listener(Duration::from_millis(200)) - .port(Box::new(MockPoll::default()), Duration::from_secs(300)); + .termion_input_listener(Duration::from_millis(200), 1) + .add_port(Box::new(MockPoll::default()), Duration::from_secs(300), 1); assert_eq!(builder.ports.len(), 2); let mut listener = builder.start(); assert!(listener.stop().is_ok()); @@ -104,4 +155,16 @@ mod test { .poll_timeout(Duration::from_secs(0)) .start(); } + + #[test] + fn should_add_port_via_port_1() { + let builder = EventListenerCfg::::default(); + assert!(builder.ports.is_empty()); + let builder = builder.port(Port::new( + Box::new(MockPoll::default()), + Duration::from_millis(1), + 1, + )); + assert_eq!(builder.ports.len(), 1); + } } diff --git a/src/listener/mod.rs b/src/listener/mod.rs index 2433d37..bfa9fda 100644 --- a/src/listener/mod.rs +++ b/src/listener/mod.rs @@ -8,8 +8,9 @@ mod builder; mod port; mod worker; +use std::sync::atomic::AtomicBool; // -- export -use std::sync::{mpsc, Arc, RwLock}; +use std::sync::{mpsc, Arc}; use std::thread::{self, JoinHandle}; use std::time::Duration; @@ -20,10 +21,9 @@ use worker::EventListenerWorker; // -- internal use super::Event; -pub use crate::adapter::InputEventListener; -/// Result returned by `EventListener`. Ok value depends on the method, while the -/// Err value is always `ListenerError`. +/// Result returned by [`EventListener`]. Ok value depends on the method, while the +/// Err value is always [`ListenerError`]. pub type ListenerResult = Result; #[derive(Debug, Error)] @@ -38,7 +38,7 @@ pub enum ListenerError { PollFailed, } -/// The poll trait defines the function `poll`, which will be called by the event listener +/// The poll trait defines the function [`Poll::poll`], which will be called by the event listener /// dedicated thread to poll for events. pub trait Poll: Send where @@ -61,9 +61,9 @@ where /// Max Time to wait when calling `recv()` on thread receiver poll_timeout: Duration, /// Indicates whether the worker should paused polling ports - paused: Arc>, + paused: Arc, /// Indicates whether the worker should keep running - running: Arc>, + running: Arc, /// Msg receiver from worker recv: mpsc::Receiver>, /// Join handle for worker @@ -74,7 +74,7 @@ impl EventListener where U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, { - /// Create a new `EventListener` and start it. + /// Create a new [`EventListener`] and start it. /// - `poll` is the trait object which polls for input events /// - `poll_interval` is the interval to poll for input events. It should always be at least a poll time used by `poll` /// - `tick_interval` is the interval used to send the `Tick` event. If `None`, no tick will be sent. @@ -105,14 +105,9 @@ where /// Stop event listener pub fn stop(&mut self) -> ListenerResult<()> { - { - // NOTE: keep these brackets to drop running after block - let mut running = match self.running.write() { - Ok(lock) => Ok(lock), - Err(_) => Err(ListenerError::CouldNotStop), - }?; - *running = false; - } + self.running + .store(false, std::sync::atomic::Ordering::Relaxed); + // Join thread match self.thread.take().map(|x| x.join()) { Some(Ok(_)) => Ok(()), @@ -123,23 +118,15 @@ where /// Pause event listener worker pub fn pause(&mut self) -> ListenerResult<()> { - // NOTE: keep these brackets to drop running after block - let mut paused = match self.paused.write() { - Ok(lock) => Ok(lock), - Err(_) => Err(ListenerError::CouldNotStop), - }?; - *paused = true; + self.paused + .store(true, std::sync::atomic::Ordering::Relaxed); Ok(()) } /// Unpause event listener worker pub fn unpause(&mut self) -> ListenerResult<()> { - // NOTE: keep these brackets to drop running after block - let mut paused = match self.paused.write() { - Ok(lock) => Ok(lock), - Err(_) => Err(ListenerError::CouldNotStop), - }?; - *paused = false; + self.paused + .store(false, std::sync::atomic::Ordering::Relaxed); Ok(()) } @@ -155,9 +142,9 @@ where /// Setup the thread and returns the structs necessary to interact with it fn setup_thread(ports: Vec>, tick_interval: Option) -> ThreadConfig { let (sender, recv) = mpsc::channel(); - let paused = Arc::new(RwLock::new(false)); + let paused = Arc::new(AtomicBool::new(false)); let paused_t = Arc::clone(&paused); - let running = Arc::new(RwLock::new(true)); + let running = Arc::new(AtomicBool::new(true)); let running_t = Arc::clone(&running); // Start thread let thread = thread::spawn(move || { @@ -184,8 +171,8 @@ where U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, { rx: mpsc::Receiver>, - paused: Arc>, - running: Arc>, + paused: Arc, + running: Arc, thread: JoinHandle<()>, } @@ -195,8 +182,8 @@ where { pub fn new( rx: mpsc::Receiver>, - paused: Arc>, - running: Arc>, + paused: Arc, + running: Arc, thread: JoinHandle<()>, ) -> Self { Self { @@ -248,6 +235,7 @@ mod test { vec![Port::new( Box::new(MockPoll::default()), Duration::from_secs(10), + 1, )], Duration::from_millis(10), Some(Duration::from_secs(3)), diff --git a/src/listener/port.rs b/src/listener/port.rs index ec22844..326eb3b 100644 --- a/src/listener/port.rs +++ b/src/listener/port.rs @@ -8,7 +8,7 @@ use std::time::{Duration, Instant}; use super::{Event, ListenerResult, Poll}; /// A port is a wrapper around the poll trait object, which also defines an interval, which defines -/// the amount of time between each poll() call. +/// the amount of time between each [`Poll::poll`] call. /// Its purpose is to listen for incoming events of a user-defined type pub struct Port where @@ -17,22 +17,35 @@ where poll: Box>, interval: Duration, next_poll: Instant, + max_poll: usize, } impl Port where U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, { - /// Define a new `Port` - pub fn new(poll: Box>, interval: Duration) -> Self { + /// Define a new [`Port`] + /// + /// # Parameters + /// + /// * `poll` - The poll trait object + /// * `interval` - The interval between each poll + /// * `max_poll` - The maximum amount of times the port should be polled in a single poll + pub fn new(poll: Box>, interval: Duration, max_poll: usize) -> Self { Self { poll, interval, next_poll: Instant::now(), + max_poll, } } - /// Returns the interval for the current `Port` + /// Get how often a port should get polled in a single poll + pub fn max_poll(&self) -> usize { + self.max_poll + } + + /// Returns the interval for the current [`Port`] pub fn interval(&self) -> &Duration { &self.interval } @@ -47,7 +60,7 @@ where self.next_poll <= Instant::now() } - /// Calls poll on the inner `Poll` trait object. + /// Calls [`Poll::poll`] on the inner [`Poll`] trait object. pub fn poll(&mut self) -> ListenerResult>> { self.poll.poll() } @@ -69,7 +82,7 @@ mod test { #[test] fn test_single_listener() { let mut listener = - Port::::new(Box::new(MockPoll::default()), Duration::from_secs(5)); + Port::::new(Box::new(MockPoll::default()), Duration::from_secs(5), 1); assert!(listener.next_poll() <= Instant::now()); assert_eq!(listener.should_poll(), true); assert!(listener.poll().ok().unwrap().is_some()); diff --git a/src/listener/worker.rs b/src/listener/worker.rs index 165f76c..11187db 100644 --- a/src/listener/worker.rs +++ b/src/listener/worker.rs @@ -3,7 +3,8 @@ //! This module implements the worker thread for the event listener use std::ops::{Add, Sub}; -use std::sync::{mpsc, Arc, RwLock}; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; use std::thread; use std::time::{Duration, Instant}; @@ -18,8 +19,8 @@ where { ports: Vec>, sender: mpsc::Sender>, - paused: Arc>, - running: Arc>, + paused: Arc, + running: Arc, next_tick: Instant, tick_interval: Option, } @@ -31,8 +32,8 @@ where pub(super) fn new( ports: Vec>, sender: mpsc::Sender>, - paused: Arc>, - running: Arc>, + paused: Arc, + running: Arc, tick_interval: Option, ) -> Self { Self { @@ -77,18 +78,12 @@ where /// Returns whether should keep running fn running(&self) -> bool { - if let Ok(lock) = self.running.read() { - return *lock; - } - true + self.running.load(std::sync::atomic::Ordering::Relaxed) } /// Returns whether worker is paused fn paused(&self) -> bool { - if let Ok(lock) = self.paused.read() { - return *lock; - } - false + self.paused.load(std::sync::atomic::Ordering::Relaxed) } /// Returns whether it's time to tick. @@ -118,35 +113,32 @@ where /// Returns only the messages, while the None returned by poll are discarded #[allow(clippy::needless_collect)] fn poll(&mut self) -> Result<(), mpsc::SendError>> { - let msg: Vec> = self - .ports - .iter_mut() - .filter_map(|x| { - if x.should_poll() { - let msg = match x.poll() { - Ok(Some(ev)) => Some(ListenerMsg::User(ev)), - Ok(None) => None, - Err(err) => Some(ListenerMsg::Error(err)), - }; - // Update next poll - x.calc_next_poll(); - msg - } else { - None + let port_iter = self.ports.iter_mut().filter(|port| port.should_poll()); + + for port in port_iter { + let mut times_remaining = port.max_poll(); + // poll a port until it has nothing anymore + loop { + let msg = match port.poll() { + Ok(Some(ev)) => ListenerMsg::User(ev), + Ok(None) => break, + Err(err) => ListenerMsg::Error(err), + }; + + self.sender.send(msg)?; + + // do this at the end to at least call it once + times_remaining = times_remaining.saturating_sub(1); + + if times_remaining == 0 { + break; } - }) - .collect(); - // Send messages - match msg - .into_iter() - .map(|x| self.sender.send(x)) - .filter(|x| x.is_err()) - .map(|x| x.err().unwrap()) - .next() - { - None => Ok(()), - Some(e) => Err(e), + } + // Update next poll + port.calc_next_poll(); } + + Ok(()) } /// thread run method @@ -180,23 +172,47 @@ mod test { use pretty_assertions::assert_eq; - use super::super::{ListenerError, ListenerResult}; + use super::super::ListenerResult; use super::*; use crate::core::event::{Key, KeyEvent}; use crate::mock::{MockEvent, MockPoll}; use crate::Event; + #[test] + fn worker_should_poll_multiple_times() { + let (tx, rx) = mpsc::channel(); + let paused = Arc::new(AtomicBool::new(false)); + let paused_t = Arc::clone(&paused); + let running = Arc::new(AtomicBool::new(true)); + let running_t = Arc::clone(&running); + + let mock_port = Port::new(Box::new(MockPoll::default()), Duration::from_secs(5), 10); + + let mut worker = + EventListenerWorker::::new(vec![mock_port], tx, paused_t, running_t, None); + assert!(worker.poll().is_ok()); + assert!(worker.next_event() <= Duration::from_secs(5)); + let mut recieved = Vec::new(); + + while let Ok(msg) = rx.try_recv() { + recieved.push(msg); + } + + assert_eq!(recieved.len(), 10); + } + #[test] fn worker_should_send_poll() { let (tx, rx) = mpsc::channel(); - let paused = Arc::new(RwLock::new(false)); + let paused = Arc::new(AtomicBool::new(false)); let paused_t = Arc::clone(&paused); - let running = Arc::new(RwLock::new(true)); + let running = Arc::new(AtomicBool::new(true)); let running_t = Arc::clone(&running); let mut worker = EventListenerWorker::::new( vec![Port::new( Box::new(MockPoll::default()), Duration::from_secs(5), + 1, )], tx, paused_t, @@ -215,14 +231,15 @@ mod test { #[test] fn worker_should_send_tick() { let (tx, rx) = mpsc::channel(); - let paused = Arc::new(RwLock::new(false)); + let paused = Arc::new(AtomicBool::new(false)); let paused_t = Arc::clone(&paused); - let running = Arc::new(RwLock::new(true)); + let running = Arc::new(AtomicBool::new(true)); let running_t = Arc::clone(&running); let mut worker = EventListenerWorker::::new( vec![Port::new( Box::new(MockPoll::default()), Duration::from_secs(5), + 1, )], tx, paused_t, @@ -241,14 +258,15 @@ mod test { #[test] fn worker_should_calc_times_correctly_with_tick() { let (tx, rx) = mpsc::channel(); - let paused = Arc::new(RwLock::new(false)); + let paused = Arc::new(AtomicBool::new(false)); let paused_t = Arc::clone(&paused); - let running = Arc::new(RwLock::new(true)); + let running = Arc::new(AtomicBool::new(true)); let running_t = Arc::clone(&running); let mut worker = EventListenerWorker::::new( vec![Port::new( Box::new(MockPoll::default()), Duration::from_secs(5), + 1, )], tx, paused_t, @@ -269,15 +287,7 @@ mod test { // Now should no more tick and poll assert_eq!(worker.should_tick(), false); // Stop - { - let mut running_flag = match running.write() { - Ok(lock) => Ok(lock), - Err(_) => Err(ListenerError::CouldNotStop), - } - .ok() - .unwrap(); - *running_flag = false; - } + running.store(false, std::sync::atomic::Ordering::Relaxed); assert_eq!(worker.running(), false); drop(rx); } @@ -285,14 +295,15 @@ mod test { #[test] fn worker_should_calc_times_correctly_without_tick() { let (tx, rx) = mpsc::channel(); - let paused = Arc::new(RwLock::new(false)); + let paused = Arc::new(AtomicBool::new(false)); let paused_t = Arc::clone(&paused); - let running = Arc::new(RwLock::new(true)); + let running = Arc::new(AtomicBool::new(true)); let running_t = Arc::clone(&running); let worker = EventListenerWorker::::new( vec![Port::new( Box::new(MockPoll::default()), Duration::from_secs(3), + 1, )], tx, paused_t, @@ -308,15 +319,8 @@ mod test { // Next event should be in 3 second (poll) assert!(worker.next_event() <= Duration::from_secs(3)); // Stop - { - let mut running_flag = match running.write() { - Ok(lock) => Ok(lock), - Err(_) => Err(ListenerError::CouldNotStop), - } - .ok() - .unwrap(); - *running_flag = false; - } + running.store(false, std::sync::atomic::Ordering::Relaxed); + assert_eq!(worker.running(), false); drop(rx); } @@ -325,9 +329,9 @@ mod test { #[should_panic] fn worker_should_panic_when_trying_next_tick_without_it() { let (tx, _) = mpsc::channel(); - let paused = Arc::new(RwLock::new(false)); + let paused = Arc::new(AtomicBool::new(false)); let paused_t = Arc::clone(&paused); - let running = Arc::new(RwLock::new(true)); + let running = Arc::new(AtomicBool::new(true)); let running_t = Arc::clone(&running); let mut worker = EventListenerWorker::::new(vec![], tx, paused_t, running_t, None); diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..82dd756 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,91 @@ +/// A macro to generate a chain of [`crate::SubClause::And`] from a list of +/// Ids with the case [`crate::SubClause::IsMounted`] for every id. +/// +/// ### example +/// +/// ```rust +/// use tuirealm::{SubClause, subclause_and}; +/// +/// #[derive(Debug, Eq, PartialEq, Clone, Hash)] +/// pub enum Id { +/// InputBar, +/// InputFoo, +/// InputOmar, +/// } +/// +/// let sub_clause = subclause_and!( +/// Id::InputBar, +/// Id::InputFoo, +/// Id::InputOmar +/// ); +/// +/// assert_eq!( +/// sub_clause, +/// SubClause::And( +/// Box::new(SubClause::IsMounted(Id::InputBar)), +/// Box::new(SubClause::And( +/// Box::new(SubClause::IsMounted(Id::InputFoo)), +/// Box::new(SubClause::IsMounted(Id::InputOmar)) +/// )) +/// ) +/// ); +/// ``` +/// +#[macro_export] +macro_rules! subclause_and { + ($id:expr) => { + SubClause::IsMounted($id) + }; + ($id:expr, $($rest:expr),+) => { + SubClause::and( + SubClause::IsMounted($id), + subclause_and!($($rest),+) + ) + }; +} + +/// A macro to generate a chain of [`crate::SubClause::Or`] from a list of +/// Ids with the case [`crate::SubClause::IsMounted`] for every id. +/// +/// ### example +/// +/// ```rust +/// use tuirealm::{SubClause, subclause_or}; +/// +/// #[derive(Debug, Eq, PartialEq, Clone, Hash)] +/// pub enum Id { +/// InputBar, +/// InputFoo, +/// InputOmar, +/// } +/// +/// let sub_clause = subclause_or!( +/// Id::InputBar, +/// Id::InputFoo, +/// Id::InputOmar +/// ); +/// +/// assert_eq!( +/// sub_clause, +/// SubClause::or( +/// SubClause::IsMounted(Id::InputBar), +/// SubClause::or( +/// SubClause::IsMounted(Id::InputFoo), +/// SubClause::IsMounted(Id::InputOmar) +/// ) +/// ) +/// ); +/// ``` +/// +#[macro_export] +macro_rules! subclause_or { + ($id:expr) => { + SubClause::IsMounted($id) + }; + ($id:expr, $($rest:expr),+) => { + SubClause::or( + SubClause::IsMounted($id), + subclause_or!($($rest),+) + ) + }; +} diff --git a/src/mock/components.rs b/src/mock/components.rs index 58543eb..6c23061 100644 --- a/src/mock/components.rs +++ b/src/mock/components.rs @@ -2,6 +2,8 @@ //! //! mock components +use ratatui::Frame; + use super::{MockEvent, MockMsg}; use crate::command::{Cmd, CmdResult, Direction}; use crate::event::{Event, Key, KeyEvent, KeyModifiers}; @@ -23,7 +25,7 @@ impl Default for MockInput { } impl MockComponent for MockInput { - fn view(&mut self, _: &mut crate::Frame, _: crate::tui::layout::Rect) {} + fn view(&mut self, _: &mut Frame, _: crate::ratatui::layout::Rect) {} fn query(&self, attr: Attribute) -> Option { self.props.get(attr) diff --git a/src/ratatui.rs b/src/ratatui.rs new file mode 100644 index 0000000..1cd250e --- /dev/null +++ b/src/ratatui.rs @@ -0,0 +1,5 @@ +//! ## ratatui +//! +//! `ratatui` just exposes the ratatui modules, in order to include the entire library inside realm + +pub use ratatui::*; diff --git a/src/terminal.rs b/src/terminal.rs index 637e83c..4b9ef28 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -2,15 +2,33 @@ //! //! Cross platform Terminal helper +mod adapter; +mod event_listener; + +use ratatui::{CompletedFrame, Frame}; use thiserror::Error; -use crate::Terminal; +#[cfg(feature = "crossterm")] +#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))] +pub use self::adapter::CrosstermTerminalAdapter; +pub use self::adapter::TerminalAdapter; +#[cfg(feature = "termion")] +#[cfg_attr(docsrs, doc(cfg(feature = "termion")))] +pub use self::adapter::TermionTerminalAdapter; +#[cfg(feature = "crossterm")] +#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))] +pub use self::event_listener::CrosstermInputListener; +#[cfg(feature = "termion")] +#[cfg_attr(docsrs, doc(cfg(feature = "termion")))] +pub use self::event_listener::TermionInputListener; -// -- types +/// TerminalResult is a type alias for a Result that uses [`TerminalError`] as the error type. pub type TerminalResult = Result; #[derive(Debug, Error)] pub enum TerminalError { + #[error("cannot draw frame")] + CannotDrawFrame, #[error("cannot connect to stdout")] CannotConnectStdout, #[error("cannot enter alternate mode")] @@ -23,56 +41,204 @@ pub enum TerminalError { CannotClear, #[error("backend doesn't support this command")] Unsupported, + #[error("cannot activate / deactivate mouse capture")] + CannotToggleMouseCapture, } -/// An helper around `Terminal` to quickly setup and perform on terminal. +/// An helper around [`Terminal`] to quickly setup and perform on terminal. /// You can opt whether to use or not this structure to interact with the terminal /// Anyway this structure is 100% cross-backend compatible and is really easy to use, so I suggest you to use it. /// If you need more advance terminal command, you can get a reference to it using the `raw()` and `raw_mut()` methods. -pub struct TerminalBridge { - terminal: Terminal, +/// +/// To quickly setup a terminal with default settings, you can use the [`TerminalBridge::init()`] method. +/// +/// ```rust +/// use tuirealm::terminal::TerminalBridge; +/// +/// #[cfg(feature = "crossterm")] +/// let mut terminal = TerminalBridge::init_crossterm().unwrap(); +/// #[cfg(feature = "termion")] +/// let mut terminal = TerminalBridge::init_termion().unwrap(); +/// ``` +pub struct TerminalBridge +where + T: TerminalAdapter, +{ + terminal: T, } -impl TerminalBridge { - /// Instantiates a new Terminal bridge - pub fn new() -> TerminalResult { - Ok(Self { - terminal: Self::adapt_new_terminal()?, - }) +impl TerminalBridge +where + T: TerminalAdapter, +{ + /// Instantiates a new Terminal bridge from a [`TerminalAdapter`] + pub fn new(terminal: T) -> Self { + Self { terminal } + } + + /// Initialize a terminal with reasonable defaults for most applications. + /// + /// This will create a new [`TerminalBridge`] and initialize it with the following defaults: + /// + /// - Raw mode is enabled + /// - Alternate screen buffer enabled + /// - A panic hook is installed that restores the terminal before panicking. Ensure that this method + /// is called after any other panic hooks that may be installed to ensure that the terminal is + /// restored before those hooks are called. + /// + /// For more control over the terminal initialization, use [`TerminalBridge::new`]. + pub fn init(terminal: T) -> TerminalResult { + let mut terminal = Self::new(terminal); + terminal.enable_raw_mode()?; + terminal.enter_alternate_screen()?; + Self::set_panic_hook(); + + Ok(terminal) + } + + /// Restore the terminal to its original state. + /// + /// This function will attempt to restore the terminal to its original state by leaving the alternate screen + /// and disabling raw mode. If either of these operations fail, the error will be returned. + pub fn restore(&mut self) -> TerminalResult<()> { + self.leave_alternate_screen()?; + self.disable_raw_mode() + } + + /// Sets a panic hook that restores the terminal before panicking. + /// + /// Replaces the panic hook with a one that will restore the terminal state before calling the + /// original panic hook. This ensures that the terminal is left in a good state when a panic occurs. + pub fn set_panic_hook() { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + #[cfg(feature = "crossterm")] + { + let _ = crossterm::terminal::disable_raw_mode(); + let _ = crossterm::execute!( + std::io::stdout(), + crossterm::terminal::LeaveAlternateScreen + ); + } + + hook(info); + })); } /// Enter in alternate screen using the terminal adapter pub fn enter_alternate_screen(&mut self) -> TerminalResult<()> { - self.adapt_enter_alternate_screen() + self.terminal.enter_alternate_screen() } /// Leave the alternate screen using the terminal adapter pub fn leave_alternate_screen(&mut self) -> TerminalResult<()> { - self.adapt_leave_alternate_screen() + self.terminal.leave_alternate_screen() } /// Clear the screen pub fn clear_screen(&mut self) -> TerminalResult<()> { - self.adapt_clear_screen() + self.terminal.clear_screen() } /// Enable terminal raw mode pub fn enable_raw_mode(&mut self) -> TerminalResult<()> { - self.adapt_enable_raw_mode() + self.terminal.enable_raw_mode() } /// Disable terminal raw mode pub fn disable_raw_mode(&mut self) -> TerminalResult<()> { - self.adapt_disable_raw_mode() + self.terminal.disable_raw_mode() + } + + /// Enable mouse-event capture, if the backend supports it + pub fn enable_mouse_capture(&mut self) -> TerminalResult<()> { + self.terminal.enable_mouse_capture() + } + + /// Disable mouse-event capture, if the backend supports it + pub fn disable_mouse_capture(&mut self) -> TerminalResult<()> { + self.terminal.disable_mouse_capture() + } + + /// Draws a single frame to the terminal. + /// + /// Returns a [`CompletedFrame`] if successful, otherwise a [`TerminalError`]. + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal + /// + /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing + /// purposes, but it is often not used in regular applicationss. + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render callback does not fully render the frame, the terminal will not be + /// in a consistent state. + pub fn draw(&mut self, render_callback: F) -> TerminalResult + where + F: FnOnce(&mut Frame<'_>), + { + self.terminal.draw(render_callback) + } +} + +#[cfg(feature = "crossterm")] +impl TerminalBridge { + /// Create a new instance of the [`TerminalBridge`] using [`crossterm`] as backend + pub fn new_crossterm() -> TerminalResult { + Ok(Self::new(adapter::CrosstermTerminalAdapter::new()?)) + } + + /// Initialize a terminal with reasonable defaults for most applications using [`crossterm`] as backend. + /// + /// See [`TerminalBridge::init`] for more information. + pub fn init_crossterm() -> TerminalResult { + Self::init(adapter::CrosstermTerminalAdapter::new()?) + } + + /// Returns a reference to the underlying [`Terminal`] + pub fn raw( + &self, + ) -> &crate::ratatui::Terminal> { + self.terminal.raw() + } + + /// Returns a mutable reference the underlying [`Terminal`] + pub fn raw_mut( + &mut self, + ) -> &mut crate::ratatui::Terminal> + { + self.terminal.raw_mut() + } +} + +#[cfg(feature = "termion")] +impl TerminalBridge { + /// Create a new instance of the [`TerminalBridge`] using [`termion`] as backend + pub fn new_termion() -> Self { + Self::new(adapter::TermionTerminalAdapter::new().unwrap()) + } + + /// Initialize a terminal with reasonable defaults for most applications using [`termion`] as backend. + /// + /// See [`TerminalBridge::init`] for more information. + pub fn init_termion() -> TerminalResult { + Self::init(adapter::TermionTerminalAdapter::new().unwrap()) } - /// Returna an immutable reference to the raw `Terminal` structure - pub fn raw(&self) -> &Terminal { - &self.terminal + /// Returns a reference to the underlying [`Terminal`] + pub fn raw(&self) -> &adapter::TermionBackend { + self.terminal.raw() } - /// Return a mutable reference to the raw `Terminal` structure - pub fn raw_mut(&mut self) -> &mut Terminal { - &mut self.terminal + /// Returns a mutable reference to the underlying [`Terminal`] + pub fn raw_mut(&mut self) -> &mut adapter::TermionBackend { + self.terminal.raw_mut() } } diff --git a/src/terminal/adapter.rs b/src/terminal/adapter.rs new file mode 100644 index 0000000..131b3d3 --- /dev/null +++ b/src/terminal/adapter.rs @@ -0,0 +1,63 @@ +#[cfg(feature = "crossterm")] +mod crossterm; +#[cfg(feature = "termion")] +mod termion; + +#[cfg(feature = "crossterm")] +pub use crossterm::CrosstermTerminalAdapter; +use ratatui::{CompletedFrame, Frame}; +#[cfg(feature = "termion")] +pub use termion::{TermionBackend, TermionTerminalAdapter}; + +use super::TerminalResult; + +/// TerminalAdapter is a trait that defines the methods that a terminal adapter should implement. +/// +/// This trait is used to abstract the terminal implementation from the rest of the application. +/// This allows tui-realm to be used with different terminal libraries, such as crossterm, termion, etc. +pub trait TerminalAdapter { + /// Draws a single frame to the terminal. + /// + /// Returns a [`CompletedFrame`] if successful, otherwise a [`super::TerminalError`]. + /// + /// This method will: + /// + /// - autoresize the terminal if necessary + /// - call the render callback, passing it a [`Frame`] reference to render to + /// - flush the current internal state by copying the current buffer to the backend + /// - move the cursor to the last known position if it was set during the rendering closure + /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal + /// + /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing + /// purposes, but it is often not used in regular applicationss. + /// + /// The render callback should fully render the entire frame when called, including areas that + /// are unchanged from the previous frame. This is because each frame is compared to the + /// previous frame to determine what has changed, and only the changes are written to the + /// terminal. If the render callback does not fully render the frame, the terminal will not be + /// in a consistent state. + fn draw(&mut self, render_callback: F) -> TerminalResult + where + F: FnOnce(&mut Frame<'_>); + + /// Clear the screen + fn clear_screen(&mut self) -> TerminalResult<()>; + + /// Enable terminal raw mode + fn enable_raw_mode(&mut self) -> TerminalResult<()>; + + /// Disable terminal raw mode + fn disable_raw_mode(&mut self) -> TerminalResult<()>; + + /// Enter in alternate screen using the terminal adapter + fn enter_alternate_screen(&mut self) -> TerminalResult<()>; + + /// Leave the alternate screen using the terminal adapter + fn leave_alternate_screen(&mut self) -> TerminalResult<()>; + + /// Enable mouse capture using the terminal adapter + fn enable_mouse_capture(&mut self) -> TerminalResult<()>; + + /// Disable mouse capture using the terminal adapter + fn disable_mouse_capture(&mut self) -> TerminalResult<()>; +} diff --git a/src/terminal/adapter/crossterm.rs b/src/terminal/adapter/crossterm.rs new file mode 100644 index 0000000..26b1652 --- /dev/null +++ b/src/terminal/adapter/crossterm.rs @@ -0,0 +1,88 @@ +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::Terminal; + +use super::{TerminalAdapter, TerminalResult}; +use crate::ratatui::backend::CrosstermBackend; +use crate::terminal::TerminalError; + +/// CrosstermTerminalAdapter is the adapter for the [`crossterm`] terminal +/// +/// It implements the [`TerminalAdapter`] trait +pub struct CrosstermTerminalAdapter { + terminal: Terminal>, +} + +impl CrosstermTerminalAdapter { + /// Create a new instance of the CrosstermTerminalAdapter + pub fn new() -> TerminalResult { + let backend = CrosstermBackend::new(std::io::stdout()); + let terminal = Terminal::new(backend).map_err(|_| TerminalError::CannotConnectStdout)?; + + Ok(Self { terminal }) + } + + pub fn raw(&self) -> &Terminal> { + &self.terminal + } + + pub fn raw_mut(&mut self) -> &mut Terminal> { + &mut self.terminal + } +} + +impl TerminalAdapter for CrosstermTerminalAdapter { + fn draw(&mut self, render_callback: F) -> TerminalResult + where + F: FnOnce(&mut ratatui::Frame<'_>), + { + self.raw_mut() + .draw(render_callback) + .map_err(|_| TerminalError::CannotDrawFrame) + } + + fn clear_screen(&mut self) -> TerminalResult<()> { + self.terminal + .clear() + .map_err(|_| TerminalError::CannotClear) + } + + fn enable_raw_mode(&mut self) -> TerminalResult<()> { + enable_raw_mode().map_err(|_| TerminalError::CannotToggleRawMode) + } + + fn disable_raw_mode(&mut self) -> TerminalResult<()> { + disable_raw_mode().map_err(|_| TerminalError::CannotToggleRawMode) + } + + fn enter_alternate_screen(&mut self) -> TerminalResult<()> { + execute!( + self.terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture + ) + .map_err(|_| TerminalError::CannotEnterAlternateMode) + } + + fn leave_alternate_screen(&mut self) -> TerminalResult<()> { + execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .map_err(|_| TerminalError::CannotLeaveAlternateMode) + } + + fn enable_mouse_capture(&mut self) -> TerminalResult<()> { + execute!(self.raw_mut().backend_mut(), EnableMouseCapture) + .map_err(|_| TerminalError::CannotToggleMouseCapture) + } + + fn disable_mouse_capture(&mut self) -> TerminalResult<()> { + execute!(self.raw_mut().backend_mut(), DisableMouseCapture) + .map_err(|_| TerminalError::CannotToggleMouseCapture) + } +} diff --git a/src/terminal/adapter/termion.rs b/src/terminal/adapter/termion.rs new file mode 100644 index 0000000..2357387 --- /dev/null +++ b/src/terminal/adapter/termion.rs @@ -0,0 +1,85 @@ +use std::io::Stdout; + +use ratatui::prelude::TermionBackend as TermionLibBackend; +use ratatui::Terminal; +use termion::input::MouseTerminal; +use termion::raw::{IntoRawMode as _, RawTerminal}; +use termion::screen::{AlternateScreen, IntoAlternateScreen as _}; + +use super::{TerminalAdapter, TerminalResult}; +use crate::terminal::TerminalError; + +pub type TermionBackend = + Terminal>>>>; + +/// TermionTerminalAdapter is the adapter for the [`termion`] terminal +/// +/// It implements the [`TerminalAdapter`] trait +pub struct TermionTerminalAdapter { + terminal: TermionBackend, +} + +impl TermionTerminalAdapter { + pub fn new() -> TerminalResult { + let stdout = std::io::stdout() + .into_raw_mode() + .map_err(|_| TerminalError::CannotConnectStdout)? + .into_alternate_screen() + .map_err(|_| TerminalError::CannotConnectStdout)?; + let stdout = MouseTerminal::from(stdout); + + let terminal = Terminal::new(TermionLibBackend::new(stdout)) + .map_err(|_| TerminalError::CannotConnectStdout)?; + + Ok(Self { terminal }) + } + + pub fn raw(&self) -> &TermionBackend { + &self.terminal + } + + pub fn raw_mut(&mut self) -> &mut TermionBackend { + &mut self.terminal + } +} + +impl TerminalAdapter for TermionTerminalAdapter { + fn draw(&mut self, render_callback: F) -> TerminalResult + where + F: FnOnce(&mut ratatui::Frame<'_>), + { + self.raw_mut() + .draw(render_callback) + .map_err(|_| TerminalError::CannotDrawFrame) + } + + fn clear_screen(&mut self) -> TerminalResult<()> { + self.terminal + .clear() + .map_err(|_| TerminalError::CannotClear) + } + + fn disable_raw_mode(&mut self) -> TerminalResult<()> { + Err(TerminalError::Unsupported) + } + + fn enable_raw_mode(&mut self) -> TerminalResult<()> { + Err(TerminalError::Unsupported) + } + + fn enter_alternate_screen(&mut self) -> TerminalResult<()> { + Err(TerminalError::Unsupported) + } + + fn leave_alternate_screen(&mut self) -> TerminalResult<()> { + Err(TerminalError::Unsupported) + } + + fn disable_mouse_capture(&mut self) -> TerminalResult<()> { + Err(TerminalError::Unsupported) + } + + fn enable_mouse_capture(&mut self) -> TerminalResult<()> { + Err(TerminalError::Unsupported) + } +} diff --git a/src/terminal/event_listener.rs b/src/terminal/event_listener.rs new file mode 100644 index 0000000..f7fa3d1 --- /dev/null +++ b/src/terminal/event_listener.rs @@ -0,0 +1,11 @@ +#[cfg(feature = "crossterm")] +mod crossterm; +#[cfg(feature = "termion")] +mod termion; + +#[cfg(feature = "crossterm")] +pub use crossterm::CrosstermInputListener; +#[cfg(feature = "termion")] +pub use termion::TermionInputListener; + +use crate::Event; diff --git a/src/adapter/crossterm/event.rs b/src/terminal/event_listener/crossterm.rs similarity index 55% rename from src/adapter/crossterm/event.rs rename to src/terminal/event_listener/crossterm.rs index 2f80f57..1fdd2d8 100644 --- a/src/adapter/crossterm/event.rs +++ b/src/terminal/event_listener/crossterm.rs @@ -1,14 +1,58 @@ -//! ## Event -//! -//! event adapter for crossterm +use std::marker::PhantomData; +use std::time::Duration; use crossterm::event::{ - Event as XtermEvent, KeyCode as XtermKeyCode, KeyEvent as XtermKeyEvent, + self as xterm, Event as XtermEvent, KeyCode as XtermKeyCode, KeyEvent as XtermKeyEvent, KeyEventKind as XtermEventKind, KeyModifiers as XtermKeyModifiers, - MediaKeyCode as XtermMediaKeyCode, + MediaKeyCode as XtermMediaKeyCode, MouseButton as XtermMouseButton, + MouseEvent as XtermMouseEvent, MouseEventKind as XtermMouseEventKind, }; -use super::{Event, Key, KeyEvent, KeyModifiers, MediaKeyCode}; +use super::Event; +use crate::event::{ + Key, KeyEvent, KeyModifiers, MediaKeyCode, MouseButton, MouseEvent, MouseEventKind, +}; +use crate::listener::{ListenerResult, Poll}; +use crate::ListenerError; + +/// The input listener for crossterm. +/// If crossterm is enabled, this will already be exported as `InputEventListener` in the `adapter` module +/// or you can use it directly in the event listener, calling `default_input_listener()` in the `EventListenerCfg` +#[doc(alias = "InputEventListener")] +pub struct CrosstermInputListener +where + U: Eq + PartialEq + Clone + PartialOrd + Send, +{ + ghost: PhantomData, + interval: Duration, +} + +impl CrosstermInputListener +where + U: Eq + PartialEq + Clone + PartialOrd + Send, +{ + pub fn new(interval: Duration) -> Self { + Self { + ghost: PhantomData, + interval: interval / 2, + } + } +} + +impl Poll for CrosstermInputListener +where + U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, +{ + fn poll(&mut self) -> ListenerResult>> { + match xterm::poll(self.interval) { + Ok(true) => xterm::read() + .map(|x| Some(Event::from(x))) + .map_err(|_| ListenerError::PollFailed), + Ok(false) => Ok(None), + Err(_) => Err(ListenerError::PollFailed), + } + } +} impl From for Event where @@ -18,7 +62,7 @@ where match e { XtermEvent::Key(key) if key.kind == XtermEventKind::Press => Self::Keyboard(key.into()), XtermEvent::Key(_) => Self::None, - XtermEvent::Mouse(_) => Self::None, + XtermEvent::Mouse(ev) => Self::Mouse(ev.into()), XtermEvent::Resize(w, h) => Self::WindowResize(w, h), XtermEvent::FocusGained => Self::FocusGained, XtermEvent::FocusLost => Self::FocusLost, @@ -105,6 +149,42 @@ impl From for MediaKeyCode { } } +impl From for MouseEvent { + fn from(value: XtermMouseEvent) -> Self { + Self { + kind: value.kind.into(), + modifiers: value.modifiers.into(), + column: value.column, + row: value.row, + } + } +} + +impl From for MouseEventKind { + fn from(value: XtermMouseEventKind) -> Self { + match value { + XtermMouseEventKind::Down(b) => Self::Down(b.into()), + XtermMouseEventKind::Up(b) => Self::Up(b.into()), + XtermMouseEventKind::Drag(b) => Self::Drag(b.into()), + XtermMouseEventKind::Moved => Self::Moved, + XtermMouseEventKind::ScrollDown => Self::ScrollDown, + XtermMouseEventKind::ScrollUp => Self::ScrollUp, + XtermMouseEventKind::ScrollLeft => Self::ScrollLeft, + XtermMouseEventKind::ScrollRight => Self::ScrollRight, + } + } +} + +impl From for MouseButton { + fn from(value: XtermMouseButton) -> Self { + match value { + XtermMouseButton::Left => Self::Left, + XtermMouseButton::Right => Self::Right, + XtermMouseButton::Middle => Self::Middle, + } + } +} + #[cfg(test)] mod test { @@ -112,6 +192,7 @@ mod test { use pretty_assertions::assert_eq; use super::*; + use crate::event::{Key, MediaKeyCode}; use crate::mock::MockEvent; #[test] @@ -206,6 +287,122 @@ mod test { ); } + #[test] + fn should_adapt_mouse_event() { + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::Moved, + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::Down(XtermMouseButton::Left), + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::Up(XtermMouseButton::Right), + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::Drag(XtermMouseButton::Middle), + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Middle), + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::ScrollUp, + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::ScrollUp, + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::ScrollDown, + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::ScrollDown, + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::ScrollLeft, + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::ScrollLeft, + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + assert_eq!( + MouseEvent::from(XtermMouseEvent { + kind: XtermMouseEventKind::ScrollRight, + column: 1, + row: 1, + modifiers: XtermKeyModifiers::NONE + }), + MouseEvent { + kind: MouseEventKind::ScrollRight, + modifiers: KeyModifiers::NONE, + column: 1, + row: 1 + } + ); + } + #[test] fn adapt_crossterm_key_event() { assert_eq!( @@ -237,7 +434,12 @@ mod test { row: 0, modifiers: XtermKeyModifiers::NONE, })), - Event::None + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + modifiers: KeyModifiers::NONE, + column: 0, + row: 0 + }) ); assert_eq!( AppEvent::from(XtermEvent::FocusGained), diff --git a/src/adapter/termion/event.rs b/src/terminal/event_listener/termion.rs similarity index 74% rename from src/adapter/termion/event.rs rename to src/terminal/event_listener/termion.rs index ed4edcf..5821d09 100644 --- a/src/adapter/termion/event.rs +++ b/src/terminal/event_listener/termion.rs @@ -1,10 +1,44 @@ -//! ## Event -//! -//! event adapter for termion +use std::marker::PhantomData; +use std::time::Duration; use termion::event::{Event as TonEvent, Key as TonKey}; +use termion::input::TermRead as _; -use super::{Event, Key, KeyEvent, KeyModifiers}; +use super::Event; +use crate::event::{Key, KeyEvent, KeyModifiers}; +use crate::listener::{ListenerResult, Poll}; +use crate::ListenerError; + +/// The input listener for [`termion`]. +#[doc(alias = "InputEventListener")] +pub struct TermionInputListener +where + U: Eq + PartialEq + Clone + PartialOrd + Send, +{ + ghost: PhantomData, +} + +impl TermionInputListener +where + U: Eq + PartialEq + Clone + PartialOrd + Send, +{ + pub fn new(_interval: Duration) -> Self { + Self { ghost: PhantomData } + } +} + +impl Poll for TermionInputListener +where + U: Eq + PartialEq + Clone + PartialOrd + Send + 'static, +{ + fn poll(&mut self) -> ListenerResult>> { + match std::io::stdin().events().next() { + Some(Ok(ev)) => Ok(Some(Event::from(ev))), + Some(Err(_)) => Err(ListenerError::PollFailed), + None => Ok(None), + } + } +} impl From for Event where @@ -49,6 +83,20 @@ impl From for KeyEvent { TonKey::Null => Key::Null, TonKey::Esc => Key::Esc, TonKey::__IsNotComplete => Key::Null, + TonKey::ShiftLeft => Key::ShiftLeft, + TonKey::AltLeft => Key::AltLeft, + TonKey::CtrlLeft => Key::CtrlLeft, + TonKey::ShiftRight => Key::ShiftRight, + TonKey::AltRight => Key::AltRight, + TonKey::CtrlRight => Key::CtrlRight, + TonKey::ShiftUp => Key::ShiftUp, + TonKey::AltUp => Key::AltUp, + TonKey::CtrlUp => Key::CtrlUp, + TonKey::ShiftDown => Key::ShiftDown, + TonKey::AltDown => Key::AltDown, + TonKey::CtrlDown => Key::CtrlDown, + TonKey::CtrlHome => Key::CtrlHome, + TonKey::CtrlEnd => Key::CtrlEnd, }; Self { code, modifiers } } diff --git a/src/tui.rs b/src/tui.rs deleted file mode 100644 index c5ae484..0000000 --- a/src/tui.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! ## tui -//! -//! `tui` just exposes the ratatui modules, in order to include the entire library inside realm - -#[cfg(feature = "ratatui")] -pub use ratatui::*; -#[cfg(feature = "tui")] -pub use tui::*; diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 4fa0eb6..4e436f9 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -7,7 +7,7 @@ use std::str::FromStr; use lazy_regex::{Lazy, Regex}; use super::{Email, PhoneNumber}; -use crate::tui::style::Color; +use crate::ratatui::style::Color; /** * Regex matches: * - group 1: Red