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,264 @@
|
||||
//! Writing text, previews, objects and new images (PDF-03, PDF-04).
|
||||
|
||||
use std::{io, path::PathBuf, sync::Arc};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
overlays::Dialog as Card,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::{Closing as _, Tabs as _};
|
||||
use ely_pdf_engine::{Writing, Wrote};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, IntoElement, KeyBinding, MenuItem, ParentElement as _, Styled as _, Window, actions,
|
||||
div, img, px,
|
||||
};
|
||||
|
||||
use crate::content::{Aim, Tool};
|
||||
use crate::editing::Preview;
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
actions!(pdf, [EditContent, AddText, AddImage, StopEditing]);
|
||||
|
||||
/// The key context while an editing tool is on.
|
||||
pub const EDITING: &str = "PdfEditing";
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
cx.bind_keys([KeyBinding::new("escape", StopEditing, Some(EDITING))]);
|
||||
}
|
||||
|
||||
/// The editing items of the Edit menu.
|
||||
pub fn items(cx: &App) -> [MenuItem; 3] {
|
||||
let label = |key: &str| Viewer::text(cx, key, &[]);
|
||||
[
|
||||
MenuItem::action(label("pdf.tool.edit"), EditContent),
|
||||
MenuItem::action(label("pdf.tool.text"), AddText),
|
||||
MenuItem::action(label("pdf.tool.image"), AddImage),
|
||||
]
|
||||
}
|
||||
|
||||
/// Pixels a point in a preview.
|
||||
const PREVIEW: f32 = 2.0;
|
||||
|
||||
impl Viewer {
|
||||
/// Turns `tool` on, or off when it is on.
|
||||
pub(crate) fn toggle_tool(&mut self, tool: Tool, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
view.update(cx, |view, cx| {
|
||||
let now = if view.tool == tool { Tool::Read } else { tool };
|
||||
view.set_tool(now, cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// Writes the panel's text, or empties the block when `erase`.
|
||||
pub fn write_text(&mut self, erase: bool, cx: &mut Context<Self>) {
|
||||
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
|
||||
return;
|
||||
};
|
||||
let Some(aim) = view.read(cx).aim else {
|
||||
return;
|
||||
};
|
||||
let Some((page, mut writing)) = self.edit_panel.read(cx).writing(view.read(cx), aim, cx) else {
|
||||
return;
|
||||
};
|
||||
if erase {
|
||||
writing.text.clear();
|
||||
}
|
||||
// A replacement that changes the layout is shown first.
|
||||
let (replacing, version) = (matches!(aim, Aim::Text { .. }), view.read(cx).version);
|
||||
let (asked, viewer) = (writing.clone(), cx.weak_entity());
|
||||
view.update(cx, |view, cx| {
|
||||
let doc = view.doc;
|
||||
view.read(
|
||||
move |worker| worker.put_text(doc, page, &asked, PREVIEW),
|
||||
move |_, answer, cx| {
|
||||
// The viewer edits this view; wait until it is free.
|
||||
cx.defer(move |cx| {
|
||||
if let Some(viewer) = viewer.upgrade() {
|
||||
viewer.update(cx, |viewer, cx| viewer.previewed((tab, version, page), writing, answer, replacing, cx));
|
||||
}
|
||||
})
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Shows a layout change, a missing glyph, or writes.
|
||||
fn previewed(
|
||||
&mut self,
|
||||
(tab, version, page): (u64, u64, usize),
|
||||
writing: Writing,
|
||||
answer: io::Result<Wrote>,
|
||||
replacing: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match answer {
|
||||
Ok(Wrote::Missing(c)) => {
|
||||
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
|
||||
self.edit_panel.update(cx, |panel, cx| {
|
||||
panel.note = Some(note);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Ok(Wrote::Preview(pixels, size, grew)) if replacing && (grew || writing.font != "original") => {
|
||||
let Some(image) = crate::pages::picture(size, pixels) else {
|
||||
return log::error!("pdf: a preview came back the wrong size");
|
||||
};
|
||||
self.end_preview();
|
||||
self.preview = Some(Preview { tab, version, page, writing, image });
|
||||
cx.notify();
|
||||
}
|
||||
Ok(_) => self.commit_text((tab, version, page), writing, cx),
|
||||
Err(error) => self.failed_read("previewing text", &error, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes for real if tab `tab` is still at `version`.
|
||||
pub fn commit_text(&mut self, (tab, version, page): (u64, u64, usize), writing: Writing, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
|
||||
let text = Self::text(cx, "pdf.object.changed", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
self.on_same(&view, version, cx, |view, cx| {
|
||||
view.edit(
|
||||
"writing text",
|
||||
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||
Wrote::Done(mark) => Ok(mark),
|
||||
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
|
||||
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn failed_read(&self, what: &str, error: &io::Error, cx: &mut App) {
|
||||
log::error!("pdf: {what} failed ({:?})", error.kind());
|
||||
let text = Self::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// The aimed object: its page and index.
|
||||
fn aimed_object(&self, cx: &App) -> Option<(usize, usize)> {
|
||||
match self.active_view()?.read(cx).aim? {
|
||||
Aim::Object { page, index } => Some((page, index)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_object(&mut self, cx: &mut Context<Self>) {
|
||||
let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else {
|
||||
return;
|
||||
};
|
||||
let version = view.read(cx).version;
|
||||
self.on_same(&view, version, cx, |view, cx| {
|
||||
view.edit("deleting an object", move |worker, doc| worker.delete_object(doc, page, index), cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// Asks for a picture to take the aimed image's place.
|
||||
pub fn replace_image(&mut self, cx: &mut Context<Self>) {
|
||||
let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else {
|
||||
return;
|
||||
};
|
||||
let version = view.read(cx).version;
|
||||
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
||||
viewer.on_same(&view, version, cx, |view, cx| {
|
||||
view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs `step` on `view` if no edit came since `version`.
|
||||
pub(crate) fn on_same(
|
||||
&self,
|
||||
view: &gpui::Entity<crate::view::PdfView>,
|
||||
version: u64,
|
||||
cx: &mut Context<Self>,
|
||||
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
|
||||
) {
|
||||
// Landed or queued, an edit since may move the target.
|
||||
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
|
||||
if !open || view.read(cx).version != version || !view.read(cx).settled() {
|
||||
let text = Self::text(cx, "pdf.object.changed", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
view.update(cx, step);
|
||||
}
|
||||
|
||||
/// Asks for a picture; it lands mid-page.
|
||||
fn add_image(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let (version, page) = (view.read(cx).version, view.read(cx).current);
|
||||
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
||||
let (w, h) = match image::image_dimensions(&path) {
|
||||
Ok(size) => size,
|
||||
Err(error) => return viewer.failed_read("reading the picture", &io::Error::other(error), cx),
|
||||
};
|
||||
viewer.on_same(&view, version, cx, |view, cx| {
|
||||
let (pw, ph) = view.sizes[page];
|
||||
// Pixels as points, shrunk to half the page.
|
||||
let fit = (pw * 0.5 / w as f32).min(ph * 0.5 / h as f32).min(0.75);
|
||||
let (iw, ih) = (w as f32 * fit, h as f32 * fit);
|
||||
let rect = [(pw - iw) / 2.0, (ph - ih) / 2.0, (pw + iw) / 2.0, (ph + ih) / 2.0];
|
||||
view.edit("adding an image", move |worker, doc| worker.add_image(doc, page, &path, rect), cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The preview of a layout change, while open.
|
||||
pub fn preview_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let preview = self.preview.as_ref()?;
|
||||
let this = cx.entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |viewer, cx| {
|
||||
viewer.end_preview();
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let go = Self::text(cx, "pdf.text.apply", &[]);
|
||||
let picture = img(Arc::clone(&preview.image)).max_w(px(560.0)).max_h(px(420.0));
|
||||
let card = Card::new("pdf-preview", Self::text(cx, "pdf.preview.title", &[]), dismiss)
|
||||
.detail(Self::text(cx, "pdf.preview.detail", &[]))
|
||||
.child(div().flex().justify_center().child(picture))
|
||||
.action(ely_office::cancel(cx))
|
||||
.action(move |_| {
|
||||
Button::new("pdf-preview-go", go).variant(ButtonVariant::Primary).on_click(move |_, _, cx| {
|
||||
this.update(cx, |viewer, cx| {
|
||||
if let Some(Preview { tab, version, page, writing, image }) = viewer.preview.take() {
|
||||
viewer.retired.push(image);
|
||||
viewer.commit_text((tab, version, page), writing, cx);
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
});
|
||||
Some(card.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the editing actions onto the root.
|
||||
pub fn on(root: gpui::Div, cx: &mut Context<Viewer>) -> gpui::Div {
|
||||
use gpui::InteractiveElement as _;
|
||||
root.on_action(cx.listener(|v, _: &EditContent, _, cx| v.toggle_tool(Tool::Edit, cx)))
|
||||
.on_action(cx.listener(|v, _: &AddText, _, cx| v.toggle_tool(Tool::AddText, cx)))
|
||||
.on_action(cx.listener(|v, _: &AddImage, _, cx| v.add_image(cx)))
|
||||
.on_action(cx.listener(|v, _: &StopEditing, _, cx| v.on_view(|view, cx| view.set_tool(Tool::Read, cx), cx)))
|
||||
}
|
||||
|
||||
impl Viewer {
|
||||
/// Drops the text preview; its picture is freed next render.
|
||||
pub fn end_preview(&mut self) {
|
||||
if let Some(preview) = self.preview.take() {
|
||||
self.retired.push(preview.image);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user