Compare commits

..
125 changed files with 1359 additions and 5141 deletions
Generated
+6 -35
View File
@@ -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"
@@ -7050,7 +7017,6 @@ dependencies = [
"craft-update",
"craft-window",
"egui",
"egui_extras",
"egui_kittest",
"image",
"js-sys",
@@ -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"
+3 -4
View File
@@ -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" }
-26
View File
@@ -1,26 +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)
+1 -1
View File
@@ -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 -17
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
059cbf95c2a3eb95d637ca8584faca1129d17f48
-5
View File
@@ -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)
-18
View File
@@ -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
+10
View File
@@ -18,6 +18,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -87,6 +93,10 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
+5 -7
View File
@@ -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 {
+8 -26
View File
@@ -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));
}
}
}
-43
View File
@@ -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):
-197
View File
@@ -229,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"));
@@ -501,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;
}
+9 -181
View File
@@ -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,113 +692,6 @@ pub fn missing(s: &Session) -> Vec<Pack> {
out
}
/// 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 {
+1 -3
View File
@@ -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
View File
@@ -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());
-33
View File
@@ -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);
}
+1 -48
View File
@@ -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
+3 -13
View File
@@ -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
}
+1 -54
View File
@@ -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> {
-3
View File
@@ -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};
+2 -54
View File
@@ -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());
}
}
-4
View File
@@ -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;
}
}
+9 -13
View File
@@ -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);
}
+8 -355
View File
@@ -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");
}
+9 -68
View File
@@ -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
}
-1
View File
@@ -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 }
-3
View File
@@ -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,
+1 -14
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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)]));
});
}
+1 -3
View File
@@ -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 };
+9 -29
View File
@@ -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
+20 -36
View File
@@ -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!({}));
-84
View File
@@ -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
View File
@@ -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()));
}
}
});
});
}
-11
View File
@@ -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");
}
+4 -4
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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]);
}
}
+6 -6
View File
@@ -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();
});
});
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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();
+14 -16
View File
@@ -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));
+8 -11
View File
@@ -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") {
+7 -7
View File
@@ -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);
+12 -12
View File
@@ -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;
+12 -12
View File
@@ -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();
});
});
+130 -168
View File
@@ -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) {
+3 -3
View File
@@ -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);
+7 -7
View File
@@ -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);
+5 -4
View File
@@ -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);
+11 -15
View File
@@ -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();
});
}
-6
View File
@@ -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,
};
+9 -9
View File
@@ -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,
+4 -16
View File
@@ -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();
+2 -2
View File
@@ -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
}
+3 -3
View File
@@ -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) {
+3 -59
View File
@@ -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
-234
View File
@@ -597,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);
}
-110
View File
@@ -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}");
}
+57 -480
View File
@@ -75,39 +75,21 @@ pub(crate) struct Runtime {
/// Failures the window had itself (turning downloading on, starting, removing), by locale
/// ("" for the list), shown until the next try. The engine keeps the installs' own.
errors: HashMap<String, String>,
/// (document, pack locale) pairs whose bar was closed.
/// (document, locale) pairs whose bar was closed.
dismissed: HashSet<(u64, String)>,
/// (document, language tag) pairs whose bar was closed before the list was known: the pack
/// the list then has for the tag stays closed.
dismissed_tags: HashSet<(u64, String)>,
/// What the bar offers for the shown document, what it was worked out from, and when.
missing: Option<(MissingKey, f64, Offers)>,
/// The missing packs of the shown document, what they were worked out from, and when.
missing: Option<(MissingKey, f64, Vec<Pack>)>,
/// [`packs::finished`] when last looked at.
finished: u64,
/// The person clicked the bar's Install before the list was known: the list's failure then
/// shows in the bar (an automatic fetch's stays in Options).
asked: bool,
}
/// What the bar can offer.
#[derive(Clone, Default)]
struct Offers {
/// Available packs the document asks for ([`packs::missing`]).
packs: Vec<Pack>,
/// Languages it asks for while there is no list yet ([`packs::unlisted`]).
unlisted: Vec<String>,
}
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone, Copy, PartialEq, Eq)]
struct MissingKey {
document: u64,
rev: u64,
generation: u64,
/// [`packs::index_generation`]: the list changed.
packs: u64,
/// The caret's typing language ([`packs::caret_language`]): choosing a language with nothing
/// selected changes no text.
caret: Option<String>,
}
/// Bytes as people read them: `1.9 MB`, `640 KB`.
@@ -121,49 +103,56 @@ pub(crate) fn size_text(bytes: u64) -> String {
}
}
/// English names of the Language pack languages the Language dialog's list doesn't have, by the
/// tag a document stores for them ([`crate::ribbon::tag_for_pack`]): every pack in the published
/// index has a name in the interface language (CP4.3: Belarusian showed as Беларуская).
pub(crate) const MORE_LANGUAGES: [(&str, &str); 28] = [
("af-ZA", "Afrikaans"),
("be-BY", "Belarusian"),
("bg-BG", "Bulgarian"),
("bs-BA", "Bosnian"),
("ca-ES", "Catalan"),
("ca-ES-valencia", "Catalan (Valencian)"),
("cy-GB", "Welsh"),
("eo", "Esperanto"),
("fa-IR", "Persian"),
("ga-IE", "Irish"),
("gl-ES", "Galician"),
("hi-IN", "Hindi"),
("hu-HU", "Hungarian"),
("hy-AM", "Armenian"),
("is-IS", "Icelandic"),
("ka-GE", "Georgian"),
("lt-LT", "Lithuanian"),
("lv-LV", "Latvian"),
("mk-MK", "Macedonian"),
("nn-NO", "Norwegian (Nynorsk)"),
("ro-RO", "Romanian"),
("sk-SK", "Slovak"),
("sl-SI", "Slovenian"),
("sq-AL", "Albanian"),
("sw-KE", "Swahili"),
("ta-IN", "Tamil"),
("th-TH", "Thai"),
("vi-VN", "Vietnamese"),
/// English names of pack languages the Language dialog's list doesn't have.
const MORE_LANGUAGES: [(&str, &str); 14] = [
("bg", "Bulgarian"),
("bs", "Bosnian"),
("ca", "Catalan"),
("ga", "Irish"),
("hi", "Hindi"),
("lt", "Lithuanian"),
("lv", "Latvian"),
("ro", "Romanian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sq", "Albanian"),
("sw", "Swahili"),
("ta", "Tamil"),
("vi", "Vietnamese"),
];
/// The language a pack is for, in the interface language (`Dutch`, `Serbian (Latin)`): the name
/// of the tag text in it is marked with ([`crate::ribbon::tag_for_pack`]), as the status bar and
/// the Language dialog name it; else the pack's own name; else its locale.
/// The language a pack is for, in the interface language (`Dutch`, `Serbian (Latin)`), else its
/// own name.
pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String {
let tag = crate::ribbon::tag_for_pack(pack_locale);
if !crate::ribbon::language_name(Some(&tag)).eq_ignore_ascii_case(&tag) {
return crate::ribbon::display_name(Some(&tag));
let langs = crate::dialogs_layout::LANGUAGES;
let exact = langs.iter().find(|(t, _)| t.eq_ignore_ascii_case(pack_locale)).map(|(_, n)| *n);
// `sr-latn` is `sr-Latn-RS` (Serbian (Latin)); a bare `nl` is the language without the region.
let prefixed = || {
langs
.iter()
.find(|(t, _)| {
t.len() > pack_locale.len()
&& t.get(..pack_locale.len()).is_some_and(|p| p.eq_ignore_ascii_case(pack_locale))
&& t.as_bytes().get(pack_locale.len()) == Some(&b'-')
})
.map(|(_, n)| if pack_locale.contains('-') { n } else { n.split(" (").next().unwrap_or(n) })
};
let base = pack_locale.split('-').next().unwrap_or(pack_locale);
let more = || MORE_LANGUAGES.iter().find(|(t, _)| *t == base).map(|(_, n)| *n);
if let Some(n) = exact.or_else(prefixed) {
return tl!(n).to_string();
}
// A language with a region the list lacks (`de-ch`: German (Switzerland)), as the status bar
// names it.
let named = crate::ribbon::language_name(Some(pack_locale));
if !named.eq_ignore_ascii_case(pack_locale) {
return tl!(&named).to_string();
}
match more() {
Some(n) => tl!(n).to_string(),
None if !endonym.is_empty() => endonym.to_string(),
None => pack_locale.to_string(),
}
if endonym.is_empty() { pack_locale.to_string() } else { endonym.to_string() }
}
/// Once per frame: installs and fetches run on the engine's thread, which repaints the window as
@@ -176,8 +165,6 @@ pub(crate) fn tick(app: &mut WordApp) {
{
packs::set_background(Some(Arc::new(move || ctx.request_repaint())));
}
// Downloading already on and no list (none cached, or one for an older pin): fetch it once.
packs::refresh_if_stale();
let finished = packs::finished();
if finished != app.packs_rt.finished {
app.packs_rt.finished = finished;
@@ -264,45 +251,25 @@ fn remove(app: &mut WordApp, locale: &str) {
}
}
/// What the bar offers for the shown document. Worked out again when the document, the caret's
/// language, the packs or the list changed; while it is being typed in, at most once a second.
fn missing(app: &mut WordApp, now: f64) -> Offers {
/// The packs the shown document asks for. Worked out again when the document, the packs or the
/// list changed; while it is being typed in, at most once a second.
fn missing(app: &mut WordApp, now: f64) -> Vec<Pack> {
let key = MissingKey {
document: app.session.document_id(),
rev: app.session.rev(),
generation: wordcraft_engine::proof::dictionary_generation(),
packs: packs::index_generation(),
caret: packs::caret_language(&app.session),
};
if let Some((k, at, m)) = &app.packs_rt.missing
&& (*k == key || (MissingKey { rev: key.rev, ..k.clone() } == key && now - at < MISSING_REFRESH_SECS))
&& (*k == key || (MissingKey { rev: key.rev, ..*k } == key && now - at < MISSING_REFRESH_SECS))
{
return m.clone();
}
let packs = packs::missing(&app.session);
let unlisted = if packs.is_empty() { packs::unlisted(&app.session) } else { Vec::new() };
let m = Offers { packs, unlisted };
let m = packs::missing(&app.session);
app.packs_rt.missing = Some((key, now, m.clone()));
m
}
/// Get `tag`'s pack before the list is known: the person clicked the bar's Install. Their click
/// turns on downloading ([`consent`]); the list is fetched, then the pack installed if it lists
/// one (on the engine's thread).
fn start_get(app: &mut WordApp, ctx: &egui::Context, tag: &str) {
app.packs_rt.errors.remove("");
if !consent(app, ctx, "") {
return;
}
app.packs_rt.asked = true;
if let Err(e) = packs::start_get(tag) {
app.packs_rt
.errors
.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))]));
}
app.packs_rt.missing = None;
}
/// The missing-pack bar under the ribbon: "<Language> proofing isn't installed. Install (<size>)",
/// for the first language of the document with an available pack that isn't installed. Closing it
/// hides it for that language in this document. Never shown for a language with no pack.
@@ -312,13 +279,7 @@ pub(crate) fn bar(app: &mut WordApp, ui: &mut Ui) {
}
let document = app.session.document_id();
let now = ui.input(|i| i.time);
let offers = missing(app, now);
// The whole list, for a tag closed before it was known (only then: it is a copy).
let index = if app.packs_rt.dismissed_tags.is_empty() { Vec::new() } else { packs::available() };
let Some(pack) = offers.packs.into_iter().find(|p| !dismissed(app, document, p, &index)) else {
if let Some(tag) = offers.unlisted.into_iter().find(|t| !app.packs_rt.dismissed_tags.contains(&(document, t.clone()))) {
unlisted_bar(app, ui, &tag);
}
let Some(pack) = missing(app, now).into_iter().find(|p| !app.packs_rt.dismissed.contains(&(document, p.locale.clone()))) else {
return;
};
let t = Tokens::get(ui.ctx());
@@ -357,67 +318,6 @@ pub(crate) fn bar(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Whether the bar for `pack` was closed in `document`: for the pack itself, or for a language
/// tag it serves, closed before the list was known (`kl-GL` for `kl`).
fn dismissed(app: &WordApp, document: u64, pack: &Pack, index: &[Pack]) -> bool {
// A closed pack is that pack alone (closing `de-ch` leaves `de`); a closed tag is the pack it
// finds in the whole list (`de-CH` is `de-ch`'s, not `de`'s, once the list has both).
app.packs_rt.dismissed.contains(&(document, pack.locale.clone()))
|| app
.packs_rt
.dismissed_tags
.iter()
.any(|(d, tag)| *d == document && packs::pack_for_tag(tag, index).is_some_and(|p| p.locale == pack.locale))
}
/// The bar for a language the document asks for while there is no list of packs yet (a fresh
/// install, or a list cached for an older pin): "<Language> proofing isn't installed. Install".
/// Install fetches the list, then installs the language's pack if it has one; if not, the bar
/// goes. Hidden while an automatic fetch of the list runs.
fn unlisted_bar(app: &mut WordApp, ui: &mut Ui, tag: &str) {
let fetching = packs::fetching();
if fetching && !app.packs_rt.asked {
return;
}
let document = app.session.document_id();
let t = Tokens::get(ui.ctx());
egui::Panel::top("language_pack_bar")
.exact_size(36.0)
.show_separator_line(false)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(12, 0)))
.show(ui, |ui| {
let bottom = ui.max_rect().bottom();
let full = ui.max_rect();
ui.painter().hline(full.x_range(), bottom - 0.5, egui::Stroke::new(1.0, t.border));
ui.horizontal_centered(|ui| {
let language = shown(&language_of(&tag.to_ascii_lowercase(), ""), MAX_BAR_NAME);
ui.label(RichText::new(crate::i18n::fmt(tl!("{language} proofing isn't installed."), &[("language", &language)])).color(t.text));
if fetching {
ui.spinner();
ui.label(RichText::new(tl!("Getting the list of languages…")).color(t.text_dim));
} else {
let mut hover = vec![tl!("Gets the list of Language packs, then this language's pack if there is one.").to_string()];
if packs::backend().is_some_and(|b| !b.network_on()) {
hover.push(tl!("Installing turns on downloading of fonts and Language packs from GitHub.").to_string());
}
if ui.add(crate::widgets::primary_button(ui, tl!("Install").to_string())).on_hover_text(hover.join("\n")).clicked() {
start_get(app, &ui.ctx().clone(), tag);
}
}
// The window's own failure (consent), else the list's when the person asked.
let e = app.packs_rt.errors.get("").cloned().or_else(|| if app.packs_rt.asked { error_of(app, "") } else { None });
if let Some(e) = e {
ui.add(egui::Label::new(RichText::new(e).color(t.red)).truncate());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "close", tl!("Not now")).clicked() {
app.packs_rt.dismissed_tags.insert((document, tag.to_string()));
}
});
});
});
}
/// The bar's Install tooltip: the licence and source, that it turns on downloading when it is
/// off, and the test source when packs come from one.
fn licence_hover(pack: &Pack) -> String {
@@ -437,8 +337,6 @@ fn licence_hover(pack: &Pack) -> String {
/// File › Options › Language › Language packs: every available and installed pack with its
/// name, size, licence and source, and Install or Remove.
pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) {
// Pack names include Arabic and other scripts; ask for a system Arabic face when none is embedded.
app.want_system_arabic = true;
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(tl!("Language packs")).font(semibold(15.0)));
ui.add_space(2.0);
@@ -509,7 +407,7 @@ pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) {
let mut install = None;
let mut removal = None;
egui::ScrollArea::vertical().id_salt("options_language_packs").max_height(260.0).show(ui, |ui| {
crate::rtl::grid(ui, egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true), |ui| {
egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true).show(ui, |ui| {
for h in ["Language", "Size", "Licence", "Source", ""] {
ui.label(RichText::new(if h.is_empty() { "" } else { tl!(h) }).small().color(t.text_dim));
}
@@ -686,209 +584,6 @@ mod tests {
/// Tests that set the process-wide pack backend take turns.
static BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Every locale of the published index (`language-packs-2/index.json`), with its own name.
const INDEX_LOCALES: [(&str, &str); 75] = [
("af", "Afrikaans"),
("ar", "العربية"),
("be", "Беларуская"),
("bg", "Български"),
("bs", "Bosanski"),
("ca", "Català"),
("ca-valencia", "Català (valencià)"),
("cs", "Čeština"),
("cy", "Cymraeg"),
("da", "Dansk"),
("de", "Deutsch (Deutschland)"),
("de-at", "Deutsch (Österreich)"),
("de-ch", "Deutsch (Schweiz)"),
("el", "Ελληνικά"),
("eo", "Esperanto"),
("es", "Español (España)"),
("es-ar", "Español (Argentina)"),
("es-bo", "Español (Bolivia)"),
("es-cl", "Español (Chile)"),
("es-co", "Español (Colombia)"),
("es-cr", "Español (Costa Rica)"),
("es-cu", "Español (Cuba)"),
("es-do", "Español (República Dominicana)"),
("es-ec", "Español (Ecuador)"),
("es-gq", "Español (Guinea Ecuatorial)"),
("es-gt", "Español (Guatemala)"),
("es-hn", "Español (Honduras)"),
("es-mx", "Español (México)"),
("es-ni", "Español (Nicaragua)"),
("es-pa", "Español (Panamá)"),
("es-pe", "Español (Perú)"),
("es-ph", "Español (Filipinas)"),
("es-pr", "Español (Puerto Rico)"),
("es-py", "Español (Paraguay)"),
("es-sv", "Español (El Salvador)"),
("es-us", "Español (Estados Unidos)"),
("es-uy", "Español (Uruguay)"),
("es-ve", "Español (Venezuela)"),
("et", "Eesti"),
("fa", "فارسی"),
("fr", "Français"),
("ga", "Gaeilge"),
("gl", "Galego"),
("he", "עברית"),
("hi", "\u{939}\u{93f}\u{928}\u{94d}\u{926}\u{940}"),
("hr", "Hrvatski"),
("hu", "Magyar"),
("hy", "Հայերեն"),
("id", "Bahasa Indonesia"),
("is", "Íslenska"),
("it", "Italiano"),
("ka", "ქართული"),
("ko", "한국어"),
("lt", "Lietuvių"),
("lv", "Latviešu"),
("mk", "Македонски"),
("nb", "Norsk bokmål"),
("nl", "Nederlands"),
("nn", "Norsk nynorsk"),
("pl", "Polski"),
("pt-br", "Português (Brasil)"),
("pt-pt", "Português (Portugal)"),
("ro", "Română"),
("ru", "Русский"),
("sk", "Slovenčina"),
("sl", "Slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sr-latn", "Srpski (latinica)"),
("sv", "Svenska"),
("sv-fi", "Svenska (Finland)"),
("sw", "Kiswahili"),
("ta", "\u{ba4}\u{bae}\u{bbf}\u{bb4}\u{bcd}"),
("tr", "Türkçe"),
("vi", "Tiếng Việt"),
];
fn index_packs() -> Vec<packs::Pack> {
let base = FixedBackend::sample().packs.remove(0);
INDEX_LOCALES.iter().map(|(l, n)| packs::Pack { locale: (*l).into(), name: (*n).into(), ..base.clone() }).collect()
}
/// CP4.3: every pack of the published index has an English name (Belarusian showed as
/// Беларуская, Arabic as its own name), no two packs share one, and a pack's language is
/// named as the status bar names the tag text in it is marked with.
#[test]
fn every_published_pack_has_an_english_name() {
let mut seen = std::collections::HashSet::new();
for (locale, endonym) in INDEX_LOCALES {
let name = super::language_of(locale, endonym);
// In Latin letters (Norwegian (Bokmål)), never the pack's own name in its script.
assert!(name.chars().all(|c| (c as u32) < 0x250) && name != *locale, "{locale}: {name}");
assert!(seen.insert(name.clone()), "{locale}: {name} twice");
assert_eq!(name, crate::ribbon::language_name(Some(&crate::ribbon::tag_for_pack(locale))), "{locale}");
}
assert_eq!(super::language_of("be", "Беларуская"), "Belarusian");
assert_eq!(super::language_of("ar", "العربية"), "Arabic");
assert_eq!(super::language_of("nn", "Norsk nynorsk"), "Norwegian (Nynorsk)");
assert_eq!(super::language_of("es-cu", ""), "Spanish (Cuba)");
// A locale WordCraft has no name for keeps the pack's own name.
assert_eq!(super::language_of("xq", "Xq name"), "Xq name");
}
/// CP4.3: the tag text is marked with when a pack's language is chosen finds that pack again,
/// for every published pack: Bulgarian chosen from its pack is proofed by it, Serbian (Latin)
/// by `sr-latn` rather than the Cyrillic `sr`.
#[test]
fn a_pack_language_tag_finds_its_pack() {
let index = index_packs();
for p in &index {
let tag = crate::ribbon::tag_for_pack(&p.locale);
assert_eq!(packs::pack_for_tag(&tag, &index).map(|f| f.locale.as_str()), Some(p.locale.as_str()), "{} → {tag}", p.locale);
assert!(packs::proofable(&tag), "{tag}");
}
// Valencian is stored as Word writes it, and Word's tag finds the Valencian pack, not the
// Catalan one; Catalan of Spain stays the Catalan pack's.
assert_eq!(crate::ribbon::language_name(Some("ca-ES-valencia")), "Catalan (Valencian)");
assert_eq!(super::language_of("ca-valencia", ""), "Catalan (Valencian)");
assert_eq!(packs::pack_for_tag("ca-ES-valencia", &index).map(|f| f.locale.as_str()), Some("ca-valencia"));
assert_eq!(packs::pack_for_tag("ca-ES", &index).map(|f| f.locale.as_str()), Some("ca"));
for (locale, tag) in [
("ca-valencia", "ca-ES-valencia"),
("ca", "ca-ES"),
("bg", "bg-BG"),
("sr-latn", "sr-Latn-RS"),
("sr", "sr-Cyrl-RS"),
("nl", "nl-NL"),
("de-at", "de-AT"),
("pt-br", "pt-BR"),
("eo", "eo"),
("es", "es-ES"),
] {
assert_eq!(crate::ribbon::tag_for_pack(locale), tag);
}
}
/// CP4.3: the Set Proofing Language dialog lists every installed and available pack's language
/// beside the built-in ones, each once and sorted by name; Review › Language and the status
/// bar's menu add the installed ones to the short list.
#[test]
fn pack_languages_are_in_the_language_lists() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
packs::set_background(None);
packs::set_backend(None);
}
}
let _reset = Reset;
let backend = std::sync::Arc::new(FixedBackend { packs: index_packs(), ..Default::default() });
backend.installed.lock().unwrap().push(packs::Installed {
locale: "bg".into(),
name: "Български".into(),
licence: String::new(),
path: std::path::PathBuf::new(),
});
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
let all = crate::ribbon::all_languages();
for (tag, name) in [
("bg-BG", "Bulgarian"),
("be-BY", "Belarusian"),
("sr-Latn-RS", "Serbian (Latin)"),
("es-CU", "Spanish (Cuba)"),
("ar-SA", "Arabic"),
("hr-HR", "Croatian"),
("ja-JP", "Japanese"),
] {
assert!(all.iter().any(|(t, n)| t == tag && n == name), "{tag} {name}: {all:?}");
}
let mut tags: Vec<String> = all.iter().map(|(t, _)| t.to_ascii_lowercase()).collect();
let n = tags.len();
tags.sort();
tags.dedup();
assert_eq!(tags.len(), n, "each language once");
assert!(all.windows(2).all(|w| crate::ribbon::sort_key(&w[0].1) <= crate::ribbon::sort_key(&w[1].1)), "sorted by name");
assert!(!all.iter().any(|(_, n)| n == "Беларуская" || n == "Български"), "every pack named in the interface language");
// One Catalan row and one Valencian, no stray Catalan (Spain).
assert_eq!(all.iter().filter(|(_, n)| n.starts_with("Catalan")).map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["ca-ES", "ca-ES-valencia"]);
// A bare tag is its regioned row, not a second row of the same name.
for (current, row) in [("nl", "nl-NL"), ("sv", "sv-SE"), ("ca-valencia", "ca-ES-valencia")] {
let list = crate::ribbon::language_list(current);
assert_eq!(list, all, "{current}");
assert_eq!(list.iter().filter(|(t, _)| crate::ribbon::same_language(current, t)).map(|(t, _)| t.as_str()).collect::<Vec<_>>(), [row]);
}
// The dialog shows the same list, and a tag none of them has besides.
assert_eq!(crate::ribbon::language_list("bg-BG"), all);
assert!(crate::ribbon::language_list("xx-YY").iter().any(|(t, _)| t == "xx-YY"));
// The short list: the built-in sixteen, then the installed Bulgarian.
let short = crate::ribbon::short_languages();
assert_eq!(short.len(), crate::ribbon::PROOFING_LANGUAGES.len() + 1);
assert_eq!(short.last().map(|(t, n)| (t.as_str(), n.as_str())), Some(("bg-BG", "Bulgarian")));
// Choosing it marks the text with the tag its pack proofs.
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("document.setText", json!({"text": "Добър ден"})).unwrap();
a.run("select.all", json!({})).unwrap();
a.run("review.language", json!({"lang": "bg-BG"})).unwrap();
assert_eq!(crate::ribbon::caret_language(&a), "Bulgarian");
assert_eq!(packs::pack_for_tag("bg-BG", &packs::available()).map(|p| p.locale.as_str()), Some("bg"));
}
/// Characters a pack index or a server's answer could use to hide, reorder or break text.
fn hostile(c: char) -> bool {
matches!(c, '\u{2028}' | '\u{2029}' | '\u{202E}' | '\u{E0000}'..='\u{E007F}') || (c.is_control() && c != '\n' && c != '\t')
@@ -1027,122 +722,4 @@ mod tests {
}
assert!(packs::is_installed("pl"));
}
/// A font store that never fetched the list (a fresh install): `index` has nothing.
struct Uncached(FixedBackend);
impl Backend for Uncached {
fn index(&self) -> Result<Vec<packs::Pack>, String> {
Ok(Vec::new())
}
fn fetch_index(&self, c: &packs::Cancel) -> Result<Vec<packs::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: &packs::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()
}
}
/// Martin's test of a fresh install: no list fetched, Greenlandic text. The bar still offers
/// it; a faked click leaves downloading off, and the person's click fetches the list and
/// installs the pack.
#[test]
fn with_no_list_yet_the_bar_offers_the_language_and_gets_it() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
ctx.enable_accesskit();
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
packs::set_background(None);
packs::set_backend(None);
}
}
let _reset = Reset;
let mut inner = FixedBackend::sample();
let kl = packs::Pack { locale: "kl".into(), name: "Kalaallisut".into(), ..inner.packs[0].clone() };
inner.packs.push(kl);
let backend = std::sync::Arc::new(Uncached(inner));
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("document.setText", json!({"text": "Aluu, qanoq ippit?"})).unwrap();
a.run("select.all", json!({})).unwrap();
a.run("review.language", json!({"lang": "kl-GL"})).unwrap();
let mut tree = frame(&mut a, &ctx, Vec::new());
for _ in 0..4 {
tree = frame(&mut a, &ctx, Vec::new());
}
assert!(packs::available().is_empty());
let install = spot(&tree, "Install").expect("the bar offers Greenlandic with no list");
as_agent(&mut a, &ctx, "ui.click", json!({"x": install.0, "y": install.1}));
assert!(!backend.0.network.load(Ordering::Relaxed), "a faked click leaves downloading off");
assert!(!packs::fetched() && !packs::is_installed("kl"));
assert!(a.packs_rt.errors.get("").is_some_and(|e| e.contains("Only you")), "{:?}", a.packs_rt.errors);
user_click(&mut a, &ctx, install);
assert!(backend.0.network.load(Ordering::Relaxed), "the person's click turns it on");
for _ in 0..500 {
if packs::is_installed("kl") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(packs::fetched(), "the list was fetched");
assert!(packs::is_installed("kl"), "then its pack installed");
}
/// Review: a language whose bar was closed before the list was known stays closed once the
/// list lists its pack (the dismissal is kept by document tag, the pack bar by pack locale).
#[test]
fn a_language_closed_before_the_list_stays_closed_after_it() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let doc = a.session.document_id();
let kl = packs::Pack { locale: "kl".into(), ..FixedBackend::sample().packs[0].clone() };
let pt_pt = packs::Pack { locale: "pt-pt".into(), ..kl.clone() };
let index = vec![kl.clone(), pt_pt.clone()];
assert!(!super::dismissed(&a, doc, &kl, &index));
a.packs_rt.dismissed_tags.insert((doc, "kl-GL".into()));
assert!(super::dismissed(&a, doc, &kl, &index), "closed as kl-GL: kl stays closed");
assert!(!super::dismissed(&a, doc, &pt_pt, &index), "other languages aren't");
assert!(!super::dismissed(&a, doc.wrapping_add(1), &kl, &index), "nor other documents");
// Closing a regional pack's bar leaves the base language's (Swiss German, then German).
let de = packs::Pack { locale: "de".into(), ..kl.clone() };
let de_ch = packs::Pack { locale: "de-ch".into(), ..kl.clone() };
let index = vec![de.clone(), de_ch.clone()];
a.packs_rt.dismissed.insert((doc, "de-ch".into()));
assert!(super::dismissed(&a, doc, &de_ch, &index));
assert!(!super::dismissed(&a, doc, &de, &index), "German's bar still shows");
// Closed as de-CH before the list was known: Swiss German's pack, not German's.
a.packs_rt.dismissed.clear();
a.packs_rt.dismissed_tags.insert((doc, "de-CH".into()));
assert!(super::dismissed(&a, doc, &de_ch, &index));
assert!(!super::dismissed(&a, doc, &de, &index), "a de-CH tag doesn't close German");
}
/// Martin's test: a spelling issue's context menu heading is in the interface language, not
/// the text's (craft-proof's message for French text is "Faute d'orthographe possible").
#[test]
fn the_spelling_heading_is_in_the_interface_language() {
let french = json!({"kind": "spelling", "message": "Faute d\u{2019}orthographe possible", "lang": "fr"});
assert_eq!(crate::canvas::issue_heading(&french), tl!("Possible spelling mistake"));
let grammar = json!({"kind": "grammar", "message": "Repeated word"});
assert_eq!(crate::canvas::issue_heading(&grammar), tl!("Repeated word"));
}
}
-8
View File
@@ -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)>,
+1 -1
View File
@@ -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());
+2 -4
View File
@@ -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
+72 -111
View File
@@ -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.");
}
@@ -393,11 +369,10 @@ fn dictionaries(app: &mut WordApp, 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());
@@ -474,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() {
@@ -562,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() {
@@ -713,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:"));
@@ -773,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() };
+3 -7
View File
@@ -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 {
+10 -28
View File
@@ -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();
}
-3
View File
@@ -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",
+1 -1
View File
@@ -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() {
+3 -3
View File
@@ -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();
+8 -35
View File
@@ -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).
-54
View File
@@ -378,37 +378,6 @@ fn pane_dock_command_docks_floats_and_reads_each_pane() {
assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating, "junk changed nothing");
}
/// Explicit `left` / `right` stay on the physical window sides in an Arabic (RTL) interface;
/// defaults still flip to the reading-start side. Covers `ui.paneDock` and `view.stylesPaneDock`.
#[test]
fn pane_dock_left_and_right_are_physical_in_rtl() {
use craft_ui::panes::{Edge, Place};
use serde_json::json;
let arabic = crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n");
crate::i18n::set_current(arabic);
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default());
// Defaults: Navigation's Start opens on the physical right (reading start) until set.
assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Docked(Edge::End));
assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap()["dock"], "right");
// Explicit left stays physical left and is reported as left.
assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation", "dock": "left"})).unwrap()["dock"], "left");
assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Docked(Edge::Start));
assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap()["dock"], "left");
assert_eq!(app.run("ui.paneDock", json!({"pane": "comments", "dock": "right"})).unwrap()["dock"], "right");
assert_eq!(super::pane_state(&app, Pane::Comments).place, Place::Docked(Edge::End));
// Styles through view.stylesPaneDock — same physical contract.
assert_eq!(app.run("view.stylesPaneDock", json!({"dock": "left"})).unwrap()["dock"], "left");
assert_eq!(super::pane_state(&app, Pane::Styles).place, Place::Docked(Edge::Start));
assert_eq!(app.run("view.stylesPaneDock", json!({})).unwrap()["dock"], "left");
assert_eq!(app.run("view.stylesPaneDock", json!({"dock": "right"})).unwrap()["dock"], "right");
assert_eq!(super::pane_state(&app, Pane::Styles).place, Place::Docked(Edge::End));
// A host Place request (Dock Left menu / drag) saves physical left without flipping.
super::apply(&mut app, craft_ui::panes::PaneRequest::Place(Pane::Clipboard, Place::Docked(Edge::Start)));
assert_eq!(app.ui.pane_places.get("clipboard").and_then(|p| p.dock), Some(super::SavedDock::Left));
assert_eq!(super::pane_state(&app, Pane::Clipboard).place, Place::Docked(Edge::Start));
crate::i18n::set_current(crate::i18n::Lang::EN);
}
/// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses
/// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled.
#[test]
@@ -445,26 +414,3 @@ fn built_in_styles_grey_out_delete_in_the_styles_pane() {
assert!(delete_is_disabled("Normal"), "the built-in style's Delete is greyed out");
assert!(!delete_is_disabled("Callout"), "the custom style's Delete stays enabled");
}
/// #3382: a long comment's card in the Comments pane stops at ten lines of text, so its buttons
/// and the card after it stay in view.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn a_long_comment_card_keeps_its_buttons_and_the_next_card_in_view() {
use egui_kittest::kittest::Queryable;
let mut app = crate::WordApp::new(
wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Alpha paragraph one.\n\nBeta paragraph two.")),
crate::Services::default(),
);
app.run("select.text", serde_json::json!({"text": "Alpha"})).unwrap();
let long = (1..=200).map(|i| format!("Line {i}")).collect::<Vec<_>>().join("\n");
app.run("review.newComment", serde_json::json!({"text": long})).unwrap();
app.run("select.text", serde_json::json!({"text": "Beta"})).unwrap();
app.run("review.newComment", serde_json::json!({"text": "Short note."})).unwrap();
app.run("view.commentsPane", serde_json::json!({"value": true})).unwrap();
let h = pane_harness(app);
let go_to: Vec<egui::Rect> = h.get_all_by_label("Go to").map(|n| n.rect()).collect();
assert_eq!(go_to.len(), 2, "one Go to button per card");
assert!(go_to.iter().all(|r| r.max.y < 900.0), "both cards' buttons are in view: {go_to:?}");
}
+3 -73
View File
@@ -399,7 +399,7 @@ pub fn ink_mask(img: &mut egui::ColorImage) {
}
}
/// A font list entry: `name` in its own face, 244×22 pt. The face is read for the preview alone
/// A font list entry: `name` in its own face, 240×20 pt. The face is read for the preview alone
/// and dropped (#848): browsing the list used to load every family shown for good, so each trip
/// through the Font box grew memory by tens of megabytes. A name the font has no glyphs for (a
/// symbol font's), or a font that can't be read, shows in the default face.
@@ -409,36 +409,8 @@ pub fn font_entry_image(name: &str, ppp: f32) -> egui::ColorImage {
.preview_face(name)
.filter(|f| name.chars().filter(|c| !c.is_whitespace()).all(|c| f.covers(c)))
.unwrap_or_else(|| db.face(wordcraft_fonts::FALLBACK_FAMILY, "Regular"));
// Rendered taller than the row and cut back to the drawn text: faces with tall line metrics
// (Arabic, CJK) put their baseline well below a Latin face's, and a fixed 22 pt strip showed
// only the top of their names.
let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, FONT_PREVIEW_RENDER_H, 2.0, ppp);
let img = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
center_rows_on_ink(&img, (FONT_PREVIEW_H * ppp).round() as usize)
}
/// Height of a font-menu entry's picture, in points.
const FONT_PREVIEW_H: f32 = 22.0;
/// Height the entry is first rendered at, tall enough for any face's first line at 13 pt.
const FONT_PREVIEW_RENDER_H: f32 = 56.0;
/// `img` cut to `rows` pixel rows centred on what is drawn in it (the rows that differ from the
/// top-left background pixel). An image that is already that short, or blank, keeps its top rows.
fn center_rows_on_ink(img: &egui::ColorImage, rows: usize) -> egui::ColorImage {
let [w, h] = img.size;
let rows = rows.clamp(1, h.max(1));
if w == 0 || h <= rows {
return img.clone();
}
let background = img.pixels.first().copied().unwrap_or_default();
let inked =
|y: usize| img.pixels.get(y.saturating_mul(w)..y.saturating_add(1).saturating_mul(w)).is_some_and(|row| row.iter().any(|p| *p != background));
let top = (0..h).find(|y| inked(*y)).unwrap_or(0);
let bottom = (0..h).rev().find(|y| inked(*y)).unwrap_or(top);
let middle = top.saturating_add(bottom).saturating_add(1) / 2;
let start = middle.saturating_sub(rows / 2).min(h.saturating_sub(rows));
let pixels = img.pixels.get(start.saturating_mul(w)..start.saturating_add(rows).saturating_mul(w)).unwrap_or_default().to_vec();
egui::ColorImage::new([w, rows], pixels)
let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, 22.0, 2.0, ppp);
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight())
}
/// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it.
@@ -712,45 +684,3 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
#[cfg(test)]
mod tests;
#[test]
fn font_preview_rows_are_centred_on_the_drawn_text() {
let ink = egui::Color32::BLACK;
let paper = egui::Color32::WHITE;
// Ink low in a tall picture, as a face with a deep baseline draws it.
let mut img = egui::ColorImage::filled([4, 40], paper);
for y in 28..34 {
img.pixels[y * 4 + 1] = ink;
}
let cut = center_rows_on_ink(&img, 10);
assert_eq!(cut.size, [4, 10]);
let rows: Vec<usize> = (0..10).filter(|y| cut.pixels[y * 4 + 1] == ink).collect();
assert_eq!(rows, [2, 3, 4, 5, 6, 7], "all six inked rows, with equal space above and below");
// Blank, short, empty and oversized requests never panic.
assert_eq!(center_rows_on_ink(&egui::ColorImage::filled([4, 40], paper), 10).size, [4, 10]);
assert_eq!(center_rows_on_ink(&img, 400).size, [4, 40]);
assert_eq!(center_rows_on_ink(&img, 0).size, [4, 1]);
assert_eq!(center_rows_on_ink(&egui::ColorImage::filled([0, 0], paper), 10).size, [0, 0]);
}
/// Every loaded face's name fits its font-menu row: faces with tall line metrics (the
/// craft-fonts Arabic and Japanese faces) were cut off at the bottom.
#[test]
fn font_preview_shows_the_whole_name_in_every_loaded_face() {
let ppp = 2.0;
let rows = (FONT_PREVIEW_H * ppp).round() as usize;
for family in wordcraft_fonts::FontDb::with_font_dirs(Vec::new()).families() {
let face = wordcraft_fonts::FontDb::global().face(&family, "Regular");
let tall = wordcraft_render::render_text_line(&face, &family, 13.0, 244.0, FONT_PREVIEW_RENDER_H, 2.0, ppp);
let tall = egui::ColorImage::from_rgba_unmultiplied([tall.width as usize, tall.height as usize], &tall.to_straight());
let img = center_rows_on_ink(&tall, rows);
let [w, h] = img.size;
assert_eq!(h, rows, "{family}");
let background = img.pixels[0];
let inked = |y: usize| img.pixels[y * w..(y + 1) * w].iter().any(|p| *p != background);
assert!((0..h).any(inked), "{family}: the name is drawn");
assert!(!inked(0) && !inked(h - 1), "{family}: the name touches the edge of its row");
// Nothing of the name is left outside the cut.
let all = (0..tall.size[1]).filter(|y| tall.pixels[y * w..(y + 1) * w].iter().any(|p| *p != tall.pixels[0])).count();
assert_eq!((0..h).filter(|y| inked(*y)).count(), all, "{family}: part of the name is cut off");
}
}
+14 -50
View File
@@ -156,15 +156,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let current = app.session.current_pages(&lay);
app.ui.print.preview = app.ui.print.preview.min(total.saturating_sub(1));
let settings = |app: &mut WordApp, ui: &mut Ui| {
{
ui.set_width(SETTINGS_WIDTH);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(380.0);
// The settings scroll on their own: the preview beside them keeps the whole height (#1156).
// egui draws the bar at the right edge, over the right-aligned controls of a mirrored
// column: a solid bar there takes its own gutter instead of floating over them.
if crate::rtl::flows(ui) {
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
}
egui::ScrollArea::vertical().id_salt("print_settings").show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
let native = app.services.print_native.is_some();
@@ -203,7 +198,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if native {
if app.ui.print.copies > 1 {
ui.add_enabled_ui(!app.printing.busy(), |ui| ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies")));
ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies")));
}
} else {
let copies_label = ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
@@ -260,37 +255,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
export_formats(app, ui);
});
}
};
// A right-to-left interface splits the room by hand, settings on the side reading starts from:
// a `horizontal_top` row there lays the preview over the settings.
if crate::rtl::flows(ui) {
let room = ui.available_rect_before_wrap();
let split = (room.max.x - SETTINGS_WIDTH).max(room.min.x);
let settings_rect = Rect::from_min_max(pos2(split, room.min.y), room.max);
let preview_rect = Rect::from_min_max(room.min, pos2((split - SETTINGS_GAP).max(room.min.x), room.max.y));
let layout = egui::Layout::top_down(egui::Align::Max);
ui.scope_builder(egui::UiBuilder::new().max_rect(settings_rect).layout(layout), |ui| settings(app, ui));
// A window too narrow for a usable preview shows the settings alone.
if preview_rect.width() >= MIN_PREVIEW_WIDTH {
ui.scope_builder(egui::UiBuilder::new().max_rect(preview_rect).layout(layout), |ui| preview(app, ui, &lay, total));
}
ui.allocate_rect(room, Sense::hover());
return;
}
crate::rtl::horizontal_top(ui, |ui| {
crate::rtl::vertical(ui, |ui| settings(app, ui));
ui.add_space(SETTINGS_GAP);
crate::rtl::vertical(ui, |ui| preview(app, ui, &lay, total));
});
ui.add_space(24.0);
ui.vertical(|ui| preview(app, ui, &lay, total));
});
}
/// The settings column's width, and the space between it and the preview.
const SETTINGS_WIDTH: f32 = 380.0;
const SETTINGS_GAP: f32 = 24.0;
/// The narrowest preview pane worth drawing when the layout is mirrored.
const MIN_PREVIEW_WIDTH: f32 = 200.0;
/// Room around the page inside the preview pane.
const PREVIEW_MARGIN: f32 = 10.0;
/// The preview's bottom bar: the page indicator on the left, the zoom on the right.
@@ -331,7 +301,6 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
ui.painter().image(tex.id(), on_sheet, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
}
});
let rtl = crate::rtl::flows(ui);
ui.horizontal(|ui| {
ui.set_height(PREVIEW_BAR);
let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| {
@@ -344,26 +313,21 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
icons::paint(ui.painter(), r.shrink(4.0), icon, col, col);
enabled && resp.on_hover_text(tip).clicked()
};
if nav(ui, if rtl { "chevronRight" } else { "chevronLeft" }, tl!("Previous Page"), page > 0) {
if nav(ui, "chevronLeft", tl!("Previous Page"), page > 0) {
app.ui.print.preview = page - 1;
}
ui.label(egui::RichText::new(&label).font(regular(13.0)));
if nav(ui, if rtl { "chevronLeft" } else { "chevronRight" }, tl!("Next Page"), page + 1 < total) {
if nav(ui, "chevronRight", tl!("Next Page"), page + 1 < total) {
app.ui.print.preview = page + 1;
}
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 z = scale / crate::canvas::PX_PER_PT;
if nav(ui, "onePage", tl!("One Page"), true) {
app.ui.print.zoom = None;
}
ui.label(egui::RichText::new(format!("{}%", (z * 100.0).round())).font(regular(13.0)));
// The slider grows to the right in every language, as in the status bar: minus at its
// left end, plus at its right. This cluster fills from the far end inwards.
let (inner, outer) = (("minus", "Zoom Out", false), ("plus", "Zoom In", true));
let (near, far) = if rtl { (inner, outer) } else { (outer, inner) };
let enabled = |up: bool| if up { z < 5.0 } else { z > 0.1 };
if nav(ui, near.0, tl!(near.1), enabled(near.2)) {
app.ui.print.zoom = Some(zoom_step(z, near.2));
if nav(ui, "plus", tl!("Zoom In"), z < 5.0) {
app.ui.print.zoom = Some(zoom_step(z, true));
}
// A narrow window shortens the slider, then drops it, before it covers the page indicator.
let room = ui.available_width() - 26.0 - 2.0 * ui.spacing().item_spacing.x - 12.0;
@@ -376,8 +340,8 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
app.ui.print.zoom = Some(v.clamp(0.1, 5.0));
}
}
if nav(ui, far.0, tl!(far.1), enabled(far.2)) {
app.ui.print.zoom = Some(zoom_step(z, far.2));
if nav(ui, "minus", tl!("Zoom Out"), z > 0.1) {
app.ui.print.zoom = Some(zoom_step(z, false));
}
});
});
-1
View File
@@ -373,7 +373,6 @@ mod tests {
#[test]
#[ignore = "offscreen visual review; requires a software or hardware graphics adapter"]
fn spelling_review_dialog_preview() {
let _gpu = crate::table_resize::tests::gpu_turn();
let mut a = app("The quick brwn fox.");
a.run("review.spelling", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a);
+171 -226
View File
@@ -47,17 +47,11 @@ use insert::insert;
#[cfg(test)]
pub(crate) use language::PROOFING_LANGUAGES;
#[cfg(test)]
pub(crate) use language::all_languages;
#[cfg(test)]
use language::language_checks;
#[cfg(test)]
pub(crate) use language::short_languages;
#[cfg(test)]
pub(crate) use language::sort_key;
pub(crate) use language::language_name;
pub(crate) use language::{
caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu,
};
pub(crate) use language::{display_name, language_list, language_name, same_language, tag_for_pack};
use layout::layout;
#[cfg(test)]
use layout::{columns_items, margins_items, orientation_items};
@@ -136,200 +130,184 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.exact_size(30.0)
.frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 }))
.show(ui, |ui| {
crate::rtl::root(ui, |ui| {
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
// Share, Editing mode and Comments keep their room; the tabs get what is left of it
// (#897, #2026), and the buttons give up their labels before a tab is drawn over them.
let share = tl!("Share");
let editing_label = if app.session.doc.settings.track_changes { tl!("Reviewing") } else { tl!("Editing") };
let comments = tl!("Comments");
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
let spaced = |w: f32| w + 2.0;
let button_widths = |level: usize| -> f32 {
let labels = level == 0;
let share_full = (share_w + 36.0).max(74.0);
let share_button = if level < 2 { share_full } else { 30.0 };
let editing = crate::widgets::icon_text_button_width(ui, editing_label, regular(12.0), true, labels);
let comment = crate::widgets::icon_text_button_width(ui, comments, regular(12.0), false, labels);
// The gaps (6 + 4 + 8) and the strip's margin.
spaced(share_button) + spaced(editing) + spaced(comment) + 18.0 + 12.0
};
let button_levels = [button_widths(0), button_widths(1), button_widths(2)];
let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect();
let specs: Vec<TabSpec> = tabs
.iter()
.zip(&labels)
.map(|((tab, custom), shown)| TabSpec {
text_w: ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x),
file: tab == "File",
active: *tab == app.ui.tab,
contextual: !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()),
})
.collect();
let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0);
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
// No document open (#1129): nothing to share, edit or comment on — but only
// these buttons. Disabling the whole line took the tab strip with it: the
// tabs took clicks that did nothing, so the empty window had no way back to
// the Start screen (#3045).
ui.add_enabled_ui(app.has_document(), |ui| {
let (share_size, share_text) =
if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) };
let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click());
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
let rtl = crate::rtl::on();
let icon_x = if share_text { crate::rtl::lead_x(rtl, r, 14.0) } else { r.center().x };
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)),
"share",
t.on_accent,
t.on_accent,
);
if share_text {
ui.painter().text(
pos2(crate::rtl::lead_x(rtl, r, 26.0), r.center().y),
crate::rtl::lead_center(rtl),
share,
medium(12.0),
t.on_accent,
);
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
// Share, Editing mode and Comments keep their room; the tabs get what is left of it
// (#897, #2026), and the buttons give up their labels before a tab is drawn over them.
let share = tl!("Share");
let editing_label = if app.session.doc.settings.track_changes { tl!("Reviewing") } else { tl!("Editing") };
let comments = tl!("Comments");
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
let spaced = |w: f32| w + 2.0;
let button_widths = |level: usize| -> f32 {
let labels = level == 0;
let share_full = (share_w + 36.0).max(74.0);
let share_button = if level < 2 { share_full } else { 30.0 };
let editing = crate::widgets::icon_text_button_width(ui, editing_label, regular(12.0), true, labels);
let comment = crate::widgets::icon_text_button_width(ui, comments, regular(12.0), false, labels);
// The gaps (6 + 4 + 8) and the strip's margin.
spaced(share_button) + spaced(editing) + spaced(comment) + 18.0 + 12.0
};
let button_levels = [button_widths(0), button_widths(1), button_widths(2)];
let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect();
let specs: Vec<TabSpec> = tabs
.iter()
.zip(&labels)
.map(|((tab, custom), shown)| TabSpec {
text_w: ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x),
file: tab == "File",
active: *tab == app.ui.tab,
contextual: !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()),
})
.collect();
let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// No document open (#1129): nothing to share, edit or comment on — but only
// these buttons. Disabling the whole line took the tab strip with it: the
// tabs took clicks that did nothing, so the empty window had no way back to
// the Start screen (#3045).
ui.add_enabled_ui(app.has_document(), |ui| {
let (share_size, share_text) =
if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) };
let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click());
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
let icon_x = if share_text { r.min.x + 14.0 } else { r.center().x };
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)),
"share",
t.on_accent,
t.on_accent,
);
if share_text {
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent);
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share));
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
}
ui.add_space(6.0);
let track = app.session.doc.settings.track_changes;
let mode = if plan.buttons == 0 {
crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0))
} else {
crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true)
};
egui::Popup::menu(&mode).show(|ui| {
if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() {
let _ = app.run("review.trackChanges", json!({"value": false}));
ui.close();
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share));
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
}
ui.add_space(6.0);
let track = app.session.doc.settings.track_changes;
let mode = if plan.buttons == 0 {
crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0))
} else {
crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true)
};
crate::rtl::menu(&mode).show(|ui| {
if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() {
let _ = app.run("review.trackChanges", json!({"value": false}));
ui.close();
}
if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() {
let _ = app.run("review.trackChanges", json!({"value": true}));
ui.close();
}
});
ui.add_space(4.0);
let comments_button = if plan.buttons == 0 {
crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0))
} else {
crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false)
};
if comments_button.clicked() {
let _ = app.run("view.commentsPane", json!({}));
if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() {
let _ = app.run("review.trackChanges", json!({"value": true}));
ui.close();
}
});
ui.add_space(8.0);
// The tabs fill the room left of the cluster, from the side reading starts from.
// (By language: this row sits in the far-side cluster's layout, which fills from the other end.)
let tabs_layout = if crate::rtl::on() {
egui::Layout::right_to_left(egui::Align::Center)
ui.add_space(4.0);
let comments_button = if plan.buttons == 0 {
crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0))
} else {
egui::Layout::left_to_right(egui::Align::Center)
crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false)
};
ui.with_layout(tabs_layout, |ui| {
// The tabs that don't fit go into a » menu at the end of the strip (#897, #2026).
for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) {
let (tab, custom) = (tab.as_str(), *custom);
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab);
// A custom tab's name is the user's own text: shown as typed.
let shown = if custom { tab } else { tl!(tab) };
let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0;
let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
if app.ui.keytips == crate::keytips::Phase::Tabs {
crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects);
} else if app.ui.keytips == crate::keytips::Phase::Legacy {
// Every tab: they only place the legacy keys' hint (#1697).
crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects);
}
let active = app.ui.tab == tab && !app.ui.backstage;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
if resp.hovered() && !active {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
}
let color = if contextual || active { t.accent_text } else { t.text };
if cut {
let mut job = egui::text::LayoutJob::single_section(
shown.to_string(),
egui::text::TextFormat::simple(if active { semibold(12.5) } else { medium(12.5) }, color),
);
job.wrap = egui::text::TextWrapping {
max_width: (w - plan.pad).max(1.0),
max_rows: 1,
break_anywhere: true,
overflow_character: Some('…'),
};
let galley = ui.painter().layout_job(job);
ui.painter().galley(r.center() - galley.size() / 2.0, galley, color);
} else {
ui.painter().text(
r.center(),
Align2::CENTER_CENTER,
shown,
if active { semibold(12.5) } else { medium(12.5) },
color,
);
}
let resp = if cut { resp.on_hover_text(shown) } else { resp };
if active {
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
ui.painter().rect_filled(u, 2.0, t.accent);
}
if resp.clicked() {
let _ = app.run("ui.tab", json!({"tab": tab}));
}
if resp.double_clicked() && tab != "File" {
let _ = app.run("ui.collapseRibbon", json!({}));
}
resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
if comments_button.clicked() {
let _ = app.run("view.commentsPane", json!({}));
}
});
ui.add_space(8.0);
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
// The tabs that don't fit go into a » menu at the end of the strip (#897, #2026).
for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) {
let (tab, custom) = (tab.as_str(), *custom);
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab);
// A custom tab's name is the user's own text: shown as typed.
let shown = if custom { tab } else { tl!(tab) };
let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0;
let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
if app.ui.keytips == crate::keytips::Phase::Tabs {
crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects);
} else if app.ui.keytips == crate::keytips::Phase::Legacy {
// Every tab: they only place the legacy keys' hint (#1697).
crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects);
}
let left_out: Vec<(&str, &str)> = tabs
.iter()
.zip(&labels)
.zip(&plan.fits)
.filter(|(_, fits)| !**fits)
.map(|(((tab, _), shown), _)| (tab.as_str(), *shown))
.collect();
if !left_out.is_empty() {
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
}
// The active tab is in the menu: the button says so.
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
let active = app.ui.tab == tab && !app.ui.backstage;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
if resp.hovered() && !active {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
}
let color = if contextual || active { t.accent_text } else { t.text };
if cut {
let mut job = egui::text::LayoutJob::single_section(
shown.to_string(),
egui::text::TextFormat::simple(if active { semibold(12.5) } else { medium(12.5) }, color),
);
job.wrap = egui::text::TextWrapping {
max_width: (w - plan.pad).max(1.0),
max_rows: 1,
break_anywhere: true,
overflow_character: Some('…'),
};
let galley = ui.painter().layout_job(job);
ui.painter().galley(r.center() - galley.size() / 2.0, galley, color);
} else {
ui.painter().text(
r.center(),
Align2::CENTER_CENTER,
"»",
medium(12.5),
if holds_active { t.accent_text } else { t.text },
shown,
if active { semibold(12.5) } else { medium(12.5) },
color,
);
// "»" means nothing read aloud: the button says what it holds.
let more = tl!("More tabs");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more));
let resp = resp.on_hover_text(more);
crate::rtl::menu(&resp).show(|ui| {
for (tab, shown) in left_out {
if ui.selectable_label(app.ui.tab == tab, shown).clicked() {
let _ = app.run("ui.tab", json!({"tab": tab}));
ui.close();
}
}
});
}
});
let resp = if cut { resp.on_hover_text(shown) } else { resp };
if active {
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
ui.painter().rect_filled(u, 2.0, t.accent);
}
if resp.clicked() {
let _ = app.run("ui.tab", json!({"tab": tab}));
}
if resp.double_clicked() && tab != "File" {
let _ = app.run("ui.collapseRibbon", json!({}));
}
resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
}
let left_out: Vec<(&str, &str)> = tabs
.iter()
.zip(&labels)
.zip(&plan.fits)
.filter(|(_, fits)| !**fits)
.map(|(((tab, _), shown), _)| (tab.as_str(), *shown))
.collect();
if !left_out.is_empty() {
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
}
// The active tab is in the menu: the button says so.
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
ui.painter().text(
r.center(),
Align2::CENTER_CENTER,
"»",
medium(12.5),
if holds_active { t.accent_text } else { t.text },
);
// "»" means nothing read aloud: the button says what it holds.
let more = tl!("More tabs");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more));
let resp = resp.on_hover_text(more);
egui::Popup::menu(&resp).show(|ui| {
for (tab, shown) in left_out {
if ui.selectable_label(app.ui.tab == tab, shown).clicked() {
let _ = app.run("ui.tab", json!({"tab": tab}));
ui.close();
}
}
});
}
});
});
})
});
});
if app.ui.ribbon_collapsed {
return;
@@ -347,32 +325,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.disable();
}
let bg = ui.interact(ui.max_rect(), ui.scope_id().with("ribbon_background"), Sense::click());
crate::rtl::context_menu(&bg, |ui| crate::rtl::root(ui, |ui| crate::widgets::customize_menu_items(ui, app)));
bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
let entry = user_tabs.iter().find(|e| e.name == app.ui.tab);
crate::ribbon_fit::begin(ui.ctx(), &app.ui.tab, ui.available_width() - 4.0);
// 0.37 keeps the scroll offset private: an edge-chevron click leaves a one-frame
// request here and the next scroll area is built at that offset.
let pending_id = ui.scope_id().with("ribbon_scroll_pending");
let pending = ui.data(|d| d.get_temp::<f32>(pending_id));
// Right to left, the groups start at the right edge. A scroll area's content is
// unbounded, so the row gets a width: the view's, or last frame's content if wider.
// The scroll position is kept as a distance from the right, where reading starts.
let rtl = crate::rtl::on();
let view_w = ui.available_width();
let row_id = ui.scope_id().with(("ribbon_row", app.ui.tab.as_str()));
let (content_w, from_right, last_over) = ui.data(|d| d.get_temp::<(f32, f32, f32)>(row_id)).unwrap_or((0.0, 0.0, 0.0));
let row_w = content_w.max(view_w);
let set_offset = (last_over - from_right).max(0.0);
let mut area = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
if rtl {
area = area.horizontal_scroll_offset(set_offset);
} else if let Some(x) = pending {
if let Some(x) = pending {
area = area.horizontal_scroll_offset(x);
}
let mut measured = 0.0;
let scroll = area.show(ui, |ui| {
let size = vec2(if rtl { row_w } else { ui.available_size_before_wrap().x }, ui.available_size_before_wrap().y);
let body = |ui: &mut Ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
match entry {
// Every tab hidden: nothing to show.
@@ -406,24 +371,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if let Some(e) = entry {
custom_groups(app, ui, &e.groups);
}
};
if rtl {
measured = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Min), body).response.rect.width();
} else {
crate::rtl::horizontal_top(ui, body);
}
});
});
if rtl {
let over = (scroll.content_size.x - scroll.inner_rect.width()).max(0.0);
// Only the user's own scrolling moves the remembered distance: while the width
// is still settling, the offset set above lags a frame behind.
let scrolled = (scroll.state.clamped_offset().x - set_offset).abs() > 0.5 && set_offset <= over;
let from_right = if scrolled { (over - scroll.state.clamped_offset().x).max(0.0) } else { from_right.min(over) };
ui.data_mut(|d| d.insert_temp(row_id, (measured, from_right, over)));
if (over - last_over).abs() > 0.5 {
ui.ctx().request_repaint();
}
}
if pending.is_some() {
let _ = ui.data_mut(|d| d.remove_temp::<f32>(pending_id));
}
@@ -448,11 +397,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
if let Some(x) = next {
let x = x.clamp(0.0, overflow.max(0.0));
if rtl {
ui.data_mut(|d| d.insert_temp(row_id, (measured, (overflow - x).max(0.0), overflow.max(0.0))));
}
ui.data_mut(|d| d.insert_temp(pending_id, x));
ui.data_mut(|d| d.insert_temp(pending_id, x.clamp(0.0, overflow.max(0.0))));
ui.ctx().request_repaint();
}
});
+8 -10
View File
@@ -55,16 +55,14 @@ pub(super) fn wrap_menu(ui: &mut Ui, app: &mut WordApp) {
}
ui.separator();
ui.menu_button(tl!("Wrap Text Sides"), |ui| {
crate::rtl::root(ui, |ui| {
for (l, s, v) in [
("Both Sides", WrapText::BothSides, "bothSides"),
("Left Only", WrapText::Left, "left"),
("Right Only", WrapText::Right, "right"),
("Largest Only", WrapText::Largest, "largest"),
] {
mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v}));
}
})
for (l, s, v) in [
("Both Sides", WrapText::BothSides, "bothSides"),
("Left Only", WrapText::Left, "left"),
("Right Only", WrapText::Right, "right"),
("Largest Only", WrapText::Largest, "largest"),
] {
mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v}));
}
});
});
}
+1 -1
View File
@@ -28,7 +28,7 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
});
group(ui, "Layouts", None, app, |ui, app| {
let t = Tokens::get(ui.ctx());
crate::rtl::grid(ui, egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)), |ui| {
egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
for (k, layout) in SmartArtLayout::ALL.into_iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click());
let on = g.layout == layout;
+2 -2
View File
@@ -66,7 +66,7 @@ pub(super) fn esc_cancelled(ui: &Ui, r: &egui::Response, id: egui::Id) -> bool {
}
pub(super) fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
crate::rtl::vertical(ui, |ui| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
add(ui);
});
@@ -110,7 +110,7 @@ pub(super) fn mi_icon(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, i
pub(super) fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) {
let sc = crate::widgets::shortcut_text(app, id);
let enabled = crate::widgets::enabled(app, id);
let resp = ui.add_enabled(enabled, crate::rtl::menu_item(ui, text, &sc, 200.0));
let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0)));
if resp.clicked() {
crate::widgets::run_from_ribbon(app, id, params);
ui.close();
+1 -1
View File
@@ -116,7 +116,7 @@ fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::
if resp.clicked() {
let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width}));
}
crate::rtl::context_menu(&resp, |ui| {
resp.context_menu(|ui| {
if ui.button(tl!("Remove Pen")).clicked() {
let _ = app.run("draw.removePen", json!({"index": i}));
ui.close();

Some files were not shown because too many files have changed in this diff Show More