use std::cmp::Reverse; use std::collections::HashSet; use std::iter; use glutin::event::ModifiersState; use alacritty_terminal::grid::{BidirectionalIterator, Dimensions}; use alacritty_terminal::index::{Boundary, Column, Direction, Line, Point}; use alacritty_terminal::term::cell::Hyperlink; use alacritty_terminal::term::search::{Match, RegexIter, RegexSearch}; use alacritty_terminal::term::{Term, TermMode}; use crate::config::ui_config::{Hint, HintAction}; use crate::config::UiConfig; /// Maximum number of linewraps followed outside of the viewport during search highlighting. pub const MAX_SEARCH_LINES: usize = 100; /// Percentage of characters in the hints alphabet used for the last character. const HINT_SPLIT_PERCENTAGE: f32 = 0.5; /// Keyboard regex hint state. pub struct HintState { /// Hint currently in use. hint: Option, /// Alphabet for hint labels. alphabet: String, /// Visible matches. matches: Vec, /// Key label for each visible match. labels: Vec>, /// Keys pressed for hint selection. keys: Vec, } impl HintState { /// Initialize an inactive hint state. pub fn new>(alphabet: S) -> Self { Self { alphabet: alphabet.into(), hint: Default::default(), matches: Default::default(), labels: Default::default(), keys: Default::default(), } } /// Check if a hint selection is in progress. pub fn active(&self) -> bool { self.hint.is_some() } /// Start the hint selection process. pub fn start(&mut self, hint: Hint) { self.hint = Some(hint); } /// Cancel the hint highlighting process. fn stop(&mut self) { self.matches.clear(); self.labels.clear(); self.keys.clear(); self.hint = None; } /// Update the visible hint matches and key labels. pub fn update_matches(&mut self, term: &Term) { let hint = match self.hint.as_mut() { Some(hint) => hint, None => return, }; // Clear current matches. self.matches.clear(); // Add escape sequence hyperlinks. if hint.content.hyperlinks { self.matches.extend(visible_unique_hyperlink_iter(term)); } // Add visible regex matches. if let Some(regex) = hint.content.regex.as_ref() { regex.with_compiled(|regex| { let matches = visible_regex_match_iter(term, regex); // Apply post-processing and search for sub-matches if necessary. if hint.post_processing { self.matches.extend(matches.flat_map(|rm| { HintPostProcessor::new(term, regex, rm).collect::>() })); } else { self.matches.extend(matches); } }); } // Cancel highlight with no visible matches. if self.matches.is_empty() { self.stop(); return; } // Sort and dedup ranges. Currently overlapped but not exactly same ranges are kept. self.matches.sort_by_key(|bounds| (*bounds.start(), Reverse(*bounds.end()))); self.matches.dedup_by_key(|bounds| *bounds.start()); let mut generator = HintLabels::new(&self.alphabet, HINT_SPLIT_PERCENTAGE); let match_count = self.matches.len(); let keys_len = self.keys.len(); // Get the label for each match. self.labels.resize(match_count, Vec::new()); for i in (0..match_count).rev() { let mut label = generator.next(); if label.len() >= keys_len && label[..keys_len] == self.keys[..] { self.labels[i] = label.split_off(keys_len); } else { self.labels[i] = Vec::new(); } } } /// Handle keyboard input during hint selection. pub fn keyboard_input(&mut self, term: &Term, c: char) -> Option { match c { // Use backspace to remove the last character pressed. '\x08' | '\x1f' => { self.keys.pop(); }, // Cancel hint highlighting on ESC/Ctrl+c. '\x1b' | '\x03' => self.stop(), _ => (), } // Update the visible matches. self.update_matches(term); let hint = self.hint.as_ref()?; // Find the last label starting with the input character. let mut labels = self.labels.iter().enumerate().rev(); let (index, label) = labels.find(|(_, label)| !label.is_empty() && label[0] == c)?; // Check if the selected label is fully matched. if label.len() == 1 { let bounds = self.matches[index].clone(); let action = hint.action.clone(); self.stop(); // Hyperlinks take precedence over regex matches. let hyperlink = term.grid()[*bounds.start()].hyperlink(); Some(HintMatch { action, bounds, hyperlink }) } else { // Store character to preserve the selection. self.keys.push(c); None } } /// Hint key labels. pub fn labels(&self) -> &Vec> { &self.labels } /// Visible hint regex matches. pub fn matches(&self) -> &[Match] { &self.matches } /// Update the alphabet used for hint labels. pub fn update_alphabet(&mut self, alphabet: &str) { if self.alphabet != alphabet { self.alphabet = alphabet.to_owned(); self.keys.clear(); } } } /// Hint match which was selected by the user. #[derive(PartialEq, Eq, Debug, Clone)] pub struct HintMatch { /// Action for handling the text. action: HintAction, /// Terminal range matching the hint. bounds: Match, hyperlink: Option, } impl HintMatch { #[inline] pub fn should_highlight(&self, point: Point, pointed_hyperlink: Option<&Hyperlink>) -> bool { self.bounds.contains(&point) && self.hyperlink.as_ref() == pointed_hyperlink } #[inline] pub fn action(&self) -> &HintAction { &self.action } #[inline] pub fn bounds(&self) -> &Match { &self.bounds } pub fn hyperlink(&self) -> Option<&Hyperlink> { self.hyperlink.as_ref() } } /// Generator for creating new hint labels. struct HintLabels { /// Full character set available. alphabet: Vec, /// Alphabet indices for the next label. indices: Vec, /// Point separating the alphabet's head and tail characters. /// /// To make identification of the tail character easy, part of the alphabet cannot be used for /// any other position. /// /// All characters in the alphabet before this index will be used for the last character, while /// the rest will be used for everything else. split_point: usize, } impl HintLabels { /// Create a new label generator. /// /// The `split_ratio` should be a number between 0.0 and 1.0 representing the percentage of /// elements in the alphabet which are reserved for the tail of the hint label. fn new(alphabet: impl Into, split_ratio: f32) -> Self { let alphabet: Vec = alphabet.into().chars().collect(); let split_point = ((alphabet.len() - 1) as f32 * split_ratio.min(1.)) as usize; Self { indices: vec![0], split_point, alphabet } } /// Get the characters for the next label. fn next(&mut self) -> Vec { let characters = self.indices.iter().rev().map(|index| self.alphabet[*index]).collect(); self.increment(); characters } /// Increment the character sequence. fn increment(&mut self) { // Increment the last character; if it's not at the split point we're done. let tail = &mut self.indices[0]; if *tail < self.split_point { *tail += 1; return; } *tail = 0; // Increment all other characters in reverse order. let alphabet_len = self.alphabet.len(); for index in self.indices.iter_mut().skip(1) { if *index + 1 == alphabet_len { // Reset character and move to the next if it's already at the limit. *index = self.split_point + 1; } else { // If the character can be incremented, we're done. *index += 1; return; } } // Extend the sequence with another character when nothing could be incremented. self.indices.push(self.split_point + 1); } } /// Iterate over all visible regex matches. pub fn visible_regex_match_iter<'a, T>( term: &'a Term, regex: &'a RegexSearch, ) -> impl Iterator + 'a { let viewport_start = Line(-(term.grid().display_offset() as i32)); let viewport_end = viewport_start + term.bottommost_line(); let mut start = term.line_search_left(Point::new(viewport_start, Column(0))); let mut end = term.line_search_right(Point::new(viewport_end, Column(0))); start.line = start.line.max(viewport_start - MAX_SEARCH_LINES); end.line = end.line.min(viewport_start + MAX_SEARCH_LINES); RegexIter::new(start, end, Direction::Right, term, regex) .skip_while(move |rm| rm.end().line < viewport_start) .take_while(move |rm| rm.start().line <= viewport_end) } /// Iterate over all visible hyperlinks, yanking only unique ones. pub fn visible_unique_hyperlink_iter(term: &Term) -> impl Iterator + '_ { let mut display_iter = term.grid().display_iter().peekable(); // Avoid creating hints for the same hyperlinks, but from a different places. let mut unique_hyperlinks = HashSet::new(); iter::from_fn(move || { // Find the start of the next unique hyperlink. let (cell, hyperlink) = display_iter.find_map(|cell| { let hyperlink = cell.hyperlink()?; unique_hyperlinks.contains(&hyperlink).then(|| { unique_hyperlinks.insert(hyperlink.clone()); (cell, hyperlink) }) })?; let start = cell.point; let mut end = start; // Find the end bound of just found unique hyperlink. while let Some(next_cell) = display_iter.peek() { // Cell at display iter doesn't match, yield the hyperlink and start over with // `find_map`. if next_cell.hyperlink().as_ref() != Some(&hyperlink) { break; } // Advance to the next cell. end = next_cell.point; let _ = display_iter.next(); } Some(start..=end) }) } /// Retrieve the match, if the specified point is inside the content matching the regex. fn regex_match_at( term: &Term, point: Point, regex: &RegexSearch, post_processing: bool, ) -> Option { let regex_match = visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point))?; // Apply post-processing and search for sub-matches if necessary. if post_processing { HintPostProcessor::new(term, regex, regex_match).find(|rm| rm.contains(&point)) } else { Some(regex_match) } } /// Check if there is a hint highlighted at the specified point. pub fn highlighted_at( term: &Term, config: &UiConfig, point: Point, mouse_mods: ModifiersState, ) -> Option { let mouse_mode = term.mode().intersects(TermMode::MOUSE_MODE); config.hints.enabled.iter().find_map(|hint| { // Check if all required modifiers are pressed. let highlight = hint.mouse.map_or(false, |mouse| { mouse.enabled && mouse_mods.contains(mouse.mods.0) && (!mouse_mode || mouse_mods.contains(ModifiersState::SHIFT)) }); if !highlight { return None; } if let Some((hyperlink, bounds)) = hint.content.hyperlinks.then(|| hyperlink_at(term, point)).flatten() { return Some(HintMatch { bounds, action: hint.action.clone(), hyperlink: Some(hyperlink), }); } if let Some(bounds) = hint.content.regex.as_ref().and_then(|regex| { regex.with_compiled(|regex| regex_match_at(term, point, regex, hint.post_processing)) }) { return Some(HintMatch { bounds, action: hint.action.clone(), hyperlink: None }); } None }) } /// Retrieve the hyperlink with its range, if there is one at the specified point. fn hyperlink_at(term: &Term, point: Point) -> Option<(Hyperlink, Match)> { let hyperlink = term.grid()[point].hyperlink()?; let viewport_start = Line(-(term.grid().display_offset() as i32)); let viewport_end = viewport_start + term.bottommost_line(); let mut match_start = Point::new(point.line, Column(0)); let mut match_end = Point::new(point.line, Column(term.columns() - 1)); let grid = term.grid(); // Find adjacent lines that have the same `hyperlink`. The end purpose to highlight hyperlinks // that span across multiple lines or not directly attached to each other. // Find the closest to the viewport start adjucent line. while match_start.line > viewport_start { let next_line = match_start.line - 1i32; // Iterate over all the cells in the grid's line and check if any of those cells contains // the hyperlink we've found at original `point`. let line_contains_hyperlink = grid[next_line] .into_iter() .any(|cell| cell.hyperlink().map(|h| h == hyperlink).unwrap_or(false)); // There's no hyperlink on the next line, break. if !line_contains_hyperlink { break; } match_start.line = next_line; } // Ditto for the end. while match_end.line < viewport_end { let next_line = match_end.line + 1i32; let line_contains_hyperlink = grid[next_line] .into_iter() .any(|cell| cell.hyperlink().map(|h| h == hyperlink).unwrap_or(false)); if !line_contains_hyperlink { break; } match_end.line = next_line; } Some((hyperlink, match_start..=match_end)) } /// 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 mut post_processor = Self { next_match: None, start: *regex_match.start(), end: *regex_match.end(), term, regex, }; // Post-process the first hint match. post_processor.next_processed_match(regex_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) -> Option { 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, } } if start > iter.point() { None } else { Some(start..=iter.point()) } } /// Loop over submatches until a non-empty post-processed match is found. fn next_processed_match(&mut self, mut regex_match: Match) { self.next_match = loop { if let Some(next_match) = self.hint_post_processing(®ex_match) { self.start = next_match.end().add(self.term, Boundary::Grid, 1); break Some(next_match); } self.start = regex_match.start().add(self.term, Boundary::Grid, 1); if self.start > self.end { return; } match self.term.regex_search_right(self.regex, self.start, self.end) { Some(rm) => regex_match = rm, None => return, } }; } } 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) { self.next_processed_match(rm); } } Some(next_match) } } #[cfg(test)] mod tests { use alacritty_terminal::index::{Column, Line}; use alacritty_terminal::term::test::mock_term; use super::*; #[test] fn hint_label_generation() { let mut generator = HintLabels::new("0123", 0.5); assert_eq!(generator.next(), vec!['0']); assert_eq!(generator.next(), vec!['1']); assert_eq!(generator.next(), vec!['2', '0']); assert_eq!(generator.next(), vec!['2', '1']); assert_eq!(generator.next(), vec!['3', '0']); assert_eq!(generator.next(), vec!['3', '1']); assert_eq!(generator.next(), vec!['2', '2', '0']); assert_eq!(generator.next(), vec!['2', '2', '1']); assert_eq!(generator.next(), vec!['2', '3', '0']); assert_eq!(generator.next(), vec!['2', '3', '1']); assert_eq!(generator.next(), vec!['3', '2', '0']); assert_eq!(generator.next(), vec!['3', '2', '1']); assert_eq!(generator.next(), vec!['3', '3', '0']); assert_eq!(generator.next(), vec!['3', '3', '1']); assert_eq!(generator.next(), vec!['2', '2', '2', '0']); assert_eq!(generator.next(), vec!['2', '2', '2', '1']); assert_eq!(generator.next(), vec!['2', '2', '3', '0']); assert_eq!(generator.next(), vec!['2', '2', '3', '1']); assert_eq!(generator.next(), vec!['2', '3', '2', '0']); assert_eq!(generator.next(), vec!['2', '3', '2', '1']); assert_eq!(generator.next(), vec!['2', '3', '3', '0']); assert_eq!(generator.next(), vec!['2', '3', '3', '1']); assert_eq!(generator.next(), vec!['3', '2', '2', '0']); assert_eq!(generator.next(), vec!['3', '2', '2', '1']); assert_eq!(generator.next(), vec!['3', '2', '3', '0']); assert_eq!(generator.next(), vec!['3', '2', '3', '1']); assert_eq!(generator.next(), vec!['3', '3', '2', '0']); assert_eq!(generator.next(), vec!['3', '3', '2', '1']); assert_eq!(generator.next(), vec!['3', '3', '3', '0']); assert_eq!(generator.next(), vec!['3', '3', '3', '1']); } #[test] fn closed_bracket_does_not_result_in_infinite_iterator() { let term = mock_term(" ) "); let search = RegexSearch::new("[^/ ]").unwrap(); let count = HintPostProcessor::new( &term, &search, Point::new(Line(0), Column(1))..=Point::new(Line(0), Column(1)), ) .take(1) .count(); assert_eq!(count, 0); } }