summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--alacritty.yml49
-rw-r--r--alacritty/src/config/bindings.rs8
-rw-r--r--alacritty/src/config/color.rs37
-rw-r--r--alacritty/src/config/mod.rs3
-rw-r--r--alacritty/src/config/ui_config.rs168
-rw-r--r--alacritty/src/display/content.rs171
-rw-r--r--alacritty/src/display/hint.rs264
-rw-r--r--alacritty/src/display/mod.rs14
-rw-r--r--alacritty/src/event.rs45
-rw-r--r--alacritty/src/input.rs77
-rw-r--r--alacritty_terminal/src/term/search.rs1
-rw-r--r--docs/features.md9
13 files changed, 749 insertions, 98 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84cf4283..9fbe458b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- IME composition preview not appearing on Windows
- Synchronized terminal updates using `DCS = 1 s ST`/`DCS = 2 s ST`
+- Regex terminal hints ([see features.md](./docs/features.md#hints))
### Fixed
diff --git a/alacritty.yml b/alacritty.yml
index 4a23f5c9..2ffadc2d 100644
--- a/alacritty.yml
+++ b/alacritty.yml
@@ -195,7 +195,7 @@
#
# Colors which should be used to draw the terminal cursor.
#
- # Allowed values are CellForeground and CellBackground, which reference the
+ # Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#cursor:
# text: CellBackground
@@ -205,7 +205,7 @@
#
# Colors for the cursor when the vi mode is active.
#
- # Allowed values are CellForeground and CellBackground, which reference the
+ # Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#vi_mode_cursor:
# text: CellBackground
@@ -215,7 +215,7 @@
#
# Colors used for the search bar and match highlighting.
#search:
- # Allowed values are CellForeground and CellBackground, which reference the
+ # Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#matches:
# foreground: '#000000'
@@ -228,6 +228,24 @@
# background: '#c5c8c6'
# foreground: '#1d1f21'
+ # Keyboard hints
+ #hints:
+ # Fist character in the hint label
+ #
+ # Allowed values are CellForeground/CellBackground, which reference the
+ # affected cell, or hexadecimal colors like #ff00ff.
+ #start:
+ # foreground: '#1d1f21'
+ # background: '#e9ff5e'
+
+ # All characters after the first one in the hint label
+ #
+ # Allowed values are CellForeground/CellBackground, which reference the
+ # affected cell, or hexadecimal colors like #ff00ff.
+ #end:
+ # foreground: '#e9ff5e'
+ # background: '#1d1f21'
+
# Line indicator
#
# Color used for the indicator displaying the position in history during
@@ -242,7 +260,7 @@
#
# Colors which should be used to draw the selection area.
#
- # Allowed values are CellForeground and CellBackground, which reference the
+ # Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#selection:
# text: CellBackground
@@ -450,6 +468,29 @@
# binding section.
#modifiers: None
+# Regex hints
+#
+# Terminal hints can be used to find text 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
+ #
+ # The fields `command`, `binding.key` and `binding.mods` accept the same
+ # values as they do in the `key_bindings` section.
+ #
+ # Example
+ #
+ # enabled:
+ # - regex: "alacritty/alacritty#\\d*"
+ # command: firefox
+ # binding:
+ # key: G
+ # mods: Control|Shift
+ #enabled: []
+
# Mouse bindings
#
# Mouse bindings are specified as a list of objects, much like the key
diff --git a/alacritty/src/config/bindings.rs b/alacritty/src/config/bindings.rs
index 4d0fcadd..732875db 100644
--- a/alacritty/src/config/bindings.rs
+++ b/alacritty/src/config/bindings.rs
@@ -16,6 +16,8 @@ use alacritty_terminal::config::Program;
use alacritty_terminal::term::TermMode;
use alacritty_terminal::vi_mode::ViMotion;
+use crate::config::ui_config::Hint;
+
/// Describes a state and action to take in that state.
///
/// This is the shared component of `MouseBinding` and `KeyBinding`.
@@ -91,6 +93,10 @@ pub enum Action {
#[config(skip)]
Command(Program),
+ /// Regex keyboard hints.
+ #[config(skip)]
+ Hint(Hint),
+
/// Move vi mode cursor.
#[config(skip)]
ViMotion(ViMotion),
@@ -1132,7 +1138,7 @@ impl<'a> Deserialize<'a> for KeyBinding {
/// Our deserialize impl wouldn't be covered by a derive(Deserialize); see the
/// impl below.
#[derive(Debug, Copy, Clone, Hash, Default, Eq, PartialEq)]
-pub struct ModsWrapper(ModifiersState);
+pub struct ModsWrapper(pub ModifiersState);
impl ModsWrapper {
pub fn into_inner(self) -> ModifiersState {
diff --git a/alacritty/src/config/color.rs b/alacritty/src/config/color.rs
index cd5d964d..d55cf26f 100644
--- a/alacritty/src/config/color.rs
+++ b/alacritty/src/config/color.rs
@@ -16,6 +16,7 @@ pub struct Colors {
pub indexed_colors: Vec<IndexedColor>,
pub search: SearchColors,
pub line_indicator: LineIndicatorColors,
+ pub hints: HintColors,
}
impl Colors {
@@ -34,6 +35,42 @@ pub struct LineIndicatorColors {
pub background: Option<Rgb>,
}
+#[derive(ConfigDeserialize, Default, Copy, Clone, Debug, PartialEq, Eq)]
+pub struct HintColors {
+ pub start: HintStartColors,
+ pub end: HintEndColors,
+}
+
+#[derive(ConfigDeserialize, Copy, Clone, Debug, PartialEq, Eq)]
+pub struct HintStartColors {
+ pub foreground: CellRgb,
+ pub background: CellRgb,
+}
+
+impl Default for HintStartColors {
+ fn default() -> Self {
+ Self {
+ foreground: CellRgb::Rgb(Rgb { r: 0x1d, g: 0x1f, b: 0x21 }),
+ background: CellRgb::Rgb(Rgb { r: 0xe9, g: 0xff, b: 0x5e }),
+ }
+ }
+}
+
+#[derive(ConfigDeserialize, Copy, Clone, Debug, PartialEq, Eq)]
+pub struct HintEndColors {
+ pub foreground: CellRgb,
+ pub background: CellRgb,
+}
+
+impl Default for HintEndColors {
+ fn default() -> Self {
+ Self {
+ foreground: CellRgb::Rgb(Rgb { r: 0xe9, g: 0xff, b: 0x5e }),
+ background: CellRgb::Rgb(Rgb { r: 0x1d, g: 0x1f, b: 0x21 }),
+ }
+ }
+}
+
#[derive(Deserialize, Copy, Clone, Default, Debug, PartialEq, Eq)]
pub struct IndexedColor {
pub color: Rgb,
diff --git a/alacritty/src/config/mod.rs b/alacritty/src/config/mod.rs
index c321915e..4ddc81c2 100644
--- a/alacritty/src/config/mod.rs
+++ b/alacritty/src/config/mod.rs
@@ -159,6 +159,9 @@ fn read_config(path: &Path, cli_config: Value) -> Result<Config> {
let mut config = Config::deserialize(config_value)?;
config.ui_config.config_paths = config_paths;
+ // Create key bindings for regex hints.
+ config.ui_config.generate_hint_bindings();
+
Ok(config)
}
diff --git a/alacritty/src/config/ui_config.rs b/alacritty/src/config/ui_config.rs
index 2d7b5c98..6d06aa7d 100644
--- a/alacritty/src/config/ui_config.rs
+++ b/alacritty/src/config/ui_config.rs
@@ -1,13 +1,20 @@
+use std::cell::RefCell;
use std::path::PathBuf;
+use std::rc::Rc;
use log::error;
+use serde::de::Error as SerdeError;
use serde::{Deserialize, Deserializer};
+use unicode_width::UnicodeWidthChar;
use alacritty_config_derive::ConfigDeserialize;
-use alacritty_terminal::config::{Percentage, LOG_TARGET_CONFIG};
+use alacritty_terminal::config::{Percentage, Program, LOG_TARGET_CONFIG};
+use alacritty_terminal::term::search::RegexSearch;
use crate::config::bell::BellConfig;
-use crate::config::bindings::{self, Binding, KeyBinding, MouseBinding};
+use crate::config::bindings::{
+ self, Action, Binding, BindingMode, Key, KeyBinding, ModsWrapper, MouseBinding,
+};
use crate::config::color::Colors;
use crate::config::debug::Debug;
use crate::config::font::Font;
@@ -46,6 +53,9 @@ pub struct UiConfig {
#[config(skip)]
pub config_paths: Vec<PathBuf>,
+ /// Regex hints for interacting with terminal content.
+ pub hints: Hints,
+
/// Keybindings.
key_bindings: KeyBindings,
@@ -72,11 +82,27 @@ impl Default for UiConfig {
bell: Default::default(),
colors: Default::default(),
draw_bold_text_with_bright_colors: Default::default(),
+ hints: Default::default(),
}
}
}
impl UiConfig {
+ /// Generate key bindings for all keyboard hints.
+ pub fn generate_hint_bindings(&mut self) {
+ for hint in self.hints.enabled.drain(..) {
+ let binding = KeyBinding {
+ trigger: hint.binding.key,
+ mods: hint.binding.mods.0,
+ mode: BindingMode::empty(),
+ notmode: BindingMode::empty(),
+ action: Action::Hint(hint),
+ };
+
+ self.key_bindings.0.push(binding);
+ }
+ }
+
#[inline]
pub fn background_opacity(&self) -> f32 {
self.background_opacity.as_f32()
@@ -169,3 +195,141 @@ pub struct Delta<T: Default> {
/// Vertical change.
pub y: T,
}
+
+/// Regex terminal hints.
+#[derive(ConfigDeserialize, Default, Debug, PartialEq, Eq)]
+pub struct Hints {
+ /// Characters for the hint labels.
+ alphabet: HintsAlphabet,
+
+ /// All configured terminal hints.
+ enabled: Vec<Hint>,
+}
+
+impl Hints {
+ /// Characters for the hint labels.
+ pub fn alphabet(&self) -> &str {
+ &self.alphabet.0
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct HintsAlphabet(String);
+
+impl Default for HintsAlphabet {
+ fn default() -> Self {
+ Self(String::from("jfkdls;ahgurieowpq"))
+ }
+}
+
+impl<'de> Deserialize<'de> for HintsAlphabet {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+
+ let mut character_count = 0;
+ for character in value.chars() {
+ if character.width() != Some(1) {
+ return Err(D::Error::custom("characters must be of width 1"));
+ }
+ character_count += 1;
+ }
+
+ if character_count < 2 {
+ return Err(D::Error::custom("must include at last 2 characters"));
+ }
+
+ Ok(Self(value))
+ }
+}
+
+/// Hint configuration.
+#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
+pub struct Hint {
+ /// Command the text will be piped to.
+ pub command: Program,
+
+ /// Regex for finding matches.
+ pub regex: LazyRegex,
+
+ /// Binding required to search for this hint.
+ binding: HintBinding,
+}
+
+/// Binding for triggering a keyboard hint.
+#[derive(Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
+pub struct HintBinding {
+ pub key: Key,
+ pub mods: ModsWrapper,
+}
+
+/// Lazy regex with interior mutability.
+#[derive(Clone, Debug)]
+pub struct LazyRegex(Rc<RefCell<LazyRegexVariant>>);
+
+impl LazyRegex {
+ /// Execute a function with the compiled regex DFAs as parameter.
+ pub fn with_compiled<T, F>(&self, f: F) -> T
+ where
+ F: Fn(&RegexSearch) -> T,
+ {
+ f(self.0.borrow_mut().compiled())
+ }
+}
+
+impl<'de> Deserialize<'de> for LazyRegex {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let regex = LazyRegexVariant::Pattern(String::deserialize(deserializer)?);
+ Ok(Self(Rc::new(RefCell::new(regex))))
+ }
+}
+
+/// Implement placeholder to allow derive upstream, since we never need it for this struct itself.
+impl PartialEq for LazyRegex {
+ fn eq(&self, _other: &Self) -> bool {
+ false
+ }
+}
+impl Eq for LazyRegex {}
+
+/// Regex which is compiled on demand, to avoid expensive computations at startup.
+#[derive(Clone, Debug)]
+pub enum LazyRegexVariant {
+ Compiled(Box<RegexSearch>),
+ Pattern(String),
+}
+
+impl LazyRegexVariant {
+ /// Get a reference to the compiled regex.
+ ///
+ /// If the regex is not already compiled, this will compile the DFAs and store them for future
+ /// access.
+ fn compiled(&mut self) -> &RegexSearch {
+ // Check if the regex has already been compiled.
+ let regex = match self {
+ Self::Compiled(regex_search) => return regex_search,
+ Self::Pattern(regex) => regex,
+ };
+
+ // Compile the regex.
+ let regex_search = match RegexSearch::new(&regex) {
+ Ok(regex_search) => regex_search,
+ Err(error) => {
+ error!("hint regex is invalid: {}", error);
+ RegexSearch::new("").unwrap()
+ },
+ };
+ *self = Self::Compiled(Box::new(regex_search));
+
+ // Return a reference to the compiled DFAs.
+ match self {
+ Self::Compiled(dfas) => dfas,
+ Self::Pattern(_) => unreachable!(),
+ }
+ }
+}
diff --git a/alacritty/src/display/content.rs b/alacritty/src/display/content.rs
index 9f035a1c..6532f236 100644
--- a/alacritty/src/display/content.rs
+++ b/alacritty/src/display/content.rs
@@ -1,6 +1,7 @@
+use std::borrow::Cow;
use std::cmp::max;
use std::mem;
-use std::ops::RangeInclusive;
+use std::ops::{Deref, DerefMut, RangeInclusive};
use alacritty_terminal::ansi::{Color, CursorShape, NamedColor};
use alacritty_terminal::config::Config;
@@ -16,6 +17,8 @@ use alacritty_terminal::term::{
use crate::config::ui_config::UiConfig;
use crate::display::color::{List, DIM_FACTOR};
+use crate::display::hint::HintState;
+use crate::display::Display;
/// Minimum contrast between a fixed cursor color and the cell's background.
pub const MIN_CURSOR_CONTRAST: f64 = 1.5;
@@ -30,31 +33,38 @@ pub struct RenderableContent<'a> {
terminal_content: TerminalContent<'a>,
terminal_cursor: TerminalCursor,
cursor: Option<RenderableCursor>,
- search: RenderableSearch,
+ search: Regex<'a>,
+ hint: Hint<'a>,
config: &'a Config<UiConfig>,
colors: &'a List,
}
impl<'a> RenderableContent<'a> {
pub fn new<T: EventListener>(
- term: &'a Term<T>,
- dfas: Option<&RegexSearch>,
config: &'a Config<UiConfig>,
- colors: &'a List,
- show_cursor: bool,
+ display: &'a mut Display,
+ term: &'a Term<T>,
+ search_dfas: Option<&RegexSearch>,
) -> Self {
- let search = dfas.map(|dfas| RenderableSearch::new(&term, dfas)).unwrap_or_default();
+ let search = search_dfas.map(|dfas| Regex::new(&term, dfas)).unwrap_or_default();
let terminal_content = term.renderable_content();
// Copy the cursor and override its shape if necessary.
let mut terminal_cursor = terminal_content.cursor;
- if !show_cursor || terminal_cursor.shape == CursorShape::Hidden {
+ if terminal_cursor.shape == CursorShape::Hidden
+ || display.cursor_hidden
+ || search_dfas.is_some()
+ {
terminal_cursor.shape = CursorShape::Hidden;
} else if !term.is_focused && config.cursor.unfocused_hollow {
terminal_cursor.shape = CursorShape::HollowBlock;
}
- Self { cursor: None, terminal_content, terminal_cursor, search, config, colors }
+ display.hint_state.update_matches(term);
+ let hint = Hint::from(&display.hint_state);
+
+ let colors = &display.colors;
+ Self { cursor: None, terminal_content, terminal_cursor, search, config, colors, hint }
}
/// Viewport offset.
@@ -193,37 +203,40 @@ impl RenderableCell {
.map_or(false, |selection| selection.contains_cell(&cell, content.terminal_cursor));
let mut is_match = false;
+ let mut character = cell.c;
+
let colors = &content.config.ui_config.colors;
- if is_selected {
+ if let Some((c, is_first)) = content.hint.advance(cell.point) {
+ let (config_fg, config_bg) = if is_first {
+ (colors.hints.start.foreground, colors.hints.start.background)
+ } else {
+ (colors.hints.end.foreground, colors.hints.end.background)
+ };
+ Self::compute_cell_rgb(&mut fg_rgb, &mut bg_rgb, &mut bg_alpha, config_fg, config_bg);
+
+ character = c;
+ } else if is_selected {
+ let config_fg = colors.selection.foreground;
let config_bg = colors.selection.background;
- let selected_fg = colors.selection.foreground.color(fg_rgb, bg_rgb);
- bg_rgb = config_bg.color(fg_rgb, bg_rgb);
- fg_rgb = selected_fg;
+ Self::compute_cell_rgb(&mut fg_rgb, &mut bg_rgb, &mut bg_alpha, config_fg, config_bg);
if fg_rgb == bg_rgb && !cell.flags.contains(Flags::HIDDEN) {
// Reveal inversed text when fg/bg is the same.
fg_rgb = content.color(NamedColor::Background as usize);
bg_rgb = content.color(NamedColor::Foreground as usize);
bg_alpha = 1.0;
- } else if config_bg != CellRgb::CellBackground {
- bg_alpha = 1.0;
}
} else if content.search.advance(cell.point) {
// Highlight the cell if it is part of a search match.
+ let config_fg = colors.search.matches.foreground;
let config_bg = colors.search.matches.background;
- let matched_fg = colors.search.matches.foreground.color(fg_rgb, bg_rgb);
- bg_rgb = config_bg.color(fg_rgb, bg_rgb);
- fg_rgb = matched_fg;
-
- if config_bg != CellRgb::CellBackground {
- bg_alpha = 1.0;
- }
+ Self::compute_cell_rgb(&mut fg_rgb, &mut bg_rgb, &mut bg_alpha, config_fg, config_bg);
is_match = true;
}
RenderableCell {
- character: cell.c,
+ character,
zerowidth: cell.zerowidth().map(|zerowidth| zerowidth.to_vec()),
point: cell.point,
fg: fg_rgb,
@@ -242,6 +255,22 @@ impl RenderableCell {
&& self.zerowidth.is_none()
}
+ /// Apply [`CellRgb`] colors to the cell's colors.
+ fn compute_cell_rgb(
+ cell_fg: &mut Rgb,
+ cell_bg: &mut Rgb,
+ bg_alpha: &mut f32,
+ fg: CellRgb,
+ bg: CellRgb,
+ ) {
+ let old_fg = mem::replace(cell_fg, fg.color(*cell_fg, *cell_bg));
+ *cell_bg = bg.color(old_fg, *cell_bg);
+
+ if bg != CellRgb::CellBackground {
+ *bg_alpha = 1.0;
+ }
+ }
+
/// Get the RGB color from a cell's foreground color.
fn compute_fg_rgb(content: &mut RenderableContent<'_>, fg: Color, flags: Flags) -> Rgb {
let ui_config = &content.config.ui_config;
@@ -339,18 +368,58 @@ impl RenderableCursor {
}
}
-/// Regex search highlight tracking.
-#[derive(Default)]
-pub struct RenderableSearch {
- /// All visible search matches.
- matches: Vec<RangeInclusive<Point>>,
+/// Regex hints for keyboard shortcuts.
+struct Hint<'a> {
+ /// Hint matches and position.
+ regex: Regex<'a>,
- /// Index of the last match checked.
- index: usize,
+ /// Last match checked against current cell position.
+ labels: &'a Vec<Vec<char>>,
+}
+
+impl<'a> Hint<'a> {
+ /// Advance the hint iterator.
+ ///
+ /// If the point is within a hint, the keyboard shortcut character that should be displayed at
+ /// this position will be returned.
+ ///
+ /// The tuple's [`bool`] will be `true` when the character is the first for this hint.
+ fn advance(&mut self, point: Point) -> Option<(char, bool)> {
+ // Check if we're within a match at all.
+ if !self.regex.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| regex_match.start())
+ .filter(|start| start.line == point.line)?;
+
+ // Position within the hint label.
+ let label_position = point.column.0 - start.column.0;
+ let is_first = label_position == 0;
+
+ // Hint label character.
+ self.labels[self.regex.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 }
+ }
}
-impl RenderableSearch {
- /// Create a new renderable search iterator.
+/// Wrapper for finding visible regex matches.
+#[derive(Default, Clone)]
+pub struct RegexMatches(Vec<RangeInclusive<Point>>);
+
+impl RegexMatches {
+ /// Find all visible matches.
pub fn new<T>(term: &Term<T>, dfas: &RegexSearch) -> Self {
let viewport_end = term.grid().display_offset();
let viewport_start = viewport_end + term.screen_lines().0 - 1;
@@ -383,12 +452,44 @@ impl RenderableSearch {
viewport_start..=viewport_end
});
- Self { matches: iter.collect(), index: 0 }
+ 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.
+#[derive(Default)]
+struct Regex<'a> {
+ /// All visible matches.
+ matches: Cow<'a, RegexMatches>,
+
+ /// 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 }
}
- /// Advance the search tracker to the next point.
+ /// Advance the regex tracker to the next point.
///
- /// This will return `true` if the point passed is part of a search match.
+ /// 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 {
diff --git a/alacritty/src/display/hint.rs b/alacritty/src/display/hint.rs
new file mode 100644
index 00000000..6499a959
--- /dev/null
+++ b/alacritty/src/display/hint.rs
@@ -0,0 +1,264 @@
+use alacritty_terminal::term::Term;
+
+use crate::config::ui_config::Hint;
+use crate::daemon::start_daemon;
+use crate::display::content::RegexMatches;
+
+/// Percentage of characters in the hints alphabet used for the last character.
+const HINT_SPLIT_PERCENTAGE: f32 = 0.5;
+
+/// Keyboard regex hint state.
+pub struct HintState {
+ /// Hint currently in use.
+ hint: Option<Hint>,
+
+ /// Alphabet for hint labels.
+ alphabet: String,
+
+ /// Visible matches.
+ matches: RegexMatches,
+
+ /// Key label for each visible match.
+ labels: Vec<Vec<char>>,
+
+ /// Keys pressed for hint selection.
+ keys: Vec<char>,
+}
+
+impl HintState {
+ /// Initialize an inactive hint state.
+ pub fn new<S: Into<String>>(alphabet: S) -> Self {
+ Self {
+ alphabet: alphabet.into(),
+ hint: Default::default(),
+ matches: Default::default(),
+ labels: Default::default(),
+ keys: Default::default(),
+ }
+ }
+
+ /// Check if a hint selection is in progress.
+ pub fn active(&self) -> bool {
+ self.hint.is_some()
+ }
+
+ /// Start the hint selection process.
+ pub fn start(&mut self, hint: Hint) {
+ self.hint = Some(hint);
+ }
+
+ /// Cancel the hint highlighting process.
+ fn stop(&mut self) {
+ self.matches.clear();
+ self.labels.clear();
+ self.keys.clear();
+ self.hint = None;
+ }
+
+ /// Update the visible hint matches and key labels.
+ pub fn update_matches<T>(&mut self, term: &Term<T>) {
+ let hint = match self.hint.as_mut() {
+ Some(hint) => hint,
+ None => return,
+ };
+
+ // Find visible matches.
+ self.matches = hint.regex.with_compiled(|regex| RegexMatches::new(term, regex));
+
+ // Cancel highlight with no visible matches.
+ if self.matches.is_empty() {
+ self.stop();
+ return;
+ }
+
+ let mut generator = HintLabels::new(&self.alphabet, HINT_SPLIT_PERCENTAGE);
+ let match_count = self.matches.len();
+ let keys_len = self.keys.len();
+
+ // Get the label for each match.
+ self.labels.resize(match_count, Vec::new());
+ for i in (0..match_count).rev() {
+ let mut label = generator.next();
+ if label.len() >= keys_len && label[..keys_len] == self.keys[..] {
+ self.labels[i] = label.split_off(keys_len);
+ } else {
+ self.labels[i] = Vec::new();
+ }
+ }
+ }
+
+ /// Handle keyboard input during hint selection.
+ pub fn keyboard_input<T>(&mut self, term: &Term<T>, c: char) {
+ match c {
+ // Use backspace to remove the last character pressed.
+ '\x08' | '\x1f' => {
+ self.keys.pop();
+ },
+ // Cancel hint highlighting on ESC.
+ '\x1b' => self.stop(),
+ _ => (),
+ }
+
+ // Update the visible matches.
+ self.update_matches(term);
+
+ let hint = match self.hint.as_ref() {
+ Some(hint) => hint,
+ None => return,
+ };
+
+ // Find the last label starting with the input character.
+ let mut labels = self.labels.iter().enumerate().rev();
+ let (index, label) = match labels.find(|(_, label)| !label.is_empty() && label[0] == c) {
+ Some(last) => last,
+ None => return,
+ };
+
+ // Check if the selected label is fully matched.
+ if label.len() == 1 {
+ // Get text for the hint's regex match.
+ let hint_match = &self.matches[index];
+ let start = term.visible_to_buffer(*hint_match.start());
+ let end = term.visible_to_buffer(*hint_match.end());
+ let text = term.bounds_to_string(start, end);
+
+ // Append text as last argument and launch command.
+ let program = hint.command.program();
+ let mut args = hint.command.args().to_vec();
+ args.push(text);
+ start_daemon(program, &args);
+
+ self.stop();
+ } else {
+ // Store character to preserve the selection.
+ self.keys.push(c);
+ }
+ }
+
+ /// Hint key labels.
+ pub fn labels(&self) -> &Vec<Vec<char>> {
+ &self.labels
+ }
+
+ /// Visible hint regex matches.
+ pub fn matches(&self) -> &RegexMatches {
+ &self.matches
+ }
+
+ /// Update the alphabet used for hint labels.
+ pub fn update_alphabet(&mut self, alphabet: &str) {
+ if self.alphabet != alphabet {
+ self.alphabet = alphabet.to_owned();
+ self.keys.clear();
+ }
+ }
+}
+
+/// Generator for creating new hint labels.
+struct HintLabels {
+ /// Full character set available.
+ alphabet: Vec<char>,
+
+ /// Alphabet indices for the next label.
+ indices: Vec<usize>,
+
+ /// Point separating the alphabet's head and tail characters.
+ ///
+ /// To make identification of the tail character easy, part of the alphabet cannot be used for
+ /// any other position.
+ ///
+ /// All characters in the alphabet before this index will be used for the last character, while
+ /// the rest will be used for everything else.
+ split_point: usize,
+}
+
+impl HintLabels {
+ /// Create a new label generator.
+ ///
+ /// The `split_ratio` should be a number between 0.0 and 1.0 representing the percentage of
+ /// elements in the alphabet which are reserved for the tail of the hint label.
+ fn new(alphabet: impl Into<String>, split_ratio: f32) -> Self {
+ let alphabet: Vec<char> = alphabet.into().chars().collect();
+ let split_point = ((alphabet.len() - 1) as f32 * split_ratio.min(1.)) as usize;
+
+ Self { indices: vec![0], split_point, alphabet }
+ }
+
+ /// Get the characters for the next label.
+ fn next(&mut self) -> Vec<char> {
+ let characters = self.indices.iter().rev().map(|index| self.alphabet[*index]).collect();
+ self.increment();
+ characters
+ }
+
+ /// Increment the character sequence.
+ fn increment(&mut self) {
+ // Increment the last character; if it's not at the split point we're done.
+ let tail = &mut self.indices[0];
+ if *tail < self.split_point {
+ *tail += 1;
+ return;
+ }
+ *tail = 0;
+
+ // Increment all other characters in reverse order.
+ let alphabet_len = self.alphabet.len();
+ for index in self.indices.iter_mut().skip(1) {
+ if *index + 1 == alphabet_len {
+ // Reset character and move to the next if it's already at the limit.
+ *index = self.split_point + 1;
+ } else {
+ // If the character can be incremented, we're done.
+ *index += 1;
+ return;
+ }
+ }
+
+ // Extend the sequence with another character when nothing could be incremented.
+ self.indices.push(self.split_point + 1);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn hint_label_generation() {
+ let mut generator = HintLabels::new("0123", 0.5);
+
+ assert_eq!(generator.next(), vec!['0']);
+ assert_eq!(generator.next(), vec!['1']);
+
+ assert_eq!(generator.next(), vec!['2', '0']);
+ assert_eq!(generator.next(), vec!['2', '1']);
+ assert_eq!(generator.next(), vec!['3', '0']);
+ assert_eq!(generator.next(), vec!['3', '1']);
+
+ assert_eq!(generator.next(), vec!['2', '2', '0']);
+ assert_eq!(generator.next(), vec!['2', '2', '1']);
+ assert_eq!(generator.next(), vec!['2', '3', '0']);
+ assert_eq!(generator.next(), vec!['2', '3', '1']);
+ assert_eq!(generator.next(), vec!['3', '2', '0']);
+ assert_eq!(generator.next(), vec!['3', '2', '1']);
+ assert_eq!(generator.next(), vec!['3', '3', '0']);
+ assert_eq!(generator.next(), vec!['3', '3', '1']);
+
+ assert_eq!(generator.next(), vec!['2', '2', '2', '0']);
+ assert_eq!(generator.next(), vec!['2', '2', '2', '1']);
+ assert_eq!(generator.next(), vec!['2', '2', '3', '0']);
+ assert_eq!(generator.next(), vec!['2', '2', '3', '1']);
+ assert_eq!(generator.next(), vec!['2', '3', '2', '0']);
+ assert_eq!(generator.next(), vec!['2', '3', '2', '1']);
+ assert_eq!(generator.next(), vec!['2', '3', '3', '0']);
+ assert_eq!(generator.next(), vec!['2', '3', '3', '1']);
+ assert_eq!(generator.next(), vec!['3', '2', '2', '0']);
+ assert_eq!(generator.next(), vec!['3', '2', '2', '1']);
+ assert_eq!(generator.next(), vec!['3', '2', '3', '0']);
+ assert_eq!(generator.next(), vec!['3', '2', '3', '1']);
+ assert_eq!(generator.next(), vec!['3', '3', '2', '0']);
+ assert_eq!(generator.next(), vec!['3', '3', '2', '1']);
+ assert_eq!(generator.next(), vec!['3', '3', '3', '0']);
+ assert_eq!(generator.next(), vec!['3', '3', '3', '1']);
+ }
+}
diff --git a/alacritty/src/display/mod.rs b/alacritty/src/display/mod.rs
index 9c37bd0e..d44013c4 100644
--- a/alacritty/src/display/mod.rs
+++ b/alacritty/src/display/mod.rs
@@ -38,6 +38,7 @@ use crate::display::bell::VisualBell;
use crate::display::color::List;
use crate::display::content::RenderableContent;
use crate::display::cursor::IntoRects;
+use crate::display::hint::HintState;
use crate::display::meter::Meter;
use crate::display::window::Window;
use crate::event::{Mouse, SearchState};
@@ -48,6 +49,7 @@ use crate::url::{Url, Urls};
pub mod content;
pub mod cursor;
+pub mod hint;
pub mod window;
mod bell;
@@ -181,6 +183,9 @@ pub struct Display {
/// Mapped RGB values for each terminal color.
pub colors: List,
+ /// State of the keyboard hints.
+ pub hint_state: HintState,
+
renderer: QuadRenderer,
glyph_cache: GlyphCache,
meter: Meter,
@@ -317,10 +322,13 @@ impl Display {
_ => (),
}
+ let hint_state = HintState::new(config.ui_config.hints.alphabet());
+
Ok(Self {
window,
renderer,
glyph_cache,
+ hint_state,
meter: Meter::new(),
size_info,
urls: Urls::new(),
@@ -474,12 +482,10 @@ impl Display {
let viewport_match = search_state
.focused_match()
.and_then(|focused_match| terminal.grid().clamp_buffer_range_to_visible(focused_match));
- let cursor_hidden = self.cursor_hidden || search_state.regex().is_some();
// Collect renderable content before the terminal is dropped.
- let dfas = search_state.dfas();
- let colors = &self.colors;
- let mut content = RenderableContent::new(&terminal, dfas, config, colors, !cursor_hidden);
+ let search_dfas = search_state.dfas();
+ let mut content = RenderableContent::new(config, self, &terminal, search_dfas);
let mut grid_cells = Vec::new();
while let Some(cell) = content.next() {
grid_cells.push(cell);
diff --git a/alacritty/src/event.rs b/alacritty/src/event.rs
index bed4d5fe..7faf380e 100644
--- a/alacritty/src/event.rs
+++ b/alacritty/src/event.rs
@@ -42,9 +42,9 @@ use alacritty_terminal::tty;
use crate::cli::Options as CLIOptions;
use crate::clipboard::Clipboard;
-use crate::config;
-use crate::config::Config;
+use crate::config::{self, Config};
use crate::daemon::start_daemon;
+use crate::display::hint::HintState;
use crate::display::window::Window;
use crate::display::{Display, DisplayUpdate};
use crate::input::{self, ActionContext as _, FONT_SIZE_STEP};
@@ -61,7 +61,7 @@ pub const TYPING_SEARCH_DELAY: Duration = Duration::from_millis(500);
const MAX_SEARCH_WHILE_TYPING: Option<usize> = Some(1000);
/// Maximum number of search terms stored in the history.
-const MAX_HISTORY_SIZE: usize = 255;
+const MAX_SEARCH_HISTORY_SIZE: usize = 255;
/// Events dispatched through the UI event loop.
#[derive(Debug, Clone)]
@@ -117,10 +117,6 @@ pub struct SearchState {
}
impl SearchState {
- fn new() -> Self {
- Self::default()
- }
-
/// Search regex text if a search is active.
pub fn regex(&self) -> Option<&String> {
self.history_index.and_then(|index| self.history.get(index))
@@ -440,7 +436,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
// Only create new history entry if the previous regex wasn't empty.
if self.search_state.history.get(0).map_or(true, |regex| !regex.is_empty()) {
self.search_state.history.push_front(String::new());
- self.search_state.history.truncate(MAX_HISTORY_SIZE);
+ self.search_state.history.truncate(MAX_SEARCH_HISTORY_SIZE);
}
self.search_state.history_index = Some(0);
@@ -660,6 +656,16 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
}
}
+ fn hint_state(&mut self) -> &mut HintState {
+ &mut self.display.hint_state
+ }
+
+ /// Process a new character for keyboard hints.
+ fn hint_input(&mut self, c: char) {
+ self.display.hint_state.keyboard_input(self.terminal, c);
+ *self.dirty = true;
+ }
+
/// Toggle the vi mode status.
#[inline]
fn toggle_vi_mode(&mut self) {
@@ -951,19 +957,19 @@ impl<N: Notify + OnResize> Processor<N> {
cli_options: CLIOptions,
) -> Processor<N> {
Processor {
- notifier,
- mouse: Default::default(),
- received_count: 0,
- suppress_chars: false,
- modifiers: Default::default(),
font_size: config.ui_config.font.size(),
- config,
message_buffer,
- display,
- event_queue: Vec::new(),
- search_state: SearchState::new(),
cli_options,
- dirty: false,
+ notifier,
+ display,
+ config,
+ received_count: Default::default(),
+ suppress_chars: Default::default(),
+ search_state: Default::default(),
+ event_queue: Default::default(),
+ modifiers: Default::default(),
+ mouse: Default::default(),
+ dirty: Default::default(),
}
}
@@ -1381,6 +1387,9 @@ impl<N: Notify + OnResize> Processor<N> {
#[cfg(target_os = "macos")]
processor.ctx.window_mut().set_has_shadow(config.ui_config.background_opacity() >= 1.0);
+ // Update hint keys.
+ processor.ctx.display.hint_state.update_alphabet(config.ui_config.hints.alphabet());
+
*processor.ctx.config = config;
// Update cursor blinking.
diff --git a/alacritty/src/input.rs b/alacritty/src/input.rs
index 778dffc7..0d6a066d 100644
--- a/alacritty/src/input.rs
+++ b/alacritty/src/input.rs
@@ -10,8 +10,6 @@ use std::cmp::{max, min, Ordering};
use std::marker::PhantomData;
use std::time::{Duration, Instant};
-use log::trace;
-
use glutin::dpi::PhysicalPosition;
use glutin::event::{
ElementState, KeyboardInput, ModifiersState, MouseButton, MouseScrollDelta, TouchPhase,
@@ -31,8 +29,9 @@ use alacritty_terminal::term::{ClipboardType, SizeInfo, Term, TermMode};
use alacritty_terminal::vi_mode::ViMotion;
use crate::clipboard::Clipboard;
-use crate::config::{Action, Binding, BindingMode, Config, Key, SearchAction, ViAction};
+use crate::config::{Action, BindingMode, Config, Key, SearchAction, ViAction};
use crate::daemon::start_daemon;
+use crate::display::hint::HintState;
use crate::display::window::Window;
use crate::event::{ClickState, Event, Mouse, TYPING_SEARCH_DELAY};
use crate::message_bar::{self, Message};
@@ -112,18 +111,8 @@ pub trait ActionContext<T: EventListener> {
fn search_active(&self) -> bool;
fn on_typing_start(&mut self) {}
fn toggle_vi_mode(&mut self) {}
-}
-
-trait Execute<T: EventListener> {
- fn execute<A: ActionContext<T>>(&self, ctx: &mut A);
-}
-
-impl<T, U: EventListener> Execute<U> for Binding<T> {
- /// Execute the action associate with this binding.
- #[inline]
- fn execute<A: ActionContext<U>>(&self, ctx: &mut A) {
- self.action.execute(ctx)
- }
+ fn hint_state(&mut self) -> &mut HintState;
+ fn hint_input(&mut self, _character: char) {}
}
impl Action {
@@ -142,41 +131,43 @@ impl Action {
}
}
+trait Execute<T: EventListener> {
+ fn execute<A: ActionContext<T>>(&self, ctx: &mut A);
+}
+
impl<T: EventListener> Execute<T> for Action {
#[inline]
fn execute<A: ActionContext<T>>(&self, ctx: &mut A) {
- match *self {
- Action::Esc(ref s) => {
+ match self {
+ Action::Esc(s) => {
ctx.on_typing_start();
ctx.clear_selection();
ctx.scroll(Scroll::Bottom);
ctx.write_to_pty(s.clone().into_bytes())
},
- Action::Command(ref program) => {
- let args = program.args();
- let program = program.program();
- trace!("Running command {} with args {:?}", program, args);
-
- start_daemon(program, args);
+ Action::Command(program) => start_daemon(program.program(), program.args()),
+ Action::Hint(hint) => {
+ ctx.hint_state().start(hint.clone());
+ ctx.mark_dirty();
},
Action::ToggleViMode => ctx.toggle_vi_mode(),
Action::ViMotion(motion) => {
ctx.on_typing_start();
- ctx.terminal_mut().vi_motion(motion);
+ ctx.terminal_mut().vi_motion(*motion);
ctx.mark_dirty();
},
Action::ViAction(ViAction::ToggleNormalSelection) => {
- Self::toggle_selection(ctx, SelectionType::Simple)
+ Self::toggle_selection(ctx, SelectionType::Simple);
},
Action::ViAction(ViAction::ToggleLineSelection) => {
- Self::toggle_selection(ctx, SelectionType::Lines)
+ Self::toggle_selection(ctx, SelectionType::Lines);
},
Action::ViAction(ViAction::ToggleBlockSelection) => {
- Self::toggle_selection(ctx, SelectionType::Block)
+ Self::toggle_selection(ctx, SelectionType::Block);
},
Action::ViAction(ViAction::ToggleSemanticSelection) => {
- Self::toggle_selection(ctx, SelectionType::Semantic)
+ Self::toggle_selection(ctx, SelectionType::Semantic);
},
Action::ViAction(ViAction::Open) => {
ctx.mouse_mut().block_url_launcher = false;
@@ -840,6 +831,12 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
/// Process key input.
pub fn key_input(&mut self, input: KeyboardInput) {
+ // All key bindings are disabled while a hint is being selected.
+ if self.ctx.hint_state().active() {
+ *self.ctx.suppress_chars() = false;
+ return;
+ }
+
// Reset search delay when the user is still typing.
if self.ctx.search_active() {
if let Some(timer) = self.ctx.scheduler_mut().get_mut(TimerId::DelayedSearch) {
@@ -876,8 +873,16 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
/// Process a received character.
pub fn received_char(&mut self, c: char) {
let suppress_chars = *self.ctx.suppress_chars();
+
+ // Handle hint selection over anything else.
+ if self.ctx.hint_state().active() && !suppress_chars {
+ self.ctx.hint_input(c);
+ return;
+ }
+
+ // Pass keys to search and ignore them during `suppress_chars`.
let search_active = self.ctx.search_active();
- if suppress_chars || self.ctx.terminal().mode().contains(TermMode::VI) || search_active {
+ if suppress_chars || search_active || self.ctx.terminal().mode().contains(TermMode::VI) {
if search_active && !suppress_chars {
self.ctx.search_input(c);
}
@@ -929,12 +934,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
};
if binding.is_triggered_by(mode, mods, &key) {
- // Binding was triggered; run the action.
- let binding = binding.clone();
- binding.execute(&mut self.ctx);
-
// Pass through the key if any of the bindings has the `ReceiveChar` action.
*suppress_chars.get_or_insert(true) &= binding.action != Action::ReceiveChar;
+
+ // Binding was triggered; run the action.
+ binding.action.clone().execute(&mut self.ctx);
}
}
@@ -960,7 +964,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
}
if binding.is_triggered_by(mode, mods, &button) {
- binding.execute(&mut self.ctx);
+ binding.action.execute(&mut self.ctx);
}
}
}
@@ -1092,6 +1096,7 @@ mod tests {
use alacritty_terminal::event::Event as TerminalEvent;
use alacritty_terminal::selection::Selection;
+ use crate::config::Binding;
use crate::message_bar::MessageBuffer;
const KEY: VirtualKeyCode = VirtualKeyCode::Key0;
@@ -1226,6 +1231,10 @@ mod tests {
fn scheduler_mut(&mut self) -> &mut Scheduler {
unimplemented!();
}
+
+ fn hint_state(&mut self) -> &mut HintState {
+ unimplemented!();
+ }
}
macro_rules! test_clickstate {
diff --git a/alacritty_terminal/src/term/search.rs b/alacritty_terminal/src/term/search.rs
index d4fd61eb..0eba7567 100644
--- a/alacritty_terminal/src/term/search.rs
+++ b/alacritty_terminal/src/term/search.rs
@@ -15,6 +15,7 @@ const BRACKET_PAIRS: [(char, char); 4] = [('(', ')'), ('[', ']'), ('{', '}'), ('
pub type Match = RangeInclusive<Point<usize>>;
/// Terminal regex search state.
+#[derive(Clone, Debug)]
pub struct RegexSearch {
/// Locate end of match searching right.
right_fdfa: DenseDFA<Vec<usize>, usize>,
diff --git a/docs/features.md b/docs/features.md
index 7f621ea2..55f1d91a 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -54,6 +54,15 @@ you can still jump between matches using <kbd>Enter</kbd> and <kbd>Shift</kbd>
<kbd>Enter</kbd>. After leaving search with <kbd>Escape</kbd> your active match
stays selected, allowing you to easily copy it.
+## Hints
+
+Terminal hints allow easily interacting with visible text without having to
+start vi mode. They consist of a regex that detects these text elements and then
+feeds them to an external application.
+
+Hints can be configured in the `hints` and `colors.hints` sections in the
+Alacritty configuration file.
+
## Selection expansion
After making a selection, you can use the right mouse button to expand it.