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

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

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
//! Visible signatures: drawn, typed or pictured (PDF-12).
use std::{io, path::PathBuf};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
forms::{Choice, Input, Select, SignaturePad, Stroke, TextInput},
overlays::Dialog as Card,
theme::ControlSize,
};
use ely_office::Closing as _;
use ely_pdf_engine::{NewAnnot, Shape, Writing, Wrote};
use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, Window, div};
use crate::content::Tool;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// The width a click places, in points.
const WIDTH: f32 = 160.0;
/// A visible signature ready to place, in its own units.
#[derive(Clone, Debug, PartialEq)]
pub enum Stamp {
/// Strokes scaled to fit a 1×`aspect` box.
Ink { strokes: Vec<Vec<(f32, f32)>>, aspect: f32 },
Typed { text: String, font: SharedString },
Picture { path: PathBuf, aspect: f32 },
}
#[derive(Clone, Copy, PartialEq)]
enum Mode {
Draw,
Type,
Picture,
}
/// The dialog's state while it is open.
pub struct Making {
mode: Mode,
pub(crate) strokes: Vec<Stroke>,
name: Entity<TextInput>,
font: SharedString,
picture: Option<(PathBuf, f32)>,
}
/// Strokes moved to the corner, the longer side 1.
pub fn fitted(strokes: &[Vec<(f32, f32)>]) -> Option<(Vec<Vec<(f32, f32)>>, f32)> {
if strokes.iter().all(|stroke| stroke.len() < 2) {
return None;
}
let points = || strokes.iter().flatten();
let (x0, y0) = points().fold((f32::MAX, f32::MAX), |(x, y), p| (x.min(p.0), y.min(p.1)));
let (x1, y1) = points().fold((f32::MIN, f32::MIN), |(x, y), p| (x.max(p.0), y.max(p.1)));
// The longer side spans 1, so tall strokes fit too.
let width = (x1 - x0).max(y1 - y0).max(1.0);
let fitted = strokes.iter().map(|stroke| stroke.iter().map(|p| ((p.0 - x0) / width, (p.1 - y0) / width)).collect()).collect();
Some((fitted, (y1 - y0) / width))
}
/// A stamp's box: fitted to a drag, or click sized.
pub fn placed(aspect: f32, rect: [f32; 4], click: bool) -> [f32; 4] {
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
let width = if click { WIDTH } else { w.min(h / aspect.max(0.01)) };
[rect[0], rect[1], rect[0] + width, rect[1] + width * aspect]
}
impl PdfView {
/// Places the ready stamp at `rect` on `page`.
pub fn place_stamp(&mut self, page: usize, rect: [f32; 4], click: bool, cx: &mut Context<Self>) {
let Some(stamp) = self.stamp.take() else {
return;
};
self.set_tool(Tool::Read, cx);
match stamp {
Stamp::Ink { strokes, aspect } => {
let at = placed(aspect, rect, click);
let scale = at[2] - at[0];
let strokes = strokes.iter().map(|stroke| stroke.iter().map(|p| (at[0] + p.0 * scale, at[1] + p.1 * scale)).collect()).collect();
let annot = NewAnnot { page, shape: Shape::Ink(strokes), rgb: Some(0), contents: String::new(), author: String::new() };
self.annotate("placing a drawn signature", vec![annot], cx);
}
Stamp::Typed { text, font } => {
let at = placed(0.35, rect, click);
let writing = Writing {
block: None,
expected: String::new(),
text,
font: font.to_string(),
size: (at[3] - at[1]) * 0.8,
rgb: 0,
bx: [at[0], at[1], self.sizes[page].0 - at[0]],
};
self.settled_edit("placing a typed signature", cx, move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
Wrote::Done(mark) => Ok(mark),
Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))),
Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")),
});
}
Stamp::Picture { path, aspect } => {
let at = placed(aspect, rect, click);
self.settled_edit("placing a signature picture", cx, move |worker, doc| worker.add_image(doc, page, &path, at));
}
}
}
}
impl Viewer {
pub fn ask_visible(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_view().is_none() {
return self.no_document(cx);
}
let name = cx.new(|cx| TextInput::new(window, cx));
self.making = Some(Making { mode: Mode::Draw, strokes: Vec::new(), name, font: "script".into(), picture: None });
cx.notify();
}
fn choose_picture(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |viewer, path, cx| {
match image::image_dimensions(&path) {
Ok((w, h)) if w > 0 => {
if let Some(making) = viewer.making.as_mut() {
making.picture = Some((path, h as f32 / w as f32));
}
}
Ok(_) => viewer.panel_failed("a picture with no pixels", cx),
Err(error) => viewer.panel_failed(&error.to_string(), cx),
}
cx.notify();
});
}
/// The stamp the dialog describes, if it describes one.
fn stamp(&self, cx: &App) -> Option<Stamp> {
let making = self.making.as_ref()?;
match making.mode {
Mode::Draw => {
let strokes: Vec<Vec<(f32, f32)>> =
making.strokes.iter().map(|stroke| stroke.iter().map(|p| (f32::from(p.x), f32::from(p.y))).collect()).collect();
let (strokes, aspect) = fitted(&strokes)?;
Some(Stamp::Ink { strokes, aspect })
}
Mode::Type => {
let text = making.name.read(cx).text().trim().to_string();
(!text.is_empty()).then(|| Stamp::Typed { text, font: making.font.clone() })
}
Mode::Picture => making.picture.clone().map(|(path, aspect)| Stamp::Picture { path, aspect }),
}
}
/// Hands the stamp to the view's next press.
pub(crate) fn ready_stamp(&mut self, cx: &mut Context<Self>) {
let (Some(stamp), Some(view)) = (self.stamp(cx), self.active_view()) else {
return;
};
self.making = None;
view.update(cx, |view, cx| {
view.set_tool(Tool::PlaceSignature, cx);
view.stamp = Some(stamp);
});
cx.notify();
}
fn set_making(&mut self, cx: &mut Context<Self>, change: impl FnOnce(&mut Making)) {
if let Some(making) = self.making.as_mut() {
change(making);
}
cx.notify();
}
/// The visible signature dialog, while open.
pub fn visible_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let making = self.making.as_ref()?;
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let switch = this.clone();
let mode = match making.mode {
Mode::Draw => "draw",
Mode::Type => "type",
Mode::Picture => "picture",
};
let modes = SegmentedControl::new("pdf-visible-mode", mode)
.size(ControlSize::Sm)
.segment("draw", label("pdf.visible.draw"), None)
.segment("type", label("pdf.visible.type"), None)
.segment("picture", label("pdf.visible.picture"), None)
.on_change(move |value, _, cx| {
let mode = match value.as_ref() {
"type" => Mode::Type,
"picture" => Mode::Picture,
_ => Mode::Draw,
};
switch.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.mode = mode))
});
let body = match making.mode {
Mode::Draw => {
let drawn = this.clone();
SignaturePad::new("pdf-visible-pad", making.strokes.clone())
.on_change(move |strokes, _, cx| {
let strokes = strokes.to_vec();
drawn.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.strokes = strokes))
})
.into_any_element()
}
Mode::Type => {
let fonts = std::iter::once(("script", "pdf.font.script"))
.chain(crate::editing::FONTS.iter().copied().filter(|(value, _)| *value != "original"))
.map(|(value, key)| Choice::new(value, label(key)))
.collect::<Vec<_>>();
let picked = this.clone();
let font = Select::new("pdf-visible-font", fonts)
.selected(making.font.clone())
.label(label("pdf.text.font"))
.on_change(move |value, _, cx| {
let value = value.clone();
picked.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.font = value))
});
DescriptionList::new()
.item(label("pdf.visible.name"), div().w_full().child(Input::new(&making.name)))
.item(label("pdf.text.font"), font)
.into_any_element()
}
Mode::Picture => {
let chosen = making.picture.as_ref().map_or_else(|| label("pdf.visible.no_picture"), |(path, _)| ely_office::files::file_name(path));
let pick = this.clone();
div()
.flex()
.gap_2()
.items_center()
.child(div().flex_1().child(chosen))
.child(Button::new("pdf-visible-pick", label("pdf.sign.choose")).on_click(move |_, _, cx| pick.update(cx, |viewer, cx| viewer.choose_picture(cx))))
.into_any_element()
}
};
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.making = None;
cx.notify();
})
};
let (ready, go) = (self.stamp(cx).is_some(), label("pdf.visible.place"));
let card = Card::new("pdf-visible", label("pdf.visible.title"), dismiss)
.detail(label("pdf.visible.detail"))
.child(div().flex().flex_col().gap_3().child(modes).child(body))
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("pdf-visible-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.ready_stamp(cx)))
});
Some(card.into_any_element())
}
}