aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorChristian Duerr <chrisduerr@users.noreply.github.com>2018-10-22 19:39:26 +0000
committerGitHub <noreply@github.com>2018-10-22 19:39:26 +0000
commit8ee0d2b5b26faacb5f2663bae1fc056ac5ee26bf (patch)
tree093022ebdb474b891c8946ae42972dc23f73fefe /src
parent4380d0864b1098909bdcfec132b866c34924517e (diff)
downloadalacritty-8ee0d2b5b26faacb5f2663bae1fc056ac5ee26bf.tar.gz
alacritty-8ee0d2b5b26faacb5f2663bae1fc056ac5ee26bf.zip
Add option to open URLs on click
This adds the option to automatically launch URLs with a specified program when clicking on them. The config option `mouse.url_launcher` has been added to specify which program should be used to open the URL. The URL is always passed as the last parameter to the specified command. 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. 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. This fixes #113.
Diffstat (limited to 'src')
-rw-r--r--src/config.rs41
-rw-r--r--src/event.rs12
-rw-r--r--src/index.rs6
-rw-r--r--src/input.rs94
-rw-r--r--src/lib.rs1
-rw-r--r--src/selection.rs31
-rw-r--r--src/term/mod.rs159
7 files changed, 302 insertions, 42 deletions
diff --git a/src/config.rs b/src/config.rs
index 069eef0e..2e746623 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -87,12 +87,31 @@ pub struct Mouse {
pub double_click: ClickHandler,
#[serde(default, deserialize_with = "failure_default")]
pub triple_click: ClickHandler,
+ #[serde(default, deserialize_with = "failure_default")]
+ pub url: Url,
// TODO: DEPRECATED
#[serde(default)]
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 {
fn default() -> Mouse {
Mouse {
@@ -102,6 +121,7 @@ impl Default for Mouse {
triple_click: ClickHandler {
threshold: Duration::from_millis(300),
},
+ url: Url::default(),
faux_scrollback_lines: None,
}
}
@@ -644,6 +664,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
/// impl below.
+#[derive(Debug, Copy, Clone, Hash, Default, Eq, PartialEq)]
struct ModsWrapper(ModifiersState);
impl ModsWrapper {
@@ -735,9 +756,9 @@ impl<'a> de::Deserialize<'a> for ActionWrapper {
}
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
-enum CommandWrapper {
+pub enum CommandWrapper {
Just(String),
WithArgs {
program: String,
@@ -746,6 +767,22 @@ enum CommandWrapper {
},
}
+impl CommandWrapper {
+ pub fn program(&self) -> &str {
+ match self {
+ CommandWrapper::Just(program) => program,
+ CommandWrapper::WithArgs { program, .. } => program,
+ }
+ }
+
+ pub fn args(&self) -> &[String] {
+ match self {
+ CommandWrapper::Just(_) => &[],
+ CommandWrapper::WithArgs { args, .. } => args,
+ }
+ }
+}
+
use ::term::{mode, TermMode};
struct ModeWrapper {
diff --git a/src/event.rs b/src/event.rs
index eef04a8c..185a7d70 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -19,7 +19,7 @@ use index::{Line, Column, Side, Point};
use input::{self, MouseBinding, KeyBinding};
use selection::Selection;
use sync::FairMutex;
-use term::{Term, SizeInfo, TermMode};
+use term::{Term, SizeInfo, TermMode, Search};
use util::limit;
use util::fmt::Red;
use window::Window;
@@ -76,6 +76,10 @@ impl<'a, N: Notify + 'a> input::ActionContext for ActionContext<'a, N> {
}
}
+ fn selection_is_empty(&self) -> bool {
+ self.terminal.selection().as_ref().map(|s| s.is_empty()).unwrap_or(true)
+ }
+
fn clear_selection(&mut self) {
*self.terminal.selection_mut() = None;
self.terminal.dirty = true;
@@ -104,6 +108,10 @@ impl<'a, N: Notify + 'a> input::ActionContext for ActionContext<'a, N> {
self.terminal.dirty = true;
}
+ fn url(&self, point: Point<usize>) -> Option<String> {
+ self.terminal.url_search(point)
+ }
+
fn line_selection(&mut self, point: Point) {
let point = self.terminal.visible_to_buffer(point);
*self.terminal.selection_mut() = Some(Selection::lines(point));
@@ -196,6 +204,7 @@ pub struct Mouse {
pub column: Column,
pub cell_side: Side,
pub lines_scrolled: f32,
+ pub block_url_launcher: bool,
}
impl Default for Mouse {
@@ -213,6 +222,7 @@ impl Default for Mouse {
column: Column(0),
cell_side: Side::Left,
lines_scrolled: 0.0,
+ block_url_launcher: false,
}
}
}
diff --git a/src/index.rs b/src/index.rs
index 149fd458..8ac1ab3a 100644
--- a/src/index.rs
+++ b/src/index.rs
@@ -64,6 +64,12 @@ impl From<Point<isize>> for Point<usize> {
}
}
+impl From<Point> for Point<usize> {
+ fn from(point: Point) -> Self {
+ Point::new(point.line.0, point.col)
+ }
+}
+
/// A line
///
/// Newtype to avoid passing values incorrectly
diff --git a/src/input.rs b/src/input.rs
index ed9aa7fc..49a9101e 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -63,6 +63,7 @@ pub trait ActionContext {
fn simple_selection(&mut self, point: Point, side: Side);
fn semantic_selection(&mut self, point: Point);
fn line_selection(&mut self, point: Point);
+ fn selection_is_empty(&self) -> bool;
fn mouse_mut(&mut self) -> &mut Mouse;
fn mouse(&self) -> &Mouse;
fn mouse_coords(&self) -> Option<Point>;
@@ -74,6 +75,7 @@ pub trait ActionContext {
fn scroll(&mut self, scroll: Scroll);
fn clear_history(&mut self);
fn hide_window(&mut self);
+ fn url(&self, _: Point<usize>) -> Option<String>;
}
/// Describes a state and action to take in that state
@@ -326,16 +328,25 @@ impl<'a, A: ActionContext + 'a> Processor<'a, A> {
let size_info = self.ctx.size_info();
let point = size_info.pixels_to_coords(x, y);
+ let cell_side = self.get_mouse_side();
+ let prev_side = mem::replace(&mut self.ctx.mouse_mut().cell_side, cell_side);
let prev_line = mem::replace(&mut self.ctx.mouse_mut().line, point.line);
let prev_col = mem::replace(&mut self.ctx.mouse_mut().column, point.col);
let motion_mode = TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG;
let report_mode = TermMode::MOUSE_REPORT_CLICK | motion_mode;
+ // Don't launch URLs if mouse has moved
+ if prev_line != self.ctx.mouse().line
+ || prev_col != self.ctx.mouse().column
+ || prev_side != cell_side
+ {
+ self.ctx.mouse_mut().block_url_launcher = true;
+ }
+
if self.ctx.mouse().left_button_state == ElementState::Pressed &&
( modifiers.shift || !self.ctx.terminal_mode().intersects(report_mode))
{
- let cell_side = self.get_mouse_side();
self.ctx.update_selection(Point {
line: point.line,
col: point.col
@@ -357,6 +368,26 @@ impl<'a, A: ActionContext + 'a> Processor<'a, A> {
}
}
+ fn get_mouse_side(&self) -> Side {
+ let size_info = self.ctx.size_info();
+ let x = self.ctx.mouse().x;
+
+ let cell_x = x.saturating_sub(size_info.padding_x as usize) % size_info.cell_width as usize;
+ let half_cell_width = (size_info.cell_width / 2.0) as usize;
+
+ let additional_padding = (size_info.width - size_info.padding_x * 2.) % size_info.cell_width;
+ let end_of_grid = size_info.width - size_info.padding_x - additional_padding;
+
+ if cell_x > half_cell_width
+ // Edge case when mouse leaves the window
+ || x as f32 >= end_of_grid
+ {
+ Side::Right
+ } else {
+ Side::Left
+ }
+ }
+
pub fn normal_mouse_report(&mut self, button: u8) {
let (line, column) = (self.ctx.mouse().line, self.ctx.mouse().column);
@@ -427,19 +458,24 @@ impl<'a, A: ActionContext + 'a> Processor<'a, A> {
self.ctx.mouse_mut().click_state = match self.ctx.mouse().click_state {
ClickState::Click if elapsed < self.mouse_config.double_click.threshold => {
+ self.ctx.mouse_mut().block_url_launcher = true;
self.on_mouse_double_click();
ClickState::DoubleClick
},
ClickState::DoubleClick if elapsed < self.mouse_config.triple_click.threshold => {
+ self.ctx.mouse_mut().block_url_launcher = true;
self.on_mouse_triple_click();
ClickState::TripleClick
},
_ => {
+ // Don't launch URLs if this click cleared the selection
+ self.ctx.mouse_mut().block_url_launcher = !self.ctx.selection_is_empty();
+
self.ctx.clear_selection();
// Start new empty selection
if let Some(point) = self.ctx.mouse_coords() {
- let side = self.get_mouse_side();
+ let side = self.ctx.mouse().cell_side;
self.ctx.simple_selection(point, side);
}
@@ -460,26 +496,6 @@ impl<'a, A: ActionContext + 'a> Processor<'a, A> {
};
}
- fn get_mouse_side(&self) -> Side {
- let size_info = self.ctx.size_info();
- let x = self.ctx.mouse().x;
-
- let cell_x = x.saturating_sub(size_info.padding_x as usize) % size_info.cell_width as usize;
- let half_cell_width = (size_info.cell_width / 2.0) as usize;
-
- let additional_padding = (size_info.width - size_info.padding_x * 2.) % size_info.cell_width;
- let end_of_grid = size_info.width - size_info.padding_x - additional_padding;
-
- if cell_x > half_cell_width
- // Edge case when mouse leaves the window
- || x as f32 >= end_of_grid
- {
- Side::Right
- } else {
- Side::Left
- }
- }
-
pub fn on_mouse_release(&mut self, button: MouseButton, modifiers: ModifiersState) {
let report_modes = TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION;
if !modifiers.shift && self.ctx.terminal_mode().intersects(report_modes)
@@ -492,6 +508,8 @@ impl<'a, A: ActionContext + 'a> Processor<'a, A> {
MouseButton::Other(_) => (),
};
return;
+ } else {
+ self.launch_url(modifiers);
}
if self.save_to_clipboard {
@@ -500,6 +518,27 @@ impl<'a, A: ActionContext + 'a> Processor<'a, A> {
self.ctx.copy_selection(ClipboardBuffer::Selection);
}
+ // Spawn URL launcher when clicking on URLs
+ fn launch_url(&self, modifiers: ModifiersState) -> Option<()> {
+ if modifiers != self.mouse_config.url.modifiers || self.ctx.mouse().block_url_launcher {
+ return None;
+ }
+
+ let point = self.ctx.mouse_coords()?;
+ let text = self.ctx.url(point.into())?;
+
+ let launcher = self.mouse_config.url.launcher.as_ref()?;
+ let mut args = launcher.args().to_vec();
+ args.push(text);
+
+ match Command::new(launcher.program()).args(&args).spawn() {
+ Ok(_) => debug!("Launched: {} {:?}", launcher.program(), args),
+ Err(_) => warn!("Unable to launch: {} {:?}", launcher.program(), args),
+ }
+
+ Some(())
+ }
+
pub fn on_mouse_wheel(&mut self, delta: MouseScrollDelta, phase: TouchPhase, modifiers: ModifiersState) {
match delta {
MouseScrollDelta::LineDelta(_columns, lines) => {
@@ -777,6 +816,10 @@ mod tests {
self.last_action = MultiClick::TripleClick;
}
+ fn selection_is_empty(&self) -> bool {
+ true
+ }
+
fn scroll(&mut self, scroll: Scroll) {
self.terminal.scroll_display(scroll);
}
@@ -795,6 +838,10 @@ mod tests {
self.mouse
}
+ fn url(&self, _: Point<usize>) -> Option<String> {
+ None
+ }
+
fn received_count(&mut self) -> &mut usize {
&mut self.received_count
}
@@ -863,11 +910,12 @@ mod tests {
threshold: Duration::from_millis(1000),
},
faux_scrollback_lines: None,
+ url: Default::default(),
},
scrolling_config: &config::Scrolling::default(),
key_bindings: &config.key_bindings()[..],
mouse_bindings: &config.mouse_bindings()[..],
- save_to_clipboard: config.selection().save_to_clipboard
+ save_to_clipboard: config.selection().save_to_clipboard,
};
if let Event::WindowEvent { event: WindowEvent::MouseInput { state, button, modifiers, .. }, .. } = $input {
diff --git a/src/lib.rs b/src/lib.rs
index d0e48c3d..810e9a2e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -65,6 +65,7 @@ extern crate vte;
extern crate xdg;
extern crate base64;
extern crate terminfo;
+extern crate url;
#[macro_use]
pub mod macros;
diff --git a/src/selection.rs b/src/selection.rs
index 932a61ee..8eb08d70 100644
--- a/src/selection.rs
+++ b/src/selection.rs
@@ -22,6 +22,7 @@ use std::cmp::{min, max};
use std::ops::Range;
use index::{Point, Column, Side};
+use term::Search;
/// 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
pub trait Dimensions {
/// Get the size of the area
@@ -151,7 +141,7 @@ impl Selection {
pub fn to_span<G>(&self, grid: &G, alt_screen: bool) -> Option<Span>
where
- G: SemanticSearch + Dimensions,
+ G: Search + Dimensions,
{
match *self {
Selection::Simple { ref region } => {
@@ -166,12 +156,24 @@ impl Selection {
}
}
+ pub fn is_empty(&self) -> bool
+ {
+ match *self {
+ Selection::Simple { ref region } => {
+ region.start == region.end && region.start.side == region.end.side
+ },
+ Selection::Semantic { .. } | Selection::Lines { .. } => {
+ false
+ },
+ }
+ }
+
fn span_semantic<G>(
grid: &G,
region: &Range<Point<isize>>,
alt_screen: bool,
) -> Option<Span>
- where G: SemanticSearch + Dimensions
+ where G: Search + Dimensions
{
let cols = grid.dimensions().col;
let lines = grid.dimensions().line.0 as isize;
@@ -448,9 +450,10 @@ mod test {
}
}
- impl super::SemanticSearch for Dimensions {
+ impl super::Search for Dimensions {
fn semantic_search_left(&self, point: Point<usize>) -> Point<usize> { point }
fn semantic_search_right(&self, point: Point<usize>) -> Point<usize> { point }
+ fn url_search(&self, _: Point<usize>) -> Option<String> { None }
}
/// Test case of single cell selection
diff --git a/src/term/mod.rs b/src/term/mod.rs
index f4563922..3b238a41 100644
--- a/src/term/mod.rs
+++ b/src/term/mod.rs
@@ -20,6 +20,7 @@ use std::time::{Duration, Instant};
use arraydeque::ArrayDeque;
use unicode_width::UnicodeWidthChar;
+use url::Url;
use font::{self, Size};
use ansi::{self, Color, NamedColor, Attr, Handler, CharsetIndex, StandardCharset, CursorStyle};
@@ -36,7 +37,22 @@ pub mod color;
pub use self::cell::Cell;
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> {
// Limit the starting point to the last line in the history
point.line = min(point.line, self.grid.len() - 1);
@@ -80,6 +96,52 @@ impl selection::SemanticSearch for Term {
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 {
@@ -1999,7 +2061,7 @@ mod tests {
extern crate serde_json;
use super::{Cell, Term, SizeInfo};
- use term::cell;
+ use term::{cell, Search};
use grid::{Grid, Scroll};
use index::{Point, Line, Column, Side};
@@ -2231,6 +2293,99 @@ mod tests {
scrolled_grid.scroll_display(Scroll::Top);
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"))]