fix: wrap view tabs to terminal width
This commit is contained in:
+83
-7
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user