mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae5c65b9dd | ||
|
|
1f8f9e8e2c | ||
|
|
cf23ca73c9 | ||
|
|
b2b988cdc8 | ||
|
|
1ff68d64e3 | ||
|
|
051fd875ef | ||
|
|
ffb155a032 | ||
|
|
dd2a651093 | ||
|
|
bcbc8619e9 | ||
|
|
ab5ee42049 | ||
|
|
e00ff04d4a | ||
|
|
220994decf | ||
|
|
ab0020d6f4 | ||
|
|
5702fa08b2 | ||
|
|
1a8932b3ed | ||
|
|
8865455cfb | ||
|
|
0842d4faec | ||
|
|
14513ae5f6 | ||
|
|
7d9968fd98 | ||
|
|
7aab0b202c | ||
|
|
c4d7948e68 | ||
|
|
d393e99879 |
Generated
+1
-1
@@ -1103,7 +1103,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=89fa1559e94378ecef85cd7e4681400f4df053e0#89fa1559e94378ecef85cd7e4681400f4df053e0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
|
||||
+4
-5
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is the head of storytold/craft-libs#495 (review fixes), which contains #485 (CP4.2,
|
||||
# Language packs, on #483 SCOWL English and #482); the font store's `language-pack` kind is #486
|
||||
# (CP4.1). GitHub serves PR heads from storytold/craft-libs, and merged with merge commits they
|
||||
# stay in its main.
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "89fa1559e94378ecef85cd7e4681400f4df053e0", version = "=0.1.0" }
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
|
||||
@@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
|
||||
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
|
||||
+13
-3
File diff suppressed because one or more lines are too long
@@ -496,7 +496,7 @@ fn main() -> eframe::Result {
|
||||
// document too (#838).
|
||||
if !sample && app.session.prefs.region.is_some() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
app.replace_startup_document(doc);
|
||||
}
|
||||
app.watch_desktop_theme(&cc.egui_ctx);
|
||||
if prefs_enabled()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
use wordcraft_layout::ViewMode;
|
||||
|
||||
use super::{parse_pos, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -254,10 +255,24 @@ fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Page Up (`dir < 0`) / Page Down: the same place on the page before or after, or in the views
|
||||
/// that are one long page, the line a printed page's text above or below.
|
||||
fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
|
||||
let ext = extend(v);
|
||||
let l = s.layout();
|
||||
let from = s.sel.focus.clone();
|
||||
// Web Layout, Draft and Outline lay the document out as one long page (#5290).
|
||||
if !s.view.read_mode && matches!(s.view.mode, ViewMode::Web | ViewMode::Draft | ViewMode::Outline) {
|
||||
// A selection can end in text a collapsed outline heading hides: page from where it shows.
|
||||
let at = if l.caret_on(&from, s.page_hint).is_none() { super::outline::shown_pos(s, &from, false) } else { None };
|
||||
let at = at.unwrap_or_else(|| from.clone());
|
||||
let Some(c) = l.caret_on(&at, s.page_hint) else { return sel_result(s) };
|
||||
let page_h = l.pages.get(c.page).map_or(0.0, |p| p.h);
|
||||
let y = (c.top + 1.0 + dir as f32 * page_step(s, &at)).min(page_h).max(0.0);
|
||||
let to = l.line_toward(&at, s.page_hint, c.x, y, dir).unwrap_or(from);
|
||||
apply(s, to, ext);
|
||||
return sel_result(s);
|
||||
}
|
||||
let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) };
|
||||
let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) };
|
||||
let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from);
|
||||
@@ -266,6 +281,16 @@ fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// How far Page Up/Down move in the continuous views: the height of the text area of the
|
||||
/// section `at` is in (the first section when `at` isn't in the body), at least an inch.
|
||||
fn page_step(s: &Session, at: &Pos) -> f32 {
|
||||
let block = if at.story == StoryRef::Body { at.path.0.first().map_or(0, |&i| i as usize) } else { 0 };
|
||||
let sections = s.doc.sections();
|
||||
let sect = sections.iter().find(|(end, _)| *end >= block).or(sections.last()).map(|(_, p)| *p);
|
||||
let body = sect.map_or(0.0, |p| p.page_h - p.margin_top.abs() - p.margin_bottom.abs());
|
||||
if body.is_finite() { body.max(72.0) } else { 72.0 }
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let ext = extend(v);
|
||||
let to = if let Some(pv) = v.get("pos") {
|
||||
|
||||
@@ -520,13 +520,25 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
|
||||
if s.layout().caret(&s.sel.focus).is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(&block) = s.sel.focus.path.0.first() else { return };
|
||||
let block = block as usize;
|
||||
let forward = before.focus <= s.sel.focus;
|
||||
let focus = s.sel.focus.clone();
|
||||
if let Some(t) = shown_pos(s, &focus, forward) {
|
||||
s.sel = Selection::caret(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// In Outline view, the nearest place the outline shows to body position `pos`, which it doesn't
|
||||
/// show (in collapsed text, or past a body paragraph's first line), going on (`forward`) or back
|
||||
/// from it. `None` outside Outline view and the body, or when no paragraph shows that way.
|
||||
pub(crate) fn shown_pos(s: &mut Session, pos: &Pos, forward: bool) -> Option<Pos> {
|
||||
if s.view.mode != ViewMode::Outline || pos.story != StoryRef::Body {
|
||||
return None;
|
||||
}
|
||||
let block = *pos.path.0.first()? as usize;
|
||||
let levels = ol::levels(&s.doc);
|
||||
let hidden = ol::hidden(&levels, &s.view.outline.view());
|
||||
let layout = s.layout();
|
||||
let shown_para = |j: usize| !hidden.get(j).copied().unwrap_or(true) && matches!(s.doc.body.get(j).map(|b| &**b), Some(Block::Para(_)));
|
||||
let forward = before.focus <= s.sel.focus;
|
||||
let next = (block + 1..levels.len()).find(|j| shown_para(*j)).map(|j| Pos::body(j, 0));
|
||||
let prev = || {
|
||||
if shown_para(block) {
|
||||
@@ -538,8 +550,5 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
|
||||
Some(if layout.caret(&end).is_some() { end } else { Pos::body(j, 0) })
|
||||
};
|
||||
let target = if forward { next.or_else(prev) } else { prev().or(next) };
|
||||
if let Some(t) = target {
|
||||
let t = s.doc.clamp(&t);
|
||||
s.sel = Selection::caret(t);
|
||||
}
|
||||
target.map(|t| s.doc.clamp(&t))
|
||||
}
|
||||
|
||||
@@ -326,6 +326,73 @@ mod tests {
|
||||
assert!(s.ui_requests.is_empty(), "no dialog opens");
|
||||
}
|
||||
|
||||
/// Sets up [`Slow`] with downloading on and installs on a thread; returns its go-ahead.
|
||||
fn slow_in_background() -> std::sync::mpsc::Sender<()> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let slow = Arc::new(Slow { inner: FixedBackend::sample(), go: std::sync::Mutex::new(rx) });
|
||||
slow.inner.network.store(true, Ordering::Relaxed);
|
||||
packs::set_backend(Some(slow as Arc<dyn Backend>));
|
||||
packs::set_background(Some(Arc::new(|| {})));
|
||||
tx
|
||||
}
|
||||
|
||||
fn wait_for_finish(before: u64) {
|
||||
for _ in 0..500 {
|
||||
if packs::finished() != before {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert_ne!(packs::finished(), before, "the install thread ended");
|
||||
}
|
||||
|
||||
/// FU.1: the person's own Cancel isn't a failure. The install stops being listed at once and
|
||||
/// ends without an error ("Couldn't install: cancelled" in red was wrong); removing the pack
|
||||
/// while the cancelled download is still stopping says so.
|
||||
#[test]
|
||||
fn a_cancelled_install_ends_without_an_error() {
|
||||
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _reset = Reset;
|
||||
let go = slow_in_background();
|
||||
let before = packs::finished();
|
||||
assert!(packs::start_install("pl").unwrap());
|
||||
assert_eq!(packs::installing().len(), 1);
|
||||
packs::cancel("pl");
|
||||
assert!(packs::installing().is_empty(), "Install shows again at once");
|
||||
let e = packs::remove("pl").unwrap_err();
|
||||
assert!(e.contains("still stopping"), "{e}");
|
||||
go.send(()).unwrap();
|
||||
wait_for_finish(before);
|
||||
assert!(packs::installing().is_empty());
|
||||
assert_eq!(packs::error("pl"), None, "a cancel is no error");
|
||||
assert!(packs::errors().is_empty(), "{:?}", packs::errors());
|
||||
assert!(!packs::is_installed("pl"));
|
||||
}
|
||||
|
||||
/// FU.1: Install right after Cancel, before the cancelled download has stopped, isn't
|
||||
/// refused as still running: it shows as installing and the pack installs once the cancelled
|
||||
/// run ends (never two downloads of a pack at once).
|
||||
#[test]
|
||||
fn install_right_after_cancel_starts_again() {
|
||||
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _reset = Reset;
|
||||
let go = slow_in_background();
|
||||
let before = packs::finished();
|
||||
assert!(packs::start_install("pl").unwrap());
|
||||
packs::cancel("pl");
|
||||
assert!(packs::start_install("pl").unwrap(), "started again");
|
||||
let running = packs::installing();
|
||||
assert_eq!(running.len(), 1);
|
||||
assert_eq!(running[0].locale, "pl");
|
||||
// The cancelled run ends, then the new one.
|
||||
go.send(()).unwrap();
|
||||
go.send(()).unwrap();
|
||||
wait_for_finish(before);
|
||||
assert!(packs::is_installed("pl"), "the second Install installed it");
|
||||
assert!(packs::installing().is_empty());
|
||||
assert!(packs::errors().is_empty(), "{:?}", packs::errors());
|
||||
}
|
||||
|
||||
/// A pack read from its folder proofs and hyphenates its language's runs, and leaves the
|
||||
/// English runs to English: a tiny Lithuanian pack (`ProofLang::Pack`, no rules of its own).
|
||||
#[test]
|
||||
|
||||
@@ -182,14 +182,45 @@ pub(super) fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// The TOC title Word's table keeps above a section break (#4041): the TOC field's paragraph at
|
||||
/// `start` holds nothing but the field (Word's blank paragraph ending the title's one-column
|
||||
/// section, which the field moves into), and a TOC Heading paragraph of its own sits just above
|
||||
/// it. The title's block.
|
||||
fn title_above_break(s: &Session, start: usize) -> Option<usize> {
|
||||
let field = s.doc.body.get(start)?.as_para()?;
|
||||
let blank = field.text.chars().all(|c| c == OBJ)
|
||||
&& field.objects.iter().all(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let f = FieldCode::parse(instr);
|
||||
f.name == "TOC" && !f.is_table_of_figures()
|
||||
}
|
||||
InlineObject::BookmarkStart { .. } | InlineObject::BookmarkEnd { .. } | InlineObject::ControlEnd => true,
|
||||
_ => false,
|
||||
});
|
||||
if field.section.is_none() || !blank {
|
||||
return None;
|
||||
}
|
||||
let i = start.checked_sub(1)?;
|
||||
let title = s.doc.body.get(i)?.as_para()?;
|
||||
(title.props.style.as_deref() == Some("TOCHeading") && title.section.is_none() && toc_field(title).is_none()).then_some(i)
|
||||
}
|
||||
|
||||
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
|
||||
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
|
||||
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
|
||||
/// with the table.
|
||||
/// with the table. When that heading is above a section break that the field's otherwise blank
|
||||
/// paragraph ends (Word's table over entries in columns), the blank paragraph and its break go
|
||||
/// too, as Word's Remove Table of Contents takes everything in its control (#4041): the text
|
||||
/// before then belongs to the section after.
|
||||
pub(super) fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
if let Some(title) = title_above_break(s, start) {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(title))?;
|
||||
return Ok(title);
|
||||
}
|
||||
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
|
||||
Ok(p) if p.section.is_some() => {
|
||||
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
|
||||
@@ -355,6 +386,14 @@ pub(super) fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
regenerate_toc(s, None, &tabs)?;
|
||||
return sel_result(s);
|
||||
}
|
||||
// Word's table with its title above a section break (entries in columns): replacing it
|
||||
// keeps that layout, as Word's Insert Table of Contents does (#4041). The title stays
|
||||
// above the break (retitled when a title is given), the field takes the new settings in
|
||||
// its paragraph ending the title's section, and the new entries follow in their columns.
|
||||
Some((start, _)) if title_above_break(s, start).is_some() => {
|
||||
replace_toc_above_break(s, v, start, &tabs)?;
|
||||
return sel_result(s);
|
||||
}
|
||||
Some((start, end)) => {
|
||||
let start = remove_toc(s, start, end)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
|
||||
@@ -390,6 +429,36 @@ pub(super) fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Replaces a TOC whose title sits above the section break its field's paragraph ends
|
||||
/// (`title_above_break`): the field takes `v`'s settings, the title `v`'s `title` if one is given
|
||||
/// (otherwise it is kept, as Word's Insert Table of Contents keeps it), and the entries are rebuilt.
|
||||
fn replace_toc_above_break(s: &mut Session, v: &Value, start: usize, tabs: &[(String, Vec<TabStop>)]) -> Result<(), CmdError> {
|
||||
let instr = toc_instr(v);
|
||||
let field = s.doc.para_mut(StoryRef::Body, &Path::top(start))?;
|
||||
for o in &mut field.objects {
|
||||
if let InlineObject::Field { instr: old, result, dirty, .. } = o {
|
||||
let f = FieldCode::parse(old);
|
||||
if f.name == "TOC" && !f.is_table_of_figures() {
|
||||
old.clone_from(&instr);
|
||||
result.clear();
|
||||
*dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
field.touch();
|
||||
if let (Some(title), Some(i)) = (p::str(v, "title"), start.checked_sub(1)) {
|
||||
let old = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
|
||||
let mut head = Paragraph::with_text(title, old.props_of_char(0).clone());
|
||||
head.props = old.props.clone();
|
||||
head.mark = old.mark.clone();
|
||||
*old = head;
|
||||
}
|
||||
let leader = leader(p::str(v, "tabLeader").unwrap_or("dot"));
|
||||
regenerate_toc(s, Some(leader), tabs)?;
|
||||
s.clamp_selection();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A hidden `_Toc` bookmark around the heading at body block `i` (after its leading page breaks),
|
||||
/// reusing one already there; its name.
|
||||
pub(crate) fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result<String, CmdError> {
|
||||
|
||||
+139
-30
@@ -293,8 +293,12 @@ pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)
|
||||
/// Remove the installed pack for `locale`.
|
||||
pub fn remove(locale: &str) -> Result<(), String> {
|
||||
let b = backend().ok_or_else(no_store)?;
|
||||
if jobs().installs.iter().any(|(l, ..)| l == locale) {
|
||||
return Err(format!("the {locale} pack is being installed: cancel it first"));
|
||||
if let Some(job) = jobs().installs.iter().find(|j| j.locale == locale) {
|
||||
return Err(if job.stopping() {
|
||||
format!("the {locale} pack's cancelled install is still stopping: try again in a moment")
|
||||
} else {
|
||||
format!("the {locale} pack is being installed: cancel it first")
|
||||
});
|
||||
}
|
||||
if !b.installed().iter().any(|p| p.locale == locale) {
|
||||
return Err(format!("no language pack is installed for {locale}"));
|
||||
@@ -315,11 +319,33 @@ pub type Notify = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
static BACKGROUND: RwLock<Option<Notify>> = RwLock::new(None);
|
||||
|
||||
/// An install running on a thread.
|
||||
struct InstallJob {
|
||||
locale: String,
|
||||
/// Bytes done.
|
||||
done: u64,
|
||||
/// Bytes in all.
|
||||
total: u64,
|
||||
/// Stops this run. A cancelled job stays listed (hidden from [`installing`]) until its thread
|
||||
/// notices and ends, so two downloads of a locale never write its folder at once.
|
||||
cancel: Cancel,
|
||||
/// Install was asked again after a cancel, before the cancelled run ended: the thread starts
|
||||
/// over (with the fresh `cancel`) instead of ending.
|
||||
restart: bool,
|
||||
}
|
||||
|
||||
impl InstallJob {
|
||||
/// Cancelled, and its thread hasn't ended yet.
|
||||
fn stopping(&self) -> bool {
|
||||
self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs and fetches running on a thread, and how the finished ones ended.
|
||||
#[derive(Default)]
|
||||
struct Jobs {
|
||||
/// Running installs by locale: (bytes done, bytes in all, its Cancel).
|
||||
installs: Vec<(String, u64, u64, Cancel)>,
|
||||
/// Running installs, one per locale.
|
||||
installs: Vec<InstallJob>,
|
||||
fetching: bool,
|
||||
/// The last failure of each locale's install ("" for the list), until it is tried again.
|
||||
errors: Vec<(String, String)>,
|
||||
@@ -358,9 +384,9 @@ pub struct Progress {
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// The installs running now.
|
||||
/// The installs running now (not the cancelled ones still stopping).
|
||||
pub fn installing() -> Vec<Progress> {
|
||||
jobs().installs.iter().map(|(l, d, t, _)| Progress { locale: l.clone(), done: *d, total: *t }).collect()
|
||||
jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect()
|
||||
}
|
||||
|
||||
/// Whether the list is being fetched.
|
||||
@@ -383,10 +409,12 @@ pub fn finished() -> u64 {
|
||||
jobs().finished
|
||||
}
|
||||
|
||||
/// Stop `locale`'s running install.
|
||||
/// Stop `locale`'s running install. The person's own choice: it ends without an error, and
|
||||
/// [`installing`] stops listing it at once (Install shows again).
|
||||
pub fn cancel(locale: &str) {
|
||||
if let Some((_, _, _, c)) = jobs().installs.iter().find(|(l, ..)| l == locale) {
|
||||
c.cancel();
|
||||
if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) {
|
||||
job.restart = false;
|
||||
job.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +428,9 @@ fn set_error(locale: &str, e: Option<String>) {
|
||||
|
||||
/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now.
|
||||
/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once.
|
||||
/// Returns whether it is still running (`false`: done, or it already was running).
|
||||
/// Returns whether it is still running (`false`: done). Asked again while it runs, it carries on;
|
||||
/// right after a cancel, before the cancelled download has stopped, it starts over once that
|
||||
/// one ends (never two downloads of a pack at once).
|
||||
pub fn start_install(locale: &str) -> Result<bool, String> {
|
||||
let b = backend().ok_or_else(no_store)?;
|
||||
let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?;
|
||||
@@ -418,34 +448,55 @@ pub fn start_install(locale: &str) -> Result<bool, String> {
|
||||
let cancel = Cancel::new();
|
||||
{
|
||||
let mut j = jobs();
|
||||
if j.installs.iter().any(|(l, ..)| l == locale) {
|
||||
j.errors.retain(|(l, _)| l != locale);
|
||||
if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale) {
|
||||
if job.stopping() {
|
||||
// Cancelled and not ended yet: its thread starts over when it does.
|
||||
job.done = 0;
|
||||
job.total = pack.size;
|
||||
job.cancel = cancel;
|
||||
job.restart = true;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
j.installs.push((locale.to_string(), 0, pack.size, cancel.clone()));
|
||||
j.errors.retain(|(l, _)| l != locale);
|
||||
j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false });
|
||||
}
|
||||
let key = locale.to_string();
|
||||
let locale = locale.to_string();
|
||||
let started = spawn(move || {
|
||||
let progress_notify = notify.clone();
|
||||
let r = install(&locale, &cancel, &mut |done, total| {
|
||||
if let Some(job) = jobs().installs.iter_mut().find(|(l, ..)| *l == locale) {
|
||||
job.1 = done;
|
||||
job.2 = total;
|
||||
let mut cancel = cancel;
|
||||
loop {
|
||||
let progress_notify = notify.clone();
|
||||
let r = install(&locale, &cancel, &mut |done, total| {
|
||||
if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) {
|
||||
job.done = done;
|
||||
job.total = total;
|
||||
}
|
||||
progress_notify();
|
||||
});
|
||||
let mut j = jobs();
|
||||
if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale && j.restart) {
|
||||
job.restart = false;
|
||||
cancel = job.cancel.clone();
|
||||
drop(j);
|
||||
notify();
|
||||
continue;
|
||||
}
|
||||
progress_notify();
|
||||
});
|
||||
let mut j = jobs();
|
||||
j.installs.retain(|(l, ..)| *l != locale);
|
||||
if let Err(e) = r {
|
||||
j.errors.push((locale, e));
|
||||
j.installs.retain(|j| j.locale != locale);
|
||||
// The person's own cancel isn't a failure: nothing to show.
|
||||
if let Err(e) = r
|
||||
&& !cancel.is_cancelled()
|
||||
{
|
||||
j.errors.push((locale, e));
|
||||
}
|
||||
j.finished = j.finished.wrapping_add(1);
|
||||
drop(j);
|
||||
notify();
|
||||
break;
|
||||
}
|
||||
j.finished = j.finished.wrapping_add(1);
|
||||
drop(j);
|
||||
notify();
|
||||
});
|
||||
if let Err(e) = started {
|
||||
jobs().installs.retain(|(l, ..)| *l != key);
|
||||
jobs().installs.retain(|j| j.locale != key);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(true)
|
||||
@@ -579,12 +630,47 @@ pub fn proofable(tag: &str) -> bool {
|
||||
pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> {
|
||||
let name = wordcraft_proof::missing_dictionary(tag)?;
|
||||
let lang = wordcraft_proof::ProofLang::from_tag(tag);
|
||||
if lang.pack_locale().is_some() && pack_for_tag(tag.unwrap_or(""), &available()).is_none() {
|
||||
if lang.pack_locale().is_some() && !has_pack_for_tag(tag.unwrap_or("")) {
|
||||
return None;
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// The last answer of whether the index has a pack for a tag, for the index generation it was
|
||||
/// worked out for: the status bar asks every frame while the caret is in a pack language, and
|
||||
/// working it out copies the whole index.
|
||||
#[derive(Default)]
|
||||
struct PackLookup {
|
||||
/// (index generation, tag, has a pack).
|
||||
entry: Option<(u64, String, bool)>,
|
||||
}
|
||||
|
||||
impl PackLookup {
|
||||
/// The remembered answer for `tag` at `generation`, else `compute`'s (then remembered).
|
||||
fn get(&mut self, generation: u64, tag: &str, compute: impl FnOnce() -> bool) -> bool {
|
||||
if let Some((g, t, has)) = &self.entry
|
||||
&& *g == generation
|
||||
&& t == tag
|
||||
{
|
||||
return *has;
|
||||
}
|
||||
let has = compute();
|
||||
self.entry = Some((generation, tag.to_string(), has));
|
||||
has
|
||||
}
|
||||
}
|
||||
|
||||
static PACK_LOOKUP: Mutex<PackLookup> = Mutex::new(PackLookup { entry: None });
|
||||
|
||||
/// Whether the index has a pack for `tag` ([`pack_for_tag`]), worked out again only when the
|
||||
/// index changes ([`index_generation`]) or the tag does.
|
||||
fn has_pack_for_tag(tag: &str) -> bool {
|
||||
// Read before [`available`], which may fill the index and bump it: the answer is then worked
|
||||
// out once more, never kept for a list it wasn't worked out from.
|
||||
let generation = index_generation();
|
||||
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.
|
||||
pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
@@ -630,7 +716,7 @@ impl FixedBackend {
|
||||
locale: locale.into(),
|
||||
name: name.into(),
|
||||
version: "0000000".into(),
|
||||
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-1/{locale}.tar.zst"),
|
||||
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"),
|
||||
source: Some("https://github.com/LibreOffice/dictionaries".into()),
|
||||
size,
|
||||
licence: licence.into(),
|
||||
@@ -713,6 +799,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// FU.3: the status bar's "(no dictionary)" asks every frame whether the index has a pack
|
||||
/// for the caret's language; the answer is reused until the index changes (a new
|
||||
/// generation) or the tag does, so the index isn't copied every frame.
|
||||
#[test]
|
||||
fn the_pack_lookup_is_reused_until_the_index_changes() {
|
||||
let mut lookup = super::PackLookup::default();
|
||||
let mut computed = 0;
|
||||
let mut ask = |lookup: &mut super::PackLookup, generation: u64, tag: &str, answer: bool| {
|
||||
lookup.get(generation, tag, || {
|
||||
computed += 1;
|
||||
answer
|
||||
})
|
||||
};
|
||||
assert!(ask(&mut lookup, 1, "ja-JP", true));
|
||||
// Same generation and tag: the remembered answer, even where the list would now say no.
|
||||
for _ in 0..10 {
|
||||
assert!(ask(&mut lookup, 1, "ja-JP", false));
|
||||
}
|
||||
assert!(!ask(&mut lookup, 2, "ja-JP", false), "a new generation works it out again");
|
||||
assert!(ask(&mut lookup, 2, "th-TH", true), "another tag too");
|
||||
assert_eq!(computed, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_build_embeds_only_english() {
|
||||
const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"];
|
||||
|
||||
@@ -9,13 +9,13 @@ use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin};
|
||||
|
||||
use super::{Cancel, Pack};
|
||||
|
||||
/// The pack index this build trusts: craft-i18n's first Language pack release, language-packs-1
|
||||
/// (61 packs, built from craft-i18n `4504e288`), pinned by the sha256 of its `index.json`.
|
||||
/// The pack index this build trusts: craft-i18n's second Language pack release,
|
||||
/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`.
|
||||
/// `CRAFT_LANGUAGE_PACK_INDEX` (read by the font store itself) points WordCraft at a local or test
|
||||
/// index instead, for trying packs before they are released.
|
||||
pub const PACK_INDEX: PackIndexPin = PackIndexPin {
|
||||
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-1/index.json",
|
||||
sha256: "531465b50126d0594bb89a9c4512f7ae67d581259636040a410190641ec1428c",
|
||||
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-2/index.json",
|
||||
sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b",
|
||||
};
|
||||
|
||||
pub(super) struct FontStoreBackend {
|
||||
|
||||
@@ -80,12 +80,12 @@ impl Utterance {
|
||||
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
|
||||
let story = from.story;
|
||||
// Spans to leave out: whole range fields, both markers included.
|
||||
let skipped: Vec<(Pos, Pos)> = if skip_citations {
|
||||
let skipped = Spans::new(if skip_citations {
|
||||
doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let in_skipped = |p: &Pos| skipped.iter().any(|(a, b)| a <= p && p <= b);
|
||||
});
|
||||
let in_skipped = |p: &Pos| skipped.contains(p);
|
||||
let mut out = Vec::new();
|
||||
for path in doc.para_paths(story) {
|
||||
if path < from.path {
|
||||
@@ -156,6 +156,36 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
|
||||
out
|
||||
}
|
||||
|
||||
/// Spans of the document (both ends included), sorted and merged so a position is looked up by
|
||||
/// binary search: checking every character against every citation was quadratic in a document
|
||||
/// with many of them.
|
||||
struct Spans(Vec<(Pos, Pos)>);
|
||||
|
||||
impl Spans {
|
||||
fn new(mut spans: Vec<(Pos, Pos)>) -> Self {
|
||||
spans.retain(|(a, b)| a <= b);
|
||||
spans.sort();
|
||||
let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len());
|
||||
for (a, b) in spans {
|
||||
match merged.last_mut() {
|
||||
Some(last) if a <= last.1 => {
|
||||
if b > last.1 {
|
||||
last.1 = b;
|
||||
}
|
||||
}
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
Spans(merged)
|
||||
}
|
||||
|
||||
/// Is `p` inside one of the spans?
|
||||
fn contains(&self, p: &Pos) -> bool {
|
||||
let after = self.0.partition_point(|(a, _)| a <= p);
|
||||
after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b)
|
||||
}
|
||||
}
|
||||
|
||||
/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian
|
||||
/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th`
|
||||
/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script
|
||||
|
||||
@@ -364,3 +364,35 @@ fn macos_voice_matches_the_language() {
|
||||
assert_eq!(mac_voice("ru", "", "en"), None);
|
||||
assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_spans_match_a_scan_of_every_span() {
|
||||
// Overlapping, nested, touching, empty-range and reversed spans.
|
||||
let raw = vec![
|
||||
(Pos::body(0, 5), Pos::body(0, 9)),
|
||||
(Pos::body(0, 7), Pos::body(0, 12)),
|
||||
(Pos::body(0, 20), Pos::body(0, 20)),
|
||||
(Pos::body(0, 30), Pos::body(2, 3)),
|
||||
(Pos::body(1, 4), Pos::body(1, 8)),
|
||||
(Pos::body(3, 9), Pos::body(3, 2)),
|
||||
(Pos::body(4, 0), Pos::body(4, 1)),
|
||||
(Pos::body(4, 2), Pos::body(4, 3)),
|
||||
];
|
||||
let spans = Spans::new(raw.clone());
|
||||
for block in 0..6 {
|
||||
for off in 0..40 {
|
||||
let p = Pos::body(block, off);
|
||||
let scan = raw.iter().any(|(a, b)| *a <= p && p <= *b);
|
||||
assert_eq!(spans.contains(&p), scan, "{p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_citations_are_looked_up_quickly() {
|
||||
// 50,000 disjoint spans and 200,000 lookups: a scan of every span per position would take
|
||||
// ten billion comparisons.
|
||||
let spans = Spans::new((0..50_000).map(|i| (Pos::body(0, i * 4), Pos::body(0, i * 4 + 1))).collect());
|
||||
let hits = (0..200_000).filter(|&off| spans.contains(&Pos::body(0, off))).count();
|
||||
assert_eq!(hits, 100_000);
|
||||
}
|
||||
|
||||
+222
-2
@@ -4043,7 +4043,8 @@ fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table()
|
||||
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
|
||||
blocks = Some(s.doc.body.len());
|
||||
}
|
||||
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
|
||||
// The field's paragraph ends the one-column section: replacing the TOC keeps it, and
|
||||
// removing it takes the break with the title, as Word does (#4041).
|
||||
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
|
||||
assert_eq!(columns(&s), [1, 2, 1]);
|
||||
// The heading goes with the table: one "Contents" after Replace, none after Remove.
|
||||
@@ -4052,10 +4053,130 @@ fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table()
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 1, "{}", text(&s));
|
||||
run(&mut s, "references.removeToc", json!({}));
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(columns(&s), [2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 0, "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #4041: Word's TOC over entries in columns (#3889): its control holds the TOC Heading title in a
|
||||
/// one-column section, a blank paragraph ending that section, and the entries in a continuous
|
||||
/// two-column section. `title_style` is the title paragraph's style. Update Table has run.
|
||||
fn word_toc_above_a_section_break(title_style: &str) -> Session {
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let sect = |cols: &str, kind: &str| format!(r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:cols {cols} w:space="708"/></w:sectPr>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let toc = [
|
||||
p(title_style, &t("Contents")),
|
||||
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
|
||||
p("TOC1", &format!("{begin}{}", t("Chapter One\t2"))),
|
||||
p("TOC2", &t("Section\t2")),
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
|
||||
]
|
||||
.concat();
|
||||
let body = [
|
||||
format!(
|
||||
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
|
||||
),
|
||||
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, r#"<w:type w:val="continuous"/>"#)),
|
||||
p("Heading1", &t("Chapter One")),
|
||||
p("Heading2", &t("Section")),
|
||||
sect("", ""),
|
||||
]
|
||||
.concat();
|
||||
let styles = [
|
||||
style_xml("Heading1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
|
||||
style_xml("Heading2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
|
||||
style_xml("TOCHeading", "TOC Heading", "", ""),
|
||||
style_xml("TOC1", "toc 1", "", ""),
|
||||
style_xml("TOC2", "toc 2", r#"<w:ind w:left="220"/>"#, ""),
|
||||
]
|
||||
.concat();
|
||||
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The body's paragraphs as (style, ends a section, text), and each section's column count.
|
||||
fn toc_layout(s: &Session) -> (Vec<(String, bool, String)>, Vec<u32>) {
|
||||
let paras = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.map(|p| (p.props.style.clone().unwrap_or_default(), p.section.is_some(), p.plain_text()))
|
||||
.collect();
|
||||
(paras, s.doc.sections().iter().map(|(_, x)| x.columns.count).collect())
|
||||
}
|
||||
|
||||
/// #4041: replacing Word's TOC whose title sits above a section break keeps the title above the
|
||||
/// break and the entries in their columns, as Word's Insert Table of Contents does (observed in
|
||||
/// Word for Mac: the field takes the new settings; the title, the blank paragraph and its break
|
||||
/// stay). Replacing again finds the same title; a given title renames it; undo restores the table.
|
||||
#[test]
|
||||
fn replacing_a_toc_keeps_its_title_above_the_section_break() {
|
||||
let row = |st: &str, sect: bool, t: &str| (st.to_string(), sect, t.to_string());
|
||||
let laid_out = |title: &str| {
|
||||
vec![
|
||||
row("TOCHeading", false, title),
|
||||
row("", true, ""),
|
||||
row("TOC1", false, "Chapter One\t2"),
|
||||
row("TOC2", false, "Section\t2"),
|
||||
row("", true, ""),
|
||||
row("Heading1", false, "Chapter One"),
|
||||
row("Heading2", false, "Section"),
|
||||
]
|
||||
};
|
||||
let mut s = word_toc_above_a_section_break("TOCHeading");
|
||||
let before = toc_layout(&s);
|
||||
assert_eq!(before, (laid_out("Contents"), vec![1, 2, 1]), "{}", text(&s));
|
||||
run(&mut s, "references.toc", json!({"levels": 1}));
|
||||
let mut one_level = laid_out("Contents");
|
||||
one_level.remove(3);
|
||||
assert_eq!(toc_layout(&s), (one_level, vec![1, 2, 1]), "{}", text(&s));
|
||||
// The field took the new settings.
|
||||
let instr = s.doc.body.get(1).and_then(|b| b.as_para()).and_then(|p| {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::para::InlineObject::Field { instr, .. } => Some(instr.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
assert!(instr.as_deref().is_some_and(|i| i.contains(r#"\o "1-1""#)), "{instr:?}");
|
||||
run(&mut s, "references.toc", json!({"levels": 2, "title": "Table of Contents"}));
|
||||
assert_eq!(toc_layout(&s), (laid_out("Table of Contents"), vec![1, 2, 1]), "{}", text(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(toc_layout(&s), before, "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #4041: removing Word's TOC whose title sits above a section break takes the title, the blank
|
||||
/// paragraph ending its section and that break with the entries, as Word's Remove Table of
|
||||
/// Contents does (observed in Word for Mac): the empty paragraph after the table, ending the
|
||||
/// two-column section, stays, so the headings keep their own section. A Normal paragraph above
|
||||
/// the break isn't the TOC's title: it and the break stay.
|
||||
#[test]
|
||||
fn removing_a_toc_takes_its_title_and_the_section_break_under_it() {
|
||||
let row = |st: &str, sect: bool, t: &str| (st.to_string(), sect, t.to_string());
|
||||
let mut s = word_toc_above_a_section_break("TOCHeading");
|
||||
let before = toc_layout(&s);
|
||||
run(&mut s, "references.removeToc", json!({}));
|
||||
let headings = vec![row("", true, ""), row("Heading1", false, "Chapter One"), row("Heading2", false, "Section")];
|
||||
assert_eq!(toc_layout(&s), (headings, vec![2, 1]), "{}", text(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(toc_layout(&s), before, "{}", text(&s));
|
||||
// Replace, then Remove: the same.
|
||||
run(&mut s, "references.toc", json!({"levels": 2}));
|
||||
run(&mut s, "references.removeToc", json!({}));
|
||||
let (paras, columns) = toc_layout(&s);
|
||||
assert_eq!(columns, vec![2, 1], "{}", text(&s));
|
||||
assert!(paras.iter().all(|(st, _, t)| !st.starts_with("TOC") && t != "Contents"), "{paras:?}");
|
||||
|
||||
let mut s = word_toc_above_a_section_break("Normal");
|
||||
run(&mut s, "references.removeToc", json!({}));
|
||||
let (paras, columns) = toc_layout(&s);
|
||||
assert_eq!(columns, vec![1, 2, 1], "{}", text(&s));
|
||||
assert!(paras.first().is_some_and(|(_, _, t)| t == "Contents"), "{paras:?}");
|
||||
}
|
||||
|
||||
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
|
||||
/// range TOC doesn't claim the menu, so it never refreshes a different table.
|
||||
#[test]
|
||||
@@ -11761,3 +11882,102 @@ fn text_from_file_brings_the_files_pictures() {
|
||||
assert_ne!(listed[0], our_list);
|
||||
assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_and_up_move_by_a_page_in_the_continuous_views() {
|
||||
// Web Layout, Draft and Outline are one long page: Page Down/Up move as far as they do
|
||||
// between the pages of Print Layout (#5290).
|
||||
let after_page_down = |view: &str| {
|
||||
let mut s = s();
|
||||
for i in 0..200 {
|
||||
run(&mut s, "text.insert", json!({"text": format!("Paragraph {i}")}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
run(&mut s, view, json!({}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
let start = s.sel.focus.clone();
|
||||
run(&mut s, "caret.pageDown", json!({}));
|
||||
let down = s.sel.focus.clone();
|
||||
run(&mut s, "caret.pageUp", json!({}));
|
||||
assert_eq!(s.sel.focus, start, "{view}: Page Up goes back");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let end = s.sel.focus.clone();
|
||||
run(&mut s, "caret.pageDown", json!({}));
|
||||
assert_eq!(s.sel.focus, end, "{view}: Page Down stops at the end");
|
||||
down
|
||||
};
|
||||
let print = after_page_down("view.printLayout");
|
||||
assert!(print.path.0[0] > 10, "Print Layout: Page Down goes to the next page: {print:?}");
|
||||
for view in ["view.draft", "view.outline", "view.webLayout"] {
|
||||
assert_eq!(after_page_down(view), print, "{view}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_and_up_never_stay_on_a_line_in_the_continuous_views() {
|
||||
// A page with less than an inch of text area: the views move by the one-inch minimum. The
|
||||
// caret ends a long line and the paragraphs after it are narrow (a large right indent): the
|
||||
// line a page away, not the one the caret is on, is where Page Down goes, however far right
|
||||
// of it the caret is.
|
||||
for view in ["view.draft", "view.outline", "view.webLayout"] {
|
||||
let mut s = s();
|
||||
run(&mut s, "layout.margins", json!({"top": 370, "bottom": 370}));
|
||||
let long = "Lorem ipsum dolor sit amet ".repeat(4);
|
||||
run(&mut s, "text.insert", json!({"text": long}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "para.indents", json!({"right": 700}));
|
||||
for i in 0..60 {
|
||||
if i > 0 {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": "a"}));
|
||||
}
|
||||
run(&mut s, view, json!({}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
assert_eq!(s.sel.focus.off, long.chars().count(), "{view}: the first paragraph is one line");
|
||||
let mut at = s.sel.focus.clone();
|
||||
for step in 0..3 {
|
||||
run(&mut s, "caret.pageDown", json!({}));
|
||||
assert!(s.sel.focus > at, "{view}: Page Down {step} moves on from {at:?}");
|
||||
at = s.sel.focus.clone();
|
||||
}
|
||||
for step in 0..3 {
|
||||
run(&mut s, "caret.pageUp", json!({}));
|
||||
assert!(s.sel.focus < at, "{view}: Page Up {step} moves back from {at:?}");
|
||||
at = s.sel.focus.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
// Selecting a collapsed heading and its content ends the selection in hidden text; Page Down
|
||||
// still extends it from where it shows (the heading), keeping its anchor.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "First"}));
|
||||
for i in 0..5 {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": format!("Hidden {i}")}));
|
||||
}
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Second"}));
|
||||
for i in 0..200 {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": format!("Shown {i}")}));
|
||||
}
|
||||
run(&mut s, "view.outline", json!({}));
|
||||
for block in [0, 6] {
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": block}}));
|
||||
run(&mut s, "outline.promoteToHeading1", json!({}));
|
||||
}
|
||||
run(&mut s, "outline.collapse", json!({"block": 0}));
|
||||
run(&mut s, "outline.selectSubtree", json!({"block": 0}));
|
||||
let anchor = s.sel.anchor.clone();
|
||||
assert_eq!(s.sel.focus.path.0, [5], "the selection ends in the last hidden paragraph");
|
||||
assert!(s.layout().caret(&s.sel.focus).is_none(), "the outline doesn't show its end");
|
||||
run(&mut s, "caret.pageDown", json!({"extend": true}));
|
||||
assert_eq!(s.sel.anchor, anchor, "the anchor stays");
|
||||
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), String> {
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if wasm && !scripts.split(',').any(|s| s.trim() == "Geor") {
|
||||
// Arabic interface faces are small, so the web build keeps them too.
|
||||
let small_ui_face = scripts.split(',').any(|s| matches!(s.trim(), "Arab" | "Geor"));
|
||||
if wasm && !small_ui_face {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(crate) use names::face_names;
|
||||
pub use names::{LocalName, base_style, name_language_matches, system_font_dirs};
|
||||
use names::{MAX_FILE_FACES, enumerate_faces, make_face};
|
||||
#[cfg(test)]
|
||||
use names::{file_face_names, font_faces, mini_font, name};
|
||||
use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
|
||||
#[cfg(test)]
|
||||
pub(crate) use names::{last_resort_face, legacy_family};
|
||||
|
||||
|
||||
@@ -61,8 +61,16 @@ pub(super) struct CatalogEntry {
|
||||
/// The longest name a document's font table gives an embedded font that faces take on.
|
||||
pub(super) const MAX_FAMILY_NAME: usize = 128;
|
||||
|
||||
/// The longest name read from a font's `name` table, in characters. A record can hold 64K
|
||||
/// characters, and each face copies its family into its localized names and into every named
|
||||
/// instance, so a small font could claim hundreds of megabytes of names. Real names run to a
|
||||
/// few dozen characters.
|
||||
pub(super) const MAX_NAME_CHARS: usize = 128;
|
||||
|
||||
pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
ids.iter().find_map(|id| {
|
||||
font.localized_strings(*id).english_or_first().map(|s| s.chars().take(MAX_NAME_CHARS).collect::<String>()).filter(|s| !s.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// The family's names in other languages: localized typographic family names (name ID 16) stand
|
||||
@@ -70,7 +78,6 @@ pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<Strin
|
||||
/// font has no typographic names). Bounded: a damaged font can list any number of records.
|
||||
fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<LocalName> {
|
||||
const MAX_NAMES: usize = 64;
|
||||
const MAX_CHARS: usize = 128;
|
||||
let typographic = f.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME).next().is_some();
|
||||
let legacy = if typographic { id1 } else { Some(family) };
|
||||
let mut out: Vec<LocalName> = Vec::new();
|
||||
@@ -81,7 +88,7 @@ fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<
|
||||
if lang == "en" || lang.starts_with("en-") {
|
||||
continue;
|
||||
}
|
||||
let name: String = s.chars().take(MAX_CHARS).collect::<String>().trim().to_string();
|
||||
let name: String = s.chars().take(MAX_NAME_CHARS).collect::<String>().trim().to_string();
|
||||
if name.is_empty() || name.eq_ignore_ascii_case(english) || out.iter().any(|n| n.lang == lang && n.name == name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -245,15 +245,24 @@ fn postscript_names_select_faces() {
|
||||
/// A variable font holding just `name` and `fvar` tables: family "Var Test", one `wght` axis
|
||||
/// and `instances` named instances, each with its own style name.
|
||||
fn variable_font(instances: u16) -> Vec<u8> {
|
||||
let mut names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())];
|
||||
names.extend((0..instances).map(|i| (256 + i, format!("Style {i}"))));
|
||||
variable_font_named("Var Test", &[], instances)
|
||||
}
|
||||
|
||||
/// [`variable_font`] with family `family`, and family names in other languages `local`
|
||||
/// (Windows language ids).
|
||||
fn variable_font_named(family: &str, local: &[(u16, &str)], instances: u16) -> Vec<u8> {
|
||||
let mut names: Vec<(u16, u16, String)> = vec![(0x409, 1, family.into()), (0x409, 2, "Regular".into())];
|
||||
names.extend(local.iter().map(|(lang, n)| (*lang, 1, n.to_string())));
|
||||
names.extend((0..instances).map(|i| (0x409, 256 + i, format!("Style {i}"))));
|
||||
// Records sorted by language, then name id.
|
||||
names.sort_by_key(|(lang, id, _)| (*lang, *id));
|
||||
let mut name = vec![0, 0];
|
||||
name.extend_from_slice(&(names.len() as u16).to_be_bytes());
|
||||
name.extend_from_slice(&(6 + 12 * names.len() as u16).to_be_bytes());
|
||||
let mut strings = Vec::new();
|
||||
for (id, text) in &names {
|
||||
for (lang, id, text) in &names {
|
||||
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] {
|
||||
for v in [3, 1, *lang, *id, utf16.len() as u16, strings.len() as u16] {
|
||||
name.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
strings.extend(utf16);
|
||||
@@ -357,6 +366,27 @@ fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() {
|
||||
assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_names_are_cut_before_faces_copy_them() {
|
||||
// A name record can hold 64K characters, and every named instance copies the family and its
|
||||
// localized names: the names are cut when read, so the copies stay small.
|
||||
let long = "W".repeat(14_000);
|
||||
let local = "Ж".repeat(14_000);
|
||||
let font = variable_font_named(&long, &[(0x419, &local)], 32);
|
||||
let faces = font_faces(&skrifa::FontRef::new(&font).unwrap(), 0);
|
||||
assert_eq!(faces.len(), 32);
|
||||
for f in &faces {
|
||||
assert_eq!(f.family.chars().count(), MAX_NAME_CHARS);
|
||||
assert!(f.legacy.chars().count() <= MAX_NAME_CHARS + " Style 31".len(), "{}", f.legacy.len());
|
||||
assert_eq!(f.local.len(), 1);
|
||||
assert_eq!(f.local[0].name.chars().count(), MAX_NAME_CHARS);
|
||||
assert_eq!(f.local[0].english, f.family);
|
||||
}
|
||||
// Short names are kept whole.
|
||||
let faces = font_faces(&skrifa::FontRef::new(&variable_font_named("Var Test", &[(0x419, "Вар Тест")], 2)).unwrap(), 0);
|
||||
assert_eq!((faces[0].family.as_str(), faces[0].local[0].name.as_str()), ("Var Test", "Вар Тест"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_instances_and_collection_members_are_capped() {
|
||||
// #3716: a file's faces are capped before they are built, however the file multiplies them.
|
||||
|
||||
@@ -154,6 +154,7 @@ pub fn decode_font_file(bytes: &[u8]) -> Result<Vec<u8>, UserFontError> {
|
||||
unwrap_web_font(|| wuff::decompress_woff1(bytes))?
|
||||
}
|
||||
FontFormat::Woff2 => {
|
||||
check_woff2_collection(bytes)?;
|
||||
let mut too_many_points = false;
|
||||
let unwrapped = unwrap_web_font(|| {
|
||||
wuff::decompress_woff2_with_custom_brotli(bytes, &mut |data, size| {
|
||||
@@ -221,8 +222,29 @@ fn woff2_points_fit(file: &[u8], stream: &[u8]) -> bool {
|
||||
/// The transformed `glyf` tables in `stream`, found from the WOFF2 table directory in `file`
|
||||
/// (tables follow each other in directory order).
|
||||
fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option<Vec<&'a [u8]>> {
|
||||
let (tables, _) = woff2_directory(file)?;
|
||||
let (mut offset, mut glyf) = (0usize, Vec::new());
|
||||
for t in tables {
|
||||
let end = offset.checked_add(t.len)?;
|
||||
if t.transformed_glyf {
|
||||
glyf.push(stream.get(offset..end)?);
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
Some(glyf)
|
||||
}
|
||||
|
||||
/// A WOFF2 table directory entry: whether it is a transformed `glyf` table, and its length in
|
||||
/// the decompressed stream.
|
||||
struct Woff2Table {
|
||||
transformed_glyf: bool,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
/// The WOFF2 table directory in `file`, and where it ends (the collection directory follows).
|
||||
fn woff2_directory(file: &[u8]) -> Option<(Vec<Woff2Table>, usize)> {
|
||||
let tables = u16::from_be_bytes(file.get(12..14)?.try_into().ok()?);
|
||||
let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new());
|
||||
let (mut at, mut out) = (48usize, Vec::with_capacity(usize::from(tables)));
|
||||
for _ in 0..tables {
|
||||
let flags = *file.get(at)?;
|
||||
at += 1;
|
||||
@@ -238,13 +260,81 @@ fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option<Vec<&'a [
|
||||
// glyf and loca are transformed at version 0, other tables at any other version.
|
||||
let transformed = if is_glyf || is_loca { flags >> 6 == 0 } else { flags >> 6 != 0 };
|
||||
let len = if transformed { base128(file, &mut at)? } else { orig };
|
||||
let end = offset.checked_add(len)?;
|
||||
if is_glyf && transformed {
|
||||
glyf.push(stream.get(offset..end)?);
|
||||
}
|
||||
offset = end;
|
||||
out.push(Woff2Table { transformed_glyf: is_glyf && transformed, len });
|
||||
}
|
||||
Some(glyf)
|
||||
Some((out, at))
|
||||
}
|
||||
|
||||
/// Largest table directories a WOFF2 collection may rebuild, in bytes. Each font's directory is
|
||||
/// 16 bytes per table it lists, while the file spends as little as one byte per listed table, so
|
||||
/// a collection listing the same tables over and over rebuilt hundreds of megabytes of
|
||||
/// directories (twice: built, then copied into the font) before the decoder's output cap was
|
||||
/// checked. Real collections need kilobytes.
|
||||
const MAX_WOFF2_COLLECTION_HEADER: usize = 16 << 20;
|
||||
|
||||
/// Why a WOFF2 collection whose directories are too large, or list a table twice, is refused.
|
||||
const BAD_COLLECTION: &str = "the collection's font directories are too large or list a table twice";
|
||||
|
||||
/// WOFF2 collections: the table directories the decoder rebuilds for its fonts must fit
|
||||
/// [`MAX_WOFF2_COLLECTION_HEADER`], and no font may list a table twice (OpenType allows one
|
||||
/// table per tag in a font). Checked before decoding, since the decoder builds them before it
|
||||
/// checks its output size. A directory that doesn't parse is left to the decoder to report.
|
||||
fn check_woff2_collection(file: &[u8]) -> Result<(), UserFontError> {
|
||||
if file.get(4..8) != Some(b"ttcf".as_slice()) {
|
||||
return Ok(());
|
||||
}
|
||||
match woff2_collection_fits(file) {
|
||||
Some(false) => Err(UserFontError::BadWebFont(BAD_COLLECTION.into())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Do the collection's rebuilt table directories fit, with no table listed twice in one font?
|
||||
/// None when the directories don't parse.
|
||||
fn woff2_collection_fits(file: &[u8]) -> Option<bool> {
|
||||
let (tables, mut at) = woff2_directory(file)?;
|
||||
let version = u32::from_be_bytes(file.get(at..at.checked_add(4)?)?.try_into().ok()?);
|
||||
at += 4;
|
||||
let fonts = read_255_u16(file, &mut at)?;
|
||||
// TTC header (with the DSIG fields of version 2) and one offset per font.
|
||||
let dsig = if version == 0x0002_0000 { 12 } else { 0 };
|
||||
let mut size = 12 + dsig + 4 * fonts;
|
||||
let mut indices: Vec<usize> = Vec::new();
|
||||
for _ in 0..fonts {
|
||||
let n = read_255_u16(file, &mut at)?;
|
||||
size = size.saturating_add(12 + 16 * n);
|
||||
if size > MAX_WOFF2_COLLECTION_HEADER {
|
||||
return Some(false);
|
||||
}
|
||||
at = at.checked_add(4)?; // flavor
|
||||
indices.clear();
|
||||
for _ in 0..n {
|
||||
let i = read_255_u16(file, &mut at)?;
|
||||
if i >= tables.len() {
|
||||
return None;
|
||||
}
|
||||
indices.push(i);
|
||||
}
|
||||
indices.sort_unstable();
|
||||
if indices.windows(2).any(|w| w[0] == w[1]) {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// WOFF2's 255UInt16.
|
||||
fn read_255_u16(b: &[u8], at: &mut usize) -> Option<usize> {
|
||||
let mut byte = || {
|
||||
*at += 1;
|
||||
b.get(*at - 1).copied().map(usize::from)
|
||||
};
|
||||
Some(match byte()? {
|
||||
253 => byte()? << 8 | byte()?,
|
||||
254 => byte()? + 253 * 2,
|
||||
255 => byte()? + 253,
|
||||
v => v,
|
||||
})
|
||||
}
|
||||
|
||||
/// WOFF2's UIntBase128.
|
||||
@@ -278,19 +368,7 @@ fn glyf_points_fit(t: &[u8]) -> Option<bool> {
|
||||
let n = u16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?);
|
||||
let mut total = 0usize;
|
||||
for _ in 0..if n == 0xFFFF { 0 } else { n } {
|
||||
// 255UInt16
|
||||
let code = *points.get(at)?;
|
||||
at += 1;
|
||||
let mut byte = || {
|
||||
at += 1;
|
||||
points.get(at - 1).copied().map(usize::from)
|
||||
};
|
||||
total += match code {
|
||||
253 => byte()? << 8 | byte()?,
|
||||
254 => byte()? + 253 * 2,
|
||||
255 => byte()? + 253,
|
||||
v => usize::from(v),
|
||||
};
|
||||
total += read_255_u16(points, &mut at)?;
|
||||
if total > 1 << 16 {
|
||||
return Some(false);
|
||||
}
|
||||
|
||||
@@ -455,3 +455,50 @@ fn fonts_stored_at_once_under_one_name_each_get_their_own_file() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A WOFF2 collection of `tables` untransformed tables whose fonts list the tables in `fonts`.
|
||||
fn woff2_collection(tables: usize, fonts: &[Vec<u16>]) -> Vec<u8> {
|
||||
fn u255(v: u16, out: &mut Vec<u8>) {
|
||||
match v {
|
||||
0..=252 => out.push(v as u8),
|
||||
_ => {
|
||||
out.push(253);
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut dir = Vec::new();
|
||||
for i in 0..tables {
|
||||
dir.push(63);
|
||||
dir.extend_from_slice(&(i as u32).to_be_bytes()); // a made-up tag per table
|
||||
base128(4, &mut dir);
|
||||
}
|
||||
dir.extend_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
u255(fonts.len() as u16, &mut dir);
|
||||
for font in fonts {
|
||||
u255(font.len() as u16, &mut dir);
|
||||
dir.extend_from_slice(&[0, 1, 0, 0]);
|
||||
for &i in font {
|
||||
u255(i, &mut dir);
|
||||
}
|
||||
}
|
||||
woff2_file(b"ttcf", tables, &dir, &vec![0; 4 * tables], 4 * tables + 1024)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_woff2_collection_rebuilding_huge_directories_is_refused_before_decoding() {
|
||||
// Each font's rebuilt directory takes 16 bytes per table it lists, the file about two: 600
|
||||
// fonts listing 2,000 tables each come to 19 MB of directories from a 2.4 MB file.
|
||||
let all: Vec<u16> = (0..2_000).collect();
|
||||
let file = woff2_collection(2_000, &vec![all; 600]);
|
||||
assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_woff2_collection_font_listing_a_table_twice_is_refused() {
|
||||
let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 1]]);
|
||||
assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
|
||||
// Fonts sharing tables are what collections are for: passed on to the decoder.
|
||||
let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 2]]);
|
||||
assert_ne!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
|
||||
}
|
||||
|
||||
+41
-1
@@ -24,7 +24,10 @@ pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
pub use ui_fallback::{
|
||||
SystemUiFont, is_cjk, system_arabic_ui_font, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk,
|
||||
ui_uncovered_cjk,
|
||||
};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
@@ -98,6 +101,18 @@ pub fn ui_cjk_fonts_for(prefer_hans: bool, prefer_korean: bool) -> Vec<&'static
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Arabic script (`"Arab"`), in manifest order: the document fallback
|
||||
/// for Arabic text. Empty without craft-fonts.
|
||||
pub fn arabic_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab"))
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Arabic script (`"Arab"`), Regular styles only: the interface's
|
||||
/// fallback for an Arabic interface and for Arabic file names. Empty without craft-fonts.
|
||||
pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> {
|
||||
arabic_fonts().filter(|f| f.style == "Regular").collect()
|
||||
}
|
||||
|
||||
/// The bundled serif used when a document asks for nothing better.
|
||||
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
|
||||
|
||||
@@ -514,6 +529,31 @@ mod tests {
|
||||
assert!(fb.family.contains("Mincho"), "{fb:?}");
|
||||
}
|
||||
|
||||
/// Arabic text in a document needs no system font: the craft-fonts Arabic face is the
|
||||
/// fallback (the browser build has no other), and its letters join when shaped.
|
||||
#[test]
|
||||
fn craft_fonts_arabic_face_is_the_document_fallback_for_arabic() {
|
||||
if arabic_fonts().next().is_none() {
|
||||
eprintln!("skipped: built without a craft-fonts Arabic face (set CRAFT_FONTS_DIR to a checkout)");
|
||||
return;
|
||||
}
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
for ch in "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأإةى،؛؟٠١٢٣٤٥٦٧٨٩ًٌٍَُِّْ".chars() {
|
||||
assert!(!latin.covers(ch), "the bundled serif has no Arabic");
|
||||
let fb = db.fallback_for(ch, latin.id()).unwrap_or_else(|| panic!("no fallback for {ch}"));
|
||||
assert!(fb.family.contains("Arabic"), "{ch}: {}", fb.family);
|
||||
}
|
||||
let face = db.fallback_for('ع', latin.id()).unwrap();
|
||||
// Joined forms: the same letter gets a different glyph alone and inside a word.
|
||||
let alone = shape(&face, "ع", &[], |c| c);
|
||||
let joined = shape(&face, "ععع", &[], |c| c);
|
||||
assert_eq!((alone.len(), joined.len()), (1, 3));
|
||||
assert!(joined.iter().all(|g| g.gid != 0));
|
||||
assert!(joined.iter().any(|g| g.gid != alone[0].gid), "letters take their joining forms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_run_uses_one_face_covering_the_whole_run() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
|
||||
@@ -218,6 +218,36 @@ pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Opti
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans))
|
||||
}
|
||||
|
||||
/// Arabic font files to look for, relative to a font folder, best first.
|
||||
fn arabic_candidates() -> &'static [&'static str] {
|
||||
if cfg!(windows) {
|
||||
&["segoeui.ttf", "tahoma.ttf", "arial.ttf"]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&["SFArabic.ttf", "GeezaPro.ttc", "Supplemental/GeezaPro.ttc", "Supplemental/Arial Unicode.ttf", "Supplemental/Arial.ttf"]
|
||||
} else {
|
||||
&[
|
||||
"noto/NotoSansArabic-Regular.ttf",
|
||||
"truetype/noto/NotoSansArabic-Regular.ttf",
|
||||
"google-noto/NotoSansArabic-Regular.ttf",
|
||||
"google-noto-sans-arabic-vf-fonts/NotoSansArabic[wght].ttf",
|
||||
"noto/NotoNaskhArabic-Regular.ttf",
|
||||
"truetype/noto/NotoNaskhArabic-Regular.ttf",
|
||||
"google-noto/NotoNaskhArabic-Regular.ttf",
|
||||
"vazirmatn/Vazirmatn-Regular.ttf",
|
||||
"truetype/dejavu/DejaVuSans.ttf",
|
||||
"dejavu/DejaVuSans.ttf",
|
||||
"TTF/DejaVuSans.ttf",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed font with Arabic letters, for an Arabic interface in a build whose craft-fonts
|
||||
/// input has no Arabic face. Reads at most one font file per candidate; never panics on damaged
|
||||
/// files.
|
||||
pub fn system_arabic_ui_font() -> Option<SystemUiFont> {
|
||||
find_ui_font(&crate::system_font_dirs(), arabic_candidates(), &['ع'], Region::Other)
|
||||
}
|
||||
|
||||
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
|
||||
/// the `AssetData` folder of every `<hash>.asset` in it: macOS ships PingFang there, e.g.
|
||||
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData/PingFang.ttc`
|
||||
@@ -424,6 +454,9 @@ mod tests {
|
||||
// No file maps every needed character: the one mapping the most is used.
|
||||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap();
|
||||
assert_eq!(found.family, "Source Sans 3");
|
||||
// The bundled Latin face has no Arabic letters, so it is never taken for an Arabic interface.
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['ع'], Region::Other).is_none());
|
||||
assert!(!arabic_candidates().is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,8 @@ fn worst_coefficients(bytes: &[u8]) -> u64 {
|
||||
}
|
||||
|
||||
/// The compression of a classic TIFF's first image (the one the image crate decodes): 1 when the
|
||||
/// tag is missing. None when the header can't be read.
|
||||
/// tag is missing. None when the header can't be read, or when the tag appears more than once:
|
||||
/// the decoder keeps the last copy, so a harmless first one must not hide a JPEG one after it.
|
||||
fn tiff_compression(bytes: &[u8]) -> Option<u16> {
|
||||
let le = match bytes.get(..4)? {
|
||||
b"II*\0" => true,
|
||||
@@ -223,21 +224,22 @@ fn tiff_compression(bytes: &[u8]) -> Option<u16> {
|
||||
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
};
|
||||
let ifd = usize::try_from(u32_at(4)?).ok()?;
|
||||
let mut found = None;
|
||||
for i in 0..usize::from(u16_at(ifd)?) {
|
||||
let entry = ifd.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
|
||||
if u16_at(entry)? != 259 {
|
||||
continue;
|
||||
}
|
||||
if u32_at(entry.checked_add(4)?)? != 1 {
|
||||
if found.is_some() || u32_at(entry.checked_add(4)?)? != 1 {
|
||||
return None;
|
||||
}
|
||||
return match u16_at(entry.checked_add(2)?)? {
|
||||
3 => u16_at(entry.checked_add(8)?),
|
||||
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok(),
|
||||
_ => None,
|
||||
};
|
||||
found = Some(match u16_at(entry.checked_add(2)?)? {
|
||||
3 => u16_at(entry.checked_add(8)?)?,
|
||||
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok()?,
|
||||
_ => return None,
|
||||
});
|
||||
}
|
||||
Some(1)
|
||||
Some(found.unwrap_or(1))
|
||||
}
|
||||
|
||||
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
|
||||
@@ -410,6 +412,19 @@ mod tests {
|
||||
assert_eq!(tiff_compression(&be), Some(7), "big-endian: the short is left-justified");
|
||||
be.truncate(12);
|
||||
assert_eq!(tiff_compression(&be), None, "cut short");
|
||||
// A second compression tag (the one the decoder keeps) can't hide behind an allowed first.
|
||||
let mut twice = b"II*\0".to_vec();
|
||||
twice.extend(8u32.to_le_bytes());
|
||||
twice.extend(2u16.to_le_bytes());
|
||||
for compression in [1u32, 7] {
|
||||
twice.extend(259u16.to_le_bytes());
|
||||
twice.extend(3u16.to_le_bytes());
|
||||
twice.extend(1u32.to_le_bytes());
|
||||
twice.extend(compression.to_le_bytes());
|
||||
}
|
||||
twice.extend(0u32.to_le_bytes());
|
||||
assert_eq!(tiff_compression(&twice), None, "duplicate compression tags");
|
||||
assert!(matches!(probe(&twice), Err(Refusal::Unsupported(_))));
|
||||
// An uncompressed TIFF reads as before.
|
||||
let tiff = encode(image::RgbImage::from_pixel(5, 3, image::Rgb([9, 8, 7])), ImageFormat::Tiff);
|
||||
assert_eq!(tiff_compression(&tiff), Some(1));
|
||||
|
||||
+23
-12
@@ -285,6 +285,13 @@ pub fn page_lines(page: &Page, story: StoryRef) -> Vec<LineHit<'_>> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Whether line `l` is above (`dir < 0`) or below the caret `c`: past the caret's middle, and
|
||||
/// not the caret's own line.
|
||||
fn beyond(l: &LineHit, c: &Caret, dir: i32) -> bool {
|
||||
let (mid, cur_mid) = ((l.top + l.bottom) / 2.0, c.top + c.height / 2.0);
|
||||
if dir < 0 { mid < cur_mid - 1.0 && l.bottom <= cur_mid } else { mid > cur_mid + 1.0 && l.top >= cur_mid }
|
||||
}
|
||||
|
||||
/// Of `lines`, the one nearest the caret moving up (`dir < 0`) or down: the lowest above it or
|
||||
/// the highest below it.
|
||||
fn nearest<'a, 'b>(lines: &[&'b LineHit<'a>], dir: i32) -> Option<&'b LineHit<'a>> {
|
||||
@@ -635,18 +642,7 @@ impl DocLayout {
|
||||
let gx = goal_x.unwrap_or(c.x);
|
||||
let page = self.pages.get(c.page)?;
|
||||
let lines = page_lines(page, pos.story);
|
||||
let cur_mid = c.top + c.height / 2.0;
|
||||
let cands: Vec<&LineHit> = lines
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
let mid = (l.top + l.bottom) / 2.0;
|
||||
if dir < 0 {
|
||||
mid < cur_mid - 1.0 && l.bottom <= c.top + c.height * 0.5
|
||||
} else {
|
||||
mid > cur_mid + 1.0 && l.top >= c.top + c.height * 0.5
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cands: Vec<&LineHit> = lines.iter().filter(|l| beyond(l, &c, dir)).collect();
|
||||
let across: Vec<&LineHit> = cands.iter().copied().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).collect();
|
||||
let target = match nearest(&across, dir) {
|
||||
// The nearest line across `gx`, unless lines between the caret's paragraph and that
|
||||
@@ -676,6 +672,21 @@ impl DocLayout {
|
||||
Some((Pos { story: l.story, path: l.path.clone(), off }, gx))
|
||||
}
|
||||
|
||||
/// Page Up (`dir < 0`) or Page Down within the caret's page: of the lines above (or below)
|
||||
/// the caret at `pos`, the one nearest page height `y`, on a tie the one horizontally nearest
|
||||
/// page x `x`; the position at `x` on it. Unlike [`DocLayout::hit`], how far the lines are
|
||||
/// sideways never keeps the caret on its own line. `None` when no line is that way.
|
||||
pub fn line_toward(&self, pos: &Pos, page_hint: usize, x: f32, y: f32, dir: i32) -> Option<Pos> {
|
||||
let c = self.caret_on(pos, page_hint)?;
|
||||
let page = self.pages.get(c.page)?;
|
||||
let lines = page_lines(page, pos.story);
|
||||
let dy = |l: &LineHit| band_dist(y, l.top, l.bottom - l.top);
|
||||
let dx = |l: &LineHit| (l.left - x).max(x - l.right).max(0.0);
|
||||
let l = lines.iter().filter(|l| beyond(l, &c, dir)).min_by(|a, b| dy(a).total_cmp(&dy(b)).then(dx(a).total_cmp(&dx(b))))?;
|
||||
let off = l.para.off_at_x(l.li, x - l.x);
|
||||
Some(Pos { story: l.story, path: l.path.clone(), off })
|
||||
}
|
||||
|
||||
/// Start and end of the visual line holding `pos` (Home / End).
|
||||
pub fn line_bounds(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos)> {
|
||||
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
||||
|
||||
@@ -46,8 +46,37 @@ pub fn paragraph_issues(
|
||||
}
|
||||
let text = proof_text(p);
|
||||
let spans: Vec<(std::ops::Range<usize>, wordcraft_proof::ProofLang)> = runs.iter().map(|r| (r.range.clone(), r.lang)).collect();
|
||||
let skip = |a: usize, b: usize| runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end);
|
||||
wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item).into_iter().filter(|i| !skip(i.start, i.end)).collect()
|
||||
let skipped = skipped_ranges(runs);
|
||||
let issues = wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item);
|
||||
if skipped.is_empty() {
|
||||
return issues;
|
||||
}
|
||||
issues.into_iter().filter(|i| !overlaps(&skipped, i.start, i.end)).collect()
|
||||
}
|
||||
|
||||
/// The byte ranges of the runs left unchecked, in order, those that overlap merged: their
|
||||
/// starts and ends both rise, so [`overlaps`] finds an issue's by binary search instead of
|
||||
/// looking at every run for every issue.
|
||||
fn skipped_ranges(runs: &[ProofRun]) -> Vec<(usize, usize)> {
|
||||
let mut ranges: Vec<(usize, usize)> = runs.iter().filter(|r| r.skip).map(|r| (r.range.start, r.range.end)).collect();
|
||||
ranges.sort_unstable();
|
||||
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
|
||||
for (s, e) in ranges {
|
||||
crate::work::steps(1);
|
||||
match merged.last_mut() {
|
||||
Some(last) if s < last.1 => last.1 = last.1.max(e),
|
||||
_ => merged.push((s, e)),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Whether bytes `a..b` overlap one of `skipped` ([`skipped_ranges`]): the first range ending
|
||||
/// after `a` is the only one that can, as every later one starts no earlier.
|
||||
fn overlaps(skipped: &[(usize, usize)], a: usize, b: usize) -> bool {
|
||||
crate::work::steps(1);
|
||||
let k = skipped.partition_point(|&(_, e)| e <= a);
|
||||
skipped.get(k).is_some_and(|&(s, _)| s < b)
|
||||
}
|
||||
|
||||
/// Whether the paragraph is a list item: its own numbering, or its style's, names a list (not 0).
|
||||
@@ -101,3 +130,26 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(range: std::ops::Range<usize>, skip: bool) -> ProofRun {
|
||||
ProofRun { range, lang: Default::default(), skip }
|
||||
}
|
||||
|
||||
/// The lookup finds what a look at every run finds, overlapping and empty runs included.
|
||||
#[test]
|
||||
fn skipped_lookup_matches_a_scan_of_every_run() {
|
||||
let runs = [run(0..4, false), run(4..9, true), run(5..6, true), run(9..9, true), run(9..12, false), run(12..20, true), run(14..30, true)];
|
||||
let skipped = skipped_ranges(&runs);
|
||||
for a in 0..32 {
|
||||
for b in a..32 {
|
||||
let scan = runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end);
|
||||
assert_eq!(overlaps(&skipped, a, b), scan, "{a}..{b}");
|
||||
}
|
||||
}
|
||||
assert!(skipped_ranges(&[run(0..5, false)]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,9 @@ impl WordApp {
|
||||
fonts_korean: false,
|
||||
fonts_hant: false,
|
||||
fonts_system_cjk: false,
|
||||
fonts_system_arabic: false,
|
||||
want_system_cjk: false,
|
||||
want_system_arabic: false,
|
||||
recover_fonts_for: None,
|
||||
recent_fonts_for: Vec::new(),
|
||||
fonts_frames: 0,
|
||||
@@ -223,6 +225,7 @@ impl WordApp {
|
||||
crate::language_packs::tick(self);
|
||||
let lang = i18n::Lang::from_pref(&self.ui.language);
|
||||
i18n::set_current(lang);
|
||||
crate::rtl::sync(ctx);
|
||||
// New documents are written in the interface's language when WordCraft can proof it
|
||||
// (a Polish interface: Polish spelling, grammar and hyphenation from the first word).
|
||||
// An English interface writes the region's English (English (UK) on a British system,
|
||||
|
||||
@@ -155,11 +155,15 @@ impl WordApp {
|
||||
/// face has changed since the last install.
|
||||
pub(super) fn install_ui_fonts(&mut self, ctx: &egui::Context, lang: i18n::Lang) {
|
||||
let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk();
|
||||
// An Arabic interface, or a screen that shows Arabic names (language picker / packs) in
|
||||
// another interface language, asks for an installed Arabic font when none is embedded.
|
||||
let system_arabic = lang.rtl() || self.want_system_arabic;
|
||||
if !self.styled
|
||||
|| lang.prefers_hans() != self.fonts_hans
|
||||
|| lang.prefers_korean() != self.fonts_korean
|
||||
|| lang.prefers_hant() != self.fonts_hant
|
||||
|| system_cjk != self.fonts_system_cjk
|
||||
|| system_arabic != self.fonts_system_arabic
|
||||
{
|
||||
// Every window's borrowed faces: the fonts are the app's.
|
||||
let mut faces = self.canvas.ime_faces.clone();
|
||||
@@ -170,11 +174,20 @@ impl WordApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), lang.prefers_hant(), system_cjk, &faces);
|
||||
theme::install_fonts_language_arabic(
|
||||
ctx,
|
||||
lang.prefers_hans(),
|
||||
lang.prefers_korean(),
|
||||
lang.prefers_hant(),
|
||||
system_cjk,
|
||||
system_arabic,
|
||||
&faces,
|
||||
);
|
||||
self.fonts_hans = lang.prefers_hans();
|
||||
self.fonts_korean = lang.prefers_korean();
|
||||
self.fonts_hant = lang.prefers_hant();
|
||||
self.fonts_system_cjk = system_cjk;
|
||||
self.fonts_system_arabic = system_arabic;
|
||||
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);
|
||||
// egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom.
|
||||
// egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph
|
||||
|
||||
@@ -10,7 +10,7 @@ impl WordApp {
|
||||
/// whatever handed it over (the command line, a macOS open event, a drop). Returns the message.
|
||||
pub fn report_open_failure(&mut self, path: &str, error: &str) -> String {
|
||||
log::warn!("{path}: {error}");
|
||||
let msg = crate::i18n::fmt(crate::tl!("Couldn't open {path}: {error}"), &[("path", path), ("error", error)]);
|
||||
let msg = crate::i18n::fmt(crate::tl!("Couldn't open {path}: {error}"), &[("path", &crate::i18n::isolate_path(path)), ("error", error)]);
|
||||
self.status(msg.clone());
|
||||
msg
|
||||
}
|
||||
|
||||
+159
-85
@@ -1,6 +1,6 @@
|
||||
//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Close, Options, About.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use egui::{Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::{Tokens, medium, regular, semibold};
|
||||
@@ -65,12 +65,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.ui.backstage_page = "home".into();
|
||||
}
|
||||
let mac = app.integrated_titlebar;
|
||||
egui::Panel::left("backstage_nav")
|
||||
crate::rtl::start_panel("backstage_nav")
|
||||
.exact_size(200.0)
|
||||
// Fixed width, as in Word: without this egui shows a resize handle that does nothing (#502).
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.nav).inner_margin(egui::Margin { left: 0, right: 0, top: if mac { 0 } else { 12 }, bottom: 12 }))
|
||||
.show(ui, |ui| {
|
||||
// Right to left the navigation is on the right, away from the macOS traffic lights.
|
||||
let rtl = crate::rtl::on();
|
||||
let back = if mac {
|
||||
// macOS (#222): the Backstage covers the title bar, so its top band is the title
|
||||
// bar: the back button sits beside the traffic lights, centred with them and as far
|
||||
@@ -80,18 +82,34 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
// The chevron's stroke starts at the content edge.
|
||||
let c = back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left);
|
||||
let c = if rtl {
|
||||
pos2(band.max.x - 26.0, band.center().y)
|
||||
} else {
|
||||
back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left)
|
||||
};
|
||||
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.scope_id().with("backstage_back"), Sense::click());
|
||||
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(c, vec2(18.0, 18.0)),
|
||||
if rtl { "chevronRight" } else { "chevronLeft" },
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
resp
|
||||
} else {
|
||||
// The hit area is scoped to the chevron, not the whole 200px-wide row (#3041):
|
||||
// the row reads as plain sidebar background next to it, so clicking there must
|
||||
// not also close the File page, same as the macOS chevron fix (#2972) below.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::hover());
|
||||
let c = pos2(r.min.x + 26.0, r.center().y);
|
||||
let c = pos2(crate::rtl::lead_x(rtl, r, 26.0), r.center().y);
|
||||
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.scope_id().with("backstage_back"), Sense::click());
|
||||
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(c, vec2(18.0, 18.0)),
|
||||
if rtl { "chevronRight" } else { "chevronLeft" },
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
resp
|
||||
};
|
||||
// Escape in the built-in file browser closes only the browser.
|
||||
@@ -112,10 +130,16 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
let ink = if on { egui::Color32::WHITE } else { egui::Color32::from_white_alpha(110) };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 31.0, r.center().y), vec2(18.0, 18.0)), icon, ink, ink);
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 31.0), r.center().y), vec2(18.0, 18.0)),
|
||||
icon,
|
||||
ink,
|
||||
ink,
|
||||
);
|
||||
ui.painter().text(
|
||||
pos2(r.min.x + 52.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
pos2(crate::rtl::lead_x(rtl, r, 52.0), r.center().y),
|
||||
crate::rtl::lead_center(rtl),
|
||||
tl!(label),
|
||||
if active { semibold(14.0) } else { medium(14.0) },
|
||||
ink,
|
||||
@@ -137,7 +161,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, tl!(label), regular(13.0), egui::Color32::WHITE);
|
||||
let at = pos2(crate::rtl::lead_x(rtl, r, 22.0), r.center().y);
|
||||
ui.painter().text(at, crate::rtl::lead_center(rtl), tl!(label), regular(13.0), egui::Color32::WHITE);
|
||||
if resp.clicked() {
|
||||
if id == "discord" {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
@@ -151,19 +176,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| {
|
||||
// The Print page fills the pane and scrolls its settings and its preview itself (#1156).
|
||||
if app.ui.backstage_page == "print" {
|
||||
export_page(app, ui);
|
||||
crate::rtl::root(ui, |ui| export_page(app, ui));
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"home" => home_page(app, ui),
|
||||
"new" => new_page(app, ui),
|
||||
"open" => open_page(app, ui),
|
||||
"saveAs" => save_as_page(app, ui),
|
||||
"info" => info_page(app, ui),
|
||||
"recover" => recovery_page(app, ui),
|
||||
"print" | "export" => export_page(app, ui),
|
||||
"options" => crate::options::page(app, ui),
|
||||
_ => home_page(app, ui),
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"home" => home_page(app, ui),
|
||||
"new" => new_page(app, ui),
|
||||
"open" => open_page(app, ui),
|
||||
"saveAs" => save_as_page(app, ui),
|
||||
"info" => info_page(app, ui),
|
||||
"recover" => recovery_page(app, ui),
|
||||
"print" | "export" => export_page(app, ui),
|
||||
"options" => crate::options::page(app, ui),
|
||||
_ => home_page(app, ui),
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -187,7 +214,7 @@ fn recovery_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.label(tl!("No recovery copies."));
|
||||
}
|
||||
let busy = app.session.recovery_busy();
|
||||
egui::Grid::new("recovery_documents").min_col_width(180.0).spacing(vec2(24.0, 16.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("recovery_documents").min_col_width(180.0).spacing(vec2(24.0, 16.0)), |ui| {
|
||||
if !documents.is_empty() {
|
||||
ui.strong(tl!("Document"));
|
||||
ui.strong(tl!("Recovery copy"));
|
||||
@@ -269,7 +296,7 @@ fn heading(ui: &mut Ui, s: &str) {
|
||||
/// region-aware page preview, and creates a document from the template on click.
|
||||
fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
// The thumbnail and its caption are one button named by the caption, so a screen reader
|
||||
// announces "Blank document, button" rather than unnamed text (#3176); the caption opens
|
||||
// the template too, as in Word.
|
||||
@@ -513,7 +540,7 @@ fn matching_personal(query: &str, list: &[PersonalTemplate]) -> Vec<PersonalTemp
|
||||
/// click makes a new document from it.
|
||||
fn personal_tile(ui: &mut Ui, app: &mut WordApp, p: &PersonalTemplate) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
ui.set_max_width(150.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click());
|
||||
@@ -568,15 +595,27 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
|
||||
let names = shown.iter().map(|p| file_name(p)).collect::<Vec<_>>();
|
||||
for (p, name) in shown.iter().zip(&names) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click());
|
||||
// The row shows a name and a path that may not fit: the path in full is its tooltip.
|
||||
let resp = resp.on_hover_text(crate::i18n::isolate_path(p));
|
||||
// Two documents with the same file name are told apart by their path, as on screen.
|
||||
let spoken = if names.iter().filter(|n| *n == name).count() > 1 { format!("{name}, {p}") } else { name.clone() };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &spoken));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, name, medium(13.0), t.text);
|
||||
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, p, regular(11.0), t.text_dim);
|
||||
// Names and paths are set apart (a right-to-left line would scramble their marks and
|
||||
// folders) and the row starts at the side reading starts from.
|
||||
let rtl = crate::rtl::on();
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 18.0), r.center().y), vec2(22.0, 22.0)),
|
||||
"printLayout",
|
||||
t.icon,
|
||||
t.accent,
|
||||
);
|
||||
let side = crate::rtl::lead_center(rtl);
|
||||
ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 13.0), side, crate::i18n::isolate(name), medium(13.0), t.text);
|
||||
ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 29.0), side, crate::i18n::isolate_path(p), regular(11.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let failed = open_recent(app, p).err();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default()));
|
||||
@@ -664,73 +703,108 @@ fn save_as_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
fn info_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
heading(ui, "Info");
|
||||
let info = app.session.run("file.info", &json!({})).unwrap_or_default();
|
||||
// Properties at the reading start, so at the right in a mirrored interface, Statistics after.
|
||||
let (first, second) = if crate::rtl::on() { (1, 0) } else { (0, 1) };
|
||||
ui.columns(2, |cols| {
|
||||
let ui = &mut cols[0];
|
||||
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
|
||||
// The fields edit a copy of the document's properties that is read afresh every frame,
|
||||
// so each change goes into the document as it is typed (#260): waiting for the field to
|
||||
// lose focus dropped the keystrokes, and leaving File with Escape never loses it at all.
|
||||
// The keystrokes of one visit to a field are a single undo step.
|
||||
let mut props = app.session.doc.core.clone();
|
||||
let mut edited = None;
|
||||
let mut focused = None;
|
||||
egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] {
|
||||
let label = ui.label(tl!(l));
|
||||
let r = ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id);
|
||||
if r.changed() {
|
||||
crate::rtl::root(&mut cols[first], |ui| info_properties(app, ui, &info));
|
||||
crate::rtl::root(&mut cols[second], |ui| info_statistics(ui, &info));
|
||||
});
|
||||
}
|
||||
|
||||
fn info_properties(app: &mut WordApp, ui: &mut Ui, info: &serde_json::Value) {
|
||||
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
|
||||
// The fields edit a copy of the document's properties that is read afresh every frame,
|
||||
// so each change goes into the document as it is typed (#260): waiting for the field to
|
||||
// lose focus dropped the keystrokes, and leaving File with Escape never loses it at all.
|
||||
// The keystrokes of one visit to a field are a single undo step.
|
||||
let mut props = app.session.doc.core.clone();
|
||||
let mut edited = None;
|
||||
let mut focused = None;
|
||||
crate::rtl::Table::new("props").columns(2).spacing(vec2(12.0, 6.0)).show(ui, |t| {
|
||||
for (l, v) in [
|
||||
("Title", &mut props.title),
|
||||
("Subject", &mut props.subject),
|
||||
("Author", &mut props.creator),
|
||||
("Keywords", &mut props.keywords),
|
||||
("Category", &mut props.category),
|
||||
] {
|
||||
t.row(|r| {
|
||||
let label = r.cell(|ui| ui.label(tl!(l)));
|
||||
let field = r.cell(|ui| ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id));
|
||||
if field.changed() {
|
||||
edited = Some(l);
|
||||
}
|
||||
if r.has_focus() {
|
||||
if field.has_focus() {
|
||||
focused = Some(l);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if let Some(field) = edited {
|
||||
if app.info_editing == Some((field, app.session.rev())) {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok();
|
||||
app.info_editing = ok.then(|| (field, app.session.rev()));
|
||||
});
|
||||
}
|
||||
if focused != app.info_editing.map(|(f, _)| f) {
|
||||
app.info_editing = None;
|
||||
});
|
||||
if let Some(field) = edited {
|
||||
if app.info_editing == Some((field, app.session.rev())) {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
// Advanced Properties › Custom (#2982): view and edit the document's custom properties.
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5)))
|
||||
.on_hover_text(tl!("View and edit the document's custom properties."))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "customProperties"}));
|
||||
let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok();
|
||||
app.info_editing = ok.then(|| (field, app.session.rev()));
|
||||
}
|
||||
if focused != app.info_editing.map(|(f, _)| f) {
|
||||
app.info_editing = None;
|
||||
}
|
||||
// Advanced Properties › Custom (#2982): view and edit the document's custom properties.
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5)))
|
||||
.on_hover_text(tl!("View and edit the document's custom properties."))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "customProperties"}));
|
||||
}
|
||||
// Protect Document › Encrypt with Password (#55).
|
||||
ui.add_space(18.0);
|
||||
ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0)));
|
||||
let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
ui.label(if encrypted {
|
||||
tl!("A password is required to open this document.")
|
||||
} else {
|
||||
tl!("Anyone can open this document. Encrypt it with a password to keep it private.")
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") };
|
||||
if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({}));
|
||||
}
|
||||
// Protect Document › Encrypt with Password (#55).
|
||||
ui.add_space(18.0);
|
||||
ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0)));
|
||||
let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
ui.label(if encrypted {
|
||||
tl!("A password is required to open this document.")
|
||||
} else {
|
||||
tl!("Anyone can open this document. Encrypt it with a password to keep it private.")
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") };
|
||||
if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({}));
|
||||
}
|
||||
if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({"password": null}));
|
||||
}
|
||||
});
|
||||
let ui = &mut cols[1];
|
||||
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] {
|
||||
ui.label(format!("{}: {}", tl!(l), info.get(k).map(|v| v.to_string()).unwrap_or_default()));
|
||||
if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({"password": null}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn info_statistics(ui: &mut Ui, info: &serde_json::Value) {
|
||||
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] {
|
||||
ui.label(format!("{}: {}", tl!(l), info.get(k).map(|v| v.to_string()).unwrap_or_default()));
|
||||
}
|
||||
let path = info.get("path").and_then(|v| v.as_str()).unwrap_or(tl!("Not saved yet"));
|
||||
// A path reads left to right whatever its folders are called, so it is a label of its own
|
||||
// beside the words around it (one right-to-left line would swap its folders), and a long
|
||||
// one is cut short, the whole path on hover, rather than wrapped to strand its last part.
|
||||
let shown = crate::i18n::isolate_path(path);
|
||||
let template = tl!("Location: {path}");
|
||||
let (before, after) = template.split_once("{path}").unwrap_or((template, ""));
|
||||
// The columns start left to right, so the row's own direction is set: the words before the
|
||||
// path come first at the right edge, the path after them.
|
||||
let row = if crate::rtl::on() { egui::Layout::right_to_left(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Min) };
|
||||
ui.scope_builder(egui::UiBuilder::new().layout(row), |ui| {
|
||||
if !before.trim().is_empty() {
|
||||
ui.label(before.trim_end());
|
||||
}
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let room = ui.available_width() - if after.trim().is_empty() { 0.0 } else { 40.0 };
|
||||
let fitted = crate::theme::fit_text(ui.painter(), &shown, &font, 0.0, ui.visuals().text_color(), room);
|
||||
ui.label(fitted).on_hover_text(&shown);
|
||||
if !after.trim().is_empty() {
|
||||
ui.label(after.trim_start());
|
||||
}
|
||||
let path = info.get("path").and_then(|v| v.as_str()).unwrap_or(tl!("Not saved yet"));
|
||||
ui.label(crate::i18n::fmt(tl!("Location: {path}"), &[("path", path)]));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1199,7 +1199,7 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA
|
||||
if open {
|
||||
app.escape_free = false;
|
||||
}
|
||||
let menu = egui::Popup::menu(&resp).open_bool(&mut shown);
|
||||
let menu = crate::rtl::menu(&resp).open_bool(&mut shown);
|
||||
let menu = menu.at_position(app.canvas.context_menu_at.unwrap_or(out.inner_rect.left_top()));
|
||||
let with_toolbar = crate::mini_toolbar::context_menu_visible(app);
|
||||
let menu = if with_toolbar { menu.frame(egui::Frame::NONE).close_behavior(egui::PopupCloseBehavior::IgnoreClicks) } else { menu };
|
||||
|
||||
@@ -56,7 +56,7 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
let item = |ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: serde_json::Value| {
|
||||
let sc = crate::widgets::shortcut_text(app, id);
|
||||
let on = crate::widgets::enabled(app, id);
|
||||
if ui.add_enabled(on, egui::Button::new(tl!(label)).shortcut_text(sc)).clicked() {
|
||||
if ui.add_enabled(on, crate::rtl::menu_item(ui, tl!(label), &sc, 0.0)).clicked() {
|
||||
let _ = app.run(id, params);
|
||||
ui.close();
|
||||
}
|
||||
@@ -95,17 +95,19 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.separator();
|
||||
if let Some(syn) = app.canvas.context_synonyms.clone().and_then(|v| v.as_array().cloned()).filter(|a| !a.is_empty()) {
|
||||
ui.menu_button(tl!("Synonyms"), |ui| {
|
||||
for w in syn.iter().filter_map(|x| x.as_str()) {
|
||||
if ui.button(w).clicked() {
|
||||
let _ = app.run("select.word", json!({}));
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0);
|
||||
let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b };
|
||||
app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end };
|
||||
let _ = app.run("text.insert", json!({"text": w, "raw": true}));
|
||||
ui.close();
|
||||
crate::rtl::root(ui, |ui| {
|
||||
for w in syn.iter().filter_map(|x| x.as_str()) {
|
||||
if ui.button(w).clicked() {
|
||||
let _ = app.run("select.word", json!({}));
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0);
|
||||
let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b };
|
||||
app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end };
|
||||
let _ = app.run("text.insert", json!({"text": w, "raw": true}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
item(ui, app, "Font…", "ui.dialog", json!({"name": "font"}));
|
||||
@@ -154,10 +156,12 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
let pick = table_pick(&app.session);
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Insert"), |ui| {
|
||||
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
|
||||
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
|
||||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||||
crate::rtl::root(ui, |ui| {
|
||||
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
|
||||
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
|
||||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||||
})
|
||||
});
|
||||
// Every Delete is there whatever is selected, as on Word's Delete button: with the caret
|
||||
// in a cell, Delete Table is how the table goes (#1198).
|
||||
@@ -179,9 +183,11 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
if pick == TablePick::Table {
|
||||
ui.menu_button(tl!("AutoFit"), |ui| {
|
||||
item(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"}));
|
||||
item(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
|
||||
item(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
|
||||
crate::rtl::root(ui, |ui| {
|
||||
item(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"}));
|
||||
item(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
|
||||
item(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
|
||||
})
|
||||
});
|
||||
}
|
||||
item(ui, app, "Text Direction", "table.textDirection", json!({}));
|
||||
|
||||
+293
-250
@@ -12,7 +12,7 @@ pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::Pat
|
||||
match (unreadable, missing) {
|
||||
(Some((file, reason)), _) => Some(crate::i18n::fmt(
|
||||
tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"),
|
||||
&[("language", lang), ("file", &file.to_string_lossy()), ("reason", reason)],
|
||||
&[("language", lang), ("file", &crate::i18n::isolate_path(&file.to_string_lossy())), ("reason", reason)],
|
||||
)),
|
||||
(None, Some(name)) => Some(crate::i18n::fmt(
|
||||
tl!(
|
||||
@@ -93,9 +93,9 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), "more", t.icon, t.accent);
|
||||
let resp = resp.on_hover_text(tl!("Customize Quick Access Toolbar"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
qat_menu(ui, app);
|
||||
crate::rtl::root(ui, |ui| qat_menu(ui, app));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ fn undo_list_button(ui: &mut Ui, app: &mut WordApp, enabled: bool) {
|
||||
let c = if enabled { t.icon } else { t.text_disabled };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(9.0, 9.0)), "dropdown", c, c);
|
||||
let resp = resp.on_hover_text(tl!("Undo History"));
|
||||
egui::Popup::menu(&resp).show(|ui| undo_list(ui, app));
|
||||
crate::rtl::menu(&resp).show(|ui| undo_list(ui, app));
|
||||
}
|
||||
|
||||
/// The Undo list (#701): recent steps, newest first. Hovering a row highlights it and every newer
|
||||
@@ -275,6 +275,12 @@ impl MacTitleBar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the AutoSave switch's knob sits in its track `r`: on at the right and off at the left,
|
||||
/// mirrored in a right-to-left interface (on at the left).
|
||||
fn autosave_knob_x(rtl: bool, on: bool, r: Rect) -> f32 {
|
||||
crate::rtl::lead_x(rtl != on, r, 8.0)
|
||||
}
|
||||
|
||||
/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search
|
||||
/// controls, and the centred clickable document title.
|
||||
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
@@ -299,83 +305,97 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
let mut qat_end = full.min.x;
|
||||
let mut right_start = full.max.x;
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't
|
||||
// cover the document (#196, #176); clicking it then offers Save As where that helps.
|
||||
let block = app.autosave_block();
|
||||
let on = app.autosaves();
|
||||
let color = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim };
|
||||
// The text is part of the switch (#3370): clicking it toggles AutoSave, and chrome
|
||||
// text is not document text to select (double-clicking it copied "AutoSave").
|
||||
let text = ui.add(
|
||||
egui::Label::new(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(color)).selectable(false).sense(Sense::click()),
|
||||
);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let (fill, stroke, knob_fill) = match (&block, on) {
|
||||
(Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)),
|
||||
(None, true) => (t.accent, t.accent, t.on_accent),
|
||||
(None, false) => (t.input, t.border_strong, t.text_dim),
|
||||
};
|
||||
ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
|
||||
let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 };
|
||||
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill);
|
||||
let tip = block
|
||||
.as_ref()
|
||||
.map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason());
|
||||
let text = text.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let resp = resp.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if text.clicked() || resp.clicked() {
|
||||
match block {
|
||||
None => {
|
||||
let _ = app.run("file.autosave", json!({}));
|
||||
// The free stretch between the two clusters, for the centred title. Right to left the
|
||||
// clusters swap sides: AutoSave and the Quick Access Toolbar start at the right.
|
||||
let rtl = crate::rtl::on();
|
||||
let mut qat_end = if rtl { full.max.x } else { full.min.x };
|
||||
let mut right_start = if rtl { full.min.x } else { full.max.x };
|
||||
crate::rtl::root(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't
|
||||
// cover the document (#196, #176); clicking it then offers Save As where that helps.
|
||||
let block = app.autosave_block();
|
||||
let on = app.autosaves();
|
||||
let color = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim };
|
||||
// The text is part of the switch (#3370): clicking it toggles AutoSave, and chrome
|
||||
// text is not document text to select (double-clicking it copied "AutoSave").
|
||||
let text = ui.add(
|
||||
egui::Label::new(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(color))
|
||||
.selectable(false)
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let (fill, stroke, knob_fill) = match (&block, on) {
|
||||
(Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)),
|
||||
(None, true) => (t.accent, t.accent, t.on_accent),
|
||||
(None, false) => (t.input, t.border_strong, t.text_dim),
|
||||
};
|
||||
ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
|
||||
let knob = autosave_knob_x(rtl, on, r);
|
||||
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill);
|
||||
let tip = block
|
||||
.as_ref()
|
||||
.map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason());
|
||||
let text = text.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let resp = resp.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if text.clicked() || resp.clicked() {
|
||||
match block {
|
||||
None => {
|
||||
let _ = app.run("file.autosave", json!({}));
|
||||
}
|
||||
// AutoSave comes on once Save As has saved (maybe on a later frame, #94).
|
||||
Some(b) if b.save_as_helps() => {
|
||||
let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave);
|
||||
}
|
||||
Some(b) => app.status(b.reason()),
|
||||
}
|
||||
// AutoSave comes on once Save As has saved (maybe on a later frame, #94).
|
||||
Some(b) if b.save_as_helps() => {
|
||||
let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave);
|
||||
}
|
||||
Some(b) => app.status(b.reason()),
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Hidden (#1945): neither the buttons nor the … menu; the title centres as before.
|
||||
if app.ui.quick_access_toolbar {
|
||||
quick_access(ui, app);
|
||||
}
|
||||
qat_end = ui.min_rect().max.x;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Account / community.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account")));
|
||||
let initials: String = app.session.user_initials().chars().take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
|
||||
if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if app.ui.show_discord {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)),
|
||||
"discord",
|
||||
t.on_accent,
|
||||
t.on_accent,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, tl!("Discord"), medium(11.5), t.on_accent);
|
||||
if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
// Hidden (#1945): neither the buttons nor the … menu; the title centres as before.
|
||||
if app.ui.quick_access_toolbar {
|
||||
quick_access(ui, app);
|
||||
}
|
||||
qat_end = if rtl { ui.min_rect().min.x } else { ui.min_rect().max.x };
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
// Account / community.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account")));
|
||||
let initials: String = app.session.user_initials().chars().take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
|
||||
if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
search_box(app, ui, control_h);
|
||||
right_start = ui.min_rect().min.x;
|
||||
});
|
||||
if app.ui.show_discord {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 13.0), r.center().y), vec2(14.0, 14.0)),
|
||||
"discord",
|
||||
t.on_accent,
|
||||
t.on_accent,
|
||||
);
|
||||
ui.painter().text(
|
||||
pos2(crate::rtl::lead_x(rtl, r, 24.0), r.center().y),
|
||||
crate::rtl::lead_center(rtl),
|
||||
tl!("Discord"),
|
||||
medium(11.5),
|
||||
t.on_accent,
|
||||
);
|
||||
if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
search_box(app, ui, control_h);
|
||||
right_start = if rtl { ui.min_rect().max.x } else { ui.min_rect().min.x };
|
||||
});
|
||||
})
|
||||
});
|
||||
let (qat_end, right_start) = if rtl { (right_start, qat_end) } else { (qat_end, right_start) };
|
||||
// Centred title.
|
||||
let title = if !app.has_document() {
|
||||
// No document open (#1129): the app's name alone.
|
||||
@@ -383,7 +403,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
} else {
|
||||
format!(
|
||||
"{} — {}",
|
||||
app.title_stem(),
|
||||
crate::i18n::isolate(&app.title_stem()),
|
||||
if app.session.dirty {
|
||||
tl!("Edited")
|
||||
} else if app.session.path.is_some() {
|
||||
@@ -430,17 +450,24 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
/// of Full Screen (#1678).
|
||||
fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rtl = crate::rtl::on();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
|
||||
let ph = tl!("Search commands and help");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph));
|
||||
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 16.0), r.center().y), vec2(16.0, 16.0)),
|
||||
"search",
|
||||
t.text_dim,
|
||||
t.accent,
|
||||
);
|
||||
let pfont = TypeRung::Control.regular();
|
||||
let ptrack = TypeRung::Control.tracking();
|
||||
paint_text(
|
||||
ui.painter(),
|
||||
pos2(r.min.x + 30.0, r.center().y - TypeRung::Control.line_height() / 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
pos2(crate::rtl::lead_x(rtl, r, 30.0), r.center().y - TypeRung::Control.line_height() / 2.0),
|
||||
crate::rtl::lead_top(rtl),
|
||||
ph,
|
||||
&pfont,
|
||||
ptrack,
|
||||
@@ -555,187 +582,203 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
if !app.has_document() {
|
||||
return;
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
let page = if let Some(p) = app.canvas.viewed_page {
|
||||
// The page in view (#3880), as the scrollbar's tip names it.
|
||||
p.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else if app.session.selects_whole_story() {
|
||||
app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else {
|
||||
l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1)
|
||||
};
|
||||
let st = |ui: &mut Ui, s: &str| {
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s));
|
||||
resp
|
||||
};
|
||||
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
|
||||
.on_hover_text(tl!("Go To (⌘⌥G)"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
// A selected picture/shape/text box isn't a text selection: show the total.
|
||||
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
|
||||
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
let label = match crate::canvas::part_kind(app, app.session.sel.focus.story) {
|
||||
Some(PartKind::Footnote) => tl!("Editing footnote"),
|
||||
Some(PartKind::Endnote) => tl!("Editing endnote"),
|
||||
_ => tl!("Editing header/footer"),
|
||||
let rtl = crate::rtl::on();
|
||||
crate::rtl::root(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
let page = if let Some(p) = app.canvas.viewed_page {
|
||||
// The page in view (#3880), as the scrollbar's tip names it.
|
||||
p.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else if app.session.selects_whole_story() {
|
||||
app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1
|
||||
} else {
|
||||
l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1)
|
||||
};
|
||||
st(ui, label);
|
||||
}
|
||||
// The language at the caret; a click sets the proofing language (Review › Language).
|
||||
let lang = crate::ribbon::caret_language(app);
|
||||
let missing = crate::ribbon::caret_missing_dictionary(app);
|
||||
let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
|
||||
let shown = if unreadable.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)])
|
||||
} else if missing.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)])
|
||||
} else {
|
||||
lang.clone()
|
||||
};
|
||||
let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
|
||||
let resp = st(ui, &shown).on_hover_text(tip);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
crate::ribbon::language_menu(ui, app);
|
||||
});
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, tl!("Track Changes: On"));
|
||||
}
|
||||
if app.session.prefs.overtype && st(ui, tl!("Overtype")).on_hover_text(tl!("Click to switch back to inserting")).clicked() {
|
||||
let _ = app.run("text.overtype", json!({"value": false}));
|
||||
}
|
||||
// The message is drawn where the row is now; the controls on the right are laid
|
||||
// out first, so the message can be shortened to the space they leave (#2402): a
|
||||
// long one (the path of a file that isn't there) must not be drawn over the
|
||||
// Focus and view buttons.
|
||||
let msg_at = ui.cursor().min;
|
||||
let mut controls_left = f32::INFINITY;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
let reading = app.session.view.read_mode;
|
||||
let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round();
|
||||
let ztxt = format!("{z}%");
|
||||
let zfont = TypeRung::Caption.regular();
|
||||
let ztrack = TypeRung::Caption.tracking();
|
||||
let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim);
|
||||
// A fixed width for three digits plus a small reserve, so the controls to the
|
||||
// left don't shift as the percentage gains or loses digits (#1975).
|
||||
let zslot = text_width(ui.painter(), "000%", &zfont, ztrack, t.text_dim) + 4.0;
|
||||
let (zr, zresp) = ui.allocate_exact_size(vec2(zw.max(zslot) + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(zr.max.x - 1.0, zr.min.y), Align2::RIGHT_TOP, &ztxt, &zfont, ztrack, t.text_dim);
|
||||
if zresp.clicked() && !reading {
|
||||
let _ = app.run("ui.dialog", json!({ "name": "zoom" }));
|
||||
let st = |ui: &mut Ui, s: &str| {
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s));
|
||||
resp
|
||||
};
|
||||
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
|
||||
.on_hover_text(tl!("Go To (⌘⌥G)"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
if small_icon(ui, "plus", tl!("Zoom In")) {
|
||||
if reading {
|
||||
let _ = app.run("view.readTextSize", json!({"value": (z + 10.0).min(150.0)}));
|
||||
} else {
|
||||
let _ = app.run("view.zoomIn", json!({}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
// A selected picture/shape/text box isn't a text selection: show the total.
|
||||
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
|
||||
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
// Reading zoom changes only reading presentation, never the normal view zoom.
|
||||
let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT };
|
||||
let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false);
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
let response = ui.add(slider);
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") })
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
let label = match crate::canvas::part_kind(app, app.session.sel.focus.story) {
|
||||
Some(PartKind::Footnote) => tl!("Editing footnote"),
|
||||
Some(PartKind::Endnote) => tl!("Editing endnote"),
|
||||
_ => tl!("Editing header/footer"),
|
||||
};
|
||||
st(ui, label);
|
||||
}
|
||||
// The language at the caret; a click sets the proofing language (Review › Language).
|
||||
let lang = crate::ribbon::caret_language(app);
|
||||
let missing = crate::ribbon::caret_missing_dictionary(app);
|
||||
let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
|
||||
let shown = if unreadable.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)])
|
||||
} else if missing.is_some() {
|
||||
crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)])
|
||||
} else {
|
||||
lang.clone()
|
||||
};
|
||||
let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
|
||||
let resp = st(ui, &shown).on_hover_text(tip);
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
crate::rtl::root(ui, |ui| crate::ribbon::language_menu(ui, app));
|
||||
});
|
||||
if response.changed() {
|
||||
let command = if reading { "view.readTextSize" } else { "view.zoom" };
|
||||
let _ = app.run(command, json!({"value": (v * 100.0).round()}));
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, tl!("Track Changes: On"));
|
||||
}
|
||||
if small_icon(ui, "minus", tl!("Zoom Out")) {
|
||||
if reading {
|
||||
let _ = app.run("view.readTextSize", json!({"value": (z - 10.0).max(75.0)}));
|
||||
} else {
|
||||
let _ = app.run("view.zoomOut", json!({}));
|
||||
if app.session.prefs.overtype && st(ui, tl!("Overtype")).on_hover_text(tl!("Click to switch back to inserting")).clicked() {
|
||||
let _ = app.run("text.overtype", json!({"value": false}));
|
||||
}
|
||||
// The message is drawn where the row is now; the controls on the right are laid
|
||||
// out first, so the message can be shortened to the space they leave (#2402): a
|
||||
// long one (the path of a file that isn't there) must not be drawn over the
|
||||
// Focus and view buttons.
|
||||
// Right to left the row runs the other way: the message starts at the right edge of
|
||||
// what is left, and the controls (a cluster at the left) end at their right edge.
|
||||
let msg_at = ui.cursor().min;
|
||||
let msg_edge = ui.available_rect_before_wrap().max.x;
|
||||
let mut controls_left = if rtl { f32::NEG_INFINITY } else { f32::INFINITY };
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
let reading = app.session.view.read_mode;
|
||||
let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round();
|
||||
let ztxt = format!("{z}%");
|
||||
let zfont = TypeRung::Caption.regular();
|
||||
let ztrack = TypeRung::Caption.tracking();
|
||||
let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim);
|
||||
// A fixed width for three digits plus a small reserve, so the controls to the
|
||||
// left don't shift as the percentage gains or loses digits (#1975).
|
||||
let zslot = text_width(ui.painter(), "000%", &zfont, ztrack, t.text_dim) + 4.0;
|
||||
let (zr, zresp) = ui.allocate_exact_size(vec2(zw.max(zslot) + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(zr.max.x - 1.0, zr.min.y), Align2::RIGHT_TOP, &ztxt, &zfont, ztrack, t.text_dim);
|
||||
if zresp.clicked() && !reading {
|
||||
let _ = app.run("ui.dialog", json!({ "name": "zoom" }));
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, name, on) in [
|
||||
("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", tl!("Read Mode"), read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 3.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
// The slider grows to the right in every language, so the buttons stay at its
|
||||
// ends: plus on the right. This cluster fills from the far end inwards.
|
||||
let zoom_step = |app: &mut WordApp, plus: bool| {
|
||||
if reading {
|
||||
let value = if plus { (z + 10.0).min(150.0) } else { (z - 10.0).max(75.0) };
|
||||
let _ = app.run("view.readTextSize", json!({"value": value}));
|
||||
} else {
|
||||
let _ = app.run(if plus { "view.zoomIn" } else { "view.zoomOut" }, json!({}));
|
||||
}
|
||||
};
|
||||
let (inner, outer) = (("minus", "Zoom Out", false), ("plus", "Zoom In", true));
|
||||
let (near, far) = if rtl { (inner, outer) } else { (outer, inner) };
|
||||
if small_icon(ui, near.0, tl!(near.1)) {
|
||||
zoom_step(app, near.2);
|
||||
}
|
||||
// Reading zoom changes only reading presentation, never the normal view zoom.
|
||||
let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT };
|
||||
let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false);
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
let response = ui.add(slider);
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") })
|
||||
});
|
||||
if response.changed() {
|
||||
let command = if reading { "view.readTextSize" } else { "view.zoom" };
|
||||
let _ = app.run(command, json!({"value": (v * 100.0).round()}));
|
||||
}
|
||||
if small_icon(ui, far.0, tl!(far.1)) {
|
||||
zoom_step(app, far.2);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, name, on) in [
|
||||
("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", tl!("Read Mode"), read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 3.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name));
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let focus = tl!("Focus");
|
||||
let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click());
|
||||
// The Focus button is the last of the controls, so it is the leftmost one: the
|
||||
// message has to stop before it (#2402).
|
||||
controls_left = if rtl { r.max.x } else { r.min.x };
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name));
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
let icon_at = pos2(crate::rtl::lead_left(rtl, r, 2.0, 16.0), r.min.y + 2.0);
|
||||
icons::paint(ui.painter(), Rect::from_min_size(icon_at, vec2(16.0, 16.0)), "focus", t.icon, t.accent);
|
||||
let ffont = TypeRung::Caption.regular();
|
||||
let ftrack = TypeRung::Caption.tracking();
|
||||
paint_text(
|
||||
ui.painter(),
|
||||
pos2(crate::rtl::lead_x(rtl, r, 22.0), r.center().y - TypeRung::Caption.line_height() / 2.0),
|
||||
crate::rtl::lead_top(rtl),
|
||||
focus,
|
||||
&ffont,
|
||||
ftrack,
|
||||
t.text_dim,
|
||||
);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.focus", json!({}));
|
||||
}
|
||||
});
|
||||
if let Some((msg, at)) = &app.status_msg
|
||||
&& crate::now_ms() - at < 6000.0
|
||||
{
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
// The room the message has: what is left between here and the controls.
|
||||
let room = if rtl {
|
||||
msg_edge - controls_left - ui.spacing().item_spacing.x
|
||||
} else {
|
||||
controls_left - ui.spacing().item_spacing.x - msg_at.x
|
||||
};
|
||||
let msg_shown = fit_text(ui.painter(), msg, &font, track, t.accent_text, room);
|
||||
if !msg_shown.is_empty() {
|
||||
let w = text_width(ui.painter(), &msg_shown, &font, track, t.accent_text);
|
||||
let at = if rtl { pos2(msg_edge - w - 1.0, msg_at.y) } else { msg_at };
|
||||
let rect = Rect::from_min_size(at, vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
// The whole message is in the tooltip; the bar only has room for the
|
||||
// beginning of it (#2402).
|
||||
let _ = ui.interact(rect, status_message_id(), Sense::hover()).on_hover_text(msg.clone());
|
||||
paint_text(ui.painter(), rect.min, Align2::LEFT_TOP, &msg_shown, &font, track, t.accent_text);
|
||||
ui.allocate_space(vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let focus = tl!("Focus");
|
||||
let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click());
|
||||
// The Focus button is the last of the controls, so it is the leftmost one: the
|
||||
// message has to stop before it (#2402).
|
||||
controls_left = r.min.x;
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent);
|
||||
let ffont = TypeRung::Caption.regular();
|
||||
let ftrack = TypeRung::Caption.tracking();
|
||||
paint_text(
|
||||
ui.painter(),
|
||||
pos2(r.min.x + 22.0, r.center().y - TypeRung::Caption.line_height() / 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
focus,
|
||||
&ffont,
|
||||
ftrack,
|
||||
t.text_dim,
|
||||
);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.focus", json!({}));
|
||||
}
|
||||
});
|
||||
if let Some((msg, at)) = &app.status_msg
|
||||
&& crate::now_ms() - at < 6000.0
|
||||
{
|
||||
let font = TypeRung::Caption.regular();
|
||||
let track = TypeRung::Caption.tracking();
|
||||
// The room the message has: what is left between here and the controls.
|
||||
let room = controls_left - ui.spacing().item_spacing.x - msg_at.x;
|
||||
let msg_shown = fit_text(ui.painter(), msg, &font, track, t.accent_text, room);
|
||||
if !msg_shown.is_empty() {
|
||||
let w = text_width(ui.painter(), &msg_shown, &font, track, t.accent_text);
|
||||
let rect = Rect::from_min_size(msg_at, vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
// The whole message is in the tooltip; the bar only has room for the
|
||||
// beginning of it (#2402).
|
||||
let _ = ui.interact(rect, status_message_id(), Sense::hover()).on_hover_text(msg.clone());
|
||||
paint_text(ui.painter(), rect.min, Align2::LEFT_TOP, &msg_shown, &font, track, t.accent_text);
|
||||
ui.allocate_space(vec2(w + 1.0, TypeRung::Caption.line_height()));
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,3 +307,14 @@ fn clicking_the_autosave_label_toggles_autosave() {
|
||||
assert!(h.state().autosaves(), "and clicking it again turns it back on");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The AutoSave knob is at the right when the switch is on and at the left when it is off; a
|
||||
/// right-to-left interface mirrors that.
|
||||
#[test]
|
||||
fn the_autosave_knob_sits_right_when_on_and_mirrors_right_to_left() {
|
||||
let track = Rect::from_min_max(egui::pos2(100.0, 0.0), egui::pos2(130.0, 16.0));
|
||||
assert_eq!(autosave_knob_x(false, true, track), 122.0, "left to right, on: right");
|
||||
assert_eq!(autosave_knob_x(false, false, track), 108.0, "left to right, off: left");
|
||||
assert_eq!(autosave_knob_x(true, true, track), 108.0, "right to left, on: left");
|
||||
assert_eq!(autosave_knob_x(true, false, track), 122.0, "right to left, off: right");
|
||||
}
|
||||
|
||||
@@ -497,14 +497,14 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_width(330.0);
|
||||
match d.tab {
|
||||
ColorsTab::Standard => standard(ui, d),
|
||||
ColorsTab::Custom => custom(ui, d),
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.add_space(110.0);
|
||||
preview(ui, d, &t);
|
||||
});
|
||||
@@ -512,7 +512,7 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
|
||||
let mut out = None;
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!("OK"))), tl!("Cancel"));
|
||||
if cancelb.clicked() {
|
||||
out = Some(false);
|
||||
@@ -670,7 +670,7 @@ fn custom(ui: &mut Ui, d: &mut ColorsDialog) {
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)), |ui| {
|
||||
ui.label(tl!("Color model:"));
|
||||
craft_ui::ComboBox::from_id_salt("colors_model")
|
||||
.selected_text(match d.model {
|
||||
|
||||
@@ -230,7 +230,7 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
|
||||
// 1970-01-01 was a Thursday: Monday-first column of the 1st.
|
||||
let lead = (first + 3).rem_euclid(7) as u32;
|
||||
let days = wordcraft_engine::cmd::controls::days_in_month(y, m);
|
||||
egui::Grid::new("cc_calendar").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cc_calendar").spacing(vec2(2.0, 2.0)), |ui| {
|
||||
for cell in 0..(lead + days) {
|
||||
if cell < lead {
|
||||
ui.label("");
|
||||
|
||||
@@ -249,27 +249,29 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
);
|
||||
ui.label(RichText::new(summary).small().weak());
|
||||
ui.separator();
|
||||
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 {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.label(RichText::new("·").weak());
|
||||
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 {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.label(RichText::new("·").weak());
|
||||
}
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
ui.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) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
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() {
|
||||
@@ -305,22 +307,24 @@ pub fn models_ui(ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(tl!(h)).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
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();
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
let marks = bookmark_names(&app.session.doc, *by_location, *hidden);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (mut add, mut delete, mut go_to) = (false, false, false);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
let name_label = ui.label(tl!("Bookmark name:"));
|
||||
ui.add(egui::TextEdit::singleline(name).desired_width(240.0)).labelled_by(name_label.id);
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
|
||||
@@ -51,7 +51,7 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.add_space(18.0);
|
||||
let listed = marks.iter().any(|n| n == name.trim());
|
||||
let w = vec2(90.0, 0.0);
|
||||
@@ -61,9 +61,9 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
ui.label(tl!("Sort by:"));
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.radio_value(by_location, false, tl!("Name"));
|
||||
ui.radio_value(by_location, true, tl!("Location"));
|
||||
});
|
||||
@@ -84,7 +84,7 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
close |= ui.button(if *deleted { tl!("Close") } else { tl!("Cancel") }).clicked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false));
|
||||
let shown = place_dialog(ctx, id, window).show(ctx, |ui| {
|
||||
ui.set_min_width(340.0);
|
||||
close = body(app, ui, &mut d);
|
||||
close = crate::rtl::root(ui, |ui| body(app, ui, &mut d));
|
||||
});
|
||||
if let Some(shown) = &shown {
|
||||
if let Dialog::SaveChanges { name, message, .. } = &d {
|
||||
@@ -187,7 +187,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
// Enter and Escape belong to the Colors dialog while it is open above this one.
|
||||
let keys = !crate::colors::is_open(ui.ctx());
|
||||
let (okb, cancelb) = ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, ok)), tl!("Cancel"));
|
||||
@@ -206,7 +206,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
||||
pub(super) fn close_button(ui: &mut Ui) -> bool {
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked();
|
||||
});
|
||||
close
|
||||
|
||||
@@ -198,7 +198,7 @@ pub fn type_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartTypeForm) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let col = accents(app);
|
||||
let mut chosen = None;
|
||||
egui::Grid::new("chart_types").spacing(vec2(8.0, 8.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("chart_types").spacing(vec2(8.0, 8.0)), |ui| {
|
||||
for (k, kind) in ChartType::ALL.into_iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(112.0, 92.0), Sense::click());
|
||||
let on = f.kind == kind;
|
||||
@@ -256,7 +256,7 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool {
|
||||
let mut remove_row = None;
|
||||
let (can_remove_series, can_remove_row) = (f.names.len() > 1, f.categories.len() > 1);
|
||||
egui::ScrollArea::horizontal().id_salt("chart_data_h").max_width(760.0).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized([CELL_W, ROW_H - 4.0], egui::Label::new(egui::RichText::new(tl!("Categories")).strong()));
|
||||
ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover());
|
||||
|
||||
@@ -113,7 +113,7 @@ impl ControlForm {
|
||||
/// Draws the dialog; returns true to close it.
|
||||
pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool {
|
||||
ui.set_width(420.0);
|
||||
egui::Grid::new("cc_props").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cc_props").num_columns(2).spacing([10.0, 6.0]), |ui| {
|
||||
ui.label(tl!("Title"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.title).desired_width(280.0));
|
||||
ui.end_row();
|
||||
@@ -163,7 +163,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool {
|
||||
let mut done = false;
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
// #1906: no forced label color; the theme button text reads on the theme fill.
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.button(tl!("OK")), tl!("Cancel"));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
@@ -184,7 +184,7 @@ fn entries(ui: &mut Ui, f: &mut ControlForm) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("List entries")).font(semibold(12.0)));
|
||||
let mut remove = None;
|
||||
egui::Grid::new("cc_entries").num_columns(3).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cc_entries").num_columns(3).spacing([6.0, 4.0]), |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Display text")).small());
|
||||
ui.label(egui::RichText::new(tl!("Value")).small());
|
||||
ui.end_row();
|
||||
|
||||
@@ -99,7 +99,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) ->
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!(cancel));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(super) fn drop_cap_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DropCapForm) -
|
||||
let preview = app.previews.font_preview_fn();
|
||||
let unit = crate::options::unit();
|
||||
ui.add_enabled_ui(f.position != "none", |ui| {
|
||||
egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Font:"));
|
||||
if let Some(v) = crate::widgets::font_combo(ui, "dropcap_font", 190.0, &f.font, &fams, Some(&*preview))
|
||||
&& v != crate::widgets::ADD_FONT
|
||||
@@ -380,11 +380,11 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) ->
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("Width and spacing")).font(semibold(12.5)));
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
let mut changed = None;
|
||||
egui::ScrollArea::vertical().id_salt("columns_widths").max_height(130.0).show(ui, |ui| {
|
||||
egui::Grid::new("columns_grid").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("columns_grid").num_columns(3).spacing(vec2(12.0, 4.0)), |ui| {
|
||||
ui.label(tl!("Col #:"));
|
||||
ui.label(tl!("Width:"));
|
||||
ui.label(tl!("Spacing:"));
|
||||
@@ -416,7 +416,7 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) ->
|
||||
f.rebalance(i);
|
||||
}
|
||||
let mut equal = f.equal;
|
||||
if ui.add_enabled(f.count > 1, egui::Checkbox::new(&mut equal, tl!("Equal column width"))).changed() {
|
||||
if ui.add_enabled_ui(f.count > 1, |ui| ui.checkbox(&mut equal, tl!("Equal column width"))).inner.changed() {
|
||||
f.equal = equal;
|
||||
if equal {
|
||||
f.even_out();
|
||||
@@ -424,7 +424,7 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) ->
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Preview"));
|
||||
columns_preview(ui, f, &t);
|
||||
});
|
||||
|
||||
@@ -264,9 +264,9 @@ fn merge_field_name(code: &str) -> String {
|
||||
pub(super) fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_width(620.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
// Left: category and field names.
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_width(200.0);
|
||||
ui.label(egui::RichText::new(tl!("Pick a field from the list")).font(semibold(12.5)));
|
||||
ui.label(tl!("Categories:"));
|
||||
@@ -293,7 +293,7 @@ pub(super) fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> boo
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
// Right: the field's options, or its code.
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Field properties")).font(semibold(12.5)));
|
||||
if let Some((_, _, desc)) = FIELDS.iter().find(|(n, _, _)| *n == f.name) {
|
||||
ui.label(egui::RichText::new(format!("{} {}", tl!("Description:"), tl!(desc))).small().color(t.text_dim));
|
||||
|
||||
@@ -388,7 +388,7 @@ pub(super) fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> b
|
||||
ui.horizontal(|ui| {
|
||||
let name = f.selected.map(|c| format!("U+{:04X}", c as u32)).unwrap_or_default();
|
||||
ui.label(egui::RichText::new(name).font(semibold(12.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.label(tl!("from Unicode (hex)"));
|
||||
let resp = ui.add(egui::TextEdit::singleline(&mut f.code).desired_width(70.0).font(egui::TextStyle::Monospace));
|
||||
if resp.changed() {
|
||||
@@ -427,7 +427,7 @@ pub(super) fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> b
|
||||
fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, insert: &mut Option<(Value, String, String)>, t: &Tokens) -> bool {
|
||||
let mut double = false;
|
||||
egui::ScrollArea::vertical().id_salt("special_chars").max_height(CELL * GRID_ROWS as f32 + 60.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)), |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Character")).font(semibold(12.0)));
|
||||
ui.label("");
|
||||
ui.label(egui::RichText::new(tl!("Shortcut key:")).font(semibold(12.0)));
|
||||
|
||||
@@ -200,8 +200,8 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("No matching commands")).weak());
|
||||
}
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Categories:"));
|
||||
if let Some(c) = list(ui, "kb_categories", vec2(left, 170.0), &cats, &f.category) {
|
||||
f.category = c;
|
||||
@@ -210,7 +210,7 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
f.current.clear();
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Commands:"));
|
||||
if let Some(c) = list(ui, "kb_commands", vec2(right, 170.0), &cmds, &f.command) {
|
||||
f.command = c;
|
||||
@@ -225,15 +225,15 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
f.current = keys.first().cloned().unwrap_or_default();
|
||||
}
|
||||
let mut close = false;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Keys for this command:"));
|
||||
let rows: Vec<(String, String)> = keys.iter().map(|k| (k.clone(), key_text(k))).collect();
|
||||
if let Some(k) = list(ui, "kb_current", vec2(left, 70.0), &rows, &f.current) {
|
||||
f.current = k;
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("New shortcut:"));
|
||||
close = capture(ui, f);
|
||||
ui.add_space(4.0);
|
||||
@@ -280,7 +280,7 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
|
||||
if ui.add_enabled(!app.session.keymap.is_empty(), egui::Button::new(tl!("Reset All…"))).clicked() {
|
||||
f.confirm_reset = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!("Cancel"));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool
|
||||
(changed && v != was).then_some(v)
|
||||
};
|
||||
heading(ui, "Size");
|
||||
egui::Grid::new("size_lengths").num_columns(4).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("size_lengths").num_columns(4).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
if let Some(h) = length(ui, "Height:", f.height) {
|
||||
f.set_height(h);
|
||||
}
|
||||
@@ -175,7 +175,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool
|
||||
});
|
||||
heading(ui, "Scale");
|
||||
let (sw, sh) = f.scale();
|
||||
egui::Grid::new("size_scale").num_columns(4).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("size_scale").num_columns(4).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
if let Some(p) = percent(ui, "Height:", sh) {
|
||||
f.set_scale_height(p);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool
|
||||
ui.end_row();
|
||||
});
|
||||
ui.checkbox(&mut f.lock, tl!("Lock aspect ratio"));
|
||||
ui.add_enabled(f.original.is_some(), egui::Checkbox::new(&mut f.relative, tl!("Relative to original picture size")));
|
||||
ui.add_enabled_ui(f.original.is_some(), |ui| ui.checkbox(&mut f.relative, tl!("Relative to original picture size")));
|
||||
if let Some((w, h)) = f.uncropped {
|
||||
heading(ui, "Original size");
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(super) fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm)
|
||||
ui.add_space(8.0);
|
||||
let (mut add, mut new, mut cancel) = (false, false, false);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
new = ui.add_enabled(ready, egui::Button::new(tl!("New Document"))).clicked();
|
||||
add = ui.add_enabled(ready, crate::widgets::primary_button(ui, tl!("Add to Document"))).clicked();
|
||||
|
||||
@@ -126,7 +126,7 @@ pub(super) fn sort_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SortForm) -> bool
|
||||
ui.selectable_value(&mut f.column, i, label);
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.radio_value(&mut f.descending, false, tl!("Ascending"));
|
||||
ui.radio_value(&mut f.descending, true, tl!("Descending"));
|
||||
});
|
||||
@@ -253,7 +253,7 @@ fn alert(ui: &mut Ui, text: &Option<String>) {
|
||||
pub(super) fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsForm) -> bool {
|
||||
// Several cells split one by one keep their rows.
|
||||
let rows_on = f.cells <= 1 || f.merge_first;
|
||||
egui::Grid::new("split_cells").num_columns(2).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("split_cells").num_columns(2), |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(&mut f.columns).range(1..=63));
|
||||
ui.end_row();
|
||||
@@ -262,7 +262,7 @@ pub(super) fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsF
|
||||
ui.end_row();
|
||||
});
|
||||
let mut merge = f.merge_first;
|
||||
if ui.add_enabled(f.several, egui::Checkbox::new(&mut merge, tl!("Merge cells before split"))).changed() {
|
||||
if ui.add_enabled_ui(f.several, |ui| ui.checkbox(&mut merge, tl!("Merge cells before split"))).inner.changed() {
|
||||
f.set_merge(merge);
|
||||
}
|
||||
alert(ui, &f.error);
|
||||
@@ -379,7 +379,7 @@ impl TextToTableForm {
|
||||
pub(super) fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) -> bool {
|
||||
let before = (f.separator.clone(), f.other_char.clone(), f.columns);
|
||||
heading(ui, "Table size");
|
||||
egui::Grid::new("text_to_table").num_columns(2).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("text_to_table").num_columns(2), |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(&mut f.columns).range(1..=63));
|
||||
ui.end_row();
|
||||
@@ -395,7 +395,7 @@ pub(super) fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTab
|
||||
ui.radio_value(&mut f.autofit, "contents".to_string(), tl!("AutoFit to contents"));
|
||||
ui.radio_value(&mut f.autofit, "window".to_string(), tl!("AutoFit to window"));
|
||||
heading(ui, "Separate text at");
|
||||
egui::Grid::new("text_to_table_sep").num_columns(2).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("text_to_table_sep").num_columns(2), |ui| {
|
||||
ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraphs"));
|
||||
ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas"));
|
||||
ui.end_row();
|
||||
|
||||
@@ -50,7 +50,7 @@ impl LineNumbersForm {
|
||||
pub(super) fn line_numbers_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LineNumbersForm) -> bool {
|
||||
ui.checkbox(&mut f.on, tl!("Add line numbering"));
|
||||
ui.add_enabled_ui(f.on, |ui| {
|
||||
egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Start at:"));
|
||||
ui.add(egui::DragValue::new(&mut f.start).range(1..=32_767));
|
||||
ui.end_row();
|
||||
@@ -112,7 +112,7 @@ impl HyphenationForm {
|
||||
pub(super) fn hyphenation_ui(app: &mut WordApp, ui: &mut Ui, f: &mut HyphenationForm) -> bool {
|
||||
ui.checkbox(&mut f.auto, tl!("Automatically hyphenate document"));
|
||||
ui.checkbox(&mut f.caps, tl!("Hyphenate words in CAPS"));
|
||||
egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Hyphenation zone:"));
|
||||
ui.add(egui::DragValue::new(&mut f.zone).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).measure(unit()).max_decimals(2));
|
||||
ui.end_row();
|
||||
@@ -236,7 +236,7 @@ pub(super) fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> b
|
||||
ui.add_space(8.0);
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -171,8 +171,8 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||||
};
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Click level to modify:"));
|
||||
for i in 0..LEVELS {
|
||||
if ui.selectable_label(f.level == i, format!("{}", i + 1)).clicked() {
|
||||
@@ -180,7 +180,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
|
||||
preview(ui, f);
|
||||
});
|
||||
@@ -189,7 +189,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
let styles = f.styles.clone();
|
||||
let Some(l) = f.levels.get_mut(i) else { return true };
|
||||
heading(ui, "Number format");
|
||||
egui::Grid::new("mll_number").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("mll_number").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Enter formatting for number:"));
|
||||
ui.text_edit_singleline(&mut l.text);
|
||||
ui.end_row();
|
||||
@@ -257,7 +257,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Position");
|
||||
egui::Grid::new("mll_position").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("mll_position").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Number alignment:"));
|
||||
ui.horizontal(|ui| {
|
||||
for (v, s) in [("left", "Left"), ("center", "Center"), ("right", "Right")] {
|
||||
@@ -283,7 +283,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool {
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.add_enabled(l.follow == "tab", egui::Checkbox::new(&mut l.tab_on, tl!("Add tab stop at:")));
|
||||
ui.add_enabled_ui(l.follow == "tab", |ui| ui.checkbox(&mut l.tab_on, tl!("Add tab stop at:")));
|
||||
ui.add_enabled(l.follow == "tab" && l.tab_on, len(&mut l.tab_at, unit));
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -455,11 +455,13 @@ fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_lab
|
||||
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
|
||||
let label = if c.is_some() { value.clone() } else { tl!(none_label).to_string() };
|
||||
ui.menu_button(label, |ui| {
|
||||
if ui.button(tl!(none_label)).clicked() {
|
||||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::field_menu(ui, theme, id, value);
|
||||
crate::rtl::root(ui, |ui| {
|
||||
if ui.button(tl!(none_label)).clicked() {
|
||||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::field_menu(ui, theme, id, value);
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -510,7 +512,7 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||||
};
|
||||
heading(ui, "Show");
|
||||
egui::Grid::new("tco_show").num_columns(2).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tco_show").num_columns(2).spacing(vec2(16.0, 4.0)), |ui| {
|
||||
ui.checkbox(&mut f.comments, tl!("Comments"));
|
||||
ui.checkbox(&mut f.ink, tl!("Ink"));
|
||||
ui.end_row();
|
||||
@@ -531,7 +533,7 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
ui.radio_value(&mut f.comment_display, v.to_string(), tl!(l));
|
||||
}
|
||||
heading(ui, "Advanced");
|
||||
egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Insertions:"));
|
||||
choice(ui, "tco_ins", &mut f.insert_mark, INSERT_MARKS);
|
||||
color(ui, &theme, &mut f.insert_color, "By author", None);
|
||||
@@ -551,7 +553,7 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Moves");
|
||||
egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Moved from:"));
|
||||
choice(ui, "tco_move_from", &mut f.move_from_mark, DELETE_MARKS);
|
||||
ui.push_id("tco_move_from_color", |ui| color(ui, &theme, &mut f.move_from_color, "By author", None));
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
};
|
||||
heading(ui, "Location");
|
||||
let mut convert = None;
|
||||
egui::Grid::new("notes_location").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("notes_location").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.radio_value(&mut f.endnote, false, tl!("Footnotes"));
|
||||
ui.add_enabled_ui(!f.endnote, |ui| {
|
||||
choice(ui, "notes_fpos", &mut f.pos[0], &[("pageBottom", "Bottom of Page"), ("beneathText", "Below text")], true);
|
||||
@@ -99,12 +99,15 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.menu_button(format!("{}…", tl!("Convert")), |ui| {
|
||||
for (to, l) in [("endnote", "Footnotes to endnotes"), ("footnote", "Endnotes to footnotes"), ("swap", "Swap footnotes and endnotes")] {
|
||||
if ui.button(tl!(l)).clicked() {
|
||||
convert = Some(to);
|
||||
ui.close();
|
||||
crate::rtl::root(ui, |ui| {
|
||||
for (to, l) in [("endnote", "Footnotes to endnotes"), ("footnote", "Endnotes to footnotes"), ("swap", "Swap footnotes and endnotes")]
|
||||
{
|
||||
if ui.button(tl!(l)).clicked() {
|
||||
convert = Some(to);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -115,7 +118,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
}
|
||||
heading(ui, "Format");
|
||||
let k = usize::from(f.endnote);
|
||||
egui::Grid::new("notes_format").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("notes_format").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Number format"));
|
||||
if let Some(v) = f.fmt.get_mut(k) {
|
||||
choice(ui, "notes_fmt", v, &FORMATS, false);
|
||||
@@ -147,7 +150,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
let (mut insert, mut apply, mut cancel) = (false, false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let label = if f.endnote { "Insert Endnote" } else { "Insert Footnote" };
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if cfg!(target_os = "macos") {
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
|
||||
let u = unit();
|
||||
let k = u.pt_per_unit();
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("New")).clicked() {
|
||||
f.new_stop();
|
||||
@@ -283,7 +283,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
|
||||
f.set_leader(i, l);
|
||||
pick = Some(i);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.add_space(10.0); // clear of the scrollbar
|
||||
if ui.add(egui::Button::new("×").small().frame(false)).on_hover_text(tl!("Clear")).clicked() {
|
||||
remove = Some(i);
|
||||
@@ -320,7 +320,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_min_width(130.0);
|
||||
let default_label = ui.label(tl!("Default tab stops:"));
|
||||
ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2))
|
||||
@@ -617,8 +617,8 @@ impl From<bool> for Target {
|
||||
/// Setting, style, colour and width on the left; the preview with its side toggles on the right.
|
||||
fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rgb>, target: Target) {
|
||||
let between = target == Target::Paragraph;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Setting:")).font(semibold(12.5)));
|
||||
for (s, label) in
|
||||
[(Setting::None, "None"), (Setting::Box, "Box"), (Setting::Shadow, "Shadow"), (Setting::ThreeD, "3-D"), (Setting::Custom, "Custom")]
|
||||
@@ -629,7 +629,7 @@ fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rg
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Style:"));
|
||||
let current = STYLES.iter().find(|(s, _)| *s == l.style).map(|(_, n)| *n).unwrap_or("Single");
|
||||
craft_ui::ComboBox::from_id_salt((id, "style")).selected_text(tl!(current)).width(120.0).show_ui(ui, |ui| {
|
||||
@@ -650,7 +650,7 @@ fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rg
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Preview")).font(semibold(12.5)));
|
||||
if let Some(side) = preview(ui, l, fill, target) {
|
||||
l.toggle(side);
|
||||
|
||||
@@ -105,7 +105,7 @@ pub(super) fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) ->
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let mut then = None;
|
||||
egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let label_label = ui.label(tl!("Label:"));
|
||||
let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect();
|
||||
let combo = craft_ui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
|
||||
@@ -150,7 +150,7 @@ pub(super) fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) ->
|
||||
/// Draws the caption numbering format, chapter-number toggle and chapter level and separator
|
||||
/// controls, returning to the caption dialog with the edited or original form.
|
||||
pub(super) fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next {
|
||||
egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let format_label = ui.label(tl!("Format:"));
|
||||
let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
|
||||
choice(ui, "cap_fmt", &mut f.format, &opts, &format_label);
|
||||
@@ -158,7 +158,7 @@ pub(super) fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm)
|
||||
});
|
||||
ui.checkbox(&mut f.chapter, tl!("Add the chapter number"));
|
||||
ui.add_enabled_ui(f.chapter, |ui| {
|
||||
egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let chapter_label = ui.label(tl!("Chapters begin at:"));
|
||||
let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect();
|
||||
let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default();
|
||||
|
||||
@@ -65,7 +65,7 @@ impl CrossRefForm {
|
||||
/// Insert.
|
||||
pub(super) fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next {
|
||||
let was = f.to.clone();
|
||||
egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let type_label = ui.label(tl!("Reference type:"));
|
||||
let types = ref_types(app);
|
||||
let types: Vec<(String, &str)> = types.iter().map(|(v, l)| (v.clone(), l.as_str())).collect();
|
||||
|
||||
@@ -68,7 +68,7 @@ pub(super) fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Nex
|
||||
};
|
||||
sample(ui, &lines, f.right_align, leader_char(&f.leader));
|
||||
ui.add_space(6.0);
|
||||
egui::Grid::new("idx").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("idx").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Type:"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut f.kind, "indented".to_string(), tl!("Indented"));
|
||||
@@ -153,7 +153,7 @@ impl MarkForm {
|
||||
/// and Mark All running `references.markEntry`.
|
||||
pub(super) fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next {
|
||||
heading(ui, "Index");
|
||||
egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let entry_label = ui.label(tl!("Main entry:"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)).labelled_by(entry_label.id);
|
||||
ui.end_row();
|
||||
@@ -191,7 +191,7 @@ pub(super) fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next
|
||||
let mut close = false;
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ impl SourceForm {
|
||||
/// Draws a source's type and fields, optionally every bibliography field, and adds it as a
|
||||
/// bibliography source or inserts a citation on OK.
|
||||
pub(super) fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next {
|
||||
egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)), |ui| {
|
||||
let kind_label = ui.label(tl!("Source type:"));
|
||||
let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default();
|
||||
let combo = craft_ui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
@@ -286,7 +286,7 @@ pub(super) fn sources_ui(app: &mut WordApp, ui: &mut Ui, selected: &mut Option<u
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
next = Next::Close;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ pub(super) fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, wa
|
||||
// A solid scroll bar takes its own room beside the list instead of floating over the level boxes.
|
||||
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
|
||||
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
|
||||
egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)), |ui| {
|
||||
for (name, level) in f.style_levels.iter_mut() {
|
||||
// Built-in styles read in the interface language; the document keeps the English name.
|
||||
let shown = app.session.doc.styles.styles.iter().find(|st| st.name == *name).map_or_else(|| name.clone(), crate::i18n::style_name);
|
||||
|
||||
@@ -67,7 +67,7 @@ fn buttons(ui: &mut Ui, ok_enabled: bool) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(
|
||||
ui,
|
||||
|ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK"))),
|
||||
@@ -197,8 +197,8 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
if !browse {
|
||||
ui.label(egui::RichText::new(tl!("Comparing with another file needs the desktop app.")).small().weak());
|
||||
}
|
||||
egui::Grid::new("compare_files").num_columns(2).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("compare_files").num_columns(2).spacing(vec2(16.0, 4.0)), |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
heading(ui, "Original document");
|
||||
ui.radio_value(&mut f.original_current, true, tl!("This document"));
|
||||
ui.horizontal(|ui| {
|
||||
@@ -209,7 +209,7 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
heading(ui, "Revised document");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.revised).desired_width(170.0));
|
||||
@@ -230,7 +230,7 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
}
|
||||
if f.more {
|
||||
heading(ui, "Comparison settings");
|
||||
egui::Grid::new("compare_settings").num_columns(2).spacing(vec2(16.0, 2.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("compare_settings").num_columns(2).spacing(vec2(16.0, 2.0)), |ui| {
|
||||
for (i, (_, label)) in COMPARE_SETTINGS.iter().enumerate() {
|
||||
if let Some(on) = f.settings.get_mut(i) {
|
||||
ui.checkbox(on, tl!(label));
|
||||
@@ -245,13 +245,13 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool {
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
egui::Grid::new("compare_show").num_columns(2).spacing(vec2(24.0, 2.0)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("compare_show").num_columns(2).spacing(vec2(24.0, 2.0)), |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
heading(ui, "Show changes at:");
|
||||
ui.radio_value(&mut f.character_level, false, tl!("Word level"));
|
||||
ui.radio_value(&mut f.character_level, true, tl!("Character level"));
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
heading(ui, "Show changes in:");
|
||||
for (v, l) in [("original", "Original document"), ("revised", "Revised document"), ("new", "New document")] {
|
||||
ui.radio_value(&mut f.show_in, v.to_string(), tl!(l));
|
||||
@@ -445,7 +445,7 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
ui.checkbox(&mut o.cap_days, tl!("Capitalize names of days"));
|
||||
ui.checkbox(&mut o.caps_lock, tl!("Correct accidental usage of cAPS LOCK key"));
|
||||
ui.checkbox(&mut o.replace_text, tl!("Replace text as you type"));
|
||||
egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)), |ui| {
|
||||
let replace_label = ui.label(tl!("Replace:"));
|
||||
let with_label = ui.label(tl!("With:"));
|
||||
ui.end_row();
|
||||
@@ -462,7 +462,7 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
// #3036: the filter matches nothing — say so instead of showing an empty box.
|
||||
ui.label(egui::RichText::new(tl!("(none)")).small().color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true), |ui| {
|
||||
for (a, b, _) in list.iter().filter(|(a, _, _)| typed.is_empty() || a.to_lowercase().starts_with(&typed)).take(500) {
|
||||
let selected = f.from == *a;
|
||||
if ui.add_sized(vec2(110.0, 16.0), egui::Button::selectable(selected, a.as_str())).clicked() {
|
||||
@@ -543,7 +543,7 @@ fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
});
|
||||
let filter = f.math_filter.trim().trim_start_matches('\\').to_string();
|
||||
egui::ScrollArea::vertical().id_salt("ac_math").max_height(240.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("ac_math_grid").num_columns(2).spacing(vec2(16.0, 1.0)).striped(true).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("ac_math_grid").num_columns(2).spacing(vec2(16.0, 1.0)).striped(true), |ui| {
|
||||
for (name, c) in wordcraft_doc::math_symbols::SYMBOLS.iter().filter(|(n, _)| filter.is_empty() || n.starts_with(filter.as_str())) {
|
||||
ui.label(format!("\\{name}"));
|
||||
ui.label(c.to_string());
|
||||
@@ -662,7 +662,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) {
|
||||
let inside = app.session.doc.perm_ranges().iter().any(|r| r.everyone() && r.start <= a && b <= r.end && r.start.story == a.story);
|
||||
let mut everyone = inside;
|
||||
let can = inside || a != b;
|
||||
if ui.add_enabled(can, egui::Checkbox::new(&mut everyone, tl!("Everyone"))).changed() {
|
||||
if ui.add_enabled_ui(can, |ui| ui.checkbox(&mut everyone, tl!("Everyone"))).inner.changed() {
|
||||
let p = if everyone { json!({"exception": "add"}) } else { json!({"exception": "remove"}) };
|
||||
if let Err(e) = app.run("review.restrict", p) {
|
||||
st.message = e;
|
||||
|
||||
@@ -108,10 +108,10 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b
|
||||
if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("No matching commands")).weak());
|
||||
}
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5)));
|
||||
ui.horizontal_top(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) {
|
||||
f.category = c;
|
||||
f.filter.clear();
|
||||
@@ -163,7 +163,7 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b
|
||||
if ui.add_enabled(custom, egui::Button::new(tl!("Reset…"))).clicked() {
|
||||
f.confirm_reset = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
@@ -219,7 +219,7 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
let sel = f.selected.clone();
|
||||
let tab_custom = app.session.ribbon.tabs().iter().any(|t| t.name == sel.tab && t.custom);
|
||||
// Add >> / << Remove.
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
let can_add = sel.group.is_some() && !f.command.is_empty();
|
||||
let add = ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0)));
|
||||
@@ -245,11 +245,11 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Ribbon tabs")).font(semibold(12.5)));
|
||||
ui.horizontal_top(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
tree(app, ui, f, &rows, h);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
for (text, by) in [("Up", -1), ("Down", 1)] {
|
||||
if ui.add(egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() {
|
||||
@@ -354,7 +354,7 @@ fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
if !ids.contains(&f.qat_selected) {
|
||||
f.qat_selected = ids.first().cloned().unwrap_or_default();
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
let can_add = !f.command.is_empty() && !ids.contains(&f.command);
|
||||
if ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0))).clicked()
|
||||
@@ -367,14 +367,14 @@ fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Quick Access Toolbar")).font(semibold(12.5)));
|
||||
ui.horizontal_top(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
let rows: Vec<(String, String)> = ids.iter().map(|id| (id.clone(), label_of(app, id))).collect();
|
||||
if let Some(id) = list(ui, "rb_qat", vec2(250.0, h), &rows, &f.qat_selected) {
|
||||
f.qat_selected = id;
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.add_space(h / 2.0 - 10.0);
|
||||
for (text, by) in [("Up", -1), ("Down", 1)] {
|
||||
if ui.add_enabled(!f.qat_selected.is_empty(), egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() {
|
||||
|
||||
@@ -159,7 +159,7 @@ const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0;
|
||||
fn picker_row(ui: &mut Ui, columns: impl FnOnce(&mut Ui)) {
|
||||
ui.scope(|ui| {
|
||||
ui.set_max_height(PICKER_HEIGHT);
|
||||
ui.horizontal_top(columns);
|
||||
crate::rtl::horizontal_top(ui, columns);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
let mut chosen = None;
|
||||
picker_row(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_width(120.0);
|
||||
for cat in CATEGORIES {
|
||||
if ui.selectable_label(f.category == cat, tl!(cat)).clicked() {
|
||||
@@ -183,10 +183,10 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_width(3.0 * 96.0 + 16.0);
|
||||
ui.set_min_height(2.0 * 96.0 + 8.0);
|
||||
egui::Grid::new("smart_art_layouts").spacing(vec2(8.0, 8.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("smart_art_layouts").spacing(vec2(8.0, 8.0)), |ui| {
|
||||
for (k, layout) in f.shown().into_iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 96.0), Sense::click());
|
||||
let on = f.layout == layout;
|
||||
@@ -241,7 +241,7 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_width(200.0);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(200.0, 150.0), Sense::hover());
|
||||
ui.painter().rect(rect, 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
|
||||
@@ -469,7 +469,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
|
||||
.collect();
|
||||
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
|
||||
let mut rebased = false;
|
||||
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let name_label = ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name).labelled_by(name_label.id);
|
||||
ui.end_row();
|
||||
@@ -567,7 +567,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.label(tl!("Alignment:"));
|
||||
egui::Grid::new("tstyle_align").spacing(vec2(1.0, 1.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tstyle_align").spacing(vec2(1.0, 1.0)), |ui| {
|
||||
for (i, ((n, v, h), tip)) in CELL_ALIGNS.iter().zip(ALIGN_TIPS).enumerate() {
|
||||
let on = r.align == *n;
|
||||
if align_button(ui, (*v, *h), on, tl!(tip)).clicked() {
|
||||
|
||||
@@ -181,8 +181,8 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool
|
||||
let mut rename = false;
|
||||
let mut browse = false;
|
||||
let left_builtin = mine.iter().find(|x| s(x, "id") == f.left).is_none_or(|x| x.get("builtIn").and_then(Value::as_bool).unwrap_or(true));
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Document")).font(semibold(12.5)));
|
||||
list_frame(ui, "organizer_mine", &mut |ui| {
|
||||
for st in &mine {
|
||||
@@ -194,14 +194,16 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.add_space(60.0);
|
||||
let w = vec2(96.0, 0.0);
|
||||
copy = ui.add_enabled(!f.right.is_empty(), egui::Button::new(format!("◀ {}", tl!("Copy"))).min_size(w)).clicked();
|
||||
// Point at the Document column (destination of copyFrom): left in LTR, right in RTL.
|
||||
let copy_arrow = if crate::rtl::flows(ui) { "▶" } else { "◀" };
|
||||
copy = ui.add_enabled(!f.right.is_empty(), egui::Button::new(format!("{copy_arrow} {}", tl!("Copy"))).min_size(w)).clicked();
|
||||
delete = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Delete")).min_size(w)).clicked();
|
||||
rename = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Rename…")).min_size(w)).clicked();
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
let name = std::path::Path::new(&f.file).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "—".into());
|
||||
ui.label(egui::RichText::new(name).font(semibold(12.5))).on_hover_text(f.file.as_str());
|
||||
list_frame(ui, "organizer_other", &mut |ui| {
|
||||
@@ -254,7 +256,7 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,81 +137,109 @@ fn font_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if *strike {
|
||||
fmt.strikethrough = egui::Stroke::new(1.0, preview_color);
|
||||
}
|
||||
egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::Table::new("fontdlg").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| {
|
||||
// Word's two font boxes when East Asian text is in use: the Latin text font and
|
||||
// the Asian text font, each with its own list (type to find a font).
|
||||
let prev = app.previews.font_preview_fn();
|
||||
if *asian {
|
||||
ui.label(tl!("Asian text font:"));
|
||||
let fams = app.previews.east_asian_families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font_ea = f;
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Asian text font:")));
|
||||
r.cell(|ui| {
|
||||
let fams = app.previews.east_asian_families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font_ea = f;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") }));
|
||||
r.cell(|ui| {
|
||||
let fams = app.previews.families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font = f;
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") });
|
||||
let fams = app.previews.families();
|
||||
if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) {
|
||||
if f == crate::widgets::ADD_FONT {
|
||||
let _ = app.run("font.add", json!({}));
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
let _ = app.run("font.loadSystem", json!({}));
|
||||
} else {
|
||||
*font = f;
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
let size_label = ui.label(tl!("Size:"));
|
||||
ui.text_edit_singleline(size).labelled_by(size_label.id);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Font color:"));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
color_field(ui, &theme, color, "Automatic", "auto");
|
||||
ui.end_row();
|
||||
let spacing_label = ui.label(tl!("Spacing (pt):"));
|
||||
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id);
|
||||
ui.end_row();
|
||||
let label = tl!("Kerning for fonts:");
|
||||
match kern {
|
||||
Some(on) => {
|
||||
ui.checkbox(on, label);
|
||||
}
|
||||
None => {
|
||||
if ui.add(egui::Checkbox::new(&mut false, label).indeterminate(true)).clicked() {
|
||||
*kern = Some(true);
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let size_label = r.cell(|ui| ui.label(tl!("Size:")));
|
||||
r.cell(|ui| ui.text_edit_singleline(size).labelled_by(size_label.id));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Font color:")));
|
||||
r.cell(|ui| {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
color_field(ui, &theme, color, "Automatic", "auto");
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let spacing_label = r.cell(|ui| ui.label(tl!("Spacing (pt):")));
|
||||
r.cell(|ui| ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id));
|
||||
});
|
||||
t.row(|r| {
|
||||
let label = tl!("Kerning for fonts:");
|
||||
r.cell(|ui| match kern {
|
||||
Some(on) => {
|
||||
ui.checkbox(on, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let points = ui.add_enabled(*kern == Some(true), egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1));
|
||||
let points_label = ui.label(tl!("Points and above"));
|
||||
points.labelled_by(points_label.id);
|
||||
None => {
|
||||
if ui.add(egui::Checkbox::new(&mut false, label).indeterminate(true)).clicked() {
|
||||
*kern = Some(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
r.cell(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let enabled = *kern == Some(true);
|
||||
let field = egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1);
|
||||
// The number comes first in the reading order, then "points and above"; a left-to-right
|
||||
// row in a right-to-left interface runs the other way, so there the label is first.
|
||||
if crate::rtl::on() && !crate::rtl::flows(ui) {
|
||||
let points_label = ui.label(tl!("Points and above"));
|
||||
ui.add_enabled(enabled, field).labelled_by(points_label.id);
|
||||
} else {
|
||||
let points = ui.add_enabled(enabled, field);
|
||||
let points_label = ui.label(tl!("Points and above"));
|
||||
points.labelled_by(points_label.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Effects")).font(semibold(12.5)));
|
||||
egui::Grid::new("fx").num_columns(2).show(ui, |ui| {
|
||||
ui.checkbox(bold, tl!("Bold"));
|
||||
ui.checkbox(italic, tl!("Italic"));
|
||||
ui.end_row();
|
||||
ui.checkbox(underline, tl!("Underline"));
|
||||
ui.checkbox(strike, tl!("Strikethrough"));
|
||||
ui.end_row();
|
||||
ui.checkbox(sup, tl!("Superscript"));
|
||||
ui.checkbox(sub, tl!("Subscript"));
|
||||
ui.end_row();
|
||||
ui.checkbox(small_caps, tl!("Small caps"));
|
||||
ui.checkbox(caps, tl!("All caps"));
|
||||
ui.end_row();
|
||||
ui.checkbox(hidden, tl!("Hidden"));
|
||||
ui.end_row();
|
||||
crate::rtl::Table::new("fx").columns(2).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(bold, tl!("Bold")));
|
||||
r.cell(|ui| ui.checkbox(italic, tl!("Italic")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(underline, tl!("Underline")));
|
||||
r.cell(|ui| ui.checkbox(strike, tl!("Strikethrough")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(sup, tl!("Superscript")));
|
||||
r.cell(|ui| ui.checkbox(sub, tl!("Subscript")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(small_caps, tl!("Small caps")));
|
||||
r.cell(|ui| ui.checkbox(caps, tl!("All caps")));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.checkbox(hidden, tl!("Hidden")));
|
||||
});
|
||||
});
|
||||
// Live preview of the chosen font/size/colour/effects.
|
||||
ui.separator();
|
||||
@@ -286,27 +314,31 @@ fn paragraph_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
|
||||
// Mirrored indents are the inside and outside ones (they swap on even pages).
|
||||
let (l_label, r_label) = if opts[0] { ("Inside:", "Outside:") } else { ("Left:", "Right:") };
|
||||
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
|
||||
let left_label = ui.label(tl!(l_label));
|
||||
para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label);
|
||||
let right_label = ui.label(tl!(r_label));
|
||||
para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label);
|
||||
ui.end_row();
|
||||
let first_label = ui.label(tl!("First line:"));
|
||||
para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label);
|
||||
ui.end_row();
|
||||
crate::rtl::Table::new("ind").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let left_label = r.cell(|ui| ui.label(tl!(l_label)));
|
||||
r.cell(|ui| para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label));
|
||||
let right_label = r.cell(|ui| ui.label(tl!(r_label)));
|
||||
r.cell(|ui| para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label));
|
||||
});
|
||||
t.row(|r| {
|
||||
let first_label = r.cell(|ui| ui.label(tl!("First line:")));
|
||||
r.cell(|ui| para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label));
|
||||
});
|
||||
});
|
||||
para_check(ui, &mut opts[0], tl!("Mirror indents"), "mirrorIndents", mixed);
|
||||
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
|
||||
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
|
||||
let before_label = ui.label(tl!("Before:"));
|
||||
para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label);
|
||||
let line_label = ui.label(tl!("Line spacing:"));
|
||||
para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label);
|
||||
ui.end_row();
|
||||
let after_label = ui.label(tl!("After:"));
|
||||
para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label);
|
||||
ui.end_row();
|
||||
crate::rtl::Table::new("sp").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let before_label = r.cell(|ui| ui.label(tl!("Before:")));
|
||||
r.cell(|ui| para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label));
|
||||
let line_label = r.cell(|ui| ui.label(tl!("Line spacing:")));
|
||||
r.cell(|ui| para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label));
|
||||
});
|
||||
t.row(|r| {
|
||||
let after_label = r.cell(|ui| ui.label(tl!("After:")));
|
||||
r.cell(|ui| para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label));
|
||||
});
|
||||
});
|
||||
para_check(ui, &mut opts[1], tl!("Don't add space between paragraphs of the same style"), "contextualSpacing", mixed);
|
||||
}
|
||||
@@ -387,8 +419,8 @@ fn find_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
// Word greys out Match case and the word options with wildcards (they always match case).
|
||||
let plain = !extra.wildcards;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(plain, egui::Checkbox::new(match_case, tl!("Match case")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(whole_word, tl!("Whole words")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(match_case, tl!("Match case")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(whole_word, tl!("Whole words")));
|
||||
ui.checkbox(&mut extra.wildcards, tl!("Use wildcards"));
|
||||
});
|
||||
let arrow = if extra.more { "▾" } else { "▸" };
|
||||
@@ -397,12 +429,12 @@ fn find_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
if extra.more {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.prefix, tl!("Match prefix")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.suffix, tl!("Match suffix")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.prefix, tl!("Match prefix")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.suffix, tl!("Match suffix")));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_punct, tl!("Ignore punctuation characters")));
|
||||
ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_space, tl!("Ignore white-space characters")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.ignore_punct, tl!("Ignore punctuation characters")));
|
||||
ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.ignore_space, tl!("Ignore white-space characters")));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(regex, tl!("Regular expressions"));
|
||||
@@ -478,15 +510,15 @@ fn enclose_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Text:"));
|
||||
ui.add(egui::TextEdit::singleline(text).desired_width(110.0).char_limit(2));
|
||||
ui.add_space(6.0);
|
||||
enclose_preview(ui, style, text, shape);
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Enclosure"));
|
||||
for (v, l) in [("circle", "Circle"), ("square", "Square"), ("triangle", "Triangle"), ("diamond", "Diamond")] {
|
||||
ui.add_enabled_ui(style != "none", |ui| ui.selectable_value(shape, v.to_string(), tl!(l)));
|
||||
@@ -535,13 +567,15 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::InsertTable { rows, cols } = d else {
|
||||
return false;
|
||||
};
|
||||
egui::Grid::new("it").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Number of columns:"));
|
||||
ui.add(egui::DragValue::new(cols).range(1..=63));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Number of rows:"));
|
||||
ui.add(egui::DragValue::new(rows).range(1..=1000));
|
||||
ui.end_row();
|
||||
crate::rtl::Table::new("it").columns(2).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Number of columns:")));
|
||||
r.cell(|ui| ui.add(egui::DragValue::new(cols).range(1..=63)));
|
||||
});
|
||||
t.row(|r| {
|
||||
r.cell(|ui| ui.label(tl!("Number of rows:")));
|
||||
r.cell(|ui| ui.add(egui::DragValue::new(rows).range(1..=1000)));
|
||||
});
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
@@ -550,6 +584,11 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ok || cancel
|
||||
}
|
||||
|
||||
/// A margin's number box, in the unit the interface measures in.
|
||||
fn margin(value: &mut f32) -> egui::DragValue<'_> {
|
||||
egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)
|
||||
}
|
||||
|
||||
fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::PageSetup {
|
||||
top,
|
||||
@@ -573,21 +612,19 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
return false;
|
||||
};
|
||||
ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5)));
|
||||
egui::Grid::new("ps").num_columns(4).show(ui, |ui| {
|
||||
let top_label = ui.label(tl!("Top:"));
|
||||
ui.add(egui::DragValue::new(top).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(top_label.id);
|
||||
let bottom_label = ui.label(tl!("Bottom:"));
|
||||
ui.add(egui::DragValue::new(bottom).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(bottom_label.id);
|
||||
ui.end_row();
|
||||
let left_label = ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") });
|
||||
ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(left_label.id);
|
||||
let right_label = ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") });
|
||||
ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2))
|
||||
.labelled_by(right_label.id);
|
||||
ui.end_row();
|
||||
crate::rtl::Table::new("ps").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let top_label = r.cell(|ui| ui.label(tl!("Top:")));
|
||||
r.cell(|ui| ui.add(margin(top)).labelled_by(top_label.id));
|
||||
let bottom_label = r.cell(|ui| ui.label(tl!("Bottom:")));
|
||||
r.cell(|ui| ui.add(margin(bottom)).labelled_by(bottom_label.id));
|
||||
});
|
||||
t.row(|r| {
|
||||
let left_label = r.cell(|ui| ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") }));
|
||||
r.cell(|ui| ui.add(margin(left)).labelled_by(left_label.id));
|
||||
let right_label = r.cell(|ui| ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") }));
|
||||
r.cell(|ui| ui.add(margin(right)).labelled_by(right_label.id));
|
||||
});
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Orientation")).font(semibold(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
@@ -604,12 +641,13 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.radio_value(&mut grid.0, 2, tl!("Lines and characters"));
|
||||
ui.radio_value(&mut grid.0, 3, tl!("Text snaps to characters"));
|
||||
});
|
||||
egui::Grid::new("ps_grid").num_columns(4).show(ui, |ui| {
|
||||
let chars_label = ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:")));
|
||||
ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id);
|
||||
let lines_label = ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:")));
|
||||
ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id);
|
||||
ui.end_row();
|
||||
crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id));
|
||||
let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))));
|
||||
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id));
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Section direction:"));
|
||||
@@ -680,7 +718,7 @@ fn link_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("lnk").num_columns(2).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("lnk").num_columns(2), |ui| {
|
||||
ui.label(tl!("Text to display:"));
|
||||
ui.text_edit_singleline(text);
|
||||
ui.end_row();
|
||||
@@ -762,7 +800,7 @@ fn word_count_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::WordCount { stats } = d else {
|
||||
return false;
|
||||
};
|
||||
egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)), |ui| {
|
||||
for (l, k) in [
|
||||
("Pages", "pages"),
|
||||
("Words", "words"),
|
||||
@@ -855,7 +893,7 @@ fn phonetic_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
("right", "Right"),
|
||||
("rightVertical", "Right (vertical)"),
|
||||
];
|
||||
egui::Grid::new("phonetic").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("phonetic").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Base text:"));
|
||||
ui.label(egui::RichText::new(base.as_str()).strong());
|
||||
ui.end_row();
|
||||
@@ -900,7 +938,7 @@ fn new_style_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::NewStyle { name, based_on, back } = d else {
|
||||
return false;
|
||||
};
|
||||
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("ns").num_columns(2), |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
@@ -924,7 +962,7 @@ fn modify_style_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let Dialog::ModifyStyle { back, resume, id, name, font, size, bold, italic, color, before, after } = d else {
|
||||
return false;
|
||||
};
|
||||
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("ms").num_columns(2), |ui| {
|
||||
let name_label = ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name).labelled_by(name_label.id);
|
||||
ui.end_row();
|
||||
@@ -1144,7 +1182,7 @@ fn save_changes_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let buttons = ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let cancel = ui.button(tl!("Cancel"));
|
||||
let dont_save = ui.button(tl!("Don't Save"));
|
||||
let save = ui.add(crate::widgets::primary_button(ui, tl!("Save")));
|
||||
@@ -1254,7 +1292,7 @@ fn encrypt_password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bo
|
||||
};
|
||||
ui.label(tl!("Anyone who opens the document will need this password."));
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Password:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0));
|
||||
if password.is_empty() && confirm.is_empty() && !r.has_focus() {
|
||||
@@ -1437,7 +1475,7 @@ fn merge_rule_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
"Insert one text or another, depending on a field's value."
|
||||
}));
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("merge_rule").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("merge_rule").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Field:"));
|
||||
if fields.is_empty() {
|
||||
ui.text_edit_singleline(field);
|
||||
@@ -1479,7 +1517,7 @@ fn match_fields_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
} else {
|
||||
ui.label(tl!("The recipient-list field that fills each part of an address block and greeting line."));
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("match_fields").num_columns(2).spacing(vec2(24.0, 6.0)).striped(true).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("match_fields").num_columns(2).spacing(vec2(24.0, 6.0)).striped(true), |ui| {
|
||||
for (part, key) in ADDRESS_PARTS {
|
||||
ui.label(tl!(part));
|
||||
match address.get(key).and_then(Value::as_str) {
|
||||
|
||||
@@ -177,8 +177,8 @@ pub(super) fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, tar
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.label(tl!("Go to what:"));
|
||||
for (w, label) in [(GoToWhat::Page, "Page"), (GoToWhat::Bookmark, "Bookmark")] {
|
||||
if ui.selectable_label(*what == w, tl!(label)).clicked() && *what != w {
|
||||
@@ -189,7 +189,7 @@ pub(super) fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, tar
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| match what {
|
||||
crate::rtl::vertical(ui, |ui| match what {
|
||||
GoToWhat::Page => {
|
||||
let label = ui.label(tl!("Enter page number:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).labelled_by(label.id);
|
||||
|
||||
@@ -343,7 +343,7 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
|
||||
});
|
||||
let mut new_hover = (0, 0);
|
||||
let mut clicked = None;
|
||||
egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)), |ui| {
|
||||
for r in 1..=8 {
|
||||
for c in 1..=10 {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
@@ -390,7 +390,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
|
||||
};
|
||||
heading(ui, "Table");
|
||||
egui::Grid::new("tp_table").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_table").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.checkbox(&mut f.width_on, tl!("Preferred width:"));
|
||||
ui.add_enabled(f.width_on, len(&mut f.width, 0.1, unit));
|
||||
ui.end_row();
|
||||
@@ -412,7 +412,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Row");
|
||||
egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let specify = ui.checkbox(&mut f.row_height_on, tl!("Specify height:"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(f.row_height_on, len(&mut f.row_height, 0.02, unit));
|
||||
@@ -433,13 +433,13 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
super::paragraph::para_check(ui, &mut f.allow_break, tl!("Allow row to break across pages"), "allowBreak", &mut f.mixed);
|
||||
super::paragraph::para_check(ui, &mut f.header_row, tl!("Repeat as header row on each page"), "headerRow", &mut f.mixed);
|
||||
heading(ui, "Column");
|
||||
egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Preferred width:"));
|
||||
ui.add(len(&mut f.column_width, 0.1, unit));
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Cell");
|
||||
egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
ui.checkbox(&mut f.cell_width_on, tl!("Preferred width:"));
|
||||
ui.add_enabled(f.cell_width_on, len(&mut f.cell_width, 0.1, unit));
|
||||
ui.end_row();
|
||||
@@ -456,7 +456,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
|
||||
pub(super) fn cell_margins_dialog(ui: &mut Ui, f: &mut CellMarginsForm) {
|
||||
let unit = crate::options::unit();
|
||||
let max = 144.0 / unit.pt_per_unit();
|
||||
egui::Grid::new("cell_margins").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cell_margins").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
for (label, value) in [("Top:", &mut f.top), ("Left:", &mut f.left), ("Bottom:", &mut f.bottom), ("Right:", &mut f.right)] {
|
||||
ui.label(tl!(label));
|
||||
ui.add(egui::DragValue::new(value).speed(0.01).range(0.0..=max).measure(unit).max_decimals(2));
|
||||
@@ -512,7 +512,7 @@ pub(super) fn custom_properties(app: &mut WordApp, ui: &mut Ui, f: &mut CustomPr
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| {
|
||||
let name = ui.label(tl!("Name:"));
|
||||
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(300.0).char_limit(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME))
|
||||
.labelled_by(name.id);
|
||||
|
||||
@@ -261,8 +261,7 @@ pub(crate) fn recipient_list(
|
||||
if !editing {
|
||||
filter.editing = None;
|
||||
}
|
||||
let merged_only = egui::Checkbox::new(&mut filter.only_merged, tl!("Show only records that will be merged"));
|
||||
ui.add_enabled(order.is_some(), merged_only);
|
||||
ui.add_enabled_ui(order.is_some(), |ui| ui.checkbox(&mut filter.only_merged, tl!("Show only records that will be merged")));
|
||||
let visible = ui
|
||||
.horizontal(|ui| {
|
||||
ui.label(tl!("Find in list:"));
|
||||
@@ -284,7 +283,7 @@ pub(crate) fn recipient_list(
|
||||
let mut x_w = X_W;
|
||||
let mut focused = None;
|
||||
egui::ScrollArea::horizontal().id_salt("recipients_h").max_width(720.0).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover());
|
||||
for (i, f) in fields.iter_mut().enumerate() {
|
||||
@@ -424,7 +423,7 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter
|
||||
});
|
||||
};
|
||||
let mut remove = None;
|
||||
egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)), |ui| {
|
||||
ui.label("");
|
||||
ui.label(egui::RichText::new(tl!("Field")).weak());
|
||||
ui.label(egui::RichText::new(tl!("Comparison")).weak());
|
||||
@@ -466,7 +465,7 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)), |ui| {
|
||||
for (i, (field, desc)) in filter.sort.iter_mut().enumerate() {
|
||||
ui.label(tl!(if i == 0 { "Sort by" } else { "Then by" }));
|
||||
field_combo(ui, ("rs_field", i), field, true);
|
||||
|
||||
@@ -106,19 +106,23 @@ pub(super) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
egui::Grid::new("ps_paper").num_columns(4).show(ui, |ui| {
|
||||
crate::rtl::Table::new("ps_paper").columns(4).show(ui, |t| {
|
||||
use wordcraft_engine::cmd::page::{MAX_PAGE_SIDE, MIN_PAGE_SIDE};
|
||||
let range = MIN_PAGE_SIDE / ppu..=MAX_PAGE_SIDE / ppu;
|
||||
let width_label = ui.label(tl!(l.width));
|
||||
let w = ui
|
||||
.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2))
|
||||
.labelled_by(width_label.id);
|
||||
let height_label = ui.label(tl!(l.height));
|
||||
let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id);
|
||||
if (w.changed() || h.changed()) && paper.0 != paper.1 {
|
||||
*landscape = paper.0 > paper.1;
|
||||
}
|
||||
ui.end_row();
|
||||
t.row(|r| {
|
||||
let width_label = r.cell(|ui| ui.label(tl!(l.width)));
|
||||
let w = r.cell(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2))
|
||||
.labelled_by(width_label.id)
|
||||
});
|
||||
let height_label = r.cell(|ui| ui.label(tl!(l.height)));
|
||||
let h = r.cell(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id)
|
||||
});
|
||||
if (w.changed() || h.changed()) && paper.0 != paper.1 {
|
||||
*landscape = paper.0 > paper.1;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ pub(super) enum ParaNumber {
|
||||
Lines,
|
||||
}
|
||||
|
||||
/// The right-to-left mark.
|
||||
const RLM: char = '\u{200F}';
|
||||
|
||||
/// A Paragraph dialog number box for the field `key`. While the selected paragraphs differ in
|
||||
/// it, it shows nothing, not even the unit, until a value is typed or dragged in.
|
||||
pub(super) fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: &mut BTreeSet<String>, label: &egui::Response) {
|
||||
@@ -76,6 +79,9 @@ pub(super) fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &
|
||||
ParaNumber::Length if blank => field.measure_blank(unit),
|
||||
ParaNumber::Length => field.measure(unit),
|
||||
_ if blank => field.custom_formatter(|_, _| String::new()),
|
||||
// Right to left, the unit's space goes after the word and a right-to-left mark closes it: a
|
||||
// plain leading space between the digits and an Arabic unit is drawn with no width.
|
||||
ParaNumber::Points if crate::rtl::on() => field.suffix(format!("{} {RLM}", tl!("pt"))),
|
||||
ParaNumber::Points => field.suffix(format!(" {}", tl!("pt"))),
|
||||
ParaNumber::Lines => field,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,17 @@ pub(crate) struct Parked {
|
||||
}
|
||||
|
||||
impl WordApp {
|
||||
/// Replace the document the window opened with before anything else happens (the first blank
|
||||
/// document, made again on the region File › Options chose): the session's document is
|
||||
/// swapped and the window follows it at once. Replacing it with `session.set_document`
|
||||
/// alone leaves the window showing the old document, so the first frame took the change for
|
||||
/// a switch and dropped the dialog a command line file had just asked for, a password box
|
||||
/// among them (#5299).
|
||||
pub fn replace_startup_document(&mut self, doc: wordcraft_doc::Document) {
|
||||
self.session.set_document(doc);
|
||||
self.sync_document();
|
||||
}
|
||||
|
||||
/// Follow the session's active document. When it isn't the one the window last showed, the
|
||||
/// shown document's own state is put aside (or dropped, if it was closed or replaced), the
|
||||
/// new one's comes back, and the window's state about the page that was shown starts afresh
|
||||
|
||||
@@ -124,7 +124,7 @@ fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str,
|
||||
y += LABEL_PITCH;
|
||||
}
|
||||
let resp = resp.on_hover_text(label.replace('\n', " "));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(260.0);
|
||||
menu(ui, app);
|
||||
});
|
||||
@@ -239,7 +239,7 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) {
|
||||
let _ = app.run("insert.equation", json!({}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
egui::Popup::menu(&rb).show(|ui| builtin_menu(ui, app));
|
||||
crate::rtl::menu(&rb).show(|ui| builtin_menu(ui, app));
|
||||
}
|
||||
|
||||
/// The Symbols group: a strip of the current set's symbols, and a menu with every set.
|
||||
@@ -253,7 +253,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) {
|
||||
let all: Vec<char> = chars.chars().collect();
|
||||
let start = ui.data(|d| d.get_temp::<usize>(start_id)).unwrap_or(0).min(all.len().saturating_sub(1));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
|
||||
for row in 0..rows {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -272,7 +272,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) {
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
|
||||
let page = per_row * rows;
|
||||
if ui.add_enabled(start > 0, egui::Button::new("▲").min_size(vec2(16.0, 22.0))).clicked() {
|
||||
@@ -282,7 +282,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) {
|
||||
ui.data_mut(|d| d.insert_temp(start_id, start + per_row));
|
||||
}
|
||||
let resp = ui.add(egui::Button::new("…").min_size(vec2(16.0, 22.0))).on_hover_text(tl!("All symbol sets"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
ui.set_width(420.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, (name, _)) in SYMBOL_SETS.iter().enumerate() {
|
||||
@@ -341,7 +341,7 @@ fn symbol_tooltip(c: char, display: &str) -> String {
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Tools", None, app, |ui, app| {
|
||||
math_menu_button(ui, app, EQUATION_PICTURE, "Equation", builtin_menu);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
let latex = app.session.math_latex;
|
||||
small(
|
||||
@@ -386,7 +386,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
},
|
||||
);
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
let normal = app.session.math_normal_text;
|
||||
small(
|
||||
@@ -407,7 +407,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
group(ui, "Equation Options", None, app, |ui, app| {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
let info = app.session.run("equation.get", &json!({})).ok();
|
||||
let display = info.as_ref().and_then(|v| v.get("display")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
@@ -437,7 +437,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
crate::widgets::ButtonAction::new("equation.number", json!({}), numbered),
|
||||
);
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
crate::widgets::menu_button(
|
||||
ui,
|
||||
|
||||
@@ -316,7 +316,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0));
|
||||
let shown = crate::window_close::window(ctx, window).show(ctx, |ui| {
|
||||
ui.set_width(620.0);
|
||||
done = body(ui, &mut b, &t);
|
||||
done = crate::rtl::root(ui, |ui| body(ui, &mut b, &t));
|
||||
});
|
||||
if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) {
|
||||
done = Some(None);
|
||||
@@ -385,9 +385,21 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
let icon = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(18.0, 18.0));
|
||||
let rtl = crate::rtl::on();
|
||||
let icon = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 14.0), r.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(ui.painter(), icon, if e.dir { "folder" } else { "file" }, t.icon, t.accent);
|
||||
ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &e.name, regular(13.0), t.text);
|
||||
// A long name keeps its beginning and ends in an ellipsis inside the row, from the
|
||||
// side reading starts from; the whole name is the row's tooltip then.
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(crate::i18n::isolate(&e.name), regular(13.0), t.text);
|
||||
job.wrap.max_width = (r.width() - 36.0).max(0.0);
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.break_anywhere = true;
|
||||
let galley = ui.painter().layout_job(job);
|
||||
let text_x = crate::rtl::lead_x(rtl, r, 30.0);
|
||||
let at = pos2(if rtl { text_x - galley.size().x } else { text_x }, r.center().y - galley.size().y / 2.0);
|
||||
let elided = galley.elided;
|
||||
ui.painter().galley(at, galley, t.text);
|
||||
let resp = if elided { resp.on_hover_text(&e.name) } else { resp };
|
||||
if resp.double_clicked() {
|
||||
if e.dir {
|
||||
go = Some(e.path.clone());
|
||||
@@ -459,7 +471,7 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
|
||||
let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let (ok, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!(b.ok_label()))), tl!("Cancel"));
|
||||
cancel |= cancelb.clicked();
|
||||
|
||||
@@ -301,7 +301,7 @@ impl WordApp {
|
||||
self.run("file.saveAs", json!({"path": path}))
|
||||
};
|
||||
if r.is_ok() {
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &crate::i18n::isolate_path(&path))]));
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -311,7 +311,7 @@ impl WordApp {
|
||||
params["path"] = json!(path);
|
||||
let r = self.run("file.printPdf", params);
|
||||
if r.is_ok() {
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &crate::i18n::isolate_path(&path))]));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ fn kind_of(t: usize, focus: [f32; 4], angle: f32, scaled: bool) -> GradientKind
|
||||
/// add and remove, and the selected stop's colour, position, transparency and rotate-with-shape
|
||||
/// controls.
|
||||
fn gradient(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], g: &Gradient) {
|
||||
egui::Grid::new("format_shape.gradient").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("format_shape.gradient").num_columns(2).spacing(vec2(8.0, 6.0)), |ui| {
|
||||
ui.label(tl!("Preset gradients"));
|
||||
ui.menu_button("▾", |ui| {
|
||||
if let Some(f) = presets_gallery(ui, theme) {
|
||||
@@ -394,7 +394,7 @@ fn stops_bar(app: &mut WordApp, ui: &mut Ui, g: &Gradient, sel: usize) {
|
||||
fn presets_gallery(ui: &mut Ui, theme: &[Rgb]) -> Option<Fill> {
|
||||
let presets = shape_fill::presets(theme);
|
||||
let mut out = None;
|
||||
egui::Grid::new("format_shape.presets").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("format_shape.presets").spacing(vec2(4.0, 4.0)), |ui| {
|
||||
for (i, f) in presets.iter().enumerate() {
|
||||
let style = shape_fill::PRESET_STYLES.get(i / 6).copied().unwrap_or("");
|
||||
let name = format!("{}, {} {}", tl!(style), tl!("Accent"), i % 6 + 1);
|
||||
@@ -418,7 +418,7 @@ fn directions(ui: &mut Ui, g: &Gradient, t: usize) -> Option<GradientKind> {
|
||||
PATH_DIRECTIONS.iter().map(|&f| (kind_of(t, f, 90.0, true), shape_fill::path_direction_name(f))).collect()
|
||||
};
|
||||
let mut out = None;
|
||||
egui::Grid::new("format_shape.directions").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("format_shape.directions").spacing(vec2(4.0, 4.0)), |ui| {
|
||||
for (i, (k, name)) in kinds.into_iter().enumerate() {
|
||||
let f = with(g, |g| set_kind(g, k));
|
||||
if swatch(ui, &f, &name) {
|
||||
|
||||
@@ -70,7 +70,7 @@ pub struct LangInfo {
|
||||
|
||||
/// The languages WordCraft offers, when their catalogs are embedded. English first: it is the
|
||||
/// fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 27] = [
|
||||
pub static LANGUAGES: [LangInfo; 28] = [
|
||||
LangInfo { code: "en", prefer_hans: false, editing_lang: None },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (the crate's matching).
|
||||
LangInfo { code: "zh-hans", prefer_hans: true, editing_lang: None },
|
||||
@@ -124,10 +124,18 @@ pub static LANGUAGES: [LangInfo; 27] = [
|
||||
LangInfo { code: "da", prefer_hans: false, editing_lang: None },
|
||||
// Romanian; `ro`, `ro-RO`, `ro_RO.UTF-8` and `ro-MD` resolve here.
|
||||
LangInfo { code: "ro", prefer_hans: false, editing_lang: None },
|
||||
// Arabic (Modern Standard); `ar` and every `ar-*` locale (`ar-MA`, `ar-EG`, `ar-SA` …) resolve
|
||||
// here. Right to left: see [`RTL_LANGUAGES`] and [`Lang::rtl`].
|
||||
LangInfo { code: "ar", prefer_hans: false, editing_lang: None },
|
||||
// Slovenian; `sl`, `sl-SI` and `sl_SI.UTF-8` resolve here.
|
||||
LangInfo { code: "sl", prefer_hans: false, editing_lang: None },
|
||||
];
|
||||
|
||||
/// Interface languages written right to left. Their catalogs are written in typing order and
|
||||
/// egui's text layout orders each line itself, so the lookups return them as they are; the window
|
||||
/// is mirrored ([`crate::rtl`]).
|
||||
const RTL_LANGUAGES: [&str; 1] = ["ar"];
|
||||
|
||||
/// A language the UI can be shown in: English, or an offered language whose catalogs are embedded.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Lang {
|
||||
@@ -171,6 +179,12 @@ impl Lang {
|
||||
self.i18n().name(self.lang)
|
||||
}
|
||||
|
||||
/// Whether the interface is written right to left. Its translations come back from the lookups
|
||||
/// as written, in typing order (see [`RTL_LANGUAGES`]).
|
||||
pub fn rtl(self) -> bool {
|
||||
RTL_LANGUAGES.contains(&self.code())
|
||||
}
|
||||
|
||||
pub fn prefers_hans(self) -> bool {
|
||||
self.info().is_some_and(|l| l.prefer_hans)
|
||||
}
|
||||
@@ -400,6 +414,46 @@ pub fn tr(lang: Lang, s: &str) -> &str {
|
||||
lang.i18n().t(lang.lang, s)
|
||||
}
|
||||
|
||||
/// Left-to-right mark: it keeps what it touches from joining a right-to-left run next to it.
|
||||
const LRM: char = '\u{200E}';
|
||||
|
||||
/// `name` with a left-to-right mark before its extension (`ملف 2.docx`), which a right-to-left
|
||||
/// line would otherwise draw as `ملف docx.2`.
|
||||
fn mark_extension(name: &str) -> String {
|
||||
match name.rfind('.') {
|
||||
Some(dot)
|
||||
if dot > 0 && name.get(dot + 1..).is_some_and(|e| !e.is_empty() && e.len() <= 8 && e.bytes().all(|b| b.is_ascii_alphanumeric())) =>
|
||||
{
|
||||
format!("{}{LRM}{}", &name[..dot], &name[dot..])
|
||||
}
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` (a file or folder name, a document's name) set apart from the right-to-left text around
|
||||
/// it, so its own direction and its neutral marks (`(`, `.`, `-`) keep their places. Only in a
|
||||
/// right-to-left interface; elsewhere the text as it is. Display only: never for text that is saved.
|
||||
pub fn isolate(name: &str) -> String {
|
||||
if current().rtl() { format!("\u{2068}{}\u{2069}", mark_extension(name)) } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// [`isolate`] for a path or a web address, which always reads left to right: its folders keep
|
||||
/// their order even where neighbouring ones are Arabic (a mark after every separator keeps two
|
||||
/// right-to-left names from joining into one run that swaps them).
|
||||
pub fn isolate_path(path: &str) -> String {
|
||||
if !current().rtl() {
|
||||
return path.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(path.len() + 8);
|
||||
for c in mark_extension(path).chars() {
|
||||
out.push(c);
|
||||
if matches!(c, '/' | '\\') {
|
||||
out.push(LRM);
|
||||
}
|
||||
}
|
||||
format!("\u{2066}{out}\u{2069}")
|
||||
}
|
||||
|
||||
/// A style's name as the interface shows it: built-in styles (`Normal`, `Heading 2`, `TOC 3`) in
|
||||
/// the current language, styles the author made exactly as named. Display only: the document,
|
||||
/// commands and saved files keep the English name.
|
||||
@@ -425,9 +479,11 @@ pub fn builtin_style_name(name: &str) -> String {
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// A ribbon location (`Home › Font`) in the current language, segment by segment.
|
||||
/// A ribbon location (`Home › Font`) in the current language, segment by segment. A
|
||||
/// right-to-left language reads it from the right, so the mark points left.
|
||||
pub fn location(loc: &str) -> String {
|
||||
loc.split(" › ").map(t).collect::<Vec<_>>().join(" › ")
|
||||
let segments: Vec<&str> = loc.split(" › ").map(t).collect();
|
||||
segments.join(if current().rtl() { " ‹ " } else { " › " })
|
||||
}
|
||||
|
||||
/// A label with an action after a fixed English prefix, e.g. `Undo Typing`: both parts translated
|
||||
|
||||
@@ -597,3 +597,237 @@ fn disabled_reasons() -> std::collections::BTreeSet<String> {
|
||||
}
|
||||
reasons
|
||||
}
|
||||
|
||||
/// One frame at a given window width; returns the left edge of every text drawn.
|
||||
fn frame_at(ctx: &egui::Context, app: &mut WordApp, width: f32) -> Vec<(String, f32)> {
|
||||
fn collect(shape: &egui::Shape, out: &mut Vec<(String, f32)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos.x)),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0));
|
||||
let mut drawn = Vec::new();
|
||||
for _ in 0..4 {
|
||||
TIME.set(TIME.get() + 0.1);
|
||||
let input = egui::RawInput { screen_rect: Some(screen), time: Some(TIME.get()), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
drawn.clear();
|
||||
for s in &out.shapes {
|
||||
collect(&s.shape, &mut drawn);
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
drawn
|
||||
}
|
||||
|
||||
/// The Arabic interface is mirrored: reading starts at the right. And nothing in it can panic:
|
||||
/// every tab (wide, and narrow enough to scroll), File page, dialog, pane, context menu and
|
||||
/// ribbon control is laid out right to left. egui's `Grid` asserts against right-to-left parents,
|
||||
/// so a grid that is not wrapped in `rtl::grid` fails here.
|
||||
#[test]
|
||||
fn the_arabic_interface_is_mirrored_and_never_panics() {
|
||||
// Arabic comes from the craft-i18n catalogs: a build without them (English only) has nothing to mirror.
|
||||
let Some(ar) = super::Lang::from_code("ar") else {
|
||||
eprintln!("skipped: built without the craft-i18n Arabic catalog (set CRAFT_I18N_DIR to a checkout)");
|
||||
return;
|
||||
};
|
||||
let start = |ctx: &egui::Context| {
|
||||
let mut app = fresh_app(ctx);
|
||||
app.ui.language = "ar".into();
|
||||
frame(ctx, &mut app);
|
||||
app
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = start(&ctx);
|
||||
assert!(crate::rtl::on());
|
||||
let x_of = |drawn: &[(String, f32)], english: &str| {
|
||||
let label = super::tr(ar, english);
|
||||
drawn.iter().find(|(t, _)| t == label).map(|(_, x)| *x).unwrap_or_else(|| panic!("{english} is not drawn"))
|
||||
};
|
||||
// Tabs run from the right: File, Home, Insert … and the Share button is at the far left.
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let (file, home, insert, share) = (x_of(&drawn, "File"), x_of(&drawn, "Home"), x_of(&drawn, "Insert"), x_of(&drawn, "Share"));
|
||||
assert!(file > home && home > insert && insert > 800.0, "tabs: {file} {home} {insert}");
|
||||
assert!(share < 200.0, "Share: {share}");
|
||||
// Ribbon groups too: Clipboard is right of Font, which is right of Styles.
|
||||
assert!(x_of(&drawn, "Clipboard") > x_of(&drawn, "Font") && x_of(&drawn, "Font") > x_of(&drawn, "Styles"));
|
||||
// The status bar starts at the right, and a narrow window still shows the ribbon's first group.
|
||||
let page = drawn.iter().find(|(t, _)| t.contains(super::tr(ar, "Editing"))).map(|(_, x)| *x);
|
||||
assert!(page.is_some());
|
||||
let narrow = frame_at(&ctx, &mut app, 700.0);
|
||||
let clipboard = x_of(&narrow, "Clipboard");
|
||||
assert!((350.0..700.0).contains(&clipboard), "the narrow ribbon starts at its right end: {clipboard}");
|
||||
// Side panes swap sides: Navigation is on the right.
|
||||
let _ = app.run("view.navigationPane", json!({"value": true}));
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
assert!(x_of(&drawn, "Navigation") > 1300.0);
|
||||
let _ = app.run("view.navigationPane", json!({"value": false}));
|
||||
// English stays as it was.
|
||||
{
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = fresh_app(&ctx);
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let x = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, x)| *x).unwrap();
|
||||
assert!(x("File") < x("Home") && x("Home") < x("Insert") && x("Insert") < 400.0 && x("Share") > 1400.0);
|
||||
assert!(x("Clipboard") < x("Font") && x("Font") < x("Styles"));
|
||||
assert!(x("Cut") < 200.0, "ribbon buttons keep their icon on the left and start at the left");
|
||||
assert!(!crate::rtl::on());
|
||||
super::set_current(ar);
|
||||
}
|
||||
// Everything else: no panic at any width.
|
||||
let tabs: Vec<&str> = crate::ribbon::TABS.iter().copied().filter(|t| *t != "File").collect();
|
||||
for tab in &tabs {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
for width in [2560.0, 1100.0, 420.0] {
|
||||
frame_at(&ctx, &mut app, width);
|
||||
}
|
||||
}
|
||||
let _ = app.run("ui.tab", json!({"tab": "Home"}));
|
||||
// Reading Mode's toolbar is mirrored too: its far-side button ("Edit Document") is at the left.
|
||||
let _ = app.run("view.readMode", json!({}));
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let edit = x_of(&drawn, "Edit Document");
|
||||
assert!(edit < 400.0, "Reading Mode: Edit Document at {edit}");
|
||||
let _ = app.run("view.printLayout", json!({}));
|
||||
// File › Print: the settings are on the right and the preview on the left, not over each other.
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "print"}));
|
||||
for width in [1600.0, 1100.0] {
|
||||
let drawn = frame_at(&ctx, &mut app, width);
|
||||
let settings = x_of(&drawn, "Save these pages as PDF…");
|
||||
let indicator = super::tr(ar, "Page {page} of {pages}").split('{').next().unwrap_or_default();
|
||||
// (The status bar's page count is drawn under the page too, at the right: take the leftmost.)
|
||||
let preview = drawn.iter().filter(|(t, _)| t.starts_with(indicator)).map(|(_, x)| *x).fold(f32::MAX, f32::min);
|
||||
assert!(preview < f32::MAX, "the preview's page indicator is drawn");
|
||||
assert!(preview + 100.0 < settings, "print page at {width}: settings {settings}, preview {preview}");
|
||||
}
|
||||
for page in ["home", "new", "open", "info", "export", "print", "options"] {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": page}));
|
||||
frame(&ctx, &mut app);
|
||||
frame_at(&ctx, &mut app, 500.0);
|
||||
}
|
||||
let _ = app.run("ui.backstage", json!({"value": false}));
|
||||
// A dialog's columns start at the right too: Go To's list of targets is right of its input.
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
let drawn = frame_at(&ctx, &mut app, 1600.0);
|
||||
let (targets, input) = (x_of(&drawn, "Go to what:"), x_of(&drawn, "Enter page number:"));
|
||||
assert!(targets > input, "Go To: targets {targets}, input {input}");
|
||||
app.dialog = None;
|
||||
let dialogs = [
|
||||
"font",
|
||||
"paragraph",
|
||||
"tabs",
|
||||
"goto",
|
||||
"insertTable",
|
||||
"pageSetup",
|
||||
"link",
|
||||
"bookmark",
|
||||
"wordCount",
|
||||
"zoom",
|
||||
"watermark",
|
||||
"phonetic",
|
||||
"newStyle",
|
||||
"manageStyles",
|
||||
"newTableStyle",
|
||||
"modifyTableStyle",
|
||||
"commands",
|
||||
"icons",
|
||||
"tableProperties",
|
||||
"enclose",
|
||||
"pasteSpecial",
|
||||
"about",
|
||||
"contributors",
|
||||
"models",
|
||||
"insertMergeField",
|
||||
"findRecipient",
|
||||
"fieldCode",
|
||||
"encryptPassword",
|
||||
"defineList",
|
||||
"trackChangesOptions",
|
||||
"customizeKeyboard",
|
||||
"customizeRibbon",
|
||||
"customizeQuickAccess",
|
||||
"noteOptions",
|
||||
"caption",
|
||||
"index",
|
||||
"markEntry",
|
||||
"toc",
|
||||
"sourceManager",
|
||||
"createSource",
|
||||
"lineNumbers",
|
||||
"hyphenation",
|
||||
"language",
|
||||
"envelopes",
|
||||
"labels",
|
||||
"find",
|
||||
"replace",
|
||||
"symbol",
|
||||
"field",
|
||||
"columns",
|
||||
"borders",
|
||||
"pageBorders",
|
||||
"newRecipientList",
|
||||
"editRecipientList",
|
||||
"rules",
|
||||
"matchFields",
|
||||
"dropCap",
|
||||
"dateTime",
|
||||
"crossReference",
|
||||
];
|
||||
for name in dialogs {
|
||||
let _ = app.run("ui.dialog", json!({"name": name}));
|
||||
frame(&ctx, &mut app);
|
||||
app.dialog = None;
|
||||
}
|
||||
let panes: Vec<String> =
|
||||
app.session.registry.all().iter().map(|c| c.id.to_string()).filter(|id| id.starts_with("view.") && id.ends_with("Pane")).collect();
|
||||
for pane in &panes {
|
||||
let _ = app.run(pane, json!({"value": true}));
|
||||
frame(&ctx, &mut app);
|
||||
frame_at(&ctx, &mut app, 600.0);
|
||||
let _ = app.run(pane, json!({"value": false}));
|
||||
}
|
||||
frames(&ctx, &mut app, click(egui::pos2(1280.0, 520.0), egui::PointerButton::Secondary));
|
||||
frames(&ctx, &mut app, escape());
|
||||
let _ = app.run("insert.table", json!({"rows": 2, "cols": 2}));
|
||||
for tab in ["Table Design", "Table Layout"] {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
frame(&ctx, &mut app);
|
||||
frame_at(&ctx, &mut app, 600.0);
|
||||
}
|
||||
let _ = app.run("insert.equation", json!({}));
|
||||
let _ = app.run("ui.tab", json!({"tab": "Equation"}));
|
||||
frame(&ctx, &mut app);
|
||||
// Every ribbon control on every tab, hovered and clicked: its tooltip, menu or dialog.
|
||||
let contextual = ["Table Design", "Table Layout", "Equation"];
|
||||
let open = |tab: &str| {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = start(&ctx);
|
||||
match tab {
|
||||
"Table Design" | "Table Layout" => drop(app.run("insert.table", json!({"rows": 2, "cols": 2}))),
|
||||
"Equation" => drop(app.run("insert.equation", json!({}))),
|
||||
_ => {}
|
||||
}
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
frame(&ctx, &mut app);
|
||||
(ctx, app)
|
||||
};
|
||||
for tab in tabs.iter().chain(&contextual) {
|
||||
let (ctx, mut app) = open(tab);
|
||||
for at in ribbon_controls(&ctx) {
|
||||
frames(&ctx, &mut app, vec![vec![egui::Event::PointerMoved(at)]; 2]);
|
||||
frames(&ctx, &mut app, click(at, egui::PointerButton::Primary));
|
||||
frames(&ctx, &mut app, escape());
|
||||
app.dialog = None;
|
||||
app.ui.backstage = false;
|
||||
if app.ui.tab != *tab {
|
||||
(_, app) = open(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
super::set_current(super::Lang::EN);
|
||||
}
|
||||
|
||||
@@ -1189,3 +1189,113 @@ fn the_flatpak_manifest_pins_the_craft_i18n_rev() {
|
||||
assert_eq!(commit.trim().trim_matches(['\'', '"']), rev);
|
||||
assert!(manifest.contains("CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n"), "the build embeds the catalogs");
|
||||
}
|
||||
|
||||
/// A right-to-left test language whose catalog is `source`, selected as the current language.
|
||||
fn arabic_fixture(source: &'static str) -> Lang {
|
||||
let ar = fixture_lang("ar", source);
|
||||
set_current(ar);
|
||||
ar
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_locales_and_saved_preference_work_without_changing_the_document() {
|
||||
needs_catalogs!();
|
||||
let Some(ar) = Lang::from_code("ar") else {
|
||||
eprintln!("skipped: the embedded catalogs have no Arabic");
|
||||
return;
|
||||
};
|
||||
for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant", "ar-Arab-AE"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(ar), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["xx-XX", "ar-MA", "en-US"]), Some(ar));
|
||||
assert_eq!(normalize_pref("AR"), Some("ar"));
|
||||
assert!(ar.rtl());
|
||||
assert!(Lang::all().filter(|l| l.rtl()).eq([ar]), "Arabic is the only right-to-left language");
|
||||
assert!(!ar.uses_cjk());
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default());
|
||||
let before = serde_json::to_value(&app.session.doc).unwrap();
|
||||
let result = app.run("ui.language", serde_json::json!({"value": "AR"})).unwrap();
|
||||
assert_eq!(result["effective"], "ar");
|
||||
assert_eq!(app.ui.language, "ar");
|
||||
assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(Lang::from_pref(&restored.language), ar);
|
||||
assert!(app.run("ui.language", serde_json::json!({"value": "ar-unknown"})).is_err());
|
||||
assert_eq!(app.ui.language, "ar", "invalid preferences must keep the saved language");
|
||||
}
|
||||
|
||||
/// Arabic comes back from the lookups exactly as the catalog has it, in typing order: egui's text
|
||||
/// layout orders the words of each line itself, so nothing here reverses them. Placeholders are
|
||||
/// filled in place and text for the document stays as typed.
|
||||
#[test]
|
||||
fn arabic_lookups_keep_typing_order() {
|
||||
let ar = arabic_fixture(concat!(
|
||||
"\tTable of Contents\tجدول المحتويات\n",
|
||||
"\tPaste Special…\tلصق خاص…\n",
|
||||
"\tAbout WordCraft\tحول WordCraft\n",
|
||||
"\tPassword:\tكلمة المرور:\n",
|
||||
"\tTable of\\nContents\tجدول\\nالمحتويات\n",
|
||||
"\tPage {page} of {pages}\tالصفحة {page} من {pages}\n",
|
||||
"\tExported {path}\tتم التصدير إلى {path}\n",
|
||||
"preview\tTitle\tالعنوان\n",
|
||||
"\tHome\tالرئيسية\n",
|
||||
"\tFont\tالخط\n",
|
||||
));
|
||||
assert!(ar.rtl());
|
||||
assert_eq!(tr(ar, "Table of Contents"), "جدول المحتويات");
|
||||
assert_eq!(tr(ar, "Paste Special…"), "لصق خاص…");
|
||||
assert_eq!(tr(ar, "About WordCraft"), "حول WordCraft");
|
||||
assert_eq!(tr(ar, "Password:"), "كلمة المرور:");
|
||||
// A two-line ribbon label keeps its two lines.
|
||||
assert_eq!(tr(ar, "Table of\nContents"), "جدول\nالمحتويات");
|
||||
// Placeholders are filled in place; inserted text is never touched, and the text for the
|
||||
// document is the same string.
|
||||
let page = fmt_plain(tr(ar, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]);
|
||||
assert_eq!(page, "الصفحة 3 من 9");
|
||||
assert_eq!(fmt_plain(tr(ar, "Exported {path}"), &[("path", "draft-{words}.docx")]), "تم التصدير إلى draft-{words}.docx");
|
||||
// The preview context is looked up the same way.
|
||||
assert_eq!(trc(ar, "preview", "Title"), "العنوان");
|
||||
// A string without a translation stays English, untouched.
|
||||
assert_eq!(tr(ar, "unknown future label"), "unknown future label");
|
||||
set_current(ar);
|
||||
// A ribbon location reads from the right: the tab first, the mark pointing left.
|
||||
assert_eq!(location("Home › Font"), "الرئيسية ‹ الخط");
|
||||
// A search query typed in reading order is a plain substring of the label.
|
||||
assert!(lowercase(t("Table of Contents")).contains("جدول المحتويات"));
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(location("Home › Font"), "Home › Font");
|
||||
}
|
||||
|
||||
/// File names and paths shown in a right-to-left line are set apart and marked: the name in its
|
||||
/// own isolate with its extension kept left to right, a path as a left-to-right isolate with a
|
||||
/// mark after every separator so neighbouring Arabic folders do not swap. Other languages get
|
||||
/// the text untouched.
|
||||
#[test]
|
||||
fn names_and_paths_are_isolated_in_a_right_to_left_interface() {
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(isolate("تقرير 2.docx"), "تقرير 2.docx");
|
||||
assert_eq!(isolate_path("C:/ملفات/تقرير.docx"), "C:/ملفات/تقرير.docx");
|
||||
let ar = fixture_lang("ar", "\tHome\tالرئيسية\n");
|
||||
set_current(ar);
|
||||
assert_eq!(isolate("تقرير 2.docx"), "\u{2068}تقرير 2\u{200E}.docx\u{2069}");
|
||||
assert_eq!(isolate("ملاحظات"), "\u{2068}ملاحظات\u{2069}", "no extension, no mark");
|
||||
assert_eq!(isolate("v1.2 تقرير"), "\u{2068}v1.2 تقرير\u{2069}", "a dot that is not an extension");
|
||||
assert_eq!(isolate_path(r"C:\ملفات\تقرير.docx"), "\u{2066}C:\\\u{200E}ملفات\\\u{200E}تقرير\u{200E}.docx\u{2069}");
|
||||
assert_eq!(isolate_path("C:/ملفات/تقرير.docx"), "\u{2066}C:/\u{200E}ملفات/\u{200E}تقرير\u{200E}.docx\u{2069}");
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
/// A path inside a sentence (the open failure on the status bar) is isolated in a right-to-left
|
||||
/// interface, so Arabic folders do not scramble; in English the message holds the path as it is.
|
||||
#[test]
|
||||
fn a_path_in_a_message_is_isolated_in_a_right_to_left_interface() {
|
||||
let path = r"C:\ملفات\تقرير.docx";
|
||||
set_current(Lang::EN);
|
||||
let mut a = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
assert!(a.report_open_failure(path, "bad").contains(path));
|
||||
set_current(fixture_lang("ar", "\tCouldn't open {path}: {error}\tتعذر فتح {path}: {error}\n"));
|
||||
let msg = a.report_open_failure(path, "bad");
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(msg, "تعذر فتح \u{2066}C:\\\u{200E}ملفات\\\u{200E}تقرير\u{200E}.docx\u{2069}: \u{2068}bad\u{2069}");
|
||||
}
|
||||
|
||||
@@ -32,10 +32,33 @@ fn shown_text(text: &str) -> String {
|
||||
shown_keeping(text, usize::MAX, true)
|
||||
}
|
||||
|
||||
/// Most characters of a failure the window shows: the font store's errors can quote the index
|
||||
/// URL or the server's answer, which aren't ours to trust either.
|
||||
const MAX_ERROR: usize = 500;
|
||||
|
||||
/// A failure from the engine or the font store as shown: [`shown`], at most [`MAX_ERROR`]
|
||||
/// characters.
|
||||
fn shown_error(e: &str) -> String {
|
||||
shown(e, MAX_ERROR)
|
||||
}
|
||||
|
||||
fn shown_keeping(text: &str, max: usize, lines: bool) -> String {
|
||||
// Line and paragraph separators (U+2028/9) break lines even where `lines` is off, and tag
|
||||
// characters (U+E0000–U+E007F) are invisible text an index could hide words in.
|
||||
let format = |c: char| {
|
||||
(c.is_control() && !(lines && matches!(c, '\n' | '\t')))
|
||||
|| matches!(c, '\u{061C}' | '\u{00AD}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}')
|
||||
|| matches!(
|
||||
c,
|
||||
'\u{061C}'
|
||||
| '\u{00AD}'
|
||||
| '\u{180E}'
|
||||
| '\u{200B}'..='\u{200F}'
|
||||
| '\u{2028}'..='\u{202E}'
|
||||
| '\u{2060}'..='\u{206F}'
|
||||
| '\u{FEFF}'
|
||||
| '\u{FFF9}'..='\u{FFFB}'
|
||||
| '\u{E0000}'..='\u{E007F}'
|
||||
)
|
||||
};
|
||||
let clean: String = text.chars().filter(|c| !format(*c)).collect();
|
||||
if clean.chars().count() <= max {
|
||||
@@ -165,9 +188,9 @@ fn error_of(app: &WordApp, locale: &str) -> Option<String> {
|
||||
}
|
||||
let e = packs::error(locale)?;
|
||||
Some(if locale.is_empty() {
|
||||
crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)])
|
||||
crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))])
|
||||
} else {
|
||||
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)])
|
||||
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &shown_error(&e))])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,7 +207,7 @@ fn consent(app: &mut WordApp, ctx: &egui::Context, key: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = backend.enable_network() {
|
||||
app.packs_rt.errors.insert(key.to_string(), crate::i18n::fmt(tl!("Couldn't turn on downloading: {error}"), &[("error", &e)]));
|
||||
app.packs_rt.errors.insert(key.to_string(), crate::i18n::fmt(tl!("Couldn't turn on downloading: {error}"), &[("error", &shown_error(&e))]));
|
||||
return false;
|
||||
}
|
||||
true
|
||||
@@ -197,7 +220,7 @@ fn start_install(app: &mut WordApp, ctx: &egui::Context, pack: &Pack) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = packs::start_install(&pack.locale) {
|
||||
app.packs_rt.errors.insert(pack.locale.clone(), crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)]));
|
||||
app.packs_rt.errors.insert(pack.locale.clone(), crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &shown_error(&e))]));
|
||||
}
|
||||
app.packs_rt.missing = None;
|
||||
}
|
||||
@@ -210,7 +233,9 @@ fn start_fetch(app: &mut WordApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = packs::start_refresh() {
|
||||
app.packs_rt.errors.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)]));
|
||||
app.packs_rt
|
||||
.errors
|
||||
.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +246,7 @@ fn remove(app: &mut WordApp, locale: &str) {
|
||||
app.packs_rt.missing = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.packs_rt.errors.insert(locale.to_string(), crate::i18n::fmt(tl!("Couldn't remove: {error}"), &[("error", &e)]));
|
||||
app.packs_rt.errors.insert(locale.to_string(), crate::i18n::fmt(tl!("Couldn't remove: {error}"), &[("error", &shown_error(&e))]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,6 +337,8 @@ fn licence_hover(pack: &Pack) -> String {
|
||||
/// File › Options › Language › Language packs: every available and installed pack with its
|
||||
/// name, size, licence and source, and Install or Remove.
|
||||
pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) {
|
||||
// Pack names include Arabic and other scripts; ask for a system Arabic face when none is embedded.
|
||||
app.want_system_arabic = true;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(RichText::new(tl!("Language packs")).font(semibold(15.0)));
|
||||
ui.add_space(2.0);
|
||||
@@ -382,7 +409,7 @@ pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) {
|
||||
let mut install = None;
|
||||
let mut removal = None;
|
||||
egui::ScrollArea::vertical().id_salt("options_language_packs").max_height(260.0).show(ui, |ui| {
|
||||
egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true), |ui| {
|
||||
for h in ["Language", "Size", "Licence", "Source", ""] {
|
||||
ui.label(RichText::new(if h.is_empty() { "" } else { tl!(h) }).small().color(t.text_dim));
|
||||
}
|
||||
@@ -556,11 +583,109 @@ mod tests {
|
||||
assert_eq!(super::language_of("ja", "日本語"), "Japanese");
|
||||
}
|
||||
|
||||
/// Tests that set the process-wide pack backend take turns.
|
||||
static BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// 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')
|
||||
}
|
||||
|
||||
/// FU.2: tag characters (U+E0000–U+E007F) and line/paragraph separators (U+2028/9) are
|
||||
/// stripped like the other format characters, from names and from licence texts alike.
|
||||
#[test]
|
||||
fn shown_strips_tag_characters_and_separators() {
|
||||
let name = "Ned\u{E0001}\u{E0041}\u{E007F}erlands\u{2028}\u{2029}";
|
||||
assert_eq!(super::shown(name, 100), "Nederlands");
|
||||
assert_eq!(super::shown_text("one\u{2028}two\nthree\u{2029}\tfour\u{E0020}"), "onetwo\nthree\tfour");
|
||||
// A cut never splits what's left, and counts characters, not bytes.
|
||||
assert_eq!(super::shown("\u{E0041}ab\u{2028}cd", 3), "ab…");
|
||||
}
|
||||
|
||||
/// A store that fails with a hostile message: the index URL with a right-to-left override,
|
||||
/// separators, tag characters, and far too long.
|
||||
struct Hostile {
|
||||
inner: FixedBackend,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl Backend for Hostile {
|
||||
fn index(&self) -> Result<Vec<packs::Pack>, String> {
|
||||
self.inner.index()
|
||||
}
|
||||
fn fetch_index(&self, _: &packs::Cancel) -> Result<Vec<packs::Pack>, String> {
|
||||
Err(self.message.clone())
|
||||
}
|
||||
fn network_on(&self) -> bool {
|
||||
self.inner.network_on()
|
||||
}
|
||||
fn enable_network(&self) -> Result<(), String> {
|
||||
Err(self.message.clone())
|
||||
}
|
||||
fn install(&self, _: &packs::Pack, _: &packs::Cancel, _: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
|
||||
Err(self.message.clone())
|
||||
}
|
||||
fn remove(&self, _: &str) -> Result<(), String> {
|
||||
Err(self.message.clone())
|
||||
}
|
||||
fn packs_dir(&self) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// FU.2: the font store's failures (which can quote the index URL and the server's answer)
|
||||
/// are shown through the same sanitiser as the index's text, whether the window had them
|
||||
/// (turning downloading on) or the engine did (an install, the list).
|
||||
#[test]
|
||||
fn font_store_errors_are_sanitised() {
|
||||
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 message = format!("https://evil.example/\u{202E}gnp.exe\u{2028}second line\u{E0041}\u{E0042}{}", "x".repeat(5000));
|
||||
let backend = std::sync::Arc::new(Hostile { inner: FixedBackend::sample(), message });
|
||||
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let clean = |shown: &str| {
|
||||
assert!(!shown.chars().any(hostile), "{shown:?}");
|
||||
assert!(shown.contains("https://evil.example/gnp.exe"), "{shown:?}");
|
||||
assert!(shown.chars().count() < super::MAX_ERROR + 100, "capped: {} characters", shown.chars().count());
|
||||
};
|
||||
// Turning downloading on fails: the window's own failure.
|
||||
assert!(!super::consent(&mut a, &ctx, "nl"));
|
||||
clean(&super::error_of(&a, "nl").expect("a failure is shown"));
|
||||
// An install and the list fail in the engine. Another test's window, drawn meanwhile,
|
||||
// can move them onto the engine's thread (`tick`): the call then starts the job, and its
|
||||
// failure is recorded when the job ends.
|
||||
let failed = |r: Result<bool, String>| {
|
||||
if r == Ok(true) {
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while super::busy() && std::time::Instant::now() < until {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
r != Ok(false)
|
||||
};
|
||||
a.packs_rt.errors.clear();
|
||||
backend.inner.network.store(true, Ordering::Relaxed);
|
||||
assert!(failed(packs::start_install("nl")));
|
||||
clean(&super::error_of(&a, "nl").expect("the install's failure is shown"));
|
||||
assert!(failed(packs::start_refresh()));
|
||||
clean(&super::error_of(&a, "").expect("the list's failure is shown"));
|
||||
}
|
||||
|
||||
/// CP4.3 review: a click the control channel made (MCP's `click`, `ui.click`) on the bar's
|
||||
/// Install never turns on downloading, which is the font store's one setting for fonts too;
|
||||
/// it says why instead. The person's own click on the same button does, and installs.
|
||||
#[test]
|
||||
fn a_faked_click_never_turns_on_downloading() {
|
||||
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);
|
||||
|
||||
@@ -66,6 +66,7 @@ pub mod proofing_pane;
|
||||
pub mod read_aloud;
|
||||
pub mod ribbon;
|
||||
pub mod ribbon_fit;
|
||||
pub mod rtl;
|
||||
pub mod ruler_tabs;
|
||||
pub mod scroll;
|
||||
pub mod system_clipboard;
|
||||
@@ -532,9 +533,16 @@ pub struct WordApp {
|
||||
fonts_hant: bool,
|
||||
/// Whether the installed UI fonts include an installed CJK fallback font (#241).
|
||||
fonts_system_cjk: bool,
|
||||
/// Whether the installed UI fonts were asked to include an installed Arabic font (an Arabic
|
||||
/// interface; used when no embedded face has Arabic letters).
|
||||
fonts_system_arabic: bool,
|
||||
/// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in
|
||||
/// File ▸ Options: load the installed CJK fallback font if no embedded face covers it.
|
||||
pub(crate) want_system_cjk: bool,
|
||||
/// Arabic text is (about to be) on screen in a non-Arabic interface, e.g. "العربية" in the
|
||||
/// language picker or Language packs list: load an installed Arabic font when no embedded face
|
||||
/// covers it (dev builds without craft-fonts).
|
||||
pub(crate) want_system_arabic: bool,
|
||||
/// The document (id, revision) whose comment text [`WordApp::ensure_comment_fonts`] last
|
||||
/// looked at.
|
||||
comment_fonts_for: Option<(u64, u64)>,
|
||||
|
||||
@@ -354,7 +354,7 @@ pub(crate) fn highlight_button_appearance(name: &str) -> (&'static str, Option<e
|
||||
/// No Color too, becomes the split button's colour, as in Word: after No Color a click on the
|
||||
/// button removes highlighting (#2105).
|
||||
pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
egui::Grid::new("mini_hl").spacing(Vec2::new(3.0, 3.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("mini_hl").spacing(Vec2::new(3.0, 3.0)), |ui| {
|
||||
for (i, h) in wordcraft_doc::props::Highlight::ALL.iter().enumerate().skip(1) {
|
||||
let Some(c) = h.rgb() else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
|
||||
|
||||
@@ -563,7 +563,8 @@ fn an_inline_picture_drags_to_the_insertion_cursor_and_stays_inline() {
|
||||
/// page: its preview has its red outline and nothing dark inside.
|
||||
#[test]
|
||||
fn a_dragged_shape_leaves_the_text_behind_it() {
|
||||
use crate::table_resize::tests::{H, button, steps};
|
||||
use crate::table_resize::tests::{H, button, gpu_turn, steps};
|
||||
let _gpu = gpu_turn();
|
||||
let text = "MMMMMMMMMMMM ".repeat(12);
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::from_text(&text)), Default::default());
|
||||
let shape = InlineObject::Shape {
|
||||
@@ -758,7 +759,8 @@ fn rotation_window(app: WordApp) -> crate::table_resize::tests::H {
|
||||
/// pictures whose cached preview already contains a rotation and either reflection.
|
||||
#[test]
|
||||
fn rotation_drag_turns_picture_pixels_live_and_commits_once_or_cancels() {
|
||||
use crate::table_resize::tests::{H, button, steps};
|
||||
use crate::table_resize::tests::{H, button, gpu_turn, steps};
|
||||
let _gpu = gpu_turn();
|
||||
for (initial, flip_h, flip_v) in [(0.0, false, false), (30.0, true, false), (90.0, false, true), (315.0, true, true)] {
|
||||
// Four original colour quadrants make both accidental extra rotation and reflected
|
||||
// UVs observable. Sample near the long ends after a quarter turn, outside the old
|
||||
|
||||
+119
-73
@@ -140,8 +140,9 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.label(RichText::new(tl!("Options")).font(semibold(26.0)));
|
||||
ui.add_space(16.0);
|
||||
let current = if PANES.contains(&app.ui.options_pane.as_str()) { app.ui.options_pane.clone() } else { "general".to_string() };
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
let rtl = crate::rtl::on();
|
||||
let body = |ui: &mut Ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_width(170.0);
|
||||
for id in PANES {
|
||||
let label = tl!(pane_label(id));
|
||||
@@ -155,7 +156,13 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let font = if active { semibold(14.0) } else { crate::theme::regular(14.0) };
|
||||
// Long translations stay inside the row; WidgetInfo above keeps the full name.
|
||||
ui.painter().with_clip_rect(r).text(egui::pos2(r.min.x + 12.0, r.center().y), egui::Align2::LEFT_CENTER, label, font, t.text);
|
||||
ui.painter().with_clip_rect(r).text(
|
||||
egui::pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y),
|
||||
crate::rtl::lead_center(rtl),
|
||||
label,
|
||||
font,
|
||||
t.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
app.ui.options_pane = id.to_string();
|
||||
}
|
||||
@@ -165,7 +172,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (line, _) = ui.allocate_exact_size(vec2(1.0, 420.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(line, 0.0, t.border);
|
||||
ui.add_space(20.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_max_width(640.0);
|
||||
match current.as_str() {
|
||||
"display" => display(app, ui),
|
||||
@@ -178,7 +185,13 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) {
|
||||
_ => general(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
// Right to left the pane list is on the right and the settings fill in from it.
|
||||
if rtl {
|
||||
ui.with_layout(crate::rtl::row_layout(ui, egui::Align::Min), body)
|
||||
} else {
|
||||
crate::rtl::horizontal_top(ui, body)
|
||||
};
|
||||
}
|
||||
|
||||
fn section(ui: &mut Ui, title: &str) {
|
||||
@@ -237,27 +250,38 @@ fn general(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Personalize");
|
||||
egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
let user_label = ui.label(tl!("User name:"));
|
||||
let mut n = app.session.author.clone();
|
||||
let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc);
|
||||
if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed() && !protected {
|
||||
app.session.author = n;
|
||||
}
|
||||
ui.end_row();
|
||||
let initials_label = ui.label(tl!("Initials:"));
|
||||
let mut i = app.session.initials.clone();
|
||||
let hint = app.session.user_initials();
|
||||
if ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut i).desired_width(80.0).char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS).hint_text(hint),
|
||||
)
|
||||
.labelled_by(initials_label.id)
|
||||
.changed()
|
||||
{
|
||||
set(app, json!({"initials": i}));
|
||||
}
|
||||
ui.end_row();
|
||||
crate::rtl::Table::new("options_user").columns(2).spacing(vec2(8.0, 6.0)).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let user_label = r.cell(|ui| ui.label(tl!("User name:")));
|
||||
r.cell(|ui| {
|
||||
let mut n = app.session.author.clone();
|
||||
let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc);
|
||||
if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed()
|
||||
&& !protected
|
||||
{
|
||||
app.session.author = n;
|
||||
}
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let initials_label = r.cell(|ui| ui.label(tl!("Initials:")));
|
||||
r.cell(|ui| {
|
||||
let mut i = app.session.initials.clone();
|
||||
let hint = app.session.user_initials();
|
||||
if ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut i)
|
||||
.desired_width(80.0)
|
||||
.char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS)
|
||||
.hint_text(hint),
|
||||
)
|
||||
.labelled_by(initials_label.id)
|
||||
.changed()
|
||||
{
|
||||
set(app, json!({"initials": i}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
note(ui, "New comments and tracked changes are recorded under this name and these initials.");
|
||||
}
|
||||
@@ -369,10 +393,11 @@ fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
|
||||
};
|
||||
for (language, file, error) in found {
|
||||
let line = match (file, error) {
|
||||
(Some(f), Some(e)) => {
|
||||
crate::i18n::fmt(tl!("{language}: {file} (unreadable: {reason})"), &[("language", &language), ("file", &f), ("reason", &e)])
|
||||
}
|
||||
(Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &f)]),
|
||||
(Some(f), Some(e)) => crate::i18n::fmt(
|
||||
tl!("{language}: {file} (unreadable: {reason})"),
|
||||
&[("language", &language), ("file", &crate::i18n::isolate_path(&f)), ("reason", &e)],
|
||||
),
|
||||
(Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &crate::i18n::isolate_path(&f))]),
|
||||
(None, _) => crate::i18n::fmt(tl!("{language}: no dictionary found"), &[("language", &language)]),
|
||||
};
|
||||
ui.add(egui::Label::new(RichText::new(line).small()).truncate());
|
||||
@@ -396,7 +421,14 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str {
|
||||
ProofLang::Es419 => tl!("Spanish (Latin America)"),
|
||||
ProofLang::EsUy => tl!("Spanish (Uruguay)"),
|
||||
// English uses its bundled word list; Bulgarian and German have no dictionary yet.
|
||||
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Pack(_) | ProofLang::Other => "",
|
||||
ProofLang::En
|
||||
| ProofLang::EnGb
|
||||
| ProofLang::EnCa
|
||||
| ProofLang::EnAu
|
||||
| ProofLang::Bg
|
||||
| ProofLang::De
|
||||
| ProofLang::Pack(_)
|
||||
| ProofLang::Other => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +474,7 @@ fn save(app: &mut WordApp, ui: &mut Ui) {
|
||||
// The browser can't write to the user's files, so AutoSave can't be turned on there (#176).
|
||||
let browser = app.autosave_block() == Some(crate::AutoSaveBlock::Browser);
|
||||
let mut autosave = app.autosave && !browser;
|
||||
let r = ui.add_enabled(!browser, egui::Checkbox::new(&mut autosave, tl!("AutoSave documents you have saved in WordCraft")));
|
||||
let r = ui.add_enabled_ui(!browser, |ui| ui.checkbox(&mut autosave, tl!("AutoSave documents you have saved in WordCraft"))).inner;
|
||||
if browser {
|
||||
r.on_disabled_hover_text(crate::AutoSaveBlock::Browser.reason());
|
||||
} else if r.changed() {
|
||||
@@ -530,51 +562,61 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Display");
|
||||
egui::Grid::new("options_display").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
let recent_label = ui.label(tl!("Recent documents to show:"));
|
||||
let mut n = app.ui.recent_shown();
|
||||
if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() {
|
||||
app.ui.recent_count = n;
|
||||
}
|
||||
ui.end_row();
|
||||
let units_label = ui.label(tl!("Measurement units:"));
|
||||
// The user's choice, else the region's (#838): Automatic follows the region.
|
||||
let units = app.session.units();
|
||||
let auto = app.session.prefs.units.is_none();
|
||||
let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n);
|
||||
let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() };
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(auto, tl!("Automatic")).clicked() {
|
||||
set(app, json!({"units": "auto"}));
|
||||
}
|
||||
for (u, label) in UNITS {
|
||||
if ui.selectable_label(!auto && units == u, tl!(label)).clicked() {
|
||||
set(app, json!({"units": u}));
|
||||
crate::rtl::Table::new("options_display").columns(2).spacing(vec2(8.0, 6.0)).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let recent_label = r.cell(|ui| ui.label(tl!("Recent documents to show:")));
|
||||
r.cell(|ui| {
|
||||
let mut n = app.ui.recent_shown();
|
||||
if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() {
|
||||
app.ui.recent_count = n;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
t.row(|r| {
|
||||
let units_label = r.cell(|ui| ui.label(tl!("Measurement units:")));
|
||||
r.cell(|ui| {
|
||||
// The user's choice, else the region's (#838): Automatic follows the region.
|
||||
let units = app.session.units();
|
||||
let auto = app.session.prefs.units.is_none();
|
||||
let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n);
|
||||
let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() };
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(auto, tl!("Automatic")).clicked() {
|
||||
set(app, json!({"units": "auto"}));
|
||||
}
|
||||
for (u, label) in UNITS {
|
||||
if ui.selectable_label(!auto && units == u, tl!(label)).clicked() {
|
||||
set(app, json!({"units": u}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(units_label.id);
|
||||
});
|
||||
});
|
||||
combo.response.labelled_by(units_label.id);
|
||||
ui.end_row();
|
||||
// The region new documents, units and the decimal separator follow; Automatic is the
|
||||
// system's (#838).
|
||||
let region_label = ui.label(tl!("Region:"));
|
||||
let chosen = app.session.prefs.region.clone();
|
||||
let system = &app.session.system_region;
|
||||
let system_tag = if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) };
|
||||
let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]);
|
||||
let shown = chosen.clone().unwrap_or_else(|| automatic.clone());
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() {
|
||||
set(app, json!({"region": "auto"}));
|
||||
}
|
||||
for tag in REGIONS {
|
||||
if ui.selectable_label(chosen.as_deref() == Some(tag), tag).clicked() {
|
||||
set(app, json!({"region": tag}));
|
||||
}
|
||||
}
|
||||
t.row(|r| {
|
||||
let region_label = r.cell(|ui| ui.label(tl!("Region:")));
|
||||
r.cell(|ui| {
|
||||
let chosen = app.session.prefs.region.clone();
|
||||
let system = &app.session.system_region;
|
||||
let system_tag =
|
||||
if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) };
|
||||
let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]);
|
||||
let shown = chosen.clone().unwrap_or_else(|| automatic.clone());
|
||||
let combo = craft_ui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() {
|
||||
set(app, json!({"region": "auto"}));
|
||||
}
|
||||
for tag in REGIONS {
|
||||
if ui.selectable_label(chosen.as_deref() == Some(tag), tag).clicked() {
|
||||
set(app, json!({"region": tag}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(region_label.id);
|
||||
});
|
||||
});
|
||||
combo.response.labelled_by(region_label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
let mut keytips = app.ui.keytips_enabled;
|
||||
if ui.checkbox(&mut keytips, tl!("Show KeyTips (Alt or F10)")).changed() {
|
||||
@@ -671,7 +713,10 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::theme::InterfaceSize;
|
||||
let size = app.ui.interface_size;
|
||||
let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0);
|
||||
let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())]);
|
||||
// The template keeps the percent sign outside its placeholder, so `fmt` isolates the number
|
||||
// without it and a right-to-left line draws "(%200)": the sign goes inside the isolate.
|
||||
let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())])
|
||||
.replace("\u{2069}%", "%\u{2069}");
|
||||
let selected = size.percent().map_or_else(|| auto.clone(), |pct| format!("{pct}%"));
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Interface size:"));
|
||||
@@ -728,6 +773,7 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::i18n::{AUTO, Lang};
|
||||
// The language names are in their own scripts: fonts for them load from the next frame.
|
||||
app.want_system_cjk = true;
|
||||
app.want_system_arabic = true;
|
||||
let system = crate::i18n::system_lang();
|
||||
let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]);
|
||||
let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() };
|
||||
|
||||
@@ -29,6 +29,8 @@ 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};
|
||||
use host::Content;
|
||||
#[cfg(test)]
|
||||
pub(crate) use host::apply;
|
||||
pub use host::show;
|
||||
pub(crate) use host::{dock_command, overlay_open};
|
||||
pub(crate) use inspector::describe_props;
|
||||
@@ -153,7 +155,7 @@ impl PaneContent<Pane> for Content<'_> {
|
||||
|
||||
fn body(&mut self, ui: &mut Ui, pane: &Pane) {
|
||||
let app = &mut *self.app;
|
||||
match pane {
|
||||
crate::rtl::root(ui, |ui| match pane {
|
||||
Pane::SmartArtText => crate::dialogs_smart_art::text_pane(app, ui),
|
||||
Pane::Navigation => nav(app, ui),
|
||||
Pane::Clipboard => clipboard(app, ui),
|
||||
@@ -167,7 +169,7 @@ impl PaneContent<Pane> for Content<'_> {
|
||||
Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui),
|
||||
Pane::Proofing => crate::proofing_pane::show(app, ui),
|
||||
Pane::Comments => comments(app, ui),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn default_state(&self, pane: &Pane) -> PaneState {
|
||||
|
||||
@@ -74,6 +74,8 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest<Pane>) {
|
||||
match request {
|
||||
PaneRequest::Close(pane) => close(app, pane),
|
||||
PaneRequest::Place(Pane::Styles, place) => {
|
||||
// Physical sides: the host is not RTL-flipped, and Dock Left / Dock Right mean the
|
||||
// window's left and right (same as `ui.paneDock` / `view.stylesPaneDock`).
|
||||
let dock = match place {
|
||||
Place::Docked(Edge::Start) => "left",
|
||||
Place::Docked(Edge::End) => "right",
|
||||
@@ -138,6 +140,7 @@ pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result<Value, String
|
||||
if let Some(dock) = dock {
|
||||
app.ui.pane_places.entry(key.into()).or_default().dock = Some(dock);
|
||||
}
|
||||
// Report the physical side the pane occupies (matches what was stored / requested).
|
||||
let name = match pane_state(app, pane).place {
|
||||
Place::Docked(Edge::Start) => "left",
|
||||
Place::Floating => "float",
|
||||
|
||||
@@ -84,7 +84,7 @@ fn level_row(ui: &mut Ui, t: &Tokens, caption: &str, value: &str, dim: bool, tip
|
||||
let text = egui::RichText::new(value).font(regular(12.5)).color(if dim { t.text_disabled } else { t.text });
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Min), |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), egui::Sense::click());
|
||||
let enabled = !disabled;
|
||||
if enabled && resp.hovered() {
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(super) fn clipboard(app: &mut WordApp, ui: &mut Ui) {
|
||||
let _ = app.run("edit.pasteClipboardItem", json!({"index": i}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Min), |ui| {
|
||||
if ui.small_button(tl!("Delete")).clicked() {
|
||||
let _ = app.run("edit.deleteClipboardItem", json!({"index": i}));
|
||||
app.canvas.want_focus = true;
|
||||
@@ -87,14 +87,14 @@ pub(super) fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
for (id, name, is_char, builtin) in list {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.selectable_label(id == cur, &name);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(if is_char { "a" } else { "¶" }).weak());
|
||||
});
|
||||
if r.clicked() {
|
||||
let _ = app.run("para.style", json!({"style": id}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
r.context_menu(|ui| {
|
||||
crate::rtl::context_menu(&r, |ui| {
|
||||
if ui.button(tl!("Modify…")).clicked() {
|
||||
app.dialog = crate::dialogs::Dialog::modify_style(app, &id);
|
||||
ui.close();
|
||||
|
||||
@@ -114,19 +114,46 @@ pub(crate) fn float_rect(v: &Value) -> Option<[f32; 4]> {
|
||||
|
||||
/// A pane's place and size: Styles' from `view.stylesPaneDock` and its own preferences, the
|
||||
/// others' from `panePlaces`.
|
||||
///
|
||||
/// `left` / `right` in the docking API and in saved prefs are **physical** window sides (and the
|
||||
/// Dock Left / Dock Right menus mean the same). Only an unset default place is mirrored in a
|
||||
/// right-to-left interface, so a pane that has never been docked still opens on the reading-start
|
||||
/// side.
|
||||
pub(super) fn pane_state(app: &WordApp, pane: Pane) -> PaneState {
|
||||
let (mut state, explicit) = stored_state(app, pane);
|
||||
if !explicit {
|
||||
state.place = mirror_default(state.place);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
/// Flip a default dock edge in a right-to-left interface so Start (reading start in LTR) opens on
|
||||
/// the physical right. Explicit and saved left/right are never flipped — see [`pane_state`].
|
||||
pub(super) fn mirror_default(place: Place) -> Place {
|
||||
match place {
|
||||
Place::Docked(Edge::Start) if crate::rtl::on() => Place::Docked(Edge::End),
|
||||
Place::Docked(Edge::End) if crate::rtl::on() => Place::Docked(Edge::Start),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical place from prefs, and whether that place was explicitly saved (`true`) or is still the
|
||||
/// pane's default (`false`).
|
||||
fn stored_state(app: &WordApp, pane: Pane) -> (PaneState, bool) {
|
||||
if pane == Pane::Styles {
|
||||
return styles_state(app);
|
||||
return (styles_state(app), true);
|
||||
}
|
||||
let default = pane.default_state();
|
||||
let Some(saved) = app.ui.pane_places.get(pane.key()) else { return default };
|
||||
let place = match saved.dock {
|
||||
Some(SavedDock::Left) => Place::Docked(Edge::Start),
|
||||
Some(SavedDock::Right) => Place::Docked(Edge::End),
|
||||
Some(SavedDock::Float) => Place::Floating,
|
||||
None => default.place,
|
||||
let Some(saved) = app.ui.pane_places.get(pane.key()) else {
|
||||
return (default, false);
|
||||
};
|
||||
PaneState { place, size: saved.width.or(default.size), float_rect: saved.float }
|
||||
let (place, explicit) = match saved.dock {
|
||||
Some(SavedDock::Left) => (Place::Docked(Edge::Start), true),
|
||||
Some(SavedDock::Right) => (Place::Docked(Edge::End), true),
|
||||
Some(SavedDock::Float) => (Place::Floating, true),
|
||||
None => (default.place, false),
|
||||
};
|
||||
(PaneState { place, size: saved.width.or(default.size), float_rect: saved.float }, explicit)
|
||||
}
|
||||
|
||||
/// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756).
|
||||
|
||||
@@ -378,6 +378,37 @@ fn pane_dock_command_docks_floats_and_reads_each_pane() {
|
||||
assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating, "junk changed nothing");
|
||||
}
|
||||
|
||||
/// Explicit `left` / `right` stay on the physical window sides in an Arabic (RTL) interface;
|
||||
/// defaults still flip to the reading-start side. Covers `ui.paneDock` and `view.stylesPaneDock`.
|
||||
#[test]
|
||||
fn pane_dock_left_and_right_are_physical_in_rtl() {
|
||||
use craft_ui::panes::{Edge, Place};
|
||||
use serde_json::json;
|
||||
let arabic = crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n");
|
||||
crate::i18n::set_current(arabic);
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default());
|
||||
// Defaults: Navigation's Start opens on the physical right (reading start) until set.
|
||||
assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Docked(Edge::End));
|
||||
assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap()["dock"], "right");
|
||||
// Explicit left stays physical left and is reported as left.
|
||||
assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation", "dock": "left"})).unwrap()["dock"], "left");
|
||||
assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Docked(Edge::Start));
|
||||
assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap()["dock"], "left");
|
||||
assert_eq!(app.run("ui.paneDock", json!({"pane": "comments", "dock": "right"})).unwrap()["dock"], "right");
|
||||
assert_eq!(super::pane_state(&app, Pane::Comments).place, Place::Docked(Edge::End));
|
||||
// Styles through view.stylesPaneDock — same physical contract.
|
||||
assert_eq!(app.run("view.stylesPaneDock", json!({"dock": "left"})).unwrap()["dock"], "left");
|
||||
assert_eq!(super::pane_state(&app, Pane::Styles).place, Place::Docked(Edge::Start));
|
||||
assert_eq!(app.run("view.stylesPaneDock", json!({})).unwrap()["dock"], "left");
|
||||
assert_eq!(app.run("view.stylesPaneDock", json!({"dock": "right"})).unwrap()["dock"], "right");
|
||||
assert_eq!(super::pane_state(&app, Pane::Styles).place, Place::Docked(Edge::End));
|
||||
// A host Place request (Dock Left menu / drag) saves physical left without flipping.
|
||||
super::apply(&mut app, craft_ui::panes::PaneRequest::Place(Pane::Clipboard, Place::Docked(Edge::Start)));
|
||||
assert_eq!(app.ui.pane_places.get("clipboard").and_then(|p| p.dock), Some(super::SavedDock::Left));
|
||||
assert_eq!(super::pane_state(&app, Pane::Clipboard).place, Place::Docked(Edge::Start));
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
}
|
||||
|
||||
/// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses
|
||||
/// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled.
|
||||
#[test]
|
||||
|
||||
@@ -399,7 +399,7 @@ pub fn ink_mask(img: &mut egui::ColorImage) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A font list entry: `name` in its own face, 240×20 pt. The face is read for the preview alone
|
||||
/// A font list entry: `name` in its own face, 244×22 pt. The face is read for the preview alone
|
||||
/// and dropped (#848): browsing the list used to load every family shown for good, so each trip
|
||||
/// through the Font box grew memory by tens of megabytes. A name the font has no glyphs for (a
|
||||
/// symbol font's), or a font that can't be read, shows in the default face.
|
||||
@@ -409,8 +409,36 @@ pub fn font_entry_image(name: &str, ppp: f32) -> egui::ColorImage {
|
||||
.preview_face(name)
|
||||
.filter(|f| name.chars().filter(|c| !c.is_whitespace()).all(|c| f.covers(c)))
|
||||
.unwrap_or_else(|| db.face(wordcraft_fonts::FALLBACK_FAMILY, "Regular"));
|
||||
let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, 22.0, 2.0, ppp);
|
||||
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight())
|
||||
// Rendered taller than the row and cut back to the drawn text: faces with tall line metrics
|
||||
// (Arabic, CJK) put their baseline well below a Latin face's, and a fixed 22 pt strip showed
|
||||
// only the top of their names.
|
||||
let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, FONT_PREVIEW_RENDER_H, 2.0, ppp);
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
|
||||
center_rows_on_ink(&img, (FONT_PREVIEW_H * ppp).round() as usize)
|
||||
}
|
||||
|
||||
/// Height of a font-menu entry's picture, in points.
|
||||
const FONT_PREVIEW_H: f32 = 22.0;
|
||||
/// Height the entry is first rendered at, tall enough for any face's first line at 13 pt.
|
||||
const FONT_PREVIEW_RENDER_H: f32 = 56.0;
|
||||
|
||||
/// `img` cut to `rows` pixel rows centred on what is drawn in it (the rows that differ from the
|
||||
/// top-left background pixel). An image that is already that short, or blank, keeps its top rows.
|
||||
fn center_rows_on_ink(img: &egui::ColorImage, rows: usize) -> egui::ColorImage {
|
||||
let [w, h] = img.size;
|
||||
let rows = rows.clamp(1, h.max(1));
|
||||
if w == 0 || h <= rows {
|
||||
return img.clone();
|
||||
}
|
||||
let background = img.pixels.first().copied().unwrap_or_default();
|
||||
let inked =
|
||||
|y: usize| img.pixels.get(y.saturating_mul(w)..y.saturating_add(1).saturating_mul(w)).is_some_and(|row| row.iter().any(|p| *p != background));
|
||||
let top = (0..h).find(|y| inked(*y)).unwrap_or(0);
|
||||
let bottom = (0..h).rev().find(|y| inked(*y)).unwrap_or(top);
|
||||
let middle = top.saturating_add(bottom).saturating_add(1) / 2;
|
||||
let start = middle.saturating_sub(rows / 2).min(h.saturating_sub(rows));
|
||||
let pixels = img.pixels.get(start.saturating_mul(w)..start.saturating_add(rows).saturating_mul(w)).unwrap_or_default().to_vec();
|
||||
egui::ColorImage::new([w, rows], pixels)
|
||||
}
|
||||
|
||||
/// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it.
|
||||
@@ -684,3 +712,45 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[test]
|
||||
fn font_preview_rows_are_centred_on_the_drawn_text() {
|
||||
let ink = egui::Color32::BLACK;
|
||||
let paper = egui::Color32::WHITE;
|
||||
// Ink low in a tall picture, as a face with a deep baseline draws it.
|
||||
let mut img = egui::ColorImage::filled([4, 40], paper);
|
||||
for y in 28..34 {
|
||||
img.pixels[y * 4 + 1] = ink;
|
||||
}
|
||||
let cut = center_rows_on_ink(&img, 10);
|
||||
assert_eq!(cut.size, [4, 10]);
|
||||
let rows: Vec<usize> = (0..10).filter(|y| cut.pixels[y * 4 + 1] == ink).collect();
|
||||
assert_eq!(rows, [2, 3, 4, 5, 6, 7], "all six inked rows, with equal space above and below");
|
||||
// Blank, short, empty and oversized requests never panic.
|
||||
assert_eq!(center_rows_on_ink(&egui::ColorImage::filled([4, 40], paper), 10).size, [4, 10]);
|
||||
assert_eq!(center_rows_on_ink(&img, 400).size, [4, 40]);
|
||||
assert_eq!(center_rows_on_ink(&img, 0).size, [4, 1]);
|
||||
assert_eq!(center_rows_on_ink(&egui::ColorImage::filled([0, 0], paper), 10).size, [0, 0]);
|
||||
}
|
||||
|
||||
/// Every loaded face's name fits its font-menu row: faces with tall line metrics (the
|
||||
/// craft-fonts Arabic and Japanese faces) were cut off at the bottom.
|
||||
#[test]
|
||||
fn font_preview_shows_the_whole_name_in_every_loaded_face() {
|
||||
let ppp = 2.0;
|
||||
let rows = (FONT_PREVIEW_H * ppp).round() as usize;
|
||||
for family in wordcraft_fonts::FontDb::with_font_dirs(Vec::new()).families() {
|
||||
let face = wordcraft_fonts::FontDb::global().face(&family, "Regular");
|
||||
let tall = wordcraft_render::render_text_line(&face, &family, 13.0, 244.0, FONT_PREVIEW_RENDER_H, 2.0, ppp);
|
||||
let tall = egui::ColorImage::from_rgba_unmultiplied([tall.width as usize, tall.height as usize], &tall.to_straight());
|
||||
let img = center_rows_on_ink(&tall, rows);
|
||||
let [w, h] = img.size;
|
||||
assert_eq!(h, rows, "{family}");
|
||||
let background = img.pixels[0];
|
||||
let inked = |y: usize| img.pixels[y * w..(y + 1) * w].iter().any(|p| *p != background);
|
||||
assert!((0..h).any(inked), "{family}: the name is drawn");
|
||||
assert!(!inked(0) && !inked(h - 1), "{family}: the name touches the edge of its row");
|
||||
// Nothing of the name is left outside the cut.
|
||||
let all = (0..tall.size[1]).filter(|y| tall.pixels[y * w..(y + 1) * w].iter().any(|p| *p != tall.pixels[0])).count();
|
||||
assert_eq!((0..h).filter(|y| inked(*y)).count(), all, "{family}: part of the name is cut off");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +156,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let current = app.session.current_pages(&lay);
|
||||
app.ui.print.preview = app.ui.print.preview.min(total.saturating_sub(1));
|
||||
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(380.0);
|
||||
let settings = |app: &mut WordApp, ui: &mut Ui| {
|
||||
{
|
||||
ui.set_width(SETTINGS_WIDTH);
|
||||
// The settings scroll on their own: the preview beside them keeps the whole height (#1156).
|
||||
// egui draws the bar at the right edge, over the right-aligned controls of a mirrored
|
||||
// column: a solid bar there takes its own gutter instead of floating over them.
|
||||
if crate::rtl::flows(ui) {
|
||||
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
|
||||
}
|
||||
egui::ScrollArea::vertical().id_salt("print_settings").show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
let native = app.services.print_native.is_some();
|
||||
@@ -198,7 +203,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
|
||||
if native {
|
||||
if app.ui.print.copies > 1 {
|
||||
ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies")));
|
||||
ui.add_enabled_ui(!app.printing.busy(), |ui| ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies")));
|
||||
}
|
||||
} else {
|
||||
let copies_label = ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
|
||||
@@ -255,12 +260,37 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
export_formats(app, ui);
|
||||
});
|
||||
});
|
||||
ui.add_space(24.0);
|
||||
ui.vertical(|ui| preview(app, ui, &lay, total));
|
||||
}
|
||||
};
|
||||
// A right-to-left interface splits the room by hand, settings on the side reading starts from:
|
||||
// a `horizontal_top` row there lays the preview over the settings.
|
||||
if crate::rtl::flows(ui) {
|
||||
let room = ui.available_rect_before_wrap();
|
||||
let split = (room.max.x - SETTINGS_WIDTH).max(room.min.x);
|
||||
let settings_rect = Rect::from_min_max(pos2(split, room.min.y), room.max);
|
||||
let preview_rect = Rect::from_min_max(room.min, pos2((split - SETTINGS_GAP).max(room.min.x), room.max.y));
|
||||
let layout = egui::Layout::top_down(egui::Align::Max);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(settings_rect).layout(layout), |ui| settings(app, ui));
|
||||
// A window too narrow for a usable preview shows the settings alone.
|
||||
if preview_rect.width() >= MIN_PREVIEW_WIDTH {
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(preview_rect).layout(layout), |ui| preview(app, ui, &lay, total));
|
||||
}
|
||||
ui.allocate_rect(room, Sense::hover());
|
||||
return;
|
||||
}
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
crate::rtl::vertical(ui, |ui| settings(app, ui));
|
||||
ui.add_space(SETTINGS_GAP);
|
||||
crate::rtl::vertical(ui, |ui| preview(app, ui, &lay, total));
|
||||
});
|
||||
}
|
||||
|
||||
/// The settings column's width, and the space between it and the preview.
|
||||
const SETTINGS_WIDTH: f32 = 380.0;
|
||||
const SETTINGS_GAP: f32 = 24.0;
|
||||
/// The narrowest preview pane worth drawing when the layout is mirrored.
|
||||
const MIN_PREVIEW_WIDTH: f32 = 200.0;
|
||||
|
||||
/// Room around the page inside the preview pane.
|
||||
const PREVIEW_MARGIN: f32 = 10.0;
|
||||
/// The preview's bottom bar: the page indicator on the left, the zoom on the right.
|
||||
@@ -301,6 +331,7 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
|
||||
ui.painter().image(tex.id(), on_sheet, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
});
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_height(PREVIEW_BAR);
|
||||
let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| {
|
||||
@@ -313,21 +344,26 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
|
||||
icons::paint(ui.painter(), r.shrink(4.0), icon, col, col);
|
||||
enabled && resp.on_hover_text(tip).clicked()
|
||||
};
|
||||
if nav(ui, "chevronLeft", tl!("Previous Page"), page > 0) {
|
||||
if nav(ui, if rtl { "chevronRight" } else { "chevronLeft" }, tl!("Previous Page"), page > 0) {
|
||||
app.ui.print.preview = page - 1;
|
||||
}
|
||||
ui.label(egui::RichText::new(&label).font(regular(13.0)));
|
||||
if nav(ui, "chevronRight", tl!("Next Page"), page + 1 < total) {
|
||||
if nav(ui, if rtl { "chevronLeft" } else { "chevronRight" }, tl!("Next Page"), page + 1 < total) {
|
||||
app.ui.print.preview = page + 1;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
let z = scale / crate::canvas::PX_PER_PT;
|
||||
if nav(ui, "onePage", tl!("One Page"), true) {
|
||||
app.ui.print.zoom = None;
|
||||
}
|
||||
ui.label(egui::RichText::new(format!("{}%", (z * 100.0).round())).font(regular(13.0)));
|
||||
if nav(ui, "plus", tl!("Zoom In"), z < 5.0) {
|
||||
app.ui.print.zoom = Some(zoom_step(z, true));
|
||||
// The slider grows to the right in every language, as in the status bar: minus at its
|
||||
// left end, plus at its right. This cluster fills from the far end inwards.
|
||||
let (inner, outer) = (("minus", "Zoom Out", false), ("plus", "Zoom In", true));
|
||||
let (near, far) = if rtl { (inner, outer) } else { (outer, inner) };
|
||||
let enabled = |up: bool| if up { z < 5.0 } else { z > 0.1 };
|
||||
if nav(ui, near.0, tl!(near.1), enabled(near.2)) {
|
||||
app.ui.print.zoom = Some(zoom_step(z, near.2));
|
||||
}
|
||||
// A narrow window shortens the slider, then drops it, before it covers the page indicator.
|
||||
let room = ui.available_width() - 26.0 - 2.0 * ui.spacing().item_spacing.x - 12.0;
|
||||
@@ -340,8 +376,8 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
|
||||
app.ui.print.zoom = Some(v.clamp(0.1, 5.0));
|
||||
}
|
||||
}
|
||||
if nav(ui, "minus", tl!("Zoom Out"), z > 0.1) {
|
||||
app.ui.print.zoom = Some(zoom_step(z, false));
|
||||
if nav(ui, far.0, tl!(far.1), enabled(far.2)) {
|
||||
app.ui.print.zoom = Some(zoom_step(z, far.2));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,6 +373,7 @@ mod tests {
|
||||
#[test]
|
||||
#[ignore = "offscreen visual review; requires a software or hardware graphics adapter"]
|
||||
fn spelling_review_dialog_preview() {
|
||||
let _gpu = crate::table_resize::tests::gpu_turn();
|
||||
let mut a = app("The quick brwn fox.");
|
||||
a.run("review.spelling", json!({})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a);
|
||||
|
||||
+219
-170
@@ -130,184 +130,200 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.exact_size(30.0)
|
||||
.frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// Share, Editing mode and Comments keep their room; the tabs get what is left of it
|
||||
// (#897, #2026), and the buttons give up their labels before a tab is drawn over them.
|
||||
let share = tl!("Share");
|
||||
let editing_label = if app.session.doc.settings.track_changes { tl!("Reviewing") } else { tl!("Editing") };
|
||||
let comments = tl!("Comments");
|
||||
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
|
||||
let spaced = |w: f32| w + 2.0;
|
||||
let button_widths = |level: usize| -> f32 {
|
||||
let labels = level == 0;
|
||||
let share_full = (share_w + 36.0).max(74.0);
|
||||
let share_button = if level < 2 { share_full } else { 30.0 };
|
||||
let editing = crate::widgets::icon_text_button_width(ui, editing_label, regular(12.0), true, labels);
|
||||
let comment = crate::widgets::icon_text_button_width(ui, comments, regular(12.0), false, labels);
|
||||
// The gaps (6 + 4 + 8) and the strip's margin.
|
||||
spaced(share_button) + spaced(editing) + spaced(comment) + 18.0 + 12.0
|
||||
};
|
||||
let button_levels = [button_widths(0), button_widths(1), button_widths(2)];
|
||||
let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect();
|
||||
let specs: Vec<TabSpec> = tabs
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|((tab, custom), shown)| TabSpec {
|
||||
text_w: ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x),
|
||||
file: tab == "File",
|
||||
active: *tab == app.ui.tab,
|
||||
contextual: !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()),
|
||||
})
|
||||
.collect();
|
||||
let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// No document open (#1129): nothing to share, edit or comment on — but only
|
||||
// these buttons. Disabling the whole line took the tab strip with it: the
|
||||
// tabs took clicks that did nothing, so the empty window had no way back to
|
||||
// the Start screen (#3045).
|
||||
ui.add_enabled_ui(app.has_document(), |ui| {
|
||||
let (share_size, share_text) =
|
||||
if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
let icon_x = if share_text { r.min.x + 14.0 } else { r.center().x };
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)),
|
||||
"share",
|
||||
t.on_accent,
|
||||
t.on_accent,
|
||||
);
|
||||
if share_text {
|
||||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share));
|
||||
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let track = app.session.doc.settings.track_changes;
|
||||
let mode = if plan.buttons == 0 {
|
||||
crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0))
|
||||
} else {
|
||||
crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true)
|
||||
};
|
||||
egui::Popup::menu(&mode).show(|ui| {
|
||||
if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": false}));
|
||||
ui.close();
|
||||
crate::rtl::root(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// Share, Editing mode and Comments keep their room; the tabs get what is left of it
|
||||
// (#897, #2026), and the buttons give up their labels before a tab is drawn over them.
|
||||
let share = tl!("Share");
|
||||
let editing_label = if app.session.doc.settings.track_changes { tl!("Reviewing") } else { tl!("Editing") };
|
||||
let comments = tl!("Comments");
|
||||
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
|
||||
let spaced = |w: f32| w + 2.0;
|
||||
let button_widths = |level: usize| -> f32 {
|
||||
let labels = level == 0;
|
||||
let share_full = (share_w + 36.0).max(74.0);
|
||||
let share_button = if level < 2 { share_full } else { 30.0 };
|
||||
let editing = crate::widgets::icon_text_button_width(ui, editing_label, regular(12.0), true, labels);
|
||||
let comment = crate::widgets::icon_text_button_width(ui, comments, regular(12.0), false, labels);
|
||||
// The gaps (6 + 4 + 8) and the strip's margin.
|
||||
spaced(share_button) + spaced(editing) + spaced(comment) + 18.0 + 12.0
|
||||
};
|
||||
let button_levels = [button_widths(0), button_widths(1), button_widths(2)];
|
||||
let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect();
|
||||
let specs: Vec<TabSpec> = tabs
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|((tab, custom), shown)| TabSpec {
|
||||
text_w: ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x),
|
||||
file: tab == "File",
|
||||
active: *tab == app.ui.tab,
|
||||
contextual: !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()),
|
||||
})
|
||||
.collect();
|
||||
let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0);
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
// No document open (#1129): nothing to share, edit or comment on — but only
|
||||
// these buttons. Disabling the whole line took the tab strip with it: the
|
||||
// tabs took clicks that did nothing, so the empty window had no way back to
|
||||
// the Start screen (#3045).
|
||||
ui.add_enabled_ui(app.has_document(), |ui| {
|
||||
let (share_size, share_text) =
|
||||
if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
let rtl = crate::rtl::on();
|
||||
let icon_x = if share_text { crate::rtl::lead_x(rtl, r, 14.0) } else { r.center().x };
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)),
|
||||
"share",
|
||||
t.on_accent,
|
||||
t.on_accent,
|
||||
);
|
||||
if share_text {
|
||||
ui.painter().text(
|
||||
pos2(crate::rtl::lead_x(rtl, r, 26.0), r.center().y),
|
||||
crate::rtl::lead_center(rtl),
|
||||
share,
|
||||
medium(12.0),
|
||||
t.on_accent,
|
||||
);
|
||||
}
|
||||
if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": true}));
|
||||
ui.close();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share));
|
||||
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let track = app.session.doc.settings.track_changes;
|
||||
let mode = if plan.buttons == 0 {
|
||||
crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0))
|
||||
} else {
|
||||
crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true)
|
||||
};
|
||||
crate::rtl::menu(&mode).show(|ui| {
|
||||
if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": false}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": true}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let comments_button = if plan.buttons == 0 {
|
||||
crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0))
|
||||
} else {
|
||||
crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false)
|
||||
};
|
||||
if comments_button.clicked() {
|
||||
let _ = app.run("view.commentsPane", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let comments_button = if plan.buttons == 0 {
|
||||
crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0))
|
||||
ui.add_space(8.0);
|
||||
// The tabs fill the room left of the cluster, from the side reading starts from.
|
||||
// (By language: this row sits in the far-side cluster's layout, which fills from the other end.)
|
||||
let tabs_layout = if crate::rtl::on() {
|
||||
egui::Layout::right_to_left(egui::Align::Center)
|
||||
} else {
|
||||
crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false)
|
||||
egui::Layout::left_to_right(egui::Align::Center)
|
||||
};
|
||||
if comments_button.clicked() {
|
||||
let _ = app.run("view.commentsPane", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
// The tabs that don't fit go into a » menu at the end of the strip (#897, #2026).
|
||||
for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) {
|
||||
let (tab, custom) = (tab.as_str(), *custom);
|
||||
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab);
|
||||
// A custom tab's name is the user's own text: shown as typed.
|
||||
let shown = if custom { tab } else { tl!(tab) };
|
||||
let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0;
|
||||
let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
if app.ui.keytips == crate::keytips::Phase::Tabs {
|
||||
crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects);
|
||||
} else if app.ui.keytips == crate::keytips::Phase::Legacy {
|
||||
// Every tab: they only place the legacy keys' hint (#1697).
|
||||
crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects);
|
||||
ui.with_layout(tabs_layout, |ui| {
|
||||
// The tabs that don't fit go into a » menu at the end of the strip (#897, #2026).
|
||||
for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) {
|
||||
let (tab, custom) = (tab.as_str(), *custom);
|
||||
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab);
|
||||
// A custom tab's name is the user's own text: shown as typed.
|
||||
let shown = if custom { tab } else { tl!(tab) };
|
||||
let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0;
|
||||
let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
if app.ui.keytips == crate::keytips::Phase::Tabs {
|
||||
crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects);
|
||||
} else if app.ui.keytips == crate::keytips::Phase::Legacy {
|
||||
// Every tab: they only place the legacy keys' hint (#1697).
|
||||
crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects);
|
||||
}
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
let color = if contextual || active { t.accent_text } else { t.text };
|
||||
if cut {
|
||||
let mut job = egui::text::LayoutJob::single_section(
|
||||
shown.to_string(),
|
||||
egui::text::TextFormat::simple(if active { semibold(12.5) } else { medium(12.5) }, color),
|
||||
);
|
||||
job.wrap = egui::text::TextWrapping {
|
||||
max_width: (w - plan.pad).max(1.0),
|
||||
max_rows: 1,
|
||||
break_anywhere: true,
|
||||
overflow_character: Some('…'),
|
||||
};
|
||||
let galley = ui.painter().layout_job(job);
|
||||
ui.painter().galley(r.center() - galley.size() / 2.0, galley, color);
|
||||
} else {
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
shown,
|
||||
if active { semibold(12.5) } else { medium(12.5) },
|
||||
color,
|
||||
);
|
||||
}
|
||||
let resp = if cut { resp.on_hover_text(shown) } else { resp };
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
ui.painter().rect_filled(u, 2.0, t.accent);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
}
|
||||
if resp.double_clicked() && tab != "File" {
|
||||
let _ = app.run("ui.collapseRibbon", json!({}));
|
||||
}
|
||||
resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
|
||||
}
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
let color = if contextual || active { t.accent_text } else { t.text };
|
||||
if cut {
|
||||
let mut job = egui::text::LayoutJob::single_section(
|
||||
shown.to_string(),
|
||||
egui::text::TextFormat::simple(if active { semibold(12.5) } else { medium(12.5) }, color),
|
||||
);
|
||||
job.wrap = egui::text::TextWrapping {
|
||||
max_width: (w - plan.pad).max(1.0),
|
||||
max_rows: 1,
|
||||
break_anywhere: true,
|
||||
overflow_character: Some('…'),
|
||||
};
|
||||
let galley = ui.painter().layout_job(job);
|
||||
ui.painter().galley(r.center() - galley.size() / 2.0, galley, color);
|
||||
} else {
|
||||
let left_out: Vec<(&str, &str)> = tabs
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.zip(&plan.fits)
|
||||
.filter(|(_, fits)| !**fits)
|
||||
.map(|(((tab, _), shown), _)| (tab.as_str(), *shown))
|
||||
.collect();
|
||||
if !left_out.is_empty() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
// The active tab is in the menu: the button says so.
|
||||
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
shown,
|
||||
if active { semibold(12.5) } else { medium(12.5) },
|
||||
color,
|
||||
"»",
|
||||
medium(12.5),
|
||||
if holds_active { t.accent_text } else { t.text },
|
||||
);
|
||||
}
|
||||
let resp = if cut { resp.on_hover_text(shown) } else { resp };
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
ui.painter().rect_filled(u, 2.0, t.accent);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
}
|
||||
if resp.double_clicked() && tab != "File" {
|
||||
let _ = app.run("ui.collapseRibbon", json!({}));
|
||||
}
|
||||
resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
|
||||
}
|
||||
let left_out: Vec<(&str, &str)> = tabs
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.zip(&plan.fits)
|
||||
.filter(|(_, fits)| !**fits)
|
||||
.map(|(((tab, _), shown), _)| (tab.as_str(), *shown))
|
||||
.collect();
|
||||
if !left_out.is_empty() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
// The active tab is in the menu: the button says so.
|
||||
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"»",
|
||||
medium(12.5),
|
||||
if holds_active { t.accent_text } else { t.text },
|
||||
);
|
||||
// "»" means nothing read aloud: the button says what it holds.
|
||||
let more = tl!("More tabs");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more));
|
||||
let resp = resp.on_hover_text(more);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for (tab, shown) in left_out {
|
||||
if ui.selectable_label(app.ui.tab == tab, shown).clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
ui.close();
|
||||
// "»" means nothing read aloud: the button says what it holds.
|
||||
let more = tl!("More tabs");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more));
|
||||
let resp = resp.on_hover_text(more);
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
for (tab, shown) in left_out {
|
||||
if ui.selectable_label(app.ui.tab == tab, shown).clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
if app.ui.ribbon_collapsed {
|
||||
return;
|
||||
@@ -325,19 +341,32 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.disable();
|
||||
}
|
||||
let bg = ui.interact(ui.max_rect(), ui.scope_id().with("ribbon_background"), Sense::click());
|
||||
bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
|
||||
crate::rtl::context_menu(&bg, |ui| crate::rtl::root(ui, |ui| crate::widgets::customize_menu_items(ui, app)));
|
||||
let entry = user_tabs.iter().find(|e| e.name == app.ui.tab);
|
||||
crate::ribbon_fit::begin(ui.ctx(), &app.ui.tab, ui.available_width() - 4.0);
|
||||
// 0.37 keeps the scroll offset private: an edge-chevron click leaves a one-frame
|
||||
// request here and the next scroll area is built at that offset.
|
||||
let pending_id = ui.scope_id().with("ribbon_scroll_pending");
|
||||
let pending = ui.data(|d| d.get_temp::<f32>(pending_id));
|
||||
// Right to left, the groups start at the right edge. A scroll area's content is
|
||||
// unbounded, so the row gets a width: the view's, or last frame's content if wider.
|
||||
// The scroll position is kept as a distance from the right, where reading starts.
|
||||
let rtl = crate::rtl::on();
|
||||
let view_w = ui.available_width();
|
||||
let row_id = ui.scope_id().with(("ribbon_row", app.ui.tab.as_str()));
|
||||
let (content_w, from_right, last_over) = ui.data(|d| d.get_temp::<(f32, f32, f32)>(row_id)).unwrap_or((0.0, 0.0, 0.0));
|
||||
let row_w = content_w.max(view_w);
|
||||
let set_offset = (last_over - from_right).max(0.0);
|
||||
let mut area = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden);
|
||||
if let Some(x) = pending {
|
||||
if rtl {
|
||||
area = area.horizontal_scroll_offset(set_offset);
|
||||
} else if let Some(x) = pending {
|
||||
area = area.horizontal_scroll_offset(x);
|
||||
}
|
||||
let mut measured = 0.0;
|
||||
let scroll = area.show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
let size = vec2(if rtl { row_w } else { ui.available_size_before_wrap().x }, ui.available_size_before_wrap().y);
|
||||
let body = |ui: &mut Ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match entry {
|
||||
// Every tab hidden: nothing to show.
|
||||
@@ -371,8 +400,24 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
if let Some(e) = entry {
|
||||
custom_groups(app, ui, &e.groups);
|
||||
}
|
||||
});
|
||||
};
|
||||
if rtl {
|
||||
measured = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Min), body).response.rect.width();
|
||||
} else {
|
||||
crate::rtl::horizontal_top(ui, body);
|
||||
}
|
||||
});
|
||||
if rtl {
|
||||
let over = (scroll.content_size.x - scroll.inner_rect.width()).max(0.0);
|
||||
// Only the user's own scrolling moves the remembered distance: while the width
|
||||
// is still settling, the offset set above lags a frame behind.
|
||||
let scrolled = (scroll.state.clamped_offset().x - set_offset).abs() > 0.5 && set_offset <= over;
|
||||
let from_right = if scrolled { (over - scroll.state.clamped_offset().x).max(0.0) } else { from_right.min(over) };
|
||||
ui.data_mut(|d| d.insert_temp(row_id, (measured, from_right, over)));
|
||||
if (over - last_over).abs() > 0.5 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
if pending.is_some() {
|
||||
let _ = ui.data_mut(|d| d.remove_temp::<f32>(pending_id));
|
||||
}
|
||||
@@ -397,7 +442,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
if let Some(x) = next {
|
||||
ui.data_mut(|d| d.insert_temp(pending_id, x.clamp(0.0, overflow.max(0.0))));
|
||||
let x = x.clamp(0.0, overflow.max(0.0));
|
||||
if rtl {
|
||||
ui.data_mut(|d| d.insert_temp(row_id, (measured, (overflow - x).max(0.0), overflow.max(0.0))));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(pending_id, x));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,14 +55,16 @@ pub(super) fn wrap_menu(ui: &mut Ui, app: &mut WordApp) {
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Wrap Text Sides"), |ui| {
|
||||
for (l, s, v) in [
|
||||
("Both Sides", WrapText::BothSides, "bothSides"),
|
||||
("Left Only", WrapText::Left, "left"),
|
||||
("Right Only", WrapText::Right, "right"),
|
||||
("Largest Only", WrapText::Largest, "largest"),
|
||||
] {
|
||||
mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v}));
|
||||
}
|
||||
crate::rtl::root(ui, |ui| {
|
||||
for (l, s, v) in [
|
||||
("Both Sides", WrapText::BothSides, "bothSides"),
|
||||
("Left Only", WrapText::Left, "left"),
|
||||
("Right Only", WrapText::Right, "right"),
|
||||
("Largest Only", WrapText::Largest, "largest"),
|
||||
] {
|
||||
mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v}));
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
group(ui, "Layouts", None, app, |ui, app| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)), |ui| {
|
||||
for (k, layout) in SmartArtLayout::ALL.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click());
|
||||
let on = g.layout == layout;
|
||||
|
||||
@@ -66,7 +66,7 @@ pub(super) fn esc_cancelled(ui: &Ui, r: &egui::Response, id: egui::Id) -> bool {
|
||||
}
|
||||
|
||||
pub(super) fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
ui.vertical(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
add(ui);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ pub(super) fn mi_icon(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, i
|
||||
pub(super) fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) {
|
||||
let sc = crate::widgets::shortcut_text(app, id);
|
||||
let enabled = crate::widgets::enabled(app, id);
|
||||
let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0)));
|
||||
let resp = ui.add_enabled(enabled, crate::rtl::menu_item(ui, text, &sc, 200.0));
|
||||
if resp.clicked() {
|
||||
crate::widgets::run_from_ribbon(app, id, params);
|
||||
ui.close();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user