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.
|
//! 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());
|
||||||
|
|||||||
Reference in New Issue
Block a user