fix: wrap view tabs to terminal width

This commit is contained in:
2026-10-11 10:58:11 +02:00
parent ce01ea097d
commit 198cfbe6ee
+83 -7
View File
@@ -9,6 +9,9 @@
//! Active text fields show a steady block cursor at the scrolled end; other views hide it. //! Active text fields show a steady block cursor at the scrolled end; other views hide it.
//! The terminal's configured default cursor shape is restored when the app exits. //! The terminal's configured default cursor shape is restored when the app exits.
//! Normal: j/k selects, Enter opens the video in Terebi, and i edits the query. //! Normal: j/k selects, Enter opens the video in Terebi, and i edits the query.
//! View tabs wrap whole labels into rows using terminal-cell widths and reflow on resize.
//! Short terminals keep the selected tab row visible while reserving space for the other panes.
//! A single label wider than the terminal is clipped rather than split across rows.
//! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages. //! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages.
//! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells. //! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells.
//! Esc closes the popup; j/k loads the selected video's description and i returns to editing. //! Esc closes the popup; j/k loads the selected video's description and i returns to editing.
@@ -65,8 +68,8 @@ use ratatui::{
}, },
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize}, style::{Color, Style, Stylize},
text::Line, text::{Line, Span},
widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Tabs, Wrap}, widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap},
}; };
use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
@@ -841,18 +844,44 @@ impl App {
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) { fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
const SPINNER: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "]; const SPINNER: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
let spinner = SPINNER[(elapsed.as_millis() / 100 % SPINNER.len() as u128) as usize]; let spinner = SPINNER[(elapsed.as_millis() / 100 % SPINNER.len() as u128) as usize];
let area = frame.area();
let mut tab_lines = vec![Line::default()];
let mut row_width = 0;
let mut selected_row = 0;
for (index, view) in self.views.iter().enumerate() {
let mut title = Span::raw(view.name.as_str());
let title_width = title.width() + 2; // One space of padding on each side.
if row_width != 0 && row_width + 1 + title_width > usize::from(area.width) {
tab_lines.push(Line::default());
row_width = 0;
}
if index == self.view_index {
title.style = Style::new().fg(Color::Cyan).bold();
selected_row = tab_lines.len() - 1;
}
let row = tab_lines.last_mut().expect("at least one tab row");
if row_width != 0 {
row.spans.push("│".into());
}
row.spans.extend([" ".into(), title, " ".into()]);
row_width += usize::from(row_width != 0) + title_width;
}
// Leave space for search, a usable results pane, status, and help.
let tab_height = tab_lines
.len()
.min(usize::from(area.height.saturating_sub(10).max(1)))
as u16;
let first_row = selected_row.saturating_sub(usize::from(tab_height).saturating_sub(1));
let [tabs, search, results, status, help] = Layout::vertical([ let [tabs, search, results, status, help] = Layout::vertical([
Constraint::Length(1), Constraint::Length(tab_height),
Constraint::Length(3), Constraint::Length(3),
Constraint::Min(3), Constraint::Min(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(1), Constraint::Length(1),
]) ])
.areas(frame.area()); .areas(area);
frame.render_widget( frame.render_widget(
Tabs::new(self.views.iter().map(|view| view.name.as_str())) Paragraph::new(tab_lines).scroll((first_row.min(u16::MAX as usize) as u16, 0)),
.select(self.view_index)
.highlight_style(Style::new().fg(Color::Cyan).bold()),
tabs, tabs,
); );
let search_style = if self.mode == Mode::Insert { let search_style = if self.mode == Mode::Insert {
@@ -1205,6 +1234,53 @@ mod tests {
) )
} }
#[test]
fn view_tabs_wrap_whole_labels_and_keep_the_selected_row_visible() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
app.views = ["Search", "Feed", "Language immersion", "日本語だけ", "中文"]
.into_iter()
.map(|name| View::new(name.into()))
.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),
] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
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(), "中");
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()
);
}
}
#[test] #[test]
fn search_pages_preserve_relevance_selection_and_cached_descriptions() { fn search_pages_preserve_relevance_selection_and_cached_descriptions() {
let mut view = View::new("Search".into()); let mut view = View::new("Search".into());