Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
379 lines
14 KiB
Rust
379 lines
14 KiB
Rust
use std::{cell::RefCell, rc::Rc};
|
|
|
|
use ely_gpui_component::theme::Theme;
|
|
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> {
|
|
let 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
|
|
});
|
|
// The lists come from a background read.
|
|
settle(cx, |app| {
|
|
let props = props.read(app);
|
|
!props.fonts.is_empty() && props.shown.as_ref().is_some_and(|shown| !shown.paragraph.is_empty())
|
|
});
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// A window root that shows the panel.
|
|
struct Shown(Entity<Props>);
|
|
|
|
impl gpui::Render for Shown {
|
|
fn render(
|
|
&mut self,
|
|
_: &mut gpui::Window,
|
|
_: &mut gpui::Context<Self>,
|
|
) -> impl gpui::IntoElement {
|
|
use gpui::{ParentElement as _, Styled as _};
|
|
gpui::div().size_full().child(self.0.clone())
|
|
}
|
|
}
|
|
|
|
/// The top of the element marked `selector`.
|
|
fn top(cx: &mut gpui::VisualTestContext, selector: &'static str) -> Option<gpui::Pixels> {
|
|
// Past reduced motion's 1ms folds.
|
|
for _ in 0..3 {
|
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
cx.update(|window, _| window.refresh());
|
|
cx.run_until_parked();
|
|
}
|
|
cx.debug_bounds(selector).map(|bounds| bounds.origin.y)
|
|
}
|
|
|
|
#[test]
|
|
fn in_a_table_the_table_group_comes_first_and_folds_stay_put() {
|
|
const DOWN: i32 = 1024;
|
|
const UP: i32 = 1025;
|
|
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;
|
|
};
|
|
cx.update(|cx| {
|
|
ely_gpui_component::init_for_tests(cx);
|
|
Theme::update(cx, |theme| theme.reduced_motion = true);
|
|
});
|
|
blank(&mut cx, &docs);
|
|
type_into(&mut cx, &docs, "Table");
|
|
let props = panel(&mut cx, &docs);
|
|
let window = cx.add_window(|_, _| Shown(props.clone()));
|
|
let mut shown = gpui::VisualTestContext::from_window(window.into(), &cx);
|
|
assert_eq!(
|
|
top(&mut shown, "doc-props-table"),
|
|
None,
|
|
"no table, no group"
|
|
);
|
|
let header = gpui::point(gpui::px(10.0), gpui::px(12.0));
|
|
shown.simulate_click(header, gpui::Modifiers::none());
|
|
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds");
|
|
shown.simulate_click(header, gpui::Modifiers::none());
|
|
assert!(top(&mut shown, "doc-props-text").is_some(), "Text opens");
|
|
with_window(&mut cx, &docs, |docs, _, cx| {
|
|
docs.insert_table(3.0, 2.0, cx)
|
|
});
|
|
let in_table = |app: &gpui::App| props.read(app).state(".uno:DeleteTable", app);
|
|
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
|
|
let table = top(&mut shown, "doc-props-table").expect("the table group");
|
|
let text = top(&mut shown, "doc-props-text").expect("the text group");
|
|
assert!(table < text, "the table group leads: {table:?} vs {text:?}");
|
|
keys(&mut cx, &docs, &[UP]);
|
|
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
|
|
shown.simulate_click(header, gpui::Modifiers::none());
|
|
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds again");
|
|
keys(&mut cx, &docs, &[DOWN]);
|
|
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
|
|
assert!(top(&mut shown, "doc-props-table").is_some(), "Table opens");
|
|
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text stays folded");
|
|
keys(&mut cx, &docs, &[UP]);
|
|
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
|
|
assert_eq!(
|
|
top(&mut shown, "doc-props-text"),
|
|
None,
|
|
"and after the table"
|
|
);
|
|
}
|