mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-04 01:10:37 -06:00
Compare commits
23
Commits
Generated
+35
-6
@@ -1103,7 +1103,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1520,6 +1520,19 @@ 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"
|
||||
@@ -1587,6 +1600,26 @@ 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"
|
||||
@@ -7017,6 +7050,7 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -7580,11 +7614,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+4
-3
@@ -24,10 +24,11 @@ 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 #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 #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
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", 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
@@ -0,0 +1,26 @@
|
||||
# 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)
|
||||
+15
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
|
||||
@@ -228,6 +228,11 @@ fn block_cost(b: &Block) -> (usize, bool) {
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
|
||||
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
|
||||
// The formatting a clone copies too: style ids, font names and the like in the runs,
|
||||
// the mark and the paragraph's properties.
|
||||
let runs = p.runs.iter().filter(|r| !r.props.is_empty()).fold(0usize, |n, r| n.saturating_add(serialized_len(&r.props)));
|
||||
let props = serialized_len(&p.props).saturating_add(serialized_len(&p.mark)).saturating_add(p.section.as_ref().map_or(0, serialized_len));
|
||||
cost = cost.saturating_add(runs).saturating_add(props);
|
||||
boxes |= box_ids(p).next().is_some();
|
||||
});
|
||||
(cost, boxes)
|
||||
|
||||
@@ -415,6 +415,24 @@ fn pasted_box_copies_are_capped_in_bytes() {
|
||||
assert_eq!(got.iter().flatten().count(), added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_formatting_they_copy() {
|
||||
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
|
||||
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
|
||||
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
|
||||
let mut p = Paragraph::with_text("x", props);
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_objects_they_copy() {
|
||||
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
|
||||
|
||||
@@ -364,6 +364,11 @@ 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();
|
||||
@@ -374,11 +379,8 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if is_final {
|
||||
continue; // handled below through `last`
|
||||
}
|
||||
for p in paras.iter_mut() {
|
||||
if p.end_cp == sec.end_cp {
|
||||
p.para.section = Some(Box::new(props));
|
||||
break;
|
||||
}
|
||||
if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) {
|
||||
p.para.section = Some(Box::new(props));
|
||||
}
|
||||
}
|
||||
if let Some(mut l) = last {
|
||||
|
||||
@@ -101,6 +101,12 @@ 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> {
|
||||
@@ -108,6 +114,15 @@ 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;
|
||||
@@ -144,8 +159,10 @@ 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));
|
||||
}
|
||||
@@ -158,7 +175,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
1 => VMerge::Continue,
|
||||
_ => VMerge::None,
|
||||
};
|
||||
if m != VMerge::None {
|
||||
// The first override for a cell wins.
|
||||
if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) {
|
||||
r.vmerge.push((*itc, m));
|
||||
}
|
||||
}
|
||||
@@ -168,7 +186,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) {
|
||||
r.shading.push((c, Some(rgb)));
|
||||
set(&mut r.shading, c, Some(rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +194,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) {
|
||||
r.shading.push((i as u8, Some(rgb)));
|
||||
set(&mut r.shading, i as u8, Some(rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +214,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
|
||||
continue;
|
||||
}
|
||||
r.shading.push(((first + i) as u8, shd_rgb(b)));
|
||||
set(&mut r.shading, (first + i) as u8, shd_rgb(b));
|
||||
}
|
||||
}
|
||||
T_CELL_WIDTH => {
|
||||
@@ -207,12 +225,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) {
|
||||
r.widths.push((c, w));
|
||||
set(&mut r.widths, 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) {
|
||||
r.width_pcts.push((c, pct));
|
||||
set(&mut r.width_pcts, c, pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,8 +242,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
2 => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
for c in *a..*b {
|
||||
r.valign.push((c, v));
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.valign, c, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,32 @@ 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 {
|
||||
@@ -2375,6 +2401,23 @@ 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):
|
||||
|
||||
@@ -229,6 +229,21 @@ 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"));
|
||||
@@ -486,5 +501,187 @@ 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;
|
||||
}
|
||||
|
||||
+181
-9
@@ -143,6 +143,9 @@ 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);
|
||||
}
|
||||
@@ -219,6 +222,8 @@ 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)
|
||||
}
|
||||
|
||||
@@ -575,15 +580,50 @@ 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 its language
|
||||
/// (`nl` for `nl-NL`), whichever the index has. `None` for English and tags with no pack.
|
||||
/// 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.
|
||||
pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
|
||||
let tag = tag.trim().replace('_', "-").to_ascii_lowercase();
|
||||
let base = tag.split('-').next().unwrap_or("");
|
||||
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("");
|
||||
if base.is_empty() || base == "en" {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
packs.iter().find(|p| p.locale == tag).or_else(|| packs.iter().find(|p| p.locale == base))
|
||||
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
|
||||
}
|
||||
|
||||
/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order.
|
||||
@@ -671,8 +711,33 @@ 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 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.
|
||||
/// 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.
|
||||
pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
let packs = available();
|
||||
if packs.is_empty() {
|
||||
@@ -680,7 +745,7 @@ pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
}
|
||||
let installed = installed();
|
||||
let mut out: Vec<Pack> = Vec::new();
|
||||
for tag in document_languages(s) {
|
||||
for tag in wanted_languages(s) {
|
||||
if proofable(&tag)
|
||||
&& let Some(p) = pack_for_tag(&tag, &packs)
|
||||
&& !installed.iter().any(|i| i.locale == p.locale)
|
||||
@@ -692,6 +757,113 @@ 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 {
|
||||
|
||||
@@ -206,6 +206,11 @@ 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.
|
||||
@@ -223,9 +228,16 @@ impl Alphas {
|
||||
Some(alpha) => alpha.clone(),
|
||||
None if self.refused.contains(&key) => return Err(OverBudget),
|
||||
None => {
|
||||
let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| {
|
||||
// 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(|_| {
|
||||
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
|
||||
@@ -233,6 +245,41 @@ impl Alphas {
|
||||
};
|
||||
Ok(alpha.and_then(|a| alpha_in_crop(&a, crop)))
|
||||
}
|
||||
|
||||
/// The pixels picture `bytes` took of the budget when it was decoded in this layout (0:
|
||||
/// not decoded, or none to look at; a picture that failed to decode still cost its pixels).
|
||||
pub(crate) fn cost(&self, bytes: &[u8]) -> u64 {
|
||||
self.costs.get(&(bytes.as_ptr() as usize, bytes.len())).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Charges the budget for picture `bytes`, whose outline was found kept from an earlier
|
||||
/// layout (`cost`: the pixels it took then), as decoding it now would: pictures get an outline
|
||||
/// by their place in the document, not by what the cache holds. [`OverBudget`] when it
|
||||
/// wouldn't have fitted: its rectangle is its outline in this layout.
|
||||
pub(crate) fn charge(&mut self, bytes: &[u8], cost: u64) -> Result<(), OverBudget> {
|
||||
let key = (bytes.as_ptr() as usize, bytes.len());
|
||||
if self.seen.contains_key(&key) || self.charged.contains(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.refused.contains(&key) {
|
||||
return Err(OverBudget);
|
||||
}
|
||||
if cost == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
match self.decoded.checked_add(cost).filter(|t| *t <= MAX_DECODED_PIXELS) {
|
||||
Some(total) => {
|
||||
self.decoded = total;
|
||||
self.charged.insert(key);
|
||||
self.costs.insert(key, cost);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
self.refused.insert(key);
|
||||
Err(OverBudget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures
|
||||
|
||||
@@ -69,6 +69,9 @@ 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> {
|
||||
@@ -144,10 +147,14 @@ 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.
|
||||
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>)>;
|
||||
///
|
||||
/// 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)>;
|
||||
|
||||
/// 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,
|
||||
@@ -155,14 +162,17 @@ pub(crate) fn picture_outline(
|
||||
bytes: &Arc<Vec<u8>>,
|
||||
crop: [f32; 4],
|
||||
) -> Option<Arc<Vec<(f32, f32)>>> {
|
||||
if let Some((_, o)) = outlines.get(&key) {
|
||||
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()?;
|
||||
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()));
|
||||
outlines.insert(key, (Arc::downgrade(bytes), outline.clone(), alphas.cost(bytes)));
|
||||
outline
|
||||
}
|
||||
|
||||
|
||||
@@ -109,11 +109,64 @@ 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 a table, later pages carry no header rows;
|
||||
/// counts at least one) have been repeated across the whole layout (every table together), later pages carry no header rows;
|
||||
/// pagination still finishes. Far above any real table (three ordinary header rows over some
|
||||
/// 5 000 pages).
|
||||
pub(crate) const MAX_HEADER_ITEMS: usize = 500_000;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A smaller [`MAX_HEADER_ITEMS`] for tests, so a short table can reach it.
|
||||
pub(crate) static HEADER_ITEMS_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_HEADER_ITEMS) };
|
||||
}
|
||||
|
||||
/// [`MAX_HEADER_ITEMS`], or the test cap ([`HEADER_ITEMS_CAP`]) in tests.
|
||||
pub(crate) fn header_items_cap() -> usize {
|
||||
#[cfg(test)]
|
||||
return HEADER_ITEMS_CAP.with(|c| c.get());
|
||||
#[cfg(not(test))]
|
||||
MAX_HEADER_ITEMS
|
||||
}
|
||||
|
||||
/// What re-placing `it` costs against [`MAX_HEADER_ITEMS`] (and laying it out against
|
||||
/// [`textbox::MAX_FLOW_WORK`]): one, and for a clipped group (a floating Drawing Canvas) every
|
||||
/// member below it, nested groups included, as placing clones them all. Counted with an explicit
|
||||
/// stack, and counting stops once the total passes `limit` (the result is then just over it), so
|
||||
/// neither deep nesting nor a huge group makes the count itself costly.
|
||||
pub(crate) fn placed_cost(it: &Placed, limit: usize) -> usize {
|
||||
placed_cost_by(it, limit, |_| 1)
|
||||
}
|
||||
|
||||
/// [`placed_cost`] with each item (the group itself and every member) costing `cost` of it.
|
||||
pub(crate) fn placed_cost_by(it: &Placed, limit: usize, cost: impl Fn(&Placed) -> usize) -> usize {
|
||||
let mut count = 0usize;
|
||||
let mut stack: Vec<std::slice::Iter<Placed>> = Vec::new();
|
||||
let mut next = Some(it);
|
||||
loop {
|
||||
let cur = match next.take() {
|
||||
Some(c) => c,
|
||||
None => loop {
|
||||
match stack.last_mut() {
|
||||
None => return count,
|
||||
Some(iter) => match iter.next() {
|
||||
Some(c) => break c,
|
||||
None => {
|
||||
stack.pop();
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
count = count.saturating_add(cost(cur));
|
||||
if count > limit {
|
||||
return count;
|
||||
}
|
||||
if let Placed::Clip { items, .. } = cur {
|
||||
stack.push(items.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header tables can displace a non-overlapping body table. Page-positioned drawings keep
|
||||
/// their own wrapping rules, so only table areas are retained from the header layout.
|
||||
pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
|
||||
@@ -66,6 +66,9 @@ 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};
|
||||
|
||||
@@ -109,6 +109,7 @@ fn new_ctx<'a>(
|
||||
floats_above: HashMap::new(),
|
||||
hf: Arc::new(HfLinks::new(doc.sections())),
|
||||
alphas: Default::default(),
|
||||
header_repeats: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +561,7 @@ struct Snap<'a> {
|
||||
eq_count: u32,
|
||||
page_field: u32,
|
||||
boxes: usize,
|
||||
header_repeats: usize,
|
||||
}
|
||||
|
||||
impl<'a> Snap<'a> {
|
||||
@@ -577,6 +579,7 @@ impl<'a> Snap<'a> {
|
||||
eq_count: ctx.eq_count,
|
||||
page_field: ctx.fields.page,
|
||||
boxes: ctx.boxes.total(),
|
||||
header_repeats: ctx.header_repeats,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,6 +602,7 @@ impl<'a> Snap<'a> {
|
||||
ctx.counters = self.counters.clone();
|
||||
ctx.eq_count = self.eq_count;
|
||||
ctx.fields.page = self.page_field;
|
||||
ctx.header_repeats = self.header_repeats;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::boxes::{BoxInput, clear_of_float_tables, layout_box};
|
||||
use super::ctx::{Ctx, with_pct_base};
|
||||
use super::floats::{WrapArea, align_in, body_wraps};
|
||||
use super::flow::again;
|
||||
use super::headers::{MAX_HEADER_ITEMS, header_table_areas};
|
||||
use super::headers::{header_items_cap, header_table_areas, placed_cost};
|
||||
use super::notes::table_notes;
|
||||
use super::page::{PageBuilder, PageFrame, page_frame};
|
||||
use super::placed::Placed;
|
||||
@@ -127,10 +127,14 @@ 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();
|
||||
// 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);
|
||||
// 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);
|
||||
// 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() {
|
||||
@@ -180,7 +184,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 = !header_rows.contains(&ri)
|
||||
let splittable = ri >= header_rows.len()
|
||||
&& !t
|
||||
.rows
|
||||
.get(ri)
|
||||
@@ -237,9 +241,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 this table's cap.
|
||||
if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS {
|
||||
header_repeats += header_cost;
|
||||
// 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);
|
||||
for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) {
|
||||
place(pb, h);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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};
|
||||
@@ -36,10 +37,11 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
|
||||
fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
|
||||
// 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 cap, later boxes reuse the nearest
|
||||
// width already laid out, so every box still shows its share of the story.
|
||||
// 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));
|
||||
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);
|
||||
@@ -56,7 +58,85 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
|
||||
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();
|
||||
assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len());
|
||||
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");
|
||||
}
|
||||
|
||||
/// The paragraph layouts (by address) showing story `head` across the document.
|
||||
@@ -95,7 +175,8 @@ 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.
|
||||
let n = textbox::MAX_FLOW_WIDTHS + 1;
|
||||
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
|
||||
let n = 17;
|
||||
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();
|
||||
@@ -109,7 +190,12 @@ 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_eq!(widths.len(), n, "the vertical box has a layout of its own");
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -276,7 +362,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
|
||||
@@ -288,9 +374,270 @@ fn a_kept_outline_holds_on_to_its_pictures_allocation() {
|
||||
let mut outlines = HashMap::new();
|
||||
let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]);
|
||||
assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some());
|
||||
let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") };
|
||||
let Some((weak, ..)) = outlines.get(&key) else { panic!("the outline is kept") };
|
||||
assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes");
|
||||
assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes");
|
||||
drop(disc);
|
||||
assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept");
|
||||
}
|
||||
|
||||
/// A floating Drawing Canvas in a header cell is one `Clip` holding its members, and re-placing
|
||||
/// the header clones every one of them: they all count against the repeat budget.
|
||||
#[test]
|
||||
fn a_clipped_group_costs_its_members_when_a_header_repeats() {
|
||||
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
|
||||
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
|
||||
let big = usize::MAX;
|
||||
assert_eq!(placed_cost(&rule(), big), 1);
|
||||
assert_eq!(placed_cost(&clip((0..1000).map(|_| rule()).collect()), big), 1001);
|
||||
// Nested groups count too, however deep: every descendant is charged.
|
||||
assert_eq!(placed_cost(&clip(vec![clip(vec![rule(), rule()]), rule()]), big), 5);
|
||||
let mut deep = clip((0..1000).map(|_| rule()).collect());
|
||||
for _ in 0..20 {
|
||||
deep = clip(vec![deep]);
|
||||
}
|
||||
assert_eq!(placed_cost(&deep, big), 1021, "members below the eighth level still cost one each");
|
||||
// Counting stops once past the limit, so a huge or very deep group is cheap to count.
|
||||
assert_eq!(placed_cost(&deep, 10), 11);
|
||||
let mut deeper = rule();
|
||||
for _ in 0..100_000 {
|
||||
deeper = clip(vec![deeper]);
|
||||
}
|
||||
assert_eq!(placed_cost(&deeper, 50), 51);
|
||||
assert_eq!(placed_cost(&deeper, big), 100_001);
|
||||
std::mem::forget(deeper);
|
||||
}
|
||||
|
||||
/// The linked-box work budget counts a clipped group's members, nested groups included.
|
||||
#[test]
|
||||
fn flow_work_counts_clip_members() {
|
||||
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
|
||||
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
|
||||
let mut deep = clip((0..100).map(|_| rule()).collect());
|
||||
for _ in 0..12 {
|
||||
deep = clip(vec![deep, rule()]);
|
||||
}
|
||||
assert_eq!(textbox::flow_work(&[rule(), deep]), 1 + 100 + 1 + 12 * 2);
|
||||
}
|
||||
|
||||
/// Lines inside a clipped group (a text box on a canvas) cost each line, as they do outside one.
|
||||
#[test]
|
||||
fn flow_work_counts_each_line_in_a_clip() {
|
||||
let l = lay(&Document::from_text(&"Words that run on and on. ".repeat(200)));
|
||||
let lines = l.pages.iter().flat_map(|p| &p.items).find(|it| matches!(it, Placed::Lines { .. })).cloned().unwrap();
|
||||
let (l0, l1) = match &lines {
|
||||
Placed::Lines { l0, l1, .. } => (*l0, *l1),
|
||||
_ => (0, 0),
|
||||
};
|
||||
assert!(l1 - l0 > 1);
|
||||
let clip = Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items: vec![lines] };
|
||||
assert_eq!(textbox::flow_work(&[clip]), 1 + (l1 - l0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_header_items_are_capped_across_all_the_tables_of_a_layout() {
|
||||
// Each table's repeats used to have a budget of their own: a hundred tables, a hundred times
|
||||
// the cap.
|
||||
let table = || {
|
||||
let mut t = Table::new(201, 1, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
t.rows[0].props.height = Some(20.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect();
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.props.height = Some(600.0);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
t
|
||||
};
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table())).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(table())).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(3), Block::Table(table())).unwrap();
|
||||
let l = lay(&d);
|
||||
let items: usize = l.pages.iter().map(|p| p.items.len()).sum();
|
||||
assert!(items <= MAX_HEADER_ITEMS + 3 * 3000 + 50 * l.pages.len(), "{items} items placed");
|
||||
}
|
||||
|
||||
/// Which pictures wrap by their outline must not depend on what the cache already holds: a
|
||||
/// picture's outline kept from an earlier layout is charged to the budget like a decoded one.
|
||||
#[test]
|
||||
fn cold_and_warm_caches_give_the_same_outlines() {
|
||||
let (a, b) = (Arc::new(disc_png(64)), Arc::new(disc_png(64)));
|
||||
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
|
||||
// Room for one of the two pictures.
|
||||
let pass = |outlines: &mut Outlines| {
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64;
|
||||
let got = [(&a, "a"), (&b, "b")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
|
||||
(got, alphas.decoded)
|
||||
};
|
||||
let mut roomy = contour::Alphas::default();
|
||||
let mut warm = Outlines::new();
|
||||
for (p, n) in [(&a, "a"), (&b, "b")] {
|
||||
assert!(picture_outline(&mut warm, &mut roomy, key(p, n), p, [0.0; 4]).is_some());
|
||||
}
|
||||
assert_eq!(warm.len(), 2, "both outlines are kept");
|
||||
let (cold, cold_spent) = pass(&mut Outlines::new());
|
||||
assert_eq!(cold, [true, false], "the first picture fits, the second wraps as its rectangle");
|
||||
let (warmed, warm_spent) = pass(&mut warm);
|
||||
assert_eq!(warmed, cold, "the same pictures get outlines with the cache warm");
|
||||
assert_eq!(warm_spent, cold_spent);
|
||||
// And again: a layout after a layout.
|
||||
assert_eq!(pass(&mut warm).0, cold);
|
||||
}
|
||||
|
||||
/// A PNG whose header says `side` × `side` RGBA and whose body is missing: it has transparency
|
||||
/// to look at, costs its pixels, and then fails to decode.
|
||||
fn truncated_png(side: u32) -> Vec<u8> {
|
||||
fn crc(data: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for b in data {
|
||||
c ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
let mut ihdr = b"IHDR".to_vec();
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend(side.to_be_bytes());
|
||||
ihdr.extend([8, 6, 0, 0, 0]);
|
||||
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
|
||||
png.extend(&ihdr);
|
||||
png.extend(crc(&ihdr).to_be_bytes());
|
||||
// An image data chunk of junk: the header is read, the pixels aren't there.
|
||||
let mut idat = b"IDAT".to_vec();
|
||||
idat.extend([0x78, 0x9c, 1, 2, 3, 4]);
|
||||
png.extend(6u32.to_be_bytes());
|
||||
png.extend(&idat);
|
||||
png.extend(crc(&idat).to_be_bytes());
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_picture_costs_the_same_cold_and_warm() {
|
||||
// Its header promises transparency, so a layout spends its pixels before the body fails to
|
||||
// decode; a layout finding its (empty) outline kept spends them too, or pictures after it
|
||||
// would get outlines only with the cache warm.
|
||||
let (big, disc) = (Arc::new(truncated_png(8000)), Arc::new(disc_png(64)));
|
||||
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
|
||||
let pass = |outlines: &mut Outlines| {
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 8000 * 8000;
|
||||
let got = [(&big, "big"), (&disc, "disc")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
|
||||
(got, alphas.decoded)
|
||||
};
|
||||
let mut warm = Outlines::new();
|
||||
let (cold, spent) = pass(&mut warm);
|
||||
assert_eq!(spent, contour::MAX_DECODED_PIXELS, "the truncated picture spent its pixels");
|
||||
assert_eq!(cold, [false, false], "no budget is left for the disc");
|
||||
assert_eq!(warm.get(&key(&big, "big")).map(|e| e.2), Some(8000 * 8000), "the entry holds the cost");
|
||||
// Also with a roomy first layout filling the cache, then a tight one.
|
||||
let mut roomy_cache = Outlines::new();
|
||||
let mut roomy = contour::Alphas::default();
|
||||
for (p, n) in [(&big, "big"), (&disc, "disc")] {
|
||||
let _ = picture_outline(&mut roomy_cache, &mut roomy, key(p, n), p, [0.0; 4]);
|
||||
}
|
||||
assert_eq!(pass(&mut roomy_cache), (cold, spent), "warm matches cold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_row_holding_a_canvas_repeats_within_the_budget() {
|
||||
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
|
||||
// A canvas of 800 members in an exact-height header row, over two thousand pages: each page
|
||||
// would clone all 800 members.
|
||||
let member = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 4.0,
|
||||
h: 4.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let canvas = InlineObject::Group {
|
||||
w: 40.0,
|
||||
h: 40.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 5.0, y: 5.0, ..Default::default() },
|
||||
ch_w: 40.0,
|
||||
ch_h: 40.0,
|
||||
children: (0..800).map(|_| GroupChild { x: 1.0, y: 1.0, obj: member.clone() }).collect(),
|
||||
canvas: Some(CanvasStyle { fill: None, stroke: None, stroke_width: 0.0 }),
|
||||
};
|
||||
let mut t = Table::new(2001, 1, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
t.rows[0].props.height = Some(60.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
let mut p = Paragraph::with_text("", Default::default());
|
||||
p.insert_object(0, canvas, &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![para_block(p)];
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.props.height = Some(600.0);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
fn count(it: &Placed) -> usize {
|
||||
match it {
|
||||
Placed::Clip { items, .. } => items.iter().map(count).fold(1, usize::saturating_add),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 1000, "{} pages", l.pages.len());
|
||||
let items: usize = l.pages.iter().flat_map(|p| &p.items).map(count).sum();
|
||||
assert!(items <= MAX_HEADER_ITEMS + 2000 + 50 * l.pages.len(), "{items} items placed");
|
||||
assert!(items > 100_000, "the header still repeats on early pages: {items}");
|
||||
}
|
||||
|
||||
/// A table with a repeating header row in a two-column section ended by a continuous break, so
|
||||
/// its columns are balanced: laid out once, then again from a snapshot for each balancing try.
|
||||
fn balanced_header_table(rows: usize) -> Document {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let mut d = Document::from_text("Before\nEnd of the section\nAfter the break");
|
||||
let mut s = SectionProps::default();
|
||||
s.columns.count = 2;
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(s));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
let mut t = Table::new(rows, 1, 200.0);
|
||||
t.rows[0].props.header = true;
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Header rows placed (each repeat of the one-column header row is one cell of row 0).
|
||||
fn header_cells(l: &DocLayout) -> usize {
|
||||
l.pages.iter().flat_map(|p| &p.items).filter(|i| matches!(i, Placed::Cell { row: 0, .. })).count()
|
||||
}
|
||||
|
||||
fn with_header_cap<T>(cap: usize, f: impl FnOnce() -> T) -> T {
|
||||
headers::HEADER_ITEMS_CAP.with(|c| c.set(cap));
|
||||
let out = f();
|
||||
headers::HEADER_ITEMS_CAP.with(|c| c.set(MAX_HEADER_ITEMS));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balancing_columns_gives_back_the_header_repeats_it_throws_away() {
|
||||
// The table runs over both columns of a page and onto a second one.
|
||||
let d = balanced_header_table(120);
|
||||
let full = lay(&d);
|
||||
let shown = header_cells(&full);
|
||||
assert!(shown >= 3, "the header repeats in each later column: {shown} header rows");
|
||||
// What one repeat costs: the smallest cap at which a plain two-page table repeats its header.
|
||||
let mut plain = Table::new(80, 1, 200.0);
|
||||
plain.rows[0].props.header = true;
|
||||
let mut p = Document::from_text("");
|
||||
p.insert_block(StoryRef::Body, &Path::top(1), Block::Table(plain)).unwrap();
|
||||
let cost = (1..=200).find(|cap| with_header_cap(*cap, || header_cells(&lay(&p))) >= 2).expect("a header repeat costs a few items");
|
||||
// A budget holding exactly the repeats shown is enough: the balancing tries thrown away don't
|
||||
// spend it.
|
||||
let capped = with_header_cap((shown - 1) * cost, || lay(&d));
|
||||
assert_eq!(header_cells(&capped), shown, "the final layout repeats the header as often");
|
||||
}
|
||||
|
||||
@@ -208,13 +208,66 @@ struct Cut {
|
||||
byte: usize,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -237,6 +290,9 @@ 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);
|
||||
@@ -249,17 +305,20 @@ 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 cap the box takes the nearest layout in its direction: the widest line length
|
||||
// Past the work 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 layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None });
|
||||
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if work >= flow_work_cap() { 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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 }
|
||||
|
||||
@@ -16,6 +16,8 @@ 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;
|
||||
|
||||
@@ -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)
|
||||
let mut lines: Vec<(craft_ui::text::AlignedGalley, f32, bool)> = Vec::new();
|
||||
// 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();
|
||||
let card = if active {
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0)))
|
||||
} else {
|
||||
@@ -113,15 +113,21 @@ 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 };
|
||||
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), gap, i > 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));
|
||||
let when = crate::local_time::comment_time(r);
|
||||
if !when.is_empty() {
|
||||
lines.push((balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0), 1.0, false));
|
||||
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));
|
||||
}
|
||||
let text = app.session.doc.plain_text(StoryRef::Part(r.part));
|
||||
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0, false));
|
||||
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));
|
||||
}
|
||||
let h = lines.iter().map(|(g, gap, _)| g.height() + gap).sum::<f32>() + 12.0;
|
||||
let h = lines.iter().map(|(_, gap, _, h)| h + gap).sum::<f32>() + 12.0;
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, h))
|
||||
};
|
||||
if !card.intersects(clip) {
|
||||
@@ -145,14 +151,19 @@ 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) in lines {
|
||||
for (g, gap, rule, h) 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));
|
||||
}
|
||||
let h = g.height();
|
||||
g.paint(&text_painter, pos2(card.min.x + 10.0, y), t.text);
|
||||
// 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);
|
||||
y += h;
|
||||
}
|
||||
let r = ui.interact(card, ui.scope_id().with(("balloon", *id)), Sense::click());
|
||||
@@ -263,6 +274,15 @@ fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The height of a balloon body's first [`COMMENT_ROWS`](crate::panes::COMMENT_ROWS) rows; the
|
||||
/// rest is cut off until the balloon is selected and its field scrolls.
|
||||
fn shown_height(g: &craft_ui::text::AlignedGalley) -> f32 {
|
||||
match g {
|
||||
craft_ui::text::AlignedGalley::Aligned { galley, .. } => galley.rows.get(crate::panes::COMMENT_ROWS).map_or(g.height(), |r| r.pos.y),
|
||||
craft_ui::text::AlignedGalley::InvalidWidth { .. } => g.height(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only balloon's text, wrapped to `width`. The fork's layout orders right-to-left and
|
||||
/// mixed-direction paragraphs itself; `TextDirection::Auto` takes each paragraph's first strong
|
||||
/// character. Place each already-shaped row against the card's start edge, including neutral
|
||||
|
||||
@@ -48,6 +48,17 @@ 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,8 +78,7 @@ 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();
|
||||
// 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());
|
||||
ui.label(egui::RichText::new(issue_heading(&issue)).small().weak());
|
||||
if sugg.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
|
||||
}
|
||||
|
||||
@@ -4809,3 +4809,87 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
+210
-52
@@ -250,12 +250,17 @@ 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 {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
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));
|
||||
});
|
||||
} 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 {
|
||||
@@ -264,40 +269,149 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
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));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
crate::rtl::grid(ui, egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()), |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();
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
})
|
||||
.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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
@@ -307,24 +421,53 @@ pub fn models_ui(ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
crate::rtl::island(ui, |ui| {
|
||||
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("credits_models").striped(true).num_columns(6), |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();
|
||||
}
|
||||
});
|
||||
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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -404,4 +547,19 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,21 +322,17 @@ 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 {
|
||||
LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)).map(|(_, n)| tl!(n).to_string()).unwrap_or_else(|| tag.to_string())
|
||||
crate::ribbon::display_name(Some(tag))
|
||||
}
|
||||
|
||||
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
|
||||
ui.label(tl!("Mark selected text as:"));
|
||||
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());
|
||||
let list = crate::ribbon::language_list(&f.lang);
|
||||
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 = f.lang.eq_ignore_ascii_case(tag);
|
||||
let on = crate::ribbon::same_language(&f.lang, tag);
|
||||
let r = ui.selectable_label(on, name.as_str());
|
||||
if r.clicked() {
|
||||
f.lang = tag.clone();
|
||||
|
||||
@@ -1112,7 +1112,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::About { tab } = d else {
|
||||
return false;
|
||||
};
|
||||
ui.set_width(660.0);
|
||||
ui.set_width(820.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;
|
||||
|
||||
@@ -75,21 +75,39 @@ 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, locale) pairs whose bar was closed.
|
||||
/// (document, pack locale) pairs whose bar was closed.
|
||||
dismissed: HashSet<(u64, String)>,
|
||||
/// The missing packs of the shown document, what they were worked out from, and when.
|
||||
missing: Option<(MissingKey, f64, Vec<Pack>)>,
|
||||
/// (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)>,
|
||||
/// [`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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
/// 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)]
|
||||
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`.
|
||||
@@ -103,56 +121,49 @@ pub(crate) fn size_text(bytes: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"),
|
||||
/// 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"),
|
||||
];
|
||||
|
||||
/// The language a pack is for, in the interface language (`Dutch`, `Serbian (Latin)`), else its
|
||||
/// own name.
|
||||
/// 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.
|
||||
pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String {
|
||||
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(),
|
||||
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));
|
||||
}
|
||||
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
|
||||
@@ -165,6 +176,8 @@ 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;
|
||||
@@ -251,25 +264,45 @@ fn remove(app: &mut WordApp, locale: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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 {
|
||||
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 } == key && now - at < MISSING_REFRESH_SECS))
|
||||
&& (*k == key || (MissingKey { rev: key.rev, ..k.clone() } == key && now - at < MISSING_REFRESH_SECS))
|
||||
{
|
||||
return m.clone();
|
||||
}
|
||||
let m = packs::missing(&app.session);
|
||||
let packs = packs::missing(&app.session);
|
||||
let unlisted = if packs.is_empty() { packs::unlisted(&app.session) } else { Vec::new() };
|
||||
let m = Offers { packs, unlisted };
|
||||
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.
|
||||
@@ -279,7 +312,13 @@ pub(crate) fn bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let document = app.session.document_id();
|
||||
let now = ui.input(|i| i.time);
|
||||
let Some(pack) = missing(app, now).into_iter().find(|p| !app.packs_rt.dismissed.contains(&(document, p.locale.clone()))) else {
|
||||
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);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -318,6 +357,67 @@ 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 {
|
||||
@@ -586,6 +686,209 @@ 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')
|
||||
@@ -724,4 +1027,122 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ mod state;
|
||||
#[cfg(test)]
|
||||
pub(crate) use comments::comment_draft;
|
||||
use comments::comments;
|
||||
pub(crate) use comments::{CommentDrafts, comment_editor, commit_comment, commit_reply, flush_comment_drafts, has_comment_drafts, reply_editor};
|
||||
pub(crate) use comments::{
|
||||
COMMENT_ROWS, 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;
|
||||
|
||||
@@ -119,16 +119,17 @@ 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(app, ui, "comment_edit", id))
|
||||
.id(field)
|
||||
.interactive(editable)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(tl!("Add a comment"));
|
||||
if let Some(f) = font {
|
||||
edit = edit.font(f);
|
||||
if let Some(f) = &font {
|
||||
edit = edit.font(f.clone());
|
||||
}
|
||||
let r = ui.add(edit);
|
||||
let r = capped_field(ui, field.with("scroll"), font.as_ref(), edit);
|
||||
if focus {
|
||||
r.request_focus();
|
||||
}
|
||||
@@ -146,8 +147,24 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
fn field_id(app: &WordApp, ui: &Ui, kind: &str, id: u32) -> egui::Id {
|
||||
ui.scope_id().with((kind, app.session.document_id(), id))
|
||||
}
|
||||
@@ -179,16 +196,17 @@ 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(app, ui, "comment_reply", id))
|
||||
.id(field)
|
||||
.interactive(editable)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(tl!("Write a reply"));
|
||||
if let Some(f) = font {
|
||||
edit = edit.font(f);
|
||||
if let Some(f) = &font {
|
||||
edit = edit.font(f.clone());
|
||||
}
|
||||
let r = ui.add(edit);
|
||||
let r = capped_field(ui, field.with("scroll"), font.as_ref(), edit);
|
||||
if focus {
|
||||
r.request_focus();
|
||||
}
|
||||
|
||||
@@ -445,3 +445,26 @@ 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:?}");
|
||||
}
|
||||
|
||||
@@ -47,11 +47,17 @@ 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;
|
||||
pub(crate) use language::language_name;
|
||||
#[cfg(test)]
|
||||
pub(crate) use language::short_languages;
|
||||
#[cfg(test)]
|
||||
pub(crate) use language::sort_key;
|
||||
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};
|
||||
|
||||
@@ -20,36 +20,221 @@ pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [
|
||||
("es-UY", "Spanish (Uruguay)"),
|
||||
];
|
||||
|
||||
/// Every language WordCraft has an English name for, by tag: the Language menu's short list, the
|
||||
/// Language dialog's built-in list, then the Language pack languages neither has (Bulgarian…).
|
||||
fn known() -> impl Iterator<Item = &'static (&'static str, &'static str)> {
|
||||
PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).chain(crate::language_packs::MORE_LANGUAGES.iter())
|
||||
}
|
||||
|
||||
/// The language tag a document stores for text in a Language pack's language: the tag WordCraft
|
||||
/// already names for it (`nl` → `nl-NL`, `sr-latn` → `sr-Latn-RS`, `bg` → `bg-BG`), else the
|
||||
/// locale in BCP 47 case (`de-at` → `de-AT`). [`wordcraft_engine::packs::pack_for_tag`] finds the
|
||||
/// same pack again from it, so text marked in a pack's language is proofed by that pack.
|
||||
pub(crate) fn tag_for_pack(locale: &str) -> String {
|
||||
let wanted = locale.replace('_', "-");
|
||||
let exact = known().find(|(t, _)| t.eq_ignore_ascii_case(&wanted));
|
||||
// A variant WordCraft names with its region (`ca-valencia` is `ca-ES-valencia`).
|
||||
let regioned = || known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(&wanted));
|
||||
if let Some((t, _)) = exact.or_else(|| named_starting(&wanted)).or_else(regioned) {
|
||||
return (*t).to_string();
|
||||
}
|
||||
let parts: Vec<String> = locale
|
||||
.split(['-', '_'])
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let alpha = p.chars().all(|c| c.is_ascii_alphabetic());
|
||||
if i > 0 && wordcraft_engine::packs::is_region(p) {
|
||||
p.to_ascii_uppercase()
|
||||
} else if i > 0 && alpha && p.len() == 4 {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let mut c = lower.chars();
|
||||
c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default()
|
||||
} else {
|
||||
p.to_ascii_lowercase()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
parts.join("-")
|
||||
}
|
||||
|
||||
/// The first language WordCraft names whose tag starts with `prefix` and a `-` (`bg` is
|
||||
/// `bg-BG`, `sr-Latn` is `sr-Latn-RS`).
|
||||
fn named_starting(prefix: &str) -> Option<&'static (&'static str, &'static str)> {
|
||||
known().find(|(t, _)| {
|
||||
t.len() > prefix.len()
|
||||
&& t.get(..prefix.len()).is_some_and(|p| p.eq_ignore_ascii_case(prefix))
|
||||
&& t.as_bytes().get(prefix.len()) == Some(&b'-')
|
||||
})
|
||||
}
|
||||
|
||||
/// A pack language's name for the lists, in English: the name WordCraft has for its tag, else the
|
||||
/// pack's own name (as shown, without format characters), else the tag.
|
||||
fn pack_language_name(tag: &str, endonym: &str) -> String {
|
||||
let named = language_name(Some(tag));
|
||||
if !named.eq_ignore_ascii_case(tag) || endonym.trim().is_empty() {
|
||||
return named;
|
||||
}
|
||||
crate::language_packs::shown(endonym, 40)
|
||||
}
|
||||
|
||||
/// The installed and available Language packs, as (document tag, pack's own name), installed first.
|
||||
fn pack_languages(available: bool) -> Vec<(String, String)> {
|
||||
let installed = wordcraft_engine::packs::installed().into_iter().map(|i| (i.locale, i.name));
|
||||
let index: Vec<(String, String)> =
|
||||
if available { wordcraft_engine::packs::available().into_iter().map(|p| (p.locale, p.name)).collect() } else { Vec::new() };
|
||||
installed.chain(index).map(|(locale, name)| (tag_for_pack(&locale), name)).collect()
|
||||
}
|
||||
|
||||
/// Review › Language's and the status bar's short list, as (tag, English name): the languages
|
||||
/// WordCraft proofs itself, then each installed Language pack's language not among them, by name.
|
||||
pub(crate) fn short_languages() -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = PROOFING_LANGUAGES.iter().map(|(t, n)| ((*t).to_string(), (*n).to_string())).collect();
|
||||
let mut more: Vec<(String, String)> = Vec::new();
|
||||
for (tag, endonym) in pack_languages(false) {
|
||||
if !out.iter().chain(&more).any(|(t, _)| t.eq_ignore_ascii_case(&tag)) {
|
||||
let name = pack_language_name(&tag, &endonym);
|
||||
more.push((tag, name));
|
||||
}
|
||||
}
|
||||
more.sort_by_cached_key(|(t, n)| sort_key(&display_of(t, n)));
|
||||
out.extend(more);
|
||||
out
|
||||
}
|
||||
|
||||
/// The Set Proofing Language dialog's list, as (tag, name in the interface language), sorted by
|
||||
/// name: every language WordCraft names, every installed Language pack's and every available
|
||||
/// one's, each once.
|
||||
pub(crate) fn all_languages() -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let builtin = PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).map(|(t, n)| ((*t).to_string(), (*n).to_string()));
|
||||
let packs = pack_languages(true).into_iter().map(|(tag, endonym)| {
|
||||
let name = pack_language_name(&tag, &endonym);
|
||||
(tag, name)
|
||||
});
|
||||
for (tag, name) in builtin.chain(packs) {
|
||||
if !out.iter().any(|(t, _)| t.eq_ignore_ascii_case(&tag)) {
|
||||
out.push((tag.clone(), display_of(&tag, &name)));
|
||||
}
|
||||
}
|
||||
out.sort_by_cached_key(|(_, n)| sort_key(n));
|
||||
out
|
||||
}
|
||||
|
||||
/// The Set Proofing Language dialog's list: [`all_languages`], with the `current` language when
|
||||
/// it is none of them ([`same_language`]: a row is checked for it then).
|
||||
pub(crate) fn language_list(current: &str) -> Vec<(String, String)> {
|
||||
let mut list = all_languages();
|
||||
// A tag the list has under another spelling (`nl` for `nl-NL`) is that row, not another.
|
||||
if !current.is_empty() && !list.iter().any(|(t, _)| same_language(current, t)) {
|
||||
list.push((current.to_string(), display_name(Some(current))));
|
||||
list.sort_by_cached_key(|(_, n)| sort_key(n));
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// How a language name sorts: case-insensitively, a Latin letter with a mark beside the plain one
|
||||
/// (Čeština with Czech, Ελληνικά and other scripts after Latin, by code point).
|
||||
pub(crate) fn sort_key(name: &str) -> (String, String) {
|
||||
const FOLD: [(&str, &str); 22] = [
|
||||
("àáâãäåāăą", "a"),
|
||||
("æ", "ae"),
|
||||
("çćĉċč", "c"),
|
||||
("ďđð", "d"),
|
||||
("èéêëēĕėęě", "e"),
|
||||
("ĝğġģ", "g"),
|
||||
("ĥħ", "h"),
|
||||
("ìíîïĩīĭįı", "i"),
|
||||
("ĵ", "j"),
|
||||
("ķ", "k"),
|
||||
("ĺļľŀł", "l"),
|
||||
("ñńņňʼn", "n"),
|
||||
("òóôõöøōŏő", "o"),
|
||||
("œ", "oe"),
|
||||
("ŕŗř", "r"),
|
||||
("śŝşšș", "s"),
|
||||
("ß", "ss"),
|
||||
("ţťŧțþ", "t"),
|
||||
("ùúûüũūŭůűų", "u"),
|
||||
("ŵ", "w"),
|
||||
("ýÿŷ", "y"),
|
||||
("źżž", "z"),
|
||||
];
|
||||
let lower = name.to_lowercase();
|
||||
let mut folded = String::with_capacity(lower.len());
|
||||
for c in lower.chars() {
|
||||
match FOLD.iter().find(|(from, _)| from.contains(c)) {
|
||||
Some((_, to)) => folded.push_str(to),
|
||||
None => folded.push(c),
|
||||
}
|
||||
}
|
||||
(folded, lower)
|
||||
}
|
||||
|
||||
/// A language's name in English, as the lists build it: a whole name (`Polish`, `Serbian (Latin)`)
|
||||
/// or a language with a region the lists have no entry for (`Swedish` and `Finland`).
|
||||
enum NameParts {
|
||||
Whole(String),
|
||||
Regioned { language: String, region: String },
|
||||
}
|
||||
|
||||
/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's
|
||||
/// default, US English): a listed language by its name; another region of one by the language
|
||||
/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag.
|
||||
pub(crate) fn language_name(tag: Option<&str>) -> String {
|
||||
match name_parts(tag) {
|
||||
NameParts::Whole(n) => n,
|
||||
NameParts::Regioned { language, region } => format!("{language} ({region})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`language_name`] in the interface language: a whole name is looked up as it is, a language
|
||||
/// with a region is the translated `{language} ({region})` of the translated language and region
|
||||
/// (a name built at run time has no catalog row of its own). English is unchanged.
|
||||
pub(crate) fn display_name(tag: Option<&str>) -> String {
|
||||
display_name_with(tag, &|s| tl!(s).to_string())
|
||||
}
|
||||
|
||||
/// [`display_name`] with the lookup given (`translate`: English in, interface language out).
|
||||
pub(super) fn display_name_with(tag: Option<&str>, translate: &dyn Fn(&str) -> String) -> String {
|
||||
match name_parts(tag) {
|
||||
NameParts::Whole(n) => translate(&n),
|
||||
NameParts::Regioned { language, region } => {
|
||||
crate::i18n::fmt(&translate("{language} ({region})"), &[("language", &translate(&language)), ("region", &translate(®ion))])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name a list shows for `tag` whose English name is `name`: [`display_name`] for the name
|
||||
/// WordCraft has for the tag, else `name` as it is (a pack's own name for a language WordCraft
|
||||
/// has none for).
|
||||
pub(crate) fn display_of(tag: &str, name: &str) -> String {
|
||||
if name == language_name(Some(tag)) { display_name(Some(tag)) } else { name.to_string() }
|
||||
}
|
||||
|
||||
fn name_parts(tag: Option<&str>) -> NameParts {
|
||||
let tag = tag.unwrap_or("en-US");
|
||||
let named =
|
||||
|t: &str| PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n);
|
||||
let named = |t: &str| known().find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n);
|
||||
// Any language the Language dialog offers (French, German…) by its name, not its tag (#860).
|
||||
if let Some(name) = named(tag) {
|
||||
return name.to_string();
|
||||
return NameParts::Whole(name.to_string());
|
||||
}
|
||||
let mut parts = tag.split(['-', '_']);
|
||||
let primary = parts.next().unwrap_or("");
|
||||
// The region: the first two-letter or three-digit subtag after the language (and script).
|
||||
let region =
|
||||
parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())));
|
||||
// The region: the first region subtag after the language (and script).
|
||||
let region = parts.find(|p| wordcraft_engine::packs::is_region(p));
|
||||
match region {
|
||||
// A region the list doesn't have: the language's name without its own region or variant.
|
||||
Some(r) => {
|
||||
let language = crate::dialogs_layout::LANGUAGES
|
||||
.iter()
|
||||
let language = known()
|
||||
.find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary)))
|
||||
.map(|(_, n)| n.split(" (").next().unwrap_or(n));
|
||||
match language {
|
||||
Some(language) => format!("{language} ({})", region_name(r)),
|
||||
None => tag.to_string(),
|
||||
Some(language) => NameParts::Regioned { language: language.to_string(), region: region_name(r) },
|
||||
None => NameParts::Whole(tag.to_string()),
|
||||
}
|
||||
}
|
||||
// A bare language subtag ("pt", "pl"): the variant WordCraft proofs.
|
||||
None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
|
||||
None => NameParts::Whole(match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
|
||||
wordcraft_engine::proof::ProofLang::Pl => "Polish".into(),
|
||||
wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(),
|
||||
wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(),
|
||||
@@ -61,14 +246,18 @@ pub(crate) fn language_name(tag: Option<&str>) -> String {
|
||||
wordcraft_engine::proof::ProofLang::Es => "Spanish (Spain)".into(),
|
||||
wordcraft_engine::proof::ProofLang::De => "German (Germany)".into(),
|
||||
wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(),
|
||||
_ => tag.to_string(),
|
||||
},
|
||||
// A language (and script) without its region: the named tag it starts (`bg` is
|
||||
// Bulgarian, `sr-Latn` Serbian (Latin)).
|
||||
_ => named_starting(tag)
|
||||
.or_else(|| known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(tag)))
|
||||
.map_or_else(|| tag.to_string(), |(_, n)| (*n).to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The English name of a region subtag the language list has no entry for (else the subtag).
|
||||
fn region_name(code: &str) -> String {
|
||||
const REGIONS: [(&str, &str); 62] = [
|
||||
const REGIONS: [(&str, &str); 69] = [
|
||||
("AE", "U.A.E."),
|
||||
("AO", "Angola"),
|
||||
("AR", "Argentina"),
|
||||
@@ -85,6 +274,7 @@ fn region_name(code: &str) -> String {
|
||||
("CN", "China"),
|
||||
("CO", "Colombia"),
|
||||
("CR", "Costa Rica"),
|
||||
("CU", "Cuba"),
|
||||
("CV", "Cabo Verde"),
|
||||
("CY", "Cyprus"),
|
||||
("DE", "Germany"),
|
||||
@@ -92,10 +282,13 @@ fn region_name(code: &str) -> String {
|
||||
("EC", "Ecuador"),
|
||||
("EG", "Egypt"),
|
||||
("ES", "Spain"),
|
||||
("FI", "Finland"),
|
||||
("FR", "France"),
|
||||
("GB", "United Kingdom"),
|
||||
("GQ", "Equatorial Guinea"),
|
||||
("GT", "Guatemala"),
|
||||
("HK", "Hong Kong SAR"),
|
||||
("HN", "Honduras"),
|
||||
("IE", "Ireland"),
|
||||
("IN", "India"),
|
||||
("IQ", "Iraq"),
|
||||
@@ -114,8 +307,10 @@ fn region_name(code: &str) -> String {
|
||||
("MX", "Mexico"),
|
||||
("MY", "Malaysia"),
|
||||
("MZ", "Mozambique"),
|
||||
("NI", "Nicaragua"),
|
||||
("NL", "Netherlands"),
|
||||
("NZ", "New Zealand"),
|
||||
("PA", "Panama"),
|
||||
("PE", "Peru"),
|
||||
("PH", "Philippines"),
|
||||
("PR", "Puerto Rico"),
|
||||
@@ -124,6 +319,7 @@ fn region_name(code: &str) -> String {
|
||||
("QA", "Qatar"),
|
||||
("RU", "Russia"),
|
||||
("SG", "Singapore"),
|
||||
("SV", "El Salvador"),
|
||||
("SY", "Syria"),
|
||||
("TW", "Taiwan"),
|
||||
("UA", "Ukraine"),
|
||||
@@ -137,7 +333,7 @@ fn region_name(code: &str) -> String {
|
||||
|
||||
/// The language at the caret (resolved through the styles), translated, for the status bar.
|
||||
pub(crate) fn caret_language(app: &WordApp) -> String {
|
||||
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
|
||||
display_name(caret_lang_tag(app).as_deref())
|
||||
}
|
||||
|
||||
/// The formatting at the caret, resolved through the styles and the document default.
|
||||
@@ -189,24 +385,13 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
|
||||
_ => String::new(),
|
||||
};
|
||||
let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false);
|
||||
let listed = |tag: &str| {
|
||||
current.eq_ignore_ascii_case(tag)
|
||||
|| (tag == "pl-PL" && current.eq_ignore_ascii_case("pl"))
|
||||
|| (tag == "pt-BR" && current.eq_ignore_ascii_case("pt"))
|
||||
|| (tag == "it-IT" && current.eq_ignore_ascii_case("it"))
|
||||
|| (tag == "hr-HR" && current.eq_ignore_ascii_case("hr"))
|
||||
|| (tag == "tr-TR" && current.eq_ignore_ascii_case("tr"))
|
||||
|| (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Cs)
|
||||
|| (tag == "id-ID" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Id)
|
||||
|| (tag == "es-ES" && current.eq_ignore_ascii_case("es"))
|
||||
|| (tag == "en-US" && current.eq_ignore_ascii_case("en"))
|
||||
};
|
||||
let checks = language_checks(¤t, listed);
|
||||
if let Some(name) = checks.extra {
|
||||
mi_check(ui, app, &name, true, "review.language", json!({"lang": current}));
|
||||
let entries = short_languages();
|
||||
let checks = language_checks(¤t, &entries);
|
||||
if checks.extra.is_some() {
|
||||
mi_check(ui, app, &display_name(Some(¤t)), true, "review.language", json!({"lang": current}));
|
||||
}
|
||||
for ((tag, name), on) in PROOFING_LANGUAGES.iter().zip(checks.listed) {
|
||||
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
|
||||
for ((tag, name), on) in entries.iter().zip(checks.listed) {
|
||||
mi_check(ui, app, &display_of(tag, name), on, "review.language", json!({"lang": tag}));
|
||||
}
|
||||
ui.separator();
|
||||
mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof}));
|
||||
@@ -215,19 +400,159 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
|
||||
mi_check(ui, app, "Language Preferences…", false, "file.options", json!({"pane": "language"}));
|
||||
}
|
||||
|
||||
/// The Language menu's check marks for the `current` tag: which short-list entries are checked
|
||||
/// (`listed` matches a tag), and the name of the current language when it is none of them,
|
||||
/// shown checked above. A language named like a short-list entry checks that entry, so no name
|
||||
/// shows twice.
|
||||
/// The Language menu's check marks for the `current` tag: which `entries` (tag, English name) are
|
||||
/// checked, and the name of the current language when it is none of them, shown checked above.
|
||||
/// An entry is checked for its own tag (any case), or for a tag that is the same language
|
||||
/// ([`same_language`]: `pl` for Polish, `ces` for Czech, `PT-br-x-test` for Portuguese (Brazil)),
|
||||
/// so no language shows twice; English (New Zealand) doesn't check English (United Kingdom).
|
||||
pub(super) struct LanguageChecks {
|
||||
pub(super) extra: Option<String>,
|
||||
pub(super) listed: [bool; PROOFING_LANGUAGES.len()],
|
||||
pub(super) listed: Vec<bool>,
|
||||
}
|
||||
|
||||
pub(super) fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChecks {
|
||||
let name = (!current.is_empty()).then(|| language_name(Some(current)));
|
||||
let on = PROOFING_LANGUAGES.map(|(t, n)| listed(t) || name.as_deref() == Some(n));
|
||||
LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on }
|
||||
pub(super) fn language_checks(current: &str, entries: &[(String, String)]) -> LanguageChecks {
|
||||
let on: Vec<bool> = entries.iter().map(|(t, _)| same_language(current, t)).collect();
|
||||
let extra = (!current.is_empty() && !on.contains(&true)).then(|| language_name(Some(current)));
|
||||
LanguageChecks { extra, listed: on }
|
||||
}
|
||||
|
||||
/// A language tag's parts, lowercase: what [`same_language`] compares instead of names.
|
||||
#[derive(Default)]
|
||||
struct TagParts {
|
||||
language: String,
|
||||
/// Four letters (`latn`).
|
||||
script: Option<String>,
|
||||
/// [`wordcraft_engine::packs::is_region`].
|
||||
region: Option<String>,
|
||||
/// Five to eight letters or digits, or four starting with a digit (`valencia`).
|
||||
variants: Vec<String>,
|
||||
}
|
||||
|
||||
/// A tag's parts. An extension or private use (`-x-test`) ends the tag; a tag is a handful of short
|
||||
/// subtags, so only the start of a longer one is read.
|
||||
fn tag_parts(tag: &str) -> TagParts {
|
||||
let lower = tag.trim().replace('_', "-").to_ascii_lowercase();
|
||||
let mut subtags = lower.split('-').take(8);
|
||||
let mut out = TagParts { language: subtags.next().unwrap_or("").to_string(), ..Default::default() };
|
||||
for p in subtags {
|
||||
let alnum = p.bytes().all(|b| b.is_ascii_alphanumeric());
|
||||
if p.len() < 2 || !alnum {
|
||||
break;
|
||||
}
|
||||
if out.script.is_none() && out.region.is_none() && p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphabetic()) {
|
||||
out.script = Some(p.to_string());
|
||||
} else if out.region.is_none() && wordcraft_engine::packs::is_region(p) {
|
||||
out.region = Some(p.to_string());
|
||||
} else if (5..=8).contains(&p.len()) || (p.len() == 4 && p.bytes().next().is_some_and(|b| b.is_ascii_digit())) {
|
||||
out.variants.push(p.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A tag's parts, with the region (and script) the lists name it with when it has none: `nl` is
|
||||
/// `nl-NL`, `sr-latn` is `sr-Latn-RS`, `pt` is `pt-BR`.
|
||||
fn listed_parts(tag: &str) -> TagParts {
|
||||
let mut parts = tag_parts(tag);
|
||||
if parts.region.is_none() {
|
||||
let named = tag_parts(&tag_for_pack(tag));
|
||||
parts.region = named.region;
|
||||
if parts.script.is_none() {
|
||||
parts.script = named.script;
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// Are `a` and `b` the same language as the lists show it: the same tag (any case), or tags that
|
||||
/// agree in script, region and variant (one lacking a script or region takes the one the lists name
|
||||
/// it with) and that WordCraft proofs alike ([`same_proofing`]): `nl` is the `nl-NL` row, `pl` the
|
||||
/// `pl-PL` one, `ces` the `cs-CZ` one. Tags are compared, never the names the lists show for them.
|
||||
pub(crate) fn same_language(a: &str, b: &str) -> bool {
|
||||
if a.eq_ignore_ascii_case(b) {
|
||||
return true;
|
||||
}
|
||||
if a.trim().is_empty() || b.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (pa, pb) = (listed_parts(a), listed_parts(b));
|
||||
let agree = |x: &Option<String>, y: &Option<String>| x.is_none() || y.is_none() || x == y;
|
||||
// A language the lists name no region for (`xx`, `kl`) is not its regioned tag (`xx-AA`): they
|
||||
// stay separate rows. Rules WordCraft has built in (`ces` for `cs-CZ`) match without one.
|
||||
use wordcraft_engine::proof::ProofLang;
|
||||
if pa.region.is_none() != pb.region.is_none() && matches!(ProofLang::from_tag(Some(a)), ProofLang::Pack(_) | ProofLang::Other) {
|
||||
return false;
|
||||
}
|
||||
agree(&pa.script, &pb.script) && agree(&pa.region, &pb.region) && variants_agree(&pa.variants, &pb.variants) && same_proofing(a, b)
|
||||
}
|
||||
|
||||
/// Do two tags' variants (`valencia`, `1996`) agree: the same ones, or one tag has none and the
|
||||
/// other's are no variant a pack or the lists name (`de-DE-1996` is the `de-DE` row, but
|
||||
/// `ca-ES-valencia` is not the `ca` one).
|
||||
fn variants_agree(a: &[String], b: &[String]) -> bool {
|
||||
let (mut a, mut b) = (a.to_vec(), b.to_vec());
|
||||
a.sort();
|
||||
b.sort();
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
let named = |v: &String| {
|
||||
let in_tag = |t: &str| t.split(['-', '_']).skip(1).any(|p| p.eq_ignore_ascii_case(v));
|
||||
known().any(|(t, _)| in_tag(t))
|
||||
|| wordcraft_engine::packs::available().iter().any(|p| in_tag(&p.locale))
|
||||
|| wordcraft_engine::packs::installed().iter().any(|p| in_tag(&p.locale))
|
||||
};
|
||||
let extra = if a.is_empty() {
|
||||
&b
|
||||
} else if b.is_empty() {
|
||||
&a
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
!extra.iter().any(named)
|
||||
}
|
||||
|
||||
/// Does WordCraft proof `a` and `b` the same way: the same built-in language, or two Language
|
||||
/// pack languages read from the same pack ([`wordcraft_engine::packs::pack_for_tag`]: `nl` and
|
||||
/// `nl-NL` are both the `nl` pack's, though their pack locales differ); with neither pack known,
|
||||
/// the same language and script whatever the region. Languages WordCraft has no rules or pack for
|
||||
/// are the same only when their language subtags are.
|
||||
fn same_proofing(a: &str, b: &str) -> bool {
|
||||
use wordcraft_engine::packs;
|
||||
use wordcraft_engine::proof::ProofLang;
|
||||
let (la, lb) = (ProofLang::from_tag(Some(a)), ProofLang::from_tag(Some(b)));
|
||||
if la == lb {
|
||||
// Languages without rules or a pack are the same only as the same language, script and
|
||||
// region (`xx` is not `xx-AA`).
|
||||
if la == ProofLang::Other {
|
||||
let (pa, pb) = (tag_parts(a), tag_parts(b));
|
||||
return pa.language == pb.language && pa.script == pb.script && pa.region == pb.region;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let (Some(pa), Some(pb)) = (la.pack_locale(), lb.pack_locale()) else { return false };
|
||||
let mut known = packs::available();
|
||||
for i in packs::installed() {
|
||||
if !known.iter().any(|p| p.locale == i.locale) {
|
||||
known.push(packs::Pack {
|
||||
locale: i.locale,
|
||||
name: i.name,
|
||||
version: String::new(),
|
||||
url: String::new(),
|
||||
source: None,
|
||||
size: 0,
|
||||
licence: i.licence,
|
||||
class: String::new(),
|
||||
spelling: false,
|
||||
hyphenation: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
match (packs::pack_for_tag(a, &known), packs::pack_for_tag(b, &known)) {
|
||||
(Some(x), Some(y)) => x.locale == y.locale,
|
||||
(None, None) => packs::without_region(pa.as_str()).eq_ignore_ascii_case(&packs::without_region(pb.as_str())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu item with a check mark when `checked` (the current choice of several).
|
||||
|
||||
@@ -229,6 +229,111 @@ fn page_setup_menus_mark_the_current_setting() {
|
||||
assert_eq!(first_words, ["Narrow", "Landscape", "A4", "Right"], "{checked:?}");
|
||||
}
|
||||
|
||||
/// The Language menu's built-in short list, as `language_checks` takes it.
|
||||
fn short() -> Vec<(String, String)> {
|
||||
PROOFING_LANGUAGES.iter().map(|(t, n)| ((*t).to_string(), (*n).to_string())).collect()
|
||||
}
|
||||
|
||||
/// Check marks with only `tag` checked.
|
||||
fn listed_only(tag: &str) -> Vec<bool> {
|
||||
PROOFING_LANGUAGES.iter().map(|(t, _)| *t == tag).collect()
|
||||
}
|
||||
|
||||
/// CP4.3: the menu's check marks are worked out one way (the tag, or the same proofing language
|
||||
/// named alike), not by a case per language: a bare or ISO 639-2 tag checks its language, and a
|
||||
/// region WordCraft proofs like another (New Zealand like the UK) doesn't check that one.
|
||||
#[test]
|
||||
fn language_menu_checks_by_tag_or_proofing_language() {
|
||||
for (current, tag) in [
|
||||
("pl", "pl-PL"),
|
||||
("pt", "pt-BR"),
|
||||
("it", "it-IT"),
|
||||
("hr", "hr-HR"),
|
||||
("tr", "tr-TR"),
|
||||
("ces", "cs-CZ"),
|
||||
("in", "id-ID"),
|
||||
("es", "es-ES"),
|
||||
("en", "en-US"),
|
||||
("RU-ru", "ru-RU"),
|
||||
] {
|
||||
let c = language_checks(current, &short());
|
||||
assert_eq!((c.extra, c.listed), (None, listed_only(tag)), "{current}");
|
||||
}
|
||||
let c = language_checks("en-NZ", &short());
|
||||
assert_eq!(c.extra.as_deref(), Some("English (New Zealand)"));
|
||||
assert!(!c.listed.contains(&true), "English (New Zealand) doesn't check English (United Kingdom)");
|
||||
// A pack language in the list (once installed) is checked for its bare tag too.
|
||||
let mut entries = short();
|
||||
entries.push(("bg-BG".into(), "Bulgarian".into()));
|
||||
let c = language_checks("bg", &entries);
|
||||
assert_eq!((c.extra, c.listed.last().copied()), (None, Some(true)));
|
||||
// CP4.3 review: a bare pack-language tag checks the regioned row of the same pack, however
|
||||
// the packs are known (`nl` and `nl-NL` are both the `nl` pack's), and shows no second Dutch.
|
||||
entries.push(("nl-NL".into(), "Dutch".into()));
|
||||
for current in ["nl", "NL-nl"] {
|
||||
let c = language_checks(current, &entries);
|
||||
assert_eq!((c.extra, c.listed.last().copied()), (None, Some(true)), "{current}");
|
||||
}
|
||||
let c = language_checks("nl-BE", &entries);
|
||||
assert_eq!((c.extra.as_deref(), c.listed.last().copied()), (Some("Dutch (Belgium)"), Some(false)));
|
||||
assert!(super::language::same_language("sv", "sv-SE") && !super::language::same_language("sv-FI", "sv-SE"));
|
||||
// Nothing set (mixed selection): nothing checked, nothing above.
|
||||
let c = language_checks("", &entries);
|
||||
assert_eq!((c.extra, c.listed.contains(&true)), (None, false));
|
||||
}
|
||||
|
||||
/// CP4.3 review: rows are matched by tag, never by the name shown. Serbian in Latin and in
|
||||
/// Cyrillic letters of Bosnia show under the same name but are two languages, so neither checks
|
||||
/// the other's row; a name that is translated or different doesn't matter to the match.
|
||||
#[test]
|
||||
fn rows_are_matched_by_tag_not_by_display_name() {
|
||||
use super::language::same_language;
|
||||
assert_eq!(language_name(Some("sr-Latn-BA")), language_name(Some("sr-Cyrl-BA")), "same display name");
|
||||
assert!(!same_language("sr-Latn-BA", "sr-Cyrl-BA"));
|
||||
let entries = vec![("sr-Latn-BA".to_string(), "Serbian".to_string()), ("sr-Cyrl-BA".to_string(), "Serbian".to_string())];
|
||||
assert_eq!(language_checks("sr-Cyrl-BA", &entries).listed, [false, true]);
|
||||
// A variant on an otherwise equal tag is the row without it, unless a pack or the lists name
|
||||
// the variant (Valencian is not Catalan); a language WordCraft has nothing for keeps its regions apart.
|
||||
assert!(same_language("de-DE-1996", "de-DE") && same_language("de-DE", "de-DE-1996"));
|
||||
assert!(!same_language("ca-ES-valencia", "ca-ES"));
|
||||
assert!(!same_language("xx", "xx-AA"), "xx vs xx-AA");
|
||||
assert!(same_language("xx-AA", "XX_aa"), "xx-AA vs XX_aa");
|
||||
assert_eq!(language_checks("de-DE-1996", &[("de-DE".to_string(), "German (Germany)".to_string())]).listed, [true]);
|
||||
// Two unknown languages never merge, though neither has a name but its tag.
|
||||
assert!(!same_language("xx", "yy") && !same_language("xx-AA", "yy-AA"));
|
||||
// The row's name can be anything (translated): the tag decides.
|
||||
let renamed = vec![("pl-PL".to_string(), "Polski".to_string())];
|
||||
assert_eq!(language_checks("pl", &renamed).listed, [true]);
|
||||
assert_eq!(language_checks("PL_pl", &renamed).listed, [true]);
|
||||
assert_eq!(language_checks("pt", &[("pt-PT".to_string(), "Portuguese (Brazil)".to_string())]).listed, [false]);
|
||||
}
|
||||
|
||||
/// CP4.3 review: a name built from a language and a region (`Swedish (Finland)`) has no catalog row
|
||||
/// of its own, so the language, the region and the `{language} ({region})` template are looked up
|
||||
/// separately and joined. English stays as `language_name` builds it.
|
||||
#[test]
|
||||
fn a_language_and_region_name_is_translated_in_parts() {
|
||||
use super::language::display_name_with;
|
||||
let fake = |s: &str| -> String {
|
||||
match s {
|
||||
"Swedish" => "X".into(),
|
||||
"Finland" => "Y".into(),
|
||||
"{language} ({region})" => "{language} \u{2013} {region}".into(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
};
|
||||
assert_eq!(display_name_with(Some("sv-FI"), &fake), "X \u{2013} Y");
|
||||
// A whole name is looked up as it is; the template isn't used for it.
|
||||
assert_eq!(display_name_with(Some("sv-SE"), &fake), "X");
|
||||
// Without a catalogue (English) the name is byte-identical to the English name.
|
||||
for tag in ["sv-FI", "es-CU", "fr-CA", "de-CH", "pt-AO", "en-SG", "sr-Latn-BA", "pl-PL", "nl", "xx-YY", "xx", "ca-valencia"] {
|
||||
assert_eq!(super::language::display_name(Some(tag)), language_name(Some(tag)), "{tag}");
|
||||
}
|
||||
assert_eq!(language_name(Some("sv-FI")), "Swedish (Finland)");
|
||||
// Matching never uses a translated name: Finnish Swedish and Swedish of Sweden stay two rows.
|
||||
assert!(!super::language::same_language("sv-FI", "sv-SE"));
|
||||
}
|
||||
|
||||
/// #860 review: a tag the language list doesn't have shows by language and region, not as
|
||||
/// the tag; Portuguese (Angola) isn't Brazil's, and no name shows twice in the menu.
|
||||
#[test]
|
||||
@@ -240,17 +345,16 @@ fn other_regions_show_by_name_once() {
|
||||
assert_eq!(language_name(Some("sr-Latn-BA")), "Serbian (Bosnia and Herzegovina)");
|
||||
assert_eq!(language_name(Some("en-ZZ")), "English (ZZ)");
|
||||
assert_eq!(language_name(Some("xx-YY")), "xx-YY");
|
||||
let exact = |cur: &'static str| move |t: &str| t.eq_ignore_ascii_case(cur);
|
||||
for tag in ["fr-CA", "pt-AO", "en-SG"] {
|
||||
let c = language_checks(tag, exact(tag));
|
||||
let c = language_checks(tag, &short());
|
||||
let name = c.extra.expect("shown above the list");
|
||||
assert!(!PROOFING_LANGUAGES.iter().any(|(_, n)| *n == name), "{name} is listed once");
|
||||
assert!(!c.listed.contains(&true), "{tag}: nothing in the list checked");
|
||||
}
|
||||
// A tag named like a list entry checks that entry instead of showing it again.
|
||||
let c = language_checks("PT-br-x-test", |_| false);
|
||||
let c = language_checks("PT-br-x-test", &short());
|
||||
assert_eq!(c.extra, None);
|
||||
assert_eq!(c.listed, PROOFING_LANGUAGES.map(|(t, _)| t == "pt-BR"));
|
||||
assert_eq!(c.listed, listed_only("pt-BR"));
|
||||
}
|
||||
|
||||
/// The Englishes WordCraft proofs differently are in the menu, checked by their tags, and
|
||||
@@ -262,8 +366,8 @@ fn english_dialects_are_in_the_language_menu() {
|
||||
{
|
||||
assert!(PROOFING_LANGUAGES.contains(&(tag, name)), "{tag}");
|
||||
assert_eq!(language_name(Some(tag)), name);
|
||||
let c = language_checks(&tag.to_ascii_lowercase(), |t| t.eq_ignore_ascii_case(tag));
|
||||
assert_eq!((c.extra, c.listed), (None, PROOFING_LANGUAGES.map(|(t, _)| t == tag)), "{tag}");
|
||||
let c = language_checks(&tag.to_ascii_lowercase(), &short());
|
||||
assert_eq!((c.extra, c.listed), (None, listed_only(tag)), "{tag}");
|
||||
}
|
||||
for (tag, name) in
|
||||
[("en-NZ", "English (New Zealand)"), ("en-IE", "English (Ireland)"), ("en-ZA", "English (South Africa)"), ("en-IN", "English (India)")]
|
||||
@@ -292,12 +396,12 @@ fn spanish_varieties_are_in_the_language_menu() {
|
||||
assert!(PROOFING_LANGUAGES.contains(&(tag, name)), "{tag}");
|
||||
assert!(crate::dialogs_layout::LANGUAGES.contains(&(tag, name)), "{tag}");
|
||||
assert_eq!(language_name(Some(tag)), name);
|
||||
let c = language_checks(&tag.to_ascii_lowercase(), |t| t.eq_ignore_ascii_case(tag));
|
||||
assert_eq!((c.extra, c.listed), (None, PROOFING_LANGUAGES.map(|(t, _)| t == tag)), "{tag}");
|
||||
let c = language_checks(&tag.to_ascii_lowercase(), &short());
|
||||
assert_eq!((c.extra, c.listed), (None, listed_only(tag)), "{tag}");
|
||||
}
|
||||
assert_eq!(language_name(Some("es")), "Spanish (Spain)");
|
||||
assert_eq!(language_name(Some("es-AR")), "Spanish (Argentina)");
|
||||
let c = language_checks("es-AR", |t| t.eq_ignore_ascii_case("es-AR"));
|
||||
let c = language_checks("es-AR", &short());
|
||||
assert_eq!(c.extra.as_deref(), Some("Spanish (Argentina)"));
|
||||
assert!(!c.listed.contains(&true));
|
||||
}
|
||||
|
||||
+6
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Interface languages, script support and proofing per language. How translation works (adding a
|
||||
language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and
|
||||
@@ -44,8 +44,7 @@ native-speaker review (human).
|
||||
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). Arabic UI landed (#1195).
|
||||
- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through
|
||||
the styles; Review › Language and the status bar set it: a short list, plus Set Proofing
|
||||
Language… for any of 38 languages and Set As Default for the document and future blank documents). Languages without proofing tools get
|
||||
the styles; Review › Language and the status bar set it). Languages without proofing tools get
|
||||
Language… for every built-in language and every installed or available Language pack language, and Set As Default for the document and future blank documents). Languages without proofing tools get
|
||||
no spelling or grammar marks (as in Word without the language's tools) instead of being flagged
|
||||
as English. Detail in [Proofing per language](#proofing-per-language).
|
||||
- Document scripts: shaping via HarfRust (HarfBuzz port) for every script; bidi (UAX #9) and Arabic
|
||||
@@ -64,7 +63,7 @@ shared items below.
|
||||
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
|
||||
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
|
||||
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) |
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (spelling and suggestions from the French Language pack, elided forms first: `Jadore` → `J'adore`; no grammar marks) | 1–2 (grammar) |
|
||||
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
|
||||
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
|
||||
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; ruby (Phonetic Guide) over its base text, kept by HTML export/import (#1681); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text or document grid | no | partial | 3–5 |
|
||||
@@ -175,9 +174,21 @@ pinned pack index and installed through the font store into `<font store>/langua
|
||||
agents). A pack is read at once; the lookup order is the dictionaries folder, the installed pack,
|
||||
then the system's Hunspell dictionaries.
|
||||
- A document with text in a language whose pack is available but not installed shows a bar
|
||||
offering it; it never shows for a language without a pack, and closes per document.
|
||||
offering it, and closes per document. Once the list of packs is known it never shows for a
|
||||
language without a pack; before then (a fresh install) it offers a language with no dictionary
|
||||
provisionally: Install fetches the list, then installs the pack if there is one, and the bar goes
|
||||
if there isn't. The caret's typing language counts, and the spelling context menu's heading is in
|
||||
the interface language.
|
||||
- Help › About › Language data shows the full licence texts of the embedded English data and the
|
||||
installed packs.
|
||||
- One language list: Set Proofing Language lists the built-in languages and every installed or
|
||||
available pack's language (Bulgarian, Belarusian, Spanish (Cuba)…), each once and sorted by
|
||||
name; Review › Language and the status bar's menu show the short list plus the installed packs'
|
||||
languages. A pack's language is stored as the tag WordCraft names (`bg-BG`, `sr-Latn-RS`,
|
||||
`de-AT`, Word's `ca-ES-valencia` for Valencian), which finds that pack again, so the text is
|
||||
proofed by it; a bare tag (`nl`) is the same row as its regioned one (`nl-NL`), compared by tag
|
||||
(language, script, region, variant), never by the name shown. Every pack in the published
|
||||
index has an English name.
|
||||
- language-packs-2 is published and pinned (75 packs, craft-i18n `20467b63`), adding the
|
||||
compounding languages (Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian,
|
||||
Persian, Esperanto); Mongolian and Thai stay hidden. Install reads it,
|
||||
@@ -256,7 +267,12 @@ WordCraft keeps 2,54 cm).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review |
|
||||
| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review |
|
||||
| 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack |
|
||||
| 2026-11-03 | minor | Arabic (`ar`) interface: ~2,730 catalog rows from craft-i18n, `ar-*` locales, mirrored shell (ribbon, panes, File page, dialogs, menus), Arabic UI font fallback, document canvas independent (#1195); 28 languages; Hebrew UI still open |
|
||||
| 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian); Valencian is `ca-ES-valencia`; rows are matched by tag, not by the name shown |
|
||||
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language |
|
||||
| 2026-11-03 | trivial | Language packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto |
|
||||
| 2026-11-02 | trivial | Language packs (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded |
|
||||
| 2026-11-01 | minor | Language packs (CP4.3–CP4.5): install and remove a language's spelling and hyphenation from File › Options › Language or the missing-pack bar, About › Language data, the commands; the default build embeds English data only; the language requests point at their packs: Lithuanian and Romanian available once language-packs-1 is published, the others waiting on craft-proof engine support |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installing languages and spelling dictionaries
|
||||
|
||||
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
|
||||
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
|
||||
|
||||
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian,
|
||||
Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
|
||||
@@ -32,7 +32,12 @@ built in; every other language is a pack.
|
||||
again. The first time, click **Show available languages** to get the list.
|
||||
- When a document has text in a language whose pack isn't installed, a bar under the ribbon says
|
||||
*Dutch proofing isn't installed.* with an **Install** button showing the download size. ✕ hides
|
||||
it for that document. It never shows for a language that has no pack.
|
||||
it for that document. Once WordCraft has the list of packs, the bar shows only for a language
|
||||
that has a pack. Before the list has been fetched (a fresh install, or the list of an older
|
||||
WordCraft), it can't know, so it shows for a language that has no dictionary, with **Install**
|
||||
alone: clicking it gets the list, then the pack if there is one (if not, the bar goes). With
|
||||
downloading on, WordCraft fetches a missing or out-of-date list by itself once. Choosing a
|
||||
language with nothing selected counts too.
|
||||
- Installing needs **downloading** on: the same single setting as WordCraft's font downloads
|
||||
(the font store). Clicking Install, Show available languages or Refresh turns it on; nothing is
|
||||
downloaded before. Each pack is checked against the sha256 in WordCraft's pinned list before it
|
||||
@@ -183,6 +188,7 @@ Never commit dictionary files to this repository: they stay on the user's system
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list is fetched and gets the list, then the pack, on Install; a missing or stale list is fetched once while downloading is on; the caret's typing language counts |
|
||||
| 2026-11-03 | trivial | Language packs: language-packs-2 is published and pinned (75 languages, adding the compounding languages); Mongolian and Thai aren't packs yet, and why |
|
||||
| 2026-11-02 | trivial | Language packs: language-packs-1 is published and pinned (61 languages); which languages aren't packs yet and why (CP4.4) |
|
||||
| 2026-11-02 | trivial | The Flatpak's own dictionaries folder (`~/.var/app/ai.storyteller.wordcraft/data/wordcraft/dictionaries`); the host's folders are outside its sandbox |
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
|
||||
# the two equal).
|
||||
- type: git
|
||||
url: https://github.com/storytold/craft-i18n
|
||||
commit: 059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
dest: craft-i18n
|
||||
- cargo-sources.json
|
||||
|
||||
Reference in New Issue
Block a user