From dbf68e0370981dc4ad0fa74596b57347f7048fab Mon Sep 17 00:00:00 2001
From: "Mr. E" <2804556+etienne-k@users.noreply.github.com>
Date: Mon, 18 Jul 2022 02:57:01 +0200
Subject: [PATCH] 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!()
---
 book/src/configuration.md       |  32 +++-
 helix-term/src/ui/editor.rs     | 163 +----------------
 helix-term/src/ui/mod.rs        |   1 +
 helix-term/src/ui/statusline.rs | 310 ++++++++++++++++++++++++++++++++
 helix-view/src/editor.rs        |  51 ++++++
 5 files changed, 401 insertions(+), 156 deletions(-)
 create mode 100644 helix-term/src/ui/statusline.rs

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(),