mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-04 09:20:15 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c511aeab | ||
|
|
51e82f3375 |
Generated
+25
-54
@@ -1103,7 +1103,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1520,19 +1520,6 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1600,26 +1587,6 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -6785,7 +6752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6820,7 +6787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6833,11 +6800,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6851,7 +6818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6868,7 +6835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6892,7 +6859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6925,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6939,7 +6906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -6958,7 +6925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6966,7 +6933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -6986,7 +6953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6998,7 +6965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7006,7 +6973,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7027,7 +6994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7041,7 +7008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -7050,7 +7017,6 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -7073,7 +7039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7092,7 +7058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7255,7 +7221,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7614,6 +7580,11 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+4
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia
|
||||
# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also
|
||||
# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" }
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
# WordCraft
|
||||
|
||||
A clean-room, Rust-native word processor. These are the words its interface, docs and code use for
|
||||
the same things.
|
||||
|
||||
## Languages and proofing
|
||||
|
||||
**Interface language**:
|
||||
The language of WordCraft's own menus, buttons and messages.
|
||||
_Avoid_: Display language, UI locale
|
||||
|
||||
**Proofing language**:
|
||||
The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation
|
||||
are checked. A language is told apart from another by its tag (language, script, region, variant),
|
||||
never by the name a list shows for it.
|
||||
_Avoid_: Text language, editing language, document language
|
||||
|
||||
**Language pack**:
|
||||
A downloadable bundle of a language's spelling dictionary and hyphenation patterns.
|
||||
_Avoid_: Dictionary download, language data (that is the embedded data)
|
||||
|
||||
**Language list**:
|
||||
The proofing languages offered in Set Proofing Language, Review › Language and the status bar's
|
||||
menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language,
|
||||
every available) Language pack, each once.
|
||||
_Avoid_: Language menu (that is only one of the places it is shown)
|
||||
|
||||
**Your languages**:
|
||||
The proofing languages Options › Language lists: each English variant in use (the default proofing
|
||||
language and any in the open document), every language with an installed
|
||||
Language pack, and every proofing language used in the open document.
|
||||
_Avoid_: Authoring languages, preferred languages
|
||||
|
||||
**Default proofing language**:
|
||||
The proofing language new blank documents get, set with Set as default; without one, the interface
|
||||
language.
|
||||
_Avoid_: Preferred language
|
||||
|
||||
**Proofing status**:
|
||||
Where a language's proofing comes from: built in, an installed Language pack, your own dictionary,
|
||||
a system dictionary, available to install, or not available.
|
||||
|
||||
**Custom dictionary**:
|
||||
The words you added with Add to Dictionary, accepted in every language.
|
||||
_Avoid_: User dictionary (in the interface)
|
||||
|
||||
**Dictionaries folder**:
|
||||
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
|
||||
comes after it).
|
||||
_Avoid_: Custom dictionaries
|
||||
@@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) |
|
||||
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
|
||||
+4
-19
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
|
||||
@@ -228,11 +228,6 @@ fn block_cost(b: &Block) -> (usize, bool) {
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
|
||||
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
|
||||
// The formatting a clone copies too: style ids, font names and the like in the runs,
|
||||
// the mark and the paragraph's properties.
|
||||
let runs = p.runs.iter().filter(|r| !r.props.is_empty()).fold(0usize, |n, r| n.saturating_add(serialized_len(&r.props)));
|
||||
let props = serialized_len(&p.props).saturating_add(serialized_len(&p.mark)).saturating_add(p.section.as_ref().map_or(0, serialized_len));
|
||||
cost = cost.saturating_add(runs).saturating_add(props);
|
||||
boxes |= box_ids(p).next().is_some();
|
||||
});
|
||||
(cost, boxes)
|
||||
|
||||
@@ -415,24 +415,6 @@ fn pasted_box_copies_are_capped_in_bytes() {
|
||||
assert_eq!(got.iter().flatten().count(), added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_formatting_they_copy() {
|
||||
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
|
||||
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
|
||||
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
|
||||
let mut p = Paragraph::with_text("x", props);
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_objects_they_copy() {
|
||||
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
|
||||
|
||||
@@ -364,11 +364,6 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
// Sections: each section's properties attach to the paragraph that ends it (its story of
|
||||
// header/footer parts included); the section reaching the end of the text is the final one.
|
||||
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
|
||||
// The first paragraph ending at each CP, found once rather than by a scan per section.
|
||||
let mut ending: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
|
||||
for (i, p) in paras.iter().enumerate() {
|
||||
ending.entry(p.end_cp).or_insert(i);
|
||||
}
|
||||
let last = sects.last().map(|s| s.props.clone());
|
||||
for (si, sec) in sects.iter().enumerate() {
|
||||
let mut props = sec.props.clone();
|
||||
@@ -379,8 +374,11 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if is_final {
|
||||
continue; // handled below through `last`
|
||||
}
|
||||
if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) {
|
||||
p.para.section = Some(Box::new(props));
|
||||
for p in paras.iter_mut() {
|
||||
if p.end_cp == sec.end_cp {
|
||||
p.para.section = Some(Box::new(props));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut l) = last {
|
||||
|
||||
@@ -101,12 +101,6 @@ impl RowInfo {
|
||||
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
|
||||
}
|
||||
|
||||
/// How many per-cell overrides the row keeps, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn overrides(&self) -> usize {
|
||||
self.vmerge.len() + self.shading.len() + self.widths.len() + self.width_pcts.len() + self.valign.len() + self.merges.len()
|
||||
}
|
||||
|
||||
/// The width percentage cell `i` ends up with, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn width_pct_of(&self, i: u8) -> Option<f32> {
|
||||
@@ -114,15 +108,6 @@ impl RowInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cell `c`'s override to `v`, replacing an earlier one: one slot per cell, however often
|
||||
/// a PAPX repeats the sprm, so a shared PAPX can't pile up copies on every paragraph mark.
|
||||
fn set<T>(slots: &mut Vec<(u8, T)>, c: u8, v: T) {
|
||||
match slots.iter_mut().find(|(k, _)| *k == c) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => slots.push((c, v)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
let mut r = RowInfo::default();
|
||||
let mut gap_half: Option<i16> = None;
|
||||
@@ -159,10 +144,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
T_DXA_GAP_HALF => gap_half = s16(&prl),
|
||||
T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand),
|
||||
T_MERGE => {
|
||||
// Rows apply only the first MAX_COLS merges.
|
||||
if let [a, b, ..] = prl.operand
|
||||
&& b > a
|
||||
&& r.merges.len() < MAX_COLS
|
||||
{
|
||||
r.merges.push((*a, *b));
|
||||
}
|
||||
@@ -175,8 +158,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
1 => VMerge::Continue,
|
||||
_ => VMerge::None,
|
||||
};
|
||||
// The first override for a cell wins.
|
||||
if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) {
|
||||
if m != VMerge::None {
|
||||
r.vmerge.push((*itc, m));
|
||||
}
|
||||
}
|
||||
@@ -186,7 +168,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
|
||||
{
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.shading, c, Some(rgb));
|
||||
r.shading.push((c, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +176,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
let cells = prl.operand.get(1..).unwrap_or(&[]);
|
||||
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
|
||||
if let Some(rgb) = shd80_rgb(b) {
|
||||
set(&mut r.shading, i as u8, Some(rgb));
|
||||
r.shading.push((i as u8, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +196,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
|
||||
continue;
|
||||
}
|
||||
set(&mut r.shading, (first + i) as u8, shd_rgb(b));
|
||||
r.shading.push(((first + i) as u8, shd_rgb(b)));
|
||||
}
|
||||
}
|
||||
T_CELL_WIDTH => {
|
||||
@@ -225,12 +207,12 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if *fts == 3 {
|
||||
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.widths, c, w);
|
||||
r.widths.push((c, w));
|
||||
}
|
||||
} else if *fts == 2 {
|
||||
let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.width_pcts, c, pct);
|
||||
r.width_pcts.push((c, pct));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,8 +224,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
2 => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.valign, c, v);
|
||||
for c in *a..*b {
|
||||
r.valign.push((c, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,32 +1018,6 @@ fn two_sections_box_the_first() {
|
||||
assert!(!doc.last_section.title_page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_ending_off_a_paragraph_mark_attach_nowhere() {
|
||||
// Hostile section ends that miss every paragraph mark are skipped; the one that ends a
|
||||
// paragraph still boxes it. Paragraphs are found by their end, not scanned per section.
|
||||
let mut sections: Vec<(u32, Vec<u8>)> = (1..6).map(|cp| (cp, Vec::new())).collect();
|
||||
sections.push((6, grpprl(&[(0x300A, &[0x01])])));
|
||||
sections.push((12, Vec::new()));
|
||||
let spec = StructSpec {
|
||||
text: "first\rsecond\r",
|
||||
header_stories: vec![],
|
||||
sections,
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page);
|
||||
assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_story_becomes_part() {
|
||||
let spec = StructSpec {
|
||||
@@ -2401,23 +2375,6 @@ fn second_table_gets_its_own_grid() {
|
||||
assert_eq!(tables[1].props.indent, None, "indent reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cell_sprms_keep_one_override_per_cell() {
|
||||
// A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one
|
||||
// slot per cell, the last value winning, instead of a copy per repetition.
|
||||
let mut sprms = Vec::new();
|
||||
for w in 0..200u16 {
|
||||
let [w0, w1] = (w * 20).to_le_bytes();
|
||||
sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1]));
|
||||
sprms.push(sprm_cb(0xD62C, &[0, 255, 1]));
|
||||
sprms.push([0x24, 0x56, 0, 2].to_vec());
|
||||
}
|
||||
let row = crate::table::decode(&sprms.concat());
|
||||
assert_eq!(row.width_of(0), Some(199.0), "the last width wins");
|
||||
assert_eq!(row.width_of(62), Some(199.0));
|
||||
assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprm_t_cell_width_twips_and_percentage() {
|
||||
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("proof.packs", "Language Packs", "File › Options › Language", list)
|
||||
.params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on; in the app it runs in the background: see "refreshing")} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (where the data comes from), "archive" (the download), "spelling", "hyphenation", "installed"}], "languages": [{"tags", "pack", "installed", "size", "proofing" (built_in, installed, own_dictionary, system_dictionary, install, grammar_built_in, not_available), "default"}] (Options › Language › Your languages), "installed": [{"locale", "name", "licence"}], "installing": [{"locale", "done", "total"} (bytes)], "refreshing": bool, "errors": {locale or "" (the list): message} (the last failure of each), "index_override": string|null (a test index in use instead of the pinned one), "network": bool (downloading on), "available": bool (a font store to install into)}"#)
|
||||
.params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on; in the app it runs in the background: see "refreshing")} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (where the data comes from), "archive" (the download), "spelling", "hyphenation", "installed"}], "installed": [{"locale", "name", "licence"}], "installing": [{"locale", "done", "total"} (bytes)], "refreshing": bool, "errors": {locale or "" (the list): message} (the last failure of each), "index_override": string|null (a test index in use instead of the pinned one), "network": bool (downloading on), "available": bool (a font store to install into)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("proof.installPack", "Install Language Pack", "File › Options › Language", install)
|
||||
.params(r#"{"locale": string (a pack's locale from proof.packs, like nl or pt-pt)} — downloads, verifies (sha256) and installs it; spelling and hyphenation in that language use it at once. Needs downloading on (only the person in the app can turn it on). Opens nothing. In the app it runs in the background and returns {"started": true}: follow it with proof.packs ("installing", then "installed" or "errors"); elsewhere it returns when installed."#)
|
||||
@@ -33,7 +33,7 @@ fn locale_param(v: &Value) -> Result<String, CmdError> {
|
||||
packs::parse_locale(raw)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, v: &Value) -> CmdResult {
|
||||
fn list(_: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::bool(v, "refresh") == Some(true) {
|
||||
packs::start_refresh().map_err(CmdError::Failed)?;
|
||||
if let Some(e) = packs::error("") {
|
||||
@@ -55,7 +55,6 @@ fn list(s: &mut Session, v: &Value) -> CmdResult {
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"packs": rows,
|
||||
"languages": packs::your_languages(s),
|
||||
"installed": installed.iter().map(|i| json!({"locale": i.locale, "name": i.name, "licence": i.licence})).collect::<Vec<_>>(),
|
||||
"installing": packs::installing(),
|
||||
"refreshing": packs::fetching(),
|
||||
@@ -163,166 +162,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// Options › Language › Your languages: fresh, with Dutch installed, a document with Thai,
|
||||
/// Turkish without a pack, and en-GB as the default.
|
||||
mod your_languages {
|
||||
use super::*;
|
||||
use crate::packs::Proofing;
|
||||
|
||||
fn none(_: &str) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn row<'a>(rows: &'a [packs::LanguageRow], tag: &str) -> &'a packs::LanguageRow {
|
||||
rows.iter().find(|r| r.is_tag(tag) || r.pack.as_deref() == Some(tag)).unwrap_or_else(|| panic!("no row for {tag}: {rows:?}"))
|
||||
}
|
||||
|
||||
fn mark(s: &mut Session, para: usize, lang: &str) {
|
||||
s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap();
|
||||
s.run("select.paragraph", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": lang})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_install_lists_only_english() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!(rows[0].proofing, Proofing::BuiltIn);
|
||||
assert!(rows[0].default);
|
||||
assert_eq!(rows[0].pack, None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_pack_is_a_row_with_remove() {
|
||||
with_backend(FixedBackend::sample(), |s, b| {
|
||||
b.network.store(true, Ordering::Relaxed);
|
||||
s.run("proof.installPack", &json!({"locale": "nl"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(rows.len(), 2);
|
||||
let nl = row(&rows, "nl");
|
||||
assert!(nl.installed && nl.tags.is_empty() && !nl.default);
|
||||
assert_eq!(nl.proofing, Proofing::Installed);
|
||||
let r = s.run("proof.packs", &json!({})).unwrap();
|
||||
assert_eq!(r["languages"].as_array().unwrap().len(), 2, "MCP sees the same rows");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_languages_show_install_grammar_and_not_available() {
|
||||
with_backend(FixedBackend::sample(), |s, b| {
|
||||
s.run("document.setText", &json!({"text": "Hallo\nMerhaba\nสวัสดี\nCześć\nBonjour"})).unwrap();
|
||||
mark(s, 0, "nl-NL");
|
||||
mark(s, 1, "tr-TR");
|
||||
mark(s, 2, "th-TH");
|
||||
mark(s, 3, "pl-PL");
|
||||
mark(s, 4, "fr-FR");
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
let nl = row(&rows, "nl-NL");
|
||||
assert_eq!((nl.proofing, nl.size, nl.installed), (Proofing::Install, 1_950_000, false));
|
||||
// Turkish has grammar rules built in and no pack in the sample index.
|
||||
assert_eq!(row(&rows, "tr-TR").proofing, Proofing::NotAvailable);
|
||||
assert_eq!(row(&rows, "th-TH").proofing, Proofing::NotAvailable);
|
||||
let pl = row(&rows, "pl-PL");
|
||||
assert_eq!((pl.proofing, pl.size), (Proofing::GrammarBuiltIn, 4_620_000));
|
||||
// Installing turns Install into Installed; the document keeps the row.
|
||||
b.network.store(true, Ordering::Relaxed);
|
||||
s.run("proof.installPack", &json!({"locale": "nl"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(row(&rows, "nl-NL").proofing, Proofing::Installed);
|
||||
s.run("proof.removePack", &json!({"locale": "nl"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(row(&rows, "nl-NL").proofing, Proofing::Install, "still used by the document");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valencian_text_is_on_the_valencian_packs_row() {
|
||||
let mut b = FixedBackend::sample();
|
||||
let mut v = b.packs.first().cloned().unwrap();
|
||||
v.locale = "ca-valencia".into();
|
||||
v.size = 1_500_000;
|
||||
b.packs.push(v);
|
||||
with_backend(b, |s, b| {
|
||||
s.run("document.setText", &json!({"text": "Bon dia"})).unwrap();
|
||||
mark(s, 0, "ca-ES-valencia");
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(row(&rows, "ca-ES-valencia").pack.as_deref(), Some("ca-valencia"), "as the bar offers it");
|
||||
// Installing the Catalan pack doesn't make Valencian installed.
|
||||
b.network.store(true, Ordering::Relaxed);
|
||||
s.run("proof.installPack", &json!({"locale": "ca"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
let v = row(&rows, "ca-ES-valencia");
|
||||
assert_eq!((v.pack.as_deref(), v.installed, v.proofing), (Some("ca-valencia"), false, Proofing::Install));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_own_folder_and_the_default_language() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Cześć\nMerhaba"})).unwrap();
|
||||
mark(s, 0, "pl-PL");
|
||||
mark(s, 1, "tr-TR");
|
||||
let dic = |tag: &str| {
|
||||
if tag.starts_with("pl") {
|
||||
Some(std::path::PathBuf::from("/mine/dicts/pl_PL.dic"))
|
||||
} else if tag.starts_with("tr") {
|
||||
Some(std::path::PathBuf::from("/usr/share/hunspell/tr_TR.dic"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let rows = packs::your_languages_with(s, Some("/mine/dicts"), &dic);
|
||||
assert_eq!(row(&rows, "pl-PL").proofing, Proofing::OwnDictionary);
|
||||
assert_eq!(row(&rows, "tr-TR").proofing, Proofing::SystemDictionary);
|
||||
// en-GB as the default is its own row, the default one; en-US stays if the document uses it.
|
||||
s.run("review.language", &json!({"lang": "en-GB", "default": true})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
let gb = row(&rows, "en-GB");
|
||||
assert!(gb.default && gb.proofing == Proofing::BuiltIn);
|
||||
assert_eq!(rows.iter().filter(|r| r.default).count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_as_default_sets_the_pref_and_nothing_else() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let (doc, sel) = (s.doc.styles.default_chr.lang.clone(), s.sel.clone());
|
||||
let normal = s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone());
|
||||
// A redo step exists: the setting is no edit, so it neither adds an undo step nor clears it.
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let rev = s.rev();
|
||||
let (depth, redo, dirty) = (s.undo_depth(), s.can_redo(), s.dirty);
|
||||
assert!(redo);
|
||||
s.run("review.language.preferred", &json!({"lang": "nl-NL"})).unwrap();
|
||||
assert_eq!((s.undo_depth(), s.can_redo(), s.dirty), (depth, redo, dirty), "history, redo and modified flag are kept");
|
||||
assert_eq!(s.prefs.default_language.as_deref(), Some("nl-NL"));
|
||||
assert_eq!(s.doc.styles.default_chr.lang, doc);
|
||||
assert_eq!(s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone()), normal);
|
||||
assert_eq!(s.sel, sel);
|
||||
assert_eq!(packs::document_languages(s), ["en-US"], "the text keeps its language");
|
||||
assert_eq!(s.rev(), rev);
|
||||
assert!(s.run("review.language.preferred", &json!({})).is_err());
|
||||
assert!(s.run("review.language.preferred", &json!({"lang": "not a tag!!"})).is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_tags_never_panic() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "a b"})).unwrap();
|
||||
s.prefs.default_language = Some("x".repeat(10_000));
|
||||
let _ = packs::your_languages_with(s, Some(""), &none);
|
||||
s.prefs.default_language = Some("\u{0}-\u{202e}".into());
|
||||
let _ = packs::your_languages_with(s, None, &none);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_hostile_locales_are_errors() {
|
||||
with_backend(FixedBackend::sample(), |s, b| {
|
||||
@@ -390,21 +229,6 @@ mod tests {
|
||||
assert_eq!(packs::pack_for_tag("pt-PT", &packs).map(|p| p.locale.as_str()), Some("pt-pt"));
|
||||
assert_eq!(packs::pack_for_tag("pt_BR", &packs).map(|p| p.locale.as_str()), Some("pt"));
|
||||
assert_eq!(packs::pack_for_tag("nl-BE", &packs).map(|p| p.locale.as_str()), Some("nl"));
|
||||
// A script-qualified tag finds its script's pack before the language's (CP4.3: Serbian
|
||||
// (Latin) chosen in the Language dialog is proofed by `sr-latn`, not the Cyrillic `sr`).
|
||||
packs.push(Pack { locale: "sr".into(), ..packs[0].clone() });
|
||||
packs.push(Pack { locale: "sr-latn".into(), ..packs[0].clone() });
|
||||
assert_eq!(packs::pack_for_tag("sr-Latn-RS", &packs).map(|p| p.locale.as_str()), Some("sr-latn"));
|
||||
assert_eq!(packs::pack_for_tag("sr-Cyrl-RS", &packs).map(|p| p.locale.as_str()), Some("sr"));
|
||||
assert_eq!(packs::pack_for_tag("sr-latn", &packs).map(|p| p.locale.as_str()), Some("sr-latn"));
|
||||
assert_eq!(packs::pack_for_tag("-", &packs), None);
|
||||
assert_eq!(packs::pack_for_tag("nl--", &packs).map(|p| p.locale.as_str()), Some("nl"));
|
||||
// A variant after the region is its language-and-variant pack's; the region alone isn't.
|
||||
packs.push(Pack { locale: "ca-valencia".into(), ..packs[0].clone() });
|
||||
assert_eq!(packs::pack_for_tag("ca-ES-valencia", &packs).map(|p| p.locale.as_str()), Some("ca-valencia"));
|
||||
assert_eq!(packs::pack_for_tag("ca_es_VALENCIA", &packs).map(|p| p.locale.as_str()), Some("ca-valencia"));
|
||||
assert_eq!(packs::pack_for_tag("ca-ES", &packs).map(|p| p.locale.as_str()), Some("ca"));
|
||||
assert_eq!(packs::pack_for_tag("ca", &packs).map(|p| p.locale.as_str()), Some("ca"));
|
||||
assert_eq!(packs::pack_for_tag("en-GB", &packs), None);
|
||||
assert_eq!(packs::pack_for_tag("", &packs), None);
|
||||
assert!(packs::valid_locale("sr-latn"));
|
||||
@@ -662,187 +486,5 @@ mod tests {
|
||||
assert_eq!(messages(pt_pt), messages(ProofLang::Pt), "Portugal text gets the Portuguese grammar checks");
|
||||
}
|
||||
|
||||
/// The one region test and region-dropping step the engine and the UI share: two letters or
|
||||
/// three digits after the language; hostile text never panics.
|
||||
#[test]
|
||||
fn a_region_is_two_letters_or_three_digits() {
|
||||
for r in ["NL", "nl", "419", "ZZ"] {
|
||||
assert!(packs::is_region(r), "{r}");
|
||||
}
|
||||
for r in ["", "n", "nld", "4190", "41", "Latn", "valencia", "é1", "\u{202e}\u{202e}"] {
|
||||
assert!(!packs::is_region(r), "{r:?}");
|
||||
}
|
||||
for (tag, bare) in [
|
||||
("nl-NL", "nl"),
|
||||
("sr-Latn-RS", "sr-Latn"),
|
||||
("ca-ES-valencia", "ca-valencia"),
|
||||
("es_419", "es"),
|
||||
("pt", "pt"),
|
||||
("en-US-x-test", "en-x-test"),
|
||||
("", ""),
|
||||
("-", "-"),
|
||||
] {
|
||||
assert_eq!(packs::without_region(tag), bare, "{tag}");
|
||||
}
|
||||
// The language itself is never a region, even when it looks like one.
|
||||
assert_eq!(packs::without_region("de"), "de");
|
||||
let _ = packs::without_region(&"-".repeat(10_000));
|
||||
let _ = packs::without_region("\u{0}-\u{202e}");
|
||||
}
|
||||
|
||||
/// A font store that has never fetched the list (a fresh install), or whose cached list is
|
||||
/// for an older pin: `index` has nothing until a fetch.
|
||||
struct Uncached(FixedBackend);
|
||||
|
||||
impl Backend for Uncached {
|
||||
fn index(&self) -> Result<Vec<Pack>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn fetch_index(&self, c: &packs::Cancel) -> Result<Vec<Pack>, String> {
|
||||
self.0.fetch_index(c)
|
||||
}
|
||||
fn network_on(&self) -> bool {
|
||||
self.0.network_on()
|
||||
}
|
||||
fn enable_network(&self) -> Result<(), String> {
|
||||
self.0.enable_network()
|
||||
}
|
||||
fn install(&self, pack: &Pack, cancel: &packs::Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
|
||||
self.0.install(pack, cancel, progress)
|
||||
}
|
||||
fn remove(&self, locale: &str) -> Result<(), String> {
|
||||
self.0.remove(locale)
|
||||
}
|
||||
fn packs_dir(&self) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
fn installed(&self) -> Vec<packs::Installed> {
|
||||
self.0.installed()
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample packs and Greenlandic (`kl`), a language no system has a dictionary for.
|
||||
fn with_greenlandic() -> FixedBackend {
|
||||
let mut b = FixedBackend::sample();
|
||||
let kl = Pack { locale: "kl".into(), name: "Kalaallisut".into(), ..b.packs[0].clone() };
|
||||
b.packs.push(kl);
|
||||
b
|
||||
}
|
||||
|
||||
fn with_uncached<T>(b: FixedBackend, f: impl FnOnce(&mut Session, &Uncached) -> T) -> T {
|
||||
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _reset = Reset;
|
||||
let b = Arc::new(Uncached(b));
|
||||
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
f(&mut s, &b)
|
||||
}
|
||||
|
||||
/// Martin's test of a fresh install: Greenlandic text, no list fetched yet. The bar still
|
||||
/// offers the language (never English, nor one with a dictionary); getting it fetches the
|
||||
/// list and installs its pack. A language the list then has no pack for (Klingon) stops
|
||||
/// being offered.
|
||||
#[test]
|
||||
fn with_no_list_yet_the_bar_still_offers_the_language() {
|
||||
with_uncached(with_greenlandic(), |s, b| {
|
||||
s.run("document.setText", &json!({"text": "Hello there\nAluu, qanoq ippit?\ntlhIngan Hol"})).unwrap();
|
||||
for (para, lang) in [(1, "kl-GL"), (2, "tlh")] {
|
||||
s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap();
|
||||
s.run("select.paragraph", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": lang})).unwrap();
|
||||
}
|
||||
assert!(packs::available().is_empty() && packs::missing(s).is_empty(), "no list: no pack to offer");
|
||||
for tag in ["kl-GL", "tlh"] {
|
||||
assert!(wordcraft_proof::missing_dictionary(Some(tag)).is_some(), "{tag} has no dictionary here");
|
||||
}
|
||||
assert_eq!(packs::unlisted(s), ["kl-GL", "tlh"], "English is never offered");
|
||||
// Downloading off: getting it fails up front, and asks for nothing.
|
||||
assert!(packs::start_get("kl-GL").is_err());
|
||||
assert!(!packs::fetched());
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert_eq!(packs::start_get("kl-GL"), Ok(false), "fetched and installed here (no background thread)");
|
||||
assert!(packs::is_installed("kl"));
|
||||
assert!(packs::fetched());
|
||||
assert!(packs::unlisted(s).is_empty(), "the list is known now: Klingon, which it lacks, isn't offered");
|
||||
assert!(packs::missing(s).is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
/// Review: a get refused because downloading is off isn't remembered, so a later fetch (the
|
||||
/// person's Refresh in Options) doesn't install it unasked.
|
||||
#[test]
|
||||
fn a_refused_get_is_not_installed_by_a_later_fetch() {
|
||||
with_uncached(with_greenlandic(), |_, b| {
|
||||
assert!(packs::start_get("kl-GL").is_err(), "downloading off");
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert_eq!(packs::start_refresh(), Ok(false));
|
||||
assert!(packs::find("kl").is_some(), "the list has it");
|
||||
assert!(!packs::is_installed("kl"), "but nobody asked for it after the refusal");
|
||||
});
|
||||
}
|
||||
|
||||
/// The list was fetched, but lists nothing for the language: the bar stops offering it.
|
||||
#[test]
|
||||
fn a_language_the_fetched_list_lacks_is_no_longer_offered() {
|
||||
with_uncached(FixedBackend::sample(), |s, b| {
|
||||
s.run("document.setText", &json!({"text": "tlhIngan Hol"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": "tlh"})).unwrap();
|
||||
assert_eq!(packs::unlisted(s), ["tlh"]);
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert_eq!(packs::start_get("tlh"), Ok(false));
|
||||
assert!(packs::installed().is_empty(), "nothing to install");
|
||||
assert!(packs::unlisted(s).is_empty() && packs::missing(s).is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
/// With downloading already on and no list (or one for an older pin), the window fetches it
|
||||
/// once in the background, asking nothing; never with downloading off, never twice.
|
||||
#[test]
|
||||
fn a_missing_list_is_fetched_once_when_downloading_is_on() {
|
||||
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _reset = Reset;
|
||||
let b = Arc::new(Uncached(with_greenlandic()));
|
||||
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
|
||||
assert!(!packs::refresh_if_stale(), "no background thread (the CLI, MCP): never");
|
||||
packs::set_background(Some(Arc::new(|| {})));
|
||||
assert!(!packs::refresh_if_stale(), "downloading off: no request");
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert!(!packs::refresh_if_stale(), "asked once a session: turned on later, the person's own click fetches");
|
||||
// A new session (the backend set again) with downloading on.
|
||||
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Aluu"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": "kl"})).unwrap();
|
||||
let before = packs::finished();
|
||||
assert!(packs::refresh_if_stale());
|
||||
wait_for_finish(before);
|
||||
assert!(packs::fetched());
|
||||
assert_eq!(packs::missing(&s).into_iter().map(|p| p.locale).collect::<Vec<_>>(), ["kl"], "the bar offers the pack");
|
||||
assert!(!packs::refresh_if_stale(), "once a session");
|
||||
assert!(packs::installed().is_empty(), "nothing installed without a click");
|
||||
}
|
||||
|
||||
/// Martin's test: choosing a language with nothing selected (the caret only) sets the typing
|
||||
/// language; the bar offers its pack before anything is typed.
|
||||
#[test]
|
||||
fn the_caret_typing_language_asks_for_its_pack() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Hello there"})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 11}})).unwrap();
|
||||
assert!(packs::missing(s).is_empty());
|
||||
s.run("review.language", &json!({"lang": "nl-NL"})).unwrap();
|
||||
assert!(s.sel.is_collapsed());
|
||||
assert!(!packs::document_languages(s).iter().any(|l| l == "nl-NL"), "no Dutch text yet");
|
||||
assert_eq!(packs::caret_language(s).as_deref(), Some("nl-NL"));
|
||||
assert_eq!(packs::missing(s).into_iter().map(|p| p.locale).collect::<Vec<_>>(), ["nl"]);
|
||||
// Do not check spelling or grammar at the caret: nothing asked.
|
||||
s.run("review.language", &json!({"lang": "nl-NL", "noProof": true})).unwrap();
|
||||
assert_eq!(packs::caret_language(s), None);
|
||||
assert!(packs::missing(s).is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
use crate::packs::Pack;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
|
||||
|
||||
/// Options › Language › Set as default: only the saved default proofing language (what future
|
||||
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
|
||||
/// document, the selection, the undo history and the modified flag are left alone.
|
||||
pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let t = p::str(v, "lang").ok_or_else(|| CmdError::Params("`lang` is required".into()))?;
|
||||
let l = wordcraft_proof::lang::normalize_tag(t)
|
||||
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?;
|
||||
s.prefs.default_language = Some(l.clone());
|
||||
Ok(json!({"preferred": l}))
|
||||
}
|
||||
|
||||
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
|
||||
/// or (`default`) the document's default and the saved language for future blank documents;
|
||||
/// `detect` is the per-user detection preference.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::actions::{
|
||||
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
||||
};
|
||||
use super::actions::{customize_keyboard, customize_ribbon, hf_nav, language, macros, quick_parts, search_commands, select_similar};
|
||||
use super::autocorrect::autocorrect_options;
|
||||
use super::compare::{COMPARE_PARAMS, compare};
|
||||
use super::inspect::{accessibility, inspect_doc};
|
||||
@@ -341,9 +339,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
|
||||
),
|
||||
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
|
||||
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
|
||||
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
|
||||
s.relayout();
|
||||
|
||||
+9
-333
@@ -143,9 +143,6 @@ pub fn set_backend(backend: Option<Arc<dyn Backend>>) {
|
||||
let dir = backend.as_ref().and_then(|b| b.packs_dir());
|
||||
*BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend;
|
||||
set_index(None);
|
||||
FETCHED.store(false, Ordering::Relaxed);
|
||||
AUTO_REFRESHED.store(false, Ordering::Relaxed);
|
||||
WANTED.lock().unwrap_or_else(PoisonError::into_inner).clear();
|
||||
jobs().errors.clear();
|
||||
wordcraft_proof::packs::set_packs_dir(dir);
|
||||
}
|
||||
@@ -222,8 +219,6 @@ pub fn refresh(cancel: &Cancel) -> Result<Vec<Pack>, String> {
|
||||
}
|
||||
let index = clean_index(b.fetch_index(cancel)?);
|
||||
set_index(Some(index.clone()));
|
||||
FETCHED.store(true, Ordering::Relaxed);
|
||||
install_wanted(&index);
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
@@ -580,50 +575,15 @@ pub fn licence_texts() -> Vec<(String, String)> {
|
||||
/// About › Language data's title for SCOWL's notices.
|
||||
pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)";
|
||||
|
||||
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else each shorter
|
||||
/// prefix of it (`sr-latn` for `sr-Latn-RS`, `nl` for `nl-NL`), the first the index has. A
|
||||
/// variant after the region is also tried without the region, before the region alone
|
||||
/// (`ca-ES-valencia` is the `ca-valencia` pack's), as craft-proof looks its dictionary up. `None`
|
||||
/// for English and tags with no pack.
|
||||
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else its language
|
||||
/// (`nl` for `nl-NL`), whichever the index has. `None` for English and tags with no pack.
|
||||
pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
|
||||
pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c))
|
||||
}
|
||||
|
||||
/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or
|
||||
/// three digits (`419`)? The one test of what a region is; the lists, the menus and
|
||||
/// [`pack_for_tag`] all use it.
|
||||
pub fn is_region(subtag: &str) -> bool {
|
||||
(subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia`
|
||||
/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept.
|
||||
/// The language itself (the first subtag) is never a region.
|
||||
pub fn without_region(tag: &str) -> String {
|
||||
tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::<Vec<&str>>().join("-")
|
||||
}
|
||||
|
||||
/// The pack locales [`pack_for_tag`] tries for `tag`, best first.
|
||||
fn pack_candidates(tag: &str) -> Vec<String> {
|
||||
// A tag is a handful of short subtags; anything longer is junk and only its start is read.
|
||||
let tag: String = tag.trim().chars().take(64).collect::<String>().replace('_', "-").to_ascii_lowercase();
|
||||
let parts: Vec<&str> = tag.split('-').collect();
|
||||
let base = parts.first().copied().unwrap_or("");
|
||||
let tag = tag.trim().replace('_', "-").to_ascii_lowercase();
|
||||
let base = tag.split('-').next().unwrap_or("");
|
||||
if base.is_empty() || base == "en" {
|
||||
return Vec::new();
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for n in (1..=parts.len()).rev() {
|
||||
let prefix = parts.get(..n).unwrap_or_default().join("-");
|
||||
// The prefix, then without its region: the variant's pack before the region alone.
|
||||
let stripped = without_region(&prefix);
|
||||
for c in [prefix, stripped] {
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
packs.iter().find(|p| p.locale == tag).or_else(|| packs.iter().find(|p| p.locale == base))
|
||||
}
|
||||
|
||||
/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order.
|
||||
@@ -711,33 +671,8 @@ fn has_pack_for_tag(tag: &str) -> bool {
|
||||
PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some())
|
||||
}
|
||||
|
||||
/// The language new text at the caret gets (its typing language, resolved through the styles
|
||||
/// and the document default), unless it is marked Do not check spelling or grammar: choosing a
|
||||
/// language with nothing selected asks for its pack before anything is typed in it.
|
||||
pub fn caret_language(s: &Session) -> Option<String> {
|
||||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
|
||||
let rc = s.doc.styles.resolve_char(style, &s.typing_props());
|
||||
if rc.no_proof {
|
||||
return None;
|
||||
}
|
||||
Some(rc.lang.unwrap_or_else(|| "en-US".into()))
|
||||
}
|
||||
|
||||
/// The languages the missing-pack bar looks at: the body text's ([`document_languages`]), then
|
||||
/// the caret's typing language ([`caret_language`]).
|
||||
pub fn wanted_languages(s: &Session) -> Vec<String> {
|
||||
let mut out = document_languages(s);
|
||||
if let Some(tag) = caret_language(s)
|
||||
&& !out.contains(&tag)
|
||||
{
|
||||
out.push(tag);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The packs the document's text (or the caret's typing language) asks for that are available
|
||||
/// and not installed: what the missing-pack bar offers. Never a language without a pack, nor one
|
||||
/// proofing can't use.
|
||||
/// The packs the document's text asks for that are available and not installed: what the
|
||||
/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use.
|
||||
pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
let packs = available();
|
||||
if packs.is_empty() {
|
||||
@@ -745,7 +680,7 @@ pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
}
|
||||
let installed = installed();
|
||||
let mut out: Vec<Pack> = Vec::new();
|
||||
for tag in wanted_languages(s) {
|
||||
for tag in document_languages(s) {
|
||||
if proofable(&tag)
|
||||
&& let Some(p) = pack_for_tag(&tag, &packs)
|
||||
&& !installed.iter().any(|i| i.locale == p.locale)
|
||||
@@ -757,265 +692,6 @@ pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a language's proofing comes from (Options › Language › Your languages).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Proofing {
|
||||
/// English: the embedded dictionaries.
|
||||
BuiltIn,
|
||||
/// An installed Language pack.
|
||||
Installed,
|
||||
/// A Hunspell dictionary in the person's own dictionaries folder.
|
||||
OwnDictionary,
|
||||
/// A Hunspell dictionary the system has.
|
||||
SystemDictionary,
|
||||
/// No dictionary yet; a Language pack can be installed.
|
||||
Install,
|
||||
/// Grammar and hyphenation rules built in; a Language pack adds spelling.
|
||||
GrammarBuiltIn,
|
||||
/// No dictionary and no pack for it.
|
||||
NotAvailable,
|
||||
}
|
||||
|
||||
/// A row of Options › Language › Your languages.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct LanguageRow {
|
||||
/// The tags the default proofing language or the open document use for this language (the
|
||||
/// first is what Set as default sets); empty for a language listed for its installed pack.
|
||||
pub tags: Vec<String>,
|
||||
/// The pack locale: the installed pack, else the one the index offers.
|
||||
pub pack: Option<String>,
|
||||
/// Is the pack installed?
|
||||
pub installed: bool,
|
||||
/// Bytes of the offered pack (0 when the index has none).
|
||||
pub size: u64,
|
||||
pub proofing: Proofing,
|
||||
/// Is this the default proofing language?
|
||||
pub default: bool,
|
||||
}
|
||||
|
||||
impl LanguageRow {
|
||||
/// Is `tag` (the insertion point's language) one of this row's languages?
|
||||
pub fn is_tag(&self, tag: &str) -> bool {
|
||||
self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
|
||||
}
|
||||
}
|
||||
|
||||
/// The first locale `has` accepts for `tag`, in [`pack_for_tag`]'s order. `None` for English and junk.
|
||||
fn locale_for_tag(tag: &str, has: &dyn Fn(&str) -> bool) -> Option<String> {
|
||||
pack_candidates(tag).into_iter().find(|c| has(c))
|
||||
}
|
||||
|
||||
/// [`your_languages_with`] against the dictionaries on this computer.
|
||||
pub fn your_languages(s: &Session) -> Vec<LanguageRow> {
|
||||
let folder = s.prefs.dictionaries_folder.clone();
|
||||
your_languages_with(s, folder.as_deref(), &dictionary_file)
|
||||
}
|
||||
|
||||
/// The Hunspell `.dic` file proofing reads for `tag`'s language now (a built-in-rules language's
|
||||
/// own, or a pack language's), if any.
|
||||
fn dictionary_file(tag: &str) -> Option<PathBuf> {
|
||||
let lang = wordcraft_proof::ProofLang::from_tag(Some(tag));
|
||||
if let Some((_, f)) = wordcraft_proof::installed_dictionaries().into_iter().find(|(l, _)| *l == lang) {
|
||||
return f;
|
||||
}
|
||||
wordcraft_proof::pack_dictionary_file(lang)
|
||||
}
|
||||
|
||||
/// Options › Language › Your languages (unsorted; the window names and sorts them): each English
|
||||
/// variant in use (the default proofing language, American English and any in the open document), every language
|
||||
/// with an installed Language pack, and every proofing language of the open document. One row per
|
||||
/// language: tags one pack proofs share a row. `folder` is the person's own dictionaries folder;
|
||||
/// `dictionary` finds the `.dic` file proofing reads for a tag.
|
||||
pub fn your_languages_with(s: &Session, folder: Option<&str>, dictionary: &dyn Fn(&str) -> Option<PathBuf>) -> Vec<LanguageRow> {
|
||||
let index = available();
|
||||
let installed = installed();
|
||||
let default_tag = s
|
||||
.prefs
|
||||
.default_language
|
||||
.clone()
|
||||
.or_else(|| s.editing_lang.clone())
|
||||
.or_else(|| s.doc.styles.default_chr.lang.clone())
|
||||
.unwrap_or_else(|| "en-US".to_string());
|
||||
// American English stays listed whatever the default is, so it can always be set back.
|
||||
let mut tags = vec![default_tag.clone(), "en-US".to_string()];
|
||||
tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||
for t in document_languages(s) {
|
||||
if !tags.iter().any(|x| x.eq_ignore_ascii_case(&t)) {
|
||||
tags.push(t);
|
||||
}
|
||||
}
|
||||
let mut rows: Vec<LanguageRow> = Vec::new();
|
||||
let mut add = |tag: Option<&str>, locale: Option<String>| {
|
||||
let english = tag.is_some_and(|t| wordcraft_proof::ProofLang::from_tag(Some(t)).is_english());
|
||||
// English variants stay apart (en-GB and en-US are two rows); a pack's tags share one.
|
||||
let existing = rows.iter().position(|r| match (&locale, &r.pack) {
|
||||
(Some(l), Some(p)) if !english => l == p,
|
||||
_ => tag.is_some_and(|t| r.is_tag(t)),
|
||||
});
|
||||
let i = existing.unwrap_or_else(|| {
|
||||
rows.push(LanguageRow {
|
||||
tags: Vec::new(),
|
||||
pack: locale.clone(),
|
||||
installed: false,
|
||||
size: 0,
|
||||
proofing: Proofing::NotAvailable,
|
||||
default: false,
|
||||
});
|
||||
rows.len() - 1
|
||||
});
|
||||
if let (Some(t), Some(row)) = (tag, rows.get_mut(i))
|
||||
&& !row.is_tag(t)
|
||||
{
|
||||
row.tags.push(t.to_string());
|
||||
}
|
||||
};
|
||||
for t in &tags {
|
||||
if !proofable(t) {
|
||||
continue;
|
||||
}
|
||||
// The most specific pack, installed or offered, as the bar picks it ([`pack_for_tag`]): with
|
||||
// `ca` installed, Valencian text is still the `ca-valencia` pack's row.
|
||||
let locale = locale_for_tag(t, &|l| installed.iter().any(|i| i.locale == l) || index.iter().any(|p| p.locale == l));
|
||||
add(Some(t), locale);
|
||||
}
|
||||
for i in &installed {
|
||||
add(None, Some(i.locale.clone()));
|
||||
}
|
||||
for row in &mut rows {
|
||||
let installed_here = row.pack.as_ref().is_some_and(|l| installed.iter().any(|i| &i.locale == l));
|
||||
let offered = row.pack.as_ref().and_then(|l| index.iter().find(|p| &p.locale == l));
|
||||
row.installed = installed_here;
|
||||
row.size = offered.map_or(0, |p| p.size);
|
||||
row.default = row.is_tag(&default_tag);
|
||||
let tag = row.tags.first().cloned().or_else(|| row.pack.clone()).unwrap_or_default();
|
||||
let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag));
|
||||
let file = if lang.is_english() { None } else { dictionary(&tag) };
|
||||
let own = file.as_ref().is_some_and(|f| folder.is_some_and(|d| !d.trim().is_empty() && f.starts_with(d.trim())));
|
||||
row.proofing = if lang.is_english() {
|
||||
Proofing::BuiltIn
|
||||
} else if own {
|
||||
Proofing::OwnDictionary
|
||||
} else if installed_here {
|
||||
Proofing::Installed
|
||||
} else if file.is_some() {
|
||||
Proofing::SystemDictionary
|
||||
} else if offered.is_some() {
|
||||
if matches!(lang, wordcraft_proof::ProofLang::Pack(_)) { Proofing::Install } else { Proofing::GrammarBuiltIn }
|
||||
} else {
|
||||
Proofing::NotAvailable
|
||||
};
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The index was fetched this session (since the backend was set): what it lists is then the
|
||||
/// whole answer, even when it lists nothing.
|
||||
static FETCHED: AtomicBool = AtomicBool::new(false);
|
||||
/// The automatic fetch of a missing or stale list ([`refresh_if_stale`]) was tried.
|
||||
static AUTO_REFRESHED: AtomicBool = AtomicBool::new(false);
|
||||
/// Languages (document tags) whose pack the person asked for before the list was known
|
||||
/// ([`start_get`]): installed once a fetch lists one.
|
||||
static WANTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Whether the index was fetched since the backend was set.
|
||||
pub fn fetched() -> bool {
|
||||
FETCHED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The languages the document (or the caret) asks for while there is no list of packs yet (a
|
||||
/// fresh install, or a list cached for an older pin): proofable, not English, with no dictionary
|
||||
/// and no pack installed, one per proofing language, as the document tags them (`tr-TR`). The
|
||||
/// bar offers to get them: the list is fetched first, and the language then installed if it has
|
||||
/// a pack. Nothing once a list is known or was fetched this session ([`missing`] then answers).
|
||||
pub fn unlisted(s: &Session) -> Vec<String> {
|
||||
if backend().is_none() || fetched() || available_len() > 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let installed = installed();
|
||||
let mut seen: Vec<wordcraft_proof::ProofLang> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for tag in wanted_languages(s) {
|
||||
let base = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
|
||||
let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag));
|
||||
if base.is_empty()
|
||||
|| base == "en"
|
||||
|| !proofable(&tag)
|
||||
|| wordcraft_proof::missing_dictionary(Some(&tag)).is_none()
|
||||
|| seen.contains(&lang)
|
||||
|| installed.iter().any(|i| i.locale == base || tag.eq_ignore_ascii_case(&i.locale))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seen.push(lang);
|
||||
out.push(tag);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The person asked for `tag`'s pack before the list was known (the bar's Install, after their
|
||||
/// consent turned downloading on): fetch the list ([`start_refresh`]), then install the pack it
|
||||
/// lists for `tag`, if any; with none, the bar stops offering it. Downloading must be on.
|
||||
/// Returns whether it is still running.
|
||||
pub fn start_get(tag: &str) -> Result<bool, String> {
|
||||
let tag = tag.trim();
|
||||
if tag.is_empty() || tag.len() > MAX_LOCALE_LEN {
|
||||
return Err(format!("`{tag}` isn't a language tag"));
|
||||
}
|
||||
if let Some(p) = pack_for_tag(tag, &available()) {
|
||||
return start_install(&p.locale);
|
||||
}
|
||||
let b = backend().ok_or_else(no_store)?;
|
||||
if !b.network_on() {
|
||||
// Never queued: a later fetch mustn't install what was refused.
|
||||
return Err(network_off());
|
||||
}
|
||||
let queued = {
|
||||
let mut w = WANTED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let new = !w.iter().any(|t| t == tag) && w.len() < MAX_DOC_LANGUAGES;
|
||||
if new {
|
||||
w.push(tag.to_string());
|
||||
}
|
||||
new
|
||||
};
|
||||
let r = start_refresh();
|
||||
if r.is_err() && queued {
|
||||
WANTED.lock().unwrap_or_else(PoisonError::into_inner).retain(|t| t != tag);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// After a fetch: install the packs asked for before the list was known ([`start_get`]).
|
||||
fn install_wanted(index: &[Pack]) {
|
||||
let wanted = std::mem::take(&mut *WANTED.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
for tag in wanted {
|
||||
let Some(p) = pack_for_tag(&tag, index) else { continue };
|
||||
if is_installed(&p.locale) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = start_install(&p.locale) {
|
||||
set_error(&p.locale, Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a session (the first time it is asked once requests run on a thread, [`runs_in_background`]:
|
||||
/// the window), with downloading already on and no list (none cached, or one cached for an older
|
||||
/// pin): fetch it in the background, so the bar can offer packs. The person's consent is the
|
||||
/// downloading setting itself; nothing is asked. Its failure is recorded for Options (""). The
|
||||
/// window asks every frame: after the first time this reads nothing (the downloading setting is
|
||||
/// a file). Returns whether a fetch was started.
|
||||
pub fn refresh_if_stale() -> bool {
|
||||
let Some(b) = backend() else { return false };
|
||||
if !runs_in_background() || AUTO_REFRESHED.swap(true, Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
if !b.network_on() || fetched() || fetching() || available_len() > 0 {
|
||||
return false;
|
||||
}
|
||||
start_refresh().is_ok()
|
||||
}
|
||||
|
||||
/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`.
|
||||
#[derive(Default)]
|
||||
pub struct FixedBackend {
|
||||
|
||||
@@ -35,7 +35,7 @@ pub struct Prefs {
|
||||
/// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system.
|
||||
pub region: Option<String>,
|
||||
/// A folder searched for Hunspell dictionaries before the system locations (File › Options ›
|
||||
/// Language, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// Proofing, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only.
|
||||
pub dictionaries_folder: Option<String>,
|
||||
/// The user templates folder File › New lists personal templates from and Save as Word
|
||||
|
||||
@@ -8638,7 +8638,7 @@ const PORTUGUESE_TEST_AFF: &str =
|
||||
"SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n";
|
||||
const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n";
|
||||
|
||||
/// #1063: File › Options › Language › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// #1063: File › Options › Proofing › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// searched first and reported per language; a missing folder or a relative path is refused and
|
||||
/// changes nothing. The folder holds the same test dictionaries the other tests install, so tests
|
||||
/// running meanwhile see the same words; it stays set for the rest of the run for that reason.
|
||||
|
||||
@@ -56,9 +56,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), String> {
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
// Arabic interface faces are small, so the web build keeps them too.
|
||||
let small_ui_face = scripts.split(',').any(|s| matches!(s.trim(), "Arab" | "Geor"));
|
||||
if wasm && !small_ui_face {
|
||||
if wasm && !scripts.split(',').any(|s| s.trim() == "Geor") {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
+1
-41
@@ -24,10 +24,7 @@ pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{
|
||||
SystemUiFont, is_cjk, system_arabic_ui_font, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk,
|
||||
ui_uncovered_cjk,
|
||||
};
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
@@ -101,18 +98,6 @@ pub fn ui_cjk_fonts_for(prefer_hans: bool, prefer_korean: bool) -> Vec<&'static
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Arabic script (`"Arab"`), in manifest order: the document fallback
|
||||
/// for Arabic text. Empty without craft-fonts.
|
||||
pub fn arabic_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab"))
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Arabic script (`"Arab"`), Regular styles only: the interface's
|
||||
/// fallback for an Arabic interface and for Arabic file names. Empty without craft-fonts.
|
||||
pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> {
|
||||
arabic_fonts().filter(|f| f.style == "Regular").collect()
|
||||
}
|
||||
|
||||
/// The bundled serif used when a document asks for nothing better.
|
||||
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
|
||||
|
||||
@@ -529,31 +514,6 @@ mod tests {
|
||||
assert!(fb.family.contains("Mincho"), "{fb:?}");
|
||||
}
|
||||
|
||||
/// Arabic text in a document needs no system font: the craft-fonts Arabic face is the
|
||||
/// fallback (the browser build has no other), and its letters join when shaped.
|
||||
#[test]
|
||||
fn craft_fonts_arabic_face_is_the_document_fallback_for_arabic() {
|
||||
if arabic_fonts().next().is_none() {
|
||||
eprintln!("skipped: built without a craft-fonts Arabic face (set CRAFT_FONTS_DIR to a checkout)");
|
||||
return;
|
||||
}
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
for ch in "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأإةى،؛؟٠١٢٣٤٥٦٧٨٩ًٌٍَُِّْ".chars() {
|
||||
assert!(!latin.covers(ch), "the bundled serif has no Arabic");
|
||||
let fb = db.fallback_for(ch, latin.id()).unwrap_or_else(|| panic!("no fallback for {ch}"));
|
||||
assert!(fb.family.contains("Arabic"), "{ch}: {}", fb.family);
|
||||
}
|
||||
let face = db.fallback_for('ع', latin.id()).unwrap();
|
||||
// Joined forms: the same letter gets a different glyph alone and inside a word.
|
||||
let alone = shape(&face, "ع", &[], |c| c);
|
||||
let joined = shape(&face, "ععع", &[], |c| c);
|
||||
assert_eq!((alone.len(), joined.len()), (1, 3));
|
||||
assert!(joined.iter().all(|g| g.gid != 0));
|
||||
assert!(joined.iter().any(|g| g.gid != alone[0].gid), "letters take their joining forms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_run_uses_one_face_covering_the_whole_run() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
|
||||
@@ -218,36 +218,6 @@ pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Opti
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans))
|
||||
}
|
||||
|
||||
/// Arabic font files to look for, relative to a font folder, best first.
|
||||
fn arabic_candidates() -> &'static [&'static str] {
|
||||
if cfg!(windows) {
|
||||
&["segoeui.ttf", "tahoma.ttf", "arial.ttf"]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&["SFArabic.ttf", "GeezaPro.ttc", "Supplemental/GeezaPro.ttc", "Supplemental/Arial Unicode.ttf", "Supplemental/Arial.ttf"]
|
||||
} else {
|
||||
&[
|
||||
"noto/NotoSansArabic-Regular.ttf",
|
||||
"truetype/noto/NotoSansArabic-Regular.ttf",
|
||||
"google-noto/NotoSansArabic-Regular.ttf",
|
||||
"google-noto-sans-arabic-vf-fonts/NotoSansArabic[wght].ttf",
|
||||
"noto/NotoNaskhArabic-Regular.ttf",
|
||||
"truetype/noto/NotoNaskhArabic-Regular.ttf",
|
||||
"google-noto/NotoNaskhArabic-Regular.ttf",
|
||||
"vazirmatn/Vazirmatn-Regular.ttf",
|
||||
"truetype/dejavu/DejaVuSans.ttf",
|
||||
"dejavu/DejaVuSans.ttf",
|
||||
"TTF/DejaVuSans.ttf",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed font with Arabic letters, for an Arabic interface in a build whose craft-fonts
|
||||
/// input has no Arabic face. Reads at most one font file per candidate; never panics on damaged
|
||||
/// files.
|
||||
pub fn system_arabic_ui_font() -> Option<SystemUiFont> {
|
||||
find_ui_font(&crate::system_font_dirs(), arabic_candidates(), &['ع'], Region::Other)
|
||||
}
|
||||
|
||||
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
|
||||
/// the `AssetData` folder of every `<hash>.asset` in it: macOS ships PingFang there, e.g.
|
||||
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData/PingFang.ttc`
|
||||
@@ -454,9 +424,6 @@ mod tests {
|
||||
// No file maps every needed character: the one mapping the most is used.
|
||||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap();
|
||||
assert_eq!(found.family, "Source Sans 3");
|
||||
// The bundled Latin face has no Arabic letters, so it is never taken for an Arabic interface.
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['ع'], Region::Other).is_none());
|
||||
assert!(!arabic_candidates().is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,11 +206,6 @@ pub(crate) struct Alphas {
|
||||
/// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to
|
||||
/// the bytes, as [`crate::LayoutCache`]'s outlines do.
|
||||
seen: HashMap<(usize, usize), Option<Arc<Alpha>>>,
|
||||
/// Pictures whose cost was charged for a lasting outline found in [`crate::LayoutCache`]
|
||||
/// ([`Alphas::charge`]) without decoding them in this layout.
|
||||
charged: HashSet<(usize, usize)>,
|
||||
/// Pixels each picture took of the budget (also when it then failed to decode), by bytes.
|
||||
costs: HashMap<(usize, usize), u64>,
|
||||
/// Pictures refused for the budget (bytes' address and length), not looked at again.
|
||||
refused: HashSet<(usize, usize)>,
|
||||
/// Pixels decoded so far.
|
||||
@@ -228,16 +223,9 @@ impl Alphas {
|
||||
Some(alpha) => alpha.clone(),
|
||||
None if self.refused.contains(&key) => return Err(OverBudget),
|
||||
None => {
|
||||
// A picture already charged for (its outline was cached) isn't charged twice.
|
||||
let mut free = 0u64;
|
||||
let paid = self.charged.contains(&key);
|
||||
let before = self.decoded;
|
||||
let alpha = decode_alpha(bytes, if paid { &mut free } else { &mut self.decoded }).inspect_err(|_| {
|
||||
let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| {
|
||||
self.refused.insert(key);
|
||||
})?;
|
||||
if !paid {
|
||||
self.costs.insert(key, self.decoded.saturating_sub(before));
|
||||
}
|
||||
let alpha = alpha.map(Arc::new);
|
||||
self.seen.insert(key, alpha.clone());
|
||||
alpha
|
||||
@@ -245,41 +233,6 @@ impl Alphas {
|
||||
};
|
||||
Ok(alpha.and_then(|a| alpha_in_crop(&a, crop)))
|
||||
}
|
||||
|
||||
/// The pixels picture `bytes` took of the budget when it was decoded in this layout (0:
|
||||
/// not decoded, or none to look at; a picture that failed to decode still cost its pixels).
|
||||
pub(crate) fn cost(&self, bytes: &[u8]) -> u64 {
|
||||
self.costs.get(&(bytes.as_ptr() as usize, bytes.len())).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Charges the budget for picture `bytes`, whose outline was found kept from an earlier
|
||||
/// layout (`cost`: the pixels it took then), as decoding it now would: pictures get an outline
|
||||
/// by their place in the document, not by what the cache holds. [`OverBudget`] when it
|
||||
/// wouldn't have fitted: its rectangle is its outline in this layout.
|
||||
pub(crate) fn charge(&mut self, bytes: &[u8], cost: u64) -> Result<(), OverBudget> {
|
||||
let key = (bytes.as_ptr() as usize, bytes.len());
|
||||
if self.seen.contains_key(&key) || self.charged.contains(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.refused.contains(&key) {
|
||||
return Err(OverBudget);
|
||||
}
|
||||
if cost == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
match self.decoded.checked_add(cost).filter(|t| *t <= MAX_DECODED_PIXELS) {
|
||||
Some(total) => {
|
||||
self.decoded = total;
|
||||
self.charged.insert(key);
|
||||
self.costs.insert(key, cost);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
self.refused.insert(key);
|
||||
Err(OverBudget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures
|
||||
|
||||
@@ -69,9 +69,6 @@ pub(crate) struct Ctx<'a> {
|
||||
pub(crate) hf: Arc<HfLinks<'a>>,
|
||||
/// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]).
|
||||
pub(crate) alphas: contour::Alphas,
|
||||
/// Items re-placed as repeated table header rows so far, in the whole layout (see
|
||||
/// [`MAX_HEADER_ITEMS`]).
|
||||
pub(crate) header_repeats: usize,
|
||||
}
|
||||
|
||||
struct ParaEnvInput<'a> {
|
||||
@@ -147,14 +144,10 @@ pub(crate) fn with_pin<T>(
|
||||
/// an address names the same bytes only while their allocation lives: the weak reference keeps
|
||||
/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address
|
||||
/// and length and be taken for it.
|
||||
///
|
||||
/// Each also holds the pixels its picture took of a layout's decoding budget, which a layout
|
||||
/// finding the entry charges as if it had decoded the picture ([`contour::Alphas::charge`]).
|
||||
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>, u64)>;
|
||||
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>)>;
|
||||
|
||||
/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this
|
||||
/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it.
|
||||
/// One kept from before is charged to the budget as if decoded now.
|
||||
pub(crate) fn picture_outline(
|
||||
outlines: &mut Outlines,
|
||||
alphas: &mut contour::Alphas,
|
||||
@@ -162,17 +155,14 @@ pub(crate) fn picture_outline(
|
||||
bytes: &Arc<Vec<u8>>,
|
||||
crop: [f32; 4],
|
||||
) -> Option<Arc<Vec<(f32, f32)>>> {
|
||||
if let Some((_, o, cost)) = outlines.get(&key) {
|
||||
// Kept from an earlier layout, but it still costs this one's budget: the same pictures
|
||||
// get outlines whatever the cache holds.
|
||||
alphas.charge(bytes, *cost).ok()?;
|
||||
if let Some((_, o)) = outlines.get(&key) {
|
||||
return o.clone();
|
||||
}
|
||||
let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new);
|
||||
if outlines.len() >= 256 {
|
||||
outlines.clear();
|
||||
}
|
||||
outlines.insert(key, (Arc::downgrade(bytes), outline.clone(), alphas.cost(bytes)));
|
||||
outlines.insert(key, (Arc::downgrade(bytes), outline.clone()));
|
||||
outline
|
||||
}
|
||||
|
||||
|
||||
@@ -109,64 +109,11 @@ impl Linked {
|
||||
/// Header rows are re-placed on every page a table spills onto, so a table is O(header items ×
|
||||
/// pages) placed items — and an exact-height header row keeps all its overflowing text without
|
||||
/// growing, so a few KB of document could otherwise fill memory. Once this many items (a row
|
||||
/// counts at least one) have been repeated across the whole layout (every table together), later pages carry no header rows;
|
||||
/// counts at least one) have been repeated across a table, later pages carry no header rows;
|
||||
/// pagination still finishes. Far above any real table (three ordinary header rows over some
|
||||
/// 5 000 pages).
|
||||
pub(crate) const MAX_HEADER_ITEMS: usize = 500_000;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A smaller [`MAX_HEADER_ITEMS`] for tests, so a short table can reach it.
|
||||
pub(crate) static HEADER_ITEMS_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_HEADER_ITEMS) };
|
||||
}
|
||||
|
||||
/// [`MAX_HEADER_ITEMS`], or the test cap ([`HEADER_ITEMS_CAP`]) in tests.
|
||||
pub(crate) fn header_items_cap() -> usize {
|
||||
#[cfg(test)]
|
||||
return HEADER_ITEMS_CAP.with(|c| c.get());
|
||||
#[cfg(not(test))]
|
||||
MAX_HEADER_ITEMS
|
||||
}
|
||||
|
||||
/// What re-placing `it` costs against [`MAX_HEADER_ITEMS`] (and laying it out against
|
||||
/// [`textbox::MAX_FLOW_WORK`]): one, and for a clipped group (a floating Drawing Canvas) every
|
||||
/// member below it, nested groups included, as placing clones them all. Counted with an explicit
|
||||
/// stack, and counting stops once the total passes `limit` (the result is then just over it), so
|
||||
/// neither deep nesting nor a huge group makes the count itself costly.
|
||||
pub(crate) fn placed_cost(it: &Placed, limit: usize) -> usize {
|
||||
placed_cost_by(it, limit, |_| 1)
|
||||
}
|
||||
|
||||
/// [`placed_cost`] with each item (the group itself and every member) costing `cost` of it.
|
||||
pub(crate) fn placed_cost_by(it: &Placed, limit: usize, cost: impl Fn(&Placed) -> usize) -> usize {
|
||||
let mut count = 0usize;
|
||||
let mut stack: Vec<std::slice::Iter<Placed>> = Vec::new();
|
||||
let mut next = Some(it);
|
||||
loop {
|
||||
let cur = match next.take() {
|
||||
Some(c) => c,
|
||||
None => loop {
|
||||
match stack.last_mut() {
|
||||
None => return count,
|
||||
Some(iter) => match iter.next() {
|
||||
Some(c) => break c,
|
||||
None => {
|
||||
stack.pop();
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
count = count.saturating_add(cost(cur));
|
||||
if count > limit {
|
||||
return count;
|
||||
}
|
||||
if let Placed::Clip { items, .. } = cur {
|
||||
stack.push(items.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header tables can displace a non-overlapping body table. Page-positioned drawings keep
|
||||
/// their own wrapping rules, so only table areas are retained from the header layout.
|
||||
pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
|
||||
@@ -66,9 +66,6 @@ pub use floats::{is_floating, shape_figure_alt};
|
||||
use floats::{left_out_objects, wrap_area};
|
||||
#[cfg(test)]
|
||||
use headers::MAX_HEADER_ITEMS;
|
||||
#[cfg(test)]
|
||||
use headers::placed_cost;
|
||||
use headers::placed_cost_by;
|
||||
pub use notes::DECOR_STORY;
|
||||
pub(crate) use notes::NOTE_SEP_W;
|
||||
use notes::{MAX_NOTE_PASSES, footnote_pages};
|
||||
|
||||
@@ -46,37 +46,8 @@ pub fn paragraph_issues(
|
||||
}
|
||||
let text = proof_text(p);
|
||||
let spans: Vec<(std::ops::Range<usize>, wordcraft_proof::ProofLang)> = runs.iter().map(|r| (r.range.clone(), r.lang)).collect();
|
||||
let skipped = skipped_ranges(runs);
|
||||
let issues = wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item);
|
||||
if skipped.is_empty() {
|
||||
return issues;
|
||||
}
|
||||
issues.into_iter().filter(|i| !overlaps(&skipped, i.start, i.end)).collect()
|
||||
}
|
||||
|
||||
/// The byte ranges of the runs left unchecked, in order, those that overlap merged: their
|
||||
/// starts and ends both rise, so [`overlaps`] finds an issue's by binary search instead of
|
||||
/// looking at every run for every issue.
|
||||
fn skipped_ranges(runs: &[ProofRun]) -> Vec<(usize, usize)> {
|
||||
let mut ranges: Vec<(usize, usize)> = runs.iter().filter(|r| r.skip).map(|r| (r.range.start, r.range.end)).collect();
|
||||
ranges.sort_unstable();
|
||||
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
|
||||
for (s, e) in ranges {
|
||||
crate::work::steps(1);
|
||||
match merged.last_mut() {
|
||||
Some(last) if s < last.1 => last.1 = last.1.max(e),
|
||||
_ => merged.push((s, e)),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Whether bytes `a..b` overlap one of `skipped` ([`skipped_ranges`]): the first range ending
|
||||
/// after `a` is the only one that can, as every later one starts no earlier.
|
||||
fn overlaps(skipped: &[(usize, usize)], a: usize, b: usize) -> bool {
|
||||
crate::work::steps(1);
|
||||
let k = skipped.partition_point(|&(_, e)| e <= a);
|
||||
skipped.get(k).is_some_and(|&(s, _)| s < b)
|
||||
let skip = |a: usize, b: usize| runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end);
|
||||
wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item).into_iter().filter(|i| !skip(i.start, i.end)).collect()
|
||||
}
|
||||
|
||||
/// Whether the paragraph is a list item: its own numbering, or its style's, names a list (not 0).
|
||||
@@ -130,26 +101,3 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(range: std::ops::Range<usize>, skip: bool) -> ProofRun {
|
||||
ProofRun { range, lang: Default::default(), skip }
|
||||
}
|
||||
|
||||
/// The lookup finds what a look at every run finds, overlapping and empty runs included.
|
||||
#[test]
|
||||
fn skipped_lookup_matches_a_scan_of_every_run() {
|
||||
let runs = [run(0..4, false), run(4..9, true), run(5..6, true), run(9..9, true), run(9..12, false), run(12..20, true), run(14..30, true)];
|
||||
let skipped = skipped_ranges(&runs);
|
||||
for a in 0..32 {
|
||||
for b in a..32 {
|
||||
let scan = runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end);
|
||||
assert_eq!(overlaps(&skipped, a, b), scan, "{a}..{b}");
|
||||
}
|
||||
}
|
||||
assert!(skipped_ranges(&[run(0..5, false)]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@ fn new_ctx<'a>(
|
||||
floats_above: HashMap::new(),
|
||||
hf: Arc::new(HfLinks::new(doc.sections())),
|
||||
alphas: Default::default(),
|
||||
header_repeats: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,7 +560,6 @@ struct Snap<'a> {
|
||||
eq_count: u32,
|
||||
page_field: u32,
|
||||
boxes: usize,
|
||||
header_repeats: usize,
|
||||
}
|
||||
|
||||
impl<'a> Snap<'a> {
|
||||
@@ -579,7 +577,6 @@ impl<'a> Snap<'a> {
|
||||
eq_count: ctx.eq_count,
|
||||
page_field: ctx.fields.page,
|
||||
boxes: ctx.boxes.total(),
|
||||
header_repeats: ctx.header_repeats,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +599,6 @@ impl<'a> Snap<'a> {
|
||||
ctx.counters = self.counters.clone();
|
||||
ctx.eq_count = self.eq_count;
|
||||
ctx.fields.page = self.page_field;
|
||||
ctx.header_repeats = self.header_repeats;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::boxes::{BoxInput, clear_of_float_tables, layout_box};
|
||||
use super::ctx::{Ctx, with_pct_base};
|
||||
use super::floats::{WrapArea, align_in, body_wraps};
|
||||
use super::flow::again;
|
||||
use super::headers::{header_items_cap, header_table_areas, placed_cost};
|
||||
use super::headers::{MAX_HEADER_ITEMS, header_table_areas};
|
||||
use super::notes::table_notes;
|
||||
use super::page::{PageBuilder, PageFrame, page_frame};
|
||||
use super::placed::Placed;
|
||||
@@ -127,14 +127,10 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
// A table in the text flow starts below the floating objects in its way.
|
||||
below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
|
||||
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
|
||||
// What one more repeat of the header rows costs (see `MAX_HEADER_ITEMS`; the items repeated
|
||||
// so far are counted in the whole layout).
|
||||
let header_cap = header_items_cap();
|
||||
let header_cost = header_rows
|
||||
.iter()
|
||||
.filter_map(|i| tl.rows.get(*i))
|
||||
.map(|r| r.items.iter().map(|it| placed_cost(it, header_cap)).fold(0, usize::saturating_add).max(1))
|
||||
.fold(0, usize::saturating_add);
|
||||
// Items repeated so far, and what one more repeat of the header rows costs (see
|
||||
// `MAX_HEADER_ITEMS`).
|
||||
let mut header_repeats = 0usize;
|
||||
let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add);
|
||||
// Resolve kept chains once; nested paragraphs cannot keep their enclosing row.
|
||||
let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect();
|
||||
for i in (0..tl.rows.len().saturating_sub(1)).rev() {
|
||||
@@ -184,7 +180,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
let table_at_top = pb.at_top();
|
||||
for (ri, row) in tl.rows.iter().enumerate() {
|
||||
// Word never splits a row of an exact height: it moves on whole.
|
||||
let splittable = ri >= header_rows.len()
|
||||
let splittable = !header_rows.contains(&ri)
|
||||
&& !t
|
||||
.rows
|
||||
.get(ri)
|
||||
@@ -241,9 +237,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
None => {}
|
||||
}
|
||||
pb.advance(block, body_top);
|
||||
// Repeat header rows, up to the layout's cap.
|
||||
if ri >= header_rows.len() && ctx.header_repeats.saturating_add(header_cost) <= header_cap {
|
||||
ctx.header_repeats = ctx.header_repeats.saturating_add(header_cost);
|
||||
// Repeat header rows, up to this table's cap.
|
||||
if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS {
|
||||
header_repeats += header_cost;
|
||||
for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) {
|
||||
place(pb, h);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Hostile documents whose layout work or memory would otherwise grow far past their size: each
|
||||
//! is bounded by a cap, and real documents stay well under it.
|
||||
|
||||
use crate::ctx::Outlines;
|
||||
use crate::*;
|
||||
use wordcraft_doc::props::HeightRule;
|
||||
use wordcraft_doc::table::{MAX_COLS, Table};
|
||||
@@ -37,11 +36,10 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
|
||||
fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
|
||||
// Forty linked boxes, each a line deep and each a different width, over a long story: every
|
||||
// width would lay the whole story out again. Past the work cap, later boxes reuse the
|
||||
// nearest width already laid out, so every box still shows its share of the story.
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
|
||||
// width would lay the whole story out again. Past the cap, later boxes reuse the nearest
|
||||
// width already laid out, so every box still shows its share of the story.
|
||||
let n = 40;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
@@ -58,85 +56,7 @@ fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
|
||||
lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect();
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
|
||||
// The cap is spent after the first few layouts (each is far over 2000 / 8 work).
|
||||
assert!(widths.len() <= 8, "{} widths laid the story out", widths.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twenty_distinct_widths_of_an_ordinary_story_all_keep_their_width() {
|
||||
// A real chain of 20 boxes, 100, 105, ... 195 pt wide: each is laid out at its own width.
|
||||
let n = 20;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 5.0 * i as f32, 28.0)).collect();
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
let mut widths = story_layouts(&lay(&d), ids[0]);
|
||||
assert_eq!(widths.len(), n, "every box shows the story");
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert_eq!(widths.len(), n, "every box has a layout at its own width");
|
||||
}
|
||||
|
||||
/// How many times story layouts ran while `doc` was laid out with the chain work cap at `cap`.
|
||||
fn chain_layouts(d: &Document, cap: usize) -> usize {
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(cap));
|
||||
textbox::FLOW_LAYOUTS.with(|c| c.set(0));
|
||||
lay(d);
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
|
||||
textbox::FLOW_LAYOUTS.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// `n` linked boxes `w0`, `w0 + 0.1`, ... pt wide (each its own layout) and a line deep, the
|
||||
/// first holding `blocks`; the first box's id.
|
||||
fn hairline_chain(d: &mut Document, n: usize, w0: f32, blocks: Vec<Arc<Block>>) -> u32 {
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(d, i, "", w0 + 0.1 * i as f32, 28.0)).collect();
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
d.parts.get_mut(&ids[0]).unwrap().blocks = blocks;
|
||||
ids[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_whose_story_shows_little_still_pays_for_its_text() {
|
||||
// All 256 boxes a hair apart in width, each showing one short visible line of a story that
|
||||
// hides about 1 MB of text between them: each layout gives some 300 lines but works through
|
||||
// the whole story. Charged by its lines alone, every width was laid out (256 times the story).
|
||||
let n = wordcraft_doc::MAX_LINKED_BOXES;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let hidden = "Hidden words that never show. ".repeat(3500 / 30);
|
||||
let hide = wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() };
|
||||
let story = (0..300)
|
||||
.map(|k| {
|
||||
let shown = format!("Line {k}.");
|
||||
let mut p = Paragraph::with_text(&format!("{shown}{hidden}"), Default::default());
|
||||
p.runs = vec![
|
||||
wordcraft_doc::para::Run { len: shown.len(), props: Default::default() },
|
||||
wordcraft_doc::para::Run { len: hidden.len(), props: hide.clone() },
|
||||
];
|
||||
p.touch();
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
hairline_chain(&mut d, n, 300.0, story);
|
||||
// Ordinary stories of this many lines keep all 256 widths under this cap.
|
||||
let layouts = chain_layouts(&d, 100_000);
|
||||
assert!(layouts <= 8, "{layouts} layouts of a 1 MB story");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_of_wide_boxes_pays_for_a_long_paragraph() {
|
||||
// 256 wide boxes a hair apart over one long paragraph: few lines per layout, but each
|
||||
// layout shapes all of it.
|
||||
let n = wordcraft_doc::MAX_LINKED_BOXES;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(64 * 1024 / 58);
|
||||
hairline_chain(&mut d, n, 1500.0, vec![para_block(Paragraph::with_text(&story, Default::default()))]);
|
||||
let layouts = chain_layouts(&d, 2000);
|
||||
assert!(layouts <= 4, "{layouts} layouts of a 64 KB story");
|
||||
assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len());
|
||||
}
|
||||
|
||||
/// The paragraph layouts (by address) showing story `head` across the document.
|
||||
@@ -175,8 +95,7 @@ fn stacked_boxes_of_any_width_share_a_layout() {
|
||||
fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
|
||||
// The cap is full of horizontal widths when a vertical box comes: it lays the story out
|
||||
// its own way rather than showing a horizontal layout.
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
|
||||
let n = 17;
|
||||
let n = textbox::MAX_FLOW_WIDTHS + 1;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
|
||||
@@ -190,12 +109,7 @@ fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
|
||||
assert_eq!(widths.len(), n, "every box shows the story");
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert!(widths.len() > 1 && widths.len() < n, "{} layouts: the vertical box has its own, the rest are shared", widths.len());
|
||||
// The vertical box's layout is none of the horizontal boxes', still past the cap.
|
||||
let mut all = story_layouts(&lay(&d), ids[0]);
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
|
||||
let vertical = all.pop();
|
||||
assert!(vertical.is_some_and(|v| !all.contains(&v)), "the vertical box shares a horizontal layout");
|
||||
assert_eq!(widths.len(), n, "the vertical box has a layout of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -362,7 +276,7 @@ fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() {
|
||||
assert!(outlines.is_empty(), "the refusal isn't kept");
|
||||
let mut fresh = contour::Alphas::default();
|
||||
assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some());
|
||||
assert!(outlines.get(&key).is_some_and(|(_, o, _)| o.is_some()), "the outline is kept");
|
||||
assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept");
|
||||
}
|
||||
|
||||
/// The outline cache outlives the documents it is used with and tells pictures apart by their
|
||||
@@ -374,270 +288,9 @@ fn a_kept_outline_holds_on_to_its_pictures_allocation() {
|
||||
let mut outlines = HashMap::new();
|
||||
let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]);
|
||||
assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some());
|
||||
let Some((weak, ..)) = outlines.get(&key) else { panic!("the outline is kept") };
|
||||
let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") };
|
||||
assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes");
|
||||
assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes");
|
||||
drop(disc);
|
||||
assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept");
|
||||
}
|
||||
|
||||
/// A floating Drawing Canvas in a header cell is one `Clip` holding its members, and re-placing
|
||||
/// the header clones every one of them: they all count against the repeat budget.
|
||||
#[test]
|
||||
fn a_clipped_group_costs_its_members_when_a_header_repeats() {
|
||||
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
|
||||
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
|
||||
let big = usize::MAX;
|
||||
assert_eq!(placed_cost(&rule(), big), 1);
|
||||
assert_eq!(placed_cost(&clip((0..1000).map(|_| rule()).collect()), big), 1001);
|
||||
// Nested groups count too, however deep: every descendant is charged.
|
||||
assert_eq!(placed_cost(&clip(vec![clip(vec![rule(), rule()]), rule()]), big), 5);
|
||||
let mut deep = clip((0..1000).map(|_| rule()).collect());
|
||||
for _ in 0..20 {
|
||||
deep = clip(vec![deep]);
|
||||
}
|
||||
assert_eq!(placed_cost(&deep, big), 1021, "members below the eighth level still cost one each");
|
||||
// Counting stops once past the limit, so a huge or very deep group is cheap to count.
|
||||
assert_eq!(placed_cost(&deep, 10), 11);
|
||||
let mut deeper = rule();
|
||||
for _ in 0..100_000 {
|
||||
deeper = clip(vec![deeper]);
|
||||
}
|
||||
assert_eq!(placed_cost(&deeper, 50), 51);
|
||||
assert_eq!(placed_cost(&deeper, big), 100_001);
|
||||
std::mem::forget(deeper);
|
||||
}
|
||||
|
||||
/// The linked-box work budget counts a clipped group's members, nested groups included.
|
||||
#[test]
|
||||
fn flow_work_counts_clip_members() {
|
||||
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
|
||||
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
|
||||
let mut deep = clip((0..100).map(|_| rule()).collect());
|
||||
for _ in 0..12 {
|
||||
deep = clip(vec![deep, rule()]);
|
||||
}
|
||||
assert_eq!(textbox::flow_work(&[rule(), deep]), 1 + 100 + 1 + 12 * 2);
|
||||
}
|
||||
|
||||
/// Lines inside a clipped group (a text box on a canvas) cost each line, as they do outside one.
|
||||
#[test]
|
||||
fn flow_work_counts_each_line_in_a_clip() {
|
||||
let l = lay(&Document::from_text(&"Words that run on and on. ".repeat(200)));
|
||||
let lines = l.pages.iter().flat_map(|p| &p.items).find(|it| matches!(it, Placed::Lines { .. })).cloned().unwrap();
|
||||
let (l0, l1) = match &lines {
|
||||
Placed::Lines { l0, l1, .. } => (*l0, *l1),
|
||||
_ => (0, 0),
|
||||
};
|
||||
assert!(l1 - l0 > 1);
|
||||
let clip = Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items: vec![lines] };
|
||||
assert_eq!(textbox::flow_work(&[clip]), 1 + (l1 - l0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_header_items_are_capped_across_all_the_tables_of_a_layout() {
|
||||
// Each table's repeats used to have a budget of their own: a hundred tables, a hundred times
|
||||
// the cap.
|
||||
let table = || {
|
||||
let mut t = Table::new(201, 1, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
t.rows[0].props.height = Some(20.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect();
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.props.height = Some(600.0);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
t
|
||||
};
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table())).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(table())).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(3), Block::Table(table())).unwrap();
|
||||
let l = lay(&d);
|
||||
let items: usize = l.pages.iter().map(|p| p.items.len()).sum();
|
||||
assert!(items <= MAX_HEADER_ITEMS + 3 * 3000 + 50 * l.pages.len(), "{items} items placed");
|
||||
}
|
||||
|
||||
/// Which pictures wrap by their outline must not depend on what the cache already holds: a
|
||||
/// picture's outline kept from an earlier layout is charged to the budget like a decoded one.
|
||||
#[test]
|
||||
fn cold_and_warm_caches_give_the_same_outlines() {
|
||||
let (a, b) = (Arc::new(disc_png(64)), Arc::new(disc_png(64)));
|
||||
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
|
||||
// Room for one of the two pictures.
|
||||
let pass = |outlines: &mut Outlines| {
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64;
|
||||
let got = [(&a, "a"), (&b, "b")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
|
||||
(got, alphas.decoded)
|
||||
};
|
||||
let mut roomy = contour::Alphas::default();
|
||||
let mut warm = Outlines::new();
|
||||
for (p, n) in [(&a, "a"), (&b, "b")] {
|
||||
assert!(picture_outline(&mut warm, &mut roomy, key(p, n), p, [0.0; 4]).is_some());
|
||||
}
|
||||
assert_eq!(warm.len(), 2, "both outlines are kept");
|
||||
let (cold, cold_spent) = pass(&mut Outlines::new());
|
||||
assert_eq!(cold, [true, false], "the first picture fits, the second wraps as its rectangle");
|
||||
let (warmed, warm_spent) = pass(&mut warm);
|
||||
assert_eq!(warmed, cold, "the same pictures get outlines with the cache warm");
|
||||
assert_eq!(warm_spent, cold_spent);
|
||||
// And again: a layout after a layout.
|
||||
assert_eq!(pass(&mut warm).0, cold);
|
||||
}
|
||||
|
||||
/// A PNG whose header says `side` × `side` RGBA and whose body is missing: it has transparency
|
||||
/// to look at, costs its pixels, and then fails to decode.
|
||||
fn truncated_png(side: u32) -> Vec<u8> {
|
||||
fn crc(data: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for b in data {
|
||||
c ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
let mut ihdr = b"IHDR".to_vec();
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend([8, 6, 0, 0, 0]);
|
||||
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
|
||||
png.extend(&ihdr);
|
||||
png.extend(crc(&ihdr).to_be_bytes());
|
||||
// An image data chunk of junk: the header is read, the pixels aren't there.
|
||||
let mut idat = b"IDAT".to_vec();
|
||||
idat.extend([0x78, 0x9c, 1, 2, 3, 4]);
|
||||
png.extend(6u32.to_be_bytes());
|
||||
png.extend(&idat);
|
||||
png.extend(crc(&idat).to_be_bytes());
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_picture_costs_the_same_cold_and_warm() {
|
||||
// Its header promises transparency, so a layout spends its pixels before the body fails to
|
||||
// decode; a layout finding its (empty) outline kept spends them too, or pictures after it
|
||||
// would get outlines only with the cache warm.
|
||||
let (big, disc) = (Arc::new(truncated_png(8000)), Arc::new(disc_png(64)));
|
||||
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
|
||||
let pass = |outlines: &mut Outlines| {
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 8000 * 8000;
|
||||
let got = [(&big, "big"), (&disc, "disc")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
|
||||
(got, alphas.decoded)
|
||||
};
|
||||
let mut warm = Outlines::new();
|
||||
let (cold, spent) = pass(&mut warm);
|
||||
assert_eq!(spent, contour::MAX_DECODED_PIXELS, "the truncated picture spent its pixels");
|
||||
assert_eq!(cold, [false, false], "no budget is left for the disc");
|
||||
assert_eq!(warm.get(&key(&big, "big")).map(|e| e.2), Some(8000 * 8000), "the entry holds the cost");
|
||||
// Also with a roomy first layout filling the cache, then a tight one.
|
||||
let mut roomy_cache = Outlines::new();
|
||||
let mut roomy = contour::Alphas::default();
|
||||
for (p, n) in [(&big, "big"), (&disc, "disc")] {
|
||||
let _ = picture_outline(&mut roomy_cache, &mut roomy, key(p, n), p, [0.0; 4]);
|
||||
}
|
||||
assert_eq!(pass(&mut roomy_cache), (cold, spent), "warm matches cold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_row_holding_a_canvas_repeats_within_the_budget() {
|
||||
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
|
||||
// A canvas of 800 members in an exact-height header row, over two thousand pages: each page
|
||||
// would clone all 800 members.
|
||||
let member = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 4.0,
|
||||
h: 4.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let canvas = InlineObject::Group {
|
||||
w: 40.0,
|
||||
h: 40.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 5.0, y: 5.0, ..Default::default() },
|
||||
ch_w: 40.0,
|
||||
ch_h: 40.0,
|
||||
children: (0..800).map(|_| GroupChild { x: 1.0, y: 1.0, obj: member.clone() }).collect(),
|
||||
canvas: Some(CanvasStyle { fill: None, stroke: None, stroke_width: 0.0 }),
|
||||
};
|
||||
let mut t = Table::new(2001, 1, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
t.rows[0].props.height = Some(60.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
let mut p = Paragraph::with_text("", Default::default());
|
||||
p.insert_object(0, canvas, &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.props.height = Some(600.0);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
fn count(it: &Placed) -> usize {
|
||||
match it {
|
||||
Placed::Clip { items, .. } => items.iter().map(count).fold(1, usize::saturating_add),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 1000, "{} pages", l.pages.len());
|
||||
let items: usize = l.pages.iter().flat_map(|p| &p.items).map(count).sum();
|
||||
assert!(items <= MAX_HEADER_ITEMS + 2000 + 50 * l.pages.len(), "{items} items placed");
|
||||
assert!(items > 100_000, "the header still repeats on early pages: {items}");
|
||||
}
|
||||
|
||||
/// A table with a repeating header row in a two-column section ended by a continuous break, so
|
||||
/// its columns are balanced: laid out once, then again from a snapshot for each balancing try.
|
||||
fn balanced_header_table(rows: usize) -> Document {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let mut d = Document::from_text("Before\nEnd of the section\nAfter the break");
|
||||
let mut s = SectionProps::default();
|
||||
s.columns.count = 2;
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(s));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
let mut t = Table::new(rows, 1, 200.0);
|
||||
t.rows[0].props.header = true;
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Header rows placed (each repeat of the one-column header row is one cell of row 0).
|
||||
fn header_cells(l: &DocLayout) -> usize {
|
||||
l.pages.iter().flat_map(|p| &p.items).filter(|i| matches!(i, Placed::Cell { row: 0, .. })).count()
|
||||
}
|
||||
|
||||
fn with_header_cap<T>(cap: usize, f: impl FnOnce() -> T) -> T {
|
||||
headers::HEADER_ITEMS_CAP.with(|c| c.set(cap));
|
||||
let out = f();
|
||||
headers::HEADER_ITEMS_CAP.with(|c| c.set(MAX_HEADER_ITEMS));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balancing_columns_gives_back_the_header_repeats_it_throws_away() {
|
||||
// The table runs over both columns of a page and onto a second one.
|
||||
let d = balanced_header_table(120);
|
||||
let full = lay(&d);
|
||||
let shown = header_cells(&full);
|
||||
assert!(shown >= 3, "the header repeats in each later column: {shown} header rows");
|
||||
// What one repeat costs: the smallest cap at which a plain two-page table repeats its header.
|
||||
let mut plain = Table::new(80, 1, 200.0);
|
||||
plain.rows[0].props.header = true;
|
||||
let mut p = Document::from_text("");
|
||||
p.insert_block(StoryRef::Body, &Path::top(1), Block::Table(plain)).unwrap();
|
||||
let cost = (1..=200).find(|cap| with_header_cap(*cap, || header_cells(&lay(&p))) >= 2).expect("a header repeat costs a few items");
|
||||
// A budget holding exactly the repeats shown is enough: the balancing tries thrown away don't
|
||||
// spend it.
|
||||
let capped = with_header_cap((shown - 1) * cost, || lay(&d));
|
||||
assert_eq!(header_cells(&capped), shown, "the final layout repeats the header as often");
|
||||
}
|
||||
|
||||
@@ -208,66 +208,13 @@ struct Cut {
|
||||
byte: usize,
|
||||
}
|
||||
|
||||
/// Most work a chain's story is laid out for, summed over its layouts. Each distinct line length
|
||||
/// (and text direction) lays the whole story out again and is kept while the chain is split, so
|
||||
/// a chain of boxes that each differ in width multiplies its story's layout work and memory by
|
||||
/// its widths. A layout costs the lines and other items it gives, or its story's text at
|
||||
/// [`STORY_BYTES_PER_WORK`] bytes a unit, whichever is more: text that gives few lines (hidden,
|
||||
/// deleted, very wide lines, or cut by a box inside) still costs what shaping it does. So the
|
||||
/// layouts of a chain shape at most about 64 MB of text, plus the one that crosses the cap and
|
||||
/// the first in each other direction. Within this every box of an ordinary story (even all
|
||||
/// [`wordcraft_doc::MAX_LINKED_BOXES`]) keeps its exact width; past it, a box whose length isn't
|
||||
/// among those laid out takes the nearest narrower one laid out in its text direction, so its
|
||||
/// lines still fit across it (they stop short of its far edge), else the narrowest, whose lines
|
||||
/// are longer than the box. Only a huge story gets there.
|
||||
pub(crate) const MAX_FLOW_WORK: usize = 1_000_000;
|
||||
|
||||
/// Bytes of story text that cost one unit of [`MAX_FLOW_WORK`]: about a line's worth.
|
||||
pub(crate) const STORY_BYTES_PER_WORK: usize = 64;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A smaller cap for tests, so a short story can reach it.
|
||||
pub(crate) static FLOW_WORK_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_FLOW_WORK) };
|
||||
/// How many times chains' stories have been laid out, for tests.
|
||||
pub(crate) static FLOW_LAYOUTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn flow_work_cap() -> usize {
|
||||
#[cfg(test)]
|
||||
return FLOW_WORK_CAP.with(|c| c.get());
|
||||
#[cfg(not(test))]
|
||||
MAX_FLOW_WORK
|
||||
}
|
||||
|
||||
/// What laying `items` out cost: its lines and other items (a clipped group's members, nested
|
||||
/// ones too, each count, its lines each).
|
||||
pub(crate) fn flow_work(items: &[Placed]) -> usize {
|
||||
let cap = flow_work_cap();
|
||||
items.iter().map(|it| super::placed_cost_by(it, cap, line_count)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// One for an item, or its lines for a run of lines.
|
||||
fn line_count(it: &Placed) -> usize {
|
||||
match it {
|
||||
Placed::Lines { l0, l1, .. } => l1.saturating_sub(*l0).max(1),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The work laying `blocks` out costs whatever it gives (see [`MAX_FLOW_WORK`]): its text's
|
||||
/// bytes, tables' cells too, over [`STORY_BYTES_PER_WORK`].
|
||||
fn story_work(blocks: &Blocks) -> usize {
|
||||
let mut bytes = 0usize;
|
||||
let mut stack: Vec<&Arc<Block>> = blocks.iter().collect();
|
||||
while let Some(b) = stack.pop() {
|
||||
match &**b {
|
||||
Block::Para(p) => bytes = bytes.saturating_add(p.text.len()),
|
||||
Block::Table(t) => stack.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks)),
|
||||
}
|
||||
}
|
||||
bytes / STORY_BYTES_PER_WORK
|
||||
}
|
||||
/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole
|
||||
/// story out again and is kept while the chain is split, so a chain of boxes that each differ in
|
||||
/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't
|
||||
/// among them takes the nearest narrower one laid out in its text direction, so its lines still
|
||||
/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer
|
||||
/// than the box. Far above a real chain's few box sizes.
|
||||
pub(crate) const MAX_FLOW_WIDTHS: usize = 16;
|
||||
|
||||
/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart
|
||||
/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size.
|
||||
@@ -290,9 +237,6 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
||||
let snap = (ctx.counters.clone(), ctx.eq_count);
|
||||
let mut after = None;
|
||||
let mut layouts: Vec<((i32, TextVert), Vec<Placed>)> = Vec::new();
|
||||
// Work done by the layouts so far (see `MAX_FLOW_WORK`).
|
||||
let mut work = 0usize;
|
||||
let floor = story_work(&blocks);
|
||||
let mut out = Vec::with_capacity(chain.len());
|
||||
// Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown.
|
||||
let mut start: Option<Option<Cut>> = Some(None);
|
||||
@@ -305,20 +249,17 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
||||
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
|
||||
let (len, across) = frame(&body, w, h);
|
||||
let key = flow_key(len, body.vert);
|
||||
// Past the work cap the box takes the nearest layout in its direction: the widest line length
|
||||
// Past the cap the box takes the nearest layout in its direction: the widest line length
|
||||
// that fits it, else the narrowest. The first box in a direction is laid out (one more
|
||||
// layout for each of the few directions at most).
|
||||
let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0)));
|
||||
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if work >= flow_work_cap() { near() } else { None });
|
||||
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None });
|
||||
let items = match kept {
|
||||
Some((_, items)) => items.clone(),
|
||||
None => {
|
||||
(ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1);
|
||||
let items = lay(ctx, head, &blocks, len, body.vert, depth);
|
||||
after.get_or_insert_with(|| (ctx.counters.clone(), ctx.eq_count));
|
||||
#[cfg(test)]
|
||||
FLOW_LAYOUTS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
work = work.saturating_add(flow_work(&items).max(floor));
|
||||
layouts.push((key, items.clone()));
|
||||
items
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[us
|
||||
cmaps: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
vectors: HashMap::new(),
|
||||
vector_bytes_left: crate::exporter::MAX_CACHED_VECTOR_BYTES,
|
||||
tags: Vec::new(),
|
||||
tag_index: HashMap::new(),
|
||||
links: Vec::new(),
|
||||
|
||||
@@ -15,6 +15,9 @@ mod draw;
|
||||
mod nav;
|
||||
mod text;
|
||||
|
||||
/// Most bytes of parsed metafiles (geometry, text and pixels) one export keeps between pages.
|
||||
pub(crate) const MAX_CACHED_VECTOR_BYTES: usize = 256 << 20;
|
||||
|
||||
pub(crate) struct Exporter<'a> {
|
||||
pub(crate) doc: &'a Document,
|
||||
pub(crate) lay: &'a DocLayout,
|
||||
@@ -30,6 +33,8 @@ pub(crate) struct Exporter<'a> {
|
||||
pub(crate) images: HashMap<String, Option<Image>>,
|
||||
/// Parsed metafiles by media key (`None`: not readable).
|
||||
pub(crate) vectors: HashMap<String, Option<Arc<Vector>>>,
|
||||
/// Bytes of parsed metafiles `vectors` may still keep ([`MAX_CACHED_VECTOR_BYTES`]).
|
||||
pub(crate) vector_bytes_left: usize,
|
||||
pub(crate) tags: Vec<TagEntry>,
|
||||
pub(crate) tag_index: HashMap<(StoryRef, DocPath), usize>,
|
||||
/// Link rectangles on the current page: (x0, y0, x1, y1, target).
|
||||
@@ -84,7 +89,7 @@ impl Exporter<'_> {
|
||||
|
||||
/// The metafile picture for a media key, parsed once. None when the bytes are not a metafile
|
||||
/// (the raster path takes them) or do not parse (the raster path then draws the grey box).
|
||||
fn vector(&mut self, key: &str) -> Option<Arc<Vector>> {
|
||||
pub(crate) fn vector(&mut self, key: &str) -> Option<Arc<Vector>> {
|
||||
let doc = self.doc;
|
||||
let bytes = doc.media.get(key)?;
|
||||
if !wordcraft_metafile::is_metafile(bytes) {
|
||||
@@ -97,7 +102,16 @@ impl Exporter<'_> {
|
||||
if v.is_none() {
|
||||
log::warn!("PDF: metafile `{key}` could not be read");
|
||||
}
|
||||
self.vectors.insert(key.to_string(), v.clone());
|
||||
// Kept for the pages that draw it again while the cache has room; past that it is parsed
|
||||
// again when it is next drawn, rather than every metafile's geometry piling up.
|
||||
let size = v.as_ref().map_or(0, |v| v.heap_bytes());
|
||||
match self.vector_bytes_left.checked_sub(size) {
|
||||
Some(left) => {
|
||||
self.vector_bytes_left = left;
|
||||
self.vectors.insert(key.to_string(), v.clone());
|
||||
}
|
||||
None => log::debug!("PDF: metafile `{key}` ({size} bytes parsed) is not kept between pages"),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,23 @@ pub(crate) struct Vector {
|
||||
images: Vec<Option<Image>>,
|
||||
}
|
||||
|
||||
impl Vector {
|
||||
/// Bytes the parsed picture holds: its paths' segments, its text and its bitmaps' pixels.
|
||||
pub(crate) fn heap_bytes(&self) -> usize {
|
||||
let item = |it: &wordcraft_metafile::Item| match it {
|
||||
wordcraft_metafile::Item::Path { segs, .. } => segs.len().saturating_mul(std::mem::size_of::<wordcraft_metafile::Seg>()),
|
||||
wordcraft_metafile::Item::Bitmap { width, height, .. } => (*width as usize).saturating_mul(*height as usize).saturating_mul(4),
|
||||
wordcraft_metafile::Item::Text(t) => t.text.len().saturating_add(t.font.len()),
|
||||
};
|
||||
let items = self
|
||||
.pic
|
||||
.items
|
||||
.iter()
|
||||
.fold(self.pic.items.len().saturating_mul(std::mem::size_of::<wordcraft_metafile::Item>()), |n, it| n.saturating_add(item(it)));
|
||||
items.saturating_add(self.images.len().saturating_mul(std::mem::size_of::<Option<Image>>()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a metafile and embeds its bitmaps. None when the picture has no usable size.
|
||||
pub(crate) fn build_vector(bytes: &[u8]) -> Option<Vector> {
|
||||
let mut pic = wordcraft_metafile::parse(bytes).ok()?;
|
||||
|
||||
@@ -1367,6 +1367,7 @@ fn bookmark_destination_is_the_page_of_its_start() {
|
||||
cmaps: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
vectors: HashMap::new(),
|
||||
vector_bytes_left: crate::exporter::MAX_CACHED_VECTOR_BYTES,
|
||||
tags: Vec::new(),
|
||||
tag_index: HashMap::new(),
|
||||
links: Vec::new(),
|
||||
@@ -2488,3 +2489,37 @@ fn baseline_is_the_most_shared_y() {
|
||||
assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0));
|
||||
assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_metafiles_are_kept_only_within_the_cache_budget() {
|
||||
let mut d = Document::new();
|
||||
let media = d.add_media(red_right_half_emf(), "emf");
|
||||
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let outlined = HashSet::new();
|
||||
let mut ex = Exporter {
|
||||
doc: &d,
|
||||
lay: &lay,
|
||||
opts: &PdfOptions::default(),
|
||||
out_index: HashMap::new(),
|
||||
fonts: HashMap::new(),
|
||||
font_error: None,
|
||||
outlined: &outlined,
|
||||
cmaps: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
vectors: HashMap::new(),
|
||||
vector_bytes_left: crate::exporter::MAX_CACHED_VECTOR_BYTES,
|
||||
tags: Vec::new(),
|
||||
tag_index: HashMap::new(),
|
||||
links: Vec::new(),
|
||||
bookmarks: std::cell::OnceCell::new(),
|
||||
};
|
||||
let v = ex.vector(&media).expect("parses");
|
||||
assert!(v.heap_bytes() > 0);
|
||||
assert_eq!(ex.vectors.len(), 1, "kept while the budget has room");
|
||||
assert_eq!(ex.vector_bytes_left, crate::exporter::MAX_CACHED_VECTOR_BYTES - v.heap_bytes());
|
||||
// With the budget spent, a metafile is still drawn, just not kept.
|
||||
ex.vectors.clear();
|
||||
ex.vector_bytes_left = 0;
|
||||
assert!(ex.vector(&media).is_some());
|
||||
assert!(ex.vectors.is_empty());
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ wordcraft-render = { workspace = true }
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-zotero = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}`
|
||||
//! starts the way the desktop app does with nothing to open (File › Home, #766). A line
|
||||
//! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and
|
||||
//! (with `{"network": true}` on a line, downloading is already on) `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary
|
||||
//! `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary
|
||||
//! folder rather than the person's, or in `"storeDir"` given on the same line (with
|
||||
//! `CRAFT_LANGUAGE_PACK_INDEX` for a local pack index).
|
||||
|
||||
@@ -40,14 +40,7 @@ fn main() {
|
||||
.unwrap_or_else(|| std::env::temp_dir().join(format!("ui_shot-font-store-{}", std::process::id())));
|
||||
wordcraft_engine::packs::use_font_store_in(dir, true);
|
||||
}
|
||||
Some(_) => {
|
||||
// `"network": true` on a line has downloading already on (to show what follows a click on Add a language…).
|
||||
let backend = wordcraft_engine::packs::FixedBackend::sample();
|
||||
if lines.iter().any(|l| l.get("network") == Some(&serde_json::Value::Bool(true))) {
|
||||
backend.network.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
wordcraft_engine::packs::set_backend(Some(std::sync::Arc::new(backend)));
|
||||
}
|
||||
Some(_) => wordcraft_engine::packs::set_backend(Some(std::sync::Arc::new(wordcraft_engine::packs::FixedBackend::sample()))),
|
||||
None => {}
|
||||
}
|
||||
let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx);
|
||||
|
||||
@@ -116,9 +116,7 @@ impl WordApp {
|
||||
fonts_korean: false,
|
||||
fonts_hant: false,
|
||||
fonts_system_cjk: false,
|
||||
fonts_system_arabic: false,
|
||||
want_system_cjk: false,
|
||||
want_system_arabic: false,
|
||||
recover_fonts_for: None,
|
||||
recent_fonts_for: Vec::new(),
|
||||
fonts_frames: 0,
|
||||
@@ -225,7 +223,6 @@ impl WordApp {
|
||||
crate::language_packs::tick(self);
|
||||
let lang = i18n::Lang::from_pref(&self.ui.language);
|
||||
i18n::set_current(lang);
|
||||
crate::rtl::sync(ctx);
|
||||
// New documents are written in the interface's language when WordCraft can proof it
|
||||
// (a Polish interface: Polish spelling, grammar and hyphenation from the first word).
|
||||
// An English interface writes the region's English (English (UK) on a British system,
|
||||
|
||||
@@ -155,15 +155,11 @@ impl WordApp {
|
||||
/// face has changed since the last install.
|
||||
pub(super) fn install_ui_fonts(&mut self, ctx: &egui::Context, lang: i18n::Lang) {
|
||||
let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk();
|
||||
// An Arabic interface, or a screen that shows Arabic names (language picker / packs) in
|
||||
// another interface language, asks for an installed Arabic font when none is embedded.
|
||||
let system_arabic = lang.rtl() || self.want_system_arabic;
|
||||
if !self.styled
|
||||
|| lang.prefers_hans() != self.fonts_hans
|
||||
|| lang.prefers_korean() != self.fonts_korean
|
||||
|| lang.prefers_hant() != self.fonts_hant
|
||||
|| system_cjk != self.fonts_system_cjk
|
||||
|| system_arabic != self.fonts_system_arabic
|
||||
{
|
||||
// Every window's borrowed faces: the fonts are the app's.
|
||||
let mut faces = self.canvas.ime_faces.clone();
|
||||
@@ -174,20 +170,11 @@ impl WordApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
theme::install_fonts_language_arabic(
|
||||
ctx,
|
||||
lang.prefers_hans(),
|
||||
lang.prefers_korean(),
|
||||
lang.prefers_hant(),
|
||||
system_cjk,
|
||||
system_arabic,
|
||||
&faces,
|
||||
);
|
||||
theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), lang.prefers_hant(), system_cjk, &faces);
|
||||
self.fonts_hans = lang.prefers_hans();
|
||||
self.fonts_korean = lang.prefers_korean();
|
||||
self.fonts_hant = lang.prefers_hant();
|
||||
self.fonts_system_cjk = system_cjk;
|
||||
self.fonts_system_arabic = system_arabic;
|
||||
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);
|
||||
// egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom.
|
||||
// egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph
|
||||
|
||||
@@ -10,7 +10,7 @@ impl WordApp {
|
||||
/// whatever handed it over (the command line, a macOS open event, a drop). Returns the message.
|
||||
pub fn report_open_failure(&mut self, path: &str, error: &str) -> String {
|
||||
log::warn!("{path}: {error}");
|
||||
let msg = crate::i18n::fmt(crate::tl!("Couldn't open {path}: {error}"), &[("path", &crate::i18n::isolate_path(path)), ("error", error)]);
|
||||
let msg = crate::i18n::fmt(crate::tl!("Couldn't open {path}: {error}"), &[("path", path), ("error", error)]);
|
||||
self.status(msg.clone());
|
||||
msg
|
||||
}
|
||||
|
||||
+85
-159
@@ -1,6 +1,6 @@
|
||||
//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Close, Options, About.
|
||||
|
||||
use egui::{Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::{Tokens, medium, regular, semibold};
|
||||
@@ -65,14 +65,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.ui.backstage_page = "home".into();
|
||||
}
|
||||
let mac = app.integrated_titlebar;
|
||||
crate::rtl::start_panel("backstage_nav")
|
||||
egui::Panel::left("backstage_nav")
|
||||
.exact_size(200.0)
|
||||
// Fixed width, as in Word: without this egui shows a resize handle that does nothing (#502).
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.nav).inner_margin(egui::Margin { left: 0, right: 0, top: if mac { 0 } else { 12 }, bottom: 12 }))
|
||||
.show(ui, |ui| {
|
||||
// Right to left the navigation is on the right, away from the macOS traffic lights.
|
||||
let rtl = crate::rtl::on();
|
||||
let back = if mac {
|
||||
// macOS (#222): the Backstage covers the title bar, so its top band is the title
|
||||
// bar: the back button sits beside the traffic lights, centred with them and as far
|
||||
@@ -82,34 +80,18 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
// The chevron's stroke starts at the content edge.
|
||||
let c = if rtl {
|
||||
pos2(band.max.x - 26.0, band.center().y)
|
||||
} else {
|
||||
back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left)
|
||||
};
|
||||
let c = back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left);
|
||||
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.scope_id().with("backstage_back"), Sense::click());
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(c, vec2(18.0, 18.0)),
|
||||
if rtl { "chevronRight" } else { "chevronLeft" },
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
resp
|
||||
} else {
|
||||
// The hit area is scoped to the chevron, not the whole 200px-wide row (#3041):
|
||||
// the row reads as plain sidebar background next to it, so clicking there must
|
||||
// not also close the File page, same as the macOS chevron fix (#2972) below.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::hover());
|
||||
let c = pos2(crate::rtl::lead_x(rtl, r, 26.0), r.center().y);
|
||||
let c = pos2(r.min.x + 26.0, r.center().y);
|
||||
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.scope_id().with("backstage_back"), Sense::click());
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(c, vec2(18.0, 18.0)),
|
||||
if rtl { "chevronRight" } else { "chevronLeft" },
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
resp
|
||||
};
|
||||
// Escape in the built-in file browser closes only the browser.
|
||||
@@ -130,16 +112,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
let ink = if on { egui::Color32::WHITE } else { egui::Color32::from_white_alpha(110) };
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 31.0), r.center().y), vec2(18.0, 18.0)),
|
||||
icon,
|
||||
ink,
|
||||
ink,
|
||||
);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 31.0, r.center().y), vec2(18.0, 18.0)), icon, ink, ink);
|
||||
ui.painter().text(
|
||||
pos2(crate::rtl::lead_x(rtl, r, 52.0), r.center().y),
|
||||
crate::rtl::lead_center(rtl),
|
||||
pos2(r.min.x + 52.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
tl!(label),
|
||||
if active { semibold(14.0) } else { medium(14.0) },
|
||||
ink,
|
||||
@@ -161,8 +137,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
let at = pos2(crate::rtl::lead_x(rtl, r, 22.0), r.center().y);
|
||||
ui.painter().text(at, crate::rtl::lead_center(rtl), tl!(label), regular(13.0), egui::Color32::WHITE);
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, tl!(label), regular(13.0), egui::Color32::WHITE);
|
||||
if resp.clicked() {
|
||||
if id == "discord" {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
@@ -176,21 +151,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| {
|
||||
// The Print page fills the pane and scrolls its settings and its preview itself (#1156).
|
||||
if app.ui.backstage_page == "print" {
|
||||
crate::rtl::root(ui, |ui| export_page(app, ui));
|
||||
export_page(app, ui);
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"home" => home_page(app, ui),
|
||||
"new" => new_page(app, ui),
|
||||
"open" => open_page(app, ui),
|
||||
"saveAs" => save_as_page(app, ui),
|
||||
"info" => info_page(app, ui),
|
||||
"recover" => recovery_page(app, ui),
|
||||
"print" | "export" => export_page(app, ui),
|
||||
"options" => crate::options::page(app, ui),
|
||||
_ => home_page(app, ui),
|
||||
})
|
||||
egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"home" => home_page(app, ui),
|
||||
"new" => new_page(app, ui),
|
||||
"open" => open_page(app, ui),
|
||||
"saveAs" => save_as_page(app, ui),
|
||||
"info" => info_page(app, ui),
|
||||
"recover" => recovery_page(app, ui),
|
||||
"print" | "export" => export_page(app, ui),
|
||||
"options" => crate::options::page(app, ui),
|
||||
_ => home_page(app, ui),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -214,7 +187,7 @@ fn recovery_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.label(tl!("No recovery copies."));
|
||||
}
|
||||
let busy = app.session.recovery_busy();
|
||||
crate::rtl::grid(ui, egui::Grid::new("recovery_documents").min_col_width(180.0).spacing(vec2(24.0, 16.0)), |ui| {
|
||||
egui::Grid::new("recovery_documents").min_col_width(180.0).spacing(vec2(24.0, 16.0)).show(ui, |ui| {
|
||||
if !documents.is_empty() {
|
||||
ui.strong(tl!("Document"));
|
||||
ui.strong(tl!("Recovery copy"));
|
||||
@@ -296,7 +269,7 @@ fn heading(ui: &mut Ui, s: &str) {
|
||||
/// region-aware page preview, and creates a document from the template on click.
|
||||
fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
// The thumbnail and its caption are one button named by the caption, so a screen reader
|
||||
// announces "Blank document, button" rather than unnamed text (#3176); the caption opens
|
||||
// the template too, as in Word.
|
||||
@@ -540,7 +513,7 @@ fn matching_personal(query: &str, list: &[PersonalTemplate]) -> Vec<PersonalTemp
|
||||
/// click makes a new document from it.
|
||||
fn personal_tile(ui: &mut Ui, app: &mut WordApp, p: &PersonalTemplate) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.set_max_width(150.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click());
|
||||
@@ -595,27 +568,15 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
|
||||
let names = shown.iter().map(|p| file_name(p)).collect::<Vec<_>>();
|
||||
for (p, name) in shown.iter().zip(&names) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click());
|
||||
// The row shows a name and a path that may not fit: the path in full is its tooltip.
|
||||
let resp = resp.on_hover_text(crate::i18n::isolate_path(p));
|
||||
// Two documents with the same file name are told apart by their path, as on screen.
|
||||
let spoken = if names.iter().filter(|n| *n == name).count() > 1 { format!("{name}, {p}") } else { name.clone() };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &spoken));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
// Names and paths are set apart (a right-to-left line would scramble their marks and
|
||||
// folders) and the row starts at the side reading starts from.
|
||||
let rtl = crate::rtl::on();
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 18.0), r.center().y), vec2(22.0, 22.0)),
|
||||
"printLayout",
|
||||
t.icon,
|
||||
t.accent,
|
||||
);
|
||||
let side = crate::rtl::lead_center(rtl);
|
||||
ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 13.0), side, crate::i18n::isolate(name), medium(13.0), t.text);
|
||||
ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 29.0), side, crate::i18n::isolate_path(p), regular(11.0), t.text_dim);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, name, medium(13.0), t.text);
|
||||
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, p, regular(11.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let failed = open_recent(app, p).err();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default()));
|
||||
@@ -703,108 +664,73 @@ fn save_as_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
fn info_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
heading(ui, "Info");
|
||||
let info = app.session.run("file.info", &json!({})).unwrap_or_default();
|
||||
// Properties at the reading start, so at the right in a mirrored interface, Statistics after.
|
||||
let (first, second) = if crate::rtl::on() { (1, 0) } else { (0, 1) };
|
||||
ui.columns(2, |cols| {
|
||||
crate::rtl::root(&mut cols[first], |ui| info_properties(app, ui, &info));
|
||||
crate::rtl::root(&mut cols[second], |ui| info_statistics(ui, &info));
|
||||
});
|
||||
}
|
||||
|
||||
fn info_properties(app: &mut WordApp, ui: &mut Ui, info: &serde_json::Value) {
|
||||
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
|
||||
// The fields edit a copy of the document's properties that is read afresh every frame,
|
||||
// so each change goes into the document as it is typed (#260): waiting for the field to
|
||||
// lose focus dropped the keystrokes, and leaving File with Escape never loses it at all.
|
||||
// The keystrokes of one visit to a field are a single undo step.
|
||||
let mut props = app.session.doc.core.clone();
|
||||
let mut edited = None;
|
||||
let mut focused = None;
|
||||
crate::rtl::Table::new("props").columns(2).spacing(vec2(12.0, 6.0)).show(ui, |t| {
|
||||
for (l, v) in [
|
||||
("Title", &mut props.title),
|
||||
("Subject", &mut props.subject),
|
||||
("Author", &mut props.creator),
|
||||
("Keywords", &mut props.keywords),
|
||||
("Category", &mut props.category),
|
||||
] {
|
||||
t.row(|r| {
|
||||
let label = r.cell(|ui| ui.label(tl!(l)));
|
||||
let field = r.cell(|ui| ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id));
|
||||
if field.changed() {
|
||||
let ui = &mut cols[0];
|
||||
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
|
||||
// The fields edit a copy of the document's properties that is read afresh every frame,
|
||||
// so each change goes into the document as it is typed (#260): waiting for the field to
|
||||
// lose focus dropped the keystrokes, and leaving File with Escape never loses it at all.
|
||||
// The keystrokes of one visit to a field are a single undo step.
|
||||
let mut props = app.session.doc.core.clone();
|
||||
let mut edited = None;
|
||||
let mut focused = None;
|
||||
egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] {
|
||||
let label = ui.label(tl!(l));
|
||||
let r = ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id);
|
||||
if r.changed() {
|
||||
edited = Some(l);
|
||||
}
|
||||
if field.has_focus() {
|
||||
if r.has_focus() {
|
||||
focused = Some(l);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if let Some(field) = edited {
|
||||
if app.info_editing == Some((field, app.session.rev())) {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok();
|
||||
app.info_editing = ok.then(|| (field, app.session.rev()));
|
||||
}
|
||||
});
|
||||
if let Some(field) = edited {
|
||||
if app.info_editing == Some((field, app.session.rev())) {
|
||||
app.session.join_next_undo();
|
||||
if focused != app.info_editing.map(|(f, _)| f) {
|
||||
app.info_editing = None;
|
||||
}
|
||||
let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok();
|
||||
app.info_editing = ok.then(|| (field, app.session.rev()));
|
||||
}
|
||||
if focused != app.info_editing.map(|(f, _)| f) {
|
||||
app.info_editing = None;
|
||||
}
|
||||
// Advanced Properties › Custom (#2982): view and edit the document's custom properties.
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5)))
|
||||
.on_hover_text(tl!("View and edit the document's custom properties."))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "customProperties"}));
|
||||
}
|
||||
// Protect Document › Encrypt with Password (#55).
|
||||
ui.add_space(18.0);
|
||||
ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0)));
|
||||
let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
ui.label(if encrypted {
|
||||
tl!("A password is required to open this document.")
|
||||
} else {
|
||||
tl!("Anyone can open this document. Encrypt it with a password to keep it private.")
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") };
|
||||
if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({}));
|
||||
// Advanced Properties › Custom (#2982): view and edit the document's custom properties.
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5)))
|
||||
.on_hover_text(tl!("View and edit the document's custom properties."))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "customProperties"}));
|
||||
}
|
||||
if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({"password": null}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn info_statistics(ui: &mut Ui, info: &serde_json::Value) {
|
||||
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] {
|
||||
ui.label(format!("{}: {}", tl!(l), info.get(k).map(|v| v.to_string()).unwrap_or_default()));
|
||||
}
|
||||
let path = info.get("path").and_then(|v| v.as_str()).unwrap_or(tl!("Not saved yet"));
|
||||
// A path reads left to right whatever its folders are called, so it is a label of its own
|
||||
// beside the words around it (one right-to-left line would swap its folders), and a long
|
||||
// one is cut short, the whole path on hover, rather than wrapped to strand its last part.
|
||||
let shown = crate::i18n::isolate_path(path);
|
||||
let template = tl!("Location: {path}");
|
||||
let (before, after) = template.split_once("{path}").unwrap_or((template, ""));
|
||||
// The columns start left to right, so the row's own direction is set: the words before the
|
||||
// path come first at the right edge, the path after them.
|
||||
let row = if crate::rtl::on() { egui::Layout::right_to_left(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Min) };
|
||||
ui.scope_builder(egui::UiBuilder::new().layout(row), |ui| {
|
||||
if !before.trim().is_empty() {
|
||||
ui.label(before.trim_end());
|
||||
}
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let room = ui.available_width() - if after.trim().is_empty() { 0.0 } else { 40.0 };
|
||||
let fitted = crate::theme::fit_text(ui.painter(), &shown, &font, 0.0, ui.visuals().text_color(), room);
|
||||
ui.label(fitted).on_hover_text(&shown);
|
||||
if !after.trim().is_empty() {
|
||||
ui.label(after.trim_start());
|
||||
// Protect Document › Encrypt with Password (#55).
|
||||
ui.add_space(18.0);
|
||||
ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0)));
|
||||
let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
ui.label(if encrypted {
|
||||
tl!("A password is required to open this document.")
|
||||
} else {
|
||||
tl!("Anyone can open this document. Encrypt it with a password to keep it private.")
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") };
|
||||
if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({}));
|
||||
}
|
||||
if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({"password": null}));
|
||||
}
|
||||
});
|
||||
let ui = &mut cols[1];
|
||||
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] {
|
||||
ui.label(format!("{}: {}", tl!(l), info.get(k).map(|v| v.to_string()).unwrap_or_default()));
|
||||
}
|
||||
let path = info.get("path").and_then(|v| v.as_str()).unwrap_or(tl!("Not saved yet"));
|
||||
ui.label(crate::i18n::fmt(tl!("Location: {path}"), &[("path", path)]));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ use crate::theme::{Tokens, regular, semibold};
|
||||
|
||||
mod balloons;
|
||||
mod context_menu;
|
||||
#[cfg(test)]
|
||||
pub(crate) use context_menu::issue_heading;
|
||||
mod cursor;
|
||||
mod geometry;
|
||||
mod input;
|
||||
@@ -1201,7 +1199,7 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA
|
||||
if open {
|
||||
app.escape_free = false;
|
||||
}
|
||||
let menu = crate::rtl::menu(&resp).open_bool(&mut shown);
|
||||
let menu = egui::Popup::menu(&resp).open_bool(&mut shown);
|
||||
let menu = menu.at_position(app.canvas.context_menu_at.unwrap_or(out.inner_rect.left_top()));
|
||||
let with_toolbar = crate::mini_toolbar::context_menu_visible(app);
|
||||
let menu = if with_toolbar { menu.frame(egui::Frame::NONE).close_behavior(egui::PopupCloseBehavior::IgnoreClicks) } else { menu };
|
||||
|
||||
@@ -103,8 +103,8 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter,
|
||||
let top = (anchor.y + if over_page { 4.0 } else { -12.0 }).max(next_y);
|
||||
let active = app.canvas.balloon == Some(*id);
|
||||
// Lay out a read-only balloon's text: the comment, then each reply under a rule,
|
||||
// each with who wrote it and when. (galley, gap above, a rule above, height shown)
|
||||
let mut lines: Vec<(craft_ui::text::AlignedGalley, f32, bool, f32)> = Vec::new();
|
||||
// each with who wrote it and when. (galley, gap above, a rule above)
|
||||
let mut lines: Vec<(craft_ui::text::AlignedGalley, f32, bool)> = Vec::new();
|
||||
let card = if active {
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0)))
|
||||
} else {
|
||||
@@ -113,21 +113,15 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter,
|
||||
for (i, cid) in std::iter::once(*id).chain(thread.iter().copied()).enumerate() {
|
||||
let Some(r) = app.session.doc.comments.get(&cid) else { continue };
|
||||
let gap = if i == 0 { 0.0 } else { 9.0 };
|
||||
let g = balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0);
|
||||
let h = g.height();
|
||||
lines.push((g, gap, i > 0, h));
|
||||
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), gap, i > 0));
|
||||
let when = crate::local_time::comment_time(r);
|
||||
if !when.is_empty() {
|
||||
let g = balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0);
|
||||
let h = g.height();
|
||||
lines.push((g, 1.0, false, h));
|
||||
lines.push((balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0), 1.0, false));
|
||||
}
|
||||
let text = app.session.doc.plain_text(StoryRef::Part(r.part));
|
||||
let g = balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0);
|
||||
let h = shown_height(&g);
|
||||
lines.push((g, 4.0, false, h));
|
||||
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0, false));
|
||||
}
|
||||
let h = lines.iter().map(|(_, gap, _, h)| h + gap).sum::<f32>() + 12.0;
|
||||
let h = lines.iter().map(|(g, gap, _)| g.height() + gap).sum::<f32>() + 12.0;
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, h))
|
||||
};
|
||||
if !card.intersects(clip) {
|
||||
@@ -151,19 +145,14 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter,
|
||||
card_shapes(painter, &t, card, color, selected, None);
|
||||
let text_painter = painter.with_clip_rect(card.intersect(clip));
|
||||
let mut y = card.min.y + 6.0;
|
||||
for (g, gap, rule, h) in lines {
|
||||
for (g, gap, rule) in lines {
|
||||
y += gap;
|
||||
if rule {
|
||||
// A reply starts: a hairline between it and what it answers.
|
||||
painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border));
|
||||
}
|
||||
// A body cut off at its shown rows paints none of the rows below them.
|
||||
let shown = if h < g.height() {
|
||||
text_painter.with_clip_rect(text_painter.clip_rect().intersect(Rect::from_x_y_ranges(card.x_range(), y..=y + h)))
|
||||
} else {
|
||||
text_painter.clone()
|
||||
};
|
||||
g.paint(&shown, pos2(card.min.x + 10.0, y), t.text);
|
||||
let h = g.height();
|
||||
g.paint(&text_painter, pos2(card.min.x + 10.0, y), t.text);
|
||||
y += h;
|
||||
}
|
||||
let r = ui.interact(card, ui.scope_id().with(("balloon", *id)), Sense::click());
|
||||
@@ -274,15 +263,6 @@ fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The height of a balloon body's first [`COMMENT_ROWS`](crate::panes::COMMENT_ROWS) rows; the
|
||||
/// rest is cut off until the balloon is selected and its field scrolls.
|
||||
fn shown_height(g: &craft_ui::text::AlignedGalley) -> f32 {
|
||||
match g {
|
||||
craft_ui::text::AlignedGalley::Aligned { galley, .. } => galley.rows.get(crate::panes::COMMENT_ROWS).map_or(g.height(), |r| r.pos.y),
|
||||
craft_ui::text::AlignedGalley::InvalidWidth { .. } => g.height(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only balloon's text, wrapped to `width`. The fork's layout orders right-to-left and
|
||||
/// mixed-direction paragraphs itself; `TextDirection::Auto` takes each paragraph's first strong
|
||||
/// character. Place each already-shaped row against the card's start edge, including neutral
|
||||
|
||||
@@ -48,17 +48,6 @@ fn equation_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading of a proofing issue's context menu, in the interface language like the rest of
|
||||
/// the menu: a spelling issue's own message is in the text's language (craft-proof's, "Faute
|
||||
/// d'orthographe possible" for French text), so it says Possible spelling mistake instead.
|
||||
/// English grammar rules' messages are catalog keys; other languages' rules explain in their own.
|
||||
pub(crate) fn issue_heading(issue: &serde_json::Value) -> String {
|
||||
if issue.get("kind").and_then(|k| k.as_str()) == Some("spelling") {
|
||||
return tl!("Possible spelling mistake").to_string();
|
||||
}
|
||||
tl!(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).to_string()
|
||||
}
|
||||
|
||||
pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
if app.session.math.is_some() {
|
||||
return equation_menu(app, ui);
|
||||
@@ -67,7 +56,7 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
let item = |ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: serde_json::Value| {
|
||||
let sc = crate::widgets::shortcut_text(app, id);
|
||||
let on = crate::widgets::enabled(app, id);
|
||||
if ui.add_enabled(on, crate::rtl::menu_item(ui, tl!(label), &sc, 0.0)).clicked() {
|
||||
if ui.add_enabled(on, egui::Button::new(tl!(label)).shortcut_text(sc)).clicked() {
|
||||
let _ = app.run(id, params);
|
||||
ui.close();
|
||||
}
|
||||
@@ -78,7 +67,8 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
ui.label(egui::RichText::new(issue_heading(&issue)).small().weak());
|
||||
// English rules' messages are catalog keys; Polish rules already explain in Polish.
|
||||
ui.label(egui::RichText::new(tl!(issue.get("message").and_then(|m| m.as_str()).unwrap_or(""))).small().weak());
|
||||
if sugg.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
|
||||
}
|
||||
@@ -105,19 +95,17 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.separator();
|
||||
if let Some(syn) = app.canvas.context_synonyms.clone().and_then(|v| v.as_array().cloned()).filter(|a| !a.is_empty()) {
|
||||
ui.menu_button(tl!("Synonyms"), |ui| {
|
||||
crate::rtl::root(ui, |ui| {
|
||||
for w in syn.iter().filter_map(|x| x.as_str()) {
|
||||
if ui.button(w).clicked() {
|
||||
let _ = app.run("select.word", json!({}));
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0);
|
||||
let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b };
|
||||
app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end };
|
||||
let _ = app.run("text.insert", json!({"text": w, "raw": true}));
|
||||
ui.close();
|
||||
}
|
||||
for w in syn.iter().filter_map(|x| x.as_str()) {
|
||||
if ui.button(w).clicked() {
|
||||
let _ = app.run("select.word", json!({}));
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0);
|
||||
let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b };
|
||||
app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end };
|
||||
let _ = app.run("text.insert", json!({"text": w, "raw": true}));
|
||||
ui.close();
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
item(ui, app, "Font…", "ui.dialog", json!({"name": "font"}));
|
||||
@@ -166,12 +154,10 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
let pick = table_pick(&app.session);
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Insert"), |ui| {
|
||||
crate::rtl::root(ui, |ui| {
|
||||
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
|
||||
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
|
||||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||||
})
|
||||
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
|
||||
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
|
||||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||||
});
|
||||
// Every Delete is there whatever is selected, as on Word's Delete button: with the caret
|
||||
// in a cell, Delete Table is how the table goes (#1198).
|
||||
@@ -193,11 +179,9 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
if pick == TablePick::Table {
|
||||
ui.menu_button(tl!("AutoFit"), |ui| {
|
||||
crate::rtl::root(ui, |ui| {
|
||||
item(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"}));
|
||||
item(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
|
||||
item(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
|
||||
})
|
||||
item(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"}));
|
||||
item(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
|
||||
item(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
|
||||
});
|
||||
}
|
||||
item(ui, app, "Text Direction", "table.textDirection", json!({}));
|
||||
|
||||
@@ -4809,87 +4809,3 @@ fn the_view_key_changes_when_a_drag_takes_an_object_off_the_page() {
|
||||
assert_ne!(hidden, view_key(&app, false, None), "the page without the dragged object is rendered again");
|
||||
assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame");
|
||||
}
|
||||
|
||||
/// A two-paragraph document with a 200-line comment (`line` gives each line's text) on the first
|
||||
/// and a short one on the second, drawn without the Comments pane; returns both comment ids.
|
||||
fn long_comment_balloons(line: fn(usize) -> String) -> (egui_kittest::Harness<'static, WordApp>, u32, u32) {
|
||||
let mut a = WordApp::new(
|
||||
wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Alpha paragraph one.\n\nBeta paragraph two.")),
|
||||
Default::default(),
|
||||
);
|
||||
a.run("select.text", json!({"text": "Alpha"})).unwrap();
|
||||
let long = (1..=200).map(line).collect::<Vec<_>>().join("\n");
|
||||
let first = a.run("review.newComment", json!({"text": long})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
a.run("select.text", json!({"text": "Beta"})).unwrap();
|
||||
let second = a.run("review.newComment", json!({"text": "Short note."})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
(h, first, second)
|
||||
}
|
||||
|
||||
fn balloon_rect(h: &egui_kittest::Harness<'static, WordApp>, id: u32) -> Option<egui::Rect> {
|
||||
h.state().canvas.balloon_rects.iter().find(|(i, _)| *i == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
/// #3382: a long comment's balloon, once selected, stops at about ten lines of text: its field
|
||||
/// scrolls, so its buttons and the next comment's balloon stay in view.
|
||||
#[test]
|
||||
fn a_long_comment_selected_balloon_keeps_the_next_one_in_view() {
|
||||
let (mut h, first, second) = long_comment_balloons(|i| format!("Line {i}"));
|
||||
click_balloon(&mut h, first);
|
||||
let selected = balloon_rect(&h, first).expect("the selected balloon");
|
||||
assert!(selected.height() < 350.0, "the selected balloon's field scrolls instead of growing: {selected:?}");
|
||||
assert!(balloon_rect(&h, second).is_some_and(|r| r.max.y < 900.0), "the next balloon is in view");
|
||||
}
|
||||
|
||||
/// #3382: a long comment's balloon that isn't selected shows its first ten lines and cuts off the
|
||||
/// rest, in either reading direction, so the next comment's balloon stays in view.
|
||||
#[test]
|
||||
fn a_long_comment_balloon_shows_ten_lines_and_keeps_the_next_one_in_view() {
|
||||
for line in [(|i| format!("Line {i}")) as fn(usize) -> String, |i| format!("سطر {i}")] {
|
||||
let (h, first, second) = long_comment_balloons(line);
|
||||
let card = balloon_rect(&h, first).expect("the long balloon");
|
||||
assert!(card.height() < 350.0, "the balloon stops at ten lines of its comment: {card:?}");
|
||||
assert!(balloon_rect(&h, second).is_some_and(|r| r.max.y < 900.0), "the next balloon is in view");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3382: a long comment's balloon that isn't selected paints its first ten rows inside the
|
||||
/// card's clip and none of the rows after them, in either reading direction.
|
||||
#[test]
|
||||
fn a_long_comment_balloon_paints_only_its_first_ten_rows() {
|
||||
for line in [(|i| format!("Line {i}")) as fn(usize) -> String, |i| format!("سطر {i}")] {
|
||||
let (mut h, first, _) = long_comment_balloons(line);
|
||||
h.step();
|
||||
let body = (1..=200).map(line).collect::<Vec<_>>().join("\n");
|
||||
let card = balloon_rect(&h, first).expect("the long balloon");
|
||||
let (clip, rows) = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|clipped| match &clipped.shape {
|
||||
egui::Shape::Text(text) if text.galley.text() == body => {
|
||||
Some((clipped.clip_rect, text.galley.rows.iter().map(|r| r.rect().translate(text.pos.to_vec2())).collect::<Vec<_>>()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("the long comment's body is painted");
|
||||
assert!(card.contains_rect(clip), "the body is clipped to its card: {clip:?} in {card:?}");
|
||||
let (shown, hidden) = rows.split_at(crate::panes::COMMENT_ROWS);
|
||||
assert!(
|
||||
shown.iter().all(|r| r.min.y >= clip.min.y - 0.5 && r.max.y <= clip.max.y + 0.5),
|
||||
"the first ten rows are inside the clip: {shown:?}, clip {clip:?}"
|
||||
);
|
||||
assert!(hidden.iter().all(|r| r.min.y >= clip.max.y - 0.5), "the rows after the tenth are cut off: {:?}, clip {clip:?}", hidden.first());
|
||||
}
|
||||
}
|
||||
|
||||
+250
-293
@@ -12,7 +12,7 @@ pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::Pat
|
||||
match (unreadable, missing) {
|
||||
(Some((file, reason)), _) => Some(crate::i18n::fmt(
|
||||
tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"),
|
||||
&[("language", lang), ("file", &crate::i18n::isolate_path(&file.to_string_lossy())), ("reason", reason)],
|
||||
&[("language", lang), ("file", &file.to_string_lossy()), ("reason", reason)],
|
||||
)),
|
||||
(None, Some(name)) => Some(crate::i18n::fmt(
|
||||
tl!(
|
||||
@@ -93,9 +93,9 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), "more", t.icon, t.accent);
|
||||
let resp = resp.on_hover_text(tl!("Customize Quick Access Toolbar"));
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
crate::rtl::root(ui, |ui| qat_menu(ui, app));
|
||||
qat_menu(ui, app);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ fn undo_list_button(ui: &mut Ui, app: &mut WordApp, enabled: bool) {
|
||||
let c = if enabled { t.icon } else { t.text_disabled };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(9.0, 9.0)), "dropdown", c, c);
|
||||
let resp = resp.on_hover_text(tl!("Undo History"));
|
||||
crate::rtl::menu(&resp).show(|ui| undo_list(ui, app));
|
||||
egui::Popup::menu(&resp).show(|ui| undo_list(ui, app));
|
||||
}
|
||||
|
||||
/// The Undo list (#701): recent steps, newest first. Hovering a row highlights it and every newer
|
||||
@@ -275,12 +275,6 @@ impl MacTitleBar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the AutoSave switch's knob sits in its track `r`: on at the right and off at the left,
|
||||
/// mirrored in a right-to-left interface (on at the left).
|
||||
fn autosave_knob_x(rtl: bool, on: bool, r: Rect) -> f32 {
|
||||
crate::rtl::lead_x(rtl != on, r, 8.0)
|
||||
}
|
||||
|
||||
/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search
|
||||
/// controls, and the centred clickable document title.
|
||||
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
@@ -305,97 +299,83 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
// The free stretch between the two clusters, for the centred title. Right to left the
|
||||
// clusters swap sides: AutoSave and the Quick Access Toolbar start at the right.
|
||||
let rtl = crate::rtl::on();
|
||||
let mut qat_end = if rtl { full.max.x } else { full.min.x };
|
||||
let mut right_start = if rtl { full.min.x } else { full.max.x };
|
||||
crate::rtl::root(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't
|
||||
// cover the document (#196, #176); clicking it then offers Save As where that helps.
|
||||
let block = app.autosave_block();
|
||||
let on = app.autosaves();
|
||||
let color = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim };
|
||||
// The text is part of the switch (#3370): clicking it toggles AutoSave, and chrome
|
||||
// text is not document text to select (double-clicking it copied "AutoSave").
|
||||
let text = ui.add(
|
||||
egui::Label::new(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(color))
|
||||
.selectable(false)
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let (fill, stroke, knob_fill) = match (&block, on) {
|
||||
(Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)),
|
||||
(None, true) => (t.accent, t.accent, t.on_accent),
|
||||
(None, false) => (t.input, t.border_strong, t.text_dim),
|
||||
};
|
||||
ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
|
||||
let knob = autosave_knob_x(rtl, on, r);
|
||||
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill);
|
||||
let tip = block
|
||||
.as_ref()
|
||||
.map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason());
|
||||
let text = text.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let resp = resp.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if text.clicked() || resp.clicked() {
|
||||
match block {
|
||||
None => {
|
||||
let _ = app.run("file.autosave", json!({}));
|
||||
}
|
||||
// AutoSave comes on once Save As has saved (maybe on a later frame, #94).
|
||||
Some(b) if b.save_as_helps() => {
|
||||
let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave);
|
||||
}
|
||||
Some(b) => app.status(b.reason()),
|
||||
let mut qat_end = full.min.x;
|
||||
let mut right_start = full.max.x;
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't
|
||||
// cover the document (#196, #176); clicking it then offers Save As where that helps.
|
||||
let block = app.autosave_block();
|
||||
let on = app.autosaves();
|
||||
let color = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim };
|
||||
// The text is part of the switch (#3370): clicking it toggles AutoSave, and chrome
|
||||
// text is not document text to select (double-clicking it copied "AutoSave").
|
||||
let text = ui.add(
|
||||
egui::Label::new(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(color)).selectable(false).sense(Sense::click()),
|
||||
);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let (fill, stroke, knob_fill) = match (&block, on) {
|
||||
(Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)),
|
||||
(None, true) => (t.accent, t.accent, t.on_accent),
|
||||
(None, false) => (t.input, t.border_strong, t.text_dim),
|
||||
};
|
||||
ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
|
||||
let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 };
|
||||
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill);
|
||||
let tip = block
|
||||
.as_ref()
|
||||
.map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason());
|
||||
let text = text.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let resp = resp.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if text.clicked() || resp.clicked() {
|
||||
match block {
|
||||
None => {
|
||||
let _ = app.run("file.autosave", json!({}));
|
||||
}
|
||||
// AutoSave comes on once Save As has saved (maybe on a later frame, #94).
|
||||
Some(b) if b.save_as_helps() => {
|
||||
let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave);
|
||||
}
|
||||
Some(b) => app.status(b.reason()),
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Hidden (#1945): neither the buttons nor the … menu; the title centres as before.
|
||||
if app.ui.quick_access_toolbar {
|
||||
quick_access(ui, app);
|
||||
}
|
||||
qat_end = ui.min_rect().max.x;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Account / community.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account")));
|
||||
let initials: String = app.session.user_initials().chars().take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
|
||||
if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Hidden (#1945): neither the buttons nor the … menu; the title centres as before.
|
||||
if app.ui.quick_access_toolbar {
|
||||
quick_access(ui, app);
|
||||
}
|
||||
qat_end = if rtl { ui.min_rect().min.x } else { ui.min_rect().max.x };
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
// Account / community.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account")));
|
||||
let initials: String = app.session.user_initials().chars().take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
|
||||
if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"}));
|
||||
if app.ui.show_discord {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)),
|
||||
"discord",
|
||||
t.on_accent,
|
||||
t.on_accent,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, tl!("Discord"), medium(11.5), t.on_accent);
|
||||
if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if app.ui.show_discord {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 13.0), r.center().y), vec2(14.0, 14.0)),
|
||||
"discord",
|
||||
t.on_accent,
|
||||
t.on_accent,
|
||||
);
|
||||
ui.painter().text(
|
||||
pos2(crate::rtl::lead_x(rtl, r, 24.0), r.center().y),
|
||||
crate::rtl::lead_center(rtl),
|
||||
tl!("Discord"),
|
||||
medium(11.5),
|
||||
t.on_accent,
|
||||
);
|
||||
if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
search_box(app, ui, control_h);
|
||||
right_start = if rtl { ui.min_rect().max.x } else { ui.min_rect().min.x };
|
||||
});
|
||||
})
|
||||
}
|
||||
search_box(app, ui, control_h);
|
||||
right_start = ui.min_rect().min.x;
|
||||
});
|
||||
});
|
||||
let (qat_end, right_start) = if rtl { (right_start, qat_end) } else { (qat_end, right_start) };
|
||||
// Centred title.
|
||||
let title = if !app.has_document() {
|
||||
// No document open (#1129): the app's name alone.
|
||||
@@ -403,7 +383,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
} else {
|
||||
format!(
|
||||
"{} — {}",
|
||||
crate::i18n::isolate(&app.title_stem()),
|
||||
app.title_stem(),
|
||||
if app.session.dirty {
|
||||
tl!("Edited")
|
||||
} else if app.session.path.is_some() {
|
||||
@@ -450,24 +430,17 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
/// of Full Screen (#1678).
|
||||
fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rtl = crate::rtl::on();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
|
||||
let ph = tl!("Search commands and help");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph));
|
||||
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 16.0), r.center().y), vec2(16.0, 16.0)),
|
||||
"search",
|
||||
t.text_dim,
|
||||
t.accent,
|
||||
);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
|
||||
let pfont = TypeRung::Control.regular();
|
||||
let ptrack = TypeRung::Control.tracking();
|
||||
paint_text(
|
||||
ui.painter(),
|
||||
pos2(crate::rtl::lead_x(rtl, r, 30.0), r.center().y - TypeRung::Control.line_height() / 2.0),
|
||||
crate::rtl::lead_top(rtl),
|
||||
pos2(r.min.x + 30.0, r.center().y - TypeRung::Control.line_height() / 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
ph,
|
||||
&pfont,
|
||||
ptrack,
|
||||
@@ -582,203 +555,187 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
if !app.has_document() {
|
||||
return;
|
||||
}
|
||||
let rtl = crate::rtl::on();
|
||||
crate::rtl::root(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
let page = if let Some(p) = app.canvas.viewed_page {
|
||||
// The page in view (#3880), as the scrollbar's tip names it.
|
||||
p.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else if app.session.selects_whole_story() {
|
||||
app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else {
|
||||
l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1)
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
let page = if let Some(p) = app.canvas.viewed_page {
|
||||
// The page in view (#3880), as the scrollbar's tip names it.
|
||||
p.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else if app.session.selects_whole_story() {
|
||||
app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else {
|
||||
l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1)
|
||||
};
|
||||
let st = |ui: &mut Ui, s: &str| {
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s));
|
||||
resp
|
||||
};
|
||||
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
|
||||
.on_hover_text(tl!("Go To (⌘⌥G)"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
// A selected picture/shape/text box isn't a text selection: show the total.
|
||||
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
|
||||
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
let label = match crate::canvas::part_kind(app, app.session.sel.focus.story) {
|
||||
Some(PartKind::Footnote) => tl!("Editing footnote"),
|
||||
Some(PartKind::Endnote) => tl!("Editing endnote"),
|
||||
_ => tl!("Editing header/footer"),
|
||||
};
|
||||
let st = |ui: &mut Ui, s: &str| {
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s));
|
||||
resp
|
||||
};
|
||||
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
|
||||
.on_hover_text(tl!("Go To (⌘⌥G)"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
st(ui, label);
|
||||
}
|
||||
// The language at the caret; a click sets the proofing language (Review › Language).
|
||||
let lang = crate::ribbon::caret_language(app);
|
||||
let missing = crate::ribbon::caret_missing_dictionary(app);
|
||||
let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
|
||||
let shown = if unreadable.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)])
|
||||
} else if missing.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)])
|
||||
} else {
|
||||
lang.clone()
|
||||
};
|
||||
let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
|
||||
let resp = st(ui, &shown).on_hover_text(tip);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
crate::ribbon::language_menu(ui, app);
|
||||
});
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, tl!("Track Changes: On"));
|
||||
}
|
||||
if app.session.prefs.overtype && st(ui, tl!("Overtype")).on_hover_text(tl!("Click to switch back to inserting")).clicked() {
|
||||
let _ = app.run("text.overtype", json!({"value": false}));
|
||||
}
|
||||
// The message is drawn where the row is now; the controls on the right are laid
|
||||
// out first, so the message can be shortened to the space they leave (#2402): a
|
||||
// long one (the path of a file that isn't there) must not be drawn over the
|
||||
// Focus and view buttons.
|
||||
let msg_at = ui.cursor().min;
|
||||
let mut controls_left = f32::INFINITY;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
let reading = app.session.view.read_mode;
|
||||
let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round();
|
||||
let ztxt = format!("{z}%");
|
||||
let zfont = TypeRung::Caption.regular();
|
||||
let ztrack = TypeRung::Caption.tracking();
|
||||
let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim);
|
||||
// A fixed width for three digits plus a small reserve, so the controls to the
|
||||
// left don't shift as the percentage gains or loses digits (#1975).
|
||||
let zslot = text_width(ui.painter(), "000%", &zfont, ztrack, t.text_dim) + 4.0;
|
||||
let (zr, zresp) = ui.allocate_exact_size(vec2(zw.max(zslot) + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(zr.max.x - 1.0, zr.min.y), Align2::RIGHT_TOP, &ztxt, &zfont, ztrack, t.text_dim);
|
||||
if zresp.clicked() && !reading {
|
||||
let _ = app.run("ui.dialog", json!({ "name": "zoom" }));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
// A selected picture/shape/text box isn't a text selection: show the total.
|
||||
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
|
||||
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
if small_icon(ui, "plus", tl!("Zoom In")) {
|
||||
if reading {
|
||||
let _ = app.run("view.readTextSize", json!({"value": (z + 10.0).min(150.0)}));
|
||||
} else {
|
||||
let _ = app.run("view.zoomIn", json!({}));
|
||||
}
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
let label = match crate::canvas::part_kind(app, app.session.sel.focus.story) {
|
||||
Some(PartKind::Footnote) => tl!("Editing footnote"),
|
||||
Some(PartKind::Endnote) => tl!("Editing endnote"),
|
||||
_ => tl!("Editing header/footer"),
|
||||
};
|
||||
st(ui, label);
|
||||
}
|
||||
// The language at the caret; a click sets the proofing language (Review › Language).
|
||||
let lang = crate::ribbon::caret_language(app);
|
||||
let missing = crate::ribbon::caret_missing_dictionary(app);
|
||||
let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
|
||||
let shown = if unreadable.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)])
|
||||
} else if missing.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)])
|
||||
} else {
|
||||
lang.clone()
|
||||
};
|
||||
let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
|
||||
let resp = st(ui, &shown).on_hover_text(tip);
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
crate::rtl::root(ui, |ui| crate::ribbon::language_menu(ui, app));
|
||||
// Reading zoom changes only reading presentation, never the normal view zoom.
|
||||
let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT };
|
||||
let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false);
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
let response = ui.add(slider);
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") })
|
||||
});
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, tl!("Track Changes: On"));
|
||||
if response.changed() {
|
||||
let command = if reading { "view.readTextSize" } else { "view.zoom" };
|
||||
let _ = app.run(command, json!({"value": (v * 100.0).round()}));
|
||||
}
|
||||
if app.session.prefs.overtype && st(ui, tl!("Overtype")).on_hover_text(tl!("Click to switch back to inserting")).clicked() {
|
||||
let _ = app.run("text.overtype", json!({"value": false}));
|
||||
if small_icon(ui, "minus", tl!("Zoom Out")) {
|
||||
if reading {
|
||||
let _ = app.run("view.readTextSize", json!({"value": (z - 10.0).max(75.0)}));
|
||||
} else {
|
||||
let _ = app.run("view.zoomOut", json!({}));
|
||||
}
|
||||
}
|
||||
// The message is drawn where the row is now; the controls on the right are laid
|
||||
// out first, so the message can be shortened to the space they leave (#2402): a
|
||||
// long one (the path of a file that isn't there) must not be drawn over the
|
||||
// Focus and view buttons.
|
||||
// Right to left the row runs the other way: the message starts at the right edge of
|
||||
// what is left, and the controls (a cluster at the left) end at their right edge.
|
||||
let msg_at = ui.cursor().min;
|
||||
let msg_edge = ui.available_rect_before_wrap().max.x;
|
||||
let mut controls_left = if rtl { f32::NEG_INFINITY } else { f32::INFINITY };
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
let reading = app.session.view.read_mode;
|
||||
let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round();
|
||||
let ztxt = format!("{z}%");
|
||||
let zfont = TypeRung::Caption.regular();
|
||||
let ztrack = TypeRung::Caption.tracking();
|
||||
let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim);
|
||||
// A fixed width for three digits plus a small reserve, so the controls to the
|
||||
// left don't shift as the percentage gains or loses digits (#1975).
|
||||
let zslot = text_width(ui.painter(), "000%", &zfont, ztrack, t.text_dim) + 4.0;
|
||||
let (zr, zresp) = ui.allocate_exact_size(vec2(zw.max(zslot) + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(zr.max.x - 1.0, zr.min.y), Align2::RIGHT_TOP, &ztxt, &zfont, ztrack, t.text_dim);
|
||||
if zresp.clicked() && !reading {
|
||||
let _ = app.run("ui.dialog", json!({ "name": "zoom" }));
|
||||
}
|
||||
// The slider grows to the right in every language, so the buttons stay at its
|
||||
// ends: plus on the right. This cluster fills from the far end inwards.
|
||||
let zoom_step = |app: &mut WordApp, plus: bool| {
|
||||
if reading {
|
||||
let value = if plus { (z + 10.0).min(150.0) } else { (z - 10.0).max(75.0) };
|
||||
let _ = app.run("view.readTextSize", json!({"value": value}));
|
||||
} else {
|
||||
let _ = app.run(if plus { "view.zoomIn" } else { "view.zoomOut" }, json!({}));
|
||||
}
|
||||
};
|
||||
let (inner, outer) = (("minus", "Zoom Out", false), ("plus", "Zoom In", true));
|
||||
let (near, far) = if rtl { (inner, outer) } else { (outer, inner) };
|
||||
if small_icon(ui, near.0, tl!(near.1)) {
|
||||
zoom_step(app, near.2);
|
||||
}
|
||||
// Reading zoom changes only reading presentation, never the normal view zoom.
|
||||
let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT };
|
||||
let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false);
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
let response = ui.add(slider);
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") })
|
||||
});
|
||||
if response.changed() {
|
||||
let command = if reading { "view.readTextSize" } else { "view.zoom" };
|
||||
let _ = app.run(command, json!({"value": (v * 100.0).round()}));
|
||||
}
|
||||
if small_icon(ui, far.0, tl!(far.1)) {
|
||||
zoom_step(app, far.2);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, name, on) in [
|
||||
("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", tl!("Read Mode"), read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 3.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name));
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let focus = tl!("Focus");
|
||||
let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click());
|
||||
// The Focus button is the last of the controls, so it is the leftmost one: the
|
||||
// message has to stop before it (#2402).
|
||||
controls_left = if rtl { r.max.x } else { r.min.x };
|
||||
if resp.hovered() {
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, name, on) in [
|
||||
("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", tl!("Read Mode"), read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 3.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let icon_at = pos2(crate::rtl::lead_left(rtl, r, 2.0, 16.0), r.min.y + 2.0);
|
||||
icons::paint(ui.painter(), Rect::from_min_size(icon_at, vec2(16.0, 16.0)), "focus", t.icon, t.accent);
|
||||
let ffont = TypeRung::Caption.regular();
|
||||
let ftrack = TypeRung::Caption.tracking();
|
||||
paint_text(
|
||||
ui.painter(),
|
||||
pos2(crate::rtl::lead_x(rtl, r, 22.0), r.center().y - TypeRung::Caption.line_height() / 2.0),
|
||||
crate::rtl::lead_top(rtl),
|
||||
focus,
|
||||
&ffont,
|
||||
ftrack,
|
||||
t.text_dim,
|
||||
);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.focus", json!({}));
|
||||
}
|
||||
});
|
||||
if let Some((msg, at)) = &app.status_msg
|
||||
&& crate::now_ms() - at < 6000.0
|
||||
{
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
// The room the message has: what is left between here and the controls.
|
||||
let room = if rtl {
|
||||
msg_edge - controls_left - ui.spacing().item_spacing.x
|
||||
} else {
|
||||
controls_left - ui.spacing().item_spacing.x - msg_at.x
|
||||
};
|
||||
let msg_shown = fit_text(ui.painter(), msg, &font, track, t.accent_text, room);
|
||||
if !msg_shown.is_empty() {
|
||||
let w = text_width(ui.painter(), &msg_shown, &font, track, t.accent_text);
|
||||
let at = if rtl { pos2(msg_edge - w - 1.0, msg_at.y) } else { msg_at };
|
||||
let rect = Rect::from_min_size(at, vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
// The whole message is in the tooltip; the bar only has room for the
|
||||
// beginning of it (#2402).
|
||||
let _ = ui.interact(rect, status_message_id(), Sense::hover()).on_hover_text(msg.clone());
|
||||
paint_text(ui.painter(), rect.min, Align2::LEFT_TOP, &msg_shown, &font, track, t.accent_text);
|
||||
ui.allocate_space(vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name));
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
}
|
||||
})
|
||||
ui.add_space(8.0);
|
||||
let focus = tl!("Focus");
|
||||
let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click());
|
||||
// The Focus button is the last of the controls, so it is the leftmost one: the
|
||||
// message has to stop before it (#2402).
|
||||
controls_left = r.min.x;
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent);
|
||||
let ffont = TypeRung::Caption.regular();
|
||||
let ftrack = TypeRung::Caption.tracking();
|
||||
paint_text(
|
||||
ui.painter(),
|
||||
pos2(r.min.x + 22.0, r.center().y - TypeRung::Caption.line_height() / 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
focus,
|
||||
&ffont,
|
||||
ftrack,
|
||||
t.text_dim,
|
||||
);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.focus", json!({}));
|
||||
}
|
||||
});
|
||||
if let Some((msg, at)) = &app.status_msg
|
||||
&& crate::now_ms() - at < 6000.0
|
||||
{
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
// The room the message has: what is left between here and the controls.
|
||||
let room = controls_left - ui.spacing().item_spacing.x - msg_at.x;
|
||||
let msg_shown = fit_text(ui.painter(), msg, &font, track, t.accent_text, room);
|
||||
if !msg_shown.is_empty() {
|
||||
let w = text_width(ui.painter(), &msg_shown, &font, track, t.accent_text);
|
||||
let rect = Rect::from_min_size(msg_at, vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
// The whole message is in the tooltip; the bar only has room for the
|
||||
// beginning of it (#2402).
|
||||
let _ = ui.interact(rect, status_message_id(), Sense::hover()).on_hover_text(msg.clone());
|
||||
paint_text(ui.painter(), rect.min, Align2::LEFT_TOP, &msg_shown, &font, track, t.accent_text);
|
||||
ui.allocate_space(vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,14 +307,3 @@ fn clicking_the_autosave_label_toggles_autosave() {
|
||||
assert!(h.state().autosaves(), "and clicking it again turns it back on");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The AutoSave knob is at the right when the switch is on and at the left when it is off; a
|
||||
/// right-to-left interface mirrors that.
|
||||
#[test]
|
||||
fn the_autosave_knob_sits_right_when_on_and_mirrors_right_to_left() {
|
||||
let track = Rect::from_min_max(egui::pos2(100.0, 0.0), egui::pos2(130.0, 16.0));
|
||||
assert_eq!(autosave_knob_x(false, true, track), 122.0, "left to right, on: right");
|
||||
assert_eq!(autosave_knob_x(false, false, track), 108.0, "left to right, off: left");
|
||||
assert_eq!(autosave_knob_x(true, true, track), 108.0, "right to left, on: left");
|
||||
assert_eq!(autosave_knob_x(true, false, track), 122.0, "right to left, off: right");
|
||||
}
|
||||
|
||||
@@ -497,14 +497,14 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(330.0);
|
||||
match d.tab {
|
||||
ColorsTab::Standard => standard(ui, d),
|
||||
ColorsTab::Custom => custom(ui, d),
|
||||
}
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(110.0);
|
||||
preview(ui, d, &t);
|
||||
});
|
||||
@@ -512,7 +512,7 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
|
||||
let mut out = None;
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!("OK"))), tl!("Cancel"));
|
||||
if cancelb.clicked() {
|
||||
out = Some(false);
|
||||
@@ -670,7 +670,7 @@ fn custom(ui: &mut Ui, d: &mut ColorsDialog) {
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)), |ui| {
|
||||
egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Color model:"));
|
||||
craft_ui::ComboBox::from_id_salt("colors_model")
|
||||
.selected_text(match d.model {
|
||||
|
||||
@@ -230,7 +230,7 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
|
||||
// 1970-01-01 was a Thursday: Monday-first column of the 1st.
|
||||
let lead = (first + 3).rem_euclid(7) as u32;
|
||||
let days = wordcraft_engine::cmd::controls::days_in_month(y, m);
|
||||
crate::rtl::grid(ui, egui::Grid::new("cc_calendar").spacing(vec2(2.0, 2.0)), |ui| {
|
||||
egui::Grid::new("cc_calendar").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
for cell in 0..(lead + days) {
|
||||
if cell < lead {
|
||||
ui.label("");
|
||||
|
||||
+49
-211
@@ -249,169 +249,53 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
);
|
||||
ui.label(RichText::new(summary).small().weak());
|
||||
ui.separator();
|
||||
crate::rtl::island(ui, |ui| {
|
||||
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label(tl!("No contributor data was built into this copy."));
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
|
||||
ui.set_max_height(360.0);
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
});
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.label(RichText::new("·").weak());
|
||||
}
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.label(RichText::new("·").weak());
|
||||
}
|
||||
});
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, down to [`MIN_COLUMN`].
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if spare >= 0.0 && weight > 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
(w + spare * w / flexible).max(MIN_COLUMN)
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
|
||||
/// whatever width is left but never shrinks below its opening width, so widening a column grows
|
||||
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
|
||||
/// than the window scrolls sideways. Text too long for its column is clipped.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
let last = widths.len().saturating_sub(1);
|
||||
for (i, w) in widths.into_iter().enumerate() {
|
||||
let c = if i == last {
|
||||
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
|
||||
} else {
|
||||
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
|
||||
};
|
||||
t = t.column(c.clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(tl!(k.label().1)).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(tl!(k.label().0));
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", tl!(k.label().1))).strong()).on_hover_text(tl!(k.label().0)).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
@@ -421,52 +305,21 @@ pub fn models_ui(ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(tl!(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
crate::rtl::island(ui, |ui| {
|
||||
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
|
||||
ui.set_max_height(360.0);
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(tl!(title)).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(tl!(h)).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -547,19 +400,4 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
let marks = bookmark_names(&app.session.doc, *by_location, *hidden);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (mut add, mut delete, mut go_to) = (false, false, false);
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
let name_label = ui.label(tl!("Bookmark name:"));
|
||||
ui.add(egui::TextEdit::singleline(name).desired_width(240.0)).labelled_by(name_label.id);
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
|
||||
@@ -51,7 +51,7 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
});
|
||||
});
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(18.0);
|
||||
let listed = marks.iter().any(|n| n == name.trim());
|
||||
let w = vec2(90.0, 0.0);
|
||||
@@ -61,9 +61,9 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.label(tl!("Sort by:"));
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.radio_value(by_location, false, tl!("Name"));
|
||||
ui.radio_value(by_location, true, tl!("Location"));
|
||||
});
|
||||
@@ -84,7 +84,7 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close |= ui.button(if *deleted { tl!("Close") } else { tl!("Cancel") }).clicked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false));
|
||||
let shown = place_dialog(ctx, id, window).show(ctx, |ui| {
|
||||
ui.set_min_width(340.0);
|
||||
close = crate::rtl::root(ui, |ui| body(app, ui, &mut d));
|
||||
close = body(app, ui, &mut d);
|
||||
});
|
||||
if let Some(shown) = &shown {
|
||||
if let Dialog::SaveChanges { name, message, .. } = &d {
|
||||
@@ -187,7 +187,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Enter and Escape belong to the Colors dialog while it is open above this one.
|
||||
let keys = !crate::colors::is_open(ui.ctx());
|
||||
let (okb, cancelb) = ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, ok)), tl!("Cancel"));
|
||||
@@ -206,7 +206,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
||||
pub(super) fn close_button(ui: &mut Ui) -> bool {
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked();
|
||||
});
|
||||
close
|
||||
|
||||
@@ -198,7 +198,7 @@ pub fn type_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartTypeForm) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let col = accents(app);
|
||||
let mut chosen = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("chart_types").spacing(vec2(8.0, 8.0)), |ui| {
|
||||
egui::Grid::new("chart_types").spacing(vec2(8.0, 8.0)).show(ui, |ui| {
|
||||
for (k, kind) in ChartType::ALL.into_iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(112.0, 92.0), Sense::click());
|
||||
let on = f.kind == kind;
|
||||
@@ -256,7 +256,7 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool {
|
||||
let mut remove_row = None;
|
||||
let (can_remove_series, can_remove_row) = (f.names.len() > 1, f.categories.len() > 1);
|
||||
egui::ScrollArea::horizontal().id_salt("chart_data_h").max_width(760.0).show(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized([CELL_W, ROW_H - 4.0], egui::Label::new(egui::RichText::new(tl!("Categories")).strong()));
|
||||
ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover());
|
||||
|
||||
@@ -113,7 +113,7 @@ impl ControlForm {
|
||||
/// Draws the dialog; returns true to close it.
|
||||
pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool {
|
||||
ui.set_width(420.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("cc_props").num_columns(2).spacing([10.0, 6.0]), |ui| {
|
||||
egui::Grid::new("cc_props").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Title"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.title).desired_width(280.0));
|
||||
ui.end_row();
|
||||
@@ -163,7 +163,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool {
|
||||
let mut done = false;
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// #1906: no forced label color; the theme button text reads on the theme fill.
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.button(tl!("OK")), tl!("Cancel"));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
@@ -184,7 +184,7 @@ fn entries(ui: &mut Ui, f: &mut ControlForm) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("List entries")).font(semibold(12.0)));
|
||||
let mut remove = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("cc_entries").num_columns(3).spacing([6.0, 4.0]), |ui| {
|
||||
egui::Grid::new("cc_entries").num_columns(3).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Display text")).small());
|
||||
ui.label(egui::RichText::new(tl!("Value")).small());
|
||||
ui.end_row();
|
||||
|
||||
@@ -99,7 +99,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) ->
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!(cancel));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(super) fn drop_cap_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DropCapForm) -
|
||||
let preview = app.previews.font_preview_fn();
|
||||
let unit = crate::options::unit();
|
||||
ui.add_enabled_ui(f.position != "none", |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Font:"));
|
||||
if let Some(v) = crate::widgets::font_combo(ui, "dropcap_font", 190.0, &f.font, &fams, Some(&*preview))
|
||||
&& v != crate::widgets::ADD_FONT
|
||||
@@ -380,11 +380,11 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) ->
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("Width and spacing")).font(semibold(12.5)));
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
let mut changed = None;
|
||||
egui::ScrollArea::vertical().id_salt("columns_widths").max_height(130.0).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("columns_grid").num_columns(3).spacing(vec2(12.0, 4.0)), |ui| {
|
||||
egui::Grid::new("columns_grid").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Col #:"));
|
||||
ui.label(tl!("Width:"));
|
||||
ui.label(tl!("Spacing:"));
|
||||
@@ -416,7 +416,7 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) ->
|
||||
f.rebalance(i);
|
||||
}
|
||||
let mut equal = f.equal;
|
||||
if ui.add_enabled_ui(f.count > 1, |ui| ui.checkbox(&mut equal, tl!("Equal column width"))).inner.changed() {
|
||||
if ui.add_enabled(f.count > 1, egui::Checkbox::new(&mut equal, tl!("Equal column width"))).changed() {
|
||||
f.equal = equal;
|
||||
if equal {
|
||||
f.even_out();
|
||||
@@ -424,7 +424,7 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) ->
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Preview"));
|
||||
columns_preview(ui, f, &t);
|
||||
});
|
||||
|
||||
@@ -264,9 +264,9 @@ fn merge_field_name(code: &str) -> String {
|
||||
pub(super) fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_width(620.0);
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
// Left: category and field names.
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(200.0);
|
||||
ui.label(egui::RichText::new(tl!("Pick a field from the list")).font(semibold(12.5)));
|
||||
ui.label(tl!("Categories:"));
|
||||
@@ -293,7 +293,7 @@ pub(super) fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> boo
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
// Right: the field's options, or its code.
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Field properties")).font(semibold(12.5)));
|
||||
if let Some((_, _, desc)) = FIELDS.iter().find(|(n, _, _)| *n == f.name) {
|
||||
ui.label(egui::RichText::new(format!("{} {}", tl!("Description:"), tl!(desc))).small().color(t.text_dim));
|
||||
|
||||
@@ -388,7 +388,7 @@ pub(super) fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> b
|
||||
ui.horizontal(|ui| {
|
||||
let name = f.selected.map(|c| format!("U+{:04X}", c as u32)).unwrap_or_default();
|
||||
ui.label(egui::RichText::new(name).font(semibold(12.0)));
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(tl!("from Unicode (hex)"));
|
||||
let resp = ui.add(egui::TextEdit::singleline(&mut f.code).desired_width(70.0).font(egui::TextStyle::Monospace));
|
||||
if resp.changed() {
|
||||
@@ -427,7 +427,7 @@ pub(super) fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> b
|
||||
fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, insert: &mut Option<(Value, String, String)>, t: &Tokens) -> bool {
|
||||
let mut double = false;
|
||||
egui::ScrollArea::vertical().id_salt("special_chars").max_height(CELL * GRID_ROWS as f32 + 60.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)), |ui| {
|
||||
egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Character")).font(semibold(12.0)));
|
||||
ui.label("");
|
||||
ui.label(egui::RichText::new(tl!("Shortcut key:")).font(semibold(12.0)));
|
||||
|
||||
@@ -200,8 +200,8 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("No matching commands")).weak());
|
||||
}
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Categories:"));
|
||||
if let Some(c) = list(ui, "kb_categories", vec2(left, 170.0), &cats, &f.category) {
|
||||
f.category = c;
|
||||
@@ -210,7 +210,7 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
f.current.clear();
|
||||
}
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Commands:"));
|
||||
if let Some(c) = list(ui, "kb_commands", vec2(right, 170.0), &cmds, &f.command) {
|
||||
f.command = c;
|
||||
@@ -225,15 +225,15 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
f.current = keys.first().cloned().unwrap_or_default();
|
||||
}
|
||||
let mut close = false;
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Keys for this command:"));
|
||||
let rows: Vec<(String, String)> = keys.iter().map(|k| (k.clone(), key_text(k))).collect();
|
||||
if let Some(k) = list(ui, "kb_current", vec2(left, 70.0), &rows, &f.current) {
|
||||
f.current = k;
|
||||
}
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("New shortcut:"));
|
||||
close = capture(ui, f);
|
||||
ui.add_space(4.0);
|
||||
@@ -280,7 +280,7 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
if ui.add_enabled(!app.session.keymap.is_empty(), egui::Button::new(tl!("Reset All…"))).clicked() {
|
||||
f.confirm_reset = true;
|
||||
}
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!("Cancel"));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool
|
||||
(changed && v != was).then_some(v)
|
||||
};
|
||||
heading(ui, "Size");
|
||||
crate::rtl::grid(ui, egui::Grid::new("size_lengths").num_columns(4).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("size_lengths").num_columns(4).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
if let Some(h) = length(ui, "Height:", f.height) {
|
||||
f.set_height(h);
|
||||
}
|
||||
@@ -175,7 +175,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool
|
||||
});
|
||||
heading(ui, "Scale");
|
||||
let (sw, sh) = f.scale();
|
||||
crate::rtl::grid(ui, egui::Grid::new("size_scale").num_columns(4).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("size_scale").num_columns(4).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
if let Some(p) = percent(ui, "Height:", sh) {
|
||||
f.set_scale_height(p);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool
|
||||
ui.end_row();
|
||||
});
|
||||
ui.checkbox(&mut f.lock, tl!("Lock aspect ratio"));
|
||||
ui.add_enabled_ui(f.original.is_some(), |ui| ui.checkbox(&mut f.relative, tl!("Relative to original picture size")));
|
||||
ui.add_enabled(f.original.is_some(), egui::Checkbox::new(&mut f.relative, tl!("Relative to original picture size")));
|
||||
if let Some((w, h)) = f.uncropped {
|
||||
heading(ui, "Original size");
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(super) fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm)
|
||||
ui.add_space(8.0);
|
||||
let (mut add, mut new, mut cancel) = (false, false, false);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
new = ui.add_enabled(ready, egui::Button::new(tl!("New Document"))).clicked();
|
||||
add = ui.add_enabled(ready, crate::widgets::primary_button(ui, tl!("Add to Document"))).clicked();
|
||||
|
||||
@@ -126,7 +126,7 @@ pub(super) fn sort_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SortForm) -> bool
|
||||
ui.selectable_value(&mut f.column, i, label);
|
||||
}
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.radio_value(&mut f.descending, false, tl!("Ascending"));
|
||||
ui.radio_value(&mut f.descending, true, tl!("Descending"));
|
||||
});
|
||||
@@ -253,7 +253,7 @@ fn alert(ui: &mut Ui, text: &Option<String>) {
|
||||
pub(super) fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsForm) -> bool {
|
||||
// Several cells split one by one keep their rows.
|
||||
let rows_on = f.cells <= 1 || f.merge_first;
|
||||
crate::rtl::grid(ui, egui::Grid::new("split_cells").num_columns(2), |ui| {
|
||||
egui::Grid::new("split_cells").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(&mut f.columns).range(1..=63));
|
||||
ui.end_row();
|
||||
@@ -262,7 +262,7 @@ pub(super) fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsF
|
||||
ui.end_row();
|
||||
});
|
||||
let mut merge = f.merge_first;
|
||||
if ui.add_enabled_ui(f.several, |ui| ui.checkbox(&mut merge, tl!("Merge cells before split"))).inner.changed() {
|
||||
if ui.add_enabled(f.several, egui::Checkbox::new(&mut merge, tl!("Merge cells before split"))).changed() {
|
||||
f.set_merge(merge);
|
||||
}
|
||||
alert(ui, &f.error);
|
||||
@@ -379,7 +379,7 @@ impl TextToTableForm {
|
||||
pub(super) fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) -> bool {
|
||||
let before = (f.separator.clone(), f.other_char.clone(), f.columns);
|
||||
heading(ui, "Table size");
|
||||
crate::rtl::grid(ui, egui::Grid::new("text_to_table").num_columns(2), |ui| {
|
||||
egui::Grid::new("text_to_table").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(&mut f.columns).range(1..=63));
|
||||
ui.end_row();
|
||||
@@ -395,7 +395,7 @@ pub(super) fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTab
|
||||
ui.radio_value(&mut f.autofit, "contents".to_string(), tl!("AutoFit to contents"));
|
||||
ui.radio_value(&mut f.autofit, "window".to_string(), tl!("AutoFit to window"));
|
||||
heading(ui, "Separate text at");
|
||||
crate::rtl::grid(ui, egui::Grid::new("text_to_table_sep").num_columns(2), |ui| {
|
||||
egui::Grid::new("text_to_table_sep").num_columns(2).show(ui, |ui| {
|
||||
ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraphs"));
|
||||
ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas"));
|
||||
ui.end_row();
|
||||
|
||||
@@ -50,7 +50,7 @@ impl LineNumbersForm {
|
||||
pub(super) fn line_numbers_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LineNumbersForm) -> bool {
|
||||
ui.checkbox(&mut f.on, tl!("Add line numbering"));
|
||||
ui.add_enabled_ui(f.on, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Start at:"));
|
||||
ui.add(egui::DragValue::new(&mut f.start).range(1..=32_767));
|
||||
ui.end_row();
|
||||
@@ -112,7 +112,7 @@ impl HyphenationForm {
|
||||
pub(super) fn hyphenation_ui(app: &mut WordApp, ui: &mut Ui, f: &mut HyphenationForm) -> bool {
|
||||
ui.checkbox(&mut f.auto, tl!("Automatically hyphenate document"));
|
||||
ui.checkbox(&mut f.caps, tl!("Hyphenate words in CAPS"));
|
||||
crate::rtl::grid(ui, egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Hyphenation zone:"));
|
||||
ui.add(egui::DragValue::new(&mut f.zone).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).measure(unit()).max_decimals(2));
|
||||
ui.end_row();
|
||||
@@ -236,7 +236,7 @@ pub(super) fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> b
|
||||
ui.add_space(8.0);
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
@@ -322,17 +322,21 @@ impl LanguageForm {
|
||||
|
||||
/// A language's name in the interface language (the tag itself when we don't know it).
|
||||
pub(super) fn language_name(tag: &str) -> String {
|
||||
crate::ribbon::display_name(Some(tag))
|
||||
LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)).map(|(_, n)| tl!(n).to_string()).unwrap_or_else(|| tag.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
|
||||
ui.label(tl!("Mark selected text as:"));
|
||||
let list = crate::ribbon::language_list(&f.lang);
|
||||
let mut list: Vec<(String, String)> = LANGUAGES.iter().map(|(t, _)| (t.to_string(), language_name(t))).collect();
|
||||
if !LANGUAGES.iter().any(|(t, _)| t.eq_ignore_ascii_case(&f.lang)) {
|
||||
list.push((f.lang.clone(), f.lang.clone()));
|
||||
}
|
||||
list.sort_by_key(|a| a.1.to_lowercase());
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).border_strong)).inner_margin(4.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(220.0).min_scrolled_height(220.0).show(ui, |ui| {
|
||||
ui.set_min_width(300.0);
|
||||
for (tag, name) in &list {
|
||||
let on = crate::ribbon::same_language(&f.lang, tag);
|
||||
let on = f.lang.eq_ignore_ascii_case(tag);
|
||||
let r = ui.selectable_label(on, name.as_str());
|
||||
if r.clicked() {
|
||||
f.lang = tag.clone();
|
||||
|
||||
@@ -171,8 +171,8 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||||
};
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Click level to modify:"));
|
||||
for i in 0..LEVELS {
|
||||
if ui.selectable_label(f.level == i, format!("{}", i + 1)).clicked() {
|
||||
@@ -180,7 +180,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
|
||||
preview(ui, f);
|
||||
});
|
||||
@@ -189,7 +189,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
let styles = f.styles.clone();
|
||||
let Some(l) = f.levels.get_mut(i) else { return true };
|
||||
heading(ui, "Number format");
|
||||
crate::rtl::grid(ui, egui::Grid::new("mll_number").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("mll_number").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Enter formatting for number:"));
|
||||
ui.text_edit_singleline(&mut l.text);
|
||||
ui.end_row();
|
||||
@@ -257,7 +257,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Position");
|
||||
crate::rtl::grid(ui, egui::Grid::new("mll_position").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("mll_position").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Number alignment:"));
|
||||
ui.horizontal(|ui| {
|
||||
for (v, s) in [("left", "Left"), ("center", "Center"), ("right", "Right")] {
|
||||
@@ -283,7 +283,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.add_enabled_ui(l.follow == "tab", |ui| ui.checkbox(&mut l.tab_on, tl!("Add tab stop at:")));
|
||||
ui.add_enabled(l.follow == "tab", egui::Checkbox::new(&mut l.tab_on, tl!("Add tab stop at:")));
|
||||
ui.add_enabled(l.follow == "tab" && l.tab_on, len(&mut l.tab_at, unit));
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -455,13 +455,11 @@ fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_lab
|
||||
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
|
||||
let label = if c.is_some() { value.clone() } else { tl!(none_label).to_string() };
|
||||
ui.menu_button(label, |ui| {
|
||||
crate::rtl::root(ui, |ui| {
|
||||
if ui.button(tl!(none_label)).clicked() {
|
||||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::field_menu(ui, theme, id, value);
|
||||
})
|
||||
if ui.button(tl!(none_label)).clicked() {
|
||||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::field_menu(ui, theme, id, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -512,7 +510,7 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||||
};
|
||||
heading(ui, "Show");
|
||||
crate::rtl::grid(ui, egui::Grid::new("tco_show").num_columns(2).spacing(vec2(16.0, 4.0)), |ui| {
|
||||
egui::Grid::new("tco_show").num_columns(2).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
|
||||
ui.checkbox(&mut f.comments, tl!("Comments"));
|
||||
ui.checkbox(&mut f.ink, tl!("Ink"));
|
||||
ui.end_row();
|
||||
@@ -533,7 +531,7 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
ui.radio_value(&mut f.comment_display, v.to_string(), tl!(l));
|
||||
}
|
||||
heading(ui, "Advanced");
|
||||
crate::rtl::grid(ui, egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Insertions:"));
|
||||
choice(ui, "tco_ins", &mut f.insert_mark, INSERT_MARKS);
|
||||
color(ui, &theme, &mut f.insert_color, "By author", None);
|
||||
@@ -553,7 +551,7 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Moves");
|
||||
crate::rtl::grid(ui, egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Moved from:"));
|
||||
choice(ui, "tco_move_from", &mut f.move_from_mark, DELETE_MARKS);
|
||||
ui.push_id("tco_move_from_color", |ui| color(ui, &theme, &mut f.move_from_color, "By author", None));
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
};
|
||||
heading(ui, "Location");
|
||||
let mut convert = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("notes_location").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("notes_location").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.radio_value(&mut f.endnote, false, tl!("Footnotes"));
|
||||
ui.add_enabled_ui(!f.endnote, |ui| {
|
||||
choice(ui, "notes_fpos", &mut f.pos[0], &[("pageBottom", "Bottom of Page"), ("beneathText", "Below text")], true);
|
||||
@@ -99,15 +99,12 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.menu_button(format!("{}…", tl!("Convert")), |ui| {
|
||||
crate::rtl::root(ui, |ui| {
|
||||
for (to, l) in [("endnote", "Footnotes to endnotes"), ("footnote", "Endnotes to footnotes"), ("swap", "Swap footnotes and endnotes")]
|
||||
{
|
||||
if ui.button(tl!(l)).clicked() {
|
||||
convert = Some(to);
|
||||
ui.close();
|
||||
}
|
||||
for (to, l) in [("endnote", "Footnotes to endnotes"), ("footnote", "Endnotes to footnotes"), ("swap", "Swap footnotes and endnotes")] {
|
||||
if ui.button(tl!(l)).clicked() {
|
||||
convert = Some(to);
|
||||
ui.close();
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -118,7 +115,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
}
|
||||
heading(ui, "Format");
|
||||
let k = usize::from(f.endnote);
|
||||
crate::rtl::grid(ui, egui::Grid::new("notes_format").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("notes_format").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Number format"));
|
||||
if let Some(v) = f.fmt.get_mut(k) {
|
||||
choice(ui, "notes_fmt", v, &FORMATS, false);
|
||||
@@ -150,7 +147,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
let (mut insert, mut apply, mut cancel) = (false, false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let label = if f.endnote { "Insert Endnote" } else { "Insert Footnote" };
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if cfg!(target_os = "macos") {
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
|
||||
let u = unit();
|
||||
let k = u.pt_per_unit();
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("New")).clicked() {
|
||||
f.new_stop();
|
||||
@@ -283,7 +283,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
|
||||
f.set_leader(i, l);
|
||||
pick = Some(i);
|
||||
}
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_space(10.0); // clear of the scrollbar
|
||||
if ui.add(egui::Button::new("×").small().frame(false)).on_hover_text(tl!("Clear")).clicked() {
|
||||
remove = Some(i);
|
||||
@@ -320,7 +320,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_width(130.0);
|
||||
let default_label = ui.label(tl!("Default tab stops:"));
|
||||
ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2))
|
||||
@@ -617,8 +617,8 @@ impl From<bool> for Target {
|
||||
/// Setting, style, colour and width on the left; the preview with its side toggles on the right.
|
||||
fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rgb>, target: Target) {
|
||||
let between = target == Target::Paragraph;
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Setting:")).font(semibold(12.5)));
|
||||
for (s, label) in
|
||||
[(Setting::None, "None"), (Setting::Box, "Box"), (Setting::Shadow, "Shadow"), (Setting::ThreeD, "3-D"), (Setting::Custom, "Custom")]
|
||||
@@ -629,7 +629,7 @@ fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rg
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Style:"));
|
||||
let current = STYLES.iter().find(|(s, _)| *s == l.style).map(|(_, n)| *n).unwrap_or("Single");
|
||||
craft_ui::ComboBox::from_id_salt((id, "style")).selected_text(tl!(current)).width(120.0).show_ui(ui, |ui| {
|
||||
@@ -650,7 +650,7 @@ fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rg
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Preview")).font(semibold(12.5)));
|
||||
if let Some(side) = preview(ui, l, fill, target) {
|
||||
l.toggle(side);
|
||||
|
||||
@@ -105,7 +105,7 @@ pub(super) fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) ->
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let mut then = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let label_label = ui.label(tl!("Label:"));
|
||||
let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect();
|
||||
let combo = craft_ui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
|
||||
@@ -150,7 +150,7 @@ pub(super) fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) ->
|
||||
/// Draws the caption numbering format, chapter-number toggle and chapter level and separator
|
||||
/// controls, returning to the caption dialog with the edited or original form.
|
||||
pub(super) fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let format_label = ui.label(tl!("Format:"));
|
||||
let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
|
||||
choice(ui, "cap_fmt", &mut f.format, &opts, &format_label);
|
||||
@@ -158,7 +158,7 @@ pub(super) fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm)
|
||||
});
|
||||
ui.checkbox(&mut f.chapter, tl!("Add the chapter number"));
|
||||
ui.add_enabled_ui(f.chapter, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let chapter_label = ui.label(tl!("Chapters begin at:"));
|
||||
let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect();
|
||||
let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default();
|
||||
|
||||
@@ -65,7 +65,7 @@ impl CrossRefForm {
|
||||
/// Insert.
|
||||
pub(super) fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next {
|
||||
let was = f.to.clone();
|
||||
crate::rtl::grid(ui, egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let type_label = ui.label(tl!("Reference type:"));
|
||||
let types = ref_types(app);
|
||||
let types: Vec<(String, &str)> = types.iter().map(|(v, l)| (v.clone(), l.as_str())).collect();
|
||||
|
||||
@@ -68,7 +68,7 @@ pub(super) fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Nex
|
||||
};
|
||||
sample(ui, &lines, f.right_align, leader_char(&f.leader));
|
||||
ui.add_space(6.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("idx").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("idx").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Type:"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut f.kind, "indented".to_string(), tl!("Indented"));
|
||||
@@ -153,7 +153,7 @@ impl MarkForm {
|
||||
/// and Mark All running `references.markEntry`.
|
||||
pub(super) fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next {
|
||||
heading(ui, "Index");
|
||||
crate::rtl::grid(ui, egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let entry_label = ui.label(tl!("Main entry:"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)).labelled_by(entry_label.id);
|
||||
ui.end_row();
|
||||
@@ -191,7 +191,7 @@ pub(super) fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next
|
||||
let mut close = false;
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ impl SourceForm {
|
||||
/// Draws a source's type and fields, optionally every bibliography field, and adds it as a
|
||||
/// bibliography source or inserts a citation on OK.
|
||||
pub(super) fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next {
|
||||
crate::rtl::grid(ui, egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)), |ui| {
|
||||
egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
|
||||
let kind_label = ui.label(tl!("Source type:"));
|
||||
let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default();
|
||||
let combo = craft_ui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
@@ -286,7 +286,7 @@ pub(super) fn sources_ui(app: &mut WordApp, ui: &mut Ui, selected: &mut Option<u
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
next = Next::Close;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ pub(super) fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, wa
|
||||
// A solid scroll bar takes its own room beside the list instead of floating over the level boxes.
|
||||
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
|
||||
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)), |ui| {
|
||||
egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||||
for (name, level) in f.style_levels.iter_mut() {
|
||||
// Built-in styles read in the interface language; the document keeps the English name.
|
||||
let shown = app.session.doc.styles.styles.iter().find(|st| st.name == *name).map_or_else(|| name.clone(), crate::i18n::style_name);
|
||||
|
||||
@@ -67,7 +67,7 @@ fn buttons(ui: &mut Ui, ok_enabled: bool) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(
|
||||
ui,
|
||||
|ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK"))),
|
||||
@@ -197,8 +197,8 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
if !browse {
|
||||
ui.label(egui::RichText::new(tl!("Comparing with another file needs the desktop app.")).small().weak());
|
||||
}
|
||||
crate::rtl::grid(ui, egui::Grid::new("compare_files").num_columns(2).spacing(vec2(16.0, 4.0)), |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
egui::Grid::new("compare_files").num_columns(2).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Original document");
|
||||
ui.radio_value(&mut f.original_current, true, tl!("This document"));
|
||||
ui.horizontal(|ui| {
|
||||
@@ -209,7 +209,7 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
}
|
||||
});
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Revised document");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.revised).desired_width(170.0));
|
||||
@@ -230,7 +230,7 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
}
|
||||
if f.more {
|
||||
heading(ui, "Comparison settings");
|
||||
crate::rtl::grid(ui, egui::Grid::new("compare_settings").num_columns(2).spacing(vec2(16.0, 2.0)), |ui| {
|
||||
egui::Grid::new("compare_settings").num_columns(2).spacing(vec2(16.0, 2.0)).show(ui, |ui| {
|
||||
for (i, (_, label)) in COMPARE_SETTINGS.iter().enumerate() {
|
||||
if let Some(on) = f.settings.get_mut(i) {
|
||||
ui.checkbox(on, tl!(label));
|
||||
@@ -245,13 +245,13 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
crate::rtl::grid(ui, egui::Grid::new("compare_show").num_columns(2).spacing(vec2(24.0, 2.0)), |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
egui::Grid::new("compare_show").num_columns(2).spacing(vec2(24.0, 2.0)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Show changes at:");
|
||||
ui.radio_value(&mut f.character_level, false, tl!("Word level"));
|
||||
ui.radio_value(&mut f.character_level, true, tl!("Character level"));
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Show changes in:");
|
||||
for (v, l) in [("original", "Original document"), ("revised", "Revised document"), ("new", "New document")] {
|
||||
ui.radio_value(&mut f.show_in, v.to_string(), tl!(l));
|
||||
@@ -445,7 +445,7 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
ui.checkbox(&mut o.cap_days, tl!("Capitalize names of days"));
|
||||
ui.checkbox(&mut o.caps_lock, tl!("Correct accidental usage of cAPS LOCK key"));
|
||||
ui.checkbox(&mut o.replace_text, tl!("Replace text as you type"));
|
||||
crate::rtl::grid(ui, egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)), |ui| {
|
||||
egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
|
||||
let replace_label = ui.label(tl!("Replace:"));
|
||||
let with_label = ui.label(tl!("With:"));
|
||||
ui.end_row();
|
||||
@@ -462,7 +462,7 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
// #3036: the filter matches nothing — say so instead of showing an empty box.
|
||||
ui.label(egui::RichText::new(tl!("(none)")).small().color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
crate::rtl::grid(ui, egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true), |ui| {
|
||||
egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true).show(ui, |ui| {
|
||||
for (a, b, _) in list.iter().filter(|(a, _, _)| typed.is_empty() || a.to_lowercase().starts_with(&typed)).take(500) {
|
||||
let selected = f.from == *a;
|
||||
if ui.add_sized(vec2(110.0, 16.0), egui::Button::selectable(selected, a.as_str())).clicked() {
|
||||
@@ -543,7 +543,7 @@ fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
});
|
||||
let filter = f.math_filter.trim().trim_start_matches('\\').to_string();
|
||||
egui::ScrollArea::vertical().id_salt("ac_math").max_height(240.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("ac_math_grid").num_columns(2).spacing(vec2(16.0, 1.0)).striped(true), |ui| {
|
||||
egui::Grid::new("ac_math_grid").num_columns(2).spacing(vec2(16.0, 1.0)).striped(true).show(ui, |ui| {
|
||||
for (name, c) in wordcraft_doc::math_symbols::SYMBOLS.iter().filter(|(n, _)| filter.is_empty() || n.starts_with(filter.as_str())) {
|
||||
ui.label(format!("\\{name}"));
|
||||
ui.label(c.to_string());
|
||||
@@ -662,7 +662,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) {
|
||||
let inside = app.session.doc.perm_ranges().iter().any(|r| r.everyone() && r.start <= a && b <= r.end && r.start.story == a.story);
|
||||
let mut everyone = inside;
|
||||
let can = inside || a != b;
|
||||
if ui.add_enabled_ui(can, |ui| ui.checkbox(&mut everyone, tl!("Everyone"))).inner.changed() {
|
||||
if ui.add_enabled(can, egui::Checkbox::new(&mut everyone, tl!("Everyone"))).changed() {
|
||||
let p = if everyone { json!({"exception": "add"}) } else { json!({"exception": "remove"}) };
|
||||
if let Err(e) = app.run("review.restrict", p) {
|
||||
st.message = e;
|
||||
|
||||
@@ -108,10 +108,10 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b
|
||||
if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("No matching commands")).weak());
|
||||
}
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5)));
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) {
|
||||
f.category = c;
|
||||
f.filter.clear();
|
||||
@@ -163,7 +163,7 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b
|
||||
if ui.add_enabled(custom, egui::Button::new(tl!("Reset…"))).clicked() {
|
||||
f.confirm_reset = true;
|
||||
}
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
@@ -219,7 +219,7 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
let sel = f.selected.clone();
|
||||
let tab_custom = app.session.ribbon.tabs().iter().any(|t| t.name == sel.tab && t.custom);
|
||||
// Add >> / << Remove.
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
let can_add = sel.group.is_some() && !f.command.is_empty();
|
||||
let add = ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0)));
|
||||
@@ -245,11 +245,11 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Ribbon tabs")).font(semibold(12.5)));
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
tree(app, ui, f, &rows, h);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
for (text, by) in [("Up", -1), ("Down", 1)] {
|
||||
if ui.add(egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() {
|
||||
@@ -354,7 +354,7 @@ fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
if !ids.contains(&f.qat_selected) {
|
||||
f.qat_selected = ids.first().cloned().unwrap_or_default();
|
||||
}
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
let can_add = !f.command.is_empty() && !ids.contains(&f.command);
|
||||
if ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0))).clicked()
|
||||
@@ -367,14 +367,14 @@ fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Quick Access Toolbar")).font(semibold(12.5)));
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
let rows: Vec<(String, String)> = ids.iter().map(|id| (id.clone(), label_of(app, id))).collect();
|
||||
if let Some(id) = list(ui, "rb_qat", vec2(250.0, h), &rows, &f.qat_selected) {
|
||||
f.qat_selected = id;
|
||||
}
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
for (text, by) in [("Up", -1), ("Down", 1)] {
|
||||
if ui.add_enabled(!f.qat_selected.is_empty(), egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() {
|
||||
|
||||
@@ -159,7 +159,7 @@ const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0;
|
||||
fn picker_row(ui: &mut Ui, columns: impl FnOnce(&mut Ui)) {
|
||||
ui.scope(|ui| {
|
||||
ui.set_max_height(PICKER_HEIGHT);
|
||||
crate::rtl::horizontal_top(ui, columns);
|
||||
ui.horizontal_top(columns);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
let mut chosen = None;
|
||||
picker_row(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(120.0);
|
||||
for cat in CATEGORIES {
|
||||
if ui.selectable_label(f.category == cat, tl!(cat)).clicked() {
|
||||
@@ -183,10 +183,10 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(3.0 * 96.0 + 16.0);
|
||||
ui.set_min_height(2.0 * 96.0 + 8.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("smart_art_layouts").spacing(vec2(8.0, 8.0)), |ui| {
|
||||
egui::Grid::new("smart_art_layouts").spacing(vec2(8.0, 8.0)).show(ui, |ui| {
|
||||
for (k, layout) in f.shown().into_iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 96.0), Sense::click());
|
||||
let on = f.layout == layout;
|
||||
@@ -241,7 +241,7 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(200.0);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(200.0, 150.0), Sense::hover());
|
||||
ui.painter().rect(rect, 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
|
||||
@@ -469,7 +469,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
|
||||
.collect();
|
||||
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
|
||||
let mut rebased = false;
|
||||
crate::rtl::grid(ui, egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let name_label = ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name).labelled_by(name_label.id);
|
||||
ui.end_row();
|
||||
@@ -567,7 +567,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.label(tl!("Alignment:"));
|
||||
crate::rtl::grid(ui, egui::Grid::new("tstyle_align").spacing(vec2(1.0, 1.0)), |ui| {
|
||||
egui::Grid::new("tstyle_align").spacing(vec2(1.0, 1.0)).show(ui, |ui| {
|
||||
for (i, ((n, v, h), tip)) in CELL_ALIGNS.iter().zip(ALIGN_TIPS).enumerate() {
|
||||
let on = r.align == *n;
|
||||
if align_button(ui, (*v, *h), on, tl!(tip)).clicked() {
|
||||
|
||||
@@ -181,8 +181,8 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool
|
||||
let mut rename = false;
|
||||
let mut browse = false;
|
||||
let left_builtin = mine.iter().find(|x| s(x, "id") == f.left).is_none_or(|x| x.get("builtIn").and_then(Value::as_bool).unwrap_or(true));
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Document")).font(semibold(12.5)));
|
||||
list_frame(ui, "organizer_mine", &mut |ui| {
|
||||
for st in &mine {
|
||||
@@ -194,16 +194,14 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool
|
||||
}
|
||||
});
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(60.0);
|
||||
let w = vec2(96.0, 0.0);
|
||||
// Point at the Document column (destination of copyFrom): left in LTR, right in RTL.
|
||||
let copy_arrow = if crate::rtl::flows(ui) { "▶" } else { "◀" };
|
||||
copy = ui.add_enabled(!f.right.is_empty(), egui::Button::new(format!("{copy_arrow} {}", tl!("Copy"))).min_size(w)).clicked();
|
||||
copy = ui.add_enabled(!f.right.is_empty(), egui::Button::new(format!("◀ {}", tl!("Copy"))).min_size(w)).clicked();
|
||||
delete = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Delete")).min_size(w)).clicked();
|
||||
rename = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Rename…")).min_size(w)).clicked();
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
let name = std::path::Path::new(&f.file).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "—".into());
|
||||
ui.label(egui::RichText::new(name).font(semibold(12.5))).on_hover_text(f.file.as_str());
|
||||
list_frame(ui, "organizer_other", &mut |ui| {
|
||||
@@ -256,7 +254,7 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,109 +137,81 @@ fn font_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if *strike {
|
||||
fmt.strikethrough = egui::Stroke::new(1.0, preview_color);
|
||||
}
|
||||
crate::rtl::Table::new("fontdlg").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| {
|
||||
egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
// Word's two font boxes when East Asian text is in use: the Latin text font and
|
||||
// the Asian text font, each with its own list (type to find a font).
|
||||
let prev = app.previews.font_preview_fn();
|
||||
if *asian {
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Asian text font:")));
|
||||
r.cell(|ui| {
|
||||
let fams = app.previews.east_asian_families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font_ea = f;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(tl!("Asian text font:"));
|
||||
let fams = app.previews.east_asian_families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font_ea = f;
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") }));
|
||||
r.cell(|ui| {
|
||||
let fams = app.previews.families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font = f;
|
||||
}
|
||||
ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") });
|
||||
let fams = app.previews.families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font = f;
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
let size_label = ui.label(tl!("Size:"));
|
||||
ui.text_edit_singleline(size).labelled_by(size_label.id);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Font color:"));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
color_field(ui, &theme, color, "Automatic", "auto");
|
||||
ui.end_row();
|
||||
let spacing_label = ui.label(tl!("Spacing (pt):"));
|
||||
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id);
|
||||
ui.end_row();
|
||||
let label = tl!("Kerning for fonts:");
|
||||
match kern {
|
||||
Some(on) => {
|
||||
ui.checkbox(on, label);
|
||||
}
|
||||
None => {
|
||||
if ui.add(egui::Checkbox::new(&mut false, label).indeterminate(true)).clicked() {
|
||||
*kern = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let size_label = r.cell(|ui| ui.label(tl!("Size:")));
|
||||
r.cell(|ui| ui.text_edit_singleline(size).labelled_by(size_label.id));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Font color:")));
|
||||
r.cell(|ui| {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
color_field(ui, &theme, color, "Automatic", "auto");
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let spacing_label = r.cell(|ui| ui.label(tl!("Spacing (pt):")));
|
||||
r.cell(|ui| ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id));
|
||||
});
|
||||
t.row(|r| {
|
||||
let label = tl!("Kerning for fonts:");
|
||||
r.cell(|ui| match kern {
|
||||
Some(on) => {
|
||||
ui.checkbox(on, label);
|
||||
}
|
||||
None => {
|
||||
if ui.add(egui::Checkbox::new(&mut false, label).indeterminate(true)).clicked() {
|
||||
*kern = Some(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
r.cell(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let enabled = *kern == Some(true);
|
||||
let field = egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1);
|
||||
// The number comes first in the reading order, then "points and above"; a left-to-right
|
||||
// row in a right-to-left interface runs the other way, so there the label is first.
|
||||
if crate::rtl::on() && !crate::rtl::flows(ui) {
|
||||
let points_label = ui.label(tl!("Points and above"));
|
||||
ui.add_enabled(enabled, field).labelled_by(points_label.id);
|
||||
} else {
|
||||
let points = ui.add_enabled(enabled, field);
|
||||
let points_label = ui.label(tl!("Points and above"));
|
||||
points.labelled_by(points_label.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let points = ui.add_enabled(*kern == Some(true), egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1));
|
||||
let points_label = ui.label(tl!("Points and above"));
|
||||
points.labelled_by(points_label.id);
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Effects")).font(semibold(12.5)));
|
||||
crate::rtl::Table::new("fx").columns(2).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(bold, tl!("Bold")));
|
||||
r.cell(|ui| ui.checkbox(italic, tl!("Italic")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(underline, tl!("Underline")));
|
||||
r.cell(|ui| ui.checkbox(strike, tl!("Strikethrough")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(sup, tl!("Superscript")));
|
||||
r.cell(|ui| ui.checkbox(sub, tl!("Subscript")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(small_caps, tl!("Small caps")));
|
||||
r.cell(|ui| ui.checkbox(caps, tl!("All caps")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(hidden, tl!("Hidden")));
|
||||
});
|
||||
egui::Grid::new("fx").num_columns(2).show(ui, |ui| {
|
||||
ui.checkbox(bold, tl!("Bold"));
|
||||
ui.checkbox(italic, tl!("Italic"));
|
||||
ui.end_row();
|
||||
ui.checkbox(underline, tl!("Underline"));
|
||||
ui.checkbox(strike, tl!("Strikethrough"));
|
||||
ui.end_row();
|
||||
ui.checkbox(sup, tl!("Superscript"));
|
||||
ui.checkbox(sub, tl!("Subscript"));
|
||||
ui.end_row();
|
||||
ui.checkbox(small_caps, tl!("Small caps"));
|
||||
ui.checkbox(caps, tl!("All caps"));
|
||||
ui.end_row();
|
||||
ui.checkbox(hidden, tl!("Hidden"));
|
||||
ui.end_row();
|
||||
});
|
||||
// Live preview of the chosen font/size/colour/effects.
|
||||
ui.separator();
|
||||
@@ -314,31 +286,27 @@ fn paragraph_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
|
||||
// Mirrored indents are the inside and outside ones (they swap on even pages).
|
||||
let (l_label, r_label) = if opts[0] { ("Inside:", "Outside:") } else { ("Left:", "Right:") };
|
||||
crate::rtl::Table::new("ind").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let left_label = r.cell(|ui| ui.label(tl!(l_label)));
|
||||
r.cell(|ui| para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label));
|
||||
let right_label = r.cell(|ui| ui.label(tl!(r_label)));
|
||||
r.cell(|ui| para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label));
|
||||
});
|
||||
t.row(|r| {
|
||||
let first_label = r.cell(|ui| ui.label(tl!("First line:")));
|
||||
r.cell(|ui| para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label));
|
||||
});
|
||||
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
|
||||
let left_label = ui.label(tl!(l_label));
|
||||
para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label);
|
||||
let right_label = ui.label(tl!(r_label));
|
||||
para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label);
|
||||
ui.end_row();
|
||||
let first_label = ui.label(tl!("First line:"));
|
||||
para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label);
|
||||
ui.end_row();
|
||||
});
|
||||
para_check(ui, &mut opts[0], tl!("Mirror indents"), "mirrorIndents", mixed);
|
||||
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
|
||||
crate::rtl::Table::new("sp").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let before_label = r.cell(|ui| ui.label(tl!("Before:")));
|
||||
r.cell(|ui| para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label));
|
||||
let line_label = r.cell(|ui| ui.label(tl!("Line spacing:")));
|
||||
r.cell(|ui| para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label));
|
||||
});
|
||||
t.row(|r| {
|
||||
let after_label = r.cell(|ui| ui.label(tl!("After:")));
|
||||
r.cell(|ui| para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label));
|
||||
});
|
||||
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
|
||||
let before_label = ui.label(tl!("Before:"));
|
||||
para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label);
|
||||
let line_label = ui.label(tl!("Line spacing:"));
|
||||
para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label);
|
||||
ui.end_row();
|
||||
let after_label = ui.label(tl!("After:"));
|
||||
para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label);
|
||||
ui.end_row();
|
||||
});
|
||||
para_check(ui, &mut opts[1], tl!("Don't add space between paragraphs of the same style"), "contextualSpacing", mixed);
|
||||
}
|
||||
@@ -419,8 +387,8 @@ fn find_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
// Word greys out Match case and the word options with wildcards (they always match case).
|
||||
let plain = !extra.wildcards;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(match_case, tl!("Match case")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(whole_word, tl!("Whole words")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(match_case, tl!("Match case")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(whole_word, tl!("Whole words")));
|
||||
ui.checkbox(&mut extra.wildcards, tl!("Use wildcards"));
|
||||
});
|
||||
let arrow = if extra.more { "▾" } else { "▸" };
|
||||
@@ -429,12 +397,12 @@ fn find_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
if extra.more {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.prefix, tl!("Match prefix")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.suffix, tl!("Match suffix")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.prefix, tl!("Match prefix")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.suffix, tl!("Match suffix")));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.ignore_punct, tl!("Ignore punctuation characters")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.ignore_space, tl!("Ignore white-space characters")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_punct, tl!("Ignore punctuation characters")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_space, tl!("Ignore white-space characters")));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(regex, tl!("Regular expressions"));
|
||||
@@ -510,15 +478,15 @@ fn enclose_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Text:"));
|
||||
ui.add(egui::TextEdit::singleline(text).desired_width(110.0).char_limit(2));
|
||||
ui.add_space(6.0);
|
||||
enclose_preview(ui, style, text, shape);
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Enclosure"));
|
||||
for (v, l) in [("circle", "Circle"), ("square", "Square"), ("triangle", "Triangle"), ("diamond", "Diamond")] {
|
||||
ui.add_enabled_ui(style != "none", |ui| ui.selectable_value(shape, v.to_string(), tl!(l)));
|
||||
@@ -567,15 +535,13 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::InsertTable { rows, cols } = d else {
|
||||
return false;
|
||||
};
|
||||
crate::rtl::Table::new("it").columns(2).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Number of columns:")));
|
||||
r.cell(|ui| ui.add(egui::DragValue::new(cols).range(1..=63)));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Number of rows:")));
|
||||
r.cell(|ui| ui.add(egui::DragValue::new(rows).range(1..=1000)));
|
||||
});
|
||||
egui::Grid::new("it").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(cols).range(1..=63));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Number of rows:"));
|
||||
ui.add(egui::DragValue::new(rows).range(1..=1000));
|
||||
ui.end_row();
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
@@ -584,11 +550,6 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ok || cancel
|
||||
}
|
||||
|
||||
/// A margin's number box, in the unit the interface measures in.
|
||||
fn margin(value: &mut f32) -> egui::DragValue<'_> {
|
||||
egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)
|
||||
}
|
||||
|
||||
fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::PageSetup {
|
||||
top,
|
||||
@@ -612,19 +573,21 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
return false;
|
||||
};
|
||||
ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5)));
|
||||
crate::rtl::Table::new("ps").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let top_label = r.cell(|ui| ui.label(tl!("Top:")));
|
||||
r.cell(|ui| ui.add(margin(top)).labelled_by(top_label.id));
|
||||
let bottom_label = r.cell(|ui| ui.label(tl!("Bottom:")));
|
||||
r.cell(|ui| ui.add(margin(bottom)).labelled_by(bottom_label.id));
|
||||
});
|
||||
t.row(|r| {
|
||||
let left_label = r.cell(|ui| ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") }));
|
||||
r.cell(|ui| ui.add(margin(left)).labelled_by(left_label.id));
|
||||
let right_label = r.cell(|ui| ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") }));
|
||||
r.cell(|ui| ui.add(margin(right)).labelled_by(right_label.id));
|
||||
});
|
||||
egui::Grid::new("ps").num_columns(4).show(ui, |ui| {
|
||||
let top_label = ui.label(tl!("Top:"));
|
||||
ui.add(egui::DragValue::new(top).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(top_label.id);
|
||||
let bottom_label = ui.label(tl!("Bottom:"));
|
||||
ui.add(egui::DragValue::new(bottom).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(bottom_label.id);
|
||||
ui.end_row();
|
||||
let left_label = ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") });
|
||||
ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(left_label.id);
|
||||
let right_label = ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") });
|
||||
ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(right_label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Orientation")).font(semibold(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
@@ -641,13 +604,12 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.radio_value(&mut grid.0, 2, tl!("Lines and characters"));
|
||||
ui.radio_value(&mut grid.0, 3, tl!("Text snaps to characters"));
|
||||
});
|
||||
crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id));
|
||||
let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id));
|
||||
});
|
||||
egui::Grid::new("ps_grid").num_columns(4).show(ui, |ui| {
|
||||
let chars_label = ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:")));
|
||||
ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id);
|
||||
let lines_label = ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:")));
|
||||
ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Section direction:"));
|
||||
@@ -718,7 +680,7 @@ fn link_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("lnk").num_columns(2), |ui| {
|
||||
egui::Grid::new("lnk").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Text to display:"));
|
||||
ui.text_edit_singleline(text);
|
||||
ui.end_row();
|
||||
@@ -800,7 +762,7 @@ fn word_count_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::WordCount { stats } = d else {
|
||||
return false;
|
||||
};
|
||||
crate::rtl::grid(ui, egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)), |ui| {
|
||||
egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| {
|
||||
for (l, k) in [
|
||||
("Pages", "pages"),
|
||||
("Words", "words"),
|
||||
@@ -893,7 +855,7 @@ fn phonetic_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
("right", "Right"),
|
||||
("rightVertical", "Right (vertical)"),
|
||||
];
|
||||
crate::rtl::grid(ui, egui::Grid::new("phonetic").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("phonetic").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Base text:"));
|
||||
ui.label(egui::RichText::new(base.as_str()).strong());
|
||||
ui.end_row();
|
||||
@@ -938,7 +900,7 @@ fn new_style_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::NewStyle { name, based_on, back } = d else {
|
||||
return false;
|
||||
};
|
||||
crate::rtl::grid(ui, egui::Grid::new("ns").num_columns(2), |ui| {
|
||||
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
@@ -962,7 +924,7 @@ fn modify_style_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::ModifyStyle { back, resume, id, name, font, size, bold, italic, color, before, after } = d else {
|
||||
return false;
|
||||
};
|
||||
crate::rtl::grid(ui, egui::Grid::new("ms").num_columns(2), |ui| {
|
||||
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
|
||||
let name_label = ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name).labelled_by(name_label.id);
|
||||
ui.end_row();
|
||||
@@ -1112,7 +1074,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::About { tab } = d else {
|
||||
return false;
|
||||
};
|
||||
ui.set_width(820.0);
|
||||
ui.set_width(660.0);
|
||||
let names = ["About", "Contributors", "Models", "Language data"].map(|n| tl!(n).to_string());
|
||||
if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
|
||||
*tab = i as u8;
|
||||
@@ -1182,7 +1144,7 @@ fn save_changes_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let buttons = ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let cancel = ui.button(tl!("Cancel"));
|
||||
let dont_save = ui.button(tl!("Don't Save"));
|
||||
let save = ui.add(crate::widgets::primary_button(ui, tl!("Save")));
|
||||
@@ -1292,7 +1254,7 @@ fn encrypt_password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bo
|
||||
};
|
||||
ui.label(tl!("Anyone who opens the document will need this password."));
|
||||
ui.add_space(4.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Password:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0));
|
||||
if password.is_empty() && confirm.is_empty() && !r.has_focus() {
|
||||
@@ -1475,7 +1437,7 @@ fn merge_rule_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
"Insert one text or another, depending on a field's value."
|
||||
}));
|
||||
ui.add_space(4.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("merge_rule").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("merge_rule").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Field:"));
|
||||
if fields.is_empty() {
|
||||
ui.text_edit_singleline(field);
|
||||
@@ -1517,7 +1479,7 @@ fn match_fields_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
} else {
|
||||
ui.label(tl!("The recipient-list field that fills each part of an address block and greeting line."));
|
||||
ui.add_space(4.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("match_fields").num_columns(2).spacing(vec2(24.0, 6.0)).striped(true), |ui| {
|
||||
egui::Grid::new("match_fields").num_columns(2).spacing(vec2(24.0, 6.0)).striped(true).show(ui, |ui| {
|
||||
for (part, key) in ADDRESS_PARTS {
|
||||
ui.label(tl!(part));
|
||||
match address.get(key).and_then(Value::as_str) {
|
||||
|
||||
@@ -177,8 +177,8 @@ pub(super) fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, tar
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Go to what:"));
|
||||
for (w, label) in [(GoToWhat::Page, "Page"), (GoToWhat::Bookmark, "Bookmark")] {
|
||||
if ui.selectable_label(*what == w, tl!(label)).clicked() && *what != w {
|
||||
@@ -189,7 +189,7 @@ pub(super) fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, tar
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
crate::rtl::vertical(ui, |ui| match what {
|
||||
ui.vertical(|ui| match what {
|
||||
GoToWhat::Page => {
|
||||
let label = ui.label(tl!("Enter page number:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).labelled_by(label.id);
|
||||
|
||||
@@ -343,7 +343,7 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
|
||||
});
|
||||
let mut new_hover = (0, 0);
|
||||
let mut clicked = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)), |ui| {
|
||||
egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
for r in 1..=8 {
|
||||
for c in 1..=10 {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
@@ -390,7 +390,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||||
};
|
||||
heading(ui, "Table");
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_table").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("tp_table").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.checkbox(&mut f.width_on, tl!("Preferred width:"));
|
||||
ui.add_enabled(f.width_on, len(&mut f.width, 0.1, unit));
|
||||
ui.end_row();
|
||||
@@ -412,7 +412,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Row");
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let specify = ui.checkbox(&mut f.row_height_on, tl!("Specify height:"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(f.row_height_on, len(&mut f.row_height, 0.02, unit));
|
||||
@@ -433,13 +433,13 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
super::paragraph::para_check(ui, &mut f.allow_break, tl!("Allow row to break across pages"), "allowBreak", &mut f.mixed);
|
||||
super::paragraph::para_check(ui, &mut f.header_row, tl!("Repeat as header row on each page"), "headerRow", &mut f.mixed);
|
||||
heading(ui, "Column");
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Preferred width:"));
|
||||
ui.add(len(&mut f.column_width, 0.1, unit));
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Cell");
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.checkbox(&mut f.cell_width_on, tl!("Preferred width:"));
|
||||
ui.add_enabled(f.cell_width_on, len(&mut f.cell_width, 0.1, unit));
|
||||
ui.end_row();
|
||||
@@ -456,7 +456,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
pub(super) fn cell_margins_dialog(ui: &mut Ui, f: &mut CellMarginsForm) {
|
||||
let unit = crate::options::unit();
|
||||
let max = 144.0 / unit.pt_per_unit();
|
||||
crate::rtl::grid(ui, egui::Grid::new("cell_margins").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("cell_margins").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
for (label, value) in [("Top:", &mut f.top), ("Left:", &mut f.left), ("Bottom:", &mut f.bottom), ("Right:", &mut f.right)] {
|
||||
ui.label(tl!(label));
|
||||
ui.add(egui::DragValue::new(value).speed(0.01).range(0.0..=max).measure(unit).max_decimals(2));
|
||||
@@ -512,7 +512,7 @@ pub(super) fn custom_properties(app: &mut WordApp, ui: &mut Ui, f: &mut CustomPr
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
crate::rtl::grid(ui, egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
let name = ui.label(tl!("Name:"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(300.0).char_limit(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME))
|
||||
.labelled_by(name.id);
|
||||
|
||||
@@ -261,7 +261,8 @@ pub(crate) fn recipient_list(
|
||||
if !editing {
|
||||
filter.editing = None;
|
||||
}
|
||||
ui.add_enabled_ui(order.is_some(), |ui| ui.checkbox(&mut filter.only_merged, tl!("Show only records that will be merged")));
|
||||
let merged_only = egui::Checkbox::new(&mut filter.only_merged, tl!("Show only records that will be merged"));
|
||||
ui.add_enabled(order.is_some(), merged_only);
|
||||
let visible = ui
|
||||
.horizontal(|ui| {
|
||||
ui.label(tl!("Find in list:"));
|
||||
@@ -283,7 +284,7 @@ pub(crate) fn recipient_list(
|
||||
let mut x_w = X_W;
|
||||
let mut focused = None;
|
||||
egui::ScrollArea::horizontal().id_salt("recipients_h").max_width(720.0).show(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover());
|
||||
for (i, f) in fields.iter_mut().enumerate() {
|
||||
@@ -423,7 +424,7 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter
|
||||
});
|
||||
};
|
||||
let mut remove = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)), |ui| {
|
||||
egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
|
||||
ui.label("");
|
||||
ui.label(egui::RichText::new(tl!("Field")).weak());
|
||||
ui.label(egui::RichText::new(tl!("Comparison")).weak());
|
||||
@@ -465,7 +466,7 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
crate::rtl::grid(ui, egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)), |ui| {
|
||||
egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
|
||||
for (i, (field, desc)) in filter.sort.iter_mut().enumerate() {
|
||||
ui.label(tl!(if i == 0 { "Sort by" } else { "Then by" }));
|
||||
field_combo(ui, ("rs_field", i), field, true);
|
||||
|
||||
@@ -106,23 +106,19 @@ pub(super) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
crate::rtl::Table::new("ps_paper").columns(4).show(ui, |t| {
|
||||
egui::Grid::new("ps_paper").num_columns(4).show(ui, |ui| {
|
||||
use wordcraft_engine::cmd::page::{MAX_PAGE_SIDE, MIN_PAGE_SIDE};
|
||||
let range = MIN_PAGE_SIDE / ppu..=MAX_PAGE_SIDE / ppu;
|
||||
t.row(|r| {
|
||||
let width_label = r.cell(|ui| ui.label(tl!(l.width)));
|
||||
let w = r.cell(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2))
|
||||
.labelled_by(width_label.id)
|
||||
});
|
||||
let height_label = r.cell(|ui| ui.label(tl!(l.height)));
|
||||
let h = r.cell(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id)
|
||||
});
|
||||
if (w.changed() || h.changed()) && paper.0 != paper.1 {
|
||||
*landscape = paper.0 > paper.1;
|
||||
}
|
||||
});
|
||||
let width_label = ui.label(tl!(l.width));
|
||||
let w = ui
|
||||
.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2))
|
||||
.labelled_by(width_label.id);
|
||||
let height_label = ui.label(tl!(l.height));
|
||||
let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id);
|
||||
if (w.changed() || h.changed()) && paper.0 != paper.1 {
|
||||
*landscape = paper.0 > paper.1;
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,6 @@ pub(super) enum ParaNumber {
|
||||
Lines,
|
||||
}
|
||||
|
||||
/// The right-to-left mark.
|
||||
const RLM: char = '\u{200F}';
|
||||
|
||||
/// A Paragraph dialog number box for the field `key`. While the selected paragraphs differ in
|
||||
/// it, it shows nothing, not even the unit, until a value is typed or dragged in.
|
||||
pub(super) fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: &mut BTreeSet<String>, label: &egui::Response) {
|
||||
@@ -79,9 +76,6 @@ pub(super) fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &
|
||||
ParaNumber::Length if blank => field.measure_blank(unit),
|
||||
ParaNumber::Length => field.measure(unit),
|
||||
_ if blank => field.custom_formatter(|_, _| String::new()),
|
||||
// Right to left, the unit's space goes after the word and a right-to-left mark closes it: a
|
||||
// plain leading space between the digits and an Arabic unit is drawn with no width.
|
||||
ParaNumber::Points if crate::rtl::on() => field.suffix(format!("{} {RLM}", tl!("pt"))),
|
||||
ParaNumber::Points => field.suffix(format!(" {}", tl!("pt"))),
|
||||
ParaNumber::Lines => field,
|
||||
};
|
||||
|
||||
@@ -124,7 +124,7 @@ fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str,
|
||||
y += LABEL_PITCH;
|
||||
}
|
||||
let resp = resp.on_hover_text(label.replace('\n', " "));
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(260.0);
|
||||
menu(ui, app);
|
||||
});
|
||||
@@ -239,7 +239,7 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) {
|
||||
let _ = app.run("insert.equation", json!({}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
crate::rtl::menu(&rb).show(|ui| builtin_menu(ui, app));
|
||||
egui::Popup::menu(&rb).show(|ui| builtin_menu(ui, app));
|
||||
}
|
||||
|
||||
/// The Symbols group: a strip of the current set's symbols, and a menu with every set.
|
||||
@@ -253,7 +253,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) {
|
||||
let all: Vec<char> = chars.chars().collect();
|
||||
let start = ui.data(|d| d.get_temp::<usize>(start_id)).unwrap_or(0).min(all.len().saturating_sub(1));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
|
||||
for row in 0..rows {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -272,7 +272,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) {
|
||||
});
|
||||
}
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
|
||||
let page = per_row * rows;
|
||||
if ui.add_enabled(start > 0, egui::Button::new("▲").min_size(vec2(16.0, 22.0))).clicked() {
|
||||
@@ -282,7 +282,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) {
|
||||
ui.data_mut(|d| d.insert_temp(start_id, start + per_row));
|
||||
}
|
||||
let resp = ui.add(egui::Button::new("…").min_size(vec2(16.0, 22.0))).on_hover_text(tl!("All symbol sets"));
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_width(420.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, (name, _)) in SYMBOL_SETS.iter().enumerate() {
|
||||
@@ -341,7 +341,7 @@ fn symbol_tooltip(c: char, display: &str) -> String {
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Tools", None, app, |ui, app| {
|
||||
math_menu_button(ui, app, EQUATION_PICTURE, "Equation", builtin_menu);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
let latex = app.session.math_latex;
|
||||
small(
|
||||
@@ -386,7 +386,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
},
|
||||
);
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
let normal = app.session.math_normal_text;
|
||||
small(
|
||||
@@ -407,7 +407,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
group(ui, "Equation Options", None, app, |ui, app| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
let info = app.session.run("equation.get", &json!({})).ok();
|
||||
let display = info.as_ref().and_then(|v| v.get("display")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
@@ -437,7 +437,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
crate::widgets::ButtonAction::new("equation.number", json!({}), numbered),
|
||||
);
|
||||
});
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
crate::widgets::menu_button(
|
||||
ui,
|
||||
|
||||
@@ -316,7 +316,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0));
|
||||
let shown = crate::window_close::window(ctx, window).show(ctx, |ui| {
|
||||
ui.set_width(620.0);
|
||||
done = crate::rtl::root(ui, |ui| body(ui, &mut b, &t));
|
||||
done = body(ui, &mut b, &t);
|
||||
});
|
||||
if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) {
|
||||
done = Some(None);
|
||||
@@ -385,21 +385,9 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
let rtl = crate::rtl::on();
|
||||
let icon = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 14.0), r.center().y), vec2(18.0, 18.0));
|
||||
let icon = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(ui.painter(), icon, if e.dir { "folder" } else { "file" }, t.icon, t.accent);
|
||||
// A long name keeps its beginning and ends in an ellipsis inside the row, from the
|
||||
// side reading starts from; the whole name is the row's tooltip then.
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(crate::i18n::isolate(&e.name), regular(13.0), t.text);
|
||||
job.wrap.max_width = (r.width() - 36.0).max(0.0);
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.break_anywhere = true;
|
||||
let galley = ui.painter().layout_job(job);
|
||||
let text_x = crate::rtl::lead_x(rtl, r, 30.0);
|
||||
let at = pos2(if rtl { text_x - galley.size().x } else { text_x }, r.center().y - galley.size().y / 2.0);
|
||||
let elided = galley.elided;
|
||||
ui.painter().galley(at, galley, t.text);
|
||||
let resp = if elided { resp.on_hover_text(&e.name) } else { resp };
|
||||
ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &e.name, regular(13.0), t.text);
|
||||
if resp.double_clicked() {
|
||||
if e.dir {
|
||||
go = Some(e.path.clone());
|
||||
@@ -471,7 +459,7 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
|
||||
let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (ok, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!(b.ok_label()))), tl!("Cancel"));
|
||||
cancel |= cancelb.clicked();
|
||||
|
||||
@@ -301,7 +301,7 @@ impl WordApp {
|
||||
self.run("file.saveAs", json!({"path": path}))
|
||||
};
|
||||
if r.is_ok() {
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &crate::i18n::isolate_path(&path))]));
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -311,7 +311,7 @@ impl WordApp {
|
||||
params["path"] = json!(path);
|
||||
let r = self.run("file.printPdf", params);
|
||||
if r.is_ok() {
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &crate::i18n::isolate_path(&path))]));
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ fn kind_of(t: usize, focus: [f32; 4], angle: f32, scaled: bool) -> GradientKind
|
||||
/// add and remove, and the selected stop's colour, position, transparency and rotate-with-shape
|
||||
/// controls.
|
||||
fn gradient(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], g: &Gradient) {
|
||||
crate::rtl::grid(ui, egui::Grid::new("format_shape.gradient").num_columns(2).spacing(vec2(8.0, 6.0)), |ui| {
|
||||
egui::Grid::new("format_shape.gradient").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Preset gradients"));
|
||||
ui.menu_button("▾", |ui| {
|
||||
if let Some(f) = presets_gallery(ui, theme) {
|
||||
@@ -394,7 +394,7 @@ fn stops_bar(app: &mut WordApp, ui: &mut Ui, g: &Gradient, sel: usize) {
|
||||
fn presets_gallery(ui: &mut Ui, theme: &[Rgb]) -> Option<Fill> {
|
||||
let presets = shape_fill::presets(theme);
|
||||
let mut out = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("format_shape.presets").spacing(vec2(4.0, 4.0)), |ui| {
|
||||
egui::Grid::new("format_shape.presets").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
for (i, f) in presets.iter().enumerate() {
|
||||
let style = shape_fill::PRESET_STYLES.get(i / 6).copied().unwrap_or("");
|
||||
let name = format!("{}, {} {}", tl!(style), tl!("Accent"), i % 6 + 1);
|
||||
@@ -418,7 +418,7 @@ fn directions(ui: &mut Ui, g: &Gradient, t: usize) -> Option<GradientKind> {
|
||||
PATH_DIRECTIONS.iter().map(|&f| (kind_of(t, f, 90.0, true), shape_fill::path_direction_name(f))).collect()
|
||||
};
|
||||
let mut out = None;
|
||||
crate::rtl::grid(ui, egui::Grid::new("format_shape.directions").spacing(vec2(4.0, 4.0)), |ui| {
|
||||
egui::Grid::new("format_shape.directions").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
for (i, (k, name)) in kinds.into_iter().enumerate() {
|
||||
let f = with(g, |g| set_kind(g, k));
|
||||
if swatch(ui, &f, &name) {
|
||||
|
||||
@@ -70,7 +70,7 @@ pub struct LangInfo {
|
||||
|
||||
/// The languages WordCraft offers, when their catalogs are embedded. English first: it is the
|
||||
/// fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 28] = [
|
||||
pub static LANGUAGES: [LangInfo; 27] = [
|
||||
LangInfo { code: "en", prefer_hans: false, editing_lang: None },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (the crate's matching).
|
||||
LangInfo { code: "zh-hans", prefer_hans: true, editing_lang: None },
|
||||
@@ -124,18 +124,10 @@ pub static LANGUAGES: [LangInfo; 28] = [
|
||||
LangInfo { code: "da", prefer_hans: false, editing_lang: None },
|
||||
// Romanian; `ro`, `ro-RO`, `ro_RO.UTF-8` and `ro-MD` resolve here.
|
||||
LangInfo { code: "ro", prefer_hans: false, editing_lang: None },
|
||||
// Arabic (Modern Standard); `ar` and every `ar-*` locale (`ar-MA`, `ar-EG`, `ar-SA` …) resolve
|
||||
// here. Right to left: see [`RTL_LANGUAGES`] and [`Lang::rtl`].
|
||||
LangInfo { code: "ar", prefer_hans: false, editing_lang: None },
|
||||
// Slovenian; `sl`, `sl-SI` and `sl_SI.UTF-8` resolve here.
|
||||
LangInfo { code: "sl", prefer_hans: false, editing_lang: None },
|
||||
];
|
||||
|
||||
/// Interface languages written right to left. Their catalogs are written in typing order and
|
||||
/// egui's text layout orders each line itself, so the lookups return them as they are; the window
|
||||
/// is mirrored ([`crate::rtl`]).
|
||||
const RTL_LANGUAGES: [&str; 1] = ["ar"];
|
||||
|
||||
/// A language the UI can be shown in: English, or an offered language whose catalogs are embedded.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Lang {
|
||||
@@ -179,12 +171,6 @@ impl Lang {
|
||||
self.i18n().name(self.lang)
|
||||
}
|
||||
|
||||
/// Whether the interface is written right to left. Its translations come back from the lookups
|
||||
/// as written, in typing order (see [`RTL_LANGUAGES`]).
|
||||
pub fn rtl(self) -> bool {
|
||||
RTL_LANGUAGES.contains(&self.code())
|
||||
}
|
||||
|
||||
pub fn prefers_hans(self) -> bool {
|
||||
self.info().is_some_and(|l| l.prefer_hans)
|
||||
}
|
||||
@@ -414,46 +400,6 @@ pub fn tr(lang: Lang, s: &str) -> &str {
|
||||
lang.i18n().t(lang.lang, s)
|
||||
}
|
||||
|
||||
/// Left-to-right mark: it keeps what it touches from joining a right-to-left run next to it.
|
||||
const LRM: char = '\u{200E}';
|
||||
|
||||
/// `name` with a left-to-right mark before its extension (`ملف 2.docx`), which a right-to-left
|
||||
/// line would otherwise draw as `ملف docx.2`.
|
||||
fn mark_extension(name: &str) -> String {
|
||||
match name.rfind('.') {
|
||||
Some(dot)
|
||||
if dot > 0 && name.get(dot + 1..).is_some_and(|e| !e.is_empty() && e.len() <= 8 && e.bytes().all(|b| b.is_ascii_alphanumeric())) =>
|
||||
{
|
||||
format!("{}{LRM}{}", &name[..dot], &name[dot..])
|
||||
}
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` (a file or folder name, a document's name) set apart from the right-to-left text around
|
||||
/// it, so its own direction and its neutral marks (`(`, `.`, `-`) keep their places. Only in a
|
||||
/// right-to-left interface; elsewhere the text as it is. Display only: never for text that is saved.
|
||||
pub fn isolate(name: &str) -> String {
|
||||
if current().rtl() { format!("\u{2068}{}\u{2069}", mark_extension(name)) } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// [`isolate`] for a path or a web address, which always reads left to right: its folders keep
|
||||
/// their order even where neighbouring ones are Arabic (a mark after every separator keeps two
|
||||
/// right-to-left names from joining into one run that swaps them).
|
||||
pub fn isolate_path(path: &str) -> String {
|
||||
if !current().rtl() {
|
||||
return path.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(path.len() + 8);
|
||||
for c in mark_extension(path).chars() {
|
||||
out.push(c);
|
||||
if matches!(c, '/' | '\\') {
|
||||
out.push(LRM);
|
||||
}
|
||||
}
|
||||
format!("\u{2066}{out}\u{2069}")
|
||||
}
|
||||
|
||||
/// A style's name as the interface shows it: built-in styles (`Normal`, `Heading 2`, `TOC 3`) in
|
||||
/// the current language, styles the author made exactly as named. Display only: the document,
|
||||
/// commands and saved files keep the English name.
|
||||
@@ -479,11 +425,9 @@ pub fn builtin_style_name(name: &str) -> String {
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// A ribbon location (`Home › Font`) in the current language, segment by segment. A
|
||||
/// right-to-left language reads it from the right, so the mark points left.
|
||||
/// A ribbon location (`Home › Font`) in the current language, segment by segment.
|
||||
pub fn location(loc: &str) -> String {
|
||||
let segments: Vec<&str> = loc.split(" › ").map(t).collect();
|
||||
segments.join(if current().rtl() { " ‹ " } else { " › " })
|
||||
loc.split(" › ").map(t).collect::<Vec<_>>().join(" › ")
|
||||
}
|
||||
|
||||
/// A label with an action after a fixed English prefix, e.g. `Undo Typing`: both parts translated
|
||||
|
||||
@@ -175,17 +175,6 @@ impl<'a> Exempt<'a> {
|
||||
for p in wordcraft_engine::packs::installed() {
|
||||
names.extend([p.name.clone(), p.licence, format!("{} · {}", p.name, p.locale), p.locale]);
|
||||
}
|
||||
// Options › Language › Your languages: each row's language tag, and a pack's own name
|
||||
// beside it, as the index and the document wrote them.
|
||||
let index = wordcraft_engine::packs::available();
|
||||
for row in wordcraft_engine::packs::your_languages(&app.session) {
|
||||
let tag = row.tags.first().cloned().or_else(|| row.pack.as_deref().map(crate::ribbon::tag_for_pack)).unwrap_or_default();
|
||||
if let Some(p) = row.pack.as_deref().and_then(|l| index.iter().find(|p| p.locale == l)) {
|
||||
names.insert(format!("{} · {tag}", p.name));
|
||||
}
|
||||
names.extend(row.tags.iter().cloned());
|
||||
names.insert(tag);
|
||||
}
|
||||
for m in crate::credits::MODELS {
|
||||
names.extend([m.company, m.model, m.version].map(str::to_string));
|
||||
}
|
||||
@@ -608,237 +597,3 @@ fn disabled_reasons() -> std::collections::BTreeSet<String> {
|
||||
}
|
||||
reasons
|
||||
}
|
||||
|
||||
/// One frame at a given window width; returns the left edge of every text drawn.
|
||||
fn frame_at(ctx: &egui::Context, app: &mut WordApp, width: f32) -> Vec<(String, f32)> {
|
||||
fn collect(shape: &egui::Shape, out: &mut Vec<(String, f32)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos.x)),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0));
|
||||
let mut drawn = Vec::new();
|
||||
for _ in 0..4 {
|
||||
TIME.set(TIME.get() + 0.1);
|
||||
let input = egui::RawInput { screen_rect: Some(screen), time: Some(TIME.get()), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
drawn.clear();
|
||||
for s in &out.shapes {
|
||||
collect(&s.shape, &mut drawn);
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
drawn
|
||||
}
|
||||
|
||||
/// The Arabic interface is mirrored: reading starts at the right. And nothing in it can panic:
|
||||
/// every tab (wide, and narrow enough to scroll), File page, dialog, pane, context menu and
|
||||
/// ribbon control is laid out right to left. egui's `Grid` asserts against right-to-left parents,
|
||||
/// so a grid that is not wrapped in `rtl::grid` fails here.
|
||||
#[test]
|
||||
fn the_arabic_interface_is_mirrored_and_never_panics() {
|
||||
// Arabic comes from the craft-i18n catalogs: a build without them (English only) has nothing to mirror.
|
||||
let Some(ar) = super::Lang::from_code("ar") else {
|
||||
eprintln!("skipped: built without the craft-i18n Arabic catalog (set CRAFT_I18N_DIR to a checkout)");
|
||||
return;
|
||||
};
|
||||
let start = |ctx: &egui::Context| {
|
||||
let mut app = fresh_app(ctx);
|
||||
app.ui.language = "ar".into();
|
||||
frame(ctx, &mut app);
|
||||
app
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = start(&ctx);
|
||||
assert!(crate::rtl::on());
|
||||
let x_of = |drawn: &[(String, f32)], english: &str| {
|
||||
let label = super::tr(ar, english);
|
||||
drawn.iter().find(|(t, _)| t == label).map(|(_, x)| *x).unwrap_or_else(|| panic!("{english} is not drawn"))
|
||||
};
|
||||
// Tabs run from the right: File, Home, Insert … and the Share button is at the far left.
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let (file, home, insert, share) = (x_of(&drawn, "File"), x_of(&drawn, "Home"), x_of(&drawn, "Insert"), x_of(&drawn, "Share"));
|
||||
assert!(file > home && home > insert && insert > 800.0, "tabs: {file} {home} {insert}");
|
||||
assert!(share < 200.0, "Share: {share}");
|
||||
// Ribbon groups too: Clipboard is right of Font, which is right of Styles.
|
||||
assert!(x_of(&drawn, "Clipboard") > x_of(&drawn, "Font") && x_of(&drawn, "Font") > x_of(&drawn, "Styles"));
|
||||
// The status bar starts at the right, and a narrow window still shows the ribbon's first group.
|
||||
let page = drawn.iter().find(|(t, _)| t.contains(super::tr(ar, "Editing"))).map(|(_, x)| *x);
|
||||
assert!(page.is_some());
|
||||
let narrow = frame_at(&ctx, &mut app, 700.0);
|
||||
let clipboard = x_of(&narrow, "Clipboard");
|
||||
assert!((350.0..700.0).contains(&clipboard), "the narrow ribbon starts at its right end: {clipboard}");
|
||||
// Side panes swap sides: Navigation is on the right.
|
||||
let _ = app.run("view.navigationPane", json!({"value": true}));
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
assert!(x_of(&drawn, "Navigation") > 1300.0);
|
||||
let _ = app.run("view.navigationPane", json!({"value": false}));
|
||||
// English stays as it was.
|
||||
{
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = fresh_app(&ctx);
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let x = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, x)| *x).unwrap();
|
||||
assert!(x("File") < x("Home") && x("Home") < x("Insert") && x("Insert") < 400.0 && x("Share") > 1400.0);
|
||||
assert!(x("Clipboard") < x("Font") && x("Font") < x("Styles"));
|
||||
assert!(x("Cut") < 200.0, "ribbon buttons keep their icon on the left and start at the left");
|
||||
assert!(!crate::rtl::on());
|
||||
super::set_current(ar);
|
||||
}
|
||||
// Everything else: no panic at any width.
|
||||
let tabs: Vec<&str> = crate::ribbon::TABS.iter().copied().filter(|t| *t != "File").collect();
|
||||
for tab in &tabs {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
for width in [2560.0, 1100.0, 420.0] {
|
||||
frame_at(&ctx, &mut app, width);
|
||||
}
|
||||
}
|
||||
let _ = app.run("ui.tab", json!({"tab": "Home"}));
|
||||
// Reading Mode's toolbar is mirrored too: its far-side button ("Edit Document") is at the left.
|
||||
let _ = app.run("view.readMode", json!({}));
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let edit = x_of(&drawn, "Edit Document");
|
||||
assert!(edit < 400.0, "Reading Mode: Edit Document at {edit}");
|
||||
let _ = app.run("view.printLayout", json!({}));
|
||||
// File › Print: the settings are on the right and the preview on the left, not over each other.
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "print"}));
|
||||
for width in [1600.0, 1100.0] {
|
||||
let drawn = frame_at(&ctx, &mut app, width);
|
||||
let settings = x_of(&drawn, "Save these pages as PDF…");
|
||||
let indicator = super::tr(ar, "Page {page} of {pages}").split('{').next().unwrap_or_default();
|
||||
// (The status bar's page count is drawn under the page too, at the right: take the leftmost.)
|
||||
let preview = drawn.iter().filter(|(t, _)| t.starts_with(indicator)).map(|(_, x)| *x).fold(f32::MAX, f32::min);
|
||||
assert!(preview < f32::MAX, "the preview's page indicator is drawn");
|
||||
assert!(preview + 100.0 < settings, "print page at {width}: settings {settings}, preview {preview}");
|
||||
}
|
||||
for page in ["home", "new", "open", "info", "export", "print", "options"] {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": page}));
|
||||
frame(&ctx, &mut app);
|
||||
frame_at(&ctx, &mut app, 500.0);
|
||||
}
|
||||
let _ = app.run("ui.backstage", json!({"value": false}));
|
||||
// A dialog's columns start at the right too: Go To's list of targets is right of its input.
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let (targets, input) = (x_of(&drawn, "Go to what:"), x_of(&drawn, "Enter page number:"));
|
||||
assert!(targets > input, "Go To: targets {targets}, input {input}");
|
||||
app.dialog = None;
|
||||
let dialogs = [
|
||||
"font",
|
||||
"paragraph",
|
||||
"tabs",
|
||||
"goto",
|
||||
"insertTable",
|
||||
"pageSetup",
|
||||
"link",
|
||||
"bookmark",
|
||||
"wordCount",
|
||||
"zoom",
|
||||
"watermark",
|
||||
"phonetic",
|
||||
"newStyle",
|
||||
"manageStyles",
|
||||
"newTableStyle",
|
||||
"modifyTableStyle",
|
||||
"commands",
|
||||
"icons",
|
||||
"tableProperties",
|
||||
"enclose",
|
||||
"pasteSpecial",
|
||||
"about",
|
||||
"contributors",
|
||||
"models",
|
||||
"insertMergeField",
|
||||
"findRecipient",
|
||||
"fieldCode",
|
||||
"encryptPassword",
|
||||
"defineList",
|
||||
"trackChangesOptions",
|
||||
"customizeKeyboard",
|
||||
"customizeRibbon",
|
||||
"customizeQuickAccess",
|
||||
"noteOptions",
|
||||
"caption",
|
||||
"index",
|
||||
"markEntry",
|
||||
"toc",
|
||||
"sourceManager",
|
||||
"createSource",
|
||||
"lineNumbers",
|
||||
"hyphenation",
|
||||
"language",
|
||||
"envelopes",
|
||||
"labels",
|
||||
"find",
|
||||
"replace",
|
||||
"symbol",
|
||||
"field",
|
||||
"columns",
|
||||
"borders",
|
||||
"pageBorders",
|
||||
"newRecipientList",
|
||||
"editRecipientList",
|
||||
"rules",
|
||||
"matchFields",
|
||||
"dropCap",
|
||||
"dateTime",
|
||||
"crossReference",
|
||||
];
|
||||
for name in dialogs {
|
||||
let _ = app.run("ui.dialog", json!({"name": name}));
|
||||
frame(&ctx, &mut app);
|
||||
app.dialog = None;
|
||||
}
|
||||
let panes: Vec<String> =
|
||||
app.session.registry.all().iter().map(|c| c.id.to_string()).filter(|id| id.starts_with("view.") && id.ends_with("Pane")).collect();
|
||||
for pane in &panes {
|
||||
let _ = app.run(pane, json!({"value": true}));
|
||||
frame(&ctx, &mut app);
|
||||
frame_at(&ctx, &mut app, 600.0);
|
||||
let _ = app.run(pane, json!({"value": false}));
|
||||
}
|
||||
frames(&ctx, &mut app, click(egui::pos2(1280.0, 520.0), egui::PointerButton::Secondary));
|
||||
frames(&ctx, &mut app, escape());
|
||||
let _ = app.run("insert.table", json!({"rows": 2, "cols": 2}));
|
||||
for tab in ["Table Design", "Table Layout"] {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
frame(&ctx, &mut app);
|
||||
frame_at(&ctx, &mut app, 600.0);
|
||||
}
|
||||
let _ = app.run("insert.equation", json!({}));
|
||||
let _ = app.run("ui.tab", json!({"tab": "Equation"}));
|
||||
frame(&ctx, &mut app);
|
||||
// Every ribbon control on every tab, hovered and clicked: its tooltip, menu or dialog.
|
||||
let contextual = ["Table Design", "Table Layout", "Equation"];
|
||||
let open = |tab: &str| {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = start(&ctx);
|
||||
match tab {
|
||||
"Table Design" | "Table Layout" => drop(app.run("insert.table", json!({"rows": 2, "cols": 2}))),
|
||||
"Equation" => drop(app.run("insert.equation", json!({}))),
|
||||
_ => {}
|
||||
}
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
frame(&ctx, &mut app);
|
||||
(ctx, app)
|
||||
};
|
||||
for tab in tabs.iter().chain(&contextual) {
|
||||
let (ctx, mut app) = open(tab);
|
||||
for at in ribbon_controls(&ctx) {
|
||||
frames(&ctx, &mut app, vec![vec![egui::Event::PointerMoved(at)]; 2]);
|
||||
frames(&ctx, &mut app, click(at, egui::PointerButton::Primary));
|
||||
frames(&ctx, &mut app, escape());
|
||||
app.dialog = None;
|
||||
app.ui.backstage = false;
|
||||
if app.ui.tab != *tab {
|
||||
(_, app) = open(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
super::set_current(super::Lang::EN);
|
||||
}
|
||||
|
||||
@@ -1189,113 +1189,3 @@ fn the_flatpak_manifest_pins_the_craft_i18n_rev() {
|
||||
assert_eq!(commit.trim().trim_matches(['\'', '"']), rev);
|
||||
assert!(manifest.contains("CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n"), "the build embeds the catalogs");
|
||||
}
|
||||
|
||||
/// A right-to-left test language whose catalog is `source`, selected as the current language.
|
||||
fn arabic_fixture(source: &'static str) -> Lang {
|
||||
let ar = fixture_lang("ar", source);
|
||||
set_current(ar);
|
||||
ar
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_locales_and_saved_preference_work_without_changing_the_document() {
|
||||
needs_catalogs!();
|
||||
let Some(ar) = Lang::from_code("ar") else {
|
||||
eprintln!("skipped: the embedded catalogs have no Arabic");
|
||||
return;
|
||||
};
|
||||
for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant", "ar-Arab-AE"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(ar), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["xx-XX", "ar-MA", "en-US"]), Some(ar));
|
||||
assert_eq!(normalize_pref("AR"), Some("ar"));
|
||||
assert!(ar.rtl());
|
||||
assert!(Lang::all().filter(|l| l.rtl()).eq([ar]), "Arabic is the only right-to-left language");
|
||||
assert!(!ar.uses_cjk());
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
|
||||
let before = serde_json::to_value(&app.session.doc).unwrap();
|
||||
let result = app.run("ui.language", serde_json::json!({"value": "AR"})).unwrap();
|
||||
assert_eq!(result["effective"], "ar");
|
||||
assert_eq!(app.ui.language, "ar");
|
||||
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(Lang::from_pref(&restored.language), ar);
|
||||
assert!(app.run("ui.language", serde_json::json!({"value": "ar-unknown"})).is_err());
|
||||
assert_eq!(app.ui.language, "ar", "invalid preferences must keep the saved language");
|
||||
}
|
||||
|
||||
/// Arabic comes back from the lookups exactly as the catalog has it, in typing order: egui's text
|
||||
/// layout orders the words of each line itself, so nothing here reverses them. Placeholders are
|
||||
/// filled in place and text for the document stays as typed.
|
||||
#[test]
|
||||
fn arabic_lookups_keep_typing_order() {
|
||||
let ar = arabic_fixture(concat!(
|
||||
"\tTable of Contents\tجدول المحتويات\n",
|
||||
"\tPaste Special…\tلصق خاص…\n",
|
||||
"\tAbout WordCraft\tحول WordCraft\n",
|
||||
"\tPassword:\tكلمة المرور:\n",
|
||||
"\tTable of\\nContents\tجدول\\nالمحتويات\n",
|
||||
"\tPage {page} of {pages}\tالصفحة {page} من {pages}\n",
|
||||
"\tExported {path}\tتم التصدير إلى {path}\n",
|
||||
"preview\tTitle\tالعنوان\n",
|
||||
"\tHome\tالرئيسية\n",
|
||||
"\tFont\tالخط\n",
|
||||
));
|
||||
assert!(ar.rtl());
|
||||
assert_eq!(tr(ar, "Table of Contents"), "جدول المحتويات");
|
||||
assert_eq!(tr(ar, "Paste Special…"), "لصق خاص…");
|
||||
assert_eq!(tr(ar, "About WordCraft"), "حول WordCraft");
|
||||
assert_eq!(tr(ar, "Password:"), "كلمة المرور:");
|
||||
// A two-line ribbon label keeps its two lines.
|
||||
assert_eq!(tr(ar, "Table of\nContents"), "جدول\nالمحتويات");
|
||||
// Placeholders are filled in place; inserted text is never touched, and the text for the
|
||||
// document is the same string.
|
||||
let page = fmt_plain(tr(ar, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]);
|
||||
assert_eq!(page, "الصفحة 3 من 9");
|
||||
assert_eq!(fmt_plain(tr(ar, "Exported {path}"), &[("path", "draft-{words}.docx")]), "تم التصدير إلى draft-{words}.docx");
|
||||
// The preview context is looked up the same way.
|
||||
assert_eq!(trc(ar, "preview", "Title"), "العنوان");
|
||||
// A string without a translation stays English, untouched.
|
||||
assert_eq!(tr(ar, "unknown future label"), "unknown future label");
|
||||
set_current(ar);
|
||||
// A ribbon location reads from the right: the tab first, the mark pointing left.
|
||||
assert_eq!(location("Home › Font"), "الرئيسية ‹ الخط");
|
||||
// A search query typed in reading order is a plain substring of the label.
|
||||
assert!(lowercase(t("Table of Contents")).contains("جدول المحتويات"));
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(location("Home › Font"), "Home › Font");
|
||||
}
|
||||
|
||||
/// File names and paths shown in a right-to-left line are set apart and marked: the name in its
|
||||
/// own isolate with its extension kept left to right, a path as a left-to-right isolate with a
|
||||
/// mark after every separator so neighbouring Arabic folders do not swap. Other languages get
|
||||
/// the text untouched.
|
||||
#[test]
|
||||
fn names_and_paths_are_isolated_in_a_right_to_left_interface() {
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(isolate("تقرير 2.docx"), "تقرير 2.docx");
|
||||
assert_eq!(isolate_path("C:/ملفات/تقرير.docx"), "C:/ملفات/تقرير.docx");
|
||||
let ar = fixture_lang("ar", "\tHome\tالرئيسية\n");
|
||||
set_current(ar);
|
||||
assert_eq!(isolate("تقرير 2.docx"), "\u{2068}تقرير 2\u{200E}.docx\u{2069}");
|
||||
assert_eq!(isolate("ملاحظات"), "\u{2068}ملاحظات\u{2069}", "no extension, no mark");
|
||||
assert_eq!(isolate("v1.2 تقرير"), "\u{2068}v1.2 تقرير\u{2069}", "a dot that is not an extension");
|
||||
assert_eq!(isolate_path(r"C:\ملفات\تقرير.docx"), "\u{2066}C:\\\u{200E}ملفات\\\u{200E}تقرير\u{200E}.docx\u{2069}");
|
||||
assert_eq!(isolate_path("C:/ملفات/تقرير.docx"), "\u{2066}C:/\u{200E}ملفات/\u{200E}تقرير\u{200E}.docx\u{2069}");
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
/// A path inside a sentence (the open failure on the status bar) is isolated in a right-to-left
|
||||
/// interface, so Arabic folders do not scramble; in English the message holds the path as it is.
|
||||
#[test]
|
||||
fn a_path_in_a_message_is_isolated_in_a_right_to_left_interface() {
|
||||
let path = r"C:\ملفات\تقرير.docx";
|
||||
set_current(Lang::EN);
|
||||
let mut a = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
assert!(a.report_open_failure(path, "bad").contains(path));
|
||||
set_current(fixture_lang("ar", "\tCouldn't open {path}: {error}\tتعذر فتح {path}: {error}\n"));
|
||||
let msg = a.report_open_failure(path, "bad");
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(msg, "تعذر فتح \u{2066}C:\\\u{200E}ملفات\\\u{200E}تقرير\u{200E}.docx\u{2069}: \u{2068}bad\u{2069}");
|
||||
}
|
||||
|
||||
+153
-1094
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,6 @@ pub mod proofing_pane;
|
||||
pub mod read_aloud;
|
||||
pub mod ribbon;
|
||||
pub mod ribbon_fit;
|
||||
pub mod rtl;
|
||||
pub mod ruler_tabs;
|
||||
pub mod scroll;
|
||||
pub mod system_clipboard;
|
||||
@@ -533,16 +532,9 @@ pub struct WordApp {
|
||||
fonts_hant: bool,
|
||||
/// Whether the installed UI fonts include an installed CJK fallback font (#241).
|
||||
fonts_system_cjk: bool,
|
||||
/// Whether the installed UI fonts were asked to include an installed Arabic font (an Arabic
|
||||
/// interface; used when no embedded face has Arabic letters).
|
||||
fonts_system_arabic: bool,
|
||||
/// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in
|
||||
/// File ▸ Options: load the installed CJK fallback font if no embedded face covers it.
|
||||
pub(crate) want_system_cjk: bool,
|
||||
/// Arabic text is (about to be) on screen in a non-Arabic interface, e.g. "العربية" in the
|
||||
/// language picker or Language packs list: load an installed Arabic font when no embedded face
|
||||
/// covers it (dev builds without craft-fonts).
|
||||
pub(crate) want_system_arabic: bool,
|
||||
/// The document (id, revision) whose comment text [`WordApp::ensure_comment_fonts`] last
|
||||
/// looked at.
|
||||
comment_fonts_for: Option<(u64, u64)>,
|
||||
|
||||
@@ -354,7 +354,7 @@ pub(crate) fn highlight_button_appearance(name: &str) -> (&'static str, Option<e
|
||||
/// No Color too, becomes the split button's colour, as in Word: after No Color a click on the
|
||||
/// button removes highlighting (#2105).
|
||||
pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
crate::rtl::grid(ui, egui::Grid::new("mini_hl").spacing(Vec2::new(3.0, 3.0)), |ui| {
|
||||
egui::Grid::new("mini_hl").spacing(Vec2::new(3.0, 3.0)).show(ui, |ui| {
|
||||
for (i, h) in wordcraft_doc::props::Highlight::ALL.iter().enumerate().skip(1) {
|
||||
let Some(c) = h.rgb() else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
|
||||
|
||||
@@ -563,8 +563,7 @@ fn an_inline_picture_drags_to_the_insertion_cursor_and_stays_inline() {
|
||||
/// page: its preview has its red outline and nothing dark inside.
|
||||
#[test]
|
||||
fn a_dragged_shape_leaves_the_text_behind_it() {
|
||||
use crate::table_resize::tests::{H, button, gpu_turn, steps};
|
||||
let _gpu = gpu_turn();
|
||||
use crate::table_resize::tests::{H, button, steps};
|
||||
let text = "MMMMMMMMMMMM ".repeat(12);
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::from_text(&text)), Default::default());
|
||||
let shape = InlineObject::Shape {
|
||||
@@ -759,8 +758,7 @@ fn rotation_window(app: WordApp) -> crate::table_resize::tests::H {
|
||||
/// pictures whose cached preview already contains a rotation and either reflection.
|
||||
#[test]
|
||||
fn rotation_drag_turns_picture_pixels_live_and_commits_once_or_cancels() {
|
||||
use crate::table_resize::tests::{H, button, gpu_turn, steps};
|
||||
let _gpu = gpu_turn();
|
||||
use crate::table_resize::tests::{H, button, steps};
|
||||
for (initial, flip_h, flip_v) in [(0.0, false, false), (30.0, true, false), (90.0, false, true), (315.0, true, true)] {
|
||||
// Four original colour quadrants make both accidental extra rotation and reflected
|
||||
// UVs observable. Sample near the long ends after a quarter turn, outside the old
|
||||
|
||||
+79
-129
@@ -140,9 +140,8 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.label(RichText::new(tl!("Options")).font(semibold(26.0)));
|
||||
ui.add_space(16.0);
|
||||
let current = if PANES.contains(&app.ui.options_pane.as_str()) { app.ui.options_pane.clone() } else { "general".to_string() };
|
||||
let rtl = crate::rtl::on();
|
||||
let body = |ui: &mut Ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(170.0);
|
||||
for id in PANES {
|
||||
let label = tl!(pane_label(id));
|
||||
@@ -156,13 +155,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let font = if active { semibold(14.0) } else { crate::theme::regular(14.0) };
|
||||
// Long translations stay inside the row; WidgetInfo above keeps the full name.
|
||||
ui.painter().with_clip_rect(r).text(
|
||||
egui::pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y),
|
||||
crate::rtl::lead_center(rtl),
|
||||
label,
|
||||
font,
|
||||
t.text,
|
||||
);
|
||||
ui.painter().with_clip_rect(r).text(egui::pos2(r.min.x + 12.0, r.center().y), egui::Align2::LEFT_CENTER, label, font, t.text);
|
||||
if resp.clicked() {
|
||||
app.ui.options_pane = id.to_string();
|
||||
}
|
||||
@@ -172,7 +165,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (line, _) = ui.allocate_exact_size(vec2(1.0, 420.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(line, 0.0, t.border);
|
||||
ui.add_space(20.0);
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(640.0);
|
||||
match current.as_str() {
|
||||
"display" => display(app, ui),
|
||||
@@ -185,13 +178,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
_ => general(app, ui),
|
||||
}
|
||||
});
|
||||
};
|
||||
// Right to left the pane list is on the right and the settings fill in from it.
|
||||
if rtl {
|
||||
ui.with_layout(crate::rtl::row_layout(ui, egui::Align::Min), body)
|
||||
} else {
|
||||
crate::rtl::horizontal_top(ui, body)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn section(ui: &mut Ui, title: &str) {
|
||||
@@ -250,38 +237,27 @@ fn general(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Personalize");
|
||||
crate::rtl::Table::new("options_user").columns(2).spacing(vec2(8.0, 6.0)).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let user_label = r.cell(|ui| ui.label(tl!("User name:")));
|
||||
r.cell(|ui| {
|
||||
let mut n = app.session.author.clone();
|
||||
let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc);
|
||||
if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed()
|
||||
&& !protected
|
||||
{
|
||||
app.session.author = n;
|
||||
}
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let initials_label = r.cell(|ui| ui.label(tl!("Initials:")));
|
||||
r.cell(|ui| {
|
||||
let mut i = app.session.initials.clone();
|
||||
let hint = app.session.user_initials();
|
||||
if ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut i)
|
||||
.desired_width(80.0)
|
||||
.char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS)
|
||||
.hint_text(hint),
|
||||
)
|
||||
.labelled_by(initials_label.id)
|
||||
.changed()
|
||||
{
|
||||
set(app, json!({"initials": i}));
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
let user_label = ui.label(tl!("User name:"));
|
||||
let mut n = app.session.author.clone();
|
||||
let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc);
|
||||
if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed() && !protected {
|
||||
app.session.author = n;
|
||||
}
|
||||
ui.end_row();
|
||||
let initials_label = ui.label(tl!("Initials:"));
|
||||
let mut i = app.session.initials.clone();
|
||||
let hint = app.session.user_initials();
|
||||
if ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut i).desired_width(80.0).char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS).hint_text(hint),
|
||||
)
|
||||
.labelled_by(initials_label.id)
|
||||
.changed()
|
||||
{
|
||||
set(app, json!({"initials": i}));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
note(ui, "New comments and tracked changes are recorded under this name and these initials.");
|
||||
}
|
||||
@@ -337,13 +313,15 @@ fn proofing(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Custom dictionary");
|
||||
custom_dictionary(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Dictionaries");
|
||||
dictionaries(app, ui);
|
||||
}
|
||||
|
||||
/// Where the spelling dictionaries come from (#1063): a folder searched before the system's
|
||||
/// usual places, saved with the settings. An app started from Finder or the Dock doesn't see
|
||||
/// `WORDCRAFT_DICTIONARIES`, so this is how it finds a dictionary kept in a folder of the user's
|
||||
/// own. Each language's status ("Your own dictionary") is in Your languages above, and the file
|
||||
/// each language found is under Dictionary files.
|
||||
/// usual places, saved with the settings, and the dictionary each language found. An app started
|
||||
/// from Finder or the Dock doesn't see `WORDCRAFT_DICTIONARIES`, so this is how it finds a
|
||||
/// dictionary kept in a folder of the user's own.
|
||||
fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
|
||||
let saved = app.session.prefs.dictionaries_folder.clone().unwrap_or_default();
|
||||
let id = egui::Id::unique("options_dictionaries_folder");
|
||||
@@ -371,15 +349,6 @@ fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, folder));
|
||||
note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's.");
|
||||
note(ui, "An installed Language pack is used after this folder.");
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(tl!("Dictionary files")).strong());
|
||||
dictionary_files(ui);
|
||||
}
|
||||
|
||||
/// The dictionary each language found (#1063): its file, "no dictionary found", or why the file
|
||||
/// couldn't be read, looked up again after the folder changes.
|
||||
fn dictionary_files(ui: &mut Ui) {
|
||||
// The dictionary each language found, looked up again after the folder changes.
|
||||
let found_id = egui::Id::unique("options_dictionaries_found");
|
||||
let generation = wordcraft_engine::proof::dictionary_generation();
|
||||
@@ -400,11 +369,10 @@ fn dictionary_files(ui: &mut Ui) {
|
||||
};
|
||||
for (language, file, error) in found {
|
||||
let line = match (file, error) {
|
||||
(Some(f), Some(e)) => crate::i18n::fmt(
|
||||
tl!("{language}: {file} (unreadable: {reason})"),
|
||||
&[("language", &language), ("file", &crate::i18n::isolate_path(&f)), ("reason", &e)],
|
||||
),
|
||||
(Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &crate::i18n::isolate_path(&f))]),
|
||||
(Some(f), Some(e)) => {
|
||||
crate::i18n::fmt(tl!("{language}: {file} (unreadable: {reason})"), &[("language", &language), ("file", &f), ("reason", &e)])
|
||||
}
|
||||
(Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &f)]),
|
||||
(None, _) => crate::i18n::fmt(tl!("{language}: no dictionary found"), &[("language", &language)]),
|
||||
};
|
||||
ui.add(egui::Label::new(RichText::new(line).small()).truncate());
|
||||
@@ -481,7 +449,7 @@ fn save(app: &mut WordApp, ui: &mut Ui) {
|
||||
// The browser can't write to the user's files, so AutoSave can't be turned on there (#176).
|
||||
let browser = app.autosave_block() == Some(crate::AutoSaveBlock::Browser);
|
||||
let mut autosave = app.autosave && !browser;
|
||||
let r = ui.add_enabled_ui(!browser, |ui| ui.checkbox(&mut autosave, tl!("AutoSave documents you have saved in WordCraft"))).inner;
|
||||
let r = ui.add_enabled(!browser, egui::Checkbox::new(&mut autosave, tl!("AutoSave documents you have saved in WordCraft")));
|
||||
if browser {
|
||||
r.on_disabled_hover_text(crate::AutoSaveBlock::Browser.reason());
|
||||
} else if r.changed() {
|
||||
@@ -526,11 +494,7 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
|
||||
};
|
||||
ui.label(RichText::new(text).small().weak());
|
||||
ui.add_space(8.0);
|
||||
crate::language_packs::your_languages_section(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Dictionaries");
|
||||
dictionaries(app, ui);
|
||||
crate::language_packs::add_language_window(app, &ui.ctx().clone());
|
||||
crate::language_packs::options_section(app, ui);
|
||||
}
|
||||
|
||||
/// Draws Accessibility options: the animation setting and Read Aloud speed and skip-citations.
|
||||
@@ -573,61 +537,51 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Display");
|
||||
crate::rtl::Table::new("options_display").columns(2).spacing(vec2(8.0, 6.0)).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let recent_label = r.cell(|ui| ui.label(tl!("Recent documents to show:")));
|
||||
r.cell(|ui| {
|
||||
let mut n = app.ui.recent_shown();
|
||||
if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() {
|
||||
app.ui.recent_count = n;
|
||||
egui::Grid::new("options_display").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
let recent_label = ui.label(tl!("Recent documents to show:"));
|
||||
let mut n = app.ui.recent_shown();
|
||||
if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() {
|
||||
app.ui.recent_count = n;
|
||||
}
|
||||
ui.end_row();
|
||||
let units_label = ui.label(tl!("Measurement units:"));
|
||||
// The user's choice, else the region's (#838): Automatic follows the region.
|
||||
let units = app.session.units();
|
||||
let auto = app.session.prefs.units.is_none();
|
||||
let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n);
|
||||
let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() };
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(auto, tl!("Automatic")).clicked() {
|
||||
set(app, json!({"units": "auto"}));
|
||||
}
|
||||
for (u, label) in UNITS {
|
||||
if ui.selectable_label(!auto && units == u, tl!(label)).clicked() {
|
||||
set(app, json!({"units": u}));
|
||||
}
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let units_label = r.cell(|ui| ui.label(tl!("Measurement units:")));
|
||||
r.cell(|ui| {
|
||||
// The user's choice, else the region's (#838): Automatic follows the region.
|
||||
let units = app.session.units();
|
||||
let auto = app.session.prefs.units.is_none();
|
||||
let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n);
|
||||
let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() };
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(auto, tl!("Automatic")).clicked() {
|
||||
set(app, json!({"units": "auto"}));
|
||||
}
|
||||
for (u, label) in UNITS {
|
||||
if ui.selectable_label(!auto && units == u, tl!(label)).clicked() {
|
||||
set(app, json!({"units": u}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(units_label.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(units_label.id);
|
||||
ui.end_row();
|
||||
// The region new documents, units and the decimal separator follow; Automatic is the
|
||||
// system's (#838).
|
||||
t.row(|r| {
|
||||
let region_label = r.cell(|ui| ui.label(tl!("Region:")));
|
||||
r.cell(|ui| {
|
||||
let chosen = app.session.prefs.region.clone();
|
||||
let system = &app.session.system_region;
|
||||
let system_tag =
|
||||
if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) };
|
||||
let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]);
|
||||
let shown = chosen.clone().unwrap_or_else(|| automatic.clone());
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() {
|
||||
set(app, json!({"region": "auto"}));
|
||||
}
|
||||
for tag in REGIONS {
|
||||
if ui.selectable_label(chosen.as_deref() == Some(tag), tag).clicked() {
|
||||
set(app, json!({"region": tag}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(region_label.id);
|
||||
});
|
||||
let region_label = ui.label(tl!("Region:"));
|
||||
let chosen = app.session.prefs.region.clone();
|
||||
let system = &app.session.system_region;
|
||||
let system_tag = if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) };
|
||||
let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]);
|
||||
let shown = chosen.clone().unwrap_or_else(|| automatic.clone());
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() {
|
||||
set(app, json!({"region": "auto"}));
|
||||
}
|
||||
for tag in REGIONS {
|
||||
if ui.selectable_label(chosen.as_deref() == Some(tag), tag).clicked() {
|
||||
set(app, json!({"region": tag}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(region_label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
let mut keytips = app.ui.keytips_enabled;
|
||||
if ui.checkbox(&mut keytips, tl!("Show KeyTips (Alt or F10)")).changed() {
|
||||
@@ -724,10 +678,7 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::theme::InterfaceSize;
|
||||
let size = app.ui.interface_size;
|
||||
let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0);
|
||||
// The template keeps the percent sign outside its placeholder, so `fmt` isolates the number
|
||||
// without it and a right-to-left line draws "(%200)": the sign goes inside the isolate.
|
||||
let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())])
|
||||
.replace("\u{2069}%", "%\u{2069}");
|
||||
let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())]);
|
||||
let selected = size.percent().map_or_else(|| auto.clone(), |pct| format!("{pct}%"));
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Interface size:"));
|
||||
@@ -784,7 +735,6 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::i18n::{AUTO, Lang};
|
||||
// The language names are in their own scripts: fonts for them load from the next frame.
|
||||
app.want_system_cjk = true;
|
||||
app.want_system_arabic = true;
|
||||
let system = crate::i18n::system_lang();
|
||||
let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]);
|
||||
let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() };
|
||||
|
||||
@@ -259,13 +259,8 @@ fn options_panes_open_and_change_settings() {
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("autoCorrect"));
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
// #1063: the dictionaries folder and the dictionary each language found moved to Language
|
||||
// (CP4.3); Proofing has no Dictionaries section.
|
||||
assert!(h.query_by_label("Dictionaries folder:").is_none() && h.query_by_label_contains("Polish: ").is_none());
|
||||
h.get_by_label("Language").click();
|
||||
h.run_steps(4);
|
||||
// #1063: the dictionaries folder and the dictionary each language found.
|
||||
assert!(h.query_by_label("Dictionaries folder:").is_some());
|
||||
assert!(h.query_by_label("Dictionary files").is_some());
|
||||
assert!(h.query_by_label_contains("Polish: ").is_some(), "the Polish dictionary's line");
|
||||
h.get_by_label("Advanced").click();
|
||||
h.run_steps(4);
|
||||
|
||||
@@ -27,12 +27,8 @@ mod state;
|
||||
#[cfg(test)]
|
||||
pub(crate) use comments::comment_draft;
|
||||
use comments::comments;
|
||||
pub(crate) use comments::{
|
||||
COMMENT_ROWS, CommentDrafts, comment_editor, commit_comment, commit_reply, flush_comment_drafts, has_comment_drafts, reply_editor,
|
||||
};
|
||||
pub(crate) use comments::{CommentDrafts, comment_editor, commit_comment, commit_reply, flush_comment_drafts, has_comment_drafts, reply_editor};
|
||||
use host::Content;
|
||||
#[cfg(test)]
|
||||
pub(crate) use host::apply;
|
||||
pub use host::show;
|
||||
pub(crate) use host::{dock_command, overlay_open};
|
||||
pub(crate) use inspector::describe_props;
|
||||
@@ -157,7 +153,7 @@ impl PaneContent<Pane> for Content<'_> {
|
||||
|
||||
fn body(&mut self, ui: &mut Ui, pane: &Pane) {
|
||||
let app = &mut *self.app;
|
||||
crate::rtl::root(ui, |ui| match pane {
|
||||
match pane {
|
||||
Pane::SmartArtText => crate::dialogs_smart_art::text_pane(app, ui),
|
||||
Pane::Navigation => nav(app, ui),
|
||||
Pane::Clipboard => clipboard(app, ui),
|
||||
@@ -171,7 +167,7 @@ impl PaneContent<Pane> for Content<'_> {
|
||||
Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui),
|
||||
Pane::Proofing => crate::proofing_pane::show(app, ui),
|
||||
Pane::Comments => comments(app, ui),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn default_state(&self, pane: &Pane) -> PaneState {
|
||||
|
||||
@@ -119,17 +119,16 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
|
||||
let mut text = draft.unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part)));
|
||||
// Read-only where comments can't change (a protected document), so nothing is typed in vain.
|
||||
let editable = crate::widgets::enabled(app, "review.editComment");
|
||||
let field = field_id(app, ui, "comment_edit", id);
|
||||
let mut edit = egui::TextEdit::multiline(&mut text)
|
||||
.id(field)
|
||||
.id(field_id(app, ui, "comment_edit", id))
|
||||
.interactive(editable)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(tl!("Add a comment"));
|
||||
if let Some(f) = &font {
|
||||
edit = edit.font(f.clone());
|
||||
if let Some(f) = font {
|
||||
edit = edit.font(f);
|
||||
}
|
||||
let r = capped_field(ui, field.with("scroll"), font.as_ref(), edit);
|
||||
let r = ui.add(edit);
|
||||
if focus {
|
||||
r.request_focus();
|
||||
}
|
||||
@@ -147,24 +146,8 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines of a comment's text that its card or balloon shows; longer text scrolls in its field and
|
||||
/// is cut off in a balloon that isn't selected (#3382).
|
||||
pub(crate) const COMMENT_ROWS: usize = 10;
|
||||
|
||||
/// The height a text field's frame and padding add to its text, in points.
|
||||
const FIELD_FRAME: f32 = 4.0;
|
||||
|
||||
/// A comment or reply field at most [`COMMENT_ROWS`] lines tall. Longer text scrolls inside it,
|
||||
/// and the field keeps its caret in view.
|
||||
fn capped_field(ui: &mut Ui, scroll_id: egui::Id, font: Option<&egui::FontId>, edit: egui::TextEdit<'_>) -> egui::Response {
|
||||
let font = font.cloned().unwrap_or_else(|| egui::FontSelection::Default.resolve(ui.style()));
|
||||
let row = ui.fonts_mut(|f| f.row_height(&font));
|
||||
let max = row * COMMENT_ROWS as f32 + FIELD_FRAME;
|
||||
egui::ScrollArea::vertical().id_salt(scroll_id).max_height(max).auto_shrink([false, true]).show(ui, |ui| ui.add(edit)).inner
|
||||
}
|
||||
|
||||
/// A comment field's widget id, per document: egui's own state for the field (its cursor, undo
|
||||
/// history and scroll position) never carries over to another document's comment with the same id.
|
||||
/// A comment field's widget id, per document: egui's own state for the field (its cursor and
|
||||
/// undo history) never carries over to another document's comment with the same id.
|
||||
fn field_id(app: &WordApp, ui: &Ui, kind: &str, id: u32) -> egui::Id {
|
||||
ui.scope_id().with((kind, app.session.document_id(), id))
|
||||
}
|
||||
@@ -196,17 +179,16 @@ fn thread_root(app: &WordApp, id: u32) -> u32 {
|
||||
pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option<egui::FontId>, focus: bool) -> bool {
|
||||
let mut text = drafts(app).replies.get(&id).cloned().unwrap_or_default();
|
||||
let editable = crate::widgets::enabled(app, "review.reply");
|
||||
let field = field_id(app, ui, "comment_reply", id);
|
||||
let mut edit = egui::TextEdit::multiline(&mut text)
|
||||
.id(field)
|
||||
.id(field_id(app, ui, "comment_reply", id))
|
||||
.interactive(editable)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(tl!("Write a reply"));
|
||||
if let Some(f) = &font {
|
||||
edit = edit.font(f.clone());
|
||||
if let Some(f) = font {
|
||||
edit = edit.font(f);
|
||||
}
|
||||
let r = capped_field(ui, field.with("scroll"), font.as_ref(), edit);
|
||||
let r = ui.add(edit);
|
||||
if focus {
|
||||
r.request_focus();
|
||||
}
|
||||
|
||||
@@ -74,8 +74,6 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest<Pane>) {
|
||||
match request {
|
||||
PaneRequest::Close(pane) => close(app, pane),
|
||||
PaneRequest::Place(Pane::Styles, place) => {
|
||||
// Physical sides: the host is not RTL-flipped, and Dock Left / Dock Right mean the
|
||||
// window's left and right (same as `ui.paneDock` / `view.stylesPaneDock`).
|
||||
let dock = match place {
|
||||
Place::Docked(Edge::Start) => "left",
|
||||
Place::Docked(Edge::End) => "right",
|
||||
@@ -140,7 +138,6 @@ pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result<Value, String
|
||||
if let Some(dock) = dock {
|
||||
app.ui.pane_places.entry(key.into()).or_default().dock = Some(dock);
|
||||
}
|
||||
// Report the physical side the pane occupies (matches what was stored / requested).
|
||||
let name = match pane_state(app, pane).place {
|
||||
Place::Docked(Edge::Start) => "left",
|
||||
Place::Floating => "float",
|
||||
|
||||
@@ -84,7 +84,7 @@ fn level_row(ui: &mut Ui, t: &Tokens, caption: &str, value: &str, dim: bool, tip
|
||||
let text = egui::RichText::new(value).font(regular(12.5)).color(if dim { t.text_disabled } else { t.text });
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
});
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Min), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), egui::Sense::click());
|
||||
let enabled = !disabled;
|
||||
if enabled && resp.hovered() {
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(super) fn clipboard(app: &mut WordApp, ui: &mut Ui) {
|
||||
let _ = app.run("edit.pasteClipboardItem", json!({"index": i}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Min), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
if ui.small_button(tl!("Delete")).clicked() {
|
||||
let _ = app.run("edit.deleteClipboardItem", json!({"index": i}));
|
||||
app.canvas.want_focus = true;
|
||||
@@ -87,14 +87,14 @@ pub(super) fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
for (id, name, is_char, builtin) in list {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.selectable_label(id == cur, &name);
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(if is_char { "a" } else { "¶" }).weak());
|
||||
});
|
||||
if r.clicked() {
|
||||
let _ = app.run("para.style", json!({"style": id}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
crate::rtl::context_menu(&r, |ui| {
|
||||
r.context_menu(|ui| {
|
||||
if ui.button(tl!("Modify…")).clicked() {
|
||||
app.dialog = crate::dialogs::Dialog::modify_style(app, &id);
|
||||
ui.close();
|
||||
|
||||
@@ -114,46 +114,19 @@ pub(crate) fn float_rect(v: &Value) -> Option<[f32; 4]> {
|
||||
|
||||
/// A pane's place and size: Styles' from `view.stylesPaneDock` and its own preferences, the
|
||||
/// others' from `panePlaces`.
|
||||
///
|
||||
/// `left` / `right` in the docking API and in saved prefs are **physical** window sides (and the
|
||||
/// Dock Left / Dock Right menus mean the same). Only an unset default place is mirrored in a
|
||||
/// right-to-left interface, so a pane that has never been docked still opens on the reading-start
|
||||
/// side.
|
||||
pub(super) fn pane_state(app: &WordApp, pane: Pane) -> PaneState {
|
||||
let (mut state, explicit) = stored_state(app, pane);
|
||||
if !explicit {
|
||||
state.place = mirror_default(state.place);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
/// Flip a default dock edge in a right-to-left interface so Start (reading start in LTR) opens on
|
||||
/// the physical right. Explicit and saved left/right are never flipped — see [`pane_state`].
|
||||
pub(super) fn mirror_default(place: Place) -> Place {
|
||||
match place {
|
||||
Place::Docked(Edge::Start) if crate::rtl::on() => Place::Docked(Edge::End),
|
||||
Place::Docked(Edge::End) if crate::rtl::on() => Place::Docked(Edge::Start),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical place from prefs, and whether that place was explicitly saved (`true`) or is still the
|
||||
/// pane's default (`false`).
|
||||
fn stored_state(app: &WordApp, pane: Pane) -> (PaneState, bool) {
|
||||
if pane == Pane::Styles {
|
||||
return (styles_state(app), true);
|
||||
return styles_state(app);
|
||||
}
|
||||
let default = pane.default_state();
|
||||
let Some(saved) = app.ui.pane_places.get(pane.key()) else {
|
||||
return (default, false);
|
||||
let Some(saved) = app.ui.pane_places.get(pane.key()) else { return default };
|
||||
let place = match saved.dock {
|
||||
Some(SavedDock::Left) => Place::Docked(Edge::Start),
|
||||
Some(SavedDock::Right) => Place::Docked(Edge::End),
|
||||
Some(SavedDock::Float) => Place::Floating,
|
||||
None => default.place,
|
||||
};
|
||||
let (place, explicit) = match saved.dock {
|
||||
Some(SavedDock::Left) => (Place::Docked(Edge::Start), true),
|
||||
Some(SavedDock::Right) => (Place::Docked(Edge::End), true),
|
||||
Some(SavedDock::Float) => (Place::Floating, true),
|
||||
None => (default.place, false),
|
||||
};
|
||||
(PaneState { place, size: saved.width.or(default.size), float_rect: saved.float }, explicit)
|
||||
PaneState { place, size: saved.width.or(default.size), float_rect: saved.float }
|
||||
}
|
||||
|
||||
/// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user