mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
refactor(ui-egui): split dialogs.rs into dialogs/
This commit is contained in:
+33
-3532
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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}))
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
@@ -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(", ")
|
||||
}
|
||||
Reference in New Issue
Block a user