1
0
Fork 0
mirror of https://github.com/alacritty/alacritty.git synced 2024-11-18 13:55:23 -05:00

Add optional modifiers for url launching

It is not always desired for URLs to open automatically when clicking on
them. To resolve this a new `modifiers` field has been introduced to the
config, which allows specifying which keyboard modifiers need to be held
down to launch URLs in the specified launcher.

Since the config now has two different fields for the URL clicking
feature, the new `url` field has been added to `mouse` and the
`mouse.url_launcher` field has been moved to `mouse.url.launcher`.

Some tests have been added to make sure that the edge-cases of the URL
parsing are protected against future regressions. To make testing easier
the parsing method has been moved into the `SemanticSearch` trait. The
name of the trait has also been changed to just `Search` and it has been
moved to `src/term/mod.rs` to fit the additional functionality.

Some minor style improvements have also been made.
This commit is contained in:
Christian Dürr 2018-10-05 11:45:43 +02:00
parent 4102fc994a
commit 06d3edabfb
7 changed files with 219 additions and 89 deletions

View file

@ -259,11 +259,18 @@ mouse:
double_click: { threshold: 300 } double_click: { threshold: 300 }
triple_click: { threshold: 300 } triple_click: { threshold: 300 }
# URL Launcher url:
# URL launcher
# #
# This program is executed when clicking on a text which is recognized as a URL. # 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. # The URL is always added to the command as the last parameter.
url_launcher: xdg-open launcher: open
# 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: Control|Shift
selection: selection:
semantic_escape_chars: ",│`|:\"' ()[]{}<>" semantic_escape_chars: ",│`|:\"' ()[]{}<>"

View file

@ -257,11 +257,18 @@ mouse:
double_click: { threshold: 300 } double_click: { threshold: 300 }
triple_click: { threshold: 300 } triple_click: { threshold: 300 }
# URL Launcher url:
# URL launcher
# #
# This program is executed when clicking on a text which is recognized as a URL. # 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. # The URL is always added to the command as the last parameter.
url_launcher: open launcher: open
# 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: Control|Shift
selection: selection:
semantic_escape_chars: ",│`|:\"' ()[]{}<>" semantic_escape_chars: ",│`|:\"' ()[]{}<>"

View file

@ -87,16 +87,31 @@ pub struct Mouse {
pub double_click: ClickHandler, pub double_click: ClickHandler,
#[serde(default, deserialize_with = "failure_default")] #[serde(default, deserialize_with = "failure_default")]
pub triple_click: ClickHandler, pub triple_click: ClickHandler,
// Program for opening links
#[serde(default, deserialize_with = "failure_default")] #[serde(default, deserialize_with = "failure_default")]
pub url_launcher: Option<CommandWrapper>, pub url: Url,
// TODO: DEPRECATED // TODO: DEPRECATED
#[serde(default)] #[serde(default)]
pub faux_scrollback_lines: Option<usize>, pub faux_scrollback_lines: Option<usize>,
} }
#[derive(Default, Clone, Debug, Deserialize)]
pub struct Url {
// Program for opening links
#[serde(default, deserialize_with = "failure_default")]
pub launcher: Option<CommandWrapper>,
// Modifier used to open links
#[serde(default, deserialize_with = "deserialize_modifiers")]
pub modifiers: ModifiersState,
}
fn deserialize_modifiers<'a, D>(deserializer: D) -> ::std::result::Result<ModifiersState, D::Error>
where D: de::Deserializer<'a>
{
ModsWrapper::deserialize(deserializer).map(|wrapper| wrapper.into_inner())
}
impl Default for Mouse { impl Default for Mouse {
fn default() -> Mouse { fn default() -> Mouse {
Mouse { Mouse {
@ -106,7 +121,7 @@ impl Default for Mouse {
triple_click: ClickHandler { triple_click: ClickHandler {
threshold: Duration::from_millis(300), threshold: Duration::from_millis(300),
}, },
url_launcher: None, url: Url::default(),
faux_scrollback_lines: None, faux_scrollback_lines: None,
} }
} }
@ -647,6 +662,7 @@ fn deserialize_scrolling_multiplier<'a, D>(deserializer: D) -> ::std::result::Re
/// ///
/// Our deserialize impl wouldn't be covered by a derive(Deserialize); see the /// Our deserialize impl wouldn't be covered by a derive(Deserialize); see the
/// impl below. /// impl below.
#[derive(Debug, Copy, Clone, Hash, Default, Eq, PartialEq)]
struct ModsWrapper(ModifiersState); struct ModsWrapper(ModifiersState);
impl ModsWrapper { impl ModsWrapper {
@ -750,17 +766,17 @@ pub enum CommandWrapper {
} }
impl CommandWrapper { impl CommandWrapper {
pub fn program(&self) -> &String { pub fn program(&self) -> &str {
match *self { match self {
CommandWrapper::Just(ref program) => program, CommandWrapper::Just(program) => program,
CommandWrapper::WithArgs { ref program, .. } => program, CommandWrapper::WithArgs { program, .. } => program,
} }
} }
pub fn args(&self) -> &[String] { pub fn args(&self) -> &[String] {
match *self { match self {
CommandWrapper::Just(_) => &[], CommandWrapper::Just(_) => &[],
CommandWrapper::WithArgs { ref args, .. } => &args, CommandWrapper::WithArgs { args, .. } => args,
} }
} }
} }

View file

@ -4,16 +4,14 @@ use std::fs::File;
use std::io::Write; use std::io::Write;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Instant}; use std::time::{Instant};
use std::cmp::min;
use serde_json as json; use serde_json as json;
use parking_lot::MutexGuard; use parking_lot::MutexGuard;
use glutin::{self, ModifiersState, Event, ElementState}; use glutin::{self, ModifiersState, Event, ElementState};
use copypasta::{Clipboard, Load, Store, Buffer as ClipboardBuffer}; use copypasta::{Clipboard, Load, Store, Buffer as ClipboardBuffer};
use url::Url;
use ansi::{Handler, ClearMode}; use ansi::{Handler, ClearMode};
use grid::{Scroll, BidirectionalIterator}; use grid::Scroll;
use config::{self, Config}; use config::{self, Config};
use cli::Options; use cli::Options;
use display::OnResize; use display::OnResize;
@ -21,13 +19,11 @@ use index::{Line, Column, Side, Point};
use input::{self, MouseBinding, KeyBinding}; use input::{self, MouseBinding, KeyBinding};
use selection::Selection; use selection::Selection;
use sync::FairMutex; use sync::FairMutex;
use term::{Term, SizeInfo, TermMode}; use term::{Term, SizeInfo, TermMode, Search};
use util::limit; use util::limit;
use util::fmt::Red; use util::fmt::Red;
use window::Window; use window::Window;
const URL_SEPARATOR_CHARS: [char; 3] = [' ', '"', '\''];
/// Byte sequences are sent to a `Notify` in response to some events /// Byte sequences are sent to a `Notify` in response to some events
pub trait Notify { pub trait Notify {
/// Notify that an escape sequence should be written to the pty /// Notify that an escape sequence should be written to the pty
@ -108,51 +104,8 @@ impl<'a, N: Notify + 'a> input::ActionContext for ActionContext<'a, N> {
self.terminal.dirty = true; self.terminal.dirty = true;
} }
fn url(&self, mut point: Point<usize>) -> Option<String> { fn url(&self, point: Point<usize>) -> Option<String> {
let grid = self.terminal.grid(); self.terminal.url_search(point)
point.line = grid.num_lines().0 - point.line - 1;
// Limit the starting point to the last line in the history
point.line = min(point.line, grid.len() - 1);
// Create forwards and backwards iterators
let iterf = grid.iter_from(point);
point.col += 1;
let mut iterb = grid.iter_from(point);
// Put all characters until separators into a string
let mut buf = String::new();
while let Some(cell) = iterb.prev() {
if URL_SEPARATOR_CHARS.contains(&cell.c) {
break;
}
buf.insert(0, cell.c);
}
for cell in iterf {
if URL_SEPARATOR_CHARS.contains(&cell.c) {
break;
}
buf.push(cell.c);
}
// Heuristic to remove all leading '('
while buf.starts_with('(') {
buf.remove(0);
}
// Heuristic to remove all ')' from end of URLs without matching '('
let str_count = |text: &str, c: char| {
text.chars().filter(|tc| *tc == c).count()
};
while buf.ends_with(')') && str_count(&buf, '(') < str_count(&buf, ')') {
buf.pop();
}
// Check if string is valid url
match Url::parse(&buf) {
Ok(_) => Some(buf),
Err(_) => None,
}
} }
fn line_selection(&mut self, point: Point) { fn line_selection(&mut self, point: Point) {

View file

@ -488,8 +488,9 @@ impl<'a, A: ActionContext + 'a> Processor<'a, A> {
} else { } else {
// Spawn URL launcher when clicking on URLs // Spawn URL launcher when clicking on URLs
let moved = self.ctx.mouse().last_press_pos != (self.ctx.mouse().x, self.ctx.mouse().y); let moved = self.ctx.mouse().last_press_pos != (self.ctx.mouse().x, self.ctx.mouse().y);
if let (Some(point), Some(launcher), false) = let modifiers_match = modifiers == self.mouse_config.url.modifiers;
(self.ctx.mouse_coords(), &self.mouse_config.url_launcher, moved) if let (Some(point), Some(launcher), true, true) =
(self.ctx.mouse_coords(), &self.mouse_config.url.launcher, !moved, modifiers_match)
{ {
if let Some(text) = self.ctx.url(Point::new(point.line.0, point.col)) { if let Some(text) = self.ctx.url(Point::new(point.line.0, point.col)) {
let mut args = launcher.args().to_vec(); let mut args = launcher.args().to_vec();

View file

@ -22,6 +22,7 @@ use std::cmp::{min, max};
use std::ops::Range; use std::ops::Range;
use index::{Point, Column, Side}; use index::{Point, Column, Side};
use term::Search;
/// Describes a region of a 2-dimensional area /// Describes a region of a 2-dimensional area
/// ///
@ -71,17 +72,6 @@ impl Anchor {
} }
} }
/// A type that can expand a given point to a region
///
/// Usually this is implemented for some 2-D array type since
/// points are two dimensional indices.
pub trait SemanticSearch {
/// Find the nearest semantic boundary _to the left_ of provided point.
fn semantic_search_left(&self, _: Point<usize>) -> Point<usize>;
/// Find the nearest semantic boundary _to the point_ of provided point.
fn semantic_search_right(&self, _: Point<usize>) -> Point<usize>;
}
/// A type that has 2-dimensional boundaries /// A type that has 2-dimensional boundaries
pub trait Dimensions { pub trait Dimensions {
/// Get the size of the area /// Get the size of the area
@ -149,7 +139,7 @@ impl Selection {
} }
} }
pub fn to_span<G: SemanticSearch + Dimensions>(&self, grid: &G) -> Option<Span> { pub fn to_span<G: Search + Dimensions>(&self, grid: &G) -> Option<Span> {
match *self { match *self {
Selection::Simple { ref region } => { Selection::Simple { ref region } => {
Selection::span_simple(grid, region) Selection::span_simple(grid, region)
@ -166,7 +156,7 @@ impl Selection {
grid: &G, grid: &G,
region: &Range<Point<usize>>, region: &Range<Point<usize>>,
) -> Option<Span> ) -> Option<Span>
where G: SemanticSearch + Dimensions where G: Search + Dimensions
{ {
// Normalize ordering of selected cells // Normalize ordering of selected cells
let (front, tail) = if region.start < region.end { let (front, tail) = if region.start < region.end {
@ -383,9 +373,10 @@ mod test {
} }
} }
impl super::SemanticSearch for Dimensions { impl super::Search for Dimensions {
fn semantic_search_left(&self, _: Point<usize>) -> Point<usize> { unimplemented!(); } fn semantic_search_left(&self, _: Point<usize>) -> Point<usize> { unimplemented!(); }
fn semantic_search_right(&self, _: Point<usize>) -> Point<usize> { unimplemented!(); } fn semantic_search_right(&self, _: Point<usize>) -> Point<usize> { unimplemented!(); }
fn url_search(&self, _: Point<usize>) -> Option<String> { unimplemented!(); }
} }
/// Test case of single cell selection /// Test case of single cell selection

View file

@ -20,6 +20,7 @@ use std::time::{Duration, Instant};
use arraydeque::ArrayDeque; use arraydeque::ArrayDeque;
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
use url::Url;
use font::{self, Size}; use font::{self, Size};
use ansi::{self, Color, NamedColor, Attr, Handler, CharsetIndex, StandardCharset, CursorStyle}; use ansi::{self, Color, NamedColor, Attr, Handler, CharsetIndex, StandardCharset, CursorStyle};
@ -36,7 +37,22 @@ pub mod color;
pub use self::cell::Cell; pub use self::cell::Cell;
use self::cell::LineLength; use self::cell::LineLength;
impl selection::SemanticSearch for Term { const URL_SEPARATOR_CHARS: [char; 3] = [' ', '"', '\''];
/// A type that can expand a given point to a region
///
/// Usually this is implemented for some 2-D array type since
/// points are two dimensional indices.
pub trait Search {
/// Find the nearest semantic boundary _to the left_ of provided point.
fn semantic_search_left(&self, _: Point<usize>) -> Point<usize>;
/// Find the nearest semantic boundary _to the point_ of provided point.
fn semantic_search_right(&self, _: Point<usize>) -> Point<usize>;
/// Find the nearest URL boundary in both directions.
fn url_search(&self, _: Point<usize>) -> Option<String>;
}
impl Search for Term {
fn semantic_search_left(&self, mut point: Point<usize>) -> Point<usize> { fn semantic_search_left(&self, mut point: Point<usize>) -> Point<usize> {
// Limit the starting point to the last line in the history // Limit the starting point to the last line in the history
point.line = min(point.line, self.grid.len() - 1); point.line = min(point.line, self.grid.len() - 1);
@ -80,6 +96,52 @@ impl selection::SemanticSearch for Term {
point point
} }
fn url_search(&self, mut point: Point<usize>) -> Option<String> {
point.line = self.grid.num_lines().0 - point.line - 1;
// Limit the starting point to the last line in the history
point.line = min(point.line, self.grid.len() - 1);
// Create forwards and backwards iterators
let iterf = self.grid.iter_from(point);
point.col += 1;
let mut iterb = self.grid.iter_from(point);
// Put all characters until separators into a string
let mut buf = String::new();
while let Some(cell) = iterb.prev() {
if URL_SEPARATOR_CHARS.contains(&cell.c) {
break;
}
buf.insert(0, cell.c);
}
for cell in iterf {
if URL_SEPARATOR_CHARS.contains(&cell.c) {
break;
}
buf.push(cell.c);
}
// Heuristic to remove all leading '('
while buf.starts_with('(') {
buf.remove(0);
}
// Heuristic to remove all ')' from end of URLs without matching '('
let str_count = |text: &str, c: char| {
text.chars().filter(|tc| *tc == c).count()
};
while buf.ends_with(')') && str_count(&buf, '(') < str_count(&buf, ')') {
buf.pop();
}
// Check if string is valid url
match Url::parse(&buf) {
Ok(_) => Some(buf),
Err(_) => None,
}
}
} }
impl selection::Dimensions for Term { impl selection::Dimensions for Term {
@ -1996,7 +2058,7 @@ mod tests {
extern crate serde_json; extern crate serde_json;
use super::{Cell, Term, SizeInfo}; use super::{Cell, Term, SizeInfo};
use term::cell; use term::{cell, Search};
use grid::{Grid, Scroll}; use grid::{Grid, Scroll};
use index::{Point, Line, Column, Side}; use index::{Point, Line, Column, Side};
@ -2228,6 +2290,99 @@ mod tests {
scrolled_grid.scroll_display(Scroll::Top); scrolled_grid.scroll_display(Scroll::Top);
assert_eq!(term.grid, scrolled_grid); assert_eq!(term.grid, scrolled_grid);
} }
// `((ftp://a.de))` -> `Some("ftp://a.de")`
#[test]
fn url_trim_unmatched_parens() {
let size = SizeInfo {
width: 21.0,
height: 51.0,
cell_width: 3.0,
cell_height: 3.0,
padding_x: 0.0,
padding_y: 0.0,
};
let mut term = Term::new(&Default::default(), size);
let mut grid: Grid<Cell> = Grid::new(Line(1), Column(15), 0, Cell::default());
grid[Line(0)][Column(0)].c = '(';
grid[Line(0)][Column(1)].c = '(';
grid[Line(0)][Column(2)].c = 'f';
grid[Line(0)][Column(3)].c = 't';
grid[Line(0)][Column(4)].c = 'p';
grid[Line(0)][Column(5)].c = ':';
grid[Line(0)][Column(6)].c = '/';
grid[Line(0)][Column(7)].c = '/';
grid[Line(0)][Column(8)].c = 'a';
grid[Line(0)][Column(9)].c = '.';
grid[Line(0)][Column(10)].c = 'd';
grid[Line(0)][Column(11)].c = 'e';
grid[Line(0)][Column(12)].c = ')';
grid[Line(0)][Column(13)].c = ')';
mem::swap(&mut term.grid, &mut grid);
// Search for URL in grid
let url = term.url_search(Point::new(0, Column(4)));
assert_eq!(url, Some("ftp://a.de".into()));
}
// `ftp://a.de/()` -> `Some("ftp://a.de/()")`
#[test]
fn url_allow_matching_parens() {
let size = SizeInfo {
width: 21.0,
height: 51.0,
cell_width: 3.0,
cell_height: 3.0,
padding_x: 0.0,
padding_y: 0.0,
};
let mut term = Term::new(&Default::default(), size);
let mut grid: Grid<Cell> = Grid::new(Line(1), Column(15), 0, Cell::default());
grid[Line(0)][Column(0)].c = 'f';
grid[Line(0)][Column(1)].c = 't';
grid[Line(0)][Column(2)].c = 'p';
grid[Line(0)][Column(3)].c = ':';
grid[Line(0)][Column(4)].c = '/';
grid[Line(0)][Column(5)].c = '/';
grid[Line(0)][Column(6)].c = 'a';
grid[Line(0)][Column(7)].c = '.';
grid[Line(0)][Column(8)].c = 'd';
grid[Line(0)][Column(9)].c = 'e';
grid[Line(0)][Column(10)].c = '/';
grid[Line(0)][Column(11)].c = '(';
grid[Line(0)][Column(12)].c = ')';
mem::swap(&mut term.grid, &mut grid);
// Search for URL in grid
let url = term.url_search(Point::new(0, Column(4)));
assert_eq!(url, Some("ftp://a.de/()".into()));
}
// `aze` -> `None`
#[test]
fn url_skip_invalid() {
let size = SizeInfo {
width: 21.0,
height: 51.0,
cell_width: 3.0,
cell_height: 3.0,
padding_x: 0.0,
padding_y: 0.0,
};
let mut term = Term::new(&Default::default(), size);
let mut grid: Grid<Cell> = Grid::new(Line(1), Column(15), 0, Cell::default());
grid[Line(0)][Column(0)].c = 'a';
grid[Line(0)][Column(1)].c = 'z';
grid[Line(0)][Column(2)].c = 'e';
mem::swap(&mut term.grid, &mut grid);
// Search for URL in grid
let url = term.url_search(Point::new(0, Column(1)));
assert_eq!(url, None);
}
} }
#[cfg(all(test, feature = "bench"))] #[cfg(all(test, feature = "bench"))]