diff options
author | Mr. E | 2022-07-18 00:57:01 +0000 |
---|---|---|
committer | GitHub | 2022-07-18 00:57:01 +0000 |
commit | dbf68e0370981dc4ad0fa74596b57347f7048fab (patch) | |
tree | f0b04c445708158827b611ce602235bb5ff72142 | |
parent | 43761d426cb0c508bfcea93c4bf1d08949e6c7c1 (diff) |
Customizable/configurable status line (#2434)
* feat(statusline): add the file type (language id) to the status line
* refactor(statusline): move the statusline implementation into an own struct
* refactor(statusline): split the statusline implementation into different functions
* refactor(statusline): Append elements using a consistent API
This is a preparation for the configurability which is about to be
implemented.
* refactor(statusline): implement render_diagnostics()
This avoid cluttering the render() function and will simplify
configurability.
* feat(statusline): make the status line configurable
* refactor(statusline): make clippy happy
* refactor(statusline): avoid intermediate StatusLineObject
Use a more functional approach to obtain render functions and write to
the buffers, and avoid an intermediate StatusLineElement object.
* fix(statusline): avoid rendering the left elements twice
* refactor(statusline): make clippy happy again
* refactor(statusline): rename `buffer` into `parts`
* refactor(statusline): ensure the match is exhaustive
* fix(statusline): avoid an overflow when calculating the maximal center width
* chore(statusline): Describe the statusline configurability in the book
* chore(statusline): Correct and add documentation
* refactor(statusline): refactor some code following the code review
Avoid very small helper functions for the diagnositcs and inline them
instead.
Rename the config field `status_line` to `statusline` to remain
consistent with `bufferline`.
* chore(statusline): adjust documentation following the config field refactoring
* revert(statusline): revert regression introduced by c0a1870
* chore(statusline): slight adjustment in the configuration documentation
* feat(statusline): integrate changes from #2676 after rebasing
* refactor(statusline): remove the StatusLine struct
Because none of the functions need `Self` and all of them are in an own
file, there is no explicit need for the struct.
* fix(statusline): restore the configurability of color modes
The configuration was ignored after reintegrating the changes of #2676
in 8d28f95.
* fix(statusline): remove the spinner padding
* refactor(statusline): remove unnecessary format!()
-rw-r--r-- | book/src/configuration.md | 32 | ||||
-rw-r--r-- | helix-term/src/ui/editor.rs | 163 | ||||
-rw-r--r-- | helix-term/src/ui/mod.rs | 1 | ||||
-rw-r--r-- | helix-term/src/ui/statusline.rs | 310 | ||||
-rw-r--r-- | helix-view/src/editor.rs | 51 |
5 files changed, 401 insertions, 156 deletions
diff --git a/book/src/configuration.md b/book/src/configuration.md index 0a6e5fdd..4c849f26 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -48,13 +48,43 @@ hidden = false | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` | | `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` | +### `[editor.statusline]` Section + +Allows configuring the statusline at the bottom of the editor. + +The configuration distinguishes between three areas of the status line: + +`[ ... ... LEFT ... ... | ... ... ... ... CENTER ... ... ... ... | ... ... RIGHT ... ... ]` + +Statusline elements can be defined as follows: + +```toml +[editor.statusline] +left = ["mode", "spinner"] +center = ["file-name"] +right = ["diagnostics", "selections", "position", "file-encoding", "file-type"] +``` + +The following elements can be configured: + +| Key | Description | +| ------ | ----------- | +| `mode` | The current editor mode (`NOR`/`INS`/`SEL`) | +| `spinner` | A progress spinner indicating LSP activity | +| `file-name` | The path/name of the opened file | +| `file-encoding` | The encoding of the opened file if it differs from UTF-8 | +| `file-type` | The type of the opened file | +| `diagnostics` | The number of warnings and/or errors | +| `selections` | The number of active selections | +| `position` | The cursor position | + ### `[editor.lsp]` Section | Key | Description | Default | | --- | ----------- | ------- | | `display-messages` | Display LSP progress messages below statusline[^1] | `false` | -[^1]: A progress spinner is always shown in the statusline beside the file path. +[^1]: By default, a progress spinner is shown in the statusline beside the file path. ### `[editor.cursor-shape]` Section diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index a7c67a21..9b8bf8eb 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -7,7 +7,6 @@ use crate::{ }; use helix_core::{ - coords_at_pos, encoding, graphemes::{ ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary, }, @@ -17,7 +16,7 @@ use helix_core::{ LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ - document::{Mode, SCRATCH_BUFFER_NAME}, + document::Mode, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, input::KeyEvent, @@ -29,6 +28,8 @@ use std::borrow::Cow; use crossterm::event::{Event, MouseButton, MouseEvent, MouseEventKind}; use tui::buffer::Buffer as Surface; +use super::statusline; + pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>, @@ -161,7 +162,11 @@ impl EditorView { .area .clip_top(view.area.height.saturating_sub(1)) .clip_bottom(1); // -1 from bottom to remove commandline - self.render_statusline(editor, doc, view, statusline_area, surface, is_focused); + + let mut context = + statusline::RenderContext::new(editor, doc, view, is_focused, &self.spinners); + + statusline::render(&mut context, statusline_area, surface); } pub fn render_rulers( @@ -730,158 +735,6 @@ impl EditorView { } } - pub fn render_statusline( - &self, - editor: &Editor, - doc: &Document, - view: &View, - viewport: Rect, - surface: &mut Surface, - is_focused: bool, - ) { - use tui::text::{Span, Spans}; - - //------------------------------- - // Left side of the status line. - //------------------------------- - - let theme = &editor.theme; - let (mode, mode_style) = match doc.mode() { - Mode::Insert => (" INS ", theme.get("ui.statusline.insert")), - Mode::Select => (" SEL ", theme.get("ui.statusline.select")), - Mode::Normal => (" NOR ", theme.get("ui.statusline.normal")), - }; - let progress = doc - .language_server() - .and_then(|srv| { - self.spinners - .get(srv.id()) - .and_then(|spinner| spinner.frame()) - }) - .unwrap_or(""); - - let base_style = if is_focused { - theme.get("ui.statusline") - } else { - theme.get("ui.statusline.inactive") - }; - // statusline - surface.set_style(viewport.with_height(1), base_style); - if is_focused { - let color_modes = editor.config().color_modes; - surface.set_string( - viewport.x, - viewport.y, - mode, - if color_modes { mode_style } else { base_style }, - ); - } - surface.set_string(viewport.x + 5, viewport.y, progress, base_style); - - //------------------------------- - // Right side of the status line. - //------------------------------- - - let mut right_side_text = Spans::default(); - - // Compute the individual info strings and add them to `right_side_text`. - - // Diagnostics - let diags = doc.diagnostics().iter().fold((0, 0), |mut counts, diag| { - use helix_core::diagnostic::Severity; - match diag.severity { - Some(Severity::Warning) => counts.0 += 1, - Some(Severity::Error) | None => counts.1 += 1, - _ => {} - } - counts - }); - let (warnings, errors) = diags; - let warning_style = theme.get("warning"); - let error_style = theme.get("error"); - for i in 0..2 { - let (count, style) = match i { - 0 => (warnings, warning_style), - 1 => (errors, error_style), - _ => unreachable!(), - }; - if count == 0 { - continue; - } - let style = base_style.patch(style); - right_side_text.0.push(Span::styled("●", style)); - right_side_text - .0 - .push(Span::styled(format!(" {} ", count), base_style)); - } - - // Selections - let sels_count = doc.selection(view.id).len(); - right_side_text.0.push(Span::styled( - format!( - " {} sel{} ", - sels_count, - if sels_count == 1 { "" } else { "s" } - ), - base_style, - )); - - // Position - let pos = coords_at_pos( - doc.text().slice(..), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - ); - right_side_text.0.push(Span::styled( - format!(" {}:{} ", pos.row + 1, pos.col + 1), // Convert to 1-indexing. - base_style, - )); - - let enc = doc.encoding(); - if enc != encoding::UTF_8 { - right_side_text - .0 - .push(Span::styled(format!(" {} ", enc.name()), base_style)); - } - - // Render to the statusline. - surface.set_spans( - viewport.x - + viewport - .width - .saturating_sub(right_side_text.width() as u16), - viewport.y, - &right_side_text, - right_side_text.width() as u16, - ); - - //------------------------------- - // Middle / File path / Title - //------------------------------- - let title = { - let rel_path = doc.relative_path(); - let path = rel_path - .as_ref() - .map(|p| p.to_string_lossy()) - .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()); - format!("{}{}", path, if doc.is_modified() { "[+]" } else { "" }) - }; - - surface.set_string_truncated( - viewport.x + 8, // 8: 1 space + 3 char mode string + 1 space + 1 spinner + 1 space - viewport.y, - &title, - viewport - .width - .saturating_sub(6) - .saturating_sub(right_side_text.width() as u16 + 1) as usize, // "+ 1": a space between the title and the selection info - |_| base_style, - true, - true, - ); - } - /// Handle events by looking them up in `self.keymaps`. Returns None /// if event was handled (a command was executed or a subkeymap was /// activated). Only KeymapResult::{NotFound, Cancelled} is returned diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index ca4cedb5..c7d409e9 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -8,6 +8,7 @@ mod picker; mod popup; mod prompt; mod spinner; +mod statusline; mod text; pub use completion::Completion; diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs new file mode 100644 index 00000000..895043cd --- /dev/null +++ b/helix-term/src/ui/statusline.rs @@ -0,0 +1,310 @@ +use helix_core::{coords_at_pos, encoding}; +use helix_view::{ + document::{Mode, SCRATCH_BUFFER_NAME}, + graphics::Rect, + theme::Style, + Document, Editor, View, +}; + +use crate::ui::ProgressSpinners; + +use helix_view::editor::StatusLineElement as StatusLineElementID; +use tui::buffer::Buffer as Surface; +use tui::text::{Span, Spans}; + +pub struct RenderContext<'a> { + pub editor: &'a Editor, + pub doc: &'a Document, + pub view: &'a View, + pub focused: bool, + pub spinners: &'a ProgressSpinners, + pub parts: RenderBuffer<'a>, +} + +impl<'a> RenderContext<'a> { + pub fn new( + editor: &'a Editor, + doc: &'a Document, + view: &'a View, + focused: bool, + spinners: &'a ProgressSpinners, + ) -> Self { + RenderContext { + editor, + doc, + view, + focused, + spinners, + parts: RenderBuffer::default(), + } + } +} + +#[derive(Default)] +pub struct RenderBuffer<'a> { + pub left: Spans<'a>, + pub center: Spans<'a>, + pub right: Spans<'a>, +} + +pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface) { + let base_style = if context.focused { + context.editor.theme.get("ui.statusline") + } else { + context.editor.theme.get("ui.statusline.inactive") + }; + + surface.set_style(viewport.with_height(1), base_style); + + let write_left = |context: &mut RenderContext, text, style| { + append(&mut context.parts.left, text, &base_style, style) + }; + let write_center = |context: &mut RenderContext, text, style| { + append(&mut context.parts.center, text, &base_style, style) + }; + let write_right = |context: &mut RenderContext, text, style| { + append(&mut context.parts.right, text, &base_style, style) + }; + + // Left side of the status line. + + let element_ids = &context.editor.config().statusline.left; + element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .for_each(|render| render(context, write_left)); + + surface.set_spans( + viewport.x, + viewport.y, + &context.parts.left, + context.parts.left.width() as u16, + ); + + // Right side of the status line. + + let element_ids = &context.editor.config().statusline.right; + element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .for_each(|render| render(context, write_right)); + + surface.set_spans( + viewport.x + + viewport + .width + .saturating_sub(context.parts.right.width() as u16), + viewport.y, + &context.parts.right, + context.parts.right.width() as u16, + ); + + // Center of the status line. + + let element_ids = &context.editor.config().statusline.center; + element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .for_each(|render| render(context, write_center)); + + // Width of the empty space between the left and center area and between the center and right area. + let spacing = 1u16; + + let edge_width = context.parts.left.width().max(context.parts.right.width()) as u16; + let center_max_width = viewport.width.saturating_sub(2 * edge_width + 2 * spacing); + let center_width = center_max_width.min(context.parts.center.width() as u16); + + surface.set_spans( + viewport.x + viewport.width / 2 - center_width / 2, + viewport.y, + &context.parts.center, + center_width, + ); +} + +fn append(buffer: &mut Spans, text: String, base_style: &Style, style: Option<Style>) { + buffer.0.push(Span::styled( + text, + style.map_or(*base_style, |s| (*base_style).patch(s)), + )); +} + +fn get_render_function<F>(element_id: StatusLineElementID) -> impl Fn(&mut RenderContext, F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + match element_id { + helix_view::editor::StatusLineElement::Mode => render_mode, + helix_view::editor::StatusLineElement::Spinner => render_lsp_spinner, + helix_view::editor::StatusLineElement::FileName => render_file_name, + helix_view::editor::StatusLineElement::FileEncoding => render_file_encoding, + helix_view::editor::StatusLineElement::FileType => render_file_type, + helix_view::editor::StatusLineElement::Diagnostics => render_diagnostics, + helix_view::editor::StatusLineElement::Selections => render_selections, + helix_view::editor::StatusLineElement::Position => render_position, + } +} + +fn render_mode<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let visible = context.focused; + + write( + context, + format!( + " {} ", + if visible { + match context.doc.mode() { + Mode::Insert => "INS", + Mode::Select => "SEL", + Mode::Normal => "NOR", + } + } else { + // If not focused, explicitly leave an empty space instead of returning None. + " " + } + ), + if visible && context.editor.config().color_modes { + match context.doc.mode() { + Mode::Insert => Some(context.editor.theme.get("ui.statusline.insert")), + Mode::Select => Some(context.editor.theme.get("ui.statusline.select")), + Mode::Normal => Some(context.editor.theme.get("ui.statusline.normal")), + } + } else { + None + }, + ); +} + +fn render_lsp_spinner<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + write( + context, + context + .doc + .language_server() + .and_then(|srv| { + context + .spinners + .get(srv.id()) + .and_then(|spinner| spinner.frame()) + }) + // Even if there's no spinner; reserve its space to avoid elements frequently shifting. + .unwrap_or(" ") + .to_string(), + None, + ); +} + +fn render_diagnostics<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let (warnings, errors) = context + .doc + .diagnostics() + .iter() + .fold((0, 0), |mut counts, diag| { + use helix_core::diagnostic::Severity; + match diag.severity { + Some(Severity::Warning) => counts.0 += 1, + Some(Severity::Error) | None => counts.1 += 1, + _ => {} + } + counts + }); + + if warnings > 0 { + write( + context, + "●".to_string(), + Some(context.editor.theme.get("warning")), + ); + write(context, format!(" {} ", warnings), None); + } + + if errors > 0 { + write( + context, + "●".to_string(), + Some(context.editor.theme.get("error")), + ); + write(context, format!(" {} ", errors), None); + } +} + +fn render_selections<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let count = context.doc.selection(context.view.id).len(); + write( + context, + format!(" {} sel{} ", count, if count == 1 { "" } else { "s" }), + None, + ); +} + +fn render_position<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let position = coords_at_pos( + context.doc.text().slice(..), + context + .doc + .selection(context.view.id) + .primary() + .cursor(context.doc.text().slice(..)), + ); + + write( + context, + format!(" {}:{} ", position.row + 1, position.col + 1), + None, + ); +} + +fn render_file_encoding<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let enc = context.doc.encoding(); + + if enc != encoding::UTF_8 { + write(context, format!(" {} ", enc.name()), None); + } +} + +fn render_file_type<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let file_type = context.doc.language_id().unwrap_or("text"); + + write(context, format!(" {} ", file_type), None); +} + +fn render_file_name<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let title = { + let rel_path = context.doc.relative_path(); + let path = rel_path + .as_ref() + .map(|p| p.to_string_lossy()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()); + format!( + " {}{} ", + path, + if context.doc.is_modified() { "[+]" } else { "" } + ) + }; + + write(context, title, None); +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index a2943af9..51c0eee0 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -147,6 +147,8 @@ pub struct Config { /// Whether to display infoboxes. Defaults to true. pub auto_info: bool, pub file_picker: FilePickerConfig, + /// Configuration of the statusline elements + pub statusline: StatusLineConfig, /// Shape for cursor in each mode pub cursor_shape: CursorShapeConfig, /// Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. Defaults to `false`. @@ -180,6 +182,54 @@ pub struct SearchConfig { pub wrap_around: bool, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +pub struct StatusLineConfig { + pub left: Vec<StatusLineElement>, + pub center: Vec<StatusLineElement>, + pub right: Vec<StatusLineElement>, +} + +impl Default for StatusLineConfig { + fn default() -> Self { + use StatusLineElement as E; + + Self { + left: vec![E::Mode, E::Spinner, E::FileName], + center: vec![], + right: vec![E::Diagnostics, E::Selections, E::Position, E::FileEncoding], + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum StatusLineElement { + /// The editor mode (Normal, Insert, Visual/Selection) + Mode, + + /// The LSP activity spinner + Spinner, + + /// The file nane/path, including a dirty flag if it's unsaved + FileName, + + /// The file encoding + FileEncoding, + + /// The file type (language ID or "text") + FileType, + + /// A summary of the number of errors and warnings + Diagnostics, + + /// The number of selections (cursors) + Selections, + + /// The cursor position + Position, +} + // Cursor shape is read and used on every rendered frame and so needs // to be fast. Therefore we avoid a hashmap and use an enum indexed array. #[derive(Debug, Clone, PartialEq)] @@ -409,6 +459,7 @@ impl Default for Config { completion_trigger_len: 2, auto_info: true, file_picker: FilePickerConfig::default(), + statusline: StatusLineConfig::default(), cursor_shape: CursorShapeConfig::default(), true_color: false, search: SearchConfig::default(), |