diff --git a/Cargo.lock b/Cargo.lock index 91fde2e7d0b84ce016ebe4a772afcb18e3013274..ae2718d6aec23bd883eacd738bbc38ace12ed9cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5482,11 +5482,13 @@ dependencies = [ "futures", "gpui", "itertools", + "lazy_static", "libc", "mio-extras", "ordered-float", "procinfo", "project", + "rand 0.8.5", "settings", "shellexpand", "smallvec", diff --git a/crates/gpui/src/elements/overlay.rs b/crates/gpui/src/elements/overlay.rs index 20b6c75c8fd6906713a5cf2e2c0afc101eefe992..d47a39e958b8cc18ac12e5b32fa52c602ad0d714 100644 --- a/crates/gpui/src/elements/overlay.rs +++ b/crates/gpui/src/elements/overlay.rs @@ -14,6 +14,7 @@ pub struct Overlay { anchor_position: Option, anchor_corner: AnchorCorner, fit_mode: OverlayFitMode, + position_mode: OverlayPositionMode, hoverable: bool, } @@ -24,6 +25,12 @@ pub enum OverlayFitMode { None, } +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum OverlayPositionMode { + Window, + Local, +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum AnchorCorner { TopLeft, @@ -73,6 +80,7 @@ impl Overlay { anchor_position: None, anchor_corner: AnchorCorner::TopLeft, fit_mode: OverlayFitMode::None, + position_mode: OverlayPositionMode::Window, hoverable: false, } } @@ -92,6 +100,11 @@ impl Overlay { self } + pub fn with_position_mode(mut self, position_mode: OverlayPositionMode) -> Self { + self.position_mode = position_mode; + self + } + pub fn with_hoverable(mut self, hoverable: bool) -> Self { self.hoverable = hoverable; self @@ -123,8 +136,20 @@ impl Element for Overlay { size: &mut Self::LayoutState, cx: &mut PaintContext, ) { - let anchor_position = self.anchor_position.unwrap_or_else(|| bounds.origin()); - let mut bounds = self.anchor_corner.get_bounds(anchor_position, *size); + let (anchor_position, mut bounds) = match self.position_mode { + OverlayPositionMode::Window => { + let anchor_position = self.anchor_position.unwrap_or_else(|| bounds.origin()); + let bounds = self.anchor_corner.get_bounds(anchor_position, *size); + (anchor_position, bounds) + } + OverlayPositionMode::Local => { + let anchor_position = self.anchor_position.unwrap_or_default(); + let bounds = self + .anchor_corner + .get_bounds(bounds.origin() + anchor_position, *size); + (anchor_position, bounds) + } + }; match self.fit_mode { OverlayFitMode::SnapToWindow => { diff --git a/crates/gpui/src/elements/tooltip.rs b/crates/gpui/src/elements/tooltip.rs index 55ab7e44d25e0fb8edfdcdb210a6630ce62bbad6..c86230a5e158f30c5cef1716b67113a664b3a4df 100644 --- a/crates/gpui/src/elements/tooltip.rs +++ b/crates/gpui/src/elements/tooltip.rs @@ -36,10 +36,10 @@ struct TooltipState { #[derive(Clone, Deserialize, Default)] pub struct TooltipStyle { #[serde(flatten)] - container: ContainerStyle, - text: TextStyle, + pub container: ContainerStyle, + pub text: TextStyle, keystroke: KeystrokeStyle, - max_text_width: f32, + pub max_text_width: f32, } #[derive(Clone, Deserialize, Default)] @@ -126,7 +126,7 @@ impl Tooltip { } } - fn render_tooltip( + pub fn render_tooltip( text: String, style: TooltipStyle, action: Option>, diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index 7831be1c5af47f1dd9e80bac7f5be5d070e21e51..da59979145955131c50f4614d9444f8c00cf3934 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -29,6 +29,8 @@ shellexpand = "2.1.0" libc = "0.2" anyhow = "1" thiserror = "1.0" +lazy_static = "1.4.0" + [dev-dependencies] @@ -36,3 +38,4 @@ gpui = { path = "../gpui", features = ["test-support"] } client = { path = "../client", features = ["test-support"]} project = { path = "../project", features = ["test-support"]} workspace = { path = "../workspace", features = ["test-support"] } +rand = "0.8.5" diff --git a/crates/terminal/src/mappings/mouse.rs b/crates/terminal/src/mappings/mouse.rs index 7d92036b714abc7085134fced7f00798dc8050e9..1616540cff65aaa245551ef7a2da37f7da2b4613 100644 --- a/crates/terminal/src/mappings/mouse.rs +++ b/crates/terminal/src/mappings/mouse.rs @@ -202,7 +202,7 @@ pub fn mouse_side(pos: Vector2F, cur_size: TerminalSize) -> alacritty_terminal:: } } -pub fn mouse_point(pos: Vector2F, cur_size: TerminalSize, display_offset: usize) -> Point { +pub fn grid_point(pos: Vector2F, cur_size: TerminalSize, display_offset: usize) -> Point { let col = pos.x() / cur_size.cell_width; let col = min(GridCol(col as usize), cur_size.last_column()); let line = pos.y() / cur_size.line_height; @@ -295,7 +295,7 @@ fn sgr_mouse_report(point: Point, button: u8, pressed: bool) -> String { #[cfg(test)] mod test { - use crate::mappings::mouse::mouse_point; + use crate::mappings::mouse::grid_point; #[test] fn test_mouse_to_selection() { @@ -317,7 +317,7 @@ mod test { let mouse_pos = gpui::geometry::vector::vec2f(mouse_pos_x, mouse_pos_y); let origin = gpui::geometry::vector::vec2f(origin_x, origin_y); //Position of terminal window, 1 'cell' in let mouse_pos = mouse_pos - origin; - let point = mouse_point(mouse_pos, cur_size, 0); + let point = grid_point(mouse_pos, cur_size, 0); assert_eq!( point, alacritty_terminal::index::Point::new( diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index b05750b84157ca0c1211fc757740273c16c78de0..0a518d2b15a7decf564488250b32cad11f000414 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -29,18 +29,22 @@ use futures::{ }; use mappings::mouse::{ - alt_scroll, mouse_button_report, mouse_moved_report, mouse_point, mouse_side, scroll_report, + alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report, }; use procinfo::LocalProcessInfo; use settings::{AlternateScroll, Settings, Shell, TerminalBlink}; +use util::ResultExt; use std::{ + cmp::min, collections::{HashMap, VecDeque}, fmt::Display, + io, ops::{Deref, RangeInclusive, Sub}, - os::unix::prelude::AsRawFd, + os::unix::{prelude::AsRawFd, process::CommandExt}, path::PathBuf, + process::Command, sync::Arc, time::{Duration, Instant}, }; @@ -59,6 +63,7 @@ use crate::mappings::{ colors::{get_color_at_index, to_alac_rgb}, keys::to_esc_str, }; +use lazy_static::lazy_static; ///Initialize and register all of our action handlers pub fn init(cx: &mut MutableAppContext) { @@ -70,12 +75,18 @@ pub fn init(cx: &mut MutableAppContext) { ///Scroll multiplier that is set to 3 by default. This will be removed when I ///Implement scroll bars. const SCROLL_MULTIPLIER: f32 = 4.; -// const MAX_SEARCH_LINES: usize = 100; +const MAX_SEARCH_LINES: usize = 100; const DEBUG_TERMINAL_WIDTH: f32 = 500.; const DEBUG_TERMINAL_HEIGHT: f32 = 30.; const DEBUG_CELL_WIDTH: f32 = 5.; const DEBUG_LINE_HEIGHT: f32 = 5.; +// Regex Copied from alacritty's ui_config.rs + +lazy_static! { + static ref URL_REGEX: RegexSearch = RegexSearch::new("(ipfs:|ipns:|magnet:|mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`]+").unwrap(); +} + ///Upward flowing events, for changing the title and such #[derive(Clone, Copy, Debug)] pub enum Event { @@ -98,6 +109,8 @@ enum InternalEvent { ScrollToPoint(Point), SetSelection(Option<(Selection, Point)>), UpdateSelection(Vector2F), + // Adjusted mouse position, should open + Hyperlink(Vector2F, bool), Copy, } @@ -267,7 +280,6 @@ impl TerminalBuilder { working_directory: Option, shell: Option, env: Option>, - initial_size: TerminalSize, blink_settings: Option, alternate_scroll: &AlternateScroll, window_id: usize, @@ -307,7 +319,11 @@ impl TerminalBuilder { //TODO: Remove with a bounded sender which can be dispatched on &self let (events_tx, events_rx) = unbounded(); //Set up the terminal... - let mut term = Term::new(&config, &initial_size, ZedListener(events_tx.clone())); + let mut term = Term::new( + &config, + &TerminalSize::default(), + ZedListener(events_tx.clone()), + ); //Start off blinking if we need to if let Some(TerminalBlink::On) = blink_settings { @@ -322,7 +338,11 @@ impl TerminalBuilder { let term = Arc::new(FairMutex::new(term)); //Setup the pty... - let pty = match tty::new(&pty_config, initial_size.into(), window_id as u64) { + let pty = match tty::new( + &pty_config, + TerminalSize::default().into(), + window_id as u64, + ) { Ok(pty) => pty, Err(error) => { bail!(TerminalError { @@ -354,7 +374,6 @@ impl TerminalBuilder { term, events: VecDeque::with_capacity(10), //Should never get this high. last_content: Default::default(), - cur_size: initial_size, last_mouse: None, matches: Vec::new(), last_synced: Instant::now(), @@ -365,6 +384,9 @@ impl TerminalBuilder { foreground_process_info: None, breadcrumb_text: String::new(), scroll_px: 0., + last_mouse_position: None, + next_link_id: 0, + selection_phase: SelectionPhase::Ended, }; Ok(TerminalBuilder { @@ -450,6 +472,8 @@ pub struct TerminalContent { selection: Option, cursor: RenderableCursor, cursor_char: char, + size: TerminalSize, + last_hovered_hyperlink: Option<(String, RangeInclusive, usize)>, } impl Default for TerminalContent { @@ -465,17 +489,27 @@ impl Default for TerminalContent { point: Point::new(Line(0), Column(0)), }, cursor_char: Default::default(), + size: Default::default(), + last_hovered_hyperlink: None, } } } +#[derive(PartialEq, Eq)] +pub enum SelectionPhase { + Selecting, + Ended, +} + pub struct Terminal { pty_tx: Notifier, term: Arc>>, events: VecDeque, + /// This is only used for mouse mode cell change detection last_mouse: Option<(Point, AlacDirection)>, + /// This is only used for terminal hyperlink checking + last_mouse_position: Option, pub matches: Vec>, - cur_size: TerminalSize, last_content: TerminalContent, last_synced: Instant, sync_task: Option>, @@ -485,6 +519,8 @@ pub struct Terminal { shell_fd: u32, foreground_process_info: Option, scroll_px: f32, + next_link_id: usize, + selection_phase: SelectionPhase, } impl Terminal { @@ -508,7 +544,7 @@ impl Terminal { )), AlacTermEvent::PtyWrite(out) => self.write_to_pty(out.clone()), AlacTermEvent::TextAreaSizeRequest(format) => { - self.write_to_pty(format(self.cur_size.into())) + self.write_to_pty(format(self.last_content.size.into())) } AlacTermEvent::CursorBlinkingChange => { cx.emit(Event::BlinkChanged); @@ -577,7 +613,7 @@ impl Terminal { new_size.height = f32::max(new_size.line_height, new_size.height); new_size.width = f32::max(new_size.cell_width, new_size.width); - self.cur_size = new_size.clone(); + self.last_content.size = new_size.clone(); self.pty_tx.0.send(Msg::Resize((new_size).into())).ok(); @@ -606,8 +642,12 @@ impl Terminal { } InternalEvent::UpdateSelection(position) => { if let Some(mut selection) = term.selection.take() { - let point = mouse_point(*position, self.cur_size, term.grid().display_offset()); - let side = mouse_side(*position, self.cur_size); + let point = grid_point( + *position, + self.last_content.size, + term.grid().display_offset(), + ); + let side = mouse_side(*position, self.last_content.size); selection.update(point, side); term.selection = Some(selection); @@ -623,9 +663,52 @@ impl Terminal { } } InternalEvent::ScrollToPoint(point) => term.scroll_to_point(*point), + InternalEvent::Hyperlink(position, open) => { + let prev_hyperlink = self.last_content.last_hovered_hyperlink.take(); + + let point = grid_point( + *position, + self.last_content.size, + term.grid().display_offset(), + ); + + if let Some(url_match) = regex_match_at(term, point, &URL_REGEX) { + let url = term.bounds_to_string(*url_match.start(), *url_match.end()); + + if *open { + open_uri(&url).log_err(); + } else { + self.update_hyperlink(prev_hyperlink, url, url_match); + } + } + } + } + } + + fn update_hyperlink( + &mut self, + prev_hyperlink: Option<(String, RangeInclusive, usize)>, + url: String, + url_match: RangeInclusive, + ) { + if let Some(prev_hyperlink) = prev_hyperlink { + if prev_hyperlink.0 == url && prev_hyperlink.1 == url_match { + self.last_content.last_hovered_hyperlink = Some((url, url_match, prev_hyperlink.2)); + } else { + self.last_content.last_hovered_hyperlink = + Some((url, url_match, self.next_link_id())); + } + } else { + self.last_content.last_hovered_hyperlink = Some((url, url_match, self.next_link_id())); } } + fn next_link_id(&mut self) -> usize { + let res = self.next_link_id; + self.next_link_id = self.next_link_id.wrapping_add(1); + res + } + pub fn last_content(&self) -> &TerminalContent { &self.last_content } @@ -730,11 +813,11 @@ impl Terminal { self.process_terminal_event(&e, &mut terminal, cx) } - self.last_content = Self::make_content(&terminal); + self.last_content = Self::make_content(&terminal, &self.last_content); self.last_synced = Instant::now(); } - fn make_content(term: &Term) -> TerminalContent { + fn make_content(term: &Term, last_content: &TerminalContent) -> TerminalContent { let content = term.renderable_content(); TerminalContent { cells: content @@ -757,6 +840,8 @@ impl Terminal { selection: content.selection, cursor: content.cursor, cursor_char: term.grid()[content.cursor.point].c, + size: last_content.size, + last_hovered_hyperlink: last_content.last_hovered_hyperlink.clone(), } } @@ -766,7 +851,8 @@ impl Terminal { } } - pub fn focus_out(&self) { + pub fn focus_out(&mut self) { + self.last_mouse_position = None; if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) { self.write_to_pty("\x1b[O".to_string()); } @@ -794,22 +880,81 @@ impl Terminal { } pub fn mouse_move(&mut self, e: &MouseMovedEvent, origin: Vector2F) { + let prev_hyperlink = self.last_content.last_hovered_hyperlink.take(); + let position = e.position.sub(origin); + self.last_mouse_position = Some(position); + if self.mouse_mode(e.shift) { + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + let side = mouse_side(position, self.last_content.size); + + if self.mouse_changed(point, side) { + if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) { + self.pty_tx.notify(bytes); + } + } + } else { + self.fill_hyperlink(Some(position), prev_hyperlink); + } + } + + fn fill_hyperlink( + &mut self, + position: Option, + prev_hyperlink: Option<(String, RangeInclusive, usize)>, + ) { + if self.selection_phase == SelectionPhase::Selecting { + self.last_content.last_hovered_hyperlink = None; + } else if let Some(position) = position { + let content_index = content_index_for_mouse(position, &self.last_content); + let link = self.last_content.cells[content_index].hyperlink(); + if link.is_some() { + let mut min_index = content_index; + loop { + if min_index >= 1 && self.last_content.cells[min_index - 1].hyperlink() == link + { + min_index = min_index - 1; + } else { + break; + } + } - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); - let side = mouse_side(position, self.cur_size); + let mut max_index = content_index; + let len = self.last_content.cells.len(); + loop { + if max_index < len - 1 + && self.last_content.cells[max_index + 1].hyperlink() == link + { + max_index = max_index + 1; + } else { + break; + } + } - if self.mouse_changed(point, side) && self.mouse_mode(e.shift) { - if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) { - self.pty_tx.notify(bytes); + if let Some(link) = link { + let url = link.uri().to_owned(); + let url_match = self.last_content.cells[min_index].point + ..=self.last_content.cells[max_index].point; + + self.update_hyperlink(prev_hyperlink, url, url_match); + }; + } else { + self.events + .push_back(InternalEvent::Hyperlink(position, false)); } } } pub fn mouse_drag(&mut self, e: DragRegionEvent, origin: Vector2F) { let position = e.position.sub(origin); + self.last_mouse_position = Some(position); if !self.mouse_mode(e.shift) { + self.selection_phase = SelectionPhase::Selecting; // Alacritty has the same ordering, of first updating the selection // then scrolling 15ms later self.events @@ -822,7 +967,7 @@ impl Terminal { None => return, }; - let scroll_lines = (scroll_delta / self.cur_size.line_height) as i32; + let scroll_lines = (scroll_delta / self.last_content.size.line_height) as i32; self.events .push_back(InternalEvent::Scroll(AlacScroll::Delta(scroll_lines))); @@ -834,8 +979,8 @@ impl Terminal { fn drag_line_delta(&mut self, e: DragRegionEvent) -> Option { //TODO: Why do these need to be doubled? Probably the same problem that the IME has - let top = e.region.origin_y() + (self.cur_size.line_height * 2.); - let bottom = e.region.lower_left().y() - (self.cur_size.line_height * 2.); + let top = e.region.origin_y() + (self.last_content.size.line_height * 2.); + let bottom = e.region.lower_left().y() - (self.last_content.size.line_height * 2.); let scroll_delta = if e.position.y() < top { (top - e.position.y()).powf(1.1) } else if e.position.y() > bottom { @@ -848,8 +993,12 @@ impl Terminal { pub fn mouse_down(&mut self, e: &DownRegionEvent, origin: Vector2F) { let position = e.position.sub(origin); - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); - let side = mouse_side(position, self.cur_size); + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + let side = mouse_side(position, self.last_content.size); if self.mouse_mode(e.shift) { if let Some(bytes) = mouse_button_report(point, e, true, self.last_content.mode) { @@ -865,25 +1014,42 @@ impl Terminal { pub fn left_click(&mut self, e: &ClickRegionEvent, origin: Vector2F) { let position = e.position.sub(origin); - if !self.mouse_mode(e.shift) { - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); - let side = mouse_side(position, self.cur_size); - - let selection_type = match e.click_count { - 0 => return, //This is a release - 1 => Some(SelectionType::Simple), - 2 => Some(SelectionType::Semantic), - 3 => Some(SelectionType::Lines), - _ => None, - }; + //Hyperlinks + { + let mouse_cell_index = content_index_for_mouse(position, &self.last_content); + if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() { + open_uri(link.uri()).log_err(); + } else { + self.events + .push_back(InternalEvent::Hyperlink(position, true)); + } + } - let selection = - selection_type.map(|selection_type| Selection::new(selection_type, point, side)); + // Selections + { + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + let side = mouse_side(position, self.last_content.size); + + let selection_type = match e.click_count { + 0 => return, //This is a release + 1 => Some(SelectionType::Simple), + 2 => Some(SelectionType::Semantic), + 3 => Some(SelectionType::Lines), + _ => None, + }; - if let Some(sel) = selection { - self.events - .push_back(InternalEvent::SetSelection(Some((sel, point)))); + let selection = selection_type + .map(|selection_type| Selection::new(selection_type, point, side)); + + if let Some(sel) = selection { + self.events + .push_back(InternalEvent::SetSelection(Some((sel, point)))); + } } } } @@ -891,7 +1057,11 @@ impl Terminal { pub fn mouse_up(&mut self, e: &UpRegionEvent, origin: Vector2F) { let position = e.position.sub(origin); if self.mouse_mode(e.shift) { - let point = mouse_point(position, self.cur_size, self.last_content.display_offset); + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); if let Some(bytes) = mouse_button_report(point, e, false, self.last_content.mode) { self.pty_tx.notify(bytes); @@ -901,6 +1071,7 @@ impl Terminal { // so let's do that here self.copy(); } + self.selection_phase = SelectionPhase::Ended; self.last_mouse = None; } @@ -910,9 +1081,9 @@ impl Terminal { if let Some(scroll_lines) = self.determine_scroll_lines(&e, mouse_mode) { if mouse_mode { - let point = mouse_point( + let point = grid_point( e.position.sub(origin), - self.cur_size, + self.last_content.size, self.last_content.display_offset, ); @@ -940,6 +1111,17 @@ impl Terminal { } } + pub fn refresh_hyperlink(&mut self, cmd: bool) -> bool { + let prev_hyperlink = self.last_content.last_hovered_hyperlink.take(); + + if cmd { + self.fill_hyperlink(self.last_mouse_position, prev_hyperlink); + true + } else { + false + } + } + fn determine_scroll_lines( &mut self, e: &ScrollWheelRegionEvent, @@ -955,20 +1137,22 @@ impl Terminal { } /* Calculate the appropriate scroll lines */ Some(gpui::TouchPhase::Moved) => { - let old_offset = (self.scroll_px / self.cur_size.line_height) as i32; + let old_offset = (self.scroll_px / self.last_content.size.line_height) as i32; self.scroll_px += e.delta.y() * scroll_multiplier; - let new_offset = (self.scroll_px / self.cur_size.line_height) as i32; + let new_offset = (self.scroll_px / self.last_content.size.line_height) as i32; // Whenever we hit the edges, reset our stored scroll to 0 // so we can respond to changes in direction quickly - self.scroll_px %= self.cur_size.height; + self.scroll_px %= self.last_content.size.height; Some(new_offset - old_offset) } /* Fall back to delta / line_height */ - None => Some(((e.delta.y() * scroll_multiplier) / self.cur_size.line_height) as i32), + None => Some( + ((e.delta.y() * scroll_multiplier) / self.last_content.size.line_height) as i32, + ), _ => None, } } @@ -1011,30 +1195,36 @@ impl Entity for Terminal { type Event = Event; } +/// Based on alacritty/src/display/hint.rs > regex_match_at +/// Retrieve the match, if the specified point is inside the content matching the regex. +fn regex_match_at(term: &Term, point: Point, regex: &RegexSearch) -> Option { + visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point)) +} + +/// Copied from alacritty/src/display/hint.rs: +/// Iterate over all visible regex matches. +pub fn visible_regex_match_iter<'a, T>( + term: &'a Term, + regex: &'a RegexSearch, +) -> impl Iterator + 'a { + let viewport_start = Line(-(term.grid().display_offset() as i32)); + let viewport_end = viewport_start + term.bottommost_line(); + let mut start = term.line_search_left(Point::new(viewport_start, Column(0))); + let mut end = term.line_search_right(Point::new(viewport_end, Column(0))); + start.line = start.line.max(viewport_start - MAX_SEARCH_LINES); + end.line = end.line.min(viewport_end + MAX_SEARCH_LINES); + + RegexIter::new(start, end, AlacDirection::Right, term, regex) + .skip_while(move |rm| rm.end().line < viewport_start) + .take_while(move |rm| rm.start().line <= viewport_end) +} + fn make_selection(range: &RangeInclusive) -> Selection { let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left); selection.update(*range.end(), AlacDirection::Right); selection } -/// Copied from alacritty/src/display/hint.rs HintMatches::visible_regex_matches() -/// Iterate over all visible regex matches. -// fn visible_search_matches<'a, T>( -// term: &'a Term, -// regex: &'a RegexSearch, -// ) -> impl Iterator + 'a { -// let viewport_start = Line(-(term.grid().display_offset() as i32)); -// let viewport_end = viewport_start + term.bottommost_line(); -// let mut start = term.line_search_left(Point::new(viewport_start, Column(0))); -// let mut end = term.line_search_right(Point::new(viewport_end, Column(0))); -// start.line = start.line.max(viewport_start - MAX_SEARCH_LINES); -// end.line = end.line.min(viewport_end + MAX_SEARCH_LINES); - -// RegexIter::new(start, end, AlacDirection::Right, term, regex) -// .skip_while(move |rm| rm.end().line < viewport_start) -// .take_while(move |rm| rm.start().line <= viewport_end) -// } - fn all_search_matches<'a, T>( term: &'a Term, regex: &'a RegexSearch, @@ -1044,7 +1234,115 @@ fn all_search_matches<'a, T>( RegexIter::new(start, end, AlacDirection::Right, term, regex) } +fn content_index_for_mouse<'a>(pos: Vector2F, content: &'a TerminalContent) -> usize { + let col = min( + (pos.x() / content.size.cell_width()) as usize, + content.size.columns() - 1, + ) as usize; + let line = min( + (pos.y() / content.size.line_height()) as usize, + content.size.screen_lines() - 1, + ) as usize; + + line * content.size.columns() + col +} + +fn open_uri(uri: &str) -> Result<(), std::io::Error> { + let mut command = Command::new("open"); + command.arg(uri); + + unsafe { + command + .pre_exec(|| { + match libc::fork() { + -1 => return Err(io::Error::last_os_error()), + 0 => (), + _ => libc::_exit(0), + } + + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + + Ok(()) + }) + .spawn()? + .wait() + .map(|_| ()) + } +} + #[cfg(test)] mod tests { + use gpui::geometry::vector::vec2f; + use rand::{thread_rng, Rng}; + + use crate::content_index_for_mouse; + + use self::terminal_test_context::TerminalTestContext; + pub mod terminal_test_context; + + #[test] + fn test_mouse_to_cell() { + let mut rng = thread_rng(); + + for _ in 0..10 { + let viewport_cells = rng.gen_range(5..50); + let cell_size = rng.gen_range(5.0..20.0); + + let size = crate::TerminalSize { + cell_width: cell_size, + line_height: cell_size, + height: cell_size * (viewport_cells as f32), + width: cell_size * (viewport_cells as f32), + }; + + let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng); + + for i in 0..(viewport_cells - 1) { + let i = i as usize; + for j in 0..(viewport_cells - 1) { + let j = j as usize; + let min_row = i as f32 * cell_size; + let max_row = (i + 1) as f32 * cell_size; + let min_col = j as f32 * cell_size; + let max_col = (j + 1) as f32 * cell_size; + + let mouse_pos = vec2f( + rng.gen_range(min_row..max_row), + rng.gen_range(min_col..max_col), + ); + + assert_eq!( + content.cells[content_index_for_mouse(mouse_pos, &content)].c, + cells[j][i] + ); + } + } + } + } + + #[test] + fn test_mouse_to_cell_clamp() { + let mut rng = thread_rng(); + + let size = crate::TerminalSize { + cell_width: 10., + line_height: 10., + height: 100., + width: 100., + }; + + let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng); + + assert_eq!( + content.cells[content_index_for_mouse(vec2f(-10., -10.), &content)].c, + cells[0][0] + ); + assert_eq!( + content.cells[content_index_for_mouse(vec2f(1000., 1000.), &content)].c, + cells[9][9] + ); + } } diff --git a/crates/terminal/src/terminal_container_view.rs b/crates/terminal/src/terminal_container_view.rs index 1aebd1f5e7a06200594df31bd4bc2dd6fc42b356..e0fe6ef6cbf275d768970da9d926b1adda7b509f 100644 --- a/crates/terminal/src/terminal_container_view.rs +++ b/crates/terminal/src/terminal_container_view.rs @@ -11,7 +11,6 @@ use util::truncate_and_trailoff; use workspace::searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}; use workspace::{Item, ItemEvent, ToolbarItemLocation, Workspace}; -use crate::TerminalSize; use project::{LocalWorktree, Project, ProjectPath}; use settings::{AlternateScroll, Settings, WorkingDirectory}; use smallvec::SmallVec; @@ -87,9 +86,6 @@ impl TerminalContainer { modal: bool, cx: &mut ViewContext, ) -> Self { - //The exact size here doesn't matter, the terminal will be resized on the first layout - let size_info = TerminalSize::default(); - let settings = cx.global::(); let shell = settings.terminal_overrides.shell.clone(); let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap. @@ -111,7 +107,6 @@ impl TerminalContainer { working_directory.clone(), shell, envs, - size_info, settings.terminal_overrides.blinking.clone(), scroll, cx.window_id(), diff --git a/crates/terminal/src/terminal_element.rs b/crates/terminal/src/terminal_element.rs index 00b6c7e39aa6cc60ea80aeba6a7e22eb57f1ee12..10d6d279c4fca52bbe78747350180441f3c1a9d7 100644 --- a/crates/terminal/src/terminal_element.rs +++ b/crates/terminal/src/terminal_element.rs @@ -7,15 +7,17 @@ use alacritty_terminal::{ use editor::{Cursor, CursorShape, HighlightedRange, HighlightedRangeLine}; use gpui::{ color::Color, - fonts::{Properties, Style::Italic, TextStyle, Underline, Weight}, + elements::{Empty, Overlay}, + fonts::{HighlightStyle, Properties, Style::Italic, TextStyle, Underline, Weight}, geometry::{ rect::RectF, vector::{vec2f, Vector2F}, }, serde_json::json, text_layout::{Line, RunStyle}, - Element, Event, EventContext, FontCache, KeyDownEvent, ModelContext, MouseButton, MouseRegion, - PaintContext, Quad, TextLayoutCache, WeakModelHandle, WeakViewHandle, + Element, ElementBox, Event, EventContext, FontCache, KeyDownEvent, ModelContext, + ModifiersChangedEvent, MouseButton, MouseRegion, PaintContext, Quad, SizeConstraint, + TextLayoutCache, WeakModelHandle, WeakViewHandle, }; use itertools::Itertools; use ordered_float::OrderedFloat; @@ -42,6 +44,7 @@ pub struct LayoutState { size: TerminalSize, mode: TermMode, display_offset: usize, + hyperlink_tooltip: Option, } ///Helper struct for converting data between alacritty's cursor points, and displayed cursor points @@ -180,6 +183,7 @@ impl TerminalElement { text_layout_cache: &TextLayoutCache, font_cache: &FontCache, modal: bool, + hyperlink: Option<(HighlightStyle, &RangeInclusive)>, ) -> (Vec, Vec) { let mut cells = vec![]; let mut rects = vec![]; @@ -237,7 +241,7 @@ impl TerminalElement { //Layout current cell text { let cell_text = &cell.c.to_string(); - if cell_text != " " { + if !is_blank(&cell) { let cell_style = TerminalElement::cell_style( &cell, fg, @@ -245,6 +249,7 @@ impl TerminalElement { text_style, font_cache, modal, + hyperlink, ); let layout_cell = text_layout_cache.layout_str( @@ -257,8 +262,8 @@ impl TerminalElement { Point::new(line_index as i32, cell.point.column.0 as i32), layout_cell, )) - } - }; + }; + } } if cur_rect.is_some() { @@ -304,11 +309,12 @@ impl TerminalElement { text_style: &TextStyle, font_cache: &FontCache, modal: bool, + hyperlink: Option<(HighlightStyle, &RangeInclusive)>, ) -> RunStyle { let flags = indexed.cell.flags; let fg = convert_color(&fg, &style.colors, modal); - let underline = flags + let mut underline = flags .intersects(Flags::ALL_UNDERLINES) .then(|| Underline { color: Some(fg), @@ -317,6 +323,12 @@ impl TerminalElement { }) .unwrap_or_default(); + if indexed.cell.hyperlink().is_some() { + if underline.thickness == OrderedFloat(0.) { + underline.thickness = OrderedFloat(1.); + } + } + let mut properties = Properties::new(); if indexed .flags @@ -332,11 +344,25 @@ impl TerminalElement { .select_font(text_style.font_family_id, &properties) .unwrap_or(text_style.font_id); - RunStyle { + let mut result = RunStyle { color: fg, font_id, underline, + }; + + if let Some((style, range)) = hyperlink { + if range.contains(&indexed.point) { + if let Some(underline) = style.underline { + result.underline = underline; + } + + if let Some(color) = style.color { + result.color = color; + } + } } + + result } fn generic_button_handler( @@ -366,7 +392,7 @@ impl TerminalElement { ) { let connection = self.terminal; - let mut region = MouseRegion::new::(view_id, view_id, visible_bounds); + let mut region = MouseRegion::new::(view_id, 0, visible_bounds); // Terminal Emulator controlled behavior: region = region @@ -428,6 +454,16 @@ impl TerminalElement { }); } }) + .on_move(move |event, cx| { + if cx.is_parent_view_focused() { + if let Some(conn_handle) = connection.upgrade(cx.app) { + conn_handle.update(cx.app, |terminal, cx| { + terminal.mouse_move(&event, origin); + cx.notify(); + }) + } + } + }) .on_scroll(TerminalElement::generic_button_handler( connection, origin, @@ -481,21 +517,6 @@ impl TerminalElement { ), ) } - //Mouse move manages both dragging and motion events - if mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) { - region = region - //TODO: This does not fire on right-mouse-down-move events. - .on_move(move |event, cx| { - if cx.is_parent_view_focused() { - if let Some(conn_handle) = connection.upgrade(cx.app) { - conn_handle.update(cx.app, |terminal, cx| { - terminal.mouse_move(&event, origin); - cx.notify(); - }) - } - } - }) - } cx.scene.push_mouse_region(region); } @@ -547,6 +568,9 @@ impl Element for TerminalElement { //Setup layout information let terminal_theme = settings.theme.terminal.clone(); //TODO: Try to minimize this clone. + let link_style = settings.theme.editor.link_definition; + let tooltip_style = settings.theme.tooltip.clone(); + let text_style = TerminalElement::make_text_style(font_cache, settings); let selection_color = settings.theme.editor.selection.selection; let match_color = settings.theme.search.match_background; @@ -569,9 +593,34 @@ impl Element for TerminalElement { }; let terminal_handle = self.terminal.upgrade(cx).unwrap(); - terminal_handle.update(cx.app, |terminal, cx| { + let last_hovered_hyperlink = terminal_handle.update(cx.app, |terminal, cx| { terminal.set_size(dimensions); - terminal.try_sync(cx) + terminal.try_sync(cx); + terminal.last_content.last_hovered_hyperlink.clone() + }); + + let view_handle = self.view.clone(); + let hyperlink_tooltip = last_hovered_hyperlink.and_then(|(uri, _, id)| { + // last_mouse.and_then(|_last_mouse| { + view_handle.upgrade(cx).map(|handle| { + let mut tooltip = cx.render(&handle, |_, cx| { + Overlay::new( + Empty::new() + .contained() + .constrained() + .with_width(dimensions.width()) + .with_height(dimensions.height()) + .with_tooltip::(id, uri, None, tooltip_style, cx) + .boxed(), + ) + .with_position_mode(gpui::elements::OverlayPositionMode::Local) + .boxed() + }); + + tooltip.layout(SizeConstraint::new(Vector2F::zero(), cx.window_size), cx); + tooltip + }) + // }) }); let TerminalContent { @@ -581,8 +630,9 @@ impl Element for TerminalElement { cursor_char, selection, cursor, + last_hovered_hyperlink, .. - } = &terminal_handle.read(cx).last_content; + } = { &terminal_handle.read(cx).last_content }; // searches, highlights to a single range representations let mut relative_highlighted_ranges = Vec::new(); @@ -602,6 +652,9 @@ impl Element for TerminalElement { cx.text_layout_cache, cx.font_cache(), self.modal, + last_hovered_hyperlink + .as_ref() + .map(|(_, range, _)| (link_style, range)), ); //Layout cursor. Rectangle is used for IME, so we should lay it out even @@ -633,10 +686,11 @@ impl Element for TerminalElement { ) }; + let focused = self.focused; TerminalElement::shape_cursor(cursor_point, dimensions, &cursor_text).map( move |(cursor_position, block_width)| { let shape = match cursor.shape { - AlacCursorShape::Block if !self.focused => CursorShape::Hollow, + AlacCursorShape::Block if !focused => CursorShape::Hollow, AlacCursorShape::Block => CursorShape::Block, AlacCursorShape::Underline => CursorShape::Underscore, AlacCursorShape::Beam => CursorShape::Bar, @@ -669,6 +723,7 @@ impl Element for TerminalElement { relative_highlighted_ranges, mode: *mode, display_offset: *display_offset, + hyperlink_tooltip, }, ) } @@ -691,7 +746,11 @@ impl Element for TerminalElement { cx.scene.push_cursor_region(gpui::CursorRegion { bounds, - style: gpui::CursorStyle::IBeam, + style: if layout.hyperlink_tooltip.is_some() { + gpui::CursorStyle::PointingHand + } else { + gpui::CursorStyle::IBeam + }, }); cx.paint_layer(clip_bounds, |cx| { @@ -743,6 +802,10 @@ impl Element for TerminalElement { }) } } + + if let Some(element) = &mut layout.hyperlink_tooltip { + element.paint(origin, visible_bounds, cx) + } }); } @@ -781,6 +844,18 @@ impl Element for TerminalElement { }) }) .unwrap_or(false) + } else if let Event::ModifiersChanged(ModifiersChangedEvent { cmd, .. }) = event { + self.terminal + .upgrade(cx.app) + .map(|model_handle| { + if model_handle.update(cx.app, |term, _| term.refresh_hyperlink(*cmd)) { + cx.notify(); + true + } else { + false + } + }) + .unwrap_or(false) } else { false } @@ -824,6 +899,29 @@ impl Element for TerminalElement { } } +fn is_blank(cell: &IndexedCell) -> bool { + if cell.c != ' ' { + return false; + } + + if cell.bg != AnsiColor::Named(NamedColor::Background) { + return false; + } + + if cell.hyperlink().is_some() { + return false; + } + + if cell + .flags + .intersects(Flags::ALL_UNDERLINES | Flags::INVERSE | Flags::STRIKEOUT) + { + return false; + } + + return true; +} + fn to_highlighted_range_lines( range: &RangeInclusive, layout: &LayoutState, diff --git a/crates/terminal/src/terminal_view.rs b/crates/terminal/src/terminal_view.rs index fc8bf20ca7e994e41641c027f89fdf28f54fbade..33d573a76a2755e60548c42d389d1099f19d5694 100644 --- a/crates/terminal/src/terminal_view.rs +++ b/crates/terminal/src/terminal_view.rs @@ -362,7 +362,9 @@ impl View for TerminalView { } fn on_focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.terminal.read(cx).focus_out(); + self.terminal.update(cx, |terminal, _| { + terminal.focus_out(); + }); cx.notify(); } diff --git a/crates/terminal/src/tests/terminal_test_context.rs b/crates/terminal/src/tests/terminal_test_context.rs index f9ee6e808266092f5767faaf4401c3392ca5cc31..3e3d1243d5fbed3c3bb407a32684b6d85857deeb 100644 --- a/crates/terminal/src/tests/terminal_test_context.rs +++ b/crates/terminal/src/tests/terminal_test_context.rs @@ -1,10 +1,17 @@ use std::{path::Path, time::Duration}; +use alacritty_terminal::{ + index::{Column, Line, Point}, + term::cell::Cell, +}; use gpui::{ModelHandle, TestAppContext, ViewHandle}; use project::{Entry, Project, ProjectPath, Worktree}; +use rand::{rngs::ThreadRng, Rng}; use workspace::{AppState, Workspace}; +use crate::{IndexedCell, TerminalContent, TerminalSize}; + pub struct TerminalTestContext<'a> { pub cx: &'a mut TestAppContext, } @@ -88,6 +95,39 @@ impl<'a> TerminalTestContext<'a> { project.update(cx, |project, cx| project.set_active_path(Some(p), cx)); }); } + + pub fn create_terminal_content( + size: TerminalSize, + rng: &mut ThreadRng, + ) -> (TerminalContent, Vec>) { + let mut ic = Vec::new(); + let mut cells = Vec::new(); + + for row in 0..((size.height() / size.line_height()) as usize) { + let mut row_vec = Vec::new(); + for col in 0..((size.width() / size.cell_width()) as usize) { + let cell_char = rng.gen(); + ic.push(IndexedCell { + point: Point::new(Line(row as i32), Column(col)), + cell: Cell { + c: cell_char, + ..Default::default() + }, + }); + row_vec.push(cell_char) + } + cells.push(row_vec) + } + + ( + TerminalContent { + cells: ic, + size, + ..Default::default() + }, + cells, + ) + } } impl<'a> Drop for TerminalTestContext<'a> {