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, pub search: SearchColors, pub line_indicator: LineIndicatorColors, + pub hints: HintColors, } impl Colors { @@ -34,6 +35,42 @@ pub struct LineIndicatorColors { pub background: Option, } +#[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 { 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, + /// 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 { /// 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, +} + +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(deserializer: D) -> Result + 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>); + +impl LazyRegex { + /// Execute a function with the compiled regex DFAs as parameter. + pub fn with_compiled(&self, f: F) -> T + where + F: Fn(&RegexSearch) -> T, + { + f(self.0.borrow_mut().compiled()) + } +} + +impl<'de> Deserialize<'de> for LazyRegex { + fn deserialize(deserializer: D) -> Result + 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), + 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(®ex) { + 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, - search: RenderableSearch, + search: Regex<'a>, + hint: Hint<'a>, config: &'a Config, colors: &'a List, } impl<'a> RenderableContent<'a> { pub fn new( - term: &'a Term, - dfas: Option<&RegexSearch>, config: &'a Config, - colors: &'a List, - show_cursor: bool, + display: &'a mut Display, + term: &'a Term, + 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>, +/// 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>, } -impl RenderableSearch { - /// Create a new renderable search iterator. +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 } + } +} + +/// Wrapper for finding visible regex matches. +#[derive(Default, Clone)] +pub struct RegexMatches(Vec>); + +impl RegexMatches { + /// Find all visible matches. pub fn new(term: &Term, 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>; + + 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(term: &Term, 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, + + /// Alphabet for hint labels. + alphabet: String, + + /// Visible matches. + matches: RegexMatches, + + /// Key label for each visible match. + labels: Vec>, + + /// Keys pressed for hint selection. + keys: Vec, +} + +impl HintState { + /// Initialize an inactive hint state. + pub fn new>(alphabet: S) -> Self { + Self { + alphabet: alphabet.into(), + hint: Default::default(), + matches: Default::default(), + labels: Default::default(), + keys: Default::default(), + } + } + + /// Check if a hint selection is in progress. + pub fn active(&self) -> bool { + self.hint.is_some() + } + + /// Start the hint selection process. + pub fn start(&mut self, hint: Hint) { + self.hint = Some(hint); + } + + /// Cancel the hint highlighting process. + fn stop(&mut self) { + self.matches.clear(); + self.labels.clear(); + self.keys.clear(); + self.hint = None; + } + + /// Update the visible hint matches and key labels. + pub fn update_matches(&mut self, term: &Term) { + let hint = match self.hint.as_mut() { + Some(hint) => hint, + None => return, + }; + + // 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(&mut self, term: &Term, 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> { + &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, + + /// Alphabet indices for the next label. + indices: Vec, + + /// Point separating the alphabet's head and tail characters. + /// + /// To make identification of the tail character easy, part of the alphabet cannot be used for + /// any other position. + /// + /// All characters in the alphabet before this index will be used for the last character, while + /// the rest will be used for everything else. + split_point: usize, +} + +impl HintLabels { + /// Create a new label generator. + /// + /// The `split_ratio` should be a number between 0.0 and 1.0 representing the percentage of + /// elements in the alphabet which are reserved for the tail of the hint label. + fn new(alphabet: impl Into, split_ratio: f32) -> Self { + let alphabet: Vec = alphabet.into().chars().collect(); + let split_point = ((alphabet.len() - 1) as f32 * split_ratio.min(1.)) as usize; + + Self { indices: vec![0], split_point, alphabet } + } + + /// Get the characters for the next label. + fn next(&mut self) -> Vec { + let characters = self.indices.iter().rev().map(|index| self.alphabet[*index]).collect(); + self.increment(); + characters + } + + /// Increment the character sequence. + fn increment(&mut self) { + // Increment the last character; if it's not at the split point we're done. + let tail = &mut self.indices[0]; + if *tail < self.split_point { + *tail += 1; + return; + } + *tail = 0; + + // Increment all other characters in reverse order. + let alphabet_len = self.alphabet.len(); + for index in self.indices.iter_mut().skip(1) { + if *index + 1 == alphabet_len { + // Reset character and move to the next if it's already at the limit. + *index = self.split_point + 1; + } else { + // If the character can be incremented, we're done. + *index += 1; + return; + } + } + + // Extend the sequence with another character when nothing could be incremented. + self.indices.push(self.split_point + 1); + } +} + +#[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 = 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 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 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 Processor { cli_options: CLIOptions, ) -> Processor { 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 Processor { #[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 { fn search_active(&self) -> bool; fn on_typing_start(&mut self) {} fn toggle_vi_mode(&mut self) {} -} - -trait Execute { - fn execute>(&self, ctx: &mut A); -} - -impl Execute for Binding { - /// Execute the action associate with this binding. - #[inline] - fn execute>(&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 { + fn execute>(&self, ctx: &mut A); +} + impl Execute for Action { #[inline] fn execute>(&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> Processor { /// 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> Processor { /// 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> Processor { }; 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> Processor { } 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>; /// Terminal regex search state. +#[derive(Clone, Debug)] pub struct RegexSearch { /// Locate end of match searching right. right_fdfa: DenseDFA, 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 Enter and Shift Enter. After leaving search with Escape 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.