diff options
40 files changed, 1583 insertions, 765 deletions
diff --git a/.github/ISSUE_TEMPLATE/blank_issue.md b/.github/ISSUE_TEMPLATE/blank_issue.md new file mode 100644 index 00000000..9aef3ebe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/blank_issue.md @@ -0,0 +1,4 @@ +--- +name: Blank Issue +about: Create a blank issue. +--- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..43ba412b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature request +about: Suggest a new feature or improvement +title: '' +labels: C-enchancement +assignees: '' +--- + +<!-- Your feature may already be reported! +Please search on the issue tracker before creating one. --> + +#### Describe your feature request + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d6ffd43..afb9a7b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --locked + args: --release --locked - name: Build release binary uses: actions-rs/cargo@v1 @@ -18,6 +18,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15af2628f6890fe2609a3b91bef4c83450512802e59489f9c1cb1fa5df064a61" [[package]] +name = "arc-swap" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e906254e445520903e7fc9da4f709886c84ae4bc4ddaf0e093188d66df4dc820" + +[[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -135,6 +141,12 @@ dependencies = [ ] [[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + +[[package]] name = "etcetera" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -254,6 +266,7 @@ dependencies = [ name = "helix-core" version = "0.2.0" dependencies = [ + "arc-swap", "etcetera", "helix-syntax", "once_cell", @@ -354,6 +367,7 @@ dependencies = [ "tokio", "toml", "url", + "which", ] [[package]] @@ -1058,6 +1072,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] +name = "which" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe" +dependencies = [ + "either", + "libc", +] + +[[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 3ea1fb9a..5dea3112 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -3,6 +3,7 @@ - [Installation](./install.md) - [Usage](./usage.md) - [Configuration](./configuration.md) + - [Themes](./themes.md) - [Keymap](./keymap.md) - [Key Remapping](./remapping.md) - [Hooks](./hooks.md) diff --git a/book/src/configuration.md b/book/src/configuration.md index 51a08e03..087d3fbb 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -1,97 +1,10 @@ # Configuration +To override global configuration parameters create a `config.toml` file located in your config directory (i.e `~/.config/helix/config.toml`). + ## LSP To disable language server progress report from being displayed in the status bar add this option to your `config.toml`: ```toml lsp-progress = false ``` - -## Theme - -Use a custom theme by placing a theme.toml in your config directory (i.e ~/.config/helix/theme.toml). The default theme.toml can be found [here](https://github.com/helix-editor/helix/blob/master/theme.toml), and user submitted themes [here](https://github.com/helix-editor/helix/blob/master/contrib/themes). - -Styles in theme.toml are specified of in the form: - -```toml -key = { fg = "#ffffff", bg = "#000000", modifiers = ["bold", "italic"] } -``` - -where `name` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, and `modifiers` is a list of style modifiers. `bg` and `modifiers` can be omitted to defer to the defaults. - -To specify only the foreground color: - -```toml -key = "#ffffff" -``` - -if the key contains a dot `'.'`, it must be quoted to prevent it being parsed as a [dotted key](https://toml.io/en/v1.0.0#keys). - -```toml -"key.key" = "#ffffff" -``` - -Possible modifiers: - -| Modifier | -| --- | -| `bold` | -| `dim` | -| `italic` | -| `underlined` | -| `slow_blink` | -| `rapid_blink` | -| `reversed` | -| `hidden` | -| `crossed_out` | - -Possible keys: - -| Key | Notes | -| --- | --- | -| `attribute` | | -| `keyword` | | -| `keyword.directive` | Preprocessor directives (\#if in C) | -| `namespace` | | -| `punctuation` | | -| `punctuation.delimiter` | | -| `operator` | | -| `special` | | -| `property` | | -| `variable` | | -| `variable.parameter` | | -| `type` | | -| `type.builtin` | | -| `constructor` | | -| `function` | | -| `function.macro` | | -| `function.builtin` | | -| `comment` | | -| `variable.builtin` | | -| `constant` | | -| `constant.builtin` | | -| `string` | | -| `number` | | -| `escape` | Escaped characters | -| `label` | For lifetimes | -| `module` | | -| `ui.background` | | -| `ui.linenr` | | -| `ui.linenr.selected` | For lines with cursors | -| `ui.statusline` | | -| `ui.popup` | | -| `ui.window` | | -| `ui.help` | | -| `ui.text` | | -| `ui.text.focus` | | -| `ui.menu.selected` | | -| `ui.selection` | For selections in the editing area | -| `warning` | LSP warning | -| `error` | LSP error | -| `info` | LSP info | -| `hint` | LSP hint | - -These keys match [tree-sitter scopes](https://tree-sitter.github.io/tree-sitter/syntax-highlighting#theme). We half-follow the common scopes from [macromates language grammars](https://macromates.com/manual/en/language_grammars) with some differences. - -For a given highlight produced, styling will be determined based on the longest matching theme key. So it's enough to provide function to highlight `function.macro` and `function.builtin` as well, but you can use more specific scopes to highlight specific cases differently. - diff --git a/book/src/keymap.md b/book/src/keymap.md index aee4b3a4..1e159f81 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -69,9 +69,8 @@ | `;` | Collapse selection onto a single cursor | | `Alt-;` | Flip selection cursor and anchor | | `%` | Select entire file | -| `x` | Select current line | -| `X` | Extend to next line | -| `[` | Expand selection to parent syntax node TODO: pick a key | +| `x` | Select current line, if already selected, extend to next line | +| `` | Expand selection to parent syntax node TODO: pick a key | | `J` | join lines inside selection | | `K` | keep selections matching the regex TODO: overlapped by hover help | | `Space` | keep only the primary selection TODO: overlapped by space mode | @@ -155,10 +154,10 @@ This layer is similar to vim keybindings as kakoune does not support window. | Key | Description | | ----- | ------------- | -| `w`, `ctrl-w` | Switch to next window | -| `v`, `ctrl-v` | Vertical right split | -| `h`, `ctrl-h` | Horizontal bottom split | -| `q`, `ctrl-q` | Close current window | +| `w`, `Ctrl-w` | Switch to next window | +| `v`, `Ctrl-v` | Vertical right split | +| `h`, `Ctrl-h` | Horizontal bottom split | +| `q`, `Ctrl-q` | Close current window | ## Space mode @@ -171,6 +170,11 @@ This layer is a kludge of mappings I had under leader key in neovim. | `s` | Open symbol picker (current document) | | `w` | Enter [window mode](#window-mode) | | `space` | Keep primary selection TODO: it's here because space mode replaced it | +| `p` | paste system clipboard after selections | +| `P` | paste system clipboard before selections | +| `y` | join and yank selections to clipboard | +| `Y` | yank main selection to clipboard | +| `R` | replace selections by clipboard contents | # Picker @@ -184,4 +188,4 @@ Keys to use within picker. | `Enter` | Open selected | | `Ctrl-h` | Open horizontally | | `Ctrl-v` | Open vertically | -| `Escape`, `ctrl-c` | Close picker | +| `Escape`, `Ctrl-c` | Close picker | diff --git a/book/src/remapping.md b/book/src/remapping.md index 610d5179..1b724be7 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -22,27 +22,29 @@ A-x = "normal_mode" # Maps Alt-X to enter normal mode Control, Shift and Alt modifiers are encoded respectively with the prefixes `C-`, `S-` and `A-`. Special keys are encoded as follows: -* Backspace => "backspace" -* Space => "space" -* Return/Enter => "ret" -* < => "lt" -* \> => "gt" -* \+ => "plus" -* \- => "minus" -* ; => "semicolon" -* % => "percent" -* Left => "left" -* Right => "right" -* Up => "up" -* Home => "home" -* End => "end" -* Page Up => "pageup" -* Page Down => "pagedown" -* Tab => "tab" -* Back Tab => "backtab" -* Delete => "del" -* Insert => "ins" -* Null => "null" -* Escape => "esc" +| Key name | Representation | +| --- | --- | +| Backspace | `"backspace"` | +| Space | `"space"` | +| Return/Enter | `"ret"` | +| < | `"lt"` | +| \> | `"gt"` | +| \+ | `"plus"` | +| \- | `"minus"` | +| ; | `"semicolon"` | +| % | `"percent"` | +| Left | `"left"` | +| Right | `"right"` | +| Up | `"up"` | +| Home | `"home"` | +| End | `"end"` | +| Page | `"pageup"` | +| Page | `"pagedown"` | +| Tab | `"tab"` | +| Back | `"backtab"` | +| Delete | `"del"` | +| Insert | `"ins"` | +| Null | `"null"` | +| Escape | `"esc"` | -Commands can be found in the source code at `../../helix-term/src/commands.rs` +Commands can be found in the source code at [`helix-term/src/commands.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands.rs) diff --git a/book/src/themes.md b/book/src/themes.md new file mode 100644 index 00000000..80fee3d7 --- /dev/null +++ b/book/src/themes.md @@ -0,0 +1,94 @@ +# Themes + +First you'll need to place selected themes in your `themes` directory (i.e `~/.config/helix/themes`), the directory might have to be created beforehand. + +To use a custom theme add `theme = <name>` to your [`config.toml`](./configuration.md) or override it during runtime using `:theme <name>`. + +The default theme.toml can be found [here](https://github.com/helix-editor/helix/blob/master/theme.toml), and user submitted themes [here](https://github.com/helix-editor/helix/blob/master/runtime/themes). + +## Creating a theme + +First create a file with the name of your theme as file name (i.e `mytheme.toml`) and place it in your `themes` directory (i.e `~/.config/helix/themes`). + +Each line in the theme file is specified as below: + +```toml +key = { fg = "#ffffff", bg = "#000000", modifiers = ["bold", "italic"] } +``` + +where `key` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, and `modifiers` is a list of style modifiers. `bg` and `modifiers` can be omitted to defer to the defaults. + +To specify only the foreground color: + +```toml +key = "#ffffff" +``` + +if the key contains a dot `'.'`, it must be quoted to prevent it being parsed as a [dotted key](https://toml.io/en/v1.0.0#keys). + +```toml +"key.key" = "#ffffff" +``` + +Possible modifiers: + +| Modifier | +| --- | +| `bold` | +| `dim` | +| `italic` | +| `underlined` | +| `slow\_blink` | +| `rapid\_blink` | +| `reversed` | +| `hidden` | +| `crossed\_out` | + +Possible keys: + +| Key | Notes | +| --- | --- | +| `attribute` | | +| `keyword` | | +| `keyword.directive` | Preprocessor directives (\#if in C) | +| `namespace` | | +| `punctuation` | | +| `punctuation.delimiter` | | +| `operator` | | +| `special` | | +| `property` | | +| `variable` | | +| `variable.parameter` | | +| `type` | | +| `type.builtin` | | +| `constructor` | | +| `function` | | +| `function.macro` | | +| `function.builtin` | | +| `comment` | | +| `variable.builtin` | | +| `constant` | | +| `constant.builtin` | | +| `string` | | +| `number` | | +| `escape` | Escaped characters | +| `label` | For lifetimes | +| `module` | | +| `ui.background` | | +| `ui.linenr` | | +| `ui.statusline` | | +| `ui.popup` | | +| `ui.window` | | +| `ui.help` | | +| `ui.text` | | +| `ui.text.focus` | | +| `ui.menu.selected` | | +| `ui.selection` | For selections in the editing area | +| `warning` | LSP warning | +| `error` | LSP error | +| `info` | LSP info | +| `hint` | LSP hint | + +These keys match [tree-sitter scopes](https://tree-sitter.github.io/tree-sitter/syntax-highlighting#theme). We half-follow the common scopes from [macromates language grammars](https://macromates.com/manual/en/language_grammars) with some differences. + +For a given highlight produced, styling will be determined based on the longest matching theme key. So it's enough to provide function to highlight `function.macro` and `function.builtin` as well, but you can use more specific scopes to highlight specific cases differently. diff --git a/contrib/themes b/contrib/themes new file mode 120000 index 00000000..d09bf827 --- /dev/null +++ b/contrib/themes @@ -0,0 +1 @@ +../runtime/themes
\ No newline at end of file diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 13ac35fb..bab062e1 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -19,12 +19,13 @@ helix-syntax = { version = "0.2", path = "../helix-syntax" } ropey = "1.3" smallvec = "1.4" tendril = "0.4.2" -unicode-segmentation = "1.7.1" +unicode-segmentation = "1.7" unicode-width = "0.1" -unicode-general-category = "0.4.0" +unicode-general-category = "0.4" # slab = "0.4.2" tree-sitter = "0.19" once_cell = "1.8" +arc-swap = "1" regex = "1" serde = { version = "1.0", features = ["derive"] } diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 58124ed2..8e0379e2 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -254,26 +254,23 @@ where Configuration, IndentationConfiguration, Lang, LanguageConfiguration, Loader, }; use once_cell::sync::OnceCell; - let loader = Loader::new( - Configuration { - language: vec![LanguageConfiguration { - scope: "source.rust".to_string(), - file_types: vec!["rs".to_string()], - language_id: Lang::Rust, - highlight_config: OnceCell::new(), - // - roots: vec![], - auto_format: false, - language_server: None, - indent: Some(IndentationConfiguration { - tab_width: 4, - unit: String::from(" "), - }), - indent_query: OnceCell::new(), - }], - }, - Vec::new(), - ); + let loader = Loader::new(Configuration { + language: vec![LanguageConfiguration { + scope: "source.rust".to_string(), + file_types: vec!["rs".to_string()], + language_id: Lang::Rust, + highlight_config: OnceCell::new(), + // + roots: vec![], + auto_format: false, + language_server: None, + indent: Some(IndentationConfiguration { + tab_width: 4, + unit: String::from(" "), + }), + indent_query: OnceCell::new(), + }], + }); // set runtime path so we can find the queries let mut runtime = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 183b9f0a..69294688 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -19,6 +19,12 @@ mod state; pub mod syntax; mod transaction; +pub mod unicode { + pub use unicode_general_category as category; + pub use unicode_segmentation as segmentation; + pub use unicode_width as width; +} + static RUNTIME_DIR: once_cell::sync::Lazy<std::path::PathBuf> = once_cell::sync::Lazy::new(runtime_dir); @@ -51,7 +57,7 @@ pub fn find_root(root: Option<&str>) -> Option<std::path::PathBuf> { } #[cfg(not(embed_runtime))] -fn runtime_dir() -> std::path::PathBuf { +pub fn runtime_dir() -> std::path::PathBuf { if let Ok(dir) = std::env::var("HELIX_RUNTIME") { return dir.into(); } @@ -98,8 +104,6 @@ pub use ropey::{Rope, RopeSlice}; pub use tendril::StrTendril as Tendril; -pub use unicode_general_category::get_general_category; - #[doc(inline)] pub use {regex, tree_sitter}; diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 92e52d73..63ca424e 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -1,6 +1,8 @@ use crate::{chars::char_is_line_ending, regex::Regex, Change, Rope, RopeSlice, Transaction}; pub use helix_syntax::{get_language, get_language_name, Lang}; +use arc_swap::ArcSwap; + use std::{ borrow::Cow, cell::RefCell, @@ -143,37 +145,49 @@ fn read_query(language: &str, filename: &str) -> String { } impl LanguageConfiguration { - pub fn highlight_config(&self, scopes: &[String]) -> Option<Arc<HighlightConfiguration>> { - self.highlight_config - .get_or_init(|| { - let language = get_language_name(self.language_id).to_ascii_lowercase(); + fn initialize_highlight(&self, scopes: &[String]) -> Option<Arc<HighlightConfiguration>> { + let language = get_language_name(self.language_id).to_ascii_lowercase(); - let highlights_query = read_query(&language, "highlights.scm"); - // always highlight syntax errors - // highlights_query += "\n(ERROR) @error"; + let highlights_query = read_query(&language, "highlights.scm"); + // always highlight syntax errors + // highlights_query += "\n(ERROR) @error"; - let injections_query = read_query(&language, "injections.scm"); + let injections_query = read_query(&language, "injections.scm"); - let locals_query = ""; + let locals_query = ""; - if highlights_query.is_empty() { - None - } else { - let language = get_language(self.language_id); - let mut config = HighlightConfiguration::new( - language, - &highlights_query, - &injections_query, - locals_query, - ) - .unwrap(); // TODO: no unwrap - config.configure(scopes); - Some(Arc::new(config)) - } - }) + if highlights_query.is_empty() { + None + } else { + let language = get_language(self.language_id); + let mut config = HighlightConfiguration::new( + language, + &highlights_query, + &injections_query, + locals_query, + ) + .unwrap(); // TODO: no unwrap + config.configure(scopes); + Some(Arc::new(config)) + } + } + + pub fn reconfigure(&self, scopes: &[String]) { + if let Some(Some(config)) = self.highlight_config.get() { + config.configure(scopes); + } + } + + pub fn highlight_config(&self, scopes: &[String]) -> Option<Arc<HighlightConfiguration>> { + self.highlight_config + .get_or_init(|| self.initialize_highlight(scopes)) .clone() } + pub fn is_highlight_initialized(&self) -> bool { + self.highlight_config.get().is_some() + } + pub fn indent_query(&self) -> Option<&IndentQuery> { self.indent_query .get_or_init(|| { @@ -190,22 +204,18 @@ impl LanguageConfiguration { } } -pub static LOADER: OnceCell<Loader> = OnceCell::new(); - #[derive(Debug)] pub struct Loader { // highlight_names ? language_configs: Vec<Arc<LanguageConfiguration>>, language_config_ids_by_file_type: HashMap<String, usize>, // Vec<usize> - scopes: Vec<String>, } impl Loader { - pub fn new(config: Configuration, scopes: Vec<String>) -> Self { + pub fn new(config: Configuration) -> Self { let mut loader = Self { language_configs: Vec::new(), language_config_ids_by_file_type: HashMap::new(), - scopes, }; for config in config.language { @@ -225,10 +235,6 @@ impl Loader { loader } - pub fn scopes(&self) -> &[String] { - &self.scopes - } - pub fn language_config_for_file_name(&self, path: &Path) -> Option<Arc<LanguageConfiguration>> { // Find all the language configurations that match this file name // or a suffix of the file name. @@ -253,6 +259,10 @@ impl Loader { .find(|config| config.scope == scope) .cloned() } + + pub fn language_configs_iter(&self) -> impl Iterator<Item = &Arc<LanguageConfiguration>> { + self.language_configs.iter() + } } pub struct TsParser { @@ -772,7 +782,7 @@ pub struct HighlightConfiguration { combined_injections_query: Option<Query>, locals_pattern_index: usize, highlights_pattern_index: usize, - highlight_indices: Vec<Option<Highlight>>, + highlight_indices: ArcSwap<Vec<Option<Highlight>>>, non_local_variable_patterns: Vec<bool>, injection_content_capture_index: Option<u32>, injection_language_capture_index: Option<u32>, @@ -924,7 +934,7 @@ impl HighlightConfiguration { } } - let highlight_indices = vec![None; query.capture_names().len()]; + let highlight_indices = ArcSwap::from_pointee(vec![None; query.capture_names().len()]); Ok(Self { language, query, @@ -957,17 +967,20 @@ impl HighlightConfiguration { /// /// When highlighting, results are returned as `Highlight` values, which contain the index /// of the matched highlight this list of highlight names. - pub fn configure(&mut self, recognized_names: &[String]) { + pub fn configure(&self, recognized_names: &[String]) { let mut capture_parts = Vec::new(); - self.highlight_indices.clear(); - self.highlight_indices - .extend(self.query.capture_names().iter().map(move |capture_name| { + let indices: Vec<_> = self + .query + .capture_names() + .iter() + .map(move |capture_name| { capture_parts.clear(); capture_parts.extend(capture_name.split('.')); let mut best_index = None; let mut best_match_len = 0; for (i, recognized_name) in recognized_names.iter().enumerate() { + let recognized_name = recognized_name; let mut len = 0; let mut matches = true; for part in recognized_name.split('.') { @@ -983,7 +996,10 @@ impl HighlightConfiguration { } } best_index.map(Highlight) - })); + }) + .collect(); + + self.highlight_indices.store(Arc::new(indices)); } } @@ -1562,7 +1578,7 @@ where } } - let current_highlight = layer.config.highlight_indices[capture.index as usize]; + let current_highlight = layer.config.highlight_indices.load()[capture.index as usize]; // If this node represents a local definition, then store the current // highlight value on the local scope entry representing this node. diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index ce43808a..08853ed0 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,7 +1,8 @@ +use helix_core::syntax; use helix_lsp::{lsp, LspProgressMap}; -use helix_view::{document::Mode, Document, Editor, Theme, View}; +use helix_view::{document::Mode, theme, Document, Editor, Theme, View}; -use crate::{args::Args, compositor::Compositor, config::Config, ui}; +use crate::{args::Args, compositor::Compositor, config::Config, keymap::Keymaps, ui}; use log::{error, info}; @@ -14,7 +15,7 @@ use std::{ time::Duration, }; -use anyhow::Error; +use anyhow::{Context, Error}; use crossterm::{ event::{Event, EventStream}, @@ -36,6 +37,8 @@ pub struct Application { compositor: Compositor, editor: Editor, + theme_loader: Arc<theme::Loader>, + syn_loader: Arc<syntax::Loader>, callbacks: LspCallbacks, lsp_progress: LspProgressMap, @@ -47,9 +50,36 @@ impl Application { use helix_view::editor::Action; let mut compositor = Compositor::new()?; let size = compositor.size(); - let mut editor = Editor::new(size); - let mut editor_view = Box::new(ui::EditorView::new(config.keys)); + let conf_dir = helix_core::config_dir(); + + let theme_loader = + std::sync::Arc::new(theme::Loader::new(&conf_dir, &helix_core::runtime_dir())); + + // load $HOME/.config/helix/languages.toml, fallback to default config + let lang_conf = std::fs::read(conf_dir.join("languages.toml")); + let lang_conf = lang_conf + .as_deref() + .unwrap_or(include_bytes!("../../languages.toml")); + + let theme = if let Some(theme) = &config.global.theme { + match theme_loader.load(theme) { + Ok(theme) => theme, + Err(e) => { + log::warn!("failed to load theme `{}` - {}", theme, e); + theme_loader.default() + } + } + } else { + theme_loader.default() + }; + + let syn_loader_conf = toml::from_slice(lang_conf).expect("Could not parse languages.toml"); + let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); + + let mut editor = Editor::new(size, theme_loader.clone(), syn_loader.clone()); + + let mut editor_view = Box::new(ui::EditorView::new(config.keymaps)); compositor.push(editor_view); if !args.files.is_empty() { @@ -72,10 +102,14 @@ impl Application { editor.new_file(Action::VerticalSplit); } + editor.set_theme(theme); + let mut app = Self { compositor, editor, + theme_loader, + syn_loader, callbacks: FuturesUnordered::new(), lsp_progress: LspProgressMap::new(), lsp_progress_enabled: config.global.lsp_progress, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index b006504b..28c4fe3a 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -11,7 +11,6 @@ use helix_core::{ use helix_view::{ document::{IndentStyle, Mode}, - input::{KeyCode, KeyEvent}, view::{View, PADDING}, Document, DocumentId, Editor, ViewId, }; @@ -39,8 +38,8 @@ use std::{ path::{Path, PathBuf}, }; +use crossterm::event::{KeyCode, KeyEvent}; use once_cell::sync::Lazy; -use serde::de::{self, Deserialize, Deserializer}; pub struct Context<'a> { pub selected_register: helix_view::RegisterSelection, @@ -186,7 +185,6 @@ impl Command { search_next, extend_search_next, search_selection, - select_line, extend_line, delete_selection, change_selection, @@ -223,9 +221,14 @@ impl Command { undo, redo, yank, + yank_joined_to_clipboard, + yank_main_selection_to_clipboard, replace_with_yanked, + replace_selections_with_clipboard, paste_after, paste_before, + paste_clipboard_after, + paste_clipboard_before, indent, unindent, format_selections, @@ -253,48 +256,6 @@ impl Command { ); } -impl fmt::Debug for Command { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Command(name, _) = self; - f.debug_tuple("Command").field(name).finish() - } -} - -impl fmt::Display for Command { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Command(name, _) = self; - f.write_str(name) - } -} - -impl std::str::FromStr for Command { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - Command::COMMAND_LIST - .iter() - .copied() - .find(|cmd| cmd.0 == s) - .ok_or_else(|| anyhow!("No command named '{}'", s)) - } -} - -impl<'de> Deserialize<'de> for Command { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - s.parse().map_err(de::Error::custom) - } -} - -impl PartialEq for Command { - fn eq(&self, other: &Self) -> bool { - self.name() == other.name() - } -} - fn move_char_left(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); @@ -926,21 +887,6 @@ fn search_selection(cx: &mut Context) { // -fn select_line(cx: &mut Context) { - let count = cx.count(); - let (view, doc) = current!(cx.editor); - - let pos = doc.selection(view.id).primary(); - let text = doc.text(); - - let line = text.char_to_line(pos.head); - let start = text.line_to_char(line); - let end = text - .line_to_char(std::cmp::min(doc.text().len_lines(), line + count)) - .saturating_sub(1); - - doc.set_selection(view.id, Selection::single(start, end)); -} fn extend_line(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); @@ -1318,6 +1264,57 @@ mod cmd { quit_all_impl(editor, args, event, true) } + fn theme(editor: &mut Editor, args: &[&str], event: PromptEvent) { + let theme = if let Some(theme) = args.first() { + theme + } else { + editor.set_error("theme name not provided".into()); + return; + }; + + editor.set_theme_from_name(theme); + } + + fn yank_main_selection_to_clipboard(editor: &mut Editor, _: &[&str], _: PromptEvent) { + yank_main_selection_to_clipboard_impl(editor); + } + + fn yank_joined_to_clipboard(editor: &mut Editor, args: &[&str], _: PromptEvent) { + let separator = args.first().copied().unwrap_or("\n"); + yank_joined_to_clipboard_impl(editor, separator); + } + + fn paste_clipboard_after(editor: &mut Editor, _: &[&str], _: PromptEvent) { + paste_clipboard_impl(editor, Paste::After); + } + + fn paste_clipboard_before(editor: &mut Editor, _: &[&str], _: PromptEvent) { + paste_clipboard_impl(editor, Paste::After); + } + + fn replace_selections_with_clipboard(editor: &mut Editor, _: &[&str], _: PromptEvent) { + let (view, doc) = current!(editor); + + match editor.clipboard_provider.get_contents() { + Ok(contents) => { + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { + let max_to = doc.text().len_chars().saturating_sub(1); + let to = std::cmp::min(max_to, range.to() + 1); + (range.from(), to, Some(contents.as_str().into())) + }); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + } + Err(e) => log::error!("Couldn't get system clipboard contents: {:?}", e), + } + } + + fn show_clipboard_provider(editor: &mut Editor, _: &[&str], _: PromptEvent) { + editor.set_status(editor.clipboard_provider.name().into()); + } + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -1431,7 +1428,55 @@ mod cmd { fun: force_quit_all, completer: None, }, - + TypableCommand { + name: "theme", + alias: None, + doc: "Change the theme of current view. Requires theme name as argument (:theme <name>)", + fun: theme, + completer: Some(completers::theme), + }, + TypableCommand { + name: "clipboard-yank", + alias: None, + doc: "Yank main selection into system clipboard.", + fun: yank_main_selection_to_clipboard, + completer: None, + }, + TypableCommand { + name: "clipboard-yank-join", + alias: None, + doc: "Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. + fun: yank_joined_to_clipboard, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-after", + alias: None, + doc: "Paste system clipboard after selections.", + fun: paste_clipboard_after, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-before", + alias: None, + doc: "Paste system clipboard before selections.", + fun: paste_clipboard_before, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-replace", + alias: None, + doc: "Replace selections with content of system clipboard.", + fun: replace_selections_with_clipboard, + completer: None, + }, + TypableCommand { + name: "show-clipboard-provider", + alias: None, + doc: "Show clipboard provider name in status bar.", + fun: show_clipboard_provider, + completer: None, + }, ]; pub static COMMANDS: Lazy<HashMap<&'static str, &'static TypableCommand>> = Lazy::new(|| { @@ -2424,6 +2469,52 @@ fn yank(cx: &mut Context) { cx.editor.set_status(msg) } +fn yank_joined_to_clipboard_impl(editor: &mut Editor, separator: &str) { + let (view, doc) = current!(editor); + + let values: Vec<String> = doc + .selection(view.id) + .fragments(doc.text().slice(..)) + .map(Cow::into_owned) + .collect(); + + let msg = format!( + "joined and yanked {} selection(s) to system clipboard", + values.len(), + ); + + let joined = values.join(separator); + + if let Err(e) = editor.clipboard_provider.set_contents(joined) { + log::error!("Couldn't set system clipboard content: {:?}", e); + } + + editor.set_status(msg); +} + +fn yank_joined_to_clipboard(cx: &mut Context) { + yank_joined_to_clipboard_impl(&mut cx.editor, "\n"); +} + +fn yank_main_selection_to_clipboard_impl(editor: &mut Editor) { + let (view, doc) = current!(editor); + + let value = doc + .selection(view.id) + .primary() + .fragment(doc.text().slice(..)); + + if let Err(e) = editor.clipboard_provider.set_contents(value.into_owned()) { + log::error!("Couldn't set system clipboard content: {:?}", e); + } + + editor.set_status("yanked main selection to system clipboard".to_owned()); +} + +fn yank_main_selection_to_clipboard(cx: &mut Context) { + yank_main_selection_to_clipboard_impl(&mut cx.editor); +} + #[derive(Copy, Clone)] enum Paste { Before, @@ -2469,6 +2560,31 @@ fn paste_impl( Some(transaction) } +fn paste_clipboard_impl(editor: &mut Editor, action: Paste) { + let (view, doc) = current!(editor); + + match editor + .clipboard_provider + .get_contents() + .map(|contents| paste_impl(&[contents], doc, view, action)) + { + Ok(Some(transaction)) => { + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + } + Ok(None) => {} + Err(e) => log::error!("Couldn't get system clipboard contents: {:?}", e), + } +} + +fn paste_clipboard_after(cx: &mut Context) { + paste_clipboard_impl(&mut cx.editor, Paste::After); +} + +fn paste_clipboard_before(cx: &mut Context) { + paste_clipboard_impl(&mut cx.editor, Paste::Before); +} + fn replace_with_yanked(cx: &mut Context) { let reg_name = cx.selected_register.name(); let (view, doc) = current!(cx.editor); @@ -2489,6 +2605,29 @@ fn replace_with_yanked(cx: &mut Context) { } } +fn replace_selections_with_clipboard_impl(editor: &mut Editor) { + let (view, doc) = current!(editor); + + match editor.clipboard_provider.get_contents() { + Ok(contents) => { + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { + let max_to = doc.text().len_chars().saturating_sub(1); + let to = std::cmp::min(max_to, range.to() + 1); + (range.from(), to, Some(contents.as_str().into())) + }); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + } + Err(e) => log::error!("Couldn't get system clipboard contents: {:?}", e), + } +} + +fn replace_selections_with_clipboard(cx: &mut Context) { + replace_selections_with_clipboard_impl(&mut cx.editor); +} + // alt-p => paste every yanked selection after selected text // alt-P => paste every yanked selection before selected text // R => replace selected text with yanked text @@ -2854,7 +2993,7 @@ fn hover(cx: &mut Context) { // skip if contents empty - let contents = ui::Markdown::new(contents); + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); let mut popup = Popup::new(contents); compositor.push(Box::new(popup)); } @@ -3009,6 +3148,11 @@ fn space_mode(cx: &mut Context) { 'b' => buffer_picker(cx), 's' => symbol_picker(cx), 'w' => window_mode(cx), + 'y' => yank_joined_to_clipboard(cx), + 'Y' => yank_main_selection_to_clipboard(cx), + 'p' => paste_clipboard_after(cx), + 'P' => paste_clipboard_before(cx), + 'R' => replace_selections_with_clipboard(cx), // ' ' => toggle_alternate_buffer(cx), // TODO: temporary since space mode took its old key ' ' => keep_primary_selection(cx), @@ -3092,3 +3236,29 @@ fn right_bracket_mode(cx: &mut Context) { } }) } + +impl fmt::Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Command(name, _) = self; + f.write_str(name) + } +} + +impl std::str::FromStr for Command { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Command::COMMAND_LIST + .iter() + .copied() + .find(|cmd| cmd.0 == s) + .ok_or_else(|| anyhow!("No command named '{}'", s)) + } +} + +impl fmt::Debug for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Command(name, _) = self; + f.debug_tuple("Command").field(name).finish() + } +} diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 6b39bb62..0e6a313d 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -178,13 +178,13 @@ pub trait AnyComponent { /// Returns a boxed any from a boxed self. /// /// Can be used before `Box::downcast()`. - /// - /// # Examples - /// - /// ```rust - /// // let boxed: Box<Component> = Box::new(TextComponent::new("text")); - /// // let text: Box<TextComponent> = boxed.as_boxed_any().downcast().unwrap(); - /// ``` + // + // # Examples + // + // ```rust + // let boxed: Box<Component> = Box::new(TextComponent::new("text")); + // let text: Box<TextComponent> = boxed.as_boxed_any().downcast().unwrap(); + // ``` fn as_boxed_any(self: Box<Self>) -> Box<dyn Any>; } diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 9c962299..2c95fae3 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -1,63 +1,55 @@ -use serde::Deserialize; +use anyhow::{Error, Result}; +use std::{collections::HashMap, str::FromStr}; -use crate::commands::Command; -use crate::keymap::Keymaps; +use serde::{de::Error as SerdeError, Deserialize, Serialize}; + +use crate::keymap::{parse_keymaps, Keymaps}; -#[derive(Debug, PartialEq, Deserialize)] pub struct GlobalConfig { + pub theme: Option<String>, pub lsp_progress: bool, } impl Default for GlobalConfig { fn default() -> Self { - Self { lsp_progress: true } + Self { + lsp_progress: true, + theme: None, + } } } -#[derive(Debug, Default, PartialEq, Deserialize)] -#[serde(default)] +#[derive(Default)] pub struct Config { pub global: GlobalConfig, - pub keys: Keymaps, + pub keymaps: Keymaps, } -#[test] -fn parsing_keymaps_config_file() { - use helix_core::hashmap; - use helix_view::document::Mode; - use helix_view::input::{KeyCode, KeyEvent, KeyModifiers}; - - let sample_keymaps = r#" - [keys.insert] - y = "move_line_down" - S-C-a = "delete_selection" - - [keys.normal] - A-F12 = "move_next_word_end" - "#; +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct TomlConfig { + theme: Option<String>, + lsp_progress: Option<bool>, + keys: Option<HashMap<String, HashMap<String, String>>>, +} - assert_eq!( - toml::from_str::<Config>(sample_keymaps).unwrap(), - Config { - global: Default::default(), - keys: Keymaps(hashmap! { - Mode::Insert => hashmap! { - KeyEvent { - code: KeyCode::Char('y'), - modifiers: KeyModifiers::NONE, - } => Command::move_line_down, - KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL, - } => Command::delete_selection, - }, - Mode::Normal => hashmap! { - KeyEvent { - code: KeyCode::F(12), - modifiers: KeyModifiers::ALT, - } => Command::move_next_word_end, - }, - }) - } - ); +impl<'de> Deserialize<'de> for Config { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let config = TomlConfig::deserialize(deserializer)?; + Ok(Self { + global: GlobalConfig { + lsp_progress: config.lsp_progress.unwrap_or(true), + theme: config.theme, + }, + keymaps: config + .keys + .map(|r| parse_keymaps(&r)) + .transpose() + .map_err(|e| D::Error::custom(format!("Error deserializing keymap: {}", e)))? + .unwrap_or_else(Keymaps::default), + }) + } } diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 24924832..46d495c3 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -3,8 +3,6 @@ pub use crate::commands::Command; use anyhow::{anyhow, Error, Result}; use helix_core::hashmap; use helix_view::document::Mode; -use helix_view::input::{KeyCode, KeyEvent, KeyModifiers}; -use serde::Deserialize; use std::{ collections::HashMap, fmt::Display, @@ -101,6 +99,14 @@ use std::{ // D] = last diagnostic // } +// #[cfg(feature = "term")] +pub use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Clone, Debug)] +pub struct Keymap(pub HashMap<KeyEvent, Command>); +#[derive(Clone, Debug)] +pub struct Keymaps(pub HashMap<Mode, Keymap>); + #[macro_export] macro_rules! key { ($key:ident) => { @@ -135,21 +141,9 @@ macro_rules! alt { }; } -#[derive(Debug, PartialEq, Deserialize)] -#[serde(transparent)] -pub struct Keymaps(pub HashMap<Mode, HashMap<KeyEvent, Command>>); - -impl Deref for Keymaps { - type Target = HashMap<Mode, HashMap<KeyEvent, Command>>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - impl Default for Keymaps { - fn default() -> Keymaps { - let normal = hashmap!( + fn default() -> Self { + let normal = Keymap(hashmap!( key!('h') => Command::move_char_left, key!('j') => Command::move_line_down, key!('k') => Command::move_line_up, @@ -202,9 +196,7 @@ impl Default for Keymaps { key!(';') => Command::collapse_selection, alt!(';') => Command::flip_selections, key!('%') => Command::select_all, - key!('x') => Command::select_line, - key!('X') => Command::extend_line, - // or select mode X? + key!('x') => Command::extend_line, // extend_to_whole_line, crop_to_whole_line @@ -283,12 +275,12 @@ impl Default for Keymaps { key!('z') => Command::view_mode, key!('"') => Command::select_register, - ); + )); // TODO: decide whether we want normal mode to also be select mode (kakoune-like), or whether // we keep this separate select mode. More keys can fit into normal mode then, but it's weird // because some selection operations can now be done from normal mode, some from select mode. let mut select = normal.clone(); - select.extend( + select.0.extend( hashmap!( key!('h') => Command::extend_char_left, key!('j') => Command::extend_line_down, @@ -321,7 +313,7 @@ impl Default for Keymaps { // TODO: select could be normal mode with some bindings merged over Mode::Normal => normal, Mode::Select => select, - Mode::Insert => hashmap!( + Mode::Insert => Keymap(hashmap!( key!(Esc) => Command::normal_mode as Command, key!(Backspace) => Command::delete_char_backward, key!(Delete) => Command::delete_char_forward, @@ -333,9 +325,313 @@ impl Default for Keymaps { key!(Right) => Command::move_char_right, key!(PageUp) => Command::page_up, key!(PageDown) => Command::page_down, + key!(Home) => Command::move_line_start, + key!(End) => Command::move_line_end, ctrl!('x') => Command::completion, ctrl!('w') => Command::delete_word_backward, - ), + )), )) } } + +// Newtype wrapper over keys to allow toml serialization/parsing +#[derive(Debug, PartialEq, PartialOrd, Clone, Copy, Hash)] +pub struct RepresentableKeyEvent(pub KeyEvent); +impl Display for RepresentableKeyEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(key) = self; + f.write_fmt(format_args!( + "{}{}{}", + if key.modifiers.contains(KeyModifiers::SHIFT) { + "S-" + } else { + "" + }, + if key.modifiers.contains(KeyModifiers::ALT) { + "A-" + } else { + "" + }, + if key.modifiers.contains(KeyModifiers::CONTROL) { + "C-" + } else { + "" + }, + ))?; + match key.code { + KeyCode::Backspace => f.write_str("backspace")?, + KeyCode::Enter => f.write_str("ret")?, + KeyCode::Left => f.write_str("left")?, + KeyCode::Right => f.write_str("right")?, + KeyCode::Up => f.write_str("up")?, + KeyCode::Down => f.write_str("down")?, + KeyCode::Home => f.write_str("home")?, + KeyCode::End => f.write_str("end")?, + KeyCode::PageUp => f.write_str("pageup")?, + KeyCode::PageDown => f.write_str("pagedown")?, + KeyCode::Tab => f.write_str("tab")?, + KeyCode::BackTab => f.write_str("backtab")?, + KeyCode::Delete => f.write_str("del")?, + KeyCode::Insert => f.write_str("ins")?, + KeyCode::Null => f.write_str("null")?, + KeyCode::Esc => f.write_str("esc")?, + KeyCode::Char('<') => f.write_str("lt")?, + KeyCode::Char('>') => f.write_str("gt")?, + KeyCode::Char('+') => f.write_str("plus")?, + KeyCode::Char('-') => f.write_str("minus")?, + KeyCode::Char(';') => f.write_str("semicolon")?, + KeyCode::Char('%') => f.write_str("percent")?, + KeyCode::F(i) => f.write_fmt(format_args!("F{}", i))?, + KeyCode::Char(c) => f.write_fmt(format_args!("{}", c))?, + }; + Ok(()) + } +} + +impl FromStr for RepresentableKeyEvent { + type Err = Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let mut tokens: Vec<_> = s.split('-').collect(); + let code = match tokens.pop().ok_or_else(|| anyhow!("Missing key code"))? { + "backspace" => KeyCode::Backspace, + "space" => KeyCode::Char(' '), + "ret" => KeyCode::Enter, + "lt" => KeyCode::Char('<'), + "gt" => KeyCode::Char('>'), + "plus" => KeyCode::Char('+'), + "minus" => KeyCode::Char('-'), + "semicolon" => KeyCode::Char(';'), + "percent" => KeyCode::Char('%'), + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "tab" => KeyCode::Tab, + "backtab" => KeyCode::BackTab, + "del" => KeyCode::Delete, + "ins" => KeyCode::Insert, + "null" => KeyCode::Null, + "esc" => KeyCode::Esc, + single if single.len() == 1 => KeyCode::Char(single.chars().next().unwrap()), + function if function.len() > 1 && function.starts_with('F') => { + let function: String = function.chars().skip(1).collect(); + let function = str::parse::<u8>(&function)?; + (function > 0 && function < 13) + .then(|| KeyCode::F(function)) + .ok_or_else(|| anyhow!("Invalid function key '{}'", function))? + } + invalid => return Err(anyhow!("Invalid key code '{}'", invalid)), + }; + + let mut modifiers = KeyModifiers::empty(); + for token in tokens { + let flag = match token { + "S" => KeyModifiers::SHIFT, + "A" => KeyModifiers::ALT, + "C" => KeyModifiers::CONTROL, + _ => return Err(anyhow!("Invalid key modifier '{}-'", token)), + }; + + if modifiers.contains(flag) { + return Err(anyhow!("Repeated key modifier '{}-'", token)); + } + modifiers.insert(flag); + } + + Ok(RepresentableKeyEvent(KeyEvent { code, modifiers })) + } +} + +pub fn parse_keymaps(toml_keymaps: &HashMap<String, HashMap<String, String>>) -> Result<Keymaps> { + let mut keymaps = Keymaps::default(); + + for (mode, map) in toml_keymaps { + let mode = Mode::from_str(&mode)?; + for (key, command) in map { + let key = str::parse::<RepresentableKeyEvent>(&key)?; + let command = str::parse::<Command>(&command)?; + keymaps.0.get_mut(&mode).unwrap().0.insert(key.0, command); + } + } + Ok(keymaps) +} + +impl Deref for Keymap { + type Target = HashMap<KeyEvent, Command>; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Deref for Keymaps { + type Target = HashMap<Mode, Keymap>; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Keymap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl DerefMut for Keymaps { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(test)] +mod test { + use crate::config::Config; + + use super::*; + + impl PartialEq for Command { + fn eq(&self, other: &Self) -> bool { + self.name() == other.name() + } + } + + #[test] + fn parsing_keymaps_config_file() { + let sample_keymaps = r#" + [keys.insert] + y = "move_line_down" + S-C-a = "delete_selection" + + [keys.normal] + A-F12 = "move_next_word_end" + "#; + + let config: Config = toml::from_str(sample_keymaps).unwrap(); + assert_eq!( + *config + .keymaps + .0 + .get(&Mode::Insert) + .unwrap() + .0 + .get(&KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::NONE + }) + .unwrap(), + Command::move_line_down + ); + assert_eq!( + *config + .keymaps + .0 + .get(&Mode::Insert) + .unwrap() + .0 + .get(&KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL + }) + .unwrap(), + Command::delete_selection + ); + assert_eq!( + *config + .keymaps + .0 + .get(&Mode::Normal) + .unwrap() + .0 + .get(&KeyEvent { + code: KeyCode::F(12), + modifiers: KeyModifiers::ALT + }) + .unwrap(), + Command::move_next_word_end + ); + } + + #[test] + fn parsing_unmodified_keys() { + assert_eq!( + str::parse::<RepresentableKeyEvent>("backspace").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::NONE + }) + ); + + assert_eq!( + str::parse::<RepresentableKeyEvent>("left").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE + }) + ); + + assert_eq!( + str::parse::<RepresentableKeyEvent>(",").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::Char(','), + modifiers: KeyModifiers::NONE + }) + ); + + assert_eq!( + str::parse::<RepresentableKeyEvent>("w").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::NONE + }) + ); + + assert_eq!( + str::parse::<RepresentableKeyEvent>("F12").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::F(12), + modifiers: KeyModifiers::NONE + }) + ); + } + + fn parsing_modified_keys() { + assert_eq!( + str::parse::<RepresentableKeyEvent>("S-minus").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::Char('-'), + modifiers: KeyModifiers::SHIFT + }) + ); + + assert_eq!( + str::parse::<RepresentableKeyEvent>("C-A-S-F12").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::F(12), + modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT + }) + ); + + assert_eq!( + str::parse::<RepresentableKeyEvent>("S-C-2").unwrap(), + RepresentableKeyEvent(KeyEvent { + code: KeyCode::F(2), + modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL + }) + ); + } + + #[test] + fn parsing_nonsensical_keys_fails() { + assert!(str::parse::<RepresentableKeyEvent>("F13").is_err()); + assert!(str::parse::<RepresentableKeyEvent>("F0").is_err()); + assert!(str::parse::<RepresentableKeyEvent>("aaa").is_err()); + assert!(str::parse::<RepresentableKeyEvent>("S-S-a").is_err()); + assert!(str::parse::<RepresentableKeyEvent>("C-A-S-C-1").is_err()); + assert!(str::parse::<RepresentableKeyEvent>("FU").is_err()); + assert!(str::parse::<RepresentableKeyEvent>("123").is_err()); + assert!(str::parse::<RepresentableKeyEvent>("S--").is_err()); + } +} diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 12176910..ef912480 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -1,9 +1,10 @@ -use anyhow::{Context, Error, Result}; use helix_term::application::Application; use helix_term::args::Args; use helix_term::config::Config; use std::path::PathBuf; +use anyhow::{Context, Result}; + fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { let mut base_config = fern::Dispatch::new(); @@ -88,11 +89,12 @@ FLAGS: std::fs::create_dir_all(&conf_dir).ok(); } - let config = match std::fs::read_to_string(conf_dir.join("config.toml")) { - Ok(config) => toml::from_str(&config)?, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(), - Err(err) => return Err(Error::new(err)), - }; + let config = std::fs::read_to_string(conf_dir.join("config.toml")) + .ok() + .map(|s| toml::from_str(&s)) + .transpose()? + .or_else(|| Some(Config::default())) + .unwrap(); setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 06ed966d..80f7d590 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -238,6 +238,9 @@ impl Component for Completion { .language() .and_then(|scope| scope.strip_prefix("source.")) .unwrap_or(""); + let cursor_pos = doc.selection(view.id).cursor(); + let cursor_pos = (helix_core::coords_at_pos(doc.text().slice(..), cursor_pos).row + - view.first_line) as u16; let doc = match &option.documentation { Some(lsp::Documentation::String(contents)) @@ -246,42 +249,60 @@ impl Component for Completion { value: contents, })) => { // TODO: convert to wrapped text - Markdown::new(format!( - "```{}\n{}\n```\n{}", - language, - option.detail.as_deref().unwrap_or_default(), - contents.clone() - )) + Markdown::new( + format!( + "```{}\n{}\n```\n{}", + language, + option.detail.as_deref().unwrap_or_default(), + contents.clone() + ), + cx.editor.syn_loader.clone(), + ) } Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: contents, })) => { // TODO: set language based on doc scope - Markdown::new(format!( - "```{}\n{}\n```\n{}", - language, - option.detail.as_deref().unwrap_or_default(), - contents.clone() - )) + Markdown::new( + format!( + "```{}\n{}\n```\n{}", + language, + option.detail.as_deref().unwrap_or_default(), + contents.clone() + ), + cx.editor.syn_loader.clone(), + ) } None if option.detail.is_some() => { // TODO: copied from above // TODO: set language based on doc scope - Markdown::new(format!( - "```{}\n{}\n```", - language, - option.detail.as_deref().unwrap_or_default(), - )) + Markdown::new( + format!( + "```{}\n{}\n```", + language, + option.detail.as_deref().unwrap_or_default(), + ), + cx.editor.syn_loader.clone(), + ) } None => return, }; let half = area.height / 2; let height = 15.min(half); - // -2 to subtract command line + statusline. a bit of a hack, because of splits. - let area = Rect::new(0, area.height - height - 2, area.width, height); + // we want to make sure the cursor is visible (not hidden behind the documentation) + let y = if cursor_pos + view.area.y + >= (cx.editor.tree.area().height - height - 2/* statusline + commandline */) + { + 0 + } else { + // -2 to subtract command line + statusline. a bit of a hack, because of splits. + area.height.saturating_sub(height).saturating_sub(2) + }; + + let area = Rect::new(0, y, area.width, height); // clear area let background = cx.editor.theme.get("ui.popup"); diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index da8f0f53..faede58c 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -11,13 +11,12 @@ use helix_core::{ syntax::{self, HighlightEvent}, LineEnding, Position, Range, }; -use helix_view::input::{KeyCode, KeyEvent, KeyModifiers}; use helix_view::{document::Mode, Document, Editor, Theme, View}; use std::borrow::Cow; use crossterm::{ cursor, - event::{read, Event, EventStream}, + event::{read, Event, EventStream, KeyCode, KeyEvent, KeyModifiers}, }; use tui::{ backend::CrosstermBackend, @@ -130,7 +129,7 @@ impl EditorView { })], }; let mut spans = Vec::new(); - let mut visual_x = 0; + let mut visual_x = 0u16; let mut line = 0u16; let tab_width = doc.tab_width(); @@ -186,7 +185,7 @@ impl EditorView { break 'outer; } } else if grapheme == "\t" { - visual_x += (tab_width as u16); + visual_x = visual_x.saturating_add(tab_width as u16); } else { let out_of_bounds = visual_x < view.first_col as u16 || visual_x >= viewport.width + view.first_col as u16; @@ -198,7 +197,7 @@ impl EditorView { if out_of_bounds { // if we're offscreen just keep going until we hit a new line - visual_x += width; + visual_x = visual_x.saturating_add(width); continue; } @@ -608,8 +607,7 @@ impl Component for EditorView { cx.editor.resize(Rect::new(0, 0, width, height - 1)); EventResult::Consumed(None) } - Event::Key(key) => { - let mut key = KeyEvent::from(key); + Event::Key(mut key) => { canonicalize_key(&mut key); // clear status cx.editor.status_msg = None; diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 3ce3a5b8..72a3e4ff 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -7,25 +7,34 @@ use tui::{ text::Text, }; -use std::borrow::Cow; +use std::{borrow::Cow, sync::Arc}; -use helix_core::Position; +use helix_core::{syntax, Position}; use helix_view::{Editor, Theme}; pub struct Markdown { contents: String, + + config_loader: Arc<syntax::Loader>, } // TODO: pre-render and self reference via Pin // better yet, just use Tendril + subtendril for references impl Markdown { - pub fn new(contents: String) -> Self { - Self { contents } + pub fn new(contents: String, config_loader: Arc<syntax::Loader>) -> Self { + Self { + contents, + config_loader, + } } } -fn parse<'a>(contents: &'a str, theme: Option<&Theme>) -> tui::text::Text<'a> { +fn parse<'a>( + contents: &'a str, + theme: Option<&Theme>, + loader: &syntax::Loader, +) -> tui::text::Text<'a> { use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; use tui::text::{Span, Spans, Text}; @@ -79,9 +88,7 @@ fn parse<'a>(contents: &'a str, theme: Option<&Theme>) -> tui::text::Text<'a> { use helix_core::Rope; let rope = Rope::from(text.as_ref()); - let syntax = syntax::LOADER - .get() - .unwrap() + let syntax = loader .language_config_for_scope(&format!("source.{}", language)) .and_then(|config| config.highlight_config(theme.scopes())) .map(|config| Syntax::new(&rope, config)); @@ -101,9 +108,7 @@ fn parse<'a>(contents: &'a str, theme: Option<&Theme>) -> tui::text::Text<'a> { } HighlightEvent::Source { start, end } => { let style = match highlights.first() { - Some(span) => { - theme.get(theme.scopes()[span.0].as_str()) - } + Some(span) => theme.get(&theme.scopes()[span.0]), None => text_style, }; @@ -159,7 +164,6 @@ fn parse<'a>(contents: &'a str, theme: Option<&Theme>) -> tui::text::Text<'a> { } } Event::Code(text) | Event::Html(text) => { - log::warn!("code {:?}", text); let mut span = to_span(text); span.style = code_style; spans.push(span); @@ -198,7 +202,7 @@ impl Component for Markdown { fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { use tui::widgets::{Paragraph, Widget, Wrap}; - let text = parse(&self.contents, Some(&cx.editor.theme)); + let text = parse(&self.contents, Some(&cx.editor.theme), &self.config_loader); let par = Paragraph::new(text) .wrap(Wrap { trim: false }) @@ -209,7 +213,7 @@ impl Component for Markdown { } fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { - let contents = parse(&self.contents, None); + let contents = parse(&self.contents, None, &self.config_loader); let padding = 2; let width = std::cmp::min(contents.width() as u16 + padding, viewport.0); let height = std::cmp::min(contents.height() as u16 + padding, viewport.1); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 39e11cd6..e0177b7c 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -115,10 +115,43 @@ pub fn file_picker(root: PathBuf) -> Picker<PathBuf> { pub mod completers { use crate::ui::prompt::Completion; - use std::borrow::Cow; + use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; + use fuzzy_matcher::FuzzyMatcher; + use helix_view::theme; + use std::cmp::Reverse; + use std::{borrow::Cow, sync::Arc}; pub type Completer = fn(&str) -> Vec<Completion>; + pub fn theme(input: &str) -> Vec<Completion> { + let mut names = theme::Loader::read_names(&helix_core::runtime_dir().join("themes")); + names.extend(theme::Loader::read_names( + &helix_core::config_dir().join("themes"), + )); + names.push("default".into()); + + let mut names: Vec<_> = names + .into_iter() + .map(|name| ((0..), Cow::from(name))) + .collect(); + + let matcher = Matcher::default(); + + let mut matches: Vec<_> = names + .into_iter() + .filter_map(|(range, name)| { + matcher + .fuzzy_match(&name, &input) + .map(|score| (name, score)) + }) + .collect(); + + matches.sort_unstable_by_key(|(_file, score)| Reverse(*score)); + names = matches.into_iter().map(|(name, _)| ((0..), name)).collect(); + + names + } + // TODO: we could return an iter/lazy thing so it can fetch as many as it needs. pub fn filename(input: &str) -> Vec<Completion> { // Rust's filename handling is really annoying. @@ -178,10 +211,6 @@ pub mod completers { // if empty, return a list of dirs and files in current dir if let Some(file_name) = file_name { - use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; - use fuzzy_matcher::FuzzyMatcher; - use std::cmp::Reverse; - let matcher = Matcher::default(); // inefficient, but we need to calculate the scores, filter out None, then sort. diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 991b328d..7ca4308c 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -6,6 +6,11 @@ use helix_view::{Editor, Theme}; use std::{borrow::Cow, ops::RangeFrom}; use tui::terminal::CursorKind; +use helix_core::{ + unicode::segmentation::{GraphemeCursor, GraphemeIncomplete}, + unicode::width::UnicodeWidthStr, +}; + pub type Completion = (RangeFrom<usize>, Cow<'static, str>); pub struct Prompt { @@ -34,6 +39,17 @@ pub enum CompletionDirection { Backward, } +#[derive(Debug, Clone, Copy)] +pub enum Movement { + BackwardChar(usize), + BackwardWord(usize), + ForwardChar(usize), + ForwardWord(usize), + StartOfLine, + EndOfLine, + None, +} + impl Prompt { pub fn new( prompt: String, @@ -52,30 +68,120 @@ impl Prompt { } } + /// Compute the cursor position after applying movement + /// Taken from: https://github.com/wez/wezterm/blob/e0b62d07ca9bf8ce69a61e30a3c20e7abc48ce7e/termwiz/src/lineedit/mod.rs#L516-L611 + fn eval_movement(&self, movement: Movement) -> usize { + match movement { + Movement::BackwardChar(rep) => { + let mut position = self.cursor; + for _ in 0..rep { + let mut cursor = GraphemeCursor::new(position, self.line.len(), false); + if let Ok(Some(pos)) = cursor.prev_boundary(&self.line, 0) { + position = pos; + } else { + break; + } + } + position + } + Movement::BackwardWord(rep) => { + let char_indices: Vec<(usize, char)> = self.line.char_indices().collect(); + if char_indices.is_empty() { + return self.cursor; + } + let mut char_position = char_indices + .iter() + .position(|(idx, _)| *idx == self.cursor) + .unwrap_or(char_indices.len() - 1); + + for _ in 0..rep { + if char_position == 0 { + break; + } + + let mut found = None; + for prev in (0..char_position - 1).rev() { + if char_indices[prev].1.is_whitespace() { + found = Some(prev + 1); + break; + } + } + + char_position = found.unwrap_or(0); + } + char_indices[char_position].0 + } + Movement::ForwardWord(rep) => { + let char_indices: Vec<(usize, char)> = self.line.char_indices().collect(); + if char_indices.is_empty() { + return self.cursor; + } + let mut char_position = char_indices + .iter() + .position(|(idx, _)| *idx == self.cursor) + .unwrap_or_else(|| char_indices.len()); + + for _ in 0..rep { + // Skip any non-whitespace characters + while char_position < char_indices.len() + && !char_indices[char_position].1.is_whitespace() + { + char_position += 1; + } + + // Skip any whitespace characters + while char_position < char_indices.len() + && char_indices[char_position].1.is_whitespace() + { + char_position += 1; + } + + // We are now on the start of the next word + } + char_indices + .get(char_position) + .map(|(i, _)| *i) + .unwrap_or_else(|| self.line.len()) + } + Movement::ForwardChar(rep) => { + let mut position = self.cursor; + for _ in 0..rep { + let mut cursor = GraphemeCursor::new(position, self.line.len(), false); + if let Ok(Some(pos)) = cursor.next_boundary(&self.line, 0) { + position = pos; + } else { + break; + } + } + position + } + Movement::StartOfLine => 0, + Movement::EndOfLine => { + let mut cursor = + GraphemeCursor::new(self.line.len().saturating_sub(1), self.line.len(), false); + if let Ok(Some(pos)) = cursor.next_boundary(&self.line, 0) { + pos + } else { + self.cursor + } + } + Movement::None => self.cursor, + } + } + pub fn insert_char(&mut self, c: char) { - let pos = if self.line.is_empty() { - 0 - } else { - self.line - .char_indices() - .nth(self.cursor) - .map(|(pos, _)| pos) - .unwrap_or_else(|| self.line.len()) - }; - self.line.insert(pos, c); - self.cursor += 1; + self.line.insert(self.cursor, c); + let mut cursor = GraphemeCursor::new(self.cursor, self.line.len(), false); + if let Ok(Some(pos)) = cursor.next_boundary(&self.line, 0) { + self.cursor = pos; + } self.completion = (self.completion_fn)(&self.line); self.exit_selection(); } - pub fn move_char_left(&mut self) { - self.cursor = self.cursor.saturating_sub(1) - } - - pub fn move_char_right(&mut self) { - if self.cursor < self.line.len() { - self.cursor += 1; - } + pub fn move_cursor(&mut self, movement: Movement) { + let pos = self.eval_movement(movement); + self.cursor = pos } pub fn move_start(&mut self) { @@ -87,39 +193,29 @@ impl Prompt { } pub fn delete_char_backwards(&mut self) { - if self.cursor > 0 { - let pos = self - .line - .char_indices() - .nth(self.cursor - 1) - .map(|(pos, _)| pos) - .expect("line is not empty"); - self.line.remove(pos); - self.cursor -= 1; - self.completion = (self.completion_fn)(&self.line); - } + let pos = self.eval_movement(Movement::BackwardChar(1)); + self.line.replace_range(pos..self.cursor, ""); + self.cursor = pos; + self.exit_selection(); + self.completion = (self.completion_fn)(&self.line); } pub fn delete_word_backwards(&mut self) { - use helix_core::get_general_category; - let mut chars = self.line.char_indices().rev(); - // TODO add skipping whitespace logic here - let (mut i, cat) = match chars.next() { - Some((i, c)) => (i, get_general_category(c)), - None => return, - }; - self.cursor -= 1; - for (nn, nc) in chars { - if get_general_category(nc) != cat { - break; - } - i = nn; - self.cursor -= 1; - } - self.line.drain(i..); + let pos = self.eval_movement(Movement::BackwardWord(1)); + self.line.replace_range(pos..self.cursor, ""); + self.cursor = pos; + + self.exit_selection(); self.completion = (self.completion_fn)(&self.line); + } + + pub fn kill_to_end_of_line(&mut self) { + let pos = self.eval_movement(Movement::EndOfLine); + self.line.replace_range(self.cursor..pos, ""); + self.exit_selection(); + self.completion = (self.completion_fn)(&self.line); } pub fn clear(&mut self) { @@ -293,32 +389,72 @@ impl Component for Prompt { (self.callback_fn)(cx.editor, &self.line, PromptEvent::Update); } KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + } + | KeyEvent { code: KeyCode::Esc, .. } => { (self.callback_fn)(cx.editor, &self.line, PromptEvent::Abort); return close_fn; } KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::CONTROL, + } + | KeyEvent { code: KeyCode::Right, .. - } => self.move_char_right(), + } => self.move_cursor(Movement::ForwardChar(1)), KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::CONTROL, + } + | KeyEvent { code: KeyCode::Left, .. - } => self.move_char_left(), + } => self.move_cursor(Movement::BackwardChar(1)), KeyEvent { + code: KeyCode::End, + modifiers: KeyModifiers::NONE, + } + | KeyEvent { code: KeyCode::Char('e'), modifiers: KeyModifiers::CONTROL, } => self.move_end(), KeyEvent { + code: KeyCode::Home, + modifiers: KeyModifiers::NONE, + } + | KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::CONTROL, } => self.move_start(), KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::ALT, + } + | KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::ALT, + } => self.move_cursor(Movement::BackwardWord(1)), + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::ALT, + } + | KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::ALT, + } => self.move_cursor(Movement::ForwardWord(1)), + KeyEvent { code: KeyCode::Char('w'), modifiers: KeyModifiers::CONTROL, } => self.delete_word_backwards(), KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + } => self.kill_to_end_of_line(), + KeyEvent { code: KeyCode::Backspace, modifiers: KeyModifiers::NONE, } => { @@ -363,7 +499,9 @@ impl Component for Prompt { ( Some(Position::new( area.y as usize + line, - area.x as usize + self.prompt.len() + self.cursor, + area.x as usize + + self.prompt.len() + + UnicodeWidthStr::width(&self.line[..self.cursor]), )), CursorKind::Block, ) diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs index c584ee7f..0d1edc46 100644 --- a/helix-tui/src/buffer.rs +++ b/helix-tui/src/buffer.rs @@ -203,16 +203,6 @@ impl Buffer { /// # Panics /// /// Panics when given an coordinate that is outside of this Buffer's area. - /// - /// ```should_panic - /// # use helix_tui::buffer::Buffer; - /// # use helix_tui::layout::Rect; - /// let rect = Rect::new(200, 100, 10, 10); - /// let buffer = Buffer::empty(rect); - /// // Top coordinate is outside of the buffer in global coordinate space, as the Buffer's area - /// // starts at (200, 100). - /// buffer.index_of(0, 0); // Panics - /// ``` pub fn index_of(&self, x: u16, y: u16) -> usize { debug_assert!( x >= self.area.left() @@ -245,15 +235,6 @@ impl Buffer { /// # Panics /// /// Panics when given an index that is outside the Buffer's content. - /// - /// ```should_panic - /// # use helix_tui::buffer::Buffer; - /// # use helix_tui::layout::Rect; - /// let rect = Rect::new(0, 0, 10, 10); // 100 cells in total - /// let buffer = Buffer::empty(rect); - /// // Index 100 is the 101th cell, which lies outside of the area of this Buffer. - /// buffer.pos_of(100); // Panics - /// ``` pub fn pos_of(&self, i: usize) -> (u16, u16) { debug_assert!( i < self.content.len(), @@ -510,6 +491,7 @@ mod tests { #[test] #[should_panic(expected = "outside the buffer")] + #[cfg(debug_assertions)] fn pos_of_panics_on_out_of_bounds() { let rect = Rect::new(0, 0, 10, 10); let buf = Buffer::empty(rect); @@ -520,6 +502,7 @@ mod tests { #[test] #[should_panic(expected = "outside the buffer")] + #[cfg(debug_assertions)] fn index_of_panics_on_out_of_bounds() { let rect = Rect::new(0, 0, 10, 10); let buf = Buffer::empty(rect); diff --git a/helix-tui/src/lib.rs b/helix-tui/src/lib.rs index 0d466f8b..05263bc8 100644 --- a/helix-tui/src/lib.rs +++ b/helix-tui/src/lib.rs @@ -44,7 +44,7 @@ //! implement your own. //! //! Each widget follows a builder pattern API providing a default configuration along with methods -//! to customize them. The widget is then rendered using the [`Frame::render_widget`] which take +//! to customize them. The widget is then rendered using the `Frame::render_widget` which take //! your widget instance an area to draw to. //! //! The following example renders a block of the size of the terminal: diff --git a/helix-tui/src/widgets/mod.rs b/helix-tui/src/widgets/mod.rs index e334b894..484ad50e 100644 --- a/helix-tui/src/widgets/mod.rs +++ b/helix-tui/src/widgets/mod.rs @@ -1,4 +1,4 @@ -//! `widgets` is a collection of types that implement [`Widget`] or [`StatefulWidget`] or both. +//! `widgets` is a collection of types that implement [`Widget`]. //! //! All widgets are implemented using the builder pattern and are consumable objects. They are not //! meant to be stored but used as *commands* to draw common figures in the UI. diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 7f18e9a2..8d93d2d9 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -34,3 +34,6 @@ slotmap = "1" serde = { version = "1.0", features = ["derive"] } toml = "0.5" log = "~0.4" + +which = "4.1" + diff --git a/helix-view/src/clipboard.rs b/helix-view/src/clipboard.rs new file mode 100644 index 00000000..dcc44340 --- /dev/null +++ b/helix-view/src/clipboard.rs @@ -0,0 +1,193 @@ +// Implementation reference: https://github.com/neovim/neovim/blob/f2906a4669a2eef6d7bf86a29648793d63c98949/runtime/autoload/provider/clipboard.vim#L68-L152 + +use anyhow::Result; +use std::borrow::Cow; + +pub trait ClipboardProvider: std::fmt::Debug { + fn name(&self) -> Cow<str>; + fn get_contents(&self) -> Result<String>; + fn set_contents(&self, contents: String) -> Result<()>; +} + +macro_rules! command_provider { + (paste => $get_prg:literal $( , $get_arg:literal )* ; copy => $set_prg:literal $( , $set_arg:literal )* ; ) => {{ + Box::new(provider::CommandProvider { + get_cmd: provider::CommandConfig { + prg: $get_prg, + args: &[ $( $get_arg ),* ], + }, + set_cmd: provider::CommandConfig { + prg: $set_prg, + args: &[ $( $set_arg ),* ], + }, + }) + }}; +} + +pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> { + // TODO: support for user-defined provider, probably when we have plugin support by setting a + // variable? + + if exists("pbcopy") && exists("pbpaste") { + command_provider! { + paste => "pbpaste"; + copy => "pbcopy"; + } + } else if env_var_is_set("WAYLAND_DISPLAY") && exists("wl-copy") && exists("wl-paste") { + command_provider! { + paste => "wl-paste", "--no-newline"; + copy => "wl-copy", "--foreground", "--type", "text/plain"; + } + } else if env_var_is_set("DISPLAY") && exists("xclip") { + command_provider! { + paste => "xclip", "-o", "-selection", "clipboard"; + copy => "xclip", "-i", "-selection", "clipboard"; + } + } else if env_var_is_set("DISPLAY") && exists("xsel") && is_exit_success("xsel", &["-o", "-b"]) + { + // FIXME: check performance of is_exit_success + command_provider! { + paste => "xsel", "-o", "-b"; + copy => "xsel", "--nodetach", "-i", "-b"; + } + } else if exists("lemonade") { + command_provider! { + paste => "lemonade", "paste"; + copy => "lemonade", "copy"; + } + } else if exists("doitclient") { + command_provider! { + paste => "doitclient", "wclip", "-r"; + copy => "doitclient", "wclip"; + } + } else if exists("win32yank.exe") { + // FIXME: does it work within WSL? + command_provider! { + paste => "win32yank.exe", "-o", "--lf"; + copy => "win32yank.exe", "-i", "--crlf"; + } + } else if exists("termux-clipboard-set") && exists("termux-clipboard-get") { + command_provider! { + paste => "termux-clipboard-get"; + copy => "termux-clipboard-set"; + } + } else if env_var_is_set("TMUX") && exists("tmux") { + command_provider! { + paste => "tmux", "save-buffer", "-"; + copy => "tmux", "load-buffer", "-"; + } + } else { + Box::new(provider::NopProvider) + } +} + +fn exists(executable_name: &str) -> bool { + which::which(executable_name).is_ok() +} + +fn env_var_is_set(env_var_name: &str) -> bool { + std::env::var_os(env_var_name).is_some() +} + +fn is_exit_success(program: &str, args: &[&str]) -> bool { + std::process::Command::new(program) + .args(args) + .output() + .ok() + .and_then(|out| out.status.success().then(|| ())) // TODO: use then_some when stabilized + .is_some() +} + +mod provider { + use super::ClipboardProvider; + use anyhow::{bail, Context as _, Result}; + use std::borrow::Cow; + + #[derive(Debug)] + pub struct NopProvider; + + impl ClipboardProvider for NopProvider { + fn name(&self) -> Cow<str> { + Cow::Borrowed("none") + } + + fn get_contents(&self) -> Result<String> { + Ok(String::new()) + } + + fn set_contents(&self, _: String) -> Result<()> { + Ok(()) + } + } + + #[derive(Debug)] + pub struct CommandConfig { + pub prg: &'static str, + pub args: &'static [&'static str], + } + + impl CommandConfig { + fn execute(&self, input: Option<&str>, pipe_output: bool) -> Result<Option<String>> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let stdin = input.map(|_| Stdio::piped()).unwrap_or_else(Stdio::null); + let stdout = pipe_output.then(Stdio::piped).unwrap_or_else(Stdio::null); + + let mut child = Command::new(self.prg) + .args(self.args) + .stdin(stdin) + .stdout(stdout) + .stderr(Stdio::null()) + .spawn()?; + + if let Some(input) = input { + let mut stdin = child.stdin.take().context("stdin is missing")?; + stdin + .write_all(input.as_bytes()) + .context("couldn't write in stdin")?; + } + + // TODO: add timer? + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("clipboard provider {} failed", self.prg); + } + + if pipe_output { + Ok(Some(String::from_utf8(output.stdout)?)) + } else { + Ok(None) + } + } + } + + #[derive(Debug)] + pub struct CommandProvider { + pub get_cmd: CommandConfig, + pub set_cmd: CommandConfig, + } + + impl ClipboardProvider for CommandProvider { + fn name(&self) -> Cow<str> { + if self.get_cmd.prg != self.set_cmd.prg { + Cow::Owned(format!("{}+{}", self.get_cmd.prg, self.set_cmd.prg)) + } else { + Cow::Borrowed(self.get_cmd.prg) + } + } + + fn get_contents(&self) -> Result<String> { + let output = self + .get_cmd + .execute(None, true)? + .context("output is missing")?; + Ok(output) + } + + fn set_contents(&self, value: String) -> Result<()> { + self.set_cmd.execute(Some(&value), false).map(|_| ()) + } + } +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 3e38c24d..9326fb79 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1,7 +1,5 @@ use anyhow::{anyhow, Context, Error}; -use serde::de::{self, Deserialize, Deserializer}; use std::cell::Cell; -use std::collections::HashMap; use std::fmt::Display; use std::future::Future; use std::path::{Component, Path, PathBuf}; @@ -12,12 +10,14 @@ use helix_core::{ auto_detect_line_ending, chars::{char_is_line_ending, char_is_whitespace}, history::History, - syntax::{LanguageConfiguration, LOADER}, + syntax::{self, LanguageConfiguration}, ChangeSet, Diagnostic, LineEnding, Rope, Selection, State, Syntax, Transaction, DEFAULT_LINE_ENDING, }; -use crate::{DocumentId, ViewId}; +use crate::{DocumentId, Theme, ViewId}; + +use std::collections::HashMap; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Mode { @@ -26,40 +26,6 @@ pub enum Mode { Insert, } -impl Display for Mode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Mode::Normal => f.write_str("normal"), - Mode::Select => f.write_str("select"), - Mode::Insert => f.write_str("insert"), - } - } -} - -impl FromStr for Mode { - type Err = Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - match s { - "normal" => Ok(Mode::Normal), - "select" => Ok(Mode::Select), - "insert" => Ok(Mode::Insert), - _ => Err(anyhow!("Invalid mode '{}'", s)), - } - } -} - -// toml deserializer doesn't seem to recognize string as enum -impl<'de> Deserialize<'de> for Mode { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - s.parse().map_err(de::Error::custom) - } -} - #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum IndentStyle { Tabs, @@ -127,6 +93,29 @@ impl fmt::Debug for Document { } } +impl Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Mode::Normal => f.write_str("normal"), + Mode::Select => f.write_str("select"), + Mode::Insert => f.write_str("insert"), + } + } +} + +impl FromStr for Mode { + type Err = Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "normal" => Ok(Mode::Normal), + "select" => Ok(Mode::Select), + "insert" => Ok(Mode::Insert), + _ => Err(anyhow!("Invalid mode '{}'", s)), + } + } +} + /// Like std::mem::replace() except it allows the replacement value to be mapped from the /// original value. fn take_with<T, F>(mut_ref: &mut T, closure: F) @@ -181,7 +170,7 @@ pub fn fold_home_dir(path: &Path) -> PathBuf { /// [`std::fs::canonicalize`] can be hard to use correctly, since it can often /// fail, or on Windows returns annoying device paths. This is a problem Cargo /// needs to improve on. -/// Copied from cargo: https://github.com/rust-lang/cargo/blob/070e459c2d8b79c5b2ac5218064e7603329c92ae/crates/cargo-util/src/paths.rs#L81 +/// Copied from cargo: <https://github.com/rust-lang/cargo/blob/070e459c2d8b79c5b2ac5218064e7603329c92ae/crates/cargo-util/src/paths.rs#L81> pub fn normalize_path(path: &Path) -> PathBuf { let path = expand_tilde(path); let mut components = path.components().peekable(); @@ -253,7 +242,11 @@ impl Document { } // TODO: async fn? - pub fn load(path: PathBuf) -> Result<Self, Error> { + pub fn load( + path: PathBuf, + theme: Option<&Theme>, + config_loader: Option<&syntax::Loader>, + ) -> Result<Self, Error> { use std::{fs::File, io::BufReader}; let mut doc = if !path.exists() { @@ -277,6 +270,10 @@ impl Document { doc.detect_indent_style(); doc.set_line_ending(line_ending); + if let Some(loader) = config_loader { + doc.detect_language(theme, loader); + } + Ok(doc) } @@ -351,12 +348,10 @@ impl Document { } } - fn detect_language(&mut self) { - if let Some(path) = self.path() { - let loader = LOADER.get().unwrap(); - let language_config = loader.language_config_for_file_name(path); - let scopes = loader.scopes(); - self.set_language(language_config, scopes); + pub fn detect_language(&mut self, theme: Option<&Theme>, config_loader: &syntax::Loader) { + if let Some(path) = &self.path { + let language_config = config_loader.language_config_for_file_name(path); + self.set_language(theme, language_config); } } @@ -493,18 +488,16 @@ impl Document { // and error out when document is saved self.path = Some(path); - // try detecting the language based on filepath - self.detect_language(); - Ok(()) } pub fn set_language( &mut self, + theme: Option<&Theme>, language_config: Option<Arc<helix_core::syntax::LanguageConfiguration>>, - scopes: &[String], ) { if let Some(language_config) = language_config { + let scopes = theme.map(|theme| theme.scopes()).unwrap_or(&[]); if let Some(highlight_config) = language_config.highlight_config(scopes) { let syntax = Syntax::new(&self.text, highlight_config); self.syntax = Some(syntax); @@ -518,12 +511,15 @@ impl Document { }; } - pub fn set_language2(&mut self, scope: &str) { - let loader = LOADER.get().unwrap(); - let language_config = loader.language_config_for_scope(scope); - let scopes = loader.scopes(); + pub fn set_language2( + &mut self, + scope: &str, + theme: Option<&Theme>, + config_loader: Arc<syntax::Loader>, + ) { + let language_config = config_loader.language_config_for_scope(scope); - self.set_language(language_config, scopes); + self.set_language(theme, language_config); } pub fn set_language_server(&mut self, language_server: Option<Arc<helix_lsp::Client>>) { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index fb2eb36d..839bcdcd 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,10 +1,15 @@ -use crate::{theme::Theme, tree::Tree, Document, DocumentId, RegisterSelection, View, ViewId}; +use crate::clipboard::{get_clipboard_provider, ClipboardProvider}; +use crate::{ + theme::{self, Theme}, + tree::Tree, + Document, DocumentId, RegisterSelection, View, ViewId, +}; +use helix_core::syntax; use tui::layout::Rect; use tui::terminal::CursorKind; use futures_util::future; -use std::path::PathBuf; -use std::time::Duration; +use std::{path::PathBuf, sync::Arc, time::Duration}; use slotmap::SlotMap; @@ -23,6 +28,10 @@ pub struct Editor { pub registers: Registers, pub theme: Theme, pub language_servers: helix_lsp::Registry, + pub clipboard_provider: Box<dyn ClipboardProvider>, + + pub syn_loader: Arc<syntax::Loader>, + pub theme_loader: Arc<theme::Loader>, pub status_msg: Option<(String, Severity)>, } @@ -35,27 +44,11 @@ pub enum Action { } impl Editor { - pub fn new(mut area: tui::layout::Rect) -> Self { - use helix_core::config_dir; - let config = std::fs::read(config_dir().join("theme.toml")); - // load $HOME/.config/helix/theme.toml, fallback to default config - let toml = config - .as_deref() - .unwrap_or(include_bytes!("../../theme.toml")); - let theme: Theme = toml::from_slice(toml).expect("failed to parse theme.toml"); - - // initialize language registry - use helix_core::syntax::{Loader, LOADER}; - - // load $HOME/.config/helix/languages.toml, fallback to default config - let config = std::fs::read(helix_core::config_dir().join("languages.toml")); - let toml = config - .as_deref() - .unwrap_or(include_bytes!("../../languages.toml")); - - let config = toml::from_slice(toml).expect("Could not parse languages.toml"); - LOADER.get_or_init(|| Loader::new(config, theme.scopes().to_vec())); - + pub fn new( + mut area: tui::layout::Rect, + themes: Arc<theme::Loader>, + config_loader: Arc<syntax::Loader>, + ) -> Self { let language_servers = helix_lsp::Registry::new(); // HAXX: offset the render area height by 1 to account for prompt/commandline @@ -66,9 +59,12 @@ impl Editor { documents: SlotMap::with_key(), count: None, selected_register: RegisterSelection::default(), - theme, + theme: themes.default(), language_servers, + syn_loader: config_loader, + theme_loader: themes, registers: Registers::default(), + clipboard_provider: get_clipboard_provider(), status_msg: None, } } @@ -85,6 +81,32 @@ impl Editor { self.status_msg = Some((error, Severity::Error)); } + pub fn set_theme(&mut self, theme: Theme) { + let scopes = theme.scopes(); + for config in self + .syn_loader + .language_configs_iter() + .filter(|cfg| cfg.is_highlight_initialized()) + { + config.reconfigure(scopes); + } + + self.theme = theme; + self._refresh(); + } + + pub fn set_theme_from_name(&mut self, theme: &str) { + let theme = match self.theme_loader.load(theme.as_ref()) { + Ok(theme) => theme, + Err(e) => { + log::warn!("failed setting theme `{}` - {}", theme, e); + return; + } + }; + + self.set_theme(theme); + } + fn _refresh(&mut self) { for (view, _) in self.tree.views_mut() { let doc = &self.documents[view.doc]; @@ -168,7 +190,7 @@ impl Editor { let id = if let Some(id) = id { id } else { - let mut doc = Document::load(path)?; + let mut doc = Document::load(path, Some(&self.theme), Some(&self.syn_loader))?; // try to find a language server based on the language name let language_server = doc @@ -254,6 +276,10 @@ impl Editor { self.documents.iter().map(|(_id, doc)| doc) } + pub fn documents_mut(&mut self) -> impl Iterator<Item = &mut Document> { + self.documents.iter_mut().map(|(_id, doc)| doc) + } + // pub fn current_document(&self) -> Document { // let id = self.view().doc; // let doc = &mut editor.documents[id]; diff --git a/helix-view/src/input.rs b/helix-view/src/input.rs deleted file mode 100644 index ab417819..00000000 --- a/helix-view/src/input.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Input event handling, currently backed by crossterm. -use anyhow::{anyhow, Error}; -use crossterm::event; -use serde::de::{self, Deserialize, Deserializer}; -use std::fmt; - -pub use crossterm::event::{KeyCode, KeyModifiers}; - -/// Represents a key event. -// We use a newtype here because we want to customize Deserialize and Display. -#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy, Hash)] -pub struct KeyEvent { - pub code: KeyCode, - pub modifiers: KeyModifiers, -} - -impl fmt::Display for KeyEvent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!( - "{}{}{}", - if self.modifiers.contains(KeyModifiers::SHIFT) { - "S-" - } else { - "" - }, - if self.modifiers.contains(KeyModifiers::ALT) { - "A-" - } else { - "" - }, - if self.modifiers.contains(KeyModifiers::CONTROL) { - "C-" - } else { - "" - }, - ))?; - match self.code { - KeyCode::Backspace => f.write_str("backspace")?, - KeyCode::Enter => f.write_str("ret")?, - KeyCode::Left => f.write_str("left")?, - KeyCode::Right => f.write_str("right")?, - KeyCode::Up => f.write_str("up")?, - KeyCode::Down => f.write_str("down")?, - KeyCode::Home => f.write_str("home")?, - KeyCode::End => f.write_str("end")?, - KeyCode::PageUp => f.write_str("pageup")?, - KeyCode::PageDown => f.write_str("pagedown")?, - KeyCode::Tab => f.write_str("tab")?, - KeyCode::BackTab => f.write_str("backtab")?, - KeyCode::Delete => f.write_str("del")?, - KeyCode::Insert => f.write_str("ins")?, - KeyCode::Null => f.write_str("null")?, - KeyCode::Esc => f.write_str("esc")?, - KeyCode::Char('<') => f.write_str("lt")?, - KeyCode::Char('>') => f.write_str("gt")?, - KeyCode::Char('+') => f.write_str("plus")?, - KeyCode::Char('-') => f.write_str("minus")?, - KeyCode::Char(';') => f.write_str("semicolon")?, - KeyCode::Char('%') => f.write_str("percent")?, - KeyCode::F(i) => f.write_fmt(format_args!("F{}", i))?, - KeyCode::Char(c) => f.write_fmt(format_args!("{}", c))?, - }; - Ok(()) - } -} - -impl std::str::FromStr for KeyEvent { - type Err = Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - let mut tokens: Vec<_> = s.split('-').collect(); - let code = match tokens.pop().ok_or_else(|| anyhow!("Missing key code"))? { - "backspace" => KeyCode::Backspace, - "space" => KeyCode::Char(' '), - "ret" => KeyCode::Enter, - "lt" => KeyCode::Char('<'), - "gt" => KeyCode::Char('>'), - "plus" => KeyCode::Char('+'), - "minus" => KeyCode::Char('-'), - "semicolon" => KeyCode::Char(';'), - "percent" => KeyCode::Char('%'), - "left" => KeyCode::Left, - "right" => KeyCode::Right, - "up" => KeyCode::Down, - "home" => KeyCode::Home, - "end" => KeyCode::End, - "pageup" => KeyCode::PageUp, - "pagedown" => KeyCode::PageDown, - "tab" => KeyCode::Tab, - "backtab" => KeyCode::BackTab, - "del" => KeyCode::Delete, - "ins" => KeyCode::Insert, - "null" => KeyCode::Null, - "esc" => KeyCode::Esc, - single if single.len() == 1 => KeyCode::Char(single.chars().next().unwrap()), - function if function.len() > 1 && function.starts_with('F') => { - let function: String = function.chars().skip(1).collect(); - let function = str::parse::<u8>(&function)?; - (function > 0 && function < 13) - .then(|| KeyCode::F(function)) - .ok_or_else(|| anyhow!("Invalid function key '{}'", function))? - } - invalid => return Err(anyhow!("Invalid key code '{}'", invalid)), - }; - - let mut modifiers = KeyModifiers::empty(); - for token in tokens { - let flag = match token { - "S" => KeyModifiers::SHIFT, - "A" => KeyModifiers::ALT, - "C" => KeyModifiers::CONTROL, - _ => return Err(anyhow!("Invalid key modifier '{}-'", token)), - }; - - if modifiers.contains(flag) { - return Err(anyhow!("Repeated key modifier '{}-'", token)); - } - modifiers.insert(flag); - } - - Ok(KeyEvent { code, modifiers }) - } -} - -impl<'de> Deserialize<'de> for KeyEvent { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - s.parse().map_err(de::Error::custom) - } -} - -impl From<event::KeyEvent> for KeyEvent { - fn from(event::KeyEvent { code, modifiers }: event::KeyEvent) -> KeyEvent { - KeyEvent { code, modifiers } - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parsing_unmodified_keys() { - assert_eq!( - str::parse::<KeyEvent>("backspace").unwrap(), - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::NONE - } - ); - - assert_eq!( - str::parse::<KeyEvent>("left").unwrap(), - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE - } - ); - - assert_eq!( - str::parse::<KeyEvent>(",").unwrap(), - KeyEvent { - code: KeyCode::Char(','), - modifiers: KeyModifiers::NONE - } - ); - - assert_eq!( - str::parse::<KeyEvent>("w").unwrap(), - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::NONE - } - ); - - assert_eq!( - str::parse::<KeyEvent>("F12").unwrap(), - KeyEvent { - code: KeyCode::F(12), - modifiers: KeyModifiers::NONE - } - ); - } - - #[test] - fn parsing_modified_keys() { - assert_eq!( - str::parse::<KeyEvent>("S-minus").unwrap(), - KeyEvent { - code: KeyCode::Char('-'), - modifiers: KeyModifiers::SHIFT - } - ); - - assert_eq!( - str::parse::<KeyEvent>("C-A-S-F12").unwrap(), - KeyEvent { - code: KeyCode::F(12), - modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT - } - ); - - assert_eq!( - str::parse::<KeyEvent>("S-C-2").unwrap(), - KeyEvent { - code: KeyCode::Char('2'), - modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL - } - ); - } - - #[test] - fn parsing_nonsensical_keys_fails() { - assert!(str::parse::<KeyEvent>("F13").is_err()); - assert!(str::parse::<KeyEvent>("F0").is_err()); - assert!(str::parse::<KeyEvent>("aaa").is_err()); - assert!(str::parse::<KeyEvent>("S-S-a").is_err()); - assert!(str::parse::<KeyEvent>("C-A-S-C-1").is_err()); - assert!(str::parse::<KeyEvent>("FU").is_err()); - assert!(str::parse::<KeyEvent>("123").is_err()); - assert!(str::parse::<KeyEvent>("S--").is_err()); - } -} diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index 8b635700..17f415fc 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -1,18 +1,17 @@ #[macro_use] pub mod macros; +pub mod clipboard; pub mod document; pub mod editor; -pub mod input; pub mod register_selection; pub mod theme; pub mod tree; pub mod view; -slotmap::new_key_type! { - pub struct DocumentId; - pub struct ViewId; -} +use slotmap::new_key_type; +new_key_type! { pub struct DocumentId; } +new_key_type! { pub struct ViewId; } pub use document::Document; pub use editor::Editor; diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index 51a21421..66b91294 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -1,6 +1,11 @@ -use std::collections::HashMap; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; +use anyhow::Context; use log::warn; +use once_cell::sync::Lazy; use serde::{Deserialize, Deserializer}; use toml::Value; @@ -86,7 +91,84 @@ pub use tui::style::{Color, Modifier, Style}; // } /// Color theme for syntax highlighting. -#[derive(Debug)] + +pub static DEFAULT_THEME: Lazy<Theme> = Lazy::new(|| { + toml::from_slice(include_bytes!("../../theme.toml")).expect("Failed to parse default theme") +}); + +#[derive(Clone, Debug)] +pub struct Loader { + user_dir: PathBuf, + default_dir: PathBuf, +} +impl Loader { + /// Creates a new loader that can load themes from two directories. + pub fn new<P: AsRef<Path>>(user_dir: P, default_dir: P) -> Self { + Self { + user_dir: user_dir.as_ref().join("themes"), + default_dir: default_dir.as_ref().join("themes"), + } + } + + /// Loads a theme first looking in the `user_dir` then in `default_dir` + pub fn load(&self, name: &str) -> Result<Theme, anyhow::Error> { + if name == "default" { + return Ok(self.default()); + } + let filename = format!("{}.toml", name); + + let user_path = self.user_dir.join(&filename); + let path = if user_path.exists() { + user_path + } else { + self.default_dir.join(filename) + }; + + let data = std::fs::read(&path)?; + toml::from_slice(data.as_slice()).context("Failed to deserialize theme") + } + + pub fn read_names(path: &Path) -> Vec<String> { + std::fs::read_dir(path) + .map(|entries| { + entries + .filter_map(|entry| { + if let Ok(entry) = entry { + let path = entry.path(); + if let Some(ext) = path.extension() { + if ext != "toml" { + return None; + } + return Some( + entry + .file_name() + .to_string_lossy() + .trim_end_matches(".toml") + .to_owned(), + ); + } + } + None + }) + .collect() + }) + .unwrap_or_default() + } + + /// Lists all theme names available in default and user directory + pub fn names(&self) -> Vec<String> { + let mut names = Self::read_names(&self.user_dir); + names.extend(Self::read_names(&self.default_dir)); + names + } + + /// Returns the default theme + pub fn default(&self) -> Theme { + DEFAULT_THEME.clone() + } +} + +#[derive(Clone, Debug)] pub struct Theme { scopes: Vec<String>, styles: HashMap<String, Style>, diff --git a/helix-view/src/tree.rs b/helix-view/src/tree.rs index a0c466d9..f7d6c1f2 100644 --- a/helix-view/src/tree.rs +++ b/helix-view/src/tree.rs @@ -434,6 +434,10 @@ impl Tree { self.focus = key; } } + + pub fn area(&self) -> Rect { + self.area + } } #[derive(Debug)] diff --git a/contrib/themes/README.md b/runtime/themes/README.md index 1c9c5ae9..1c9c5ae9 100644 --- a/contrib/themes/README.md +++ b/runtime/themes/README.md diff --git a/contrib/themes/bogster.toml b/runtime/themes/bogster.toml index 43b422f3..43b422f3 100644 --- a/contrib/themes/bogster.toml +++ b/runtime/themes/bogster.toml diff --git a/contrib/themes/ingrid.toml b/runtime/themes/ingrid.toml index d32a89d1..d32a89d1 100644 --- a/contrib/themes/ingrid.toml +++ b/runtime/themes/ingrid.toml diff --git a/contrib/themes/onedark.toml b/runtime/themes/onedark.toml index 65f26725..65f26725 100644 --- a/contrib/themes/onedark.toml +++ b/runtime/themes/onedark.toml |