Files
wordcraft/crates/ui-egui/src/dialogs_insert.rs
T
AlikandBrandon Thomas d8b11c69d3 Columns on selected text go in their own section (#2024) (#2071)
With text selected, Layout > Columns used to change the whole section. As in
Word (observed black-box), a continuous section break now goes where the
selection starts and one where it ends, splitting a paragraph there, unless that
end already meets a section boundary (the document's start or end, or an
existing break); the columns go on the section between, in one undo step.
`layout.columns` gains `apply: "selection"`, its default when text is selected
in the main text. More Columns... offers "Selected text" and picks it then.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:59:01 -04:00

1439 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Columns, Symbol and Field dialogs (#321): Layout › Columns › More Columns…, Insert › Symbol ›
//! More Symbols… and Insert › Quick Parts › Field…. Each ends by running one command
//! (`layout.columns`, `insert.symbol`, `insert.field`), so scripts and agents get the same result
//! without the dialog. Shown through [`crate::dialogs::Dialog::Insert`].
use egui::{Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::WordApp;
use crate::options::MeasureExt;
use crate::theme::{Tokens, regular, semibold};
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "form", rename_all = "camelCase")]
pub enum InsertDialog {
Columns(ColumnsForm),
Symbol(SymbolForm),
Field(FieldForm),
SmartArt(crate::dialogs_smart_art::SmartArtForm),
ChartType(crate::dialogs_chart::ChartTypeForm),
ChartData(crate::dialogs_chart::ChartDataForm),
}
impl InsertDialog {
pub fn name(&self) -> &'static str {
match self {
InsertDialog::Columns(_) => "columns",
InsertDialog::Symbol(_) => "symbol",
InsertDialog::Field(_) => "field",
InsertDialog::SmartArt(_) => "insertSmartArt",
InsertDialog::ChartType(f) if f.change => "changeChartType",
InsertDialog::ChartType(_) => "insertChart",
InsertDialog::ChartData(_) => "chartData",
}
}
/// The window title (English; the caller translates it).
pub fn title(&self) -> &'static str {
match self {
InsertDialog::Columns(_) => "Columns",
InsertDialog::Symbol(_) => "Symbol",
InsertDialog::Field(_) => "Field",
InsertDialog::SmartArt(_) => "Insert SmartArt",
InsertDialog::ChartType(f) if f.change => "Change Chart Type",
InsertDialog::ChartType(_) => "Insert Chart",
InsertDialog::ChartData(_) => "Edit Data",
}
}
}
/// The dialog `ui.dialog` opens by `name`: `columns`, `symbol`, `specialCharacters`, `field`,
/// `insertChart`, `changeChartType` or `chartData` (the last two for the selected chart), or
/// `insertSmartArt`.
pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
Some(match name {
"columns" => InsertDialog::Columns(ColumnsForm::read(app)),
"symbol" => InsertDialog::Symbol(SymbolForm::new(app, 0)),
"specialCharacters" => InsertDialog::Symbol(SymbolForm::new(app, 1)),
"field" => InsertDialog::Field(FieldForm::new(app)),
"insertSmartArt" => InsertDialog::SmartArt(crate::dialogs_smart_art::SmartArtForm::new(app)),
"insertChart" => InsertDialog::ChartType(crate::dialogs_chart::ChartTypeForm::new(app, false)?),
"changeChartType" => InsertDialog::ChartType(crate::dialogs_chart::ChartTypeForm::new(app, true)?),
"chartData" => InsertDialog::ChartData(crate::dialogs_chart::ChartDataForm::new(app)?),
_ => return None,
})
}
/// Draws the dialog; returns true to close it.
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut InsertDialog) -> bool {
match d {
InsertDialog::Columns(f) => columns_ui(app, ui, f),
InsertDialog::Symbol(f) => symbol_ui(app, ui, f),
InsertDialog::Field(f) => field_ui(app, ui, f),
InsertDialog::SmartArt(f) => crate::dialogs_smart_art::picker_ui(app, ui, f),
InsertDialog::ChartType(f) => crate::dialogs_chart::type_ui(app, ui, f),
InsertDialog::ChartData(f) => crate::dialogs_chart::data_ui(app, ui, f),
}
}
/// OK / Cancel (or another OK label); returns (ok, cancel). `ok_enabled` greys OK out.
pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) -> (bool, bool) {
let mut r = (false, false);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(tl!(cancel)).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
r.1 = true;
}
let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok)));
if okb.clicked() || (ok_enabled && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
r.0 = true;
}
});
});
r
}
// ---------------------------------------------------------------------------------------------
// Columns
/// Narrowest column, points (the engine refuses narrower ones).
const MIN_COL_PT: f32 = 18.0;
/// The Columns dialog's fields. Lengths are in the interface unit ([`wordcraft_geom::Unit`]).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ColumnsForm {
pub count: u32,
pub equal: bool,
/// Width and the spacing after it, per column (the last column's spacing is unused).
pub cols: Vec<(f32, f32)>,
/// Line between columns.
pub separator: bool,
/// `section`, `document`, `forward` (this point forward) or `selection` (selected text).
pub apply: String,
/// The section's text width, in the interface unit.
pub text_width: f32,
/// The document has more than one section (offer "This section").
pub sections: bool,
/// Text is selected that can take columns of its own (offer "Selected text", #2024).
pub selection: bool,
}
impl ColumnsForm {
fn k() -> f32 {
crate::options::unit().pt_per_unit()
}
pub fn read(app: &WordApp) -> ColumnsForm {
let k = Self::k();
let sp = wordcraft_engine::cmd::page::sect(&app.session);
let c = &sp.columns;
let count = c.count.clamp(1, 12);
let sections = app.session.doc.sections().len() > 1;
let selection = wordcraft_engine::cmd::page::selection_sectionable(&app.session);
let apply = match (selection, sections) {
(true, _) => "selection",
(false, true) => "section",
(false, false) => "document",
};
let mut f = ColumnsForm {
count,
equal: true,
cols: Vec::new(),
separator: c.separator,
apply: apply.into(),
text_width: sp.text_width() / k,
sections,
selection,
};
if c.widths.len() == count as usize && count > 1 {
f.equal = false;
f.cols = c.widths.iter().map(|(w, s)| (w / k, s / k)).collect();
} else {
f.cols = vec![(0.0, c.space.max(0.0) / k); count as usize];
f.even_out();
}
f
}
fn min_w() -> f32 {
MIN_COL_PT / Self::k()
}
/// Spacing the first column has (or the default 0.5").
fn spacing(&self) -> f32 {
self.cols.first().map(|c| c.1).unwrap_or(36.0 / Self::k())
}
/// Equal widths for `count` columns sharing the first column's spacing.
fn even_out(&mut self) {
let n = self.count.clamp(1, 12) as usize;
let room = (self.text_width - Self::min_w() * n as f32).max(0.0);
let sp = if n > 1 { self.spacing().clamp(0.0, room / (n - 1) as f32) } else { self.spacing() };
let w = (self.text_width - sp * (n as f32 - 1.0)) / n as f32;
self.cols = (0..n).map(|i| (w, if i + 1 < n { sp } else { 0.0 })).collect();
if n == 1
&& let Some(c) = self.cols.first_mut()
{
c.1 = sp;
}
}
pub fn set_count(&mut self, n: u32) {
self.count = n.clamp(1, 12);
self.even_out();
}
/// One, Two, Three, Left or Right.
pub fn preset(&mut self, p: &str) {
let k = Self::k();
let sp = 36.0 / k;
match p {
"left" | "right" => {
self.count = 2;
self.equal = false;
let narrow = (self.text_width - sp) / 3.0;
let wide = (self.text_width - sp) * 2.0 / 3.0;
self.cols = if p == "left" { vec![(narrow, sp), (wide, 0.0)] } else { vec![(wide, sp), (narrow, 0.0)] };
}
_ => {
self.count = match p {
"two" => 2,
"three" => 3,
_ => 1,
};
self.equal = true;
if let Some(c) = self.cols.first_mut() {
c.1 = sp;
}
self.even_out();
}
}
}
/// The preset the current settings match, if any.
pub fn current_preset(&self) -> Option<&'static str> {
let near = |a: f32, b: f32| (a - b).abs() < 0.02;
if self.equal || self.count == 1 {
return match self.count {
1 => Some("one"),
2 => Some("two"),
3 => Some("three"),
_ => None,
};
}
let (a, b) = (self.cols.first()?.0, self.cols.get(1)?.0);
if self.count == 2 && near(a * 2.0, b) {
Some("left")
} else if self.count == 2 && near(b * 2.0, a) {
Some("right")
} else {
None
}
}
/// After column `i`'s width or spacing changed: the next column (the one before, for the
/// last) takes up the difference so the columns fill the text width.
pub fn rebalance(&mut self, i: usize) {
let n = self.cols.len();
if self.equal {
if let (Some(&(w, sp)), true) = (self.cols.get(i), n > 1) {
// Equal columns: a changed width sets the spacing, a changed spacing the width.
let sp = if i == 0 && (w - self.cols.get(1).map(|c| c.0).unwrap_or(w)).abs() > 1e-4 {
(self.text_width - w * n as f32) / (n - 1) as f32
} else {
sp
};
if let Some(c) = self.cols.first_mut() {
c.1 = sp.max(0.0);
}
}
self.even_out();
return;
}
if n < 2 {
return;
}
if let Some(last) = self.cols.last_mut() {
last.1 = 0.0;
}
let j = if i + 1 < n { i + 1 } else { i.saturating_sub(1) };
let total: f32 = self.cols.iter().map(|(w, s)| w + s).sum();
let excess = total - self.text_width;
let min = Self::min_w();
let short = match self.cols.get_mut(j) {
Some(c) => {
c.0 -= excess;
let short = (min - c.0).max(0.0);
c.0 = c.0.max(min);
short
}
None => 0.0,
};
// The neighbour can't shrink further: take the rest back from the edited column.
if short > 0.0
&& let Some(c) = self.cols.get_mut(i)
{
let from_w = short.min((c.0 - min).max(0.0));
c.0 -= from_w;
c.1 = (c.1 - (short - from_w)).max(0.0);
}
}
/// The `layout.columns` parameters.
pub fn params(&self) -> Value {
let k = Self::k();
let r = |x: f32| (x * k * 100.0).round() / 100.0;
if self.equal || self.count <= 1 {
json!({"count": self.count, "space": r(self.spacing()), "separator": self.separator, "apply": self.apply})
} else {
let widths: Vec<[f32; 2]> = self.cols.iter().map(|(w, s)| [r(*w), r(*s)]).collect();
json!({"widths": widths, "separator": self.separator, "apply": self.apply})
}
}
}
/// A preset tile: a small page with its columns.
fn preset_tile(ui: &mut Ui, label: &str, widths: &[f32], on: bool, t: &Tokens) -> bool {
let resp = ui
.vertical(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(44.0, 52.0), Sense::click());
let fill = if on {
t.checked
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside);
let page = Rect::from_min_size(r.min + vec2(9.0, 7.0), vec2(26.0, 38.0));
ui.painter().rect(page, 0.0, t.input, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let inner = page.shrink(4.0);
let gap = 3.5;
let total: f32 = widths.iter().sum::<f32>().max(1.0);
let avail = inner.width() - gap * (widths.len() as f32 - 1.0);
let mut x = inner.min.x;
for w in widths {
let cw = avail * w / total;
let mut y = inner.min.y;
while y + 1.5 <= inner.max.y {
ui.painter().line_segment([pos2(x, y), pos2(x + cw, y)], Stroke::new(1.0, t.text_dim));
y += 3.0;
}
x += cw + gap;
}
ui.label(egui::RichText::new(tl!(label)).small());
resp
})
.inner;
resp.clicked()
}
/// The page preview: the columns (and the line between them) on a page.
fn columns_preview(ui: &mut Ui, f: &ColumnsForm, t: &Tokens) {
let (r, _) = ui.allocate_exact_size(vec2(96.0, 124.0), Sense::hover());
let page = Rect::from_center_size(r.center(), vec2(84.0, 110.0));
ui.painter().rect(page, 0.0, t.input, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let inner = page.shrink(9.0);
let scale = inner.width() / f.text_width.max(0.1);
let mut x = inner.min.x;
let n = f.cols.len();
for (i, (w, sp)) in f.cols.iter().enumerate() {
let cw = (w * scale).max(2.0);
let mut y = inner.min.y;
while y + 2.0 <= inner.max.y {
ui.painter().line_segment([pos2(x, y), pos2((x + cw).min(inner.max.x), y)], Stroke::new(1.2, t.text_dim));
y += 4.0;
}
x += cw;
if i + 1 < n {
let gap = sp * scale;
if f.separator {
let mx = x + gap / 2.0;
ui.painter().line_segment([pos2(mx, inner.min.y), pos2(mx, inner.max.y)], Stroke::new(1.0, t.text));
}
x += gap;
}
}
}
fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) -> bool {
let t = Tokens::get(ui.ctx());
let unit = crate::options::unit();
ui.set_width(420.0);
ui.label(egui::RichText::new(tl!("Presets")).font(semibold(12.5)));
let current = f.current_preset();
ui.horizontal(|ui| {
for (key, label, widths) in [
("one", "One", &[1.0][..]),
("two", "Two", &[1.0, 1.0][..]),
("three", "Three", &[1.0, 1.0, 1.0][..]),
("left", "Left", &[1.0, 2.0][..]),
("right", "Right", &[2.0, 1.0][..]),
] {
if preset_tile(ui, label, widths, current == Some(key), &t) {
f.preset(key);
}
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(tl!("Number of columns:"));
let mut n = f.count;
if ui.add(egui::DragValue::new(&mut n).range(1..=12)).changed() {
f.set_count(n);
}
ui.add_space(16.0);
ui.checkbox(&mut f.separator, tl!("Line between"));
});
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Width and spacing")).font(semibold(12.5)));
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
let mut changed = None;
egui::ScrollArea::vertical().id_salt("columns_widths").max_height(130.0).show(ui, |ui| {
egui::Grid::new("columns_grid").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| {
ui.label(tl!("Col #:"));
ui.label(tl!("Width:"));
ui.label(tl!("Spacing:"));
ui.end_row();
let n = f.cols.len();
let max = f.text_width.max(0.1);
for i in 0..n {
let editable = i == 0 || !f.equal;
ui.label(format!("{}:", i + 1));
if let Some(c) = f.cols.get_mut(i) {
let w = egui::DragValue::new(&mut c.0).speed(0.01).range(ColumnsForm::min_w()..=max).measure(unit).max_decimals(2);
if ui.add_enabled(editable, w).changed() {
changed = Some(i);
}
if i + 1 < n {
let s = egui::DragValue::new(&mut c.1).speed(0.01).range(0.0..=max).measure(unit).max_decimals(2);
if ui.add_enabled(editable, s).changed() {
changed = Some(i);
}
} else {
ui.label("");
}
}
ui.end_row();
}
});
});
if let Some(i) = changed {
f.rebalance(i);
}
let mut equal = f.equal;
if ui.add_enabled(f.count > 1, egui::Checkbox::new(&mut equal, tl!("Equal column width"))).changed() {
f.equal = equal;
if equal {
f.even_out();
}
}
});
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(tl!("Preview"));
columns_preview(ui, f, &t);
});
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(tl!("Apply to:"));
let mut options = vec![("document", "Whole document"), ("forward", "This point forward")];
if f.sections {
options.insert(0, ("section", "This section"));
}
if f.selection {
options.push(("selection", "Selected text"));
}
let shown = options.iter().find(|(k, _)| *k == f.apply).map(|(_, l)| *l).unwrap_or("This section");
egui::ComboBox::from_id_salt("columns_apply").selected_text(tl!(shown)).width(180.0).show_ui(ui, |ui| {
for (k, l) in &options {
ui.selectable_value(&mut f.apply, (*k).to_string(), tl!(l));
}
});
});
let (ok, cancel) = buttons(ui, "OK", "Cancel", true);
if ok {
let _ = app.run("layout.columns", f.params());
}
ok || cancel
}
// ---------------------------------------------------------------------------------------------
// Symbol
/// A symbol inserted from the Symbol dialog, kept for its Recently used row and the ribbon's
/// Symbol gallery (`font` empty = the text's own font).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct RecentSymbol {
pub ch: String,
pub font: String,
}
/// How many recent symbols are kept.
pub const RECENT_SYMBOLS: usize = 20;
/// Remember an inserted symbol (most recent first).
pub fn remember_symbol(app: &mut WordApp, ch: &str, font: &str) {
let r = RecentSymbol { ch: ch.to_string(), font: font.to_string() };
let list = &mut app.ui.recent_symbols;
list.retain(|x| *x != r);
list.insert(0, r);
list.truncate(RECENT_SYMBOLS);
}
/// Unicode blocks the subset list offers (name, first, last code point). The names are the
/// Unicode Standard's block names and stay as they are in every language.
pub const SUBSETS: &[(&str, u32, u32)] = &[
("Basic Latin", 0x0020, 0x007F),
("Latin-1 Supplement", 0x0080, 0x00FF),
("Latin Extended-A", 0x0100, 0x017F),
("Latin Extended-B", 0x0180, 0x024F),
("IPA Extensions", 0x0250, 0x02AF),
("Spacing Modifier Letters", 0x02B0, 0x02FF),
("Combining Diacritical Marks", 0x0300, 0x036F),
("Greek and Coptic", 0x0370, 0x03FF),
("Cyrillic", 0x0400, 0x052F),
("Armenian", 0x0530, 0x058F),
("Hebrew", 0x0590, 0x05FF),
("Arabic", 0x0600, 0x06FF),
("Devanagari", 0x0900, 0x097F),
("Thai", 0x0E00, 0x0E7F),
("Georgian", 0x10A0, 0x10FF),
("Hangul Jamo", 0x1100, 0x11FF),
("Phonetic Extensions", 0x1D00, 0x1DBF),
("Latin Extended Additional", 0x1E00, 0x1EFF),
("Greek Extended", 0x1F00, 0x1FFF),
("General Punctuation", 0x2000, 0x206F),
("Superscripts and Subscripts", 0x2070, 0x209F),
("Currency Symbols", 0x20A0, 0x20CF),
("Letterlike Symbols", 0x2100, 0x214F),
("Number Forms", 0x2150, 0x218F),
("Arrows", 0x2190, 0x21FF),
("Mathematical Operators", 0x2200, 0x22FF),
("Miscellaneous Technical", 0x2300, 0x23FF),
("Enclosed Alphanumerics", 0x2460, 0x24FF),
("Box Drawing", 0x2500, 0x257F),
("Block Elements", 0x2580, 0x259F),
("Geometric Shapes", 0x25A0, 0x25FF),
("Miscellaneous Symbols", 0x2600, 0x26FF),
("Dingbats", 0x2700, 0x27BF),
("Supplemental Arrows", 0x27F0, 0x27FF),
("Braille Patterns", 0x2800, 0x28FF),
("Supplemental Mathematical Operators", 0x2A00, 0x2AFF),
("CJK Symbols and Punctuation", 0x3000, 0x303F),
("Hiragana", 0x3040, 0x309F),
("Katakana", 0x30A0, 0x30FF),
("CJK Unified Ideographs", 0x4E00, 0x9FFF),
("Hangul Syllables", 0xAC00, 0xD7AF),
("Private Use Area", 0xE000, 0xF8FF),
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F),
("Halfwidth and Fullwidth Forms", 0xFF00, 0xFFEF),
("Specials", 0xFFF0, 0xFFFF),
("Mathematical Alphanumeric Symbols", 0x1D400, 0x1D7FF),
("Emoji and Pictographs", 0x1F300, 0x1FAFF),
];
/// The subset a character belongs to.
pub fn subset_of(c: char) -> Option<&'static str> {
let cp = c as u32;
SUBSETS.iter().find(|(_, a, b)| (*a..=*b).contains(&cp)).map(|(n, _, _)| *n)
}
/// Special Characters: (name, character, the command that inserts it, when there is one).
pub const SPECIAL: &[(&str, char, Option<&str>)] = &[
("Em Dash", '\u{2014}', None),
("En Dash", '\u{2013}', None),
("Nonbreaking Hyphen", '\u{2011}', Some("text.nbHyphen")),
("Optional Hyphen", '\u{00AD}', Some("text.optionalHyphen")),
("Em Space", '\u{2003}', None),
("En Space", '\u{2002}', None),
("1/4 Em Space", '\u{2005}', None),
("Nonbreaking Space", '\u{00A0}', Some("text.nbsp")),
("Copyright", '\u{00A9}', None),
("Registered", '\u{00AE}', None),
("Trademark", '\u{2122}', None),
("Section Mark", '\u{00A7}', None),
("Paragraph Mark", '\u{00B6}', None),
("Ellipsis", '\u{2026}', None),
("Single Opening Quote", '\u{2018}', None),
("Single Closing Quote", '\u{2019}', None),
("Double Opening Quote", '\u{201C}', None),
("Double Closing Quote", '\u{201D}', None),
("No-Width Optional Break", '\u{200B}', None),
("No-Width Non Break", '\u{2060}', None),
];
/// The Symbol dialog: the Symbols tab (a font's characters by Unicode subset) and the Special
/// Characters tab.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SymbolForm {
/// 0 Symbols, 1 Special Characters.
pub tab: u8,
/// The chosen font; empty = (normal text), the text's own font.
pub font: String,
pub selected: Option<char>,
/// Character code, hexadecimal Unicode.
pub code: String,
/// Selected row on the Special Characters tab.
pub special: usize,
/// The caret's font: what (normal text) shows.
pub text_font: String,
/// The characters the shown font has, sorted.
#[serde(skip)]
chars: Vec<char>,
/// The font `chars` was read from.
#[serde(skip)]
chars_of: Option<String>,
/// Scroll the grid to this row on the next frame.
#[serde(skip)]
scroll_to: Option<usize>,
}
/// Grid geometry.
const GRID_COLS: usize = 16;
const CELL: f32 = 30.0;
const GRID_ROWS: usize = 8;
impl SymbolForm {
pub fn new(app: &WordApp, tab: u8) -> SymbolForm {
let text_font = app
.session
.doc
.para_at(&app.session.sel.focus)
.map(|p| app.session.doc.styles.resolve_char(p.props.style.as_deref(), &app.session.typing_props()).font)
.unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
let recent = app.ui.recent_symbols.first().cloned();
let font = recent.as_ref().map(|r| r.font.clone()).unwrap_or_default();
let selected = recent.and_then(|r| r.ch.chars().next());
let mut f =
SymbolForm { tab, font, selected, code: String::new(), special: 0, text_font, chars: Vec::new(), chars_of: None, scroll_to: None };
f.sync_code();
f
}
/// The font the grid shows: the chosen one, or the text's.
pub fn shown_font(&self) -> &str {
if self.font.is_empty() { &self.text_font } else { &self.font }
}
/// Read the shown font's characters when it changed.
fn load(&mut self) {
let family = self.shown_font().to_string();
if self.chars_of.as_deref() == Some(family.as_str()) {
return;
}
// The face the document's text would use (with Word's font substitutes).
let face = wordcraft_fonts::word::resolve(&family, false, false).face.get();
self.chars = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control() && !(0xD800..=0xDFFF).contains(&(*c as u32))).collect();
self.chars_of = Some(family);
// Keep the selection when the new font has it; otherwise its first character.
if self.selected.is_none_or(|c| self.chars.binary_search(&c).is_err()) {
self.selected = self.chars.iter().copied().find(|c| *c > ' ').or(self.chars.first().copied());
}
self.sync_code();
self.scroll_to = self.selected.and_then(|c| self.row_of(c));
}
fn row_of(&self, c: char) -> Option<usize> {
self.chars.binary_search(&c).ok().map(|i| i / GRID_COLS)
}
fn sync_code(&mut self) {
self.code = self.selected.map(|c| format!("{:04X}", c as u32)).unwrap_or_default();
}
/// Select by typed character code (hex): the character, when the font has it.
pub fn select_code(&mut self, code: &str) -> bool {
let cp = u32::from_str_radix(code.trim().trim_start_matches("U+").trim_start_matches("u+"), 16).ok();
let Some(c) = cp.and_then(char::from_u32) else { return false };
if self.chars.binary_search(&c).is_err() {
return false;
}
self.selected = Some(c);
self.scroll_to = self.row_of(c);
true
}
/// Jump to a Unicode subset: its first character in this font.
pub fn jump_to(&mut self, subset: &str) {
let Some((_, a, b)) = SUBSETS.iter().find(|(n, _, _)| *n == subset) else { return };
let i = self.chars.partition_point(|c| (*c as u32) < *a);
if let Some(c) = self.chars.get(i).filter(|c| (**c as u32) <= *b) {
self.selected = Some(*c);
self.sync_code();
self.scroll_to = Some(i / GRID_COLS);
}
}
/// The `insert.symbol` parameters for the selection.
pub fn params(&self) -> Option<Value> {
let c = self.selected?;
Some(if self.font.is_empty() { json!({"char": c.to_string()}) } else { json!({"char": c.to_string(), "font": self.font}) })
}
}
/// A character rendered in `font` as a coverage mask (tinted when painted), cached; `None`
/// while waiting for this frame's rendering budget.
fn glyph_texture(app: &mut WordApp, ctx: &egui::Context, font: &str, c: char) -> Option<egui::TextureHandle> {
let ppp = ctx.pixels_per_point();
let key = format!("symbol:{font}:{:x}:{ppp}", c as u32);
let font = font.to_string();
app.previews.get_or(ctx, &key, || glyph_image(&font, c, ppp))
}
fn glyph_image(font: &str, c: char, ppp: f32) -> Option<egui::ColorImage> {
use wordcraft_doc::props::{Align, CharProps, LineSpacing};
let size = 16.0;
let mut p =
wordcraft_doc::Paragraph::with_text(&c.to_string(), CharProps { font: Some(font.to_string()), size: Some(size), ..Default::default() });
p.props.align = Some(Align::Center);
p.props.space_before = Some(((CELL - size * 1.3) / 2.0).max(0.0));
p.props.space_after = Some(0.0);
p.props.line_spacing = Some(LineSpacing::Multiple(1.0));
let mut img = crate::previews::snippet(&wordcraft_doc::Document::new(), p, CELL - 2.0, CELL - 2.0, ppp, 0.0)?;
// Ink against the paper (the first pixel) becomes coverage.
let lum = |p: &Color32| (p.r() as u16 + p.g() as u16 + p.b() as u16) / 3;
let bg = img.pixels.first().map(lum).unwrap_or(255);
for px in img.pixels.iter_mut() {
let a = lum(&*px).abs_diff(bg).min(255) as u8;
*px = Color32::from_rgba_premultiplied(a, a, a, a);
}
Some(img)
}
/// One grid cell; returns the click response.
fn symbol_cell(app: &mut WordApp, ui: &mut Ui, font: &str, c: char, selected: bool, t: &Tokens) -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click());
let fill = if selected {
t.accent
} else if resp.hovered() {
t.hover
} else {
t.input
};
ui.painter().rect(r, 0.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
if let Some(tex) = glyph_texture(app, ui.ctx(), font, c) {
let sz = tex.size_vec2() / ui.ctx().pixels_per_point();
let tint = if selected { t.on_accent } else { t.text };
ui.painter().image(tex.id(), Rect::from_center_size(r.center(), sz), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint);
}
resp
}
fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> bool {
let t = Tokens::get(ui.ctx());
ui.set_width(GRID_COLS as f32 * CELL + 24.0);
ui.horizontal(|ui| {
for (i, l) in ["Symbols", "Special Characters"].into_iter().enumerate() {
if ui.selectable_label(f.tab == i as u8, tl!(l)).clicked() {
f.tab = i as u8;
}
}
});
ui.separator();
let mut insert: Option<(Value, String, String)> = None;
if f.tab == 1 {
if special_ui(app, ui, f, &mut insert, &t) {
return true;
}
} else {
f.load();
ui.horizontal(|ui| {
ui.label(tl!("Font:"));
let normal = tl!("(normal text)").to_string();
let mut items = vec![normal.clone()];
items.extend(app.previews.families());
let shown = if f.font.is_empty() { normal.clone() } else { f.font.clone() };
let pf = app.previews.font_preview_fn();
let normal_row = normal.clone();
let preview = move |ui: &mut Ui, name: &str| -> egui::Response {
if name == normal_row {
let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click());
let t = Tokens::get(ui.ctx());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text);
resp
} else {
pf(ui, name)
}
};
if let Some(v) = crate::widgets::combo(ui, "symbol_font", 190.0, &shown, &items, Some(&preview)) {
f.font = if v == normal || v.trim().is_empty() { String::new() } else { v.trim().to_string() };
f.load();
}
ui.add_space(8.0);
ui.label(tl!("Subset:"));
let current = f.selected.and_then(subset_of).unwrap_or("");
let mut chosen = None;
egui::ComboBox::from_id_salt("symbol_subset").selected_text(current).width(170.0).show_ui(ui, |ui| {
for (name, a, b) in SUBSETS {
let i = f.chars.partition_point(|c| (*c as u32) < *a);
if f.chars.get(i).is_some_and(|c| (*c as u32) <= *b) && ui.selectable_label(*name == current, *name).clicked() {
chosen = Some(*name);
}
}
});
if let Some(s) = chosen {
f.jump_to(s);
}
});
ui.add_space(4.0);
let rows = f.chars.len().div_ceil(GRID_COLS);
let mut area = egui::ScrollArea::vertical().id_salt("symbol_grid").max_height(CELL * GRID_ROWS as f32).auto_shrink([false, false]);
if let Some(row) = f.scroll_to.take() {
// Keep the row in view: it lands on the grid's second row when there is room.
area = area.vertical_scroll_offset(row.saturating_sub(1) as f32 * CELL);
}
let font = f.shown_font().to_string();
let chars = f.chars.clone();
let mut picked = None;
let mut double = false;
area.show_rows(ui, CELL, rows, |ui, range| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
for row in range {
ui.horizontal(|ui| {
for c in chars.iter().skip(row * GRID_COLS).take(GRID_COLS) {
let resp = symbol_cell(app, ui, &font, *c, f.selected == Some(*c), &t);
if resp.clicked() {
picked = Some(*c);
}
if resp.double_clicked() {
picked = Some(*c);
double = true;
}
}
});
}
});
if chars.is_empty() {
ui.label(egui::RichText::new(tl!("This font has no characters to show.")).weak());
}
if let Some(c) = picked {
f.selected = Some(c);
f.sync_code();
}
ui.add_space(6.0);
ui.label(tl!("Recently used symbols:"));
let recent = app.ui.recent_symbols.clone();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
for r in recent.iter().take(GRID_COLS) {
let Some(c) = r.ch.chars().next() else { continue };
let rfont = if r.font.is_empty() { f.text_font.clone() } else { r.font.clone() };
let resp = symbol_cell(app, ui, &rfont, c, false, &t);
if resp.clicked() || resp.double_clicked() {
f.font = r.font.clone();
f.selected = Some(c);
f.load();
f.sync_code();
f.scroll_to = f.row_of(c);
}
if resp.double_clicked() {
double = true;
}
}
if recent.is_empty() {
let _ = ui.allocate_exact_size(vec2(CELL, CELL), Sense::hover());
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
let name = f.selected.map(|c| format!("U+{:04X}", c as u32)).unwrap_or_default();
ui.label(egui::RichText::new(name).font(semibold(12.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(tl!("from Unicode (hex)"));
let resp = ui.add(egui::TextEdit::singleline(&mut f.code).desired_width(70.0).font(egui::TextStyle::Monospace));
if resp.changed() {
let code = f.code.clone();
f.select_code(&code);
}
ui.label(tl!("Character code:"));
});
});
if double && let Some(p) = f.params() {
insert = Some((p, f.selected.map(String::from).unwrap_or_default(), f.font.clone()));
}
// The Insert button.
let (ok, close) = buttons(ui, "Insert", "Close", f.selected.is_some());
if ok && let Some(p) = f.params() {
insert = Some((p, f.selected.map(String::from).unwrap_or_default(), f.font.clone()));
}
if close {
return true;
}
}
if let Some((params, ch, font)) = insert {
// Special characters with their own command insert through it (non-breaking hyphen…).
let r = match params.get("command").and_then(Value::as_str) {
Some(id) => app.run(id, json!({})),
None => app.run("insert.symbol", params),
};
if r.is_ok() {
remember_symbol(app, &ch, &font);
}
}
false
}
/// The Special Characters tab; returns true to close.
fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, insert: &mut Option<(Value, String, String)>, t: &Tokens) -> bool {
let mut double = false;
egui::ScrollArea::vertical().id_salt("special_chars").max_height(CELL * GRID_ROWS as f32 + 60.0).auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)).show(ui, |ui| {
ui.label(egui::RichText::new(tl!("Character")).font(semibold(12.0)));
ui.label("");
ui.label(egui::RichText::new(tl!("Shortcut key:")).font(semibold(12.0)));
ui.end_row();
for (i, (name, c, cmd)) in SPECIAL.iter().enumerate() {
let shown = if c.is_whitespace() || matches!(*c, '\u{00AD}' | '\u{200B}' | '\u{2060}') { String::new() } else { c.to_string() };
ui.label(egui::RichText::new(shown).font(regular(15.0)).color(t.text));
let resp = ui.selectable_label(f.special == i, tl!(name));
if resp.clicked() {
f.special = i;
}
if resp.double_clicked() {
f.special = i;
double = true;
}
ui.label(cmd.map(|id| crate::widgets::shortcut_text(app, id)).unwrap_or_default());
ui.end_row();
}
});
});
let (ok, close) = buttons(ui, "Insert", "Close", true);
if (ok || double)
&& let Some((_, c, cmd)) = SPECIAL.get(f.special)
{
let params = match cmd {
Some(id) => json!({"command": id}),
None => json!({"char": c.to_string()}),
};
*insert = Some((params, c.to_string(), String::new()));
}
close
}
// ---------------------------------------------------------------------------------------------
// Field
/// What a field's options are about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldKind {
Date,
Number,
Bookmark,
Sequence,
Merge,
Toc,
/// STYLEREF: a style and its search and number switches.
Style,
Plain,
}
/// The fields WordCraft computes, by category: (name, category, description).
pub const FIELDS: &[(&str, &str, &str)] = &[
("CREATEDATE", "Date and Time", "When this document was first created"),
("DATE", "Date and Time", "The date it is today"),
("PRINTDATE", "Date and Time", "When this document was most recently printed"),
("SAVEDATE", "Date and Time", "When this document was most recently saved"),
("TIME", "Date and Time", "The time right now"),
("AUTHOR", "Document Information", "Who the document properties name as author"),
("FILENAME", "Document Information", "Name of the file this document is saved as"),
("NUMPAGES", "Document Information", "How many pages the whole document has"),
("NUMWORDS", "Document Information", "How many words the document holds"),
("TITLE", "Document Information", "Title from the document properties"),
("PAGE", "Numbering", "Number of the page the field sits on"),
("SECTION", "Numbering", "Number of the section the field sits in"),
("SECTIONPAGES", "Numbering", "How many pages this section has"),
("SEQ", "Numbering", "A numbered sequence (figures, tables…)"),
("PAGEREF", "Links and References", "Page on which a bookmarked passage falls"),
("REF", "Links and References", "Copy of the text a bookmark marks"),
("STYLEREF", "Links and References", "Text of the nearest paragraph with a given style (running headers)"),
("TOC", "Index and Tables", "A table of contents"),
("MERGEFIELD", "Mail Merge", "A mail merge field"),
];
/// STYLEREF's options (ECMA-376 §17.16.5.59): switch letter and label.
pub const STYLEREF_SWITCHES: &[(char, &str)] = &[
('l', "Take the last match on the page"),
('n', "Show its list number"),
('r', "Show its number relative to this field"),
('w', "Show its number with all higher levels"),
('t', "Number only, without the label's words"),
('p', "Add whether it is above or below"),
];
/// The categories, in list order ("(All)" first).
pub const FIELD_CATEGORIES: &[&str] =
&["(All)", "Date and Time", "Document Information", "Links and References", "Index and Tables", "Mail Merge", "Numbering"];
/// Date and time pictures for the `\@` switch.
pub const DATE_FORMATS: &[&str] = &[
"M/d/yyyy",
"dddd, MMMM d, yyyy",
"MMMM d, yyyy",
"M/d/yy",
"yyyy-MM-dd",
"d-MMM-yy",
"M.d.yyyy",
"d MMMM yyyy",
"MMMM yy",
"MMM-yy",
"M/d/yyyy h:mm am/pm",
"M/d/yyyy h:mm:ss am/pm",
"h:mm am/pm",
"h:mm:ss am/pm",
"HH:mm",
"HH:mm:ss",
];
/// Number formats for the `\*` switch: (switch value, example).
pub const NUMBER_FORMATS: &[(&str, &str)] = &[
("", "(none)"),
("Arabic", "1, 2, 3, …"),
("alphabetic", "a, b, c, …"),
("ALPHABETIC", "A, B, C, …"),
("roman", "i, ii, iii, …"),
("ROMAN", "I, II, III, …"),
("Ordinal", "1st, 2nd, 3rd, …"),
("CardText", "One, Two, Three, …"),
("OrdText", "First, Second, Third, …"),
];
pub fn field_kind(name: &str) -> FieldKind {
match name {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => FieldKind::Date,
"PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" => FieldKind::Number,
"REF" | "PAGEREF" => FieldKind::Bookmark,
"SEQ" => FieldKind::Sequence,
"MERGEFIELD" => FieldKind::Merge,
"TOC" => FieldKind::Toc,
"STYLEREF" => FieldKind::Style,
_ => FieldKind::Plain,
}
}
/// The Field dialog: a field by category, its options, and the field code it makes (or the code
/// typed directly with Field Codes on).
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldForm {
pub category: String,
pub name: String,
/// `\@` picture for date fields.
pub date_format: String,
/// `\*` value for number fields (empty = none).
pub number_format: String,
/// Bookmark (REF, PAGEREF), sequence name (SEQ), merge field (MERGEFIELD) or style name
/// (STYLEREF).
pub target: String,
/// STYLEREF switches, as their letters (`l` search from the bottom of the page, `n`
/// paragraph number, `r` relative context, `w` full context, `t` suppress non-numbers,
/// `p` above/below).
pub style_switches: String,
/// Keep the formatting applied to the result when it updates (`\* MERGEFORMAT`).
pub preserve: bool,
/// Field Codes: type the code directly.
pub codes: bool,
pub code: String,
#[serde(skip)]
bookmarks: Vec<String>,
#[serde(skip)]
merge_fields: Vec<String>,
/// Paragraph and character style names, for STYLEREF.
#[serde(skip)]
styles: Vec<String>,
}
impl FieldForm {
pub fn new(app: &WordApp) -> FieldForm {
let mut bookmarks: Vec<String> = app.session.doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| !n.starts_with('_')).collect();
bookmarks.sort();
bookmarks.dedup();
let mut f = FieldForm {
category: FIELD_CATEGORIES.first().copied().unwrap_or("(All)").to_string(),
name: "DATE".into(),
date_format: DATE_FORMATS.first().copied().unwrap_or("M/d/yyyy").to_string(),
number_format: String::new(),
target: String::new(),
preserve: true,
codes: false,
code: String::new(),
bookmarks,
merge_fields: app.session.merge.headers.clone(),
style_switches: String::new(),
styles: {
let mut v: Vec<String> = app
.session
.doc
.styles
.styles
.iter()
.filter(|s| matches!(s.kind, wordcraft_doc::StyleKind::Paragraph | wordcraft_doc::StyleKind::Character) && !s.hidden)
.map(|s| s.name.clone())
.collect();
v.sort_by_key(|n| n.to_lowercase());
v.dedup();
v
},
};
f.select("DATE");
f
}
/// Choose a field: its options start from sensible defaults.
pub fn select(&mut self, name: &str) {
self.name = name.to_string();
self.number_format.clear();
self.target = match field_kind(name) {
FieldKind::Bookmark => self.bookmarks.first().cloned().unwrap_or_default(),
FieldKind::Sequence => "Figure".into(),
FieldKind::Merge => self.merge_fields.first().cloned().unwrap_or_default(),
FieldKind::Style => self
.styles
.iter()
.find(|n| n.eq_ignore_ascii_case("Heading 1"))
.or(self.styles.first())
.cloned()
.unwrap_or_else(|| "Heading 1".into()),
_ => String::new(),
};
self.style_switches.clear();
if field_kind(name) == FieldKind::Date {
self.date_format = if name == "TIME" { "h:mm am/pm" } else { "M/d/yyyy" }.into();
}
self.code = self.instruction();
}
/// The field code the options make.
pub fn instruction(&self) -> String {
let mut s = self.name.clone();
let target = self.target.trim();
match field_kind(&self.name) {
FieldKind::Date if !self.date_format.trim().is_empty() => s.push_str(&format!(" \\@ \"{}\"", self.date_format.trim().replace('"', ""))),
FieldKind::Bookmark if !target.is_empty() => s.push_str(&format!(" {} \\h", target.split_whitespace().collect::<Vec<_>>().join("_"))),
FieldKind::Sequence => s.push_str(&format!(
" {}",
if target.is_empty() { "Figure".to_string() } else { target.split_whitespace().collect::<Vec<_>>().join("_") }
)),
FieldKind::Merge if !target.is_empty() => {
if target.contains(char::is_whitespace) {
s.push_str(&format!(" \"{}\"", target.replace('"', "")));
} else {
s.push_str(&format!(" {target}"));
}
}
FieldKind::Toc => s.push_str(" \\o \"1-3\" \\h \\z \\u"),
FieldKind::Style if !target.is_empty() => {
s.push_str(&format!(" \"{}\"", target.replace(['"', '\\'], "")));
for (c, _) in STYLEREF_SWITCHES {
if self.style_switches.contains(*c) {
s.push_str(&format!(" \\{c}"));
}
}
}
_ => {}
}
if matches!(field_kind(&self.name), FieldKind::Number | FieldKind::Sequence) && !self.number_format.is_empty() {
s.push_str(&format!(" \\* {}", self.number_format));
}
if self.preserve && field_kind(&self.name) != FieldKind::Toc {
s.push_str(" \\* MERGEFORMAT");
}
s
}
/// The code to insert: typed (Field Codes) or built from the options.
pub fn final_code(&self) -> String {
if self.codes { self.code.trim().to_string() } else { self.instruction() }
}
/// The `insert.field` parameters (a merge field shows «name» until merged).
pub fn params(&self) -> Option<Value> {
let code = self.final_code();
if code.is_empty() || code.len() > 4096 {
return None;
}
let name = wordcraft_layout::fields::field_name(&code);
Some(if name == "MERGEFIELD" { json!({"instr": code, "result": format!("«{}»", merge_field_name(&code))}) } else { json!({"instr": code}) })
}
}
/// The merge field a `MERGEFIELD` code names (quoted names may hold spaces).
fn merge_field_name(code: &str) -> String {
let rest = code.trim_start().split_once(char::is_whitespace).map(|x| x.1.trim_start()).unwrap_or("");
match rest.strip_prefix('"') {
Some(q) => q.split('"').next().unwrap_or("").to_string(),
None => rest.split_whitespace().next().unwrap_or("").to_string(),
}
}
fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> bool {
let t = Tokens::get(ui.ctx());
ui.set_width(620.0);
ui.horizontal_top(|ui| {
// Left: category and field names.
ui.vertical(|ui| {
ui.set_width(200.0);
ui.label(egui::RichText::new(tl!("Pick a field from the list")).font(semibold(12.5)));
ui.label(tl!("Categories:"));
let shown = tl!(f.category.as_str()).to_string();
egui::ComboBox::from_id_salt("field_category").selected_text(shown).width(190.0).show_ui(ui, |ui| {
for c in FIELD_CATEGORIES {
ui.selectable_value(&mut f.category, (*c).to_string(), tl!(c));
}
});
ui.label(tl!("Field names:"));
let mut picked = None;
egui::Frame::new().stroke(Stroke::new(1.0, t.border)).inner_margin(2.0).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("field_names").max_height(230.0).auto_shrink([false, false]).show(ui, |ui| {
for (name, cat, _) in FIELDS {
if (f.category == "(All)" || f.category == *cat) && ui.selectable_label(f.name == *name, *name).clicked() {
picked = Some(*name);
}
}
});
});
if let Some(n) = picked {
f.select(n);
}
});
ui.add_space(14.0);
// Right: the field's options, or its code.
ui.vertical(|ui| {
ui.label(egui::RichText::new(tl!("Field properties")).font(semibold(12.5)));
if let Some((_, _, desc)) = FIELDS.iter().find(|(n, _, _)| *n == f.name) {
ui.label(egui::RichText::new(format!("{} {}", tl!("Description:"), tl!(desc))).small().color(t.text_dim));
}
ui.add_space(4.0);
if f.codes {
ui.label(tl!("Field codes:"));
ui.add(egui::TextEdit::multiline(&mut f.code).desired_rows(3).desired_width(f32::INFINITY).font(egui::TextStyle::Monospace));
ui.label(egui::RichText::new(tl!("Type the field's name and its switches, for example PAGE \\* roman.")).small().color(t.text_dim));
} else {
field_options(ui, f, &t);
ui.add_space(4.0);
ui.checkbox(&mut f.preserve, tl!("Preserve formatting during updates"));
}
});
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Field code:")).font(semibold(12.0)));
ui.label(egui::RichText::new(f.final_code()).monospace());
});
ui.horizontal(|ui| {
let label = if f.codes { "Hide Codes" } else { "Field Codes" };
if ui.button(tl!(label)).clicked() {
if !f.codes {
f.code = f.instruction();
}
f.codes = !f.codes;
}
});
let params = f.params();
let (ok, cancel) = buttons(ui, "OK", "Cancel", params.is_some());
if ok && let Some(p) = params {
let _ = app.run("insert.field", p);
}
ok || cancel
}
fn field_options(ui: &mut Ui, f: &mut FieldForm, t: &Tokens) {
match field_kind(&f.name) {
FieldKind::Date => {
ui.label(tl!("Date formats:"));
ui.add(egui::TextEdit::singleline(&mut f.date_format).desired_width(f32::INFINITY));
egui::Frame::new().stroke(Stroke::new(1.0, t.border)).inner_margin(2.0).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("field_dates").max_height(170.0).auto_shrink([false, false]).show(ui, |ui| {
for pic in DATE_FORMATS {
let sample = wordcraft_engine::cmd::insert::format_date(pic);
if ui.selectable_label(f.date_format == *pic, sample).on_hover_text(*pic).clicked() {
f.date_format = (*pic).to_string();
}
}
});
});
}
FieldKind::Number | FieldKind::Sequence => {
if field_kind(&f.name) == FieldKind::Sequence {
ui.label(tl!("Sequence name:"));
ui.add(egui::TextEdit::singleline(&mut f.target).desired_width(200.0));
}
ui.label(tl!("Format:"));
let shown = NUMBER_FORMATS.iter().find(|(v, _)| *v == f.number_format).map(|(_, l)| *l).unwrap_or("(none)");
egui::ComboBox::from_id_salt("field_number").selected_text(if shown == "(none)" { tl!(shown) } else { shown }).width(200.0).show_ui(
ui,
|ui| {
for (v, l) in NUMBER_FORMATS {
ui.selectable_value(&mut f.number_format, (*v).to_string(), if *l == "(none)" { tl!(l) } else { l });
}
},
);
}
FieldKind::Bookmark => {
ui.label(tl!("Bookmark name:"));
if f.bookmarks.is_empty() {
ui.add(egui::TextEdit::singleline(&mut f.target).desired_width(200.0));
ui.label(egui::RichText::new(tl!("The document has no bookmarks yet.")).small().color(t.text_dim));
} else {
egui::ComboBox::from_id_salt("field_bookmark").selected_text(f.target.clone()).width(200.0).show_ui(ui, |ui| {
for b in &f.bookmarks {
ui.selectable_value(&mut f.target, b.clone(), b);
}
});
}
}
FieldKind::Merge => {
ui.label(tl!("Field name:"));
ui.add(egui::TextEdit::singleline(&mut f.target).desired_width(200.0));
if !f.merge_fields.is_empty() {
ui.horizontal_wrapped(|ui| {
for m in f.merge_fields.clone() {
if ui.selectable_label(f.target == m, &m).clicked() {
f.target = m;
}
}
});
}
}
FieldKind::Style => {
ui.label(tl!("Style name:"));
egui::ComboBox::from_id_salt("field_style").selected_text(f.target.clone()).width(200.0).show_ui(ui, |ui| {
for n in &f.styles {
ui.selectable_value(&mut f.target, n.clone(), n);
}
});
ui.add_space(4.0);
for (c, label) in STYLEREF_SWITCHES {
let mut on = f.style_switches.contains(*c);
if ui.checkbox(&mut on, tl!(label)).changed() {
f.style_switches.retain(|x| x != *c);
if on {
f.style_switches.push(*c);
}
}
}
}
FieldKind::Toc | FieldKind::Plain => {
ui.label(egui::RichText::new(tl!("This field has no options.")).small().color(t.text_dim));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> WordApp {
WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one\ntwo")), Default::default())
}
/// More Columns: two unequal columns with a line between apply as one undo step.
#[test]
fn columns_dialog_applies_unequal_columns_in_one_step() {
let mut a = app();
a.run("ui.dialog", json!({"name": "columns"})).unwrap();
let Some(crate::dialogs::Dialog::Insert(d)) = a.dialog.take() else { panic!("no dialog") };
let InsertDialog::Columns(mut f) = *d else { panic!("not columns") };
assert_eq!((f.count, f.equal), (1, true));
f.preset("left");
assert_eq!(f.current_preset(), Some("left"));
// Widen the first column: the second gives up the room, the total stays the text width.
if let Some(c) = f.cols.first_mut() {
c.0 += 0.5;
}
f.rebalance(0);
let total: f32 = f.cols.iter().map(|(w, s)| w + s).sum();
assert!((total - f.text_width).abs() < 0.01, "{total} vs {}", f.text_width);
f.separator = true;
let undo_before = a.session.undo_depth();
a.run("layout.columns", f.params()).unwrap();
let c = wordcraft_engine::cmd::page::sect(&a.session).columns;
assert_eq!((c.count, c.separator, c.widths.len()), (2, true, 2));
assert_eq!(a.session.undo_depth(), undo_before + 1, "one undo step");
// The dialog reopens with what was applied.
let again = ColumnsForm::read(&a);
assert!(!again.equal && again.separator && again.cols.len() == 2);
a.run("edit.undo", json!({})).unwrap();
assert_eq!(wordcraft_engine::cmd::page::sect(&a.session).columns.count, 1);
}
/// With text selected, More Columns applies to the selected text by default, as in Word
/// (#2024): the other paragraphs keep one column.
#[test]
fn columns_dialog_applies_to_the_selected_text() {
let mut a = app();
assert!(!ColumnsForm::read(&a).selection, "no selection, no Selected text");
a.run("select.paragraph", json!({})).unwrap();
let mut f = ColumnsForm::read(&a);
assert_eq!((f.selection, f.apply.as_str()), (true, "selection"));
f.preset("two");
a.run("layout.columns", f.params()).unwrap();
let counts: Vec<u32> = a.session.doc.sections().iter().map(|(_, x)| x.columns.count).collect();
assert_eq!(counts, vec![2, 1]);
}
/// The Symbol dialog inserts in the chosen font and remembers the symbol.
#[test]
fn symbol_dialog_inserts_with_its_font() {
let mut a = app();
let mut f = SymbolForm::new(&a, 0);
f.font = "DejaVu Sans".into();
f.selected = Some('\u{2665}');
let p = f.params().unwrap();
assert_eq!(p, json!({"char": "\u{2665}", "font": "DejaVu Sans"}));
a.run("insert.symbol", p).unwrap();
remember_symbol(&mut a, "\u{2665}", "DejaVu Sans");
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).starts_with('\u{2665}'));
assert_eq!(a.ui.recent_symbols.first().map(|r| r.font.as_str()), Some("DejaVu Sans"));
assert_eq!(subset_of('\u{2665}'), Some("Miscellaneous Symbols"));
// (normal text) inserts without a font.
f.font.clear();
assert_eq!(f.params().unwrap(), json!({"char": "\u{2665}"}));
}
/// The Field dialog builds the field code from its options.
#[test]
fn field_dialog_builds_the_instruction() {
let a = app();
let mut f = FieldForm::new(&a);
assert_eq!(f.instruction(), r#"DATE \@ "M/d/yyyy" \* MERGEFORMAT"#);
f.date_format = "yyyy-MM-dd".into();
f.preserve = false;
assert_eq!(f.instruction(), r#"DATE \@ "yyyy-MM-dd""#);
f.select("PAGE");
f.number_format = "roman".into();
assert_eq!(f.instruction(), r"PAGE \* roman");
f.select("SEQ");
f.target = "Table".into();
assert_eq!(f.instruction(), r"SEQ Table");
f.select("REF");
f.target = "intro".into();
assert_eq!(f.instruction(), r"REF intro \h");
f.select("STYLEREF");
assert_eq!(f.target, "Heading 1");
f.style_switches = "pl".into();
assert_eq!(f.instruction(), r#"STYLEREF "Heading 1" \l \p"#);
f.select("MERGEFIELD");
f.target = "First Name".into();
assert_eq!(f.params().unwrap(), json!({"instr": r#"MERGEFIELD "First Name""#, "result": "«First Name»"}));
// Field Codes: the typed code wins.
f.codes = true;
f.code = "NUMPAGES \\* Arabic".into();
assert_eq!(f.final_code(), "NUMPAGES \\* Arabic");
f.code = " ".into();
assert!(f.params().is_none());
}
}