//! PDFs in the shared shell, one worker behind (PDF-01). use std::{ path::PathBuf, sync::{Arc, Mutex}, }; use ely_gpui_component::{ feedback::EmptyState, primitives::IconName, shell::{StatusBar, StatusBarItem}, }; use ely_office::{Core, Host, Kind, prelude::*}; use ely_platform::AppId; use ely_ui::{Root, Shell, commands}; use gpui::{ App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, Window, div, }; use crate::engine::{Mupdf, Pdf}; use crate::view::PdfView; pub struct Viewer { pub core: Core, pub side: Entity, /// Crop or split, while asked. pub ask: Option, pub edit_panel: Entity, pub note_panel: Entity, /// A layout change shown before it is made. pub preview: Option, /// Preview pictures no longer drawn, freed next render. pub retired: ely_gpui_component::primitives::Retired, pub find: Option, /// A bookmark title being asked. pub naming: Option, pub props: Option, pub ocr: Option, /// OCR runs started so far. pub ocr_runs: u64, pub removal: Option, pub secure: Option, pub signer: Option, pub making: Option, pub signs_open: bool, pub exporting: Option, pub repair_open: bool, /// Find boxes opened so far. pub finds: u64, } impl Host for Viewer { type Extra = (); type Engine = Pdf; const KIND: Kind = Kind { app: AppId::Pdf, kept: &["pdf"], save_as: &["pdf"], macros: None, }; fn core(&self) -> &Core { &self.core } fn core_mut(&mut self) -> &mut Core { &mut self.core } fn read_at(&self, tab: u64, cx: &gpui::App) -> Option { self.tab(tab).map(|tab| tab.view.read(cx).doc.clone()) } fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { // Damage found while drawing: no overwrite from now on. if view.read(cx).damage_seen { view.update(cx, |view, _| view.damage_seen = false); if let Some(tab) = self.tab_mut(id) { log::warn!("pdf: tab {id} turned out damaged"); tab.risk = Some(ely_office::Risk::Repaired); } } // Words found before an edit are searched again. let version = view.read(cx).version; if self.find.as_ref().is_some_and(|find| find.tab == id && find.version != version) { self.search(cx); } // A preview from before an edit is void. if self.preview.as_ref().is_some_and(|preview| preview.tab == id && preview.version != version) { self.end_preview(); } cx.notify(); } } /// The window root with no document. pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { crate::keys::bind(cx); let work = ely_platform::data_dir(app) .map_err(std::io::Error::other) .and_then(|data| ely_office::fresh_work(&data)); let work = match work { Ok(work) => work, Err(error) => ely_platform::startup::fail(app, &format!("{error}")), }; // The worker starts with the first file (PERF-01). let viewer = cx.new(|cx| { let mut host = Viewer::new(Arc::default(), work, cx); // Recovery points, crash return, autosave (§6.2, §6.3). ely_office::Recovering::start_recovery(&mut host, cx); ::start_autosave(cx); host }); let root = ely_ui::document_root(app, viewer.clone().into(), &commands::FILE, crate::keys::menus(cx), window, cx); viewer.update(cx, |viewer, cx| { viewer.core.root = root.downgrade(); viewer.own_window(window, cx); window.focus(&viewer.core.focus, cx); }); for path in ely_ui::launch_files(cx) { viewer.update(cx, |viewer, cx| viewer.open_file(path, cx)); } let opening = viewer.clone(); ely_ui::on_open(cx, move |path, cx| { opening.update(cx, |viewer, cx| viewer.open_file(path, cx)) }); let closing = viewer.clone(); window.on_window_should_close(cx, move |window, cx| { closing.update(cx, |viewer, cx| viewer.should_close(window, cx)) }); root } impl Viewer { pub fn new(worker: Arc>, work: PathBuf, cx: &mut Context) -> Self { let (this, weak) = (cx.entity(), cx.weak_entity()); Viewer { core: Core::new(worker, work, cx), side: cx.new(|cx| crate::side::Side::new(&this, cx)), ask: None, edit_panel: cx.new(|_| crate::editing::EditPanel::new(weak.clone())), note_panel: cx.new(|_| crate::notes::NotePanel::new(weak.clone())), preview: None, retired: Default::default(), find: None, naming: None, props: None, ocr: None, ocr_runs: 0, removal: None, secure: None, signer: None, making: None, signs_open: false, exporting: None, repair_open: false, finds: 0, } } pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { ely_ui::i18n::text(cx, key, args) } /// The active tab's view. pub fn active_view(&self) -> Option> { self.active_tab().map(|tab| tab.view.clone()) } /// Runs `change` on the active view. pub fn on_view(&self, change: impl FnOnce(&mut PdfView, &mut Context), cx: &mut App) { if let Some(view) = self.active_view() { view.update(cx, change); } } /// Asks for files; each opens in its own tab. pub fn open_picked(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context) { let picked = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, multiple: true, prompt: None, }); cx.spawn(async move |this, cx| { let picked = match picked.await { Ok(Ok(paths)) => Ok(paths), Ok(Err(error)) => Err(error.to_string()), Err(error) => Err(error.to_string()), }; let done = this.update(cx, |viewer, cx| match picked { Ok(Some(paths)) => paths.into_iter().for_each(|path| viewer.open_file(path, cx)), Ok(None) => log::info!("pdf: the open panel was cancelled"), Err(error) => viewer.panel_failed(&error, cx), }); if done.is_err() { log::info!("pdf: the viewer closed before the panel"); } }) .detach(); } fn status(&self, cx: &mut Context) -> StatusBar { let state = StatusBarItem::new("pdf-state").label(self.save_state(cx)); let Some(view) = self.active_view() else { return StatusBar::new().left(state); }; let view = view.read(cx); let (n, count) = ((view.current + 1).to_string(), view.sizes.len().to_string()); let page = Self::text(cx, "pdf.status.page", &[("n", &n), ("count", &count)]); StatusBar::new().left(state).left(StatusBarItem::new("pdf-page").label(page)) } } impl Render for Viewer { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.retired.release(window); let title = self.active_tab().map_or_else(|| AppId::Pdf.name().into(), |tab| tab.name.clone()); let body = match self.active_view() { Some(view) => view.into_any_element(), None => EmptyState::new("pdf-empty", IconName::File, Self::text(cx, "command.blocked.no_document", &[])).into_any_element(), }; let shell = Shell::new(title, &self.core.focus) .tabs(self.tab_strip(cx)) .left(self.active_view().map(|_| self.side.clone().into())) .right(self.active_view().and_then(|view| view.read(cx).panel_aim()).map(|aim| match aim { crate::content::Aim::Note(_) => self.note_panel.clone().into(), _ => self.edit_panel.clone().into(), })) .status(self.status(cx)) .children(self.file_banner(cx)) .children(self.find_bar(cx)) .child(body); // Esc leaves a tool from pages and panel alike. let editing = self.active_view().is_some_and(|view| view.read(cx).tool != crate::content::Tool::Read); let context = match editing { true => format!("{} {}", commands::CONTEXT, crate::writes::EDITING), false => commands::CONTEXT.to_string(), }; let root = div() .size_full() .key_context(context.as_str()) .track_focus(&self.core.focus) .on_action(cx.listener(Self::open_picked)) .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.ask_element(cx)) .children(self.preview_element(cx)) .children(self.naming_element(cx)) .children(self.props_element(cx)) .children(self.ocr_element(cx)) .children(self.removal_element(cx)) .children(self.secure_element(cx)) .children(self.sign_element(cx)) .children(self.visible_element(cx)) .children(self.signs_element(cx)) .children(self.export_element(cx)) .children(self.repair_element(cx)); let root = crate::writes::on(crate::organize::on(crate::keys::on(root, cx), cx), cx); crate::document::on(crate::marking::on(root, cx), cx) } }