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,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