Files
ElyOffice/apps/ely-doc/src/props.rs
T
ZacharyZhang-NY b7fca0146d 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.
2026-10-08 21:20:08 -04:00

257 lines
8.2 KiB
Rust

//! 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))
}
}