helix/helix-view/src/gutter.rs
Pascal Kuthe 4dcf1fe66b
rework positioning/rendering and enable softwrap/virtual text (#5420)
* rework positioning/rendering, enables softwrap/virtual text

This commit is a large rework of the core text positioning and
rendering code in helix to remove the assumption that on-screen
columns/lines correspond to text columns/lines.

A generic `DocFormatter` is introduced that positions graphemes on
and is used both for rendering and for movements/scrolling.
Both virtual text support (inline, grapheme overlay and multi-line)
and a capable softwrap implementation is included.

fix picker highlight

cleanup doc formatter, use word bondaries for wrapping

make visual vertical movement a seperate commnad

estimate line gutter width to improve performance

cache cursor position

cleanup and optimize doc formatter

cleanup documentation

fix typos

Co-authored-by: Daniel Hines <d4hines@gmail.com>

update documentation

fix panic in last_visual_line funciton

improve soft-wrap documentation

add extend_visual_line_up/down commands

fix non-visual vertical movement

streamline virtual text highlighting, add softwrap indicator

fix cursor position if softwrap is disabled

improve documentation of text_annotations module

avoid crashes if view anchor is out of bounds

fix: consider horizontal offset when traslation char_idx -> vpos

improve default configuration

fix: mixed up horizontal and vertical offset

reset view position after config reload

apply suggestions from review

disabled softwrap for very small screens to avoid endless spin

fix wrap_indicator setting

fix bar cursor disappearring on the EOF character

add keybinding for linewise vertical movement

fix: inconsistent gutter highlights

improve virtual text API

make scope idx lookup more ergonomic

allow overlapping overlays

correctly track char_pos for virtual text

adjust configuration

deprecate old position fucntions

fix infinite loop in highlight lookup

fix gutter style

fix formatting

document max-line-width interaction with softwrap

change wrap-indicator example to use empty string

fix: rare panic when view is in invalid state (bis)

* Apply suggestions from code review

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>

* improve documentation for positoning functions

* simplify tests

* fix documentation of Grapheme::width

* Apply suggestions from code review

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>

* add explicit drop invocation

* Add explicit MoveFn type alias

* add docuntation to Editor::cursor_cache

* fix a few typos

* explain use of allow(deprecated)

* make gj and gk extend in select mode

* remove unneded debug and TODO

* mark tab_width_at #[inline]

* add fast-path to move_vertically_visual in case softwrap is disabled

* rename first_line to first_visual_line

* simplify duplicate if/else

---------

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2023-02-01 02:03:19 +09:00

416 lines
14 KiB
Rust

use std::fmt::Write;
use crate::{
editor::GutterType,
graphics::{Color, Style, UnderlineStyle},
Document, Editor, Theme, View,
};
fn count_digits(n: usize) -> usize {
// NOTE: if int_log gets standardized in stdlib, can use checked_log10
// (https://github.com/rust-lang/rust/issues/70887#issue)
std::iter::successors(Some(n), |&n| (n >= 10).then(|| n / 10)).count()
}
pub type GutterFn<'doc> = Box<dyn FnMut(usize, bool, bool, &mut String) -> Option<Style> + 'doc>;
pub type Gutter =
for<'doc> fn(&'doc Editor, &'doc Document, &View, &Theme, bool, usize) -> GutterFn<'doc>;
impl GutterType {
pub fn style<'doc>(
self,
editor: &'doc Editor,
doc: &'doc Document,
view: &View,
theme: &Theme,
is_focused: bool,
) -> GutterFn<'doc> {
match self {
GutterType::Diagnostics => {
diagnostics_or_breakpoints(editor, doc, view, theme, is_focused)
}
GutterType::LineNumbers => line_numbers(editor, doc, view, theme, is_focused),
GutterType::Spacer => padding(editor, doc, view, theme, is_focused),
GutterType::Diff => diff(editor, doc, view, theme, is_focused),
}
}
pub fn width(self, view: &View, doc: &Document) -> usize {
match self {
GutterType::Diagnostics => 1,
GutterType::LineNumbers => line_numbers_width(view, doc),
GutterType::Spacer => 1,
GutterType::Diff => 1,
}
}
}
pub fn diagnostic<'doc>(
_editor: &'doc Editor,
doc: &'doc Document,
_view: &View,
theme: &Theme,
_is_focused: bool,
) -> GutterFn<'doc> {
let warning = theme.get("warning");
let error = theme.get("error");
let info = theme.get("info");
let hint = theme.get("hint");
let diagnostics = doc.diagnostics();
Box::new(
move |line: usize, _selected: bool, first_visual_line: bool, out: &mut String| {
if !first_visual_line {
return None;
}
use helix_core::diagnostic::Severity;
if let Ok(index) = diagnostics.binary_search_by_key(&line, |d| d.line) {
let after = diagnostics[index..].iter().take_while(|d| d.line == line);
let before = diagnostics[..index]
.iter()
.rev()
.take_while(|d| d.line == line);
let diagnostics_on_line = after.chain(before);
// This unwrap is safe because the iterator cannot be empty as it contains at least the item found by the binary search.
let diagnostic = diagnostics_on_line.max_by_key(|d| d.severity).unwrap();
write!(out, "").unwrap();
return Some(match diagnostic.severity {
Some(Severity::Error) => error,
Some(Severity::Warning) | None => warning,
Some(Severity::Info) => info,
Some(Severity::Hint) => hint,
});
}
None
},
)
}
pub fn diff<'doc>(
_editor: &'doc Editor,
doc: &'doc Document,
_view: &View,
theme: &Theme,
_is_focused: bool,
) -> GutterFn<'doc> {
let added = theme.get("diff.plus");
let deleted = theme.get("diff.minus");
let modified = theme.get("diff.delta");
if let Some(diff_handle) = doc.diff_handle() {
let hunks = diff_handle.hunks();
let mut hunk_i = 0;
let mut hunk = hunks.nth_hunk(hunk_i);
Box::new(
move |line: usize, _selected: bool, first_visual_line: bool, out: &mut String| {
// truncating the line is fine here because we don't compute diffs
// for files with more lines than i32::MAX anyways
// we need to special case removals here
// these technically do not have a range of lines to highlight (`hunk.after.start == hunk.after.end`).
// However we still want to display these hunks correctly we must not yet skip to the next hunk here
while hunk.after.end < line as u32
|| !hunk.is_pure_removal() && line as u32 == hunk.after.end
{
hunk_i += 1;
hunk = hunks.nth_hunk(hunk_i);
}
if hunk.after.start > line as u32 {
return None;
}
let (icon, style) = if hunk.is_pure_insertion() {
("", added)
} else if hunk.is_pure_removal() {
if !first_visual_line {
return None;
}
("", deleted)
} else {
("", modified)
};
write!(out, "{}", icon).unwrap();
Some(style)
},
)
} else {
Box::new(move |_, _, _, _| None)
}
}
pub fn line_numbers<'doc>(
editor: &'doc Editor,
doc: &'doc Document,
view: &View,
theme: &Theme,
is_focused: bool,
) -> GutterFn<'doc> {
let text = doc.text().slice(..);
let width = line_numbers_width(view, doc);
let last_line_in_view = view.estimate_last_doc_line(doc);
// Whether to draw the line number for the last line of the
// document or not. We only draw it if it's not an empty line.
let draw_last = text.line_to_byte(last_line_in_view) < text.len_bytes();
let linenr = theme.get("ui.linenr");
let linenr_select = theme.get("ui.linenr.selected");
let current_line = doc
.text()
.char_to_line(doc.selection(view.id).primary().cursor(text));
let line_number = editor.config().line_number;
let mode = editor.mode;
Box::new(
move |line: usize, selected: bool, first_visual_line: bool, out: &mut String| {
if line == last_line_in_view && !draw_last {
write!(out, "{:>1$}", '~', width).unwrap();
Some(linenr)
} else {
use crate::{document::Mode, editor::LineNumber};
let relative = line_number == LineNumber::Relative
&& mode != Mode::Insert
&& is_focused
&& current_line != line;
let display_num = if relative {
abs_diff(current_line, line)
} else {
line + 1
};
let style = if selected && is_focused {
linenr_select
} else {
linenr
};
if first_visual_line {
write!(out, "{:>1$}", display_num, width).unwrap();
} else {
write!(out, "{:>1$}", " ", width).unwrap();
}
// TODO: Use then_some when MSRV reaches 1.62
first_visual_line.then(|| style)
}
},
)
}
/// The width of a "line-numbers" gutter
///
/// The width of the gutter depends on the number of lines in the document,
/// whether there is content on the last line (the `~` line), and the
/// `editor.gutters.line-numbers.min-width` settings.
fn line_numbers_width(view: &View, doc: &Document) -> usize {
let text = doc.text();
let last_line = text.len_lines().saturating_sub(1);
let draw_last = text.line_to_byte(last_line) < text.len_bytes();
let last_drawn = if draw_last { last_line + 1 } else { last_line };
let digits = count_digits(last_drawn);
let n_min = view.gutters.line_numbers.min_width;
digits.max(n_min)
}
pub fn padding<'doc>(
_editor: &'doc Editor,
_doc: &'doc Document,
_view: &View,
_theme: &Theme,
_is_focused: bool,
) -> GutterFn<'doc> {
Box::new(|_line: usize, _selected: bool, _first_visual_line: bool, _out: &mut String| None)
}
#[inline(always)]
const fn abs_diff(a: usize, b: usize) -> usize {
if a > b {
a - b
} else {
b - a
}
}
pub fn breakpoints<'doc>(
editor: &'doc Editor,
doc: &'doc Document,
_view: &View,
theme: &Theme,
_is_focused: bool,
) -> GutterFn<'doc> {
let warning = theme.get("warning");
let error = theme.get("error");
let info = theme.get("info");
let breakpoints = doc.path().and_then(|path| editor.breakpoints.get(path));
let breakpoints = match breakpoints {
Some(breakpoints) => breakpoints,
None => return Box::new(move |_, _, _, _| None),
};
Box::new(
move |line: usize, _selected: bool, first_visual_line: bool, out: &mut String| {
if !first_visual_line {
return None;
}
let breakpoint = breakpoints
.iter()
.find(|breakpoint| breakpoint.line == line)?;
let mut style = if breakpoint.condition.is_some() && breakpoint.log_message.is_some() {
error.underline_style(UnderlineStyle::Line)
} else if breakpoint.condition.is_some() {
error
} else if breakpoint.log_message.is_some() {
info
} else {
warning
};
if !breakpoint.verified {
// Faded colors
style = if let Some(Color::Rgb(r, g, b)) = style.fg {
style.fg(Color::Rgb(
((r as f32) * 0.4).floor() as u8,
((g as f32) * 0.4).floor() as u8,
((b as f32) * 0.4).floor() as u8,
))
} else {
style.fg(Color::Gray)
}
};
let sym = if breakpoint.verified { "" } else { "" };
write!(out, "{}", sym).unwrap();
Some(style)
},
)
}
pub fn diagnostics_or_breakpoints<'doc>(
editor: &'doc Editor,
doc: &'doc Document,
view: &View,
theme: &Theme,
is_focused: bool,
) -> GutterFn<'doc> {
let mut diagnostics = diagnostic(editor, doc, view, theme, is_focused);
let mut breakpoints = breakpoints(editor, doc, view, theme, is_focused);
Box::new(move |line, selected, first_visual_line: bool, out| {
breakpoints(line, selected, first_visual_line, out)
.or_else(|| diagnostics(line, selected, first_visual_line, out))
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::document::Document;
use crate::editor::{Config, GutterConfig, GutterLineNumbersConfig};
use crate::graphics::Rect;
use crate::DocumentId;
use arc_swap::ArcSwap;
use helix_core::Rope;
#[test]
fn test_default_gutter_widths() {
let mut view = View::new(DocumentId::default(), GutterConfig::default());
view.area = Rect::new(40, 40, 40, 40);
let rope = Rope::from_str("abc\n\tdef");
let doc = Document::from(
rope,
None,
Arc::new(ArcSwap::new(Arc::new(Config::default()))),
);
assert_eq!(view.gutters.layout.len(), 5);
assert_eq!(view.gutters.layout[0].width(&view, &doc), 1);
assert_eq!(view.gutters.layout[1].width(&view, &doc), 1);
assert_eq!(view.gutters.layout[2].width(&view, &doc), 3);
assert_eq!(view.gutters.layout[3].width(&view, &doc), 1);
assert_eq!(view.gutters.layout[4].width(&view, &doc), 1);
}
#[test]
fn test_configured_gutter_widths() {
let gutters = GutterConfig {
layout: vec![GutterType::Diagnostics],
..Default::default()
};
let mut view = View::new(DocumentId::default(), gutters);
view.area = Rect::new(40, 40, 40, 40);
let rope = Rope::from_str("abc\n\tdef");
let doc = Document::from(
rope,
None,
Arc::new(ArcSwap::new(Arc::new(Config::default()))),
);
assert_eq!(view.gutters.layout.len(), 1);
assert_eq!(view.gutters.layout[0].width(&view, &doc), 1);
let gutters = GutterConfig {
layout: vec![GutterType::Diagnostics, GutterType::LineNumbers],
line_numbers: GutterLineNumbersConfig { min_width: 10 },
};
let mut view = View::new(DocumentId::default(), gutters);
view.area = Rect::new(40, 40, 40, 40);
let rope = Rope::from_str("abc\n\tdef");
let doc = Document::from(
rope,
None,
Arc::new(ArcSwap::new(Arc::new(Config::default()))),
);
assert_eq!(view.gutters.layout.len(), 2);
assert_eq!(view.gutters.layout[0].width(&view, &doc), 1);
assert_eq!(view.gutters.layout[1].width(&view, &doc), 10);
}
#[test]
fn test_line_numbers_gutter_width_resizes() {
let gutters = GutterConfig {
layout: vec![GutterType::Diagnostics, GutterType::LineNumbers],
line_numbers: GutterLineNumbersConfig { min_width: 1 },
};
let mut view = View::new(DocumentId::default(), gutters);
view.area = Rect::new(40, 40, 40, 40);
let rope = Rope::from_str("a\nb");
let doc_short = Document::from(
rope,
None,
Arc::new(ArcSwap::new(Arc::new(Config::default()))),
);
let rope = Rope::from_str("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np");
let doc_long = Document::from(
rope,
None,
Arc::new(ArcSwap::new(Arc::new(Config::default()))),
);
assert_eq!(view.gutters.layout.len(), 2);
assert_eq!(view.gutters.layout[1].width(&view, &doc_short), 1);
assert_eq!(view.gutters.layout[1].width(&view, &doc_long), 2);
}
}