diff --git a/Cargo.lock b/Cargo.lock index 22c128d9..0835d107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,6 +457,7 @@ dependencies = [ "once_cell", "pulldown-cmark", "retain_mut", + "ropey", "serde", "serde_json", "signal-hook", diff --git a/book/src/configuration.md b/book/src/configuration.md index 153ebb80..fea60d2f 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -137,3 +137,29 @@ Search specific options. |--|--|---------| | `smart-case` | Enable smart case regex searching (case insensitive unless pattern contains upper case characters) | `true` | | `wrap-around`| Whether the search should wrap after depleting the matches | `true` | + +### `[editor.whitespace]` Section + +Options for rendering whitespace with visible characters. Use `:set whitespace.render all` to temporarily enable visible whitespace. + +| Key | Description | Default | +|-----|-------------|---------| +| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `tab`, and `newline`. | `"none"` | +| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space` or `newline` | See example below | + +Example + +```toml +[editor.whitespace] +render = "all" +# or control each character +[editor.whitespace.render] +space = "all" +tab = "all" +newline = "none" + +[editor.whitespace.characters] +space = "·" +tab = "→" +newline = "⏎" +``` diff --git a/book/src/themes.md b/book/src/themes.md index e23dbb69..9009e2ca 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -224,6 +224,7 @@ These scopes are used for theming the editor interface. | `ui.text` | Command prompts, popup text, etc. | | `ui.text.focus` | | | `ui.text.info` | The key: command text in `ui.popup.info` boxes | +| `ui.virtual.whitespace` | Visible white-space characters | | `ui.menu` | Code and command completion menus | | `ui.menu.selected` | Selected autocomplete item | | `ui.selection` | For selections in the editing area | @@ -233,4 +234,3 @@ These scopes are used for theming the editor interface. | `info` | Diagnostics info (gutter) | | `hint` | Diagnostics hint (gutter) | | `diagnostic` | For text in editing area | - diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 2e0b774b..4b2611ed 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -33,6 +33,7 @@ anyhow = "1" once_cell = "1.10" which = "4.2" +ropey = { version = "1.4", default-features = false } tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } num_cpus = "1" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 7733c2c6..91caade7 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -283,7 +283,7 @@ impl Application { // the Application can apply it. ConfigEvent::Update(editor_config) => { let mut app_config = (*self.config.load().clone()).clone(); - app_config.editor = editor_config; + app_config.editor = *editor_config; self.config.store(Arc::new(app_config)); } } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 318180d7..798b8ac8 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -124,7 +124,15 @@ impl EditorView { Box::new(highlights) }; - Self::render_text_highlights(doc, view.offset, inner, surface, theme, highlights); + Self::render_text_highlights( + doc, + view.offset, + inner, + surface, + theme, + highlights, + &editor.config().whitespace, + ); Self::render_gutter(editor, doc, view, view.area, surface, theme, is_focused); Self::render_rulers(editor, doc, view, inner, surface, theme); @@ -344,7 +352,10 @@ impl EditorView { surface: &mut Surface, theme: &Theme, highlights: H, + whitespace: &helix_view::editor::WhitespaceConfig, ) { + use helix_view::editor::WhitespaceRenderValue; + // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). let text = doc.text().slice(..); @@ -353,9 +364,20 @@ impl EditorView { let mut visual_x = 0u16; let mut line = 0u16; let tab_width = doc.tab_width(); - let tab = " ".repeat(tab_width); + let tab = if whitespace.render.tab() == WhitespaceRenderValue::All { + (1..tab_width).fold(whitespace.characters.tab.to_string(), |s, _| s + " ") + } else { + " ".repeat(tab_width) + }; + let space = whitespace.characters.space.to_string(); + let newline = if whitespace.render.newline() == WhitespaceRenderValue::All { + whitespace.characters.newline.to_string() + } else { + " ".to_string() + }; let text_style = theme.get("ui.text"); + let whitespace_style = theme.get("ui.virtual.whitespace"); 'outer: for event in highlights { match event { @@ -374,6 +396,14 @@ impl EditorView { .iter() .fold(text_style, |acc, span| acc.patch(theme.highlight(span.0))); + let space = if whitespace.render.space() == WhitespaceRenderValue::All + && text.len_chars() < end + { + &space + } else { + " " + }; + use helix_core::graphemes::{grapheme_width, RopeGraphemes}; for grapheme in RopeGraphemes::new(text) { @@ -386,8 +416,8 @@ impl EditorView { surface.set_string( viewport.x + visual_x - offset.col as u16, viewport.y + line, - " ", - style, + &newline, + style.patch(whitespace_style), ); } @@ -400,12 +430,21 @@ impl EditorView { } } else { let grapheme = Cow::from(grapheme); + let is_whitespace; let (grapheme, width) = if grapheme == "\t" { + is_whitespace = true; // make sure we display tab as appropriate amount of spaces let visual_tab_width = tab_width - (visual_x as usize % tab_width); - (&tab[..visual_tab_width], visual_tab_width) + let grapheme_tab_width = + ropey::str_utils::char_to_byte_idx(&tab, visual_tab_width); + + (&tab[..grapheme_tab_width], visual_tab_width) + } else if grapheme == " " { + is_whitespace = true; + (space, 1) } else { + is_whitespace = false; // Cow will prevent allocations if span contained in a single slice // which should really be the majority case let width = grapheme_width(&grapheme); @@ -418,7 +457,11 @@ impl EditorView { viewport.x + visual_x - offset.col as u16, viewport.y + line, grapheme, - style, + if is_whitespace { + style.patch(whitespace_style) + } else { + style + }, ); } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index dec59c89..3ca6965c 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -240,6 +240,7 @@ impl Component for FilePicker { surface, &cx.editor.theme, highlights, + &cx.editor.config().whitespace, ); // highlight the line diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 0c2fad2b..79775c89 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -148,6 +148,8 @@ pub struct Config { pub lsp: LspConfig, /// Column numbers at which to draw the rulers. Default to `[]`, meaning no rulers. pub rulers: Vec, + #[serde(default)] + pub whitespace: WhitespaceConfig, } #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] @@ -263,6 +265,88 @@ impl std::str::FromStr for GutterType { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct WhitespaceConfig { + pub render: WhitespaceRender, + pub characters: WhitespaceCharacters, +} + +impl Default for WhitespaceConfig { + fn default() -> Self { + Self { + render: WhitespaceRender::Basic(WhitespaceRenderValue::None), + characters: WhitespaceCharacters::default(), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged, rename_all = "kebab-case")] +pub enum WhitespaceRender { + Basic(WhitespaceRenderValue), + Specific { + default: Option, + space: Option, + tab: Option, + newline: Option, + }, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WhitespaceRenderValue { + None, + // TODO + // Selection, + All, +} + +impl WhitespaceRender { + pub fn space(&self) -> WhitespaceRenderValue { + match *self { + Self::Basic(val) => val, + Self::Specific { default, space, .. } => { + space.or(default).unwrap_or(WhitespaceRenderValue::None) + } + } + } + pub fn tab(&self) -> WhitespaceRenderValue { + match *self { + Self::Basic(val) => val, + Self::Specific { default, tab, .. } => { + tab.or(default).unwrap_or(WhitespaceRenderValue::None) + } + } + } + pub fn newline(&self) -> WhitespaceRenderValue { + match *self { + Self::Basic(val) => val, + Self::Specific { + default, newline, .. + } => newline.or(default).unwrap_or(WhitespaceRenderValue::None), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WhitespaceCharacters { + pub space: char, + pub tab: char, + pub newline: char, +} + +impl Default for WhitespaceCharacters { + fn default() -> Self { + Self { + space: '·', // U+00B7 + tab: '→', // U+2192 + newline: '⏎', // U+23CE + } + } +} + impl Default for Config { fn default() -> Self { Self { @@ -288,6 +372,7 @@ impl Default for Config { search: SearchConfig::default(), lsp: LspConfig::default(), rulers: Vec::new(), + whitespace: WhitespaceConfig::default(), } } } @@ -366,7 +451,7 @@ pub struct Editor { #[derive(Debug, Clone)] pub enum ConfigEvent { Refresh, - Update(Config), + Update(Box), } #[derive(Debug, Clone)]