Files
ElyOffice/apps/ely-show/src/deck.rs
T

459 lines
18 KiB
Rust

//! 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<Deck>,
pub dialog: Option<crate::dialog::Dialog>,
pub strip: Entity<Strip>,
pub panel: Entity<Panel>,
/// The kernel's own blank deck, written once.
pub blank: Option<PathBuf>,
/// The kernel's font families; read once.
pub fonts: Vec<SharedString>,
reading_fonts: bool,
pub guide_drag: Option<crate::guides::Drag>,
pub notes: crate::notes::NotesField,
/// The right column shows comments, not properties.
pub remarks_open: bool,
pub preview: Option<crate::preview::Preview>,
/// Counts preview requests; only the last may play.
pub preview_asked: u64,
pub probe: Option<crate::media::Probe>,
pub playing: Option<crate::media::Playing>,
pub show: Option<Entity<crate::slideshow::Show>>,
/// 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<std::mem::Discriminant<crate::dialog::Dialog>>, Option<u64>),
pub active_remark: Option<SharedString>,
pub remarks_pane: Entity<crate::remarks::Pane>,
/// What the menus last checked: grid, guides, comments.
pub snap_menus: Option<(bool, bool, bool)>,
/// 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> {
&self.core
}
fn core_mut(&mut self) -> &mut Core<Self> {
&mut self.core
}
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, _: &mut Context<Self>) {
self.retire_tab(tab);
}
fn read_at(&self, tab: u64, _: &gpui::App) -> Option<ely_lok::Doc> {
self.tab(tab).and_then(|tab| tab.extra.read_at.clone())
}
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
// 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));
}
// 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.reread(id, view, cx);
}
}
/// ElyShow's menus after File.
pub fn menus(on: (bool, bool, bool), cx: &App) -> Vec<gpui::Menu> {
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<Root> {
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);
<Deck as ely_office::Autosaving>::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<Mutex<Worker>>, work: PathBuf, cx: &mut Context<Self>) -> 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)),
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<LokView>, cx: &mut Context<Self>) {
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());
view.update(cx, |view, _| 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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
self.paint_next(id, cx);
}
fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) -> 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));
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;
}