diff --git a/Cargo.lock b/Cargo.lock index fbe0739..fd381e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,7 +192,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -309,7 +309,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.111", ] [[package]] @@ -320,7 +320,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -332,6 +332,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -350,7 +361,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "unicode-xid", ] @@ -447,6 +458,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "futures-task" version = "0.3.31" @@ -460,6 +482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", @@ -494,7 +517,7 @@ dependencies = [ "bytes", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "tokio", ] @@ -540,7 +563,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -952,6 +975,8 @@ dependencies = [ "bytes", "clap", "crossterm 0.29.0", + "derivative", + "futures-util", "fuzzy-matcher", "handle-macro", "mlua", @@ -1090,7 +1115,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1204,7 +1229,18 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -1253,7 +1289,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1264,7 +1300,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1332,7 +1368,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1366,7 +1402,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1773,5 +1809,5 @@ checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] diff --git a/Cargo.toml b/Cargo.toml index 1b36b51..eb3c191 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,10 @@ edition = "2024" [workspace.dependencies] bytes = { version = "1.11.0", features = ["serde"] } -crossterm = "0.29.0" +crossterm = { version = "0.29.0" } derive_more = { version = "2", features = ["display"] } itertools = { version = "0.14" } +futures-util = { version = "0.3" } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0.145" } thiserror = { version = "2.0.17" } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 9f59668..e6b083e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -10,6 +10,7 @@ remux-core = { path = "../core" } bytes.workspace = true crossterm.workspace = true +futures-util.workspace = true serde.workspace = true thiserror.workspace = true tokio.workspace = true @@ -19,8 +20,9 @@ tracing.workspace = true vt100.workspace = true clap = { version = "4.5.51", features = ["derive"] } -mlua = { version = "0.11.4", features = ["lua54", "serde", "async", "vendored"] } +mlua = { version = "0.11.4", features = ["lua54", "serde", "async", "vendored", "send"] } ratatui = { version = "0.29", features = ["crossterm"] } terminput = "0.5.11" tui-term = "0.2" fuzzy-matcher = "0.3.7" +derivative = "2.2.0" diff --git a/cli/src/actors/client.rs b/cli/src/actors/client.rs deleted file mode 100644 index 045021b..0000000 --- a/cli/src/actors/client.rs +++ /dev/null @@ -1,192 +0,0 @@ -use std::time::Duration; - -use bytes::Bytes; -use handle_macro::Handle; -use remux_core::{ - comm, - events::{CliEvent, DaemonEvent}, - states::DaemonState, -}; -use tokio::{io::AsyncReadExt, net::UnixStream, sync::mpsc, time::interval}; -use tracing::{Instrument, debug}; - -use crate::{ - actors::ui::{UI, UIHandle}, - input_parser::{Action, InputParser, ParsedEvent}, - prelude::*, - utils::DisplayableVec, -}; - -#[derive(Handle)] -pub enum ClientEvent { - Selected(Option), // index of the selected item -} -use ClientEvent::*; - -#[derive(Debug)] -enum ClientState { - Normal, // running normally with stdin parsed into events and sent to daemon - SelectingSession, // means that the ui is currently busy selecting redirects stdin to ui selector -} - -#[derive(Debug)] -pub struct Client { - _handle: ClientHandle, // handle used to send the client events - stream: UnixStream, // the client owns the stream - rx: mpsc::Receiver, // receiver for client events - daemon_state: DaemonState, // determines if currently accepting events from daemon - sync_daemon_state: bool, // if the state is dirty only then do we need to sync to the ui - ui_stdin_tx: mpsc::Sender, // this is for popup actor to connect to stdin - ui_handle: UIHandle, // how the client sends messages to ui - input_parser: InputParser, // converts streams of bytes into actionable events - client_state: ClientState, // the current state of the client -} -impl Client { - #[instrument(skip(stream))] - pub fn spawn(stream: UnixStream, daemon_state: DaemonState) -> Result { - Client::new(stream, daemon_state)?.run() - } - - #[instrument(skip(stream))] - fn new(stream: UnixStream, daemon_state: DaemonState) -> Result { - let (tx, rx) = mpsc::channel(100); - let (ui_stdin_tx, ui_stdin_rx) = mpsc::channel(100); - let handle = ClientHandle { tx }; - let ui_handle = UI::spawn(handle.clone(), ui_stdin_rx)?; - Ok(Self { - _handle: handle, - stream, - rx, - ui_stdin_tx, - ui_handle, - daemon_state, - sync_daemon_state: true, - input_parser: InputParser::new(), - client_state: ClientState::Normal, - }) - } - - #[instrument(skip(self), fields(client_state = ?self.client_state))] - fn run(mut self) -> Result { - let task: CliTask = tokio::spawn({ - let span = tracing::Span::current(); - let mut stdin = tokio::io::stdin(); - let mut stdin_buf = [0u8; 1024]; - async move { - let mut ticker = interval(Duration::from_millis(1000)); - loop { - tokio::select! { - Some(event) = self.rx.recv() => { - match event { - Selected(index) => { - debug!("Selected: {index:?}"); - match self.client_state { - ClientState::Normal => { - error!("should not receive selected event in normal state"); - }, - ClientState::SelectingSession => { - if let Some(index) = index { - let selected_session = self.daemon_state.session_ids[index]; - debug!("sending session selection: {selected_session}"); - comm::send_event(&mut self.stream, CliEvent::SwitchSession(selected_session)).await.unwrap(); - } - debug!("Returning to normal state"); - self.client_state = ClientState::Normal; - } - } - } - } - }, - res = comm::recv_daemon_event(&mut self.stream) => { - match res { - Ok(event) => { - match event { - DaemonEvent::Raw(bytes) => { - trace!("DaemonEvent(Raw({bytes:?}))"); - self.ui_handle.output(bytes).await?; - } - DaemonEvent::Disconnected => { - debug!("DaemonEvent(Disconnected)"); - self.ui_handle.kill().await.unwrap(); - break; - } - DaemonEvent::CurrentSessions(session_ids) => { - debug!("DaemonEvent(CurrentSessions({session_ids:?}))"); - self.daemon_state.set_sessions(session_ids); - self.sync_daemon_state = true; - } - DaemonEvent::ActiveSession(session_id) => { - debug!("DaemonEvent(ActiveSession({session_id}))"); - self.daemon_state.set_active_session(session_id); - self.sync_daemon_state = true; - } - DaemonEvent::NewSession(session_id) => { - debug!("DaemonEvent(NewSession({session_id}))"); - self.daemon_state.add_session(session_id); - self.sync_daemon_state = true; - } - DaemonEvent::DeletedSession(_session_id) => { - todo!("implement delete session"); - } - } - } - Err(e) => { - error!("Error receiving daemon event: {e}"); - break; - } - } - } - stdin_res = stdin.read(&mut stdin_buf) => { - match stdin_res { - Ok(n) if n > 0 => { - match self.client_state { - ClientState::Normal => { - trace!("Sending {n} bytes to Daemon"); - for event in self.input_parser.process(&stdin_buf[..n]) { - match event { - ParsedEvent::DaemonAction(cli_event) => { - comm::send_event(&mut self.stream, cli_event).await?; - }, - ParsedEvent::LocalAction(local_action) => { - match local_action { - Action::SwitchSession => { - self.client_state = ClientState::SelectingSession; - let items = DisplayableVec::new(self.daemon_state.session_ids.clone()); - self.ui_handle.select_fuzzy(items, "Select Session".to_owned()).await.unwrap(); - }, - } - }, - } - } - - }, - ClientState::SelectingSession => { - trace!("Sending {n} bytes to ui"); - self.ui_stdin_tx.send(Bytes::copy_from_slice(&stdin_buf[..n])).await?; - }, - } - } - Ok(_) => { - break; - } - Err(e) => { - error!("Error receiving stdin: {e}"); - continue; - } - } - }, - _ = ticker.tick(), if self.sync_daemon_state => { - debug!("syncing daemon_state"); - self.ui_handle.sync_daemon_state(self.daemon_state.clone()).await?; - self.sync_daemon_state = false; - } - } - } - debug!("Client stopped"); - Ok(()) - }.instrument(span) - }); - - Ok(task) - } -} diff --git a/cli/src/actors/lua.rs b/cli/src/actors/lua.rs deleted file mode 100644 index 5d30f68..0000000 --- a/cli/src/actors/lua.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::{ - fs, - sync::{ - Arc, RwLock, - mpsc::{self, RecvTimeoutError}, - }, - time::Duration, -}; - -use mlua::Lua as MLua; -use remux_core::states::DaemonState; -use tokio::runtime::Handle; - -use crate::{actors::ui::UIHandle, prelude::*, states::status_line_state::StatusLineState}; - -pub enum LuaEvent { - Kill, - SyncDaemonState(DaemonState), -} -use LuaEvent::*; - -#[derive(Debug, Clone)] -pub struct LuaHandle { - tx: mpsc::Sender, -} -impl LuaHandle { - pub fn kill(&mut self) -> Result<()> { - Ok(self.tx.send(LuaEvent::Kill)?) - } - pub fn sync_daemon_state(&mut self, daemon_state: DaemonState) -> Result<()> { - Ok(self.tx.send(LuaEvent::SyncDaemonState(daemon_state))?) - } -} - -#[derive(Debug)] -pub struct Lua { - pub _handle: LuaHandle, - pub rx: mpsc::Receiver, - pub lua: MLua, - pub ui_handle: UIHandle, - pub daemon_state: Arc>, -} - -impl Lua { - fn new(ui_handle: UIHandle, handle: LuaHandle, rx: mpsc::Receiver) -> Self { - Self { - _handle: handle, - rx, - lua: MLua::new(), - ui_handle, - daemon_state: Arc::new(RwLock::new(DaemonState::default())), - } - } - - fn initialize_lua_state(&mut self) -> Result<()> { - // ui configurations - let sections_table = self.lua.create_table()?; - let section_a = self.lua.create_table()?; - let section_b = self.lua.create_table()?; - let section_c = self.lua.create_table()?; - sections_table.set("a", section_a)?; - sections_table.set("b", section_b)?; - sections_table.set("c", section_c)?; - let status_line_table = self.lua.create_table()?; - status_line_table.set("sections", sections_table)?; - status_line_table.set("enabled", true)?; - // 6. Create the parent 'ui' table (if it doesn't exist) - let ui_table = self.lua.create_table()?; - ui_table.set("status_line", status_line_table)?; - - let daemon_state_clone = self.daemon_state.clone(); - let get_active_session = self.lua.create_function(move |lua, ()| { - if let Ok(guard) = daemon_state_clone.read() { - if let Some(session_id) = guard.active_session { - Ok(mlua::Value::String(lua.create_string(session_id.to_string())?)) - } else { - Ok(mlua::Value::Nil) - } - } else { - Ok(mlua::Value::Nil) - } - })?; - - self.lua.globals().set("ui", ui_table)?; - self.lua.globals().set("get_active_session", get_active_session)?; - - Ok(()) - } - - fn resolve_status_line_state(&mut self) -> Result { - let ui_table: mlua::Table = self.lua.globals().get("ui")?; - let status_line_config: mlua::Table = ui_table.get("status_line")?; - let sections_config: mlua::Table = status_line_config.get("sections")?; - let enabled: mlua::Value = status_line_config.get("enabled")?; - - if let mlua::Value::Boolean(false) = enabled { - return Ok(StatusLineState::disabled()); - } - - let mut status_line_state = StatusLineState::default(); - for pair in sections_config.pairs::() { - let (key, val) = pair?; - for pair in val.pairs::() { - let (_, val) = pair?; - let resolved_string = match val { - mlua::Value::String(s) => { - // Item is a direct string - s.to_str()?.to_owned() - } - mlua::Value::Function(func) => { - // Item is a Lua function, execute it and get the string result - func.call::(())? - } - mlua::Value::Nil => { - // Ignore nil values which can happen in sparse tables - continue; - } - _ => { - warn!("Ignoring lua type"); - continue; - } - }; - - match key.as_str() { - "a" => { - status_line_state.a.push(resolved_string); - } - "b" => { - status_line_state.b.push(resolved_string); - } - "c" => { - status_line_state.c.push(resolved_string); - } - _ => {} - } - } - } - - Ok(status_line_state) - } - - pub fn spawn(ui_handle: UIHandle) -> Result { - let (tx, rx) = mpsc::channel(); - let handle = LuaHandle { tx }; - let handle_clone = handle.clone(); - tokio::task::spawn_blocking(|| { - let mut actor = Self::new(ui_handle, handle, rx); - actor.initialize_lua_state().unwrap(); - let code = fs::read_to_string("defaults/statusbar.lua").unwrap(); - let runtime = Handle::current(); - loop { - match actor.rx.recv_timeout(Duration::from_millis(500)) { - Ok(event) => match event { - Kill => { - debug!("killed"); - break; - } - SyncDaemonState(daemon_state) => { - if let Ok(mut guard) = actor.daemon_state.write() { - *guard = daemon_state; - } - } - }, - Err(RecvTimeoutError::Disconnected) => { - warn!("disconnected"); - break; - } - Err(RecvTimeoutError::Timeout) => {} - } - - actor.lua.load(&code).exec().unwrap(); - let status_line_state = actor.resolve_status_line_state().unwrap(); - let ui_handle_clone = actor.ui_handle.clone(); - let status_line_state_clone = status_line_state.clone(); - runtime - .block_on(async move { ui_handle_clone.sync_status_line_state(status_line_state_clone).await })?; - } - Ok::<(), Error>(()) - }); - - Ok(handle_clone) - } -} diff --git a/cli/src/actors/mod.rs b/cli/src/actors/mod.rs deleted file mode 100644 index 8563029..0000000 --- a/cli/src/actors/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod client; -pub mod lua; -pub mod ui; - -pub use client::Client; diff --git a/cli/src/actors/ui.rs b/cli/src/actors/ui.rs deleted file mode 100644 index daf2706..0000000 --- a/cli/src/actors/ui.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::{ - io::stdout, - sync::{Arc, RwLock}, - time::Duration, -}; - -use bytes::Bytes; -use crossterm::{ - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen}, -}; -use handle_macro::Handle; -use ratatui::{Terminal, prelude::CrosstermBackend}; -use remux_core::states::DaemonState; -use tokio::{ - sync::{broadcast, mpsc}, - time::interval, -}; -use tracing::Instrument; -use tui_term::widget::PseudoTerminal; -use vt100::Parser; - -use crate::{ - actors::{ - client::ClientHandle, - lua::{Lua, LuaHandle}, - }, - prelude::*, - states::status_line_state::StatusLineState, - utils::DisplayableVec, - widgets::{BasicSelector, FuzzySelector, Selector}, -}; - -#[derive(Handle)] -pub enum UIEvent { - Output(Bytes), - Kill, - SyncDaemonState(DaemonState), - SyncStatusLineState(StatusLineState), - SelectBasic { items: DisplayableVec, title: String }, - SelectFuzzy { items: DisplayableVec, title: String }, -} -use UIEvent::*; - -#[derive(Debug, PartialEq)] -enum UIState { - Normal, - SelectingBasic, - SelectingFuzzy, -} - -pub struct UI { - // for communication - handle: UIHandle, - rx: mpsc::Receiver, - // state for rendering - daemon_state: DaemonState, - status_line_state: StatusLineState, - parser: Parser, - lua_handle: LuaHandle, - client_handle: ClientHandle, - ui_state: UIState, - basic_selector: Arc>, - fuzzy_selector: Arc>, - selector_rx: mpsc::Receiver>, - stdin_rx: mpsc::Receiver, -} - -impl UI { - #[instrument(skip(client_handle, stdin_rx))] - pub fn spawn(client_handle: ClientHandle, stdin_rx: mpsc::Receiver) -> Result { - Self::new(client_handle, stdin_rx)?.run() - } - #[instrument(skip(client_handle, stdin_rx))] - fn new(client_handle: ClientHandle, stdin_rx: mpsc::Receiver) -> Result { - let (tx, rx) = mpsc::channel(100); - let handle = UIHandle { tx }; - let parser = vt100::Parser::default(); - let lua_handle = Lua::spawn(handle.clone())?; - let (selector_tx, selector_rx) = mpsc::channel(100); - Ok(Self { - daemon_state: DaemonState::default(), - status_line_state: StatusLineState::default(), - rx, - handle, - parser, - lua_handle, - client_handle, - ui_state: UIState::Normal, - basic_selector: BasicSelector::new(selector_tx.clone()), - fuzzy_selector: FuzzySelector::new(selector_tx.clone()), - selector_rx, - stdin_rx, - }) - } - #[instrument(skip(self), fields(ui_state = ?self.ui_state))] - pub fn run(mut self) -> Result { - let span = tracing::Span::current(); - - let handle_clone = self.handle.clone(); - let mut term = Terminal::new(CrosstermBackend::new(stdout())).unwrap(); - let (selector_tx, _) = broadcast::channel(10000); - tokio::spawn({ - let selector_tx = selector_tx.clone(); - async move { - let mut ticker = interval(Duration::from_millis(16)); - execute!(stdout(), EnterAlternateScreen)?; - term.clear()?; - loop { - tokio::select! { - Some(event) = self.rx.recv() => { - match event { - Output(bytes) => { - self.parser.process(&bytes); - } - SyncDaemonState(daemon_state) => { - self.lua_handle.sync_daemon_state(daemon_state.clone()).unwrap(); - self.daemon_state = daemon_state; - } - SyncStatusLineState(status_line_state) => { - self.status_line_state = status_line_state; - } - SelectBasic{items, title} => { - self.ui_state = UIState::SelectingBasic; - BasicSelector::run(&self.basic_selector, selector_tx.subscribe(), items, title).unwrap(); - } - SelectFuzzy{items, title} => { - self.ui_state = UIState::SelectingFuzzy; - FuzzySelector::run(&self.fuzzy_selector, selector_tx.subscribe(), items, title).unwrap(); - } - Kill => { - self.lua_handle.kill().unwrap(); - break; - } - } - } - Some(bytes) = self.stdin_rx.recv(), if matches!(self.ui_state, UIState::SelectingFuzzy | UIState::SelectingBasic) => { - selector_tx.send(bytes).unwrap(); - } - Some(index) = self.selector_rx.recv() => { - debug!("selected index({index:?})"); - self.client_handle.selected(index).await.unwrap(); - self.ui_state = UIState::Normal; - } - _ = ticker.tick() => { - let screen = self.parser.screen(); - term.draw(|f| { - use ratatui::{ - layout::Constraint, layout::Direction, layout::Layout, - }; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), // pseudo terminal takes everything else - Constraint::Length(1), // bottom status bar - ]) - .split(f.area()); - - // render the normal terminal output - let term_ui = PseudoTerminal::new(screen); - f.render_widget(term_ui, chunks[0]); - - // render the status bar - self.status_line_state.render(f, chunks[1]); - - // render selector if active - if self.ui_state == UIState::SelectingBasic { - BasicSelector::render(&self.basic_selector, f); - } - if self.ui_state == UIState::SelectingFuzzy { - FuzzySelector::render(&self.fuzzy_selector, f); - } - - }).unwrap(); - } - } - } - debug!("ui stopped"); - execute!(stdout(), LeaveAlternateScreen)?; - Ok::<(), Error>(()) - } - .instrument(span) - }); - - Ok(handle_clone) - } -} diff --git a/cli/src/app.rs b/cli/src/app.rs new file mode 100644 index 0000000..4bf5b1f --- /dev/null +++ b/cli/src/app.rs @@ -0,0 +1,300 @@ +use std::{fmt::Debug, io::Stdout, time::Duration}; + +use bytes::Bytes; +use derivative::Derivative; +use ratatui::{Terminal, prelude::CrosstermBackend, restore, widgets::ListState}; +use remux_core::{ + comm, + events::{CliEvent, DaemonEvent}, + states::DaemonState, +}; +use terminput::Event; +use tokio::{ + net::UnixStream, + sync::{broadcast, mpsc}, + time::interval, +}; +use vt100::Parser; + +use crate::{ + input_parser::{self, InputParser}, + prelude::*, + states::status_line_state::StatusLineState, + tasks::{ + input::{self, Input}, + lua, + }, + ui::{ + self, basic_selector_widget::BasicSelectorWidget, fuzzy_selector_widget::FuzzySelectorWidget, + traits::SelectorStatefulWidget, + }, +}; + +#[derive(Derivative)] +#[derivative(Debug)] +pub struct TerminalState { + #[derivative(Debug = "ignore")] + pub emulator: Parser, + pub size: (u16, u16), + pub needs_resize: bool, +} + +#[derive(Debug)] +pub struct UiState { + pub selector: SelectorState, + pub status_line: StatusLineState, +} + +#[derive(Debug)] +pub enum SelectorType { + Basic, + Fuzzy, +} + +#[derive(Debug, Clone)] +pub struct IndexedItem { + pub index: usize, + pub item: String, +} + +impl IndexedItem { + pub fn new(index: usize, item: String) -> Self { + Self { index, item } + } +} + +#[derive(Debug)] +pub struct SelectorState { + pub selector_type: SelectorType, + pub list_state: ListState, + pub list: Vec, + pub query: String, + // selector might filter out some items - so we need to + // maintain it's original index to be able to return it + pub displaying_list: Vec, +} + +#[derive(Debug)] +pub enum AppMode { + Normal, + SelectingSession, +} + +#[derive(Debug)] +pub struct AppState { + pub terminal: TerminalState, + pub daemon: DaemonState, + pub ui: UiState, + pub mode: AppMode, +} + +pub struct App { + pub state: AppState, + input_parser: InputParser, + stream: UnixStream, + bg_tasks: Vec, +} + +impl App { + pub fn new(stream: UnixStream, daemon_state: DaemonState) -> Self { + Self { + stream, + input_parser: InputParser::default(), + state: AppState { + mode: AppMode::Normal, + terminal: TerminalState { + emulator: Parser::default(), + size: (0, 0), + needs_resize: true, + }, + daemon: daemon_state, + ui: UiState { + selector: SelectorState { + list_state: ListState::default(), + list: Vec::new(), + selector_type: SelectorType::Basic, + query: String::new(), + displaying_list: Vec::new(), + }, + status_line: StatusLineState::default(), + }, + }, + bg_tasks: Vec::new(), + } + } + + #[instrument(skip(self))] + pub async fn run(&mut self) -> Result<()> { + let mut term = ratatui::init(); + debug!("starting app"); + let (input_tx, mut input_rx) = mpsc::channel::(100); + let (lua_tx, mut lua_rx) = broadcast::channel(100); + self.bg_tasks.extend(input::start_input_listeners(input_tx)); + self.bg_tasks.push(lua::start_status_line_task(lua_tx)); + let mut ticker = interval(Duration::from_millis(50)); + debug!("Enabled raw mode"); + // execute!(stdout(), EnterAlternateScreen)?; + debug!("Entered alternate screen"); + + // need an initial render since ui updates app state to convey terminal size information + term.draw(|f| ui::draw(f, &mut self.state)).unwrap(); + loop { + if self.state.terminal.needs_resize { + let (rows, cols) = self.state.terminal.size; + debug!("setting terminal emulator size (rows={rows}, cols={cols})"); + self.state.terminal.emulator.set_size(rows, cols); + self.state.terminal.needs_resize = false; + let (rows, cols) = self.state.terminal.size; + comm::send_event(&mut self.stream, CliEvent::TerminalResize { rows, cols }) + .await + .unwrap(); + } + tokio::select! { + Some(input) = input_rx.recv() => { + use Input::{Stdin, Resize}; + match input { + Stdin(bytes) => { + trace!("stdin({bytes:?}"); + self.dispatch_stdin(bytes).await; + } + Resize => { + debug!("resize"); + self.handle_resize(&mut term).await; + } + } + } + Ok(mut status_line_state) = lua_rx.recv() => { + debug!("received status line state"); + status_line_state.apply_built_ins(&self.state); + self.state.ui.status_line = status_line_state; + } + res = comm::recv_daemon_event(&mut self.stream) => { + match res { + Ok(event) => { + match event { + DaemonEvent::Raw(bytes) => { + trace!("DaemonEvent(Raw({bytes:?}))"); + self.state.terminal.emulator.process(&bytes); + } + DaemonEvent::Disconnected => { + debug!("DaemonEvent(Disconnected)"); + break; + } + DaemonEvent::ActiveSession(session_id) => { + debug!("DaemonEvent(ActiveSession({session_id}))"); + self.state.daemon.set_active_session(session_id); + } + DaemonEvent::NewSession(session_id) => { + debug!("DaemonEvent(NewSession({session_id}))"); + self.state.daemon.add_session(session_id); + } + _ => { + todo!(); + } + // DaemonEvent::DeletedSession(_session_id) => { + // todo!("implement delete session"); + // } + } + } + Err(e) => { + error!("Error receiving daemon event: {e}"); + break; + } + } + } + _ = ticker.tick() => { + term.draw(|f| ui::draw(f, &mut self.state)).unwrap(); + } + } + } + for task in self.bg_tasks.drain(..) { + task.abort(); + let _ = task.await; + } + drop(term); + restore(); + debug!("Restoring terminal"); + Ok(()) + } + + #[instrument(skip(self, bytes))] + async fn dispatch_stdin(&mut self, bytes: Bytes) { + match self.state.mode { + AppMode::Normal => self.handle_stdin_for_normal_mode(bytes).await, + AppMode::SelectingSession => self.handle_stdin_for_selecting_mode(bytes).await, + } + } + + async fn handle_stdin_for_selecting_mode(&mut self, bytes: Bytes) { + let event = Event::parse_from(&bytes).unwrap().unwrap(); + let selection_opt = match self.state.ui.selector.selector_type { + SelectorType::Basic => BasicSelectorWidget::input(event, &mut self.state.ui.selector), + SelectorType::Fuzzy => FuzzySelectorWidget::input(event, &mut self.state.ui.selector), + }; + if let Some(selection) = selection_opt { + match selection { + ui::traits::Selection::Index(i) => match self.state.mode { + AppMode::SelectingSession => { + let session = self.state.ui.selector.list[i].parse::().unwrap(); + comm::send_event(&mut self.stream, CliEvent::SwitchSession(session)) + .await + .unwrap(); + } + AppMode::Normal => {} + }, + ui::traits::Selection::Cancelled => {} + } + self.state.mode = AppMode::Normal; + self.state.ui.selector.list_state.select(Some(0)); + self.state.ui.selector.list.clear(); + } + } + + async fn handle_stdin_for_normal_mode(&mut self, bytes: Bytes) { + for parsed_event in self.input_parser.process(&bytes) { + match parsed_event { + input_parser::ParsedEvent::LocalAction(action) => { + self.dispatch_action(action).await; + } + input_parser::ParsedEvent::DaemonAction(cli_event) => { + debug!("sending cli event: {cli_event:?}"); + comm::send_event(&mut self.stream, cli_event).await.unwrap(); + } + } + } + } + + async fn dispatch_action(&mut self, action: input_parser::Action) { + match action { + input_parser::Action::SwitchSession => { + self.state.mode = AppMode::SelectingSession; + self.state.ui.selector.query.clear(); + self.state.ui.selector.list_state.select(Some(0)); + self.state.ui.selector.selector_type = SelectorType::Fuzzy; + self.state + .ui + .selector + .list + .extend(self.state.daemon.session_ids.iter().map(|x| x.to_string())); + self.state.ui.selector.displaying_list = self + .state + .ui + .selector + .list + .iter() + .enumerate() + .map(|(i, x)| IndexedItem::new(i, x.clone())) + .collect(); + } + } + } + + #[instrument(skip(self, term))] + async fn handle_resize(&mut self, term: &mut Terminal>) { + self.state.terminal.needs_resize = true; + term.draw(|f| { + ui::draw(f, &mut self.state); + }) + .unwrap(); + } +} diff --git a/cli/src/error.rs b/cli/src/error.rs index 200d2b6..76657ec 100644 --- a/cli/src/error.rs +++ b/cli/src/error.rs @@ -2,7 +2,7 @@ use bytes::Bytes; use thiserror::Error; use tokio::sync::mpsc::error::SendError; -use crate::actors::{client::ClientEvent, lua::LuaEvent, ui::UIEvent}; +// use crate::actors::{client::ClientEvent, lua::LuaEvent, ui::UIEvent}; pub type Result = std::result::Result; @@ -37,12 +37,11 @@ pub enum Error { #[error("Lua error: {0}")] Lua(String), + // #[error("Event Send Error: {0}")] + // EventSend(EventSendError), - #[error("Event Send Error: {0}")] - EventSend(EventSendError), - - #[error("Sync Send Error: {0}")] - SyncSend(#[from] std::sync::mpsc::SendError), + // #[error("Sync Send Error: {0}")] + // SyncSend(#[from] std::sync::mpsc::SendError), } impl From for Error { @@ -51,42 +50,42 @@ impl From for Error { } } -#[derive(Error, Debug)] -pub enum EventSendError { - #[error("IO send error: {0}")] - IO(SendError), - #[error("UI send error: {0}")] - UI(SendError), - #[error("Bytes send error: {0}")] - Bytes(SendError), - // #[error("WidgetRunner send error: {0}")] - // WidgetRunner(SendError), - // #[error("LuaActor send error: {0}")] - // LuaActor(SendError), -} - -impl From> for Error { - fn from(e: SendError) -> Self { - Self::EventSend(EventSendError::IO(e)) - } -} -impl From> for Error { - fn from(e: SendError) -> Self { - Self::EventSend(EventSendError::UI(e)) - } -} -impl From> for Error { - fn from(e: SendError) -> Self { - Self::EventSend(EventSendError::Bytes(e)) - } -} -// impl From> for Error { -// fn from(e: SendError) -> Self { -// Self::EventSend(EventSendError::WidgetRunner(e)) +// #[derive(Error, Debug)] +// pub enum EventSendError { +// #[error("IO send error: {0}")] +// IO(SendError), +// #[error("UI send error: {0}")] +// UI(SendError), +// #[error("Bytes send error: {0}")] +// Bytes(SendError), +// // #[error("WidgetRunner send error: {0}")] +// // WidgetRunner(SendError), +// // #[error("LuaActor send error: {0}")] +// // LuaActor(SendError), +// } +// +// impl From> for Error { +// fn from(e: SendError) -> Self { +// Self::EventSend(EventSendError::IO(e)) +// } +// } +// impl From> for Error { +// fn from(e: SendError) -> Self { +// Self::EventSend(EventSendError::UI(e)) // } // } -// impl From> for Error { -// fn from(e: SendError) -> Self { -// Self::EventSend(EventSendError::LuaActor(e)) +// impl From> for Error { +// fn from(e: SendError) -> Self { +// Self::EventSend(EventSendError::Bytes(e)) // } // } +// // impl From> for Error { +// // fn from(e: SendError) -> Self { +// // Self::EventSend(EventSendError::WidgetRunner(e)) +// // } +// // } +// // impl From> for Error { +// // fn from(e: SendError) -> Self { +// // Self::EventSend(EventSendError::LuaActor(e)) +// // } +// // } diff --git a/cli/src/input_parser/parser.rs b/cli/src/input_parser/parser.rs index 54a994c..37241cc 100644 --- a/cli/src/input_parser/parser.rs +++ b/cli/src/input_parser/parser.rs @@ -17,16 +17,12 @@ const S: u8 = 0x73; const X: u8 = 0x78; const D: u8 = 0x64; -#[derive(Debug)] +#[derive(Debug, Default)] pub struct InputParser { buf: Vec, } impl InputParser { - pub fn new() -> Self { - Self { buf: vec![] } - } - pub fn process(&mut self, input: &[u8]) -> Vec { use ParsedEvent::{DaemonAction, LocalAction}; self.buf.extend(input); diff --git a/cli/src/main.rs b/cli/src/main.rs index 93ad54d..2d85c22 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,14 +1,16 @@ -mod actors; +mod app; mod args; mod error; mod input_parser; mod prelude; mod states; -mod utils; -mod widgets; +mod tasks; +mod ui; + +use std::fs::File; use clap::Parser; -use ratatui::crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use ratatui::crossterm::terminal::disable_raw_mode; use remux_core::{ comm, daemon_utils::get_sock_path, @@ -20,7 +22,7 @@ use remux_core::{ use tokio::net::UnixStream; use crate::{ - actors::Client, + app::App, args::{Args, Commands}, error::{Error, Result}, prelude::*, @@ -49,14 +51,12 @@ async fn main() { } fn setup_logging() -> Result { - use tracing_appender::{non_blocking, rolling}; + use tracing_appender::non_blocking; use tracing_subscriber::{EnvFilter, fmt}; - let file_appender = rolling::daily("logs", "remux-cli.log"); - let (non_blocking, guard) = non_blocking(file_appender); - + let file = File::create("./logs/remux-cli.log").unwrap(); + let (non_blocking, guard) = non_blocking(file); let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")); - let subscriber = fmt().with_writer(non_blocking).with_env_filter(env_filter).finish(); tracing::subscriber::set_global_default(subscriber)?; Ok(guard) @@ -74,20 +74,6 @@ async fn connect() -> Result { }) } -// #[instrument(skip(stream))] -// async fn handle_session_command(mut stream: UnixStream, command: SessionCommands) -> Result<()> { -// let req: Request = command.into(); -// let res: Response = comm::send_and_recv(&mut stream, &req).await?; -// assert_eq!(res.status, ResponseStatus::Ok); -// match res.body { -// ResponseBody::SessionsList { sessions } => { -// println!("{sessions:?}"); -// } -// _ => {} -// } -// Ok(()) -// } - #[instrument] async fn run(command: Commands) -> Result<()> { let stream = connect().await?; @@ -105,7 +91,6 @@ async fn run(command: Commands) -> Result<()> { ) .await } - // Commands::Session { action } => handle_session_command(stream, action).await, _ => todo!(), } } @@ -117,20 +102,10 @@ async fn attach(mut stream: UnixStream, attach_request: CliRequestMessage { - error!("Error joining client task: {e}"); - } - Err(e) => { - error!("Error joining client task: {e}"); - } - _ => {} - } - } + debug!("Starting app"); + let mut app = App::new(stream, res.initial_daemon_state); + app.run().await?; + debug!("App terminated"); disable_raw_mode()?; - debug!("Disabled raw mode"); Ok(()) } diff --git a/cli/src/states/status_line_state.rs b/cli/src/states/status_line_state.rs index fcd03f9..8c31dd4 100644 --- a/cli/src/states/status_line_state.rs +++ b/cli/src/states/status_line_state.rs @@ -1,8 +1,4 @@ -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - widgets::Paragraph, -}; +use crate::app::AppState; #[derive(Debug, Clone)] pub struct StatusLineState { @@ -29,33 +25,16 @@ impl StatusLineState { ..Default::default() } } -} -impl StatusLineState { - pub fn render(&self, f: &mut Frame, area: Rect) { - if !self.enabled || area.height < 1 { - return; + pub fn apply_built_ins(&mut self, state: &AppState) { + for item in self.a.iter_mut().chain(self.b.iter_mut()).chain(self.c.iter_mut()) { + if item.as_str() == "active-session" { + if let Some(s) = state.daemon.active_session.map(|s| s.to_string()) { + *item = s; + } else { + *item = "".to_owned(); + } + } } - - // 1. Create 3 horizontal constraints for Left, Center, Right sections - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(area.width / 3), // Fixed or Length for A - Constraint::Min(0), // Remaining space for B (Center) - Constraint::Length(area.width / 3), // Fixed or Length for C - ]) - .split(area); - - let separator = " | "; - let text_a = self.a.join(separator); - let text_b = self.b.join(separator); - let text_c = self.c.join(separator); - let paragraph_a = Paragraph::new(text_a); - f.render_widget(paragraph_a, chunks[0]); - let paragraph_b = Paragraph::new(text_b).alignment(ratatui::layout::Alignment::Center); - f.render_widget(paragraph_b, chunks[1]); - let paragraph_c = Paragraph::new(text_c).alignment(ratatui::layout::Alignment::Right); - f.render_widget(paragraph_c, chunks[2]); } } diff --git a/cli/src/tasks/input.rs b/cli/src/tasks/input.rs new file mode 100644 index 0000000..f3deb0e --- /dev/null +++ b/cli/src/tasks/input.rs @@ -0,0 +1,50 @@ +use bytes::Bytes; +use tokio::{ + io::AsyncReadExt, + signal::unix::{SignalKind, signal}, + sync::mpsc, +}; + +use crate::prelude::*; + +#[derive(Debug)] +pub enum Input { + Stdin(Bytes), + Resize, +} + +pub fn start_input_listeners(tx: mpsc::Sender) -> Vec { + let task1: CliTask = tokio::spawn({ + let tx = tx.clone(); + async move { + let mut stdin = tokio::io::stdin(); + let mut buf = [0u8; 1024]; + loop { + match stdin.read(&mut buf).await { + Ok(n) if n > 0 => { + trace!("read {} bytes from stdin", n); + tx.send(Input::Stdin(Bytes::copy_from_slice(&buf[..n]))).await.unwrap(); + } + Ok(_) => { + break; + } + Err(e) => { + error!("Error receiving stdin: {e}"); + continue; + } + } + } + Ok(()) + } + }); + + let task2: CliTask = tokio::spawn(async move { + let mut sigwinch = signal(SignalKind::window_change()).unwrap(); + while sigwinch.recv().await.is_some() { + tx.send(Input::Resize).await.unwrap(); + } + Ok(()) + }); + + vec![task1, task2] +} diff --git a/cli/src/tasks/lua.rs b/cli/src/tasks/lua.rs new file mode 100644 index 0000000..f9e7d0a --- /dev/null +++ b/cli/src/tasks/lua.rs @@ -0,0 +1,92 @@ +use std::{fs, time::Duration}; + +use mlua::Lua; +use tokio::sync::broadcast; + +use crate::{prelude::*, states::status_line_state::StatusLineState}; + +fn initialize_lua_state(lua: &mut Lua) -> Result<()> { + info!("Initializing lua state"); + let sections_table = lua.create_table()?; + let section_a = lua.create_table()?; + let section_b = lua.create_table()?; + let section_c = lua.create_table()?; + sections_table.set("a", section_a)?; + sections_table.set("b", section_b)?; + sections_table.set("c", section_c)?; + let status_line_table = lua.create_table()?; + status_line_table.set("sections", sections_table)?; + status_line_table.set("enabled", true)?; + let ui_table = lua.create_table()?; + ui_table.set("status_line", status_line_table)?; + lua.globals().set("ui", ui_table)?; + Ok(()) +} + +pub fn start_status_line_task(tx: broadcast::Sender) -> CliTask { + let mut lua = Lua::default(); + initialize_lua_state(&mut lua).unwrap(); + let code = fs::read_to_string("defaults/statusbar.lua").unwrap(); + + info!("Starting lua status line task"); + let task: CliTask = tokio::spawn({ + async move { + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + lua.load(&code).exec().unwrap(); + let ui_table: mlua::Table = lua.globals().get("ui")?; + let status_line_config: mlua::Table = ui_table.get("status_line")?; + let sections_config: mlua::Table = status_line_config.get("sections")?; + let enabled: mlua::Value = status_line_config.get("enabled")?; + + if let mlua::Value::Boolean(false) = enabled { + let _ = tx.send(StatusLineState::disabled()); + continue; + } + + let mut status_line_state = StatusLineState::default(); + for pair in sections_config.pairs::() { + let (key, val) = pair?; + for pair in val.pairs::() { + let (_, val) = pair?; + let resolved_string = match val { + mlua::Value::String(s) => { + // Item is a direct string + s.to_str()?.to_owned() + } + mlua::Value::Function(func) => { + // Item is a Lua function, execute it and get the string result + func.call::(())? + } + mlua::Value::Nil => { + // Ignore nil values which can happen in sparse tables + continue; + } + _ => { + warn!("Ignoring lua type"); + continue; + } + }; + + match key.as_str() { + "a" => { + status_line_state.a.push(resolved_string); + } + "b" => { + status_line_state.b.push(resolved_string); + } + "c" => { + status_line_state.c.push(resolved_string); + } + _ => {} + } + } + } + let _ = tx.send(status_line_state); + } + // Ok::<(), Error>(()) + } + }); + + task +} diff --git a/cli/src/tasks/mod.rs b/cli/src/tasks/mod.rs new file mode 100644 index 0000000..c21f31f --- /dev/null +++ b/cli/src/tasks/mod.rs @@ -0,0 +1,2 @@ +pub mod input; +pub mod lua; diff --git a/cli/src/ui/basic_selector_widget.rs b/cli/src/ui/basic_selector_widget.rs new file mode 100644 index 0000000..5f9e15e --- /dev/null +++ b/cli/src/ui/basic_selector_widget.rs @@ -0,0 +1,88 @@ +use ratatui::widgets::StatefulWidget; + +use crate::{ + app::SelectorState, + prelude::*, + ui::traits::{Selection, SelectorStatefulWidget}, +}; + +#[derive(Debug, Default)] +pub struct BasicSelectorWidget {} + +impl StatefulWidget for BasicSelectorWidget { + type State = SelectorState; + + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer, state: &mut Self::State) { + use ratatui::{ + layout::Rect, + prelude::Stylize, + style::{Modifier, Style}, + widgets::{Block, Borders, List}, + }; + let list_state = &mut state.list_state; + let list = &state.list; + + // Calculate popup size (width and height) + let width = (area.width / 2).min(50); // max width 50 + let height = (list.len() as u16 + 2).min(area.height / 2); // +2 for padding/border + let x = (area.width.saturating_sub(width)) / 2; + let y = (area.height.saturating_sub(height)) / 2; + let rect = Rect::new(x, y, width, height); + let list = List::new(list.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().bold()) + .title("Select") + .title_alignment(ratatui::layout::Alignment::Center), + ) + .highlight_symbol(">> ") + .highlight_style( + Style::default() + .fg(ratatui::style::Color::Green) + .add_modifier(Modifier::BOLD), + ); + + list.render(rect, buf, list_state); + } +} + +impl SelectorStatefulWidget for BasicSelectorWidget { + fn input(event: terminput::Event, state: &mut Self::State) -> Option { + use terminput::KeyCode::*; + + let list_state = &mut state.list_state; + let list = &state.list; + + match event { + terminput::Event::Key(key_event) => match key_event.code { + Up | Char('k') => { + let i = match list_state.selected() { + Some(i) if i > 0 => i - 1, + _ => 0, + }; + list_state.select(Some(i)); + None + } + Down | Char('j') => { + let i = match list_state.selected() { + Some(i) if i < list.len() - 1 => i + 1, + _ => list.len() - 1, + }; + list_state.select(Some(i)); + None + } + Enter => { + debug!("enter pressed"); + list_state.selected().map(Selection::Index) + } + Esc | Char('q') => { + debug!("esc or q pressed"); + Some(Selection::Cancelled) + } + _ => None, + }, + _ => None, + } + } +} diff --git a/cli/src/ui/fuzzy_selector_widget.rs b/cli/src/ui/fuzzy_selector_widget.rs new file mode 100644 index 0000000..e1831aa --- /dev/null +++ b/cli/src/ui/fuzzy_selector_widget.rs @@ -0,0 +1,157 @@ +use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; +use ratatui::widgets::{Padding, StatefulWidget}; +use terminput::KeyCode; + +use crate::{ + app::{IndexedItem, SelectorState}, + prelude::*, + ui::traits::{Selection, SelectorStatefulWidget}, +}; + +#[derive(Debug, Default)] +pub struct FuzzySelectorWidget {} + +impl StatefulWidget for FuzzySelectorWidget { + type State = SelectorState; + + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer, state: &mut Self::State) { + use ratatui::{ + prelude::*, + style::{Modifier, Style}, + widgets::{Block, Borders, List, Paragraph}, + }; + + let mut list_state = &mut state.list_state; + let list = &state.list; + let filtered_items = state + .displaying_list + .iter() + .map(|pair| pair.item.clone()) + .collect::>(); + let query = &state.query; + + let width = area.width / 2; + let height = area.height / 2; + let x = (area.width.saturating_sub(width)) / 2; + let y = (area.height.saturating_sub(height)) / 2; + let rect = Rect::new(x, y, width, height); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(rect); + + let max_height = chunks[0].height; + let items_height = filtered_items.len() as u16 + 2; + let subchunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(max_height - items_height), + Constraint::Length(items_height), + ]) + .split(chunks[0]); + + let list = List::new(filtered_items) + .block(Block::default().padding(Padding::uniform(1))) + .highlight_symbol(">> ") + .highlight_style( + Style::default() + .fg(ratatui::style::Color::Green) + .add_modifier(Modifier::BOLD), + ); + + let display_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().bold()) + .title("Selecting") + .title_alignment(ratatui::layout::Alignment::Center); + + let display_query = Paragraph::new(query.clone()).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().bold()) + .padding(Padding::horizontal(1)), + ); + + display_block.render(chunks[0], buf); + StatefulWidget::render(&list, subchunks[1], buf, &mut list_state); + display_query.render(chunks[1], buf); + } +} + +impl SelectorStatefulWidget for FuzzySelectorWidget { + fn input(event: terminput::Event, state: &mut Self::State) -> Option { + use terminput::KeyCode::*; + + let list_state = &mut state.list_state; + let list = state + .list + .iter() + .enumerate() + .map(|(i, x)| IndexedItem::new(i, x.clone())) + .collect::>(); + let filtered_list = &mut state.displaying_list; + let query = &mut state.query; + let matcher = SkimMatcherV2::default(); + + match event { + terminput::Event::Key(key_event) => match key_event.code { + Enter => { + debug!("enter pressed"); + let selected_index = list_state.selected(); + // Map the current filtered index to the original index + let original_index = selected_index + .and_then(|i| filtered_list.get(i)) + .map(|item| item.index) + .unwrap(); + + Some(Selection::Index(original_index)) + } + Esc => { + debug!("esc pressed"); + Some(Selection::Cancelled) + } + Up => { + let current_index = list_state.selected().unwrap_or(0); + let i = current_index.saturating_sub(1); + list_state.select(Some(i)); + None + } + Down => { + let max_index = filtered_list.len().saturating_sub(1); + let current_index = list_state.selected().unwrap_or(0); + let i = (current_index + 1).min(max_index); + list_state.select(Some(i)); + None + } + Backspace => { + query.pop(); + *filtered_list = filter_items(&matcher, query, &list); + let opt = Some(filtered_list.len().saturating_sub(1)); + list_state.select(opt); + None + } + Char(c) => { + query.push(c); + *filtered_list = filter_items(&matcher, query, &list); + let opt = Some(filtered_list.len().saturating_sub(1)); + list_state.select(opt); + None + } + _ => None, + }, + _ => None, + } + } +} + +fn filter_items(matcher: &SkimMatcherV2, query: &str, items: &[IndexedItem]) -> Vec { + let mut filtered_items = items + .iter() + .filter_map(|x| matcher.fuzzy_match(&x.item, query).map(|score| (score, x.clone()))) + .collect::>(); + + filtered_items.sort_by(|a, b| b.0.cmp(&a.0)); + filtered_items.reverse(); + filtered_items.into_iter().map(|x| x.1).collect::>() +} diff --git a/cli/src/ui/mod.rs b/cli/src/ui/mod.rs new file mode 100644 index 0000000..d0e7d96 --- /dev/null +++ b/cli/src/ui/mod.rs @@ -0,0 +1,7 @@ +pub mod basic_selector_widget; +pub mod fuzzy_selector_widget; +mod status_line_widget; +pub mod traits; +mod ui; + +pub use ui::draw; diff --git a/cli/src/ui/status_line_widget.rs b/cli/src/ui/status_line_widget.rs new file mode 100644 index 0000000..02bc282 --- /dev/null +++ b/cli/src/ui/status_line_widget.rs @@ -0,0 +1,48 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout}, + widgets::{Paragraph, Widget}, +}; +use tracing::info; + +use crate::states::status_line_state::StatusLineState; + +pub struct StatusLineWidget { + state: StatusLineState, +} + +impl StatusLineWidget { + pub fn new(state: StatusLineState) -> Self { + Self { state } + } +} + +impl Widget for StatusLineWidget { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { + if !self.state.enabled || area.height < 1 { + return; + } + + info!("rendering status line widget"); + + // 1. Create 3 horizontal constraints for Left, Center, Right sections + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(area.width / 3), // Fixed or Length for A + Constraint::Min(0), // Remaining space for B (Center) + Constraint::Length(area.width / 3), // Fixed or Length for C + ]) + .split(area); + + let separator = " | "; + let text_a = self.state.a.join(separator); + let text_b = self.state.b.join(separator); + let text_c = self.state.c.join(separator); + let paragraph_a = Paragraph::new(text_a); + paragraph_a.render(chunks[0], buf); + let paragraph_b = Paragraph::new(text_b).alignment(ratatui::layout::Alignment::Center); + paragraph_b.render(chunks[1], buf); + let paragraph_c = Paragraph::new(text_c).alignment(ratatui::layout::Alignment::Right); + paragraph_c.render(chunks[2], buf); + } +} diff --git a/cli/src/ui/traits.rs b/cli/src/ui/traits.rs new file mode 100644 index 0000000..171794f --- /dev/null +++ b/cli/src/ui/traits.rs @@ -0,0 +1,11 @@ +use ratatui::widgets::StatefulWidget; +use terminput::Event; + +pub enum Selection { + Index(usize), + Cancelled, +} + +pub trait SelectorStatefulWidget: StatefulWidget { + fn input(event: Event, state: &mut Self::State) -> Option; +} diff --git a/cli/src/ui/ui.rs b/cli/src/ui/ui.rs new file mode 100644 index 0000000..fc39a49 --- /dev/null +++ b/cli/src/ui/ui.rs @@ -0,0 +1,49 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, +}; +use tui_term::widget::PseudoTerminal; + +use crate::{ + app::{AppMode, AppState}, + prelude::*, + ui::{ + basic_selector_widget::BasicSelectorWidget, fuzzy_selector_widget::FuzzySelectorWidget, + status_line_widget::StatusLineWidget, + }, +}; + +#[instrument(skip(f))] +pub fn draw(f: &mut Frame, state: &mut AppState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // pseudo terminal takes everything else + Constraint::Length(1), // bottom status bar + ]) + .split(f.area()); + + // render the normal terminal output + let term_area = chunks[0]; + state.terminal.size = (term_area.height, term_area.width); + trace!("rendering terminal into rect: {term_area}"); + let term_ui = PseudoTerminal::new(state.terminal.emulator.screen()); + f.render_widget(term_ui, term_area); + + // render the status bar + let status_line = StatusLineWidget::new(state.ui.status_line.clone()); + f.render_widget(status_line, chunks[1]); + + if let AppMode::SelectingSession = state.mode { + match state.ui.selector.selector_type { + crate::app::SelectorType::Basic => { + let popup = BasicSelectorWidget::default(); + f.render_stateful_widget(popup, f.area(), &mut state.ui.selector); + } + crate::app::SelectorType::Fuzzy => { + let popup = FuzzySelectorWidget::default(); + f.render_stateful_widget(popup, f.area(), &mut state.ui.selector); + } + } + } +} diff --git a/cli/src/utils.rs b/cli/src/utils.rs deleted file mode 100644 index 9cfa004..0000000 --- a/cli/src/utils.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::sync::Arc; - -pub struct DisplayableVec { - inner: Arc Vec + Send + Sync>, -} - -impl DisplayableVec { - pub fn new(vec: Vec) -> Self { - Self { - inner: Arc::new(move || vec.iter().map(|x| x.to_string()).collect()), - } - } - - pub fn to_strings(&self) -> Vec { - (self.inner)() - } -} diff --git a/cli/src/widgets/basic_selector.rs b/cli/src/widgets/basic_selector.rs deleted file mode 100644 index 39e563a..0000000 --- a/cli/src/widgets/basic_selector.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::sync::{Arc, RwLock}; - -use bytes::Bytes; -use ratatui::{Frame, widgets::ListState}; -use terminput::Event; -use tokio::sync::{broadcast, mpsc}; - -use crate::{prelude::*, utils::DisplayableVec, widgets::traits::Selector}; - -#[derive(Debug, Clone)] -pub struct BasicSelector { - pub select_state: ListState, - pub title: Option, - pub items: Vec, - pub tx: mpsc::Sender>, - pub is_running: bool, -} -impl BasicSelector { - pub fn new(tx: mpsc::Sender>) -> Arc> { - Arc::new(RwLock::new(Self { - select_state: ListState::default().with_selected(Some(0)), - title: None, - items: Vec::new(), - tx, - is_running: false, - })) - } -} -impl Selector for BasicSelector { - fn run>( - selector: &Arc>, - mut rx: broadcast::Receiver, - items: DisplayableVec, - title: T, - ) -> Result<()> { - { - let mut guard = selector.write().unwrap(); - if guard.is_running { - return Err(Error::Custom("duplicate task".to_owned())); - } - guard.items = items.to_strings(); - guard.title = Some(title.into()); - } - tokio::spawn({ - let selector = Arc::clone(selector); - { - selector.write().unwrap().is_running = true; - } - async move { - loop { - debug!("Kaleb"); - let key_event = { - if let Ok(bytes) = rx.recv().await { - debug!("bytes gotten in basic selector"); - match Event::parse_from(&bytes) { - Ok(None) => { - warn!("Couldn't fully parse bytes to terminal event"); - None - } - Err(e) => { - error!("Couldn't parse bytes to terminal event: {e}"); - None - } - Ok(Some(Event::Key(key_event))) => Some(key_event), - _ => None, - } - } else { - None - } - }; - let tx = { selector.read().unwrap().tx.clone() }; - let selection = { - if let Some(key_event) = key_event { - debug!("key pressed"); - use terminput::KeyCode::*; - let mut guard = selector.write().unwrap(); - match key_event.code { - Up | Char('k') => { - let i = match guard.select_state.selected() { - Some(i) if i > 0 => i - 1, - _ => 0, - }; - guard.select_state.select(Some(i)); - None - } - Down | Char('j') => { - let i = match guard.select_state.selected() { - Some(i) if i < guard.items.len() - 1 => i + 1, - _ => guard.items.len() - 1, - }; - guard.select_state.select(Some(i)); - None - } - Enter => { - debug!("enter pressed"); - Some(guard.select_state.selected()) - } - Esc | Char('q') => { - debug!("esc or q pressed"); - Some(None) - } - _ => None, - } - } else { - None - } - }; - - if let Some(selection) = selection { - tx.send(selection).await.unwrap(); - break; - } - } - { - selector.write().unwrap().is_running = false; - } - Ok::<(), Error>(()) - } - }); - Ok(()) - } - - fn render(selector: &Arc>, f: &mut Frame) { - use ratatui::{ - layout::Rect, - prelude::*, - style::{Modifier, Style}, - widgets::{Block, Borders, List}, - }; - let mut guard = selector.write().unwrap(); - let size = f.area(); - - // Calculate popup size (width and height) - let width = (size.width / 2).min(50); // max width 50 - let height = (guard.items.len() as u16 + 2).min(size.height / 2); // +2 for padding/border - let x = (size.width.saturating_sub(width)) / 2; - let y = (size.height.saturating_sub(height)) / 2; - let rect = Rect::new(x, y, width, height); - let list = List::new(guard.items.clone()) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().bold()) - .title(guard.title.as_ref().unwrap().clone()) - .title_alignment(ratatui::layout::Alignment::Center), - ) - .highlight_symbol(">> ") - .highlight_style( - Style::default() - .fg(ratatui::style::Color::Green) - .add_modifier(Modifier::BOLD), - ); - - f.render_stateful_widget(list, rect, &mut guard.select_state); - } -} diff --git a/cli/src/widgets/fuzzy_selector.rs b/cli/src/widgets/fuzzy_selector.rs deleted file mode 100644 index ee00c10..0000000 --- a/cli/src/widgets/fuzzy_selector.rs +++ /dev/null @@ -1,243 +0,0 @@ -use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; - -use bytes::Bytes; -use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; -use ratatui::{ - Frame, - layout::Rect, - widgets::{ListState, Padding}, -}; -use terminput::{Event, KeyCode}; -use tokio::sync::{broadcast, mpsc}; - -use crate::{prelude::*, utils::DisplayableVec, widgets::traits::Selector}; - -#[derive(Debug, Clone)] -pub struct IndexedItem { - pub index: usize, - pub item: String, -} -impl IndexedItem { - pub fn new(index: usize, item: String) -> Self { - Self { index, item } - } -} - -pub struct FuzzySelector { - pub select_state: ListState, - pub title: Option, - pub items: Vec, - pub filtered_items: Vec, - pub tx: mpsc::Sender>, - pub is_running: bool, - pub query: String, -} - -impl FuzzySelector { - pub fn new(tx: mpsc::Sender>) -> Arc> { - Arc::new(RwLock::new(Self { - select_state: ListState::default().with_selected(Some(0)), - title: None, - items: Vec::new(), - filtered_items: Vec::new(), - tx, - is_running: false, - query: "".to_owned(), - })) - } -} - -impl Selector for FuzzySelector { - fn run>( - selector: &Arc>, - mut rx: broadcast::Receiver, - items: DisplayableVec, - title: T, - ) -> Result<()> { - let matcher = SkimMatcherV2::default(); - { - let mut guard = selector.write().unwrap(); - if guard.is_running { - return Err(Error::Custom("duplicate task".to_owned())); - } - guard.is_running = true; - guard.items = items - .to_strings() - .into_iter() - .enumerate() - .map(|pair| IndexedItem::new(pair.0, pair.1)) - .collect(); - guard.filtered_items = filter_items(&matcher, &guard.query, &guard.items); - let opt = Some(guard.filtered_items.len().saturating_sub(1)); - guard.select_state.select(opt); // Select first item if exists - guard.title = Some(title.into()); - guard.query = String::new(); - } - - let selector_clone = Arc::clone(selector); - tokio::spawn(async move { - loop { - let final_selection: Option> = { - if let Ok(bytes) = rx.recv().await { - match Event::parse_from(&bytes) { - Ok(Some(Event::Key(key_code))) => { - let mut guard = selector_clone.write().unwrap(); - let mut result: Option> = None; - - match key_code.code { - KeyCode::Enter => { - debug!("enter pressed"); - let selected_index = guard.select_state.selected(); - // Map the current filtered index to the original index - let original_index = selected_index - .and_then(|i| guard.filtered_items.get(i)) - .map(|item| item.index); - - result = Some(original_index); - } - KeyCode::Esc => { - debug!("esc pressed"); - result = Some(None); - } - KeyCode::Up => { - let current_index = guard.select_state.selected().unwrap_or(0); - let i = current_index.saturating_sub(1); - guard.select_state.select(Some(i)); - } - KeyCode::Down => { - let max_index = guard.filtered_items.len().saturating_sub(1); - let current_index = guard.select_state.selected().unwrap_or(0); - let i = (current_index + 1).min(max_index); - guard.select_state.select(Some(i)); - } - KeyCode::Backspace => { - guard.query.pop(); - guard.filtered_items = filter_items(&matcher, &guard.query, &guard.items); - let opt = Some(guard.filtered_items.len().saturating_sub(1)); - guard.select_state.select(opt); - } - KeyCode::Char(c) => { - guard.query.push(c); - guard.filtered_items = filter_items(&matcher, &guard.query, &guard.items); - let opt = Some(guard.filtered_items.len().saturating_sub(1)); - guard.select_state.select(opt); - } - _ => {} - } - - result - } - Ok(None) => { - warn!("Couldn't fully parse bytes to terminal event"); - None - } - Err(e) => { - error!("Couldn_t parse bytes to terminal event: {e}"); - None - } - _ => None, - } - } else { - break; - } - }; - - // If a final selection was determined (Enter/Esc), send the result and break. - if let Some(selection) = final_selection { - let tx_clone = selector_clone.read().unwrap().tx.clone(); - tx_clone.send(selection).await.unwrap(); - break; - } - } - - { - selector_clone.write().unwrap().is_running = false; - } - Ok::<(), Error>(()) - }); - Ok(()) - } - - fn render(selector: &Arc>, f: &mut Frame) { - use ratatui::{ - prelude::*, - style::{Modifier, Style}, - widgets::{Block, Borders, List, Paragraph}, - }; - let (title, query, items, mut select_state) = { - let guard = selector.read().unwrap(); - ( - guard.title.as_ref().unwrap_or(&"Select Item".to_owned()).clone(), - guard.query.clone(), - guard - .filtered_items - .iter() - .map(|pair| pair.item.clone()) - .collect::>(), - guard.select_state.clone(), - ) - }; - let rect = get_rect(f); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(3)]) - .split(rect); - - let max_height = chunks[0].height; - let items_height = items.len() as u16 + 2; - let subchunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(max_height - items_height), - Constraint::Length(items_height), - ]) - .split(chunks[0]); - - let list = List::new(items) - .block(Block::default().padding(Padding::uniform(1))) - .highlight_symbol(">> ") - .highlight_style( - Style::default() - .fg(ratatui::style::Color::Green) - .add_modifier(Modifier::BOLD), - ); - - let display_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().bold()) - .title(title) - .title_alignment(ratatui::layout::Alignment::Center); - - let display_query = Paragraph::new(query).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().bold()) - .padding(Padding::horizontal(1)), - ); - - f.render_widget(display_block, chunks[0]); - f.render_stateful_widget(list, subchunks[1], &mut select_state); - f.render_widget(display_query, chunks[1]); - } -} - -fn filter_items(matcher: &SkimMatcherV2, query: &str, items: &[IndexedItem]) -> Vec { - let mut filtered_items = items - .iter() - .filter_map(|x| matcher.fuzzy_match(&x.item, query).map(|score| (score, x.clone()))) - .collect::>(); - - filtered_items.sort_by(|a, b| b.0.cmp(&a.0)); - filtered_items.reverse(); - filtered_items.into_iter().map(|x| x.1).collect::>() -} - -fn get_rect(f: &Frame) -> Rect { - let size = f.area(); - let width = size.width / 2; - let height = size.height / 2; - let x = (size.width.saturating_sub(width)) / 2; - let y = (size.height.saturating_sub(height)) / 2; - Rect::new(x, y, width, height) -} diff --git a/cli/src/widgets/mod.rs b/cli/src/widgets/mod.rs deleted file mode 100644 index 8cbcba9..0000000 --- a/cli/src/widgets/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod basic_selector; -mod fuzzy_selector; -mod traits; - -pub use basic_selector::BasicSelector; -pub use fuzzy_selector::FuzzySelector; -pub use traits::Selector; diff --git a/cli/src/widgets/traits.rs b/cli/src/widgets/traits.rs deleted file mode 100644 index 6b07c5b..0000000 --- a/cli/src/widgets/traits.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::sync::{Arc, RwLock}; - -use bytes::Bytes; -use ratatui::Frame; -use tokio::sync::broadcast; - -use crate::{prelude::*, utils::DisplayableVec}; - -pub trait Selector { - fn run>( - selector: &Arc>, - rx: broadcast::Receiver, - items: DisplayableVec, - title: T, - ) -> Result<()>; - - fn render(selector: &Arc>, f: &mut Frame); -} diff --git a/core/src/events.rs b/core/src/events.rs index 7cb408a..e21791d 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -14,6 +14,8 @@ pub enum CliEvent { SwitchSession(u32), // switch session - does nothing if session does not exist + TerminalResize { rows: u16, cols: u16 }, + Detach, } diff --git a/daemon/src/actors/client_connection.rs b/daemon/src/actors/client_connection.rs index 3b08d2c..9640866 100644 --- a/daemon/src/actors/client_connection.rs +++ b/daemon/src/actors/client_connection.rs @@ -144,8 +144,12 @@ impl ClientConnection { trace!("Client Event Input: raw({bytes:?})"); self.session_manager_handle.user_input(self.id, bytes).await.unwrap(); }, + CliEvent::TerminalResize{rows, cols} => { + debug!("Client Event Input: terminal resize(rows={rows}, cols={cols})"); + // todo!() + }, CliEvent::Detach => { - trace!("Client Event Input: detach"); + debug!("Client Event Input: detach"); self.session_manager_handle.client_disconnect(self.id).await.unwrap(); }, CliEvent::KillPane => { diff --git a/defaults/statusbar.lua b/defaults/statusbar.lua index 14f6f83..3c71f8d 100644 --- a/defaults/statusbar.lua +++ b/defaults/statusbar.lua @@ -1,7 +1,7 @@ ui.status_line = { sections = { a = { - get_active_session(), + "active-session" }, b = { function()