diff --git a/src/main.rs b/src/main.rs index 2a0b1c8..cbb89ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,9 @@ //! 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. //! 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. //! 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. @@ -65,8 +68,8 @@ use ratatui::{ }, layout::{Constraint, Layout, Rect}, style::{Color, Style, Stylize}, - text::Line, - widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Tabs, Wrap}, + text::{Line, Span}, + widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap}, }; use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; @@ -841,18 +844,44 @@ impl App { fn draw(&mut self, frame: &mut Frame, elapsed: Duration) { const SPINNER: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "]; 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([ - Constraint::Length(1), + Constraint::Length(tab_height), Constraint::Length(3), Constraint::Min(3), Constraint::Length(3), Constraint::Length(1), ]) - .areas(frame.area()); + .areas(area); frame.render_widget( - Tabs::new(self.views.iter().map(|view| view.name.as_str())) - .select(self.view_index) - .highlight_style(Style::new().fg(Color::Cyan).bold()), + Paragraph::new(tab_lines).scroll((first_row.min(u16::MAX as usize) as u16, 0)), tabs, ); 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] fn search_pages_preserve_relevance_selection_and_cached_descriptions() { let mut view = View::new("Search".into());