Grid and header menus share ely_office::menu with ElyDoc. Each item's availability is the kernel's state for the exact command it runs (patch 0045). Patch 0046 lets a header select its row or column while a shape is selected or its text is edited: the view shell ends the drawing edit, keeps the typed text, leaves drawing mode, then marks. Ely no longer sends Esc first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
248 lines
8.3 KiB
Rust
248 lines
8.3 KiB
Rust
//! 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<Counts>,
|
|
/// The word count state last asked about.
|
|
pub counted: Option<Option<String>>,
|
|
/// A count is on its way.
|
|
pub counting: bool,
|
|
}
|
|
|
|
pub struct Docs {
|
|
pub core: Core<Docs>,
|
|
pub dialog: Option<Dialog>,
|
|
pub props: Entity<Props>,
|
|
pub review: Entity<Review>,
|
|
/// What the right column shows.
|
|
pub side: Side,
|
|
pub offer: Option<ely_office::menu::Offer<ely_lok::menu::Offered>>,
|
|
pub merge: Entity<crate::merge::Merge>,
|
|
pub navigator: Entity<crate::navigate::Navigator>,
|
|
/// 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)>,
|
|
/// Dialog reads asked; a dialog or tab change voids older.
|
|
pub dialog_asked: u64,
|
|
dialog_seen: Seen,
|
|
}
|
|
|
|
/// Open dialog kinds, Ely's and the shell's, and the tab.
|
|
type Seen = (Option<std::mem::Discriminant<Dialog>>, Option<std::mem::Discriminant<ely_office::Dialog>>, Option<u64>);
|
|
|
|
/// 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> {
|
|
&self.core
|
|
}
|
|
|
|
fn core_mut(&mut self) -> &mut Core<Self> {
|
|
&mut self.core
|
|
}
|
|
|
|
/// Tab stops come with the events: the view's state.
|
|
fn read_at(&self, tab: u64, cx: &gpui::App) -> Option<ely_lok::Doc> {
|
|
self.tab(tab).map(|tab| tab.view.read(cx).read_at())
|
|
}
|
|
|
|
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
|
self.count(id, view, cx);
|
|
self.found(id, view, cx);
|
|
self.offer_menu(id, view, cx);
|
|
}
|
|
|
|
/// A dialog about a closed tab goes with it.
|
|
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
|
|
if matches!(self.dialog, Some(Dialog::Output { tab: of, .. }) if of == tab.id) {
|
|
self.dialog = None;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn search_leaves(&mut self, cx: &mut Context<Self>) {
|
|
self.mark_place(cx);
|
|
}
|
|
}
|
|
|
|
/// The window root with a blank document.
|
|
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 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);
|
|
<Docs as ely_office::Autosaving>::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.own_window(window, cx);
|
|
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<Mutex<Worker>>, work: PathBuf, cx: &mut Context<Self>) -> 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>| {
|
|
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();
|
|
cx.observe_self(|docs, _| docs.watch_dialogs()).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,
|
|
dialog_asked: 0,
|
|
dialog_seen: (None, None, None),
|
|
}
|
|
}
|
|
|
|
/// A dialog or tab change ends earlier dialog reads.
|
|
fn watch_dialogs(&mut self) {
|
|
let ely = self.dialog.as_ref().map(std::mem::discriminant);
|
|
let now = (ely, self.core.dialog.as_ref().map(std::mem::discriminant), self.core.active);
|
|
if self.dialog_seen != now {
|
|
self.dialog_seen = now;
|
|
self.dialog_asked += 1;
|
|
}
|
|
}
|
|
|
|
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<Self>) {
|
|
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<Self>) -> 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_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 {}
|