diff --git a/Cargo.lock b/Cargo.lock index a5581cd37e96f..3de2f8eec4688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,6 +1473,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.49" @@ -2661,6 +2670,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error-code" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" + [[package]] name = "event-listener" version = "2.5.3" @@ -11435,7 +11450,9 @@ version = "0.1.0" dependencies = [ "anyhow", "atty", + "base64 0.22.1", "chrono", + "clipboard-win", "console", "crossterm 0.27.0", "dialoguer", @@ -11452,6 +11469,7 @@ dependencies = [ "turbopath", "turborepo-ci", "turborepo-vt100", + "which", "winapi", ] diff --git a/crates/turborepo-ui/Cargo.toml b/crates/turborepo-ui/Cargo.toml index 14b7ce8804ead..bc20ec39440a3 100644 --- a/crates/turborepo-ui/Cargo.toml +++ b/crates/turborepo-ui/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] atty = { workspace = true } +base64 = "0.22" chrono = { workspace = true } console = { workspace = true } crossterm = "0.27.0" @@ -30,4 +31,8 @@ tui-term = { workspace = true } turbopath = { workspace = true } turborepo-ci = { workspace = true } turborepo-vt100 = { workspace = true } +which = { workspace = true } winapi = "0.3.9" + +[target."cfg(windows)".dependencies] +clipboard-win = "5.3.1" diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index bf8f008855011..4fc591842c04b 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -32,11 +32,13 @@ pub enum LayoutSections { } pub struct App { - rows: u16, - cols: u16, + term_cols: u16, + pane_rows: u16, + pane_cols: u16, tasks: BTreeMap>, tasks_by_status: TasksByStatus, - input_options: InputOptions, + focus: LayoutSections, + tty_stdin: bool, scroll: TableState, selected_task_index: usize, has_user_scrolled: bool, @@ -51,6 +53,15 @@ pub enum Direction { impl App { pub fn new(rows: u16, cols: u16, tasks: Vec) -> Self { debug!("tasks: {tasks:?}"); + let task_width_hint = TaskTable::width_hint(tasks.iter().map(|s| s.as_str())); + + // Want to maximize pane width + let ratio_pane_width = (f32::from(cols) * PANE_SIZE_RATIO) as u16; + let full_task_width = cols.saturating_sub(task_width_hint); + let pane_cols = full_task_width.max(ratio_pane_width); + + // We use 2 rows for pane title and for the interaction info + let rows = rows.saturating_sub(2).max(1); // Initializes with the planned tasks // and will mutate as tasks change @@ -69,14 +80,13 @@ impl App { let selected_task_index: usize = 0; Self { - rows, - cols, + term_cols: cols, + pane_rows: rows, + pane_cols, done: false, - input_options: InputOptions { - focus: LayoutSections::TaskList, - // Check if stdin is a tty that we should read input from - tty_stdin: atty::is(atty::Stream::Stdin), - }, + focus: LayoutSections::TaskList, + // Check if stdin is a tty that we should read input from + tty_stdin: atty::is(atty::Stream::Stdin), tasks: tasks_by_status .task_names_in_displayed_order() .map(|task_name| (task_name.to_owned(), TerminalOutput::new(rows, cols, None))) @@ -89,20 +99,33 @@ impl App { } pub fn is_focusing_pane(&self) -> bool { - match self.input_options.focus { + match self.focus { LayoutSections::Pane => true, LayoutSections::TaskList => false, } } - pub fn active_task(&self) -> String { - self.tasks_by_status - .task_name(self.selected_task_index) - .to_string() + pub fn active_task(&self) -> &str { + self.tasks_by_status.task_name(self.selected_task_index) + } + + fn input_options(&self) -> InputOptions { + let has_selection = self.get_full_task().has_selection(); + InputOptions { + focus: self.focus, + tty_stdin: self.tty_stdin, + has_selection, + } + } + + pub fn get_full_task(&self) -> &TerminalOutput { + self.tasks.get(self.active_task()).unwrap() } pub fn get_full_task_mut(&mut self) -> &mut TerminalOutput { - self.tasks.get_mut(&self.active_task()).unwrap() + // Clippy is wrong here, we need this to avoid a borrow checker error + #[allow(clippy::unnecessary_to_owned)] + self.tasks.get_mut(&self.active_task().to_owned()).unwrap() } #[tracing::instrument(skip(self))] @@ -127,11 +150,7 @@ impl App { #[tracing::instrument(skip_all)] pub fn scroll_terminal_output(&mut self, direction: Direction) { - self.tasks - .get_mut(&self.active_task()) - .unwrap() - .scroll(direction) - .unwrap_or_default(); + self.get_full_task_mut().scroll(direction).unwrap(); } /// Mark the given task as started. @@ -247,8 +266,7 @@ impl App { } pub fn has_stdin(&self) -> bool { - let active_task = self.active_task(); - if let Some(term) = self.tasks.get(&active_task) { + if let Some(term) = self.tasks.get(self.active_task()) { term.stdin.is_some() } else { false @@ -256,10 +274,10 @@ impl App { } pub fn interact(&mut self) { - if matches!(self.input_options.focus, LayoutSections::Pane) { - self.input_options.focus = LayoutSections::TaskList + if matches!(self.focus, LayoutSections::Pane) { + self.focus = LayoutSections::TaskList } else if self.has_stdin() { - self.input_options.focus = LayoutSections::Pane; + self.focus = LayoutSections::Pane; } } @@ -269,7 +287,7 @@ impl App { for task in &tasks { self.tasks .entry(task.clone()) - .or_insert_with(|| TerminalOutput::new(self.rows, self.cols, None)); + .or_insert_with(|| TerminalOutput::new(self.pane_rows, self.pane_cols, None)); } // Trim the terminal output to only tasks that exist in new list self.tasks.retain(|name, _| tasks.contains(name)); @@ -311,6 +329,36 @@ impl App { task.cache_result = Some(result); Ok(()) } + + pub fn handle_mouse(&mut self, mut event: crossterm::event::MouseEvent) -> Result<(), Error> { + let table_width = self.term_cols - self.pane_cols; + debug!("original mouse event: {event:?}, table_width: {table_width}"); + // Only handle mouse event if it happens inside of pane + // We give a 1 cell buffer to make it easier to select the first column of a row + if event.row > 0 && event.column >= table_width { + // Subtract 1 from the y axis due to the title of the pane + event.row -= 1; + // Subtract the width of the table + event.column -= table_width; + debug!("translated mouse event: {event:?}"); + + let task = self.get_full_task_mut(); + task.handle_mouse(event)?; + } + + Ok(()) + } + + pub fn copy_selection(&self) { + let task = self + .tasks + .get(self.active_task()) + .expect("active task should exist"); + let Some(text) = task.copy_selection() else { + return; + }; + super::copy_to_clipboard(&text); + } } impl App { @@ -328,11 +376,11 @@ impl App { #[tracing::instrument(skip_all)] pub fn forward_input(&mut self, bytes: &[u8]) -> Result<(), Error> { - if matches!(self.input_options.focus, LayoutSections::Pane) { + if matches!(self.focus, LayoutSections::Pane) { let task_output = self.get_full_task_mut(); if let Some(stdin) = &mut task_output.stdin { stdin.write_all(bytes).map_err(|e| Error::Stdin { - name: self.active_task(), + name: self.active_task().to_owned(), e, })?; } @@ -355,21 +403,10 @@ impl App { pub fn run_app(tasks: Vec, receiver: AppReceiver) -> Result<(), Error> { let mut terminal = startup()?; let size = terminal.size()?; - // Figure out pane width? - let task_width_hint = TaskTable::width_hint(tasks.iter().map(|s| s.as_str())); - // Want to maximize pane width - let ratio_pane_width = (f32::from(size.width) * PANE_SIZE_RATIO) as u16; - let full_task_width = size.width.saturating_sub(task_width_hint); - - let mut app: App> = - App::new(size.height, full_task_width.max(ratio_pane_width), tasks); - - let (result, callback) = match run_app_inner( - &mut terminal, - &mut app, - receiver, - full_task_width.max(ratio_pane_width), - ) { + + let mut app: App> = App::new(size.height, size.width, tasks); + + let (result, callback) = match run_app_inner(&mut terminal, &mut app, receiver) { Ok(callback) => (Ok(()), callback), Err(err) => (Err(err), None), }; @@ -385,19 +422,18 @@ fn run_app_inner( terminal: &mut Terminal, app: &mut App>, receiver: AppReceiver, - cols: u16, ) -> Result>, Error> { // Render initial state to paint the screen - terminal.draw(|f| view(app, f, cols))?; + terminal.draw(|f| view(app, f))?; let mut last_render = Instant::now(); let mut callback = None; - while let Some(event) = poll(app.input_options, &receiver, last_render + FRAMERATE) { + while let Some(event) = poll(app.input_options(), &receiver, last_render + FRAMERATE) { callback = update(app, event)?; if app.done { break; } if FRAMERATE <= last_render.elapsed() { - terminal.draw(|f| view(app, f, cols))?; + terminal.draw(|f| view(app, f))?; last_render = Instant::now(); } } @@ -534,15 +570,22 @@ fn update( app.update_tasks(tasks); // app.table.tick(); } + Event::Mouse(m) => { + app.handle_mouse(m)?; + } + Event::CopySelection => { + app.copy_selection(); + } } Ok(None) } -fn view(app: &mut App, f: &mut Frame, cols: u16) { +fn view(app: &mut App, f: &mut Frame) { + let cols = app.pane_cols; let horizontal = Layout::horizontal([Constraint::Fill(1), Constraint::Length(cols)]); let [table, pane] = horizontal.areas(f.size()); - let active_task = app.active_task(); + let active_task = app.active_task().to_string(); let output_logs = app.tasks.get(&active_task).unwrap(); let pane_to_render: TerminalPane = diff --git a/crates/turborepo-ui/src/tui/clipboard.rs b/crates/turborepo-ui/src/tui/clipboard.rs new file mode 100644 index 0000000000000..e1572c646c78f --- /dev/null +++ b/crates/turborepo-ui/src/tui/clipboard.rs @@ -0,0 +1,112 @@ +// Inspired by https://github.com/pvolok/mprocs/blob/master/src/clipboard.rs +use std::process::Stdio; + +use base64::Engine; +use which::which; + +pub fn copy_to_clipboard(s: &str) { + match copy_impl(s, &PROVIDER) { + Ok(()) => (), + Err(err) => tracing::debug!("Unable to copy: {}", err.to_string()), + } +} + +#[allow(dead_code)] +enum Provider { + OSC52, + Exec(&'static str, Vec<&'static str>), + #[cfg(windows)] + Win, + NoOp, +} + +#[cfg(windows)] +fn detect_copy_provider() -> Provider { + Provider::Win +} + +#[cfg(target_os = "macos")] +fn detect_copy_provider() -> Provider { + if let Some(provider) = check_prog("pbcopy", &[]) { + return provider; + } + Provider::OSC52 +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn detect_copy_provider() -> Provider { + // Wayland + if std::env::var("WAYLAND_DISPLAY").is_ok() { + if let Some(provider) = check_prog("wl-copy", &["--type", "text/plain"]) { + return provider; + } + } + // X11 + if std::env::var("DISPLAY").is_ok() { + if let Some(provider) = check_prog("xclip", &["-i", "-selection", "clipboard"]) { + return provider; + } + if let Some(provider) = check_prog("xsel", &["-i", "-b"]) { + return provider; + } + } + // Termux + if let Some(provider) = check_prog("termux-clipboard-set", &[]) { + return provider; + } + // Tmux + if std::env::var("TMUX").is_ok() { + if let Some(provider) = check_prog("tmux", &["load-buffer", "-"]) { + return provider; + } + } + + Provider::OSC52 +} + +#[allow(dead_code)] +fn check_prog(cmd: &'static str, args: &[&'static str]) -> Option { + if which(cmd).is_ok() { + Some(Provider::Exec(cmd, args.to_vec())) + } else { + None + } +} + +fn copy_impl(s: &str, provider: &Provider) -> std::io::Result<()> { + match provider { + Provider::OSC52 => { + let mut stdout = std::io::stdout().lock(); + use std::io::Write; + write!( + &mut stdout, + "\x1b]52;;{}\x07", + base64::engine::general_purpose::STANDARD.encode(s) + )?; + } + + Provider::Exec(prog, args) => { + let mut child = std::process::Command::new(prog) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + std::io::Write::write_all(&mut child.stdin.as_ref().unwrap(), s.as_bytes())?; + child.wait()?; + } + + #[cfg(windows)] + Provider::Win => clipboard_win::set_clipboard_string(s) + .map_err(|e| std::io::Error::other(e.to_string()))?, + + Provider::NoOp => (), + }; + + Ok(()) +} + +lazy_static::lazy_static! { + static ref PROVIDER: Provider = detect_copy_provider(); +} diff --git a/crates/turborepo-ui/src/tui/event.rs b/crates/turborepo-ui/src/tui/event.rs index cdcac1d8803c9..716333da30272 100644 --- a/crates/turborepo-ui/src/tui/event.rs +++ b/crates/turborepo-ui/src/tui/event.rs @@ -36,6 +36,8 @@ pub enum Event { UpdateTasks { tasks: Vec, }, + Mouse(crossterm::event::MouseEvent), + CopySelection, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index be8747672ec62..ef4ac763036a8 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -8,22 +8,26 @@ use super::{app::LayoutSections, event::Event, Error}; pub struct InputOptions { pub focus: LayoutSections, pub tty_stdin: bool, + pub has_selection: bool, } /// Return any immediately available event pub fn input(options: InputOptions) -> Result, Error> { - let InputOptions { focus, tty_stdin } = options; // If stdin is not a tty, then we do not attempt to read from it - if !tty_stdin { + if !options.tty_stdin { return Ok(None); } // poll with 0 duration will only return true if event::read won't need to wait // for input if crossterm::event::poll(Duration::from_millis(0))? { match crossterm::event::read()? { - crossterm::event::Event::Key(k) => Ok(translate_key_event(&focus, k)), + crossterm::event::Event::Key(k) => Ok(translate_key_event(options, k)), crossterm::event::Event::Mouse(m) => match m.kind { crossterm::event::MouseEventKind::ScrollDown => Ok(Some(Event::ScrollDown)), crossterm::event::MouseEventKind::ScrollUp => Ok(Some(Event::ScrollUp)), + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) + | crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + Ok(Some(Event::Mouse(m))) + } _ => Ok(None), }, _ => Ok(None), @@ -34,7 +38,7 @@ pub fn input(options: InputOptions) -> Result, Error> { } /// Converts a crossterm key event into a TUI interaction event -fn translate_key_event(interact: &LayoutSections, key_event: KeyEvent) -> Option { +fn translate_key_event(options: InputOptions, key_event: KeyEvent) -> Option { // On Windows events for releasing a key are produced // We skip these to avoid emitting 2 events per key press. // There is still a `Repeat` event for when a key is held that will pass through @@ -46,15 +50,16 @@ fn translate_key_event(interact: &LayoutSections, key_event: KeyEvent) -> Option KeyCode::Char('c') if key_event.modifiers == crossterm::event::KeyModifiers::CONTROL => { ctrl_c() } + KeyCode::Char('c') if options.has_selection => Some(Event::CopySelection), // Interactive branches KeyCode::Char('z') - if matches!(interact, LayoutSections::Pane) + if matches!(options.focus, LayoutSections::Pane) && key_event.modifiers == crossterm::event::KeyModifiers::CONTROL => { Some(Event::ExitInteractive) } // If we're in interactive mode, convert the key event to bytes to send to stdin - _ if matches!(interact, LayoutSections::Pane) => Some(Event::Input { + _ if matches!(options.focus, LayoutSections::Pane) => Some(Event::Input { bytes: encode_key(key_event), }), // Fall through if we aren't in interactive mode diff --git a/crates/turborepo-ui/src/tui/mod.rs b/crates/turborepo-ui/src/tui/mod.rs index 5bcb487873a69..93a016ecc04e0 100644 --- a/crates/turborepo-ui/src/tui/mod.rs +++ b/crates/turborepo-ui/src/tui/mod.rs @@ -1,4 +1,5 @@ mod app; +mod clipboard; pub mod event; mod handle; mod input; @@ -9,6 +10,7 @@ mod task; mod term_output; pub use app::{run_app, terminal_big_enough}; +use clipboard::copy_to_clipboard; use event::{Event, TaskResult}; pub use handle::{AppReceiver, AppSender, TuiTask}; use input::{input, InputOptions}; diff --git a/crates/turborepo-ui/src/tui/pane.rs b/crates/turborepo-ui/src/tui/pane.rs index ff34d52142476..c7a26b361f543 100644 --- a/crates/turborepo-ui/src/tui/pane.rs +++ b/crates/turborepo-ui/src/tui/pane.rs @@ -9,6 +9,7 @@ use super::TerminalOutput; const FOOTER_TEXT_ACTIVE: &str = "Press`Ctrl-Z` to stop interacting."; const FOOTER_TEXT_INACTIVE: &str = "Press `Enter` to interact."; +const HAS_SELECTION: &str = "Press `c` to copy selection"; pub struct TerminalPane<'a, W> { terminal_output: &'a TerminalOutput, @@ -36,15 +37,27 @@ impl<'a, W> Widget for &TerminalPane<'a, W> { Self: Sized, { let screen = self.terminal_output.parser.screen(); - let mut block = Block::default() - .borders(Borders::LEFT) - .title(self.terminal_output.title(self.task_name)); - if self.highlight { - block = block.title_bottom(Line::from(FOOTER_TEXT_ACTIVE).centered()); - block = block.border_style(Style::new().fg(ratatui::style::Color::Yellow)); + let mut help_text = if self.highlight { + FOOTER_TEXT_ACTIVE } else { - block = block.title_bottom(Line::from(FOOTER_TEXT_INACTIVE).centered()); + FOOTER_TEXT_INACTIVE + } + .to_owned(); + + if self.terminal_output.has_selection() { + help_text.push(' '); + help_text.push_str(HAS_SELECTION); } + let block = Block::default() + .borders(Borders::LEFT) + .title(self.terminal_output.title(self.task_name)) + .title_bottom(Line::from(help_text).centered()) + .style(if self.highlight { + Style::new().fg(ratatui::style::Color::Yellow) + } else { + Style::new() + }); + let term = PseudoTerminal::new(screen).block(block); term.render(area, buf) } diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index 755600306ec07..eebc5bb30a708 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -112,4 +112,44 @@ impl TerminalOutput { } Ok(()) } + + pub fn has_selection(&self) -> bool { + self.parser + .screen() + .selected_text() + .map_or(false, |s| !s.is_empty()) + } + + pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Result<(), Error> { + match event.kind { + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + // We need to update the vterm so we don't continue to render the selection + self.parser.screen_mut().clear_selection(); + } + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + // Update selection of underlying parser + self.parser + .screen_mut() + .update_selection(event.row, event.column); + } + // Scrolling is handled elsewhere + crossterm::event::MouseEventKind::ScrollDown => (), + crossterm::event::MouseEventKind::ScrollUp => (), + // I think we can ignore this? + crossterm::event::MouseEventKind::Moved => (), + // Don't care about other mouse buttons + crossterm::event::MouseEventKind::Down(_) => (), + crossterm::event::MouseEventKind::Drag(_) => (), + // We don't support horizontal scroll + crossterm::event::MouseEventKind::ScrollLeft + | crossterm::event::MouseEventKind::ScrollRight => (), + // Cool, person stopped holding down mouse + crossterm::event::MouseEventKind::Up(_) => (), + } + Ok(()) + } + + pub fn copy_selection(&self) -> Option { + self.parser.screen().selected_text() + } } diff --git a/crates/turborepo-vt100/src/cell.rs b/crates/turborepo-vt100/src/cell.rs index b7ddd2666c5e5..3c38fb0db6163 100644 --- a/crates/turborepo-vt100/src/cell.rs +++ b/crates/turborepo-vt100/src/cell.rs @@ -8,6 +8,7 @@ pub struct Cell { contents: [char; CODEPOINTS_IN_CELL], len: u8, attrs: crate::attrs::Attrs, + selected: bool, } impl PartialEq for Cell { @@ -30,6 +31,7 @@ impl Cell { contents: Default::default(), len: 0, attrs: crate::attrs::Attrs::default(), + selected: false, } } @@ -68,6 +70,15 @@ impl Cell { pub(crate) fn clear(&mut self, attrs: crate::attrs::Attrs) { self.len = 0; self.attrs = attrs; + self.selected = false; + } + + pub(crate) fn selected(&self) -> bool { + self.selected + } + + pub(crate) fn select(&mut self, selected: bool) { + self.selected = selected; } /// Returns the text contents of the cell. @@ -161,6 +172,6 @@ impl Cell { /// attribute. #[must_use] pub fn inverse(&self) -> bool { - self.attrs.inverse() + self.attrs.inverse() || self.selected } } diff --git a/crates/turborepo-vt100/src/grid.rs b/crates/turborepo-vt100/src/grid.rs index 77b4305cfecd0..ee3a16ac91b62 100644 --- a/crates/turborepo-vt100/src/grid.rs +++ b/crates/turborepo-vt100/src/grid.rs @@ -13,6 +13,18 @@ pub struct Grid { scrollback: std::collections::VecDeque, scrollback_len: usize, scrollback_offset: usize, + selection: Option, +} + +/// Represents a selection that starts at start (inclusive) and ends at +/// end (column-wise exclusive). +/// +/// Start position is not required to be less than the end position, if that ordering is desired +/// use `.ordered()` to ensure that start is before end. +#[derive(Clone, Debug, Copy)] +pub struct Selection { + pub start: AbsPos, + pub end: AbsPos, } impl Grid { @@ -29,6 +41,7 @@ impl Grid { scrollback: std::collections::VecDeque::with_capacity(0), scrollback_len, scrollback_offset: 0, + selection: None, } } @@ -137,7 +150,8 @@ impl Grid { let rows_len = self.rows.len(); self.scrollback .iter() - .skip(scrollback_len - self.scrollback_offset) + // Saturating sub to avoid underflow if user passes a scrollback that's too large + .skip(scrollback_len.saturating_sub(self.scrollback_offset)) // when scrollback_offset > rows_len (e.g. rows = 3, // scrollback_len = 10, offset = 9) the skip(10 - 9) // will take 9 rows instead of 3. we need to set @@ -160,6 +174,12 @@ impl Grid { self.scrollback.iter().chain(self.rows.iter()) } + pub fn all_rows_mut( + &mut self, + ) -> impl Iterator { + self.scrollback.iter_mut().chain(self.rows.iter_mut()) + } + pub(crate) fn all_row(&self, row: u16) -> Option<&crate::row::Row> { let row = usize::from(row); if row < self.scrollback.len() { @@ -223,6 +243,113 @@ impl Grid { self.scrollback_offset = rows.min(self.scrollback.len()); } + pub fn clear_selection(&mut self) { + // go through and make sure all selected cells are toggled deselected + if let Some(selected_cells) = self.selection_cells() { + for cell in selected_cells { + debug_assert!( + cell.selected(), + "selected cell should be selected" + ); + cell.select(false); + } + }; + self.selection = None; + } + + pub fn set_selection( + &mut self, + start_row: u16, + start_col: u16, + end_row: u16, + end_col: u16, + ) { + self.clear_selection(); + let start = self.translate_pos(start_row, start_col); + let end = self.translate_pos(end_row, end_col); + self.selection = Some(Selection { start, end }); + if let Some(selected_cells) = self.selection_cells() { + for cell in selected_cells { + debug_assert!( + !cell.selected(), + "cell shouldn't be selected at start" + ); + cell.select(true); + } + }; + } + + pub fn update_selection(&mut self, row: u16, col: u16) { + let pos = self.translate_pos(row, col); + // Copy out current selection + let old_selection = self.selection; + // Unselect current selection + self.clear_selection(); + + // Update selection with new endpoint + let mut selection = + old_selection.unwrap_or_else(|| Selection::new(pos)); + selection.update(pos); + self.selection = Some(selection); + // Mark cells in new selection as selected + if let Some(selected_cells) = self.selection_cells() { + for cell in selected_cells { + debug_assert!( + !cell.selected(), + "cell shouldn't be selected at start" + ); + cell.select(true); + } + }; + } + + fn translate_pos(&self, row: u16, col: u16) -> AbsPos { + let Size { rows, cols } = self.size(); + let (row, col) = (row.clamp(0, rows), col.clamp(0, cols)); + // Add current scrollback length to make the row position absolute + AbsPos { + row: self.scrollback.len().saturating_sub(self.scrollback_offset) + + usize::from(row), + col, + } + } + + fn selection_cells( + &mut self, + ) -> Option + '_> { + let Selection { start, end } = self.selection()?; + let cols = self.size.cols; + Some( + self.all_rows_mut() + .enumerate() + .skip(start.row) + .take(end.row - start.row + 1) + .flat_map(move |(row_index, row)| { + debug_assert!( + start.row <= row_index && row_index <= end.row, + "only rows in selection should be returned" + ); + let (cells_to_skip, cells_to_take) = + if row_index == start.row && row_index == end.row { + (start.col, end.col - start.col + 1) + } else if row_index == start.row { + (start.col, cols) + } else if row_index == end.row { + (0, end.col) + } else { + (0, cols) + }; + row.cells_mut() + .skip(usize::from(cells_to_skip)) + .take(usize::from(cells_to_take)) + }), + ) + } + + pub fn selection(&self) -> Option { + self.selection.map(|s| s.ordered()) + } + pub fn write_contents(&self, contents: &mut String) { let mut wrapping = false; for row in self.visible_rows() { @@ -778,8 +905,44 @@ pub struct Size { pub cols: u16, } -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord)] pub struct Pos { pub row: u16, pub col: u16, } + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord)] +pub struct AbsPos { + pub row: usize, + pub col: u16, +} + +impl Selection { + /// Create a new selection that starts and ends at pos + pub fn new(pos: AbsPos) -> Self { + Self { + start: pos, + end: pos, + } + } + + /// Updates the end of the selection + pub fn update(&mut self, pos: AbsPos) { + self.end = pos; + } + + /// Returns a selection where start is before after + pub fn ordered(&self) -> Self { + let Self { start, end } = *self; + let (start, end) = match start.row.cmp(&end.row) { + // Keep + std::cmp::Ordering::Less => (start, end), + std::cmp::Ordering::Equal if start.col < end.col => (start, end), + // Flip + std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => { + (end, start) + } + }; + Self { start, end } + } +} diff --git a/crates/turborepo-vt100/src/row.rs b/crates/turborepo-vt100/src/row.rs index 4d0605eae878e..c52af32f97c77 100644 --- a/crates/turborepo-vt100/src/row.rs +++ b/crates/turborepo-vt100/src/row.rs @@ -37,6 +37,12 @@ impl Row { self.cells.iter() } + pub(crate) fn cells_mut( + &mut self, + ) -> impl Iterator { + self.cells.iter_mut() + } + pub fn get(&self, col: u16) -> Option<&crate::Cell> { self.cells.get(usize::from(col)) } diff --git a/crates/turborepo-vt100/src/screen.rs b/crates/turborepo-vt100/src/screen.rs index 0d320512685d7..22d7cd4e20c89 100644 --- a/crates/turborepo-vt100/src/screen.rs +++ b/crates/turborepo-vt100/src/screen.rs @@ -232,6 +232,118 @@ impl Screen { } } + /// Returns the text contents of the terminal logically between two cells at the given scrollback + /// This will include the remainder of the starting row after `start_col`, + /// followed by the entire contents of the rows between `start_row` and + /// `end_row`, followed by the beginning of the `end_row` up until + /// `end_col`. This is useful for things like determining the contents of + /// a clipboard selection. + #[must_use] + fn contents_between_absolute( + &self, + start_row: usize, + start_col: u16, + end_row: usize, + end_col: u16, + ) -> String { + match start_row.cmp(&end_row) { + std::cmp::Ordering::Less => { + let (_, cols) = self.size(); + let mut contents = String::new(); + for (i, row) in self + .grid() + .all_rows() + .enumerate() + .skip(start_row) + .take(end_row - start_row + 1) + { + if i == start_row { + row.write_contents( + &mut contents, + start_col, + cols - start_col, + false, + ); + if !row.wrapped() { + contents.push('\n'); + } + } else if i == end_row { + row.write_contents(&mut contents, 0, end_col, false); + } else { + row.write_contents(&mut contents, 0, cols, false); + if !row.wrapped() { + contents.push('\n'); + } + } + } + contents + } + std::cmp::Ordering::Equal => { + if start_col < end_col { + self.grid() + .all_rows() + .nth(start_row) + .map(move |row| { + let mut contents = String::new(); + row.write_contents( + &mut contents, + start_col, + end_col - start_col, + false, + ); + contents + }) + .unwrap_or_default() + } else { + String::new() + } + } + std::cmp::Ordering::Greater => String::new(), + } + } + + /// Selects text on the viewable screen. + /// + /// The positions given should be provided in relation to the viewable screen, not including the scrollback. + /// + /// Positions will be clamped to actual size of the screen. + pub fn set_selection( + &mut self, + start_row: u16, + start_col: u16, + end_row: u16, + end_col: u16, + ) { + self.grid_mut() + .set_selection(start_row, start_col, end_row, end_col); + } + + /// Updates the current selection to end at row and col. + /// + /// If no selection is currently set, then a selection will be created that starts and ends at the same position. + pub fn update_selection(&mut self, row: u16, col: u16) { + self.grid_mut().update_selection(row, col); + } + + /// Clears current selection if one exists + pub fn clear_selection(&mut self) { + self.grid_mut().clear_selection(); + } + + /// Returns text contained in selection + #[must_use] + pub fn selected_text(&self) -> Option { + let selection = self.grid().selection()?; + let start = selection.start; + let end = selection.end; + Some(self.contents_between_absolute( + start.row, + start.col, + end.row, + end.col + 1, + )) + } + /// Return escape codes sufficient to reproduce the entire contents of the /// current terminal state. This is a convenience wrapper around /// `contents_formatted`, `input_mode_formatted`, and `title_formatted`. diff --git a/crates/turborepo-vt100/tests/select.rs b/crates/turborepo-vt100/tests/select.rs new file mode 100644 index 0000000000000..f75565af655b4 --- /dev/null +++ b/crates/turborepo-vt100/tests/select.rs @@ -0,0 +1,139 @@ +use turborepo_vt100 as vt100; + +mod helpers; + +// test setting selection +// test copying +// test scrolling with a selection + +#[test] +fn visible() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + // Make sure foo is off the screen + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().set_selection(0, 0, 0, 3); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar")); + parser.screen_mut().clear_selection(); + assert!(parser.screen().selected_text().is_none()); +} + +#[test] +fn single_cell_selection() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + // Make sure foo is off the screen + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().set_selection(0, 0, 0, 0); + assert_eq!(parser.screen().selected_text().as_deref(), Some("b")); + parser.screen_mut().clear_selection(); + assert!(parser.screen().selected_text().is_none()); +} + +#[test] +fn multiline() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + // Make sure foo is off the screen + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().set_selection(0, 0, 1, 0); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar\nb")); +} + +#[test] +fn scrolling_keeps_selection() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().set_selection(0, 0, 0, 3); + // Scroll so baz is off the screen + parser.screen_mut().set_scrollback(1); + // Bar should still be selected + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar")); +} + +#[test] +fn adding_keeps_selection() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar"); + parser.screen_mut().set_selection(1, 0, 1, 3); + parser.process(b"\r\nbaz"); + // Bar should still be selected + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar")); +} + +#[test] +fn backwards_selection() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().set_selection(1, 0, 0, 0); + // Bar was selected from below + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar\nb")); +} + +#[test] +fn too_large() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().set_selection(0, 0, 5, 0); + // Entire screen was selected, but nothing extra + assert_eq!( + parser.screen().selected_text().as_deref(), + Some("bar\nbaz\n") + ); +} + +#[test] +fn selection_inversed_display() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + // Make sure foo is off the screen + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().set_selection(0, 0, 0, 3); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar")); + assert!(parser.screen().cell(0, 0).unwrap().inverse()); + assert!(parser.screen().cell(0, 1).unwrap().inverse()); + assert!(parser.screen().cell(0, 2).unwrap().inverse()); + assert!(parser.screen().cell(0, 3).unwrap().inverse()); +} + +#[test] +fn update_selection_visible() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + // Make sure foo is off the screen + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().update_selection(0, 0); + assert_eq!(parser.screen().selected_text().as_deref(), Some("b")); + parser.screen_mut().update_selection(0, 3); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar")); + parser.screen_mut().clear_selection(); + assert!(parser.screen().selected_text().is_none()); +} + +#[test] +fn update_selection_scroll() { + let mut parser = vt100::Parser::new(2, 4, 10); + parser.process(b"foo\r\nbar\r\nbaz"); + + // Make sure foo is off the screen + assert_eq!(parser.screen().contents(), "bar\nbaz"); + parser.screen_mut().update_selection(0, 3); + assert_eq!(parser.screen().selected_text().as_deref(), Some("")); + parser.screen_mut().update_selection(0, 0); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar")); + parser.screen_mut().set_scrollback(1); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar")); + parser.screen_mut().update_selection(0, 0); + assert_eq!(parser.screen().selected_text().as_deref(), Some("foo\nbar")); +}