From a041fb9de4c8ead5195a87dc9b4572ffa7764738 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 3 Jul 2024 08:28:13 -0700 Subject: [PATCH 01/15] feat(vt100): add text selection capabilities --- crates/turborepo-vt100/src/grid.rs | 58 +++++++++++++- crates/turborepo-vt100/src/screen.rs | 100 +++++++++++++++++++++++++ crates/turborepo-vt100/tests/select.rs | 79 +++++++++++++++++++ 3 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 crates/turborepo-vt100/tests/select.rs diff --git a/crates/turborepo-vt100/src/grid.rs b/crates/turborepo-vt100/src/grid.rs index 77b4305cfecd0..028a5280ceed8 100644 --- a/crates/turborepo-vt100/src/grid.rs +++ b/crates/turborepo-vt100/src/grid.rs @@ -13,6 +13,13 @@ pub struct Grid { scrollback: std::collections::VecDeque, scrollback_len: usize, scrollback_offset: usize, + selection: Option, +} + +#[derive(Clone, Debug, Copy)] +pub struct Selection { + pub start: AbsPos, + pub end: AbsPos, } impl Grid { @@ -29,6 +36,7 @@ impl Grid { scrollback: std::collections::VecDeque::with_capacity(0), scrollback_len, scrollback_offset: 0, + selection: None, } } @@ -137,7 +145,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 @@ -223,6 +232,47 @@ impl Grid { self.scrollback_offset = rows.min(self.scrollback.len()); } + pub fn clear_selection(&mut self) { + self.selection = None; + } + + pub fn set_selection( + &mut self, + start_row: u16, + start_col: u16, + end_row: u16, + end_col: u16, + ) { + let start = self.translate_pos(start_row, start_col); + let end = self.translate_pos(end_row, end_col); + 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.selection = Some(Selection { start, end }); + } + + 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, + } + } + + pub fn selection(&self) -> Option { + self.selection + } + pub fn write_contents(&self, contents: &mut String) { let mut wrapping = false; for row in self.visible_rows() { @@ -783,3 +833,9 @@ pub struct Pos { pub row: u16, pub col: u16, } + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct AbsPos { + pub row: usize, + pub col: u16, +} diff --git a/crates/turborepo-vt100/src/screen.rs b/crates/turborepo-vt100/src/screen.rs index 0d320512685d7..ca340587cb183 100644 --- a/crates/turborepo-vt100/src/screen.rs +++ b/crates/turborepo-vt100/src/screen.rs @@ -232,6 +232,106 @@ 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 == usize::from(start_row) { + row.write_contents( + &mut contents, + start_col, + cols - start_col, + false, + ); + if !row.wrapped() { + contents.push('\n'); + } + } else if i == usize::from(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) + } + + /// Clears current selection if one exists + pub fn clear_selection(&mut self) { + self.grid_mut().clear_selection(); + } + + 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, + )) + } + /// 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..14540147d11ab --- /dev/null +++ b/crates/turborepo-vt100/tests/select.rs @@ -0,0 +1,79 @@ +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 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\n")); +} + +#[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\n")); +} + +#[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") + ); +} From 92823aada563be92b3377d0000497759722587e5 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Tue, 9 Jul 2024 17:50:38 -0700 Subject: [PATCH 02/15] feat(vt100): display selected cells differently --- crates/turborepo-vt100/src/cell.rs | 13 +++++- crates/turborepo-vt100/src/grid.rs | 60 ++++++++++++++++++++++++++ crates/turborepo-vt100/src/row.rs | 6 +++ crates/turborepo-vt100/src/screen.rs | 4 +- crates/turborepo-vt100/tests/select.rs | 15 +++++++ 5 files changed, 95 insertions(+), 3 deletions(-) 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 028a5280ceed8..b2c1b8e990d63 100644 --- a/crates/turborepo-vt100/src/grid.rs +++ b/crates/turborepo-vt100/src/grid.rs @@ -169,6 +169,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() { @@ -233,6 +239,16 @@ impl Grid { } 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; } @@ -243,6 +259,7 @@ impl Grid { 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); let (start, end) = match start.row.cmp(&end.row) { @@ -256,6 +273,15 @@ impl Grid { }; 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); + } + }; } fn translate_pos(&self, row: u16, col: u16) -> AbsPos { @@ -269,6 +295,38 @@ impl Grid { } } + 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) + } 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 } @@ -839,3 +897,5 @@ pub struct AbsPos { pub row: usize, pub col: u16, } + +// pub struct SelectionIterMut<'a> {} 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 ca340587cb183..acfb7a05c57e5 100644 --- a/crates/turborepo-vt100/src/screen.rs +++ b/crates/turborepo-vt100/src/screen.rs @@ -257,7 +257,7 @@ impl Screen { .skip(start_row) .take(end_row - start_row + 1) { - if i == usize::from(start_row) { + if i == start_row { row.write_contents( &mut contents, start_col, @@ -267,7 +267,7 @@ impl Screen { if !row.wrapped() { contents.push('\n'); } - } else if i == usize::from(end_row) { + } else if i == end_row { row.write_contents(&mut contents, 0, end_col, false); } else { row.write_contents(&mut contents, 0, cols, false); diff --git a/crates/turborepo-vt100/tests/select.rs b/crates/turborepo-vt100/tests/select.rs index 14540147d11ab..95a83834c2792 100644 --- a/crates/turborepo-vt100/tests/select.rs +++ b/crates/turborepo-vt100/tests/select.rs @@ -77,3 +77,18 @@ fn too_large() { 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()); +} From 7dfed4b8c145dc5b590317a8f82c78c803081921 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Tue, 9 Jul 2024 19:00:20 -0700 Subject: [PATCH 03/15] feat(ui): implement mouse selection --- crates/turborepo-ui/src/tui/app.rs | 64 ++++++++++++------- crates/turborepo-ui/src/tui/event.rs | 1 + crates/turborepo-ui/src/tui/input.rs | 4 ++ crates/turborepo-ui/src/tui/term_output.rs | 73 ++++++++++++++++++++++ 4 files changed, 118 insertions(+), 24 deletions(-) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index bf8f008855011..23ff55ad682b3 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -32,8 +32,9 @@ 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, @@ -51,6 +52,12 @@ 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); // Initializes with the planned tasks // and will mutate as tasks change @@ -69,8 +76,9 @@ 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, @@ -269,7 +277,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 +319,22 @@ 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; + // Only handle mouse event if it happens inside of pane + 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; + + let task = self.get_full_task_mut(); + task.handle_mouse(event)?; + } + + Ok(()) + } } impl App { @@ -355,21 +379,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,10 +398,9 @@ 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) { @@ -397,7 +409,7 @@ fn run_app_inner( break; } if FRAMERATE <= last_render.elapsed() { - terminal.draw(|f| view(app, f, cols))?; + terminal.draw(|f| view(app, f))?; last_render = Instant::now(); } } @@ -534,11 +546,15 @@ fn update( app.update_tasks(tasks); // app.table.tick(); } + Event::Mouse(m) => { + app.handle_mouse(m)?; + } } 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()); diff --git a/crates/turborepo-ui/src/tui/event.rs b/crates/turborepo-ui/src/tui/event.rs index cdcac1d8803c9..a4310572bcfd2 100644 --- a/crates/turborepo-ui/src/tui/event.rs +++ b/crates/turborepo-ui/src/tui/event.rs @@ -36,6 +36,7 @@ pub enum Event { UpdateTasks { tasks: Vec, }, + Mouse(crossterm::event::MouseEvent), } #[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..e4dcfab163e71 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -24,6 +24,10 @@ pub fn input(options: InputOptions) -> Result, Error> { 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), diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index 755600306ec07..aec96e354f097 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -17,6 +17,7 @@ pub struct TerminalOutput { pub output_logs: Option, pub task_result: Option, pub cache_result: Option, + selection: Option, } #[derive(Debug, Clone, Copy)] @@ -26,6 +27,36 @@ enum LogBehavior { Nothing, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SelectionState { + start: Pos, + end: Pos, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Pos { + pub x: u16, + pub y: u16, +} + +impl SelectionState { + pub fn new(event: crossterm::event::MouseEvent) -> Self { + let start = Pos { + x: event.column, + y: event.row, + }; + let end = start; + Self { start, end } + } + + pub fn update(&mut self, event: crossterm::event::MouseEvent) { + self.end = Pos { + x: event.column, + y: event.row, + }; + } +} + impl TerminalOutput { pub fn new(rows: u16, cols: u16, stdin: Option) -> Self { Self { @@ -37,6 +68,7 @@ impl TerminalOutput { output_logs: None, task_result: None, cache_result: None, + selection: None, } } @@ -112,4 +144,45 @@ impl TerminalOutput { } Ok(()) } + + pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Result<(), Error> { + match event.kind { + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + // Here we enter copy mode with this position + let selection = SelectionState::new(event); + // we now store this in the task + self.selection = Some(selection); + } + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + // Here we change an endpoint of the selection + // Should be noted that it can go backwards + // If we didn't catch the start of a selection, use the current position + let selection = self + .selection + .get_or_insert_with(|| SelectionState::new(event)); + selection.update(event); + // Update selection of underlying parser + self.parser.screen_mut().set_selection( + selection.start.y, + selection.start.x, + selection.end.y, + selection.end.x, + ); + } + // 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(()) + } } From cffbf298c209ccd11d1865eb5c6c81e39c2fe3ff Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 10 Jul 2024 13:45:40 -0700 Subject: [PATCH 04/15] feat(ui): implement clipboard copying --- Cargo.lock | 2 + crates/turborepo-ui/Cargo.toml | 2 + crates/turborepo-ui/src/tui/app.rs | 14 +++ crates/turborepo-ui/src/tui/clipboard.rs | 113 +++++++++++++++++++++ crates/turborepo-ui/src/tui/event.rs | 1 + crates/turborepo-ui/src/tui/input.rs | 11 +- crates/turborepo-ui/src/tui/mod.rs | 2 + crates/turborepo-ui/src/tui/term_output.rs | 4 + 8 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 crates/turborepo-ui/src/tui/clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index a5581cd37e96f..f9f60236b4752 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11435,6 +11435,7 @@ version = "0.1.0" dependencies = [ "anyhow", "atty", + "base64 0.22.1", "chrono", "console", "crossterm 0.27.0", @@ -11452,6 +11453,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..fe9d70d14b8f4 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,5 @@ tui-term = { workspace = true } turbopath = { workspace = true } turborepo-ci = { workspace = true } turborepo-vt100 = { workspace = true } +which = { workspace = true } winapi = "0.3.9" diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 23ff55ad682b3..1a76c6914463b 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -335,6 +335,17 @@ impl App { 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 { @@ -549,6 +560,9 @@ fn update( Event::Mouse(m) => { app.handle_mouse(m)?; } + Event::CopySelection => { + app.copy_selection(); + } } Ok(None) } diff --git a/crates/turborepo-ui/src/tui/clipboard.rs b/crates/turborepo-ui/src/tui/clipboard.rs new file mode 100644 index 0000000000000..d9c5caa769c8f --- /dev/null +++ b/crates/turborepo-ui/src/tui/clipboard.rs @@ -0,0 +1,113 @@ +// 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| anyhow::Error::msg(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 a4310572bcfd2..716333da30272 100644 --- a/crates/turborepo-ui/src/tui/event.rs +++ b/crates/turborepo-ui/src/tui/event.rs @@ -37,6 +37,7 @@ pub enum Event { 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 e4dcfab163e71..212b295787577 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -1,6 +1,7 @@ use std::time::Duration; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use tracing::debug; use super::{app::LayoutSections, event::Event, Error}; @@ -19,7 +20,9 @@ pub fn input(options: InputOptions) -> Result, Error> { // 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()? { + let event = crossterm::event::read()?; + debug!("Received event: {event:?}"); + match event { crossterm::event::Event::Key(k) => Ok(translate_key_event(&focus, k)), crossterm::event::Event::Mouse(m) => match m.kind { crossterm::event::MouseEventKind::ScrollDown => Ok(Some(Event::ScrollDown)), @@ -28,6 +31,9 @@ pub fn input(options: InputOptions) -> Result, Error> { | crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { Ok(Some(Event::Mouse(m))) } + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => { + Ok(Some(Event::CopySelection)) + } _ => Ok(None), }, _ => Ok(None), @@ -50,6 +56,9 @@ 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 key_event.modifiers == crossterm::event::KeyModifiers::SUPER => { + return Some(Event::CopySelection); + } // Interactive branches KeyCode::Char('z') if matches!(interact, LayoutSections::Pane) 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/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index aec96e354f097..0d6ed65a8fa9b 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -185,4 +185,8 @@ impl TerminalOutput { } Ok(()) } + + pub fn copy_selection(&self) -> Option { + self.parser.screen().selected_text() + } } From a8563b619d96853265a1864fc9e33565e4789bdd Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Tue, 23 Jul 2024 16:14:56 -0500 Subject: [PATCH 05/15] fix(tui): clear selection on click --- crates/turborepo-ui/src/tui/term_output.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index 0d6ed65a8fa9b..543dbb4b99771 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -148,10 +148,9 @@ impl TerminalOutput { pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Result<(), Error> { match event.kind { crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => { - // Here we enter copy mode with this position - let selection = SelectionState::new(event); - // we now store this in the task - self.selection = Some(selection); + self.selection = None; + // 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) => { // Here we change an endpoint of the selection From a66d507faa728cff295107016554d79adbbb2df0 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 15:34:02 -0500 Subject: [PATCH 06/15] fix(tui): properly handle scrolling while selecting --- crates/turborepo-ui/src/tui/term_output.rs | 49 +------------- crates/turborepo-vt100/src/grid.rs | 75 ++++++++++++++++++---- crates/turborepo-vt100/src/screen.rs | 16 ++++- crates/turborepo-vt100/tests/select.rs | 51 ++++++++++++++- 4 files changed, 126 insertions(+), 65 deletions(-) diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index 543dbb4b99771..ffed6d691ba4e 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -17,7 +17,6 @@ pub struct TerminalOutput { pub output_logs: Option, pub task_result: Option, pub cache_result: Option, - selection: Option, } #[derive(Debug, Clone, Copy)] @@ -27,36 +26,6 @@ enum LogBehavior { Nothing, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct SelectionState { - start: Pos, - end: Pos, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Pos { - pub x: u16, - pub y: u16, -} - -impl SelectionState { - pub fn new(event: crossterm::event::MouseEvent) -> Self { - let start = Pos { - x: event.column, - y: event.row, - }; - let end = start; - Self { start, end } - } - - pub fn update(&mut self, event: crossterm::event::MouseEvent) { - self.end = Pos { - x: event.column, - y: event.row, - }; - } -} - impl TerminalOutput { pub fn new(rows: u16, cols: u16, stdin: Option) -> Self { Self { @@ -68,7 +37,6 @@ impl TerminalOutput { output_logs: None, task_result: None, cache_result: None, - selection: None, } } @@ -148,25 +116,14 @@ impl TerminalOutput { pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Result<(), Error> { match event.kind { crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => { - self.selection = None; // 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) => { - // Here we change an endpoint of the selection - // Should be noted that it can go backwards - // If we didn't catch the start of a selection, use the current position - let selection = self - .selection - .get_or_insert_with(|| SelectionState::new(event)); - selection.update(event); // Update selection of underlying parser - self.parser.screen_mut().set_selection( - selection.start.y, - selection.start.x, - selection.end.y, - selection.end.x, - ); + self.parser + .screen_mut() + .update_selection(event.row, event.column); } // Scrolling is handled elsewhere crossterm::event::MouseEventKind::ScrollDown => (), diff --git a/crates/turborepo-vt100/src/grid.rs b/crates/turborepo-vt100/src/grid.rs index b2c1b8e990d63..ee3a16ac91b62 100644 --- a/crates/turborepo-vt100/src/grid.rs +++ b/crates/turborepo-vt100/src/grid.rs @@ -16,6 +16,11 @@ pub struct Grid { 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, @@ -262,17 +267,31 @@ impl Grid { self.clear_selection(); let start = self.translate_pos(start_row, start_col); let end = self.translate_pos(end_row, end_col); - 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.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); } }; + } - self.selection = Some(Selection { start, end }); + 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!( @@ -298,7 +317,7 @@ impl Grid { fn selection_cells( &mut self, ) -> Option + '_> { - let Selection { start, end } = self.selection?; + let Selection { start, end } = self.selection()?; let cols = self.size.cols; Some( self.all_rows_mut() @@ -312,7 +331,7 @@ impl Grid { ); let (cells_to_skip, cells_to_take) = if row_index == start.row && row_index == end.row { - (start.col, end.col - start.col) + (start.col, end.col - start.col + 1) } else if row_index == start.row { (start.col, cols) } else if row_index == end.row { @@ -328,7 +347,7 @@ impl Grid { } pub fn selection(&self) -> Option { - self.selection + self.selection.map(|s| s.ordered()) } pub fn write_contents(&self, contents: &mut String) { @@ -886,16 +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)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord)] pub struct AbsPos { pub row: usize, pub col: u16, } -// pub struct SelectionIterMut<'a> {} +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/screen.rs b/crates/turborepo-vt100/src/screen.rs index acfb7a05c57e5..22d7cd4e20c89 100644 --- a/crates/turborepo-vt100/src/screen.rs +++ b/crates/turborepo-vt100/src/screen.rs @@ -315,7 +315,14 @@ impl Screen { end_col: u16, ) { self.grid_mut() - .set_selection(start_row, start_col, end_row, end_col) + .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 @@ -323,12 +330,17 @@ impl Screen { 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, + start.row, + start.col, + end.row, + end.col + 1, )) } diff --git a/crates/turborepo-vt100/tests/select.rs b/crates/turborepo-vt100/tests/select.rs index 95a83834c2792..f75565af655b4 100644 --- a/crates/turborepo-vt100/tests/select.rs +++ b/crates/turborepo-vt100/tests/select.rs @@ -19,6 +19,19 @@ fn visible() { 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); @@ -27,7 +40,7 @@ fn multiline() { // 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\n")); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar\nb")); } #[test] @@ -61,7 +74,7 @@ fn backwards_selection() { 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\n")); + assert_eq!(parser.screen().selected_text().as_deref(), Some("bar\nb")); } #[test] @@ -90,5 +103,37 @@ fn selection_inversed_display() { 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()); + 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")); } From fc2b82363ed0c5a6774771bef144a5c68fe1f2bf Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 15:47:02 -0500 Subject: [PATCH 07/15] feat(tui): add copy mode info --- crates/turborepo-ui/src/tui/app.rs | 2 ++ crates/turborepo-ui/src/tui/input.rs | 7 ++---- crates/turborepo-ui/src/tui/pane.rs | 27 ++++++++++++++++------ crates/turborepo-ui/src/tui/term_output.rs | 4 ++++ 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 1a76c6914463b..5d1bb568d5e62 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -322,12 +322,14 @@ impl App { 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 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)?; diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index 212b295787577..35aeb6fe5cefa 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -1,7 +1,6 @@ use std::time::Duration; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use tracing::debug; use super::{app::LayoutSections, event::Event, Error}; @@ -20,9 +19,7 @@ pub fn input(options: InputOptions) -> Result, Error> { // 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))? { - let event = crossterm::event::read()?; - debug!("Received event: {event:?}"); - match event { + match crossterm::event::read()? { crossterm::event::Event::Key(k) => Ok(translate_key_event(&focus, k)), crossterm::event::Event::Mouse(m) => match m.kind { crossterm::event::MouseEventKind::ScrollDown => Ok(Some(Event::ScrollDown)), @@ -56,7 +53,7 @@ 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 key_event.modifiers == crossterm::event::KeyModifiers::SUPER => { + KeyCode::Char('c') => { return Some(Event::CopySelection); } // Interactive branches 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 ffed6d691ba4e..0bd49687bfbc0 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -113,6 +113,10 @@ impl TerminalOutput { Ok(()) } + pub fn has_selection(&self) -> bool { + self.parser.screen().selected_text().is_some() + } + pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Result<(), Error> { match event.kind { crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => { From b339d80e1746bdd35f1c73fea862927727e50d39 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 15:50:04 -0500 Subject: [PATCH 08/15] fix(tui): make it easier to select first column --- crates/turborepo-ui/src/tui/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 5d1bb568d5e62..568b457f8ddb1 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -324,7 +324,8 @@ impl App { 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 - if event.row > 0 && event.column > table_width { + // 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 From e28dfd89be3054696df7c96c2fbc37862cab61e4 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 15:51:16 -0500 Subject: [PATCH 09/15] chore(tui): remove unused copy methods --- crates/turborepo-ui/src/tui/input.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index 35aeb6fe5cefa..38d6680b919f6 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -28,9 +28,6 @@ pub fn input(options: InputOptions) -> Result, Error> { | crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { Ok(Some(Event::Mouse(m))) } - crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => { - Ok(Some(Event::CopySelection)) - } _ => Ok(None), }, _ => Ok(None), @@ -53,9 +50,7 @@ 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') => { - return Some(Event::CopySelection); - } + KeyCode::Char('c') => Some(Event::CopySelection), // Interactive branches KeyCode::Char('z') if matches!(interact, LayoutSections::Pane) From 803fac74f52dc2ef3b5f84b16e398e961768e07a Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 15:57:09 -0500 Subject: [PATCH 10/15] chore(tui): only display copy message for non-empty selection --- crates/turborepo-ui/src/tui/term_output.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index 0bd49687bfbc0..eebc5bb30a708 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -114,7 +114,10 @@ impl TerminalOutput { } pub fn has_selection(&self) -> bool { - self.parser.screen().selected_text().is_some() + self.parser + .screen() + .selected_text() + .map_or(false, |s| !s.is_empty()) } pub fn handle_mouse(&mut self, event: crossterm::event::MouseEvent) -> Result<(), Error> { From dc5fd71d0c7cf3f6f0f4ef191dec4c5194856669 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 16:56:19 -0500 Subject: [PATCH 11/15] fix(tui): only intercept c if selection is made --- crates/turborepo-ui/src/tui/app.rs | 36 ++++++++++++++++++---------- crates/turborepo-ui/src/tui/input.rs | 14 +++++------ 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 568b457f8ddb1..875b612afd7c5 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -37,7 +37,8 @@ pub struct App { 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, @@ -80,11 +81,9 @@ impl App { 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))) @@ -97,7 +96,7 @@ impl App { } pub fn is_focusing_pane(&self) -> bool { - match self.input_options.focus { + match self.focus { LayoutSections::Pane => true, LayoutSections::TaskList => false, } @@ -109,6 +108,19 @@ impl App { .to_string() } + 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() } @@ -264,10 +276,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; } } @@ -366,7 +378,7 @@ 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 { @@ -417,7 +429,7 @@ fn run_app_inner( 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; diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index 38d6680b919f6..ef4ac763036a8 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -8,19 +8,19 @@ 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)), @@ -38,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 @@ -50,16 +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') => Some(Event::CopySelection), + 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 From 08a242dd9644285fd5947bad46fe51d99626d8c8 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 17:03:22 -0500 Subject: [PATCH 12/15] chore(tui): remove allocations for current task name --- crates/turborepo-ui/src/tui/app.rs | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 875b612afd7c5..550cee4111ad6 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -102,10 +102,8 @@ impl App { } } - 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 { @@ -118,11 +116,11 @@ impl App { } pub fn get_full_task(&self) -> &TerminalOutput { - self.tasks.get(&self.active_task()).unwrap() + 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() + self.tasks.get_mut(&self.active_task().to_owned()).unwrap() } #[tracing::instrument(skip(self))] @@ -147,11 +145,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. @@ -267,8 +261,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 @@ -354,7 +347,7 @@ impl App { pub fn copy_selection(&self) { let task = self .tasks - .get(&self.active_task()) + .get(self.active_task()) .expect("active task should exist"); let Some(text) = task.copy_selection() else { return; @@ -382,7 +375,7 @@ impl App { 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, })?; } @@ -587,7 +580,7 @@ fn view(app: &mut App, f: &mut Frame) { 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 = From 41d626dffe866ffd9cbc4fc559ded12c0dedd740 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Wed, 24 Jul 2024 17:10:18 -0500 Subject: [PATCH 13/15] fix(tui): account for pane header/footer --- crates/turborepo-ui/src/tui/app.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 550cee4111ad6..2c584916dd86a 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -60,6 +60,9 @@ impl App { 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 // to running, finished, etc. From e11612b839ddfe182e7d8fad8347e875306a729c Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Thu, 25 Jul 2024 08:57:08 -0500 Subject: [PATCH 14/15] fix(tui): fix windows compilation --- Cargo.lock | 16 ++++++++++++++++ crates/turborepo-ui/Cargo.toml | 3 +++ crates/turborepo-ui/src/tui/clipboard.rs | 5 ++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9f60236b4752..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" @@ -11437,6 +11452,7 @@ dependencies = [ "atty", "base64 0.22.1", "chrono", + "clipboard-win", "console", "crossterm 0.27.0", "dialoguer", diff --git a/crates/turborepo-ui/Cargo.toml b/crates/turborepo-ui/Cargo.toml index fe9d70d14b8f4..bc20ec39440a3 100644 --- a/crates/turborepo-ui/Cargo.toml +++ b/crates/turborepo-ui/Cargo.toml @@ -33,3 +33,6 @@ 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/clipboard.rs b/crates/turborepo-ui/src/tui/clipboard.rs index d9c5caa769c8f..e1572c646c78f 100644 --- a/crates/turborepo-ui/src/tui/clipboard.rs +++ b/crates/turborepo-ui/src/tui/clipboard.rs @@ -98,9 +98,8 @@ fn copy_impl(s: &str, provider: &Provider) -> std::io::Result<()> { } #[cfg(windows)] - Provider::Win => { - clipboard_win::set_clipboard_string(s).map_err(|e| anyhow::Error::msg(e.to_string()))? - } + Provider::Win => clipboard_win::set_clipboard_string(s) + .map_err(|e| std::io::Error::other(e.to_string()))?, Provider::NoOp => (), }; From 7c4535d730d779865ac9a125df0d4d6f6e04ed14 Mon Sep 17 00:00:00 2001 From: nicholaslyang Date: Mon, 29 Jul 2024 16:56:13 -0400 Subject: [PATCH 15/15] Fix clippy --- crates/turborepo-ui/src/tui/app.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 2c584916dd86a..4fc591842c04b 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -123,6 +123,8 @@ impl App { } pub fn get_full_task_mut(&mut self) -> &mut TerminalOutput { + // 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() }