refactor(ui-egui): split dialogs.rs into dialogs/

This commit is contained in:
aiongg
2026-10-26 21:16:48 +00:00
parent 6d26a751f0
commit afc3b8ad4d
11 changed files with 3593 additions and 3532 deletions
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
use super::chrome::buttons;
use super::*;
/// The innermost bookmark around the caret, which the Bookmark dialog opens on (as Word does);
/// empty when the caret is in none.
pub(super) fn bookmark_at_caret(s: &wordcraft_engine::Session) -> String {
let at = &s.sel.focus;
s.doc
.bookmarks()
.into_iter()
.rev()
.find(|(name, start)| !name.starts_with('_') && start <= at && *at <= wordcraft_engine::cmd::insert::bookmark_span(&s.doc, name, start).1)
.map(|(name, _)| name)
.unwrap_or_default()
}
/// The bookmarks the Bookmark dialog lists: by name or in document order, the hidden ones
/// (`_Toc…`, `_Ref…`) only when asked for.
pub(super) fn bookmark_names(doc: &wordcraft_doc::Document, by_location: bool, hidden: bool) -> Vec<String> {
let mut names: Vec<String> = doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| hidden || !n.starts_with('_')).collect();
if !by_location {
names.sort_by_key(|n| n.to_lowercase());
}
names
}
/// Insert › Bookmark, laid out as Word's (#1236): the name box over the list, Add / Delete / Go To
/// beside them, Sort by and Hidden bookmarks below. Add (Enter) closes the dialog; Delete and Go
/// To leave it open, and once a bookmark is deleted Cancel reads Close.
pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_location: &mut bool, hidden: &mut bool, deleted: &mut bool) -> bool {
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| {
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| {
ui.set_width(232.0);
ui.set_height(150.0);
egui::ScrollArea::vertical().id_salt("bookmark_list").max_height(150.0).auto_shrink([false, false]).show(ui, |ui| {
if marks.is_empty() {
// #1904: a blank box says nothing; reuse the Field dialog's wording.
ui.label(egui::RichText::new(tl!("The document has no bookmarks yet.")).small().color(t.text_dim));
}
for n in &marks {
if ui.selectable_label(*n == *name, n.as_str()).clicked() {
*name = n.clone();
}
}
});
});
});
ui.vertical(|ui| {
ui.add_space(18.0);
let listed = marks.iter().any(|n| n == name.trim());
let w = vec2(90.0, 0.0);
add = ui.add_enabled(wordcraft_engine::cmd::insert::bookmark_name_ok(name.trim()), egui::Button::new(tl!("Add")).min_size(w)).clicked();
delete = ui.add_enabled(listed, egui::Button::new(tl!("Delete")).min_size(w)).clicked();
go_to = ui.add_enabled(listed, egui::Button::new(tl!("Go To")).min_size(w)).clicked();
});
});
ui.add_space(6.0);
ui.horizontal_top(|ui| {
ui.label(tl!("Sort by:"));
ui.vertical(|ui| {
ui.radio_value(by_location, false, tl!("Name"));
ui.radio_value(by_location, true, tl!("Location"));
});
});
ui.checkbox(hidden, tl!("Hidden bookmarks"));
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
if add || (enter && wordcraft_engine::cmd::insert::bookmark_name_ok(name.trim())) {
let _ = app.run("insert.bookmark", json!({"name": name.trim()}));
return true;
}
if delete && app.run("insert.deleteBookmark", json!({"name": name.trim()})).is_ok() {
name.clear();
*deleted = true;
}
if go_to {
let _ = app.run("edit.goto", json!({"bookmark": name.trim()}));
}
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| {
close |= ui.button(if *deleted { tl!("Close") } else { tl!("Cancel") }).clicked();
});
});
close
}
/// path data; Insert (or a double click) runs `insert.icon`. Returns true to close.
pub(super) fn icons_body(app: &mut WordApp, ui: &mut Ui, query: &mut String, category: &mut String, selected: &mut String) -> bool {
use wordcraft_render::icon_lib::{self, Category};
let t = Tokens::get(ui.ctx());
ui.set_width(560.0);
// Typing goes to the search box (never to the document behind the dialog).
let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Search icons")).desired_width(560.0));
if !r.has_focus() {
r.request_focus();
}
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
if ui.selectable_label(category.is_empty(), tl!("All")).clicked() {
category.clear();
}
for c in Category::ALL {
if ui.selectable_label(*category == c.name(), tl!(c.name())).clicked() {
*category = c.name().to_string();
}
}
});
ui.add_space(4.0);
let hits = icon_lib::search(query, Category::parse(category));
let mut insert = false;
egui::ScrollArea::vertical().max_height(300.0).min_scrolled_height(300.0).auto_shrink([true, false]).show(ui, |ui| {
if hits.is_empty() {
ui.label(egui::RichText::new(tl!("No icons match your search.")).color(t.text_dim));
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
for icon in &hits {
let (rect, resp) = ui.allocate_exact_size(vec2(56.0, 56.0), Sense::click());
let on = *selected == icon.id;
let fill = if on {
t.checked
} else if resp.hovered() {
t.hover
} else {
egui::Color32::TRANSPARENT
};
let edge = if on { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.border) };
ui.painter().rect(rect, 4.0, fill, edge, egui::StrokeKind::Inside);
crate::icons::library(ui.painter(), rect.shrink(12.0), icon, t.text);
let resp = resp.on_hover_text(icon.name);
if resp.clicked() {
*selected = icon.id.to_string();
}
if resp.double_clicked() {
*selected = icon.id.to_string();
insert = true;
}
}
});
});
let name = icon_lib::find(selected).map(|i| i.name).unwrap_or("");
ui.add_space(4.0);
ui.label(egui::RichText::new(name).color(t.text_dim));
let (ok, cancel) = buttons(ui, tl!("Insert"));
if (ok || insert) && !selected.is_empty() {
let _ = app.run("insert.icon", json!({"id": selected.as_str()}));
return true;
}
cancel
}
/// Enclose Characters' preview: the text in the chosen shape, shrunk or with the shape enlarged
/// (drawn, so it doesn't depend on the interface font having ○ □ △ ◇).
pub(super) fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(110.0, 64.0), Sense::hover());
let p = ui.painter_at(r);
p.rect_filled(r, 2.0, t.input);
p.rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let ink = ui.visuals().text_color();
let c = r.center();
let (box_h, text_px) = match style {
"none" => (0.0, 26.0),
"enlarge" => (48.0, 26.0),
_ => (34.0, 20.0),
};
let h = box_h / 2.0;
let stroke = egui::Stroke::new(1.5, ink);
match (shape, box_h > 0.0) {
(_, false) => {}
("square", _) => {
p.rect_stroke(egui::Rect::from_center_size(c, vec2(box_h, box_h)), 0.0, stroke, egui::StrokeKind::Middle);
}
("triangle", _) => {
let pts = vec![egui::pos2(c.x, c.y - h), egui::pos2(c.x + h * 1.1, c.y + h), egui::pos2(c.x - h * 1.1, c.y + h)];
p.add(egui::Shape::closed_line(pts, stroke));
}
("diamond", _) => {
let pts = vec![egui::pos2(c.x, c.y - h), egui::pos2(c.x + h, c.y), egui::pos2(c.x, c.y + h), egui::pos2(c.x - h, c.y)];
p.add(egui::Shape::closed_line(pts, stroke));
}
_ => {
p.circle_stroke(c, h, stroke);
}
}
let px = match (style, shape) {
("shrink", "triangle" | "diamond") => text_px * 0.75,
_ => text_px,
};
let dy = if shape == "triangle" && box_h > 0.0 { h * 0.3 } else { 0.0 };
p.text(c + vec2(0.0, dy), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(px), ink);
}
+212
View File
@@ -0,0 +1,212 @@
use super::dispatch::body;
use super::*;
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
color_field(ui, theme, value, "No Color", "");
}
/// A colour field: a swatch and a drop-down with `none_label` (which sets `none_value`), the
/// colour grid, Recent Colors and More Colors… (#606). `value` is hex or `none_value`.
pub(crate) fn color_field(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, none_value: &str) {
ui.horizontal(|ui| {
let id = ui.scope_id().with("colorField");
crate::colors::take_field(ui.ctx(), id, value);
let c = wordcraft_doc::Rgb::parse(value);
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
let t = Tokens::get(ui.ctx());
match c {
Some(c) => {
ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));
}
None => {
ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));
}
}
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 = none_value.to_string();
ui.close();
}
crate::colors::field_menu(ui, theme, id, value);
});
});
}
/// The open dialog, as (window id, frames it has been centred for).
fn dialog_top_key() -> egui::Id {
egui::Id::unique("dialog_top")
}
/// Frames a dialog stays centred: the first one measures it, the second centres that size.
const DIALOG_CENTRED_FRAMES: u8 = 2;
/// Places a dialog: centred for its first frames, then left where it is with its top-centre point
/// as the pivot, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves
/// the bottom edge and the tab strip stays under the mouse (#507). It is no longer anchored after
/// that, so dragging the title bar moves it (#732). egui keeps it on screen if it grows past the
/// bottom.
fn place_dialog<'a>(ctx: &egui::Context, id: egui::Id, w: egui::Window<'a>) -> egui::Window<'a> {
let w = w.pivot(egui::Align2::CENTER_TOP);
match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) {
Some((open, frames)) if open == id && frames >= DIALOG_CENTRED_FRAMES => w,
_ => w.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)),
}
}
/// After a frame of a dialog: counts the frames it has been centred for.
fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) {
let frames = match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) {
Some((open, frames)) if open == id => frames,
_ => 0,
};
if frames < DIALOG_CENTRED_FRAMES {
ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, frames + 1)));
}
}
/// Takes the active dialog, resolves its translated title, hosts it as a non-resizable window, draws
/// its body, keeps it open when it stays, and gives the Save Changes prompt the AccessKit
/// alert-dialog role.
pub fn show(app: &mut WordApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else {
ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key()));
return;
};
let mut close = false;
let title = match &d {
Dialog::RenameDocument { .. } => "Rename Document",
Dialog::Font { .. } => "Font",
Dialog::Paragraph { .. } => "Paragraph",
Dialog::Tabs { .. } => "Tabs",
Dialog::Borders { .. } => "Borders and Shading",
Dialog::Find { .. } => "Find and Replace",
Dialog::InsertTable { .. } => "Insert Table",
Dialog::PageSetup { .. } => "Page Setup",
Dialog::Link { .. } => "Insert Hyperlink",
Dialog::Bookmark { .. } => "Bookmark",
Dialog::WordCount { .. } => "Word Count",
Dialog::Zoom { .. } => "Zoom",
Dialog::Watermark { .. } => "Custom Watermark",
Dialog::Phonetic { .. } => "Phonetic Guide",
Dialog::NewStyle { .. } => "Create New Style",
Dialog::ModifyStyle { .. } => "Modify Style",
Dialog::ManageStyles { .. } => "Manage Styles",
Dialog::TableStyle { id: None, .. } => "New Table Style",
Dialog::TableStyle { .. } => "Modify Table Style",
Dialog::Commands { .. } => "Search Commands",
Dialog::Insert(d) => d.title(),
Dialog::Refs(d) => d.title(),
Dialog::Icons { .. } => "Icons",
Dialog::PasteSpecial { .. } => "Paste Special",
Dialog::About { .. } => "About WordCraft",
Dialog::SaveChanges { .. } => "WordCraft",
Dialog::DiscardRecovery { .. } => "Discard recovery copy",
Dialog::RecipientList { .. } => "Recipient List",
Dialog::SelectTable { .. } => "Select Table",
Dialog::InsertMergeField { .. } => "Insert Merge Field",
Dialog::FindRecipient { .. } => "Find Recipient",
Dialog::MergeRecords { .. } => "Finish & Merge",
Dialog::FieldCode { edit: false, .. } => "Field",
Dialog::FieldCode { .. } => "Edit Field Code",
Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "Skip Record If",
Dialog::MergeRule { .. } => "If…Then…Else",
Dialog::MatchFields { .. } => "Match Fields",
Dialog::CheckErrors { .. } => "Check for Errors",
Dialog::TableProperties { .. } => "Table Properties",
Dialog::CellMargins { .. } => "Cell Margins",
Dialog::Password { .. } => "Password",
Dialog::EncryptPassword { .. } => "Encrypt with Password",
Dialog::DefineList { .. } => "Define New Multilevel List",
Dialog::TrackOptions { .. } => "Track Changes Options",
Dialog::Review(d) => d.title(),
Dialog::CustomizeKeyboard { .. } => "Customize Keyboard",
Dialog::CustomizeRibbon { .. } => "Customize Ribbon",
Dialog::Control { .. } => "Content Control Properties",
Dialog::Enclose { .. } => "Enclose Characters",
Dialog::NoteOptions { .. } => "Footnote and Endnote",
Dialog::Templates { .. } => "Templates and Add-ins",
Dialog::Organizer { .. } => "Organizer",
Dialog::CustomProperties { .. } => "Custom Document Properties",
Dialog::Layout(d) => d.title(),
};
let id = egui::Id::unique(("dialog", title));
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);
});
if let Some(shown) = &shown {
if let Dialog::SaveChanges { name, message, .. } = &d {
let question = crate::i18n::fmt(tl!("Do you want to save changes to {name}?"), &[("name", name)]);
let mut description = tl!("Your changes will be lost if you don't save them.").to_string();
if !message.is_empty() {
description.push('\n');
description.push_str(message);
}
// This is an urgent decision, not another application window. Keep its context with
// the focused choice, including when a failed Save brings the same prompt back.
ctx.accesskit_node_builder(shown.response.id, |node| {
node.set_role(egui::accesskit::Role::AlertDialog);
node.set_label(question);
node.set_description(description);
});
}
count_dialog_frame(ctx, id);
close |= crate::window_close::show(ctx, &shown.response, &Tokens::get(ctx));
}
if close {
app.canvas.want_focus = true;
} else {
app.dialog = Some(d);
}
}
/// Cancel key for dialogs: Escape everywhere, plus ⌘. (Command+Period) on macOS.
pub(crate) fn cancel_key_pressed(ui: &Ui) -> bool {
ui.input(|i| i.key_pressed(egui::Key::Escape) || (cfg!(target_os = "macos") && i.modifiers.command && i.key_pressed(egui::Key::Period)))
}
/// Lays out an OK and a Cancel button right-to-left, in the platform's order:
/// macOS puts Cancel left of the default OK button; Windows and Linux put OK left of Cancel.
/// Returns `(ok, cancel)` responses. Call inside a right-to-left layout.
pub(crate) fn ok_cancel_responses(ui: &mut Ui, draw_ok: impl FnOnce(&mut Ui) -> egui::Response, cancel: &str) -> (egui::Response, egui::Response) {
if cfg!(target_os = "macos") {
let okb = draw_ok(ui);
(okb, ui.button(cancel))
} else {
let cancelb = ui.button(cancel);
(draw_ok(ui), cancelb)
}
}
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| {
// 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"));
if cancelb.clicked() || (keys && cancel_key_pressed(ui)) {
r.1 = true;
}
if okb.clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
r.0 = true;
}
});
});
r
}
/// A dialog's single Close button (Escape too).
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| {
close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked();
});
close
}
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
use super::*;
/// The Find and Replace dialog's search options beyond the first row (#543).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FindExtra {
pub wildcards: bool,
pub prefix: bool,
pub suffix: bool,
pub ignore_punct: bool,
pub ignore_space: bool,
/// The search options are shown.
pub more: bool,
/// Special ▾ inserts into Replace with (it last had the focus), not Find what.
pub to_replace: bool,
}
/// The tabs of the Find and Replace dialog (#712).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FindTab {
#[default]
Find,
Replace,
GoTo,
}
/// What Go To moves to.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GoToWhat {
#[default]
Page,
Bookmark,
}
/// The Special ▾ menu: (name, code) for Find what, without and with wildcards.
const SPECIAL_FIND: &[(&str, &str)] = &[
("Paragraph Mark", "^p"),
("Tab Character", "^t"),
("Any Character", "^?"),
("Any Digit", "^#"),
("Any Letter", "^$"),
("Caret Character", "^^"),
("Section Character", "^%"),
("Paragraph Character", "^v"),
("Column Break", "^n"),
("Em Dash", "^+"),
("En Dash", "^="),
("Graphic", "^g"),
("Manual Line Break", "^l"),
("Manual Page Break", "^m"),
("Nonbreaking Hyphen", "^~"),
("Nonbreaking Space", "^s"),
("Optional Hyphen", "^-"),
("Section Break", "^b"),
("White Space", "^w"),
];
const SPECIAL_FIND_WILD: &[(&str, &str)] = &[
("Any Character", "?"),
("Character in Range", "[-]"),
("Beginning of Word", "<"),
("End of Word", ">"),
("Expression", "()"),
("Not", "[!]"),
("Num Occurrences", "{,}"),
("Previous 1 or More", "@"),
("0 or More Characters", "*"),
("Paragraph Mark", "^13"),
("Tab Character", "^t"),
("Caret Character", "^^"),
("Section Character", "^%"),
("Paragraph Character", "^v"),
("Column Break", "^n"),
("Em Dash", "^+"),
("En Dash", "^="),
("Graphic", "^g"),
("Manual Line Break", "^l"),
("Manual Page Break", "^m"),
("Nonbreaking Hyphen", "^~"),
("Nonbreaking Space", "^s"),
("Optional Hyphen", "^-"),
];
/// Replace with; with wildcards, "Find What Expression" (`\1`) comes first.
const SPECIAL_REPLACE: &[(&str, &str)] = &[
("Paragraph Mark", "^p"),
("Tab Character", "^t"),
("Caret Character", "^^"),
("Section Character", "^%"),
("Paragraph Character", "^v"),
("Clipboard Contents", "^c"),
("Column Break", "^n"),
("Em Dash", "^+"),
("En Dash", "^="),
("Find What Text", "^&"),
("Manual Line Break", "^l"),
("Manual Page Break", "^m"),
("Nonbreaking Hyphen", "^~"),
("Nonbreaking Space", "^s"),
("Optional Hyphen", "^-"),
];
/// The Special ▾ menu button; returns the code picked.
pub(super) fn special_menu(ui: &mut Ui, extra: &FindExtra) -> Option<&'static str> {
let mut picked = None;
ui.menu_button(format!("{} ▾", tl!("Special")), |ui| {
let wild_replace: &[(&str, &str)] = &[("Find What Expression", "\\1")];
let lists: [&[(&str, &str)]; 2] = match (extra.to_replace, extra.wildcards) {
(false, false) => [SPECIAL_FIND, &[]],
(false, true) => [SPECIAL_FIND_WILD, &[]],
(true, false) => [SPECIAL_REPLACE, &[]],
(true, true) => [wild_replace, SPECIAL_REPLACE],
};
for (name, code) in lists.into_iter().flatten() {
if ui.button(format!("{} {code}", tl!(name))).clicked() {
picked = Some(*code);
ui.close();
}
}
});
picked
}
/// What Search lists before anything is typed: common commands (#819).
pub(super) const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "file.print"];
/// Search's "Find … in the document": the Navigation pane opens on its results for `text`.
pub(super) fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) {
let qid = app.window_id("nav_query");
ctx.data_mut(|d| d.insert_temp(qid, text.to_string()));
app.ui.nav_tab = "results".into();
let _ = app.run("view.navigationPane", json!({"value": true}));
let _ = app.run("edit.find", json!({"text": text}));
}
/// Whether `e` is the text of the shortcut that opened a box (see `Dialog::Commands::swallow`):
/// a plain key press first means the user is typing, and one swallowed text is all there is.
pub(super) fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool {
match e {
egui::Event::Key { pressed: true, modifiers, .. } if !modifiers.alt => {
*swallow = false;
false
}
egui::Event::Text(_) if *swallow => {
*swallow = false;
true
}
_ => false,
}
}
/// Search's filter: every word of the query is in the command's label, location or id, in English
/// or in the interface language, so "insert table" finds Insert › Tables › Table (#819).
pub(super) fn command_matches(c: &wordcraft_engine::CommandSpec, query: &str) -> bool {
if query.trim().is_empty() {
return false;
}
let hay = format!(
"{} {} {} {} {}",
c.label.to_lowercase(),
c.location.to_lowercase(),
c.id.to_lowercase(),
crate::i18n::lowercase(tl!(c.label)),
crate::i18n::lowercase(&crate::i18n::location(c.location))
);
query.split_whitespace().all(|w| hay.contains(&w.to_lowercase()) || hay.contains(&crate::i18n::lowercase(w)))
}
/// The Find and Replace dialog's Go To tab (#712): a page (a number, or `+2` / `-1` from the
/// current page; empty, Next and Previous step a page) or a bookmark. Returns true to close.
pub(super) fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, target: &mut String, message: &mut String) -> bool {
// The document's bookmarks, only while they are listed (finding them walks the document).
let bookmarks: Vec<String> = if *what == GoToWhat::Bookmark {
app.session.doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| !n.starts_with('_')).collect()
} else {
Vec::new()
};
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.label(tl!("Go to what:"));
for (w, label) in [(GoToWhat::Page, "Page"), (GoToWhat::Bookmark, "Bookmark")] {
if ui.selectable_label(*what == w, tl!(label)).clicked() && *what != w {
*what = w;
target.clear();
message.clear();
}
}
});
ui.add_space(16.0);
ui.vertical(|ui| match what {
GoToWhat::Page => {
let label = ui.label(tl!("Enter page number:"));
let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).labelled_by(label.id);
if !r.has_focus() {
r.request_focus();
}
ui.label(egui::RichText::new(tl!("Type +2 or -1 to move from the page you are on.")).weak());
}
GoToWhat::Bookmark => {
let label = ui.label(tl!("Enter bookmark name:"));
egui::ComboBox::from_id_salt("goto_bookmark")
.width(220.0)
.selected_text(target.as_str())
.show_ui(ui, |ui| {
for b in &bookmarks {
ui.selectable_value(target, b.clone(), b.as_str());
}
})
.response
.labelled_by(label.id);
if bookmarks.is_empty() {
ui.label(egui::RichText::new(tl!("This document has no bookmarks.")).weak());
}
}
});
});
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).weak());
}
let mut close = false;
let mut go: Option<Value> = None;
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.add_space(8.0);
ui.horizontal(|ui| {
let page_step = *what == GoToWhat::Page && target.trim().is_empty();
if page_step && ui.button(tl!("Previous")).clicked() {
go = Some(json!({"pageBy": -1}));
}
let label = if page_step { tl!("Next") } else { tl!("Go To") };
if ui.button(label).clicked() || enter {
go = match what {
GoToWhat::Page => match goto_page(target) {
Some(p) => Some(p),
None => {
*message = tl!("Type a page number, or +2 / -1.").to_string();
None
}
},
GoToWhat::Bookmark if !target.is_empty() => Some(json!({"bookmark": target.clone()})),
GoToWhat::Bookmark => None,
};
}
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
});
if let Some(params) = go {
match app.run("edit.goto", params) {
Ok(_) => message.clear(),
Err(e) => *message = e,
}
}
close
}
/// Go To's page box as `edit.goto` params: empty steps one page on, `+n` / `-n` move from the
/// current page, a number is that page. `None` for anything else.
pub(super) fn goto_page(text: &str) -> Option<Value> {
let t = text.trim().replace('\u{2212}', "-");
if t.is_empty() {
return Some(json!({"pageBy": 1}));
}
if let Some(rest) = t.strip_prefix('+') {
return rest.parse::<i64>().ok().filter(|n| *n >= 0).map(|n| json!({"pageBy": n}));
}
if t.starts_with('-') {
return t.parse::<i64>().ok().map(|n| json!({"pageBy": n}));
}
t.parse::<u64>().ok().filter(|n| *n >= 1).map(|n| json!({"page": n}))
}
+84
View File
@@ -0,0 +1,84 @@
use super::*;
/// The Font dialog preview's size: the Size box's points between 6 and 72, else 12. `"NaN"` and
/// `"inf"` parse as floats too, and a NaN font size trips epaint's glyph assertion (#3663).
pub(super) fn preview_size(size: &str) -> f32 {
size.trim().parse::<f32>().ok().filter(|pt| pt.is_finite()).unwrap_or(12.0).clamp(6.0, 72.0)
}
/// The character properties the Font dialog's fields stand for.
pub(super) fn font_props(d: &Dialog) -> serde_json::Map<String, Value> {
let Dialog::Font {
font, font_ea, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing, kern, kern_at, ..
} = d
else {
return serde_json::Map::new();
};
let mut props = serde_json::Map::new();
props.insert("bold".into(), json!(*bold));
props.insert("italic".into(), json!(*italic));
props.insert("strike".into(), json!(*strike));
props.insert("underline".into(), json!(if *underline { "single" } else { "none" }));
let vert = if *sup {
"superscript"
} else if *sub {
"subscript"
} else {
"baseline"
};
props.insert("vertAlign".into(), json!(vert));
props.insert("smallCaps".into(), json!(*small_caps));
props.insert("caps".into(), json!(*caps));
props.insert("hidden".into(), json!(*hidden));
props.insert("spacing".into(), json!(*spacing));
match kern {
Some(true) => {
props.insert("kern".into(), json!(kern_at.clamp(1.0, 1638.0)));
}
Some(false) => {
props.insert("kern".into(), json!(0.0));
}
None => {}
}
if !font.trim().is_empty() {
props.insert("font".into(), json!(font.trim()));
}
if !font_ea.trim().is_empty() {
props.insert("fontEa".into(), json!(font_ea.trim()));
}
if let Ok(s) = size.trim().parse::<f64>() {
props.insert("size".into(), json!(s));
}
if let Some(c) = wordcraft_doc::Rgb::parse(color) {
props.insert("color".into(), json!({"Rgb": [c.0, c.1, c.2]}));
} else if color.eq_ignore_ascii_case("auto") {
// Automatic, from the colour field's drop-down (#606).
props.insert("color".into(), json!("Auto"));
}
props
}
/// Is `c` Chinese, Japanese or Korean text (ideographs, kana, Hangul, CJK punctuation, fullwidth
/// forms)?
pub(super) fn east_asian_char(c: char) -> bool {
matches!(u32::from(c), 0x1100..=0x11FF | 0x2E80..=0x9FFF | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF | 0x20000..=0x3FFFF)
}
/// The Font dialog's OK: one formatting step that sets only the properties the user changed, so
/// formatting the dialog can't show or left alone stays as it is. Fonts are set by their stored
/// (English) names, the Latin font for Persian/Arabic text too (as `format.font`).
pub(super) fn apply_font(app: &mut WordApp, d: &Dialog) {
let Dialog::Font { start, .. } = d else { return };
let mut changed: serde_json::Map<String, Value> = font_props(d).into_iter().filter(|(k, v)| start.get(k) != Some(v)).collect();
if let Some(f) = changed.get("font").and_then(Value::as_str).map(str::to_string) {
let f = app.previews.font_stored(&f);
changed.insert("font".into(), json!(f));
changed.insert("fontCs".into(), json!(f));
}
if let Some(f) = changed.get("fontEa").and_then(Value::as_str).map(str::to_string) {
changed.insert("fontEa".into(), json!(app.previews.font_stored(&f)));
}
if !changed.is_empty() {
let _ = app.run("format.set", json!({"props": changed}));
}
}
+514
View File
@@ -0,0 +1,514 @@
use super::chrome::buttons;
use super::*;
/// The Custom tab of File › Info › Properties › Advanced Properties (#2982): the document's
/// custom properties, edited as a working copy. OK sends the whole difference from what the
/// dialog opened with as one undoable `file.properties` step (marking the document changed and
/// refreshing File › Info); Cancel sends nothing. Text properties only for now: Word's
/// number/date/yes-no types aren't offered, and a property of another type keeps its stored
/// form — the dialog lists it but refuses to change it (Delete still removes one outright).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomPropertiesForm {
/// The working copy, in document order.
pub props: Vec<wordcraft_doc::CustomProp>,
/// What the document held when the dialog opened; OK sends only what differs.
start: Vec<wordcraft_doc::CustomProp>,
/// The row the list selection is on.
pub selected: usize,
/// The name/value boxes under the list.
pub name: String,
pub value: String,
/// The last error to show under the form (a refused name, a failed command).
pub message: String,
}
impl CustomPropertiesForm {
pub(crate) fn read(app: &WordApp) -> Self {
let props = app.session.doc.custom_props.clone();
let mut form = Self { props: props.clone(), start: props, selected: 0, name: String::new(), value: String::new(), message: String::new() };
// The first row is highlighted, so its fields are ready to modify, as in Word.
form.select(0);
form
}
/// Clicking a row: it fills the name and value boxes, as in Word.
pub(crate) fn select(&mut self, i: usize) {
self.selected = i;
self.message.clear();
match self.props.get(i) {
Some(p) => {
self.name = p.name.clone();
self.value = p.value.clone();
}
None => {
self.name.clear();
self.value.clear();
}
}
}
/// The typed name and value, validated against the engine's own limits
/// (`file.properties`, which refuses them the same way).
fn form(&self) -> Result<(String, String), String> {
let max_name = wordcraft_engine::cmd::file::MAX_CUSTOM_NAME;
let name = self.name.trim();
if name.is_empty() || name.chars().count() > max_name {
return Err(crate::i18n::fmt(tl!("Property names are 1–{max} characters."), &[("max", &max_name.to_string())]));
}
if self.value.len() > wordcraft_engine::cmd::file::MAX_CUSTOM_VALUE {
let max = wordcraft_engine::cmd::file::MAX_CUSTOM_VALUE;
return Err(crate::i18n::fmt(tl!("Property values are at most {max} bytes."), &[("max", &max.to_string())]));
}
Ok((name.to_string(), self.value.clone()))
}
/// Add: the typed name and value join the list, replacing a property of the same name
/// (names compare case-insensitively, as in Word and the engine).
pub(crate) fn add(&mut self) {
match self.form() {
Ok((name, value)) => {
self.message.clear();
match self.props.iter().position(|p| p.name.eq_ignore_ascii_case(&name)) {
Some(i) if self.props[i].kind != "lpwstr" => {
self.message = tl!("Only text properties can be changed.").to_string();
}
Some(i) => {
self.props[i].value = value;
self.selected = i;
}
None => {
self.props.push(wordcraft_doc::CustomProp { name, kind: "lpwstr".into(), value });
self.selected = self.props.len().saturating_sub(1);
}
}
}
Err(m) => self.message = m,
}
}
/// Modify: the selected property takes the typed name and value; a different name moves it
/// there (replacing a property already of that name). A refused form keeps the list as it
/// was.
pub(crate) fn modify(&mut self) {
let Some(old) = self.props.get(self.selected).cloned() else {
self.message = tl!("Select a property to change.").to_string();
return;
};
if old.kind != "lpwstr" {
self.message = tl!("Only text properties can be changed.").to_string();
return;
}
let (name, value) = match self.form() {
Ok(f) => f,
Err(m) => {
self.message = m;
return;
}
};
if self.props.iter().enumerate().any(|(i, p)| i != self.selected && p.kind != "lpwstr" && p.name.eq_ignore_ascii_case(&name)) {
self.message = tl!("Only text properties can be changed.").to_string();
return;
}
self.message.clear();
self.props.remove(self.selected);
match self.props.iter().position(|p| p.name.eq_ignore_ascii_case(&name)) {
Some(i) => {
self.props[i].value = value;
self.selected = i;
}
None => {
let i = self.selected.min(self.props.len());
self.props.insert(i, wordcraft_doc::CustomProp { name, kind: old.kind.clone(), value });
self.selected = i;
}
}
}
/// Delete: the selected property leaves the list.
pub(crate) fn delete(&mut self) {
if self.selected < self.props.len() {
self.props.remove(self.selected);
self.select(self.selected.min(self.props.len().saturating_sub(1)));
}
}
/// The one `file.properties` call OK makes: a property gone since the dialog opened is
/// null, one added or changed is its value. Untouched properties aren't sent, so one of
/// another type (Zotero's raw XML, a number) keeps its stored form. A rename that only
/// changes case sends the old spelling as null too, so the stored spelling follows it.
pub(crate) fn params(&self) -> Value {
let mut custom = serde_json::Map::new();
for p in &self.start {
match self.props.iter().find(|q| q.name.eq_ignore_ascii_case(&p.name)) {
Some(q) if q.name == p.name => {}
_ => {
custom.insert(p.name.clone(), Value::Null);
}
}
}
for p in &self.props {
let changed =
self.start.iter().find(|q| q.name.eq_ignore_ascii_case(&p.name)).is_none_or(|was| was.name != p.name || was.value != p.value);
if changed {
custom.insert(p.name.clone(), json!(p.value));
}
}
json!({"custom": custom})
}
}
/// The Table Properties dialog's fields. Lengths are in the interface unit
/// ([`wordcraft_geom::Unit`], inches by default).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableForm {
pub width_on: bool,
pub width: f32,
/// `left`, `center` or `right`.
pub align: String,
pub indent: f32,
/// Table direction: right to left (`w:bidiVisual`).
pub rtl: bool,
pub row_height_on: bool,
pub row_height: f32,
pub row_exact: bool,
pub allow_break: bool,
pub header_row: bool,
pub column_width: f32,
pub cell_width_on: bool,
pub cell_width: f32,
/// `top`, `center` or `bottom`.
pub valign: String,
}
/// The Cell Margins dialog's values, in the interface unit.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CellMarginsForm {
pub top: f32,
pub left: f32,
pub bottom: f32,
pub right: f32,
}
impl CellMarginsForm {
pub(crate) fn read(app: &WordApp) -> Option<Self> {
let (tp, _, _) = app.session.sel.focus.path.cell()?;
let table = app.session.doc.table(app.session.sel.focus.story, &tp)?;
let scale = crate::options::unit().pt_per_unit();
let style = table.props.style.as_deref().and_then(|id| app.session.doc.styles.table_style(id));
let margins = table.props.cell_margins.or_else(|| style.and_then(|s| s.parts.cell_margins)).unwrap_or([0.0, 5.4, 0.0, 5.4]);
let field = |value: f32, fallback: f32| if value.is_finite() { value.clamp(0.0, 144.0) / scale } else { fallback / scale };
Some(Self { top: field(margins[0], 0.0), left: field(margins[1], 5.4), bottom: field(margins[2], 0.0), right: field(margins[3], 5.4) })
}
/// Send all four values together: changing one side must preserve the other three.
pub(crate) fn changes(&self, basis: &CellMarginsForm) -> Value {
if [self.top, self.left, self.bottom, self.right]
.into_iter()
.zip([basis.top, basis.left, basis.bottom, basis.right])
.all(|(a, b)| (a - b).abs() <= 1e-4)
{
return json!({});
}
let scale = crate::options::unit().pt_per_unit();
let points = |v: f32, old: f32| if v.is_finite() { (v * scale).clamp(0.0, 144.0) } else { old * scale };
json!({
"top": points(self.top, basis.top),
"left": points(self.left, basis.left),
"bottom": points(self.bottom, basis.bottom),
"right": points(self.right, basis.right),
})
}
}
impl TableForm {
/// The caret's table, row, column and cell as the dialog shows them.
pub fn read(app: &WordApp) -> Option<TableForm> {
use wordcraft_doc::props::{Align, HeightRule, VAlign};
let s = &app.session;
let (tp, r, c) = s.sel.focus.path.cell()?;
let t = s.doc.table(s.sel.focus.story, &tp)?;
let k = crate::options::unit().pt_per_unit();
let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default();
let cell = t.rows.get(r).and_then(|x| x.cells.get(c)).map(|x| x.props.clone()).unwrap_or_default();
let tw = wordcraft_engine::cmd::page::sect(s).text_width();
let width = t.props.width_pct.filter(|p| *p > 0.0).map(|p| tw * p.min(100.0) / 100.0).or(t.props.width.filter(|w| *w > 0.0));
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
Some(TableForm {
width_on: width.is_some(),
width: fin(width.unwrap_or(tw)) / k,
align: match t.props.align {
Some(Align::Center) => "center",
Some(Align::Right) => "right",
_ => "left",
}
.into(),
indent: fin(t.props.indent.unwrap_or(0.0)) / k,
rtl: t.props.bidi_visual,
row_height_on: row.height.is_some() && row.height_rule != HeightRule::Auto,
row_height: fin(row.height.unwrap_or(18.0)) / k,
row_exact: row.height_rule == HeightRule::Exact,
allow_break: !row.cant_split,
header_row: row.header,
column_width: fin(t.grid.get(t.grid_col(r, c)).copied().unwrap_or(72.0)) / k,
cell_width_on: cell.width.is_some(),
cell_width: fin(cell.width.unwrap_or(72.0)) / k,
valign: match cell.valign {
Some(VAlign::Center) => "center",
Some(VAlign::Bottom) => "bottom",
_ => "top",
}
.into(),
})
}
/// `table.properties` parameters for what differs from `basis` (empty when nothing does).
pub fn changes(&self, basis: &TableForm) -> Value {
let k = crate::options::unit().pt_per_unit();
let moved = |a: f32, b: f32| (a - b).abs() > 1e-4;
let mut v = serde_json::Map::new();
if self.width_on != basis.width_on || (self.width_on && moved(self.width, basis.width)) {
v.insert("width".into(), if self.width_on { json!(self.width * k) } else { Value::Null });
}
if self.align != basis.align {
v.insert("align".into(), json!(self.align));
}
if moved(self.indent, basis.indent) {
v.insert("indent".into(), json!(self.indent * k));
}
if self.rtl != basis.rtl {
v.insert("rtl".into(), json!(self.rtl));
}
if self.row_height_on != basis.row_height_on
|| (self.row_height_on && (moved(self.row_height, basis.row_height) || self.row_exact != basis.row_exact))
{
v.insert("rowHeight".into(), if self.row_height_on { json!(self.row_height * k) } else { Value::Null });
v.insert("rowHeightRule".into(), json!(if self.row_exact { "exact" } else { "atLeast" }));
}
if self.allow_break != basis.allow_break {
v.insert("allowBreak".into(), json!(self.allow_break));
}
if self.header_row != basis.header_row {
v.insert("headerRow".into(), json!(self.header_row));
}
if moved(self.column_width, basis.column_width) {
v.insert("columnWidth".into(), json!(self.column_width * k));
}
if self.cell_width_on != basis.cell_width_on || (self.cell_width_on && moved(self.cell_width, basis.cell_width)) {
v.insert("cellWidth".into(), if self.cell_width_on { json!(self.cell_width * k) } else { Value::Null });
}
if self.valign != basis.valign {
v.insert("valign".into(), json!(self.valign));
}
Value::Object(v)
}
}
/// Grid picker for Insert › Table (hover to size and preview on the page, click to insert).
pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
let t = Tokens::get(ui.ctx());
let id = egui::Id::unique("table_picker_hover");
let hover: (usize, usize) = ui.data(|d| d.get_temp(id)).unwrap_or((0, 0));
ui.label(if hover.0 > 0 {
crate::i18n::fmt(tl!("{cols}×{rows} Table"), &[("cols", &hover.1.to_string()), ("rows", &hover.0.to_string())])
} else {
tl!("Insert Table").to_string()
});
let mut new_hover = (0, 0);
let mut clicked = None;
egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
for r in 1..=8 {
for c in 1..=10 {
let (rect, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
let on = r <= hover.0 && c <= hover.1;
ui.painter().rect(
rect,
1.0,
if on { t.checked } else { t.input },
egui::Stroke::new(1.0, if on { t.accent } else { t.border_strong }),
egui::StrokeKind::Inside,
);
if resp.hovered() {
new_hover = (r, c);
}
if resp.clicked() {
clicked = Some((r, c));
}
}
ui.end_row();
}
});
ui.data_mut(|d| d.insert_temp(id, new_hover));
if new_hover != hover {
// The page may already be drawn this frame (Focus drops the ribbon over it afterwards).
ui.ctx().request_repaint();
}
if let Some((r, c)) = clicked {
let _ = app.run("insert.table", json!({"rows": r, "cols": c}));
ui.close();
} else if new_hover.0 > 0 {
// Word's Live Preview: the page shows the hovered table until a square is clicked.
app.canvas.table_preview = Some(new_hover);
}
}
/// The Table Properties dialog's fields: Table, Row, Column and Cell sections.
pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) {
let unit = crate::options::unit();
fn len(v: &mut f32, lo: f32, unit: wordcraft_geom::Unit) -> egui::DragValue<'_> {
egui::DragValue::new(v).speed(0.01).range(lo..=22.0).measure(unit).max_decimals(2)
}
let heading = |ui: &mut Ui, s: &str| {
ui.add_space(4.0);
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| {
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();
ui.label(tl!("Alignment:"));
ui.horizontal(|ui| {
for (v, l) in [("left", "Left"), ("center", "Center"), ("right", "Right")] {
ui.radio_value(&mut f.align, v.to_string(), tl!(l));
}
});
ui.end_row();
ui.label(tl!("Indent from left:"));
ui.add_enabled(f.align == "left", egui::DragValue::new(&mut f.indent).speed(0.01).range(-11.0..=22.0).measure(unit).max_decimals(2));
ui.end_row();
ui.label(tl!("Table direction:"));
ui.horizontal(|ui| {
ui.radio_value(&mut f.rtl, false, tl!("Left-to-right"));
ui.radio_value(&mut f.rtl, true, tl!("Right-to-left"));
});
ui.end_row();
});
heading(ui, "Row");
egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
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));
ui.add_enabled_ui(f.row_height_on, |ui| {
egui::ComboBox::from_id_salt("tp_rule").selected_text(tl!(if f.row_exact { "Exactly" } else { "At least" })).show_ui(ui, |ui| {
ui.selectable_value(&mut f.row_exact, false, tl!("At least"));
ui.selectable_value(&mut f.row_exact, true, tl!("Exactly"));
});
});
});
ui.end_row();
});
ui.checkbox(&mut f.allow_break, tl!("Allow row to break across pages"));
ui.checkbox(&mut f.header_row, tl!("Repeat as header row on each page"));
heading(ui, "Column");
egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Preferred width:"));
ui.add(len(&mut f.column_width, 0.1, unit));
ui.end_row();
});
heading(ui, "Cell");
egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.checkbox(&mut f.cell_width_on, tl!("Preferred width:"));
ui.add_enabled(f.cell_width_on, len(&mut f.cell_width, 0.1, unit));
ui.end_row();
ui.label(tl!("Vertical alignment:"));
ui.horizontal(|ui| {
for (v, l) in [("top", "Top"), ("center", "Center"), ("bottom", "Bottom")] {
ui.radio_value(&mut f.valign, v.to_string(), tl!(l));
}
});
ui.end_row();
});
}
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| {
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));
ui.end_row();
}
});
}
/// A custom property's one-line list preview: its first line, at most 80 characters, with an
/// ellipsis when either is cut. The Value box keeps the whole value.
pub(super) fn custom_value_preview(value: &str) -> String {
const MAX: usize = 80;
let first = value.lines().next().unwrap_or("");
let mut chars = first.chars();
let mut shown: String = chars.by_ref().take(MAX).collect();
if chars.next().is_some() || first.len() < value.len() {
shown.push('…');
}
shown
}
/// The Custom tab of File › Info › Properties › Advanced Properties (#2982): the document's
/// custom properties with Add, Modify and Delete over a name/value form. OK is one undo step
/// (one `file.properties` call), Cancel leaves the document as it was.
pub(super) fn custom_properties(app: &mut WordApp, ui: &mut Ui, f: &mut CustomPropertiesForm) -> bool {
let t = Tokens::get(ui.ctx());
let mut clicked = None;
let rows = f.props.len().clamp(1, 8);
let row_h = ui.spacing().interact_size.y + ui.spacing().item_spacing.y;
egui::ScrollArea::vertical().id_salt("custom_props").max_height(rows as f32 * row_h + 4.0).auto_shrink([false, true]).show(ui, |ui| {
if f.props.is_empty() {
ui.label(egui::RichText::new(tl!("This document has no custom properties.")).color(t.text_dim));
}
for (i, p) in f.props.iter().enumerate() {
let row = ui.selectable_label(f.selected == i, format!("{} = {}", p.name, custom_value_preview(&p.value)));
if row.clicked() {
clicked = Some(i);
}
}
});
if let Some(i) = clicked {
f.select(i);
}
ui.horizontal(|ui| {
if ui.button(tl!("Add")).clicked() {
f.add();
}
if ui.add_enabled(f.selected < f.props.len(), egui::Button::new(tl!("Modify"))).clicked() {
f.modify();
}
if ui.add_enabled(f.selected < f.props.len(), egui::Button::new(tl!("Delete"))).clicked() {
f.delete();
}
});
ui.separator();
egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
let name = ui.label(tl!("Name:"));
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(300.0).char_limit(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME))
.labelled_by(name.id);
ui.end_row();
let value = ui.label(tl!("Value:"));
ui.add(egui::TextEdit::singleline(&mut f.value).desired_width(300.0)).labelled_by(value.id);
ui.end_row();
});
if !f.message.is_empty() {
ui.colored_label(ui.visuals().error_fg_color, f.message.as_str());
}
ui.label(egui::RichText::new(tl!("Text properties only: Word's number, date and yes-no types aren't offered.")).small().weak());
let (ok, cancel) = buttons(ui, tl!("OK"));
if cancel {
return true;
}
if ok {
let params = f.params();
if params["custom"].as_object().is_some_and(serde_json::Map::is_empty) {
return true;
}
// One call: one undo step, the document marked changed, File › Info reads it afresh.
match app.run("file.properties", params) {
Ok(_) => return true,
Err(e) => f.message = e,
}
}
false
}
+485
View File
@@ -0,0 +1,485 @@
use super::chrome::buttons;
use super::*;
/// The address parts Match Fields lists, with their keys in `mailings.matchFields`' `address`.
pub const ADDRESS_PARTS: [(&str, &str); 9] = [
("Courtesy Title", "title"),
("First Name", "firstName"),
("Last Name", "lastName"),
("Company", "company"),
("Address", "address"),
("City", "city"),
("State", "state"),
("Postal Code", "postal"),
("Country", "country"),
];
/// The `mailings.rules` parameters a Merge Rule dialog stands for, once it names a field.
pub fn rule_params(d: &Dialog) -> Option<Value> {
let Dialog::MergeRule { rule, field, value, then, els, .. } = d else { return None };
let field = field.trim();
if field.is_empty() {
return None;
}
Some(if rule == "SKIPIF" {
json!({"rule": "SKIPIF", "field": field, "value": value})
} else {
json!({"rule": "IF", "field": field, "value": value, "then": then, "else": els})
})
}
/// The fields a new recipient list starts with.
pub const NEW_LIST_FIELDS: [&str; 5] = ["First Name", "Last Name", "Address", "City", "Postal Code"];
/// A typed recipient list ready for `mailings.recipients`: field names trimmed, blank rows
/// dropped. An error says what to fix.
pub fn recipient_table(fields: &[String], rows: &[Vec<String>]) -> Result<Value, &'static str> {
let fields: Vec<&str> = fields.iter().map(|f| f.trim()).collect();
if fields.is_empty() {
return Err("Add at least one field.");
}
if fields.iter().any(|f| f.is_empty()) {
return Err("Every field needs a name.");
}
for (i, f) in fields.iter().enumerate() {
if fields.iter().skip(i + 1).any(|g| g.eq_ignore_ascii_case(f)) {
return Err("Field names must be different.");
}
}
let rows: Vec<&Vec<String>> = rows.iter().filter(|r| r.iter().any(|c| !c.trim().is_empty())).collect();
if rows.is_empty() {
return Err("Type at least one recipient.");
}
Ok(json!({"fields": fields, "rows": rows}))
}
/// The Recipient List dialog's Filter and Sort section (#778).
#[derive(Clone, Debug, Default, Serialize)]
pub struct RecipientFilter {
/// Whether the section is shown.
pub open: bool,
pub rules: Vec<FilterRow>,
/// Sort by, then by, then by: a field (empty: none) and descending.
pub sort: [(String, bool); 3],
/// Find in list (#1576): only rows with a cell containing this (any case) are shown.
/// View only, like `only_merged`: neither changes the list or the merge.
pub find: String,
/// Show only the records the filter keeps (#1576).
pub only_merged: bool,
/// The grid's rows held while one of its cells is edited (see [`hold_rows`]).
#[serde(skip)]
pub(crate) held: Option<Vec<usize>>,
/// The grid cell that had the keyboard last frame.
#[serde(skip)]
pub(crate) editing: Option<egui::Id>,
/// The last merge order worked out, keyed by a hash of the list, filter and sort, so a long
/// list isn't filtered and sorted again on every frame.
#[serde(skip)]
pub(crate) order_cache: Option<(u64, Vec<Option<usize>>)>,
}
/// One filter condition row: `field op value`, joined to the row above with And or Or.
#[derive(Clone, Debug, Serialize)]
pub struct FilterRow {
pub field: String,
#[serde(serialize_with = "op_id")]
pub op: FilterOp,
pub value: String,
pub or: bool,
}
fn op_id<S: serde::Serializer>(op: &FilterOp, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(op.id())
}
impl RecipientFilter {
/// The session's current filter and sort.
pub fn from_session(m: &wordcraft_engine::cmd::mailings::MergeState) -> Self {
let mut sort: [(String, bool); 3] = Default::default();
for (slot, k) in sort.iter_mut().zip(&m.sort) {
*slot = (k.field.clone(), k.descending);
}
let rules: Vec<FilterRow> =
m.filter.iter().map(|r| FilterRow { field: r.field.clone(), op: r.op, value: r.value.clone(), or: r.or }).collect();
RecipientFilter { open: !rules.is_empty() || !m.sort.is_empty(), rules, sort, ..Default::default() }
}
/// The rules and sort keys on `fields` (a rule on a field renamed or removed in the table
/// drops, as the engine drops it when the list is edited).
fn engine(&self, fields: &[String]) -> (Vec<FilterRule>, Vec<SortKey>) {
let known = |f: &str| !f.trim().is_empty() && fields.iter().any(|h| h.trim().eq_ignore_ascii_case(f.trim()));
let rules = self
.rules
.iter()
.filter(|r| known(&r.field))
.map(|r| FilterRule { field: r.field.clone(), op: r.op, value: r.value.clone(), or: r.or })
.collect();
let sort = self.sort.iter().filter(|(f, _)| known(f)).map(|(f, d)| SortKey { field: f.clone(), descending: *d }).collect();
(rules, sort)
}
/// Each record's place in the merge (None: filtered out), from the cache when the list,
/// filter and sort haven't changed.
fn order(&mut self, fields: &[String], rows: &[Vec<String>]) -> &[Option<usize>] {
use std::hash::{Hash, Hasher};
let (rules, sort) = self.engine(fields);
let mut h = std::collections::hash_map::DefaultHasher::new();
fields.hash(&mut h);
rows.hash(&mut h);
for r in &rules {
(&r.field, r.op.id(), &r.value, r.or).hash(&mut h);
}
for k in &sort {
(&k.field, k.descending).hash(&mut h);
}
let key = h.finish();
if self.order_cache.as_ref().is_none_or(|(k, _)| *k != key) {
let mut order = vec![None; rows.len()];
for (n, i) in wordcraft_engine::cmd::mailings::select_records(fields, rows, &rules, &sort).into_iter().enumerate() {
if let Some(slot) = order.get_mut(i) {
*slot = Some(n + 1);
}
}
self.order_cache = Some((key, order));
}
self.order_cache.as_ref().map_or(&[], |(_, o)| o.as_slice())
}
fn is_empty(&self) -> bool {
self.rules.iter().all(|r| r.field.is_empty()) && self.sort.iter().all(|(f, _)| f.is_empty())
}
/// `mailings.filterRecipients` params for a list with `fields`.
pub fn params(&self, fields: &[String]) -> Value {
let (rules, sort) = self.engine(fields);
json!({
"rules": rules.iter().map(|r| json!({"field": r.field, "op": r.op.id(), "value": r.value, "join": if r.or { "or" } else { "and" }})).collect::<Vec<_>>(),
"sort": sort.iter().map(|k| json!({"field": k.field, "descending": k.descending})).collect::<Vec<_>>(),
})
}
}
/// Each record's place in the merge (None: filtered out), when `filter` filters or sorts.
#[cfg(test)]
pub(crate) fn merge_order(fields: &[String], rows: &[Vec<String>], filter: &RecipientFilter) -> Option<Vec<Option<usize>>> {
(!filter.is_empty()).then(|| {
let (rules, sort) = filter.engine(fields);
let mut order = vec![None; rows.len()];
for (k, i) in wordcraft_engine::cmd::mailings::select_records(fields, rows, &rules, &sort).into_iter().enumerate() {
if let Some(slot) = order.get_mut(i) {
*slot = Some(k + 1);
}
}
order
})
}
/// The Recipient List grid's rows (#1576): real indices into `rows`, in list order, matching
/// `find` and, with `only_merged`, kept by the filter (`order` from [`merge_order`]).
pub(crate) fn visible_recipients(rows: &[Vec<String>], order: Option<&[Option<usize>]>, find: &str, only_merged: bool) -> Vec<usize> {
let needle: Vec<char> = find.trim().chars().flat_map(fold_char).collect();
let kept = |i: usize| !only_merged || order.is_none_or(|o| o.get(i).copied().flatten().is_some());
let found = |row: &Vec<String>| needle.is_empty() || row.iter().any(|c| contains_folded(c, &needle));
rows.iter().enumerate().filter(|(i, row)| kept(*i) && found(row)).map(|(i, _)| i).collect()
}
/// The rows the grid shows (#1576): `fresh`, except while a grid cell is being edited, when the
/// rows shown as the edit began stay put, so a row the edit stops matching doesn't vanish (or
/// slide another record under the cursor) mid-edit. `rows` is the list's length.
pub(crate) fn hold_rows(fresh: Vec<usize>, held: &mut Option<Vec<usize>>, editing: bool, rows: usize) -> Vec<usize> {
if !editing {
*held = None;
return fresh;
}
match held {
Some(h) => h.iter().copied().filter(|&r| r < rows).collect(),
None => {
*held = Some(fresh.clone());
fresh
}
}
}
impl RecipientFilter {
/// New Entry: show every row again, so the new, empty entry can't be hidden by the view.
pub(crate) fn show_all(&mut self) {
self.find.clear();
self.only_merged = false;
self.held = None;
}
}
/// Lower-cases a char for searching; final sigma `ς` counts as `σ`, so `ΟΣ` matches `ος`.
fn fold_char(c: char) -> impl Iterator<Item = char> {
c.to_lowercase().map(|l| if l == 'ς' { 'σ' } else { l })
}
/// Whether `hay` contains `needle` (folded chars) in any case, without allocating.
fn contains_folded(hay: &str, needle: &[char]) -> bool {
hay.char_indices().any(|(i, _)| {
let mut h = hay.get(i..).unwrap_or_default().chars().flat_map(fold_char);
needle.iter().all(|n| h.next() == Some(*n))
})
}
/// The Recipient List dialog's body: a file button, the editable table, OK/Cancel. True to close.
pub(crate) fn recipient_list(
app: &mut WordApp,
ui: &mut Ui,
fields: &mut Vec<String>,
rows: &mut Vec<Vec<String>>,
message: &mut String,
filter: &mut RecipientFilter,
) -> bool {
const CELL_W: f32 = 120.0;
const X_W: f32 = 18.0;
const ROW_H: f32 = 24.0;
const NUM_W: f32 = 34.0;
// Each record's place in the merge (None: filtered out), when there is a filter or sort.
let order: Option<Vec<Option<usize>>> = (!filter.is_empty()).then(|| filter.order(fields, rows).to_vec());
// Wide enough for every field (up to a limit; more scroll sideways).
let gap = ui.spacing().item_spacing.x;
// Plus the rows' scroll bar, so each row's remove button stays in view (#1576).
let scroll = ui.spacing().scroll;
let bar = scroll.bar_width + scroll.bar_inner_margin + scroll.bar_outer_margin;
let table_w = NUM_W + gap + fields.len() as f32 * (CELL_W + X_W + 2.0 * gap) + X_W + 2.0 * gap + bar + X_W;
ui.set_min_width(table_w.clamp(340.0, 720.0));
ui.label(tl!("Type the recipients, or load them from a file."));
if ui.button(tl!("Use an Existing List…")).clicked() {
// The picker takes over; the list it loads replaces this one.
let _ = app.run("ui.openRecipientList", json!({}));
return true;
}
ui.add_space(6.0);
// Find in list and the merged-only view (#1576) hide rows from the grid, never change them.
// Nothing to leave out without a filter: the box shows unticked.
if order.is_none() {
filter.only_merged = false;
}
// A grid cell still has the keyboard: its rows stay put (see `hold_rows`).
let editing = filter.editing.is_some_and(|id| ui.memory(|m| m.has_focus(id)));
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);
let visible = ui
.horizontal(|ui| {
ui.label(tl!("Find in list:"));
ui.add(egui::TextEdit::singleline(&mut filter.find).desired_width(180.0).hint_text(tl!("Any field")));
// The rows the grid shows, by real index: edits and removes go to the right record.
let fresh = visible_recipients(rows, order.as_deref(), &filter.find, filter.only_merged);
let visible = hold_rows(fresh, &mut filter.held, editing, rows.len());
if !filter.find.trim().is_empty() {
let count = visible.len().to_string();
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Matches: {count}"), &[("count", &count)])).weak());
}
visible
})
.inner;
ui.add_space(4.0);
let mut remove_field = None;
let can_remove = fields.len() > 1;
let mut remove_row = None;
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| {
ui.horizontal(|ui| {
ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover());
for (i, f) in fields.iter_mut().enumerate() {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name")))
.accessible_name(format!("{} {}", tl!("Field name"), i + 1));
let x = ui.add_enabled(can_remove, egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove field"));
x_w = x.rect.width();
if x.clicked() {
remove_field = Some(i);
}
}
});
ui.separator();
egui::ScrollArea::vertical().id_salt("recipients_v").max_height(260.0).show_rows(ui, ROW_H, visible.len(), |ui, range| {
for k in range {
let Some(&r) = visible.get(k) else { continue };
let Some(row) = rows.get_mut(r) else { continue };
// Striped rows, like a data grid: painted under the row once its size is known.
let stripe = ui.painter().add(egui::Shape::Noop);
let line = ui.horizontal(|ui| {
// The record's number in the merge, a dash when the filter leaves it out.
let (num, kept) = match order.as_ref().map(|o| o.get(r).copied().flatten()) {
None => ((r + 1).to_string(), true),
Some(Some(n)) => (n.to_string(), true),
Some(None) => ("–".to_string(), false),
};
let num = egui::RichText::new(num).weak();
let (rect, _) = ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover());
ui.put(rect, egui::Label::new(if kept { num } else { num.strikethrough() }));
for (j, c) in row.iter_mut().enumerate() {
// Keyed on the record, not its place in the grid: when the view hides
// or moves rows, the keyboard can't land in another record (#1576).
let field_name = fields.get(j).map_or("", String::as_str);
let cell = ui
.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c).id_salt(("recipient_cell", r, j)))
.accessible_name(crate::i18n::fmt(tl!("{field}, record {n}"), &[("field", field_name), ("n", &(r + 1).to_string())]));
if cell.has_focus() {
focused = Some(cell.id);
}
// Under the field's remove button.
ui.allocate_exact_size(vec2(x_w, X_W), Sense::hover());
}
if ui.add(egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove recipient")).clicked() {
remove_row = Some(r);
}
});
if k % 2 == 1 {
let half = ui.spacing().item_spacing.y / 2.0;
let rect = line.response.rect.expand2(vec2(0.0, half));
ui.painter().set(stripe, egui::Shape::rect_filled(rect, 2.0, ui.visuals().faint_bg_color));
}
}
});
});
});
if focused.is_some() {
filter.editing = focused;
}
if remove_field.is_some() || remove_row.is_some() {
// Row numbers move: hold nothing, and let go of the focused cell (its id names a row).
filter.held = None;
if let Some(id) = filter.editing.take() {
ui.memory_mut(|m| m.surrender_focus(id));
}
}
if let Some(i) = remove_field.filter(|_| fields.len() > 1) {
fields.remove(i);
for r in rows.iter_mut() {
if i < r.len() {
r.remove(i);
}
}
}
if let Some(r) = remove_row.filter(|r| *r < rows.len()) {
rows.remove(r);
}
ui.horizontal(|ui| {
if ui.button(tl!("New Entry")).clicked() {
// The view would hide the new, empty entry.
filter.show_all();
rows.push(vec![String::new(); fields.len()]);
}
if ui.button(tl!("Add Field")).clicked() {
fields.push(String::new());
for r in rows.iter_mut() {
r.push(String::new());
}
}
});
ui.add_space(4.0);
let toggle = egui::Button::new(tl!("Filter and Sort")).selected(filter.open);
if ui.add(toggle).clicked() {
filter.open = !filter.open;
}
if filter.open {
recipient_filter(ui, fields, filter);
}
if let Some(order) = &order {
let count = order.iter().flatten().count().to_string();
let total = rows.len().to_string();
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("{count} of {total} recipients will be merged."), &[("count", &count), ("total", &total)]))
.weak(),
);
}
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).color(Tokens::get(ui.ctx()).red));
}
let (ok, cancel) = buttons(ui, tl!("OK"));
if !ok {
return cancel;
}
let r = recipient_table(fields, rows).map_err(|e| tl!(e).to_string()).and_then(|params| app.load_recipients(params));
// A new list starts unfiltered; the dialog's filter and sort apply to it.
let r = r.and_then(|_| if filter.is_empty() { Ok(Value::Null) } else { app.run("mailings.filterRecipients", filter.params(fields)) });
match r {
Ok(_) => true,
Err(e) => {
*message = e;
false
}
}
}
/// Filter and Sort: condition rows (And/Or, field, comparison, value) and three sort keys.
fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter) {
let names: Vec<String> = fields.iter().map(|f| f.trim().to_string()).filter(|f| !f.is_empty()).collect();
let field_combo = |ui: &mut Ui, id: (&str, usize), value: &mut String, none: bool| {
let shown = if value.is_empty() { tl!("(none)").to_string() } else { value.clone() };
egui::ComboBox::from_id_salt(id).width(130.0).selected_text(shown).show_ui(ui, |ui| {
if none {
ui.selectable_value(value, String::new(), tl!("(none)"));
}
for n in &names {
ui.selectable_value(value, n.clone(), n.as_str());
}
});
};
let mut remove = None;
egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
ui.label("");
ui.label(egui::RichText::new(tl!("Field")).weak());
ui.label(egui::RichText::new(tl!("Comparison")).weak());
ui.label(egui::RichText::new(tl!("Compare to")).weak());
ui.end_row();
for (i, r) in filter.rules.iter_mut().enumerate() {
if i == 0 {
ui.label("");
} else {
egui::ComboBox::from_id_salt(("rf_join", i)).width(60.0).selected_text(tl!(if r.or { "Or" } else { "And" })).show_ui(ui, |ui| {
ui.selectable_value(&mut r.or, false, tl!("And"));
ui.selectable_value(&mut r.or, true, tl!("Or"));
});
}
field_combo(ui, ("rf_field", i), &mut r.field, false);
egui::ComboBox::from_id_salt(("rf_op", i)).width(150.0).selected_text(tl!(r.op.label())).show_ui(ui, |ui| {
for op in FilterOp::ALL {
ui.selectable_value(&mut r.op, op, tl!(op.label()));
}
});
ui.add_enabled(r.op.has_value(), egui::TextEdit::singleline(&mut r.value).desired_width(130.0));
if ui.add(egui::Button::new("×").small()).on_hover_text(tl!("Remove condition")).clicked() {
remove = Some(i);
}
ui.end_row();
}
});
if let Some(i) = remove.filter(|i| *i < filter.rules.len()) {
filter.rules.remove(i);
}
ui.horizontal(|ui| {
if ui.button(tl!("Add Condition")).clicked() && filter.rules.len() < 64 {
let field = names.first().cloned().unwrap_or_default();
filter.rules.push(FilterRow { field, op: FilterOp::Equal, value: String::new(), or: false });
}
if ui.button(tl!("Clear All")).clicked() {
filter.rules.clear();
filter.sort = Default::default();
}
});
ui.add_space(4.0);
egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
for (i, (field, desc)) in filter.sort.iter_mut().enumerate() {
ui.label(tl!(if i == 0 { "Sort by" } else { "Then by" }));
field_combo(ui, ("rs_field", i), field, true);
ui.add_enabled_ui(!field.is_empty(), |ui| {
egui::ComboBox::from_id_salt(("rs_dir", i)).width(110.0).selected_text(tl!(if *desc { "Descending" } else { "Ascending" })).show_ui(
ui,
|ui| {
ui.selectable_value(desc, false, tl!("Ascending"));
ui.selectable_value(desc, true, tl!("Descending"));
},
);
});
ui.end_row();
}
});
}
+151
View File
@@ -0,0 +1,151 @@
use super::*;
/// Labels of Page Setup's paper size (#1433), in one place in case Word's wording differs.
pub(crate) const PAPER_LABELS: PaperLabels =
PaperLabels { group: "Paper", size: "Paper size:", custom: "Custom size", width: "Width:", height: "Height:", more: "More Paper Sizes…" };
pub(crate) struct PaperLabels {
pub group: &'static str,
pub size: &'static str,
pub custom: &'static str,
pub width: &'static str,
pub height: &'static str,
/// The Size menu's last item, opening Page Setup.
pub more: &'static str,
}
/// The named paper size `paper` (in the measurement unit, either orientation) is, or None for a
/// custom size.
pub(crate) fn paper_entry(paper: (f32, f32)) -> Option<&'static str> {
let ppu = crate::options::unit().pt_per_unit();
wordcraft_geom::paper_name(paper.0 * ppu, paper.1 * ppu)
}
/// The commands Page Setup's OK runs for the paper, orientation and margins (#1433), all in the
/// measurement unit as the dialog shows them. Refuses when the margins don't fit the paper.
/// Otherwise it orders them so that none is refused on the way: the size first
/// when the section's margins fit it, else the margins first, else margins of 0 in between.
/// `layout.size` keeps the section's orientation; `layout.orientation` then turns the page and
/// its margins to what the dialog shows. A size equal to the opened one (either way round,
/// within 0.01 pt) doesn't run `layout.size`.
pub(super) fn page_layout_commands(
app: &WordApp,
(paper, was): ((f32, f32), (f32, f32)),
landscape: bool,
(top, bottom, left, right): (f32, f32, f32, f32),
) -> Result<Vec<(&'static str, Value)>, &'static str> {
use wordcraft_engine::cmd::page::{MARGINS_DONT_FIT, margins_fit, sect};
let ppu = crate::options::unit().pt_per_unit();
let (paper, was) = ((paper.0 * ppu, paper.1 * ppu), (was.0 * ppu, was.1 * ppu));
let m = (top * ppu, bottom * ppu, left * ppu, right * ppu);
let cur = sect(&app.session);
if !margins_fit(paper, m, cur.gutter) {
return Err(MARGINS_DONT_FIT);
}
let turned = |on: bool, (a, b): (f32, f32)| if on { (a.max(b), a.min(b)) } else { (a.min(b), a.max(b)) };
let ((w0, h0), (w1, h1)) = (turned(false, paper), turned(false, was));
let new_size = (w0 - w1).abs() >= 0.01 || (h0 - h1).abs() >= 0.01;
let size = ("layout.size", json!({"width": paper.0, "height": paper.1}));
let orientation = ("layout.orientation", json!({"value": if landscape { "landscape" } else { "portrait" }}));
let margins = ("layout.margins", json!({"top": m.0, "bottom": m.1, "left": m.2, "right": m.3}));
let old_margins = (cur.margin_top, cur.margin_bottom, cur.margin_left, cur.margin_right);
Ok(if !new_size || margins_fit(turned(cur.landscape, paper), old_margins, cur.gutter) {
let mut v = if new_size { vec![size] } else { Vec::new() };
v.extend([orientation, margins]);
v
} else if margins_fit(turned(landscape, (cur.page_w, cur.page_h)), m, cur.gutter) {
vec![orientation, margins, size]
} else {
vec![orientation, ("layout.margins", json!({"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0})), size, margins]
})
}
/// Run a dialog's commands as one undo step. If one fails, put the document and the undo stack
/// back as they were (nothing applied) and return its error.
pub(super) fn run_as_one_step(app: &mut WordApp, commands: Vec<(&str, Value)>) -> Result<(), String> {
let snapshot = app.session.edit_snapshot();
for (i, (id, params)) in commands.into_iter().enumerate() {
if i > 0 {
app.session.join_next_undo();
}
if let Err(e) = app.run(id, params) {
app.session.restore(snapshot);
return Err(e);
}
}
Ok(())
}
/// Page Setup's Paper group (#1433): a Paper size list (the named sizes and Custom size) and
/// the width and height. The fields follow the orientation (the wide side is the width in
/// landscape), and a typed size that is wider than tall turns the orientation to landscape.
pub(super) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, custom: &mut bool) {
if (*landscape && paper.0 < paper.1) || (!*landscape && paper.0 > paper.1) {
*paper = (paper.1, paper.0);
}
let unit = crate::options::unit();
let ppu = unit.pt_per_unit();
let l = &PAPER_LABELS;
ui.label(egui::RichText::new(tl!(l.group)).font(semibold(12.5)));
ui.horizontal(|ui| {
let label = ui.label(tl!(l.size));
let entry = paper_entry(*paper).filter(|_| !*custom);
let shown = tl!(entry.unwrap_or(l.custom));
let combo = egui::ComboBox::from_id_salt("ps_paper_size").width(180.0).height(400.0).selected_text(shown).show_ui(ui, |ui| {
for (n, w, h) in wordcraft_geom::PAPER_SIZES {
if ui.selectable_label(entry == Some(*n), tl!(n)).clicked() {
let (w, h) = (w / ppu, h / ppu);
*paper = if *landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) };
*custom = false;
}
}
// Custom size keeps the numbers to edit, and stays chosen while they match a name.
if ui.selectable_label(entry.is_none(), tl!(l.custom)).clicked() {
*custom = true;
}
});
combo.response.labelled_by(label.id);
});
egui::Grid::new("ps_paper").num_columns(4).show(ui, |ui| {
use wordcraft_engine::cmd::page::{MAX_PAGE_SIDE, MIN_PAGE_SIDE};
let range = MIN_PAGE_SIDE / ppu..=MAX_PAGE_SIDE / ppu;
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();
});
}
/// Page Setup's "Vertical alignment:" box (Word's Layout tab).
pub(super) fn page_valign_combo(ui: &mut egui::Ui, valign: &mut wordcraft_doc::section::PageVAlign) {
use wordcraft_doc::section::PageVAlign;
const CHOICES: [(PageVAlign, &str); 4] =
[(PageVAlign::Top, "Top"), (PageVAlign::Center, "Center"), (PageVAlign::Justify, "Justified"), (PageVAlign::Bottom, "Bottom")];
ui.horizontal(|ui| {
let label = ui.label(tl!("Vertical alignment:"));
let shown = CHOICES.iter().find(|(v, _)| v == valign).map_or("", |(_, l)| tl!(l));
let combo = egui::ComboBox::from_id_salt("ps_valign").selected_text(shown).show_ui(ui, |ui| {
for (v, l) in CHOICES {
ui.selectable_value(valign, v, tl!(l));
}
});
combo.response.labelled_by(label.id);
});
}
/// `layout.verticalAlign`'s name for a value.
pub(super) fn page_valign_value(v: wordcraft_doc::section::PageVAlign) -> &'static str {
use wordcraft_doc::section::PageVAlign;
match v {
PageVAlign::Top => "top",
PageVAlign::Center => "center",
PageVAlign::Justify => "justify",
PageVAlign::Bottom => "bottom",
}
}
+121
View File
@@ -0,0 +1,121 @@
use super::*;
/// The Paragraph dialog as one paragraph's formatting fills it, on page `tab`.
pub(super) fn paragraph_dialog(rp: &wordcraft_doc::resolve::ResolvedPara, tab: u8) -> Dialog {
let line = match rp.line_spacing {
wordcraft_doc::props::LineSpacing::Multiple(m) => m,
_ => 1.0,
};
let (vl, vr) = rp.visual_indents();
let unit = crate::options::unit().pt_per_unit();
Dialog::Paragraph {
rtl: rp.bidi,
align: format!("{:?}", rp.align.visual(rp.bidi)).to_lowercase(),
left: vl / unit,
right: vr / unit,
first: rp.indent_first / unit,
before: rp.space_before,
after: rp.space_after,
line,
keep_next: rp.keep_next,
keep_lines: rp.keep_lines,
page_break: rp.page_break_before,
widow: rp.widow_control,
tab,
asian: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
opts: [rp.mirror_indents, rp.contextual_spacing, rp.suppress_line_numbers, rp.suppress_hyphens],
start: serde_json::Map::new(),
mixed: BTreeSet::new(),
}
}
/// The Paragraph dialog's fields as `para.set` takes them: `align`, `left` and `right` as seen
/// on the page (its `onPage`), the rest as paragraph properties, line spacing as a multiple.
pub(super) fn para_fields(d: &Dialog) -> serde_json::Map<String, Value> {
let Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, asian, opts, .. } = d
else {
return serde_json::Map::new();
};
let unit = crate::options::unit().pt_per_unit();
let [kinsoku, word_wrap, overflow, top_line, de, dn] = asian;
let [mirror, contextual, no_numbers, no_hyphens] = opts;
let fields = json!({
"bidi": rtl, "align": align, "left": left * unit, "right": right * unit, "indentFirst": first * unit,
"spaceBefore": before, "spaceAfter": after, "lineSpacing": {"rule": "multiple", "value": line},
"keepNext": keep_next, "keepLines": keep_lines, "pageBreakBefore": page_break, "widowControl": widow,
"mirrorIndents": mirror, "contextualSpacing": contextual, "suppressLineNumbers": no_numbers, "suppressHyphens": no_hyphens,
"kinsoku": kinsoku, "wordWrap": word_wrap, "overflowPunct": overflow, "topLinePunct": top_line, "autoSpaceDE": de, "autoSpaceDN": dn,
});
match fields {
Value::Object(m) => m,
_ => serde_json::Map::new(),
}
}
/// The Paragraph dialog's number boxes: a length in the measurement unit (indents), points
/// (spacing) or lines (line spacing).
#[derive(Clone, Copy)]
pub(super) enum ParaNumber {
Length,
Points,
Lines,
}
/// 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) {
let unit = crate::options::unit();
let blank = mixed.contains(key);
// A value from the document outside the range (line spacing 6) stays until edited.
let field = match kind {
ParaNumber::Length => egui::DragValue::new(value).speed(3.6 / unit.pt_per_unit()).max_decimals(2),
ParaNumber::Points => egui::DragValue::new(value).speed(1.0).range(0.0..=1584.0).clamp_existing_to_range(false),
ParaNumber::Lines => egui::DragValue::new(value).speed(0.05).range(0.5..=5.0).clamp_existing_to_range(false),
};
let field = match kind {
ParaNumber::Length if blank => field.measure_blank(unit),
ParaNumber::Length => field.measure(unit),
_ if blank => field.custom_formatter(|_, _| String::new()),
ParaNumber::Points => field.suffix(format!(" {}", tl!("pt"))),
ParaNumber::Lines => field,
};
if ui.add(field).labelled_by(label.id).changed() {
mixed.remove(key);
}
}
/// A Paragraph dialog check box for the field `key`. While the selected paragraphs differ in
/// it, it shows as neither on nor off, and a click turns it on for all of them.
pub(super) fn para_check(ui: &mut Ui, on: &mut bool, text: &str, key: &str, mixed: &mut BTreeSet<String>) {
if !mixed.contains(key) {
ui.checkbox(on, text);
} else if ui.add(egui::Checkbox::new(&mut false, text).indeterminate(true)).clicked() {
*on = true;
mixed.remove(key);
}
}
/// The Paragraph dialog's OK, as one `para.set` and so one undo step (#1346): the fields that
/// differ from what every selected paragraph had when it opened, not the blank ones, which
/// stay as each paragraph has them. The alignment and indents are as seen on the page, so a
/// new direction keeps them where the dialog shows them.
pub(super) fn apply_paragraph(app: &mut WordApp, d: &Dialog) {
let Dialog::Paragraph { start, mixed, .. } = d else { return };
let fields = para_fields(d);
let turned = !mixed.contains("bidi") && fields.get("bidi") != start.get("bidi");
let (mut props, mut page) = (serde_json::Map::new(), serde_json::Map::new());
for (k, v) in fields {
let on_page = matches!(k.as_str(), "align" | "left" | "right");
if mixed.contains(&k) || (start.get(&k) == Some(&v) && !(turned && on_page)) {
continue;
}
if on_page {
page.insert(k, v);
} else {
props.insert(k, v);
}
}
if !props.is_empty() || !page.is_empty() {
let _ = app.run("para.set", json!({"props": props, "onPage": page}));
}
}
+150
View File
@@ -0,0 +1,150 @@
use super::chrome::close_button;
use super::*;
/// Manage Styles' body. Reads the list from `styles.manage`'s data (never re-running the command,
/// which would ask to open this dialog again) and acts through commands.
pub(super) fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, selected: &mut String) -> bool {
let list = wordcraft_engine::cmd::para::manage_list(&app.session, *alphabetical);
let list = list.as_array().cloned().unwrap_or_default();
let str_of = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
if !list.iter().any(|x| str_of(x, "id") == *selected) {
*selected = list.first().map(|x| str_of(x, "id")).unwrap_or_default();
}
ui.horizontal(|ui| {
ui.label(tl!("Sort order:"));
egui::ComboBox::from_id_salt("manage_styles_sort")
.selected_text(if *alphabetical { tl!("Alphabetical") } else { tl!("As Recommended") })
.show_ui(ui, |ui| {
ui.selectable_value(alphabetical, false, tl!("As Recommended"));
ui.selectable_value(alphabetical, true, tl!("Alphabetical"));
});
});
let t = Tokens::get(ui.ctx());
let shown = egui::Id::unique("manage_styles_shown");
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("manage_styles_list").max_height(200.0).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(380.0);
for st in &list {
let id = str_of(st, "id");
let glyph = match st.get("type").and_then(Value::as_str) {
Some("character") => "a",
Some("linked") => "¶a",
Some("table") => "⊞",
_ => "",
};
let hidden = st.get("hidden").and_then(Value::as_bool).unwrap_or(false);
let mut name = egui::RichText::new(format!("{glyph} {}", str_of(st, "name")));
if hidden {
name = name.weak();
}
let r = ui.selectable_label(*selected == id, name);
// Bring the selection into view once, when it changes (opening, New Style…).
if *selected == id && ui.data(|d| d.get_temp::<String>(shown)).as_deref() != Some(id.as_str()) {
r.scroll_to_me(Some(egui::Align::Center));
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
}
if r.clicked() {
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
*selected = id.clone();
}
if r.double_clicked() {
*selected = id;
if let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) =
Dialog::modify_style(app, selected)
{
app.dialog = Some(Dialog::ModifyStyle { back: true, resume: None, id, name, font, size, bold, italic, color, before, after });
}
}
}
});
});
let leave = |ui: &mut Ui| {
ui.data_mut(|d| d.remove::<String>(shown));
true
};
if app.dialog.is_some() {
return leave(ui);
}
let Some(cur) = list.iter().find(|x| str_of(x, "id") == *selected).cloned() else { return close_button(ui) && leave(ui) };
let ty = str_of(&cur, "type");
ui.add_space(6.0);
crate::previews::style_preview(app, ui, selected, &ty, vec2(388.0, if ty == "table" { 64.0 } else { 40.0 }));
ui.add_space(4.0);
ui.add(egui::Label::new(egui::RichText::new(describe(&cur)).small()).wrap());
ui.add_space(6.0);
let builtin = cur.get("builtIn").and_then(Value::as_bool).unwrap_or(true);
if ty != "table" {
let mut gallery = cur.get("inGallery").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut gallery, tl!("Show in the Styles gallery")).changed() {
let _ = app.run("styles.setVisibility", json!({"style": selected, "gallery": gallery}));
}
}
let mut hidden = cur.get("hidden").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut hidden, tl!("Hide from style lists")).changed() {
let _ = app.run("styles.setVisibility", json!({"style": selected, "hidden": hidden}));
}
ui.add_space(4.0);
ui.horizontal(|ui| {
// Table styles are edited from Table Design.
if ui.add_enabled(ty != "table", egui::Button::new(tl!("Modify…"))).clicked()
&& let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) = Dialog::modify_style(app, selected)
{
app.dialog = Some(Dialog::ModifyStyle { back: true, resume: None, id, name, font, size, bold, italic, color, before, after });
}
if ui.button(tl!("New Style…")).clicked() {
app.dialog = Some(Dialog::NewStyle { name: "Style1".into(), based_on: str_of(&cur, "name"), back: true });
}
let del = ui.add_enabled(!builtin, egui::Button::new(tl!("Delete")));
let del = if builtin { del.on_disabled_hover_text(tl!("Built-in styles can't be deleted.")) } else { del };
if del.clicked() {
let _ = app.run("styles.delete", json!({"style": selected}));
selected.clear();
}
});
(app.dialog.is_some() || close_button(ui)) && leave(ui)
}
/// A style's description: its type, the formatting it sets and what it is based on.
fn describe(st: &Value) -> String {
let f = st.get("format").cloned().unwrap_or_default();
let fs = |k: &str| f.get(k).and_then(Value::as_str);
let fnum = |k: &str| f.get(k).and_then(Value::as_f64);
let pt = |x: f64| format!("{}", (x * 10.0).round() / 10.0);
let mut parts: Vec<String> = vec![
match st.get("type").and_then(Value::as_str) {
Some("character") => tl!("Character style"),
Some("linked") => tl!("Linked style (paragraph and character)"),
Some("table") => tl!("Table style"),
_ => tl!("Paragraph style"),
}
.to_string(),
];
if st.get("builtIn").and_then(Value::as_bool).unwrap_or(false) {
parts.push(tl!("Built-in style").to_string());
}
if let Some(x) = fs("font") {
parts.push(crate::i18n::fmt(tl!("Font: {font}"), &[("font", x)]));
}
if let Some(x) = fnum("size") {
parts.push(crate::i18n::fmt(tl!("{size} pt"), &[("size", &pt(x))]));
}
if f.get("bold").and_then(Value::as_bool) == Some(true) {
parts.push(tl!("Bold").to_string());
}
if f.get("italic").and_then(Value::as_bool) == Some(true) {
parts.push(tl!("Italic").to_string());
}
if let Some(x) = fs("color") {
parts.push(crate::i18n::fmt(tl!("Color: {color}"), &[("color", &format!("#{x}"))]));
}
if let Some(x) = fnum("spaceBefore") {
parts.push(crate::i18n::fmt(tl!("Space before: {pt} pt"), &[("pt", &pt(x))]));
}
if let Some(x) = fnum("spaceAfter") {
parts.push(crate::i18n::fmt(tl!("Space after: {pt} pt"), &[("pt", &pt(x))]));
}
if let Some(b) = st.get("basedOn").and_then(Value::as_str) {
parts.push(crate::i18n::fmt(tl!("Based on: {style}"), &[("style", b)]));
}
parts.join(", ")
}