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
+3
View File
@@ -0,0 +1,3 @@
# Revision descriptions
Use concise revision descriptions without a `(TODO.md)` suffix. Include a ticket ID only when the task has a real ticket.
+5 -3
View File
@@ -43,6 +43,8 @@ Startup shows a centered **YouTube / Miruro** selector. Use `j` / `k` or arrows
After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting.
Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and two-line title/episode rows rather than YouTube's video table.
Selecting a YouTube video launches:
```sh
@@ -56,7 +58,7 @@ Each selection opens an independent player instance without replacing existing p
Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, without YouTube's Search/Feed tabs, channel controls or category selectors.
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; the metadata pane shows the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata pane, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available.
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for the selected title's synopsis and other returned facts. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first in the metadata popup, above the series synopsis. Listed counts do not guarantee that a supported Sun source is available.
| Key | Miruro action |
| --- | --- |
@@ -68,8 +70,8 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit
| `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes |
| `f` | Cycle All / Unwatched / Watched in watch later |
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection |
| `d` | Focus/unfocus the synopsis/metadata pane; `j` / `k` and page keys scroll while focused |
| Esc | Cancel input/help or return focus to the episode/title list |
| `d` | Show/hide the synopsis/metadata popup; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll; `j` / `k` change selection |
| Esc | Cancel input or close help/metadata and return to the episode/title list |
| `r` | Refresh the active screen and reload configured keys |
| `?` / `q` | Keyboard help / quit |
+2
View File
@@ -433,6 +433,8 @@ Wrapper-extraction verification, before TUI integration: `cargo test --locked` p
`tubular` offers a centered YouTube/Miruro startup selector; an initial query follows the chosen provider. `tubular --miruro "apothecary"` bypasses it and starts the dedicated Miruro interface directly. `src/miruro_ui.rs` owns catalog → series → episodes navigation, synopsis/metadata presentation, cached screen history and flat episode collections; YouTube retains its separate Search/Feed/category interface. Catalog Enter opens actual returned episodes; episode Enter resolves fresh sources off-thread. Autocomplete is limited to eight titles, without invented pagination. `src/miruro_browser.rs` supplies native catalog/series/episode models and one worker for bounded network work; changing views/queries or episode selections invalidates stale source responses rather than launching obsolete selections. Canonical title/slug rules follow the [deployed helper][link-helpers], including Unicode NFD normalization.
Both browsers share `src/ui.rs` for layout, search editing/cursor scrolling, selection styling and loading animation. Miruro uses the same full-width results/status chrome and centered metadata/help/removal popups as YouTube, while preserving its own catalog/episode rows and navigation. Press `d` for metadata; arrows/Ctrl-j/k scroll it, while plain j/k change the selected row.
Titles normalizing to an empty slug fail explicitly; the wrapper does not guess an alternative detail route. This does not reject accented Latin titles, which use NFD decomposition.
### Source document and launch
+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);
+221 -135
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 [
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"),
]
.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]
],
)
} else if self.input.is_some() {
(
"INSERT",
&[
(Action::InputSubmit, "search"),
(Action::InputCancel, "cancel"),
(Action::DeleteWord, "delete word"),
],
)
} else if self.description_focus {
(
"DESCRIPTION",
&[
Action::InfoHide,
Action::DescriptionNext,
Action::DescriptionPrevious,
Action::DescriptionPageNext,
Action::Help,
]
(Action::InfoHide, "close"),
(Action::Help, "help"),
(Action::DescriptionNext, "down"),
(Action::DescriptionPrevious, "up"),
(Action::DescriptionPageNext, "page down"),
],
)
} else if matches!(self.screen().content, Content::Collection { .. }) {
(
"NORMAL",
&[
Action::VideoPlay,
Action::CollectionRemoveSelected,
Action::CollectionRemoveBulk,
Action::WatchLaterFilter,
Action::InfoOverview,
Action::ViewPrevious,
Action::SearchEdit,
Action::Help,
Action::Quit,
]
(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 {
(
"NORMAL",
&[
(Action::Help, "help"),
(Action::SearchEdit, "search"),
(
Action::VideoPlay,
Action::SearchEdit,
Action::ViewPrevious,
Action::WatchLaterBrowse,
Action::SavedVideoBrowse,
Action::WatchingBrowse,
Action::InfoOverview,
Action::Help,
Action::Quit,
]
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,56 +1052,56 @@ 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")
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(),
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("")
});
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"
})),
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 {
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!(
@@ -1097,64 +1120,65 @@ impl Browser {
}
};
frame.render_widget(
Paragraph::new(empty)
.wrap(Wrap { trim: true })
.block(Block::bordered().title("Titles / episodes")),
list_area,
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::bordered().title(if self.description_focus {
"Titles / episodes"
} else {
"Titles / episodes · focused"
}))
.highlight_symbol("› ")
.highlight_style(Style::new().fg(Color::Cyan).bold()),
list_area,
.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
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;
.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
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;
.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]
}