Merge pull request #1637 from zed-industries/terminal-hyperlinks

Mikayla Maki created

Tracking PR for Terminal hyperlinks

Change summary

Cargo.lock                                         |   2 
crates/gpui/src/elements/overlay.rs                |  29 +
crates/gpui/src/elements/tooltip.rs                |   8 
crates/terminal/Cargo.toml                         |   3 
crates/terminal/src/mappings/mouse.rs              |   6 
crates/terminal/src/terminal.rs                    | 430 +++++++++++++--
crates/terminal/src/terminal_container_view.rs     |   5 
crates/terminal/src/terminal_element.rs            | 156 ++++-
crates/terminal/src/terminal_view.rs               |   4 
crates/terminal/src/tests/terminal_test_context.rs |  40 +
10 files changed, 573 insertions(+), 110 deletions(-)

Detailed changes

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",

crates/gpui/src/elements/overlay.rs 🔗

@@ -14,6 +14,7 @@ pub struct Overlay {
     anchor_position: Option<Vector2F>,
     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 => {

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<Box<dyn Action>>,

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"

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(

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<PathBuf>,
         shell: Option<Shell>,
         env: Option<HashMap<String, String>>,
-        initial_size: TerminalSize,
         blink_settings: Option<TerminalBlink>,
         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<SelectionRange>,
     cursor: RenderableCursor,
     cursor_char: char,
+    size: TerminalSize,
+    last_hovered_hyperlink: Option<(String, RangeInclusive<Point>, 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<FairMutex<Term<ZedListener>>>,
     events: VecDeque<InternalEvent>,
+    /// 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<Vector2F>,
     pub matches: Vec<RangeInclusive<Point>>,
-    cur_size: TerminalSize,
     last_content: TerminalContent,
     last_synced: Instant,
     sync_task: Option<Task<()>>,
@@ -485,6 +519,8 @@ pub struct Terminal {
     shell_fd: u32,
     foreground_process_info: Option<LocalProcessInfo>,
     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<Point>, usize)>,
+        url: String,
+        url_match: RangeInclusive<Point>,
+    ) {
+        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<ZedListener>) -> TerminalContent {
+    fn make_content(term: &Term<ZedListener>, 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<Vector2F>,
+        prev_hyperlink: Option<(String, RangeInclusive<Point>, 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<f32> {
         //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<T>(term: &Term<T>, point: Point, regex: &RegexSearch) -> Option<Match> {
+    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<T>,
+    regex: &'a RegexSearch,
+) -> impl Iterator<Item = Match> + '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<Point>) -> 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<T>,
-//     regex: &'a RegexSearch,
-// ) -> impl Iterator<Item = Match> + '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<T>,
     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]
+        );
+    }
 }

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>,
     ) -> 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::<Settings>();
         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(),

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<ElementBox>,
 }
 
 ///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<Point>)>,
     ) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
         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<Point>)>,
     ) -> 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<E>(
@@ -366,7 +392,7 @@ impl TerminalElement {
     ) {
         let connection = self.terminal;
 
-        let mut region = MouseRegion::new::<Self>(view_id, view_id, visible_bounds);
+        let mut region = MouseRegion::new::<Self>(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::<TerminalElement, _>(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<Point>,
     layout: &LayoutState,

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>) {
-        self.terminal.read(cx).focus_out();
+        self.terminal.update(cx, |terminal, _| {
+            terminal.focus_out();
+        });
         cx.notify();
     }
 

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<Vec<char>>) {
+        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> {