diff --git a/benches/main/draw_results_list.rs b/benches/main/draw_results_list.rs index 3ae2b72..e187528 100644 --- a/benches/main/draw_results_list.rs +++ b/benches/main/draw_results_list.rs @@ -655,6 +655,7 @@ pub fn draw_results_list(c: &mut Criterion) { ListDirection::BottomToTop, false, &colorscheme, + 100, ); }); }); diff --git a/television/screen/remote_control.rs b/television/screen/remote_control.rs index 0cdc91b..9a08ea2 100644 --- a/television/screen/remote_control.rs +++ b/television/screen/remote_control.rs @@ -81,6 +81,7 @@ fn draw_rc_channels( ListDirection::TopToBottom, use_nerd_font_icons, &colorscheme.results, + area.width, ); f.render_stateful_widget(channel_list, area, picker_state); diff --git a/television/screen/results.rs b/television/screen/results.rs index 49d7e11..5be5585 100644 --- a/television/screen/results.rs +++ b/television/screen/results.rs @@ -1,6 +1,7 @@ use crate::channels::entry::Entry; use crate::screen::colors::{Colorscheme, ResultsColorscheme}; use crate::screen::layout::InputPosition; +use crate::utils::indices::truncate_highlighted_string; use crate::utils::strings::{ make_matched_string_printable, next_char_boundary, slice_at_char_boundaries, @@ -20,6 +21,179 @@ const POINTER_SYMBOL: &str = "> "; const SELECTED_SYMBOL: &str = "● "; const DESELECTED_SYMBOL: &str = " "; +/// The max width for each part of the entry (name and value) depending on various factors. +/// +/// - name only: `available_width - 2 * (use_icons as u16) - 2 * (is_selected as u16) - line_number_width` +/// - name and value: `(available_width - 2 * (use_icons as u16) - 2 * (is_selected as u16) - line_number_width) / 2` +fn max_widths( + entry: &Entry, + available_width: u16, + use_icons: bool, + is_selected: bool, +) -> (u16, u16) { + let available_width = available_width.saturating_sub( + 2 // pointer and space + + 2 * (u16::from(use_icons)) + + 2 * (u16::from(is_selected)) + + entry + .line_number + // ":{line_number}: " + .map_or(0, |l| 1 + u16::try_from(l.checked_ilog10().unwrap_or(0)).unwrap() + 3), + ); + + if entry.value.is_none() { + return (available_width, 0); + } + + // otherwise, use up the available space for both name and value as nicely as possible + let name_len = + u16::try_from(entry.name.chars().count()).unwrap_or(u16::MAX); + let value_len = entry + .value + .as_ref() + .map_or(0, |v| u16::try_from(v.chars().count()).unwrap_or(u16::MAX)); + + if name_len < available_width / 2 { + (name_len, available_width - name_len - 2) + } else if value_len < available_width / 2 { + (available_width - value_len, value_len - 2) + } else { + (available_width / 2, available_width / 2 - 2) + } +} + +fn build_result_line<'a>( + entry: &'a Entry, + selected_entries: Option<&FxHashSet>, + use_icons: bool, + colorscheme: &ResultsColorscheme, + area_width: u16, +) -> Line<'a> { + let mut spans = Vec::new(); + let (name_max_width, value_max_width) = max_widths( + entry, + area_width, + use_icons, + selected_entries.map_or(false, |selected| selected.contains(entry)), + ); + // optional selection symbol + if let Some(selected_entries) = selected_entries { + if !selected_entries.is_empty() { + spans.push(if selected_entries.contains(entry) { + Span::styled( + SELECTED_SYMBOL, + Style::default().fg(colorscheme.result_selected_fg), + ) + } else { + Span::from(DESELECTED_SYMBOL) + }); + } + } + // optional icon + if let Some(icon) = entry.icon.as_ref() { + if use_icons { + spans.push(Span::styled( + icon.to_string(), + Style::default().fg(Color::from_str(icon.color).unwrap()), + )); + + spans.push(Span::raw(" ")); + } + } + // entry name + let (mut entry_name, mut value_match_ranges) = + make_matched_string_printable( + &entry.name, + entry.name_match_ranges.as_deref(), + ); + // if the name is too long, we need to truncate it and add an ellipsis + if entry_name.len() > name_max_width as usize { + (entry_name, value_match_ranges) = truncate_highlighted_string( + &entry_name, + &value_match_ranges, + name_max_width, + ); + } + let mut last_match_end = 0; + for (start, end) in value_match_ranges + .iter() + .map(|(s, e)| (*s as usize, *e as usize)) + { + // from the end of the last match to the start of the current one + spans.push(Span::styled( + slice_at_char_boundaries(&entry_name, last_match_end, start) + .to_string(), + Style::default().fg(colorscheme.result_name_fg), + )); + // the current match + spans.push(Span::styled( + slice_at_char_boundaries(&entry_name, start, end).to_string(), + Style::default().fg(colorscheme.match_foreground_color), + )); + last_match_end = end; + } + // we need to push a span for the remainder of the entry name + // but only if there's something left + let next_boundary = next_char_boundary(&entry_name, last_match_end); + if next_boundary < entry_name.len() { + let remainder = entry_name[next_boundary..].to_string(); + spans.push(Span::styled( + remainder, + Style::default().fg(colorscheme.result_name_fg), + )); + } + // optional line number + if let Some(line_number) = entry.line_number { + spans.push(Span::styled( + format!(":{line_number}"), + Style::default().fg(colorscheme.result_line_number_fg), + )); + } + // optional value + if let Some(value) = &entry.value { + spans.push(Span::raw(": ")); + + let (mut value, mut value_match_ranges) = + make_matched_string_printable( + value, + entry.value_match_ranges.as_deref(), + ); + // if the value is too long, we need to truncate it and add an ellipsis + if value.len() > value_max_width as usize { + (value, value_match_ranges) = truncate_highlighted_string( + &value, + &value_match_ranges, + value_max_width, + ); + } + + let mut last_match_end = 0; + for (start, end) in value_match_ranges + .iter() + .map(|(s, e)| (*s as usize, *e as usize)) + { + spans.push(Span::styled( + slice_at_char_boundaries(&value, last_match_end, start) + .to_string(), + Style::default().fg(colorscheme.result_preview_fg), + )); + spans.push(Span::styled( + slice_at_char_boundaries(&value, start, end).to_string(), + Style::default().fg(colorscheme.match_foreground_color), + )); + last_match_end = end; + } + let next_boundary = next_char_boundary(&value, last_match_end); + if next_boundary < value.len() { + spans.push(Span::styled( + value[next_boundary..].to_string(), + Style::default().fg(colorscheme.result_preview_fg), + )); + } + } + Line::from(spans) +} + pub fn build_results_list<'a, 'b>( results_block: Block<'b>, entries: &'a [Entry], @@ -27,110 +201,19 @@ pub fn build_results_list<'a, 'b>( list_direction: ListDirection, use_icons: bool, colorscheme: &ResultsColorscheme, + area_width: u16, ) -> List<'a> where 'b: 'a, { List::new(entries.iter().map(|entry| { - let mut spans = Vec::new(); - // optional selection symbol - if let Some(selected_entries) = selected_entries { - if !selected_entries.is_empty() { - spans.push(if selected_entries.contains(entry) { - Span::styled( - SELECTED_SYMBOL, - Style::default().fg(colorscheme.result_selected_fg), - ) - } else { - Span::from(DESELECTED_SYMBOL) - }); - } - } - // optional icon - if let Some(icon) = entry.icon.as_ref() { - if use_icons { - spans.push(Span::styled( - icon.to_string(), - Style::default().fg(Color::from_str(icon.color).unwrap()), - )); - - spans.push(Span::raw(" ")); - } - } - // entry name - let (entry_name, name_match_ranges) = make_matched_string_printable( - &entry.name, - entry.name_match_ranges.as_deref(), - ); - let mut last_match_end = 0; - for (start, end) in name_match_ranges - .iter() - .map(|(s, e)| (*s as usize, *e as usize)) - { - // from the end of the last match to the start of the current one - spans.push(Span::styled( - slice_at_char_boundaries(&entry_name, last_match_end, start) - .to_string(), - Style::default().fg(colorscheme.result_name_fg), - )); - // the current match - spans.push(Span::styled( - slice_at_char_boundaries(&entry_name, start, end).to_string(), - Style::default().fg(colorscheme.match_foreground_color), - )); - last_match_end = end; - } - // we need to push a span for the remainder of the entry name - // but only if there's something left - let next_boundary = next_char_boundary(&entry_name, last_match_end); - if next_boundary < entry_name.len() { - let remainder = entry_name[next_boundary..].to_string(); - spans.push(Span::styled( - remainder, - Style::default().fg(colorscheme.result_name_fg), - )); - } - // optional line number - if let Some(line_number) = entry.line_number { - spans.push(Span::styled( - format!(":{line_number}"), - Style::default().fg(colorscheme.result_line_number_fg), - )); - } - // optional preview - if let Some(preview) = &entry.value { - spans.push(Span::raw(": ")); - - let (preview, preview_match_ranges) = - make_matched_string_printable( - preview, - entry.value_match_ranges.as_deref(), - ); - let mut last_match_end = 0; - for (start, end) in preview_match_ranges - .iter() - .map(|(s, e)| (*s as usize, *e as usize)) - { - spans.push(Span::styled( - slice_at_char_boundaries(&preview, last_match_end, start) - .to_string(), - Style::default().fg(colorscheme.result_preview_fg), - )); - spans.push(Span::styled( - slice_at_char_boundaries(&preview, start, end).to_string(), - Style::default().fg(colorscheme.match_foreground_color), - )); - last_match_end = end; - } - let next_boundary = next_char_boundary(&preview, last_match_end); - if next_boundary < preview.len() { - spans.push(Span::styled( - preview[next_boundary..].to_string(), - Style::default().fg(colorscheme.result_preview_fg), - )); - } - } - Line::from(spans) + build_result_line( + entry, + selected_entries, + use_icons, + colorscheme, + area_width, + ) })) .direction(list_direction) .highlight_style( @@ -154,15 +237,9 @@ pub fn draw_results_list( preview_keybinding: &str, preview_togglable: bool, ) -> Result<()> { - let mut toggle_hints = format!( - " help: <{help_keybinding}> ", - help_keybinding = help_keybinding, - ); + let mut toggle_hints = format!(" help: <{help_keybinding}> ",); if preview_togglable { - toggle_hints.push_str(&format!( - " preview: <{preview_keybinding}> ", - preview_keybinding = preview_keybinding, - )); + toggle_hints.push_str(&format!(" preview: <{preview_keybinding}> ",)); } let results_block = Block::default() @@ -187,6 +264,7 @@ pub fn draw_results_list( }, use_nerd_font_icons, &colorscheme.results, + rect.width - 1, // right padding ); f.render_stateful_widget(results_list, rect, relative_picker_state); diff --git a/television/television.rs b/television/television.rs index 9ad9eef..d528ecc 100644 --- a/television/television.rs +++ b/television/television.rs @@ -334,7 +334,10 @@ impl Television { / 2) .into(), ) - .try_into()?, + .try_into() + // if the scroll doesn't fit in a u16, just scroll to the top + // this is a current limitation of ratatui + .unwrap_or(0), selected_entry .line_number .and_then(|l| l.try_into().ok()), diff --git a/television/utils/indices.rs b/television/utils/indices.rs index 318277b..2fffeff 100644 --- a/television/utils/indices.rs +++ b/television/utils/indices.rs @@ -1,3 +1,5 @@ +use super::strings::prev_char_boundary; + pub fn sep_name_and_value_indices( indices: &mut Vec, name_len: u32, @@ -29,3 +31,234 @@ pub fn sep_name_and_value_indices( should_add_value_indices, ) } + +const ELLIPSIS: &str = "…"; +const ELLIPSIS_CHAR_WIDTH_U16: u16 = 1; +const ELLIPSIS_BYTE_LEN_U32: u32 = 3; + +/// Truncate a string to fit within a certain width, while keeping track of the +/// indices of the highlighted characters. +/// +/// This will either truncate from the start or the end of the string, depending +/// on where the highlighted characters are. +/// +/// NOTE: This function assumes that the highlighted ranges are sorted and non-overlapping. +pub fn truncate_highlighted_string<'a>( + s: &'a str, + highlighted_ranges: &'a [(u32, u32)], + max_width: u16, +) -> (String, Vec<(u32, u32)>) { + let (byte_positions, chars) = + s.char_indices().unzip::<_, _, Vec<_>, Vec<_>>(); + + if chars.len() <= max_width as usize { + return (s.to_string(), highlighted_ranges.to_vec()); + } + + let max_byte_index = byte_positions[usize::from(max_width)]; + + let last_highlighted_byte_index = + highlighted_ranges.last().unwrap_or(&(0, 0)).1 as usize; + + // if the string isn't highlighted, or all highlighted characters are within the max byte index, + // simply truncate it from the right and add an ellipsis + if highlighted_ranges.is_empty() + // is the last highlighted byte index within the max byte index? + || last_highlighted_byte_index < max_byte_index - 1 + { + return ( + s.chars() + .take( + max_width.saturating_sub(ELLIPSIS_CHAR_WIDTH_U16) as usize + ) + .collect::() + + ELLIPSIS, + highlighted_ranges.to_vec(), + ); + } + + // otherwise, if the last highlighted byte index is within the last "max width" bytes of the + // string, truncate it from the left and add an ellipsis at the beginning + let start_offset = (chars.len() + 1).saturating_sub(max_width as usize); + let byte_offset = byte_positions[start_offset]; + if last_highlighted_byte_index > byte_offset { + return ( + ELLIPSIS.to_string() + + &s.chars().skip(start_offset).collect::(), + highlighted_ranges + .iter() + .map(|(start, end)| { + ( + start.saturating_sub( + u32::try_from(byte_offset).unwrap(), + ) + ELLIPSIS_BYTE_LEN_U32, + end.saturating_sub( + u32::try_from(byte_offset).unwrap(), + ) + ELLIPSIS_BYTE_LEN_U32, + ) + }) + .filter(|(start, end)| start != end) + .collect(), + ); + } + + // otherwise, try to put the last highlighted character towards the end of the truncated string and + // truncate from both sides to fit the max width + let byte_offset = + // note that we're using `max_width` here as a rough estimate to avoid more complex calculations + // and then finding the closest character boundary + prev_char_boundary(s, last_highlighted_byte_index.saturating_sub(max_width.saturating_sub(2) as usize)); + + ( + ELLIPSIS.to_string() + + &s[byte_offset..] + .chars() + .take(max_width.saturating_sub(2 * ELLIPSIS_CHAR_WIDTH_U16) + as usize) + .collect::() + + ELLIPSIS, + highlighted_ranges + .iter() + .map(|(start, end)| { + ( + start.saturating_sub(u32::try_from(byte_offset).unwrap()) + + ELLIPSIS_BYTE_LEN_U32, + end.saturating_sub(u32::try_from(byte_offset).unwrap()) + + ELLIPSIS_BYTE_LEN_U32, + ) + }) + .filter(|(start, end)| start != end) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + #[test] + /// string: themes/solarized-light.toml + /// highlights: ---- + /// max width: --------------------------- + /// result: themes/solarized-light.toml + /// expected: ---- + fn test_truncate_hightlighted_string_no_op() { + let s = "themes/solarized-light.toml"; + let highlighted_ranges = vec![(23, 27)]; + let max_width = 27; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + assert_eq!(truncated, s); + assert_eq!(ranges, highlighted_ranges); + } + + #[test] + /// string: hello world + /// highlights: + /// max width: ----- + /// result: hell… + fn test_truncate_hightlighted_string_no_highlight() { + let s = "hello world"; + let highlighted_ranges = vec![]; + let max_width = 5; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + assert_eq!(truncated, "hell…"); + assert_eq!(ranges, highlighted_ranges); + } + + #[test] + /// string: hello world + /// highlights: ----- + /// max width: ---------- + /// result: hello wor… + fn test_truncate_hightlighted_string_highlights_fit_left() { + let s = "hello world"; + let highlighted_ranges = vec![(0, 5)]; + let max_width = 10; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + assert_eq!(truncated, "hello wor…"); + assert_eq!(ranges, highlighted_ranges); + } + + #[test] + /// string: hello world + /// highlights: -- ---- - + /// max width: ------ + /// ------ + /// result: …world + fn test_truncate_highlighted_string_highlights_right() { + let s = "hello world"; + let highlighted_ranges = vec![(0, 2), (4, 8), (10, 11)]; + let max_width = 6; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + + assert_eq!(truncated, "…world"); + // the ellipsis is 3 bytes long + assert_eq!(ranges, vec![(3, 5), (7, 8)]); + } + + #[test] + /// string: themes/solarized-light.toml + /// highlights: ---- + /// max width: -------------------------- + /// result: …emes/solarized-light.toml + /// expected: ---- + fn test_truncate_highlighted_string_truncate_left() { + let s = "themes/solarized-light.toml"; + let highlighted_ranges = vec![(23, 27)]; + let max_width = 26; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + + assert_eq!(truncated, "…emes/solarized-light.toml"); + assert_eq!(ranges, vec![(24, 28)]); + } + + #[test] + fn ellipsis_len() { + assert_eq!( + super::ELLIPSIS.chars().count(), + super::ELLIPSIS_CHAR_WIDTH_U16 as usize + ); + assert_eq!( + super::ELLIPSIS.len(), + super::ELLIPSIS_BYTE_LEN_U32 as usize + ); + } + + #[test] + /// string: themes/solarized-light.toml + /// highlights: --- + /// max width: ------- 7 + /// result: …lariz… + /// expected: --- + fn test_truncate_highlighted_string_truncate_middle() { + let s = "themes/solarized-light.toml"; + let highlighted_ranges = vec![(11, 14)]; + let max_width = 7; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + + assert_eq!(truncated, "…lariz…"); + assert_eq!(ranges, vec![(5, 8)]); + } +}