diff --git a/src/ssh/handler.rs b/src/ssh/handler.rs index e6cc6f8..74f3473 100644 --- a/src/ssh/handler.rs +++ b/src/ssh/handler.rs @@ -1,5 +1,4 @@ use std::cmp::min; -use std::io::Write; use std::iter::once; use clap::Parser; @@ -57,11 +56,9 @@ pub struct Handler { user: Option, pty_channel: Option, - terminal: Option>>, renderer: super::Renderer, selected: Option, - - rename_buffer: Option, + rename_input: Option, } impl Handler { @@ -72,10 +69,10 @@ impl Handler { tunnels: Default::default(), user: None, pty_channel: None, - terminal: None, + renderer: Default::default(), selected: None, - rename_buffer: None, + rename_input: None, } } @@ -85,46 +82,6 @@ impl Handler { } } - async fn resize(&mut self, width: u32, height: u32) -> std::io::Result<()> { - if let Some(terminal) = &mut self.terminal { - let rect = Rect { - x: 0, - y: 0, - width: width as u16, - height: height as u16, - }; - - terminal.resize(rect)?; - self.redraw().await?; - } else { - warn!("Resize called without valid terminal"); - } - - Ok(()) - } - - pub fn close(&mut self) -> std::io::Result<()> { - if let Some(terminal) = self.terminal.take() { - drop(terminal); - } - - Ok(()) - } - - async fn redraw(&mut self) -> std::io::Result<()> { - if let Some(terminal) = &mut self.terminal { - trace!("redraw"); - self.renderer.update(&self.tunnels, self.selected).await; - terminal.draw(|frame| { - self.renderer.render(frame, &self.rename_buffer); - })?; - } else { - warn!("Redraw called without valid terminal"); - } - - Ok(()) - } - async fn set_access_selection(&mut self, access: TunnelAccess) { if let Some(selected) = self.selected { if let Some(tunnel) = self.tunnels.get_mut(selected) { @@ -135,17 +92,17 @@ impl Handler { } } - async fn handle_input(&mut self, input: Input) -> std::io::Result { - if self.rename_buffer.is_some() { + async fn handle_input(&mut self, input: Input) -> std::io::Result<()> { + if self.rename_input.is_some() { match input { Input::Char(c) if c.is_alphanumeric() => { - self.rename_buffer + self.rename_input .as_mut() .expect("input buffer should be some") .push(c.to_ascii_lowercase()); } Input::Backspace => { - self.rename_buffer + self.rename_input .as_mut() .expect("input buffer should be some") .pop(); @@ -154,85 +111,100 @@ impl Handler { debug!("Input accepted"); if let Some(selected) = self.selected && let Some(tunnel) = self.tunnels.get_mut(selected) - && let Some(buffer) = self.rename_buffer.take() + && let Some(buffer) = self.rename_input.take() { tunnel.set_name(buffer).await; + self.renderer.rows(&self.tunnels).await; } else { warn!("Trying to rename invalid tunnel"); } } Input::Esc => { debug!("Input rejected"); - self.rename_buffer = None; + self.rename_input = None; } - _ => return Ok(false), + _ => return Ok(()), } - debug!("Input: {:?}", self.rename_buffer); + debug!("Input: {:?}", self.rename_input); + self.renderer.rename(&self.rename_input); } else { match input { Input::Char('q') => { - self.close()?; - return Ok(false); + self.renderer.close(); + } + Input::Char('k') | Input::Up => { + self.previous_row(); + self.renderer.select(self.selected); + } + Input::Char('j') | Input::Down => { + self.next_row(); + self.renderer.select(self.selected); + } + Input::Esc => { + self.selected = None; + self.renderer.select(self.selected); } - Input::Char('k') | Input::Up => self.previous_row(), - Input::Char('j') | Input::Down => self.next_row(), - Input::Esc => self.selected = None, Input::Char('P') => { self.set_access_selection(TunnelAccess::Public).await; + self.renderer.rows(&self.tunnels).await; } Input::Char('p') => { if let Some(user) = self.user.clone() { self.set_access_selection(TunnelAccess::Private(user)).await; + self.renderer.rows(&self.tunnels).await; } else { warn!("User not set"); } } Input::Char('R') => { let Some(selected) = self.selected else { - return Ok(false); + return Ok(()); }; let Some(tunnel) = self.tunnels.get_mut(selected) else { warn!("Trying to retry invalid tunnel"); - return Ok(false); + return Ok(()); }; tunnel.retry().await; + self.renderer.rows(&self.tunnels).await; } Input::Char('r') => { if self.selected.is_some() { trace!("Renaming tunnel"); - self.rename_buffer = Some(String::new()); + self.rename_input = Some(String::new()); + self.renderer.rename(&self.rename_input); } } Input::Delete => { let Some(selected) = self.selected else { - return Ok(false); + return Ok(()); }; if selected >= self.tunnels.len() { warn!("Trying to delete tunnel out of bounds"); - return Ok(false); + return Ok(()); } self.tunnels.remove(selected); + self.renderer.rows(&self.tunnels).await; if self.tunnels.is_empty() { self.selected = None; } else { self.selected = Some(min(self.tunnels.len() - 1, selected)); } + self.renderer.select(self.selected); } Input::CtrlP => { self.set_access_selection(TunnelAccess::Protected).await; + self.renderer.rows(&self.tunnels).await; } - _ => { - return Ok(false); - } + _ => {} }; } - Ok(true) + Ok(()) } fn next_row(&mut self) { @@ -316,9 +288,7 @@ impl russh::server::Handler for Handler { let input: Input = data.into(); trace!(?input, "input"); - if self.handle_input(input).await? { - self.redraw().await?; - } + self.handle_input(input).await?; } Ok(()) @@ -341,31 +311,17 @@ impl russh::server::Handler for Handler { if args.make_public() { trace!("Making tunnels public"); self.set_access_all(TunnelAccess::Public).await; - self.redraw().await?; + self.renderer.rows(&self.tunnels).await; } else if args.make_protected() { trace!("Making tunnels protected"); self.set_access_all(TunnelAccess::Protected).await; - self.redraw().await?; + self.renderer.rows(&self.tunnels).await; } } Err(err) => { trace!("Sending help message and disconnecting"); - if let Some(terminal) = &mut self.terminal { - let writer = terminal.backend_mut().writer_mut(); - - writer.leave_alternate_screen()?; - writer.write_all( - err.render() - .ansi() - .to_string() - .replace('\n', "\n\r") - .as_bytes(), - )?; - writer.flush()?; - } - - self.close()?; + self.renderer.help(err.render().ansi().to_string()); } } @@ -411,7 +367,7 @@ impl russh::server::Handler for Handler { ) -> Result<(), Self::Error> { trace!(col_width, row_height, "window_change_request"); - self.resize(col_width, row_height).await?; + self.renderer.resize(col_width as u16, row_height as u16); Ok(()) } @@ -440,8 +396,10 @@ impl russh::server::Handler for Handler { let options = TerminalOptions { viewport: Viewport::Fixed(rect), }; - self.terminal = Some(Terminal::with_options(backend, options)?); - self.redraw().await?; + let terminal = Terminal::with_options(backend, options)?; + self.renderer.start(terminal); + + self.renderer.rows(&self.tunnels).await; self.pty_channel = Some(channel); diff --git a/src/ssh/renderer.rs b/src/ssh/renderer.rs index 3018347..cb1e276 100644 --- a/src/ssh/renderer.rs +++ b/src/ssh/renderer.rs @@ -1,43 +1,58 @@ use std::cmp::{self, max}; +use std::io::Write as _; use futures::StreamExt; use git_version::git_version; -use ratatui::Frame; use ratatui::layout::{Constraint, Flex, Layout, Position, Rect}; +use ratatui::prelude::CrosstermBackend; use ratatui::style::{Style, Stylize as _}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{ Block, BorderType, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, }; +use ratatui::{Frame, Terminal}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; +use tracing::error; use unicode_width::UnicodeWidthStr; +use crate::io::TerminalHandle; use crate::tunnel::Tunnel; -#[derive(Default)] -pub struct Renderer { - table_state: TableState, - table_rows: Vec>>, +enum Message { + Resize { width: u16, height: u16 }, + Redraw, + Rows(Vec>>), + Select(Option), + Rename(Option), + Help(String), + Close, } -fn command<'c>(key: &'c str, text: &'c str) -> Vec> { - vec![key.bold().light_cyan(), " ".into(), text.dim()] +struct RendererInner { + state: TableState, + rows: Vec>>, + input: Option, + rx: UnboundedReceiver, } -impl Renderer { - // NOTE: This needs to be a separate function as the render functions can not be async - pub async fn update(&mut self, tunnels: &[Tunnel], index: Option) { - self.table_rows = futures::stream::iter(tunnels) - .then(Tunnel::to_row) - .collect::>() - .await; - - self.table_state.select(index); +impl RendererInner { + fn new(rx: UnboundedReceiver) -> Self { + Self { + state: Default::default(), + rows: Default::default(), + input: None, + rx, + } } - pub fn compute_footer_text<'a>(&self, rect: Rect) -> (u16, Paragraph<'a>) { + fn compute_footer_text<'a>(&self, rect: Rect) -> (u16, Paragraph<'a>) { let width = rect.width as usize - 2; - let commands = if self.table_state.selected().is_some() { + fn command<'c>(key: &'c str, text: &'c str) -> Vec> { + vec![key.bold().light_cyan(), " ".into(), text.dim()] + } + + let commands = if self.state.selected().is_some() { vec![ command("q", "quit"), command("esc", "deselect"), @@ -92,7 +107,7 @@ impl Renderer { (height as u16, Paragraph::new(text).centered().block(block)) } - pub fn render(&mut self, frame: &mut Frame, input: &Option) { + fn render(&mut self, frame: &mut Frame) { self.render_title(frame, frame.area()); let mut area = frame.area().inner(ratatui::layout::Margin { @@ -108,12 +123,12 @@ impl Renderer { self.render_table(frame, chunks[0]); frame.render_widget(footer, chunks[1]); - if let Some(input) = input { + if let Some(input) = &self.input { self.render_rename(frame, area, input); } } - pub fn render_rename(&self, frame: &mut Frame, area: Rect, input: &str) { + fn render_rename(&self, frame: &mut Frame, area: Rect, input: &str) { let vertical = Layout::vertical([Constraint::Length(3)]).flex(Flex::Center); let horizontal = Layout::horizontal([Constraint::Max(max(20, input.width() as u16 + 4))]) .flex(Flex::Center); @@ -131,7 +146,7 @@ impl Renderer { frame.set_cursor_position(Position::new(area.x + input.width() as u16 + 2, area.y + 1)); } - pub fn render_title(&self, frame: &mut Frame, rect: Rect) { + fn render_title(&self, frame: &mut Frame, rect: Rect) { let title = format!("{} ({})", std::env!("CARGO_PKG_NAME"), git_version!()).bold(); let title = Line::from(title).centered(); frame.render_widget(title, rect); @@ -140,7 +155,7 @@ impl Renderer { fn compute_widths(&mut self) -> Vec { let table_header = Tunnel::header(); std::iter::once(&table_header) - .chain(&self.table_rows) + .chain(&self.rows) .map(|row| row.iter().map(|cell| cell.width() as u16)) .fold(vec![0; table_header.len()], |acc, row| { acc.into_iter() @@ -158,7 +173,7 @@ impl Renderer { let header_style = Style::default().bold().reversed(); let row_style = Style::default(); - let rows = self.table_rows.iter().map(|row| { + let rows = self.rows.iter().map(|row| { row.iter() .cloned() .map(Cell::from) @@ -185,6 +200,114 @@ impl Renderer { .highlight_symbol(Line::from("> ")) .highlight_spacing(HighlightSpacing::Always); - frame.render_stateful_widget(t, rect, &mut self.table_state); + frame.render_stateful_widget(t, rect, &mut self.state); + } + + pub async fn start( + &mut self, + mut terminal: Terminal>, + ) -> std::io::Result<()> { + while let Some(message) = self.rx.recv().await { + match message { + Message::Resize { width, height } => { + let rect = Rect::new(0, 0, width, height); + + terminal.resize(rect)?; + } + Message::Select(selected) => self.state.select(selected), + Message::Rename(input) => self.input = input, + Message::Rows(rows) => self.rows = rows, + Message::Redraw => { + terminal.draw(|frame| { + self.render(frame); + })?; + } + Message::Help(message) => { + let writer = terminal.backend_mut().writer_mut(); + writer.leave_alternate_screen()?; + writer.write_all(message.as_bytes())?; + writer.flush()?; + + break; + } + Message::Close => { + break; + } + } + } + + Ok(()) + } +} + +#[derive(Debug, Default, Clone)] +pub struct Renderer { + tx: Option>, +} + +impl Renderer { + pub fn start(&mut self, terminal: Terminal>) { + let (tx, rx) = unbounded_channel(); + + let mut inner = RendererInner::new(rx); + + tokio::spawn(async move { + if let Err(err) = inner.start(terminal).await { + error!("{err}"); + } + }); + + self.tx = Some(tx) + } + + pub fn select(&self, selected: Option) { + if let Some(tx) = &self.tx { + tx.send(Message::Select(selected)).ok(); + self.redraw(); + } + } + + pub fn rename(&self, input: &Option) { + if let Some(tx) = &self.tx { + tx.send(Message::Rename(input.clone())).ok(); + self.redraw(); + } + } + + pub fn help(&self, message: String) { + if let Some(tx) = &self.tx { + tx.send(Message::Help(message.replace("\n", "\n\r"))).ok(); + } + } + + pub fn close(&self) { + if let Some(tx) = &self.tx { + tx.send(Message::Close).ok(); + } + } + + pub fn resize(&self, width: u16, height: u16) { + if let Some(tx) = &self.tx { + tx.send(Message::Resize { width, height }).ok(); + self.redraw(); + } + } + + pub async fn rows(&self, tunnels: &[Tunnel]) { + if let Some(tx) = &self.tx { + let rows = futures::stream::iter(tunnels) + .then(Tunnel::to_row) + .collect::>() + .await; + + tx.send(Message::Rows(rows)).ok(); + self.redraw(); + } + } + + pub fn redraw(&self) { + if let Some(tx) = &self.tx { + tx.send(Message::Redraw).ok(); + } } }