413 lines
14 KiB
Rust
413 lines
14 KiB
Rust
//! 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 {
|
|
/// Reads a kernel list off the window thread, then `then`.
|
|
fn read_then(
|
|
&mut self,
|
|
command: &'static str,
|
|
then: impl FnOnce(&mut Self, String, &mut Window, &mut Context<Self>) + 'static,
|
|
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 id = tab.id;
|
|
self.dialog_asked += 1;
|
|
let asked = self.dialog_asked;
|
|
self.ask_in(
|
|
id,
|
|
move |worker, doc| worker.values(doc, command),
|
|
move |docs, read, window, cx| {
|
|
// A dialog opened since, or a later ask, wins.
|
|
if docs.dialog_asked != asked || docs.dialog.is_some() || docs.core.dialog.is_some() {
|
|
return log::info!("doc: a dialog read for tab {id} lapsed");
|
|
}
|
|
match read {
|
|
Ok(json) => then(docs, json, window, cx),
|
|
Err(error) => docs.engine_failed(&error.to_string(), cx),
|
|
}
|
|
},
|
|
cx,
|
|
);
|
|
}
|
|
|
|
/// The bookmarks' names, then `then`.
|
|
fn with_bookmarks(
|
|
&mut self,
|
|
then: impl FnOnce(&mut Self, Vec<String>, &mut Window, &mut Context<Self>) + 'static,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.read_then(
|
|
".uno:Bookmarks",
|
|
move |docs, json, window, cx| match ely_lok::review::bookmarks(&json) {
|
|
Ok(marks) => then(docs, marks.into_iter().map(|mark| mark.name).collect(), window, cx),
|
|
Err(error) => docs.engine_failed(&error.to_string(), cx),
|
|
},
|
|
cx,
|
|
);
|
|
}
|
|
|
|
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, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.with_bookmarks(|docs, taken, window, cx| docs.ask(Ask::Bookmark(taken), "", window, cx), 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>) {
|
|
self.with_bookmarks(
|
|
|docs, marks, _, cx| {
|
|
let Some(mark) = marks.first().cloned() else {
|
|
let text = Docs::text(cx, "doc.reference.none", &[]);
|
|
return docs.toast(text, Severity::Warning, cx);
|
|
};
|
|
docs.dialog = Some(Dialog::Reference { marks, mark, show: "2" });
|
|
cx.notify();
|
|
},
|
|
cx,
|
|
);
|
|
}
|
|
|
|
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, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.read_then(
|
|
".uno:Formula",
|
|
|docs, json, window, cx| match ely_lok::review::formula(&json) {
|
|
Ok(Some(formula)) => docs.ask(Ask::Formula(true), &formula, window, cx),
|
|
Ok(None) => docs.ask(Ask::Formula(false), "", window, cx),
|
|
Err(error) => docs.engine_failed(&error.to_string(), cx),
|
|
},
|
|
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;
|