feat: unify provider interface design

This commit is contained in:
2026-10-17 00:49:59 +02:00
parent 1e89be58b6
commit bb0a3e58ce
6 changed files with 349 additions and 237 deletions
+25 -58
View File
@@ -122,6 +122,7 @@ mod keys;
mod miruro_browser;
mod miruro_ui;
mod thumbnail;
mod ui;
mod video_state;
mod youtube;
@@ -2883,8 +2884,7 @@ 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 spinner = ui::spinner(elapsed);
let area = frame.area();
let mut tab_lines = vec![Line::default()];
let mut row_width = 0;
@@ -2919,54 +2919,23 @@ impl App {
.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(tab_height),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(area);
let [tabs, search, results, status, help] = ui::browser_layout(area, tab_height);
frame.render_widget(
Paragraph::new(tab_lines).scroll((first_row.min(u16::MAX as usize) as u16, 0)),
tabs,
);
let search_style = if self.mode == Mode::Insert {
Style::new().fg(Color::Cyan)
} else {
Style::new()
};
// End-only editing; scroll long queries without counting UTF-8 bytes as columns.
let query_view = &self.views[self.query_view_index()];
let query = ratatui::text::Line::raw(query_view.query.as_str());
let query_width = query.width();
let search_title: Line<'_> = match query_view.source {
ViewSource::Category(_) | ViewSource::CategoryGroup(_) => {
" tubular / Category search ".into()
}
_ => " tubular / YouTube search ".into(),
let search_title = match query_view.source {
ViewSource::Category(_) | ViewSource::CategoryGroup(_) => " tubular / Category search ",
_ => " tubular / YouTube search ",
};
let search_block = Block::bordered()
.title(search_title)
.border_style(search_style);
let input = search_block.inner(search);
let scroll = query_width.saturating_sub(input.width.saturating_sub(1) as usize);
frame.render_widget(
Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(search_block),
ui::search(
frame,
search,
search_title,
query_view.query.as_str(),
self.mode == Mode::Insert && self.category_dialog.is_none(),
);
if self.mode == Mode::Insert
&& self.category_dialog.is_none()
&& input.width != 0
&& input.height != 0
{
let cursor = query_width
.saturating_sub(scroll)
.min(usize::from(input.width - 1));
frame.set_cursor_position((input.x + cursor as u16, input.y));
}
let view = &mut self.views[self.view_index];
let table = Table::new(
view.videos.iter().map(|video| {
@@ -3023,8 +2992,8 @@ impl App {
},
self.job.is_some().then_some(spinner),
)))
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
.row_highlight_style(ui::SELECTED)
.highlight_symbol(ui::SELECTION_MARKER);
frame.render_stateful_widget(table, results, &mut view.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
@@ -3046,7 +3015,7 @@ impl App {
{
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let block = Block::bordered().border_style(Style::new().fg(Color::Cyan));
let block = Block::bordered().border_style(ui::ACCENT);
let inner = block.inner(popup);
let cached = cached_description(&self.descriptions, &video.id);
let text = match cached {
@@ -3209,7 +3178,7 @@ impl App {
let popup = preview_area(frame.area());
let block = Block::bordered()
.title(self.key_hints.help.as_str())
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(ratatui::layout::Margin::new(1, 0));
let viewport = (inner.width, inner.height);
if help.viewport != Some(viewport) {
@@ -3243,7 +3212,7 @@ impl App {
BrowserKind::WatchLater => self.key_hints.browser[2].as_str(),
BrowserKind::SavedVideos => self.key_hints.browser[3].as_str(),
})
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let [input, list, error] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
@@ -3324,8 +3293,8 @@ impl App {
});
frame.render_stateful_widget(
List::new(items)
.highlight_symbol("> ")
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
list,
&mut browser.selection,
);
@@ -3374,9 +3343,7 @@ impl App {
"Submitting saves this rename immediately; existing memberships and pins are preserved.",
),
};
let mut block = Block::bordered()
.title(title)
.border_style(Style::new().fg(Color::Cyan));
let mut block = Block::bordered().title(title).border_style(ui::ACCENT);
if let CategoryNameTarget::Rename { old, .. } = &input.target {
block = block.title_bottom(format!(" {old} "));
}
@@ -3450,7 +3417,7 @@ impl App {
" Tags "
})
.title_bottom(dialog.label.as_str())
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let [input, body, warning, error, help] = Layout::vertical([
Constraint::Length(if dialog.name.is_none() { 3 } else { 0 }),
Constraint::Min(1),
@@ -3522,8 +3489,8 @@ impl App {
])
}),
)
.highlight_symbol("> ")
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
body,
&mut dialog.selection,
);
@@ -3695,8 +3662,8 @@ fn choose_provider(
bindings.label(Action::SelectorClose),
);
let providers = List::new(PROVIDERS.map(|(name, _)| name))
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
.highlight_style(ui::SELECTED)
.highlight_symbol(ui::SELECTION_MARKER);
loop {
selection.select(Some(selected));
terminal.draw(|frame| {
@@ -3711,7 +3678,7 @@ fn choose_provider(
);
let block = Block::bordered()
.title(" Browse provider ")
.border_style(Style::new().fg(Color::Cyan));
.border_style(ui::ACCENT);
let [list, hints] = Layout::vertical([Constraint::Min(0), Constraint::Length(2)])
.areas(block.inner(popup));
frame.render_widget(block, popup);
+262 -176
View File
@@ -1,4 +1,8 @@
use std::{io, path::PathBuf, time::Duration};
use std::{
io,
path::PathBuf,
time::{Duration, Instant},
};
use ratatui::{
DefaultTerminal, Frame,
@@ -10,10 +14,10 @@ use ratatui::{
},
execute,
},
layout::{Constraint, Layout},
layout::Margin,
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, List, ListItem, ListState, Paragraph, Wrap},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use serde_json::Value;
use tubular::miruro::Identity;
@@ -22,6 +26,7 @@ use crate::{
Options, config,
keys::{Action, Context},
miruro_browser::{self, Episode, Media, Request, Response, Worker},
ui,
video_state::{CollectionKind, State},
youtube::{PlayerLaunch, Video},
};
@@ -85,7 +90,6 @@ struct Screen {
description_scroll: u16,
error: Option<String>,
title: String,
summary: String,
overview: String,
description: String,
rows: Vec<DisplayRow>,
@@ -98,7 +102,6 @@ impl Screen {
description_scroll: 0,
error: None,
title: String::new(),
summary: String::new(),
overview: String::new(),
description: String::new(),
rows: Vec::new(),
@@ -157,7 +160,6 @@ impl Screen {
fn rebuild_display(&mut self) {
self.rows.clear();
self.overview.clear();
self.summary.clear();
match &self.content {
Content::Catalog { media, .. } => {
self.title = "Miruro · Catalog (autocomplete, up to 8 titles)".into();
@@ -173,7 +175,6 @@ impl Screen {
}
Content::Series { media, data } => {
self.title = format!("Miruro · {} · Episodes", safe(&media.title));
self.summary = media_summary(media);
self.overview = media_details(media);
if let Some(data) = data {
self.overview.push_str(&format!(
@@ -243,9 +244,6 @@ impl Screen {
String::new()
}
);
self.summary =
"Offline episode collections · ✓ watched · watch later · saved · watching"
.into();
self.rows = videos.iter().map(|video| {
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
let title = safe(&video.title);
@@ -863,7 +861,7 @@ impl Browser {
(Action::WatchLaterBrowse, "Watch later episodes"),
(Action::SavedVideoBrowse, "Saved episodes"),
(Action::WatchingBrowse, "Currently watching episodes"),
(Action::InfoOverview, "Focus/unfocus metadata"),
(Action::InfoOverview, "Show/hide metadata"),
(Action::Help, "Keyboard help"),
(Action::Quit, "Exit"),
(Action::Interrupt, "Exit immediately"),
@@ -871,7 +869,7 @@ impl Browser {
if self.config.keys.label(Action::InfoDescription)
!= self.config.keys.label(Action::InfoOverview)
{
bindings.push((Action::InfoDescription, "Focus/unfocus metadata"));
bindings.push((Action::InfoDescription, "Show/hide metadata"));
}
if !matches!(self.screen().content, Content::Catalog { .. }) {
bindings.extend([
@@ -893,7 +891,7 @@ impl Browser {
}
if self.description_focus {
bindings.extend([
(Action::InfoHide, "Return focus to list"),
(Action::InfoHide, "Close metadata"),
(Action::DescriptionNext, "Scroll metadata down"),
(Action::DescriptionPrevious, "Scroll metadata up"),
(Action::DescriptionPageNext, "Metadata page down"),
@@ -909,61 +907,86 @@ impl Browser {
self.config.keys.label(Action::Quit)
);
}
if self.help.is_some() {
return [
(Action::HelpClose, "close"),
(Action::HelpNext, "down"),
(Action::HelpPrevious, "up"),
(Action::HelpPageNext, "page down"),
(Action::HelpPagePrevious, "page up"),
(Action::Quit, "quit"),
]
.iter()
.map(|(action, label)| format!("{} {label}", self.config.keys.label(*action)))
.collect::<Vec<_>>()
.join(" · ");
}
let actions: &[Action] = if self.input.is_some() {
&[Action::InputSubmit, Action::InputCancel, Action::DeleteWord]
let (mode, actions): (&str, &[(Action, &str)]) = if self.help.is_some() {
(
"HELP",
&[
(Action::HelpClose, "close"),
(Action::HelpNext, "down"),
(Action::HelpPrevious, "up"),
(Action::HelpPageNext, "page down"),
(Action::HelpPagePrevious, "page up"),
(Action::Quit, "quit"),
],
)
} else if self.input.is_some() {
(
"INSERT",
&[
(Action::InputSubmit, "search"),
(Action::InputCancel, "cancel"),
(Action::DeleteWord, "delete word"),
],
)
} else if self.description_focus {
&[
Action::InfoHide,
Action::DescriptionNext,
Action::DescriptionPrevious,
Action::DescriptionPageNext,
Action::Help,
]
(
"DESCRIPTION",
&[
(Action::InfoHide, "close"),
(Action::Help, "help"),
(Action::DescriptionNext, "down"),
(Action::DescriptionPrevious, "up"),
(Action::DescriptionPageNext, "page down"),
],
)
} else if matches!(self.screen().content, Content::Collection { .. }) {
&[
Action::VideoPlay,
Action::CollectionRemoveSelected,
Action::CollectionRemoveBulk,
Action::WatchLaterFilter,
Action::InfoOverview,
Action::ViewPrevious,
Action::SearchEdit,
Action::Help,
Action::Quit,
]
(
"NORMAL",
&[
(Action::Help, "help"),
(Action::VideoPlay, "play"),
(Action::InfoOverview, "info"),
(Action::CollectionRemoveSelected, "remove"),
(Action::CollectionRemoveBulk, "remove all"),
(Action::WatchLaterFilter, "filter"),
(Action::ViewPrevious, "back"),
(Action::Quit, "quit"),
],
)
} else {
&[
Action::VideoPlay,
Action::SearchEdit,
Action::ViewPrevious,
Action::WatchLaterBrowse,
Action::SavedVideoBrowse,
Action::WatchingBrowse,
Action::InfoOverview,
Action::Help,
Action::Quit,
]
(
"NORMAL",
&[
(Action::Help, "help"),
(Action::SearchEdit, "search"),
(
Action::VideoPlay,
if matches!(self.screen().content, Content::Catalog { .. }) {
"open"
} else {
"play"
},
),
(Action::InfoOverview, "info"),
(Action::ViewPrevious, "back"),
(Action::WatchLaterBrowse, "later"),
(Action::SavedVideoBrowse, "saved"),
(Action::WatchingBrowse, "watching"),
(Action::Quit, "quit"),
],
)
};
self.bindings()
.into_iter()
.filter(|(action, _)| actions.contains(action))
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
.collect::<Vec<_>>()
.join(" · ")
let supported = self.bindings();
let mut hints = mode.to_owned();
for (action, label) in actions {
if self.help.is_some() || supported.iter().any(|(supported, _)| supported == action) {
hints.push_str(" · ");
hints.push_str(self.config.keys.label(*action));
hints.push(' ');
hints.push_str(label);
}
}
hints
}
fn markers(&self, id: &str) -> String {
let mut markers = String::new();
@@ -1029,132 +1052,133 @@ impl Browser {
})
.collect()
}
fn draw(&mut self, frame: &mut Frame) {
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
let area = frame.area();
if area.width < 24 || area.height < 8 {
if area.width < 24 || area.height < 10 {
frame.render_widget(
Paragraph::new("Miruro\nTerminal too small; enlarge to browse.\nq / Ctrl-C exits")
.wrap(Wrap { trim: true }),
Paragraph::new(format!(
"Miruro\nTerminal too small; enlarge to browse.\n{} exits",
self.config.keys.label(Action::Quit)
))
.wrap(Wrap { trim: true }),
area,
);
return;
}
let [header, query_area, content, status, hints] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(2),
Constraint::Length(2),
])
.areas(area);
let [header, query_area, content, status, hints] = ui::browser_layout(area, 1);
frame.render_widget(
Paragraph::new(self.screen().title.as_str()).bold().cyan(),
Paragraph::new(Line::from(vec![
" ".into(),
self.screen().title.as_str().into(),
" ".into(),
]))
.style(ui::ACCENT)
.bold(),
header,
);
let input = self.input.as_ref().map(|input| format!("{input}▏"));
let query = input
.as_deref()
.unwrap_or_else(|| match &self.screen().content {
Content::Catalog { query, .. } => query.as_str(),
_ => self.screen().summary.as_str(),
});
frame.render_widget(
Paragraph::new(query).block(Block::bordered().title(if self.input.is_some() {
"Catalog query · editing"
} else if matches!(self.screen().content, Content::Catalog { .. }) {
"Catalog query"
} else {
"Metadata"
})),
let query = self.input.as_deref().unwrap_or_else(|| {
self.screens[..=self.index]
.iter()
.rev()
.find_map(|screen| match &screen.content {
Content::Catalog { query, .. } => Some(query.as_str()),
_ => None,
})
.unwrap_or("")
});
ui::search(
frame,
query_area,
" tubular / Miruro search ",
query,
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
);
let (list_area, metadata_area) = if area.width >= 80 {
let [list, metadata] =
Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)])
.areas(content);
(Some(list), Some(metadata))
} else if self.description_focus {
(None, Some(content))
} else {
(Some(content), None)
let title = match self.screen().content {
Content::Catalog { .. } => " Titles ",
_ => " Episodes ",
};
if let Some(list_area) = list_area {
if self.screen().rows.is_empty() {
let empty = if let Some(error) = &self.screen().error {
format!(
"{error}\n\n{} refreshes; {} searches the catalog.",
self.config.keys.label(Action::ViewRefresh),
self.config.keys.label(Action::SearchEdit)
)
} else if self.intent.is_some() {
"Loading Miruro metadata…".into()
} else {
match &self.screen().content {
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result to browse its actual returned episodes.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
Content::Collection { .. } => "No Miruro episodes in this collection/filter. Save episodes from a series; YouTube entries are not shown here.".into(),
}
};
frame.render_widget(
Paragraph::new(empty)
.wrap(Wrap { trim: true })
.block(Block::bordered().title("Titles / episodes")),
list_area,
);
let block = Block::bordered().title(crate::loading_title(
title,
"",
self.intent.is_some().then(|| ui::spinner(elapsed)),
));
if self.screen().rows.is_empty() {
let empty = if let Some(error) = &self.screen().error {
format!(
"{error}\n\n{} refreshes; {} searches the catalog.",
self.config.keys.label(Action::ViewRefresh),
self.config.keys.label(Action::SearchEdit)
)
} else if self.intent.is_some() {
"Loading Miruro metadata…".into()
} else {
let mut selection = std::mem::take(&mut self.screen_mut().selection);
frame.render_stateful_widget(
List::new(self.rows())
.block(Block::bordered().title(if self.description_focus {
"Titles / episodes"
} else {
"Titles / episodes · focused"
}))
.highlight_symbol("› ")
.highlight_style(Style::new().fg(Color::Cyan).bold()),
list_area,
&mut selection,
);
self.screen_mut().selection = selection;
}
match &self.screen().content {
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result to browse its actual returned episodes.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
Content::Collection { .. } => "No Miruro episodes in this collection/filter. Save episodes from a series; YouTube entries are not shown here.".into(),
}
};
frame.render_widget(
Paragraph::new(empty).wrap(Wrap { trim: true }).block(block),
content,
);
} else {
let mut selection = std::mem::take(&mut self.screen_mut().selection);
frame.render_stateful_widget(
List::new(self.rows())
.block(block)
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
content,
&mut selection,
);
self.screen_mut().selection = selection;
}
if let Some(metadata_area) = metadata_area {
let block = Block::bordered().title(if self.description_focus {
"Synopsis / metadata · focused"
} else {
"Synopsis / metadata"
});
let inner = block.inner(metadata_area);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
.block(Block::bordered().title(" Status ")),
status,
);
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
let popup = crate::preview_area(area);
if self.description_focus {
let block = Block::bordered()
.title(" Description ")
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0));
let mut metadata = ratatui::text::Text::from(self.screen().description.as_str());
if let Some(key) = self.screen().selected_key() {
metadata.lines.push(Line::from(self.markers(key)));
}
let paragraph = Paragraph::new(metadata).wrap(Wrap { trim: true });
let max_scroll = paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16;
let max_scroll = if inner.is_empty() {
0
} else {
paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16
};
let scroll = self.screen().description_scroll.min(max_scroll);
frame.render_widget(paragraph.scroll((scroll, 0)).block(block), metadata_area);
frame.render_widget(Clear, popup);
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
frame.render_widget(block, popup);
self.screen_mut().description_scroll = scroll;
}
let status_text = if self.intent.is_some() {
format!("{} · loading…", self.status)
} else {
self.status.clone()
};
frame.render_widget(
Paragraph::new(status_text).wrap(Wrap { trim: true }),
status,
);
frame.render_widget(
Paragraph::new(self.hints()).wrap(Wrap { trim: true }).dim(),
hints,
);
if let Some((kind, ids)) = &self.removal {
frame.render_widget(ratatui::widgets::Clear, content);
frame.render_widget(Paragraph::new(format!("Remove {} Miruro episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other collections, YouTube entries and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), collection_name(*kind))).wrap(Wrap { trim: true }).block(Block::bordered().title("Confirm removal")), content);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(format!("Remove {} Miruro episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other collections, YouTube entries and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), collection_name(*kind)))
.wrap(Wrap { trim: true })
.block(
Block::bordered()
.title(" Remove collection ")
.border_style(Style::new().fg(Color::Yellow)),
),
popup,
);
} else if let Some(scroll) = self.help {
let mut lines = vec![
Line::from("Miruro catalog → series → actual episodes").bold(),
@@ -1167,17 +1191,24 @@ impl Browser {
lines.extend(self.bindings().into_iter().map(|(action, label)| {
Line::from(format!("{} {label}", self.config.keys.label(action)))
}));
frame.render_widget(ratatui::widgets::Clear, content);
let block = Block::bordered().title("Miruro keyboard help");
let inner = block.inner(content);
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(" Miruro keyboard help ")
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(Margin::new(1, 0));
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
let max_scroll = paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16;
let max_scroll = if inner.is_empty() {
0
} else {
paragraph
.line_count(inner.width)
.saturating_sub(usize::from(inner.height))
.min(u16::MAX as usize) as u16
};
let scroll = scroll.min(max_scroll);
self.help = Some(scroll);
frame.render_widget(paragraph.scroll((scroll, 0)).block(block), content);
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
frame.render_widget(block, popup);
}
}
}
@@ -1346,9 +1377,10 @@ pub fn run(
SetCursorStyle::SteadyBlock
)?;
let mut browser = Browser::new(options, config_path, config, state);
let started = Instant::now();
loop {
browser.update();
terminal.draw(|frame| browser.draw(frame))?;
terminal.draw(|frame| browser.draw(frame, started.elapsed()))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
@@ -1460,6 +1492,60 @@ mod tests {
.collect()
}
#[test]
fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() {
use ratatui::{Terminal, backend::TestBackend};
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
browser
.screen_mut()
.overview
.push_str(&"\nMore synopsis".repeat(40));
browser.screen_mut().overview.push_str("\nTAIL");
browser.screen_mut().update_description();
let selected = browser.screen().selected_video().unwrap().id;
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
let popup = crate::preview_area(terminal.backend().buffer().area);
let popup_text = |terminal: &Terminal<TestBackend>| -> String {
(popup.y + 1..popup.bottom() - 1)
.flat_map(|y| {
(popup.x + 1..popup.right() - 1)
.map(move |x| terminal.backend().buffer()[(x, y)].symbol())
})
.collect()
};
assert!(popup_text(&terminal).contains("Episode 3"));
browser.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
assert_eq!(browser.screen().description_scroll, 1);
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = u16::MAX;
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
assert!(popup_text(&terminal).contains("TAIL"));
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!browser.description_focus);
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = 0;
}
browser.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(
browser.screen().selected_video().unwrap().id,
"miruro:first:7"
);
}
#[test]
fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes() {
let directory = Directory::new();
+52
View File
@@ -0,0 +1,52 @@
use std::time::Duration;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::Line,
widgets::{Block, Paragraph},
};
pub const ACCENT: Style = Style::new().fg(Color::Cyan);
pub const SELECTED: Style = Style::new().fg(Color::Black).bg(Color::Cyan);
pub const SELECTION_MARKER: &str = "> ";
pub fn browser_layout(area: Rect, header_height: u16) -> [Rect; 5] {
Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(area)
}
pub fn search(frame: &mut Frame, area: Rect, title: &str, query: &str, editing: bool) {
let block =
Block::bordered()
.title(title)
.border_style(if editing { ACCENT } else { Style::new() });
let inner = block.inner(area);
let query = Line::raw(query);
let width = query.width();
let scroll = width.saturating_sub(usize::from(inner.width.saturating_sub(1)));
frame.render_widget(
Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(block),
area,
);
if editing && !inner.is_empty() {
let cursor = width
.saturating_sub(scroll)
.min(usize::from(inner.width - 1));
frame.set_cursor_position((inner.x + cursor as u16, inner.y));
}
}
pub fn spinner(elapsed: Duration) -> &'static str {
const FRAMES: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
FRAMES[(elapsed.as_millis() / 100 % FRAMES.len() as u128) as usize]
}