diff --git a/benches/main/draw.rs b/benches/main/draw.rs index 8b6ec47..e3191c0 100644 --- a/benches/main/draw.rs +++ b/benches/main/draw.rs @@ -40,7 +40,7 @@ fn draw(c: &mut Criterion) { let _ = tv.update_preview_state( &tv.get_selected_entry(None).unwrap(), ); - tv.update(&Action::Tick).unwrap(); + let _ = tv.update(&Action::Tick); (tv, terminal) }, // Measurement diff --git a/benches/main/draw_results_list.rs b/benches/main/draw_results_list.rs index e187528..0c0b6e5 100644 --- a/benches/main/draw_results_list.rs +++ b/benches/main/draw_results_list.rs @@ -4,7 +4,7 @@ use ratatui::layout::Alignment; use ratatui::prelude::{Line, Style}; use ratatui::style::Color; use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding}; -use television::channels::entry::merge_ranges; +use television::channels::entry::into_ranges; use television::channels::entry::{Entry, PreviewType}; use television::screen::colors::ResultsColorscheme; use television::screen::results::build_results_list; @@ -17,12 +17,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/LICENSE".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{f016}', @@ -34,12 +29,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/README.md".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{f48a}', @@ -51,12 +41,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/re.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -68,12 +53,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/io.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -85,12 +65,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/gc.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -102,12 +77,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/uu.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -119,12 +89,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/nt.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -136,12 +101,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/dis.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -153,12 +113,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/imp.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -170,12 +125,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/bdb.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -187,12 +137,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/abc.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -204,12 +149,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/cgi.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -221,12 +161,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/bz2.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -238,12 +173,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/grp.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -255,12 +185,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/ast.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -272,12 +197,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/csv.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -289,12 +209,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/pdb.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -306,12 +221,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/pwd.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -323,12 +233,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/ssl.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -340,12 +245,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/tty.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -357,12 +257,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/nis.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -374,12 +269,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/pty.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -391,12 +281,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/cmd.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -408,12 +293,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/tests/utils.py".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -425,12 +305,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/pyproject.toml".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e6b2}', @@ -442,12 +317,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/MAINTAINERS.md".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{f48a}', @@ -459,12 +329,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/enum.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -476,12 +341,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/hmac.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -493,12 +353,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/uuid.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -510,12 +365,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/glob.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -527,12 +377,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/_ast.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -544,12 +389,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/_csv.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -561,12 +401,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/code.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -578,12 +413,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/spwd.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -595,12 +425,7 @@ pub fn draw_results_list(c: &mut Criterion) { Entry { name: "typeshed/stdlib/_msi.pyi".to_string(), value: None, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', @@ -619,12 +444,7 @@ pub fn draw_results_list(c: &mut Criterion) { }), line_number: None, preview_type: PreviewType::Files, - name_match_ranges: Some(merge_ranges(&[ - (0, 1), - (1, 2), - (2, 3), - (3, 4), - ])), + name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), value_match_ranges: None, }, ]; diff --git a/television/channels/alias.rs b/television/channels/alias.rs index 9a56877..9ebbf7f 100644 --- a/television/channels/alias.rs +++ b/television/channels/alias.rs @@ -85,7 +85,7 @@ impl OnAir for Channel { should_add_name_indices, should_add_value_indices, ) = sep_name_and_value_indices( - &mut item.match_indices.iter().map(|i| i.0).collect(), + item.match_indices, u32::try_from(item.inner.name.len()).unwrap(), ); @@ -95,17 +95,11 @@ impl OnAir for Channel { .with_icon(self.file_icon); if should_add_name_indices { - let name_indices: Vec<(u32, u32)> = - name_indices.into_iter().map(|i| (i, i + 1)).collect(); - entry = entry.with_name_match_ranges(&name_indices); + entry = entry.with_name_match_indices(&name_indices); } if should_add_value_indices { - let value_indices: Vec<(u32, u32)> = value_indices - .into_iter() - .map(|i| (i, i + 1)) - .collect(); - entry = entry.with_value_match_ranges(&value_indices); + entry = entry.with_value_match_indices(&value_indices); } entry diff --git a/television/channels/cable.rs b/television/channels/cable.rs index 8e1bac8..4aade4f 100644 --- a/television/channels/cable.rs +++ b/television/channels/cable.rs @@ -167,7 +167,7 @@ impl OnAir for Channel { PreviewKind::None => PreviewType::None, }, ) - .with_name_match_ranges(&item.match_indices) + .with_name_match_indices(&item.match_indices) }) .collect() } diff --git a/television/channels/dirs.rs b/television/channels/dirs.rs index f498b4c..2e39a4b 100644 --- a/television/channels/dirs.rs +++ b/television/channels/dirs.rs @@ -95,7 +95,7 @@ impl OnAir for Channel { " ", )), ) - .with_name_match_ranges(&item.match_indices) + .with_name_match_indices(&item.match_indices) .with_icon(FileIcon::from(&path)) }) .collect() diff --git a/television/channels/entry.rs b/television/channels/entry.rs index 2376bb6..da253be 100644 --- a/television/channels/entry.rs +++ b/television/channels/entry.rs @@ -56,22 +56,30 @@ impl PartialEq for Entry { } #[allow(clippy::needless_return)] -pub fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { - ranges.iter().fold( - Vec::new(), - |mut acc: Vec<(u32, u32)>, x: &(u32, u32)| { +/// Convert a list of indices into a list of ranges, merging contiguous ranges. +/// +/// # Example +/// ``` +/// use television::channels::entry::into_ranges; +/// let indices = vec![1, 2, 7, 8]; +/// let ranges = into_ranges(&indices); +/// assert_eq!(ranges, vec![(1, 3), (7, 9)]); +/// ``` +pub fn into_ranges(indices: &[u32]) -> Vec<(u32, u32)> { + indices + .iter() + .fold(Vec::new(), |mut acc: Vec<(u32, u32)>, x| { if let Some(last) = acc.last_mut() { - if last.1 == x.0 { - last.1 = x.1; + if last.1 == *x { + last.1 = *x + 1; } else { - acc.push(*x); + acc.push((*x, *x + 1)); } } else { - acc.push(*x); + acc.push((*x, *x + 1)); } return acc; - }, - ) + }) } impl Entry { @@ -84,8 +92,8 @@ impl Entry { /// /// let entry = Entry::new("name".to_string(), PreviewType::EnvVar) /// .with_value("value".to_string()) - /// .with_name_match_ranges(&vec![(0, 1)]) - /// .with_value_match_ranges(&vec![(0, 1)]) + /// .with_name_match_indices(&vec![0]) + /// .with_value_match_indices(&vec![0]) /// .with_icon(FileIcon::default()) /// .with_line_number(0); /// ``` @@ -114,19 +122,13 @@ impl Entry { self } - pub fn with_name_match_ranges( - mut self, - name_match_ranges: &[(u32, u32)], - ) -> Self { - self.name_match_ranges = Some(merge_ranges(name_match_ranges)); + pub fn with_name_match_indices(mut self, indices: &[u32]) -> Self { + self.name_match_ranges = Some(into_ranges(indices)); self } - pub fn with_value_match_ranges( - mut self, - value_match_ranges: &[(u32, u32)], - ) -> Self { - self.value_match_ranges = Some(merge_ranges(value_match_ranges)); + pub fn with_value_match_indices(mut self, indices: &[u32]) -> Self { + self.value_match_ranges = Some(into_ranges(indices)); self } @@ -198,26 +200,26 @@ mod tests { #[test] fn test_empty_input() { - let ranges: Vec<(u32, u32)> = vec![]; - assert_eq!(merge_ranges(&ranges), Vec::<(u32, u32)>::new()); + let ranges: Vec = vec![]; + assert_eq!(into_ranges(&ranges), Vec::<(u32, u32)>::new()); } #[test] fn test_single_range() { - let ranges = vec![(1, 3)]; - assert_eq!(merge_ranges(&ranges), vec![(1, 3)]); + let ranges = vec![1, 2]; + assert_eq!(into_ranges(&ranges), vec![(1, 3)]); } #[test] fn test_contiguous_ranges() { - let ranges = vec![(1, 2), (2, 3), (3, 4), (4, 5)]; - assert_eq!(merge_ranges(&ranges), vec![(1, 5)]); + let ranges = vec![1, 2, 3, 4]; + assert_eq!(into_ranges(&ranges), vec![(1, 5)]); } #[test] fn test_non_contiguous_ranges() { - let ranges = vec![(1, 2), (3, 4), (5, 6)]; - assert_eq!(merge_ranges(&ranges), vec![(1, 2), (3, 4), (5, 6)]); + let ranges = vec![1, 3, 5]; + assert_eq!(into_ranges(&ranges), vec![(1, 2), (3, 4), (5, 6)]); } #[test] diff --git a/television/channels/env.rs b/television/channels/env.rs index f401e1d..8dcc401 100644 --- a/television/channels/env.rs +++ b/television/channels/env.rs @@ -70,7 +70,7 @@ impl OnAir for Channel { should_add_name_indices, should_add_value_indices, ) = sep_name_and_value_indices( - &mut item.match_indices.iter().map(|i| i.0).collect(), + item.match_indices, u32::try_from(item.inner.name.len()).unwrap(), ); @@ -80,17 +80,11 @@ impl OnAir for Channel { .with_icon(self.file_icon); if should_add_name_indices { - let name_indices: Vec<(u32, u32)> = - name_indices.into_iter().map(|i| (i, i + 1)).collect(); - entry = entry.with_name_match_ranges(&name_indices); + entry = entry.with_name_match_indices(&name_indices); } if should_add_value_indices { - let value_indices: Vec<(u32, u32)> = value_indices - .into_iter() - .map(|i| (i, i + 1)) - .collect(); - entry = entry.with_value_match_ranges(&value_indices); + entry = entry.with_value_match_indices(&value_indices); } entry diff --git a/television/channels/files.rs b/television/channels/files.rs index 22ddb9f..5d77ce3 100644 --- a/television/channels/files.rs +++ b/television/channels/files.rs @@ -107,7 +107,7 @@ impl OnAir for Channel { .map(|item| { let path = item.matched_string; Entry::new(path.clone(), PreviewType::Files) - .with_name_match_ranges(&item.match_indices) + .with_name_match_indices(&item.match_indices) .with_icon(FileIcon::from(&path)) }) .collect() diff --git a/television/channels/git_repos.rs b/television/channels/git_repos.rs index c7db1e3..d459730 100644 --- a/television/channels/git_repos.rs +++ b/television/channels/git_repos.rs @@ -68,7 +68,7 @@ impl OnAir for Channel { path, PreviewType::Command(self.preview_command.clone()), ) - .with_name_match_ranges(&item.match_indices) + .with_name_match_indices(&item.match_indices) .with_icon(self.icon) }) .collect() diff --git a/television/channels/remote_control.rs b/television/channels/remote_control.rs index 7d1358b..8137e6e 100644 --- a/television/channels/remote_control.rs +++ b/television/channels/remote_control.rs @@ -145,7 +145,7 @@ impl OnAir for RemoteControl { .map(|item| { let path = item.matched_string; Entry::new(path, PreviewType::Basic) - .with_name_match_ranges(&item.match_indices) + .with_name_match_indices(&item.match_indices) .with_icon(match item.inner { RCButton::Channel(_) => TV_ICON, RCButton::CableChannel(_) => CABLE_ICON, diff --git a/television/channels/stdin.rs b/television/channels/stdin.rs index 103d45b..38958e1 100644 --- a/television/channels/stdin.rs +++ b/television/channels/stdin.rs @@ -84,7 +84,7 @@ impl OnAir for Channel { // NOTE: we're passing `PreviewType::Basic` here just as a placeholder // to avoid storing the preview command multiple times for each item. Entry::new(item.matched_string, PreviewType::Basic) - .with_name_match_ranges(&item.match_indices) + .with_name_match_indices(&item.match_indices) }) .collect() } diff --git a/television/channels/text.rs b/television/channels/text.rs index b5c95f7..6dd2e93 100644 --- a/television/channels/text.rs +++ b/television/channels/text.rs @@ -202,7 +202,7 @@ impl OnAir for Channel { item.inner.path.to_string_lossy().to_string(); Entry::new(display_path, PreviewType::Files) .with_value(line) - .with_value_match_ranges(&item.match_indices) + .with_value_match_indices(&item.match_indices) .with_icon(FileIcon::from(item.inner.path.as_path())) .with_line_number(item.inner.line_number) }) diff --git a/television/matcher/matched_item.rs b/television/matcher/matched_item.rs index ad9361c..5b4a81f 100644 --- a/television/matcher/matched_item.rs +++ b/television/matcher/matched_item.rs @@ -16,5 +16,5 @@ where /// The dimension against which the item was matched (as a string). pub matched_string: String, /// The indices of the matched characters. - pub match_indices: Vec<(u32, u32)>, + pub match_indices: Vec, } diff --git a/television/matcher/mod.rs b/television/matcher/mod.rs index 1eae8a6..6093ca5 100644 --- a/television/matcher/mod.rs +++ b/television/matcher/mod.rs @@ -181,7 +181,7 @@ where matched_item::MatchedItem { inner: item.data.clone(), matched_string, - match_indices: indices.map(|i| (i, i + 1)).collect(), + match_indices: indices.collect(), } }) .collect() diff --git a/television/screen/colors.rs b/television/screen/colors.rs index 8ec2ae7..4f9f3f1 100644 --- a/television/screen/colors.rs +++ b/television/screen/colors.rs @@ -22,7 +22,7 @@ pub struct HelpColorscheme { pub metadata_field_value_fg: Color, } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub struct ResultsColorscheme { pub result_name_fg: Color, pub result_preview_fg: Color, diff --git a/television/screen/results.rs b/television/screen/results.rs index 5be5585..1d69f9b 100644 --- a/television/screen/results.rs +++ b/television/screen/results.rs @@ -2,10 +2,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, -}; +use crate::utils::strings::make_matched_string_printable; use anyhow::Result; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::{Color, Line, Span, Style}; @@ -16,15 +13,13 @@ use ratatui::widgets::{ use ratatui::Frame; use rustc_hash::FxHashSet; use std::str::FromStr; +use unicode_width::UnicodeWidthStr; 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, @@ -35,6 +30,7 @@ fn max_widths( 2 // pointer and space + 2 * (u16::from(use_icons)) + 2 * (u16::from(is_selected)) + + 2 // borders + entry .line_number // ":{line_number}: " @@ -54,14 +50,17 @@ fn max_widths( .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) + (name_len, available_width - name_len) } else if value_len < available_width / 2 { - (available_width - value_len, value_len - 2) + (available_width - value_len, value_len) } else { - (available_width / 2, available_width / 2 - 2) + (available_width / 2, available_width / 2) } } +// TODO: could we not just iterate on chars here instead of using the indices? +// that would avoid quite some computation during the rendering and might fix multibyte char +// issues (nucleo's indices are actually char-based) fn build_result_line<'a>( entry: &'a Entry, selected_entries: Option<&FxHashSet>, @@ -101,42 +100,52 @@ fn build_result_line<'a>( } } // entry name - let (mut entry_name, mut value_match_ranges) = + let (mut entry_name, mut name_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( + if entry_name.as_str().width() > name_max_width as usize { + (entry_name, name_match_ranges) = truncate_highlighted_string( &entry_name, - &value_match_ranges, + &name_match_ranges, name_max_width, ); } + let mut last_match_end = 0; - for (start, end) in value_match_ranges + let name_chars = entry_name.chars(); + let name_len = entry_name.as_str().width(); + 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(), + name_chars + .clone() + .skip(last_match_end) + .take(start - last_match_end) + .collect::(), + //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(), + name_chars + .clone() + .skip(start) + .take(end - start) + .collect::(), 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(); + if last_match_end < name_len { + let remainder = name_chars.skip(last_match_end).collect::(); spans.push(Span::styled( remainder, Style::default().fg(colorscheme.result_name_fg), @@ -159,7 +168,7 @@ fn build_result_line<'a>( 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 { + if value.as_str().width() > value_max_width as usize { (value, value_match_ranges) = truncate_highlighted_string( &value, &value_match_ranges, @@ -168,25 +177,33 @@ fn build_result_line<'a>( } let mut last_match_end = 0; + let value_chars = value.chars(); + let value_len = value.chars().count(); 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(), + value_chars + .clone() + .skip(last_match_end) + .take(start - last_match_end) + .collect::(), Style::default().fg(colorscheme.result_preview_fg), )); spans.push(Span::styled( - slice_at_char_boundaries(&value, start, end).to_string(), + value_chars + .clone() + .skip(start) + .take(end - start) + .collect::(), 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() { + if last_match_end < value_len { spans.push(Span::styled( - value[next_boundary..].to_string(), + value_chars.skip(last_match_end).collect::(), Style::default().fg(colorscheme.result_preview_fg), )); } @@ -270,3 +287,63 @@ pub fn draw_results_list( f.render_stateful_widget(results_list, rect, relative_picker_state); Ok(()) } + +#[cfg(test)] +mod tests { + use crate::channels::entry::PreviewType; + + use super::*; + + #[test] + fn test_build_result_line() { + let entry = + Entry::new(String::from("something nice"), PreviewType::None) + .with_name_match_indices( + // something nice + // 012345678901234 + // om ni + &[1, 2, 10, 11], + ); + let result_line = build_result_line( + &entry, + None, + false, + &ResultsColorscheme::default(), + 200, + ); + + let expected_line = Line::from(vec![ + Span::raw("s").fg(Color::Reset), + Span::raw("om").fg(Color::Reset), + Span::raw("ething ").fg(Color::Reset), + Span::raw("ni").fg(Color::Reset), + Span::raw("ce").fg(Color::Reset), + ]); + + assert_eq!(result_line, expected_line); + } + + #[test] + fn test_build_result_line_multibyte_chars() { + let entry = + // See https://github.com/alexpasmantier/television/issues/439 + Entry::new(String::from("ジェイムス下地 - REDLINE Original Soundtrack - 06 - ROBOWORLD TV.mp3"), PreviewType::None) + .with_name_match_indices(&[27, 28, 29, 30, 31]); + let result_line = build_result_line( + &entry, + None, + false, + &ResultsColorscheme::default(), + // 16 + (borders + (pointer & space)) + 16 + 2 + 2, + ); + + let expected_line = Line::from(vec![ + Span::raw("…Original ").fg(Color::Reset), + Span::raw("Sound").fg(Color::Reset), + Span::raw("…").fg(Color::Reset), + ]); + + assert_eq!(result_line, expected_line); + } +} diff --git a/television/utils/indices.rs b/television/utils/indices.rs index 2fffeff..585b54a1 100644 --- a/television/utils/indices.rs +++ b/television/utils/indices.rs @@ -1,7 +1,7 @@ -use super::strings::prev_char_boundary; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; pub fn sep_name_and_value_indices( - indices: &mut Vec, + indices: Vec, name_len: u32, ) -> (Vec, Vec, bool, bool) { let mut name_indices = Vec::new(); @@ -9,7 +9,7 @@ pub fn sep_name_and_value_indices( let mut should_add_name_indices = false; let mut should_add_value_indices = false; - for i in indices.drain(..) { + for i in indices { if i < name_len { name_indices.push(i); should_add_name_indices = true; @@ -34,7 +34,8 @@ pub fn sep_name_and_value_indices( const ELLIPSIS: &str = "…"; const ELLIPSIS_CHAR_WIDTH_U16: u16 = 1; -const ELLIPSIS_BYTE_LEN_U32: u32 = 3; +const ELLIPSIS_CHAR_WIDTH_U32: u32 = 1; +const ELLIPSIS_CHAR_WIDTH_USIZE: usize = 1; /// Truncate a string to fit within a certain width, while keeping track of the /// indices of the highlighted characters. @@ -42,59 +43,116 @@ const ELLIPSIS_BYTE_LEN_U32: u32 = 3; /// 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. +/// This will take care of non-unit width characters such as emojis, or certain +/// CJK characters that are wider than a single character. +/// +/// # Note +/// This function assumes that the highlighted ranges are sorted and non-overlapping. +/// +/// # Examples +/// ``` +/// use television::utils::indices::truncate_highlighted_string; +/// +/// let s = "hello world"; +/// let highlighted_ranges = vec![(0, 2), (4, 8), (10, 11)]; +/// let max_width = 6; +/// let (truncated, ranges) = truncate_highlighted_string( +/// s, +/// &highlighted_ranges, +/// max_width, +/// ); +/// +/// assert_eq!(truncated, "…world"); +/// assert_eq!(ranges, vec![(1, 3), (5, 6)]); +/// +/// let s = "下地.mp3"; +/// let highlighted_ranges = vec![(3, 5)]; +/// let max_width = 5; +/// let (truncated, ranges) = truncate_highlighted_string( +/// s, +/// &highlighted_ranges, +/// max_width, +/// ); +/// assert_eq!(truncated, "….mp3"); +/// assert_eq!(ranges, vec![(2, 4)]); +/// ``` +/// +/// See unit tests for more examples. 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<_>>(); + let str_width = s.width(); - if chars.len() <= max_width as usize { + if str_width <= 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_char_index = + (highlighted_ranges.last().unwrap_or(&(0, 0)).1 as usize) + // ranges are exclusive on the right + .saturating_sub(1); + let width_to_last_highlighted_char = s + .chars() + .take(last_highlighted_char_index + 1) + .fold(0, |acc, c| acc + c.width().unwrap_or(0)); - 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, + // if the string isn't highlighted, or all highlighted characters are within the max 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 + // is the last highlighted char index within the first "`max_width` of" characters? + || width_to_last_highlighted_char < max_width as usize { + let mut cumulative_width = 0; return ( s.chars() - .take( - max_width.saturating_sub(ELLIPSIS_CHAR_WIDTH_U16) as usize - ) + .take_while(|c| { + cumulative_width += c.width().unwrap_or(0); + cumulative_width + <= 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 + // otherwise, if the last highlighted char index is within the last "max width" chars 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 { + // |<------- str_width ------->| + // |<-- max_width -->| + // |--------> start_width_offset - 1 (for the ellipsis) + let start_width_offset = str_width.saturating_sub(max_width as usize) + + ELLIPSIS_CHAR_WIDTH_USIZE; + if width_to_last_highlighted_char > start_width_offset { + let mut truncated_width = str_width; + let chars_to_skip = s + .chars() + .take_while(|c| { + if truncated_width >= max_width as usize { + truncated_width -= c.width().unwrap_or(0); + true + } else { + false + } + }) + .count(); + let truncated_string = + s.chars().skip(chars_to_skip).collect::(); return ( - ELLIPSIS.to_string() - + &s.chars().skip(start_offset).collect::(), + ELLIPSIS.to_string() + &truncated_string, highlighted_ranges .iter() .map(|(start, end)| { ( start.saturating_sub( - u32::try_from(byte_offset).unwrap(), - ) + ELLIPSIS_BYTE_LEN_U32, + u32::try_from(chars_to_skip).unwrap(), + ) + ELLIPSIS_CHAR_WIDTH_U32, end.saturating_sub( - u32::try_from(byte_offset).unwrap(), - ) + ELLIPSIS_BYTE_LEN_U32, + u32::try_from(chars_to_skip).unwrap(), + ) + ELLIPSIS_CHAR_WIDTH_U32, ) }) .filter(|(start, end)| start != end) @@ -104,15 +162,33 @@ pub fn truncate_highlighted_string<'a>( // 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)); + let start_width_offset = + // 0123456789012 + // ^^ ^ highlights + // a long string + // -------x width to last highlighted char: 7 + // max width = 4 + // … s… truncated string + // <--> 4 + width_to_last_highlighted_char.saturating_sub(max_width.saturating_sub(2*ELLIPSIS_CHAR_WIDTH_U16) as usize); + + let mut cumulated_width = 0; + let chars_to_skip = s + .chars() + .take_while(|c| { + if cumulated_width < start_width_offset { + cumulated_width += c.width().unwrap_or(0); + true + } else { + false + } + }) + .count(); ( ELLIPSIS.to_string() - + &s[byte_offset..] - .chars() + + &s.chars() + .skip(chars_to_skip) .take(max_width.saturating_sub(2 * ELLIPSIS_CHAR_WIDTH_U16) as usize) .collect::() @@ -121,10 +197,11 @@ pub fn truncate_highlighted_string<'a>( .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, + start + .saturating_sub(u32::try_from(chars_to_skip).unwrap()) + + ELLIPSIS_CHAR_WIDTH_U32, + end.saturating_sub(u32::try_from(chars_to_skip).unwrap()) + + ELLIPSIS_CHAR_WIDTH_U32, ) }) .filter(|(start, end)| start != end) @@ -192,6 +269,7 @@ mod tests { #[test] /// string: hello world /// highlights: -- ---- - + /// he o wo d /// max width: ------ /// ------ /// result: …world @@ -206,8 +284,25 @@ mod tests { ); assert_eq!(truncated, "…world"); - // the ellipsis is 3 bytes long - assert_eq!(ranges, vec![(3, 5), (7, 8)]); + assert_eq!(ranges, vec![(1, 3), (5, 6)]); + } + + #[test] + /// string: 下地.mp3 + /// highlights: --- + /// max width: ----- + /// result: ….mp3 + fn test_truncate_hightlighted_string_highlights_right_wide_chars() { + let s = "下地.mp3"; + let highlighted_ranges = vec![(3, 5)]; + let max_width = 5; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + assert_eq!(truncated, "….mp3"); + assert_eq!(ranges, vec![(2, 4)]); } #[test] @@ -227,7 +322,7 @@ mod tests { ); assert_eq!(truncated, "…emes/solarized-light.toml"); - assert_eq!(ranges, vec![(24, 28)]); + assert_eq!(ranges, vec![(22, 26)]); } #[test] @@ -236,10 +331,6 @@ mod tests { super::ELLIPSIS.chars().count(), super::ELLIPSIS_CHAR_WIDTH_U16 as usize ); - assert_eq!( - super::ELLIPSIS.len(), - super::ELLIPSIS_BYTE_LEN_U32 as usize - ); } #[test] @@ -259,6 +350,46 @@ mod tests { ); assert_eq!(truncated, "…lariz…"); - assert_eq!(ranges, vec![(5, 8)]); + assert_eq!(ranges, vec![(3, 6)]); + } + + #[test] + // 0123456789012 + // ^^ ^ highlights + // a long string + // ---------x last highlighted char index: 9 + // max width = 4 + // … s… truncated string + // <--> 4 + fn test_truncate_highlighted_string_truncate_both_ends() { + let s = "a long string"; + let highlighted_ranges = vec![(3, 5), (7, 8)]; + let max_width = 4; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + + assert_eq!(truncated, "… s…"); + assert_eq!(ranges, vec![(2, 3)]); + } + + #[test] + /// string: 下地下地abc下地下地 + /// highlights: --- + /// max width: ----- + /// result: …abc… + fn test_truncate_hightlighted_string_truncate_both_ends_wide_chars() { + let s = "下地下地abc下地下地"; + let highlighted_ranges = vec![(4, 7)]; + let max_width = 5; + let (truncated, ranges) = super::truncate_highlighted_string( + s, + &highlighted_ranges, + max_width, + ); + assert_eq!(truncated, "…abc…"); + assert_eq!(ranges, vec![(1, 4)]); } } diff --git a/television/utils/strings.rs b/television/utils/strings.rs index 1610f1e..d0ad531 100644 --- a/television/utils/strings.rs +++ b/television/utils/strings.rs @@ -477,6 +477,13 @@ pub fn preprocess_line(line: &str) -> (String, Vec) { /// let (printable, match_indices) = make_matched_string_printable(&matched_string, match_ranges); /// assert_eq!(printable.len(), 480); /// assert_eq!(match_indices, vec![(0, 1)]); +/// +/// let matched_string = "ジェ abc"; +/// let match_ranges = vec![(0, 1), (2, 3)]; +/// let match_ranges = Some(match_ranges.as_slice()); +/// let (printable, match_indices) = make_matched_string_printable(matched_string, match_ranges); +/// assert_eq!(printable, "ジェ abc"); +/// assert_eq!(match_indices, vec![(0, 1), (2, 3)]); /// ``` /// /// # Panics