mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-04 09:20:15 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5808531e53 | ||
|
|
b2b988cdc8 | ||
|
|
1ff68d64e3 | ||
|
|
051fd875ef | ||
|
|
472ee9b04a |
@@ -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
|
||||
|
||||
+4
-2
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -337,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);
|
||||
@@ -407,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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+111
-72
@@ -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());
|
||||
@@ -449,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() {
|
||||
@@ -537,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() {
|
||||
@@ -678,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:"));
|
||||
@@ -735,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();
|
||||
|
||||
@@ -116,7 +116,7 @@ fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::
|
||||
if resp.clicked() {
|
||||
let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
crate::rtl::context_menu(&resp, |ui| {
|
||||
if ui.button(tl!("Remove Pen")).clicked() {
|
||||
let _ = app.run("draw.removePen", json!({"index": i}));
|
||||
ui.close();
|
||||
|
||||
@@ -68,9 +68,11 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
|
||||
mi_check(ui, app, "Draw Table", drawing, "table.draw", json!({}));
|
||||
mi(ui, app, "Convert Text to Table…", "table.fromText", json!({}));
|
||||
ui.menu_button(tl!("Quick Tables"), |ui| {
|
||||
mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"}));
|
||||
mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"}));
|
||||
mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"}));
|
||||
crate::rtl::root(ui, |ui| {
|
||||
mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"}));
|
||||
mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"}));
|
||||
mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"}));
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -205,7 +207,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
items.truncate(30);
|
||||
egui::Grid::new("syms").show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("syms"), |ui| {
|
||||
for (i, (c, font)) in items.iter().enumerate() {
|
||||
let b = ui.button(egui::RichText::new(c).size(16.0));
|
||||
let b = if font.is_empty() { b } else { b.on_hover_text(font) };
|
||||
|
||||
@@ -136,7 +136,7 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.label(text);
|
||||
});
|
||||
};
|
||||
egui::Grid::new("layout_para").spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("layout_para").spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y), |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Indent")).small().strong());
|
||||
ui.label("");
|
||||
spaced(ui, egui::RichText::new(tl!("Spacing")).small().strong());
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(crate) fn gradient_menu(ui: &mut Ui, app: &mut WordApp, fill: Option<&wordcr
|
||||
}
|
||||
for (title, tiles) in ["Light Variations", "Dark Variations"].into_iter().zip(shape_fill::variations(main)) {
|
||||
ui.label(egui::RichText::new(tl!(title)).small().weak());
|
||||
egui::Grid::new(title).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new(title).spacing(vec2(4.0, 4.0)), |ui| {
|
||||
for (i, (f, name)) in tiles.iter().enumerate() {
|
||||
if crate::format_shape::swatch(ui, f, name) {
|
||||
let _ = app.run("shape.fill", shape_fill::params(Some(f)));
|
||||
|
||||
@@ -5,7 +5,7 @@ pub(super) fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
let look = app.session.sel.focus.path.cell().and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look));
|
||||
group(ui, "Table Style Options", None, app, |ui, app| {
|
||||
let Some(l) = look else { return };
|
||||
egui::Grid::new("look").show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("look"), |ui| {
|
||||
for (row, items) in [
|
||||
[("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)],
|
||||
[("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)],
|
||||
@@ -37,14 +37,16 @@ pub(super) fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
.map(|s| (s.builtin, s.id.clone(), crate::i18n::style_name(s)))
|
||||
.collect();
|
||||
styles.sort_by_key(|(builtin, _, _)| *builtin);
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (_, id, name) in styles {
|
||||
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
|
||||
let _ = app.run("table.style", json!({"style": id}));
|
||||
crate::rtl::island(ui, |ui| {
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (_, id, name) in styles {
|
||||
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
|
||||
let _ = app.run("table.style", json!({"style": id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
let current = look.and_then(|_| {
|
||||
@@ -287,7 +289,7 @@ pub(super) fn table_layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, "Alignment", None, app, |ui, app| {
|
||||
egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)), |ui| {
|
||||
for row in [
|
||||
[("topLeft", "Align Top Left"), ("topCenter", "Align Top Center"), ("topRight", "Align Top Right")],
|
||||
[("centerLeft", "Align Center Left"), ("center", "Align Center"), ("centerRight", "Align Center Right")],
|
||||
|
||||
@@ -21,77 +21,82 @@ pub(super) fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) {
|
||||
.exact_size(46.0)
|
||||
.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(12, 6)).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0)));
|
||||
ui.separator();
|
||||
let w = app.session.view.read_column_width;
|
||||
let active = match w {
|
||||
None => "Auto",
|
||||
Some(v) if v <= 180.0 => "Narrow",
|
||||
Some(v) if v <= 240.0 => "Medium",
|
||||
Some(v) if v <= 350.0 => "Wide",
|
||||
_ => "Page",
|
||||
};
|
||||
ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| {
|
||||
for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] {
|
||||
if ui.selectable_label(active == name, tl!(name)).clicked() {
|
||||
let _ = app.run("view.readColumnWidth", json!({"value": value}));
|
||||
crate::rtl::root(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0)));
|
||||
ui.separator();
|
||||
let w = app.session.view.read_column_width;
|
||||
let active = match w {
|
||||
None => "Auto",
|
||||
Some(v) if v <= 180.0 => "Narrow",
|
||||
Some(v) if v <= 240.0 => "Medium",
|
||||
Some(v) if v <= 350.0 => "Wide",
|
||||
_ => "Page",
|
||||
};
|
||||
ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| {
|
||||
for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] {
|
||||
if ui.selectable_label(active == name, tl!(name)).clicked() {
|
||||
let _ = app.run("view.readColumnWidth", json!({"value": value}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let pct = (app.session.view.read_zoom * 100.0).round() as u32;
|
||||
ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| {
|
||||
for percent in [75, 90, 100, 110, 125, 150] {
|
||||
if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() {
|
||||
let _ = app.run("view.readTextSize", json!({"value": percent}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let spacing = app.session.view.read_line_spacing;
|
||||
let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×"));
|
||||
ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| {
|
||||
if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() {
|
||||
let _ = app.run("view.readLineSpacing", json!({"value": "document"}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let pct = (app.session.view.read_zoom * 100.0).round() as u32;
|
||||
ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| {
|
||||
for percent in [75, 90, 100, 110, 125, 150] {
|
||||
if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() {
|
||||
let _ = app.run("view.readTextSize", json!({"value": percent}));
|
||||
ui.close();
|
||||
for value in [1.0, 1.25, 1.5, 2.0] {
|
||||
if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() {
|
||||
let _ = app.run("view.readLineSpacing", json!({"value": value}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let spacing = app.session.view.read_line_spacing;
|
||||
let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×"));
|
||||
ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| {
|
||||
if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() {
|
||||
let _ = app.run("view.readLineSpacing", json!({"value": "document"}));
|
||||
ui.close();
|
||||
}
|
||||
for value in [1.0, 1.25, 1.5, 2.0] {
|
||||
if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() {
|
||||
let _ = app.run("view.readLineSpacing", json!({"value": value}));
|
||||
ui.close();
|
||||
});
|
||||
ui.separator();
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let colors = [
|
||||
(ReadPageColor::None, "None", "none"),
|
||||
(ReadPageColor::Sepia, "Sepia", "sepia"),
|
||||
(ReadPageColor::Inverse, "Inverse", "inverse"),
|
||||
];
|
||||
let current = app.session.view.read_page_color;
|
||||
let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1);
|
||||
ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| {
|
||||
for (color, name, value) in colors {
|
||||
if ui.selectable_label(current == color, tl!(name)).clicked() {
|
||||
let _ = app.run("view.readPageColor", json!({"value": value}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
// Headings to jump to, in the Navigation pane beside the columns (Outline view
|
||||
// is an editing layout, so it isn't offered here).
|
||||
if ui.button(tl!("Navigation Pane")).clicked() {
|
||||
let _ = app.run("view.navigationPane", json!({"value": true}));
|
||||
app.ui.nav_tab = "headings".into();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let colors =
|
||||
[(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")];
|
||||
let current = app.session.view.read_page_color;
|
||||
let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1);
|
||||
ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| {
|
||||
for (color, name, value) in colors {
|
||||
if ui.selectable_label(current == color, tl!(name)).clicked() {
|
||||
let _ = app.run("view.readPageColor", json!({"value": value}));
|
||||
ui.close();
|
||||
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Edit Document")).clicked() {
|
||||
let _ = app.run("view.printLayout", json!({}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
// Headings to jump to, in the Navigation pane beside the columns (Outline view
|
||||
// is an editing layout, so it isn't offered here).
|
||||
if ui.button(tl!("Navigation Pane")).clicked() {
|
||||
let _ = app.run("view.navigationPane", json!({"value": true}));
|
||||
app.ui.nav_tab = "headings".into();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Edit Document")).clicked() {
|
||||
let _ = app.run("view.printLayout", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Mirrored window layout for a right-to-left interface language (Arabic).
|
||||
//!
|
||||
//! egui has no global direction, but `ui.horizontal` runs right to left wherever the enclosing
|
||||
//! layout prefers it (`Layout::prefer_right_to_left`): a right-to-left row, or a column whose
|
||||
//! items are aligned to the right. So direction is inherited from a root: each panel, window and
|
||||
//! menu body is wrapped in [`root`], columns are opened with [`vertical`] (egui's `ui.vertical`
|
||||
//! always resets to left-aligned), and "far side" clusters use [`far_side`]. Widgets that paint
|
||||
//! their own parts ask [`flows`] where their leading edge is.
|
||||
//!
|
||||
//! An area that egui lays out left to right on its own (a `Grid`, a far-side cluster) stays left
|
||||
//! to right inside, because everything here follows the enclosing layout rather than the language.
|
||||
//! The document itself is never mirrored: its direction belongs to the document.
|
||||
|
||||
use egui::{Align, Align2, InnerResponse, Layout, Rect, Ui, UiBuilder, pos2};
|
||||
|
||||
use craft_ui::direction::{self, Direction};
|
||||
|
||||
fn dir(rtl: bool) -> Direction {
|
||||
if rtl { Direction::Rtl } else { Direction::Ltr }
|
||||
}
|
||||
|
||||
/// Install the interface direction on `ctx` when the language's has changed: the shared
|
||||
/// `craft_ui::direction` state, and egui's own chrome (check boxes and radio buttons with the mark
|
||||
/// at the reading start, submenus, scroll bars, title bars, the font combo) through
|
||||
/// `Style::right_to_left`.
|
||||
pub fn sync(ctx: &egui::Context) {
|
||||
let want = dir(on());
|
||||
if direction::HasDirection::direction(ctx) != want {
|
||||
direction::apply(ctx, want);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the interface language written right to left?
|
||||
pub fn on() -> bool {
|
||||
crate::i18n::current().rtl()
|
||||
}
|
||||
|
||||
/// Is `ui` part of the mirrored interface: a right-to-left language, in an area that lays its
|
||||
/// content out from the right (so rows opened in it run right to left)? Never in a left-to-right
|
||||
/// language, whose far-side clusters also fill from the right but are not mirrored.
|
||||
pub fn flows(ui: &Ui) -> bool {
|
||||
on() && ui.layout().prefer_right_to_left()
|
||||
}
|
||||
|
||||
/// Run a container's body (a panel, window, popup or menu, which egui starts left-aligned) in the
|
||||
/// interface's direction. In a left-to-right language this is just `add`.
|
||||
pub fn root<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
if !on() || flows(ui) {
|
||||
return add(ui);
|
||||
}
|
||||
ui.scope_builder(UiBuilder::new().layout(Layout::top_down(Align::Max)), add).inner
|
||||
}
|
||||
|
||||
/// `ui.vertical`, aligned to the side rows start from: a column inside a right-to-left area keeps
|
||||
/// its content on the right and its rows right to left.
|
||||
pub fn vertical<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
if flows(ui) { ui.scope_builder(UiBuilder::new().layout(Layout::top_down(Align::Max)), add) } else { ui.vertical(add) }
|
||||
}
|
||||
|
||||
/// `ui.horizontal_top`, with its items running from the side reading starts from: a row opened
|
||||
/// in a right-to-left area is a right-to-left layout of its own, so its first column sits at the
|
||||
/// right edge.
|
||||
pub fn horizontal_top<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
if flows(ui) { ui.scope_builder(UiBuilder::new().layout(Layout::right_to_left(Align::Min)), add) } else { ui.horizontal_top(add) }
|
||||
}
|
||||
|
||||
/// The layout for a row's items that belong at its far end (the side rows end on): filled from
|
||||
/// that end inwards.
|
||||
pub fn far_side(ui: &Ui, align: Align) -> Layout {
|
||||
if flows(ui) { Layout::left_to_right(align) } else { Layout::right_to_left(align) }
|
||||
}
|
||||
|
||||
/// A row layout in the direction of `ui`, for `allocate_ui_with_layout` and `with_layout`.
|
||||
pub fn row_layout(ui: &Ui, align: Align) -> Layout {
|
||||
if flows(ui) { Layout::right_to_left(align) } else { Layout::left_to_right(align) }
|
||||
}
|
||||
|
||||
/// The x coordinate `inset` points in from the leading edge of `rect` (left, or right when `rtl`).
|
||||
pub fn lead_x(rtl: bool, rect: Rect, inset: f32) -> f32 {
|
||||
let d = dir(rtl);
|
||||
direction::start_x(&d, rect) + direction::forward(&d) * inset
|
||||
}
|
||||
|
||||
/// The x coordinate `inset` points in from the trailing edge of `rect`.
|
||||
pub fn trail_x(rtl: bool, rect: Rect, inset: f32) -> f32 {
|
||||
lead_x(!rtl, rect, inset)
|
||||
}
|
||||
|
||||
/// Text anchored at its leading edge and vertically centred: left-centre, or right-centre when `rtl`.
|
||||
pub fn lead_center(rtl: bool) -> Align2 {
|
||||
direction::align2(&dir(rtl), Align2::LEFT_CENTER)
|
||||
}
|
||||
|
||||
/// Text anchored at its leading edge and top.
|
||||
pub fn lead_top(rtl: bool) -> Align2 {
|
||||
direction::align2(&dir(rtl), Align2::LEFT_TOP)
|
||||
}
|
||||
|
||||
/// Where to put something `width` wide so that it starts `inset` in from the leading edge of
|
||||
/// `rect`: its left x.
|
||||
pub fn lead_left(rtl: bool, rect: Rect, inset: f32, width: f32) -> f32 {
|
||||
if rtl { rect.max.x - inset - width } else { rect.min.x + inset }
|
||||
}
|
||||
|
||||
/// The leading top corner of `rect`.
|
||||
pub fn lead_corner(rtl: bool, rect: Rect) -> egui::Pos2 {
|
||||
pos2(lead_x(rtl, rect, 0.0), rect.min.y)
|
||||
}
|
||||
|
||||
/// A side panel on the side reading starts from (left; right in a right-to-left interface).
|
||||
pub fn start_panel(id: &'static str) -> egui::Panel {
|
||||
direction::start_panel(&dir(on()), id)
|
||||
}
|
||||
|
||||
/// A side panel on the side reading ends on (right; left in a right-to-left interface).
|
||||
pub fn end_panel(id: &'static str) -> egui::Panel {
|
||||
direction::end_panel(&dir(on()), id)
|
||||
}
|
||||
|
||||
/// `egui::Popup::menu`, with its items aligned to the side reading starts from.
|
||||
pub fn menu(response: &egui::Response) -> egui::Popup<'_> {
|
||||
let popup = egui::Popup::menu(response);
|
||||
if on() { popup.layout(menu_layout()) } else { popup }
|
||||
}
|
||||
|
||||
/// A right-to-left menu's layout: items against the right edge. Not justified, because egui
|
||||
/// starts a full-width button's text at its left.
|
||||
fn menu_layout() -> Layout {
|
||||
Layout::top_down(Align::Max)
|
||||
}
|
||||
|
||||
/// A menu item: `text`, with its keyboard shortcut at the far side. `min_width` keeps a menu's
|
||||
/// items one width.
|
||||
pub fn menu_item<'a>(ui: &Ui, text: &'a str, shortcut: &'a str, min_width: f32) -> egui::Button<'a> {
|
||||
let button = if flows(ui) {
|
||||
egui::Button::new(egui::RichText::new(shortcut).weak()).right_text(text)
|
||||
} else {
|
||||
egui::Button::new(text).shortcut_text(shortcut)
|
||||
};
|
||||
button.min_size(egui::vec2(min_width, 0.0))
|
||||
}
|
||||
|
||||
/// `Response::context_menu`, with its items aligned to the side reading starts from.
|
||||
pub fn context_menu(response: &egui::Response, add: impl FnOnce(&mut Ui)) {
|
||||
let popup = egui::Popup::context_menu(response);
|
||||
let popup = if on() { popup.layout(menu_layout()) } else { popup };
|
||||
popup.show(add);
|
||||
}
|
||||
|
||||
/// Run `add` in a left-to-right island inside a right-to-left area, for content egui can only
|
||||
/// lay out from the left (a `Grid`, a horizontally scrolling strip). The island is set against the
|
||||
/// leading (right) edge using the width it had last frame. Outside a right-to-left area this is
|
||||
/// just `add`.
|
||||
pub fn island<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
if !flows(ui) {
|
||||
return add(ui);
|
||||
}
|
||||
// The island gets all the room there is, so its content takes its natural width; it starts
|
||||
// where content of last frame's width would end at the leading edge. A change of width shows
|
||||
// one frame late, so that frame asks for another.
|
||||
let key = ui.next_auto_id().with("rtl_island_width");
|
||||
let known = ui.data(|d| d.get_temp::<f32>(key));
|
||||
let room = ui.available_rect_before_wrap();
|
||||
let available = if room.width().is_finite() { room.width().max(0.0) } else { 0.0 };
|
||||
let width = known.unwrap_or(available);
|
||||
let left = room.max.x - width;
|
||||
if !left.is_finite() {
|
||||
return add(ui);
|
||||
}
|
||||
let max_rect = Rect::from_min_max(pos2(left, room.min.y), pos2(left + available.max(width), room.max.y));
|
||||
let shown = ui.scope_builder(UiBuilder::new().max_rect(max_rect).layout(Layout::top_down(Align::Min)), add);
|
||||
let now = shown.response.rect.width();
|
||||
if now.is_finite() && known.is_none_or(|w| (w - now).abs() > 0.5) {
|
||||
ui.data_mut(|d| d.insert_temp(key, now));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
shown.inner
|
||||
}
|
||||
|
||||
/// `grid.show(ui, add)`. egui's `Grid` cannot be laid out from the right (it places cells from a
|
||||
/// left edge that a right-to-left parent does not have, and says so with a debug assertion), so
|
||||
/// inside a right-to-left area it is drawn in an [`island`]. Its columns keep their order.
|
||||
pub fn grid<R>(ui: &mut Ui, grid: egui::Grid, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
island(ui, |ui| grid.show(ui, add))
|
||||
}
|
||||
|
||||
/// A form of rows and cells. Where the interface is mirrored (`flows`), a row's cells run from the
|
||||
/// right edge, the first cell at the right, and every column is as wide as its widest cell, so the
|
||||
/// labels sit at the reading start with their fields after them, to their left. Elsewhere it is
|
||||
/// exactly an `egui::Grid` with the same id, columns and spacing. A `Grid` itself cannot be laid
|
||||
/// out from the right, which is why [`grid`] keeps its columns in order.
|
||||
pub struct Table {
|
||||
id: &'static str,
|
||||
columns: usize,
|
||||
spacing: Option<egui::Vec2>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
pub fn new(id: &'static str) -> Self {
|
||||
Self { id, columns: 2, spacing: None }
|
||||
}
|
||||
|
||||
pub fn columns(mut self, columns: usize) -> Self {
|
||||
self.columns = columns;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn spacing(mut self, spacing: egui::Vec2) -> Self {
|
||||
self.spacing = Some(spacing);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn show<R>(self, ui: &mut Ui, add: impl FnOnce(&mut Rows) -> R) -> R {
|
||||
if !flows(ui) {
|
||||
let spec = egui::Grid::new(self.id).num_columns(self.columns);
|
||||
let spec = match self.spacing {
|
||||
Some(spacing) => spec.spacing(spacing),
|
||||
None => spec,
|
||||
};
|
||||
return grid(ui, spec, |ui| add(&mut Rows { ui, mirror: None })).inner;
|
||||
}
|
||||
let key = ui.scope_id().with(("rtl_table_widths", self.id));
|
||||
let prev = ui.data(|d| d.get_temp::<Vec<f32>>(key)).unwrap_or_default();
|
||||
let spacing = self.spacing.unwrap_or(ui.spacing().item_spacing);
|
||||
let gap = spacing.x;
|
||||
let shown = ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = spacing.y;
|
||||
let mut rows = Rows { ui, mirror: Some(Mirror { prev: prev.clone(), cur: Vec::new(), gap }) };
|
||||
let out = add(&mut rows);
|
||||
(out, rows.mirror.map(|m| m.cur).unwrap_or_default())
|
||||
});
|
||||
let (out, cur) = shown.inner;
|
||||
let same = prev.len() == cur.len() && prev.iter().zip(&cur).all(|(a, b)| (a - b).abs() <= 0.5);
|
||||
if !same {
|
||||
ui.data_mut(|d| d.insert_temp(key, cur));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
struct Mirror {
|
||||
/// The width each column had last frame.
|
||||
prev: Vec<f32>,
|
||||
/// The width each column has now (the widest of its cells).
|
||||
cur: Vec<f32>,
|
||||
gap: f32,
|
||||
}
|
||||
|
||||
/// The rows of a [`Table`].
|
||||
pub struct Rows<'a> {
|
||||
ui: &'a mut Ui,
|
||||
mirror: Option<Mirror>,
|
||||
}
|
||||
|
||||
impl Rows<'_> {
|
||||
/// One row: add its cells in reading order with [`Cells::cell`].
|
||||
pub fn row<R>(&mut self, add: impl FnOnce(&mut Cells) -> R) -> R {
|
||||
match &mut self.mirror {
|
||||
None => {
|
||||
let out = add(&mut Cells { ui: self.ui, mirror: None, col: 0, pending: 0.0 });
|
||||
self.ui.end_row();
|
||||
out
|
||||
}
|
||||
Some(mirror) => {
|
||||
self.ui
|
||||
.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
add(&mut Cells { ui, mirror: Some(mirror), col: 0, pending: 0.0 })
|
||||
})
|
||||
.inner
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells of one row of a [`Table`].
|
||||
pub struct Cells<'a> {
|
||||
ui: &'a mut Ui,
|
||||
mirror: Option<&'a mut Mirror>,
|
||||
col: usize,
|
||||
/// The space still to leave before the next cell: what its column is wider than the last cell.
|
||||
pending: f32,
|
||||
}
|
||||
|
||||
impl Cells<'_> {
|
||||
/// The next cell of the row (its widgets, in the order they read).
|
||||
pub fn cell<R>(&mut self, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let Some(mirror) = self.mirror.as_deref_mut() else {
|
||||
return add(self.ui);
|
||||
};
|
||||
if self.pending > 0.0 {
|
||||
self.ui.add_space(self.pending);
|
||||
}
|
||||
let shown = self.ui.scope(add);
|
||||
let width = shown.response.rect.width();
|
||||
let width = if width.is_finite() { width.max(0.0) } else { 0.0 };
|
||||
if mirror.cur.len() <= self.col {
|
||||
mirror.cur.resize(self.col + 1, 0.0);
|
||||
}
|
||||
if let Some(w) = mirror.cur.get_mut(self.col) {
|
||||
*w = w.max(width);
|
||||
}
|
||||
let column = mirror.prev.get(self.col).copied().unwrap_or(0.0);
|
||||
self.pending = (column - width).max(0.0) + mirror.gap;
|
||||
self.col += 1;
|
||||
shown.inner
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::i18n::{Lang, fixture_lang, set_current};
|
||||
|
||||
/// Where a two-cell row puts its label and its field, as the centres of their rectangles.
|
||||
fn row_centres() -> (f32, f32) {
|
||||
let ctx = egui::Context::default();
|
||||
let mut centres = (0.0, 0.0);
|
||||
// The second frame has the column widths the first measured.
|
||||
for _ in 0..2 {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
root(ui, |ui| {
|
||||
Table::new("t").columns(2).show(ui, |t| {
|
||||
t.row(|r| {
|
||||
let label = r.cell(|ui| ui.label("Label:"));
|
||||
let field = r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut String::new()).desired_width(80.0)));
|
||||
centres = (label.rect.center().x, field.rect.center().x);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
centres
|
||||
}
|
||||
|
||||
/// A form reads from the reading start: the label of a row first, its field after it, so in a
|
||||
/// right-to-left interface the label is at the right and the field to its left.
|
||||
#[test]
|
||||
fn a_form_row_puts_the_label_at_the_reading_start() {
|
||||
set_current(Lang::EN);
|
||||
let (label, field) = row_centres();
|
||||
assert!(label < field, "left to right: label {label}, field {field}");
|
||||
set_current(fixture_lang("ar", "\tHome\tالرئيسية\n"));
|
||||
let (label, field) = row_centres();
|
||||
set_current(Lang::EN);
|
||||
assert!(label > field, "right to left: label {label}, field {field}");
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,14 @@ pub(crate) fn steps(h: &mut H, n: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Held by a test while its harness renders through wgpu: two devices drawing at once crash
|
||||
/// Windows' software rasterizer (WARP, `d3d10warp.dll`), which is what a machine without a GPU
|
||||
/// or a remote session gets, so those tests take turns.
|
||||
pub(crate) fn gpu_turn() -> std::sync::MutexGuard<'static, ()> {
|
||||
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A real mouse drag of a column border: the resize arrow on hover, a preview and no change
|
||||
/// while dragging, then one undo step on release, the selection untouched.
|
||||
#[test]
|
||||
|
||||
@@ -485,7 +485,22 @@ pub fn install_fonts_language_extra(
|
||||
system_cjk: bool,
|
||||
faces: &[Arc<wordcraft_fonts::FontFace>],
|
||||
) {
|
||||
let mut fonts = font_definitions_language(prefer_hans, prefer_korean, prefer_hant, system_cjk);
|
||||
install_fonts_language_arabic(ctx, prefer_hans, prefer_korean, prefer_hant, system_cjk, false, faces);
|
||||
}
|
||||
|
||||
/// [`install_fonts_language_extra`], also adding an installed Arabic font when `system_arabic` is
|
||||
/// set and no embedded face has Arabic letters. Asked for by an Arabic interface only, apart from
|
||||
/// the installed CJK font: reading either is a font file read the other languages never need.
|
||||
pub fn install_fonts_language_arabic(
|
||||
ctx: &egui::Context,
|
||||
prefer_hans: bool,
|
||||
prefer_korean: bool,
|
||||
prefer_hant: bool,
|
||||
system_cjk: bool,
|
||||
system_arabic: bool,
|
||||
faces: &[Arc<wordcraft_fonts::FontFace>],
|
||||
) {
|
||||
let mut fonts = font_definitions_language_arabic(prefer_hans, prefer_korean, prefer_hant, system_cjk, system_arabic);
|
||||
add_fallback_faces(&mut fonts, faces);
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
@@ -541,6 +556,18 @@ pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions
|
||||
|
||||
/// The interface fonts, ordered for the preferred CJK writing system.
|
||||
pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, prefer_hant: bool, system_cjk: bool) -> FontDefinitions {
|
||||
font_definitions_language_arabic(prefer_hans, prefer_korean, prefer_hant, system_cjk, false)
|
||||
}
|
||||
|
||||
/// [`font_definitions_language`], also with an installed Arabic font when `system_arabic` is set
|
||||
/// and the build embeds none.
|
||||
pub fn font_definitions_language_arabic(
|
||||
prefer_hans: bool,
|
||||
prefer_korean: bool,
|
||||
prefer_hant: bool,
|
||||
system_cjk: bool,
|
||||
system_arabic: bool,
|
||||
) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||
fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes)));
|
||||
@@ -598,10 +625,33 @@ pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, prefer_
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = system_cjk;
|
||||
// Arabic-script faces last: the Arabic interface, its name in the language list, Arabic file
|
||||
// names. Without an embedded one (a build without craft-fonts), an installed font, read when
|
||||
// `system_arabic` is set: for an Arabic interface.
|
||||
let arabic = wordcraft_fonts::ui_arabic_fonts();
|
||||
for f in &arabic {
|
||||
add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if system_arabic
|
||||
&& arabic.is_empty()
|
||||
&& let Some(f) = system_arabic_font()
|
||||
{
|
||||
let mut data = FontData::from_static(&f.bytes);
|
||||
data.index = f.index;
|
||||
add_cjk(&mut fonts, format!("system {}", f.family), data);
|
||||
}
|
||||
// Symbols and emoji fall back to egui's defaults (kept in the families).
|
||||
fonts
|
||||
}
|
||||
|
||||
/// The installed Arabic interface font, read once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_arabic_font() -> Option<&'static wordcraft_fonts::SystemUiFont> {
|
||||
static ARABIC: std::sync::OnceLock<Option<wordcraft_fonts::SystemUiFont>> = std::sync::OnceLock::new();
|
||||
ARABIC.get_or_init(wordcraft_fonts::system_arabic_ui_font).as_ref()
|
||||
}
|
||||
|
||||
/// The installed CJK interface font for a Chinese (`hans`) or other interface, chosen once:
|
||||
/// `None` when the embedded faces already cover the interface text
|
||||
/// ([`cjk_font_text`](crate::i18n::Lang::cjk_font_text)). The font is picked for the catalogs'
|
||||
@@ -947,6 +997,20 @@ mod tests {
|
||||
/// explicitly (kittest's own dev-dependencies do not propagate). WordCraft inserts Inter at
|
||||
/// the front and SourceSans after egui's complete default fallback block, including the
|
||||
/// monochrome emoji faces. Production keeps only `default_fonts` through eframe.
|
||||
/// An installed Arabic font is read for an Arabic interface only (`system_arabic`), and the
|
||||
/// installed CJK font request alone never brings it in: neither read is paid by the other.
|
||||
#[test]
|
||||
fn the_installed_arabic_font_is_asked_for_by_arabic_alone() {
|
||||
let plain = super::font_definitions_language_arabic(false, false, false, false, false).font_data.len();
|
||||
let cjk_only = super::font_definitions_language_arabic(false, false, false, true, false);
|
||||
let arabic = super::font_definitions_language_arabic(false, false, false, false, true).font_data.len();
|
||||
let installed_arabic = wordcraft_fonts::ui_arabic_fonts().is_empty() && wordcraft_fonts::system_arabic_ui_font().is_some();
|
||||
assert_eq!(arabic, plain + usize::from(installed_arabic), "Arabic adds the installed font when nothing is embedded");
|
||||
let arabic_named = |defs: &egui::FontDefinitions| defs.font_data.keys().filter(|k| k.starts_with("system ")).count();
|
||||
let cjk_font = super::system_cjk_font(false, false).is_some();
|
||||
assert_eq!(arabic_named(&cjk_only), usize::from(cjk_font), "the CJK request adds the CJK font and no Arabic one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_font_order_keeps_test_default_fonts_explicit() {
|
||||
let defs = super::font_definitions(false, false);
|
||||
|
||||
@@ -122,9 +122,11 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
.collapsible(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
if let Some(msg) = &app.updates_rt.status_message {
|
||||
ui.label(msg);
|
||||
}
|
||||
crate::rtl::root(ui, |ui| {
|
||||
if let Some(msg) = &app.updates_rt.status_message {
|
||||
ui.label(msg);
|
||||
}
|
||||
})
|
||||
});
|
||||
if !open {
|
||||
app.updates_rt.manual_window_open = false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::groups::command_menu;
|
||||
use super::tooltip::{keytip_badge, shortcut_text, show_hint, tooltip, tooltip_lines};
|
||||
use super::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, Tokens, Ui, Value, WordApp, icons, pos2, regular, vec2};
|
||||
use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, Tokens, Ui, Value, WordApp, icons, pos2, regular, vec2};
|
||||
|
||||
/// Height of the ribbon content area (without group labels).
|
||||
pub const CONTENT_H: f32 = 66.0;
|
||||
@@ -147,11 +147,13 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
|
||||
keytip_badge(ui, r, id, tip);
|
||||
let on = enabled(app, id);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
let ic = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0));
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
if let Some(l) = label {
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), if on { t.text } else { t.text_disabled });
|
||||
let at = pos2(crate::rtl::lead_x(rtl, r, 24.0), r.center().y);
|
||||
ui.painter().text(at, crate::rtl::lead_center(rtl), l, regular(11.5), if on { t.text } else { t.text_disabled });
|
||||
}
|
||||
let resp = tooltip(app, resp, tip, id);
|
||||
command_menu(&resp, app, id);
|
||||
@@ -205,7 +207,7 @@ pub fn split(
|
||||
if !on {
|
||||
return;
|
||||
}
|
||||
egui::Popup::menu(&aresp).show(|ui| {
|
||||
crate::rtl::menu(&aresp).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
menu(ui, app);
|
||||
});
|
||||
@@ -263,11 +265,14 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
|
||||
keytip_badge(ui, r, "menu", tip);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, c, a);
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a);
|
||||
if let Some(l) = label {
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), text);
|
||||
ui.painter().text(pos2(lead(24.0), r.center().y), crate::rtl::lead_center(rtl), l, regular(11.5), text);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(DROPDOWN, DROPDOWN)), "dropdown", c, c);
|
||||
let arrow = pos2(crate::rtl::trail_x(rtl, r, 6.0), r.center().y);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(arrow, vec2(DROPDOWN, DROPDOWN)), "dropdown", c, c);
|
||||
resp
|
||||
};
|
||||
let reason = if on { None } else { Some(app.session.why_disabled(id).unwrap_or("")) };
|
||||
@@ -277,7 +282,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
if !on {
|
||||
return;
|
||||
}
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
crate::rtl::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
menu(ui, app);
|
||||
});
|
||||
|
||||
@@ -157,6 +157,17 @@ struct ComboOptions<'a> {
|
||||
tip: &'a str,
|
||||
}
|
||||
|
||||
/// The arrow's box next to a combo's field: its corner radii and the rectangle it paints, reaching
|
||||
/// one border width under the field on the side the two meet (the field is left of the arrow in a
|
||||
/// left-to-right row, right of it in a right-to-left one).
|
||||
fn arrow_box_shape(rtl: bool, arrow: Rect, r: CornerRadius) -> (CornerRadius, Rect) {
|
||||
if rtl {
|
||||
(CornerRadius { nw: r.nw, sw: r.sw, ne: 0, se: 0 }, Rect::from_min_max(arrow.min, pos2(arrow.max.x + ARROW_BORDER, arrow.max.y)))
|
||||
} else {
|
||||
(CornerRadius { nw: 0, sw: 0, ne: r.ne, se: r.se }, Rect::from_min_max(pos2(arrow.min.x - ARROW_BORDER, arrow.min.y), arrow.max))
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices of the `items` matching `query`, ignoring case: exact matches, then the names starting
|
||||
/// with it, then those containing it, each group in list order. A blank query matches everything.
|
||||
pub fn font_matches(query: &str, items: &[String]) -> Vec<usize> {
|
||||
@@ -220,12 +231,16 @@ fn combo_impl(ui: &mut Ui, options: ComboOptions<'_>) -> Option<String> {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
// The field and its arrow share one outline: rounded on the outside, square where they
|
||||
// meet. The field's frame takes the widget visuals' radius in every state.
|
||||
// meet. The field's frame takes the widget visuals' radius in every state. In a row that
|
||||
// runs right to left the arrow comes after the field on its left, so the corners and the
|
||||
// arrow's underlap are the mirror image.
|
||||
let rtl = ui.layout().prefer_right_to_left();
|
||||
let r = ui.visuals().widgets.inactive.corner_radius;
|
||||
let w = &mut ui.visuals_mut().widgets;
|
||||
for v in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
|
||||
v.corner_radius.ne = 0;
|
||||
v.corner_radius.se = 0;
|
||||
let (a, b) = if rtl { (&mut v.corner_radius.nw, &mut v.corner_radius.sw) } else { (&mut v.corner_radius.ne, &mut v.corner_radius.se) };
|
||||
*a = 0;
|
||||
*b = 0;
|
||||
}
|
||||
// Up/Down move the list's highlight: take them before the text field moves its cursor.
|
||||
let mut step = 0i32;
|
||||
@@ -306,8 +321,7 @@ fn combo_impl(ui: &mut Ui, options: ComboOptions<'_>) -> Option<String> {
|
||||
}
|
||||
// It reaches one border width under the field, so the field's right border is the only line
|
||||
// between them: two borders side by side read as a double-width divider.
|
||||
let ar_radius = CornerRadius { nw: 0, sw: 0, ne: r.ne, se: r.se };
|
||||
let under = Rect::from_min_max(pos2(ar.min.x - ARROW_BORDER, ar.min.y), ar.max);
|
||||
let (ar_radius, under) = arrow_box_shape(rtl, ar, r);
|
||||
let fill = if aresp.hovered() { t.hover } else { t.input };
|
||||
let arrow_box = egui::epaint::RectShape::new(under, ar_radius, fill, Stroke::new(ARROW_BORDER, t.input_border), egui::StrokeKind::Inside);
|
||||
ui.painter().set(arrow_bg, arrow_box);
|
||||
@@ -602,3 +616,24 @@ pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The arrow's box fuses with its field on the side they meet: in a left-to-right row the
|
||||
/// arrow is right of the field (rounded on the right, painted one border under the field's
|
||||
/// right edge), in a right-to-left row it is left of it (rounded on the left, under the
|
||||
/// field's left edge).
|
||||
#[test]
|
||||
fn the_arrow_box_is_rounded_away_from_its_field() {
|
||||
let arrow = Rect::from_min_max(pos2(100.0, 0.0), pos2(116.0, 20.0));
|
||||
let r = CornerRadius::same(4);
|
||||
let (ltr, under) = arrow_box_shape(false, arrow, r);
|
||||
assert_eq!((ltr.nw, ltr.sw, ltr.ne, ltr.se), (0, 0, 4, 4));
|
||||
assert_eq!((under.min.x, under.max.x), (100.0 - ARROW_BORDER, 116.0));
|
||||
let (rtl, under) = arrow_box_shape(true, arrow, r);
|
||||
assert_eq!((rtl.nw, rtl.sw, rtl.ne, rtl.se), (4, 4, 0, 0));
|
||||
assert_eq!((under.min.x, under.max.x), (100.0, 116.0 + ARROW_BORDER));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp
|
||||
/// A ribbon group whose title is shown as is (a custom group's name, Customize Ribbon).
|
||||
pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let start = ui.cursor().min;
|
||||
// Groups follow the ribbon's direction: in a right-to-left interface the content starts at
|
||||
// the right, and the launcher and the divider sit on the left.
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let start_cursor = ui.cursor();
|
||||
let start = start_cursor.min;
|
||||
// #662: the window may be too narrow for the tab; the plan says how this group fits.
|
||||
let position = crate::ribbon_fit::position(ui.ctx());
|
||||
let level = crate::ribbon_fit::level(ui.ctx(), id_title);
|
||||
@@ -22,10 +26,10 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
// Not folded: a drop-down left open while it was folded doesn't come back by itself.
|
||||
ui.data_mut(|d| d.remove::<bool>(collapsed_open_id(ui, id_title, position)));
|
||||
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
|
||||
let inner = ui.vertical(|ui| {
|
||||
let inner = crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
|
||||
crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT);
|
||||
let content = ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
let content = ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), crate::rtl::row_layout(ui, egui::Align::Min), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
|
||||
add(ui, app);
|
||||
});
|
||||
@@ -40,7 +44,7 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
let r = inner.response.rect;
|
||||
title_row(
|
||||
ui,
|
||||
Rect::from_min_max(pos2(start.x, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
|
||||
Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
|
||||
id_title,
|
||||
title,
|
||||
launcher,
|
||||
@@ -48,19 +52,27 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
);
|
||||
}
|
||||
ui.add_space(5.0);
|
||||
let x = ui.cursor().min.x;
|
||||
let x = if rtl { ui.cursor().max.x } else { ui.cursor().min.x };
|
||||
ui.painter().line_segment([pos2(x, start.y + 4.0), pos2(x, start.y + CONTENT_H + LABEL_H - 4.0)], Stroke::new(1.0, t.border));
|
||||
ui.add_space(6.0);
|
||||
crate::ribbon_fit::record(ui.ctx(), id_title, level, ui.cursor().min.x - start.x);
|
||||
crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor()));
|
||||
}
|
||||
|
||||
/// The width a group took: the cursor's way along the row, from `before` to `after`. A
|
||||
/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity,
|
||||
/// so there the width is how far the right edge moved.
|
||||
fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 {
|
||||
if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x }
|
||||
}
|
||||
|
||||
/// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher.
|
||||
fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let title_y = r.center().y;
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
ui.painter().text(pos2(r.center().x, title_y), Align2::CENTER_CENTER, title, regular(11.0), t.group_label);
|
||||
if let Some(cmd) = launcher {
|
||||
let lr = Rect::from_center_size(pos2(r.max.x - 6.0, title_y), vec2(11.0, 11.0));
|
||||
let lr = Rect::from_center_size(pos2(crate::rtl::trail_x(rtl, r, 6.0), title_y), vec2(11.0, 11.0));
|
||||
let resp = ui.interact(lr, ui.scope_id().with(("launch", id_title)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover);
|
||||
@@ -127,9 +139,9 @@ fn collapsed_group(
|
||||
// This popup isn't counted by `Popup::is_any_open`, so keep the same Esc from also leaving
|
||||
// full screen.
|
||||
app.escape_free = false;
|
||||
egui::Popup::menu(&resp).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
crate::rtl::menu(&resp).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(|ui| {
|
||||
crate::rtl::vertical(ui, |ui| {
|
||||
crate::rtl::horizontal_top(ui, |ui| {
|
||||
ui.set_min_height(CONTENT_H);
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
|
||||
add(ui, app);
|
||||
@@ -193,7 +205,7 @@ pub fn command_icon(id: &str) -> &str {
|
||||
/// Right-click on a command's button: put it on (or take it off) the Quick Access Toolbar, or
|
||||
/// open Customize Ribbon (#379).
|
||||
pub fn command_menu(resp: &Response, app: &mut WordApp, id: &str) {
|
||||
resp.context_menu(|ui| {
|
||||
crate::rtl::context_menu(resp, |ui| {
|
||||
let known = app.session.registry.get(id).is_some();
|
||||
let on_qat = app.session.ribbon.qat().contains(&id);
|
||||
if known && !on_qat && ui.button(tl!("Add to Quick Access Toolbar")).clicked() {
|
||||
@@ -235,3 +247,21 @@ pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A right-to-left row's cursor reaches to negative infinity on the left, so the width of a
|
||||
/// group in it comes from the right edge: finite and positive, as the ribbon planner needs.
|
||||
#[test]
|
||||
fn a_group_in_a_right_to_left_row_has_a_finite_width() {
|
||||
let before = Rect::from_min_max(pos2(f32::NEG_INFINITY, 0.0), pos2(900.0, 90.0));
|
||||
let after = Rect::from_min_max(pos2(f32::NEG_INFINITY, 0.0), pos2(740.0, 90.0));
|
||||
assert_eq!(group_width(true, before, after), 160.0);
|
||||
assert!(!(before.min.x - after.min.x).is_finite(), "the left edge cannot measure it");
|
||||
let before = Rect::from_min_max(pos2(20.0, 0.0), pos2(f32::INFINITY, 90.0));
|
||||
let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0));
|
||||
assert_eq!(group_width(false, before, after), 160.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,19 +63,22 @@ fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId
|
||||
let st = drawn_state(ui, &resp, true);
|
||||
let r = r.expand(st.expansion);
|
||||
ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside);
|
||||
// By language, not by layout: these buttons also sit in far-side clusters, which fill from
|
||||
// the other end.
|
||||
let rtl = crate::rtl::on();
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + pad.x + icon_w / 2.0, r.center().y), vec2(icon_w, icon_w)),
|
||||
Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, pad.x + icon_w / 2.0), r.center().y), vec2(icon_w, icon_w)),
|
||||
icon,
|
||||
t.icon,
|
||||
t.accent,
|
||||
);
|
||||
let text_x = r.min.x + pad.x + icon_w + if show_text { 4.0 } else { 0.0 };
|
||||
let text_x = crate::rtl::lead_left(rtl, r, pad.x + icon_w + if show_text { 4.0 } else { 0.0 }, text_w);
|
||||
if show_text {
|
||||
ui.painter().galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, st.text);
|
||||
}
|
||||
if marker {
|
||||
let c = pos2(text_x + text_w + 4.0 + DROPDOWN / 2.0, r.center().y);
|
||||
let c = pos2(if rtl { text_x - 4.0 - DROPDOWN / 2.0 } else { text_x + text_w + 4.0 + DROPDOWN / 2.0 }, r.center().y);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(DROPDOWN, DROPDOWN)), "dropdown", st.text, st.text);
|
||||
}
|
||||
resp
|
||||
@@ -118,13 +121,16 @@ pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option<us
|
||||
let base_y = origin.y + H;
|
||||
let base = ui.painter().add(egui::Shape::Noop);
|
||||
let top = CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 };
|
||||
let mut x = origin.x;
|
||||
// Right to left, the tabs start at the right edge and run leftwards.
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let mut x = if rtl { origin.x + width } else { origin.x };
|
||||
let mut clicked = None;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let on = i == selected;
|
||||
let galley = ui.painter().layout_no_wrap(label.clone(), regular(12.5), t.text);
|
||||
let w = galley.size().x + 28.0;
|
||||
let r = Rect::from_min_max(pos2(x, origin.y + 2.0), pos2(x + w, base_y));
|
||||
let left = if rtl { x - w } else { x };
|
||||
let r = Rect::from_min_max(pos2(left, origin.y + 2.0), pos2(left + w, base_y));
|
||||
let resp = ui.interact(r, ui.scope_id().with(("dialog_tab", i)), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, label));
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
|
||||
@@ -140,7 +146,7 @@ pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option<us
|
||||
if resp.clicked() && !on {
|
||||
clicked = Some(i);
|
||||
}
|
||||
x += w - 1.0;
|
||||
x += if rtl { 1.0 - w } else { w - 1.0 };
|
||||
}
|
||||
ui.allocate_space(vec2(width, H + 1.0));
|
||||
ui.painter().set(base, egui::Shape::line_segment([pos2(origin.x, base_y), pos2(origin.x + width, base_y)], Stroke::new(1.0, t.border)));
|
||||
|
||||
@@ -181,7 +181,9 @@ impl WordApp {
|
||||
fonts_korean: _,
|
||||
fonts_hant: _,
|
||||
fonts_system_cjk: _,
|
||||
fonts_system_arabic: _,
|
||||
want_system_cjk: _,
|
||||
want_system_arabic: _,
|
||||
recent_fonts_for: _,
|
||||
// File › Recover lists the copies of every window's documents.
|
||||
recover_fonts_for: _,
|
||||
|
||||
@@ -76,7 +76,7 @@ fn paint_style(p: &egui::Painter, r: Rect, id: &str, theme: &[Rgb]) {
|
||||
/// Our WordArt styles as a grid; a click runs `cmd` with `{"style": id}`.
|
||||
pub fn style_gallery(ui: &mut Ui, app: &mut WordApp, cmd: &str) {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
egui::Grid::new(("wordart-styles", cmd)).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new(("wordart-styles", cmd)).spacing(vec2(4.0, 4.0)), |ui| {
|
||||
for (i, (id, label)) in ART_STYLES.iter().enumerate() {
|
||||
if cell(ui, 40.0, 36.0, label, |p, r| paint_style(p, r, id, &theme)) {
|
||||
crate::widgets::run_from_ribbon(app, cmd, json!({"style": id}));
|
||||
@@ -196,7 +196,7 @@ pub fn transform_menu(ui: &mut Ui, app: &mut WordApp) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
mi(ui, app, "No Transform", "wordArt.transform", json!({"preset": "none"}));
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("wordart-warps").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
crate::rtl::grid(ui, egui::Grid::new("wordart-warps").spacing(vec2(4.0, 4.0)), |ui| {
|
||||
for (i, (id, label)) in DRAWN_WARPS.iter().enumerate() {
|
||||
if cell(ui, 46.0, 32.0, label, |p, r| paint_warp(p, r.shrink(2.0), id, t.accent)) {
|
||||
crate::widgets::run_from_ribbon(app, "wordArt.transform", json!({"preset": id}));
|
||||
|
||||
@@ -73,6 +73,14 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
|
||||
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
|
||||
|
||||
**Task pane docking.** `ui.paneDock` and `view.stylesPaneDock` use **physical** `left` / `right`
|
||||
(the window's left and right edges), matching Dock Left / Dock Right in the pane menu. That stays
|
||||
true in a right-to-left interface language: `{"dock":"left"}` always puts the pane on the physical
|
||||
left and the command reports `"left"`. A pane that has never been docked keeps its default
|
||||
start-/end-edge role when mirrored: start-default panes (e.g. Navigation) open on the physical
|
||||
right in Arabic, end-default panes (e.g. Comments) on the physical left. Saved docks and explicit
|
||||
requests are never flipped.
|
||||
|
||||
Language packs (see [MCP](mcp.md#language-packs)) are commands like any other: `proof.packs`,
|
||||
`proof.installPack {"locale": "nl"}` and `proof.removePack {"locale": "nl"}`. Through the control
|
||||
channel they act on the running app's packs, and install only while its downloading setting is on;
|
||||
|
||||
+4
-3
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only); previously minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Interface languages, script support and proofing per language. How translation works (adding a
|
||||
language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and
|
||||
@@ -21,8 +21,8 @@ native-speaker review (human).
|
||||
|
||||
## What WordCraft ships (measured)
|
||||
|
||||
- **27 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `uk`, `es`, `pt-br`,
|
||||
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr`, Bulgarian `bg`, Vietnamese `vi` (#1434), Swedish `sv` (#1219), Croatian `hr` (#1271), Romanian `ro`, Slovenian `sl`, Dutch `nl` and Danish `da` (#2722). Catalogs have 2,061–2,674 entries
|
||||
- **28 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `uk`, `es`, `pt-br`,
|
||||
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr`, Bulgarian `bg`, Vietnamese `vi` (#1434), Swedish `sv` (#1219), Croatian `hr` (#1271), Romanian `ro`, Slovenian `sl`, Dutch `nl`, Danish `da` (#2722) and Arabic `ar` (#1195). Catalogs have 2,061–2,730 entries
|
||||
each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian, Swedish, Romanian and Slovenian translate so far. A missing translation falls back to English and never fails a test or
|
||||
build. `cargo xtask i18n-report`, part of `cargo xtask ci`, prints per language the interface
|
||||
strings its catalog lacks: everything the interface audit saw looked up, the ribbon tabs, command
|
||||
@@ -34,12 +34,14 @@ native-speaker review (human).
|
||||
Tests of the lookup code (English fallback, contexts, placeholder templates, built-in style names,
|
||||
the per-thread language, command search) use small inline catalogs or the pseudo-language, so
|
||||
rewording a translation never fails a test.
|
||||
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically.
|
||||
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. `ar` and every `ar-*`
|
||||
locale pick Arabic; the shell mirrors for it (ribbon, panes, File page, dialogs, menus), while the
|
||||
document canvas keeps its own direction.
|
||||
- Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each
|
||||
catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero,
|
||||
Read Aloud) aren't wrapped in `tl!` yet, so they show in English in every
|
||||
language: the true share of visible strings is **~93–95%** (estimated).
|
||||
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20).
|
||||
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). Arabic UI landed (#1195).
|
||||
- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through
|
||||
the styles; Review › Language and the status bar set it: a short list, plus Set Proofing
|
||||
Language… for any of 38 languages and Set As Default for the document and future blank documents). Languages without proofing tools get
|
||||
@@ -61,7 +63,7 @@ shared items below.
|
||||
| Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); interface font chain covers every catalog character, installed CJK font after the embedded ones when it doesn't, PingFang found on macOS 12 (#486); font list by Chinese names (宋体, 微软雅黑, #505); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text | no | partial | 3–5 |
|
||||
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
|
||||
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
|
||||
| Arabic | ar | 0 | ❌ | shaping, joining, lam-alef, bidi ✅ (#207); no kashida; **no mirrored UI** | no | none | 6–8 |
|
||||
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) |
|
||||
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
|
||||
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
|
||||
@@ -245,7 +247,7 @@ WordCraft keeps 2,54 cm).
|
||||
| Item | Hours | Notes |
|
||||
|---|---|---|
|
||||
| Wrap the remaining English literals in `tl!` and translate them in every catalog | 4–6 | Makes the six partial languages full except script items |
|
||||
| Mirrored (right-to-left) interface for Arabic and Hebrew | 15–25 | Ribbon, panes, dialogs, rulers |
|
||||
| Mirrored (right-to-left) interface for Arabic and Hebrew | 8–12 (Arabic done #1195; Hebrew remains) | Ribbon, panes, dialogs, rulers |
|
||||
| Vertical text, ruby, document grid (Chinese, Japanese, Korean) | counted in [layout-parity.md](layout-parity.md) | |
|
||||
| Proofing dictionaries, grammar and hyphenation for the 11 non-English key languages | 20–40 | Open dictionaries only (licence check per dictionary); issues #25, #40, #100. Per-run languages, the Hunspell-style affix engine and the TeX pattern reader landed with Polish, so a language with an open Hunspell dictionary and hyphenation patterns is mostly data plus grammar rules |
|
||||
| Native-speaker review of every catalog | human | Swedish: terminology and the running app reviewed (2026-10-13); Italian reviewed (2026-10-14); the others not yet |
|
||||
@@ -254,6 +256,7 @@ WordCraft keeps 2,54 cm).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Arabic (`ar`) interface: ~2,730 catalog rows from craft-i18n, `ar-*` locales, mirrored shell (ribbon, panes, File page, dialogs, menus), Arabic UI font fallback, document canvas independent (#1195); 28 languages; Hebrew UI still open |
|
||||
| 2026-11-03 | trivial | Language packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto |
|
||||
| 2026-11-02 | trivial | Language packs (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded |
|
||||
| 2026-11-01 | minor | Language packs (CP4.3–CP4.5): install and remove a language's spelling and hyphenation from File › Options › Language or the missing-pack bar, About › Language data, the commands; the default build embeds English data only; the language requests point at their packs: Lithuanian and Romanian available once language-packs-1 is published, the others waiting on craft-proof engine support |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# WordCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (M15: Arabic mirrored UI #1195; 28 UI languages; Hebrew UI and native IME acceptance remain open); previously trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is
|
||||
[`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are
|
||||
@@ -62,7 +62,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
|
||||
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h |
|
||||
| M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h |
|
||||
| M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — |
|
||||
| M15 | Localization (12 key languages, RTL interface, proofing languages) | 27 UI languages from the shared catalogs pinned by `craft-i18n.rev` (`9205108c…`); proofing: English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish, plus Bulgarian and German hyphenation only; native-speaker review and native IME acceptance (#155–#164) remain open | 70–115 h |
|
||||
| M15 | Localization (12 key languages, RTL interface, proofing languages) | 28 UI languages from the shared catalogs pinned by `craft-i18n.rev` (Arabic mirrored UI #1195); proofing: English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish, plus Bulgarian and German hyphenation only; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open | 55–95 h |
|
||||
| M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h |
|
||||
|
||||
## Next after beta
|
||||
@@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | M15: Arabic mirrored interface (#1195); 28 UI languages from craft-i18n; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open |
|
||||
| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. |
|
||||
| 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open |
|
||||
| 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) |
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user