fix(results): fix alignment for non unit width unicode characters (#442)

Fixes #439
This commit is contained in:
Alexandre Pasmantier 2025-04-01 01:30:18 +02:00 committed by GitHub
parent f9a49acccf
commit 6ba235fa11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 375 additions and 350 deletions

View File

@ -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

View File

@ -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,
},
];

View File

@ -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

View File

@ -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()
}

View File

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

View File

@ -56,22 +56,30 @@ impl PartialEq<Entry> 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<u32> = 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]

View File

@ -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

View File

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

View File

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

View File

@ -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,

View File

@ -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()
}

View File

@ -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)
})

View File

@ -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<u32>,
}

View File

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

View File

@ -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,

View File

@ -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<Entry>>,
@ -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::<String>(),
//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::<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();
if last_match_end < name_len {
let remainder = name_chars.skip(last_match_end).collect::<String>();
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::<String>(),
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::<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() {
if last_match_end < value_len {
spans.push(Span::styled(
value[next_boundary..].to_string(),
value_chars.skip(last_match_end).collect::<String>(),
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);
}
}

View File

@ -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<u32>,
indices: Vec<u32>,
name_len: u32,
) -> (Vec<u32>, Vec<u32>, 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::<String>()
+ 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::<String>();
return (
ELLIPSIS.to_string()
+ &s.chars().skip(start_offset).collect::<String>(),
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::<String>()
@ -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)]);
}
}

View File

@ -477,6 +477,13 @@ pub fn preprocess_line(line: &str) -> (String, Vec<i16>) {
/// 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