diff --git a/Cargo.lock b/Cargo.lock index 505b64d2..5b77d602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "ab_glyph_rasterizer" version = "0.1.4" @@ -40,7 +42,6 @@ dependencies = [ "serde_yaml", "time", "unicode-width", - "urlocator", "wayland-client", "winapi 0.3.9", "x11-dl", @@ -1664,12 +1665,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" -[[package]] -name = "urlocator" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14e39a4f106dafb0a748b951494667a44e62b55fd7942b4fc12706d63cc535a0" - [[package]] name = "utf8parse" version = "0.2.0" diff --git a/alacritty.yml b/alacritty.yml index 6cca5b76..840c4ed9 100644 --- a/alacritty.yml +++ b/alacritty.yml @@ -445,29 +445,6 @@ # If this is `true`, the cursor is temporarily hidden when typing. #hide_when_typing: false - #url: - # URL launcher - # - # This program is executed when clicking on a text which is recognized as a - # URL. The URL is always added to the command as the last parameter. - # - # When set to `launcher: None`, URL launching will be disabled completely. - # - # Default: - # - (macOS) open - # - (Linux/BSD) xdg-open - # - (Windows) cmd /c start "" - #launcher: - # program: xdg-open - # args: [] - - # URL modifiers - # - # These are the modifiers that need to be held down for opening URLs when - # clicking on them. The available modifiers are documented in the key - # binding section. - #modifiers: None - # Regex hints # # Terminal hints can be used to find text in the visible part of the terminal @@ -478,10 +455,18 @@ # List with all available hints # - # Each hint takes a `regex`, `binding` and either a `command` or an `action`. + # Each hint must have a `regex` and either an `action` or a `command` field. + # The fields `mouse`, `binding` and `post_processing` are optional. # - # The fields `command`, `binding.key` and `binding.mods` accept the same - # values as they do in the `key_bindings` section. + # The fields `command`, `binding.key`, `binding.mods` and `mouse.mods` accept + # the same values as they do in the `key_bindings` section. + # + # The `mouse.enabled` field controls if the hint should be underlined while + # the mouse with all `mouse.mods` keys held or the vi mode cursor is above it. + # + # If the `post_processing` field is set to `true`, heuristics will be used to + # shorten the match if there are characters likely not to be part of the hint + # (e.g. a trailing `.`). This is most useful for URIs. # # Values for `action`: # - Copy @@ -490,16 +475,17 @@ # Paste the hint's text to the terminal or search. # - Select # Select the hint's text. - # - # Example - # - # enabled: - # - regex: "alacritty/alacritty#\\d*" - # command: firefox - # binding: - # key: G - # mods: Control|Shift - #enabled: [] + #enabled: + # - regex: "(mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)\ + # [^\u0000-\u001F\u007F-\u009F<>\" {-}\\^⟨⟩`]+" + # command: xdg-open + # post_processing: true + # mouse: + # enabled: true + # mods: None + # binding: + # key: U + # mods: Control|Shift # Mouse bindings # diff --git a/alacritty/Cargo.toml b/alacritty/Cargo.toml index febfbdc7..b2fcbe9d 100644 --- a/alacritty/Cargo.toml +++ b/alacritty/Cargo.toml @@ -29,7 +29,6 @@ glutin = { version = "0.26.0", default-features = false, features = ["serde"] } notify = "4" parking_lot = "0.11.0" crossfont = { version = "0.2.0", features = ["force_system_fontconfig"] } -urlocator = "0.1.3" copypasta = { version = "0.7.0", default-features = false } libc = "0.2" unicode-width = "0.1" diff --git a/alacritty/src/config/bindings.rs b/alacritty/src/config/bindings.rs index 91cc09d6..4e7c2fcb 100644 --- a/alacritty/src/config/bindings.rs +++ b/alacritty/src/config/bindings.rs @@ -1162,7 +1162,7 @@ impl<'a> de::Deserialize<'a> for ModsWrapper { type Value = ModsWrapper; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("a subset of Shift|Control|Super|Command|Alt|Option") + f.write_str("None or a subset of Shift|Control|Super|Command|Alt|Option") } fn visit_str(self, value: &str) -> Result diff --git a/alacritty/src/config/mouse.rs b/alacritty/src/config/mouse.rs index 2aa7557c..aed1ab04 100644 --- a/alacritty/src/config/mouse.rs +++ b/alacritty/src/config/mouse.rs @@ -1,50 +1,12 @@ use std::time::Duration; -use glutin::event::ModifiersState; - use alacritty_config_derive::ConfigDeserialize; -use alacritty_terminal::config::Program; - -use crate::config::bindings::ModsWrapper; #[derive(ConfigDeserialize, Default, Clone, Debug, PartialEq, Eq)] pub struct Mouse { pub double_click: ClickHandler, pub triple_click: ClickHandler, pub hide_when_typing: bool, - pub url: Url, -} - -#[derive(ConfigDeserialize, Clone, Debug, PartialEq, Eq)] -pub struct Url { - /// Program for opening links. - pub launcher: Option, - - /// Modifier used to open links. - modifiers: ModsWrapper, -} - -impl Url { - pub fn mods(&self) -> ModifiersState { - self.modifiers.into_inner() - } -} - -impl Default for Url { - fn default() -> Url { - Url { - #[cfg(not(any(target_os = "macos", windows)))] - launcher: Some(Program::Just(String::from("xdg-open"))), - #[cfg(target_os = "macos")] - launcher: Some(Program::Just(String::from("open"))), - #[cfg(windows)] - launcher: Some(Program::WithArgs { - program: String::from("cmd"), - args: vec!["/c".to_string(), "start".to_string(), "".to_string()], - }), - modifiers: Default::default(), - } - } } #[derive(ConfigDeserialize, Clone, Debug, PartialEq, Eq)] diff --git a/alacritty/src/config/ui_config.rs b/alacritty/src/config/ui_config.rs index 3cd2ad88..ff013d57 100644 --- a/alacritty/src/config/ui_config.rs +++ b/alacritty/src/config/ui_config.rs @@ -21,6 +21,11 @@ use crate::config::font::Font; use crate::config::mouse::Mouse; use crate::config::window::WindowConfig; +/// Regex used for the default URL hint. +#[rustfmt::skip] +const URL_REGEX: &str = "(mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)\ + [^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\" {-}\\^⟨⟩`]+"; + #[derive(ConfigDeserialize, Debug, PartialEq)] pub struct UiConfig { /// Font configuration. @@ -90,13 +95,18 @@ impl Default for UiConfig { impl UiConfig { /// Generate key bindings for all keyboard hints. pub fn generate_hint_bindings(&mut self) { - for hint in self.hints.enabled.drain(..) { + for hint in &self.hints.enabled { + let binding = match hint.binding { + Some(binding) => binding, + None => continue, + }; + let binding = KeyBinding { - trigger: hint.binding.key, - mods: hint.binding.mods.0, + trigger: binding.key, + mods: binding.mods.0, mode: BindingMode::empty(), notmode: BindingMode::empty(), - action: Action::Hint(hint), + action: Action::Hint(hint.clone()), }; self.key_bindings.0.push(binding); @@ -197,13 +207,42 @@ pub struct Delta { } /// Regex terminal hints. -#[derive(ConfigDeserialize, Default, Debug, PartialEq, Eq)] +#[derive(ConfigDeserialize, Debug, PartialEq, Eq)] pub struct Hints { /// Characters for the hint labels. alphabet: HintsAlphabet, /// All configured terminal hints. - enabled: Vec, + pub enabled: Vec, +} + +impl Default for Hints { + fn default() -> Self { + // Add URL hint by default when no other hint is present. + let pattern = LazyRegexVariant::Pattern(String::from(URL_REGEX)); + let regex = LazyRegex(Rc::new(RefCell::new(pattern))); + + #[cfg(not(any(target_os = "macos", windows)))] + let action = HintAction::Command(Program::Just(String::from("xdg-open"))); + #[cfg(target_os = "macos")] + let action = HintAction::Command(Program::Just(String::from("open"))); + #[cfg(windows)] + let action = HintAction::Command(Program::WithArgs { + program: String::from("cmd"), + args: vec!["/c".to_string(), "start".to_string(), "".to_string()], + }); + + Self { + enabled: vec![Hint { + regex, + action, + post_processing: true, + mouse: Some(HintMouse { enabled: true, mods: Default::default() }), + binding: Default::default(), + }], + alphabet: Default::default(), + } + } } impl Hints { @@ -271,33 +310,51 @@ pub enum HintAction { /// Hint configuration. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct Hint { + /// Regex for finding matches. + pub regex: LazyRegex, + /// Action executed when this hint is triggered. #[serde(flatten)] pub action: HintAction, - /// Regex for finding matches. - pub regex: LazyRegex, + /// Hint text post processing. + #[serde(default)] + pub post_processing: bool, + + /// Hint mouse highlighting. + pub mouse: Option, /// Binding required to search for this hint. - binding: HintBinding, + binding: Option, } /// Binding for triggering a keyboard hint. #[derive(Deserialize, Copy, Clone, Debug, PartialEq, Eq)] pub struct HintBinding { pub key: Key, + #[serde(default)] + pub mods: ModsWrapper, +} + +/// Hint mouse highlighting. +#[derive(ConfigDeserialize, Default, Copy, Clone, Debug, PartialEq, Eq)] +pub struct HintMouse { + /// Hint mouse highlighting availability. + pub enabled: bool, + + /// Required mouse modifiers for hint highlighting. pub mods: ModsWrapper, } /// Lazy regex with interior mutability. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct LazyRegex(Rc>); impl LazyRegex { /// Execute a function with the compiled regex DFAs as parameter. - pub fn with_compiled(&self, f: F) -> T + pub fn with_compiled(&self, mut f: F) -> T where - F: Fn(&RegexSearch) -> T, + F: FnMut(&RegexSearch) -> T, { f(self.0.borrow_mut().compiled()) } @@ -313,14 +370,6 @@ impl<'de> Deserialize<'de> for LazyRegex { } } -/// Implement placeholder to allow derive upstream, since we never need it for this struct itself. -impl PartialEq for LazyRegex { - fn eq(&self, _other: &Self) -> bool { - false - } -} -impl Eq for LazyRegex {} - /// Regex which is compiled on demand, to avoid expensive computations at startup. #[derive(Clone, Debug)] pub enum LazyRegexVariant { @@ -357,3 +406,13 @@ impl LazyRegexVariant { } } } + +impl PartialEq for LazyRegexVariant { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Pattern(regex), Self::Pattern(other_regex)) => regex == other_regex, + _ => false, + } + } +} +impl Eq for LazyRegexVariant {} diff --git a/alacritty/src/display/content.rs b/alacritty/src/display/content.rs index e356f1f3..23a2f8a4 100644 --- a/alacritty/src/display/content.rs +++ b/alacritty/src/display/content.rs @@ -18,15 +18,12 @@ use alacritty_terminal::term::{ use crate::config::ui_config::UiConfig; use crate::display::color::{List, DIM_FACTOR}; use crate::display::hint::HintState; -use crate::display::Display; +use crate::display::{self, Display, MAX_SEARCH_LINES}; use crate::event::SearchState; /// Minimum contrast between a fixed cursor color and the cell's background. pub const MIN_CURSOR_CONTRAST: f64 = 1.5; -/// Maximum number of linewraps followed outside of the viewport during search highlighting. -const MAX_SEARCH_LINES: usize = 100; - /// Renderable terminal content. /// /// This provides the terminal cursor and an iterator over all non-empty cells. @@ -138,8 +135,8 @@ impl<'a> RenderableContent<'a> { // Convert cursor point to viewport position. let cursor_point = self.terminal_cursor.point; - let line = (cursor_point.line + self.terminal_content.display_offset as i32).0 as usize; - let point = Point::new(line, cursor_point.column); + let display_offset = self.terminal_content.display_offset; + let point = display::point_to_viewport(display_offset, cursor_point).unwrap(); Some(RenderableCursor { shape: self.terminal_cursor.shape, @@ -258,8 +255,8 @@ impl RenderableCell { // Convert cell point to viewport position. let cell_point = cell.point; - let line = (cell_point.line + content.terminal_content.display_offset as i32).0 as usize; - let point = Point::new(line, cell_point.column); + let display_offset = content.terminal_content.display_offset; + let point = display::point_to_viewport(display_offset, cell_point).unwrap(); RenderableCell { zerowidth: cell.zerowidth().map(|zerowidth| zerowidth.to_vec()), @@ -441,7 +438,7 @@ impl<'a> From<&'a HintState> for Hint<'a> { /// Wrapper for finding visible regex matches. #[derive(Default, Clone)] -pub struct RegexMatches(Vec>); +pub struct RegexMatches(pub Vec>); impl RegexMatches { /// Find all visible matches. diff --git a/alacritty/src/display/hint.rs b/alacritty/src/display/hint.rs index 2a5e9c65..f9ab90d4 100644 --- a/alacritty/src/display/hint.rs +++ b/alacritty/src/display/hint.rs @@ -1,8 +1,16 @@ -use alacritty_terminal::term::search::Match; +use std::cmp::{max, min}; + +use glutin::event::ModifiersState; + +use alacritty_terminal::grid::BidirectionalIterator; +use alacritty_terminal::index::{Boundary, Point}; +use alacritty_terminal::term::search::{Match, RegexSearch}; use alacritty_terminal::term::Term; use crate::config::ui_config::{Hint, HintAction}; +use crate::config::Config; use crate::display::content::RegexMatches; +use crate::display::MAX_SEARCH_LINES; /// Percentage of characters in the hints alphabet used for the last character. const HINT_SPLIT_PERCENTAGE: f32 = 0.5; @@ -63,7 +71,20 @@ impl HintState { }; // Find visible matches. - self.matches = hint.regex.with_compiled(|regex| RegexMatches::new(term, regex)); + self.matches.0 = hint.regex.with_compiled(|regex| { + let mut matches = RegexMatches::new(term, regex); + + // Apply post-processing and search for sub-matches if necessary. + if hint.post_processing { + matches + .drain(..) + .map(|rm| HintPostProcessor::new(term, regex, rm).collect::>()) + .flatten() + .collect() + } else { + matches.0 + } + }); // Cancel highlight with no visible matches. if self.matches.is_empty() { @@ -144,6 +165,7 @@ impl HintState { } /// Hint match which was selected by the user. +#[derive(Clone)] pub struct HintMatch { /// Action for handling the text. pub action: HintAction, @@ -217,6 +239,159 @@ impl HintLabels { } } +/// Check if there is a hint highlighted at the specified point. +pub fn highlighted_at( + term: &Term, + config: &Config, + point: Point, + mouse_mods: ModifiersState, +) -> Option { + config.ui_config.hints.enabled.iter().find_map(|hint| { + // Check if all required modifiers are pressed. + if hint.mouse.map_or(true, |mouse| !mouse.enabled || !mouse_mods.contains(mouse.mods.0)) { + return None; + } + + hint.regex.with_compiled(|regex| { + // Setup search boundaries. + let mut start = term.line_search_left(point); + start.line = max(start.line, point.line - MAX_SEARCH_LINES); + let mut end = term.line_search_right(point); + end.line = min(end.line, point.line + MAX_SEARCH_LINES); + + // Function to verify if the specified point is inside the match. + let at_point = |rm: &Match| *rm.start() <= point && *rm.end() >= point; + + // Check if there's any match at the specified point. + let regex_match = term.regex_search_right(regex, start, end).filter(at_point)?; + + // Apply post-processing and search for sub-matches if necessary. + let regex_match = if hint.post_processing { + HintPostProcessor::new(term, regex, regex_match).find(at_point) + } else { + Some(regex_match) + }; + + regex_match.map(|bounds| HintMatch { action: hint.action.clone(), bounds }) + }) + }) +} + +/// Iterator over all post-processed matches inside an existing hint match. +struct HintPostProcessor<'a, T> { + /// Regex search DFAs. + regex: &'a RegexSearch, + + /// Terminal reference. + term: &'a Term, + + /// Next hint match in the iterator. + next_match: Option, + + /// Start point for the next search. + start: Point, + + /// End point for the hint match iterator. + end: Point, +} + +impl<'a, T> HintPostProcessor<'a, T> { + /// Create a new iterator for an unprocessed match. + fn new(term: &'a Term, regex: &'a RegexSearch, regex_match: Match) -> Self { + let end = *regex_match.end(); + let mut post_processor = Self { next_match: None, start: end, end, term, regex }; + + // Post-process the first hint match. + let next_match = post_processor.hint_post_processing(®ex_match); + post_processor.start = next_match.end().add(term, Boundary::Grid, 1); + post_processor.next_match = Some(next_match); + + post_processor + } + + /// Apply some hint post processing heuristics. + /// + /// This will check the end of the hint and make it shorter if certain characters are determined + /// to be unlikely to be intentionally part of the hint. + /// + /// This is most useful for identifying URLs appropriately. + fn hint_post_processing(&self, regex_match: &Match) -> Match { + let mut iter = self.term.grid().iter_from(*regex_match.start()); + + let mut c = iter.cell().c; + + // Truncate uneven number of brackets. + let end = *regex_match.end(); + let mut open_parents = 0; + let mut open_brackets = 0; + loop { + match c { + '(' => open_parents += 1, + '[' => open_brackets += 1, + ')' => { + if open_parents == 0 { + iter.prev(); + break; + } else { + open_parents -= 1; + } + }, + ']' => { + if open_brackets == 0 { + iter.prev(); + break; + } else { + open_brackets -= 1; + } + }, + _ => (), + } + + if iter.point() == end { + break; + } + + match iter.next() { + Some(indexed) => c = indexed.cell.c, + None => break, + } + } + + // Truncate trailing characters which are likely to be delimiters. + let start = *regex_match.start(); + while iter.point() != start { + if !matches!(c, '.' | ',' | ':' | ';' | '?' | '!' | '(' | '[' | '\'') { + break; + } + + match iter.prev() { + Some(indexed) => c = indexed.cell.c, + None => break, + } + } + + start..=iter.point() + } +} + +impl<'a, T> Iterator for HintPostProcessor<'a, T> { + type Item = Match; + + fn next(&mut self) -> Option { + let next_match = self.next_match.take()?; + + if self.start <= self.end { + if let Some(rm) = self.term.regex_search_right(self.regex, self.start, self.end) { + let regex_match = self.hint_post_processing(&rm); + self.start = regex_match.end().add(self.term, Boundary::Grid, 1); + self.next_match = Some(regex_match); + } + } + + Some(next_match) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/alacritty/src/display/mod.rs b/alacritty/src/display/mod.rs index 78220b59..6e40e35c 100644 --- a/alacritty/src/display/mod.rs +++ b/alacritty/src/display/mod.rs @@ -2,6 +2,7 @@ //! GPU drawing. use std::cmp::min; +use std::convert::TryFrom; use std::f64; use std::fmt::{self, Formatter}; #[cfg(all(feature = "wayland", not(any(target_os = "macos", windows))))] @@ -27,6 +28,7 @@ use alacritty_terminal::event::{EventListener, OnResize}; use alacritty_terminal::grid::Dimensions as _; use alacritty_terminal::index::{Column, Direction, Line, Point}; use alacritty_terminal::selection::Selection; +use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::{SizeInfo, Term, TermMode, MIN_COLUMNS, MIN_SCREEN_LINES}; use crate::config::font::Font; @@ -38,14 +40,13 @@ use crate::display::bell::VisualBell; use crate::display::color::List; use crate::display::content::RenderableContent; use crate::display::cursor::IntoRects; -use crate::display::hint::HintState; +use crate::display::hint::{HintMatch, HintState}; use crate::display::meter::Meter; use crate::display::window::Window; use crate::event::{Mouse, SearchState}; use crate::message_bar::{MessageBuffer, MessageType}; use crate::renderer::rects::{RenderLines, RenderRect}; use crate::renderer::{self, GlyphCache, QuadRenderer}; -use crate::url::{Url, Urls}; pub mod content; pub mod cursor; @@ -58,7 +59,13 @@ mod meter; #[cfg(all(feature = "wayland", not(any(target_os = "macos", windows))))] mod wayland_theme; +/// Maximum number of linewraps followed outside of the viewport during search highlighting. +pub const MAX_SEARCH_LINES: usize = 100; + +/// Label for the forward terminal search bar. const FORWARD_SEARCH_LABEL: &str = "Search: "; + +/// Label for the backward terminal search bar. const BACKWARD_SEARCH_LABEL: &str = "Backward Search: "; #[derive(Debug)] @@ -164,10 +171,12 @@ impl DisplayUpdate { pub struct Display { pub size_info: SizeInfo, pub window: Window, - pub urls: Urls, - /// Currently highlighted URL. - pub highlighted_url: Option, + /// Hint highlighted by the mouse. + pub highlighted_hint: Option, + + /// Hint highlighted by the vi mode cursor. + pub vi_highlighted_hint: Option, #[cfg(all(feature = "wayland", not(any(target_os = "macos", windows))))] pub wayland_event_queue: Option, @@ -331,8 +340,8 @@ impl Display { hint_state, meter: Meter::new(), size_info, - urls: Urls::new(), - highlighted_url: None, + highlighted_hint: None, + vi_highlighted_hint: None, #[cfg(not(any(target_os = "macos", windows)))] is_x11, #[cfg(all(feature = "wayland", not(any(target_os = "macos", windows))))] @@ -473,8 +482,6 @@ impl Display { terminal: MutexGuard<'_, Term>, message_buffer: &MessageBuffer, config: &Config, - mouse: &Mouse, - mods: ModifiersState, search_state: &SearchState, ) { // Collect renderable content before the terminal is dropped. @@ -492,10 +499,6 @@ impl Display { let metrics = self.glyph_cache.font_metrics(); let size_info = self.size_info; - let selection = !terminal.selection.as_ref().map(Selection::is_empty).unwrap_or(true); - let mouse_mode = terminal.mode().intersects(TermMode::MOUSE_MODE) - && !terminal.mode().contains(TermMode::VI); - let vi_mode = terminal.mode().contains(TermMode::VI); let vi_mode_cursor = if vi_mode { Some(terminal.vi_mode_cursor) } else { None }; @@ -507,18 +510,24 @@ impl Display { }); let mut lines = RenderLines::new(); - let mut urls = Urls::new(); // Draw grid. { let _sampler = self.meter.sampler(); let glyph_cache = &mut self.glyph_cache; + let highlighted_hint = &self.highlighted_hint; + let vi_highlighted_hint = &self.vi_highlighted_hint; self.renderer.with_api(&config.ui_config, &size_info, |mut api| { // Iterate over all non-empty cells in the grid. - for cell in grid_cells { - // Update URL underlines. - urls.update(&size_info, &cell); + for mut cell in grid_cells { + // Underline hints hovered by mouse or vi mode cursor. + let point = viewport_to_point(display_offset, cell.point); + if highlighted_hint.as_ref().map_or(false, |h| h.bounds.contains(&point)) + || vi_highlighted_hint.as_ref().map_or(false, |h| h.bounds.contains(&point)) + { + cell.flags.insert(Flags::UNDERLINE); + } // Update underline/strikeout. lines.update(&cell); @@ -531,33 +540,9 @@ impl Display { let mut rects = lines.rects(&metrics, &size_info); - // Update visible URLs. - self.urls = urls; - if let Some(url) = self.urls.highlighted(config, mouse, mods, mouse_mode, selection) { - rects.append(&mut url.rects(&metrics, &size_info)); - - self.window.set_mouse_cursor(CursorIcon::Hand); - - self.highlighted_url = Some(url); - } else if self.highlighted_url.is_some() { - self.highlighted_url = None; - - if mouse_mode { - self.window.set_mouse_cursor(CursorIcon::Default); - } else { - self.window.set_mouse_cursor(CursorIcon::Text); - } - } - if let Some(vi_mode_cursor) = vi_mode_cursor { - // Highlight URLs at the vi mode cursor position. - let vi_point = vi_mode_cursor.point; - let line = (vi_point.line + display_offset).0 as usize; - if let Some(url) = self.urls.find_at(Point::new(line, vi_point.column)) { - rects.append(&mut url.rects(&metrics, &size_info)); - } - // Indicate vi mode by showing the cursor's position in the top right corner. + let vi_point = vi_mode_cursor.point; let line = (-vi_point.line.0 + size_info.bottommost_line().0) as usize; self.draw_line_indicator(config, &size_info, total_lines, Some(vi_point), line); } else if search_state.regex().is_some() { @@ -671,6 +656,47 @@ impl Display { self.colors = List::from(&config.ui_config.colors); } + /// Update the mouse/vi mode cursor hint highlighting. + pub fn update_highlighted_hints( + &mut self, + term: &Term, + config: &Config, + mouse: &Mouse, + modifiers: ModifiersState, + ) { + // Update vi mode cursor hint. + if term.mode().contains(TermMode::VI) { + let mods = ModifiersState::all(); + let point = term.vi_mode_cursor.point; + self.vi_highlighted_hint = hint::highlighted_at(&term, config, point, mods); + } else { + self.vi_highlighted_hint = None; + } + + // Abort if mouse highlighting conditions are not met. + if !mouse.inside_text_area || !term.selection.as_ref().map_or(true, Selection::is_empty) { + self.highlighted_hint = None; + return; + } + + // Find highlighted hint at mouse position. + let point = viewport_to_point(term.grid().display_offset(), mouse.point); + let highlighted_hint = hint::highlighted_at(&term, config, point, modifiers); + + // Update cursor shape. + if highlighted_hint.is_some() { + self.window.set_mouse_cursor(CursorIcon::Hand); + } else if self.highlighted_hint.is_some() { + if term.mode().intersects(TermMode::MOUSE_MODE) && !term.mode().contains(TermMode::VI) { + self.window.set_mouse_cursor(CursorIcon::Default); + } else { + self.window.set_mouse_cursor(CursorIcon::Text); + } + } + + self.highlighted_hint = highlighted_hint; + } + /// Format search regex to account for the cursor and fullwidth characters. fn format_search(size_info: &SizeInfo, search_regex: &str, search_label: &str) -> String { // Add spacers for wide chars. @@ -782,6 +808,18 @@ impl Display { } } +/// Convert a terminal point to a viewport relative point. +pub fn point_to_viewport(display_offset: usize, point: Point) -> Option> { + let viewport_line = point.line.0 + display_offset as i32; + usize::try_from(viewport_line).ok().map(|line| Point::new(line, point.column)) +} + +/// Convert a viewport relative point to a terminal point. +pub fn viewport_to_point(display_offset: usize, point: Point) -> Point { + let line = Line(point.line as i32) - display_offset; + Point::new(line, point.column) +} + /// Calculate the cell dimensions based on font metrics. /// /// This will return a tuple of the cell width and height. diff --git a/alacritty/src/event.rs b/alacritty/src/event.rs index 341f398a..33b15928 100644 --- a/alacritty/src/event.rs +++ b/alacritty/src/event.rs @@ -44,15 +44,14 @@ use crate::clipboard::Clipboard; use crate::config::ui_config::{HintAction, HintInternalAction}; use crate::config::{self, Config}; use crate::daemon::start_daemon; -use crate::display::hint::{HintMatch, HintState}; +use crate::display::hint::HintMatch; use crate::display::window::Window; -use crate::display::{Display, DisplayUpdate}; +use crate::display::{self, Display, DisplayUpdate}; use crate::input::{self, ActionContext as _, FONT_SIZE_STEP}; #[cfg(target_os = "macos")] use crate::macos; use crate::message_bar::{Message, MessageBuffer}; use crate::scheduler::{Scheduler, TimerId}; -use crate::url::{Url, Urls}; /// Duration after the last user input until an unlimited search is performed. pub const TYPING_SEARCH_DELAY: Duration = Duration::from_millis(500); @@ -213,9 +212,8 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon } else if self.mouse().left_button_state == ElementState::Pressed || self.mouse().right_button_state == ElementState::Pressed { - let point = self.mouse().point; - let line = Line(point.line as i32) - self.terminal.grid().display_offset(); - let point = Point::new(line, point.column); + let display_offset = self.terminal.grid().display_offset(); + let point = display::viewport_to_point(display_offset, self.mouse().point); self.update_selection(point, self.mouse().cell_side); } @@ -322,13 +320,13 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon } #[inline] - fn window(&self) -> &Window { - &self.display.window + fn window(&mut self) -> &mut Window { + &mut self.display.window } #[inline] - fn window_mut(&mut self) -> &mut Window { - &mut self.display.window + fn display(&mut self) -> &mut Display { + &mut self.display } #[inline] @@ -385,30 +383,6 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon start_daemon(&alacritty, &args); } - /// Spawn URL launcher when clicking on URLs. - fn launch_url(&self, url: Url) { - if self.mouse.block_url_launcher { - return; - } - - if let Some(ref launcher) = self.config.ui_config.mouse.url.launcher { - let display_offset = self.terminal.grid().display_offset(); - let start = url.start(); - let start = Point::new(Line(start.line as i32 - display_offset as i32), start.column); - let end = url.end(); - let end = Point::new(Line(end.line as i32 - display_offset as i32), end.column); - - let mut args = launcher.args().to_vec(); - args.push(self.terminal.bounds_to_string(start, end)); - - start_daemon(launcher.program(), &args); - } - } - - fn highlighted_url(&self) -> Option<&Url> { - self.display.highlighted_url.as_ref() - } - fn change_font_size(&mut self, delta: f32) { *self.font_size = max(*self.font_size + delta, Size::new(FONT_SIZE_STEP)); let font = self.config.ui_config.font.clone().with_size(*self.font_size); @@ -645,42 +619,43 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon } } - fn hint_state(&mut self) -> &mut HintState { - &mut self.display.hint_state - } - /// Process a new character for keyboard hints. fn hint_input(&mut self, c: char) { - let action = self.display.hint_state.keyboard_input(self.terminal, c); + if let Some(hint) = self.display.hint_state.keyboard_input(self.terminal, c) { + self.mouse.block_hint_launcher = false; + self.trigger_hint(&hint); + } *self.dirty = true; + } - let HintMatch { action, bounds } = match action { - Some(action) => action, - None => return, - }; + /// Trigger a hint action. + fn trigger_hint(&mut self, hint: &HintMatch) { + if self.mouse.block_hint_launcher { + return; + } - match action { + match &hint.action { // Launch an external program. HintAction::Command(command) => { - let text = self.terminal.bounds_to_string(*bounds.start(), *bounds.end()); + let text = self.terminal.bounds_to_string(*hint.bounds.start(), *hint.bounds.end()); let mut args = command.args().to_vec(); args.push(text); start_daemon(command.program(), &args); }, // Copy the text to the clipboard. HintAction::Action(HintInternalAction::Copy) => { - let text = self.terminal.bounds_to_string(*bounds.start(), *bounds.end()); + let text = self.terminal.bounds_to_string(*hint.bounds.start(), *hint.bounds.end()); self.clipboard.store(ClipboardType::Clipboard, text); }, // Write the text to the PTY/search. HintAction::Action(HintInternalAction::Paste) => { - let text = self.terminal.bounds_to_string(*bounds.start(), *bounds.end()); + let text = self.terminal.bounds_to_string(*hint.bounds.start(), *hint.bounds.end()); self.paste(&text); }, // Select the text. HintAction::Action(HintInternalAction::Select) => { - self.start_selection(SelectionType::Simple, *bounds.start(), Side::Left); - self.update_selection(*bounds.end(), Side::Right); + self.start_selection(SelectionType::Simple, *hint.bounds.start(), Side::Left); + self.update_selection(*hint.bounds.end(), Side::Right); }, } } @@ -731,10 +706,6 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon self.event_loop } - fn urls(&self) -> &Urls { - &self.display.urls - } - fn clipboard_mut(&mut self) -> &mut Clipboard { self.clipboard } @@ -908,7 +879,8 @@ pub struct Mouse { pub scroll_px: f64, pub cell_side: Side, pub lines_scrolled: f32, - pub block_url_launcher: bool, + pub block_hint_launcher: bool, + pub hint_highlight_dirty: bool, pub inside_text_area: bool, pub point: Point, pub x: usize, @@ -925,7 +897,8 @@ impl Default for Mouse { right_button_state: ElementState::Released, click_state: ClickState::None, cell_side: Side::Left, - block_url_launcher: Default::default(), + hint_highlight_dirty: Default::default(), + block_hint_launcher: Default::default(), inside_text_area: Default::default(), lines_scrolled: Default::default(), scroll_px: Default::default(), @@ -1115,6 +1088,17 @@ impl Processor { return; } + if self.dirty || self.mouse.hint_highlight_dirty { + self.display.update_highlighted_hints( + &terminal, + &self.config, + &self.mouse, + self.modifiers, + ); + self.mouse.hint_highlight_dirty = false; + self.dirty = true; + } + if self.dirty { self.dirty = false; @@ -1127,14 +1111,7 @@ impl Processor { } // Redraw screen. - self.display.draw( - terminal, - &self.message_buffer, - &self.config, - &self.mouse, - self.modifiers, - &self.search_state, - ); + self.display.draw(terminal, &self.message_buffer, &self.config, &self.search_state); } }); @@ -1165,7 +1142,7 @@ impl Processor { // Resize to event's dimensions, since no resize event is emitted on Wayland. display_update_pending.set_dimensions(PhysicalSize::new(width, height)); - processor.ctx.window_mut().dpr = scale_factor; + processor.ctx.window().dpr = scale_factor; *processor.ctx.dirty = true; }, Event::Message(message) => { @@ -1184,7 +1161,7 @@ impl Processor { TerminalEvent::Title(title) => { let ui_config = &processor.ctx.config.ui_config; if ui_config.window.dynamic_title { - processor.ctx.window_mut().set_title(&title); + processor.ctx.window().set_title(&title); } }, TerminalEvent::ResetTitle => { @@ -1198,7 +1175,7 @@ impl Processor { // Set window urgency. if processor.ctx.terminal.mode().contains(TermMode::URGENCY_HINTS) { let focused = processor.ctx.terminal.is_focused; - processor.ctx.window_mut().set_urgent(!focused); + processor.ctx.window().set_urgent(!focused); } // Ring visual bell. @@ -1251,16 +1228,16 @@ impl Processor { }, WindowEvent::ReceivedCharacter(c) => processor.received_char(c), WindowEvent::MouseInput { state, button, .. } => { - processor.ctx.window_mut().set_mouse_visible(true); + processor.ctx.window().set_mouse_visible(true); processor.mouse_input(state, button); *processor.ctx.dirty = true; }, WindowEvent::CursorMoved { position, .. } => { - processor.ctx.window_mut().set_mouse_visible(true); + processor.ctx.window().set_mouse_visible(true); processor.mouse_moved(position); }, WindowEvent::MouseWheel { delta, phase, .. } => { - processor.ctx.window_mut().set_mouse_visible(true); + processor.ctx.window().set_mouse_visible(true); processor.mouse_wheel_input(delta, phase); }, WindowEvent::Focused(is_focused) => { @@ -1269,9 +1246,9 @@ impl Processor { *processor.ctx.dirty = true; if is_focused { - processor.ctx.window_mut().set_urgent(false); + processor.ctx.window().set_urgent(false); } else { - processor.ctx.window_mut().set_mouse_visible(true); + processor.ctx.window().set_mouse_visible(true); } processor.ctx.update_cursor_blinking(); @@ -1285,7 +1262,7 @@ impl Processor { WindowEvent::CursorLeft { .. } => { processor.ctx.mouse.inside_text_area = false; - if processor.ctx.highlighted_url().is_some() { + if processor.ctx.display().highlighted_hint.is_some() { *processor.ctx.dirty = true; } }, @@ -1382,12 +1359,12 @@ impl Processor { if !config.ui_config.window.dynamic_title || processor.ctx.config.ui_config.window.title != config.ui_config.window.title { - processor.ctx.window_mut().set_title(&config.ui_config.window.title); + processor.ctx.window().set_title(&config.ui_config.window.title); } #[cfg(all(feature = "wayland", not(any(target_os = "macos", windows))))] if processor.ctx.event_loop.is_wayland() { - processor.ctx.window_mut().set_wayland_theme(&config.ui_config.colors); + processor.ctx.window().set_wayland_theme(&config.ui_config.colors); } // Set subpixel anti-aliasing. @@ -1396,7 +1373,7 @@ impl Processor { // Disable shadows for transparent windows on macOS. #[cfg(target_os = "macos")] - processor.ctx.window_mut().set_has_shadow(config.ui_config.background_opacity() >= 1.0); + processor.ctx.window().set_has_shadow(config.ui_config.background_opacity() >= 1.0); // Update hint keys. processor.ctx.display.hint_state.update_alphabet(config.ui_config.hints.alphabet()); diff --git a/alacritty/src/input.rs b/alacritty/src/input.rs index 38a346d9..7dd47803 100644 --- a/alacritty/src/input.rs +++ b/alacritty/src/input.rs @@ -22,7 +22,7 @@ use glutin::window::CursorIcon; use alacritty_terminal::ansi::{ClearMode, Handler}; use alacritty_terminal::event::EventListener; use alacritty_terminal::grid::{Dimensions, Scroll}; -use alacritty_terminal::index::{Boundary, Column, Direction, Line, Point, Side}; +use alacritty_terminal::index::{Boundary, Column, Direction, Point, Side}; use alacritty_terminal::selection::SelectionType; use alacritty_terminal::term::search::Match; use alacritty_terminal::term::{ClipboardType, SizeInfo, Term, TermMode}; @@ -31,12 +31,12 @@ use alacritty_terminal::vi_mode::ViMotion; use crate::clipboard::Clipboard; use crate::config::{Action, BindingMode, Config, Key, SearchAction, ViAction}; use crate::daemon::start_daemon; -use crate::display::hint::HintState; +use crate::display::hint::HintMatch; use crate::display::window::Window; +use crate::display::{self, Display}; use crate::event::{ClickState, Event, Mouse, TYPING_SEARCH_DELAY}; use crate::message_bar::{self, Message}; use crate::scheduler::{Scheduler, TimerId}; -use crate::url::{Url, Urls}; /// Font size change interval. pub const FONT_SIZE_STEP: f32 = 0.5; @@ -75,8 +75,8 @@ pub trait ActionContext { fn suppress_chars(&mut self) -> &mut bool; fn modifiers(&mut self) -> &mut ModifiersState; fn scroll(&mut self, _scroll: Scroll) {} - fn window(&self) -> &Window; - fn window_mut(&mut self) -> &mut Window; + fn window(&mut self) -> &mut Window; + fn display(&mut self) -> &mut Display; fn terminal(&self) -> &Term; fn terminal_mut(&mut self) -> &mut Term; fn spawn_new_instance(&mut self) {} @@ -86,9 +86,6 @@ pub trait ActionContext { fn message(&self) -> Option<&Message>; fn config(&self) -> &Config; fn event_loop(&self) -> &EventLoopWindowTarget; - fn urls(&self) -> &Urls; - fn launch_url(&self, _url: Url) {} - fn highlighted_url(&self) -> Option<&Url>; fn mouse_mode(&self) -> bool; fn clipboard_mut(&mut self) -> &mut Clipboard; fn scheduler_mut(&mut self) -> &mut Scheduler; @@ -105,8 +102,8 @@ pub trait ActionContext { fn search_active(&self) -> bool; fn on_typing_start(&mut self) {} fn toggle_vi_mode(&mut self) {} - fn hint_state(&mut self) -> &mut HintState; fn hint_input(&mut self, _character: char) {} + fn trigger_hint(&mut self, _hint: &HintMatch) {} fn paste(&mut self, _text: &str) {} } @@ -142,7 +139,7 @@ impl Execute for Action { }, Action::Command(program) => start_daemon(program.program(), program.args()), Action::Hint(hint) => { - ctx.hint_state().start(hint.clone()); + ctx.display().hint_state.start(hint.clone()); ctx.mark_dirty(); }, Action::ToggleViMode => ctx.toggle_vi_mode(), @@ -164,12 +161,12 @@ impl Execute for Action { Self::toggle_selection(ctx, SelectionType::Semantic); }, Action::ViAction(ViAction::Open) => { - ctx.mouse_mut().block_url_launcher = false; - let vi_point = ctx.terminal().vi_mode_cursor.point; - let line = (vi_point.line + ctx.terminal().grid().display_offset()).0 as usize; - if let Some(url) = ctx.urls().find_at(Point::new(line, vi_point.column)) { - ctx.launch_url(url); + let hint = ctx.display().vi_highlighted_hint.take(); + if let Some(hint) = &hint { + ctx.mouse_mut().block_hint_launcher = false; + ctx.trigger_hint(hint); } + ctx.display().vi_highlighted_hint = hint; }, Action::ViAction(ViAction::SearchNext) => { let terminal = ctx.terminal(); @@ -250,9 +247,9 @@ impl Execute for Action { let text = ctx.clipboard_mut().load(ClipboardType::Selection); ctx.paste(&text); }, - Action::ToggleFullscreen => ctx.window_mut().toggle_fullscreen(), + Action::ToggleFullscreen => ctx.window().toggle_fullscreen(), #[cfg(target_os = "macos")] - Action::ToggleSimpleFullscreen => ctx.window_mut().toggle_simple_fullscreen(), + Action::ToggleSimpleFullscreen => ctx.window().toggle_simple_fullscreen(), #[cfg(target_os = "macos")] Action::Hide => ctx.event_loop().hide_application(), #[cfg(target_os = "macos")] @@ -327,25 +324,6 @@ impl Execute for Action { } } -#[derive(Debug, Clone, PartialEq)] -pub enum MouseState { - Url(Url), - MessageBar, - MessageBarButton, - Mouse, - Text, -} - -impl From for CursorIcon { - fn from(mouse_state: MouseState) -> CursorIcon { - match mouse_state { - MouseState::Url(_) | MouseState::MessageBarButton => CursorIcon::Hand, - MouseState::Text => CursorIcon::Text, - _ => CursorIcon::Default, - } - } -} - impl> Processor { pub fn new(ctx: A) -> Self { Self { ctx, _phantom: Default::default() } @@ -375,11 +353,6 @@ impl> Processor { let cell_changed = point != self.ctx.mouse().point; - // Update mouse state and check for URL change. - let mouse_state = self.mouse_state(); - self.update_url_state(&mouse_state); - self.ctx.window_mut().set_mouse_cursor(mouse_state.into()); - // If the mouse hasn't changed cells, do nothing. if !cell_changed && self.ctx.mouse().cell_side == cell_side @@ -392,13 +365,20 @@ impl> Processor { self.ctx.mouse_mut().cell_side = cell_side; self.ctx.mouse_mut().point = point; + // Update mouse state and check for URL change. + let mouse_state = self.cursor_state(); + self.ctx.window().set_mouse_cursor(mouse_state); + + // Prompt hint highlight update. + self.ctx.mouse_mut().hint_highlight_dirty = true; + // Don't launch URLs if mouse has moved. - self.ctx.mouse_mut().block_url_launcher = true; + self.ctx.mouse_mut().block_hint_launcher = true; if (lmb_pressed || rmb_pressed) && (self.ctx.modifiers().shift() || !self.ctx.mouse_mode()) { - let line = Line(point.line as i32) - self.ctx.terminal().grid().display_offset(); - let point = Point::new(line, point.column); + let display_offset = self.ctx.terminal().grid().display_offset(); + let point = display::viewport_to_point(display_offset, point); self.ctx.update_selection(point, cell_side); } else if cell_changed && point.line < self.ctx.terminal().screen_lines() @@ -562,10 +542,8 @@ impl> Processor { }; // Load mouse point, treating message bar and padding as the closest cell. - let point = self.ctx.mouse().point; let display_offset = self.ctx.terminal().grid().display_offset(); - let absolute_line = Line(point.line as i32) - display_offset; - let point = Point::new(absolute_line, point.column); + let point = display::viewport_to_point(display_offset, self.ctx.mouse().point); match button { MouseButton::Left => self.on_left_click(point), @@ -619,7 +597,7 @@ impl> Processor { match self.ctx.mouse().click_state { ClickState::Click => { // Don't launch URLs if this click cleared the selection. - self.ctx.mouse_mut().block_url_launcher = !self.ctx.selection_is_empty(); + self.ctx.mouse_mut().block_hint_launcher = !self.ctx.selection_is_empty(); self.ctx.clear_selection(); @@ -631,11 +609,11 @@ impl> Processor { } }, ClickState::DoubleClick => { - self.ctx.mouse_mut().block_url_launcher = true; + self.ctx.mouse_mut().block_hint_launcher = true; self.ctx.start_selection(SelectionType::Semantic, point, side); }, ClickState::TripleClick => { - self.ctx.mouse_mut().block_url_launcher = true; + self.ctx.mouse_mut().block_hint_launcher = true; self.ctx.start_selection(SelectionType::Lines, point, side); }, ClickState::None => (), @@ -658,10 +636,15 @@ impl> Processor { }; self.mouse_report(code, ElementState::Released); return; - } else if let (MouseButton::Left, MouseState::Url(url)) = (button, self.mouse_state()) { - self.ctx.launch_url(url); } + // Trigger hints highlighted by the mouse. + let hint = self.ctx.display().highlighted_hint.take(); + if let Some(hint) = hint.as_ref().filter(|_| button == MouseButton::Left) { + self.ctx.trigger_hint(hint); + } + self.ctx.display().highlighted_hint = hint; + self.ctx.scheduler_mut().unschedule(TimerId::SelectionScrolling); } @@ -748,7 +731,7 @@ impl> Processor { } // Skip normal mouse events if the message bar has been clicked. - if self.message_bar_mouse_state() == Some(MouseState::MessageBarButton) + if self.message_bar_cursor_state() == Some(CursorIcon::Hand) && state == ElementState::Pressed { let size = self.ctx.size_info(); @@ -773,7 +756,7 @@ impl> Processor { }, }; - self.ctx.window_mut().set_mouse_cursor(new_icon); + self.ctx.window().set_mouse_cursor(new_icon); } else { match state { ElementState::Pressed => { @@ -788,7 +771,7 @@ impl> Processor { /// Process key input. pub fn key_input(&mut self, input: KeyboardInput) { // All key bindings are disabled while a hint is being selected. - if self.ctx.hint_state().active() { + if self.ctx.display().hint_state.active() { *self.ctx.suppress_chars() = false; return; } @@ -813,17 +796,19 @@ impl> Processor { pub fn modifiers_input(&mut self, modifiers: ModifiersState) { *self.ctx.modifiers() = modifiers; + // Prompt hint highlight update. + self.ctx.mouse_mut().hint_highlight_dirty = true; + // Update mouse state and check for URL change. - let mouse_state = self.mouse_state(); - self.update_url_state(&mouse_state); - self.ctx.window_mut().set_mouse_cursor(mouse_state.into()); + let mouse_state = self.cursor_state(); + self.ctx.window().set_mouse_cursor(mouse_state); } /// Reset mouse cursor based on modifier and terminal state. #[inline] pub fn reset_mouse_cursor(&mut self) { - let mouse_state = self.mouse_state(); - self.ctx.window_mut().set_mouse_cursor(mouse_state.into()); + let mouse_state = self.cursor_state(); + self.ctx.window().set_mouse_cursor(mouse_state); } /// Process a received character. @@ -831,7 +816,7 @@ impl> Processor { let suppress_chars = *self.ctx.suppress_chars(); // Handle hint selection over anything else. - if self.ctx.hint_state().active() && !suppress_chars { + if self.ctx.display().hint_state.active() && !suppress_chars { self.ctx.hint_input(c); return; } @@ -925,8 +910,8 @@ impl> Processor { } } - /// Check mouse state in relation to the message bar. - fn message_bar_mouse_state(&self) -> Option { + /// Check mouse icon state in relation to the message bar. + fn message_bar_cursor_state(&self) -> Option { // Since search is above the message bar, the button is offset by search's height. let search_height = if self.ctx.search_active() { 1 } else { 0 }; @@ -941,53 +926,30 @@ impl> Processor { } else if mouse.y <= terminal_end + size.cell_height() as usize && mouse.point.column + message_bar::CLOSE_BUTTON_TEXT.len() >= size.columns() { - Some(MouseState::MessageBarButton) + Some(CursorIcon::Hand) } else { - Some(MouseState::MessageBar) + Some(CursorIcon::Default) } } - /// Trigger redraw when URL highlight changed. - #[inline] - fn update_url_state(&mut self, mouse_state: &MouseState) { - let highlighted_url = self.ctx.highlighted_url(); - if let MouseState::Url(url) = mouse_state { - if Some(url) != highlighted_url { - self.ctx.mark_dirty(); - } - } else if highlighted_url.is_some() { - self.ctx.mark_dirty(); - } - } + /// Icon state of the cursor. + fn cursor_state(&mut self) -> CursorIcon { + // Define function to check if mouse is on top of a hint. + let display_offset = self.ctx.terminal().grid().display_offset(); + let mouse_point = self.ctx.mouse().point; + let hint_highlighted = |hint: &HintMatch| { + let point = display::viewport_to_point(display_offset, mouse_point); + hint.bounds.contains(&point) + }; - /// Location of the mouse cursor. - fn mouse_state(&mut self) -> MouseState { - // Check message bar before URL to ignore URLs in the message bar. - if let Some(mouse_state) = self.message_bar_mouse_state() { - return mouse_state; - } - - let mouse_mode = self.ctx.mouse_mode(); - - // Check for URL at mouse cursor. - let mods = *self.ctx.modifiers(); - let highlighted_url = self.ctx.urls().highlighted( - self.ctx.config(), - self.ctx.mouse(), - mods, - mouse_mode, - !self.ctx.selection_is_empty(), - ); - - if let Some(url) = highlighted_url { - return MouseState::Url(url); - } - - // Check mouse mode if location is not special. - if !self.ctx.modifiers().shift() && mouse_mode { - MouseState::Mouse + if let Some(mouse_state) = self.message_bar_cursor_state() { + mouse_state + } else if self.ctx.display().highlighted_hint.as_ref().map_or(false, hint_highlighted) { + CursorIcon::Hand + } else if !self.ctx.modifiers().shift() && self.ctx.mouse_mode() { + CursorIcon::Default } else { - MouseState::Text + CursorIcon::Text } } @@ -1129,11 +1091,11 @@ mod tests { &mut self.modifiers } - fn window(&self) -> &Window { + fn window(&mut self) -> &mut Window { unimplemented!(); } - fn window_mut(&mut self) -> &mut Window { + fn display(&mut self) -> &mut Display { unimplemented!(); } @@ -1157,21 +1119,9 @@ mod tests { unimplemented!(); } - fn urls(&self) -> &Urls { - unimplemented!(); - } - - fn highlighted_url(&self) -> Option<&Url> { - unimplemented!(); - } - fn scheduler_mut(&mut self) -> &mut Scheduler { unimplemented!(); } - - fn hint_state(&mut self) -> &mut HintState { - unimplemented!(); - } } macro_rules! test_clickstate { diff --git a/alacritty/src/main.rs b/alacritty/src/main.rs index 0914aee4..c68f3cfb 100644 --- a/alacritty/src/main.rs +++ b/alacritty/src/main.rs @@ -45,7 +45,6 @@ mod message_bar; mod panic; mod renderer; mod scheduler; -mod url; mod gl { #![allow(clippy::all)] diff --git a/alacritty/src/url.rs b/alacritty/src/url.rs deleted file mode 100644 index add4a1aa..00000000 --- a/alacritty/src/url.rs +++ /dev/null @@ -1,288 +0,0 @@ -use std::cmp::min; -use std::mem; - -use crossfont::Metrics; -use glutin::event::{ElementState, ModifiersState}; -use urlocator::{UrlLocation, UrlLocator}; - -use alacritty_terminal::grid::Dimensions; -use alacritty_terminal::index::{Boundary, Column, Line, Point}; -use alacritty_terminal::term::cell::Flags; -use alacritty_terminal::term::color::Rgb; -use alacritty_terminal::term::SizeInfo; - -use crate::config::Config; -use crate::display::content::RenderableCell; -use crate::event::Mouse; -use crate::renderer::rects::{RenderLine, RenderRect}; - -#[derive(Clone, Debug, PartialEq)] -pub struct Url { - lines: Vec, - end_offset: u16, - size: SizeInfo, -} - -impl Url { - /// Rectangles required for underlining the URL. - pub fn rects(&self, metrics: &Metrics, size: &SizeInfo) -> Vec { - let end = self.end(); - self.lines - .iter() - .filter(|line| line.start <= end) - .map(|line| { - let mut rect_line = *line; - rect_line.end = min(line.end, end); - rect_line.rects(Flags::UNDERLINE, metrics, size) - }) - .flatten() - .collect() - } - - /// Viewport start point of the URL. - pub fn start(&self) -> Point { - self.lines[0].start - } - - /// Viewport end point of the URL. - pub fn end(&self) -> Point { - let end = self.lines[self.lines.len() - 1].end; - - // Convert to Point to make use of the grid clamping logic. - let mut end = Point::new(Line(end.line as i32), end.column); - end = end.sub(&self.size, Boundary::Cursor, self.end_offset as usize); - - Point::new(end.line.0 as usize, end.column) - } -} - -pub struct Urls { - locator: UrlLocator, - urls: Vec, - scheme_buffer: Vec<(Point, Rgb)>, - next_point: Point, - state: UrlLocation, -} - -impl Default for Urls { - fn default() -> Self { - Self { - locator: UrlLocator::new(), - scheme_buffer: Vec::new(), - urls: Vec::new(), - state: UrlLocation::Reset, - next_point: Point::new(0, Column(0)), - } - } -} - -impl Urls { - pub fn new() -> Self { - Self::default() - } - - // Update tracked URLs. - pub fn update(&mut self, size: &SizeInfo, cell: &RenderableCell) { - let point = cell.point; - let mut end = point; - - // Include the following wide char spacer. - if cell.flags.contains(Flags::WIDE_CHAR) { - end.column += 1; - } - - // Reset URL when empty cells have been skipped. - if point != Point::new(0, Column(0)) && point != self.next_point { - self.reset(); - } - - self.next_point = if end.column.0 + 1 == size.columns() { - Point::new(end.line + 1, Column(0)) - } else { - Point::new(end.line, end.column + 1) - }; - - // Extend current state if a leading wide char spacer is encountered. - if cell.flags.intersects(Flags::LEADING_WIDE_CHAR_SPACER) { - if let UrlLocation::Url(_, mut end_offset) = self.state { - if end_offset != 0 { - end_offset += 1; - } - - self.extend_url(point, end, cell.fg, end_offset); - } - - return; - } - - // Advance parser. - let last_state = mem::replace(&mut self.state, self.locator.advance(cell.character)); - match (self.state, last_state) { - (UrlLocation::Url(_length, end_offset), UrlLocation::Scheme) => { - // Create empty URL. - self.urls.push(Url { lines: Vec::new(), end_offset, size: *size }); - - // Push schemes into URL. - for (scheme_point, scheme_fg) in self.scheme_buffer.split_off(0) { - self.extend_url(scheme_point, scheme_point, scheme_fg, end_offset); - } - - // Push the new cell into URL. - self.extend_url(point, end, cell.fg, end_offset); - }, - (UrlLocation::Url(_length, end_offset), UrlLocation::Url(..)) => { - self.extend_url(point, end, cell.fg, end_offset); - }, - (UrlLocation::Scheme, _) => self.scheme_buffer.push((cell.point, cell.fg)), - (UrlLocation::Reset, _) => self.reset(), - _ => (), - } - - // Reset at un-wrapped linebreak. - if cell.point.column.0 + 1 == size.columns() && !cell.flags.contains(Flags::WRAPLINE) { - self.reset(); - } - } - - /// Extend the last URL. - fn extend_url(&mut self, start: Point, end: Point, color: Rgb, end_offset: u16) { - let url = self.urls.last_mut().unwrap(); - - // If color changed, we need to insert a new line. - if url.lines.last().map(|last| last.color) == Some(color) { - url.lines.last_mut().unwrap().end = end; - } else { - url.lines.push(RenderLine { start, end, color }); - } - - // Update excluded cells at the end of the URL. - url.end_offset = end_offset; - } - - /// Find URL below the mouse cursor. - pub fn highlighted( - &self, - config: &Config, - mouse: &Mouse, - mods: ModifiersState, - mouse_mode: bool, - selection: bool, - ) -> Option { - // Require additional shift in mouse mode. - let mut required_mods = config.ui_config.mouse.url.mods(); - if mouse_mode { - required_mods |= ModifiersState::SHIFT; - } - - // Make sure all prerequisites for highlighting are met. - if selection - || !mouse.inside_text_area - || config.ui_config.mouse.url.launcher.is_none() - || required_mods != mods - || mouse.left_button_state == ElementState::Pressed - { - return None; - } - - self.find_at(mouse.point) - } - - /// Find URL at location. - pub fn find_at(&self, point: Point) -> Option { - for url in &self.urls { - if (url.start()..=url.end()).contains(&point) { - return Some(url.clone()); - } - } - None - } - - fn reset(&mut self) { - self.locator = UrlLocator::new(); - self.state = UrlLocation::Reset; - self.scheme_buffer.clear(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use alacritty_terminal::index::Column; - - fn text_to_cells(text: &str) -> Vec { - text.chars() - .enumerate() - .map(|(i, character)| RenderableCell { - character, - zerowidth: None, - point: Point::new(0, Column(i)), - fg: Default::default(), - bg: Default::default(), - bg_alpha: 0., - flags: Flags::empty(), - }) - .collect() - } - - #[test] - fn multi_color_url() { - let mut input = text_to_cells("test https://example.org ing"); - let size = SizeInfo::new(input.len() as f32, 1., 1.0, 1.0, 0.0, 0.0, false); - - input[10].fg = Rgb { r: 0xff, g: 0x00, b: 0xff }; - - let mut urls = Urls::new(); - - for cell in input { - urls.update(&size, &cell); - } - - let url = urls.urls.first().unwrap(); - assert_eq!(url.start().column, Column(5)); - assert_eq!(url.end().column, Column(23)); - } - - #[test] - fn multiple_urls() { - let input = text_to_cells("test git:a git:b git:c ing"); - let size = SizeInfo::new(input.len() as f32, 1., 1.0, 1.0, 0.0, 0.0, false); - - let mut urls = Urls::new(); - - for cell in input { - urls.update(&size, &cell); - } - - assert_eq!(urls.urls.len(), 3); - - assert_eq!(urls.urls[0].start().column, Column(5)); - assert_eq!(urls.urls[0].end().column, Column(9)); - - assert_eq!(urls.urls[1].start().column, Column(11)); - assert_eq!(urls.urls[1].end().column, Column(15)); - - assert_eq!(urls.urls[2].start().column, Column(17)); - assert_eq!(urls.urls[2].end().column, Column(21)); - } - - #[test] - fn wide_urls() { - let input = text_to_cells("test https://こんにちは (http:여보세요) ing"); - let size = SizeInfo::new(input.len() as f32 + 9., 1., 1.0, 1.0, 0.0, 0.0, false); - - let mut urls = Urls::new(); - - for cell in input { - urls.update(&size, &cell); - } - - assert_eq!(urls.urls.len(), 2); - - assert_eq!(urls.urls[0].start().column, Column(5)); - assert_eq!(urls.urls[0].end().column, Column(17)); - - assert_eq!(urls.urls[1].start().column, Column(20)); - assert_eq!(urls.urls[1].end().column, Column(28)); - } -} diff --git a/alacritty_terminal/src/term/search.rs b/alacritty_terminal/src/term/search.rs index 638df670..93345e4f 100644 --- a/alacritty_terminal/src/term/search.rs +++ b/alacritty_terminal/src/term/search.rs @@ -82,7 +82,7 @@ impl Term { // Limit maximum number of lines searched. end = match max_lines { Some(max_lines) => { - let line = (start.line + max_lines).grid_clamp(self, Boundary::Grid); + let line = (start.line + max_lines).grid_clamp(self, Boundary::None); Point::new(line, self.last_column()) }, _ => end.sub(self, Boundary::None, 1), @@ -121,7 +121,7 @@ impl Term { // Limit maximum number of lines searched. end = match max_lines { Some(max_lines) => { - let line = (start.line - max_lines).grid_clamp(self, Boundary::Grid); + let line = (start.line - max_lines).grid_clamp(self, Boundary::None); Point::new(line, Column(0)) }, _ => end.add(self, Boundary::None, 1), diff --git a/docs/features.md b/docs/features.md index 3aa87aab..fd9a9ad2 100644 --- a/docs/features.md +++ b/docs/features.md @@ -27,13 +27,6 @@ line (Shift v) and block selection (Ctrl v). You can also toggle between them while the selection is still active. -### Opening URLs - -While in vi mode you can open URLs using the Enter key. If some text -is recognized as a URL, it will be underlined once you move the vi cursor above -it. The program used to open these URLs can be changed in the [configuration -file]. - ## Search Search allows you to find anything in Alacritty's scrollback buffer. You can @@ -61,6 +54,11 @@ start vi mode. They consist of a regex that detects these text elements and then either feeds them to an external application or triggers one of Alacritty's built-in actions. +Hints can also be triggered using the mouse or vi mode cursor. If a hint is +enabled for mouse interaction and recognized as such, it will be underlined when +the mouse or vi mode cursor is on top of it. Using the left mouse button or +Enter key in vi mode will then trigger the hint. + Hints can be configured in the `hints` and `colors.hints` sections in the Alacritty configuration file.