From b4580edca9b4360429d2baa94c957156c167ca6d Mon Sep 17 00:00:00 2001 From: a-kenji Date: Thu, 7 Dec 2023 14:11:21 +0100 Subject: [PATCH] feat: add unstable controller interface Add a controller that handles the lifecycle of a process being spawned by a pseudoterminal. This is an experimental interface that currently is gated behind the `unstable` feature flag. Feedback is appreciated. --- Cargo.toml | 4 + README.md | 12 +++ examples/README.md | 12 +++ examples/simple_ls_controller.rs | 113 +++++++++++++++++++++++++++ src/controller.rs | 127 +++++++++++++++++++++++++++++++ src/lib.rs | 3 + 6 files changed, 271 insertions(+) create mode 100644 examples/simple_ls_controller.rs create mode 100644 src/controller.rs diff --git a/Cargo.toml b/Cargo.toml index 6f6f0bb..a4e948e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,13 @@ categories = ["command-line-interface", "command-line-utilities"] # "bench", # ] +[features] +unstable = ["dep:portable-pty"] + [dependencies] ratatui = "0.25.0" vt100 = "0.15.2" +portable-pty = { version = "0.8.1", optional = true } [dev-dependencies] bytes = "1.5.0" diff --git a/README.md b/README.md index 38dea80..a159a14 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,18 @@ Check out the examples directory, for more information, or run an example: cargo run --example simple_ls_rw ``` + +## Controller + +The controller is an `experimental` feature helping with managing the lifecycle of commands that are spawned inside a pseudoterminal. +Currently the support is limited to oneshot commands. + +To activate the feature: +``` +cargo add tui-term -F unstable + +``` + ## Chat Room Join our matrix chat room, for possibly synchronous communication. diff --git a/examples/README.md b/examples/README.md index c419948..fa66b8e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,6 +25,18 @@ It demonstrates how to send messages from one thread to another to update the `P Uses a `RWLock` to manage shared read/write access. The RWLock ensures that multiple threads can read from the pseudoterminal simultaneously, while exclusive write access is granted to only one thread at a time. +## `simple_ls_controller` + +- Required: `ls` + +Uses the tui-term's controller to handle the command lifecycle. +This feature is gated behind the `unstable` flag. +Run it with: +``` +cargo run --example simple_ls_controller --features unstable +``` + + ## `nested_shell` - Description: Demonstrates nested shell functionality. diff --git a/examples/simple_ls_controller.rs b/examples/simple_ls_controller.rs new file mode 100644 index 0000000..bef3400 --- /dev/null +++ b/examples/simple_ls_controller.rs @@ -0,0 +1,113 @@ +use std::io::{self, BufWriter}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use portable_pty::CommandBuilder; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::Alignment, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; +use tui_term::{controller::Controller, widget::PseudoTerminal}; +use vt100::Screen; + +fn main() -> std::io::Result<()> { + let (mut terminal, size) = setup_terminal().unwrap(); + + // Subtract the borders from the size + let size = tui_term::controller::Size::new(size.cols - 2, size.rows, 0, 0); + + let mut cmd = CommandBuilder::new("ls"); + if let Ok(cwd) = std::env::current_dir() { + cmd.cwd(cwd); + } + + let mut controller = Controller::new(cmd, Some(size)); + controller.run(); + let screen = controller.screen(); + + run(&mut terminal, screen)?; + + cleanup_terminal(&mut terminal).unwrap(); + Ok(()) +} + +fn run(terminal: &mut Terminal, screen: Option) -> io::Result<()> { + loop { + if let Some(ref screen) = screen { + terminal.draw(|f| ui(f, &screen))?; + } + + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if let KeyCode::Char('q') = key.code { + return Ok(()); + } + } + } + } +} + +fn ui(f: &mut Frame, screen: &Screen) { + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(1) + .constraints( + [ + ratatui::layout::Constraint::Percentage(100), + ratatui::layout::Constraint::Min(1), + ] + .as_ref(), + ) + .split(f.size()); + let title = Line::from("[ Running: ls ]"); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .style(Style::default().add_modifier(Modifier::BOLD)); + let pseudo_term = PseudoTerminal::new(screen) + .cursor(tui_term::widget::Cursor::default().visibility(false)) + .block(block.clone()); + f.render_widget(pseudo_term, chunks[0]); + let explanation = "Press q to exit"; + let explanation = Paragraph::new(explanation) + .style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)) + .alignment(Alignment::Center); + f.render_widget(explanation, chunks[1]); +} + +fn setup_terminal() -> io::Result<(Terminal>>, Size)> { + enable_raw_mode()?; + let stdout = io::stdout(); + let backend = CrosstermBackend::new(BufWriter::new(stdout)); + let mut terminal = Terminal::new(backend)?; + let initial_size = terminal.size()?; + let size = Size { + rows: initial_size.height, + cols: initial_size.width, + }; + execute!(terminal.backend_mut(), EnterAlternateScreen)?; + Ok((terminal, size)) +} + +fn cleanup_terminal( + terminal: &mut Terminal>>, +) -> io::Result<()> { + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + disable_raw_mode()?; + terminal.show_cursor()?; + terminal.clear()?; + Ok(()) +} + +#[derive(Debug, Clone)] +struct Size { + cols: u16, + rows: u16, +} diff --git a/src/controller.rs b/src/controller.rs new file mode 100644 index 0000000..f979de1 --- /dev/null +++ b/src/controller.rs @@ -0,0 +1,127 @@ +//! This is an unstable interface, and can be activatet with the following +//! feature flag: `unstable`. +//! +//! The controller aims to help manage spawning and reading processess +//! to simplify the usage of `tui-term`, with the tradeoff being less flexible. +//! +//! Please do test this interface out and submit feedback, improvements and bug reports. +//! +//! +//! Currently only oneshot commands are supported by the controller: +//! Commands like `ls`, `cat`. +//! Commands like `htop`, that are persistent still need to be handled manually, +//! please look at the examples for a better overview. + +use std::{ + io::Result as IoResult, + sync::{Arc, RwLock}, +}; + +use portable_pty::{CommandBuilder, ExitStatus, PtySystem}; +use vt100::{Parser, Screen}; + +/// Controller, in charge of command dispatch +pub struct Controller { + // Needs to be set + cmd: CommandBuilder, + size: Size, + parser: Option>>, + exit_status: Option>, +} + +impl Controller { + pub fn new(cmd: CommandBuilder, size: Option) -> Self { + Self { + cmd, + size: size.unwrap_or_default(), + parser: None, + exit_status: None, + } + } + + /// This function is blocking while waiting for the command to end. + pub fn run(&mut self) { + let pair = self.init_pty(); + let mut child = pair.slave.spawn_command(self.cmd.clone()).unwrap(); + drop(pair.slave); + let mut reader = pair.master.try_clone_reader().unwrap(); + let parser = Arc::new(RwLock::new(vt100::Parser::new( + self.size.rows, + self.size.cols, + 0, + ))); + { + let parser = parser.clone(); + std::thread::spawn(move || { + // Consume the output from the child + let mut s = String::new(); + reader.read_to_string(&mut s).unwrap(); + if !s.is_empty() { + let mut parser = parser.write().unwrap(); + parser.process(s.as_bytes()); + } + }); + } + // Wait for the child to complete + self.exit_status = Some(child.wait()); + // Drop writer on purpose + let _writer = pair.master.take_writer().unwrap(); + + drop(pair.master); + self.parser = Some(parser); + } + + fn init_pty(&self) -> portable_pty::PtyPair { + use portable_pty::{NativePtySystem, PtySize}; + let pty_system = NativePtySystem::default(); + + pty_system + .openpty(PtySize { + rows: self.size.rows, + cols: self.size.cols, + pixel_width: self.size.pixel_width, + pixel_height: self.size.pixel_height, + }) + .unwrap() + } + + pub fn screen(&self) -> Option { + if let Some(parser) = &self.parser { + // We convert the read error into an option, since we might call + // the read multiple times, but we only care that we can read at some point + let binding = parser.read().ok()?; + Some(binding.screen().clone()) + } else { + None + } + } + + /// Whether the command finished running + pub fn finished(&self) -> bool { + self.exit_status.is_some() + } + + /// The exit status of the process + pub fn status(&self) -> Option<&IoResult> { + self.exit_status.as_ref() + } +} + +#[derive(Default, Clone)] +pub struct Size { + pub cols: u16, + pub rows: u16, + pixel_width: u16, + pixel_height: u16, +} + +impl Size { + pub fn new(cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) -> Self { + Self { + cols, + rows, + pixel_width, + pixel_height, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 027581b..ee91c44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,5 +51,8 @@ mod state; pub mod widget; +#[cfg(feature = "unstable")] +pub mod controller; + /// Reexport of the vt100 crate to ensure correct version compatibility pub use vt100;