From 18ea6a46072a4f8a633ad21436137b5b269a800b Mon Sep 17 00:00:00 2001 From: Alexandre Pasmantier Date: Sat, 21 Sep 2024 02:09:51 +0200 Subject: [PATCH] great progress --- .config/config.json5 | 8 +- .config/config.toml | 12 +++ Cargo.lock | 1 + Cargo.toml | 1 + TODO.md | 1 + src/action.rs | 13 +++ src/app.rs | 192 +++++++++++++++---------------------------- src/components.rs | 46 +---------- src/config.rs | 36 ++++---- src/event.rs | 156 +++++++++++++++++++++++++++++++++++ src/main.rs | 2 + src/render.rs | 84 +++++++++++++++++++ src/tui.rs | 165 +------------------------------------ 13 files changed, 357 insertions(+), 360 deletions(-) create mode 100644 .config/config.toml create mode 100644 src/event.rs create mode 100644 src/render.rs diff --git a/.config/config.json5 b/.config/config.json5 index c746239..d954871 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -1,10 +1,10 @@ { "keybindings": { - "Home": { + "Input": { "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", // Another way to quit + "": "Quit", // Yet another way to quit + "": "Suspend", // Suspend the application }, } } diff --git a/.config/config.toml b/.config/config.toml new file mode 100644 index 0000000..743fe79 --- /dev/null +++ b/.config/config.toml @@ -0,0 +1,12 @@ +FIXME: this doesn't work +[keybindings] +Input = { + "": "Quit", // Quit the application + "": "Quit", // Another way to quit + "": "Quit", // Yet another way to quit + "": "Suspend", // Suspend the application + }, + +Help = { + "" = "Quit" # Quit the help panel + } diff --git a/Cargo.lock b/Cargo.lock index 23a1fa9..2d6e628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2215,6 +2215,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "toml", "tracing", "tracing-error", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index d60ed06..8b0ef92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ strum = { version = "0.26.3", features = ["derive"] } tokio = { version = "1.39.3", features = ["full"] } tokio-stream = "0.1.16" tokio-util = "0.7.11" +toml = "0.8.19" tracing = "0.1.40" tracing-error = "0.2.0" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } diff --git a/TODO.md b/TODO.md index 5a8fc1d..1c448b4 100644 --- a/TODO.md +++ b/TODO.md @@ -12,6 +12,7 @@ - shell history - grep (maybe also inside pdfs and other files (see rga)) - fd +- recent directories - git - makefile commands - diff --git a/src/action.rs b/src/action.rs index 2830433..936eaa3 100644 --- a/src/action.rs +++ b/src/action.rs @@ -3,13 +3,26 @@ use strum::Display; #[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] pub enum Action { + KeyPress(char), Tick, Render, Resize(u16, u16), + SelectNextEntry, + SelectPreviousEntry, + GoToPaneUp, + GoToPaneDown, + GoToPaneLeft, + GoToPaneRight, + ScrollPreviewUp, + ScrollPreviewDown, + ScrollPreviewHalfPageUp, + ScrollPreviewHalfPageDown, + OpenEntry, Suspend, Resume, Quit, ClearScreen, Error(String), Help, + NoOp, } diff --git a/src/app.rs b/src/app.rs index c82000e..4a2aa28 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,17 +28,17 @@ use std::sync::{Arc, Mutex}; use color_eyre::Result; -use crossterm::event::KeyEvent; -use ratatui::prelude::Rect; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -use tracing::{debug, info}; +use tracing::debug; use crate::{ action::Action, components::{fps::FpsCounter, home::Home, Component}, config::Config, - tui::{Event, Tui}, + event::{Event, EventLoop, Key}, + render::{render, RenderingTask}, + tui::Tui, }; pub struct App { @@ -49,23 +49,28 @@ pub struct App { should_quit: bool, should_suspend: bool, mode: Mode, - last_tick_key_events: Vec, action_tx: mpsc::UnboundedSender, action_rx: mpsc::UnboundedReceiver, + event_rx: mpsc::UnboundedReceiver>, + event_abort_tx: mpsc::UnboundedSender<()>, render_tx: mpsc::UnboundedSender, - render_rx: mpsc::UnboundedReceiver, } #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Mode { #[default] - Home, + Help, + Input, + Preview, + Results, } impl App { pub fn new(tick_rate: f64, frame_rate: f64) -> Result { let (action_tx, action_rx) = mpsc::unbounded_channel(); - let (render_tx, mut render_rx) = mpsc::unbounded_channel(); + let (render_tx, _) = mpsc::unbounded_channel(); + let event_loop = EventLoop::new(Some(std::time::Duration::from_millis(250)), true); + Ok(Self { tick_rate, frame_rate, @@ -76,92 +81,72 @@ impl App { should_quit: false, should_suspend: false, config: Config::new()?, - mode: Mode::Home, - last_tick_key_events: Vec::new(), + mode: Mode::Input, action_tx, action_rx, + event_rx: event_loop.rx, + event_abort_tx: event_loop.abort_tx, render_tx, - render_rx, }) } pub async fn run(&mut self) -> Result<()> { - let mut tui = Tui::new()? - .tick_rate(self.tick_rate) - .frame_rate(self.frame_rate); + let mut tui = Tui::new()?.frame_rate(self.frame_rate); // this starts the event handling loop in Tui - tui.enter(); // Rendering loop + let (render_tx, render_rx) = mpsc::unbounded_channel(); + self.render_tx = render_tx.clone(); + let action_tx_r = self.action_tx.clone(); + let config_r = self.config.clone(); + let components_r = self.components.clone(); + let frame_rate = self.frame_rate; tokio::spawn(async move { - self.render(tui).await; + render( + &mut tui, + render_rx, + render_tx, + action_tx_r, + config_r, + components_r, + frame_rate, + ) + .await }); - // Action handling loop + // event handling loop let action_tx = self.action_tx.clone(); loop { - self.handle_events(&mut tui).await?; + // handle event and convert to action + if let Some(event) = self.event_rx.recv().await { + let action = self.convert_event_to_action(event); + action_tx.send(action)?; + } + self.handle_actions()?; - if self.should_suspend { - tui.suspend()?; - action_tx.send(Action::Resume)?; - action_tx.send(Action::ClearScreen)?; - // tui.mouse(true); - tui.enter()?; - } else if self.should_quit { - tui.stop()?; + + if self.should_quit { + self.event_abort_tx.send(())?; break; } } Ok(()) } - /// Handle incoming events and produce actions if necessary. These actions are then sent to the - /// action channel for processing. - async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { - let Some(event) = tui.next_event().await else { - return Ok(()); - }; - let action_tx = self.action_tx.clone(); + fn convert_event_to_action(&self, event: Event) -> Action { match event { - Event::Quit => action_tx.send(Action::Quit)?, - Event::Tick => action_tx.send(Action::Tick)?, - Event::Render => action_tx.send(Action::Render)?, - Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, - Event::Key(key) => self.handle_key_event(key)?, - _ => {} - } - for component in self.components.lock().unwrap().iter_mut() { - if let Some(action) = component.handle_events(Some(event.clone()))? { - action_tx.send(action)?; - } - } - Ok(()) - } - - fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { - let action_tx = self.action_tx.clone(); - let Some(keymap) = self.config.keybindings.get(&self.mode) else { - return Ok(()); - }; - match keymap.get(&vec![key]) { - Some(action) => { - info!("Got action: {action:?}"); - action_tx.send(action.clone())?; - } - _ => { - // If the key was not handled as a single key action, - // then consider it for multi-key combinations. - self.last_tick_key_events.push(key); - - // Check for multi-key combinations - if let Some(action) = keymap.get(&self.last_tick_key_events) { - info!("Got action: {action:?}"); - action_tx.send(action.clone())?; - } - } + Event::Input(keycode) => self + .config + .keybindings + .get(&self.mode) + .and_then(|keymap| keymap.get(&keycode).cloned()) + .unwrap_or(Action::NoOp), + Event::Tick => Action::Tick, + Event::Resize(x, y) => Action::Resize(x, y), + Event::FocusGained => Action::Resume, + Event::FocusLost => Action::Suspend, + _ => Action::NoOp, } - Ok(()) } fn handle_actions(&mut self) -> Result<()> { @@ -170,12 +155,19 @@ impl App { debug!("{action:?}"); } match action { - Action::Tick => { - self.last_tick_key_events.drain(..); + Action::Tick => {} + Action::Quit => { + self.should_quit = true; + self.render_tx.send(RenderingTask::Quit)? + } + Action::Suspend => { + self.should_suspend = true; + self.render_tx.send(RenderingTask::Suspend)? + } + Action::Resume => { + self.should_suspend = false; + self.render_tx.send(RenderingTask::Resume)? } - Action::Quit => self.should_quit = true, - Action::Suspend => self.should_suspend = true, - Action::Resume => self.should_suspend = false, Action::ClearScreen => self.render_tx.send(RenderingTask::ClearScreen)?, Action::Resize(w, h) => self.render_tx.send(RenderingTask::Resize(w, h))?, Action::Render => self.render_tx.send(RenderingTask::Render)?, @@ -189,54 +181,4 @@ impl App { } Ok(()) } - - async fn render(&mut self, tui: Tui) -> Result<()> { - let components = self.components.clone(); - for component in components.lock().unwrap().iter_mut() { - component.register_action_handler(self.action_tx.clone()); - } - for component in components.lock().unwrap().iter_mut() { - component.register_config_handler(self.config.clone()); - } - for component in components.lock().unwrap().iter_mut() { - component.init(tui.size().unwrap()); - } - - // Rendering loop - loop { - if let Some(task) = self.render_rx.recv().await { - match task { - RenderingTask::ClearScreen => { - tui.terminal.clear()?; - } - RenderingTask::Render => { - let mut c = components.lock().unwrap(); - tui.terminal.draw(|frame| { - for component in c.iter_mut() { - if let Err(err) = component.draw(frame, frame.area()) { - let _ = self - .action_tx - .send(Action::Error(format!("Failed to draw: {:?}", err))); - } - } - }); - } - RenderingTask::Resize(w, h) => { - tui.resize(Rect::new(0, 0, w, h))?; - let _ = self.action_tx.send(Action::Render); - } - RenderingTask::Quit => { - break Ok(()); - } - } - } - } - } -} - -enum RenderingTask { - ClearScreen, - Render, - Resize(u16, u16), - Quit, } diff --git a/src/components.rs b/src/components.rs index e500658..8f3aca2 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,12 +1,11 @@ use color_eyre::Result; -use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{ layout::{Rect, Size}, Frame, }; use tokio::sync::mpsc::UnboundedSender; -use crate::{action::Action, config::Config, tui::Event}; +use crate::{action::Action, config::Config}; pub mod fps; pub mod home; @@ -55,49 +54,6 @@ pub trait Component: Send { let _ = area; // to appease clippy Ok(()) } - /// Handle incoming events and produce actions if necessary. - /// - /// # Arguments - /// - /// * `event` - An optional event to be processed. - /// - /// # Returns - /// - /// * `Result>` - An action to be processed or none. - fn handle_events(&mut self, event: Option) -> Result> { - let action = match event { - Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, - Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, - _ => None, - }; - Ok(action) - } - /// Handle key events and produce actions if necessary. - /// - /// # Arguments - /// - /// * `key` - A key event to be processed. - /// - /// # Returns - /// - /// * `Result>` - An action to be processed or none. - fn handle_key_event(&mut self, key: KeyEvent) -> Result> { - let _ = key; // to appease clippy - Ok(None) - } - /// Handle mouse events and produce actions if necessary. - /// - /// # Arguments - /// - /// * `mouse` - A mouse event to be processed. - /// - /// # Returns - /// - /// * `Result>` - An action to be processed or none. - fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { - let _ = mouse; // to appease clippy - Ok(None) - } /// Update the state of the component based on a received action. (REQUIRED) /// /// # Arguments diff --git a/src/config.rs b/src/config.rs index 09b3d2c..0fc4d90 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,7 +11,11 @@ use ratatui::style::{Color, Modifier, Style}; use serde::{de::Deserializer, Deserialize}; use tracing::error; -use crate::{action::Action, app::Mode}; +use crate::{ + action::Action, + app::Mode, + event::{convert_raw_event_to_key, Key}, +}; const CONFIG: &str = include_str!("../.config/config.json5"); @@ -119,11 +123,11 @@ pub fn get_config_dir() -> PathBuf { } fn project_directory() -> Option { - ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) + ProjectDirs::from("com", "alexpasmantier", env!("CARGO_PKG_NAME")) } #[derive(Clone, Debug, Default, Deref, DerefMut)] -pub struct KeyBindings(pub HashMap, Action>>); +pub struct KeyBindings(pub HashMap>); impl<'de> Deserialize<'de> for KeyBindings { fn deserialize(deserializer: D) -> Result @@ -137,7 +141,7 @@ impl<'de> Deserialize<'de> for KeyBindings { .map(|(mode, inner_map)| { let converted_inner_map = inner_map .into_iter() - .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) + .map(|(key_str, cmd)| (parse_key(&key_str).unwrap(), cmd)) .collect(); (mode, converted_inner_map) }) @@ -291,31 +295,19 @@ pub fn key_event_to_string(key_event: &KeyEvent) -> String { key } -pub fn parse_key_sequence(raw: &str) -> Result, String> { +pub fn parse_key(raw: &str) -> Result { if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { return Err(format!("Unable to parse `{}`", raw)); } let raw = if !raw.contains("><") { let raw = raw.strip_prefix('<').unwrap_or(raw); - let raw = raw.strip_prefix('>').unwrap_or(raw); + let raw = raw.strip_suffix('>').unwrap_or(raw); raw } else { raw }; - let sequences = raw - .split("><") - .map(|seq| { - if let Some(s) = seq.strip_prefix('<') { - s - } else if let Some(s) = seq.strip_suffix('>') { - s - } else { - seq - } - }) - .collect::>(); - - sequences.into_iter().map(parse_key_event).collect() + let key_event = parse_key_event(raw)?; + Ok(convert_raw_event_to_key(key_event)) } #[derive(Clone, Debug, Default, Deref, DerefMut)] @@ -505,9 +497,9 @@ mod tests { let c = Config::new()?; assert_eq!( c.keybindings - .get(&Mode::Home) + .get(&Mode::Input) .unwrap() - .get(&parse_key_sequence("").unwrap_or_default()) + .get(&parse_key("").unwrap()) .unwrap(), &Action::Quit ); diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..8d2cd4a --- /dev/null +++ b/src/event.rs @@ -0,0 +1,156 @@ +use crossterm::event::{ + KeyCode::{ + BackTab, Backspace, Char, Delete, Down, End, Enter, Esc, Home, Insert, Left, Null, + PageDown, PageUp, Right, Tab, Up, F, + }, + KeyEvent, KeyModifiers, +}; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tracing::warn; + +#[derive(Debug, Clone, Copy)] +pub enum Event { + Closed, + Input(I), + FocusLost, + FocusGained, + Resize(u16, u16), + Tick, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Hash)] +pub enum Key { + CtrlBackspace, + CtrlDelete, + AltBackspace, + AltDelete, + Backspace, + Left, + Right, + Up, + Down, + Home, + End, + PageUp, + PageDown, + BackTab, + Delete, + Insert, + F(u8), + Char(char), + Alt(char), + Ctrl(char), + Null, + Esc, + Tab, +} + +pub struct EventLoop { + pub rx: mpsc::UnboundedReceiver>, + //tx: mpsc::UnboundedSender>, + pub abort_tx: mpsc::UnboundedSender<()>, + //tick_rate: std::time::Duration, +} + +const EVENT_LOOP_TICK_RATE: std::time::Duration = std::time::Duration::from_millis(250); + +impl EventLoop { + pub fn new(tick_rate: Option, init: bool) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let _tx = tx.clone(); + let should_tick = tick_rate.is_some(); + let tick_rate = tick_rate.unwrap_or(EVENT_LOOP_TICK_RATE); + + let (abort, mut abort_recv) = mpsc::unbounded_channel(); + + if init { + let mut reader = crossterm::event::EventStream::new(); + tokio::spawn(async move { + loop { + let delay = tokio::time::sleep(tick_rate); + let event = reader.next(); + + tokio::select! { + // if we receive a message on the abort channel, stop the event loop + _ = abort_recv.recv() => { + _tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event")); + _tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event")); + break; + }, + // if `delay` completes, pass to the next event "frame" + _ = delay, if should_tick => { + _tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event")); + }, + // if the receiver dropped the channel, stop the event loop + _ = _tx.closed() => break, + // if an event was received, process it + maybe_event = event => { + match maybe_event { + Some(Ok(crossterm::event::Event::Key(key))) => { + let key = convert_raw_event_to_key(key); + _tx.send(Event::Input(key)).unwrap_or_else(|_| warn!("Unable to send {:?} event", key)); + }, + Some(Ok(crossterm::event::Event::FocusLost)) => { + _tx.send(Event::FocusLost).unwrap_or_else(|_| warn!("Unable to send FocusLost event")); + }, + Some(Ok(crossterm::event::Event::FocusGained)) => { + _tx.send(Event::FocusGained).unwrap_or_else(|_| warn!("Unable to send FocusGained event")); + }, + Some(Ok(crossterm::event::Event::Resize(x, y))) => { + _tx.send(Event::Resize(x, y)).unwrap_or_else(|_| warn!("Unable to send Resize event")); + }, + _ => {} + } + } + } + } + }); + } + + Self { + //tx, + rx, + //tick_rate, + abort_tx: abort, + } + } +} + +pub fn convert_raw_event_to_key(event: KeyEvent) -> Key { + match event.code { + Backspace => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlBackspace, + KeyModifiers::ALT => Key::AltBackspace, + _ => Key::Backspace, + }, + Delete => match event.modifiers { + KeyModifiers::CONTROL => Key::CtrlDelete, + KeyModifiers::ALT => Key::AltDelete, + _ => Key::Delete, + }, + Enter => Key::Char('\n'), + Left => Key::Left, + Right => Key::Right, + Up => Key::Up, + Down => Key::Down, + Home => Key::Home, + End => Key::End, + PageUp => Key::PageUp, + PageDown => Key::PageDown, + Tab => Key::Tab, + BackTab => Key::BackTab, + Insert => Key::Insert, + F(k) => Key::F(k), + Null => Key::Null, + Esc => Key::Esc, + Char(c) => match event.modifiers { + KeyModifiers::NONE | KeyModifiers::SHIFT => Key::Char(c), + KeyModifiers::CONTROL => Key::Ctrl(c), + KeyModifiers::ALT => Key::Alt(c), + _ => Key::Null, + }, + _ => Key::Null, + } +} diff --git a/src/main.rs b/src/main.rs index 7f3f78b..7901095 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,9 @@ mod cli; mod components; mod config; mod errors; +mod event; mod logging; +mod render; mod tui; #[tokio::main] diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..21cd255 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,84 @@ +use color_eyre::Result; +use ratatui::{layout::Rect, widgets::Paragraph}; +use std::sync::{Arc, Mutex}; + +use tokio::{select, sync::mpsc}; + +use crate::{action::Action, components::Component, config::Config, tui::Tui}; + +pub enum RenderingTask { + ClearScreen, + Render, + Resize(u16, u16), + Resume, + Suspend, + Quit, +} + +pub async fn render( + tui: &mut Tui, + mut render_rx: mpsc::UnboundedReceiver, + render_tx: mpsc::UnboundedSender, + action_tx: mpsc::UnboundedSender, + config: Config, + components: Arc>>>, + frame_rate: f64, +) -> Result<()> { + tui.enter()?; + + for component in components.lock().unwrap().iter_mut() { + component.register_action_handler(action_tx.clone())?; + } + for component in components.lock().unwrap().iter_mut() { + component.register_config_handler(config.clone())?; + } + for component in components.lock().unwrap().iter_mut() { + component.init(tui.size().unwrap())?; + } + + // Rendering loop + loop { + select! { + _ = tokio::time::sleep(tokio::time::Duration::from_secs_f64(1.0 / frame_rate)) => { + action_tx.send(Action::Render)?; + } + maybe_task = render_rx.recv() => { + if let Some(task) = maybe_task { + match task { + RenderingTask::ClearScreen => { + tui.terminal.clear()?; + } + RenderingTask::Render => { + let mut c = components.lock().unwrap(); + tui.terminal.draw(|frame| { + frame.render_widget(Paragraph::new("yoyoyo"), frame.area()); + for component in c.iter_mut() { + if let Err(err) = component.draw(frame, frame.area()) { + let _ = action_tx + .send(Action::Error(format!("Failed to draw: {:?}", err))); + } + } + })?; + } + RenderingTask::Resize(w, h) => { + tui.resize(Rect::new(0, 0, w, h))?; + action_tx.send(Action::Render)?; + } + RenderingTask::Suspend => { + tui.suspend()?; + action_tx.send(Action::Resume)?; + action_tx.send(Action::ClearScreen)?; + tui.enter()?; + } + RenderingTask::Resume => { + tui.enter()?; + } + RenderingTask::Quit => { + break Ok(()); + } + } + } + } + } + } +} diff --git a/src/tui.rs b/src/tui.rs index ca83a36..95ef079 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -3,210 +3,51 @@ use std::{ io::{stdout, Stdout}, ops::{Deref, DerefMut}, - time::Duration, }; use color_eyre::Result; use crossterm::{ cursor, - event::{ - DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, - }, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; -use futures::{FutureExt, StreamExt}; use ratatui::backend::CrosstermBackend as Backend; -use serde::{Deserialize, Serialize}; -use tokio::{ - sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, - task::JoinHandle, - time::interval, -}; -use tokio_util::sync::CancellationToken; -use tracing::error; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum Event { - Init, - Quit, - Error, - Closed, - Tick, - Render, - FocusGained, - FocusLost, - Paste(String), - Key(KeyEvent), - Mouse(MouseEvent), - Resize(u16, u16), -} +use tokio::task::JoinHandle; pub struct Tui { pub terminal: ratatui::Terminal>, pub task: JoinHandle<()>, - pub cancellation_token: CancellationToken, - pub event_rx: UnboundedReceiver, - pub event_tx: UnboundedSender, pub frame_rate: f64, - pub tick_rate: f64, - pub mouse: bool, - pub paste: bool, } impl Tui { pub fn new() -> Result { - let (event_tx, event_rx) = mpsc::unbounded_channel(); Ok(Self { terminal: ratatui::Terminal::new(Backend::new(stdout()))?, task: tokio::spawn(async {}), - cancellation_token: CancellationToken::new(), - event_rx, - event_tx, frame_rate: 60.0, - tick_rate: 4.0, - mouse: false, - paste: false, }) } - pub fn tick_rate(mut self, tick_rate: f64) -> Self { - self.tick_rate = tick_rate; - self - } - pub fn frame_rate(mut self, frame_rate: f64) -> Self { self.frame_rate = frame_rate; self } - pub fn mouse(mut self, mouse: bool) -> Self { - self.mouse = mouse; - self - } - - pub fn paste(mut self, paste: bool) -> Self { - self.paste = paste; - self - } - - pub fn start(&mut self) { - self.cancel(); // Cancel any existing task - self.cancellation_token = CancellationToken::new(); - let event_loop = Self::event_loop( - self.event_tx.clone(), - self.cancellation_token.clone(), - self.tick_rate, - self.frame_rate, - ); - self.task = tokio::spawn(async { - event_loop.await; - }); - } - - // TODO: I think we can decouple the event loop from the Tui struct to better - // separate responsibilities and avoid having to share Tui references between - // the event loop and the rendering loop. - // - // Goals: - // - Have a dedicated event loop independent of the Tui struct - // - Events should be directly translated into actions inside the event loop - // and then sent to the action handling loop - // - Have a dedicated rendering loop that can own the Tui struct - // Et voilĂ  - async fn event_loop( - event_tx: UnboundedSender, - cancellation_token: CancellationToken, - tick_rate: f64, - frame_rate: f64, - ) { - let mut event_stream = EventStream::new(); - let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); - let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); - - // if this fails, then it's likely a bug in the calling code - event_tx - .send(Event::Init) - .expect("failed to send init event"); - loop { - let event = tokio::select! { - _ = cancellation_token.cancelled() => { - break; - } - _ = tick_interval.tick() => Event::Tick, - _ = render_interval.tick() => Event::Render, - crossterm_event = event_stream.next().fuse() => match crossterm_event { - Some(Ok(event)) => match event { - CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), - CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), - CrosstermEvent::Resize(x, y) => Event::Resize(x, y), - CrosstermEvent::FocusLost => Event::FocusLost, - CrosstermEvent::FocusGained => Event::FocusGained, - CrosstermEvent::Paste(s) => Event::Paste(s), - _ => continue, // ignore other events - } - Some(Err(_)) => Event::Error, - None => break, // the event stream has stopped and will not produce any more events - }, - }; - if event_tx.send(event).is_err() { - // the receiver has been dropped, so there's no point in continuing the loop - break; - } - } - cancellation_token.cancel(); - } - - pub fn stop(&self) -> Result<()> { - self.cancel(); - let mut counter = 0; - while !self.task.is_finished() { - std::thread::sleep(Duration::from_millis(1)); - counter += 1; - if counter > 50 { - self.task.abort(); - } - if counter > 100 { - error!("Failed to abort task in 100 milliseconds for unknown reason"); - break; - } - } - Ok(()) - } - pub fn enter(&mut self) -> Result<()> { crossterm::terminal::enable_raw_mode()?; crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; - if self.mouse { - crossterm::execute!(stdout(), EnableMouseCapture)?; - } - if self.paste { - crossterm::execute!(stdout(), EnableBracketedPaste)?; - } - self.start(); Ok(()) } pub fn exit(&mut self) -> Result<()> { - self.stop()?; if crossterm::terminal::is_raw_mode_enabled()? { self.flush()?; - if self.paste { - crossterm::execute!(stdout(), DisableBracketedPaste)?; - } - if self.mouse { - crossterm::execute!(stdout(), DisableMouseCapture)?; - } crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; crossterm::terminal::disable_raw_mode()?; } Ok(()) } - pub fn cancel(&self) { - self.cancellation_token.cancel(); - } - pub fn suspend(&mut self) -> Result<()> { self.exit()?; #[cfg(not(windows))] @@ -218,10 +59,6 @@ impl Tui { self.enter()?; Ok(()) } - - pub async fn next_event(&mut self) -> Option { - self.event_rx.recv().await - } } impl Deref for Tui {