//! ElyDoc's window: document tabs over one LOK worker (§8.1). use std::{ path::PathBuf, sync::{Arc, Mutex}, }; use ely_lok::{LokError, LokView, Worker, review::Counts}; use ely_office::{Core, Kind, prelude::*}; use ely_platform::AppId; use ely_ui::{Root, Shell, commands}; use gpui::{ App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, }; use crate::actions::Dialog; use crate::files::Template; use crate::props::Props; use crate::review::Review; /// What ElyDoc keeps per tab. #[derive(Default)] pub struct Extra { /// The kernel's counts, read when its word count moves. pub counts: Option, /// The word count state last asked about. pub counted: Option>, /// A count is on its way. pub counting: bool, } pub struct Docs { pub core: Core, pub dialog: Option, pub props: Entity, pub review: Entity, /// What the right column shows. pub side: Side, pub offer: Option, pub merge: Entity, pub navigator: Entity, /// The left column shows the navigator. pub navigating: bool, /// Where jumps left from, latest last. pub back: Vec<(u64, ely_lok::Rect)>, /// Last jump: tab, input, prior cursor moves, target. pub going: Option<(u64, u64, u64, ely_lok::Rect)>, } /// The right column's panels. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Side { Props, Review, Merge, } impl Host for Docs { type Extra = Extra; type Engine = ely_office::Lok; const KIND: Kind = Kind { app: AppId::Doc, kept: &["docx", "odt", "doc", "rtf", "fodt", "txt"], save_as: &["docx", "odt"], macros: Some(&["docm", "dotm"]), }; fn core(&self) -> &Core { &self.core } fn core_mut(&mut self) -> &mut Core { &mut self.core } /// Tab stops come with the events: the view's state. fn read_at(&self, tab: u64, cx: &gpui::App) -> Option { self.tab(tab).map(|tab| tab.view.read(cx).read_at()) } fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { self.count(id, view, cx); self.found(id, view, cx); self.offer_spelling(id, view, cx); } fn search_leaves(&mut self, cx: &mut Context) { self.mark_place(cx); } } /// The window root with a blank document. 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 docs = cx.new(|cx| { let mut host = Docs::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 }); cx.bind_keys([ gpui::KeyBinding::new( "secondary-f", ely_office::FindReplace, Some(commands::CONTEXT), ), gpui::KeyBinding::new( "secondary-[", crate::navigate::GoBack, Some(commands::CONTEXT), ), ]); ely_office::bind_zoom_keys(cx); let menus = vec![ gpui::Menu::new(ely_ui::i18n::text(cx, "menu.view", &[])).items(ely_office::zoom_items(false, cx)), crate::insert::menu(cx), crate::review::menu(cx), crate::merge::menu(cx), ]; let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx); docs.update(cx, |docs, cx| { docs.core.root = root.downgrade(); docs.core.window = Some(window.window_handle()); window.focus(&docs.core.focus, cx); }); let launched = ely_ui::launch_files(cx); docs.update(cx, |docs, cx| match launched.is_empty() { true => docs.new_from(Template::Blank, cx), false => launched.into_iter().for_each(|path| docs.open_file(path, cx)), }); let opening = docs.clone(); ely_ui::on_open(cx, move |path, cx| { opening.update(cx, |docs, cx| docs.open_file(path, cx)) }); let closing = docs.clone(); window.on_window_should_close(cx, move |window, cx| { closing.update(cx, |docs, cx| docs.should_close(window, cx)) }); root } impl Docs { pub fn new(worker: Arc>, work: PathBuf, cx: &mut Context) -> Self { let props = cx.new(|_| Props::new(Arc::clone(&worker))); let review = cx.new(|_| Review::new(Arc::clone(&worker))); let weak = cx.weak_entity(); let merge = cx.new(|_| crate::merge::Merge::new(weak.clone())); let navigator = cx.new(|_| crate::navigate::Navigator::new(weak, Arc::clone(&worker))); let failed = |docs: &mut Docs, error: &LokError, cx: &mut Context| { docs.engine_failed(&error.0, cx) }; cx.subscribe(&props, move |docs, _, error, cx| failed(docs, error, cx)) .detach(); cx.subscribe(&review, move |docs, _, error, cx| failed(docs, error, cx)) .detach(); cx.subscribe(&navigator, move |docs, _, error, cx| { failed(docs, error, cx) }) .detach(); Docs { core: Core::new(worker, work, cx), dialog: None, props, review, side: Side::Props, offer: None, merge, navigator, navigating: true, back: Vec::new(), going: None, } } pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { ely_ui::i18n::text(cx, key, args) } /// A new untitled document from a bundled template (DOC-01). pub fn new_from(&mut self, template: Template, cx: &mut Context) { let name = format!("{}.fodt", template.key()); self.new_from_text(&name, &template.fodt(cx), cx); } } impl Render for Docs { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let title = self .active_tab() .map_or_else(|| AppId::Doc.name().into(), |tab| tab.name.clone()); let side = self.side_view(cx); let shell = Shell::new(title, &self.core.focus) .tabs(self.tab_strip(cx)) .left(self.left_view(cx)) .right(side) .status(self.status(cx)) .child(self.body(cx)); let root = div() .size_full() .key_context(commands::CONTEXT) .track_focus(&self.core.focus) .on_action(cx.listener(Self::on_new)) .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_quit)) .on_action(cx.listener(Self::on_close_tab)) .child(shell) .children(self.core_dialog(cx)) .children(self.dialog_element(window, cx)); Self::bind_zoom(crate::insert::bind(root, cx), cx) } } impl Zooming for Docs {}