diff --git a/Cargo.lock b/Cargo.lock index 56519e0..7858623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -202,6 +208,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -282,6 +303,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "cookie" version = "0.18.1" @@ -362,6 +397,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -428,6 +497,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -587,6 +662,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -819,6 +896,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -871,6 +954,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -883,6 +988,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -980,6 +1094,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1139,6 +1262,7 @@ dependencies = [ "futures-util", "maestro-config", "maestro-protocol", + "ratatui", "serde_json", "thiserror 1.0.69", "tokio", @@ -1296,6 +1420,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1502,6 +1632,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1926,12 +2077,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2388,6 +2567,23 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/Cargo.toml b/Cargo.toml index 0cff7f0..d198b78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4", "serde"] } +ratatui = "0.28" vt100 = "0.15" [profile.release] diff --git a/crates/maestro-tui/Cargo.toml b/crates/maestro-tui/Cargo.toml index a182bbb..7bd1dd6 100644 --- a/crates/maestro-tui/Cargo.toml +++ b/crates/maestro-tui/Cargo.toml @@ -13,6 +13,7 @@ crossterm.workspace = true futures-util.workspace = true maestro-config.workspace = true maestro-protocol.workspace = true +ratatui.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] } diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 6ea96bf..a9d19a2 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -15,7 +15,14 @@ pub mod config; pub mod emulators; pub mod input; pub mod keymap; +pub mod overlays; +pub mod pane; +pub mod render; pub mod session_task; +pub mod sidebar; +pub mod state; +pub mod tabs; +pub mod terminal; /// Inputs for [`run`]. Built by the umbrella binary from the parsed /// `TuiArgs` plus any global flags. diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs new file mode 100644 index 0000000..7c4e400 --- /dev/null +++ b/crates/maestro-tui/src/overlays.rs @@ -0,0 +1,281 @@ +//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay, +//! transient toast. +//! +//! All overlays are centered popups drawn over the rest of the UI; the +//! `Clear` widget wipes the underlying buffer before drawing. + +use maestro_protocol::DaemonInfo; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; +use ratatui::Frame; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum SpawnField { + #[default] + Daemon, + Cwd, + Command, + Label, + Submit, +} + +#[derive(Debug, Default, Clone)] +pub struct SpawnModal { + pub daemons: Vec, + pub daemon_idx: usize, + pub cwd: String, + pub command: String, + pub label: String, + pub focused_field: SpawnField, +} + +impl SpawnModal { + pub fn next_field(&mut self) { + self.focused_field = match self.focused_field { + SpawnField::Daemon => SpawnField::Cwd, + SpawnField::Cwd => SpawnField::Command, + SpawnField::Command => SpawnField::Label, + SpawnField::Label => SpawnField::Submit, + SpawnField::Submit => SpawnField::Daemon, + }; + } + + pub fn prev_field(&mut self) { + self.focused_field = match self.focused_field { + SpawnField::Daemon => SpawnField::Submit, + SpawnField::Cwd => SpawnField::Daemon, + SpawnField::Command => SpawnField::Cwd, + SpawnField::Label => SpawnField::Command, + SpawnField::Submit => SpawnField::Label, + }; + } + + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = centered(parent, 60, 14); + f.render_widget(Clear, area); + let block = Block::default().title(" spawn ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(inner); + let highlight = |field: SpawnField| { + if self.focused_field == field { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + } + }; + let daemon_label = self + .daemons + .get(self.daemon_idx) + .map(|d| d.hostname.as_str()) + .unwrap_or("(none)"); + let field_row = |label: &str, value: &str, style: Style| { + Paragraph::new(Line::from(vec![ + ratatui::text::Span::raw(format!("{label:8} ")), + ratatui::text::Span::styled(value.to_string(), style), + ])) + }; + f.render_widget( + field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)), + rows[0], + ); + f.render_widget( + field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)), + rows[1], + ); + f.render_widget( + field_row("command:", &self.command, highlight(SpawnField::Command)), + rows[2], + ); + f.render_widget( + field_row("label:", &self.label, highlight(SpawnField::Label)), + rows[3], + ); + f.render_widget( + Paragraph::new(Line::from(vec![ratatui::text::Span::styled( + "[ Spawn ]", + highlight(SpawnField::Submit), + )])), + rows[4], + ); + } +} + +#[derive(Debug, Clone)] +pub struct KillConfirm { + pub label: String, +} + +impl KillConfirm { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = centered(parent, 50, 5); + f.render_widget(Clear, area); + let block = Block::default().title(" kill ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(vec![ + Line::from(format!("Kill session {}?", self.label)), + Line::from(""), + Line::from("[y] yes [n] no"), + ]), + inner, + ); + } +} + +#[derive(Debug, Clone)] +pub struct HelpOverlay { + pub lines: Vec, +} + +impl HelpOverlay { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = centered(parent, 70, parent.height.saturating_sub(4)); + f.render_widget(Clear, area); + let block = Block::default().title(" help ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + let items: Vec = self + .lines + .iter() + .map(|s| ListItem::new(s.as_str())) + .collect(); + f.render_widget(List::new(items), inner); + } +} + +#[derive(Debug, Clone)] +pub struct Toast { + pub text: String, +} + +impl Toast { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let h = 3u16; + let want = self.text.len().saturating_add(4) as u16; + let w = parent.width.min(want); + if h >= parent.height { + return; + } + let area = Rect { + x: parent.x + (parent.width.saturating_sub(w)) / 2, + y: parent.y + parent.height - h - 1, + width: w, + height: h, + }; + f.render_widget(Clear, area); + let block = Block::default().borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget(Paragraph::new(self.text.as_str()), inner); + } +} + +fn centered(parent: Rect, w: u16, h: u16) -> Rect { + let w = w.min(parent.width); + let h = h.min(parent.height); + Rect { + x: parent.x + parent.width.saturating_sub(w) / 2, + y: parent.y + parent.height.saturating_sub(h) / 2, + width: w, + height: h, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(w: u16, h: u16, draw: F) -> String + where + F: FnOnce(&mut Frame), + { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(draw).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn spawn_modal_renders_field_labels() { + let modal = SpawnModal { + daemons: vec![DaemonInfo { + daemon_id: "k".into(), + hostname: "konishi".into(), + version: "0".into(), + daemon_url: "ws://x".into(), + }], + daemon_idx: 0, + cwd: "/x".into(), + command: "bash".into(), + label: String::new(), + focused_field: SpawnField::Cwd, + }; + let rendered = render_to_string(80, 20, |f| modal.render(f, f.area())); + for s in [ + "daemon", "cwd", "command", "label", "Spawn", "konishi", "/x", "bash", + ] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + } + + #[test] + fn spawn_modal_field_rotation() { + let mut modal = SpawnModal::default(); + assert_eq!(modal.focused_field, SpawnField::Daemon); + modal.next_field(); + assert_eq!(modal.focused_field, SpawnField::Cwd); + modal.next_field(); + modal.next_field(); + modal.next_field(); + modal.next_field(); + assert_eq!(modal.focused_field, SpawnField::Daemon); + modal.prev_field(); + assert_eq!(modal.focused_field, SpawnField::Submit); + } + + #[test] + fn kill_confirm_renders_prompt() { + let modal = KillConfirm { + label: "session-7".into(), + }; + let rendered = render_to_string(60, 10, |f| modal.render(f, f.area())); + assert!(rendered.contains("Kill")); + assert!(rendered.contains("session-7")); + assert!(rendered.contains("yes")); + assert!(rendered.contains("no")); + } + + #[test] + fn help_overlay_lists_lines() { + let help = HelpOverlay { + lines: vec![ + "i EnterInsert".into(), + "s OpenSpawnModal".into(), + ], + }; + let rendered = render_to_string(80, 12, |f| help.render(f, f.area())); + assert!(rendered.contains("EnterInsert")); + assert!(rendered.contains("OpenSpawnModal")); + } +} diff --git a/crates/maestro-tui/src/pane.rs b/crates/maestro-tui/src/pane.rs new file mode 100644 index 0000000..7a9f3e6 --- /dev/null +++ b/crates/maestro-tui/src/pane.rs @@ -0,0 +1,151 @@ +//! Attach-pane widget — draws a `vt100::Screen` into a ratatui buffer +//! cell-by-cell, translating SGR (fg / bg / attrs) into `ratatui::Style`. +//! +//! The pane's `screen` is the same `Arc>` the +//! `SessionTask` writes into, so the renderer is always looking at the most +//! recent cell grid. + +use std::sync::{Arc, Mutex}; + +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::Frame; + +use crate::session_task::SessionState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaneStatus { + Connecting, + Live, + Reconnecting, + Ended, +} + +impl From for PaneStatus { + fn from(s: SessionState) -> Self { + match s { + SessionState::Connecting => PaneStatus::Connecting, + SessionState::Live => PaneStatus::Live, + SessionState::Reconnecting => PaneStatus::Reconnecting, + SessionState::Ended => PaneStatus::Ended, + } + } +} + +#[derive(Clone)] +pub struct Pane { + pub screen: Arc>, + pub status: PaneStatus, + pub focused: bool, +} + +impl std::fmt::Debug for Pane { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Pane") + .field("status", &self.status) + .field("focused", &self.focused) + .finish_non_exhaustive() + } +} + +impl Pane { + pub fn render(&self, f: &mut Frame, area: Rect) { + let parser = self.screen.lock().expect("vt100 parser mutex"); + let screen = parser.screen(); + let buf = f.buffer_mut(); + let (screen_rows, screen_cols) = screen.size(); + let rows = area.height.min(screen_rows); + let cols = area.width.min(screen_cols); + for r in 0..rows { + for c in 0..cols { + if let Some(cell) = screen.cell(r, c) { + let s = cell.contents(); + let s = if s.is_empty() { " " } else { &s }; + buf[(area.x + c, area.y + r)] + .set_symbol(s) + .set_style(translate_cell_style(cell)); + } + } + } + } +} + +fn translate_cell_style(cell: &vt100::Cell) -> Style { + let mut s = Style::default(); + if let Some(c) = translate_color(cell.fgcolor()) { + s = s.fg(c); + } + if let Some(c) = translate_color(cell.bgcolor()) { + s = s.bg(c); + } + if cell.bold() { + s = s.add_modifier(Modifier::BOLD); + } + if cell.italic() { + s = s.add_modifier(Modifier::ITALIC); + } + if cell.underline() { + s = s.add_modifier(Modifier::UNDERLINED); + } + if cell.inverse() { + s = s.add_modifier(Modifier::REVERSED); + } + s +} + +fn translate_color(c: vt100::Color) -> Option { + match c { + vt100::Color::Default => None, + vt100::Color::Idx(i) => Some(Color::Indexed(i)), + vt100::Color::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_with_bytes(rows: u16, cols: u16, bytes: &[u8]) -> String { + let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0))); + parser.lock().unwrap().process(bytes); + let pane = Pane { + screen: parser, + status: PaneStatus::Live, + focused: true, + }; + let backend = TestBackend::new(cols, rows); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| pane.render(f, f.area())).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn pane_renders_vt100_chars_into_buffer() { + let rendered = render_with_bytes(3, 20, b"hello world"); + assert!(rendered.contains("hello world"), "got:\n{rendered}"); + } + + #[test] + fn pane_status_round_trips_from_session_state() { + assert_eq!( + PaneStatus::from(SessionState::Connecting), + PaneStatus::Connecting + ); + assert_eq!(PaneStatus::from(SessionState::Live), PaneStatus::Live); + assert_eq!( + PaneStatus::from(SessionState::Reconnecting), + PaneStatus::Reconnecting + ); + assert_eq!(PaneStatus::from(SessionState::Ended), PaneStatus::Ended); + } +} diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs new file mode 100644 index 0000000..72c93d3 --- /dev/null +++ b/crates/maestro-tui/src/render.rs @@ -0,0 +1,203 @@ +//! Top-level draw orchestration. Composes sidebar / tab bar / pane / overlays +//! into one frame, and returns a [`RegionMap`] that the input router uses to +//! hit-test mouse clicks. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::widgets::{Block, Borders}; +use ratatui::Frame; + +use crate::input::Region; +use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast}; +use crate::pane::Pane; +use crate::sidebar::Sidebar; +use crate::state::{AppState, OverlayKind}; +use crate::tabs::TabBar; + +/// Tagged regions for mouse hit-testing. The input router resolves a mouse +/// coordinate to one of these via [`RegionMap::region_at`]. +#[derive(Debug, Default, Clone)] +pub struct RegionMap { + pub sidebar: Option, + pub tab_bar: Option, + pub pane: Option, + pub modal: Option, +} + +impl RegionMap { + pub fn region_at(&self, col: u16, row: u16) -> Region { + if let Some(r) = self.modal { + if inside(r, col, row) { + return Region::Modal; + } + } + if let Some(r) = self.tab_bar { + if inside(r, col, row) { + return Region::Tab; + } + } + if let Some(r) = self.sidebar { + if inside(r, col, row) { + return Region::SidebarRow; + } + } + if let Some(r) = self.pane { + if inside(r, col, row) { + return Region::Pane; + } + } + Region::Unknown + } +} + +fn inside(r: Rect, col: u16, row: u16) -> bool { + col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height +} + +/// Bundle of references the renderer reads from. Built by `App::redraw` each +/// frame. +#[derive(Debug)] +pub struct RenderInputs<'a> { + pub sidebar: &'a Sidebar, + pub tab_bar: &'a TabBar, + pub pane: Option<&'a Pane>, + pub state: &'a AppState, + pub spawn: &'a SpawnModal, + pub help: &'a HelpOverlay, +} + +/// Draw one frame. Returns the `RegionMap` the input router will consult for +/// mouse events the next time it canonicalises an event. +pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { + let mut map = RegionMap::default(); + let area = f.area(); + + let cols = if inputs.state.sidebar_visible { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(28), Constraint::Min(0)]) + .split(area) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(0), Constraint::Min(0)]) + .split(area) + }; + let sidebar_area = cols[0]; + let right = cols[1]; + + if inputs.state.sidebar_visible { + inputs.sidebar.render(f, sidebar_area); + map.sidebar = Some(sidebar_area); + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(right); + let tab_area = rows[0]; + let pane_area = rows[1]; + + inputs.tab_bar.render(f, tab_area); + map.tab_bar = Some(tab_area); + + let pane_block = Block::default().borders(Borders::ALL); + let pane_inner = pane_block.inner(pane_area); + f.render_widget(pane_block, pane_area); + if let Some(p) = inputs.pane { + p.render(f, pane_inner); + } + map.pane = Some(pane_inner); + + match &inputs.state.overlay { + Some(OverlayKind::Spawn) => { + inputs.spawn.render(f, area); + map.modal = Some(area); + } + Some(OverlayKind::KillConfirm(id)) => { + let kc = KillConfirm { + label: id.to_string(), + }; + kc.render(f, area); + map.modal = Some(area); + } + Some(OverlayKind::Help) => { + inputs.help.render(f, area); + map.modal = Some(area); + } + Some(OverlayKind::Toast(text)) => { + let t = Toast { text: text.clone() }; + t.render(f, area); + } + None => {} + } + + map +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Region; + + #[test] + fn region_map_resolves_known_areas() { + let map = RegionMap { + sidebar: Some(Rect { + x: 0, + y: 0, + width: 10, + height: 20, + }), + tab_bar: Some(Rect { + x: 10, + y: 0, + width: 30, + height: 3, + }), + pane: Some(Rect { + x: 10, + y: 3, + width: 30, + height: 17, + }), + modal: None, + }; + assert_eq!(map.region_at(5, 5), Region::SidebarRow); + assert_eq!(map.region_at(15, 1), Region::Tab); + assert_eq!(map.region_at(15, 5), Region::Pane); + assert_eq!(map.region_at(50, 50), Region::Unknown); + } + + #[test] + fn modal_eclipses_other_regions() { + let map = RegionMap { + sidebar: Some(Rect { + x: 0, + y: 0, + width: 10, + height: 20, + }), + tab_bar: Some(Rect { + x: 10, + y: 0, + width: 30, + height: 3, + }), + pane: Some(Rect { + x: 10, + y: 3, + width: 30, + height: 17, + }), + modal: Some(Rect { + x: 5, + y: 5, + width: 25, + height: 10, + }), + }; + // Even though (15, 10) is inside `pane`, the modal claims it. + assert_eq!(map.region_at(15, 10), Region::Modal); + assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal + } +} diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs new file mode 100644 index 0000000..afc5df4 --- /dev/null +++ b/crates/maestro-tui/src/sidebar.rs @@ -0,0 +1,200 @@ +//! Sidebar widget — daemon→session tree, navigation-only. +//! +//! Status glyphs: ● attached / ○ live / ✗ dead. + +use std::collections::HashSet; + +use maestro_protocol::{DaemonInfo, Session, SessionStatus}; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; +use uuid::Uuid; + +/// Renderable sidebar state. +#[derive(Debug, Clone)] +pub struct Sidebar { + pub daemons: Vec, + pub sessions: Vec, + pub highlight: Option, + pub attached: HashSet, +} + +impl Sidebar { + pub fn render(&self, f: &mut Frame, area: Rect) { + let block = Block::default().title(" daemons ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + for d in &self.daemons { + lines.push(Line::from(format!("● {}", d.hostname))); + for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) { + let glyph = match (s.status, self.attached.contains(&s.id)) { + (SessionStatus::Dead, _) => "✗", + (SessionStatus::Running, true) => "●", + (SessionStatus::Running, false) => "○", + }; + let label = label_for(s); + let style = if self.highlight == Some(s.id) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{glyph} {label}"), style), + ])); + } + } + f.render_widget(Paragraph::new(lines), inner); + } +} + +/// `metadata.label` if present, else the command line. +fn label_for(s: &Session) -> String { + if let Some(serde_json::Value::String(label)) = s.metadata.get("label") { + label.clone() + } else { + s.command.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::collections::HashMap; + + fn daemon(id: &str) -> DaemonInfo { + DaemonInfo { + daemon_id: id.into(), + hostname: id.into(), + version: "test".into(), + daemon_url: format!("ws://{id}:7070"), + } + } + + fn session_with_label(id: u128, daemon_id: &str, label: &str) -> Session { + let mut metadata = HashMap::new(); + metadata.insert("label".into(), serde_json::Value::String(label.into())); + Session { + id: Uuid::from_u128(id), + daemon_id: daemon_id.into(), + command: "bash".into(), + cwd: std::path::PathBuf::from("/tmp"), + env: Default::default(), + metadata, + status: SessionStatus::Running, + created_at: Utc::now(), + } + } + + fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| sidebar.render(f, f.area())).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn renders_hostname_and_session_labels() { + let sb = Sidebar { + daemons: vec![daemon("konishi")], + sessions: vec![ + session_with_label(1, "konishi", "claude"), + session_with_label(2, "konishi", "bash"), + ], + highlight: None, + attached: Default::default(), + }; + let rendered = render_to_string(&sb, 28, 12); + assert!( + rendered.contains("konishi"), + "missing daemon name:\n{rendered}" + ); + assert!( + rendered.contains("claude"), + "missing session label:\n{rendered}" + ); + assert!( + rendered.contains("bash"), + "missing session label:\n{rendered}" + ); + } + + #[test] + fn attached_session_uses_filled_glyph() { + let sid = Uuid::from_u128(1); + let sb = Sidebar { + daemons: vec![daemon("konishi")], + sessions: vec![session_with_label(1, "konishi", "claude")], + highlight: None, + attached: [sid].into_iter().collect(), + }; + let rendered = render_to_string(&sb, 28, 6); + let line = rendered + .lines() + .find(|l| l.contains("claude")) + .expect("claude row"); + assert!(line.contains('●'), "expected filled glyph in: {line:?}"); + assert!( + !line.contains('○'), + "should not contain hollow glyph: {line:?}" + ); + } + + #[test] + fn dead_session_uses_cross_glyph() { + let mut sess = session_with_label(1, "konishi", "claude"); + sess.status = SessionStatus::Dead; + let sb = Sidebar { + daemons: vec![daemon("konishi")], + sessions: vec![sess], + highlight: None, + attached: Default::default(), + }; + let rendered = render_to_string(&sb, 28, 6); + let line = rendered + .lines() + .find(|l| l.contains("claude")) + .expect("claude row"); + assert!(line.contains('✗'), "expected dead glyph in: {line:?}"); + } + + #[test] + fn falls_back_to_command_when_label_metadata_is_absent() { + let s = Session { + id: Uuid::from_u128(1), + daemon_id: "konishi".into(), + command: "vim".into(), + cwd: std::path::PathBuf::from("/tmp"), + env: Default::default(), + metadata: HashMap::new(), + status: SessionStatus::Running, + created_at: Utc::now(), + }; + let sb = Sidebar { + daemons: vec![daemon("konishi")], + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let rendered = render_to_string(&sb, 28, 6); + assert!( + rendered.contains("vim"), + "missing fallback command label:\n{rendered}" + ); + } +} diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs new file mode 100644 index 0000000..580159b --- /dev/null +++ b/crates/maestro-tui/src/state.rs @@ -0,0 +1,227 @@ +//! `AppState` — the top-level mutable state of the running TUI. +//! +//! v1 keeps it small on purpose: a list of tabs (each bound to at most one +//! session), an active tab index, focus split between sidebar and pane, the +//! current input mode, and an optional overlay (spawn modal, kill confirm, +//! help, transient toast). +//! +//! v1.5 will replace `Tab::pane: Option` with a split tree. + +use uuid::Uuid; + +use crate::keymap::Mode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sidebar, + Pane, +} + +#[derive(Debug, Clone)] +pub struct Tab { + pub name: String, + pub pane: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OverlayKind { + Spawn, + KillConfirm(Uuid), + Help, + Toast(String), +} + +#[derive(Debug)] +pub struct AppState { + pub tabs: Vec, + pub active_tab_idx: usize, + pub focus: Focus, + pub mode: Mode, + pub sidebar_visible: bool, + pub sidebar_highlight: Option, + pub overlay: Option, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + pub fn new() -> Self { + Self { + tabs: vec![Tab { + name: "1".into(), + pane: None, + }], + active_tab_idx: 0, + focus: Focus::Sidebar, + mode: Mode::Normal, + sidebar_visible: true, + sidebar_highlight: None, + overlay: None, + } + } + + pub fn active_tab(&self) -> &Tab { + &self.tabs[self.active_tab_idx] + } + + pub fn active_tab_mut(&mut self) -> &mut Tab { + &mut self.tabs[self.active_tab_idx] + } + + /// Append a new tab and switch to it. Sidebar regains focus so the user + /// can pick something to attach. + pub fn new_tab(&mut self) { + let name = format!("{}", self.tabs.len() + 1); + self.tabs.push(Tab { name, pane: None }); + self.active_tab_idx = self.tabs.len() - 1; + self.focus = Focus::Sidebar; + } + + /// Drop the active tab. No-op if it's the only one — there must always + /// be at least one tab. + pub fn close_tab(&mut self) { + if self.tabs.len() <= 1 { + return; + } + self.tabs.remove(self.active_tab_idx); + if self.active_tab_idx >= self.tabs.len() { + self.active_tab_idx = self.tabs.len() - 1; + } + } + + pub fn next_tab(&mut self) { + if self.tabs.is_empty() { + return; + } + self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len(); + } + + pub fn prev_tab(&mut self) { + if self.tabs.is_empty() { + return; + } + self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len(); + } + + pub fn goto_tab(&mut self, n: u8) { + let idx = (n as usize) + .saturating_sub(1) + .min(self.tabs.len().saturating_sub(1)); + self.active_tab_idx = idx; + } + + /// Bind a session to the active tab and move focus into the pane. + pub fn attach_to_focused(&mut self, session_id: Uuid) { + self.active_tab_mut().pane = Some(session_id); + self.focus = Focus::Pane; + } + + /// Toggle sidebar visibility. + pub fn toggle_sidebar(&mut self) { + self.sidebar_visible = !self.sidebar_visible; + if !self.sidebar_visible { + self.focus = Focus::Pane; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_app_starts_with_one_empty_tab_focused_on_sidebar() { + let state = AppState::new(); + assert_eq!(state.tabs.len(), 1); + assert_eq!(state.tabs[0].pane, None); + assert_eq!(state.focus, Focus::Sidebar); + assert_eq!(state.mode, Mode::Normal); + assert!(state.sidebar_visible); + assert!(state.overlay.is_none()); + } + + #[test] + fn new_tab_extends_and_focuses() { + let mut state = AppState::new(); + state.new_tab(); + assert_eq!(state.tabs.len(), 2); + assert_eq!(state.active_tab_idx, 1); + assert_eq!(state.focus, Focus::Sidebar); + } + + #[test] + fn close_tab_keeps_at_least_one() { + let mut state = AppState::new(); + state.close_tab(); + assert_eq!(state.tabs.len(), 1); + state.new_tab(); + state.close_tab(); + assert_eq!(state.tabs.len(), 1); + } + + #[test] + fn close_tab_advances_when_last_is_closed() { + let mut state = AppState::new(); + state.new_tab(); + state.new_tab(); + assert_eq!(state.active_tab_idx, 2); + state.close_tab(); + assert_eq!(state.tabs.len(), 2); + assert_eq!(state.active_tab_idx, 1); + } + + #[test] + fn next_prev_tab_wraps() { + let mut state = AppState::new(); + state.new_tab(); + state.new_tab(); + state.active_tab_idx = 0; + state.next_tab(); + assert_eq!(state.active_tab_idx, 1); + state.next_tab(); + assert_eq!(state.active_tab_idx, 2); + state.next_tab(); + assert_eq!(state.active_tab_idx, 0); + state.prev_tab(); + assert_eq!(state.active_tab_idx, 2); + } + + #[test] + fn goto_tab_uses_one_based_clamp() { + let mut state = AppState::new(); + state.new_tab(); + state.new_tab(); + state.goto_tab(1); + assert_eq!(state.active_tab_idx, 0); + state.goto_tab(2); + assert_eq!(state.active_tab_idx, 1); + state.goto_tab(99); + // Clamped to last available tab. + assert_eq!(state.active_tab_idx, state.tabs.len() - 1); + } + + #[test] + fn attach_binds_to_active_tab_and_focuses_pane() { + let mut state = AppState::new(); + let sid = Uuid::from_u128(7); + state.attach_to_focused(sid); + assert_eq!(state.tabs[0].pane, Some(sid)); + assert_eq!(state.focus, Focus::Pane); + } + + #[test] + fn toggle_sidebar_moves_focus_to_pane_when_hiding() { + let mut state = AppState::new(); + state.toggle_sidebar(); + assert!(!state.sidebar_visible); + assert_eq!(state.focus, Focus::Pane); + state.toggle_sidebar(); + assert!(state.sidebar_visible); + // Doesn't force focus back; sidebar shown but focus stays where it was. + assert_eq!(state.focus, Focus::Pane); + } +} diff --git a/crates/maestro-tui/src/tabs.rs b/crates/maestro-tui/src/tabs.rs new file mode 100644 index 0000000..7fd0156 --- /dev/null +++ b/crates/maestro-tui/src/tabs.rs @@ -0,0 +1,61 @@ +//! Tab-bar widget — top row of the main area. + +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::Tabs; +use ratatui::Frame; + +#[derive(Debug, Clone)] +pub struct TabBar { + pub tab_names: Vec, + pub active_idx: usize, +} + +impl TabBar { + pub fn render(&self, f: &mut Frame, area: Rect) { + let titles: Vec = self + .tab_names + .iter() + .map(|n| Line::from(n.as_str())) + .collect(); + let tabs = Tabs::new(titles) + .select(self.active_idx) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + f.render_widget(tabs, area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| bar.render(f, f.area())).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn renders_three_tab_names() { + let bar = TabBar { + tab_names: vec!["1".into(), "2".into(), "3".into()], + active_idx: 1, + }; + let rendered = render_to_string(&bar, 40, 1); + assert!(rendered.contains("1")); + assert!(rendered.contains("2")); + assert!(rendered.contains("3")); + } +} diff --git a/crates/maestro-tui/src/terminal.rs b/crates/maestro-tui/src/terminal.rs new file mode 100644 index 0000000..eeb5fb5 --- /dev/null +++ b/crates/maestro-tui/src/terminal.rs @@ -0,0 +1,73 @@ +//! `TerminalGuard` — RAII setup/teardown of raw mode, the alternate screen +//! buffer, and mouse capture. Restores everything on drop or panic. +//! +//! Pattern matches `crates/maestro-cli/src/attach.rs::RawTty`. + +use std::io; + +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; + +#[derive(Debug)] +pub struct TerminalGuard; + +#[derive(Debug, thiserror::Error)] +pub enum TerminalError { + #[error("io: {0}")] + Io(#[from] io::Error), +} + +impl TerminalGuard { + /// Enter raw mode, switch to alt screen, enable mouse capture. Returns a + /// guard whose `Drop` impl reverses each step. + pub fn enter() -> Result { + enable_raw_mode()?; + let mut out = io::stdout(); + execute!(out, EnterAlternateScreen, EnableMouseCapture)?; + Ok(TerminalGuard) + } + + /// Restore terminal state. Idempotent so the panic hook + `Drop` can both + /// call it without producing weird errors. + fn restore() { + let mut out = io::stdout(); + let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen); + let _ = disable_raw_mode(); + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + Self::restore(); + } +} + +static HOOK_INSTALLED: std::sync::Once = std::sync::Once::new(); + +/// Install a panic hook that restores the terminal before re-panicking, so +/// a backtrace lands in cooked mode instead of glitching against the alt +/// screen. Idempotent — safe to call from `run()` every time. +pub fn install_panic_hook() { + HOOK_INSTALLED.call_once(|| { + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + TerminalGuard::restore(); + prev(info); + })); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_panic_hook_is_idempotent() { + install_panic_hook(); + install_panic_hook(); + install_panic_hook(); + } +}