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,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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()]))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user