Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
//! New, and ElyDoc's own dialogs (DOC-01).
use std::rc::Rc;
use ely_gpui_component::{
buttons::Button,
documents::{Template as Pick, TemplatePicker},
forms::TextInput,
overlays::Dialog as Card,
};
use ely_office::prelude::*;
use ely_ui::commands::New;
use gpui::{AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Window};
use crate::docs::Docs;
use crate::files::TEMPLATES;
use crate::refs::Ask;
pub enum Dialog {
Templates,
Table {
columns: f64,
rows: f64,
},
Section,
Ask {
ask: Ask,
field: Entity<TextInput>,
},
Link {
text: Entity<TextInput>,
url: Entity<TextInput>,
},
Reference {
marks: Vec<String>,
mark: String,
show: &'static str,
},
Output {
tab: u64,
print: bool,
all: bool,
pdf: ely_lok::Pdf,
},
}
impl Docs {
pub fn on_new(&mut self, _: &New, _: &mut Window, cx: &mut Context<Self>) {
self.dialog = Some(Dialog::Templates);
cx.notify();
}
/// The open dialog, if any.
pub fn dialog_element(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Option<AnyElement> {
let this = cx.entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |docs, cx| {
docs.dialog = None;
cx.notify();
})
}
};
let card = match self.dialog.as_ref()? {
Dialog::Templates => templates(this, dismiss, cx),
Dialog::Section => {
return Some(crate::insert::section_dialog(this, dismiss, cx).into_any_element());
}
Dialog::Ask { ask, field } => {
let prompt = crate::refs::ask_dialog(this, ask, field, dismiss, cx);
return Some(prompt.into_any_element());
}
Dialog::Link { text, url } => crate::refs::link_dialog(this, (text, url), dismiss, cx),
Dialog::Reference { marks, mark, show } => {
crate::refs::reference_dialog(this, (marks, mark, show), dismiss, cx)
}
Dialog::Output {
tab,
print,
all,
pdf,
} => {
let last = self
.tab(*tab)
.expect("a modal keeps its tab")
.view
.read(cx)
.pages()
.len();
let last = u32::try_from(last).expect("a page count");
crate::output::output_dialog(this, (*print, *all, *pdf, last), dismiss, cx)
}
Dialog::Table { columns, rows } => {
crate::insert::table_dialog(this, (*columns, *rows), dismiss, cx)
}
};
Some(card.into_any_element())
}
}
/// The New dialog: bundled templates and a local file (DOC-01).
fn templates(
this: Entity<Docs>,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &mut Context<Docs>,
) -> Card {
let picks: Vec<Pick> = TEMPLATES
.iter()
.map(|template| Pick {
name: Docs::text(cx, &format!("doc.template.{}", template.key()), &[]),
icon: "▤".into(),
category: Docs::text(cx, "doc.template.category", &[]),
description: Docs::text(cx, &format!("doc.template.{}.about", template.key()), &[]),
markdown: template.preview(cx).into(),
})
.collect();
let used = this.clone();
let picker = TemplatePicker::new("doc-templates", Rc::new(picks)).on_use(move |ix, _, cx| {
used.update(cx, |docs, cx| {
docs.dialog = None;
docs.new_from(TEMPLATES[ix], cx);
})
});
let label = Docs::text(cx, "doc.new.from_file", &[]);
Card::new("doc-new", Docs::text(cx, "doc.new.title", &[]), dismiss)
.child(picker)
.action(move |close| {
Button::new("doc-new-file", label).on_click(move |_, window, cx| {
close(window, cx);
this.update(cx, |docs, cx| {
docs.pick(false, cx, |docs, path, cx| docs.new_from_file(&path, cx))
});
})
})
}
+149
View File
@@ -0,0 +1,149 @@
//! Tabs, page, status line and side panel.
use ely_gpui_component::{
feedback::{Banner, EmptyState},
primitives::{IconName, Severity},
shell::{StatusBar, StatusBarItem},
};
use ely_lok::LokView;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, AnyView, Context, Entity, IntoElement, ParentElement as _, Styled as _, div,
};
use ely_office::prelude::*;
use crate::docs::{Docs, Side};
impl Docs {
/// The save state, and the kernel's word count.
pub(crate) fn status(&self, cx: &mut Context<Self>) -> StatusBar {
let state = self.save_state(cx);
let status = StatusBar::new().left(StatusBarItem::new("doc-state").label(state));
let zoom = self.zoom_control(cx);
let Some(counts) = self.active_tab().and_then(|tab| tab.extra.counts) else {
return status.when_some(zoom, |status, zoom| status.right(zoom));
};
let (words, characters) = (counts.words.to_string(), counts.characters.to_string());
let label = match counts.selected_words {
0 => Self::text(
cx,
"doc.count.words",
&[("words", &words), ("characters", &characters)],
),
selected => Self::text(
cx,
"doc.count.selected",
&[("selected", &selected.to_string()), ("words", &words)],
),
};
status.right(StatusBarItem::new("doc-count").label(label)).when_some(zoom, |status, zoom| status.right(zoom))
}
pub(crate) fn body(&self, cx: &mut Context<Self>) -> AnyElement {
if let Some(tab) = self.active_tab() {
let view = div()
.relative()
.size_full()
.child(tab.view.clone())
.children(self.find_box(cx))
.children(self.spelling_menu(cx))
.into_any_element();
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
let fonts = tab.view.read(cx).missing_fonts().join(", ");
let fonts = (!fonts.is_empty()).then(|| {
let warning = Self::text(cx, "doc.fonts.missing", &[("fonts", &fonts)]);
Banner::new("doc-fonts", Severity::Warning, warning)
});
if risk.is_none() && fonts.is_none() {
return view;
}
return div()
.size_full()
.flex()
.flex_col()
.children(risk)
.children(fonts)
.child(div().flex_1().min_h_0().child(view))
.into_any_element();
}
let title = match self.core.loading.first() {
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
None => Self::text(cx, "command.blocked.no_document", &[]),
};
EmptyState::new("doc-empty", IconName::FileText, title).into_any_element()
}
/// The navigator column, while shown.
pub(crate) fn left_view(&mut self, cx: &mut Context<Self>) -> Option<AnyView> {
let shown = self
.active_tab()
.map(|tab| (tab.view.clone(), tab.doc.clone()));
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
self.navigator
.update(cx, |navigator, cx| navigator.show(tab, cx));
shown.filter(|_| self.navigating)?;
Some(self.navigator.clone().into())
}
/// The right column for the active tab.
pub(crate) fn side_view(&mut self, cx: &mut Context<Self>) -> Option<AnyView> {
let shown = self
.active_tab()
.map(|tab| (tab.view.clone(), tab.doc.clone()));
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
self.props.update(cx, |props, cx| props.show(tab, cx));
self.review.update(cx, |review, cx| review.show(tab, cx));
shown.as_ref()?;
Some(match self.side {
Side::Props => self.props.clone().into(),
Side::Review => self.review.clone().into(),
Side::Merge => self.merge.clone().into(),
})
}
/// Reads the counts, again when the kernel's count moves.
pub(crate) fn count(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let state = view
.read(cx)
.state(".uno:StateWordCount")
.map(str::to_string);
let Some(tab) = self.tab(id) else {
return;
};
// The kernel reports its count only once the status moves.
let asked = tab.extra.counted.as_ref() == Some(&state);
if tab.extra.counting || asked {
return;
}
let doc = tab.doc.clone();
// Asked now: a failure waits for the next change.
let extra = &mut self.tab_mut(id).expect("the tab is open").extra;
(extra.counting, extra.counted) = (true, Some(state));
ely_lok::read_off(
&self.core.worker,
&doc,
|worker, doc| {
let json = worker.values(doc, ".uno:WordCount")?;
ely_lok::review::counts(&json)
},
move |docs: &mut Self, read, cx| {
let Some(tab) = docs.tab_mut(id) else {
return;
};
tab.extra.counting = false;
match read {
Ok(counts) => tab.extra.counts = Some(counts),
Err(error) => docs.engine_failed(&error.to_string(), cx),
}
cx.notify();
// The status may have moved meanwhile.
let view = docs.tab(id).map(|tab| tab.view.clone());
if let Some(view) = view {
docs.count(id, &view, cx);
}
},
cx,
);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Compares the active document with an older file (DOC-13).
use std::path::PathBuf;
use ely_gpui_component::primitives::Severity;
use gpui::{Context, Window, actions};
use ely_office::Source;
use ely_office::files::{self, file_name};
use ely_office::prelude::*;
use crate::docs::{Docs, Side};
actions!(doc, [CompareWith]);
impl Docs {
pub fn on_compare(&mut self, _: &CompareWith, _: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.pick(false, cx, move |docs, older, cx| {
docs.compare(id, older, cx)
});
}
/// A new tab holds this version, marked against `older`.
pub(crate) fn compare(&mut self, id: u64, older: PathBuf, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |docs, _, cx| docs.compare(id, older, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
let name = tab.name.clone();
// The copy's tab and folder, reserved now.
let into = self.core.next;
self.core.next += 1;
let dir = match files::work_dir(&self.core.work, into) {
Ok(dir) => dir,
Err(error) => return self.failed_open(&name, &error, cx),
};
let copy = dir.join("compared.odt");
let url = ely_lok::file_url(&copy);
self.read_in(
id,
move |worker, doc| doc.save_as(worker, &url, "odt"),
move |docs, saved, _, cx| match saved {
Ok(()) => docs.compare_copy((id, into), name, copy, older, cx),
Err(error) => docs.failed_open(&name, &error, cx),
},
cx,
);
}
/// Opens the saved copy and marks it against `older`.
fn compare_copy(
&mut self,
(id, into): (u64, u64),
name: gpui::SharedString,
copy: PathBuf,
older: PathBuf,
cx: &mut Context<Self>,
) {
log::info!("doc: comparing tab {id} with {}", file_name(&older));
self.load_into(into, Source::Copy(copy), cx);
let url = ely_lok::file_url(&older);
self.core.waiting.push((
into,
Box::new(move |docs, _, cx| {
let Some(tab) = docs.tab_mut(into) else {
return;
};
tab.name = Docs::text(cx, "doc.compare.name", &[("name", &name)]);
let sent = tab
.view
.update(cx, |view, _| view.run("doc.compare", &[&url, "true"]));
if let Err(error) = sent {
let text =
Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return docs.toast(text, Severity::Danger, cx);
}
docs.side = Side::Review;
cx.notify();
}),
));
}
}
+221
View File
@@ -0,0 +1,221 @@
//! 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<crate::spell::Offer>,
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)>,
}
/// 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_spelling(id, view, cx);
}
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.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<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();
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<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_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 {}
+99
View File
@@ -0,0 +1,99 @@
//! The bundled templates (DOC-01).
use gpui::App;
/// The three bundled templates (DOC-01).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Template {
Blank,
Report,
Letter,
}
pub const TEMPLATES: [Template; 3] = [Template::Blank, Template::Report, Template::Letter];
impl Template {
pub fn key(self) -> &'static str {
match self {
Template::Blank => "blank",
Template::Report => "report",
Template::Letter => "letter",
}
}
/// The paragraphs, as (heading level, text); 0 is body.
fn lines(self, cx: &App) -> Vec<(u8, String)> {
let text = |key: &str| ely_ui::i18n::text(cx, key, &[]).to_string();
match self {
Template::Blank => vec![(0, String::new())],
Template::Report => vec![
(9, text("doc.report.title")),
(1, text("doc.report.summary")),
(0, text("doc.body")),
(1, text("doc.report.details")),
(0, text("doc.body")),
],
Template::Letter => vec![
(0, text("doc.letter.sender")),
(0, text("doc.letter.date")),
(0, String::new()),
(0, text("doc.letter.recipient")),
(0, String::new()),
(0, text("doc.letter.greeting")),
(0, text("doc.body")),
(0, String::new()),
(0, text("doc.letter.closing")),
(0, text("doc.letter.sender")),
],
}
}
/// Flat ODF text; level 9 is the Title style.
pub fn fodt(self, cx: &App) -> String {
let body: String = self
.lines(cx)
.into_iter()
.map(|(level, text)| {
let text = escape(&text);
match level {
0 => format!("<text:p>{text}</text:p>"),
9 => format!("<text:p text:style-name=\"Title\">{text}</text:p>"),
level => format!("<text:h text:outline-level=\"{level}\">{text}</text:h>"),
}
})
.collect();
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" office:version=\"1.3\" office:mimetype=\"application/vnd.oasis.opendocument.text\"><office:body><office:text>{body}</office:text></office:body></office:document>\n"
)
}
/// The template as the picker's markdown preview.
pub fn preview(self, cx: &App) -> String {
self.lines(cx)
.into_iter()
.map(|(level, text)| match level {
0 => text,
9 => format!("# {text}"),
level => format!("{} {text}", "#".repeat(usize::from(level) + 1)),
})
.collect::<Vec<_>>()
.join("\n\n")
}
}
/// XML-escapes template text.
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_text_is_escaped() {
assert_eq!(escape("a<b & c>"), "a&lt;b &amp; c&gt;");
}
}
+240
View File
@@ -0,0 +1,240 @@
//! The Insert menu and its dialog (DOC-05 to 08).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::NumberInput,
overlays::{ConfirmDialog, Dialog as Card},
primitives::Severity,
};
use gpui::{
App, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, ParentElement as _,
Styled as _, Window, actions, div,
};
use crate::actions::Dialog;
use crate::docs::Docs;
use ely_office::prelude::*;
actions!(
doc,
[
InsertTable,
InsertImage,
InsertPageBreak,
InsertSectionBreak,
InsertPageNumber,
InsertPageCount
]
);
/// ElyDoc's Insert menu, after File.
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Docs::text(cx, key, &[]);
let mut items = vec![
MenuItem::action(label("doc.insert.table"), InsertTable),
MenuItem::action(label("doc.insert.image"), InsertImage),
MenuItem::separator(),
MenuItem::action(label("doc.insert.page_break"), InsertPageBreak),
MenuItem::action(label("doc.insert.section_break"), InsertSectionBreak),
MenuItem::separator(),
MenuItem::action(label("doc.insert.page_number"), InsertPageNumber),
MenuItem::action(label("doc.insert.page_count"), InsertPageCount),
];
items.extend(crate::refs::items(cx));
Menu::new(label("menu.insert")).items(items)
}
/// Binds the Insert and Review actions on the window root.
pub fn bind(root: Div, cx: &mut Context<Docs>) -> Div {
root.on_action(cx.listener(Docs::on_insert_table))
.on_action(cx.listener(Docs::on_insert_image))
.on_action(cx.listener(Docs::on_page_break))
.on_action(cx.listener(Docs::on_section_break))
.on_action(cx.listener(Docs::on_page_number))
.on_action(cx.listener(Docs::on_page_count))
.on_action(cx.listener(Docs::on_contents))
.on_action(cx.listener(Docs::on_update))
.on_action(cx.listener(Docs::on_footnote))
.on_action(cx.listener(Docs::on_endnote))
.on_action(cx.listener(Docs::on_bookmark))
.on_action(cx.listener(Docs::on_link))
.on_action(cx.listener(Docs::on_reference))
.on_action(cx.listener(Docs::on_caption))
.on_action(cx.listener(Docs::on_formula))
.on_action(cx.listener(Docs::on_comment))
.on_action(cx.listener(Docs::on_track))
.on_action(cx.listener(Docs::on_accept_all))
.on_action(cx.listener(Docs::on_reject_all))
.on_action(cx.listener(Docs::on_show_review))
.on_action(cx.listener(Docs::on_find))
.on_action(cx.listener(Docs::on_compare))
.on_action(cx.listener(Docs::on_merge))
.on_action(cx.listener(Docs::on_export))
.on_action(cx.listener(Docs::on_navigator))
.on_action(cx.listener(Docs::on_back))
.on_action(cx.listener(Docs::on_print))
}
impl Docs {
/// Runs a command on the active document.
pub(crate) fn run_active(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
log::info!("doc: runs {id}");
let sent = tab.view.update(cx, |view, _| view.run(id, values));
if let Err(error) = sent {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
}
pub fn on_insert_table(&mut self, _: &InsertTable, _: &mut Window, cx: &mut Context<Self>) {
self.dialog = Some(Dialog::Table {
columns: 2.0,
rows: 2.0,
});
cx.notify();
}
pub fn on_insert_image(&mut self, _: &InsertImage, _: &mut Window, cx: &mut Context<Self>) {
self.pick(false, cx, |docs, path, cx| {
let url = ely_lok::file_url(&path);
docs.run_active("image.insert", &[&url], cx)
});
}
pub fn on_page_break(&mut self, _: &InsertPageBreak, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("page.break", &[], cx);
}
/// Asks first: a new section clears the undo history.
pub fn on_section_break(
&mut self,
_: &InsertSectionBreak,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.dialog = Some(Dialog::Section);
cx.notify();
}
/// Copies this page's style, then breaks into it.
pub fn insert_section(&mut self, cx: &mut Context<Self>) {
self.dialog = None;
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let listed = self
.core
.worker
.lock()
.expect("the worker lock")
.values(&tab.doc, ".uno:StyleApply")
.and_then(|json| ely_lok::commands::styles(&json, "8"));
let names = match listed {
Ok(names) => names,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let name = (2..)
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
.find(|name| !names.contains(name))
.expect("a free section name");
log::info!("doc: a new section");
self.run_active("style.new", &[&name, "8"], cx);
self.run_active("page.section", &["3", &name], cx);
self.props.update(cx, |props, _| props.refresh_lists());
cx.notify();
}
pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("field.page", &[], cx);
}
pub fn on_page_count(&mut self, _: &InsertPageCount, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("field.pages", &[], cx);
}
/// Inserts the dialog's table and closes it.
pub fn insert_table(&mut self, columns: f64, rows: f64, cx: &mut Context<Self>) {
self.dialog = None;
let (columns, rows) = (columns.round().to_string(), rows.round().to_string());
self.run_active("table.insert", &[&columns, &rows], cx);
cx.notify();
}
}
/// The section break's warning: undo clears.
pub fn section_dialog(
this: Entity<Docs>,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> ConfirmDialog {
ConfirmDialog::new(
"doc-insert-section",
Docs::text(cx, "doc.section.title", &[]),
Docs::text(cx, "doc.section.detail", &[]),
dismiss,
)
.confirm(Docs::text(cx, "doc.insert.insert", &[]))
.on_confirm(move |_, cx| this.update(cx, |docs, cx| docs.insert_section(cx)))
}
/// The Insert Table dialog: columns and rows.
pub fn table_dialog(
this: Entity<Docs>,
(columns, rows): (f64, f64),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let field = |id: &'static str, value: f64, max: f64, tall: bool| {
let this = this.clone();
let input = NumberInput::new(id, value)
.range(1.0, max)
.on_change(move |value, _, cx| {
this.update(cx, |docs, cx| {
if let Some(Dialog::Table { columns, rows }) = &mut docs.dialog {
*if tall { rows } else { columns } = value;
}
cx.notify();
})
});
div().w_full().max_w_32().child(input)
};
let fields = DescriptionList::new()
.item(
Docs::text(cx, "doc.insert.columns", &[]),
field("doc-table-columns", columns, 99.0, false),
)
.item(
Docs::text(cx, "doc.insert.rows", &[]),
field("doc-table-rows", rows, 999.0, true),
);
let (label, cancel) = (
Docs::text(cx, "doc.insert.insert", &[]),
Docs::text(cx, "office.cancel", &[]),
);
Card::new(
"doc-insert-table",
Docs::text(cx, "doc.insert.table_title", &[]),
dismiss,
)
.child(fields)
.action(move |close| {
Button::new("doc-insert-table-cancel", cancel)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |_| {
Button::new("doc-insert-table-go", label)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| {
this.update(cx, |docs, cx| docs.insert_table(columns, rows, cx))
})
})
}
+22
View File
@@ -0,0 +1,22 @@
mod actions;
mod chrome;
mod compare;
mod docs;
mod files;
mod insert;
mod merge;
mod navigate;
mod output;
mod props;
mod refs;
mod review;
mod spell;
use ely_platform::AppId;
fn main() {
ely_ui::run(AppId::Doc, docs::open);
}
#[cfg(test)]
mod tests;
+216
View File
@@ -0,0 +1,216 @@
//! Mail merge from a CSV (DOC-14); never sends mail.
mod run;
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::{PropertyGrid, PropertyGroup},
forms::NumberInput,
layout::ScrollArea,
primitives::Severity,
theme::ControlSize,
};
use gpui::{
Context, IntoElement, ParentElement as _, Render, Styled as _, WeakEntity, Window, actions, div,
};
use crate::docs::{Docs, Side};
use ely_office::prelude::*;
pub use run::{Table, marker, read_csv};
actions!(doc, [MailMerge]);
/// ElyDoc's Tools menu.
pub fn menu(cx: &gpui::App) -> gpui::Menu {
let label = |key: &str| Docs::text(cx, key, &[]);
gpui::Menu::new(label("menu.tools")).items([
gpui::MenuItem::action(label("office.find.open"), ely_office::FindReplace),
gpui::MenuItem::action(label("doc.merge.open"), MailMerge),
gpui::MenuItem::separator(),
gpui::MenuItem::action(label("doc.nav.open"), crate::navigate::ToggleNavigator),
gpui::MenuItem::action(label("doc.nav.back"), crate::navigate::GoBack),
])
}
/// The merge panel: the data, the record shown, the format.
pub struct Merge {
docs: WeakEntity<Docs>,
pub table: Option<Table>,
/// The record to preview, from 1.
record: usize,
pdf: bool,
}
impl Merge {
pub fn new(docs: WeakEntity<Docs>) -> Self {
Merge {
docs,
table: None,
record: 1,
pdf: false,
}
}
}
impl Docs {
pub fn on_merge(&mut self, _: &MailMerge, _: &mut Window, cx: &mut Context<Self>) {
self.side = Side::Merge;
cx.notify();
if self.merge.read(cx).table.is_none() {
self.choose_csv(cx);
}
}
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
Ok(table) => {
log::info!("doc: merge data has {} rows", table.rows.len());
docs.merge.update(cx, |merge, cx| {
merge.table = Some(table);
merge.record = 1;
cx.notify();
});
}
Err(error) => {
let name = path.display().to_string();
let error = error.to_string();
let text = Docs::text(
cx,
"doc.merge.unreadable",
&[("name", &name), ("error", &error)],
);
docs.toast(text, Severity::Danger, cx);
}
});
}
/// The tab a merge starts from: the active one.
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
if self.core.active.is_none() {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
self.toast(text, Severity::Warning, cx);
}
self.core.active
}
/// Types a field's marker at the cursor.
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
tab.view.update(cx, |view, _| view.insert(&marker(header)));
}
}
impl Render for Merge {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (docs, this) = (self.docs.clone(), cx.entity());
let text = |key: &str| Docs::text(cx, key, &[]);
let choose = {
let docs = docs.clone();
Button::new("doc-merge-choose", text("doc.merge.choose"))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
docs.update(cx, |docs, cx| docs.choose_csv(cx))
.expect("the documents live");
})
};
let Some(table) = self.table.clone() else {
let group =
PropertyGroup::new(text("doc.merge.title")).row(text("doc.merge.data"), choose);
return PropertyGrid::new("doc-merge")
.group(group)
.into_any_element();
};
let fields = table
.headers
.iter()
.enumerate()
.map(|(at, header)| {
let (docs, header) = (docs.clone(), header.clone());
Button::new(("doc-merge-field", at), header.clone())
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| {
docs.update(cx, |docs, cx| docs.insert_field(&header, cx))
.expect("the documents live");
})
})
.collect::<Vec<_>>();
let record = {
let this = this.clone();
NumberInput::new("doc-merge-record", self.record as f64)
.range(1.0, table.rows.len() as f64)
.on_commit(move |value, _, cx| {
this.update(cx, |merge, cx| {
merge.record = value.round() as usize;
cx.notify();
})
})
};
let preview = {
let (docs, table, at) = (docs.clone(), table.clone(), self.record - 1);
Button::new("doc-merge-preview", text("doc.merge.preview"))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
docs.update(cx, |docs, cx| {
if let Some(source) = docs.source(cx) {
docs.preview_record(source, &table, at, cx)
}
})
.expect("the documents live");
})
};
let format = {
let this = this.clone();
SegmentedControl::new("doc-merge-format", if self.pdf { "pdf" } else { "docx" })
.size(ControlSize::Sm)
.segment("docx", "DOCX", None)
.segment("pdf", "PDF", None)
.on_change(move |value, _, cx| {
this.update(cx, |merge, cx| {
merge.pdf = value.as_ref() == "pdf";
cx.notify();
})
})
};
let generate = {
let (pdf, table) = (self.pdf, table.clone());
Button::new("doc-merge-generate", text("doc.merge.generate"))
.size(ControlSize::Sm)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| {
let table = table.clone();
docs.update(cx, |docs, cx| {
let Some(source) = docs.source(cx) else {
return;
};
docs.pick(true, cx, move |docs, folder, cx| {
docs.generate(source, table, pdf, folder, cx)
})
})
.expect("the documents live");
})
};
let row = || div().flex().flex_wrap().items_center().gap_1();
let group = PropertyGroup::new(table.name.clone())
.row(text("doc.merge.data"), choose)
.row(text("doc.merge.fields"), row().children(fields))
.row(
text("doc.merge.record"),
row().child(div().w_full().max_w_24().child(record)).child(preview),
)
.row(
text("doc.merge.output"),
row().child(format).child(generate),
);
ScrollArea::new("doc-merge-scroll").vertical()
.size_full()
.child(PropertyGrid::new("doc-merge").group(group))
.into_any_element()
}
}
#[cfg(test)]
mod tests;
+308
View File
@@ -0,0 +1,308 @@
//! Mail merge work: the CSV, filled copies, previews and files.
use std::{
collections::hash_map::RandomState,
hash::BuildHasher,
io,
path::{Path, PathBuf},
sync::Arc,
};
use ely_gpui_component::primitives::Severity;
use ely_lok::{Doc, Worker};
use gpui::{AppContext as _, Context, SharedString, TaskExt as _};
use ely_office::files::{self, file_name};
use ely_office::prelude::*;
use ely_office::{Source, in_window};
use crate::docs::Docs;
/// A CSV: its name, column names and rows.
#[derive(Clone, Debug)]
pub struct Table {
pub name: SharedString,
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
}
/// Reads a UTF-8 CSV whose first row names the columns.
pub fn read_csv(path: &Path) -> io::Result<Table> {
let mut reader = csv::Reader::from_path(path).map_err(io::Error::other)?;
let headers: Vec<String> = reader
.headers()
.map_err(io::Error::other)?
.iter()
.map(|header| header.trim().to_string())
.collect();
if let Some(at) = headers.iter().position(String::is_empty) {
return Err(io::Error::other(format!("column {} has no name", at + 1)));
}
// A name with a guillemet would blur the marker bounds.
if let Some(header) = headers.iter().find(|header| header.contains(['«', '»'])) {
return Err(io::Error::other(format!("column {header} holds « or »")));
}
for (at, header) in headers.iter().enumerate() {
if headers[..at].contains(header) {
return Err(io::Error::other(format!("two columns are named {header}")));
}
}
let rows = reader
.records()
.map(|record| record.map(|record| record.iter().map(str::to_string).collect()))
.collect::<Result<Vec<Vec<String>>, _>>()
.map_err(io::Error::other)?;
if rows.is_empty() {
return Err(io::Error::other("the file has no rows"));
}
Ok(Table {
name: file_name(path),
headers,
rows,
})
}
/// The marker a field leaves in the text.
pub fn marker(header: &str) -> String {
format!("«{header}»")
}
/// Replaces every field marker in `doc` with `row`'s values.
fn fill(worker: &mut Worker, doc: &Doc, headers: &[String], row: &[String]) -> io::Result<()> {
// Markers become tokens first, so values are never rescanned.
let nonce = BuildHasher::hash_one(&RandomState::new(), 0);
let token = |at: usize| format!("\u{e000}{nonce:x}-{at}\u{e001}");
let mut replace = |from: &str, to: &str| {
worker.run(
doc,
"find.replace",
&[from, "false", "3", "0", "0", "1", to],
)
};
for (at, header) in headers.iter().enumerate() {
replace(&marker(header), &token(at))?;
}
for (at, value) in row.iter().enumerate() {
replace(&token(at), value)?;
}
Ok(())
}
/// Copies `from` to `to`, never over an existing file.
fn publish(from: &Path, to: &Path) -> io::Result<()> {
let mut target = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(to)?;
io::copy(&mut std::fs::File::open(from)?, &mut target)?;
target.sync_all()
}
impl Docs {
/// Saves tab `source` as the template copy, in the background.
fn template(
&mut self,
source: u64,
into: u64,
cx: &mut Context<Self>,
then: impl FnOnce(&mut Self, SharedString, PathBuf, &mut Context<Self>) + 'static,
) {
let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else {
return self.merge_failed(&io::Error::other("the source document closed"), cx);
};
let copy = match files::work_dir(&self.core.work, into) {
Ok(dir) => dir.join("merge.odt"),
Err(error) => return self.merge_failed(&error, cx),
};
let url = ely_lok::file_url(&copy);
self.read_in(
source,
move |worker, doc| doc.save_as(worker, &url, "odt"),
move |docs, saved, _, cx| match saved {
Ok(()) => then(docs, name, copy, cx),
Err(error) => docs.merge_failed(&error, cx),
},
cx,
);
}
fn merge_failed(&self, error: &io::Error, cx: &mut Context<Self>) {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
/// Opens record `at` filled in, as a new tab.
pub(crate) fn preview_record(
&mut self,
source: u64,
table: &Table,
at: usize,
cx: &mut Context<Self>,
) {
if !self.settled(source, cx) {
let table = table.clone();
return self.when_settled(source, move |docs, _, cx| {
docs.preview_record(source, &table, at, cx)
});
}
let (headers, row) = (table.headers.clone(), table.rows[at].clone());
// The preview's tab and folder, reserved now.
let into = self.core.next;
self.core.next += 1;
self.template(source, into, cx, move |docs, name, copy, cx| {
docs.preview_copy((into, at), name, copy, (headers, row), cx)
});
}
/// Opens the template copy and fills it with one row.
fn preview_copy(
&mut self,
(into, at): (u64, usize),
name: SharedString,
copy: PathBuf,
(headers, row): (Vec<String>, Vec<String>),
cx: &mut Context<Self>,
) {
log::info!("doc: previewing merge record {}", at + 1);
self.load_into(into, Source::Copy(copy), cx);
self.core.waiting.push((
into,
Box::new(move |docs, _, cx| {
let Some(tab) = docs.tab_mut(into) else {
return;
};
let n = (at + 1).to_string();
tab.name = Docs::text(cx, "doc.merge.preview_name", &[("name", &name), ("n", &n)]);
cx.notify();
docs.read_in(
into,
move |worker, doc| fill(worker, doc, &headers, &row),
|docs, filled, _, cx| {
if let Err(error) = filled {
docs.merge_failed(&error, cx);
}
},
cx,
);
}),
));
}
/// Writes one file per row into `folder`; refuses to overwrite.
pub(crate) fn generate(
&mut self,
source: u64,
table: Table,
pdf: bool,
folder: PathBuf,
cx: &mut Context<Self>,
) {
if !self.settled(source, cx) {
return self.when_settled(source, move |docs, _, cx| {
docs.generate(source, table, pdf, folder, cx)
});
}
let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else {
return self.merge_failed(&io::Error::other("the source document closed"), cx);
};
let stem = Path::new(name.as_ref()).file_stem().map_or_else(
|| name.to_string(),
|stem| stem.to_string_lossy().into_owned(),
);
let extension = if pdf { "pdf" } else { "docx" };
let targets: Vec<PathBuf> = (1..=table.rows.len())
.map(|n| folder.join(format!("{stem} {n}.{extension}")))
.collect();
let taken = targets
.iter()
.find(|target| target.exists())
.map(|target| file_name(target));
if let Some(taken) = taken {
let text = Docs::text(cx, "doc.merge.exists", &[("name", &taken)]);
return self.toast(text, Severity::Danger, cx);
}
// Each run keeps its own template and staging folder.
let into = self.core.next;
self.core.next += 1;
self.template(source, into, cx, move |docs, _, template, cx| {
docs.write_records(template, (table, targets, extension, folder), cx)
});
}
/// Writes each row's file from the template copy.
fn write_records(
&mut self,
template: PathBuf,
(table, targets, extension, folder): (Table, Vec<PathBuf>, &'static str, PathBuf),
cx: &mut Context<Self>,
) {
log::info!("doc: merging {} records", targets.len());
let worker = Arc::clone(&self.core.worker);
cx.spawn(async move |this, cx| {
let written = cx
.background_spawn(async move {
for (n, (row, target)) in table.rows.iter().zip(&targets).enumerate() {
// One record at a time: open documents keep their turns.
let mut worker = worker.lock().expect("the worker lock");
let staged = template.with_file_name(format!("record.{extension}"));
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
fill(&mut worker, &doc, &table.headers, row)?;
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?;
worker.close_doc(doc)?;
publish(&staged, target)
});
made.map_err(|error| (n + 1, error))?;
}
Ok(targets.len())
})
.await;
in_window(&this, cx, |docs, _, cx| {
let folder = folder.display().to_string();
let text = match written {
Ok(count) => {
let count = count.to_string();
let text =
Docs::text(cx, "doc.merge.done", &[("n", &count), ("folder", &folder)]);
return docs.toast(text, Severity::Success, cx);
}
Err((n, error)) => Docs::text(
cx,
"doc.merge.failed",
&[("n", &n.to_string()), ("error", &error.to_string())],
),
};
docs.toast(text, Severity::Danger, cx);
})
})
.detach_and_log_err(cx);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_csv_reads_or_says_why_not() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("people.csv");
std::fs::write(&path, "\u{feff}Name,City\nAda,\"London, UK\"\nLin,上海\n").expect("writes");
let table = read_csv(&path).expect("reads");
assert_eq!(table.headers, ["Name", "City"]);
assert_eq!(table.rows[0], ["Ada", "London, UK"]);
assert_eq!(table.rows[1][1], "上海");
for (body, why) in [
("Name,\nAda,x\n", "no name"),
("Name,Name\na,b\n", "two columns"),
("Name,«Name»\na,b\n", "holds « or »"),
("Name,City\n", "no rows"),
("Name,City\nAda\n", "found record"),
] {
std::fs::write(&path, body).expect("writes");
let error = read_csv(&path).expect_err(body).to_string();
assert!(error.contains(why), "{body}: {error}");
}
std::fs::write(&path, b"Name\n\xff\xfe\n").expect("writes");
assert!(read_csv(&path).is_err(), "bytes that are not UTF-8 fail");
}
}
+267
View File
@@ -0,0 +1,267 @@
use gpui::{Entity, TestAppContext};
use super::*;
use crate::tests::{
blank, commit, docs, settle, settled, text_of, type_into, with_window, written,
};
fn tabs(app: &gpui::App, docs: &Entity<Docs>) -> usize {
docs.read(app).core.tabs.len()
}
#[test]
fn records_fill_previews_and_files() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let source_id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Dear ");
let csv = dir.path().join("people.csv");
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
type_into(&mut cx, &docs, " of ");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.preview_record(source_id, &table, 1, cx)
})
});
let name = cx.update(|app| {
Docs::text(
app,
"doc.merge.preview_name",
&[("name", &source), ("n", "2")],
)
});
settle(&cx, |app| {
tabs(app, &docs) == 2
&& docs.read(app).core.tabs[1].name == name
&& docs.read(app).core.tabs[1].view.read(app).settled()
});
let preview = dir.path().join("preview.odt");
let id = cx.update(|app| docs.read(app).core.tabs[1].id);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, preview.clone(), None, window, cx)
});
written(&cx, &preview, None);
assert!(
text_of(&mut cx, &docs, &preview).contains("Dear «City» of 上海"),
"the preview takes queued input and keeps values literal"
);
cx.update(|app| docs.update(app, |docs, _| docs.core.active = Some(docs.core.tabs[0].id)));
let out = dir.path().join("out");
std::fs::create_dir(&out).expect("the folder");
let target = |n: usize, extension: &str| out.join(format!("{source} {n}.{extension}"));
std::fs::write(target(2, "docx"), "mine").expect("a taken name");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.generate(source_id, table.clone(), false, out.clone(), cx)
})
});
assert_eq!(
std::fs::read_to_string(target(2, "docx")).expect("reads"),
"mine",
"a taken name stops the merge"
);
assert!(!target(1, "docx").exists(), "nothing is written");
std::fs::remove_file(target(2, "docx")).expect("frees the name");
let staged = cx.update(|app| {
let docs = docs.read(app);
docs.core
.work
.join(format!("doc-{}", docs.core.next))
.join("record.pdf")
});
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.generate(source_id, table.clone(), true, out.clone(), cx)
})
});
std::fs::write(target(1, "pdf"), "EXTERNAL").expect("a file arrives late");
let worker = cx.update(|app| docs.read(app).core.worker.clone());
settle(&cx, |_| staged.exists() && worker.try_lock().is_ok());
assert_eq!(
std::fs::read_to_string(target(1, "pdf")).expect("reads"),
"EXTERNAL",
"a file that arrives late stays"
);
assert!(
!target(2, "pdf").exists(),
"the merge stops at the taken name"
);
std::fs::remove_file(target(1, "pdf")).expect("frees the name");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.generate(source_id, table, true, out.clone(), cx)
})
});
settle(&cx, |_| target(2, "pdf").exists());
let first = std::fs::read(target(1, "pdf")).expect("the pdf reads");
assert!(first.starts_with(b"%PDF"), "a PDF is written");
}
#[test]
fn compare_opens_a_marked_copy_and_keeps_both_files() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "A new line");
settled(&cx, &docs);
let older = dir.path().join("older.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, older.clone(), None, window, cx)
});
written(&cx, &older, None);
commit(&mut cx, &docs, " grows");
let before = std::fs::read(&older).expect("the older file reads");
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
cx.update(|app| docs.update(app, |docs, cx| docs.compare(id, older.clone(), cx)));
settle(&cx, |app| {
tabs(app, &docs) == 2 && docs.read(app).side == crate::docs::Side::Review
});
let review = cx.update(|app| docs.read(app).review.clone());
let tab = cx.update(|app| docs.read(app).core.tabs[1].view.clone());
cx.update(|app| {
let doc = docs.read(app).core.tabs[1].doc.clone();
review.update(app, |review, cx| review.show(Some((&tab, &doc)), cx))
});
settle(&cx, |app| !review.read(app).changes.is_empty());
let name = cx.update(|app| docs.read(app).core.tabs[1].name.to_string());
assert!(name.contains(&source), "the copy names its source: {name}");
let copy = dir.path().join("copy.odt");
let into = cx.update(|app| docs.read(app).core.tabs[1].id);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(into, copy.clone(), None, window, cx)
});
written(&cx, &copy, None);
assert!(
text_of(&mut cx, &docs, &copy).contains(" grows"),
"the copy takes the queued input"
);
assert_eq!(
std::fs::read(&older).expect("reads"),
before,
"the older file stays"
);
}
#[test]
fn a_merge_keeps_its_source_when_the_tab_changes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let other = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceB ");
let source = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceA ");
let csv = dir.path().join("one.csv");
std::fs::write(&csv, "Name\nAda\n").expect("the csv writes");
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.insert_field("Name", cx);
docs.preview_record(source, &table, 0, cx);
docs.core.active = Some(other);
})
});
let name = cx.update(|app| {
let source = docs.read(app).tab(source).expect("a tab").name.clone();
Docs::text(
app,
"doc.merge.preview_name",
&[("name", &source), ("n", "1")],
)
});
settle(&cx, |app| {
let docs = docs.read(app);
// A filled preview is a modified copy.
docs.core.tabs.len() == 3 && docs.core.tabs[2].name == name && docs.core.tabs[2].modified
});
let preview = dir.path().join("preview.odt");
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, preview.clone(), None, window, cx)
});
written(&cx, &preview, None);
let text = text_of(&mut cx, &docs, &preview);
assert!(text.contains("SourceA Ada"), "the preview keeps its source");
let stem = cx.update(|app| docs.read(app).tab(source).expect("a tab").name.to_string());
let out = dir.path().join("out");
std::fs::create_dir(&out).expect("the folder");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.core.active = Some(other);
docs.generate(source, table, false, out.clone(), cx)
})
});
let target = out.join(format!("{stem} 1.docx"));
let worker = cx.update(|app| docs.read(app).core.worker.clone());
settle(&cx, |_| target.exists() && worker.try_lock().is_ok());
assert!(
text_of(&mut cx, &docs, &target).contains("SourceA Ada"),
"the file keeps its source"
);
}
#[test]
fn two_generations_keep_their_own_templates() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let mut sources = Vec::new();
for text in ["SourceA ", "SourceB "] {
sources.push(blank(&mut cx, &docs));
type_into(&mut cx, &docs, text);
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
settled(&cx, &docs);
}
let csv = dir.path().join("one.csv");
std::fs::write(&csv, "Name\nAda\n").expect("the csv writes");
let table = read_csv(&csv).expect("the csv reads");
let outs: Vec<_> = (0..2).map(|n| dir.path().join(format!("out{n}"))).collect();
for (source, out) in sources.iter().zip(&outs) {
std::fs::create_dir(out).expect("the folder");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.generate(*source, table.clone(), false, out.clone(), cx)
})
});
}
let names: Vec<String> = cx.update(|app| {
let docs = docs.read(app);
sources
.iter()
.map(|id| docs.tab(*id).expect("a tab").name.to_string())
.collect()
});
let targets: Vec<_> = outs
.iter()
.zip(&names)
.map(|(out, name)| out.join(format!("{name} 1.docx")))
.collect();
let worker = cx.update(|app| docs.read(app).core.worker.clone());
settle(&cx, |_| {
targets.iter().all(|target| target.exists()) && worker.try_lock().is_ok()
});
for (target, want) in targets.iter().zip(["SourceA Ada", "SourceB Ada"]) {
assert!(
text_of(&mut cx, &docs, target).contains(want),
"{want} lands"
);
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Outline, pages and bookmarks beside the page (DOC-16).
use std::sync::{Arc, Mutex};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
layout::ScrollArea,
lists::{List, ListItem, Outline},
primitives::{IconName, Severity},
theme::ControlSize,
};
use ely_lok::{
Doc, LokError, LokView, Rect, Reported, Worker,
review::{Bookmark, Heading},
};
use gpui::{
App, Context, Entity, EventEmitter, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, Subscription, WeakEntity, Window, actions, div,
};
use crate::docs::Docs;
use ely_office::prelude::*;
actions!(doc, [ToggleNavigator, GoBack]);
#[derive(Clone, Copy, PartialEq)]
enum Pane {
Outline,
Pages,
Marks,
}
struct Shown {
view: Entity<LokView>,
doc: Doc,
/// Edits and cursor at the last read.
read: Option<(u64, Option<Rect>)>,
/// A read is on its way.
reading: bool,
_observe: Subscription,
}
pub struct Navigator {
docs: WeakEntity<Docs>,
worker: Arc<Mutex<Worker>>,
shown: Option<Shown>,
outline: Vec<Heading>,
marks: Vec<Bookmark>,
pane: Pane,
}
impl EventEmitter<LokError> for Navigator {}
impl Navigator {
pub fn new(docs: WeakEntity<Docs>, worker: Arc<Mutex<Worker>>) -> Self {
Navigator {
docs,
worker,
shown: None,
outline: Vec::new(),
marks: Vec::new(),
pane: Pane::Outline,
}
}
/// Follows the active tab; reads the lists after edits.
pub fn show(&mut self, tab: Option<(&Entity<LokView>, &Doc)>, cx: &mut Context<Self>) {
let id = tab.map(|(view, _)| view.entity_id());
if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id {
return;
}
self.shown = None;
self.outline.clear();
self.marks.clear();
cx.notify();
let Some((view, doc)) = tab else {
return;
};
let observe = cx.subscribe(view, |navigator, _, _: &Reported, cx| navigator.refresh(cx));
self.shown = Some(Shown {
view: view.clone(),
doc: doc.clone(),
read: None,
reading: false,
_observe: observe,
});
self.refresh(cx);
}
/// Rereads the lists after edits or moves.
fn refresh(&mut self, cx: &mut Context<Self>) {
let Some(shown) = self.shown.as_mut() else {
return;
};
let view = shown.view.read(cx);
let seen = Some((view.edited(), view.cursor()));
if shown.reading || shown.read == seen {
return;
}
(shown.read, shown.reading) = (seen, true);
let reader = shown.view.entity_id();
ely_lok::read_off(
&self.worker,
&shown.doc,
|worker, doc| {
let json = worker.values(doc, ".uno:Outline")?;
let outline = ely_lok::review::outline(&json)?;
let json = worker.values(doc, ".uno:Bookmarks")?;
Ok((outline, ely_lok::review::bookmarks(&json)?))
},
move |navigator: &mut Self, read, cx| {
let Some(shown) = navigator.shown.as_mut() else {
return;
};
if shown.view.entity_id() != reader {
return;
}
shown.reading = false;
navigator.take_lists(read, cx);
navigator.refresh(cx);
},
cx,
);
}
fn take_lists(
&mut self,
read: std::io::Result<(Vec<Heading>, Vec<Bookmark>)>,
cx: &mut Context<Self>,
) {
match read {
Ok((outline, marks)) => {
let (placed, hidden): (Vec<_>, Vec<_>) =
marks.into_iter().partition(|mark| mark.rect.is_some());
log::info!(
"doc: {} headings, {} bookmarks, {} not laid out",
outline.len(),
placed.len(),
hidden.len()
);
self.outline = outline;
self.marks = placed;
}
Err(error) => cx.emit(LokError(format!(
"the navigator lists are unreadable: {error}"
))),
}
cx.notify();
}
fn jump(docs: &WeakEntity<Docs>, to: Rect, cx: &mut App) {
docs.update(cx, |docs, cx| docs.jump(to, cx))
.expect("the documents live");
}
/// The page that holds the cursor.
fn current_page(&self, pages: &[Rect], cx: &App) -> Option<usize> {
let cursor = self.shown.as_ref()?.view.read(cx).cursor()?;
pages.iter().rposition(|page| page.1 <= cursor.1)
}
}
impl Docs {
/// Goes to `to`; Back returns to where the cursor was.
pub(crate) fn jump(&mut self, to: Rect, cx: &mut Context<Self>) {
self.mark_place(cx);
self.go(to, cx);
}
/// Remembers the place of the active tab for Back.
pub(crate) fn mark_place(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let view = tab.view.read(cx);
let Some(cursor) = view.cursor() else {
return;
};
// A jump the cursor has not caught up with.
let place = match self.going {
Some((id, seq, moves, to))
if id == tab.id && (!view.reached(seq) || view.moves() == moves) =>
{
to
}
_ => cursor,
};
self.back.push((tab.id, place));
cx.notify();
}
/// Puts the active tab's cursor at `to`.
fn go(&mut self, to: Rect, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let (id, view) = (tab.id, tab.view.clone());
let moves = view.read(cx).moves();
let seq = view.update(cx, |view, cx| {
view.reveal(to, cx);
view.sent()
});
self.going = Some((id, seq, moves, to));
}
pub fn on_back(&mut self, _: &GoBack, _: &mut Window, cx: &mut Context<Self>) {
let Some((id, place)) = self.back.pop() else {
return;
};
if self.tab(id).is_none() {
let text = Docs::text(cx, "doc.nav.gone", &[]);
return self.toast(text, Severity::Warning, cx);
}
log::info!("doc: back to tab {id}");
self.core.active = Some(id);
self.go(place, cx);
cx.notify();
}
pub fn on_navigator(&mut self, _: &ToggleNavigator, _: &mut Window, cx: &mut Context<Self>) {
self.navigating = !self.navigating;
cx.notify();
}
}
impl Render for Navigator {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let this = cx.entity();
let text = |key: &str| Docs::text(cx, key, &[]);
let docs = self.docs.clone();
let back = docs
.upgrade()
.is_some_and(|docs| !docs.read(cx).back.is_empty());
let head = Button::new("doc-nav-back", text("doc.nav.back"))
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.icon(IconName::ArrowLeft)
.disabled(!back)
.on_click(move |_, window, cx| window.dispatch_action(Box::new(GoBack), cx));
let pane = SegmentedControl::new(
"doc-nav-pane",
match self.pane {
Pane::Outline => "outline",
Pane::Pages => "pages",
Pane::Marks => "marks",
},
)
.size(ControlSize::Sm)
.segment("outline", text("doc.nav.outline"), Some(IconName::List))
.segment("pages", text("doc.nav.pages"), Some(IconName::FileText))
.segment("marks", text("doc.nav.marks"), Some(IconName::Bookmark))
.on_change({
let this = this.clone();
move |value, _, cx| {
this.update(cx, |navigator, cx| {
navigator.pane = match value.as_ref() {
"pages" => Pane::Pages,
"marks" => Pane::Marks,
_ => Pane::Outline,
};
cx.notify();
})
}
});
let body = match self.pane {
Pane::Outline => {
let mut outline = Outline::new("doc-nav-outline");
for (at, heading) in self.outline.iter().enumerate() {
let level = usize::try_from(heading.level).expect("a level") + 1;
outline = outline.item(at.to_string(), heading.text.clone(), level);
}
if let Some(at) = self.outline.iter().position(|heading| heading.current) {
outline = outline.current(at.to_string());
}
let this = this.clone();
outline
.on_select(move |key, _, cx| {
let at: usize = key.parse().expect("an outline index");
let (to, docs) = {
let navigator = this.read(cx);
(navigator.outline[at].rect, navigator.docs.clone())
};
Navigator::jump(&docs, to, cx);
})
.into_any_element()
}
Pane::Pages => {
let pages = self
.shown
.as_ref()
.map(|shown| shown.view.read(cx).pages().to_vec())
.unwrap_or_default();
let current = self.current_page(&pages, cx);
let rows = pages.iter().enumerate().map(|(at, &(x, y, _, _))| {
let docs = docs.clone();
let label: SharedString =
Docs::text(cx, "doc.nav.page", &[("n", &(at + 1).to_string())]);
ListItem::new(("doc-nav-page", at), label)
.selected(current == Some(at))
.on_click(move |_, _, cx| Navigator::jump(&docs, (x, y, 0, 0), cx))
});
List::new().children(rows).into_any_element()
}
Pane::Marks => {
let rows = self.marks.iter().enumerate().map(|(at, mark)| {
let docs = docs.clone();
let to = mark.rect.expect("listed marks are placed");
ListItem::new(("doc-nav-mark", at), mark.name.clone())
.on_click(move |_, _, cx| Navigator::jump(&docs, to, cx))
});
List::new().children(rows).into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.gap_2()
.p_2()
.child(head)
.child(pane)
.child(
ScrollArea::new("doc-nav-scroll").vertical()
.flex_1()
.min_h_0()
.child(body),
)
}
}
#[cfg(test)]
mod tests;
+254
View File
@@ -0,0 +1,254 @@
use gpui::{Entity, TestAppContext};
use super::*;
use crate::tests::{docs, settle, settled, text_of, with_window, written};
use ely_office::Dialog;
/// Three pages, two headings, a font the system lacks.
const LONG: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:font-face-decls><style:font-face style:name="Nowhere Sans" svg:font-family="&apos;Nowhere Sans&apos;"/></office:font-face-decls>
<office:automatic-styles>
<style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:break-before="page"/></style:style>
<style:style style:name="T1" style:family="text"><style:text-properties style:font-name="Nowhere Sans"/></style:style>
</office:automatic-styles>
<office:body><office:text>
<text:h text:outline-level="1">First part</text:h><text:p>alpha <text:span text:style-name="T1">odd</text:span></text:p>
<text:h text:style-name="P1" text:outline-level="1">Second part</text:h><text:p>bravo</text:p>
<text:p text:style-name="P1">charlie</text:p>
</office:text></office:body>
</office:document>"#;
/// Two headings side by side in two columns.
const COLUMNS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:automatic-styles>
<style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:break-before="column"/></style:style>
<style:page-layout style:name="pm1"><style:page-layout-properties><style:columns fo:column-count="2" fo:column-gap="0.5cm"/></style:page-layout-properties></style:page-layout>
</office:automatic-styles>
<office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles>
<office:body><office:text>
<text:h text:outline-level="1">First part</text:h><text:p>alpha</text:p>
<text:h text:style-name="P1" text:outline-level="1">Second part</text:h><text:p>bravo</text:p>
</office:text></office:body>
</office:document>"#;
fn open_long(cx: &mut TestAppContext, docs: &Entity<Docs>, dir: &std::path::Path) -> u64 {
open_text(cx, docs, &dir.join("long.fodt"), LONG)
}
fn open_text(
cx: &mut TestAppContext,
docs: &Entity<Docs>,
path: &std::path::Path,
text: &str,
) -> u64 {
let path = path.to_path_buf();
std::fs::write(&path, text).expect("the file writes");
with_window(cx, docs, |docs, _, cx| docs.open_file(path.clone(), cx));
settle(cx, |app| docs.read(app).core.tabs.len() == 1);
settled(cx, docs);
cx.update(|app| docs.read(app).core.tabs[0].id)
}
fn cursor_y(app: &gpui::App, docs: &Entity<Docs>) -> Option<i32> {
let tab = docs.read(app).active_tab()?;
tab.view.read(app).cursor().map(|cursor| cursor.1)
}
#[test]
fn headings_and_pages_jump_and_back_returns() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open_long(&mut cx, &docs, dir.path());
settle(&cx, |app| {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).missing_fonts() == ["Nowhere Sans"]
&& tab.view.read(app).cursor().is_some()
});
let navigator = cx.update(|app| docs.read(app).navigator.clone());
cx.update(|app| {
let (view, doc) = {
let tab = docs.read(app).active_tab().expect("a tab");
(tab.view.clone(), tab.doc.clone())
};
navigator.update(app, |navigator, cx| navigator.show(Some((&view, &doc)), cx));
});
settle(&cx, |app| navigator.read(app).outline.len() == 2);
let current = |app: &gpui::App| {
let navigator = navigator.read(app);
navigator.outline.iter().position(|heading| heading.current)
};
settle(&cx, |app| current(app) == Some(0));
let start = cx.update(|app| cursor_y(app, &docs)).expect("a cursor");
let (first, second) = cx.update(|app| {
let outline = &navigator.read(app).outline;
(outline[0].rect, outline[1].rect)
});
let third = cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).pages()[2]
});
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.jump(second, cx);
docs.jump((third.0, third.1, 0, 0), cx);
})
});
settle(&cx, |app| {
cursor_y(app, &docs).is_some_and(|y| y >= third.1)
});
assert_eq!(
cx.update(|app| docs.read(app).back.last().map(|place| place.1)),
Some(second),
"a jump in flight is the place"
);
settle(&cx, |app| current(app) == Some(1));
let between =
|app: &gpui::App| cursor_y(app, &docs).is_some_and(|y| y >= second.1 && y < third.1);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_back(&GoBack, window, cx)
});
settle(&cx, between);
cx.update(|app| docs.update(app, |docs, cx| docs.jump(first, cx)));
settle(&cx, |app| current(app) == Some(0));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_back(&GoBack, window, cx)
});
settle(&cx, between);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_back(&GoBack, window, cx)
});
settle(&cx, |app| {
cursor_y(app, &docs).is_some_and(|y| y <= start + 300)
});
let home = cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).cursor().expect("a cursor")
});
cx.update(|app| docs.update(app, |docs, cx| docs.jump(second, cx)));
settle(&cx, between);
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
cx.update(|app| view.update(app, |view, cx| view.reveal(home, cx)));
settle(&cx, |app| {
cursor_y(app, &docs).is_some_and(|y| y <= start + 300)
});
cx.update(|app| docs.update(app, |docs, cx| docs.jump((third.0, third.1, 0, 0), cx)));
settle(&cx, |app| {
cursor_y(app, &docs).is_some_and(|y| y >= third.1)
});
assert_eq!(
cx.update(|app| docs.read(app).back.last().map(|place| place.1)),
Some(home),
"a landed jump is no longer the place"
);
}
#[test]
fn the_current_heading_follows_the_cursor_across_columns() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open_text(&mut cx, &docs, &dir.path().join("columns.fodt"), COLUMNS);
let navigator = cx.update(|app| docs.read(app).navigator.clone());
cx.update(|app| {
let (view, doc) = {
let tab = docs.read(app).active_tab().expect("a tab");
(tab.view.clone(), tab.doc.clone())
};
navigator.update(app, |navigator, cx| navigator.show(Some((&view, &doc)), cx));
});
let current = |app: &gpui::App| {
let navigator = navigator.read(app);
navigator.outline.iter().position(|heading| heading.current)
};
settle(&cx, |app| current(app) == Some(0));
let (first, second) = cx.update(|app| {
let outline = &navigator.read(app).outline;
(outline[0].rect, outline[1].rect)
});
assert_eq!(first.1, second.1, "the headings sit side by side");
cx.update(|app| docs.update(app, |docs, cx| docs.jump(second, cx)));
settle(&cx, |app| current(app) == Some(1));
cx.update(|app| docs.update(app, |docs, cx| docs.jump(first, cx)));
settle(&cx, |app| current(app) == Some(0));
}
#[cfg(unix)]
#[test]
fn pdfs_export_a_range_and_locked_files_ask() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = open_long(&mut cx, &docs, dir.path());
let pdf = dir.path().join("two.pdf");
let only_two = ely_lok::Pdf {
pages: Some((2, 2)),
comments: false,
changes: true,
handouts: 0,
};
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.export_to(id, pdf.clone(), only_two, cx)
})
});
written(&cx, &pdf, None);
let source = dir.path().join("long.fodt");
let alias = dir.path().join("alias.pdf");
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
let link = dir.path().join("link.pdf");
std::fs::hard_link(&source, &link).expect("the hard link");
for target in [&source, &alias, &link] {
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.export_to(id, target.clone(), ely_lok::Pdf::default(), cx)
})
});
}
assert_eq!(
std::fs::read_to_string(&source).expect("the source reads"),
LONG,
"an open source is never replaced"
);
let locked = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/ely-lok/src/client/tests/locked.odt");
let copy = dir.path().join("locked.odt");
std::fs::copy(&locked, &copy).expect("the fixture copies");
let asked = |app: &gpui::App, wrong: bool| matches!(&docs.read(app).core.dialog, Some(Dialog::Password { wrong: was, .. }) if *was == wrong);
with_window(&mut cx, &docs, |docs, _, cx| {
docs.open_file(copy.clone(), cx)
});
settle(&cx, |app| asked(app, false));
// As submitting does: the prompt closes, then the load.
with_window(&mut cx, &docs, |docs, _, cx| {
docs.core.dialog = None;
docs.open_with(copy.clone(), "wrong".into(), cx)
});
settle(&cx, |app| asked(app, true));
with_window(&mut cx, &docs, |docs, _, cx| {
docs.core.dialog = None;
docs.open_with(copy.clone(), "secret".into(), cx)
});
settle(&cx, |app| docs.read(app).core.tabs.len() == 2);
let out = dir.path().join("opened.odt");
let opened = cx.update(|app| docs.read(app).core.tabs[1].id);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(opened, out.clone(), None, window, cx)
});
written(&cx, &out, None);
assert!(
text_of(&mut cx, &docs, &out).contains("Teh cat"),
"the locked text opens"
);
}
+259
View File
@@ -0,0 +1,259 @@
//! Export to PDF and print (DOC-15).
use std::path::PathBuf;
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
forms::{NumberInput, Switch},
overlays::Dialog as Card,
primitives::Severity,
theme::ControlSize,
};
use ely_lok::Pdf;
use ely_ui::commands::{Export, Print};
use gpui::{App, Context, Entity, ParentElement as _, Styled as _, TaskExt as _, Window, div};
use crate::actions::Dialog;
use ely_office::in_window;
use ely_office::prelude::*;
use crate::docs::Docs;
impl Docs {
pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context<Self>) {
self.ask_output(false, cx);
}
pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context<Self>) {
self.ask_output(true, cx);
}
fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let last = u32::try_from(tab.view.read(cx).pages().len()).expect("a page count");
self.dialog = Some(Dialog::Output {
tab: tab.id,
print,
all: true,
pdf: Pdf {
pages: Some((1, last)),
comments: false,
changes: true,
handouts: 0,
},
});
cx.notify();
}
/// Writes the asked PDF, then saves or prints it.
pub(crate) fn output(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Output {
tab,
print,
all,
pdf,
}) = self.dialog.take()
else {
return;
};
cx.notify();
let pdf = Pdf {
pages: pdf.pages.filter(|_| !all),
..pdf
};
if !self.settled(tab, cx) {
self.dialog = Some(Dialog::Output {
tab,
print,
all,
pdf,
});
return self.when_settled(tab, |docs, _, cx| docs.output(cx));
}
let Some(source) = self.tab(tab) else {
return;
};
if print {
let path = source.work.join("print.pdf");
let printing = path.clone();
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
docs.output_failed(&error, cx)
}),
Err(error) => docs.output_failed(&error.to_string(), cx),
});
}
let Some(dir) = ely_office::folder(source) else {
return self.panel_failed("no documents folder", cx);
};
let stem = source
.name
.rsplit_once('.')
.map_or(source.name.as_ref(), |(stem, _)| stem);
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.pdf")));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |docs, _, cx| match chosen {
Ok(Some(path)) => docs.export_to(tab, path, pdf, cx),
Ok(None) => log::info!("doc: the export panel was cancelled"),
Err(error) => docs.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
pub(crate) fn export_to(&mut self, tab: u64, path: PathBuf, pdf: Pdf, cx: &mut Context<Self>) {
match self.holder(&path) {
Ok(None) => {}
Ok(Some(_)) => {
log::info!("doc: the export target is an open document");
let name = ely_office::files::file_name(&path);
let text = Docs::text(cx, "doc.output.open", &[("name", &name)]);
return self.toast(text, Severity::Danger, cx);
}
Err(error) => return self.output_failed(&error.to_string(), cx),
}
let (name, opened) = (ely_office::files::file_name(&path), path.clone());
self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
Ok(()) => {
let text = Docs::text(cx, "doc.output.exported", &[("name", &name)]);
docs.toast_output(text, opened.clone(), cx);
}
Err(error) => docs.output_failed(&error.to_string(), cx),
});
}
/// Writes the PDF in the background; `then` takes the outcome.
fn write_pdf(
&mut self,
tab: u64,
path: PathBuf,
pdf: Pdf,
cx: &mut Context<Self>,
then: impl FnOnce(&mut Self, std::io::Result<()>, &mut Context<Self>) + 'static,
) {
let Some(name) = self.tab(tab).map(|tab| tab.name.clone()) else {
return self.output_failed("the document closed", cx);
};
log::info!("doc: writing a PDF of {name} ({pdf:?})");
let url = ely_lok::file_url(&path);
self.read_in(
tab,
move |worker, doc| doc.export_pdf(worker, &url, &pdf),
move |docs, written, _, cx| then(docs, written, cx),
cx,
);
}
fn output_failed(&self, error: &str, cx: &mut Context<Self>) {
let text = Docs::text(cx, "doc.output.failed", &[("error", error)]);
self.toast(text, Severity::Danger, cx);
}
}
/// Edits the open output dialog.
fn change(this: &Entity<Docs>, cx: &mut App, edit: impl FnOnce(&mut bool, &mut Pdf)) {
this.update(cx, |docs, cx| {
if let Some(Dialog::Output { all, pdf, .. }) = &mut docs.dialog {
edit(all, pdf);
}
cx.notify();
})
}
/// The dialog before a PDF goes out: pages, comments, changes.
pub fn output_dialog(
this: Entity<Docs>,
(print, all, pdf, last): (bool, bool, Pdf, u32),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let text = |key: &str| Docs::text(cx, key, &[]);
let (from, to) = pdf.pages.unwrap_or((1, last));
let pages = {
let this = this.clone();
SegmentedControl::new("doc-output-pages", if all { "all" } else { "range" })
.size(ControlSize::Sm)
.segment("all", text("doc.output.all"), None)
.segment("range", text("doc.output.range"), None)
.on_change(move |value, _, cx| {
change(&this, cx, |all, _| *all = value.as_ref() == "all")
})
};
let bound = |id: &'static str, value: u32, first: bool| {
let this = this.clone();
NumberInput::new(id, f64::from(value))
.range(1.0, f64::from(last))
.on_commit(move |value, _, cx| {
let value = value.round() as u32;
change(&this, cx, |_, pdf| {
let (from, to) = pdf.pages.unwrap_or((1, value));
pdf.pages = Some(if first {
(value, to.max(value))
} else {
(from.min(value), value)
});
})
})
};
let range = (!all).then(|| {
div()
.flex()
.items_center()
.gap_2()
.child(div().w_full().max_w_24().child(bound("doc-output-from", from, true)))
.child("–")
.child(div().w_full().max_w_24().child(bound("doc-output-to", to, false)))
});
let toggle = |id: &'static str, key: &str, on: bool, set: fn(&mut Pdf, bool)| {
let this = this.clone();
Switch::new(id, on)
.label(text(key))
.on_change(move |on, _, cx| change(&this, cx, |_, pdf| set(pdf, on)))
};
let fields = DescriptionList::new()
.item(
text("doc.output.pages"),
div().flex().flex_col().gap_2().child(pages).children(range),
)
.item(
text("doc.output.marks"),
div()
.flex()
.flex_col()
.gap_2()
.child(toggle(
"doc-output-comments",
"doc.output.comments",
pdf.comments,
|pdf, on| pdf.comments = on,
))
.child(toggle(
"doc-output-changes",
"doc.output.changes",
pdf.changes,
|pdf, on| pdf.changes = on,
)),
);
let (title, go) = match print {
true => ("doc.output.print_title", "doc.output.print"),
false => ("doc.output.export_title", "doc.output.export"),
};
let label = text(go);
Card::new("doc-output", text(title), dismiss)
.child(fields)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("doc-output-go", label)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.output(cx)))
})
}
+256
View File
@@ -0,0 +1,256 @@
//! The properties panel (DOC-02 to 08).
mod groups;
#[cfg(test)]
mod layout_tests;
mod object;
mod page;
mod pairs;
mod paragraph;
mod sheet;
mod styles;
mod table;
#[cfg(test)]
mod tests;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use ely_gpui_component::{
data_display::PropertyGrid, forms::TextInput, layout::ScrollArea, overlays::PromptDialog,
};
use ely_lok::{Doc, LokError, LokView, Reported, Worker};
use ely_office::panel::Panel as _;
use gpui::{
App, Context, Entity, EventEmitter, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, Subscription, Window, div,
};
use crate::docs::Docs;
/// The active document the panel edits.
struct Shown {
view: Entity<LokView>,
doc: Doc,
paragraph: Vec<SharedString>,
character: Vec<SharedString>,
pages: Vec<SharedString>,
_observe: Subscription,
_errors: Subscription,
}
pub struct Props {
worker: Arc<Mutex<Worker>>,
shown: Option<Shown>,
fonts: Vec<SharedString>,
/// Re-read the styles once input lands.
reload: bool,
/// The family a new style goes to: "1" or "2".
naming: Option<&'static str>,
tab_at: f64,
/// Two-value edits awaiting the kernel's echo.
pending: HashMap<&'static str, pairs::Sent>,
/// Page edits awaiting the kernel's echo.
sheet_sent: Option<sheet::Sent>,
}
impl EventEmitter<LokError> for Props {}
impl Props {
pub fn new(worker: Arc<Mutex<Worker>>) -> Self {
Props {
worker,
shown: None,
fonts: Vec::new(),
reload: false,
naming: None,
tab_at: 1.0,
pending: HashMap::new(),
sheet_sent: None,
}
}
/// Follows the active tab; reads the kernel lists on change.
pub fn show(&mut self, tab: Option<(&Entity<LokView>, &Doc)>, cx: &mut Context<Self>) {
let id = tab.map(|(view, _)| view.entity_id());
if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id {
return;
}
self.shown = None;
self.naming = None;
self.pending.clear();
self.sheet_sent = None;
let Some((view, doc)) = tab else {
return;
};
let observe = cx.subscribe(view, |props, view, _: &Reported, cx| {
if props.reload && view.read(cx).settled() {
props.reload = false;
props.read_styles(cx);
}
props.track_pairs(cx);
props.track_sheet(cx);
cx.notify();
});
let errors = cx.subscribe(view, |props, _, _: &LokError, _| {
props.pending.clear();
props.sheet_sent = None;
});
self.shown = Some(Shown {
view: view.clone(),
doc: doc.clone(),
paragraph: Vec::new(),
character: Vec::new(),
pages: Vec::new(),
_observe: observe,
_errors: errors,
});
if self.fonts.is_empty() {
self.read_fonts(doc, cx);
}
self.read_styles(cx);
}
fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) {
let read = self
.worker
.lock()
.expect("the worker lock")
.values(doc, ".uno:CharFontName")
.and_then(|json| ely_lok::commands::fonts(&json));
match read {
Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(),
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
}
}
fn read_styles(&mut self, cx: &mut Context<Self>) {
let Some(shown) = self.shown.as_mut() else {
return;
};
let read = self
.worker
.lock()
.expect("the worker lock")
.values(&shown.doc, ".uno:StyleApply")
.and_then(|json| {
let list = |family| -> std::io::Result<Vec<SharedString>> {
let names = ely_lok::commands::styles(&json, family)?;
Ok(names.into_iter().map(Into::into).collect())
};
Ok((list("2")?, list("1")?, list("8")?))
});
match read {
Ok((paragraph, character, pages)) => {
shown.paragraph = paragraph;
shown.character = character;
shown.pages = pages;
}
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
}
}
/// Re-reads the style lists once queued input lands.
pub fn refresh_lists(&mut self) {
self.reload = true;
}
/// Sends one table command to the shown document.
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
let Some(shown) = &self.shown else {
return;
};
log::info!("doc: panel runs {id}");
// Numbers here, like tab stops, are as the view shows.
let sent = shown.view.update(cx, |view, _| view.run_at(&view.read_at(), id, values));
if let Err(error) = sent {
cx.emit(LokError(format!("{id}: {error}")));
}
}
/// A style from the selection; lists reload after.
fn create_style(&mut self, name: &str, family: &'static str, cx: &mut Context<Self>) {
self.naming = None;
self.reload = true;
self.run("style.new", &[name, family], cx);
cx.notify();
}
/// The new-style prompt, while open.
fn prompt(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<PromptDialog> {
let (family, shown) = (self.naming?, self.shown.as_ref()?);
let field = window.use_keyed_state("doc-style-name", cx, |window, cx| {
TextInput::new(window, cx)
});
let taken = match family {
"1" => shown.character.clone(),
_ => shown.paragraph.clone(),
};
let title = match family {
"1" => "doc.style.new_character",
_ => "doc.style.new_paragraph",
};
let this = cx.entity();
let close = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |props, cx| {
props.naming = None;
cx.notify();
})
}
};
let (empty, exists) = (
Docs::text(cx, "doc.style.empty", &[]),
Docs::text(cx, "doc.style.taken", &[]),
);
Some(
PromptDialog::new("doc-style-new", Docs::text(cx, title, &[]), &field, close)
.label(Docs::text(cx, "doc.style.name", &[]))
.submit(Docs::text(cx, "doc.style.create", &[]))
.check(move |text| {
let name = text.trim();
if name.is_empty() {
return Err(empty.clone());
}
if taken.iter().any(|style| style == name) {
return Err(exists.clone());
}
Ok(())
})
.on_submit(move |text, _, cx| {
let name = text.trim().to_string();
this.update(cx, |props, cx| props.create_style(&name, family, cx))
}),
)
}
}
impl Render for Props {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let graphic = self
.shown
.as_ref()
.is_some_and(|shown| shown.view.read(cx).graphic().is_some());
let grid = PropertyGrid::new("doc-props");
let grid = if graphic {
grid.group(self.object_group(cx))
} else {
let grid = grid
.group(self.text_group(window, cx))
.group(self.paragraph_group(cx))
.group(self.style_group(cx))
.group(self.page_group(cx));
match self.state(".uno:DeleteTable", cx).as_deref() {
Some("enabled") => grid.group(self.table_group(cx)),
_ => grid,
}
};
div()
.size_full()
.child(ScrollArea::new("doc-props-scroll").vertical().size_full().child(grid))
.children(self.prompt(window, cx))
}
}
+96
View File
@@ -0,0 +1,96 @@
//! The panel's text group and its kernel hookup.
use ely_gpui_component::{data_display::PropertyGroup, primitives::IconName};
use ely_office::panel::{Panel, color_state, font_box, palette, row, size_menu, toggle};
use gpui::{App, Context, SharedString, Window};
use super::Props;
use crate::docs::Docs;
/// The swatch's label without a color.
const AUTOMATIC: &str = "office.props.automatic";
impl Panel for Props {
fn state(&self, uno: &str, cx: &App) -> Option<SharedString> {
let view = self.shown.as_ref()?.view.read(cx);
view.state(uno)
.map(|state| SharedString::from(state.to_string()))
}
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
Props::run(self, id, values, cx);
}
}
impl Props {
pub(super) fn text_group(&self, window: &mut Window, cx: &mut Context<Self>) -> PropertyGroup {
let why = "doc.why.selection";
let effects = row([
toggle(
self,
"format.bold",
(Some(IconName::Bold), ""),
("doc.props.bold", why),
cx,
),
toggle(
self,
"format.italic",
(Some(IconName::Italic), ""),
("doc.props.italic", why),
cx,
),
toggle(
self,
"format.underline",
(Some(IconName::Underline), ""),
("doc.props.underline", why),
cx,
),
toggle(
self,
"format.strikeout",
(Some(IconName::Strikethrough), ""),
("doc.props.strikeout", why),
cx,
),
toggle(
self,
"format.superscript",
(None, "x²"),
("doc.props.superscript", why),
cx,
),
toggle(
self,
"format.subscript",
(None, "x₂"),
("doc.props.subscript", why),
cx,
),
]);
let this = cx.entity();
let color = color_state(self, ".uno:FontColor", cx);
let highlight = color_state(self, ".uno:CharBackColor", cx);
let font = font_box(self, "doc-props-font", &self.fonts, window, cx);
let tip = |key: &str| Docs::text(cx, key, &[]);
PropertyGroup::new(tip("doc.props.text"))
.row(tip("doc.props.font"), font)
.row(tip("doc.props.size"), size_menu(self, "doc-props-size", cx))
.row(tip("doc.props.effects"), effects)
.row(
tip("doc.props.color"),
palette(&this, "format.color", (color, AUTOMATIC), None, cx),
)
.row(
tip("doc.props.highlight"),
palette(
&this,
"format.highlight",
(highlight, AUTOMATIC),
Some(("doc.props.no_highlight", "format.highlight", "-1")),
cx,
),
)
}
}
+296
View File
@@ -0,0 +1,296 @@
use gpui::{Entity, TestAppContext};
use super::Props;
use super::tests::panel;
use crate::docs::Docs;
use crate::insert::InsertPageNumber;
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
use ely_office::panel::Panel as _;
use ely_office::prelude::*;
fn state(cx: &TestAppContext, props: &Entity<Props>, uno: &str) -> Option<String> {
cx.update(|app| {
props
.read(app)
.state(uno, app)
.map(|state| state.to_string())
})
}
#[test]
fn the_page_turns_resizes_and_keeps_quick_margins() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Page");
let props = panel(&mut cx, &docs);
settle(&cx, |app| props.read(app).sheet(app).is_some());
cx.update(|app| props.update(app, |props, cx| props.set_orientation(true, cx)));
settle(&cx, |app| {
let props = props.read(app);
let size = props.state(".uno:AttributePageSize", app);
let size = size.as_deref().and_then(super::sheet::size_of);
props
.state(".uno:AttributePage", app)
.is_some_and(|page| page.starts_with("true,"))
&& size.is_some_and(|(w, h)| w > h)
});
cx.update(|app| props.update(app, |props, cx| props.set_paper("A5", cx)));
settle(&cx, |app| {
let size = props.read(app).state(".uno:AttributePageSize", app);
size.as_deref()
.and_then(super::sheet::size_of)
.is_some_and(|(w, h)| (w - 21000).abs() <= 2 && (h - 14800).abs() <= 2)
});
let margins = (".uno:PageLRMargin", "page.margins_lr");
let twips = |cm: f64| (cm * 1440.0 / 2.54).round() as i32;
cx.update(|app| {
props.update(app, |props, cx| {
props.set_pair(margins, false, twips(2.0), cx);
props.set_pair(margins, true, twips(3.0), cx);
})
});
settle(&cx, |app| {
props.read(app).state(".uno:PageLRMargin", app).as_deref() == Some("2000,3000")
});
settled(&cx, &docs);
let path = dir.path().join("page.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
assert!(
text.contains("style:print-orientation=\"landscape\""),
"landscape lands"
);
assert!(
text.contains("fo:page-width=\"8.2681in\""),
"A5 across lands"
);
assert!(
text.contains("fo:margin-left=\"0.7874in\" fo:margin-right=\"1.1811in\""),
"margins land"
);
}
#[test]
fn insert_actions_and_object_size_land() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Insert");
let props = panel(&mut cx, &docs);
settle(&cx, |app| {
props.read(app).state(".uno:PageStyleName", app).is_some()
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_page_number(&InsertPageNumber, window, cx);
docs.insert_section(cx);
docs.insert_table(3.0, 2.0, cx);
});
settle(&cx, |app| {
props.read(app).state(".uno:DeleteTable", app).as_deref() == Some("enabled")
});
let mut png = Vec::new();
image::RgbaImage::from_pixel(300, 200, image::Rgba([0, 0, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("the png encodes");
let image = dir.path().join("blue.png");
std::fs::write(&image, png).expect("the png writes");
let url = ely_lok::file_url(&image);
cx.update(|app| props.update(app, |props, cx| props.run("image.insert", &[&url], cx)));
settle(&cx, |app| {
props
.read(app)
.state(".uno:Size", app)
.is_some_and(|s| s.contains(" x "))
});
settle(&cx, |app| {
docs.read(app)
.active_tab()
.expect("a tab")
.view
.read(app)
.graphic()
.is_some()
});
let before = state(&cx, &props, ".uno:Size").expect("a size");
let (w0, h0) = super::sheet::size_of(&before).expect("parsed");
cx.update(|app| props.update(app, |props, cx| props.set_extent(false, 2.0, cx)));
settle(&cx, |app| {
let size = props.read(app).state(".uno:Size", app);
size.as_deref()
.and_then(super::sheet::size_of)
.is_some_and(|(w, _)| (w - 2000).abs() <= 3)
});
let (w, h) =
super::sheet::size_of(&state(&cx, &props, ".uno:Size").expect("a size")).expect("parsed");
let ratio = (f64::from(w) / f64::from(h)) / (f64::from(w0) / f64::from(h0));
assert!(
(ratio - 1.0).abs() < 0.01,
"the ratio holds: {w}x{h} from {w0}x{h0}"
);
settled(&cx, &docs);
let path = dir.path().join("insert.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
for mark in [
"<text:page-number",
"<table:table ",
"<draw:frame",
"style:master-page-name=",
] {
assert!(text.contains(mark), "{mark} lands");
}
}
#[test]
fn sections_footers_and_tall_images_hold_up() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "First");
let props = panel(&mut cx, &docs);
settle(&cx, |app| {
props.read(app).state(".uno:PageStyleName", app).is_some()
});
cx.update(|app| docs.update(app, |docs, cx| docs.insert_section(cx)));
let name = cx.update(|app| Docs::text(app, "doc.section.name", &[("n", "2")]));
settle(&cx, |app| {
props.read(app).state(".uno:PageStyleName", app).as_deref() == Some(&*name)
});
type_into(&mut cx, &docs, "Second");
for (command, values) in [
("page.columns", vec!["2"]),
("footer.on", vec!["true"]),
("footer.layout", vec!["2"]),
] {
cx.update(|app| props.update(app, |props, cx| props.run(command, &values, cx)));
}
settle(&cx, |app| {
props
.read(app)
.state(".uno:SwPageFooterLayout", app)
.as_deref()
== Some("2")
});
let mut png = Vec::new();
image::RgbaImage::from_pixel(20, 400, image::Rgba([0, 0, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("the png encodes");
let image = dir.path().join("tall.png");
std::fs::write(&image, png).expect("the png writes");
let url = ely_lok::file_url(&image);
cx.update(|app| props.update(app, |props, cx| props.run("image.insert", &[&url], cx)));
settle(&cx, |app| {
docs.read(app)
.active_tab()
.expect("a tab")
.view
.read(app)
.graphic()
.is_some()
});
// A person takes this long to reach the panel.
std::thread::sleep(std::time::Duration::from_millis(500));
cx.update(|app| {
props.update(app, |props, cx| {
props.run("image.size", &["58", "5670"], cx)
})
});
settle(&cx, |app| {
let size = props.read(app).state(".uno:Size", app);
size.as_deref()
.and_then(super::sheet::size_of)
.is_some_and(|(_, h)| (h - 10001).abs() <= 3)
});
cx.update(|app| props.update(app, |props, cx| props.set_extent(true, 10.1, cx)));
settle(&cx, |app| {
let size = props.read(app).state(".uno:Size", app);
size.as_deref()
.and_then(super::sheet::size_of)
.is_some_and(|(_, h)| (h - 10100).abs() <= 3)
});
settled(&cx, &docs);
let path = dir.path().join("sections.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
assert_eq!(
text.matches("fo:column-count=\"2\"").count(),
1,
"only the second section has columns"
);
assert!(
text.matches("style:master-page-name=").count() >= 1,
"a section starts"
);
assert!(
text.contains("<style:footer-left>"),
"the footer differs on even pages"
);
}
#[test]
fn queued_page_edits_footers_and_new_sections_show_up() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Page");
let props = panel(&mut cx, &docs);
settle(&cx, |app| props.read(app).sheet(app).is_some());
cx.update(|app| {
props.update(app, |props, cx| {
props.set_paper("A5", cx);
props.set_orientation(true, cx);
})
});
settle(&cx, |app| {
let props = props.read(app);
let size = props.state(".uno:AttributePageSize", app);
let size = size.as_deref().and_then(super::sheet::size_of);
props
.state(".uno:AttributePage", app)
.is_some_and(|page| page.starts_with("true,"))
&& size.is_some_and(|(w, h)| (w - 21000).abs() <= 2 && (h - 14800).abs() <= 2)
});
cx.update(|app| props.update(app, |props, cx| props.run("footer.on", &["true"], cx)));
settle(&cx, |app| {
props
.read(app)
.state(".uno:SwPageFooterLayout", app)
.as_deref()
== Some("0")
});
cx.update(|app| docs.update(app, |docs, cx| docs.insert_section(cx)));
let name = cx.update(|app| Docs::text(app, "doc.section.name", &[("n", "2")]));
settle(&cx, |app| {
let props = props.read(app);
props
.shown
.as_ref()
.is_some_and(|shown| shown.pages.contains(&name))
});
}
+160
View File
@@ -0,0 +1,160 @@
//! The object group: size, crop, replace, anchor, wrap, order (DOC-08).
use ely_gpui_component::{
buttons::Button, data_display::PropertyGroup, forms::NumberInput, theme::ControlSize,
};
use ely_office::panel::{Panel as _, button, muted, row, toggle};
use gpui::{AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div};
use super::Props;
use super::sheet::size_of;
use crate::docs::Docs;
/// 1/100 mm in whole twips, rounded as the kernel rounds.
fn twips_of(mm100: f64) -> i64 {
((mm100 * 72.0 + 63.0) / 127.0).floor() as i64
}
impl Props {
pub(super) fn object_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
let tip = |key: &str| Docs::text(cx, key, &[]);
let why = "doc.why.selection";
let anchors = row([
toggle(
self,
"image.anchor_page",
(None, &tip("doc.props.to_page")),
("", "doc.why.anchor"),
cx,
),
toggle(
self,
"image.anchor_paragraph",
(None, &tip("doc.props.to_paragraph")),
("", "doc.why.anchor"),
cx,
),
toggle(
self,
"image.anchor_char",
(None, &tip("doc.props.to_char")),
("", "doc.why.anchor"),
cx,
),
toggle(
self,
"image.anchor_as_char",
(None, &tip("doc.props.as_char")),
("", "doc.why.anchor"),
cx,
),
]);
let wraps = row([
toggle(
self,
"image.wrap_off",
(None, &tip("doc.props.wrap_none")),
("", why),
cx,
),
toggle(
self,
"image.wrap_on",
(None, &tip("doc.props.wrap_both")),
("", why),
cx,
),
toggle(
self,
"image.wrap_ideal",
(None, &tip("doc.props.wrap_ideal")),
("", why),
cx,
),
toggle(
self,
"image.wrap_left",
(None, &tip("doc.props.wrap_left")),
("", why),
cx,
),
toggle(
self,
"image.wrap_right",
(None, &tip("doc.props.wrap_right")),
("", why),
cx,
),
toggle(
self,
"image.wrap_through",
(None, &tip("doc.props.wrap_through")),
("", why),
cx,
),
]);
let order = row([
button(self, "image.front", "doc.props.front", why, cx),
button(self, "image.forward", "doc.props.forward", why, cx),
button(self, "image.backward", "doc.props.backward", why, cx),
button(self, "image.back", "doc.props.back", why, cx),
]);
let replace = Button::new("doc-props-replace", tip("doc.props.replace"))
.size(ControlSize::Sm)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(crate::insert::InsertImage), cx)
});
PropertyGroup::new(tip("doc.props.object"))
.row(tip("doc.props.width"), self.extent(false, cx))
.row(tip("doc.props.height"), self.extent(true, cx))
.row(
tip("doc.props.image"),
row([
button(self, "image.crop", "doc.props.crop", why, cx),
replace.into_any_element(),
]),
)
.row(tip("doc.props.anchor"), anchors)
.row(tip("doc.props.wrap"), wraps)
.row(tip("doc.props.order"), order)
}
/// Sizes the image from one side in cm.
pub(super) fn set_extent(&mut self, tall: bool, cm: f64, cx: &mut Context<Self>) {
let Some((width, height)) = self.state(".uno:Size", cx).as_deref().and_then(size_of) else {
return;
};
let mine = if tall { height } else { width };
let scale = cm * 1000.0 / f64::from(mine);
let (w, h) = (f64::from(width) * scale, f64::from(height) * scale);
let unchanged =
twips_of(w) == twips_of(f64::from(width)) && twips_of(h) == twips_of(f64::from(height));
if unchanged {
return;
}
let twips = [twips_of(w).to_string(), twips_of(h).to_string()];
self.run("image.size", &[&twips[0], &twips[1]], cx);
}
/// Width or height in cm, ratio kept.
fn extent(&self, tall: bool, cx: &Context<Self>) -> AnyElement {
let Some((width, height)) = self.state(".uno:Size", cx).as_deref().and_then(size_of) else {
return muted("—".into(), cx);
};
if width <= 0 || height <= 0 {
return muted("—".into(), cx);
}
let this = cx.entity();
let mine = if tall { height } else { width };
let id = SharedString::from(format!("doc-props-extent-{tall}"));
let field = NumberInput::new(id, f64::from(mine) / 1000.0)
.range(0.1, 600.0)
.step(0.1)
.precision(2)
.size(ControlSize::Sm)
.on_commit(move |cm, _, cx| {
this.update(cx, |props, cx| props.set_extent(tall, cm, cx))
});
div().w_full().max_w_32().child(field).into_any_element()
}
}
+291
View File
@@ -0,0 +1,291 @@
//! The page group: paper, margins, columns, sections, headers (DOC-05, 06).
use ely_gpui_component::{
buttons::{ToggleButton, ToggleItem},
data_display::PropertyGroup,
forms::{Choice, NumberInput, Select},
theme::ControlSize,
};
use ely_office::panel::{Panel as _, known, muted, row, send};
use gpui::{AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div};
use super::Props;
use super::sheet::Sheet;
#[cfg(test)]
use super::sheet::{page_of, size_of};
use crate::docs::Docs;
/// Paper sizes offered, portrait, in 1/100 mm.
const PAPERS: [(&str, i32, i32); 6] = [
("A3", 29_700, 42_000),
("A4", 21_000, 29_700),
("A5", 14_800, 21_000),
("B5", 17_600, 25_000),
("Letter", 21_590, 27_940),
("Legal", 21_590, 35_560),
];
const LEFT_RIGHT: (&str, &str) = (".uno:PageLRMargin", "page.margins_lr");
/// A header or footer: switch and layout.
struct Part {
on: (&'static str, &'static str),
layout: (&'static str, &'static str),
why: &'static str,
}
const HEADER: Part = Part {
on: ("header.on", ".uno:SwPageHeaderItem"),
layout: ("header.layout", ".uno:SwPageHeaderLayout"),
why: "doc.why.header",
};
const FOOTER: Part = Part {
on: ("footer.on", ".uno:SwPageFooterItem"),
layout: ("footer.layout", ".uno:SwPageFooterLayout"),
why: "doc.why.footer",
};
const TOP_BOTTOM: (&str, &str) = (".uno:PageULMargin", "page.margins_ul");
/// The offered paper within the kernel's rounding of `size`.
fn paper_of((width, height): (i32, i32)) -> Option<&'static str> {
let (short, long) = (width.min(height), width.max(height));
PAPERS
.iter()
.find(|(_, w, h)| (short - w).abs() <= 2 && (long - h).abs() <= 2)
.map(|(name, ..)| *name)
}
impl Props {
pub(super) fn page_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
let tip = |key: &str| Docs::text(cx, key, &[]);
PropertyGroup::new(tip("doc.props.page"))
.row(tip("doc.props.paper"), self.paper(cx))
.row(tip("doc.props.orientation"), self.orientation(cx))
.row(tip("doc.props.top"), self.margin(TOP_BOTTOM, false, cx))
.row(tip("doc.props.bottom"), self.margin(TOP_BOTTOM, true, cx))
.row(
tip("doc.props.margin_left"),
self.margin(LEFT_RIGHT, false, cx),
)
.row(
tip("doc.props.margin_right"),
self.margin(LEFT_RIGHT, true, cx),
)
.row(tip("doc.props.columns"), self.columns(cx))
.row(tip("doc.props.section"), self.section(cx))
.row(tip("doc.props.header"), self.part(HEADER, cx))
.row(tip("doc.props.footer"), self.part(FOOTER, cx))
}
fn paper(&self, cx: &Context<Self>) -> AnyElement {
let Some(Sheet { size, .. }) = self.sheet(cx) else {
return muted("—".into(), cx);
};
let this = cx.entity();
let choices = PAPERS.iter().map(|(name, ..)| Choice::new(*name, *name));
let cm = |mm100: i32| format!("{:.2}", f64::from(mm100) / 1000.0);
let custom = Docs::text(
cx,
"doc.props.size_cm",
&[("w", &cm(size.0)), ("h", &cm(size.1))],
);
let mut menu = Select::new("doc-props-paper", choices)
.size(ControlSize::Sm)
.placeholder(custom)
.on_change(move |name, _, cx| this.update(cx, |props, cx| props.set_paper(name, cx)));
if let Some(name) = paper_of(size) {
menu = menu.selected(name);
}
menu.into_any_element()
}
fn orientation(&self, cx: &Context<Self>) -> AnyElement {
let Some(Sheet { landscape, .. }) = self.sheet(cx) else {
return muted("—".into(), cx);
};
let face = |wide: bool| {
let this = cx.entity();
let key = if wide {
"doc.props.landscape"
} else {
"doc.props.portrait"
};
let item = ToggleItem::new(key).label(Docs::text(cx, key, &[]));
ToggleButton::new(SharedString::from(key), item, landscape == wide)
.size(ControlSize::Sm)
.on_toggle(move |_, _, cx| {
this.update(cx, |props, cx| props.set_orientation(wide, cx))
})
.into_any_element()
};
row([face(false), face(true)])
}
/// An offered paper, in the page's orientation.
pub(super) fn set_paper(&mut self, name: &str, cx: &mut Context<Self>) {
let Some(sheet) = self.sheet(cx) else {
return;
};
let (_, w, h) = PAPERS
.iter()
.find(|(paper, ..)| *paper == name)
.expect("an offered paper");
let size = if sheet.landscape { (*h, *w) } else { (*w, *h) };
self.send_sheet(Sheet { size, ..sheet }, cx);
}
/// Turns the page, size and flag together.
pub(super) fn set_orientation(&mut self, wide: bool, cx: &mut Context<Self>) {
let Some(sheet) = self.sheet(cx) else {
return;
};
let (width, height) = sheet.size;
let (short, long) = (width.min(height), width.max(height));
let size = if wide { (long, short) } else { (short, long) };
self.send_sheet(
Sheet {
size,
landscape: wide,
..sheet
},
cx,
);
}
/// One page margin in cm.
fn margin(
&self,
pair: (&'static str, &'static str),
second: bool,
cx: &Context<Self>,
) -> AnyElement {
if self.state(pair.0, cx).as_deref() == Some(ely_lok::MIXED) {
return muted(Docs::text(cx, "office.props.mixed", &[]), cx);
}
let Some((first, other)) = self.pair(pair.0, cx) else {
return muted("—".into(), cx);
};
let this = cx.entity();
let mine = if second { other } else { first };
let id = SharedString::from(format!("doc-props-{}-{second}", pair.1));
let field = NumberInput::new(id, f64::from(mine) / 1000.0)
.range(0.0, 99.99)
.step(0.1)
.precision(2)
.size(ControlSize::Sm)
.on_commit(move |cm, _, cx| {
let twips = (cm * 1440.0 / 2.54).round() as i32;
this.update(cx, |props, cx| props.set_pair(pair, second, twips, cx))
});
div().w_full().max_w_32().child(field).into_any_element()
}
fn columns(&self, cx: &Context<Self>) -> AnyElement {
let current = self.state(".uno:PageColumnType", cx);
row((1..=3).map(|count: i32| {
let this = cx.entity();
let label = count.to_string();
let on = current.as_deref() == Some(label.as_str());
ToggleButton::new(
SharedString::from(format!("doc-props-columns-{count}")),
ToggleItem::new(label.clone()).label(label),
on,
)
.size(ControlSize::Sm)
.on_toggle(move |_, _, cx| send(&this, "page.columns", &[&count.to_string()], cx))
.into_any_element()
}))
}
/// The current section's page style.
fn section(&self, cx: &Context<Self>) -> AnyElement {
let names = self
.shown
.as_ref()
.map(|shown| shown.pages.clone())
.unwrap_or_default();
let current = self
.state(".uno:PageStyleName", cx)
.filter(|name| known(name));
let this = cx.entity();
let empty = names.is_empty();
let mut menu = Select::new(
"doc-props-section",
names
.into_iter()
.map(|name| Choice::new(name.clone(), name)),
)
.size(ControlSize::Sm)
.disabled(empty)
.placeholder(current.clone().unwrap_or_default())
.on_change(move |name, _, cx| {
send(&this, "format.style", &[name.as_ref(), "PageStyles"], cx)
});
if let Some(name) = current {
menu = menu.selected(name);
}
menu.into_any_element()
}
/// On or off, then first and odd pages.
fn part(&self, part: Part, cx: &Context<Self>) -> AnyElement {
let (on_command, on_uno) = part.on;
let (layout_command, layout_uno) = part.layout;
let on = self.state(on_uno, cx).as_deref() == Some("true");
let switch = {
let this = cx.entity();
let key = if on { "doc.props.on" } else { "doc.props.off" };
let item = ToggleItem::new(on_command).label(Docs::text(cx, key, &[]));
ToggleButton::new(SharedString::from(on_command), item, on)
.size(ControlSize::Sm)
.on_toggle(move |turn_on, _, cx| {
send(&this, on_command, &[&turn_on.to_string()], cx)
})
.into_any_element()
};
let current = self
.state(layout_uno, cx)
.and_then(|state| state.parse::<i32>().ok());
let face = |bit: i32, key: &'static str| {
let this = cx.entity();
let mut item = ToggleItem::new(key).label(Docs::text(cx, key, &[]));
if current.is_none() {
item = item.tooltip(Docs::text(cx, part.why, &[]));
}
let value = current.unwrap_or(0);
let id = SharedString::from(format!("{layout_command}-{bit}"));
ToggleButton::new(id, item, value & bit != 0)
.disabled(current.is_none())
.size(ControlSize::Sm)
.on_toggle(move |_, _, cx| {
send(&this, layout_command, &[&(value ^ bit).to_string()], cx)
})
.into_any_element()
};
row([
switch,
face(1, "doc.props.first_page"),
face(2, "doc.props.odd_even"),
])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_states_read_and_papers_match_either_way() {
assert_eq!(size_of("21590 x 27940"), Some((21590, 27940)));
assert_eq!(page_of("true,0,4"), Some((true, 0, 4)));
assert_eq!(
paper_of((29700, 21001)),
Some("A4"),
"landscape A4 after rounding"
);
assert_eq!(paper_of((21590, 27940)), Some("Letter"));
assert_eq!(paper_of((10000, 10000)), None);
assert_eq!(size_of("garbage"), None);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Two-value kernel states edited one side at a time.
use ely_office::panel::Panel as _;
use gpui::{App, Context};
use super::Props;
/// Pairs sent ahead of the kernel's echo.
pub(super) struct Sent {
/// The state when the first pair left.
before: Option<String>,
pairs: Vec<(i32, i32)>,
}
/// Whole twips in 1/100 mm, rounded as the kernel does.
pub(super) fn mm100_of_twips(twips: i32) -> i32 {
(twips * 127 + 36) / 72
}
/// A "first,second" state.
fn parse(text: &str) -> Option<(i32, i32)> {
let (first, second) = text.split_once(',')?;
Some((first.parse().ok()?, second.parse().ok()?))
}
impl Props {
/// Edits build on the last sent pair, else the kernel's.
pub(super) fn pair(&self, uno: &'static str, cx: &App) -> Option<(i32, i32)> {
match self.pending.get(uno) {
Some(sent) => sent.pairs.last().copied(),
None => self.state(uno, cx).as_deref().and_then(parse),
}
}
/// Sets one side in twips; the other stays.
pub(super) fn set_pair(
&mut self,
(uno, command): (&'static str, &'static str),
second: bool,
twips: i32,
cx: &mut Context<Self>,
) {
let Some((first, other)) = self.pair(uno, cx) else {
return;
};
let value = mm100_of_twips(twips);
let next = if second {
(first, value)
} else {
(value, other)
};
if next == (first, other) {
return;
}
let before = self.state(uno, cx).map(|state| state.to_string());
self.pending
.entry(uno)
.or_insert(Sent {
before,
pairs: Vec::new(),
})
.pairs
.push(next);
let values = [next.0.to_string(), next.1.to_string()];
self.run(command, &[&values[0], &values[1]], cx);
}
/// Drops sent pairs the kernel has answered.
pub(super) fn track_pairs(&mut self, cx: &App) {
let reported: Vec<(&'static str, Option<String>)> = self
.pending
.keys()
.map(|&uno| (uno, self.state(uno, cx).map(|state| state.to_string())))
.collect();
for (uno, reported) in reported {
let sent = self.pending.get_mut(uno).expect("listed above");
if reported == sent.before {
continue;
}
let at = reported
.as_deref()
.and_then(parse)
.and_then(|seen| sent.pairs.iter().position(|pair| *pair == seen));
match at {
Some(at) if at + 1 < sent.pairs.len() => {
sent.pairs.drain(..=at);
sent.before = reported;
}
_ => {
self.pending.remove(uno);
}
}
}
}
}
+206
View File
@@ -0,0 +1,206 @@
//! The paragraph group: alignment, spacing, lists, tab stops (DOC-03).
use ely_gpui_component::{
buttons::Button, data_display::PropertyGroup, forms::NumberInput, primitives::IconName,
theme::ControlSize,
};
use ely_office::panel::{Panel as _, button, muted, row, send, toggle};
use gpui::{AnyElement, Context, IntoElement, ParentElement as _, Styled as _, div};
use super::Props;
use crate::docs::Docs;
/// The kernel's spacing state: "upper,lower" in 1/100 mm.
pub(super) const SPACING: &str = ".uno:ULSpacing";
/// Twips in one centimeter.
const TWIPS_PER_CM: f64 = 567.0;
impl Props {
pub(super) fn paragraph_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
let tip = |key: &str| Docs::text(cx, key, &[]);
let align = row([
toggle(
self,
"paragraph.left",
(Some(IconName::AlignLeft), ""),
("doc.props.left", "doc.why.selection"),
cx,
),
toggle(
self,
"paragraph.center",
(Some(IconName::AlignCenter), ""),
("doc.props.center", "doc.why.selection"),
cx,
),
toggle(
self,
"paragraph.right",
(Some(IconName::AlignRight), ""),
("doc.props.right", "doc.why.selection"),
cx,
),
toggle(
self,
"paragraph.justify",
(Some(IconName::AlignJustify), ""),
("doc.props.justify", "doc.why.selection"),
cx,
),
]);
let line = row([
toggle(
self,
"paragraph.spacing_1",
(None, "1"),
("", "doc.why.selection"),
cx,
),
toggle(
self,
"paragraph.spacing_15",
(None, "1.5"),
("", "doc.why.selection"),
cx,
),
toggle(
self,
"paragraph.spacing_2",
(None, "2"),
("", "doc.why.selection"),
cx,
),
]);
let lists = row([
toggle(
self,
"list.bullets",
(Some(IconName::List), ""),
("doc.props.bullets", "doc.why.selection"),
cx,
),
toggle(
self,
"list.numbering",
(Some(IconName::ListOrdered), ""),
("doc.props.numbering", "doc.why.selection"),
cx,
),
button(
self,
"list.higher",
"doc.props.promote",
"doc.why.selection",
cx,
),
button(
self,
"list.deeper",
"doc.props.demote",
"doc.why.selection",
cx,
),
]);
PropertyGroup::new(tip("doc.props.paragraph"))
.row(tip("doc.props.align"), align)
.row(
tip("doc.props.indent"),
row([
button(
self,
"paragraph.outdent",
"doc.props.less",
"doc.why.selection",
cx,
),
button(
self,
"paragraph.indent",
"doc.props.more",
"doc.why.selection",
cx,
),
]),
)
.row(tip("doc.props.line"), line)
.row(tip("doc.props.above"), self.spacing(false, cx))
.row(tip("doc.props.below"), self.spacing(true, cx))
.row(tip("doc.props.lists"), lists)
.row(tip("doc.props.tabs"), self.tabs(cx))
}
/// Space above or below, in points.
fn spacing(&self, below: bool, cx: &Context<Self>) -> AnyElement {
if self.state(SPACING, cx).as_deref() == Some(ely_lok::MIXED) {
return muted(Docs::text(cx, "office.props.mixed", &[]), cx);
}
let Some((upper, lower)) = self.pair(SPACING, cx) else {
return muted("—".into(), cx);
};
let this = cx.entity();
let (id, mine) = match below {
true => ("doc-props-below", lower),
false => ("doc-props-above", upper),
};
let field = NumberInput::new(id, f64::from(mine) * 72.0 / 2540.0)
.range(0.0, 283.0)
.step(1.0)
.precision(1)
.size(ControlSize::Sm)
.on_commit(move |points, _, cx| {
let twips = (points * 20.0).round() as i32;
this.update(cx, |props, cx| {
props.set_pair((SPACING, "paragraph.spacing"), below, twips, cx)
})
});
div().w_full().max_w_32().child(field).into_any_element()
}
/// The paragraph's stops, each removable, and an adder in cm.
fn tabs(&self, cx: &Context<Self>) -> AnyElement {
let this = cx.entity();
let stops = self
.shown
.as_ref()
.map(|shown| shown.view.read(cx).tabs().to_vec())
.unwrap_or_default();
let chips: Vec<AnyElement> = stops
.into_iter()
.enumerate()
.map(|(at, mm100)| {
let this = this.clone();
let cm = format!("{:.2}", f64::from(mm100) / 1000.0);
let label = Docs::text(cx, "doc.props.cm", &[("n", &cm)]);
Button::new(("doc-props-tab", at), label)
.size(ControlSize::Sm)
.trailing_icon(IconName::X)
.on_click(move |_, _, cx| {
send(&this, "paragraph.tab", &[&at.to_string(), "0", "true"], cx)
})
.into_any_element()
})
.collect();
let at = NumberInput::new("doc-props-tab-at", self.tab_at)
.range(0.0, 55.0)
.step(0.25)
.precision(2)
.size(ControlSize::Sm)
.on_change({
let this = this.clone();
move |value, _, cx| this.update(cx, |props, _| props.tab_at = value)
});
let add = Button::new(
"doc-props-tab-add",
Docs::text(cx, "doc.props.add_tab", &[]),
)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
let twips = this.read(cx).tab_at * TWIPS_PER_CM;
let twips = (twips.round() as i64).to_string();
send(&this, "paragraph.tab", &["-1", &twips, "false"], cx)
});
let at = div().w_full().max_w_32().child(at).into_any_element();
row(chips.into_iter().chain([at, add.into_any_element()]))
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The page as reported, and page edits not yet echoed.
use ely_office::panel::Panel as _;
use gpui::{App, Context};
use super::Props;
const SIZE: &str = ".uno:AttributePageSize";
const PAGE: &str = ".uno:AttributePage";
/// The page: size in 1/100 mm, orientation, layout, number type.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Sheet {
pub(super) size: (i32, i32),
pub(super) landscape: bool,
pub(super) layout: i32,
pub(super) numtype: i32,
}
impl Sheet {
/// The same page, within the kernel's twips rounding.
fn matches(&self, other: &Sheet) -> bool {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
(self.landscape, self.layout, self.numtype)
== (other.landscape, other.layout, other.numtype)
&& near(self.size.0, other.size.0)
&& near(self.size.1, other.size.1)
}
}
/// Page edits sent ahead of the kernel's echo.
pub(super) struct Sent {
before: (Option<String>, Option<String>),
sheets: Vec<Sheet>,
}
/// A "width x height" state in 1/100 mm.
pub(super) fn size_of(text: &str) -> Option<(i32, i32)> {
let (width, height) = text.split_once(" x ")?;
Some((width.trim().parse().ok()?, height.trim().parse().ok()?))
}
/// The page item: landscape, layout, number type.
pub(super) fn page_of(text: &str) -> Option<(bool, i32, i32)> {
let mut parts = text.split(',');
let landscape = parts.next()?.parse().ok()?;
Some((
landscape,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}
impl Props {
fn reported(&self, cx: &App) -> (Option<String>, Option<String>) {
let read = |uno| self.state(uno, cx).map(|state| state.to_string());
(read(SIZE), read(PAGE))
}
fn reported_sheet(&self, cx: &App) -> Option<Sheet> {
let (size, page) = self.reported(cx);
let (landscape, layout, numtype) = page_of(&page?)?;
Some(Sheet {
size: size_of(&size?)?,
landscape,
layout,
numtype,
})
}
/// Edits build on the last sent page, else the kernel's.
pub(super) fn sheet(&self, cx: &App) -> Option<Sheet> {
match &self.sheet_sent {
Some(sent) => sent.sheets.last().copied(),
None => self.reported_sheet(cx),
}
}
/// Sends `next`; one command, so one undo step.
pub(super) fn send_sheet(&mut self, next: Sheet, cx: &mut Context<Self>) {
let Some(now) = self.sheet(cx) else {
return;
};
if next == now {
return;
}
let before = self.reported(cx);
self.sheet_sent
.get_or_insert(Sent {
before,
sheets: Vec::new(),
})
.sheets
.push(next);
let (w, h) = (next.size.0.to_string(), next.size.1.to_string());
let item = (next.landscape, next.layout, next.numtype);
if item == (now.landscape, now.layout, now.numtype) {
return self.run("page.size", &[&w, &h], cx);
}
let page = [
next.landscape.to_string(),
next.layout.to_string(),
next.numtype.to_string(),
];
self.run(
"page.orientation",
&[&w, &h, &page[0], &page[1], &page[2]],
cx,
);
}
/// Drops sent pages the kernel has answered.
pub(super) fn track_sheet(&mut self, cx: &App) {
let reported = self.reported(cx);
let seen = self.reported_sheet(cx);
let Some(sent) = &mut self.sheet_sent else {
return;
};
if reported == sent.before {
return;
}
let at = seen.and_then(|seen| sent.sheets.iter().position(|sheet| sheet.matches(&seen)));
match at {
Some(at) if at + 1 < sent.sheets.len() => {
sent.sheets.drain(..=at);
sent.before = reported;
}
_ => self.sheet_sent = None,
}
}
}
+146
View File
@@ -0,0 +1,146 @@
//! The style group: apply, create, update (DOC-04).
use ely_gpui_component::primitives::Tooltip;
use ely_gpui_component::{
buttons::Button,
data_display::PropertyGroup,
forms::{Choice, Select},
theme::ControlSize,
};
use ely_office::panel::{Panel as _, known, row, send};
use gpui::{
AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, SharedString,
StatefulInteractiveElement as _, div,
};
use super::Props;
use crate::docs::Docs;
impl Props {
pub(super) fn style_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
let tip = |key: &str| Docs::text(cx, key, &[]);
let (paragraphs, characters) = self
.shown
.as_ref()
.map(|shown| (shown.paragraph.clone(), shown.character.clone()))
.unwrap_or_default();
let current = self.state(".uno:StyleApply", cx);
let paragraph = self.style_menu(
"doc-props-paragraph",
paragraphs,
current.clone(),
"ParagraphStyles",
cx,
);
let character_style = self.state(".uno:CharStyle", cx);
let character = self.style_menu(
"doc-props-character",
characters,
character_style.clone(),
"CharacterStyles",
cx,
);
PropertyGroup::new(tip("doc.props.style"))
.row(
tip("doc.props.paragraph"),
row([
paragraph,
self.new_style("2", cx),
self.update_style("2", current, cx),
]),
)
.row(
tip("doc.props.character"),
row([
character,
self.new_style("1", cx),
self.update_style("1", character_style, cx),
]),
)
}
/// A style list; unlisted or mixed names show as placeholder.
fn style_menu(
&self,
id: &'static str,
names: Vec<SharedString>,
current: Option<SharedString>,
family: &'static str,
cx: &Context<Self>,
) -> AnyElement {
let this = cx.entity();
let empty = names.is_empty();
let placeholder = match current.as_deref() {
Some(ely_lok::MIXED) => Docs::text(cx, "office.props.mixed", &[]),
Some(name) if known(name) => name.to_string().into(),
_ => Docs::text(cx, "doc.props.choose", &[]),
};
let mut menu = Select::new(
id,
names
.into_iter()
.map(|name| Choice::new(name.clone(), name)),
)
.size(ControlSize::Sm)
.disabled(empty)
.placeholder(placeholder)
.on_change(move |name, _, cx| {
this.update(cx, |props, cx| {
props.run("format.style", &[name.as_ref(), family], cx)
})
});
if let Some(name) = current.filter(|name| known(name)) {
menu = menu.selected(name);
}
menu.into_any_element()
}
fn new_style(&self, family: &'static str, cx: &Context<Self>) -> AnyElement {
let this = cx.entity();
Button::new(
SharedString::from(format!("doc-props-new-style-{family}")),
Docs::text(cx, "doc.props.new_style", &[]),
)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
this.update(cx, |props, cx| {
props.naming = Some(family);
cx.notify();
})
})
.into_any_element()
}
fn update_style(
&self,
family: &'static str,
name: Option<SharedString>,
cx: &Context<Self>,
) -> AnyElement {
let this = cx.entity();
let id = format!("doc-props-update-style-{family}");
let name = name.filter(|name| known(name));
let button = Button::new(
SharedString::from(id.clone()),
Docs::text(cx, "doc.props.update_style", &[]),
)
.size(ControlSize::Sm)
.disabled(name.is_none())
.on_click({
let name = name.clone();
move |_, _, cx| {
if let Some(name) = &name {
send(&this, "style.update", &[name.as_ref(), family], cx);
}
}
});
if name.is_some() {
return button.into_any_element();
}
div()
.id(SharedString::from(format!("{id}-why")))
.child(button)
.tooltip(Tooltip::text(Docs::text(cx, "doc.props.no_style", &[])))
.into_any_element()
}
}
+158
View File
@@ -0,0 +1,158 @@
//! The table group: rows, columns, cells, borders, flow (DOC-07).
use ely_gpui_component::{buttons::Button, data_display::PropertyGroup, theme::ControlSize};
use ely_office::panel::{button, color_state, palette, row, send, toggle};
use gpui::{AnyElement, Context, IntoElement, SharedString};
use super::Props;
use crate::docs::Docs;
impl Props {
pub(super) fn table_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
let tip = |key: &str| Docs::text(cx, key, &[]);
let why = "doc.why.selection";
PropertyGroup::new(tip("doc.props.table"))
.row(
tip("doc.props.rows"),
row([
button(self, "table.row_above", "doc.props.above_row", why, cx),
button(self, "table.row_below", "doc.props.below_row", why, cx),
button(self, "table.delete_rows", "doc.props.delete", why, cx),
]),
)
.row(
tip("doc.props.columns"),
row([
button(self, "table.column_left", "doc.props.left_column", why, cx),
button(
self,
"table.column_right",
"doc.props.right_column",
why,
cx,
),
button(self, "table.delete_columns", "doc.props.delete", why, cx),
]),
)
.row(
tip("doc.props.cells"),
row([
button(self, "table.merge", "doc.props.merge", "doc.why.merge", cx),
self.steps(
"doc-props-split-columns",
"doc.props.split_columns",
&[("table.split", &["2", "false"])],
cx,
),
self.steps(
"doc-props-split-rows",
"doc.props.split_rows",
&[("table.split", &["2", "true"])],
cx,
),
]),
)
.row(
tip("doc.props.cell_size"),
row([
self.steps(
"doc-props-even-columns",
"doc.props.even_columns",
&[("table.select", &[]), ("table.even_columns", &[])],
cx,
),
self.steps(
"doc-props-even-rows",
"doc.props.even_rows",
&[("table.select", &[]), ("table.even_rows", &[])],
cx,
),
self.steps(
"doc-props-fit-rows",
"doc.props.fit_rows",
&[("table.select", &[]), ("table.optimal_height", &[])],
cx,
),
]),
)
.row(
tip("doc.props.borders"),
row([
button(self, "table.borders_all", "doc.props.borders_all", why, cx),
button(
self,
"table.borders_none",
"doc.props.borders_none",
why,
cx,
),
]),
)
.row(
tip("doc.props.shading"),
palette(
&cx.entity(),
"table.shading",
(color_state(self, ".uno:TableCellBackgroundColor", cx), "office.props.automatic"),
None,
cx,
),
)
.row(
tip("doc.props.heading"),
row([toggle(
self,
"table.repeat_heading",
(None, &tip("doc.props.repeat")),
("", "doc.why.heading"),
cx,
)]),
)
.row(
tip("doc.props.row_break"),
row([
self.steps(
"doc-props-row-breaks",
"doc.props.may_break",
&[("table.row_split", &["true"])],
cx,
),
self.steps(
"doc-props-row-whole",
"doc.props.keep_whole",
&[("table.row_split", &["false"])],
cx,
),
]),
)
.row(
tip("doc.props.table"),
row([button(
self,
"table.delete",
"doc.props.delete_table",
why,
cx,
)]),
)
}
/// A button that runs `commands` in order.
fn steps(
&self,
id: &'static str,
key: &str,
commands: &'static [(&'static str, &'static [&'static str])],
cx: &Context<Self>,
) -> AnyElement {
let this = cx.entity();
Button::new(SharedString::from(id), Docs::text(cx, key, &[]))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
for (command, values) in commands {
send(&this, command, values, cx);
}
})
.into_any_element()
}
}
+292
View File
@@ -0,0 +1,292 @@
use std::{cell::RefCell, rc::Rc};
use ely_lok::LokError;
use ely_office::panel::Panel as _;
use gpui::{Entity, TestAppContext};
use super::Props;
use crate::docs::Docs;
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
use ely_office::prelude::*;
/// The panel, shown on the active tab.
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> {
cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
let (view, doc) = (tab.view.clone(), tab.doc.clone());
let props = docs.read(app).props.clone();
props.update(app, |props, cx| props.show(Some((&view, &doc)), cx));
props
})
}
fn run(cx: &mut TestAppContext, props: &Entity<Props>, id: &str, values: &[&str]) {
cx.update(|app| props.update(app, |props, cx| props.run(id, values, cx)));
}
#[test]
fn the_panel_formats_text_paragraphs_and_styles() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Ely panel");
let props = panel(&mut cx, &docs);
cx.update(|app| {
let props = props.read(app);
let shown = props.shown.as_ref().expect("shown");
assert!(!props.fonts.is_empty(), "the kernel lists fonts");
assert!(shown.paragraph.iter().any(|name| name == "Heading 2"));
assert!(shown.character.iter().any(|name| name == "Emphasis"));
});
for (command, values) in [
("edit.select_all", vec![]),
("format.style", vec!["Heading 2", "ParagraphStyles"]),
("format.color", vec!["16711680"]),
("format.highlight", vec!["65535"]),
("format.superscript", vec![]),
("paragraph.tab", vec!["-1", "1134", "false"]),
("list.bullets", vec![]),
] {
run(&mut cx, &props, command, &values);
}
cx.update(|app| props.update(app, |props, cx| props.create_style("Ely Note", "2", cx)));
settle(&cx, |app| {
let props = props.read(app);
let shown = props.shown.as_ref().expect("shown");
!props.reload && shown.paragraph.iter().any(|name| name == "Ely Note")
});
run(&mut cx, &props, "paragraph.right", &[]);
run(&mut cx, &props, "style.update", &["Ely Note", "2"]);
run(
&mut cx,
&props,
"format.style",
&["Emphasis", "CharacterStyles"],
);
settle(&cx, |app| {
let tab = docs.read(app).active_tab().expect("a tab");
let view = tab.view.read(app);
view.settled() && view.tabs().len() == 1 && view.state(".uno:FontColor") == Some("16711680")
});
// The shown stop, removed by its number.
cx.update(|app| ely_office::panel::send(&props, "paragraph.tab", &["0", "0", "true"], app));
settle(&cx, |app| {
let view = docs.read(app).active_tab().expect("a tab").view.read(app);
view.settled() && view.tabs().is_empty()
});
run(&mut cx, &props, "paragraph.tab", &["-1", "1134", "false"]);
let target = dir.path().join("panel.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, target.clone(), None, window, cx)
});
written(&cx, &target, None);
let text = text_of(&mut cx, &docs, &target);
for mark in [
"fo:color=\"#ff0000\"",
"fo:background-color=\"#00ffff\"",
"style:text-position=\"super",
"style:tab-stop",
"<text:list",
"style:display-name=\"Ely Note\" style:family=\"paragraph\" style:parent-style-name=\"Heading_20_2\"",
"style:parent-style-name=\"Ely_20_Note\"",
"text:style-name=\"Emphasis\"",
] {
assert!(text.contains(mark), "{mark} lands");
}
let note = text
.split("style:display-name=\"Ely Note\"")
.nth(1)
.and_then(|rest| rest.split("</style:style>").next())
.expect("the style is written");
assert!(
note.contains("fo:text-align=\"right\""),
"the update reached the style"
);
}
#[test]
fn bad_values_report_and_the_panel_follows_the_tab() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &docs);
let props = panel(&mut cx, &docs);
let errors = Rc::new(RefCell::new(Vec::new()));
cx.update(|app| {
let errors = errors.clone();
app.subscribe(&props, move |_, error: &LokError, _| {
errors.borrow_mut().push(error.0.clone())
})
.detach()
});
run(&mut cx, &props, "format.size", &["0"]);
assert_eq!(errors.borrow().len(), 1, "an out-of-range size reports");
assert!(errors.borrow()[0].starts_with("format.size"));
let first = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id());
blank(&mut cx, &docs);
let props = panel(&mut cx, &docs);
let second = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id());
assert_ne!(first, second);
cx.update(|app| {
let shown = props
.read(app)
.shown
.as_ref()
.map(|shown| shown.view.entity_id());
assert_eq!(shown, Some(second), "the panel moves to the new tab");
props.update(app, |props, cx| props.show(None, cx));
assert!(props.read(app).shown.is_none(), "no tab, no panel");
});
}
/// Posts `keys` (LOK key codes) straight to the active document.
fn keys(cx: &mut TestAppContext, docs: &Entity<Docs>, keys: &[i32]) {
cx.update(|app| {
let docs = docs.read(app);
let doc = docs.active_tab().expect("a tab").doc.clone();
let mut worker = docs.core.worker.lock().expect("the worker lock");
for &code in keys {
worker.post_key(&doc, 0, code).expect("the key posts");
}
});
}
/// A text style's definition in a flat ODF file.
fn text_style<'a>(text: &'a str, name: &str) -> &'a str {
let head = format!("style:name=\"{name}\"");
text.split(head.as_str())
.nth(1)
.and_then(|rest| rest.split("</style:style>").next())
.unwrap_or_else(|| panic!("{name} is defined"))
}
#[test]
fn update_follows_the_character_style_under_the_cursor() {
const HOME: i32 = 1028;
const END: i32 = 1029;
const LEFT: i32 = 1026;
const RIGHT: i32 = 1027;
const SHIFT: i32 = 0x1000;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Alpha Beta");
let props = panel(&mut cx, &docs);
run(&mut cx, &props, "edit.select_all", &[]);
run(
&mut cx,
&props,
"format.style",
&["Strong Emphasis", "CharacterStyles"],
);
settled(&cx, &docs);
keys(
&mut cx,
&docs,
&[
HOME,
RIGHT | SHIFT,
RIGHT | SHIFT,
RIGHT | SHIFT,
RIGHT | SHIFT,
RIGHT | SHIFT,
],
);
run(
&mut cx,
&props,
"format.style",
&["Emphasis", "CharacterStyles"],
);
settled(&cx, &docs);
keys(
&mut cx,
&docs,
&[END, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT],
);
let current = |app: &gpui::App| props.read(app).state(".uno:CharStyle", app);
settle(&cx, |app| {
current(app).as_deref() == Some("Strong Emphasis")
});
cx.update(|app| {
props.update(app, |props, cx| props.show(None, cx));
assert_eq!(current(app), None, "a hidden panel shows nothing");
});
let props = panel(&mut cx, &docs);
let target = cx.update(|app| props.read(app).state(".uno:CharStyle", app));
assert_eq!(
target.as_deref(),
Some("Strong Emphasis"),
"shown again, still synced"
);
run(&mut cx, &props, "format.color", &["16711680"]);
let target = target.expect("a reported style");
run(&mut cx, &props, "style.update", &[&target, "1"]);
settled(&cx, &docs);
run(&mut cx, &props, "edit.select_all", &[]);
settle(&cx, |app| current(app).as_deref() == Some(ely_lok::MIXED));
let mixed = cx.update(|app| current(app)).expect("reported");
assert!(
!ely_office::panel::known(&mixed),
"a mixed selection has no update target"
);
let path = dir.path().join("styles.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
assert!(text_style(&text, "Strong_20_Emphasis").contains("fo:color=\"#ff0000\""));
assert!(
!text_style(&text, "Emphasis").contains("#ff0000"),
"Emphasis stays"
);
}
#[test]
fn quick_spacing_edits_keep_each_other() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Spacing");
let props = panel(&mut cx, &docs);
let spacing = |app: &gpui::App| props.read(app).state(super::paragraph::SPACING, app);
settle(&cx, |app| spacing(app).is_some());
let set = |cx: &mut TestAppContext, below: bool, points: f64| {
let twips = (points * 20.0).round() as i32;
let pair = (super::paragraph::SPACING, "paragraph.spacing");
cx.update(|app| props.update(app, |props, cx| props.set_pair(pair, below, twips, cx)))
};
set(&mut cx, false, 6.0);
set(&mut cx, true, 10.0);
settle(&cx, |app| spacing(app).as_deref() == Some("212,353"));
set(&mut cx, false, 8.0);
set(&mut cx, true, 12.0);
settle(&cx, |app| spacing(app).as_deref() == Some("282,423"));
settled(&cx, &docs);
let path = dir.path().join("spacing.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
assert!(
text.contains("fo:margin-top=\"0.111in\" fo:margin-bottom=\"0.1665in\""),
"both quick edits land"
);
}
+402
View File
@@ -0,0 +1,402 @@
//! References, formulas and comments (DOC-09, 10, 11).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, Input, Select, TextInput},
overlays::{Dialog as Card, PromptDialog},
primitives::Severity,
};
use gpui::{
App, AppContext as _, Context, Entity, MenuItem, ParentElement as _, SharedString, Styled as _,
Window, actions, div,
};
use crate::actions::Dialog;
use crate::docs::Docs;
use ely_office::prelude::*;
actions!(
doc,
[
InsertContents,
UpdateContents,
InsertFootnote,
InsertEndnote,
InsertBookmark,
InsertLink,
InsertReference,
InsertCaption,
InsertFormula,
InsertComment
]
);
/// What a one-line prompt asks for.
#[derive(Clone)]
pub enum Ask {
Bookmark(Vec<String>),
/// The caption's category, by program name.
Caption(&'static str),
/// True when it replaces the selected formula.
Formula(bool),
Comment,
}
/// The reference formats offered, by kernel number.
const SHOWN: [(&str, &str); 4] = [
("2", "doc.reference.text"),
("0", "doc.reference.page"),
("1", "doc.reference.chapter"),
("3", "doc.reference.direction"),
];
/// The Insert menu's reference entries.
pub fn items(cx: &App) -> Vec<MenuItem> {
let label = |key: &str| Docs::text(cx, key, &[]);
vec![
MenuItem::separator(),
MenuItem::action(label("doc.insert.contents"), InsertContents),
MenuItem::action(label("doc.insert.update_contents"), UpdateContents),
MenuItem::action(label("doc.insert.footnote"), InsertFootnote),
MenuItem::action(label("doc.insert.endnote"), InsertEndnote),
MenuItem::action(label("doc.insert.bookmark"), InsertBookmark),
MenuItem::action(label("doc.insert.link"), InsertLink),
MenuItem::action(label("doc.insert.reference"), InsertReference),
MenuItem::action(label("doc.insert.caption"), InsertCaption),
MenuItem::separator(),
MenuItem::action(label("doc.insert.formula"), InsertFormula),
MenuItem::action(label("doc.insert.comment"), InsertComment),
]
}
impl Docs {
/// A kernel list for the active document; failures toast.
fn read(&self, command: &str, cx: &mut Context<Self>) -> Option<String> {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
self.toast(text, Severity::Warning, cx);
return None;
};
let read = self
.core
.worker
.lock()
.expect("the worker lock")
.values(&tab.doc, command);
read.map_err(|error| {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
})
.ok()
}
fn bookmarks(&self, cx: &mut Context<Self>) -> Option<Vec<String>> {
let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?);
listed
.map(|marks| marks.into_iter().map(|mark| mark.name).collect())
.map_err(|error| {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
})
.ok()
}
fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context<Self>) {
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text(text, cx));
self.dialog = Some(Dialog::Ask { ask, field });
cx.notify();
}
pub fn on_contents(&mut self, _: &InsertContents, _: &mut Window, cx: &mut Context<Self>) {
let title = Docs::text(cx, "doc.contents.title", &[]);
self.run_active("index.insert", &[&title], cx);
}
pub fn on_update(&mut self, _: &UpdateContents, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("index.update", &[], cx);
}
pub fn on_footnote(&mut self, _: &InsertFootnote, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("note.footnote", &[], cx);
}
pub fn on_endnote(&mut self, _: &InsertEndnote, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("note.endnote", &[], cx);
}
pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context<Self>) {
if let Some(taken) = self.bookmarks(cx) {
self.ask(Ask::Bookmark(taken), "", window, cx);
}
}
pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context<Self>) {
let text = cx.new(|cx| TextInput::new(window, cx));
let url = cx.new(|cx| TextInput::new(window, cx));
cx.observe(&url, |_, _, cx| cx.notify()).detach();
self.dialog = Some(Dialog::Link { text, url });
cx.notify();
}
pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
let Some(marks) = self.bookmarks(cx) else {
return;
};
let Some(mark) = marks.first().cloned() else {
let text = Docs::text(cx, "doc.reference.none", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.dialog = Some(Dialog::Reference {
marks,
mark,
show: "2",
});
cx.notify();
}
pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let view = tab.view.read(cx);
let category = if view.graphic().is_some() {
"Figure"
} else if view.state(".uno:DeleteTable") == Some("enabled") {
"Table"
} else {
let text = Docs::text(cx, "doc.caption.none", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.ask(Ask::Caption(category), "", window, cx);
}
pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context<Self>) {
let Some(json) = self.read(".uno:Formula", cx) else {
return;
};
match ely_lok::review::formula(&json) {
Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx),
Ok(None) => self.ask(Ask::Formula(false), "", window, cx),
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
}
}
pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context<Self>) {
self.ask(Ask::Comment, "", window, cx);
}
/// Sends the hyperlink dialog.
pub(crate) fn insert_link(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Link { text, url }) = self.dialog.take() else {
return;
};
let (words, address) = (
text.read(cx).text().trim().to_string(),
url.read(cx).text().trim().to_string(),
);
// No text shows the address.
let shown = if words.is_empty() { &address } else { &words };
self.run_active("link.insert", &[shown, &address], cx);
cx.notify();
}
/// Sends the cross-reference dialog.
pub(crate) fn insert_reference(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Reference { mark, show, .. }) = self.dialog.take() else {
return;
};
self.run_active("reference.insert", &["13", "2", &mark, show], cx);
cx.notify();
}
/// Sends a prompt's answer and closes it.
pub(crate) fn answer(&mut self, ask: &Ask, text: &str, cx: &mut Context<Self>) {
self.dialog = None;
match ask {
Ask::Bookmark(_) => self.run_active("bookmark.insert", &[text], cx),
Ask::Caption(category) => self.run_active("caption.insert", &[category, text], cx),
Ask::Formula(_) => self.run_active("formula.insert", &[text], cx),
Ask::Comment => {
self.side = crate::docs::Side::Review;
self.run_active("comment.insert", &[text], cx);
}
}
cx.notify();
}
}
/// The one-line prompt for `ask`.
pub fn ask_dialog(
this: Entity<Docs>,
ask: &Ask,
field: &Entity<TextInput>,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> PromptDialog {
let text = |key: &str| Docs::text(cx, key, &[]);
let (title, label) = match ask {
Ask::Bookmark(_) => ("doc.bookmark.title", "doc.bookmark.name"),
Ask::Caption(_) => ("doc.caption.title", "doc.caption.text"),
Ask::Formula(true) => ("doc.formula.edit", "doc.formula.text"),
Ask::Formula(false) => ("doc.formula.title", "doc.formula.text"),
Ask::Comment => ("doc.comment.title", "doc.comment.text"),
};
let submit = match ask {
Ask::Formula(true) => text("doc.formula.apply"),
_ => text("doc.insert.insert"),
};
let reasons: Vec<(&str, SharedString)> = [
"doc.bookmark.empty",
"doc.bookmark.taken",
"doc.caption.empty",
"doc.formula.empty",
"doc.comment.empty",
]
.into_iter()
.map(|key| (key, text(key)))
.collect();
let (asked, ask) = (ask.clone(), ask.clone());
PromptDialog::new("doc-ask", text(title), field, dismiss)
.label(text(label))
.submit(submit)
.check(move |value| {
refuse(&asked, value.trim()).map_err(|key| {
let (_, why) = reasons
.iter()
.find(|(known, _)| *known == key)
.expect("a reason");
why.clone()
})
})
.on_submit(move |value, _, cx| {
let value = value.trim().to_string();
this.update(cx, |docs, cx| docs.answer(&ask, &value, cx))
})
}
/// Why a prompt's answer cannot go, by message key.
fn refuse(ask: &Ask, value: &str) -> Result<(), &'static str> {
if let Ask::Bookmark(taken) = ask
&& taken.iter().any(|name| name == value)
{
return Err("doc.bookmark.taken");
}
if !value.is_empty() {
return Ok(());
}
Err(match ask {
Ask::Bookmark(_) => "doc.bookmark.empty",
Ask::Caption(_) => "doc.caption.empty",
Ask::Formula(_) => "doc.formula.empty",
Ask::Comment => "doc.comment.empty",
})
}
/// The hyperlink dialog: text and address.
pub fn link_dialog(
this: Entity<Docs>,
(text, url): (&Entity<TextInput>, &Entity<TextInput>),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let fields = DescriptionList::new()
.item(
Docs::text(cx, "doc.link.text", &[]),
div().w_full().max_w_64().child(Input::new(text)),
)
.item(
Docs::text(cx, "doc.link.url", &[]),
div().w_full().max_w_64().child(Input::new(url)),
);
let label = Docs::text(cx, "doc.insert.insert", &[]);
let empty = url.read(cx).text().trim().is_empty();
Card::new(
"doc-insert-link",
Docs::text(cx, "doc.link.title", &[]),
dismiss,
)
.child(fields)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("doc-insert-link-go", label)
.variant(ButtonVariant::Primary)
.disabled(empty)
.on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.insert_link(cx)))
})
}
/// The cross-reference dialog: which bookmark, shown how.
pub fn reference_dialog(
this: Entity<Docs>,
(marks, mark, show): (&[String], &str, &'static str),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let pick = {
let this = this.clone();
let choices = marks
.iter()
.map(|name| Choice::new(name.clone(), name.clone()));
Select::new("doc-reference-mark", choices)
.selected(mark.to_string())
.on_change(move |name, _, cx| {
this.update(cx, |docs, cx| {
if let Some(Dialog::Reference { mark, .. }) = &mut docs.dialog {
*mark = name.to_string();
}
cx.notify();
})
})
};
let how = {
let this = this.clone();
let choices = SHOWN
.iter()
.map(|(value, key)| Choice::new(*value, Docs::text(cx, key, &[])));
Select::new("doc-reference-show", choices)
.selected(show)
.on_change(move |value, _, cx| {
let Some((value, _)) = SHOWN.iter().find(|(shown, _)| *shown == value.as_ref())
else {
return;
};
this.update(cx, |docs, cx| {
if let Some(Dialog::Reference { show, .. }) = &mut docs.dialog {
*show = value;
}
cx.notify();
})
})
};
let fields = DescriptionList::new()
.item(
Docs::text(cx, "doc.reference.target", &[]),
div().w_full().max_w_64().child(pick),
)
.item(
Docs::text(cx, "doc.reference.show", &[]),
div().w_full().max_w_64().child(how),
);
let label = Docs::text(cx, "doc.insert.insert", &[]);
Card::new(
"doc-insert-reference",
Docs::text(cx, "doc.reference.title", &[]),
dismiss,
)
.child(fields)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("doc-insert-reference-go", label)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.insert_reference(cx)))
})
}
#[cfg(test)]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
use gpui::{Entity, TestAppContext};
use super::*;
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
/// The open prompt's request and field.
fn asked(cx: &TestAppContext, docs: &Entity<Docs>) -> (Ask, Entity<TextInput>) {
cx.update(|app| match &docs.read(app).dialog {
Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()),
_ => panic!("a prompt is open"),
})
}
fn answer(cx: &mut TestAppContext, docs: &Entity<Docs>, text: &str) {
let (ask, _) = asked(cx, docs);
cx.update(|app| docs.update(app, |docs, cx| docs.answer(&ask, text, cx)));
settled(cx, docs);
}
fn graphic(app: &gpui::App, docs: &Entity<Docs>) -> bool {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).graphic().is_some()
}
#[test]
fn references_go_in_through_their_dialogs() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Start");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx)
});
assert!(
cx.update(|app| docs.read(app).dialog.is_none()),
"no bookmark, no cross-reference dialog"
);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_bookmark(&InsertBookmark, window, cx)
});
answer(&mut cx, &docs, "Here");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_bookmark(&InsertBookmark, window, cx)
});
let (ask, _) = asked(&cx, &docs);
assert!(
matches!(&ask, Ask::Bookmark(taken) if taken == &["Here"]),
"the prompt knows the taken names"
);
cx.update(|app| docs.update(app, |docs, _| docs.dialog = None));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx)
});
cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx)));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_link(&InsertLink, window, cx)
});
cx.update(|app| {
docs.update(app, |docs, cx| {
let Some(Dialog::Link { url, .. }) = &docs.dialog else {
panic!("the link dialog is open");
};
url.update(cx, |url, cx| url.set_text("https://example.org/", cx));
docs.insert_link(cx);
})
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_footnote(&InsertFootnote, window, cx);
});
type_into(&mut cx, &docs, "Foot");
cx.update(|app| {
let view = docs.read(app).active_tab().expect("a tab").view.clone();
view.update(app, |view, cx| view.reveal((1500, 1500, 0, 0), cx));
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_contents(&InsertContents, window, cx);
docs.on_update(&UpdateContents, window, cx);
});
settled(&cx, &docs);
let path = dir.path().join("refs.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
for mark in [
"<text:bookmark text:name=\"Here\"",
"<text:bookmark-ref ",
"xlink:href=\"https://example.org/\"",
">https://example.org/<",
"<text:note ",
"Foot",
"<text:table-of-content ",
] {
assert!(text.contains(mark), "{mark} lands");
}
}
#[test]
fn formulas_edit_in_place_and_take_captions() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Ratio");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_caption(&InsertCaption, window, cx)
});
assert!(
cx.update(|app| docs.read(app).dialog.is_none()),
"nothing to caption"
);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_formula(&InsertFormula, window, cx)
});
assert!(matches!(asked(&cx, &docs).0, Ask::Formula(false)));
answer(&mut cx, &docs, "a over b");
settle(&cx, |app| graphic(app, &docs));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_formula(&InsertFormula, window, cx)
});
let (ask, field) = asked(&cx, &docs);
assert!(matches!(ask, Ask::Formula(true)), "the formula edits");
assert_eq!(
cx.update(|app| field.read(app).text().to_string()),
"a over b"
);
answer(&mut cx, &docs, "c over d");
settle(&cx, |app| graphic(app, &docs));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_caption(&InsertCaption, window, cx)
});
assert!(matches!(asked(&cx, &docs).0, Ask::Caption("Figure")));
answer(&mut cx, &docs, "ratio");
settled(&cx, &docs);
let path = dir.path().join("formula.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
assert_eq!(text.matches("<draw:object").count(), 1, "the edit replaces");
for mark in ["c over d", ": ratio", "text:sequence"] {
assert!(text.contains(mark), "{mark} lands");
}
}
#[test]
fn prompts_refuse_empty_and_taken_answers() {
let taken = Ask::Bookmark(vec!["Here".into()]);
assert_eq!(refuse(&taken, "Here"), Err("doc.bookmark.taken"));
assert_eq!(refuse(&taken, ""), Err("doc.bookmark.empty"));
assert_eq!(refuse(&taken, "There"), Ok(()));
assert_eq!(
refuse(&Ask::Caption("Figure"), ""),
Err("doc.caption.empty")
);
assert_eq!(refuse(&Ask::Formula(true), ""), Err("doc.formula.empty"));
assert_eq!(refuse(&Ask::Comment, "Note"), Ok(()));
}
#[test]
fn a_long_formula_prefills_whole() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Long");
let long = format!("{}1", "x + ".repeat(300));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_formula(&InsertFormula, window, cx)
});
answer(&mut cx, &docs, &long);
settle(&cx, |app| graphic(app, &docs));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_formula(&InsertFormula, window, cx)
});
let (_, field) = asked(&cx, &docs);
assert_eq!(cx.update(|app| field.read(app).text().to_string()), long);
}
+419
View File
@@ -0,0 +1,419 @@
//! Comments and tracked changes beside the page (DOC-11).
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
collab::{Comment, CommentSidebar, Peer, Thread},
feedback::EmptyState,
forms::{Switch, TextInput},
layout::ScrollArea,
lists::{List, ListItem},
primitives::IconName,
theme::ControlSize,
};
use ely_lok::{
Doc, LokError, LokView, Reported, Worker,
review::{Change, Note},
};
use gpui::{
App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render,
SharedString, Styled as _, Subscription, Window, actions, div,
};
use crate::docs::{Docs, Side};
actions!(doc, [TrackChanges, AcceptAll, RejectAll, ShowReview]);
/// ElyDoc's Review menu.
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Docs::text(cx, key, &[]);
Menu::new(label("menu.review")).items([
MenuItem::action(label("doc.insert.comment"), crate::refs::InsertComment),
MenuItem::action(label("doc.review.panel"), ShowReview),
MenuItem::separator(),
MenuItem::action(label("doc.review.track"), TrackChanges),
MenuItem::action(label("doc.review.accept_all"), AcceptAll),
MenuItem::action(label("doc.review.reject_all"), RejectAll),
MenuItem::separator(),
MenuItem::action(label("doc.compare.open"), crate::compare::CompareWith),
])
}
impl Docs {
pub fn on_track(&mut self, _: &TrackChanges, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("track.toggle", &[], cx);
}
pub fn on_accept_all(&mut self, _: &AcceptAll, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("track.accept_all", &[], cx);
}
pub fn on_reject_all(&mut self, _: &RejectAll, _: &mut Window, cx: &mut Context<Self>) {
self.run_active("track.reject_all", &[], cx);
}
pub fn on_show_review(&mut self, _: &ShowReview, _: &mut Window, cx: &mut Context<Self>) {
self.side = match self.side {
Side::Review => Side::Props,
_ => Side::Review,
};
cx.notify();
}
}
#[derive(Clone, Copy, PartialEq)]
enum Pane {
Comments,
Changes,
}
struct Shown {
view: Entity<LokView>,
doc: Doc,
/// The view's review count at the last read.
read: Option<u64>,
/// A read is on its way.
reading: bool,
_observe: Subscription,
}
pub struct Review {
worker: Arc<Mutex<Worker>>,
shown: Option<Shown>,
notes: Vec<Note>,
threads: Vec<Thread>,
pub(crate) changes: Vec<Change>,
active: Option<SharedString>,
pane: Pane,
}
impl EventEmitter<LokError> for Review {}
impl Review {
pub fn new(worker: Arc<Mutex<Worker>>) -> Self {
Review {
worker,
shown: None,
notes: Vec::new(),
threads: Vec::new(),
changes: Vec::new(),
active: None,
pane: Pane::Comments,
}
}
/// Follows the active tab; reads the lists when they change.
pub fn show(&mut self, tab: Option<(&Entity<LokView>, &Doc)>, cx: &mut Context<Self>) {
let id = tab.map(|(view, _)| view.entity_id());
if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id {
return;
}
// Another document's lists must not stay actionable.
self.shown = None;
self.active = None;
self.notes.clear();
self.threads.clear();
self.changes.clear();
cx.notify();
let Some((view, doc)) = tab else {
return;
};
let observe = cx.subscribe(view, |review, _, _: &Reported, cx| review.refresh(cx));
self.shown = Some(Shown {
view: view.clone(),
doc: doc.clone(),
read: None,
reading: false,
_observe: observe,
});
self.refresh(cx);
}
/// Rereads the lists after review events.
fn refresh(&mut self, cx: &mut Context<Self>) {
let Some(shown) = self.shown.as_mut() else {
return;
};
let count = shown.view.read(cx).review();
if shown.reading || shown.read == Some(count) {
return;
}
(shown.read, shown.reading) = (Some(count), true);
let reader = shown.view.entity_id();
ely_lok::read_off(
&self.worker,
&shown.doc,
|worker, doc| {
let json = worker.values(doc, ".uno:ViewAnnotations")?;
let notes = ely_lok::review::notes(&json)?;
let json = worker.values(doc, ".uno:AcceptTrackedChanges")?;
Ok((notes, ely_lok::review::changes(&json)?))
},
move |review: &mut Self, read, cx| {
let Some(shown) = review.shown.as_mut() else {
return;
};
if shown.view.entity_id() != reader {
return;
}
shown.reading = false;
let read = read.and_then(|(notes, changes)| {
let threads = threads(&notes).map_err(std::io::Error::other)?;
Ok((notes, threads, changes))
});
match read {
Ok((notes, threads, changes)) => {
log::info!(
"doc: review lists {} notes, {} changes",
notes.len(),
changes.len()
);
review.notes = notes;
review.threads = threads;
review.changes = changes;
}
Err(error) => cx.emit(LokError(format!(
"the review lists are unreadable: {error}"
))),
}
cx.notify();
review.refresh(cx);
},
cx,
);
}
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
let Some(shown) = &self.shown else {
return;
};
log::info!("doc: review runs {id}");
if let Err(error) = shown.view.update(cx, |view, _| view.run(id, values)) {
cx.emit(LokError(format!("{id}: {error}")));
}
}
fn reveal(&mut self, anchor: ely_lok::Rect, cx: &mut Context<Self>) {
if let Some(shown) = &self.shown {
shown.view.update(cx, |view, cx| view.reveal(anchor, cx));
}
}
/// Picks a thread and goes to its words.
fn select(&mut self, key: &SharedString, cx: &mut Context<Self>) {
self.active = Some(key.clone());
if let Some(note) = self.notes.iter().find(|note| note.id == key.as_ref()) {
self.reveal(note.anchor, cx);
}
cx.notify();
}
/// Answers the picked thread.
fn reply(&mut self, text: &str, cx: &mut Context<Self>) {
if let Some(key) = self.active.clone() {
self.run("comment.reply", &[&key, text], cx);
}
}
fn changes_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
if self.changes.is_empty() {
let none = Docs::text(cx, "doc.review.none", &[]);
return EmptyState::new("doc-review-none", IconName::Check, none).into_any_element();
}
let this = cx.entity();
let (accept, reject) = (
Docs::text(cx, "doc.review.accept", &[]),
Docs::text(cx, "doc.review.reject", &[]),
);
let rows = self.changes.iter().map(|change| {
let index = change.index.to_string();
let decide = |id: &'static str, icon, tip: &SharedString| {
let (this, index) = (this.clone(), index.clone());
IconButton::new((id, change.index as usize), icon)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip(tip.clone())
.on_click(move |_, _, cx| {
cx.stop_propagation();
this.update(cx, |review, cx| review.run(id, &[&index], cx))
})
};
let actions = div()
.flex()
.gap_1()
.child(decide("track.accept", IconName::Check, &accept))
.child(decide("track.reject", IconName::X, &reject));
let mut item = ListItem::new(
("doc-review-change", change.index as usize),
change.description.clone(),
)
.description(change.author.clone())
.trailing(actions);
if let Some(anchor) = change.anchor {
let this = this.clone();
item = item.on_click(move |_, _, cx| {
this.update(cx, |review, cx| review.reveal(anchor, cx))
});
}
item
});
List::new().divided().children(rows).into_any_element()
}
}
impl Render for Review {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let this = cx.entity();
let tracking = self.shown.as_ref().and_then(|shown| {
shown
.view
.read(cx)
.state(".uno:TrackChanges")
.map(|on| on == "true")
});
let all = |id: &'static str, key: &str| {
let this = this.clone();
Button::new(id, Docs::text(cx, key, &[]))
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.disabled(self.changes.is_empty())
.on_click(move |_, _, cx| this.update(cx, |review, cx| review.run(id, &[], cx)))
};
let head = div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.child(
Switch::new("doc-review-track", tracking == Some(true))
.disabled(tracking.is_none())
.label(Docs::text(cx, "doc.review.track", &[]))
.on_change({
let this = this.clone();
move |_, _, cx| {
this.update(cx, |review, cx| review.run("track.toggle", &[], cx))
}
}),
)
.child(all("track.accept_all", "doc.review.accept_all"))
.child(all("track.reject_all", "doc.review.reject_all"));
let roots = self.threads.len().to_string();
let pane = SegmentedControl::new(
"doc-review-pane",
match self.pane {
Pane::Comments => "comments",
Pane::Changes => "changes",
},
)
.size(ControlSize::Sm)
.segment(
"comments",
Docs::text(cx, "doc.review.comments", &[("n", &roots)]),
None,
)
.segment(
"changes",
Docs::text(
cx,
"doc.review.changes",
&[("n", &self.changes.len().to_string())],
),
None,
)
.on_change({
let this = this.clone();
move |value, _, cx| {
this.update(cx, |review, cx| {
review.pane = match value.as_ref() {
"changes" => Pane::Changes,
_ => Pane::Comments,
};
cx.notify();
})
}
});
let body = match self.pane {
Pane::Changes => ScrollArea::new("doc-review-scroll").vertical()
.size_full()
.child(self.changes_list(cx))
.into_any_element(),
Pane::Comments => {
let field = window.use_keyed_state("doc-review-reply", cx, |window, cx| {
TextInput::new(window, cx)
});
let (pick, send) = (this.clone(), this.clone());
CommentSidebar::new("doc-review-comments", self.threads.clone())
.active(self.active.clone(), move |key, _, cx| {
pick.update(cx, |review, cx| review.select(key, cx))
})
.reply(&field.clone(), move |_, cx| {
let text = field.read(cx).text().trim().to_string();
send.update(cx, |review, cx| review.reply(&text, cx));
field.update(cx, |field, cx| field.set_text("", cx));
})
.into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.gap_2()
.p_2()
.child(head)
.child(pane)
.child(div().flex_1().min_h_0().child(body))
}
}
/// The notes as threads under their first comment.
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
let parents: HashMap<&str, &str> = notes
.iter()
.map(|note| (note.id.as_str(), note.parent.as_str()))
.collect();
let root = |id: &'_ str| -> String {
let mut id = id;
while let Some(parent) = parents.get(id).filter(|parent| **parent != "0") {
id = parent;
}
id.to_string()
};
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
let mut threads: Vec<Thread> = Vec::new();
for note in notes {
let at = note
.at
.parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?;
let hue = authors
.iter()
.position(|name| *name == note.author)
.expect("the author is listed");
let comment = Comment {
author: Peer::new(note.author.clone(), note.author.clone(), hue),
at: at.timestamp(),
body: note.text.clone().into(),
reactions: Vec::new(),
};
let key: SharedString = root(&note.id).into();
match threads.iter_mut().find(|thread| thread.key == key) {
Some(thread) => thread.comments.push(comment),
None => threads.push(Thread {
key,
quote: None,
comments: vec![comment],
resolved: note.resolved,
}),
}
}
Ok(threads)
}
#[cfg(test)]
mod tests;
+84
View File
@@ -0,0 +1,84 @@
use gpui::{Entity, TestAppContext};
use super::*;
use crate::actions::Dialog;
use crate::refs::InsertComment;
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
use ely_office::prelude::*;
/// The review panel on the active tab.
fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Review> {
cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
let (view, doc) = (tab.view.clone(), tab.doc.clone());
let review = docs.read(app).review.clone();
review.update(app, |review, cx| review.show(Some((&view, &doc)), cx));
review
})
}
#[test]
fn comments_thread_and_changes_accept_or_reject() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Hello");
let review = panel(&mut cx, &docs);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_comment(&InsertComment, window, cx);
let Some(Dialog::Ask { ask, .. }) = &docs.dialog else {
panic!("the comment prompt is open");
};
let ask = ask.clone();
docs.answer(&ask, "First note", cx);
});
assert_eq!(
cx.update(|app| docs.read(app).side),
Side::Review,
"the panel shows"
);
settle(&cx, |app| review.read(app).threads.len() == 1);
let key = cx.update(|app| review.read(app).threads[0].key.clone());
cx.update(|app| review.update(app, |review, cx| review.select(&key, cx)));
cx.update(|app| review.update(app, |review, cx| review.reply("An answer", cx)));
settle(&cx, |app| {
review
.read(app)
.threads
.first()
.is_some_and(|thread| thread.comments.len() == 2)
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_track(&TrackChanges, window, cx)
});
settle(&cx, |app| {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).state(".uno:TrackChanges") == Some("true")
});
type_into(&mut cx, &docs, " kept");
settle(&cx, |app| review.read(app).changes.len() == 1);
let index = cx.update(|app| review.read(app).changes[0].index.to_string());
cx.update(|app| review.update(app, |review, cx| review.run("track.accept", &[&index], cx)));
settle(&cx, |app| review.read(app).changes.is_empty());
type_into(&mut cx, &docs, " gone");
settle(&cx, |app| review.read(app).changes.len() == 1);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reject_all(&RejectAll, window, cx)
});
settle(&cx, |app| review.read(app).changes.is_empty());
settled(&cx, &docs);
let path = dir.path().join("review.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
for mark in ["First note", "An answer", " kept"] {
assert!(text.contains(mark), "{mark} lands");
}
assert!(!text.contains("gone"), "the rejected words go");
}
+85
View File
@@ -0,0 +1,85 @@
//! Spelling suggestions from the kernel's context menu (DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::LokView;
use gpui::{Context, Entity, IntoElement, Pixels, Point};
use crate::docs::Docs;
use ely_office::prelude::*;
/// A misspelled word's menu, asked for at a point.
pub struct Offer {
tab: u64,
pub(crate) suggestions: Vec<(String, String)>,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
}
impl Docs {
/// Turns the kernel's context menu into a spelling offer.
pub(crate) fn offer_spelling(
&mut self,
id: u64,
view: &Entity<LokView>,
cx: &mut Context<Self>,
) {
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
return;
};
let spelling = match ely_lok::review::spelling(&json) {
Ok(spelling) => spelling,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let Some(spelling) = spelling else {
log::info!("doc: the kernel menu has no spelling");
return;
};
let Some(caret) = view.read(cx).cursor_bounds() else {
log::warn!("doc: a spelling menu without a caret");
return;
};
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
self.offer = Some(Offer {
tab: id,
suggestions: spelling.suggestions,
request: (number, caret.bottom_left()),
});
cx.notify();
}
/// The spelling menu's host, for the active tab.
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
let this = cx.entity();
let mut menu = Menu::new();
for (label, rule) in &offer.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if offer.suggestions.is_empty() {
let none = Docs::text(cx, "doc.spell.none", &[]);
menu = menu.item(MenuItem::new(none).disabled(true));
}
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
let menu = menu
.separator()
.item(MenuItem::new(ignore).on_click(move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
}));
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
}
}
+465
View File
@@ -0,0 +1,465 @@
use std::{
path::Path,
sync::{Arc, Mutex},
};
use gpui::{AppContext as _, Entity, TestAppContext, Window};
use crate::docs::Docs;
use crate::files::Template;
use ely_office::prelude::*;
use ely_office::{After, Dialog};
struct Blank;
impl gpui::Render for Blank {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
gpui::Empty
}
}
/// A Docs over a fresh worker, if LOK exists.
pub(crate) fn docs(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Docs>> {
// A failing test prints its log.
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Info)
.try_init();
let install = ely_lok::install_dir().ok()?;
let worker = ely_lok::Worker::spawn(&install, &dir.join("profile")).expect("the worker");
let work = dir.join("work");
std::fs::create_dir_all(&work).expect("the work dir");
cx.update(|app| {
ely_ui::settings::install_defaults(app);
ely_ui::i18n::install(app);
let worker = Arc::new(Mutex::new(worker));
Some(app.new(|cx| Docs::new(worker, work, cx)))
})
}
/// Steps the scheduler until `done` or ten seconds pass.
#[track_caller]
pub(crate) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if !cx.executor().tick() {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
}
if cx.update(|app| done(app)) {
return;
}
}
panic!("the state never settled");
}
/// Waits until `path` holds bytes other than `before`.
#[track_caller]
pub(crate) fn written(cx: &TestAppContext, path: &Path, before: Option<&[u8]>) {
settle(cx, |_| {
std::fs::read(path).is_ok_and(|bytes| Some(bytes.as_slice()) != before)
});
}
/// Runs `call` with a window, as actions get one.
pub(crate) fn with_window(
cx: &mut TestAppContext,
docs: &Entity<Docs>,
call: impl FnOnce(&mut Docs, &mut Window, &mut gpui::Context<Docs>),
) {
let window = cx.add_window(|_, _| Blank);
cx.update_window(window.into(), |_, window, app| {
docs.update(app, |docs, cx| {
docs.core.window = Some(window.window_handle());
call(docs, window, cx)
})
})
.expect("the window lives");
}
pub(crate) fn type_into(cx: &mut TestAppContext, docs: &Entity<Docs>, text: &str) {
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
let window = cx.add_window(|_, _| Blank);
cx.update_window(window.into(), |_, window, app| {
let mut ime = gpui::ElementInputHandler::new(gpui::Bounds::default(), view.clone());
gpui::InputHandler::replace_text_in_range(&mut ime, None, text, window, app);
})
.expect("the window lives");
settle(cx, |app| {
docs.read(app)
.active_tab()
.is_some_and(|tab| tab.dirty(app) && tab.view.read(app).settled())
});
}
#[test]
fn a_new_document_saves_as_docx_and_comes_back_clean() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
with_window(&mut cx, &docs, |docs, _, cx| {
docs.new_from(Template::Report, cx)
});
settle(&cx, |app| docs.read(app).core.active.is_some());
let (id, dirty) = cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
(tab.id, tab.dirty(app))
});
assert!(!dirty, "a fresh template is clean");
type_into(&mut cx, &docs, "Ely");
let target = dir.path().join("Report.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, target.clone(), None, window, cx)
});
written(&cx, &target, None);
let bytes = std::fs::read(&target).expect("the docx exists");
assert!(bytes.starts_with(b"PK"), "a docx zip");
settle(&cx, |app| {
docs.read(app)
.active_tab()
.is_some_and(|tab| !tab.dirty(app))
});
cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
assert_eq!(tab.name.as_ref(), "Report.docx");
assert!(tab.registry.is_some(), "the saved file joins the registry");
});
}
#[test]
fn a_save_keeps_the_format_and_save_as_converts_to_a_new_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let odt = dir.path().join("Notes.odt");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.new_from(Template::Blank, cx)
});
settle(&cx, |app| docs.read(app).core.active.is_some());
let id = cx.update(|app| docs.read(app).core.active.expect("a tab"));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, odt.clone(), None, window, cx)
});
written(&cx, &odt, None);
type_into(&mut cx, &docs, "more");
let first = std::fs::read(&odt).expect("the first odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.save(id, None, window, cx)
});
written(&cx, &odt, Some(&first));
let odf = std::fs::read(&odt).expect("the odt exists");
let mime = b"application/vnd.oasis.opendocument.text";
assert!(
odf.windows(mime.len()).any(|w| w == mime),
"the save kept ODT"
);
let docx = dir.path().join("Notes.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, docx.clone(), None, window, cx)
});
written(&cx, &docx, None);
assert!(std::fs::read(&docx).expect("the docx").starts_with(b"PK"));
assert_eq!(
std::fs::read(&odt).expect("the odt stays"),
odf,
"the source is untouched"
);
let pdf = dir.path().join("Notes.pdf");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, pdf.clone(), None, window, cx)
});
assert!(!pdf.exists(), "Save As writes only DOCX or ODT");
}
#[test]
fn a_local_template_opens_untitled_and_stays_untouched() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let template = dir.path().join("memo.fodt");
let fodt = cx.update(|app| Template::Letter.fodt(app));
std::fs::write(&template, &fodt).expect("the template writes");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.new_from_file(&template, cx)
});
settle(&cx, |app| docs.read(app).core.active.is_some());
type_into(&mut cx, &docs, "x");
let id = cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
assert!(tab.session.path().is_none(), "a template opens untitled");
tab.id
});
let out = dir.path().join("memo.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, out.clone(), None, window, cx)
});
written(&cx, &out, None);
assert!(out.exists());
assert_eq!(
std::fs::read_to_string(&template).expect("the template"),
fodt,
"the original stays"
);
}
#[test]
fn an_open_file_gets_focus_and_unsaved_closes_ask() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let file = dir.path().join("open.fodt");
let fodt = cx.update(|app| Template::Report.fodt(app));
std::fs::write(&file, fodt).expect("the file writes");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.open_file(file.clone(), cx)
});
settle(&cx, |app| docs.read(app).core.tabs.len() == 1);
with_window(&mut cx, &docs, |docs, _, cx| {
docs.open_file(file.clone(), cx)
});
for _ in 0..40 {
cx.executor().tick();
}
assert_eq!(
cx.update(|app| docs.read(app).core.tabs.len()),
1,
"the same file focuses"
);
type_into(&mut cx, &docs, "edit");
let id = cx.update(|app| docs.read(app).core.active.expect("a tab"));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.request_close(id, After::Tab, window, cx)
});
cx.update(|app| {
let asking =
matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { tab, .. }) if tab == id);
assert!(asking, "a dirty close asks");
});
assert!(
cx.update(|app| docs.read(app).core.tabs.len()) == 1,
"nothing closed yet"
);
}
/// The text the kernel reads back from `path`.
pub(crate) fn text_of(cx: &mut TestAppContext, docs: &Entity<Docs>, path: &Path) -> String {
let worker = cx.update(|app| docs.read(app).core.worker.clone());
let mut worker = worker.lock().expect("the worker lock");
let doc = worker
.load(&ely_lok::file_url(path))
.expect("the saved file loads");
let flat = path.with_extension("check.fodt");
doc.save_as(&mut worker, &ely_lok::file_url(&flat), "fodt")
.expect("flattens");
worker.close_doc(doc).expect("closes");
std::fs::read_to_string(&flat).expect("reads")
}
/// Waits for the active tab's queued input to land.
pub(crate) fn settled(cx: &TestAppContext, docs: &Entity<Docs>) {
settle(cx, |app| {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).settled()
});
}
/// Opens the blank template and returns its tab id.
pub(crate) fn blank(cx: &mut TestAppContext, docs: &Entity<Docs>) -> u64 {
let before = cx.update(|app| docs.read(app).core.tabs.len());
with_window(cx, docs, |docs, _, cx| docs.new_from(Template::Blank, cx));
settle(cx, |app| docs.read(app).core.tabs.len() > before);
cx.update(|app| docs.read(app).core.tabs.last().expect("a tab").id)
}
/// Commits `text` through the view without waiting.
pub(crate) fn commit(cx: &mut TestAppContext, docs: &Entity<Docs>, text: &str) {
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
let window = cx.add_window(|_, _| Blank);
cx.update_window(window.into(), |_, window, app| {
let mut ime = gpui::ElementInputHandler::new(gpui::Bounds::default(), view.clone());
gpui::InputHandler::replace_text_in_range(&mut ime, None, text, window, app);
})
.expect("the window lives");
}
#[test]
fn queued_input_reaches_the_save_and_the_close_prompt() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
commit(&mut cx, &docs, "PendingText");
let out = dir.path().join("pending.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, out.clone(), None, window, cx)
});
written(&cx, &out, None);
settle(&cx, |_| out.exists());
assert!(
text_of(&mut cx, &docs, &out).contains("PendingText"),
"the queued text saved"
);
commit(&mut cx, &docs, "More");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.request_close(id, After::Tab, window, cx)
});
settle(&cx, |app| {
matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. }))
});
}
#[test]
fn a_macro_file_opens_in_risk_mode_and_saves_only_as_a_copy() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let macro_doc = dir.path().join("macro.fodt");
let body = cx.update(|app| Template::Report.fodt(app))
.replace(
"office:version=",
"xmlns:script=\"urn:oasis:names:tc:opendocument:xmlns:script:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=",
)
.replace(
"<office:body>",
"<office:scripts><office:script script:language=\"ooo:Basic\"><ooo:libraries><ooo:library-embedded ooo:name=\"Standard\"><ooo:module ooo:name=\"Module1\"><ooo:source-code>Sub Main&#x0A;End Sub</ooo:source-code></ooo:module></ooo:library-embedded></ooo:libraries></office:script></office:scripts><office:body>",
);
std::fs::write(&macro_doc, &body).expect("the macro file writes");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.open_file(macro_doc.clone(), cx)
});
settle(&cx, |app| docs.read(app).core.active.is_some());
let id = cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
assert!(tab.risk.is_some(), "macros put the tab in risk mode");
tab.id
});
type_into(&mut cx, &docs, "x");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, macro_doc.with_extension("docx"), None, window, cx)
});
let copy = macro_doc.with_extension("docx");
written(&cx, &copy, None);
assert!(copy.exists(), "the copy writes");
assert_eq!(
std::fs::read_to_string(&macro_doc).expect("source"),
body,
"the source stays"
);
cx.update(|app| {
assert!(
!docs.read(app).active_tab().expect("a tab").risk.is_some(),
"the DOCX copy holds no macros"
)
});
}
#[cfg(unix)]
#[test]
fn an_alias_never_converts_the_open_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
let source = dir.path().join("source.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, source.clone(), None, window, cx)
});
written(&cx, &source, None);
let before = std::fs::read(&source).expect("the odt");
let alias = dir.path().join("alias.docx");
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
type_into(&mut cx, &docs, "y");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, alias.clone(), None, window, cx)
});
written(&cx, &source, Some(&before));
let after = std::fs::read(&source).expect("the odt");
let has = |bytes: &[u8], mark: &[u8]| bytes.windows(mark.len()).any(|w| w == mark);
assert!(
has(&after, b"application/vnd.oasis.opendocument.text"),
"the file stays ODT"
);
assert!(
!has(&after, b"word/document.xml"),
"no DOCX lands in the ODT"
);
assert_ne!(after, before, "the alias saved in the file's own format");
let link = dir.path().join("link.docx");
std::fs::hard_link(&source, &link).expect("the hard link");
type_into(&mut cx, &docs, "z");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, link.clone(), None, window, cx)
});
assert_eq!(
std::fs::read(&source).expect("the odt"),
after,
"a hard link never converts the open file"
);
assert_eq!(
std::fs::read(&link).expect("the link"),
after,
"the link is refused, not replaced"
);
}
#[test]
fn an_outside_change_asks_and_keep_mine_overwrites() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
let own = dir.path().join("own.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, own.clone(), None, window, cx)
});
written(&cx, &own, None);
type_into(&mut cx, &docs, "Mine");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&own, "EXTERNAL REVISION").expect("the outside write");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.save(id, None, window, cx)
});
settle(&cx, |app| {
matches!(docs.read(app).core.dialog, Some(Dialog::Conflict { .. }))
});
assert_eq!(
std::fs::read_to_string(&own).expect("own"),
"EXTERNAL REVISION"
);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.keep_mine(id, None, window, cx)
});
written(&cx, &own, Some(b"EXTERNAL REVISION"));
assert!(
text_of(&mut cx, &docs, &own).contains("Mine"),
"keep mine wrote the local version"
);
}
mod find;
mod more;
mod overlap;
+240
View File
@@ -0,0 +1,240 @@
use ely_gpui_component::forms::TextInput;
use ely_office::{FindReplace, Finder, prelude::*};
use gpui::{AppContext as _, Entity, TestAppContext};
use crate::docs::Docs;
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
fn finder<R>(cx: &TestAppContext, docs: &Entity<Docs>, read: impl Fn(&Finder) -> R) -> R {
cx.update(|app| {
read(
docs.read(app)
.core
.finder
.as_ref()
.expect("the finder is open"),
)
})
}
fn total(app: &gpui::App, docs: &Entity<Docs>) -> Option<usize> {
docs.read(app)
.core
.finder
.as_ref()
.map(|finder| finder.all.len())
}
#[test]
fn find_counts_steps_replaces_and_counts_words() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "cat Cat cat dog");
settle(&cx, |app| {
docs.read(app)
.active_tab()
.and_then(|tab| tab.extra.counts)
.is_some_and(|counts| counts.words == 4)
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_find(&FindReplace, window, cx)
});
let query = finder(&cx, &docs, |finder| finder.query.clone());
cx.update(|app| query.update(app, |query, cx| query.set_text("cat", cx)));
settle(&cx, |app| total(app, &docs) == Some(3));
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.core.finder.as_mut().expect("open").options.case = true;
docs.count_matches(cx);
})
});
settle(&cx, |app| total(app, &docs) == Some(2));
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
settle(&cx, |app| {
docs.read(app)
.core
.finder
.as_ref()
.is_some_and(|finder| finder.current == Some(0))
});
with_window(&mut cx, &docs, |docs, window, cx| {
let finder = docs.core.finder.as_mut().expect("open");
finder.replace = Some(cx.new(|cx| TextInput::new(window, cx)));
});
let with = finder(&cx, &docs, |finder| {
finder.replace.clone().expect("a field")
});
cx.update(|app| with.update(app, |with, cx| with.set_text("cow", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.replace(false, cx)));
settle(&cx, |app| total(app, &docs) == Some(1));
cx.update(|app| docs.update(app, |docs, cx| docs.replace(true, cx)));
settle(&cx, |app| total(app, &docs) == Some(0));
settled(&cx, &docs);
let path = dir.path().join("found.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let text = text_of(&mut cx, &docs, &path);
assert!(text.contains("cow Cat cow dog"), "the cased matches go");
}
#[test]
fn a_misspelled_word_offers_suggestions() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Teh end");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_find(&FindReplace, window, cx)
});
let query = finder(&cx, &docs, |finder| finder.query.clone());
cx.update(|app| query.update(app, |query, cx| query.set_text("Teh", cx)));
settle(&cx, |app| total(app, &docs) == Some(1));
let (x, y, _, h) = finder(&cx, &docs, |finder| finder.all[0]);
let (worker, doc) = cx.update(|app| {
let docs = docs.read(app);
(
docs.core.worker.clone(),
docs.active_tab().expect("a tab").doc.clone(),
)
});
{
let mut worker = worker.lock().expect("the worker lock");
for (kind, buttons) in [(0, 1), (1, 1), (0, 4), (1, 4)] {
let (x, y, count, modifiers) = (x + 60, y + h / 2, 1, 0);
let event = ely_lok::Mouse {
kind,
x,
y,
count,
buttons,
modifiers,
};
worker.post_mouse(&doc, event).expect("the click posts");
}
}
settle(&cx, |app| docs.read(app).offer.is_some());
let rule = cx.update(|app| {
let offer = docs.read(app).offer.as_ref().expect("an offer");
let (_, rule) = offer
.suggestions
.iter()
.find(|(label, _)| label == "The")
.expect("The is offered");
rule.clone()
});
cx.update(|app| docs.update(app, |docs, cx| docs.run_active("spell.apply", &[&rule], cx)));
settled(&cx, &docs);
let path = dir.path().join("spelled.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
assert!(
text_of(&mut cx, &docs, &path).contains("The end"),
"the suggestion lands"
);
}
#[test]
fn edits_mark_the_count_and_steps_scroll_to_far_matches() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let filler = "<text:p>filler</text:p>".repeat(100);
let body = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text><text:p>a b c</text:p>{filler}<text:p>needle</text:p></office:text></office:body></office:document>"#
);
let path = dir.path().join("long.fodt");
std::fs::write(&path, body).expect("the file writes");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.open_file(path.clone(), cx)
});
settle(&cx, |app| docs.read(app).core.tabs.len() == 1);
settled(&cx, &docs);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_find(&FindReplace, window, cx)
});
let query = finder(&cx, &docs, |finder| finder.query.clone());
cx.update(|app| query.update(app, |query, cx| query.set_text(" ", cx)));
settle(&cx, |app| total(app, &docs) == Some(2));
cx.update(|app| query.update(app, |query, cx| query.set_text("needle", cx)));
settle(&cx, |app| total(app, &docs) == Some(1));
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
settle(&cx, |app| {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).scroll().1 > 20_000
});
type_into(&mut cx, &docs, "needle needle");
settle(&cx, |app| {
docs.read(app)
.core
.finder
.as_ref()
.is_some_and(|finder| finder.stale)
});
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
settle(&cx, |app| {
let finder = docs.read(app).core.finder.as_ref().expect("open");
!finder.stale && finder.all.len() == 2
});
}
#[test]
fn a_step_asked_during_a_count_waits_for_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &docs);
type_into(&mut cx, &docs, "cat cat cat");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_find(&FindReplace, window, cx)
});
let query = finder(&cx, &docs, |finder| finder.query.clone());
cx.update(|app| query.update(app, |query, cx| query.set_text("cat", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
assert_eq!(
finder(&cx, &docs, |finder| finder.queued.len()),
2,
"both steps wait for the count"
);
settle(&cx, |app| {
docs.read(app)
.core
.finder
.as_ref()
.is_some_and(|finder| finder.all.len() == 3 && finder.current == Some(1))
});
cx.update(|app| query.update(app, |query, cx| query.set_text("ca", cx)));
cx.update(|app| query.update(app, |query, cx| query.set_text("", cx)));
settle(&cx, |app| {
docs.read(app)
.core
.finder
.as_ref()
.is_some_and(|finder| finder.pending.is_empty())
});
assert_eq!(
finder(&cx, &docs, |finder| finder.all.len()),
0,
"a cleared query voids the count on its way"
);
}
+217
View File
@@ -0,0 +1,217 @@
use gpui::TestAppContext;
use super::*;
/// The report template with an embedded Basic library.
fn macro_fodt(cx: &mut TestAppContext) -> String {
cx.update(|app| Template::Report.fodt(app))
.replace(
"office:version=",
"xmlns:script=\"urn:oasis:names:tc:opendocument:xmlns:script:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=",
)
.replace(
"<office:body>",
"<office:scripts><office:script script:language=\"ooo:Basic\"><ooo:libraries><ooo:library-embedded ooo:name=\"Standard\"><ooo:module ooo:name=\"Module1\"><ooo:source-code>Sub Main&#x0A;End Sub</ooo:source-code></ooo:module></ooo:library-embedded></ooo:libraries></office:script></office:scripts><office:body>",
)
}
#[test]
fn zip_macro_checks_read_real_kernel_files() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let source = dir.path().join("basic.fodt");
std::fs::write(&source, macro_fodt(&mut cx)).expect("writes");
let worker = cx.update(|app| docs.read(app).core.worker.clone());
let mut worker = worker.lock().expect("the worker lock");
let doc = worker.load(&ely_lok::file_url(&source)).expect("loads");
let (odt, docx) = (dir.path().join("basic.odt"), dir.path().join("plain.docx"));
doc.save_as(&mut worker, &ely_lok::file_url(&odt), "odt")
.expect("odt");
doc.save_as(&mut worker, &ely_lok::file_url(&docx), "docx")
.expect("docx");
worker.close_doc(doc).expect("closes");
assert!(
ely_office::files::macro_risk(&Docs::KIND, &odt).expect("odt"),
"an ODT with Basic is a risk"
);
assert!(
!ely_office::files::macro_risk(&Docs::KIND, &docx).expect("docx"),
"a DOCX drops the Basic"
);
}
#[test]
fn a_local_template_with_macros_opens_in_risk_mode() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let template = dir.path().join("macro-template.fodt");
std::fs::write(&template, macro_fodt(&mut cx)).expect("writes");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.new_from_file(&template, cx)
});
settle(&cx, |app| docs.read(app).core.active.is_some());
cx.update(|app| {
assert!(
docs.read(app).active_tab().expect("a tab").risk.is_some(),
"the copy shows the risk"
)
});
}
#[test]
fn select_all_alone_leaves_the_tab_clean() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &docs);
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
view.update(&mut cx, |view, _| {
view.run("edit.select_all", &[]).expect("select all queues")
});
settle(&cx, |app| view.read(app).settled());
for _ in 0..40 {
cx.executor().tick();
}
cx.update(|app| {
assert!(
!docs.read(app).active_tab().expect("a tab").dirty(app),
"selection is no edit"
)
});
}
#[test]
fn keep_mine_waits_and_two_queued_saves_never_crash() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
let mine = dir.path().join("mine.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, mine.clone(), None, window, cx)
});
written(&cx, &mine, None);
type_into(&mut cx, &docs, "x");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&mine, "EXTERNAL").expect("the outside write");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.save(id, None, window, cx)
});
commit(&mut cx, &docs, "PendingKeepMine");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.keep_mine(id, Some(After::Tab), window, cx)
});
settle(&cx, |app| docs.read(app).core.tabs.is_empty());
let saved = text_of(&mut cx, &docs, &mine);
assert!(
saved.contains("PendingKeepMine"),
"keep mine saved the queued text: {}",
saved[saved.find("<office:text").unwrap_or(0)..].replace("\n", " ")
);
let id = blank(&mut cx, &docs);
commit(&mut cx, &docs, "queued");
let (one, two) = (dir.path().join("one.docx"), dir.path().join("two.docx"));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, one.clone(), Some(After::Tab), window, cx)
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, two.clone(), None, window, cx)
});
settle(&cx, |app| docs.read(app).core.tabs.is_empty());
for _ in 0..40 {
cx.executor().tick();
}
assert!(
one.exists() && !two.exists(),
"the closed tab saves no more"
);
}
#[test]
fn a_kernel_write_failure_stays_visible() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "RetainedText");
let work = cx.update(|app| docs.read(app).active_tab().expect("a tab").work.clone());
std::fs::remove_dir_all(&work).expect("drops the scratch");
std::fs::write(&work, b"not a folder").expect("blocks it");
let target = dir.path().join("failure.docx");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, target.clone(), None, window, cx)
});
settle(&cx, |app| {
let tab = docs.read(app).active_tab().expect("a tab");
!tab.exporting && tab.session.state() == ely_files::SaveState::SaveFailed
});
assert!(!target.exists(), "the failure writes nothing");
std::fs::remove_file(&work).expect("unblocks");
std::fs::create_dir_all(&work).expect("the scratch returns");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, target.clone(), None, window, cx)
});
written(&cx, &target, None);
assert!(target.exists(), "a retry lands");
cx.update(|app| {
let state = docs.read(app).active_tab().expect("a tab").session.state();
assert_eq!(
state,
ely_files::SaveState::Saved,
"the retry clears the failure"
);
});
}
#[test]
fn an_edit_that_just_landed_still_asks_and_selection_does_not() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
commit(&mut cx, &docs, "JustLanded");
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
settle(&cx, |app| view.read(app).settled());
with_window(&mut cx, &docs, |docs, window, cx| {
docs.request_close(id, After::Tab, window, cx)
});
settle(&cx, |app| {
matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. }))
});
assert!(
cx.update(|app| docs.read(app).tab(id).is_some()),
"a landed edit asks before closing"
);
let id = blank(&mut cx, &docs);
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
view.update(&mut cx, |view, _| {
view.run("edit.select_all", &[]).expect("select all queues")
});
settle(&cx, |app| view.read(app).settled());
with_window(&mut cx, &docs, |docs, window, cx| {
docs.request_close(id, After::Tab, window, cx)
});
settle(&cx, |app| docs.read(app).tab(id).is_none());
}
+166
View File
@@ -0,0 +1,166 @@
use ely_office::After;
use gpui::TestAppContext;
use super::*;
use crate::merge::read_csv;
#[test]
fn typing_during_a_closing_save_is_never_lost() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "BeforeExport ");
let path = dir.path().join("closing.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), Some(After::Tab), window, cx)
});
commit(&mut cx, &docs, "AfterExport");
settle(&cx, |app| {
let docs = docs.read(app);
docs.tab(id).is_none() || matches!(docs.core.dialog, Some(Dialog::Unsaved { .. }))
});
let closed = cx.update(|app| docs.read(app).tab(id).is_none());
let text = text_of(&mut cx, &docs, &path);
assert!(text.contains("BeforeExport"), "the export saved");
assert!(
!closed || text.contains("AfterExport"),
"a closed tab lost later typing"
);
}
#[test]
fn two_tabs_saving_as_one_path_keep_one_owner() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let first = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "FirstOwner");
let second = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SecondOwner");
let same = dir.path().join("same.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(first, same.clone(), None, window, cx);
docs.write_as(second, same.clone(), None, window, cx);
});
written(&cx, &same, None);
settle(&cx, |app| {
docs.read(app).core.tabs.iter().all(|tab| !tab.exporting)
});
let owners: Vec<u64> = cx.update(|app| {
let docs = docs.read(app);
docs.core
.tabs
.iter()
.filter(|tab| {
tab.session
.path()
.is_some_and(|path| ely_files::same_file(path, &same).expect("comparable"))
})
.map(|tab| tab.id)
.collect()
});
assert_eq!(owners.len(), 1, "one tab holds the file");
let text = text_of(&mut cx, &docs, &same);
let mark = if owners[0] == first {
"FirstOwner"
} else {
"SecondOwner"
};
assert!(text.contains(mark), "the file is its owner's");
}
#[test]
fn interleaved_previews_keep_their_sources() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let alpha = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceAlpha ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
let beta = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceBeta ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
settled(&cx, &docs);
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
let table = read_csv(&csv).expect("the csv reads");
update_doc(&mut cx, &docs, |docs, cx| {
docs.preview_record(alpha, &table, 0, cx);
docs.preview_record(beta, &table, 1, cx);
});
settle(&cx, |app| {
let docs = docs.read(app);
// A filled preview is a modified copy.
docs.core.tabs.len() == 4 && docs.core.tabs[2..].iter().all(|tab| tab.modified)
});
let previews: Vec<u64> = cx.update(|app| {
let tabs = &docs.read(app).core.tabs;
tabs[2..].iter().map(|tab| tab.id).collect()
});
let mut texts = Vec::new();
for (n, id) in previews.into_iter().enumerate() {
let path = dir.path().join(format!("preview{n}.odt"));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
texts.push(text_of(&mut cx, &docs, &path));
}
assert!(
texts.iter().any(|text| text.contains("SourceAlpha Ada")),
"{texts:?}"
);
assert!(
texts.iter().any(|text| text.contains("SourceBeta Grace")),
"{texts:?}"
);
}
#[test]
fn closing_a_clean_tab_never_waits_for_the_worker() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
settled(&cx, &docs);
let worker = cx.update(|app| docs.read(app).core.worker.clone());
let (held, hold) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
let _busy = worker.lock().expect("the worker lock");
held.send(()).expect("the test waits");
std::thread::sleep(std::time::Duration::from_millis(400));
});
hold.recv().expect("the lock is held");
let started = std::time::Instant::now();
with_window(&mut cx, &docs, |docs, window, cx| {
docs.request_close(id, After::Tab, window, cx)
});
let took = started.elapsed();
assert!(
took < std::time::Duration::from_millis(100),
"the close waited {took:?}"
);
holder.join().expect("the holder ends");
settle(&cx, |app| docs.read(app).tab(id).is_none());
}
fn update_doc(
cx: &mut TestAppContext,
docs: &Entity<Docs>,
f: impl FnOnce(&mut Docs, &mut gpui::Context<Docs>),
) {
cx.update(|app| docs.update(app, f));
}