diff --git a/book/src/themes.md b/book/src/themes.md index 015ec59b3f5e..0d0827fd18e3 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -252,10 +252,14 @@ These scopes are used for theming the editor interface. | `ui.background` | | | `ui.background.separator` | Picker separator below input line | | `ui.cursor` | | +| `ui.cursor.normal` | | | `ui.cursor.insert` | | | `ui.cursor.select` | | | `ui.cursor.match` | Matching bracket etc. | | `ui.cursor.primary` | Cursor with primary selection | +| `ui.cursor.primary.normal` | | +| `ui.cursor.primary.insert` | | +| `ui.cursor.primary.select` | | | `ui.gutter` | Gutter | | `ui.gutter.selected` | Gutter for the line the cursor is on | | `ui.linenr` | Line numbers | diff --git a/helix-core/src/config.rs b/helix-core/src/config.rs deleted file mode 100644 index 2076fc2244df..000000000000 --- a/helix-core/src/config.rs +++ /dev/null @@ -1,10 +0,0 @@ -/// Syntax configuration loader based on built-in languages.toml. -pub fn default_syntax_loader() -> crate::syntax::Configuration { - helix_loader::config::default_lang_config() - .try_into() - .expect("Could not serialize built-in languages.toml") -} -/// Syntax configuration loader based on user configured languages.toml. -pub fn user_syntax_loader() -> Result { - helix_loader::config::user_lang_config()?.try_into() -} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index ee174e69d1cd..4253a9a5092f 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -3,7 +3,6 @@ pub use encoding_rs as encoding; pub mod auto_pairs; pub mod chars; pub mod comment; -pub mod config; pub mod diagnostic; pub mod diff; pub mod graphemes; diff --git a/helix-core/src/macros.rs b/helix-core/src/macros.rs index 1321ea5f25ba..56c8ecbeb6f3 100644 --- a/helix-core/src/macros.rs +++ b/helix-core/src/macros.rs @@ -7,7 +7,7 @@ macro_rules! hashmap { ($($key:expr => $value:expr),*) => { { let _cap = hashmap!(@count $($key),*); - let mut _map = ::std::collections::HashMap::with_capacity(_cap); + let mut _map = std::collections::HashMap::with_capacity(_cap); $( let _ = _map.insert($key, $value); )* diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 41ab23e1343a..3c67e7e5dad4 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -60,13 +60,21 @@ fn default_timeout() -> u64 { } #[derive(Debug, Serialize, Deserialize)] -pub struct Configuration { +pub struct LanguageConfigurations { pub language: Vec, } -impl Default for Configuration { +impl LanguageConfigurations { + /// Attemps to deserialize a merged user configured languages.toml with the repository languages.toml file. + pub fn merged() -> Result { + helix_loader::merged_lang_config()?.try_into() + } +} +impl Default for LanguageConfigurations { fn default() -> Self { - crate::config::default_syntax_loader() + helix_loader::default_lang_configs() + .try_into() + .expect("Failed to deserialize built-in languages.toml into LanguageConfigurations") } } @@ -75,11 +83,11 @@ impl Default for Configuration { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LanguageConfiguration { #[serde(rename = "name")] - pub language_id: String, // c-sharp, rust + pub language_id: String, // c-sharp, rust pub scope: String, // source.rust pub file_types: Vec, // filename extension or ends_with? #[serde(default)] - pub shebangs: Vec, // interpreter(s) associated with language + pub shebangs: Vec, // interpreter(s) associated with language pub roots: Vec, // these indicate project roots <.git, Cargo.toml> pub comment_token: Option, pub max_line_length: Option, @@ -561,7 +569,7 @@ pub struct Loader { } impl Loader { - pub fn new(config: Configuration) -> Self { + pub fn new(config: LanguageConfigurations) -> Self { let mut loader = Self { language_configs: Vec::new(), language_config_ids_by_extension: HashMap::new(), @@ -2278,7 +2286,7 @@ mod test { "#, ); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(LanguageConfigurations { language: vec![] }); let language = get_language("rust").unwrap(); let query = Query::new(language, query_str).unwrap(); @@ -2337,7 +2345,7 @@ mod test { .map(String::from) .collect(); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(LanguageConfigurations { language: vec![] }); let language = get_language("rust").unwrap(); let config = HighlightConfiguration::new( @@ -2440,7 +2448,7 @@ mod test { ) { let source = Rope::from_str(source); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(LanguageConfigurations { language: vec![] }); let language = get_language(language_name).unwrap(); let config = HighlightConfiguration::new(language, "", "", "").unwrap(); diff --git a/helix-loader/src/config.rs b/helix-loader/src/config.rs deleted file mode 100644 index 259b1318ea00..000000000000 --- a/helix-loader/src/config.rs +++ /dev/null @@ -1,42 +0,0 @@ -/// Default built-in languages.toml. -pub fn default_lang_config() -> toml::Value { - toml::from_slice(include_bytes!("../../languages.toml")) - .expect("Could not parse built-in languages.toml to valid toml") -} - -/// User configured languages.toml file, merged with the default config. -pub fn user_lang_config() -> Result { - let config = crate::local_config_dirs() - .into_iter() - .chain([crate::config_dir()].into_iter()) - .map(|path| path.join("languages.toml")) - .filter_map(|file| { - std::fs::read(&file) - .map(|config| toml::from_slice(&config)) - .ok() - }) - .collect::, _>>()? - .into_iter() - .chain([default_lang_config()].into_iter()) - .fold(toml::Value::Table(toml::value::Table::default()), |a, b| { - // combines for example - // b: - // [[language]] - // name = "toml" - // language-server = { command = "taplo", args = ["lsp", "stdio"] } - // - // a: - // [[language]] - // language-server = { command = "/usr/bin/taplo" } - // - // into: - // [[language]] - // name = "toml" - // language-server = { command = "/usr/bin/taplo" } - // - // thus it overrides the third depth-level of b with values of a if they exist, but otherwise merges their values - crate::merge_toml_values(b, a, 3) - }); - - Ok(config) -} diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs index 2aa924755112..b29cd935e0c2 100644 --- a/helix-loader/src/grammar.rs +++ b/helix-loader/src/grammar.rs @@ -191,7 +191,7 @@ pub fn build_grammars(target: Option) -> Result<()> { // merged. The `grammar_selection` key of the config is then used to filter // down all grammars into a subset of the user's choosing. fn get_grammar_configs() -> Result> { - let config: Configuration = crate::config::user_lang_config() + let config: Configuration = crate::merged_lang_config() .context("Could not parse languages.toml")? .try_into()?; @@ -511,7 +511,7 @@ fn mtime(path: &Path) -> Result { /// Gives the contents of a file from a language's `runtime/queries/` /// directory pub fn load_runtime_file(language: &str, filename: &str) -> Result { - let path = crate::RUNTIME_DIR + let path = crate::runtime_dir() .join("queries") .join(language) .join(filename); diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index 80d44a8264b8..3b3e5abd42d4 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -1,113 +1,159 @@ -pub mod config; pub mod grammar; +pub mod ts_probe; +pub mod repo_paths; use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; use std::path::PathBuf; pub const VERSION_AND_GIT_HASH: &str = env!("VERSION_AND_GIT_HASH"); -pub static RUNTIME_DIR: once_cell::sync::Lazy = once_cell::sync::Lazy::new(runtime_dir); - +static RUNTIME_DIR: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); static CONFIG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); +static LOG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + +pub fn config_file() -> PathBuf { + match CONFIG_FILE.get() { + Some(config_path) => config_path.to_path_buf(), + None => { + setup_config_file(None); + config_file() + } + } +} + +pub fn log_file() -> PathBuf { + match LOG_FILE.get() { + Some(log_path) => log_path.to_path_buf(), + None => { + setup_log_file(None); + log_file() + } + } +} -pub fn initialize_config_file(specified_file: Option) { +pub fn setup_config_file(specified_file: Option) { let config_file = specified_file.unwrap_or_else(|| { let config_dir = config_dir(); - if !config_dir.exists() { std::fs::create_dir_all(&config_dir).ok(); } - config_dir.join("config.toml") }); - - // We should only initialize this value once. - CONFIG_FILE.set(config_file).ok(); + CONFIG_FILE.set(config_file).unwrap(); } -pub fn runtime_dir() -> PathBuf { - if let Ok(dir) = std::env::var("HELIX_RUNTIME") { - return dir.into(); - } - - if let Ok(dir) = std::env::var("CARGO_MANIFEST_DIR") { - // this is the directory of the crate being run by cargo, we need the workspace path so we take the parent - let path = std::path::PathBuf::from(dir).parent().unwrap().join(RT_DIR); - log::debug!("runtime dir: {}", path.to_string_lossy()); - return path; - } - - const RT_DIR: &str = "runtime"; - let conf_dir = config_dir().join(RT_DIR); - if conf_dir.exists() { - return conf_dir; - } - - // fallback to location of the executable being run - // canonicalize the path in case the executable is symlinked - std::env::current_exe() - .ok() - .and_then(|path| std::fs::canonicalize(path).ok()) - .and_then(|path| path.parent().map(|path| path.to_path_buf().join(RT_DIR))) - .unwrap() +pub fn setup_log_file(specified_file: Option) { + let log_file = specified_file.unwrap_or_else(|| { + let log_dir = cache_dir(); + if !log_dir.exists() { + std::fs::create_dir_all(&log_dir).ok(); + } + log_dir.join("helix.log") + }); + LOG_FILE.set(log_file).ok(); } pub fn config_dir() -> PathBuf { // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); + let strategy = choose_base_strategy().expect("Unable to determine system base directory specification!"); let mut path = strategy.config_dir(); path.push("helix"); path } -pub fn local_config_dirs() -> Vec { - let directories = find_local_config_dirs() - .into_iter() - .map(|path| path.join(".helix")) - .collect(); - log::debug!("Located configuration folders: {:?}", directories); - directories -} - pub fn cache_dir() -> PathBuf { // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); + let strategy = choose_base_strategy().expect("Unable to determine system base directory specification!"); let mut path = strategy.cache_dir(); path.push("helix"); path } -pub fn config_file() -> PathBuf { - CONFIG_FILE - .get() - .map(|path| path.to_path_buf()) - .unwrap_or_else(|| config_dir().join("config.toml")) +pub fn runtime_dir() -> PathBuf { + if let Some(runtime_dir) = RUNTIME_DIR.get() { + return runtime_dir.to_path_buf(); + } + else { + RUNTIME_DIR.set(_runtime_dir()).unwrap(); + runtime_dir() + } } -pub fn lang_config_file() -> PathBuf { - config_dir().join("languages.toml") -} +/// $HELIX_RUNTIME || config_dir/runtime || repo/runtime (if run by cargo) || executable location +fn _runtime_dir() -> PathBuf { + // TODO: shouldn't it also look for XDG_RUNTIME_DIR? + if let Ok(dir) = std::env::var("HELIX_RUNTIME") { + return dir.into(); + } -pub fn log_file() -> PathBuf { - cache_dir().join("helix.log") + const RT_DIR: &str = "runtime"; + if std::env::var("CARGO_MANIFEST_DIR").is_ok() { + let path = repo_paths::project_root().join(RT_DIR); + log::debug!("runtime dir: {}", path.to_string_lossy()); + return path; + } + + let conf_dir = config_dir().join(RT_DIR); + if conf_dir.exists() { + return conf_dir; + } + + std::env::current_exe().ok() + .and_then(|path| std::fs::canonicalize(path).ok()) + .and_then(|path| path.parent().map(|path| path.to_path_buf().join(RT_DIR))) + .unwrap() } -pub fn find_local_config_dirs() -> Vec { - let current_dir = std::env::current_dir().expect("unable to determine current directory"); +// NOTE: only used for languages.toml files right now +pub fn local_config_dirs() -> Vec { + let current_dir = std::env::current_dir().expect("Unable to determine current directory."); let mut directories = Vec::new(); - for ancestor in current_dir.ancestors() { if ancestor.join(".git").exists() { - directories.push(ancestor.to_path_buf()); + directories.push(ancestor.to_path_buf().join(".helix")); // Don't go higher than repo if we're in one break; } else if ancestor.join(".helix").is_dir() { - directories.push(ancestor.to_path_buf()); + directories.push(ancestor.to_path_buf().join(".helix")); } } + log::debug!("Located langauge configuration folders: {:?}", directories); directories } +pub fn user_lang_config_file() -> PathBuf { + config_dir().join("languages.toml") +} + +/// Default built-in languages.toml. +pub fn default_lang_configs() -> toml::Value { + toml::from_slice(&std::fs::read(repo_paths::default_lang_configs()).unwrap()) + .expect("Could not parse built-in languages.toml to valid toml") +} + +/// Searces for language.toml in config path (user config) and in 'helix' directories +/// in opened git repository (local). Merge order: +/// local -> user config -> default +pub fn merged_lang_config() -> Result { + let config = crate::local_config_dirs() + .into_iter() + .chain([crate::config_dir()].into_iter()) + .map(|path| path.join("languages.toml")) + .filter_map(|file| { + std::fs::read(&file) + .map(|config| toml::from_slice(&config)) + .ok() + }) + .collect::, _>>()? + .into_iter() + .chain([default_lang_configs()].into_iter()) + .fold(toml::Value::Table(toml::value::Table::default()), |a, b| { + crate::merge_toml_values(b, a, 3) + }); + + Ok(config) +} + /// Merge two TOML documents, merging values from `right` onto `left` /// /// When an array exists in both `left` and `right`, `right`'s array is @@ -121,6 +167,22 @@ pub fn find_local_config_dirs() -> Vec { /// documents that use a top-level array of values like the `languages.toml`, /// where one usually wants to override or add to the array instead of /// replacing it altogether. +/// +/// For example: +/// +/// left: +/// [[language]] +/// name = "toml" +/// language-server = { command = "taplo", args = ["lsp", "stdio"] } +/// +/// right: +/// [[language]] +/// language-server = { command = "/usr/bin/taplo" } +/// +/// result: +/// [[language]] +/// name = "toml" +/// language-server = { command = "/usr/bin/taplo" } pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usize) -> toml::Value { use toml::Value; diff --git a/helix-loader/src/repo_paths.rs b/helix-loader/src/repo_paths.rs new file mode 100644 index 000000000000..0c40181a5b27 --- /dev/null +++ b/helix-loader/src/repo_paths.rs @@ -0,0 +1,35 @@ +use std::path::{Path, PathBuf}; + +pub fn project_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent().unwrap().to_path_buf() +} + +pub fn book_gen() -> PathBuf { + project_root().join("book/src/generated/") +} + +pub fn ts_queries() -> PathBuf { + project_root().join("runtime/queries") +} + +pub fn themes() -> PathBuf { + project_root().join("runtime/themes") +} + +pub fn default_config_dir() -> PathBuf { + // TODO: would be nice to move config files away from project root folder + project_root() +} + +pub fn default_lang_configs() -> PathBuf { + default_config_dir().join("languages.toml") +} + +pub fn default_theme() -> PathBuf { + default_config_dir().join("theme.toml") +} + +pub fn default_base16_theme() -> PathBuf { + default_config_dir().join("base16_theme.toml") +} \ No newline at end of file diff --git a/helix-loader/src/ts_probe.rs b/helix-loader/src/ts_probe.rs new file mode 100644 index 000000000000..f80900958ae7 --- /dev/null +++ b/helix-loader/src/ts_probe.rs @@ -0,0 +1,39 @@ +// NOTE: currently not making use of folds, injections, locals, tags. +// (fd --hidden --glob *.scm --exec basename {} \; | sort | uniq) +/// Helper functions for probing Tree-sitter language support in Helix +#[derive(Copy, Clone)] +pub enum TsFeature { + Highlight, + TextObject, + AutoIndent, +} + +impl TsFeature { + pub fn all() -> &'static [Self] { + &[Self::Highlight, Self::TextObject, Self::AutoIndent] + } + + pub fn runtime_filename(&self) -> &'static str { + match *self { + Self::Highlight => "highlights.scm", + Self::TextObject => "textobjects.scm", + Self::AutoIndent => "indents.scm", + } + } + + pub fn long_title(&self) -> &'static str { + match *self { + Self::Highlight => "Syntax Highlighting", + Self::TextObject => "Treesitter Textobjects", + Self::AutoIndent => "Auto Indent", + } + } + + pub fn short_title(&self) -> &'static str { + match *self { + Self::Highlight => "Highlight", + Self::TextObject => "Textobject", + Self::AutoIndent => "Indent", + } + } +} \ No newline at end of file diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index c0cbc2451483..f9c84f9a5f7b 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,9 +1,7 @@ -use arc_swap::{access::Map, ArcSwap}; -use futures_util::Stream; use helix_core::{ diagnostic::{DiagnosticTag, NumberOrString}, path::get_relative_path, - pos_at_coords, syntax, Selection, + pos_at_coords, syntax::{self, LanguageConfigurations}, Selection, }; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; use helix_view::{ @@ -15,28 +13,26 @@ use helix_view::{ tree::Layout, Align, Editor, }; -use serde_json::json; -use tui::backend::Backend; - use crate::{ args::Args, commands::apply_workspace_edit, compositor::{Compositor, Event}, config::Config, job::Jobs, - keymap::Keymaps, + keymap::keymaps::Keymaps, ui::{self, overlay::overlayed}, }; - -use log::{debug, error, warn}; use std::{ io::{stdin, stdout, Write}, sync::Arc, time::{Duration, Instant}, }; - +use arc_swap::{access::Map, ArcSwap}; +use futures_util::Stream; +use log::{debug, error, warn}; use anyhow::{Context, Error}; - +use serde_json::json; +use tui::backend::Backend; use crossterm::{ event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -45,6 +41,7 @@ use crossterm::{ execute, terminal, tty::IsTty, }; + #[cfg(not(windows))] use { signal_hook::{consts::signal, low_level}, @@ -77,7 +74,7 @@ pub struct Application { #[allow(dead_code)] theme_loader: Arc, #[allow(dead_code)] - syn_loader: Arc, + lang_configs_loader: Arc, signals: Signals, jobs: Jobs, @@ -128,7 +125,7 @@ impl Application { pub fn new( args: Args, config: Config, - syn_loader_conf: syntax::Configuration, + langauge_configurations: syntax::LanguageConfigurations, ) -> Result { #[cfg(feature = "integration")] setup_integration_logging(); @@ -156,7 +153,7 @@ impl Application { }) .unwrap_or_else(|| theme_loader.default_theme(true_color)); - let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); + let lang_configs_loader = std::sync::Arc::new(syntax::Loader::new(langauge_configurations)); #[cfg(not(feature = "integration"))] let backend = CrosstermBackend::new(stdout()); @@ -171,7 +168,7 @@ impl Application { let mut editor = Editor::new( area, theme_loader.clone(), - syn_loader.clone(), + lang_configs_loader.clone(), Box::new(Map::new(Arc::clone(&config), |config: &Config| { &config.editor })), @@ -266,7 +263,7 @@ impl Application { config, theme_loader, - syn_loader, + lang_configs_loader, signals, jobs: Jobs::new(), @@ -399,32 +396,13 @@ impl Application { /// refresh language config after config change fn refresh_language_config(&mut self) -> Result<(), Error> { - let syntax_config = helix_core::config::user_syntax_loader() + let language_configs = LanguageConfigurations::merged() .map_err(|err| anyhow::anyhow!("Failed to load language config: {}", err))?; - self.syn_loader = std::sync::Arc::new(syntax::Loader::new(syntax_config)); - self.editor.syn_loader = self.syn_loader.clone(); + self.lang_configs_loader = std::sync::Arc::new(syntax::Loader::new(language_configs)); + self.editor.lang_configs_loader = self.lang_configs_loader.clone(); for document in self.editor.documents.values_mut() { - document.detect_language(self.syn_loader.clone()); - } - - Ok(()) - } - - /// Refresh theme after config change - fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> { - if let Some(theme) = config.theme.clone() { - let true_color = self.true_color(); - let theme = self - .theme_loader - .load(&theme) - .map_err(|err| anyhow::anyhow!("Failed to load theme `{}`: {}", theme, err))?; - - if true_color || theme.is_16_color() { - self.editor.set_theme(theme); - } else { - anyhow::bail!("theme requires truecolor support, which is not available") - } + document.detect_language(self.lang_configs_loader.clone()); } Ok(()) @@ -432,29 +410,32 @@ impl Application { fn refresh_config(&mut self) { let mut refresh_config = || -> Result<(), Error> { - let default_config = Config::load_default() + let merged_user_config = Config::merged() .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; self.refresh_language_config()?; - self.refresh_theme(&default_config)?; - // Store new config - self.config.store(Arc::new(default_config)); + + if let Some(theme) = &self.config.load().theme { + let true_color = self.config.load().editor.true_color || crate::true_color(); + let theme = self.theme_loader.load(theme) + .map_err(|err| anyhow::anyhow!("Failed to load theme `{}`: {}", theme, err))?; + + if true_color || theme.is_16_color() { + self.editor.set_theme(theme); + } else { + anyhow::bail!("Theme requires truecolor support, which is not available!") + } + } + + self.config.store(Arc::new(merged_user_config)); Ok(()) }; match refresh_config() { - Ok(_) => { - self.editor.set_status("Config refreshed"); - } - Err(err) => { - self.editor.set_error(err.to_string()); - } + Ok(_) => { self.editor.set_status("Config refreshed"); }, + Err(err) => { self.editor.set_error(err.to_string()); } } } - fn true_color(&self) -> bool { - self.config.load().editor.true_color || crate::true_color() - } - #[cfg(windows)] // no signal handling available on windows pub async fn handle_signals(&mut self, _signal: ()) {} @@ -545,7 +526,7 @@ impl Application { return; } - let loader = self.editor.syn_loader.clone(); + let loader = self.editor.lang_configs_loader.clone(); // borrowing the same doc again to get around the borrow checker let doc = doc_mut!(self.editor, &doc_save_event.doc_id); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 7df53a48a396..7b8ec078b93d 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3,11 +3,11 @@ pub(crate) mod lsp; pub(crate) mod typed; pub use dap::*; -use helix_vcs::Hunk; pub use lsp::*; -use tui::text::Spans; +use tui::widgets::{Row, Cell}; pub use typed::*; +use helix_vcs::Hunk; use helix_core::{ comment, coords_at_pos, encoding, find_first_non_whitespace_char, find_root, graphemes, history::UndoKind, @@ -15,7 +15,7 @@ use helix_core::{ indent::IndentStyle, line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, match_brackets, - movement::{self, Direction}, + movement::{self, Direction, Movement}, object, pos_at_coords, pos_at_visual_coords, regex::{self, Regex, RegexBuilder}, search::{self, CharMatcher}, @@ -37,33 +37,27 @@ use helix_view::{ view::View, Document, DocumentId, Editor, ViewId, }; - -use anyhow::{anyhow, bail, ensure, Context as _}; -use fuzzy_matcher::FuzzyMatcher; -use insert::*; -use movement::Movement; - use crate::{ + commands::insert::*, args, compositor::{self, Component, Compositor}, - job::Callback, - keymap::ReverseKeymap, + job::{Callback, self, Jobs}, + keymap::CommandList, ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent}, }; - -use crate::job::{self, Jobs}; -use futures_util::StreamExt; -use std::{collections::HashMap, fmt, future::Future}; -use std::{collections::HashSet, num::NonZeroUsize}; - use std::{ + collections::{HashMap, HashSet}, + num::NonZeroUsize, + future::Future, borrow::Cow, path::{Path, PathBuf}, + fmt, }; - +use anyhow::{anyhow, bail, ensure, Context as _}; +use fuzzy_matcher::FuzzyMatcher; +use futures_util::StreamExt; use once_cell::sync::Lazy; use serde::de::{self, Deserialize, Deserializer}; - use grep_regex::RegexMatcherBuilder; use grep_searcher::{sinks, BinaryDetection, SearcherBuilder}; use ignore::{DirEntry, WalkBuilder, WalkState}; @@ -134,23 +128,23 @@ pub enum MappableCommand { Typable { name: String, args: Vec, - doc: String, + description: String, }, Static { name: &'static str, fun: fn(cx: &mut Context), - doc: &'static str, + description: &'static str, }, } macro_rules! static_commands { - ( $($name:ident, $doc:literal,)* ) => { + ( $($name:ident, $description:literal,)* ) => { $( #[allow(non_upper_case_globals)] pub const $name: Self = Self::Static { name: stringify!($name), fun: $name, - doc: $doc + description: $description }; )* @@ -163,7 +157,7 @@ macro_rules! static_commands { impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { - Self::Typable { name, args, doc: _ } => { + Self::Typable { name, args, description: _ } => { let args: Vec> = args.iter().map(Cow::from).collect(); if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { @@ -187,10 +181,10 @@ impl MappableCommand { } } - pub fn doc(&self) -> &str { + pub fn description(&self) -> &str { match &self { - Self::Typable { doc, .. } => doc, - Self::Static { doc, .. } => doc, + Self::Typable { description, .. } => description, + Self::Static { description, .. } => description, } } @@ -476,7 +470,7 @@ impl std::str::FromStr for MappableCommand { .get(name) .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), - doc: format!(":{} {:?}", cmd.name, args), + description: format!(":{} {:?}", cmd.name, args), args, }) .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) @@ -1863,7 +1857,7 @@ fn global_search(cx: &mut Context) { impl ui::menu::Item for FileResult { type Data = Option; - fn label(&self, current_path: &Self::Data) -> Spans { + fn format(&self, current_path: &Self::Data) -> Row { let relative_path = helix_core::path::get_relative_path(&self.path) .to_string_lossy() .into_owned(); @@ -2311,7 +2305,7 @@ fn buffer_picker(cx: &mut Context) { impl ui::menu::Item for BufferMeta { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { let path = self .path .as_deref() @@ -2380,7 +2374,7 @@ fn jumplist_picker(cx: &mut Context) { impl ui::menu::Item for JumpMeta { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { let path = self .path .as_deref() @@ -2450,31 +2444,43 @@ fn jumplist_picker(cx: &mut Context) { cx.push_layer(Box::new(overlayed(picker))); } + + + +// NOTE: does not present aliases impl ui::menu::Item for MappableCommand { - type Data = ReverseKeymap; + type Data = CommandList; - fn label(&self, keymap: &Self::Data) -> Spans { - let fmt_binding = |bindings: &Vec>| -> String { - bindings.iter().fold(String::new(), |mut acc, bind| { - if !acc.is_empty() { - acc.push(' '); + fn format(&self, command_list: &Self::Data) -> Row { + match self { + MappableCommand::Typable { description: doc, name, .. } => { + let mut row: Vec = vec![Cell::from(&*name.as_str()), Cell::from(""), Cell::from(&*doc.as_str())]; + match command_list.get(name as &String) { + Some(key_events) => { row[1] = Cell::from(format_key_events(key_events)); }, + None => {} } - for key in bind { - acc.push_str(&key.key_sequence_format()); + return Row::new(row); + }, + MappableCommand::Static { description: doc, name, .. } => { + let mut row: Vec = vec![Cell::from(*name), Cell::from(""), Cell::from(*doc)]; + match command_list.get(*name) { + Some(key_events) => { row[1] = Cell::from(format_key_events(key_events)); }, + None => {} } - acc - }) - }; + return Row::new(row) + } + } - match self { - MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { - Some(bindings) => format!("{} ({}) [:{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [:{}]", doc, name).into(), - }, - MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { - Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [{}]", doc, name).into(), - }, + // TODO: Generalize into a Vec Display implemention? + fn format_key_events(key_events: &Vec) -> String { + let mut result_string: String = String::new(); + for key_event in key_events { + if !result_string.is_empty() { + result_string.push_str(", "); + } + result_string.push_str(key_event); + } + result_string } } } @@ -2482,20 +2488,20 @@ impl ui::menu::Item for MappableCommand { pub fn command_palette(cx: &mut Context) { cx.callback = Some(Box::new( move |compositor: &mut Compositor, cx: &mut compositor::Context| { - let keymap = compositor.find::().unwrap().keymaps.map() + let keymap_command_lists = compositor.find::().unwrap().keymaps.load_keymaps() [&cx.editor.mode] - .reverse_map(); + .command_list(); let mut commands: Vec = MappableCommand::STATIC_COMMAND_LIST.into(); commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { MappableCommand::Typable { name: cmd.name.to_owned(), - doc: cmd.doc.to_owned(), + description: cmd.doc.to_owned(), args: Vec::new(), } })); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let picker = Picker::new(commands, keymap_command_lists, move |cx, command, _action| { let mut ctx = Context { register: None, count: std::num::NonZeroUsize::new(1), @@ -2524,6 +2530,7 @@ pub fn command_palette(cx: &mut Context) { compositor.push(Box::new(overlayed(picker))); }, )); + } fn last_picker(cx: &mut Context) { diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index b182f28c4284..b3166e395d90 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -12,7 +12,7 @@ use helix_view::editor::Breakpoint; use serde_json::{to_value, Value}; use tokio_stream::wrappers::UnboundedReceiverStream; -use tui::text::Spans; +use tui::{text::Spans, widgets::Row}; use std::collections::HashMap; use std::future::Future; @@ -25,7 +25,7 @@ use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select impl ui::menu::Item for StackFrame { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { self.name.as_str().into() // TODO: include thread_states in the label } } @@ -33,7 +33,7 @@ impl ui::menu::Item for StackFrame { impl ui::menu::Item for DebugTemplate { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { self.name.as_str().into() } } @@ -41,7 +41,7 @@ impl ui::menu::Item for DebugTemplate { impl ui::menu::Item for Thread { type Data = ThreadStates; - fn label(&self, thread_states: &Self::Data) -> Spans { + fn format(&self, thread_states: &Self::Data) -> Row { format!( "{} ({})", self.name, diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 86b0c5fa7417..77487e52fed3 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -5,7 +5,10 @@ use helix_lsp::{ util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, OffsetEncoding, }; -use tui::text::{Span, Spans}; +use tui::{ + text::{Span, Spans}, + widgets::Row, +}; use super::{align_view, push_jump, Align, Context, Editor, Open}; @@ -46,7 +49,7 @@ impl ui::menu::Item for lsp::Location { /// Current working directory. type Data = PathBuf; - fn label(&self, cwdir: &Self::Data) -> Spans { + fn format(&self, cwdir: &Self::Data) -> Row { // The preallocation here will overallocate a few characters since it will account for the // URL's scheme, which is not used most of the time since that scheme will be "file://". // Those extra chars will be used to avoid allocating when writing the line number (in the @@ -80,7 +83,7 @@ impl ui::menu::Item for lsp::SymbolInformation { /// Path to currently focussed document type Data = Option; - fn label(&self, current_doc_path: &Self::Data) -> Spans { + fn format(&self, current_doc_path: &Self::Data) -> Row { if current_doc_path.as_ref() == Some(&self.location.uri) { self.name.as_str().into() } else { @@ -110,7 +113,7 @@ struct PickerDiagnostic { impl ui::menu::Item for PickerDiagnostic { type Data = (DiagnosticStyles, DiagnosticsFormat); - fn label(&self, (styles, format): &Self::Data) -> Spans { + fn format(&self, (styles, format): &Self::Data) -> Row { let mut style = self .diag .severity @@ -149,6 +152,7 @@ impl ui::menu::Item for PickerDiagnostic { Span::styled(&self.diag.message, style), Span::styled(code, style), ]) + .into() } } @@ -467,7 +471,7 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) { impl ui::menu::Item for lsp::CodeActionOrCommand { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { match self { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), @@ -662,7 +666,7 @@ pub fn code_action(cx: &mut Context) { impl ui::menu::Item for lsp::Command { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { self.title.as_str().into() } } @@ -1093,7 +1097,7 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { let mut contents = SignatureHelp::new( signature.label.clone(), language.to_string(), - Arc::clone(&editor.syn_loader), + Arc::clone(&editor.lang_configs_loader), ); let signature_doc = if config.lsp.display_signature_help_docs { @@ -1189,7 +1193,7 @@ pub fn hover(cx: &mut Context) { // skip if contents empty - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let contents = ui::Markdown::new(contents, editor.lang_configs_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index de24c4fba5ca..7bb7e214809e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1246,7 +1246,7 @@ fn tree_sitter_scopes( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let contents = ui::Markdown::new(contents, editor.lang_configs_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); }, @@ -1539,7 +1539,7 @@ fn language( if args[0] == "text" { doc.set_language(None, None) } else { - doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?; + doc.set_language_by_language_id(&args[0], cx.editor.lang_configs_loader.clone())?; } doc.detect_indent_and_line_ending(); @@ -1675,7 +1675,7 @@ fn tree_sitter_subtree( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let contents = ui::Markdown::new(contents, editor.lang_configs_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); }, @@ -1808,7 +1808,7 @@ fn run_shell_command( move |editor: &mut Editor, compositor: &mut Compositor| { let contents = ui::Markdown::new( format!("```sh\n{}\n```", output), - editor.syn_loader.clone(), + editor.lang_configs_loader.clone(), ); let popup = Popup::new("shell", contents).position(Some( helix_core::Position::new(editor.cursor().0.unwrap_or_default().row, 2), diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 4407a882f838..5264c1adc6dc 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -1,73 +1,63 @@ -use crate::keymap::{default::default, merge_keys, Keymap}; +use crate::keymap::{default, Keymap}; use helix_view::document::Mode; use serde::Deserialize; use std::collections::HashMap; -use std::fmt::Display; -use std::io::Error as IOError; -use std::path::PathBuf; -use toml::de::Error as TomlError; +use anyhow::{Error, anyhow}; #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { pub theme: Option, - #[serde(default = "default")] + #[serde(default = "default::default")] pub keys: HashMap, #[serde(default)] pub editor: helix_view::editor::Config, } -impl Default for Config { - fn default() -> Config { - Config { - theme: None, - keys: default(), - editor: helix_view::editor::Config::default(), - } +impl Config { + pub fn merged() -> Result { + let config_string = std::fs::read_to_string(helix_loader::config_file())?; + toml::from_str(&config_string) + .map(|config: Config| config.merge_in_default_keymap()) + .map_err(|error| anyhow!("{}", error)) } -} - -#[derive(Debug)] -pub enum ConfigLoadError { - BadConfig(TomlError), - Error(IOError), -} -impl Display for ConfigLoadError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ConfigLoadError::BadConfig(err) => err.fmt(f), - ConfigLoadError::Error(err) => err.fmt(f), + pub fn merge_in_default_keymap(mut self) -> Self { + let mut delta = std::mem::replace(&mut self.keys, default::default()); + for (mode, keys) in &mut self.keys { + keys.merge_keytrie(delta.remove(mode).unwrap_or_default().root_node) } + self } } -impl Config { - pub fn load(config_path: PathBuf) -> Result { - match std::fs::read_to_string(config_path) { - Ok(config) => toml::from_str(&config) - .map(merge_keys) - .map_err(ConfigLoadError::BadConfig), - Err(err) => Err(ConfigLoadError::Error(err)), +impl Default for Config { + fn default() -> Config { + Config { + theme: None, + keys: default::default(), + editor: helix_view::editor::Config::default(), } } - - pub fn load_default() -> Result { - Config::load(helix_loader::config_file()) - } } - + #[cfg(test)] mod tests { - use super::*; + use crate::{ + commands::MappableCommand, + config::Config, + keymap::{ + default, + keytrienode::KeyTrieNode, + macros::*, + Keymap, + }, + }; + use helix_core::hashmap; + use helix_view::document::Mode; #[test] - fn parsing_keymaps_config_file() { - use crate::keymap; - use crate::keymap::Keymap; - use helix_core::hashmap; - use helix_view::document::Mode; - + fn parses_keymap_from_toml() { let sample_keymaps = r#" [keys.insert] y = "move_line_down" @@ -81,11 +71,11 @@ mod tests { toml::from_str::(sample_keymaps).unwrap(), Config { keys: hashmap! { - Mode::Insert => Keymap::new(keymap!({ "Insert mode" + Mode::Insert => Keymap::new(keytrie!({ "Insert mode" "y" => move_line_down, "S-C-a" => delete_selection, })), - Mode::Normal => Keymap::new(keymap!({ "Normal mode" + Mode::Normal => Keymap::new(keytrie!({ "Normal mode" "A-F12" => move_next_word_end, })), }, @@ -98,10 +88,76 @@ mod tests { fn keys_resolve_to_correct_defaults() { // From serde default let default_keys = toml::from_str::("").unwrap().keys; - assert_eq!(default_keys, default()); + assert_eq!(default_keys, default::default()); // From the Default trait let default_keys = Config::default().keys; - assert_eq!(default_keys, default()); + assert_eq!(default_keys, default::default()); + } + + #[test] + fn user_config_merges_with_default() { + let user_config = Config { + keys: hashmap! { + Mode::Normal => Keymap::new( + keytrie!({ "Normal mode" + "i" => normal_mode, + "无" => insert_mode, + "z" => jump_backward, + "g" => { "Merge into goto mode" + "$" => goto_line_end, + "g" => delete_char_forward, + }, + }) + ) + }, + ..Default::default() + }; + let mut merged_config = user_config.clone().merge_in_default_keymap(); + assert_ne!( + user_config, + merged_config, + "Merged user keymap with default should differ from user keymap." + ); + + let keymap_normal_root_key_trie = &merged_config.keys.get_mut(&Mode::Normal).unwrap().root_node; + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('i')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::normal_mode), + "User supplied mappable command should ovveride default mappable command bound to the same key event." + ); + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('无')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::insert_mode), + "User supplied mappable command of new key event should be present in merged keymap." + ); + // Assumes that z is a node in the default keymap + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('z')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::jump_backward), + "User supplied mappable command should replace a sub keytrie from default keymap bound to the same key event." + ); + // Assumes that `g` is a sub key trie in default keymap + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('g'), key!('$')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::goto_line_end), + "User supplied mappable command should be inserted under the correct sub keytrie." + ); + // Assumes that `gg` is in default keymap + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('g'), key!('g')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::delete_char_forward), + "User supplied mappable command should replace default even in sub keytries." + ); + // Assumes that `ge` is in default keymap + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('g'), key!('e')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::goto_last_line), + "Default mappable commands that aren't ovveridden should exist in merged keymap." + ); + + // Huh? + assert!(merged_config.keys.get(&Mode::Normal).unwrap().len() > 1); + assert!(merged_config.keys.get(&Mode::Insert).unwrap().len() > 0); } } diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index 6558fe19fb4c..95d43b72352b 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -1,73 +1,36 @@ -use crossterm::{ - style::{Color, Print, Stylize}, - tty::IsTty, -}; -use helix_core::config::{default_syntax_loader, user_syntax_loader}; -use helix_loader::grammar::load_runtime_file; -use helix_view::clipboard::get_clipboard_provider; +use crossterm::{style::{Color, Print, Stylize}, tty::IsTty}; +use helix_loader::ts_probe::TsFeature; +use helix_loader::grammar; +use helix_core::syntax::{LanguageConfigurations, LanguageConfiguration}; +use helix_view::clipboard; use std::io::Write; -#[derive(Copy, Clone)] -pub enum TsFeature { - Highlight, - TextObject, - AutoIndent, -} - -impl TsFeature { - pub fn all() -> &'static [Self] { - &[Self::Highlight, Self::TextObject, Self::AutoIndent] - } - - pub fn runtime_filename(&self) -> &'static str { - match *self { - Self::Highlight => "highlights.scm", - Self::TextObject => "textobjects.scm", - Self::AutoIndent => "indents.scm", - } - } - - pub fn long_title(&self) -> &'static str { - match *self { - Self::Highlight => "Syntax Highlighting", - Self::TextObject => "Treesitter Textobjects", - Self::AutoIndent => "Auto Indent", - } - } - - pub fn short_title(&self) -> &'static str { - match *self { - Self::Highlight => "Highlight", - Self::TextObject => "Textobject", - Self::AutoIndent => "Indent", +pub fn print_health(health_arg: Option) -> std::io::Result<()> { + match health_arg.as_deref() { + None => { + display_paths()?; + display_clipboard()?; + writeln!(std::io::stdout().lock())?; + display_all_languages()?; } + Some("paths") => display_paths()?, + Some("clipboard") => display_clipboard()?, + Some("languages") => display_all_languages()?, + Some(lang) => display_language(lang.to_string())?, } + Ok(()) } -/// Display general diagnostics. -pub fn general() -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); +fn display_paths() -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); - let config_file = helix_loader::config_file(); - let lang_file = helix_loader::lang_config_file(); - let log_file = helix_loader::log_file(); - let rt_dir = helix_loader::runtime_dir(); - let clipboard_provider = get_clipboard_provider(); + writeln!(stdout, "Default config merged with user preferences supplied in:")?; + writeln!(stdout, "Config: {}", helix_loader::config_file().display())?; + writeln!(stdout, "Language config: {}", helix_loader::user_lang_config_file().display())?; + writeln!(stdout, "Log file: {}", helix_loader::log_file().display())?; - if config_file.exists() { - writeln!(stdout, "Config file: {}", config_file.display())?; - } else { - writeln!(stdout, "Config file: default")?; - } - if lang_file.exists() { - writeln!(stdout, "Language file: {}", lang_file.display())?; - } else { - writeln!(stdout, "Language file: default")?; - } - writeln!(stdout, "Log file: {}", log_file.display())?; + let rt_dir = helix_loader::runtime_dir(); writeln!(stdout, "Runtime directory: {}", rt_dir.display())?; - if let Ok(path) = std::fs::read_link(&rt_dir) { let msg = format!("Runtime directory is symlinked to {}", path.display()); writeln!(stdout, "{}", msg.yellow())?; @@ -78,245 +41,137 @@ pub fn general() -> std::io::Result<()> { if rt_dir.read_dir().ok().map(|it| it.count()) == Some(0) { writeln!(stdout, "{}", "Runtime directory is empty.".red())?; } - writeln!(stdout, "Clipboard provider: {}", clipboard_provider.name())?; Ok(()) } -pub fn clipboard() -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let board = get_clipboard_provider(); - match board.name().as_ref() { +fn display_clipboard() -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); + let clipboard = clipboard::get_clipboard_provider(); + match clipboard.name().as_ref() { "none" => { - writeln!( - stdout, - "{}", - "System clipboard provider: Not installed".red() - )?; - writeln!( - stdout, - " {}", - "For troubleshooting system clipboard issues, refer".red() - )?; - writeln!(stdout, " {}", - "https://github.com/helix-editor/helix/wiki/Troubleshooting#copypaste-fromto-system-clipboard-not-working" - .red().underlined())?; + writeln!(stdout, "{}", "No system clipboard provider installed, refer to:".red())?; + let link = "https://github.com/helix-editor/helix/wiki/Troubleshooting#copypaste-fromto-system-clipboard-not-working"; + writeln!(stdout, "{}", link.red().underlined())?; } name => writeln!(stdout, "System clipboard provider: {}", name)?, } - Ok(()) } -pub fn languages_all() -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); +fn load_merged_language_configurations() -> std::io::Result> { + LanguageConfigurations::merged().or_else(|err| { + let mut stderr = std::io::stderr().lock(); + writeln!(stderr,"{}: {}","Error parsing user language config".red(),err)?; + writeln!(stderr, "{}", "Using default language config".yellow())?; + Ok(LanguageConfigurations::default()) + }) + .map(|lang_configs| lang_configs.language) +} - let mut syn_loader_conf = match user_syntax_loader() { - Ok(conf) => conf, - Err(err) => { - let stderr = std::io::stderr(); - let mut stderr = stderr.lock(); +fn display_language(lang_str: String) -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); - writeln!( - stderr, - "{}: {}", - "Error parsing user language config".red(), - err - )?; - writeln!(stderr, "{}", "Using default language config".yellow())?; - default_syntax_loader() + let language_configurations = load_merged_language_configurations()?; + let lang = match language_configurations.iter().find(|l| l.language_id == lang_str) { + Some(found_language) => found_language, + None => { + writeln!(stdout, "{}", format!("Language '{lang_str}' not found").red())?; + let suggestions: Vec<&str> = language_configurations.iter() + .filter(|l| l.language_id.starts_with(lang_str.chars().next().unwrap())) + .map(|l| l.language_id.as_str()) + .collect(); + if !suggestions.is_empty() { + let suggestions = suggestions.join(", "); + writeln!(stdout,"Did you mean one of these: {} ?",suggestions.yellow())?; + } + return Ok(()); } }; - let mut headings = vec!["Language", "LSP", "DAP"]; + let probe_protocol = |protocol_name: &str, server_cmd: Option| -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); + match server_cmd { + Some(server_cmd) => { + writeln!(stdout, "Configured {protocol_name}: {}", server_cmd.clone().green())?; + let result = match which::which(&server_cmd) { + Ok(path) => path.display().to_string().green(), + Err(_) => format!("Not found in $PATH").red() + }; + writeln!(stdout, "Binary for {server_cmd}: {result}")? + }, + None => writeln!(stdout, "Configured {protocol_name}: {}", "None".yellow())? + }; + Ok(()) + }; + + probe_protocol("language server",lang.language_server.as_ref() + .map(|lsp| lsp.command.to_string()))?; + probe_protocol("debug adapter",lang.debugger.as_ref() + .map(|dap| dap.command.to_string()))?; - for feat in TsFeature::all() { - headings.push(feat.short_title()) + for feature in TsFeature::all() { + let supported = match grammar::load_runtime_file(&lang.language_id, feature.runtime_filename()).is_ok() { + true => "✓".green(), + false => "✗".red(), + }; + writeln!(stdout, "{} queries: {supported}", feature.short_title())?; + } + Ok(()) +} + +fn display_all_languages() -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); + + let mut column_headers = vec!["Language", "LSP", "DAP"]; + for treesitter_feature in TsFeature::all() { + column_headers.push(treesitter_feature.short_title()) } - let terminal_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80); - let column_width = terminal_cols as usize / headings.len(); - let is_terminal = std::io::stdout().is_tty(); + let column_width = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80) as usize / column_headers.len(); + let print_column = |item: &str, color: Color| { + let mut data = format!("{:column_width$}", item + .get(..column_width - 2) + .map(|s| format!("{}…", s)) + .unwrap_or_else(|| item.to_string())); - let column = |item: &str, color: Color| { - let mut data = format!( - "{:width$}", - item.get(..column_width - 2) - .map(|s| format!("{}…", s)) - .unwrap_or_else(|| item.to_string()), - width = column_width, - ); - if is_terminal { + if std::io::stdout().is_tty() { data = data.stylize().with(color).to_string(); } - - // We can't directly use println!() because of // https://github.com/crossterm-rs/crossterm/issues/589 let _ = crossterm::execute!(std::io::stdout(), Print(data)); }; - for heading in headings { - column(heading, Color::White); + for header in column_headers { + print_column(header, Color::White); } writeln!(stdout)?; - syn_loader_conf - .language - .sort_unstable_by_key(|l| l.language_id.clone()); - let check_binary = |cmd: Option| match cmd { Some(cmd) => match which::which(&cmd) { - Ok(_) => column(&format!("✓ {}", cmd), Color::Green), - Err(_) => column(&format!("✘ {}", cmd), Color::Red), + Ok(_) => print_column(&format!("✓ {}", cmd), Color::Green), + Err(_) => print_column(&format!("✗ {}", cmd), Color::Red), }, - None => column("None", Color::Yellow), + None => print_column("None", Color::Yellow), }; - for lang in &syn_loader_conf.language { - column(&lang.language_id, Color::Reset); + let mut language_configurations = load_merged_language_configurations()?; + language_configurations.sort_unstable_by_key(|l| l.language_id.clone()); + for lang in &language_configurations { + print_column(&lang.language_id, Color::Reset); - let lsp = lang - .language_server - .as_ref() - .map(|lsp| lsp.command.to_string()); + let lsp = lang.language_server.as_ref().map(|lsp| lsp.command.to_string()); check_binary(lsp); - let dap = lang.debugger.as_ref().map(|dap| dap.command.to_string()); check_binary(dap); for ts_feat in TsFeature::all() { - match load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() { - true => column("✓", Color::Green), - false => column("✘", Color::Red), + match grammar::load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() { + true => print_column("✓", Color::Green), + false => print_column("✗", Color::Red), } } - writeln!(stdout)?; } - Ok(()) -} - -/// Display diagnostics pertaining to a particular language (LSP, -/// highlight queries, etc). -pub fn language(lang_str: String) -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let syn_loader_conf = match user_syntax_loader() { - Ok(conf) => conf, - Err(err) => { - let stderr = std::io::stderr(); - let mut stderr = stderr.lock(); - - writeln!( - stderr, - "{}: {}", - "Error parsing user language config".red(), - err - )?; - writeln!(stderr, "{}", "Using default language config".yellow())?; - default_syntax_loader() - } - }; - - let lang = match syn_loader_conf - .language - .iter() - .find(|l| l.language_id == lang_str) - { - Some(l) => l, - None => { - let msg = format!("Language '{}' not found", lang_str); - writeln!(stdout, "{}", msg.red())?; - let suggestions: Vec<&str> = syn_loader_conf - .language - .iter() - .filter(|l| l.language_id.starts_with(lang_str.chars().next().unwrap())) - .map(|l| l.language_id.as_str()) - .collect(); - if !suggestions.is_empty() { - let suggestions = suggestions.join(", "); - writeln!( - stdout, - "Did you mean one of these: {} ?", - suggestions.yellow() - )?; - } - return Ok(()); - } - }; - - probe_protocol( - "language server", - lang.language_server - .as_ref() - .map(|lsp| lsp.command.to_string()), - )?; - - probe_protocol( - "debug adapter", - lang.debugger.as_ref().map(|dap| dap.command.to_string()), - )?; - - for ts_feat in TsFeature::all() { - probe_treesitter_feature(&lang_str, *ts_feat)? - } - - Ok(()) -} - -/// Display diagnostics about LSP and DAP. -fn probe_protocol(protocol_name: &str, server_cmd: Option) -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let cmd_name = match server_cmd { - Some(ref cmd) => cmd.as_str().green(), - None => "None".yellow(), - }; - writeln!(stdout, "Configured {}: {}", protocol_name, cmd_name)?; - - if let Some(cmd) = server_cmd { - let path = match which::which(&cmd) { - Ok(path) => path.display().to_string().green(), - Err(_) => format!("'{}' not found in $PATH", cmd).red(), - }; - writeln!(stdout, "Binary for {}: {}", protocol_name, path)?; - } - - Ok(()) -} - -/// Display diagnostics about a feature that requires tree-sitter -/// query files (highlights, textobjects, etc). -fn probe_treesitter_feature(lang: &str, feature: TsFeature) -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let found = match load_runtime_file(lang, feature.runtime_filename()).is_ok() { - true => "✓".green(), - false => "✘".red(), - }; - writeln!(stdout, "{} queries: {}", feature.short_title(), found)?; - - Ok(()) -} - -pub fn print_health(health_arg: Option) -> std::io::Result<()> { - match health_arg.as_deref() { - Some("languages") => languages_all()?, - Some("clipboard") => clipboard()?, - None | Some("all") => { - general()?; - clipboard()?; - writeln!(std::io::stdout().lock())?; - languages_all()?; - } - Some(lang) => language(lang.to_string())?, - } - Ok(()) -} +} \ No newline at end of file diff --git a/helix-term/src/help.rs b/helix-term/src/help.rs new file mode 100644 index 000000000000..f210be03cf18 --- /dev/null +++ b/helix-term/src/help.rs @@ -0,0 +1,34 @@ +pub fn help() -> String { + format!("\ +{pkg_name} {version} +{authors} +{description} + +USAGE: + hx [FLAGS] [files]... + +ARGS: + ... Sets the input file to use, position can also be specified via file[:row[:col]] + +FLAGS: + -h, --help Prints help information + --tutor Loads the tutorial + --health [SECTION] Displays potential errors in editor setup. + Optional SECTION can 'paths', 'clipboard', 'languages' or a + singular language name. + -g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml + -c, --config Specifies a file to use for configuration + -v Increases logging verbosity each use for up to 3 times + --log Specifies a file to use for logging + (default file: {log_file_path}) + -V, --version Prints version information + --vsplit Splits all given files vertically into different windows + --hsplit Splits all given files horizontally into different windows +", + pkg_name = env!("CARGO_PKG_NAME"), + version = helix_loader::VERSION_AND_GIT_HASH, + authors = env!("CARGO_PKG_AUTHORS"), + description = env!("CARGO_PKG_DESCRIPTION"), + log_file_path = helix_loader::log_file().display(), + ) +} \ No newline at end of file diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 4a131f0a5217..880b405f604e 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -1,603 +1,85 @@ +pub mod keymaps; pub mod default; pub mod macros; +// NOTE: Only pub becuase of their use in macros +pub mod keytrienode; +pub mod keytrie; +mod tests; -pub use crate::commands::MappableCommand; -use crate::config::Config; -use arc_swap::{ - access::{DynAccess, DynGuard}, - ArcSwap, +use crate::{ + commands::MappableCommand, + keymap::{ + keytrie::KeyTrie, + keytrienode::KeyTrieNode + } }; -use helix_view::{document::Mode, info::Info, input::KeyEvent}; +use std::{collections::HashMap, ops::{Deref, DerefMut}}; use serde::Deserialize; -use std::{ - borrow::Cow, - collections::{BTreeSet, HashMap}, - ops::{Deref, DerefMut}, - sync::Arc, -}; - -use default::default; -use macros::key; - -#[derive(Debug, Clone)] -pub struct KeyTrieNode { - /// A label for keys coming under this node, like "Goto mode" - name: String, - map: HashMap, - order: Vec, - pub is_sticky: bool, -} - -impl<'de> Deserialize<'de> for KeyTrieNode { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let map = HashMap::::deserialize(deserializer)?; - let order = map.keys().copied().collect::>(); // NOTE: map.keys() has arbitrary order - Ok(Self { - map, - order, - ..Default::default() - }) - } -} - -impl KeyTrieNode { - pub fn new(name: &str, map: HashMap, order: Vec) -> Self { - Self { - name: name.to_string(), - map, - order, - is_sticky: false, - } - } - - pub fn name(&self) -> &str { - &self.name - } - - /// Merge another Node in. Leaves and subnodes from the other node replace - /// corresponding keyevent in self, except when both other and self have - /// subnodes for same key. In that case the merge is recursive. - pub fn merge(&mut self, mut other: Self) { - for (key, trie) in std::mem::take(&mut other.map) { - if let Some(KeyTrie::Node(node)) = self.map.get_mut(&key) { - if let KeyTrie::Node(other_node) = trie { - node.merge(other_node); - continue; - } - } - self.map.insert(key, trie); - } - for &key in self.map.keys() { - if !self.order.contains(&key) { - self.order.push(key); - } - } - } - - pub fn infobox(&self) -> Info { - let mut body: Vec<(&str, BTreeSet)> = Vec::with_capacity(self.len()); - for (&key, trie) in self.iter() { - let desc = match trie { - KeyTrie::Leaf(cmd) => { - if cmd.name() == "no_op" { - continue; - } - cmd.doc() - } - KeyTrie::Node(n) => n.name(), - KeyTrie::Sequence(_) => "[Multiple commands]", - }; - match body.iter().position(|(d, _)| d == &desc) { - Some(pos) => { - body[pos].1.insert(key); - } - None => body.push((desc, BTreeSet::from([key]))), - } - } - body.sort_unstable_by_key(|(_, keys)| { - self.order - .iter() - .position(|&k| k == *keys.iter().next().unwrap()) - .unwrap() - }); - let prefix = format!("{} ", self.name()); - if body.iter().all(|(desc, _)| desc.starts_with(&prefix)) { - body = body - .into_iter() - .map(|(desc, keys)| (desc.strip_prefix(&prefix).unwrap(), keys)) - .collect(); - } - Info::from_keymap(self.name(), body) - } - /// Get a reference to the key trie node's order. - pub fn order(&self) -> &[KeyEvent] { - self.order.as_slice() - } -} - -impl Default for KeyTrieNode { - fn default() -> Self { - Self::new("", HashMap::new(), Vec::new()) - } -} - -impl PartialEq for KeyTrieNode { - fn eq(&self, other: &Self) -> bool { - self.map == other.map - } -} - -impl Deref for KeyTrieNode { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.map - } -} - -impl DerefMut for KeyTrieNode { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.map - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum KeyTrie { - Leaf(MappableCommand), - Sequence(Vec), - Node(KeyTrieNode), -} - -impl<'de> Deserialize<'de> for KeyTrie { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - deserializer.deserialize_any(KeyTrieVisitor) - } -} - -struct KeyTrieVisitor; - -impl<'de> serde::de::Visitor<'de> for KeyTrieVisitor { - type Value = KeyTrie; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "a command, list of commands, or sub-keymap") - } - - fn visit_str(self, command: &str) -> Result - where - E: serde::de::Error, - { - command - .parse::() - .map(KeyTrie::Leaf) - .map_err(E::custom) - } - - fn visit_seq(self, mut seq: S) -> Result - where - S: serde::de::SeqAccess<'de>, - { - let mut commands = Vec::new(); - while let Some(command) = seq.next_element::<&str>()? { - commands.push( - command - .parse::() - .map_err(serde::de::Error::custom)?, - ) - } - Ok(KeyTrie::Sequence(commands)) - } - - fn visit_map(self, mut map: M) -> Result - where - M: serde::de::MapAccess<'de>, - { - let mut mapping = HashMap::new(); - let mut order = Vec::new(); - while let Some((key, value)) = map.next_entry::()? { - mapping.insert(key, value); - order.push(key); - } - Ok(KeyTrie::Node(KeyTrieNode::new("", mapping, order))) - } -} - -impl KeyTrie { - pub fn node(&self) -> Option<&KeyTrieNode> { - match *self { - KeyTrie::Node(ref node) => Some(node), - KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, - } - } - - pub fn node_mut(&mut self) -> Option<&mut KeyTrieNode> { - match *self { - KeyTrie::Node(ref mut node) => Some(node), - KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, - } - } - - /// Merge another KeyTrie in, assuming that this KeyTrie and the other - /// are both Nodes. Panics otherwise. - pub fn merge_nodes(&mut self, mut other: Self) { - let node = std::mem::take(other.node_mut().unwrap()); - self.node_mut().unwrap().merge(node); - } - - pub fn search(&self, keys: &[KeyEvent]) -> Option<&KeyTrie> { - let mut trie = self; - for key in keys { - trie = match trie { - KeyTrie::Node(map) => map.get(key), - // leaf encountered while keys left to process - KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, - }? - } - Some(trie) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum KeymapResult { - /// Needs more keys to execute a command. Contains valid keys for next keystroke. - Pending(KeyTrieNode), - Matched(MappableCommand), - /// Matched a sequence of commands to execute. - MatchedSequence(Vec), - /// Key was not found in the root keymap - NotFound, - /// Key is invalid in combination with previous keys. Contains keys leading upto - /// and including current (invalid) key. - Cancelled(Vec), -} #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(transparent)] +/// KeyTrie starting point. pub struct Keymap { - /// Always a Node - root: KeyTrie, + pub root_node: KeyTrie } -/// A map of command names to keybinds that will execute the command. -pub type ReverseKeymap = HashMap>>; - +pub type CommandList = HashMap>; impl Keymap { - pub fn new(root: KeyTrie) -> Self { - Keymap { root } - } - - pub fn reverse_map(&self) -> ReverseKeymap { - // recursively visit all nodes in keymap - fn map_node(cmd_map: &mut ReverseKeymap, node: &KeyTrie, keys: &mut Vec) { + pub fn new(root_node: KeyTrie) -> Self { + Keymap { root_node } + } + + /// Returns a key-value list of all commands associated to a given Keymap. + /// Keys are the node names (see KeyTrieNode documentation) + /// Values are lists of stringified KeyEvents that triger the command. + /// Each element in the KeyEvent list is prefixed with prefixed the ancestor KeyEvents. + /// For example: Stringified KeyEvent element for the 'goto_next_window' command could be "space>w>w". + /// Ancestor KeyEvents are in this case "space" and "w". + pub fn command_list(&self) -> CommandList { + let mut list = HashMap::new(); + _command_list(&mut list, &KeyTrieNode::KeyTrie(self.root_node.clone()), &mut String::new()); + return list; + + fn _command_list(list: &mut CommandList, node: &KeyTrieNode, prefix: &mut String) { match node { - KeyTrie::Leaf(cmd) => match cmd { - MappableCommand::Typable { name, .. } => { - cmd_map.entry(name.into()).or_default().push(keys.clone()) + KeyTrieNode::KeyTrie(trie_node) => { + for (key_event, subtrie_node) in trie_node.deref() { + let mut temp_prefix: String = prefix.to_string(); + if &temp_prefix != "" { + temp_prefix.push_str(">"); + } + temp_prefix.push_str(&key_event.to_string()); + _command_list(list, subtrie_node, &mut temp_prefix); } - MappableCommand::Static { name, .. } => cmd_map - .entry(name.to_string()) - .or_default() - .push(keys.clone()), }, - KeyTrie::Node(next) => { - for (key, trie) in &next.map { - keys.push(*key); - map_node(cmd_map, trie, keys); - keys.pop(); - } - } - KeyTrie::Sequence(_) => {} + KeyTrieNode::MappableCommand(mappable_command) => { + if mappable_command.name() == "no_op" { return } + list.entry(mappable_command.name().to_string()).or_default().push(prefix.to_string()); + }, + KeyTrieNode::CommandSequence(_) => {} }; } - - let mut res = HashMap::new(); - map_node(&mut res, &self.root, &mut Vec::new()); - res - } - - pub fn root(&self) -> &KeyTrie { - &self.root - } - - pub fn merge(&mut self, other: Self) { - self.root.merge_nodes(other.root); - } -} - -impl Deref for Keymap { - type Target = KeyTrieNode; - - fn deref(&self) -> &Self::Target { - self.root.node().unwrap() } } impl Default for Keymap { fn default() -> Self { - Self::new(KeyTrie::Node(KeyTrieNode::default())) - } -} - -pub struct Keymaps { - pub map: Box>>, - /// Stores pending keys waiting for the next key. This is relative to a - /// sticky node if one is in use. - state: Vec, - /// Stores the sticky node if one is activated. - pub sticky: Option, -} - -impl Keymaps { - pub fn new(map: Box>>) -> Self { - Self { - map, - state: Vec::new(), - sticky: None, - } - } - - pub fn map(&self) -> DynGuard> { - self.map.load() - } - - /// Returns list of keys waiting to be disambiguated in current mode. - pub fn pending(&self) -> &[KeyEvent] { - &self.state - } - - pub fn sticky(&self) -> Option<&KeyTrieNode> { - self.sticky.as_ref() - } - - /// Lookup `key` in the keymap to try and find a command to execute. Escape - /// key cancels pending keystrokes. If there are no pending keystrokes but a - /// sticky node is in use, it will be cleared. - pub fn get(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult { - // TODO: remove the sticky part and look up manually - let keymaps = &*self.map(); - let keymap = &keymaps[&mode]; - - if key!(Esc) == key { - if !self.state.is_empty() { - // Note that Esc is not included here - return KeymapResult::Cancelled(self.state.drain(..).collect()); - } - self.sticky = None; - } - - let first = self.state.get(0).unwrap_or(&key); - let trie_node = match self.sticky { - Some(ref trie) => Cow::Owned(KeyTrie::Node(trie.clone())), - None => Cow::Borrowed(&keymap.root), - }; - - let trie = match trie_node.search(&[*first]) { - Some(KeyTrie::Leaf(ref cmd)) => { - return KeymapResult::Matched(cmd.clone()); - } - Some(KeyTrie::Sequence(ref cmds)) => { - return KeymapResult::MatchedSequence(cmds.clone()); - } - None => return KeymapResult::NotFound, - Some(t) => t, - }; - - self.state.push(key); - match trie.search(&self.state[1..]) { - Some(KeyTrie::Node(map)) => { - if map.is_sticky { - self.state.clear(); - self.sticky = Some(map.clone()); - } - KeymapResult::Pending(map.clone()) - } - Some(KeyTrie::Leaf(cmd)) => { - self.state.clear(); - KeymapResult::Matched(cmd.clone()) - } - Some(KeyTrie::Sequence(cmds)) => { - self.state.clear(); - KeymapResult::MatchedSequence(cmds.clone()) - } - None => KeymapResult::Cancelled(self.state.drain(..).collect()), - } + Self::new(KeyTrie::default()) } } -impl Default for Keymaps { - fn default() -> Self { - Self::new(Box::new(ArcSwap::new(Arc::new(default())))) - } -} +/// Returns the Keymap root KeyTrie node. +impl Deref for Keymap { + type Target = KeyTrie; -/// Merge default config keys with user overwritten keys for custom user config. -pub fn merge_keys(mut config: Config) -> Config { - let mut delta = std::mem::replace(&mut config.keys, default()); - for (mode, keys) in &mut config.keys { - keys.merge(delta.remove(mode).unwrap_or_default()) + fn deref(&self) -> &Self::Target { + &self.root_node } - config } -#[cfg(test)] -mod tests { - use super::macros::keymap; - use super::*; - use arc_swap::access::Constant; - use helix_core::hashmap; - - #[test] - #[should_panic] - fn duplicate_keys_should_panic() { - keymap!({ "Normal mode" - "i" => normal_mode, - "i" => goto_definition, - }); - } - - #[test] - fn check_duplicate_keys_in_default_keymap() { - // will panic on duplicate keys, assumes that `Keymaps` uses keymap! macro - Keymaps::default(); - } - - #[test] - fn merge_partial_keys() { - let config = Config { - keys: hashmap! { - Mode::Normal => Keymap::new( - keymap!({ "Normal mode" - "i" => normal_mode, - "无" => insert_mode, - "z" => jump_backward, - "g" => { "Merge into goto mode" - "$" => goto_line_end, - "g" => delete_char_forward, - }, - }) - ) - }, - ..Default::default() - }; - let mut merged_config = merge_keys(config.clone()); - assert_ne!(config, merged_config); - - let mut keymap = Keymaps::new(Box::new(Constant(merged_config.keys.clone()))); - assert_eq!( - keymap.get(Mode::Normal, key!('i')), - KeymapResult::Matched(MappableCommand::normal_mode), - "Leaf should replace leaf" - ); - assert_eq!( - keymap.get(Mode::Normal, key!('无')), - KeymapResult::Matched(MappableCommand::insert_mode), - "New leaf should be present in merged keymap" - ); - // Assumes that z is a node in the default keymap - assert_eq!( - keymap.get(Mode::Normal, key!('z')), - KeymapResult::Matched(MappableCommand::jump_backward), - "Leaf should replace node" - ); - - let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap(); - // Assumes that `g` is a node in default keymap - assert_eq!( - keymap.root().search(&[key!('g'), key!('$')]).unwrap(), - &KeyTrie::Leaf(MappableCommand::goto_line_end), - "Leaf should be present in merged subnode" - ); - // Assumes that `gg` is in default keymap - assert_eq!( - keymap.root().search(&[key!('g'), key!('g')]).unwrap(), - &KeyTrie::Leaf(MappableCommand::delete_char_forward), - "Leaf should replace old leaf in merged subnode" - ); - // Assumes that `ge` is in default keymap - assert_eq!( - keymap.root().search(&[key!('g'), key!('e')]).unwrap(), - &KeyTrie::Leaf(MappableCommand::goto_last_line), - "Old leaves in subnode should be present in merged node" - ); - - assert!(merged_config.keys.get(&Mode::Normal).unwrap().len() > 1); - assert!(merged_config.keys.get(&Mode::Insert).unwrap().len() > 0); - } - - #[test] - fn order_should_be_set() { - let config = Config { - keys: hashmap! { - Mode::Normal => Keymap::new( - keymap!({ "Normal mode" - "space" => { "" - "s" => { "" - "v" => vsplit, - "c" => hsplit, - }, - }, - }) - ) - }, - ..Default::default() - }; - let mut merged_config = merge_keys(config.clone()); - assert_ne!(config, merged_config); - let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap(); - // Make sure mapping works - assert_eq!( - keymap - .root() - .search(&[key!(' '), key!('s'), key!('v')]) - .unwrap(), - &KeyTrie::Leaf(MappableCommand::vsplit), - "Leaf should be present in merged subnode" - ); - // Make sure an order was set during merge - let node = keymap.root().search(&[crate::key!(' ')]).unwrap(); - assert!(!node.node().unwrap().order().is_empty()) - } - - #[test] - fn aliased_modes_are_same_in_default_keymap() { - let keymaps = Keymaps::default().map(); - let root = keymaps.get(&Mode::Normal).unwrap().root(); - assert_eq!( - root.search(&[key!(' '), key!('w')]).unwrap(), - root.search(&["C-w".parse::().unwrap()]).unwrap(), - "Mismatch for window mode on `Space-w` and `Ctrl-w`" - ); - assert_eq!( - root.search(&[key!('z')]).unwrap(), - root.search(&[key!('Z')]).unwrap(), - "Mismatch for view mode on `z` and `Z`" - ); - } - - #[test] - fn reverse_map() { - let normal_mode = keymap!({ "Normal mode" - "i" => insert_mode, - "g" => { "Goto" - "g" => goto_file_start, - "e" => goto_file_end, - }, - "j" | "k" => move_line_down, - }); - let keymap = Keymap::new(normal_mode); - let mut reverse_map = keymap.reverse_map(); - - // sort keybindings in order to have consistent tests - // HashMaps can be compared but we can still get different ordering of bindings - // for commands that have multiple bindings assigned - for v in reverse_map.values_mut() { - v.sort() - } - - assert_eq!( - reverse_map, - HashMap::from([ - ("insert_mode".to_string(), vec![vec![key!('i')]]), - ( - "goto_file_start".to_string(), - vec![vec![key!('g'), key!('g')]] - ), - ( - "goto_file_end".to_string(), - vec![vec![key!('g'), key!('e')]] - ), - ( - "move_line_down".to_string(), - vec![vec![key!('j')], vec![key!('k')]] - ), - ]), - "Mismatch" - ) +/// Returns the Keymap root KeyTrie node. +impl DerefMut for Keymap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.root_node } } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index ef93dee08a77..551cf1a79bed 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -1,11 +1,10 @@ -use std::collections::HashMap; - -use super::macros::keymap; -use super::{Keymap, Mode}; +use super::{macros::keytrie, Keymap}; +use helix_view::document::Mode; use helix_core::hashmap; +use std::collections::HashMap; pub fn default() -> HashMap { - let normal = keymap!({ "Normal mode" + let normal = keytrie!({ "Normal mode" "h" | "left" => move_char_left, "j" | "down" => move_line_down, "k" | "up" => move_line_up, @@ -318,7 +317,7 @@ pub fn default() -> HashMap { "C-x" => decrement, }); let mut select = normal.clone(); - select.merge_nodes(keymap!({ "Select mode" + select.merge_keytrie(keytrie!({ "Select mode" "h" | "left" => extend_char_left, "j" | "down" => extend_line_down, "k" | "up" => extend_line_up, @@ -345,7 +344,7 @@ pub fn default() -> HashMap { "v" => normal_mode, })); - let insert = keymap!({ "Insert mode" + let insert = keytrie!({ "Insert mode" "esc" => normal_mode, "C-s" => commit_undo_checkpoint, diff --git a/helix-term/src/keymap/keymaps.rs b/helix-term/src/keymap/keymaps.rs new file mode 100644 index 000000000000..29b2d57901b2 --- /dev/null +++ b/helix-term/src/keymap/keymaps.rs @@ -0,0 +1,115 @@ +use super::{ + keytrienode::KeyTrieNode, + keytrie::KeyTrie, + macros::key, + Keymap, + default, +}; +use crate::commands::MappableCommand; +use helix_view::{document::Mode, input::KeyEvent}; +use std::{sync::Arc, collections::HashMap}; +use arc_swap::{access::{DynAccess, DynGuard}, ArcSwap}; + +#[derive(Debug, Clone, PartialEq)] +pub enum KeymapResult { + Pending(KeyTrie), + Matched(MappableCommand), + MatchedCommandSequence(Vec), + NotFound, + /// Contains pressed KeyEvents leading up to the cancellation. + Cancelled(Vec), +} + +pub struct Keymaps { + pub keymaps: Box>>, + /// Relative to a sticky node if Some. + pending_keys: Vec, + pub sticky_keytrie: Option, +} + +impl Keymaps { + pub fn new(keymaps: Box>>) -> Self { + Self { + keymaps, + pending_keys: Vec::new(), + sticky_keytrie: None, + } + } + + pub fn load_keymaps(&self) -> DynGuard> { + self.keymaps.load() + } + + /// Returns list of keys waiting to be disambiguated in current mode. + pub fn pending(&self) -> &[KeyEvent] { + &self.pending_keys + } + + pub fn sticky_keytrie(&self) -> Option<&KeyTrie> { + self.sticky_keytrie.as_ref() + } + + /// Lookup `key` in the keymap to try and find a command to execute. + /// Escape key represents cancellation. + /// This means clearing pending keystrokes, or the sticky_keytrie if none were present. + pub fn get(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult { + // TODO: remove the sticky part and look up manually + let keymaps = &*self.load_keymaps(); + let active_keymap = &keymaps[&mode]; + + if key == key!(Esc) { + if !self.pending_keys.is_empty() { + // NOTE: Esc is not included here + return KeymapResult::Cancelled(self.pending_keys.drain(..).collect()); + } + // TODO: Shouldn't we return here also? + self.sticky_keytrie = None; + } + + // Check if sticky keytrie is to be used. + let starting_keytrie = match self.sticky_keytrie { + None => &active_keymap.root_node, + Some(ref active_sticky_keytrie) => active_sticky_keytrie, + }; + + // TODO: why check either pending or regular key? + let first_key = self.pending_keys.get(0).unwrap_or(&key); + + let pending_keytrie: KeyTrie = match starting_keytrie.traverse(&[*first_key]) { + Some(KeyTrieNode::KeyTrie(sub_keytrie)) => sub_keytrie, + Some(KeyTrieNode::MappableCommand(cmd)) => { + return KeymapResult::Matched(cmd.clone()); + } + Some(KeyTrieNode::CommandSequence(cmds)) => { + return KeymapResult::MatchedCommandSequence(cmds.clone()); + } + None => return KeymapResult::NotFound, + }; + + self.pending_keys.push(key); + match pending_keytrie.traverse(&self.pending_keys[1..]) { + Some(KeyTrieNode::KeyTrie(map)) => { + if map.is_sticky { + self.pending_keys.clear(); + self.sticky_keytrie = Some(map.clone()); + } + KeymapResult::Pending(map.clone()) + } + Some(KeyTrieNode::MappableCommand(cmd)) => { + self.pending_keys.clear(); + KeymapResult::Matched(cmd.clone()) + } + Some(KeyTrieNode::CommandSequence(cmds)) => { + self.pending_keys.clear(); + KeymapResult::MatchedCommandSequence(cmds.clone()) + } + None => KeymapResult::Cancelled(self.pending_keys.drain(..).collect()), + } + } +} + +impl Default for Keymaps { + fn default() -> Self { + Self::new(Box::new(ArcSwap::new(Arc::new(default::default())))) + } +} diff --git a/helix-term/src/keymap/keytrie.rs b/helix-term/src/keymap/keytrie.rs new file mode 100644 index 000000000000..d11c6b9f196c --- /dev/null +++ b/helix-term/src/keymap/keytrie.rs @@ -0,0 +1,182 @@ +use super::keytrienode::KeyTrieNode; +use helix_view::{info::Info, input::KeyEvent}; +use std::{collections::HashMap, ops::{Deref, DerefMut}, cmp::Ordering}; +use serde::Deserialize; + +/// Edges of the trie are KeyEvents and the nodes are descrbibed by KeyTrieNode +#[derive(Debug, Clone)] +pub struct KeyTrie { + documentation: String, + children: HashMap, + pub is_sticky: bool, +} + +impl KeyTrie { + pub fn new(documentation: &str, children: HashMap) -> Self { + Self { + documentation: documentation.to_string(), + children, + is_sticky: false, + } + } + + // None symbolizes NotFound + pub fn traverse(&self, key_events: &[KeyEvent]) -> Option { + return _traverse(self, key_events, 0); + + fn _traverse(keytrie: &KeyTrie, key_events: &[KeyEvent], mut depth: usize) -> Option { + if depth == key_events.len() { + return Some(KeyTrieNode::KeyTrie(keytrie.clone())); + } + else if let Some(found_child) = keytrie.get(&key_events[depth]) { + match found_child { + KeyTrieNode::KeyTrie(sub_keytrie) => { + depth += 1; + return _traverse(sub_keytrie, key_events, depth) + }, + _ => return Some(found_child.clone()) + } + } + return None; + } + } + + pub fn merge_keytrie(&mut self, mut other_keytrie: Self) { + for (other_key_event, other_child_node) in std::mem::take(&mut other_keytrie.children) { + match other_child_node { + KeyTrieNode::KeyTrie(other_child_key_trie) => { + if let Some(KeyTrieNode::KeyTrie(self_clashing_child_key_trie)) = self.children.get_mut(&other_key_event) { + self_clashing_child_key_trie.merge_keytrie(other_child_key_trie); + } + else { + self.children.insert(other_key_event, KeyTrieNode::KeyTrie(other_child_key_trie)); + } + } + KeyTrieNode::MappableCommand(_) | KeyTrieNode::CommandSequence(_) => { + self.children.insert(other_key_event, other_child_node); + } + } + } + } + + /// Open an Info box for a given KeyTrie + /// Shows the children as possible KeyEvents and thier associated description. + pub fn infobox(&self) -> Info { + let mut body: Vec<(Vec, &str)> = Vec::with_capacity(self.len()); + for (&key_event, key_trie) in self.iter() { + let documentation: &str = match key_trie { + KeyTrieNode::MappableCommand(command) => { + if command.name() == "no_op" { + continue; + } + command.description() + }, + KeyTrieNode::KeyTrie(key_trie) => &key_trie.documentation, + // FIX: default to a join of all command names + // NOTE: Giving same documentation for all sequences will place all sequence keyvents together. + // Regardless if the command sequence is different. + KeyTrieNode::CommandSequence(_) => "[Multiple commands]", + }; + match body.iter().position(|(_, existing_documentation)| &documentation == existing_documentation) { + Some(position) => body[position].0.push(key_event.to_string()), + None => { + let mut temp_vec: Vec = Vec::new(); + temp_vec.push(key_event.to_string()); + body.push((temp_vec, documentation)) + }, + } + } + + // Shortest keyevent (as string) appears first, unless is a "C-" KeyEvent + // Those events will always be placed after the one letter KeyEvent + let mut sorted_body = body + .iter() + .map(|(key_events, description)| { + let mut temp_key_events = key_events.clone(); + temp_key_events.sort_unstable_by(|a, b| { + if a.len() == 1 { return Ordering::Less } + if b.len() > a.len() && b.starts_with("C-") { + return Ordering::Greater + } + a.len().cmp(&b.len()) + }); + (temp_key_events, *description) + }) + .collect::, &str)>>(); + sorted_body.sort_unstable_by(|a, b| a.0[0].to_lowercase().cmp(&b.0[0].to_lowercase())); + // Consistently place lowercase before uppercase of the same letter. + if sorted_body.len() > 1 { + let mut x_index = 0; + let mut y_index = 1; + + while y_index < sorted_body.len() { + let x = &sorted_body[x_index].0[0]; + let y = &sorted_body[y_index].0[0]; + if x.to_lowercase() == y.to_lowercase() { + // Uppercase regarded as lower value. + if x < y { + let temp_holder = sorted_body[x_index].clone(); + sorted_body[x_index] = sorted_body[y_index].clone(); + sorted_body[y_index] = temp_holder; + } + } + x_index = y_index; + y_index += 1; + } + } + + let stringified_key_events_body: Vec<(String, &str)> = sorted_body + .iter() + .map(|(key_events, description)| { + let key_events_string: String = key_events.iter().fold(String::new(), |mut acc, key_event| { + if !acc.is_empty() { acc.push_str(", "); } + acc.push_str(key_event); + acc + }); + (key_events_string, *description) + }) + .collect(); + + Info::new(&self.documentation, &stringified_key_events_body) + } +} + +impl Default for KeyTrie { + fn default() -> Self { + Self::new("", HashMap::new()) + } +} + +impl PartialEq for KeyTrie { + fn eq(&self, other: &Self) -> bool { + self.children == other.children + } +} + +/// Returns the children of the KeyTrie +impl Deref for KeyTrie { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.children + } +} + +/// Returns the children of the KeyTrie +impl DerefMut for KeyTrie { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.children + } +} + +impl<'de> Deserialize<'de> for KeyTrie { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(Self { + children: HashMap::::deserialize(deserializer)?, + ..Default::default() + }) + } +} diff --git a/helix-term/src/keymap/keytrienode.rs b/helix-term/src/keymap/keytrienode.rs new file mode 100644 index 000000000000..41b5a7cd3343 --- /dev/null +++ b/helix-term/src/keymap/keytrienode.rs @@ -0,0 +1,70 @@ +use super::{MappableCommand, keytrie::KeyTrie}; +use helix_view::input::KeyEvent; +use std::collections::HashMap; +use serde::{Deserialize, de::Visitor}; + +/// Each variant includes a documentaion property. +/// For the MappableCommand and CommandSequence variants, the property is self explanatory. +/// For KeyTrie, the documentation is used for respective infobox titles, +/// or infobox KeyEvent descriptions that in themselves trigger the opening of another infobox. +#[derive(Debug, Clone, PartialEq)] +pub enum KeyTrieNode { + MappableCommand(MappableCommand), + CommandSequence(Vec), + KeyTrie(KeyTrie), +} + +impl<'de> Deserialize<'de> for KeyTrieNode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(KeyTrieNodeVisitor) + } +} + +struct KeyTrieNodeVisitor; + +impl<'de> Visitor<'de> for KeyTrieNodeVisitor { + type Value = KeyTrieNode; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a KeyTrieNode") + } + + fn visit_str(self, command: &str) -> Result + where + E: serde::de::Error, + { + command + .parse::() + .map(KeyTrieNode::MappableCommand) + .map_err(E::custom) + } + + fn visit_seq(self, mut seq: S) -> Result + where + S: serde::de::SeqAccess<'de>, + { + let mut commands = Vec::new(); + while let Some(command) = seq.next_element::<&str>()? { + commands.push( + command + .parse::() + .map_err(serde::de::Error::custom)?, + ) + } + Ok(KeyTrieNode::CommandSequence(commands)) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut sub_key_trie = HashMap::new(); + while let Some((key_event, key_trie_node)) = map.next_entry::()? { + sub_key_trie.insert(key_event, key_trie_node); + } + Ok(KeyTrieNode::KeyTrie(KeyTrie::new("", sub_key_trie))) + } +} \ No newline at end of file diff --git a/helix-term/src/keymap/macros.rs b/helix-term/src/keymap/macros.rs index c4a1bfbb3064..67877fef76b4 100644 --- a/helix-term/src/keymap/macros.rs +++ b/helix-term/src/keymap/macros.rs @@ -1,15 +1,15 @@ #[macro_export] macro_rules! key { ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::NONE, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::$key, + modifiers: helix_view::keyboard::KeyModifiers::NONE, } }; ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::NONE, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: helix_view::keyboard::KeyModifiers::NONE, } }; } @@ -17,15 +17,15 @@ macro_rules! key { #[macro_export] macro_rules! shift { ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::$key, + modifiers: helix_view::keyboard::KeyModifiers::SHIFT, } }; ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: helix_view::keyboard::KeyModifiers::SHIFT, } }; } @@ -33,15 +33,15 @@ macro_rules! shift { #[macro_export] macro_rules! ctrl { ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::$key, + modifiers: helix_view::keyboard::KeyModifiers::CONTROL, } }; ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: helix_view::keyboard::KeyModifiers::CONTROL, } }; } @@ -49,15 +49,15 @@ macro_rules! ctrl { #[macro_export] macro_rules! alt { ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::ALT, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::$key, + modifiers: helix_view::keyboard::KeyModifiers::ALT, } }; ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::ALT, + helix_view::input::KeyEvent { + code: helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: helix_view::keyboard::KeyModifiers::ALT, } }; } @@ -66,9 +66,8 @@ macro_rules! alt { /// /// ``` /// # use helix_core::hashmap; -/// # use helix_term::keymap; -/// # use helix_term::keymap::Keymap; -/// let normal_mode = keymap!({ "Normal mode" +/// # use helix_term::keymap::{Keymap, macros::keytrie}; +/// let normal_mode = keytrie!({ "Normal mode" /// "i" => insert_mode, /// "g" => { "Goto" /// "g" => goto_file_start, @@ -79,49 +78,41 @@ macro_rules! alt { /// let keymap = Keymap::new(normal_mode); /// ``` #[macro_export] -macro_rules! keymap { - (@trie $cmd:ident) => { - $crate::keymap::KeyTrie::Leaf($crate::commands::MappableCommand::$cmd) - }; - - (@trie - { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } - ) => { - keymap!({ $label $(sticky=$sticky)? $($($key)|+ => $value,)+ }) - }; - - (@trie [$($cmd:ident),* $(,)?]) => { - $crate::keymap::KeyTrie::Sequence(vec![$($crate::commands::Command::$cmd),*]) - }; - - ( - { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } - ) => { +macro_rules! keytrie { + ({ $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ }) => { // modified from the hashmap! macro { let _cap = hashmap!(@count $($($key),+),*); - let mut _map = ::std::collections::HashMap::with_capacity(_cap); - let mut _order = ::std::vec::Vec::with_capacity(_cap); + let mut _map: std::collections::HashMap<::helix_view::input::KeyEvent, $crate::keymap::keytrienode::KeyTrieNode> = + std::collections::HashMap::with_capacity(_cap); $( $( - let _key = $key.parse::<::helix_view::input::KeyEvent>().unwrap(); - let _duplicate = _map.insert( - _key, - keymap!(@trie $value) - ); - assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); - _order.push(_key); + let _key = $key.parse::().unwrap(); + let _potential_duplicate = _map.insert(_key,keytrie!(@trie $value)); + assert!(_potential_duplicate.is_none(), "Duplicate key found: {:?}", _potential_duplicate.unwrap()); )+ )* - let mut _node = $crate::keymap::KeyTrieNode::new($label, _map, _order); + let mut _node = $crate::keymap::keytrie::KeyTrie::new($label, _map); $( _node.is_sticky = $sticky; )? - $crate::keymap::KeyTrie::Node(_node) + _node } }; + + (@trie {$label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ }) => { + $crate::keymap::keytrienode::KeyTrieNode::KeyTrie(keytrie!({ $label $(sticky=$sticky)? $($($key)|+ => $value,)+ })) + }; + + (@trie $cmd:ident) => { + $crate::keymap::keytrienode::KeyTrieNode::MappableCommand($crate::commands::MappableCommand::$cmd) + }; + + (@trie [$($cmd:ident),* $(,)?]) => { + $crate::keymap::keytrienode::KeyTrieNode::CommandSequence(vec![$($crate::commands::Command::$cmd),*]) + }; } -pub use alt; -pub use ctrl; pub use key; -pub use keymap; pub use shift; +pub use ctrl; +pub use alt; +pub use keytrie; diff --git a/helix-term/src/keymap/tests.rs b/helix-term/src/keymap/tests.rs new file mode 100644 index 000000000000..a08be18c7df8 --- /dev/null +++ b/helix-term/src/keymap/tests.rs @@ -0,0 +1,87 @@ +#[macro_use] +#[cfg(test)] +mod tests { + use helix_core::hashmap; + use helix_view::{document::Mode, input::KeyEvent}; + use crate::{ + keymap::macros::*, + keymap::keymaps::Keymaps, + keymap::Keymap, + }; + use std::collections::HashMap; + + #[test] + #[should_panic] + fn duplicate_keys_should_panic() { + keytrie!({ "Normal mode" + "i" => normal_mode, + "i" => goto_definition, + }); + } + + #[test] + fn check_duplicate_keys_in_default_keymap() { + // will panic on duplicate keys, assumes that `Keymaps` uses keymap! macro + Keymaps::default(); + } + + #[test] + fn aliased_modes_are_same_in_default_keymap() { + let keymaps = Keymaps::default().keymaps; + let root = keymaps.load().get(&Mode::Normal).unwrap().root_node.clone(); + assert_eq!( + root.traverse(&[key!(' '), key!('w')]).unwrap(), + root.traverse(&["C-w".parse::().unwrap()]).unwrap(), + "Mismatch for window mode on `Space-w` and `Ctrl-w`." + ); + assert_eq!( + root.traverse(&[key!('z')]).unwrap(), + root.traverse(&[key!('Z')]).unwrap(), + "Mismatch for view mode on `z` and `Z`." + ); + } + + #[test] + fn command_list() { + let normal_mode = keytrie!({ "Normal mode" + "i" => insert_mode, + "g" => { "Goto" + "g" => goto_file_start, + "e" => goto_file_end, + }, + "j" | "k" => move_line_down, + }); + let keymap = Keymap::new(normal_mode); + let mut command_list = keymap.command_list(); + + // sort keybindings in order to have consistent tests + // HashMaps can be compared but we can still get different ordering of bindings + // for commands that have multiple bindings assigned + for v in command_list.values_mut() { + v.sort() + } + + assert_eq!( + command_list, + HashMap::from([ + ( + "insert_mode".to_string(), + vec![key!('i').to_string()] + ), + ( + "goto_file_start".to_string(), + vec![format!("{}>{}", key!('g'), key!('g'))] + ), + ( + "goto_file_end".to_string(), + vec![format!("{}>{}", key!('g'), key!('e'))] + ), + ( + "move_line_down".to_string(), + vec![key!('j').to_string(), key!('k').to_string()] + ) + ]), + "Mismatch" + ) + } +} \ No newline at end of file diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index a945b20dedaf..fc8e934e1a7d 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -10,7 +10,6 @@ pub mod health; pub mod job; pub mod keymap; pub mod ui; -pub use keymap::macros::*; #[cfg(not(windows))] fn true_color() -> bool { diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index aac5c5379f37..7f6c2803f167 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -1,39 +1,14 @@ -use anyhow::{Context, Error, Result}; +mod help; + +use anyhow::{Context, Result}; use crossterm::event::EventStream; +use helix_core::syntax::LanguageConfigurations; use helix_loader::VERSION_AND_GIT_HASH; use helix_term::application::Application; use helix_term::args::Args; use helix_term::config::Config; use std::path::PathBuf; -fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { - let mut base_config = fern::Dispatch::new(); - - base_config = match verbosity { - 0 => base_config.level(log::LevelFilter::Warn), - 1 => base_config.level(log::LevelFilter::Info), - 2 => base_config.level(log::LevelFilter::Debug), - _3_or_more => base_config.level(log::LevelFilter::Trace), - }; - - // Separate file config so we can include year, month and day in file logs - let file_config = fern::Dispatch::new() - .format(|out, message, record| { - out.finish(format_args!( - "{} {} [{}] {}", - chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), - record.target(), - record.level(), - message - )) - }) - .chain(fern::log_file(logpath)?); - - base_config.chain(file_config).apply()?; - - Ok(()) -} - fn main() -> Result<()> { let exit_code = main_impl()?; std::process::exit(exit_code); @@ -41,59 +16,17 @@ fn main() -> Result<()> { #[tokio::main] async fn main_impl() -> Result { - let logpath = helix_loader::log_file(); - let parent = logpath.parent().unwrap(); - if !parent.exists() { - std::fs::create_dir_all(parent).ok(); - } - - let help = format!( - "\ -{} {} -{} -{} - -USAGE: - hx [FLAGS] [files]... - -ARGS: - ... Sets the input file to use, position can also be specified via file[:row[:col]] - -FLAGS: - -h, --help Prints help information - --tutor Loads the tutorial - --health [CATEGORY] Checks for potential errors in editor setup - CATEGORY can be a language or one of 'clipboard', 'languages' - or 'all'. 'all' is the default if not specified. - -g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml - -c, --config Specifies a file to use for configuration - -v Increases logging verbosity each use for up to 3 times - --log Specifies a file to use for logging - (default file: {}) - -V, --version Prints version information - --vsplit Splits all given files vertically into different windows - --hsplit Splits all given files horizontally into different windows -", - env!("CARGO_PKG_NAME"), - VERSION_AND_GIT_HASH, - env!("CARGO_PKG_AUTHORS"), - env!("CARGO_PKG_DESCRIPTION"), - logpath.display(), - ); + let args = Args::parse_args().context("failed to parse arguments")?; + setup_logging(args.log_file.clone(), args.verbosity).context("failed to initialize logging")?; - let args = Args::parse_args().context("could not parse arguments")?; - - // Help has a higher priority and should be handled separately. if args.display_help { - print!("{}", help); - std::process::exit(0); + print!("{}", help::help()); + return Ok(0); } - if args.display_version { println!("helix {}", VERSION_AND_GIT_HASH); - std::process::exit(0); + return Ok(0); } - if args.health { if let Err(err) = helix_term::health::print_health(args.health_arg) { // Piping to for example `head -10` requires special handling: @@ -102,58 +35,62 @@ FLAGS: return Err(err.into()); } } - - std::process::exit(0); + return Ok(0); } - if args.fetch_grammars { helix_loader::grammar::fetch_grammars()?; return Ok(0); } - if args.build_grammars { helix_loader::grammar::build_grammars(None)?; return Ok(0); } - let logpath = args.log_file.as_ref().cloned().unwrap_or(logpath); - setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; - - let config_dir = helix_loader::config_dir(); - if !config_dir.exists() { - std::fs::create_dir_all(&config_dir).ok(); - } - - helix_loader::initialize_config_file(args.config_file.clone()); - - let config = match std::fs::read_to_string(helix_loader::config_file()) { - Ok(config) => toml::from_str(&config) - .map(helix_term::keymap::merge_keys) - .unwrap_or_else(|err| { - eprintln!("Bad config: {}", err); - eprintln!("Press to continue with default config"); - use std::io::Read; - let _ = std::io::stdin().read(&mut []); - Config::default() - }), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(), - Err(err) => return Err(Error::new(err)), - }; + helix_loader::setup_config_file(args.config_file.clone()); + let config = Config::merged().unwrap_or_else(|err| { + eprintln!("Bad config: {}", err); + eprintln!("Press to continue with default config"); + let _wait_for_enter = std::io::Read::read(&mut std::io::stdin(), &mut[]); + Config::default() + }); - let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| { + let language_configurations = LanguageConfigurations::merged().unwrap_or_else(|err| { eprintln!("Bad language config: {}", err); eprintln!("Press to continue with default language config"); - use std::io::Read; - // This waits for an enter press. - let _ = std::io::stdin().read(&mut []); - helix_core::config::default_syntax_loader() + let _wait_for_enter = std::io::Read::read(&mut std::io::stdin(), &mut[]); + LanguageConfigurations::default() }); // TODO: use the thread local executor to spawn the application task separately from the work pool - let mut app = Application::new(args, config, syn_loader_conf) + let mut app = Application::new(args, config, language_configurations) .context("unable to create new application")?; - let exit_code = app.run(&mut EventStream::new()).await?; - Ok(exit_code) } + +fn setup_logging(logpath: Option, verbosity: u64) -> Result<()> { + helix_loader::setup_log_file(logpath); + + let mut base_config = fern::Dispatch::new(); + base_config = match verbosity { + 0 => base_config.level(log::LevelFilter::Warn), + 1 => base_config.level(log::LevelFilter::Info), + 2 => base_config.level(log::LevelFilter::Debug), + _3_or_more => base_config.level(log::LevelFilter::Trace), + }; + + // Separate file config so we can include year, month and day in file logs + let file_config = fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{} {} [{}] {}", + chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), + record.target(), + record.level(), + message + )) + }) + .chain(fern::log_file(helix_loader::log_file())?); + base_config.chain(file_config).apply()?; + Ok(()) +} \ No newline at end of file diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 11d7886a37d6..076506dbc56b 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,7 +1,6 @@ use crate::compositor::{Component, Context, Event, EventResult}; use helix_view::{apply_transaction, editor::CompleteAction, ViewId}; use tui::buffer::Buffer as Surface; -use tui::text::Spans; use std::borrow::Cow; @@ -33,11 +32,7 @@ impl menu::Item for CompletionItem { .into() } - fn label(&self, _data: &Self::Data) -> Spans { - self.label.as_str().into() - } - - fn row(&self, _data: &Self::Data) -> menu::Row { + fn format(&self, _data: &Self::Data) -> menu::Row { menu::Row::new(vec![ menu::Cell::from(self.label.as_str()), menu::Cell::from(match self.kind { @@ -413,7 +408,7 @@ impl Component for Completion { option.detail.as_deref().unwrap_or_default(), contents ), - cx.editor.syn_loader.clone(), + cx.editor.lang_configs_loader.clone(), ) } Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { @@ -424,10 +419,10 @@ impl Component for Completion { if let Some(detail) = &option.detail.as_deref() { Markdown::new( format!("```{}\n{}\n```\n{}", language, detail, contents), - cx.editor.syn_loader.clone(), + cx.editor.lang_configs_loader.clone(), ) } else { - Markdown::new(contents.to_string(), cx.editor.syn_loader.clone()) + Markdown::new(contents.to_string(), cx.editor.lang_configs_loader.clone()) } } None if option.detail.is_some() => { @@ -440,7 +435,7 @@ impl Component for Completion { language, option.detail.as_deref().unwrap_or_default(), ), - cx.editor.syn_loader.clone(), + cx.editor.lang_configs_loader.clone(), ) } None => return, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 35cf77abc9bf..8111720a69cd 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -3,7 +3,7 @@ use crate::{ compositor::{Component, Context, Event, EventResult}, job::{self, Callback}, key, - keymap::{KeymapResult, Keymaps}, + keymap::keymaps::{KeymapResult, Keymaps}, ui::{Completion, ProgressSpinners}, }; @@ -48,12 +48,6 @@ pub enum InsertEvent { TriggerCompletion, } -impl Default for EditorView { - fn default() -> Self { - Self::new(Keymaps::default()) - } -} - impl EditorView { pub fn new(keymaps: Keymaps) -> Self { Self { @@ -342,23 +336,29 @@ impl EditorView { let selection_scope = theme .find_scope_index("ui.selection") .expect("could not find `ui.selection` scope in the theme!"); + let primary_selection_scope = theme + .find_scope_index("ui.selection.primary") + .unwrap_or(selection_scope); let base_cursor_scope = theme .find_scope_index("ui.cursor") .unwrap_or(selection_scope); + let base_primary_cursor_scope = theme + .find_scope_index("ui.cursor.primary") + .unwrap_or(base_cursor_scope); let cursor_scope = match mode { Mode::Insert => theme.find_scope_index("ui.cursor.insert"), Mode::Select => theme.find_scope_index("ui.cursor.select"), - Mode::Normal => Some(base_cursor_scope), + Mode::Normal => theme.find_scope_index("ui.cursor.normal"), } .unwrap_or(base_cursor_scope); - let primary_cursor_scope = theme - .find_scope_index("ui.cursor.primary") - .unwrap_or(cursor_scope); - let primary_selection_scope = theme - .find_scope_index("ui.selection.primary") - .unwrap_or(selection_scope); + let primary_cursor_scope = match mode { + Mode::Insert => theme.find_scope_index("ui.cursor.primary.insert"), + Mode::Select => theme.find_scope_index("ui.cursor.primary.select"), + Mode::Normal => theme.find_scope_index("ui.cursor.primary.normal"), + } + .unwrap_or(base_primary_cursor_scope); let mut spans: Vec<(usize, std::ops::Range)> = Vec::new(); for (i, range) in selection.iter().enumerate() { @@ -915,7 +915,7 @@ impl EditorView { let mut last_mode = mode; self.pseudo_pending.extend(self.keymaps.pending()); let key_result = self.keymaps.get(mode, event); - cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox()); + cxt.editor.autoinfo = self.keymaps.sticky_keytrie().map(|node| node.infobox()); let mut execute_command = |command: &commands::MappableCommand| { command.execute(cxt); @@ -955,7 +955,7 @@ impl EditorView { execute_command(command); } KeymapResult::Pending(node) => cxt.editor.autoinfo = Some(node.infobox()), - KeymapResult::MatchedSequence(commands) => { + KeymapResult::MatchedCommandSequence(commands) => { for command in commands { execute_command(command); } diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index b9c1f9ded2e1..e92578c5a136 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -4,7 +4,7 @@ use crate::{ compositor::{Callback, Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, }; -use tui::{buffer::Buffer as Surface, text::Spans, widgets::Table}; +use tui::{buffer::Buffer as Surface, widgets::Table}; pub use tui::widgets::{Cell, Row}; @@ -18,28 +18,24 @@ pub trait Item { /// Additional editor state that is used for label calculation. type Data; - fn label(&self, data: &Self::Data) -> Spans; + fn format(&self, data: &Self::Data) -> Row; fn sort_text(&self, data: &Self::Data) -> Cow { - let label: String = self.label(data).into(); + let label: String = self.format(data).cell_text().collect(); label.into() } fn filter_text(&self, data: &Self::Data) -> Cow { - let label: String = self.label(data).into(); + let label: String = self.format(data).cell_text().collect(); label.into() } - - fn row(&self, data: &Self::Data) -> Row { - Row::new(vec![Cell::from(self.label(data))]) - } } impl Item for PathBuf { /// Root prefix to strip. type Data = PathBuf; - fn label(&self, root_path: &Self::Data) -> Spans { + fn format(&self, root_path: &Self::Data) -> Row { self.strip_prefix(root_path) .unwrap_or(self) .to_string_lossy() @@ -144,10 +140,10 @@ impl Menu { let n = self .options .first() - .map(|option| option.row(&self.editor_data).cells.len()) + .map(|option| option.format(&self.editor_data).cells.len()) .unwrap_or_default(); let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| { - let row = option.row(&self.editor_data); + let row = option.format(&self.editor_data); // maintain max for each column for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { let width = cell.content.width(); @@ -331,7 +327,9 @@ impl Component for Menu { (a + b - 1) / b } - let rows = options.iter().map(|option| option.row(&self.editor_data)); + let rows = options + .iter() + .map(|option| option.format(&self.editor_data)); let table = Table::new(rows) .style(style) .highlight_style(selected) diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index eb4807581e8c..0a2a8b0bd18d 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -368,7 +368,7 @@ pub mod completers { let text: String = "text".into(); let language_ids = editor - .syn_loader + .lang_configs_loader .language_configs() .map(|config| &config.language_id) .chain(std::iter::once(&text)); diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index eb935e5672e5..abc002a37646 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -7,23 +7,23 @@ use crate::{ use futures_util::future::BoxFuture; use tui::{ buffer::Buffer as Surface, - widgets::{Block, BorderType, Borders}, + layout::Constraint, + text::{Span, Spans}, + widgets::{Block, BorderType, Borders, Cell, Table}, }; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use tui::widgets::Widget; -use std::{ - cmp::{self, Ordering}, - time::Instant, -}; +use std::cmp::{self, Ordering}; use std::{collections::HashMap, io::Read, path::PathBuf}; use crate::ui::{Prompt, PromptEvent}; -use helix_core::{movement::Direction, Position}; +use helix_core::{movement::Direction, unicode::segmentation::UnicodeSegmentation, Position}; use helix_view::{ editor::Action, graphics::{CursorKind, Margin, Modifier, Rect}, + theme::Style, Document, DocumentId, Editor, }; @@ -211,7 +211,7 @@ impl FilePicker { // Then attempt to highlight it if it has no language set if let Some(doc) = doc { if doc.language_config().is_none() { - let loader = cx.editor.syn_loader.clone(); + let loader = cx.editor.lang_configs_loader.clone(); doc.detect_language(loader); } } @@ -389,6 +389,8 @@ pub struct Picker { pub truncate_start: bool, /// Whether to show the preview panel (default true) show_preview: bool, + /// Constraints for tabular formatting + widths: Vec, callback_fn: Box, } @@ -406,6 +408,26 @@ impl Picker { |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {}, ); + let n = options + .first() + .map(|option| option.format(&editor_data).cells.len()) + .unwrap_or_default(); + let max_lens = options.iter().fold(vec![0; n], |mut acc, option| { + let row = option.format(&editor_data); + // maintain max for each column + for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { + let width = cell.content.width(); + if width > *acc { + *acc = width; + } + } + acc + }); + let widths = max_lens + .into_iter() + .map(|len| Constraint::Length(len as u16)) + .collect(); + let mut picker = Self { options, editor_data, @@ -418,6 +440,7 @@ impl Picker { show_preview: true, callback_fn: Box::new(callback_fn), completion_height: 0, + widths, }; // scoring on empty input: @@ -437,8 +460,6 @@ impl Picker { } pub fn score(&mut self) { - let now = Instant::now(); - let pattern = self.prompt.line(); if pattern == &self.previous_pattern { @@ -480,8 +501,6 @@ impl Picker { self.force_score(); } - log::debug!("picker score {:?}", Instant::now().duration_since(now)); - // reset cursor position self.cursor = 0; let pattern = self.prompt.line(); @@ -657,7 +676,7 @@ impl Component for Picker { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let text_style = cx.editor.theme.get("ui.text"); let selected = cx.editor.theme.get("ui.text.focus"); - let highlighted = cx.editor.theme.get("special").add_modifier(Modifier::BOLD); + let highlight_style = cx.editor.theme.get("special").add_modifier(Modifier::BOLD); // -- Render the frame: // clear area @@ -697,61 +716,123 @@ impl Component for Picker { } // -- Render the contents: - // subtract area of prompt from top and current item marker " > " from left - let inner = inner.clip_top(2).clip_left(3); + // subtract area of prompt from top + let inner = inner.clip_top(2); let rows = inner.height; let offset = self.cursor - (self.cursor % std::cmp::max(1, rows as usize)); + let cursor = self.cursor.saturating_sub(offset); - let files = self + let options = self .matches .iter() .skip(offset) - .map(|pmatch| (pmatch.index, self.options.get(pmatch.index).unwrap())); - - for (i, (_index, option)) in files.take(rows as usize).enumerate() { - let is_active = i == (self.cursor - offset); - if is_active { - surface.set_string( - inner.x.saturating_sub(3), - inner.y + i as u16, - " > ", - selected, - ); - surface.set_style( - Rect::new(inner.x, inner.y + i as u16, inner.width, 1), - selected, - ); - } + .take(rows as usize) + .map(|pmatch| &self.options[pmatch.index]) + .map(|option| option.format(&self.editor_data)) + .map(|mut row| { + const TEMP_CELL_SEP: &str = " "; + + let line = row.cell_text().fold(String::new(), |mut s, frag| { + s.push_str(&frag); + s.push_str(TEMP_CELL_SEP); + s + }); + + // Items are filtered by using the text returned by menu::Item::filter_text + // but we do highlighting here using the text in Row and therefore there + // might be inconsistencies. This is the best we can do since only the + // text in Row is displayed to the end user. + let (_score, highlights) = FuzzyQuery::new(self.prompt.line()) + .fuzzy_indicies(&line, &self.matcher) + .unwrap_or_default(); + + let highlight_byte_ranges: Vec<_> = line + .char_indices() + .enumerate() + .filter_map(|(char_idx, (byte_offset, ch))| { + highlights + .contains(&char_idx) + .then(|| byte_offset..byte_offset + ch.len_utf8()) + }) + .collect(); + + // The starting byte index of the current (iterating) cell + let mut cell_start_byte_offset = 0; + for cell in row.cells.iter_mut() { + let spans = match cell.content.lines.get(0) { + Some(s) => s, + None => continue, + }; - let spans = option.label(&self.editor_data); - let (_score, highlights) = FuzzyQuery::new(self.prompt.line()) - .fuzzy_indicies(&String::from(&spans), &self.matcher) - .unwrap_or_default(); - - spans.0.into_iter().fold(inner, |pos, span| { - let new_x = surface - .set_string_truncated( - pos.x, - pos.y + i as u16, - &span.content, - pos.width as usize, - |idx| { - if highlights.contains(&idx) { - highlighted.patch(span.style) - } else if is_active { - selected.patch(span.style) + let mut cell_len = 0; + + let graphemes_with_style: Vec<_> = spans + .0 + .iter() + .flat_map(|span| { + span.content + .grapheme_indices(true) + .zip(std::iter::repeat(span.style)) + }) + .map(|((grapheme_byte_offset, grapheme), style)| { + cell_len += grapheme.len(); + let start = cell_start_byte_offset; + + let grapheme_byte_range = + grapheme_byte_offset..grapheme_byte_offset + grapheme.len(); + + if highlight_byte_ranges.iter().any(|hl_rng| { + hl_rng.start >= start + grapheme_byte_range.start + && hl_rng.end <= start + grapheme_byte_range.end + }) { + (grapheme, style.patch(highlight_style)) } else { - text_style.patch(span.style) + (grapheme, style) } - }, - true, - self.truncate_start, - ) - .0; - pos.clip_left(new_x - pos.x) + }) + .collect(); + + let mut span_list: Vec<(String, Style)> = Vec::new(); + for (grapheme, style) in graphemes_with_style { + if span_list.last().map(|(_, sty)| sty) == Some(&style) { + let (string, _) = span_list.last_mut().unwrap(); + string.push_str(grapheme); + } else { + span_list.push((String::from(grapheme), style)) + } + } + + let spans: Vec = span_list + .into_iter() + .map(|(string, style)| Span::styled(string, style)) + .collect(); + let spans: Spans = spans.into(); + *cell = Cell::from(spans); + + cell_start_byte_offset += cell_len + TEMP_CELL_SEP.len(); + } + + row }); - } + + let table = Table::new(options) + .style(text_style) + .highlight_style(selected) + .highlight_symbol(" > ") + .column_spacing(1) + .widths(&self.widths); + + use tui::widgets::TableState; + + table.render_table( + inner, + surface, + &mut TableState { + offset: 0, + selected: Some(cursor), + }, + ); } fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index ccdafad5fdfe..a3e242feb6db 100644 --- a/helix-tui/src/text.rs +++ b/helix-tui/src/text.rs @@ -436,6 +436,32 @@ impl<'a> From>> for Text<'a> { } } +impl<'a> From> for String { + fn from(text: Text<'a>) -> String { + String::from(&text) + } +} + +impl<'a> From<&Text<'a>> for String { + fn from(text: &Text<'a>) -> String { + let size = text + .lines + .iter() + .flat_map(|spans| spans.0.iter().map(|span| span.content.len())) + .sum::() + + text.lines.len().saturating_sub(1); // for newline after each line + let mut output = String::with_capacity(size); + + for spans in &text.lines { + for span in &spans.0 { + output.push_str(&span.content); + } + output.push('\n'); + } + output + } +} + impl<'a> IntoIterator for Text<'a> { type Item = Spans<'a>; type IntoIter = std::vec::IntoIter; diff --git a/helix-tui/src/widgets/table.rs b/helix-tui/src/widgets/table.rs index a8f428a7a65a..400f65e0ad98 100644 --- a/helix-tui/src/widgets/table.rs +++ b/helix-tui/src/widgets/table.rs @@ -4,14 +4,8 @@ use crate::{ text::Text, widgets::{Block, Widget}, }; -use cassowary::{ - strength::{MEDIUM, REQUIRED, WEAK}, - WeightedRelation::*, - {Expression, Solver}, -}; use helix_core::unicode::width::UnicodeWidthStr; use helix_view::graphics::{Rect, Style}; -use std::collections::HashMap; /// A [`Cell`] contains the [`Text`] to be displayed in a [`Row`] of a [`Table`]. /// @@ -126,6 +120,17 @@ impl<'a> Row<'a> { fn total_height(&self) -> u16 { self.height.saturating_add(self.bottom_margin) } + + /// Returns the contents of cells as plain text, without styles and colors. + pub fn cell_text(&self) -> impl Iterator + '_ { + self.cells.iter().map(|cell| String::from(&cell.content)) + } +} + +impl<'a, T: Into>> From for Row<'a> { + fn from(cell: T) -> Self { + Row::new(vec![cell.into()]) + } } /// A widget to display data in formatted columns. @@ -260,69 +265,32 @@ impl<'a> Table<'a> { } fn get_columns_widths(&self, max_width: u16, has_selection: bool) -> Vec { - let mut solver = Solver::new(); - let mut var_indices = HashMap::new(); - let mut ccs = Vec::new(); - let mut variables = Vec::new(); - for i in 0..self.widths.len() { - let var = cassowary::Variable::new(); - variables.push(var); - var_indices.insert(var, i); - } - let spacing_width = (variables.len() as u16).saturating_sub(1) * self.column_spacing; - let mut available_width = max_width.saturating_sub(spacing_width); + let mut constraints = Vec::with_capacity(self.widths.len() * 2 + 1); if has_selection { let highlight_symbol_width = self.highlight_symbol.map(|s| s.width() as u16).unwrap_or(0); - available_width = available_width.saturating_sub(highlight_symbol_width); + constraints.push(Constraint::Length(highlight_symbol_width)); } - for (i, constraint) in self.widths.iter().enumerate() { - ccs.push(variables[i] | GE(WEAK) | 0.); - ccs.push(match *constraint { - Constraint::Length(v) => variables[i] | EQ(MEDIUM) | f64::from(v), - Constraint::Percentage(v) => { - variables[i] | EQ(WEAK) | (f64::from(v * available_width) / 100.0) - } - Constraint::Ratio(n, d) => { - variables[i] - | EQ(WEAK) - | (f64::from(available_width) * f64::from(n) / f64::from(d)) - } - Constraint::Min(v) => variables[i] | GE(WEAK) | f64::from(v), - Constraint::Max(v) => variables[i] | LE(WEAK) | f64::from(v), - }) + for constraint in self.widths { + constraints.push(*constraint); + constraints.push(Constraint::Length(self.column_spacing)); } - solver - .add_constraint( - variables - .iter() - .fold(Expression::from_constant(0.), |acc, v| acc + *v) - | LE(REQUIRED) - | f64::from(available_width), - ) - .unwrap(); - solver.add_constraints(&ccs).unwrap(); - let mut widths = vec![0; variables.len()]; - for &(var, value) in solver.fetch_changes() { - let index = var_indices[&var]; - let value = if value.is_sign_negative() { - 0 - } else { - value.round() as u16 - }; - widths[index] = value; + if !self.widths.is_empty() { + constraints.pop(); } - // Cassowary could still return columns widths greater than the max width when there are - // fixed length constraints that cannot be satisfied. Therefore, we clamp the widths from - // left to right. - let mut available_width = max_width; - for w in &mut widths { - *w = available_width.min(*w); - available_width = available_width - .saturating_sub(*w) - .saturating_sub(self.column_spacing); + let mut chunks = crate::layout::Layout::default() + .direction(crate::layout::Direction::Horizontal) + .constraints(constraints) + .split(Rect { + x: 0, + y: 0, + width: max_width, + height: 1, + }); + if has_selection { + chunks.remove(0); } - widths + chunks.iter().step_by(2).map(|c| c.width).collect() } fn get_row_bounds( @@ -477,6 +445,9 @@ impl<'a> Table<'a> { }; let mut col = table_row_start_col; for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) { + if is_selected { + buf.set_style(table_row_area, self.highlight_style); + } render_cell( buf, cell, @@ -489,9 +460,6 @@ impl<'a> Table<'a> { ); col += *width + self.column_spacing; } - if is_selected { - buf.set_style(table_row_area, self.highlight_style); - } } } } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 547a4ffbd40d..08862af8b6ba 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -701,7 +701,7 @@ pub struct Editor { pub clipboard_provider: Box, - pub syn_loader: Arc, + pub lang_configs_loader: Arc, pub theme_loader: Arc, /// last_theme is used for theme previews. We store the current theme here, /// and if previewing is cancelled, we can return to it. @@ -809,7 +809,7 @@ impl Editor { debugger: None, debugger_events: SelectAll::new(), breakpoints: HashMap::new(), - syn_loader, + lang_configs_loader: syn_loader, theme_loader, last_theme: None, last_line_number: None, @@ -915,7 +915,7 @@ impl Editor { } let scopes = theme.scopes(); - self.syn_loader.set_scopes(scopes.to_vec()); + self.lang_configs_loader.set_scopes(scopes.to_vec()); match preview { ThemeAction::Preview => { @@ -1134,7 +1134,7 @@ impl Editor { let id = if let Some(id) = id { id } else { - let mut doc = Document::open(&path, None, Some(self.syn_loader.clone()))?; + let mut doc = Document::open(&path, None, Some(self.lang_configs_loader.clone()))?; let _ = Self::launch_language_server(&mut self.language_servers, &mut doc); if let Some(diff_base) = self.diff_providers.get_diff_base(&path) { diff --git a/helix-view/src/info.rs b/helix-view/src/info.rs index 3080cf8e1b2f..1503e855e362 100644 --- a/helix-view/src/info.rs +++ b/helix-view/src/info.rs @@ -1,6 +1,5 @@ -use crate::input::KeyEvent; use helix_core::{register::Registers, unicode::width::UnicodeWidthStr}; -use std::{collections::BTreeSet, fmt::Write}; +use std::fmt::Write; #[derive(Debug)] /// Info box used in editor. Rendering logic will be in other crate. @@ -55,18 +54,6 @@ impl Info { } } - pub fn from_keymap(title: &str, body: Vec<(&str, BTreeSet)>) -> Self { - let body: Vec<_> = body - .into_iter() - .map(|(desc, events)| { - let events = events.iter().map(ToString::to_string).collect::>(); - (events.join(", "), desc) - }) - .collect(); - - Self::new(title, &body) - } - pub fn from_registers(registers: &Registers) -> Self { let body: Vec<_> = registers .inner() diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index cb0d3ac46d34..47bc67eade5e 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -5,7 +5,7 @@ use std::{ use anyhow::{anyhow, Context, Result}; use helix_core::hashmap; -use helix_loader::merge_toml_values; +use helix_loader::{merge_toml_values, repo_paths}; use log::warn; use once_cell::sync::Lazy; use serde::{Deserialize, Deserializer}; @@ -15,11 +15,12 @@ use crate::graphics::UnderlineStyle; pub use crate::graphics::{Color, Modifier, Style}; pub static DEFAULT_THEME_DATA: Lazy = Lazy::new(|| { - toml::from_slice(include_bytes!("../../theme.toml")).expect("Failed to parse default theme") + toml::from_slice(&std::fs::read(repo_paths::default_theme()).unwrap()) + .expect("Failed to parse default theme") }); pub static BASE16_DEFAULT_THEME_DATA: Lazy = Lazy::new(|| { - toml::from_slice(include_bytes!("../../base16_theme.toml")) + toml::from_slice(&std::fs::read(repo_paths::default_base16_theme()).unwrap()) .expect("Failed to parse base 16 default theme") }); diff --git a/xtask/src/docgen.rs b/xtask/src/docgen.rs index 473882f3ee65..ff4f3efa07e9 100644 --- a/xtask/src/docgen.rs +++ b/xtask/src/docgen.rs @@ -1,9 +1,9 @@ -use crate::helpers; -use crate::path; use crate::DynError; +use helix_core::syntax::LanguageConfigurations; +use helix_loader::repo_paths; +use helix_loader::ts_probe::TsFeature; use helix_term::commands::TYPABLE_COMMAND_LIST; -use helix_term::health::TsFeature; use std::fs; pub const TYPABLE_COMMANDS_MD_OUTPUT: &str = "typable-cmd.md"; @@ -62,30 +62,37 @@ pub fn lang_features() -> Result { cols.push("Default LSP".to_owned()); md.push_str(&md_table_heading(&cols)); - let config = helpers::lang_config(); + let lang_configs = LanguageConfigurations::default(); - let mut langs = config + let mut langs = lang_configs .language .iter() .map(|l| l.language_id.clone()) .collect::>(); langs.sort_unstable(); - let mut ts_features_to_langs = Vec::new(); - for &feat in ts_features { - ts_features_to_langs.push((feat, helpers::ts_lang_support(feat))); + let mut ts_support_by_feature = Vec::with_capacity(TsFeature::all().len()); + for feature in TsFeature::all() { + let mut langs_with_ts_support: Vec = Vec::new(); + for lang in LanguageConfigurations::default().language { + if helix_loader::grammar::load_runtime_file(&lang.language_id, feature.runtime_filename()).is_ok() { + langs_with_ts_support.push(lang.language_id.clone()); + } + } + + ts_support_by_feature.push(langs_with_ts_support); } let mut row = Vec::new(); for lang in langs { - let lc = config + let lc = lang_configs .language .iter() .find(|l| l.language_id == lang) .unwrap(); // lang comes from config row.push(lc.language_id.clone()); - for (_feat, support_list) in &ts_features_to_langs { + for support_list in &ts_support_by_feature { row.push( if support_list.contains(&lang) { "✓" @@ -112,6 +119,6 @@ pub fn lang_features() -> Result { pub fn write(filename: &str, data: &str) { let error = format!("Could not write to {}", filename); - let path = path::book_gen().join(filename); + let path = repo_paths::book_gen().join(filename); fs::write(path, data).expect(&error); } diff --git a/xtask/src/helpers.rs b/xtask/src/helpers.rs deleted file mode 100644 index 4f759e74f725..000000000000 --- a/xtask/src/helpers.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::path; -use helix_core::syntax::Configuration as LangConfig; -use helix_term::health::TsFeature; - -/// Get the list of languages that support a particular tree-sitter -/// based feature. -pub fn ts_lang_support(feat: TsFeature) -> Vec { - let queries_dir = path::ts_queries(); - - find_files(&queries_dir, feat.runtime_filename()) - .iter() - .map(|f| { - // .../helix/runtime/queries/python/highlights.scm - let tail = f.strip_prefix(&queries_dir).unwrap(); // python/highlights.scm - let lang = tail.components().next().unwrap(); // python - lang.as_os_str().to_string_lossy().to_string() - }) - .collect() -} - -// naive implementation, but suffices for our needs -pub fn find_files(dir: &Path, filename: &str) -> Vec { - std::fs::read_dir(dir) - .unwrap() - .filter_map(|entry| { - let path = entry.ok()?.path(); - if path.is_dir() { - Some(find_files(&path, filename)) - } else if path.file_name()?.to_string_lossy() == filename { - Some(vec![path]) - } else { - None - } - }) - .flatten() - .collect() -} - -pub fn lang_config() -> LangConfig { - let bytes = std::fs::read(path::lang_config()).unwrap(); - toml::from_slice(&bytes).unwrap() -} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1421fd1a1de6..f4d96e348d1f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,7 +1,4 @@ mod docgen; -mod helpers; -mod path; -mod querycheck; mod themelint; use std::{env, error::Error}; @@ -11,7 +8,6 @@ type DynError = Box; pub mod tasks { use crate::docgen::{lang_features, typable_commands, write}; use crate::docgen::{LANG_SUPPORT_MD_OUTPUT, TYPABLE_COMMANDS_MD_OUTPUT}; - use crate::querycheck::query_check; use crate::themelint::{lint, lint_all}; use crate::DynError; @@ -29,7 +25,27 @@ pub mod tasks { } pub fn querycheck() -> Result<(), DynError> { - query_check() + use helix_core::{syntax, tree_sitter::Query}; + use helix_loader::grammar::get_language; + use helix_loader::ts_probe::TsFeature; + for language_config in syntax::LanguageConfigurations::default().language { + for ts_feature in TsFeature::all() { + // TODO: do language name and grammar name discrepancies exist? + let language_name = &language_config.language_id; + let grammar_name = language_config.grammar.as_ref().unwrap_or(language_name); + if let Ok(treesitter_parser) = get_language(grammar_name) { + let query_feature_file_name = ts_feature.runtime_filename(); + let query_file_text_contents = syntax::read_query(language_name, query_feature_file_name); + if !query_file_text_contents.is_empty() { + if let Err(err) = Query::new(treesitter_parser, &query_file_text_contents) { + return Err(format!("Failed to parse {query_feature_file_name} queries for {language_name}: {err}").into()); + } + } + } + } + } + println!("Query check succeeded"); + Ok(()) } pub fn print_help() { diff --git a/xtask/src/path.rs b/xtask/src/path.rs deleted file mode 100644 index 6f4545c274aa..000000000000 --- a/xtask/src/path.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::path::{Path, PathBuf}; - -pub fn project_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf() -} - -pub fn book_gen() -> PathBuf { - project_root().join("book/src/generated/") -} - -pub fn ts_queries() -> PathBuf { - project_root().join("runtime/queries") -} - -pub fn lang_config() -> PathBuf { - project_root().join("languages.toml") -} - -pub fn themes() -> PathBuf { - project_root().join("runtime/themes") -} diff --git a/xtask/src/querycheck.rs b/xtask/src/querycheck.rs deleted file mode 100644 index 454d0e5cd9cb..000000000000 --- a/xtask/src/querycheck.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::DynError; - -pub fn query_check() -> Result<(), DynError> { - use crate::helpers::lang_config; - use helix_core::{syntax::read_query, tree_sitter::Query}; - use helix_loader::grammar::get_language; - - let query_files = [ - "highlights.scm", - "locals.scm", - "injections.scm", - "textobjects.scm", - "indents.scm", - ]; - - for language in lang_config().language { - let language_name = &language.language_id; - let grammar_name = language.grammar.as_ref().unwrap_or(language_name); - for query_file in query_files { - let language = get_language(grammar_name); - let query_text = read_query(language_name, query_file); - if let Ok(lang) = language { - if !query_text.is_empty() { - if let Err(reason) = Query::new(lang, &query_text) { - return Err(format!( - "Failed to parse {} queries for {}: {}", - query_file, language_name, reason - ) - .into()); - } - } - } - } - } - - println!("Query check succeeded"); - - Ok(()) -} diff --git a/xtask/src/themelint.rs b/xtask/src/themelint.rs index 06dfae407a23..3f9821ee30b7 100644 --- a/xtask/src/themelint.rs +++ b/xtask/src/themelint.rs @@ -1,5 +1,5 @@ -use crate::path; use crate::DynError; +use helix_loader::repo_paths; use helix_view::theme::Loader; use helix_view::theme::Modifier; use helix_view::Theme; @@ -155,7 +155,7 @@ pub fn lint(file: String) -> Result<(), DynError> { println!("Skipping base16: {}", file); return Ok(()); } - let path = path::themes().join(file.clone() + ".toml"); + let path = repo_paths::themes().join(file.clone() + ".toml"); let theme = std::fs::read(&path).unwrap(); let theme: Theme = toml::from_slice(&theme).expect("Failed to parse theme"); @@ -178,7 +178,7 @@ pub fn lint(file: String) -> Result<(), DynError> { } pub fn lint_all() -> Result<(), DynError> { - let files = Loader::read_names(path::themes().as_path()); + let files = Loader::read_names(repo_paths::themes().as_path()); let files_count = files.len(); let ok_files_count = files .into_iter()