Add support for hyperlink escape sequence

This commit adds support for hyperlink escape sequence
`OSC 8 ; params ; URI ST`. The configuration option responsible for
those is `hints.enabled.hyperlinks`.

Fixes #922.
This commit is contained in:
Kirill Chibisov 2022-07-10 20:11:28 +03:00 committed by GitHub
parent 8451b75689
commit 694a52bcff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1054 additions and 262 deletions

View File

@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Vi mode keybinding (z) to center view around vi mode cursor
- Accept hexadecimal values starting with `0x` for `--embed`
- Config option `cursor.blink_timeout` to timeout cursor blinking after inactivity
- Escape sequence to set hyperlinks (`OSC 8 ; params ; URI ST`)
- Config `hints.enabled.hyperlinks` for hyperlink escape sequence hint highlight
### Changed
@ -29,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- OSC 52 is now disabled on unfocused windows
- `SpawnNewInstance` no longer inherits initial `--command`
- Blinking cursor will timeout after `5` seconds by default
- Deprecated `colors.search.bar`, use `colors.footer_bar` instead
### Fixed

View File

@ -237,11 +237,7 @@
# foreground: '#ffffff'
# background: '#000000'
#bar:
# background: '#c5c8c6'
# foreground: '#1d1f21'
# Keyboard regex hints
# Keyboard hints
#hints:
# First character in the hint label
#
@ -269,6 +265,15 @@
# foreground: None
# background: None
# Footer bar
#
# Color used for the footer bar on the bottom, used by search regex input,
# hyperlink URI preview, etc.
#
#footer_bar:
# background: '#c5c8c6'
# foreground: '#1d1f21'
# Selection colors
#
# Colors which should be used to draw the selection area.
@ -467,18 +472,22 @@
# If this is `true`, the cursor is temporarily hidden when typing.
#hide_when_typing: false
# Regex hints
# Hints
#
# Terminal hints can be used to find text in the visible part of the terminal
# and pipe it to other applications.
# Terminal hints can be used to find text or hyperlink in the visible part of
# the terminal and pipe it to other applications.
#hints:
# Keys used for the hint labels.
#alphabet: "jfkdls;ahgurieowpq"
# List with all available hints
#
# Each hint must have a `regex` and either an `action` or a `command` field.
# The fields `mouse`, `binding` and `post_processing` are optional.
# Each hint must have any of `regex` or `hyperlinks` field and either an
# `action` or a `command` field. The fields `mouse`, `binding` and
# `post_processing` are optional.
#
# The `hyperlinks` option will cause OSC 8 escape sequence hyperlinks to be
# highlighted.
#
# The fields `command`, `binding.key`, `binding.mods`, `binding.mode` and
# `mouse.mods` accept the same values as they do in the `key_bindings` section.
@ -488,7 +497,8 @@
#
# 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.
# (e.g. a trailing `.`). This is most useful for URIs and applies only to
# `regex` matches.
#
# Values for `action`:
# - Copy
@ -502,6 +512,7 @@
#enabled:
# - regex: "(ipfs:|ipns:|magnet:|mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)\
# [^\u0000-\u001F\u007F-\u009F<>\"\\s{-}\\^⟨⟩`]+"
# hyperlinks: true
# command: xdg-open
# post_processing: true
# mouse:

View File

@ -18,15 +18,16 @@ pub struct Colors {
pub line_indicator: LineIndicatorColors,
pub hints: HintColors,
pub transparent_background_colors: bool,
footer_bar: BarColors,
}
impl Colors {
pub fn search_bar_foreground(&self) -> Rgb {
self.search.bar.foreground.unwrap_or(self.primary.background)
pub fn footer_bar_foreground(&self) -> Rgb {
self.search.bar.foreground.or(self.footer_bar.foreground).unwrap_or(self.primary.background)
}
pub fn search_bar_background(&self) -> Rgb {
self.search.bar.background.unwrap_or(self.primary.foreground)
pub fn footer_bar_background(&self) -> Rgb {
self.search.bar.background.or(self.footer_bar.background).unwrap_or(self.primary.foreground)
}
}
@ -125,6 +126,7 @@ impl Default for InvertedCellColors {
pub struct SearchColors {
pub focused_match: FocusedMatchColors,
pub matches: MatchColors,
#[config(deprecated = "use `colors.footer_bar` instead")]
bar: BarColors,
}

View File

@ -1,10 +1,11 @@
use std::cell::RefCell;
use std::fmt::{self, Formatter};
use std::path::PathBuf;
use std::rc::Rc;
use glutin::event::{ModifiersState, VirtualKeyCode};
use log::error;
use serde::de::Error as SerdeError;
use serde::de::{Error as SerdeError, MapAccess, Visitor};
use serde::{self, Deserialize, Deserializer};
use unicode_width::UnicodeWidthChar;
@ -236,6 +237,7 @@ impl Default for Hints {
// 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)));
let content = HintContent::new(Some(regex), true);
#[cfg(not(any(target_os = "macos", windows)))]
let action = HintAction::Command(Program::Just(String::from("xdg-open")));
@ -249,7 +251,7 @@ impl Default for Hints {
Self {
enabled: vec![Hint {
regex,
content,
action,
post_processing: true,
mouse: Some(HintMouse { enabled: true, mods: Default::default() }),
@ -332,7 +334,8 @@ pub enum HintAction {
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Hint {
/// Regex for finding matches.
pub regex: LazyRegex,
#[serde(flatten)]
pub content: HintContent,
/// Action executed when this hint is triggered.
#[serde(flatten)]
@ -349,6 +352,80 @@ pub struct Hint {
binding: Option<HintBinding>,
}
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct HintContent {
/// Regex for finding matches.
pub regex: Option<LazyRegex>,
/// Escape sequence hyperlinks.
pub hyperlinks: bool,
}
impl HintContent {
pub fn new(regex: Option<LazyRegex>, hyperlinks: bool) -> Self {
Self { regex, hyperlinks }
}
}
impl<'de> Deserialize<'de> for HintContent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct HintContentVisitor;
impl<'a> Visitor<'a> for HintContentVisitor {
type Value = HintContent;
fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str("a mapping")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'a>,
{
let mut content = Self::Value::default();
while let Some((key, value)) = map.next_entry::<String, serde_yaml::Value>()? {
match key.as_str() {
"regex" => match Option::<LazyRegex>::deserialize(value) {
Ok(regex) => content.regex = regex,
Err(err) => {
error!(
target: LOG_TARGET_CONFIG,
"Config error: hint's regex: {}", err
);
},
},
"hyperlinks" => match bool::deserialize(value) {
Ok(hyperlink) => content.hyperlinks = hyperlink,
Err(err) => {
error!(
target: LOG_TARGET_CONFIG,
"Config error: hint's hyperlinks: {}", err
);
},
},
_ => (),
}
}
// Require at least one of hyperlinks or regex trigger hint matches.
if content.regex.is_none() && !content.hyperlinks {
return Err(M::Error::custom(
"Config error: At least on of the hint's regex or hint's hyperlinks must \
be set",
));
}
Ok(content)
}
}
deserializer.deserialize_any(HintContentVisitor)
}
}
/// Binding for triggering a keyboard hint.
#[derive(Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
pub struct HintBinding {

View File

@ -1,22 +1,21 @@
use std::borrow::Cow;
use std::cmp::{max, min};
use std::mem;
use std::ops::{Deref, DerefMut, RangeInclusive};
use std::ops::Deref;
use std::{cmp, mem};
use alacritty_terminal::ansi::{Color, CursorShape, NamedColor};
use alacritty_terminal::event::EventListener;
use alacritty_terminal::grid::{Dimensions, Indexed};
use alacritty_terminal::index::{Column, Direction, Line, Point};
use alacritty_terminal::grid::Indexed;
use alacritty_terminal::index::{Column, Line, Point};
use alacritty_terminal::selection::SelectionRange;
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::cell::{Cell, Flags, Hyperlink};
use alacritty_terminal::term::color::{CellRgb, Rgb};
use alacritty_terminal::term::search::{Match, RegexIter, RegexSearch};
use alacritty_terminal::term::search::{Match, RegexSearch};
use alacritty_terminal::term::{self, RenderableContent as TerminalContent, Term, TermMode};
use crate::config::UiConfig;
use crate::display::color::{List, DIM_FACTOR};
use crate::display::hint::HintState;
use crate::display::{Display, MAX_SEARCH_LINES};
use crate::display::hint::{self, HintState};
use crate::display::Display;
use crate::event::SearchState;
/// Minimum contrast between a fixed cursor color and the cell's background.
@ -30,7 +29,7 @@ pub struct RenderableContent<'a> {
cursor: RenderableCursor,
cursor_shape: CursorShape,
cursor_point: Point<usize>,
search: Option<Regex<'a>>,
search: Option<HintMatches<'a>>,
hint: Option<Hint<'a>>,
config: &'a UiConfig,
colors: &'a List,
@ -44,7 +43,7 @@ impl<'a> RenderableContent<'a> {
term: &'a Term<T>,
search_state: &'a SearchState,
) -> Self {
let search = search_state.dfas().map(|dfas| Regex::new(term, dfas));
let search = search_state.dfas().map(|dfas| HintMatches::visible_regex_matches(term, dfas));
let focused_match = search_state.focused_match();
let terminal_content = term.renderable_content();
@ -181,13 +180,21 @@ impl<'a> Iterator for RenderableContent<'a> {
#[derive(Clone, Debug)]
pub struct RenderableCell {
pub character: char,
pub zerowidth: Option<Vec<char>>,
pub point: Point<usize>,
pub fg: Rgb,
pub bg: Rgb,
pub bg_alpha: f32,
pub underline: Rgb,
pub flags: Flags,
pub extra: Option<Box<RenderableCellExtra>>,
}
/// Extra storage with rarely present fields for [`RenderableCell`], to reduce the cell size we
/// pass around.
#[derive(Clone, Debug)]
pub struct RenderableCellExtra {
pub zerowidth: Option<Vec<char>>,
pub hyperlink: Option<Hyperlink>,
}
impl RenderableCell {
@ -257,23 +264,24 @@ impl RenderableCell {
.underline_color()
.map_or(fg, |underline| Self::compute_fg_rgb(content, underline, flags));
RenderableCell {
zerowidth: cell.zerowidth().map(|zerowidth| zerowidth.to_vec()),
flags,
character,
bg_alpha,
point,
fg,
bg,
underline,
}
let zerowidth = cell.zerowidth();
let hyperlink = cell.hyperlink();
let extra = (zerowidth.is_some() || hyperlink.is_some()).then(|| {
Box::new(RenderableCellExtra {
zerowidth: zerowidth.map(|zerowidth| zerowidth.to_vec()),
hyperlink,
})
});
RenderableCell { flags, character, bg_alpha, point, fg, bg, underline, extra }
}
/// Check if cell contains any renderable content.
fn is_empty(&self) -> bool {
self.bg_alpha == 0.
&& self.character == ' '
&& self.zerowidth.is_none()
&& self.extra.is_none()
&& !self.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
}
@ -406,7 +414,7 @@ impl RenderableCursor {
/// Regex hints for keyboard shortcuts.
struct Hint<'a> {
/// Hint matches and position.
regex: Regex<'a>,
matches: HintMatches<'a>,
/// Last match checked against current cell position.
labels: &'a Vec<Vec<char>>,
@ -421,16 +429,15 @@ impl<'a> Hint<'a> {
/// The tuple's [`bool`] will be `true` when the character is the first for this hint.
fn advance(&mut self, viewport_start: Point, point: Point) -> Option<(char, bool)> {
// Check if we're within a match at all.
if !self.regex.advance(point) {
if !self.matches.advance(point) {
return None;
}
// Match starting position on this line; linebreaks interrupt the hint labels.
let start = self
.regex
.matches
.get(self.regex.index)
.map(|regex_match| max(*regex_match.start(), viewport_start))
.get(self.matches.index)
.map(|bounds| cmp::max(*bounds.start(), viewport_start))
.filter(|start| start.line == point.line)?;
// Position within the hint label.
@ -438,85 +445,47 @@ impl<'a> Hint<'a> {
let is_first = label_position == 0;
// Hint label character.
self.labels[self.regex.index].get(label_position).copied().map(|c| (c, is_first))
self.labels[self.matches.index].get(label_position).copied().map(|c| (c, is_first))
}
}
impl<'a> From<&'a HintState> for Hint<'a> {
fn from(hint_state: &'a HintState) -> Self {
let regex = Regex { matches: Cow::Borrowed(hint_state.matches()), index: 0 };
Self { labels: hint_state.labels(), regex }
let matches = HintMatches::new(hint_state.matches());
Self { labels: hint_state.labels(), matches }
}
}
/// Wrapper for finding visible regex matches.
#[derive(Default, Clone)]
pub struct RegexMatches(pub Vec<RangeInclusive<Point>>);
impl RegexMatches {
/// Find all visible matches.
pub fn new<T>(term: &Term<T>, dfas: &RegexSearch) -> Self {
let viewport_start = Line(-(term.grid().display_offset() as i32));
let viewport_end = viewport_start + term.bottommost_line();
// Compute start of the first and end of the last line.
let start_point = Point::new(viewport_start, Column(0));
let mut start = term.line_search_left(start_point);
let end_point = Point::new(viewport_end, term.last_column());
let mut end = term.line_search_right(end_point);
// Set upper bound on search before/after the viewport to prevent excessive blocking.
start.line = max(start.line, viewport_start - MAX_SEARCH_LINES);
end.line = min(end.line, viewport_end + MAX_SEARCH_LINES);
// Create an iterater for the current regex search for all visible matches.
let iter = RegexIter::new(start, end, Direction::Right, term, dfas)
.skip_while(move |rm| rm.end().line < viewport_start)
.take_while(move |rm| rm.start().line <= viewport_end);
Self(iter.collect())
}
}
impl Deref for RegexMatches {
type Target = Vec<RangeInclusive<Point>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for RegexMatches {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
/// Visible regex match tracking.
/// Visible hint match tracking.
#[derive(Default)]
struct Regex<'a> {
struct HintMatches<'a> {
/// All visible matches.
matches: Cow<'a, RegexMatches>,
matches: Cow<'a, [Match]>,
/// Index of the last match checked.
index: usize,
}
impl<'a> Regex<'a> {
/// Create a new renderable regex iterator.
fn new<T>(term: &Term<T>, dfas: &RegexSearch) -> Self {
let matches = Cow::Owned(RegexMatches::new(term, dfas));
Self { index: 0, matches }
impl<'a> HintMatches<'a> {
/// Create new renderable matches iterator..
fn new(matches: impl Into<Cow<'a, [Match]>>) -> Self {
Self { matches: matches.into(), index: 0 }
}
/// Create from regex matches on term visable part.
fn visible_regex_matches<T>(term: &Term<T>, dfas: &RegexSearch) -> Self {
let matches = hint::visible_regex_match_iter(term, dfas).collect::<Vec<_>>();
Self::new(matches)
}
/// Advance the regex tracker to the next point.
///
/// This will return `true` if the point passed is part of a regex match.
fn advance(&mut self, point: Point) -> bool {
while let Some(regex_match) = self.matches.get(self.index) {
if regex_match.start() > &point {
while let Some(bounds) = self.get(self.index) {
if bounds.start() > &point {
break;
} else if regex_match.end() < &point {
} else if bounds.end() < &point {
self.index += 1;
} else {
return true;
@ -525,3 +494,11 @@ impl<'a> Regex<'a> {
false
}
}
impl<'a> Deref for HintMatches<'a> {
type Target = [Match];
fn deref(&self) -> &Self::Target {
self.matches.deref()
}
}

View File

@ -1,16 +1,20 @@
use std::cmp::{max, min};
use std::cmp::Reverse;
use std::collections::HashSet;
use std::iter;
use glutin::event::ModifiersState;
use alacritty_terminal::grid::BidirectionalIterator;
use alacritty_terminal::index::{Boundary, Direction, Point};
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;
use crate::display::content::RegexMatches;
use crate::display::MAX_SEARCH_LINES;
/// 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;
@ -24,7 +28,7 @@ pub struct HintState {
alphabet: String,
/// Visible matches.
matches: RegexMatches,
matches: Vec<Match>,
/// Key label for each visible match.
labels: Vec<Vec<char>>,
@ -70,20 +74,29 @@ impl HintState {
None => return,
};
// Find visible matches.
self.matches.0 = hint.regex.with_compiled(|regex| {
let mut matches = RegexMatches::new(term, regex);
// Clear current matches.
self.matches.clear();
// Apply post-processing and search for sub-matches if necessary.
if hint.post_processing {
matches
.drain(..)
.flat_map(|rm| HintPostProcessor::new(term, regex, rm).collect::<Vec<_>>())
.collect()
} else {
matches.0
}
});
// 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::<Vec<_>>()
}));
} else {
self.matches.extend(matches);
}
});
}
// Cancel highlight with no visible matches.
if self.matches.is_empty() {
@ -91,6 +104,10 @@ impl HintState {
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();
@ -135,7 +152,9 @@ impl HintState {
self.stop();
Some(HintMatch { action, bounds })
// 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);
@ -150,7 +169,7 @@ impl HintState {
}
/// Visible hint regex matches.
pub fn matches(&self) -> &RegexMatches {
pub fn matches(&self) -> &[Match] {
&self.matches
}
@ -167,10 +186,33 @@ impl HintState {
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct HintMatch {
/// Action for handling the text.
pub action: HintAction,
action: HintAction,
/// Terminal range matching the hint.
pub bounds: Match,
bounds: Match,
hyperlink: Option<Hyperlink>,
}
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.
@ -238,6 +280,77 @@ impl HintLabels {
}
}
/// 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_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<T>(term: &Term<T>) -> impl Iterator<Item = Match> + '_ {
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<T>(
term: &Term<T>,
point: Point,
regex: &RegexSearch,
post_processing: bool,
) -> Option<Match> {
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<T>(
term: &Term<T>,
@ -258,32 +371,75 @@ pub fn highlighted_at<T>(
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);
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),
});
}
// Function to verify that the specified point is inside the match.
let at_point = |rm: &Match| *rm.end() >= point && *rm.start() <= point;
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 });
}
// Check if there's any match at the specified point.
let mut iter = RegexIter::new(start, end, Direction::Right, term, regex);
let regex_match = iter.find(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 })
})
None
})
}
/// Retrieve the hyperlink with its range, if there is one at the specified point.
fn hyperlink_at<T>(term: &Term<T>, 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.

View File

@ -16,7 +16,6 @@ use glutin::Rect as DamageRect;
use log::{debug, info};
use parking_lot::MutexGuard;
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthChar;
#[cfg(all(feature = "wayland", not(any(target_os = "macos", windows))))]
use wayland_client::EventQueue;
@ -49,6 +48,7 @@ use crate::event::{Mouse, SearchState};
use crate::message_bar::{MessageBuffer, MessageType};
use crate::renderer::rects::{RenderLines, RenderRect};
use crate::renderer::{self, GlyphCache, Renderer};
use crate::string::{ShortenDirection, StrShortener};
pub mod content;
pub mod cursor;
@ -60,15 +60,15 @@ mod color;
mod damage;
mod meter;
/// 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: ";
/// The character used to shorten the visible text like uri preview or search regex.
const SHORTENER: char = '…';
/// Color which is used to highlight damaged rects when debugging.
const DAMAGE_RECT_COLOR: Rgb = Rgb { r: 255, g: 0, b: 255 };
@ -362,9 +362,13 @@ pub struct Display {
/// The renderer update that takes place only once before the actual rendering.
pub pending_renderer_update: Option<RendererUpdate>,
// Mouse point position when highlighting hints.
hint_mouse_point: Option<Point>,
is_damage_supported: bool,
debug_damage: bool,
damage_rects: Vec<DamageRect>,
next_frame_damage_rects: Vec<DamageRect>,
renderer: Renderer,
glyph_cache: GlyphCache,
meter: Meter,
@ -515,10 +519,11 @@ impl Display {
let hint_state = HintState::new(config.hints.alphabet());
let is_damage_supported = window.swap_buffers_with_damage_supported();
let debug_damage = config.debug.highlight_damage;
let damage_rects = if is_damage_supported || debug_damage {
Vec::with_capacity(size_info.screen_lines())
let (damage_rects, next_frame_damage_rects) = if is_damage_supported || debug_damage {
let vec = Vec::with_capacity(size_info.screen_lines());
(vec.clone(), vec)
} else {
Vec::new()
(Vec::new(), Vec::new())
};
Ok(Self {
@ -540,6 +545,8 @@ impl Display {
is_damage_supported,
debug_damage,
damage_rects,
next_frame_damage_rects,
hint_mouse_point: None,
})
}
@ -757,7 +764,7 @@ impl Display {
let size_info = self.size_info;
let vi_mode = terminal.mode().contains(TermMode::VI);
let vi_mode_cursor = if vi_mode { Some(terminal.vi_mode_cursor) } else { None };
let vi_cursor_point = if vi_mode { Some(terminal.vi_mode_cursor.point) } else { None };
if self.collect_damage() {
self.update_damage(&mut terminal, selection_range, search_state);
@ -772,6 +779,10 @@ impl Display {
self.renderer.clear(background_color, config.window_opacity());
let mut lines = RenderLines::new();
// Optimize loop hint comparator.
let has_highlighted_hint =
self.highlighted_hint.is_some() || self.vi_highlighted_hint.is_some();
// Draw grid.
{
let _sampler = self.meter.sampler();
@ -783,16 +794,26 @@ impl Display {
let glyph_cache = &mut self.glyph_cache;
let highlighted_hint = &self.highlighted_hint;
let vi_highlighted_hint = &self.vi_highlighted_hint;
self.renderer.draw_cells(
&size_info,
glyph_cache,
grid_cells.into_iter().map(|mut cell| {
// Underline hints hovered by mouse or vi mode cursor.
let point = term::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);
if has_highlighted_hint {
let hyperlink =
cell.extra.as_ref().and_then(|extra| extra.hyperlink.as_ref());
if highlighted_hint
.as_ref()
.map_or(false, |hint| hint.should_highlight(point, hyperlink))
|| vi_highlighted_hint
.as_ref()
.map_or(false, |hint| hint.should_highlight(point, hyperlink))
{
cell.flags.insert(Flags::UNDERLINE);
}
}
// Update underline/strikeout.
@ -805,18 +826,17 @@ impl Display {
let mut rects = lines.rects(&metrics, &size_info);
if let Some(vi_mode_cursor) = vi_mode_cursor {
if let Some(vi_cursor_point) = vi_cursor_point {
// 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;
let obstructed_column = Some(vi_point)
let line = (-vi_cursor_point.line.0 + size_info.bottommost_line().0) as usize;
let obstructed_column = Some(vi_cursor_point)
.filter(|point| point.line == -(display_offset as i32))
.map(|point| point.column);
self.draw_line_indicator(config, &size_info, total_lines, obstructed_column, line);
self.draw_line_indicator(config, total_lines, obstructed_column, line);
} else if search_state.regex().is_some() {
// Show current display offset in vi-less search to indicate match position.
self.draw_line_indicator(config, &size_info, total_lines, None, display_offset);
}
self.draw_line_indicator(config, total_lines, None, display_offset);
};
// Draw cursor.
for rect in cursor.rects(&size_info, config.terminal_config.cursor.thickness()) {
@ -868,14 +888,21 @@ impl Display {
let fg = config.colors.primary.background;
for (i, message_text) in text.iter().enumerate() {
let point = Point::new(start_line + i, Column(0));
self.renderer.draw_string(point, fg, bg, message_text, &size_info, glyph_cache);
self.renderer.draw_string(
point,
fg,
bg,
message_text.chars(),
&size_info,
glyph_cache,
);
}
} else {
// Draw rectangles.
self.renderer.draw_rects(&size_info, &metrics, rects);
}
self.draw_render_timer(config, &size_info);
self.draw_render_timer(config);
// Handle search and IME positioning.
let ime_position = match search_state.regex() {
@ -885,10 +912,10 @@ impl Display {
Direction::Left => BACKWARD_SEARCH_LABEL,
};
let search_text = Self::format_search(&size_info, regex, search_label);
let search_text = Self::format_search(regex, search_label, size_info.columns());
// Render the search bar.
self.draw_search(config, &size_info, &search_text);
self.draw_search(config, &search_text);
// Compute IME position.
let line = Line(size_info.screen_lines() as i32 + 1);
@ -897,6 +924,11 @@ impl Display {
None => cursor_point,
};
// Draw hyperlink uri preview.
if has_highlighted_hint {
self.draw_hyperlink_preview(config, vi_cursor_point, display_offset);
}
// Update IME position.
self.window.update_ime_position(ime_position, &self.size_info);
@ -921,6 +953,9 @@ impl Display {
}
self.damage_rects.clear();
// Append damage rects we've enqueued for the next frame.
mem::swap(&mut self.damage_rects, &mut self.next_frame_damage_rects);
}
/// Update to a new configuration.
@ -964,8 +999,13 @@ impl Display {
// Update cursor shape.
if highlighted_hint.is_some() {
// If mouse changed the line, we should update the hyperlink preview, since the
// highlighted hint could be disrupted by the old preview.
dirty = self.hint_mouse_point.map(|p| p.line != point.line).unwrap_or(false);
self.hint_mouse_point = Some(point);
self.window.set_mouse_cursor(CursorIcon::Hand);
} else if self.highlighted_hint.is_some() {
self.hint_mouse_point = None;
if term.mode().intersects(TermMode::MOUSE_MODE) && !term.mode().contains(TermMode::VI) {
self.window.set_mouse_cursor(CursorIcon::Default);
} else {
@ -980,74 +1020,148 @@ impl Display {
}
/// 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.
let mut formatted_regex = String::with_capacity(search_regex.len());
for c in search_regex.chars() {
formatted_regex.push(c);
if c.width() == Some(2) {
formatted_regex.push(' ');
}
fn format_search(search_regex: &str, search_label: &str, max_width: usize) -> String {
let label_len = search_label.len();
// Skip `search_regex` formatting if only label is visible.
if label_len > max_width {
return search_label[..max_width].to_owned();
}
// Add cursor to show whitespace.
formatted_regex.push('_');
// The search string consists of `search_label` + `search_regex` + `cursor`.
let mut bar_text = String::from(search_label);
bar_text.extend(StrShortener::new(
search_regex,
max_width.wrapping_sub(label_len + 1),
ShortenDirection::Left,
Some(SHORTENER),
));
// Truncate beginning of the search regex if it exceeds the viewport width.
let num_cols = size_info.columns();
let label_len = search_label.chars().count();
let regex_len = formatted_regex.chars().count();
let truncate_len = cmp::min((regex_len + label_len).saturating_sub(num_cols), regex_len);
let index = formatted_regex.char_indices().nth(truncate_len).map(|(i, _c)| i).unwrap_or(0);
let truncated_regex = &formatted_regex[index..];
// Add search label to the beginning of the search regex.
let mut bar_text = format!("{}{}", search_label, truncated_regex);
// Make sure the label alone doesn't exceed the viewport width.
bar_text.truncate(num_cols);
bar_text.push('_');
bar_text
}
/// Draw current search regex.
fn draw_search(&mut self, config: &UiConfig, size_info: &SizeInfo, text: &str) {
let glyph_cache = &mut self.glyph_cache;
let num_cols = size_info.columns();
/// Draw preview for the currently highlighted `Hyperlink`.
#[inline(never)]
fn draw_hyperlink_preview(
&mut self,
config: &UiConfig,
vi_cursor_point: Option<Point>,
display_offset: usize,
) {
let num_cols = self.size_info.columns();
let uris: Vec<_> = self
.highlighted_hint
.iter()
.chain(&self.vi_highlighted_hint)
.filter_map(|hint| hint.hyperlink().map(|hyperlink| hyperlink.uri()))
.map(|uri| StrShortener::new(uri, num_cols, ShortenDirection::Right, Some(SHORTENER)))
.collect();
if uris.is_empty() {
return;
}
// The maximum amount of protected lines including the ones we'll show preview on.
let max_protected_lines = uris.len() * 2;
// Lines we shouldn't shouldn't show preview on, because it'll obscure the highlighted
// hint.
let mut protected_lines = Vec::with_capacity(max_protected_lines);
if self.size_info.screen_lines() >= max_protected_lines {
// Prefer to show preview even when it'll likely obscure the highlighted hint, when
// there's no place left for it.
protected_lines.push(self.hint_mouse_point.map(|point| point.line));
protected_lines.push(vi_cursor_point.map(|point| point.line));
}
// Find the line in viewport we can draw preview on without obscuring protected lines.
let viewport_bottom = self.size_info.bottommost_line() - Line(display_offset as i32);
let viewport_top = viewport_bottom - (self.size_info.screen_lines() - 1);
let uri_lines = (viewport_top.0..=viewport_bottom.0)
.rev()
.map(|line| Some(Line(line)))
.filter_map(|line| {
if protected_lines.contains(&line) {
None
} else {
protected_lines.push(line);
line
}
})
.take(uris.len())
.flat_map(|line| term::point_to_viewport(display_offset, Point::new(line, Column(0))));
let fg = config.colors.footer_bar_foreground();
let bg = config.colors.footer_bar_background();
for (uri, point) in uris.into_iter().zip(uri_lines) {
// Damage the uri preview.
if self.collect_damage() {
let uri_preview_damage = self.damage_from_point(point, num_cols as u32);
self.damage_rects.push(uri_preview_damage);
// Damage the uri preview for the next frame as well.
self.next_frame_damage_rects.push(uri_preview_damage);
}
self.renderer.draw_string(point, fg, bg, uri, &self.size_info, &mut self.glyph_cache);
}
}
/// Draw current search regex.
#[inline(never)]
fn draw_search(&mut self, config: &UiConfig, text: &str) {
// Assure text length is at least num_cols.
let num_cols = self.size_info.columns();
let text = format!("{:<1$}", text, num_cols);
let point = Point::new(size_info.screen_lines(), Column(0));
let fg = config.colors.search_bar_foreground();
let bg = config.colors.search_bar_background();
let point = Point::new(self.size_info.screen_lines(), Column(0));
self.renderer.draw_string(point, fg, bg, &text, size_info, glyph_cache);
let fg = config.colors.footer_bar_foreground();
let bg = config.colors.footer_bar_background();
self.renderer.draw_string(
point,
fg,
bg,
text.chars(),
&self.size_info,
&mut self.glyph_cache,
);
}
/// Draw render timer.
fn draw_render_timer(&mut self, config: &UiConfig, size_info: &SizeInfo) {
#[inline(never)]
fn draw_render_timer(&mut self, config: &UiConfig) {
if !config.debug.render_timer {
return;
}
let timing = format!("{:.3} usec", self.meter.average());
let point = Point::new(size_info.screen_lines().saturating_sub(2), Column(0));
let point = Point::new(self.size_info.screen_lines().saturating_sub(2), Column(0));
let fg = config.colors.primary.background;
let bg = config.colors.normal.red;
// Damage the entire line.
self.damage_from_point(point, self.size_info.columns() as u32);
if self.collect_damage() {
// Damage the entire line.
let render_timer_damage =
self.damage_from_point(point, self.size_info.columns() as u32);
self.damage_rects.push(render_timer_damage);
// Damage the render timer for the next frame.
self.next_frame_damage_rects.push(render_timer_damage)
}
let glyph_cache = &mut self.glyph_cache;
self.renderer.draw_string(point, fg, bg, &timing, size_info, glyph_cache);
self.renderer.draw_string(point, fg, bg, timing.chars(), &self.size_info, glyph_cache);
}
/// Draw an indicator for the position of a line in history.
#[inline(never)]
fn draw_line_indicator(
&mut self,
config: &UiConfig,
size_info: &SizeInfo,
total_lines: usize,
obstructed_column: Option<Column>,
line: usize,
@ -1064,14 +1178,16 @@ impl Display {
}
let text = format!("[{}/{}]", line, total_lines - 1);
let column = Column(size_info.columns().saturating_sub(text.len()));
let column = Column(self.size_info.columns().saturating_sub(text.len()));
let point = Point::new(0, column);
// Damage the maximum possible length of the format text, which could be achieved when
// using `MAX_SCROLLBACK_LINES` as current and total lines adding a `3` for formatting.
const MAX_SIZE: usize = 2 * num_digits(MAX_SCROLLBACK_LINES) + 3;
let damage_point = Point::new(0, Column(size_info.columns().saturating_sub(MAX_SIZE)));
self.damage_from_point(damage_point, MAX_SIZE as u32);
let damage_point = Point::new(0, Column(self.size_info.columns().saturating_sub(MAX_SIZE)));
if self.collect_damage() {
self.damage_rects.push(self.damage_from_point(damage_point, MAX_SIZE as u32));
}
let colors = &config.colors;
let fg = colors.line_indicator.foreground.unwrap_or(colors.primary.background);
@ -1080,23 +1196,20 @@ impl Display {
// Do not render anything if it would obscure the vi mode cursor.
if obstructed_column.map_or(true, |obstructed_column| obstructed_column < column) {
let glyph_cache = &mut self.glyph_cache;
self.renderer.draw_string(point, fg, bg, &text, size_info, glyph_cache);
self.renderer.draw_string(point, fg, bg, text.chars(), &self.size_info, glyph_cache);
}
}
/// Damage `len` starting from a `point`.
#[inline]
fn damage_from_point(&mut self, point: Point<usize>, len: u32) {
if !self.collect_damage() {
return;
}
///
/// This method also enqueues damage for the next frame automatically.
fn damage_from_point(&self, point: Point<usize>, len: u32) -> DamageRect {
let size_info: SizeInfo<u32> = self.size_info.into();
let x = size_info.padding_x() + point.column.0 as u32 * size_info.cell_width();
let y_top = size_info.height() - size_info.padding_y();
let y = y_top - (point.line as u32 + 1) * size_info.cell_height();
let width = len as u32 * size_info.cell_width();
self.damage_rects.push(DamageRect { x, y, width, height: size_info.cell_height() })
DamageRect { x, y, width, height: size_info.cell_height() }
}
/// Damage currently highlighted `Display` hints.
@ -1105,10 +1218,12 @@ impl Display {
let display_offset = terminal.grid().display_offset();
let last_visible_line = terminal.screen_lines() - 1;
for hint in self.highlighted_hint.iter().chain(&self.vi_highlighted_hint) {
for point in (hint.bounds.start().line.0..=hint.bounds.end().line.0).flat_map(|line| {
term::point_to_viewport(display_offset, Point::new(Line(line), Column(0)))
.filter(|point| point.line <= last_visible_line)
}) {
for point in
(hint.bounds().start().line.0..=hint.bounds().end().line.0).flat_map(|line| {
term::point_to_viewport(display_offset, Point::new(Line(line), Column(0)))
.filter(|point| point.line <= last_visible_line)
})
{
terminal.damage_line(point.line, 0, terminal.columns() - 1);
}
}

View File

@ -679,28 +679,31 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
return;
}
match &hint.action {
let hint_bounds = hint.bounds();
let text = match hint.hyperlink() {
Some(hyperlink) => hyperlink.uri().to_owned(),
None => self.terminal.bounds_to_string(*hint_bounds.start(), *hint_bounds.end()),
};
match &hint.action() {
// Launch an external program.
HintAction::Command(command) => {
let text = self.terminal.bounds_to_string(*hint.bounds.start(), *hint.bounds.end());
let mut args = command.args().to_vec();
args.push(text);
self.spawn_daemon(command.program(), &args);
},
// Copy the text to the clipboard.
HintAction::Action(HintInternalAction::Copy) => {
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(*hint.bounds.start(), *hint.bounds.end());
self.paste(&text);
},
// Select the text.
HintAction::Action(HintInternalAction::Select) => {
self.start_selection(SelectionType::Simple, *hint.bounds.start(), Side::Left);
self.update_selection(*hint.bounds.end(), Side::Right);
self.start_selection(SelectionType::Simple, *hint_bounds.start(), Side::Left);
self.update_selection(*hint_bounds.end(), Side::Right);
self.copy_selection(ClipboardType::Selection);
},
// Move the vi mode cursor.
@ -710,7 +713,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
self.terminal.toggle_vi_mode();
}
self.terminal.vi_goto_point(*hint.bounds.start());
self.terminal.vi_goto_point(*hint_bounds.start());
},
}
}

View File

@ -922,9 +922,10 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
fn cursor_state(&mut self) -> CursorIcon {
let display_offset = self.ctx.terminal().grid().display_offset();
let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset);
let hyperlink = self.ctx.terminal().grid()[point].hyperlink();
// Function to check if mouse is on top of a hint.
let hint_highlighted = |hint: &HintMatch| hint.bounds.contains(&point);
let hint_highlighted = |hint: &HintMatch| hint.should_highlight(point, hyperlink.as_ref());
if let Some(mouse_state) = self.message_bar_cursor_state() {
mouse_state

View File

@ -46,6 +46,7 @@ mod message_bar;
mod panic;
mod renderer;
mod scheduler;
mod string;
mod window_context;
mod gl {

View File

@ -125,14 +125,14 @@ impl Renderer {
point: Point<usize>,
fg: Rgb,
bg: Rgb,
string: &str,
string_chars: impl Iterator<Item = char>,
size_info: &SizeInfo,
glyph_cache: &mut GlyphCache,
) {
let cells = string.chars().enumerate().map(|(i, character)| RenderableCell {
let cells = string_chars.enumerate().map(|(i, character)| RenderableCell {
point: Point::new(point.line, point.column + i),
character,
zerowidth: None,
extra: None,
flags: Flags::empty(),
bg_alpha: 1.0,
fg,

View File

@ -159,7 +159,9 @@ pub trait TextRenderApi<T: TextRenderBatch>: LoadGlyph {
self.add_render_item(&cell, &glyph, size_info);
// Render visible zero-width characters.
if let Some(zerowidth) = cell.zerowidth.take().filter(|_| !hidden) {
if let Some(zerowidth) =
cell.extra.as_mut().and_then(|extra| extra.zerowidth.take().filter(|_| !hidden))
{
for character in zerowidth {
glyph_key.character = character;
let glyph = glyph_cache.get(glyph_key, self, false);

314
alacritty/src/string.rs Normal file
View File

@ -0,0 +1,314 @@
use std::cmp::Ordering;
use std::iter::Skip;
use std::str::Chars;
use unicode_width::UnicodeWidthChar;
/// The action performed by [`StrShortener`].
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TextAction {
/// Yield a spacer.
Spacer,
/// Terminate state reached.
Terminate,
/// Yield a shortener.
Shortener,
/// Yield a character.
Char,
}
/// The direction which we should shorten.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ShortenDirection {
/// Shorten to the start of the string.
Left,
/// Shorten to the end of the string.
Right,
}
/// Iterator that yield shortened version of the text.
pub struct StrShortener<'a> {
chars: Skip<Chars<'a>>,
accumulted_len: usize,
max_width: usize,
direction: ShortenDirection,
shortener: Option<char>,
text_action: TextAction,
}
impl<'a> StrShortener<'a> {
pub fn new(
text: &'a str,
max_width: usize,
direction: ShortenDirection,
mut shortener: Option<char>,
) -> Self {
if text.is_empty() {
// If we don't have any text don't produce a shortener for it.
let _ = shortener.take();
}
if direction == ShortenDirection::Right {
return Self {
chars: text.chars().skip(0),
accumulted_len: 0,
text_action: TextAction::Char,
max_width,
direction,
shortener,
};
}
let mut offset = 0;
let mut current_len = 0;
let mut iter = text.chars().rev().enumerate();
while let Some((idx, ch)) = iter.next() {
let ch_width = ch.width().unwrap_or(1);
current_len += ch_width;
match current_len.cmp(&max_width) {
// We can only be here if we've faced wide character or we've already
// handled equality situation. Anyway, break.
Ordering::Greater => break,
Ordering::Equal => {
if shortener.is_some() && iter.clone().next().is_some() {
// We have one more character after, shortener will accumulate for
// the `current_len`.
break;
} else {
// The match is exact, consume shortener.
let _ = shortener.take();
}
},
Ordering::Less => (),
}
offset = idx + 1;
}
// Consume the iterator to count the number of characters in it.
let num_chars = iter.last().map(|(idx, _)| idx + 1).unwrap_or(offset);
let skip_chars = num_chars - offset;
let text_action = if num_chars <= max_width || shortener.is_none() {
TextAction::Char
} else {
TextAction::Shortener
};
let chars = text.chars().skip(skip_chars);
Self { chars, accumulted_len: 0, text_action, max_width, direction, shortener }
}
}
impl<'a> Iterator for StrShortener<'a> {
type Item = char;
fn next(&mut self) -> Option<Self::Item> {
match self.text_action {
TextAction::Spacer => {
self.text_action = TextAction::Char;
Some(' ')
},
TextAction::Terminate => {
// We've reached the termination state.
None
},
TextAction::Shortener => {
// When we shorten from the left we yield the shortener first and process the rest.
self.text_action = if self.direction == ShortenDirection::Left {
TextAction::Char
} else {
TextAction::Terminate
};
// Consume the shortener to avoid yielding it later when shortening left.
self.shortener.take()
},
TextAction::Char => {
let ch = self.chars.next()?;
let ch_width = ch.width().unwrap_or(1);
// Advance width.
self.accumulted_len += ch_width;
if self.accumulted_len > self.max_width {
self.text_action = TextAction::Terminate;
return self.shortener;
} else if self.accumulted_len == self.max_width && self.shortener.is_some() {
// Check if we have a next char.
let has_next = self.chars.clone().next().is_some();
// We should terminate after that.
self.text_action = TextAction::Terminate;
return has_next.then(|| self.shortener.unwrap()).or(Some(ch));
}
// Add a spacer for wide character.
if ch_width == 2 {
self.text_action = TextAction::Spacer;
}
Some(ch)
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn into_shortened_with_shortener() {
let s = "Hello";
let len = s.chars().count();
assert_eq!(
"",
StrShortener::new("", 1, ShortenDirection::Left, Some('.')).collect::<String>()
);
assert_eq!(
".",
StrShortener::new(s, 1, ShortenDirection::Right, Some('.')).collect::<String>()
);
assert_eq!(
".",
StrShortener::new(s, 1, ShortenDirection::Left, Some('.')).collect::<String>()
);
assert_eq!(
"H.",
StrShortener::new(s, 2, ShortenDirection::Right, Some('.')).collect::<String>()
);
assert_eq!(
".o",
StrShortener::new(s, 2, ShortenDirection::Left, Some('.')).collect::<String>()
);
assert_eq!(
s,
&StrShortener::new(s, len * 2, ShortenDirection::Right, Some('.')).collect::<String>()
);
assert_eq!(
s,
&StrShortener::new(s, len * 2, ShortenDirection::Left, Some('.')).collect::<String>()
);
let s = "こJんにちはP";
let len = 2 + 1 + 2 + 2 + 2 + 2 + 1;
assert_eq!(
".",
&StrShortener::new(s, 1, ShortenDirection::Right, Some('.')).collect::<String>()
);
assert_eq!(
&".",
&StrShortener::new(s, 1, ShortenDirection::Left, Some('.')).collect::<String>()
);
assert_eq!(
".",
&StrShortener::new(s, 2, ShortenDirection::Right, Some('.')).collect::<String>()
);
assert_eq!(
".P",
&StrShortener::new(s, 2, ShortenDirection::Left, Some('.')).collect::<String>()
);
assert_eq!(
"こ .",
&StrShortener::new(s, 3, ShortenDirection::Right, Some('.')).collect::<String>()
);
assert_eq!(
".P",
&StrShortener::new(s, 3, ShortenDirection::Left, Some('.')).collect::<String>()
);
assert_eq!(
"こ Jん に ち は P",
&StrShortener::new(s, len * 2, ShortenDirection::Left, Some('.')).collect::<String>()
);
assert_eq!(
"こ Jん に ち は P",
&StrShortener::new(s, len * 2, ShortenDirection::Right, Some('.')).collect::<String>()
);
}
#[test]
fn into_shortened_without_shortener() {
let s = "Hello";
assert_eq!("", StrShortener::new("", 1, ShortenDirection::Left, None).collect::<String>());
assert_eq!(
"H",
&StrShortener::new(s, 1, ShortenDirection::Right, None).collect::<String>()
);
assert_eq!("o", &StrShortener::new(s, 1, ShortenDirection::Left, None).collect::<String>());
assert_eq!(
"He",
&StrShortener::new(s, 2, ShortenDirection::Right, None).collect::<String>()
);
assert_eq!(
"lo",
&StrShortener::new(s, 2, ShortenDirection::Left, None).collect::<String>()
);
assert_eq!(
&s,
&StrShortener::new(s, s.len(), ShortenDirection::Right, None).collect::<String>()
);
assert_eq!(
&s,
&StrShortener::new(s, s.len(), ShortenDirection::Left, None).collect::<String>()
);
let s = "こJんにちはP";
let len = 2 + 1 + 2 + 2 + 2 + 2 + 1;
assert_eq!("", &StrShortener::new(s, 1, ShortenDirection::Right, None).collect::<String>());
assert_eq!("P", &StrShortener::new(s, 1, ShortenDirection::Left, None).collect::<String>());
assert_eq!(
"",
&StrShortener::new(s, 2, ShortenDirection::Right, None).collect::<String>()
);
assert_eq!("P", &StrShortener::new(s, 2, ShortenDirection::Left, None).collect::<String>());
assert_eq!(
"こ J",
&StrShortener::new(s, 3, ShortenDirection::Right, None).collect::<String>()
);
assert_eq!(
"は P",
&StrShortener::new(s, 3, ShortenDirection::Left, None).collect::<String>()
);
assert_eq!(
"こ Jん に ち は P",
&StrShortener::new(s, len, ShortenDirection::Left, None).collect::<String>()
);
assert_eq!(
"こ Jん に ち は P",
&StrShortener::new(s, len, ShortenDirection::Right, None).collect::<String>()
);
}
}

View File

@ -12,6 +12,7 @@ use vte::{Params, ParamsIter};
use alacritty_config_derive::ConfigDeserialize;
use crate::index::{Column, Line};
use crate::term::cell::Hyperlink;
use crate::term::color::Rgb;
/// Maximum time before a synchronized update is aborted.
@ -457,6 +458,9 @@ pub trait Handler {
/// Report text area size in characters.
fn text_area_size_chars(&mut self) {}
/// Set hyperlink.
fn set_hyperlink(&mut self, _: Option<Hyperlink>) {}
}
/// Terminal cursor configuration.
@ -1007,6 +1011,27 @@ where
}
},
// Hyperlink.
b"8" if params.len() > 2 => {
let link_params = params[1];
let uri = str::from_utf8(params[2]).unwrap_or_default();
// The OSC 8 escape sequence must be stopped when getting an empty `uri`.
if uri.is_empty() {
self.handler.set_hyperlink(None);
return;
}
// Link parameters are in format of `key1=value1:key2=value2`. Currently only key
// `id` is defined.
let id = link_params
.split(|&b| b == b':')
.find_map(|kv| kv.strip_prefix(b"id="))
.and_then(|kv| str::from_utf8(kv).ok());
self.handler.set_hyperlink(Some(Hyperlink::new(id, uri)));
},
// Get/set Foreground, Background, Cursor colors.
b"10" | b"11" | b"12" => {
if params.len() >= 2 {

View File

@ -171,6 +171,16 @@ impl<T> Row<T> {
}
}
impl<'a, T> IntoIterator for &'a Row<T> {
type IntoIter = slice::Iter<'a, T>;
type Item = &'a T;
#[inline]
fn into_iter(self) -> slice::Iter<'a, T> {
self.inner.iter()
}
}
impl<'a, T> IntoIterator for &'a mut Row<T> {
type IntoIter = slice::IterMut<'a, T>;
type Item = &'a mut T;

View File

@ -1,3 +1,4 @@
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use bitflags::bitflags;
@ -33,6 +34,53 @@ bitflags! {
}
}
/// Counter for hyperlinks without explicit ID.
static HYPERLINK_ID_SUFFIX: AtomicU32 = AtomicU32::new(0);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct Hyperlink {
inner: Arc<HyperlinkInner>,
}
impl Hyperlink {
pub fn new<T: ToString>(id: Option<T>, uri: T) -> Self {
let inner = Arc::new(HyperlinkInner::new(id, uri));
Self { inner }
}
pub fn id(&self) -> &str {
&self.inner.id
}
pub fn uri(&self) -> &str {
&self.inner.uri
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
struct HyperlinkInner {
/// Identifier for the given hyperlink.
id: String,
/// Resource identifier of the hyperlink.
uri: String,
}
impl HyperlinkInner {
pub fn new<T: ToString>(id: Option<T>, uri: T) -> Self {
let id = match id {
Some(id) => id.to_string(),
None => {
let mut id = HYPERLINK_ID_SUFFIX.fetch_add(1, Ordering::Relaxed).to_string();
id.push_str("_alacritty");
id
},
};
Self { id, uri: uri.to_string() }
}
}
/// Trait for determining if a reset should be performed.
pub trait ResetDiscriminant<T> {
/// Value based on which equality for the reset will be determined.
@ -61,6 +109,8 @@ pub struct CellExtra {
zerowidth: Vec<char>,
underline_color: Option<Color>,
hyperlink: Option<Hyperlink>,
}
/// Content and attributes of a single cell in the terminal grid.
@ -113,14 +163,17 @@ impl Cell {
/// Set underline color on the cell.
pub fn set_underline_color(&mut self, color: Option<Color>) {
// If we reset color and we don't have zerowidth we should drop extra storage.
if color.is_none() && self.extra.as_ref().map_or(true, |extra| !extra.zerowidth.is_empty())
if color.is_none()
&& self
.extra
.as_ref()
.map_or(true, |extra| !extra.zerowidth.is_empty() || extra.hyperlink.is_some())
{
self.extra = None;
return;
} else {
let extra = self.extra.get_or_insert(Default::default());
Arc::make_mut(extra).underline_color = color;
}
let extra = self.extra.get_or_insert(Default::default());
Arc::make_mut(extra).underline_color = color;
}
/// Underline color stored in this cell.
@ -128,6 +181,27 @@ impl Cell {
pub fn underline_color(&self) -> Option<Color> {
self.extra.as_ref()?.underline_color
}
/// Set hyperlink.
pub fn set_hyperlink(&mut self, hyperlink: Option<Hyperlink>) {
let should_drop = hyperlink.is_none()
&& self.extra.as_ref().map_or(true, |extra| {
!extra.zerowidth.is_empty() || extra.underline_color.is_some()
});
if should_drop {
self.extra = None;
} else {
let extra = self.extra.get_or_insert(Default::default());
Arc::make_mut(extra).hyperlink = hyperlink;
}
}
/// Hyperlink stored in this cell.
#[inline]
pub fn hyperlink(&self) -> Option<Hyperlink> {
self.extra.as_ref()?.hyperlink.clone()
}
}
impl GridCell for Cell {

View File

@ -16,7 +16,7 @@ use crate::event::{Event, EventListener};
use crate::grid::{Dimensions, Grid, GridIterator, Scroll};
use crate::index::{self, Boundary, Column, Direction, Line, Point, Side};
use crate::selection::{Selection, SelectionRange, SelectionType};
use crate::term::cell::{Cell, Flags, LineLength};
use crate::term::cell::{Cell, Flags, Hyperlink, LineLength};
use crate::term::color::{Colors, Rgb};
use crate::vi_mode::{ViModeCursor, ViMotion};
@ -1679,6 +1679,12 @@ impl<T: EventListener> Handler for Term<T> {
}
}
#[inline]
fn set_hyperlink(&mut self, hyperlink: Option<Hyperlink>) {
trace!("Setting hyperlink: {:?}", hyperlink);
self.grid.cursor.template.set_hyperlink(hyperlink);
}
/// Set a terminal attribute.
#[inline]
fn terminal_attribute(&mut self, attr: Attr) {

View File

@ -28,15 +28,36 @@ macro_rules! ref_tests {
}
ref_tests! {
alt_reset
clear_underline
colored_reset
colored_underline
csi_rep
decaln_reset
deccolm_reset
delete_chars_reset
delete_lines
erase_chars_reset
fish_cc
grid_reset
history
hyperlinks
indexed_256_colors
insert_blank_reset
issue_855
ll
newline_with_cursor_beyond_scroll_region
region_scroll_down
row_reset
saved_cursor
saved_cursor_alt
scroll_up_reset
selective_erasure
sgr
tab_rendering
tmux_git_log
tmux_htop
underline
vim_24bitcolors_bce
vim_large_window_scroll
vim_simple_edit
@ -46,29 +67,9 @@ ref_tests! {
vttest_origin_mode_2
vttest_scroll
vttest_tab_clear_set
zsh_tab_completion
history
grid_reset
row_reset
zerowidth
selective_erasure
colored_reset
colored_underline
delete_lines
delete_chars_reset
alt_reset
deccolm_reset
decaln_reset
insert_blank_reset
erase_chars_reset
scroll_up_reset
clear_underline
region_scroll_down
wrapline_alt_toggle
saved_cursor
saved_cursor_alt
sgr
underline
zerowidth
zsh_tab_completion
}
fn read_u8<P>(path: P) -> Vec<u8>

View File

@ -0,0 +1,10 @@
[?2004hsh-5.1$ printf '\e]8;;https://example.com\e\\foo\e]8;;\e\\\n' printf '\e]8;;https://example.com\e\\foo\e]8;;\e\\\n'
[?2004l ]8;;https://example.com\foo]8;;\
[?2004hsh-5.1$ printf '\e]8;;https://example.com\e\\foo\e]8;;\e\\\n' printf '\e]8;;https://example.com\e\\foo\e]8;;\e\\\n'
[?2004l ]8;;https://example.com\foo]8;;\
[?2004hsh-5.1$ printf '\e]8;id=42;https://example.com\e\\bar\e]8;;\e\\\n' printf '\e]8;id=42;https://example.com\e\\bar\e]8;;\e\\\n'
[?2004l ]8;id=42;https://example.com\bar]8;;\
[?2004hsh-5.1$ printf '\e]8;id=42;https://example.com\e\\bar\e]8;;\e\\\n' printf '\e]8;id=42;https://example.com\e\\bar\e]8;;\e\\\n'
[?2004l ]8;id=42;https://example.com\bar]8;;\
[?2004hsh-5.1$ [?2004l
exit

View File

@ -0,0 +1 @@
{"history_size":0}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"columns":140,"screen_lines":38}

View File

@ -90,6 +90,7 @@ brevity.
| `OSC 1` | REJECTED | Icon names are not supported |
| `OSC 2` | IMPLEMENTED | |
| `OSC 4` | IMPLEMENTED | |
| `OSC 8` | IMPLEMENTED | |
| `OSC 10` | IMPLEMENTED | |
| `OSC 11` | IMPLEMENTED | |
| `OSC 12` | IMPLEMENTED | |