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

+96
View File
@@ -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,
),
)
}
}
+296
View File
@@ -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))
});
}
+160
View File
@@ -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()
}
}
+291
View File
@@ -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);
}
}
+95
View File
@@ -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);
}
}
}
}
}
+206
View File
@@ -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()]))
}
}
+131
View File
@@ -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,
}
}
}
+146
View File
@@ -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()
}
}
+158
View File
@@ -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()
}
}
+292
View File
@@ -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"
);
}