//! ElyShow's window: decks over one LOK worker (§10.1). use std::{ collections::HashSet, path::PathBuf, sync::{Arc, Mutex}, }; use ely_gpui_component::{feedback::EmptyState, primitives::IconName, shell::StatusBar}; use ely_lok::{ LokView, Worker, show::Design, }; use ely_office::{Core, Kind, prelude::*}; use ely_platform::AppId; use ely_ui::{Root, Shell, commands}; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, }; use crate::design::Panel; use crate::slides::Strip; pub use crate::extra::Extra; pub struct Deck { pub core: Core, pub dialog: Option, pub strip: Entity, pub panel: Entity, /// The kernel's own blank deck, written once. pub blank: Option, /// The kernel's font families; read once. pub fonts: Vec, reading_fonts: bool, pub guide_drag: Option, pub notes: crate::notes::NotesField, /// The right column shows comments, not properties. pub remarks_open: bool, pub preview: Option, /// Counts preview requests; only the last may play. pub preview_asked: u64, pub probe: Option, pub playing: Option, pub show: Option>, /// A show is being read or its screens picked. pub show_starting: bool, /// Shows started; each copy has its own file. pub shows: u64, /// Reads that end in a dialog; only the last opens. pub dialog_asked: u64, /// Dialog kind and tab last seen; a change ends asks. pub dialog_seen: (Option>, Option), pub active_remark: Option, pub remarks_pane: Entity, /// What the menus last checked: grid, guides, comments. pub snap_menus: Option<(bool, bool, bool)>, /// The canvas's right-click menu. pub menu: Option>, /// Pictures of closed tabs and ended previews, freed later. pub retired: ely_gpui_component::primitives::Retired, } impl Host for Deck { type Extra = Extra; type Engine = ely_office::Lok; const KIND: Kind = Kind { app: AppId::Show, kept: &["pptx", "odp", "ppt", "fodp"], save_as: &["pptx", "odp"], macros: Some(&["pptm", "potm", "ppsm"]), }; fn core(&self) -> &Core { &self.core } fn core_mut(&mut self) -> &mut Core { &mut self.core } fn tab_closed(&mut self, tab: &ely_office::Tab, _: &mut Context) { self.retire_tab(tab); } fn read_at(&self, tab: u64, _: &gpui::App) -> Option { self.tab(tab).and_then(|tab| tab.extra.read_at.clone()) } fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { // A new deck fits once; a zoom then stays. if let Some(tab) = self.tab_mut(id) && !tab.extra.fitted { tab.extra.fitted = true; view.update(cx, |view, cx| { view.fit_page(cx); view.turn_parts(); }); } // Pictures follow the kernel's light or dark scheme. let dark = view.read(cx).dark(); if let Some(tab) = self.tab_mut(id) && tab.extra.dark != dark { tab.extra.dark = dark; self.repaint_all(id, cx); } self.offer_menu(id, view, cx); self.reread(id, view, cx); } } /// ElyShow's menus after File. pub fn menus(on: (bool, bool, bool), cx: &App) -> Vec { let [insert, view] = crate::insert::menus(on, cx); vec![crate::slides::menu(cx), insert, view, crate::slideshow::menu(cx)] } /// The window root with a blank deck. pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { ely_lok::init(cx); let (worker, work) = match ely_office::start(app, cx) { Ok(started) => started, Err(error) => ely_platform::startup::fail(app, &format!("{error}")), }; let deck = cx.new(|cx| { let mut host = Deck::new(worker, work, cx); // Recovery points, crash return, autosave (§6.2, §6.3). ely_office::Recovering::start_recovery(&mut host, cx); ::start_autosave(cx); host }); crate::slideshow::bind_keys(cx); ely_office::bind_zoom_keys(cx); let menus = crate::deck::menus((false, false, false), cx); let root = ely_ui::document_root(app, deck.clone().into(), &ely_ui::commands::FILE, menus, window, cx); deck.update(cx, |deck, cx| { deck.core.root = root.downgrade(); deck.own_window(window, cx); window.focus(&deck.core.focus, cx); }); let launched = ely_ui::launch_files(cx); deck.update(cx, |deck, cx| match launched.is_empty() { true => deck.new_blank(cx), false => launched.into_iter().for_each(|path| deck.open_file(path, cx)), }); let opening = deck.clone(); ely_ui::on_open(cx, move |path, cx| { opening.update(cx, |deck, cx| deck.open_file(path, cx)) }); let closing = deck.clone(); window.on_window_should_close(cx, move |window, cx| { closing.update(cx, |deck, cx| deck.should_close(window, cx)) }); root } impl Deck { pub fn new(worker: Arc>, work: PathBuf, cx: &mut Context) -> Self { let deck = cx.weak_entity(); // Closing or switching tabs stops playback. cx.observe_self(|deck, _| { deck.check_playing(); deck.watch_dialogs(); }) .detach(); Deck { core: Core::new(worker, work, cx), dialog: None, strip: cx.new(|cx| Strip::new(deck.clone(), cx)), panel: cx.new(|cx| Panel::new(deck.clone(), cx)), remarks_pane: cx.new(|_| crate::remarks::Pane { deck }), blank: None, fonts: Vec::new(), reading_fonts: false, guide_drag: None, snap_menus: Some((false, false, false)), menu: None, notes: Default::default(), remarks_open: false, preview: None, preview_asked: 0, probe: None, playing: None, show: None, show_starting: false, shows: 0, dialog_asked: 0, retired: Default::default(), dialog_seen: (None, None), active_remark: None, } } pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { ely_ui::i18n::text(cx, key, args) } /// Rereads slides and design when the kernel moves them. fn reread(&mut self, id: u64, view: &Entity, cx: &mut Context) { let read = view.read(cx); let seen = ( read.cell().sheets, read.edited(), read.caret(), read.graphic(), ); let Some(tab) = self.tab_mut(id) else { return; }; // Numbered commands need the objects read at the latest revision. let behind = tab.extra.read_at.as_ref().is_some_and(|at| view.read(cx).read_at().newer_than(at)); if tab.extra.reading || tab.extra.read == Some(seen) && !behind { return; } // Edits and leaving text edit dirty the picture. if tab .extra .read .is_some_and(|(_, edited, caret, _)| edited != seen.1 || caret && !seen.2) && let Some(slide) = tab.extra.slides.list.get(tab.extra.slides.current) { tab.extra.stale.insert(slide.name.clone()); } (tab.extra.reading, tab.extra.read) = (true, Some(seen)); let landed = tab.extra.transition_landed(view.read(cx)); let sized = tab.extra.size_landed(view.read(cx)); let doc = tab.doc.clone(); ely_lok::read_off( &self.core.worker, &doc, |worker, doc| { let at = worker.now(doc)?; let slides = doc.sheets(worker)?; let design = ely_lok::show::design(&worker.values(doc, ely_lok::show::DESIGN)?)?; let mut steps = |command| ely_lok::history::steps(&worker.values(doc, command)?); let history = (steps(".uno:Undo")?, steps(".uno:Redo")?); let objects = ely_lok::show::objects(&worker.values(doc, ely_lok::show::OBJECTS)?)?; let notes = ely_lok::show::notes(&worker.values(doc, ely_lok::show::NOTES)?)?; let remarks = ely_lok::show::remarks(&worker.values(doc, ".uno:ViewAnnotations")?)?; let media = ely_lok::show::media(&worker.values(doc, ely_lok::show::MEDIA)?)?; let motion = match design.master_view { true => None, false => Some(( ely_lok::show::transition(&worker.values(doc, ely_lok::show::TRANSITION)?)?, ely_lok::show::effects(&worker.values(doc, ely_lok::show::ANIMATIONS)?)?, )), }; Ok(( (slides, at), (design, objects, notes, remarks, (motion, media)), doc.size(worker)?, history, )) }, move |deck: &mut Self, read, cx| { let Some(tab) = deck.tab_mut(id) else { return; }; tab.extra.reading = false; let view = tab.view.clone(); match read { Ok(((slides, at), (design, objects, notes, remarks, (motion, media)), size, history)) => { tab.extra.read_at = Some(at); log::info!("show: tab {id} has {} parts", slides.list.len()); // A queued turn or part outranks an earlier read. view.update(cx, |view, _| { if view.settled() { view.set_part(slides.current) } }); let names: HashSet<&String> = slides.list.iter().map(|slide| &slide.name).collect(); tab.extra.keep_pictures(&names); // The kernel sends no repaint when the mode flips. if tab.extra.masters != design.master_view { log::info!("show: tab {id} shows masters {}", design.master_view); tab.extra.masters = design.master_view; tab.extra.drop_pictures(); view.update(cx, |view, cx| view.invalidate(cx)); } // Size, colors and masters touch every picture. let global = |design: &Design| (design.theme.clone(), design.masters.clone()); // Undo and redo may reach any slide. let undone = tab.extra.history.as_ref().is_some_and(|(undo, redo)| { undo.iter().any(|step| !history.0.contains(step)) || history.0.iter().any(|step| redo.contains(step)) }); if undone { log::info!("show: tab {id} undid or redid; repainting all"); } // Any new step may change the shown slide. let stepped = tab .extra .history .as_ref() .is_some_and(|(undo, _)| *undo != history.0); if let Some(slide) = slides.list.get(slides.current).filter(|_| stepped) { tab.extra.stale.insert(slide.name.clone()); } tab.extra.history = Some(history); if undone || tab.extra.size != size || tab.extra.design.as_ref().map(global) != Some(global(&design)) { tab.extra.stale = tab.extra.thumbs.keys().cloned().collect(); } (tab.extra.slides, tab.extra.design) = (slides, Some(design)); tab.extra.objects = Some(objects); (tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks); (tab.extra.motion, tab.extra.media) = (motion, media); tab.extra.settle_transition(landed); tab.extra.reads += 1; tab.extra.size = size; tab.extra.settle_size(sized); tab.extra.failed.clear(); deck.paint_next(id, cx); deck.read_fonts(id, cx); deck.sync_menus(cx); } Err(error) => deck.engine_failed(&error.to_string(), cx), } cx.notify(); // The kernel may have moved again meanwhile. deck.reread(id, &view, cx); }, cx, ); } /// Reads slides and design again once input lands. pub fn force_reread(&mut self, id: u64, cx: &mut Context) { if !self.settled(id, cx) { return self.when_settled(id, move |deck, _, cx| deck.force_reread(id, cx)); } let Some(tab) = self.tab_mut(id) else { return; }; tab.extra.read = None; let view = tab.view.clone(); self.reread(id, &view, cx); } /// Checks the View menu's snapping to match the shown deck. pub fn sync_menus(&mut self, cx: &mut Context) { let Some(objects) = self.active_tab().and_then(|tab| tab.extra.objects.as_ref()) else { return; }; let on = (objects.snap_grid, objects.snap_guides, self.remarks_open); if self.snap_menus != Some(on) { self.snap_menus = Some(on); ely_ui::set_document_menus(AppId::Show, &ely_ui::commands::FILE, menus(on, cx), cx); } } /// Reads the font families once, off the window. fn read_fonts(&mut self, id: u64, cx: &mut Context) { if !self.fonts.is_empty() || self.reading_fonts { return; } let Some(doc) = self.tab_mut(id).map(|tab| tab.doc.clone()) else { return; }; self.reading_fonts = true; ely_lok::read_off( &self.core.worker, &doc, |worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?), |deck: &mut Self, read, cx| { deck.reading_fonts = false; match read { Ok(fonts) => deck.fonts = fonts.into_iter().map(Into::into).collect(), Err(error) => deck.engine_failed(&format!("the font list: {error}"), cx), } cx.notify(); }, cx, ); } /// Marks every slide's picture stale. pub fn repaint_all(&mut self, id: u64, cx: &mut Context) { let Some(tab) = self.tab_mut(id) else { return; }; tab.extra.stale = tab.extra.thumbs.keys().cloned().collect(); self.paint_next(id, cx); } pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let Some(tab) = self.active_tab() else { let title = match self.core.loading.first() { Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]), None => Self::text(cx, "command.blocked.no_document", &[]), }; return EmptyState::new("show-empty", IconName::Monitor, title).into_any_element(); }; let view = tab.view.clone(); let guides = self.guide_layer(&view, cx); let marks = self.remark_layer(&view, cx); let playing = self.media_layer(&view, window, cx); let preview = self.preview_layer(&view, window, cx); let notes = self.notes_pane(window, cx); div() .size_full() .flex() .flex_col() .children(self.stopped_banner(cx).or_else(|| self.risk_banner(cx))) .child( div() .flex_1() .min_h_0() .relative() .child(view) .children(guides) .children(marks) .children(playing) .children(preview), ) .children(notes) .into_any_element() } } impl Render for Deck { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.release_pictures(window); let title = self .active_tab() .map_or_else(|| AppId::Show.name().into(), |tab| tab.name.clone()); let shown = self.active_tab().is_some(); let state = self.save_state(cx); let status = StatusBar::new() .left(ely_gpui_component::shell::StatusBarItem::new("show-state").label(state)) .when_some(self.zoom_control(cx), |status, zoom| status.right(zoom)); let shell = Shell::new(title, &self.core.focus) .tabs(self.tab_strip(cx)) .left(shown.then(|| self.strip.clone().into())) .right(shown.then(|| match self.remarks_open { true => self.remarks_pane.clone().into(), false => self.panel.clone().into(), })) .status(status) .child(self.body(window, cx)); let root = div() .size_full() .key_context(commands::CONTEXT) .track_focus(&self.core.focus) .on_action(cx.listener(|deck, _: &commands::New, _, cx| deck.ask_new(cx))) .on_action(cx.listener(Self::on_open)) .on_action(cx.listener(Self::on_save)) .on_action(cx.listener(Self::on_save_as)) .on_action(cx.listener(Self::on_export)) .on_action(cx.listener(Self::on_print)) .on_action(cx.listener(Self::on_close_tab)) .child(shell) .children(self.core_dialog(cx)) .children(self.dialog_element(cx)) .children(self.context_menu(cx)); let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx); Self::bind_zoom(root, cx) } } impl Zooming for Deck { const FITS: bool = true; }