feat: mark pinned category tabs

This commit is contained in:
2026-10-11 14:39:39 +02:00
parent 7b7b65ca3f
commit b05d4bcb47
2 changed files with 44 additions and 27 deletions
+43 -26
View File
@@ -46,6 +46,8 @@
//! Esc returns to NORMAL without clearing the filter; another Esc closes. Search input editing shortcuts apply.
//! Matching selections survive filter edits; no matches leaves Enter/p without an action.
//! Pinned categories stay visible; browsing another category replaces the single temporary tab.
//! Pinned category tab titles use `[p]`, matching the selector; Search, Feed, and temporary tabs do not.
//! The marker shares selection styling and counts toward whole-label wrapping without changing category names.
//! Browsing a pinned category focuses its existing tab and removes the temporary tab.
//! Top-level `pinned_categories = ["Favorites"]` persists pins; missing category names are ignored.
//! Tab/Shift-Tab cycle only visible tabs; hidden views retain cached rows and selection.
@@ -1213,7 +1215,8 @@ impl App {
let mut selected_row = 0;
for (index, view) in self.visible_views() {
let mut title = Span::raw(view.name.as_str());
let title_width = title.width() + 2; // One space of padding on each side.
let pinned = index >= 2 && self.config.pinned_categories.contains(&view.name);
let title_width = title.width() + 2 + if pinned { 4 } else { 0 }; // Padding and pin marker.
if row_width != 0 && row_width + 1 + title_width > usize::from(area.width) {
tab_lines.push(Line::default());
row_width = 0;
@@ -1226,7 +1229,11 @@ impl App {
if row_width != 0 {
row.spans.push("│".into());
}
row.spans.extend([" ".into(), title, " ".into()]);
row.spans.push(" ".into());
if pinned {
row.spans.push(Span::styled("[p] ", title.style));
}
row.spans.extend([title, " ".into()]);
row_width += usize::from(row_width != 0) + title_width;
}
// Leave space for search, a usable results pane, status, and help.
@@ -2175,39 +2182,49 @@ mod tests {
.collect();
app.view_index = 4;
app.mode = Mode::Insert;
for (width, height, language, japanese, chinese, cursor_y) in [
(30, 20, Some((1, 1)), (1, 2), (14, 2), 4),
(56, 20, Some((17, 0)), (38, 0), (51, 0), 2),
(55, 20, Some((17, 0)), (38, 0), (1, 1), 3),
(9, 20, None, (1, 3), (1, 4), 6),
(30, 12, Some((1, 0)), (1, 1), (14, 1), 3),
] {
for (width, height) in [(30, 20), (56, 20), (55, 20), (9, 20), (30, 12)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let tab_height = terminal.get_cursor_position().unwrap().y - 1;
let buffer = terminal.backend().buffer();
if let Some((x, y)) = language {
for (offset, character) in "Language immersion".chars().enumerate() {
assert_eq!(
buffer.cell((x + offset as u16, y)).unwrap().symbol(),
character.to_string(),
"whole label at width {width}, height {height}"
);
}
}
assert_eq!(buffer.cell(japanese).unwrap().symbol(), "日");
assert_ne!(buffer.cell(japanese).unwrap().fg, Color::Cyan);
assert_eq!(buffer.cell(chinese).unwrap().symbol(), "中");
let locate = |symbol: &str| {
(0..tab_height)
.flat_map(|y| (0..width).map(move |x| (x, y)))
.find(|&position| buffer.cell(position).unwrap().symbol() == symbol)
};
let chinese = locate("中").expect("selected category remains visible");
assert_eq!(buffer.cell(chinese).unwrap().fg, Color::Cyan);
assert_eq!(
buffer.cell((chinese.0 + 2, chinese.1)).unwrap().symbol(),
"文"
);
assert_eq!(buffer.cell(chinese).unwrap().fg, Color::Cyan);
assert_eq!(
terminal.get_cursor_position().unwrap(),
(1, cursor_y).into()
);
if width >= 16 {
let japanese = locate("日").expect("neighboring category remains visible");
for (offset, character) in "日本語だけ".chars().enumerate() {
assert_eq!(
buffer
.cell((japanese.0 + offset as u16 * 2, japanese.1))
.unwrap()
.symbol(),
character.to_string(),
"category label must not split across rows"
);
}
assert_ne!(buffer.cell(japanese).unwrap().fg, Color::Cyan);
}
if width >= 24 {
assert!(
(0..tab_height).any(|y| {
(0..width)
.map(|x| buffer.cell((x, y)).unwrap().symbol())
.collect::<String>()
.contains("Language immersion")
}),
"whole category label must fit after wrapping"
);
}
}
}