//! Picking a title's provider entry by hand. //! //! The matcher is deliberately unwilling to guess — see //! [`pstr_meta::matching::MATCH_FLOOR`], and the reasoning that a wrong poster //! does not look like a bug but like the library being wrong. The cost of that //! is a handful of titles it will not decide about, and this dialog is where //! they get decided: the provider's own answers, unscored and in its own order, //! and a click that pins one of them. //! //! Nothing here scores anything. A person reading a list of eight entries with //! their posters next to them is not guessing, which is exactly why the floor //! that stops the scorer should not also stop them. use pstr_core::metadata::{ProviderId, TitleMetadata}; use crate::app::{Action, Matcher}; use crate::theme; use crate::ui::{self, Art}; /// How tall the result list may get before it scrolls. Enough for four rows, /// which is what a page of provider answers usually needs. const RESULTS_HEIGHT: f32 = 340.0; /// A result row's poster, at the shape a cover image actually is. const THUMB: egui::Vec2 = egui::vec2(46.0, 69.0); pub fn show( ctx: &egui::Context, matcher: &mut Matcher, art: &mut Art<'_>, provider: ProviderId, actions: &mut Vec, ) { // What the title is matched to now, cloned out before `art` is borrowed for // the rows below. let current = art .metadata .get(&matcher.title_key) .map(|record| (record.metadata.clone(), record.manual)); let modal = egui::Modal::new(egui::Id::new("matcher")) .frame( egui::Frame::new() .fill(theme::surface()) .inner_margin(egui::Margin::same(18)) .corner_radius(egui::CornerRadius::same(theme::radius::LG)), ) .show(ctx, |ui| { ui.set_width(620.0); ui.label( theme::Role::Heading .rich(format!("Match “{}”", matcher.title_name)) .strong() .color(theme::text()), ); ui.label(ui::muted(format!( "Everything {} answers with, in its own order — nothing is scored here.", provider.label() ))); ui.add_space(theme::space::L); search_row(ui, matcher, actions); ui.add_space(theme::space::S); current_row(ui, matcher, current.as_ref(), actions); ui.add_space(theme::space::L); if let Some(error) = &matcher.error { ui.label(theme::Role::Caption.rich(error).color(theme::danger())); ui.add_space(theme::space::S); } results(ui, matcher, art, actions); ui.add_space(theme::space::L); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Close").clicked() { actions.push(Action::CloseMatcher); } }); }); }); // Escape, a click on the backdrop, or the close button above: all of them // mean the same thing, and the dialog changes nothing until a row is // clicked, so leaving it needs no confirmation. if modal.should_close() { actions.push(Action::CloseMatcher); } } /// The box and the button. Enter searches, because a search box that ignores it /// is a search box people think is broken. fn search_row(ui: &mut egui::Ui, matcher: &mut Matcher, actions: &mut Vec) { ui.horizontal(|ui| { let box_width = ui.available_width() - 90.0; let field = ui.add( egui::TextEdit::singleline(&mut matcher.query) .hint_text("Title to search for") .desired_width(box_width.max(120.0)), ); if std::mem::take(&mut matcher.focus) { field.request_focus(); } let entered = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)); if matcher.searching { ui.add(egui::Spinner::new().size(16.0)); } else if (ui.button("Search").clicked() || entered) && !matcher.query.trim().is_empty() { actions.push(Action::SearchMatches); } }); } /// What the title is matched to now, and the way back to no match at all. fn current_row( ui: &mut egui::Ui, matcher: &Matcher, current: Option<&(Option, bool)>, actions: &mut Vec, ) { ui.horizontal(|ui| { match current { Some((Some(found), manual)) => { ui.label(ui::muted(format!( "Currently {}{}", found.name, if *manual { ", chosen by hand" } else { "" } ))); // Only worth offering when there is something to undo: with no // row stored, "clear" would delete nothing. // // A match chosen by hand is asked about first: it is work the // next match run does not redo. let asking = ui.id().with(("forget", &matcher.title_key)); if ui.data(|data| data.get_temp::(asking).unwrap_or(false)) { ui.label(ui::muted("Forget the match you chose?")); if ui::danger_button(ui, "Forget").clicked() { ui.data_mut(|data| data.remove::(asking)); actions.push(Action::ForgetMatch(matcher.title_key.clone())); } if ui.button("Keep").clicked() { ui.data_mut(|data| data.remove::(asking)); } } else if ui .button("Clear") .on_hover_text("Forget this match and let the next match run decide again") .clicked() { if *manual { ui.data_mut(|data| data.insert_temp(asking, true)); } else { actions.push(Action::ForgetMatch(matcher.title_key.clone())); } } } Some((None, _)) => { ui.label(ui::muted("Currently unmatched — the provider had nothing.")); if ui .button("Clear") .on_hover_text("Forget the stored miss so this title is asked about again") .clicked() { actions.push(Action::ForgetMatch(matcher.title_key.clone())); } } None => { ui.label(ui::muted("Not matched yet.")); } }; }); } /// The provider's answers, one clickable row each. fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mut Vec) { if matcher.results.is_empty() { ui.label(ui::muted(if matcher.searching { "Searching…" } else if matcher.asked { // Both providers match on whole words, so a name the index has never // seen empties the page rather than returning something near it. "Nothing came back. Try a shorter name, or the title in another language." } else { "Search to see what the provider has." })); return; } egui::ScrollArea::vertical() .max_height(RESULTS_HEIGHT) .auto_shrink([false, true]) .show(ui, |ui| { for found in &matcher.results { if row(ui, art, found).clicked() { actions.push(Action::ChooseMatch(Box::new(found.clone()))); } ui.add_space(theme::space::XS); } }); } /// One answer: its cover, its names, and enough of its synopsis to tell two /// entries in the same franchise apart — which is the whole job of this list. fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response { let response = egui::Frame::new() .fill(theme::card()) .corner_radius(egui::CornerRadius::same(theme::radius::SM)) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal_top(|ui| { let (rect, _) = ui.allocate_exact_size(THUMB, egui::Sense::hover()); if let Some(texture) = poster(art, found) { ui.painter().image( texture.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } else { ui.painter().rect_filled( rect, egui::CornerRadius::same(theme::radius::BADGE), theme::card_hover(), ); } ui.add_space(theme::space::L); ui.vertical(|ui| { ui.label( theme::Role::Body .rich(&found.name) .strong() .color(theme::text()), ); if let Some(original) = &found.original_name { ui.label(ui::muted(original.clone())); } ui.label(ui::muted(facts(found))); if let Some(overview) = &found.overview { let mut job = egui::text::LayoutJob::simple( overview.replace('\n', " "), theme::Role::Caption.font(), theme::muted(), ui.available_width(), ); job.wrap.max_rows = 2; job.wrap.overflow_character = Some('…'); ui.label(job); } }); }); }) .response; let response = response.interact(egui::Sense::click()); if response.hovered() { ui.painter().rect_stroke( response.rect, egui::CornerRadius::same(theme::radius::SM), egui::Stroke::new(1.5, theme::accent()), egui::StrokeKind::Inside, ); } response.on_hover_cursor(egui::CursorIcon::PointingHand) } /// The cover for one answer, cached under the provider's id for it rather than /// under a title key — these are not any title's artwork until one is picked. fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option { let url = found .poster_url .clone() .or_else(|| found.backdrop_url.clone())?; let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id); let engine = art.engine; art.posters .texture(&key, || engine.request_poster(key.clone(), url)) } /// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out /// simply absent. fn facts(found: &TitleMetadata) -> String { let mut parts: Vec = vec![ match found.kind { pstr_core::library::TitleKind::Series => "Series", pstr_core::library::TitleKind::Film => "Film", } .to_string(), ]; if let Some(year) = found.year { parts.push(year.to_string()); } if let Some(episodes) = found.episodes { parts.push(ui::library::plural(episodes as usize, "episode")); } if let Some(rating) = found.rating { parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); } parts.join(" · ") }