Skip to content

Commit 3c48f93

Browse files
committed
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.
1 parent 24b7c88 commit 3c48f93

File tree

6 files changed

+271
-0
lines changed

6 files changed

+271
-0
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ categories = ["command-line-interface", "command-line-utilities"]
3232
# "bench",
3333
# ]
3434

35+
[features]
36+
unstable = ["dep:portable-pty"]
37+
3538
[dependencies]
3639
ratatui = "0.25.0"
3740
vt100 = "0.15.2"
41+
portable-pty = { version = "0.8.1", optional = true }
3842

3943
[dev-dependencies]
4044
bytes = "1.5.0"

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ Check out the examples directory, for more information, or run an example:
3333
cargo run --example simple_ls_rw
3434
```
3535

36+
37+
## Controller
38+
39+
The controller is an `experimental` feature helping with managing the lifecycle of commands that are spawned inside a pseudoterminal.
40+
Currently the support is limited to oneshot commands.
41+
42+
To activate the feature:
43+
```
44+
cargo add tui-term -F unstable
45+
46+
```
47+
3648
## Chat Room
3749
Join our matrix chat room, for possibly synchronous communication.
3850

examples/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ It demonstrates how to send messages from one thread to another to update the `P
2525
Uses a `RWLock` to manage shared read/write access.
2626
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.
2727

28+
## `simple_ls_controller`
29+
30+
- Required: `ls`
31+
32+
Uses the tui-term's controller to handle the command lifecycle.
33+
This feature is gated behind the `unstable` flag.
34+
Run it with:
35+
```
36+
cargo run --example simple_ls_controller --features unstable
37+
```
38+
39+
2840
## `nested_shell`
2941

3042
- Description: Demonstrates nested shell functionality.

examples/simple_ls_controller.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
use std::io::{self, BufWriter};
2+
3+
use crossterm::{
4+
event::{self, Event, KeyCode, KeyEventKind},
5+
execute,
6+
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7+
};
8+
use portable_pty::CommandBuilder;
9+
use ratatui::{
10+
backend::{Backend, CrosstermBackend},
11+
layout::Alignment,
12+
style::{Modifier, Style},
13+
text::Line,
14+
widgets::{Block, Borders, Paragraph},
15+
Frame, Terminal,
16+
};
17+
use tui_term::{controller::Controller, widget::PseudoTerminal};
18+
use vt100::Screen;
19+
20+
fn main() -> std::io::Result<()> {
21+
let (mut terminal, size) = setup_terminal().unwrap();
22+
23+
// Subtract the borders from the size
24+
let size = tui_term::controller::Size::new(size.cols - 2, size.rows, 0, 0);
25+
26+
let mut cmd = CommandBuilder::new("ls");
27+
if let Ok(cwd) = std::env::current_dir() {
28+
cmd.cwd(cwd);
29+
}
30+
31+
let mut controller = Controller::new(cmd, Some(size));
32+
controller.run();
33+
let screen = controller.screen();
34+
35+
run(&mut terminal, screen)?;
36+
37+
cleanup_terminal(&mut terminal).unwrap();
38+
Ok(())
39+
}
40+
41+
fn run<B: Backend>(terminal: &mut Terminal<B>, screen: Option<vt100::Screen>) -> io::Result<()> {
42+
loop {
43+
if let Some(ref screen) = screen {
44+
terminal.draw(|f| ui(f, &screen))?;
45+
}
46+
47+
if let Event::Key(key) = event::read()? {
48+
if key.kind == KeyEventKind::Press {
49+
if let KeyCode::Char('q') = key.code {
50+
return Ok(());
51+
}
52+
}
53+
}
54+
}
55+
}
56+
57+
fn ui(f: &mut Frame, screen: &Screen) {
58+
let chunks = ratatui::layout::Layout::default()
59+
.direction(ratatui::layout::Direction::Vertical)
60+
.margin(1)
61+
.constraints(
62+
[
63+
ratatui::layout::Constraint::Percentage(100),
64+
ratatui::layout::Constraint::Min(1),
65+
]
66+
.as_ref(),
67+
)
68+
.split(f.size());
69+
let title = Line::from("[ Running: ls ]");
70+
let block = Block::default()
71+
.borders(Borders::ALL)
72+
.title(title)
73+
.style(Style::default().add_modifier(Modifier::BOLD));
74+
let pseudo_term = PseudoTerminal::new(screen)
75+
.cursor(tui_term::widget::Cursor::default().visibility(false))
76+
.block(block.clone());
77+
f.render_widget(pseudo_term, chunks[0]);
78+
let explanation = "Press q to exit";
79+
let explanation = Paragraph::new(explanation)
80+
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED))
81+
.alignment(Alignment::Center);
82+
f.render_widget(explanation, chunks[1]);
83+
}
84+
85+
fn setup_terminal() -> io::Result<(Terminal<CrosstermBackend<BufWriter<io::Stdout>>>, Size)> {
86+
enable_raw_mode()?;
87+
let stdout = io::stdout();
88+
let backend = CrosstermBackend::new(BufWriter::new(stdout));
89+
let mut terminal = Terminal::new(backend)?;
90+
let initial_size = terminal.size()?;
91+
let size = Size {
92+
rows: initial_size.height,
93+
cols: initial_size.width,
94+
};
95+
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
96+
Ok((terminal, size))
97+
}
98+
99+
fn cleanup_terminal(
100+
terminal: &mut Terminal<CrosstermBackend<BufWriter<io::Stdout>>>,
101+
) -> io::Result<()> {
102+
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
103+
disable_raw_mode()?;
104+
terminal.show_cursor()?;
105+
terminal.clear()?;
106+
Ok(())
107+
}
108+
109+
#[derive(Debug, Clone)]
110+
struct Size {
111+
cols: u16,
112+
rows: u16,
113+
}

src/controller.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//! This is an unstable interface, and can be activatet with the following
2+
//! feature flag: `unstable`.
3+
//!
4+
//! The controller aims to help manage spawning and reading processes
5+
//! to simplify the usage of `tui-term`, with the tradeoff being less flexible.
6+
//!
7+
//! Please do test this interface out and submit feedback, improvements and bug reports.
8+
//!
9+
//!
10+
//! Currently only oneshot commands are supported by the controller:
11+
//! Commands like `ls`, `cat`.
12+
//! Commands like `htop`, that are persistent still need to be handled manually,
13+
//! please look at the examples for a better overview.
14+
15+
use std::{
16+
io::Result as IoResult,
17+
sync::{Arc, RwLock},
18+
};
19+
20+
use portable_pty::{CommandBuilder, ExitStatus, PtySystem};
21+
use vt100::{Parser, Screen};
22+
23+
/// Controller, in charge of command dispatch
24+
pub struct Controller {
25+
// Needs to be set
26+
cmd: CommandBuilder,
27+
size: Size,
28+
parser: Option<Arc<RwLock<Parser>>>,
29+
exit_status: Option<IoResult<ExitStatus>>,
30+
}
31+
32+
impl Controller {
33+
pub fn new(cmd: CommandBuilder, size: Option<Size>) -> Self {
34+
Self {
35+
cmd,
36+
size: size.unwrap_or_default(),
37+
parser: None,
38+
exit_status: None,
39+
}
40+
}
41+
42+
/// This function is blocking while waiting for the command to end.
43+
pub fn run(&mut self) {
44+
let pair = self.init_pty();
45+
let mut child = pair.slave.spawn_command(self.cmd.clone()).unwrap();
46+
drop(pair.slave);
47+
let mut reader = pair.master.try_clone_reader().unwrap();
48+
let parser = Arc::new(RwLock::new(vt100::Parser::new(
49+
self.size.rows,
50+
self.size.cols,
51+
0,
52+
)));
53+
{
54+
let parser = parser.clone();
55+
std::thread::spawn(move || {
56+
// Consume the output from the child
57+
let mut s = String::new();
58+
reader.read_to_string(&mut s).unwrap();
59+
if !s.is_empty() {
60+
let mut parser = parser.write().unwrap();
61+
parser.process(s.as_bytes());
62+
}
63+
});
64+
}
65+
// Wait for the child to complete
66+
self.exit_status = Some(child.wait());
67+
// Drop writer on purpose
68+
let _writer = pair.master.take_writer().unwrap();
69+
70+
drop(pair.master);
71+
self.parser = Some(parser);
72+
}
73+
74+
fn init_pty(&self) -> portable_pty::PtyPair {
75+
use portable_pty::{NativePtySystem, PtySize};
76+
let pty_system = NativePtySystem::default();
77+
78+
pty_system
79+
.openpty(PtySize {
80+
rows: self.size.rows,
81+
cols: self.size.cols,
82+
pixel_width: self.size.pixel_width,
83+
pixel_height: self.size.pixel_height,
84+
})
85+
.unwrap()
86+
}
87+
88+
pub fn screen(&self) -> Option<Screen> {
89+
if let Some(parser) = &self.parser {
90+
// We convert the read error into an option, since we might call
91+
// the read multiple times, but we only care that we can read at some point
92+
let binding = parser.read().ok()?;
93+
Some(binding.screen().clone())
94+
} else {
95+
None
96+
}
97+
}
98+
99+
/// Whether the command finished running
100+
pub fn finished(&self) -> bool {
101+
self.exit_status.is_some()
102+
}
103+
104+
/// The exit status of the process
105+
pub fn status(&self) -> Option<&IoResult<ExitStatus>> {
106+
self.exit_status.as_ref()
107+
}
108+
}
109+
110+
#[derive(Default, Clone)]
111+
pub struct Size {
112+
pub cols: u16,
113+
pub rows: u16,
114+
pixel_width: u16,
115+
pixel_height: u16,
116+
}
117+
118+
impl Size {
119+
pub fn new(cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) -> Self {
120+
Self {
121+
cols,
122+
rows,
123+
pixel_width,
124+
pixel_height,
125+
}
126+
}
127+
}

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,8 @@
5151
mod state;
5252
pub mod widget;
5353

54+
#[cfg(feature = "unstable")]
55+
pub mod controller;
56+
5457
/// Reexport of the vt100 crate to ensure correct version compatibility
5558
pub use vt100;

0 commit comments

Comments
 (0)