feat: unify provider interface design
This commit is contained in:
+262
-176
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user