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:
817 files changed
+143101
No files matched your search
@@ -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))
|
||||
});
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(©);
|
||||
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();
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn template_text_is_escaped() {
|
||||
assert_eq!(escape("a<b & c>"), "a<b & c>");
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(©);
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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, ©, None);
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, ©).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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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="'Nowhere Sans'"/></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, ©).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"
|
||||
);
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()]))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(¬es).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(¬e.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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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
End Sub</ooo:source-code></ooo:module></ooo:library-embedded></ooo:libraries></office:script></office:scripts><office:body>",
|
||||
);
|
||||
std::fs::write(¯o_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, ©, None);
|
||||
assert!(copy.exists(), "the copy writes");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(¯o_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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user