From 4756606471bce329b462236f1012a66fb567fd44 Mon Sep 17 00:00:00 2001 From: Ludwig Stecher Date: Tue, 8 Feb 2022 21:38:23 +0100 Subject: [PATCH 1/3] Highlight matching text in file picker suggestions --- helix-term/src/ui/editor.rs | 2 +- helix-term/src/ui/picker.rs | 60 ++++++++++++++++++++++++------------- helix-tui/src/buffer.rs | 14 ++++----- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index d3af921e5403..9bdf76f61a26 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -662,7 +662,7 @@ impl EditorView { .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, + |_| base_style, true, true, ); diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 4068a2d43df1..ab13abc390b0 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -24,7 +24,7 @@ use crate::ui::{Prompt, PromptEvent}; use helix_core::Position; use helix_view::{ editor::Action, - graphics::{Color, CursorKind, Margin, Rect, Style}, + graphics::{Color, CursorKind, Margin, Modifier, Rect, Style}, Document, Editor, }; @@ -266,8 +266,8 @@ pub struct Picker { options: Vec, // filter: String, matcher: Box, - /// (index, score) - matches: Vec<(usize, i64)>, + /// (index, score, highlight indices) + matches: Vec<(usize, i64, Option>)>, /// Filter over original options. filters: Vec, // could be optimized into bit but not worth it now @@ -334,13 +334,13 @@ impl Picker { } // TODO: maybe using format_fn isn't the best idea here let text = (self.format_fn)(option); - // TODO: using fuzzy_indices could give us the char idx for match highlighting + // Highlight indices are computed lazily in the render function and cached self.matcher .fuzzy_match(&text, pattern) - .map(|score| (index, score)) + .map(|score| (index, score, None)) }), ); - self.matches.sort_unstable_by_key(|(_, score)| -score); + self.matches.sort_unstable_by_key(|(_, score, _)| -score); // reset cursor position self.cursor = 0; @@ -367,13 +367,13 @@ impl Picker { pub fn selection(&self) -> Option<&T> { self.matches .get(self.cursor) - .map(|(index, _score)| &self.options[*index]) + .map(|(index, _score, _highlights)| &self.options[*index]) } pub fn save_filter(&mut self) { self.filters.clear(); self.filters - .extend(self.matches.iter().map(|(index, _)| *index)); + .extend(self.matches.iter().map(|(index, _, _)| *index)); self.filters.sort_unstable(); // used for binary search later self.prompt.clear(); } @@ -465,6 +465,8 @@ impl Component for Picker { }; let text_style = cx.editor.theme.get("ui.text"); + let selected = cx.editor.theme.get("ui.text.focus"); + let highlighted = cx.editor.theme.get("special").add_modifier(Modifier::BOLD); // -- Render the frame: // clear area @@ -507,29 +509,45 @@ impl Component for Picker { // subtract area of prompt from top and current item marker " > " from left let inner = inner.clip_top(2).clip_left(3); - let selected = cx.editor.theme.get("ui.text.focus"); - let rows = inner.height; let offset = self.cursor - (self.cursor % std::cmp::max(1, rows as usize)); - let files = self.matches.iter().skip(offset).map(|(index, _score)| { - (index, self.options.get(*index).unwrap()) // get_unchecked - }); - - for (i, (_index, option)) in files.take(rows as usize).enumerate() { - if i == (self.cursor - offset) { + let files = self + .matches + .iter_mut() + .skip(offset) + .map(|(index, _score, highlights)| { + (*index, self.options.get(*index).unwrap(), highlights) + }); + + for (i, (_index, option, highlights)) in files.take(rows as usize).enumerate() { + let is_active = i == (self.cursor - offset); + if is_active { surface.set_string(inner.x.saturating_sub(2), inner.y + i as u16, ">", selected); } + let formatted = (self.format_fn)(option); + + let highlights = highlights.get_or_insert_with(|| { + self.matcher + .fuzzy_indices(&formatted, &self.prompt.line) + .unwrap_or_default() + .1 + }); + surface.set_string_truncated( inner.x, inner.y + i as u16, - (self.format_fn)(option), + &formatted, inner.width as usize, - if i == (self.cursor - offset) { - selected - } else { - text_style + |idx| { + if highlights.contains(&idx) { + highlighted + } else if is_active { + selected + } else { + text_style + } }, true, self.truncate_start, diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs index f8673e4360b8..852ec673f62a 100644 --- a/helix-tui/src/buffer.rs +++ b/helix-tui/src/buffer.rs @@ -287,7 +287,7 @@ impl Buffer { where S: AsRef, { - self.set_string_truncated(x, y, string, width, style, false, false) + self.set_string_truncated(x, y, string, width, |_| style, false, false) } /// Print at most the first `width` characters of a string if enough space is available @@ -301,7 +301,7 @@ impl Buffer { y: u16, string: S, width: usize, - style: Style, + style: impl Fn(usize) -> Style, // Map a grapheme's string offset to a style ellipsis: bool, truncate_start: bool, ) -> (u16, u16) @@ -316,10 +316,10 @@ impl Buffer { let mut index = self.index_of(x, y); let mut x_offset = x as usize; let width = if ellipsis { width - 1 } else { width }; - let graphemes = UnicodeSegmentation::graphemes(string.as_ref(), true); + let graphemes = string.as_ref().grapheme_indices(true); let max_offset = min(self.area.right() as usize, width.saturating_add(x as usize)); if !truncate_start { - for s in graphemes { + for (byte_offset, s) in graphemes { let width = s.width(); if width == 0 { continue; @@ -331,7 +331,7 @@ impl Buffer { } self.content[index].set_symbol(s); - self.content[index].set_style(style); + self.content[index].set_style(style(byte_offset)); // Reset following cells if multi-width (they would be hidden by the grapheme), for i in index + 1..index + width { self.content[i].reset(); @@ -355,7 +355,7 @@ impl Buffer { if !truncated { index -= width - total_width; } - for s in graphemes.rev() { + for (byte_offset, s) in graphemes.rev() { let width = s.width(); if width == 0 { continue; @@ -365,7 +365,7 @@ impl Buffer { break; } self.content[start].set_symbol(s); - self.content[start].set_style(style); + self.content[start].set_style(style(byte_offset)); for i in start + 1..index { self.content[i].reset(); } From 615cfcdbefa8127bc471bc4ae658edfd7eb464db Mon Sep 17 00:00:00 2001 From: Ludwig Stecher Date: Wed, 9 Feb 2022 06:54:38 +0100 Subject: [PATCH 2/3] Remove cache, specialize highlighting code --- helix-term/src/ui/editor.rs | 2 +- helix-term/src/ui/picker.rs | 26 +++++++--------- helix-tui/src/buffer.rs | 60 ++++++++++++++++++++++++++++++------- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9bdf76f61a26..bea4957dc6a0 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -657,7 +657,7 @@ impl EditorView { surface.set_string_truncated( viewport.x + 8, // 8: 1 space + 3 char mode string + 1 space + 1 spinner + 1 space viewport.y, - title, + &title, viewport .width .saturating_sub(6) diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index ab13abc390b0..2bc0db67277a 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -267,7 +267,7 @@ pub struct Picker { // filter: String, matcher: Box, /// (index, score, highlight indices) - matches: Vec<(usize, i64, Option>)>, + matches: Vec<(usize, i64)>, /// Filter over original options. filters: Vec, // could be optimized into bit but not worth it now @@ -337,10 +337,10 @@ impl Picker { // Highlight indices are computed lazily in the render function and cached self.matcher .fuzzy_match(&text, pattern) - .map(|score| (index, score, None)) + .map(|score| (index, score)) }), ); - self.matches.sort_unstable_by_key(|(_, score, _)| -score); + self.matches.sort_unstable_by_key(|(_, score)| -score); // reset cursor position self.cursor = 0; @@ -367,13 +367,13 @@ impl Picker { pub fn selection(&self) -> Option<&T> { self.matches .get(self.cursor) - .map(|(index, _score, _highlights)| &self.options[*index]) + .map(|(index, _score)| &self.options[*index]) } pub fn save_filter(&mut self) { self.filters.clear(); self.filters - .extend(self.matches.iter().map(|(index, _, _)| *index)); + .extend(self.matches.iter().map(|(index, _)| *index)); self.filters.sort_unstable(); // used for binary search later self.prompt.clear(); } @@ -516,11 +516,9 @@ impl Component for Picker { .matches .iter_mut() .skip(offset) - .map(|(index, _score, highlights)| { - (*index, self.options.get(*index).unwrap(), highlights) - }); + .map(|(index, _score)| (*index, self.options.get(*index).unwrap())); - for (i, (_index, option, highlights)) in files.take(rows as usize).enumerate() { + for (i, (_index, option)) in files.take(rows as usize).enumerate() { let is_active = i == (self.cursor - offset); if is_active { surface.set_string(inner.x.saturating_sub(2), inner.y + i as u16, ">", selected); @@ -528,12 +526,10 @@ impl Component for Picker { let formatted = (self.format_fn)(option); - let highlights = highlights.get_or_insert_with(|| { - self.matcher - .fuzzy_indices(&formatted, &self.prompt.line) - .unwrap_or_default() - .1 - }); + let (_score, highlights) = self + .matcher + .fuzzy_indices(&formatted, &self.prompt.line) + .unwrap_or_default(); surface.set_string_truncated( inner.x, diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs index 852ec673f62a..22956b04cdde 100644 --- a/helix-tui/src/buffer.rs +++ b/helix-tui/src/buffer.rs @@ -287,7 +287,7 @@ impl Buffer { where S: AsRef, { - self.set_string_truncated(x, y, string, width, |_| style, false, false) + self.set_string_truncated_at_end(x, y, string.as_ref(), width, style) } /// Print at most the first `width` characters of a string if enough space is available @@ -295,19 +295,16 @@ impl Buffer { /// truncated lines. If `truncate_start` is `true`, truncate the beginning of the string /// instead of the end. #[allow(clippy::too_many_arguments)] - pub fn set_string_truncated( + pub fn set_string_truncated( &mut self, x: u16, y: u16, - string: S, + string: &str, width: usize, style: impl Fn(usize) -> Style, // Map a grapheme's string offset to a style ellipsis: bool, truncate_start: bool, - ) -> (u16, u16) - where - S: AsRef, - { + ) -> (u16, u16) { // prevent panic if out of range if !self.in_bounds(x, y) || width == 0 { return (x, y); @@ -316,7 +313,7 @@ impl Buffer { let mut index = self.index_of(x, y); let mut x_offset = x as usize; let width = if ellipsis { width - 1 } else { width }; - let graphemes = string.as_ref().grapheme_indices(true); + let graphemes = string.grapheme_indices(true); let max_offset = min(self.area.right() as usize, width.saturating_add(x as usize)); if !truncate_start { for (byte_offset, s) in graphemes { @@ -339,14 +336,14 @@ impl Buffer { index += width; x_offset += width; } - if ellipsis && x_offset - (x as usize) < string.as_ref().width() { + if ellipsis && x_offset - (x as usize) < string.width() { self.content[index].set_symbol("…"); } } else { let mut start_index = self.index_of(x, y); let mut index = self.index_of(max_offset as u16, y); - let total_width = string.as_ref().width(); + let total_width = string.width(); let truncated = total_width > width; if ellipsis && truncated { self.content[start_index].set_symbol("…"); @@ -375,6 +372,49 @@ impl Buffer { (x_offset as u16, y) } + /// Print at most the first `width` characters of a string if enough space is available + /// until the end of the line. + pub fn set_string_truncated_at_end( + &mut self, + x: u16, + y: u16, + string: &str, + width: usize, + style: Style, + ) -> (u16, u16) { + // prevent panic if out of range + if !self.in_bounds(x, y) { + return (x, y); + } + + let mut index = self.index_of(x, y); + let mut x_offset = x as usize; + let max_x_offset = min(self.area.right() as usize, width.saturating_add(x as usize)); + + for s in string.graphemes(true) { + let width = s.width(); + if width == 0 { + continue; + } + // `x_offset + width > max_offset` could be integer overflow on 32-bit machines if we + // change dimensions to usize or u32 and someone resizes the terminal to 1x2^32. + if width > max_x_offset.saturating_sub(x_offset) { + break; + } + + self.content[index].set_symbol(s); + self.content[index].set_style(style); + // Reset following cells if multi-width (they would be hidden by the grapheme), + for i in index + 1..index + width { + self.content[i].reset(); + } + index += width; + x_offset += width; + } + + (x_offset as u16, y) + } + pub fn set_spans<'a>(&mut self, x: u16, y: u16, spans: &Spans<'a>, width: u16) -> (u16, u16) { let mut remaining_width = width; let mut x = x; From 2c717ce44321600b73cf224ade7a35912073cd8a Mon Sep 17 00:00:00 2001 From: Ludwig Stecher Date: Fri, 25 Feb 2022 01:38:02 +0100 Subject: [PATCH 3/3] Fix outdated comments --- helix-term/src/ui/picker.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 2bc0db67277a..fe486c3403c9 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -266,7 +266,7 @@ pub struct Picker { options: Vec, // filter: String, matcher: Box, - /// (index, score, highlight indices) + /// (index, score) matches: Vec<(usize, i64)>, /// Filter over original options. filters: Vec, // could be optimized into bit but not worth it now @@ -334,7 +334,7 @@ impl Picker { } // TODO: maybe using format_fn isn't the best idea here let text = (self.format_fn)(option); - // Highlight indices are computed lazily in the render function and cached + // Highlight indices are computed lazily in the render function self.matcher .fuzzy_match(&text, pattern) .map(|score| (index, score))