1use std::{
2 fmt, fs,
3 io::{self, BufWriter, Read, Write},
4 path::PathBuf,
5 sync::{Arc, Mutex, RwLock},
6 time::Duration,
7};
8
9use bytes::Bytes;
10use crossterm::{
11 event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
12 execute,
13 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
14};
15use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
16use ratatui::{
17 backend::CrosstermBackend,
18 layout::{Alignment, Constraint, Direction, Layout, Rect},
19 style::{Color, Modifier, Style},
20 widgets::{Block, Borders, Paragraph},
21 Terminal,
22};
23use tokio::{
24 sync::mpsc::{channel, Sender},
25 task::spawn_blocking,
26};
27use tracing::Level;
28use tracing_subscriber::FmtSubscriber;
29use tui_term::widget::{Cursor, PseudoTerminal};
30
31#[derive(Debug, Clone, Copy)]
32struct Size {
33 cols: u16,
34 rows: u16,
35}
36
37#[tokio::main]
38async fn main() -> io::Result<()> {
39 init_panic_hook();
40 let (mut terminal, mut size) = setup_terminal().unwrap();
41
42 let cwd = std::env::current_dir().unwrap();
43 let mut cmd = CommandBuilder::new_default_prog();
44 cmd.cwd(cwd);
45
46 let mut panes: Vec<PtyPane> = Vec::new();
47 let mut active_pane: Option<usize> = None;
48
49 let pane_size = calc_pane_size(size, 1);
51 open_new_pane(&mut panes, &mut active_pane, &cmd, pane_size)?;
52
53 loop {
54 terminal.draw(|f| {
55 let chunks = Layout::default()
56 .direction(Direction::Vertical)
57 .margin(1)
58 .constraints([Constraint::Percentage(100), Constraint::Min(1)].as_ref())
59 .split(f.area());
60
61 let pane_height = if panes.is_empty() {
62 chunks[0].height
63 } else {
64 (chunks[0].height.saturating_sub(1)) / panes.len() as u16
65 };
66
67 for (index, pane) in panes.iter().enumerate() {
68 let block = Block::default()
69 .borders(Borders::ALL)
70 .style(Style::default().add_modifier(Modifier::BOLD));
71 let mut cursor = Cursor::default();
72 let block = if Some(index) == active_pane {
73 block.style(
74 Style::default()
75 .add_modifier(Modifier::BOLD)
76 .fg(Color::LightMagenta),
77 )
78 } else {
79 cursor.hide();
80 block
81 };
82 let parser = pane.parser.read().unwrap();
83 let screen = parser.screen();
84 let pseudo_term = PseudoTerminal::new(screen).block(block).cursor(cursor);
85 let pane_chunk = Rect {
86 x: chunks[0].x,
87 y: chunks[0].y + (index as u16 * pane_height), width: chunks[0].width,
90 height: pane_height, };
92 f.render_widget(pseudo_term, pane_chunk);
93 }
94
95 let explanation =
96 "Ctrl+n to open a new pane | Ctrl+x to close the active pane | Ctrl+q to quit";
97 let explanation = Paragraph::new(explanation)
98 .style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED))
99 .alignment(Alignment::Center);
100 f.render_widget(explanation, chunks[1]);
101 })?;
102
103 if event::poll(Duration::from_millis(10))? {
104 tracing::info!("Terminal Size: {:?}", terminal.size());
105 match event::read()? {
106 Event::Key(key) => match key.code {
107 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
108 cleanup_terminal(&mut terminal).unwrap();
109 return Ok(());
110 }
111 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
112 let pane_size = calc_pane_size(size, panes.len() + 1);
113 tracing::info!("Opened new pane with size: {size:?}");
114 resize_all_panes(&mut panes, pane_size);
115 open_new_pane(&mut panes, &mut active_pane, &cmd, pane_size)?;
116 }
117 KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
118 close_active_pane(&mut panes, &mut active_pane).await?;
119 resize_all_panes(&mut panes, pane_size);
120 }
121 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
122 if let Some(pane) = active_pane {
123 active_pane = Some(pane.saturating_sub(1));
124 }
125 }
126 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
127 if let Some(pane) = active_pane {
128 if pane < panes.len() - 1 {
129 active_pane = Some(pane.saturating_add(1));
130 }
131 }
132 }
133 _ => {
134 if let Some(index) = active_pane {
135 if handle_pane_key_event(&mut panes[index], &key).await {
136 continue;
137 }
138 }
139 }
140 },
141 Event::Resize(cols, rows) => {
142 tracing::info!("Resized to: rows: {} cols: {}", rows, cols);
143 size.rows = rows;
144 size.cols = cols;
145 let pane_size = calc_pane_size(size, panes.len());
146 resize_all_panes(&mut panes, pane_size);
147 }
148 _ => {}
149 }
150 }
151 }
152}
153
154fn calc_pane_size(mut size: Size, nr_panes: usize) -> Size {
155 size.rows -= 2;
156 size.rows /= nr_panes as u16;
157 size
158}
159
160fn resize_all_panes(panes: &mut Vec<PtyPane>, size: Size) {
161 for pane in panes.iter() {
162 pane.resize(size);
163 }
164}
165
166struct PtyPane {
167 parser: Arc<RwLock<vt100::Parser>>,
168 sender: Sender<Bytes>,
169 master_pty: Box<dyn MasterPty>,
170}
171
172impl PtyPane {
173 fn new(size: Size, cmd: CommandBuilder) -> io::Result<Self> {
174 let pty_system = native_pty_system();
175 let pty_pair = pty_system
176 .openpty(PtySize {
177 rows: size.rows - 4,
178 cols: size.cols - 4,
179 pixel_width: 0,
180 pixel_height: 0,
181 })
182 .unwrap();
183 let parser = Arc::new(RwLock::new(vt100::Parser::new(
184 size.rows - 4,
185 size.cols - 4,
186 0,
187 )));
188
189 spawn_blocking(move || {
190 let mut child = pty_pair.slave.spawn_command(cmd).unwrap();
191 let _ = child.wait();
192 drop(pty_pair.slave);
193 });
194
195 {
196 let mut reader = pty_pair.master.try_clone_reader().unwrap();
197 let parser = parser.clone();
198 tokio::spawn(async move {
199 let mut processed_buf = Vec::new();
200 let mut buf = [0u8; 8192];
201
202 loop {
203 let size = reader.read(&mut buf).unwrap();
204 if size == 0 {
205 break;
206 }
207 if size > 0 {
208 processed_buf.extend_from_slice(&buf[..size]);
209 let mut parser = parser.write().unwrap();
210 parser.process(&processed_buf);
211
212 processed_buf.clear();
214 }
215 }
216 });
217 }
218
219 let (tx, mut rx) = channel::<Bytes>(32);
220
221 let mut writer = BufWriter::new(pty_pair.master.take_writer().unwrap());
222 tokio::spawn(async move {
224 while let Some(bytes) = rx.recv().await {
225 writer.write_all(&bytes).unwrap();
226 writer.flush().unwrap();
227 }
228 });
229
230 Ok(Self {
231 parser,
232 sender: tx,
233 master_pty: pty_pair.master,
234 })
235 }
236
237 fn resize(&self, size: Size) {
238 self.parser
239 .write()
240 .unwrap()
241 .set_size(size.rows - 4, size.cols - 4);
242 self.master_pty
243 .resize(PtySize {
244 rows: size.rows - 4,
245 cols: size.cols - 4,
246 pixel_width: 0,
247 pixel_height: 0,
248 })
249 .unwrap();
250 }
251}
252
253async fn handle_pane_key_event(pane: &mut PtyPane, key: &KeyEvent) -> bool {
254 let input_bytes = match key.code {
255 KeyCode::Char(ch) => {
256 let mut send = vec![ch as u8];
257 let upper = ch.to_ascii_uppercase();
258 if key.modifiers == KeyModifiers::CONTROL {
259 match upper {
260 'N' => {
261 return true;
263 }
264 'X' => {
265 return false;
267 }
268 '2' | '@' | ' ' => send = vec![0],
271 '3' | '[' => send = vec![27],
272 '4' | '\\' => send = vec![28],
273 '5' | ']' => send = vec![29],
274 '6' | '^' => send = vec![30],
275 '7' | '-' | '_' => send = vec![31],
276 char if ('A'..='_').contains(&char) => {
277 let ascii_val = char as u8;
281 let ascii_to_send = ascii_val - 64;
282 send = vec![ascii_to_send];
283 }
284 _ => {}
285 }
286 }
287 send
288 }
289 #[cfg(unix)]
290 KeyCode::Enter => vec![b'\n'],
291 #[cfg(windows)]
292 KeyCode::Enter => vec![b'\r', b'\n'],
293 KeyCode::Backspace => vec![8],
294 KeyCode::Left => vec![27, 91, 68],
295 KeyCode::Right => vec![27, 91, 67],
296 KeyCode::Up => vec![27, 91, 65],
297 KeyCode::Down => vec![27, 91, 66],
298 KeyCode::Tab => vec![9],
299 KeyCode::Home => vec![27, 91, 72],
300 KeyCode::End => vec![27, 91, 70],
301 KeyCode::PageUp => vec![27, 91, 53, 126],
302 KeyCode::PageDown => vec![27, 91, 54, 126],
303 KeyCode::BackTab => vec![27, 91, 90],
304 KeyCode::Delete => vec![27, 91, 51, 126],
305 KeyCode::Insert => vec![27, 91, 50, 126],
306 KeyCode::Esc => vec![27],
307 _ => return true,
308 };
309
310 pane.sender.send(Bytes::from(input_bytes)).await.ok();
311 true
312}
313
314fn open_new_pane(
315 panes: &mut Vec<PtyPane>,
316 active_pane: &mut Option<usize>,
317 cmd: &CommandBuilder,
318 size: Size,
319) -> io::Result<()> {
320 let new_pane = PtyPane::new(size, cmd.clone())?;
321 let new_pane_index = panes.len();
322 panes.push(new_pane);
323 *active_pane = Some(new_pane_index);
324 Ok(())
325}
326
327async fn close_active_pane(
328 panes: &mut Vec<PtyPane>,
329 active_pane: &mut Option<usize>,
330) -> io::Result<()> {
331 if let Some(active_index) = active_pane {
332 let _pane = panes.remove(*active_index);
333 if !panes.is_empty() {
335 let remaining_panes = panes.len();
336 let new_active_index = *active_index % remaining_panes;
337 *active_pane = Some(new_active_index);
338 }
339 }
340 Ok(())
341}
342
343fn setup_terminal() -> io::Result<(Terminal<CrosstermBackend<BufWriter<io::Stdout>>>, Size)> {
344 enable_raw_mode()?;
345 let stdout = io::stdout();
346 let backend = CrosstermBackend::new(BufWriter::new(stdout));
347 let mut terminal = Terminal::new(backend)?;
348 let initial_size = terminal.size()?;
349 let size = Size {
350 rows: initial_size.height,
351 cols: initial_size.width,
352 };
353 execute!(terminal.backend_mut(), EnterAlternateScreen)?;
354 Ok((terminal, size))
355}
356
357fn cleanup_terminal(
358 terminal: &mut Terminal<CrosstermBackend<BufWriter<io::Stdout>>>,
359) -> io::Result<()> {
360 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
361 disable_raw_mode()?;
362 terminal.show_cursor()?;
363 terminal.clear()?;
364 Ok(())
365}
366
367fn init_panic_hook() {
368 let log_file = Some(PathBuf::from("/tmp/tui-term/smux.log"));
369 let log_file = match log_file {
370 Some(path) => {
371 if let Some(parent) = path.parent() {
372 let _ = fs::create_dir_all(parent);
373 }
374 Some(fs::File::create(path).unwrap())
375 }
376 None => None,
377 };
378
379 let subscriber = FmtSubscriber::builder()
380 .with_max_level(Level::TRACE)
383 .with_writer(Mutex::new(log_file.unwrap()))
384 .with_thread_ids(true)
385 .with_ansi(true)
386 .with_line_number(true);
387
388 let subscriber = subscriber.finish();
389 tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
390
391 std::panic::set_hook(Box::new(|panic| {
393 let original_hook = std::panic::take_hook();
394 tracing::error!("Panic Error: {}", panic);
395 crossterm::terminal::disable_raw_mode().expect("Could not disable raw mode");
396 crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen)
397 .expect("Could not leave the alternate screen");
398
399 original_hook(panic);
400 }));
401 tracing::debug!("Set panic hook")
402}
403
404impl fmt::Debug for PtyPane {
405 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406 let parser = self.parser.read().unwrap();
407 let screen = parser.screen();
408
409 f.debug_struct("PtyPane")
410 .field("screen", screen)
411 .field("title:", &screen.title())
412 .field("icon_name:", &screen.icon_name())
413 .finish()
414 }
415}