//! What may go wrong in a show, and where (SHOW-14). use ely_gpui_component::{ lists::{ListItem, SelectableList}, overlays::Dialog as Card, primitives::Severity, }; use ely_lok::show::{Check, PageCheck, ShowSlide}; use ely_office::prelude::*; use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Window, div}; use crate::deck::Deck; use crate::dialog::Dialog; /// What a finding is. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Problem { Font, Media, /// A linked file; `missing` says if it is gone. Link, /// Something the kernel cannot show. Object, } /// One thing to fix, and where it is. #[derive(Clone, Debug, PartialEq)] pub struct Finding { pub problem: Problem, pub name: String, pub missing: bool, /// Slide indexes from zero. pub slides: Vec, pub masters: Vec, } /// Every page's checks: slides by index, masters by name. fn pages(check: &Check) -> impl Iterator, &PageCheck)> { let slides = check.slides.iter().enumerate().map(|(at, page)| (Ok(at), page)); slides.chain(check.masters.iter().map(|(name, page)| (Err(name.as_str()), page))) } fn add(found: &mut Vec, (problem, name, missing): (Problem, &str, bool), at: Result) { let index = found.iter().position(|f| f.problem == problem && f.name == name); let finding = match index { Some(index) => &mut found[index], None => { found.push(Finding { problem, name: name.to_string(), missing, slides: Vec::new(), masters: Vec::new(), }); found.last_mut().expect("just pushed") } }; match at { Ok(slide) if !finding.slides.contains(&slide) => finding.slides.push(slide), Err(master) if !finding.masters.iter().any(|m| m == master) => { finding.masters.push(master.to_string()) } _ => {} } } /// Missing fonts, missing media, links, then broken objects. pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -> Vec { let mut found = Vec::new(); for font in missing_fonts { let fonts = (Problem::Font, font.as_str(), true); let mut placed = false; for (at, _) in pages(check).filter(|(_, page)| page.fonts.contains(font)) { add(&mut found, fonts, at); placed = true; } // Missing, yet no slide text asks for it. if !placed { found.push(Finding { problem: Problem::Font, name: font.clone(), missing: true, slides: Vec::new(), masters: Vec::new(), }); } } for (at, slide) in slides.iter().enumerate() { for media in &slide.media { match (media.missing, media.link) { (true, _) => add(&mut found, (Problem::Media, &media.url, true), Ok(at)), (false, true) => add(&mut found, (Problem::Link, &media.url, false), Ok(at)), (false, false) => {} } } } for (at, page) in pages(check) { for (url, missing) in &page.links { add(&mut found, (Problem::Link, url, *missing), at); } } for (at, page) in pages(check) { for name in &page.broken { add(&mut found, (Problem::Object, name, true), at); } } found } impl Deck { /// Shows a finding's slide; master view numbers masters instead. pub(crate) fn go_to_finding(&mut self, part: usize, cx: &mut Context) { let Some(tab) = self.active_tab() else { return; }; if tab.extra.masters { let text = Deck::text(cx, "show.masters.busy", &[]); return self.toast(text, Severity::Warning, cx); } let view = tab.view.clone(); view.update(cx, |view, _| view.show_part(part)); self.dialog = None; cx.notify(); } /// Reads every slide and master, then lists what to fix. pub fn check_show(&mut self, cx: &mut Context) { let Some(tab) = self.active_tab() else { let text = Deck::text(cx, "command.blocked.no_document", &[]); return self.toast(text, Severity::Warning, cx); }; let (id, missing) = (tab.id, tab.view.read(cx).missing_fonts().to_vec()); let asked = self.ask_dialog(); log::info!("show: checks the deck"); self.read_in( id, |worker, doc| { let check = ely_lok::show::check(&worker.values(doc, ely_lok::show::SLIDE_CHECK)?)?; let slides = ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?)?; Ok((check, slides)) }, move |deck, read, _, cx| match read { Ok((check, slides)) if deck.dialog_due(asked, id) => { let found = findings(&check, &slides, &missing); log::info!("show: the check found {} things", found.len()); // Refused while another dialog is open; it says so. let _ = deck.open_dialog(Dialog::Check(found), cx); } Ok(_) => log::info!("show: the check is no longer due"), Err(error) => deck.engine_failed(&format!("the check: {error}"), cx), }, cx, ); } /// The findings; a row goes to its first slide. pub fn check_dialog(&self, found: &[Finding], cx: &mut Context) -> AnyElement { let this = cx.entity(); let text = |key: &str| Deck::text(cx, key, &[]); let closing = this.clone(); let dismiss = move |_: &mut Window, cx: &mut App| { closing.update(cx, |deck, cx| { deck.dialog = None; cx.notify(); }) }; let card = Card::new("show-check", text("show.check.title"), dismiss); if found.is_empty() { return card.child(div().child(text("show.check.clear"))).into_any_element(); } let mut list = SelectableList::new("show-check-list").label(text("show.check.title")); for (at, finding) in found.iter().enumerate() { let kind = match (finding.problem, finding.missing) { (Problem::Font, _) => "show.check.font", (Problem::Media, _) => "show.check.media", (Problem::Link, true) => "show.check.link_missing", (Problem::Link, false) => "show.check.link", (Problem::Object, _) => "show.check.object", }; let mut places: Vec = (finding.slides.iter()) .map(|slide| Deck::text(cx, "show.check.slide", &[("n", &(slide + 1).to_string())]).to_string()) .collect(); places.extend(finding.masters.iter().map(|name| { Deck::text(cx, "show.check.master", &[("name", name)]).to_string() })); let name = match finding.name.is_empty() { true => text("show.check.unnamed"), false => SharedString::from(finding.name.clone()), }; let place = match places.is_empty() { true => text("show.check.nowhere").to_string(), false => places.join(", "), }; let item = ListItem::new(SharedString::from(format!("show-check-{at}")), name) .description(format!("{} ยท {place}", text(kind))); list = list.row(SharedString::from(at.to_string()), item); } let firsts: Vec> = found.iter().map(|f| f.slides.first().copied()).collect(); let list = list.on_change(move |keys, _, cx| { let first = keys.first().and_then(|key| key.parse::().ok()).and_then(|at| firsts[at]); if let Some(part) = first { this.update(cx, |deck, cx| deck.go_to_finding(part, cx)); } }); card.child(list).into_any_element() } } #[cfg(test)] mod tests { use super::*; use ely_lok::show::{Media, Transition}; fn slide(media: Vec) -> ShowSlide { ShowSlide { hidden: false, notes: String::new(), transition: Transition { preset: String::new(), duration: 0.0, auto: false, time: 0.0, }, effects: Vec::new(), media, } } fn media(url: &str, link: bool, missing: bool) -> Media { Media { index: 0, url: url.into(), link, file: url.into(), missing, repeat: false, bounds: (0, 0, 1, 1), } } #[test] fn findings_say_what_and_where_in_order() { let page = |fonts: &[&str], links: &[(&str, bool)], broken: &[&str]| PageCheck { fonts: fonts.iter().map(|f| f.to_string()).collect(), links: links.iter().map(|(u, m)| (u.to_string(), *m)).collect(), broken: broken.iter().map(|b| b.to_string()).collect(), }; let check = Check { slides: vec![ page(&["Lost", "Inter"], &[("file:///a.png", true)], &[]), page(&["Lost"], &[], &["Chart 1"]), ], masters: vec![("Plain".into(), page(&["Lost"], &[("file:///a.png", true)], &[]))], }; let slides = [slide(vec![media("file:///clip.mp4", false, true)]), slide(vec![media("file:///b.mp3", true, false)])]; let missing = ["Lost".to_string(), "Gone".to_string()]; let found = findings(&check, &slides, &missing); let short: Vec<(Problem, &str, Vec, Vec)> = found .iter() .map(|f| (f.problem, f.name.as_str(), f.slides.clone(), f.masters.clone())) .collect(); assert_eq!( short, [ (Problem::Font, "Lost", vec![0, 1], vec!["Plain".to_string()]), (Problem::Font, "Gone", vec![], vec![]), (Problem::Media, "file:///clip.mp4", vec![0], vec![]), (Problem::Link, "file:///b.mp3", vec![1], vec![]), (Problem::Link, "file:///a.png", vec![0], vec!["Plain".to_string()]), (Problem::Object, "Chart 1", vec![1], vec![]), ] ); assert!(found[4].missing && !found[3].missing); } }