diff --git a/crates/ui-egui/src/dialogs/dialogs_insert.rs b/crates/ui-egui/src/dialogs/dialogs_insert.rs index 0e68e6a6c..8ab16eb06 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert.rs @@ -11,6 +11,17 @@ use crate::WordApp; use crate::options::MeasureExt; use crate::theme::{Tokens, regular, semibold}; +mod columns; +mod field; +mod symbol; + +pub use columns::{ColumnsForm, DropCapForm}; +use columns::{columns_ui, drop_cap_ui}; +use field::field_ui; +pub use field::{DATE_FORMATS, FIELD_CATEGORIES, FIELDS, FieldForm, FieldKind, NUMBER_FORMATS, STYLEREF_SWITCHES, field_kind}; +use symbol::symbol_ui; +pub use symbol::{RECENT_SYMBOLS, RecentSymbol, SPECIAL, SUBSETS, SymbolForm, remember_symbol, subset_of}; + #[derive(Clone, Debug, Serialize)] #[serde(tag = "form", rename_all = "camelCase")] pub enum InsertDialog { @@ -102,1331 +113,5 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) -> r } -// --------------------------------------------------------------------------------------------- -// Drop Cap (#2601) - -/// The Drop Cap dialog's fields (Insert › Drop Cap › Drop Cap Options…). `distance` is in the -/// interface unit. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DropCapForm { - /// `none`, `dropped` or `margin`. - pub position: String, - /// The letter's font: its own, else the text's. - pub font: String, - /// The text's font: choosing it leaves the letter without a font of its own. - pub text_font: String, - pub lines: u32, - pub distance: f32, -} - -impl DropCapForm { - /// The paragraph's drop cap; `None` where it can't take one (Word greys the button out). - pub fn read(app: &WordApp) -> Option { - if !crate::widgets::enabled(app, "insert.dropCap") { - return None; - } - let st = wordcraft_engine::cmd::insert::drop_cap_state(&app.session); - Some(DropCapForm { - position: st.position.into(), - font: st.font.clone().unwrap_or_else(|| st.text_font.clone()), - text_font: st.text_font, - lines: u32::from(st.lines.clamp(1, 10)), - distance: st.distance.max(0.0) / crate::options::unit().pt_per_unit(), - }) - } - - /// `insert.dropCap` parameters. - pub fn params(&self) -> Value { - if self.position == "none" { - return json!({"position": "none"}); - } - let font = Some(self.font.trim()).filter(|f| !f.is_empty() && *f != self.text_font); - json!({ - "position": self.position, - "lines": self.lines.clamp(1, 10), - "distance": (self.distance * crate::options::unit().pt_per_unit()).max(0.0), - "font": font, - }) - } -} - -fn drop_cap_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DropCapForm) -> bool { - ui.label(egui::RichText::new(tl!("Position")).font(semibold(12.5))); - ui.horizontal(|ui| { - for (v, l) in [("none", "None"), ("dropped", "Dropped"), ("margin", "In margin")] { - ui.radio_value(&mut f.position, v.to_string(), tl!(l)); - } - }); - ui.add_space(6.0); - ui.label(egui::RichText::new(tl!("Options")).font(semibold(12.5))); - let fams = app.previews.families(); - let preview = app.previews.font_preview_fn(); - let unit = crate::options::unit(); - ui.add_enabled_ui(f.position != "none", |ui| { - egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Font:")); - if let Some(v) = crate::widgets::font_combo(ui, "dropcap_font", 190.0, &f.font, &fams, Some(&*preview)) - && v != crate::widgets::ADD_FONT - { - f.font = v; - } - ui.end_row(); - ui.label(tl!("Lines to drop:")); - ui.add(egui::DragValue::new(&mut f.lines).range(1..=10)); - ui.end_row(); - ui.label(tl!("Distance from text:")); - ui.add(egui::DragValue::new(&mut f.distance).speed(0.01).range(0.0..=22.0 * 72.0 / unit.pt_per_unit()).measure(unit).max_decimals(2)); - ui.end_row(); - }); - }); - let (ok, cancel) = buttons(ui, "OK", "Cancel", true); - if ok { - let _ = app.run("insert.dropCap", f.params()); - } - ok || cancel -} - -// --------------------------------------------------------------------------------------------- -// 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::().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, - /// 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, - /// The font `chars` was read from. - #[serde(skip)] - chars_of: Option, - /// Scroll the grid to this row on the next frame. - #[serde(skip)] - scroll_to: Option, -} - -/// 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 { - 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 { - 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 { - 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 { - 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"), - ("COMMENTS", "Document Information", "Comments from the document properties"), - ("FILENAME", "Document Information", "Name of the file this document is saved as"), - ("KEYWORDS", "Document Information", "Keywords from the document properties"), - ("LASTSAVEDBY", "Document Information", "Who last saved the document"), - ("NUMCHARS", "Document Information", "How many characters the document holds"), - ("NUMPAGES", "Document Information", "How many pages the whole document has"), - ("NUMWORDS", "Document Information", "How many words the document holds"), - ("REVNUM", "Document Information", "How many times the document has been saved"), - ("SUBJECT", "Document Information", "Subject from the document properties"), - ("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" | "NUMCHARS" | "REVNUM" => 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, - #[serde(skip)] - merge_fields: Vec, - /// Paragraph and character style names, for STYLEREF. - #[serde(skip)] - styles: Vec, -} - -impl FieldForm { - pub fn new(app: &WordApp) -> FieldForm { - let mut bookmarks: Vec = 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 = 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::>().join("_"))), - FieldKind::Sequence => s.push_str(&format!( - " {}", - if target.is_empty() { "Figure".to_string() } else { target.split_whitespace().collect::>().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 { - 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; diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs b/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs new file mode 100644 index 000000000..c2edf3bcb --- /dev/null +++ b/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs @@ -0,0 +1,454 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Drop Cap (#2601) + +/// The Drop Cap dialog's fields (Insert › Drop Cap › Drop Cap Options…). `distance` is in the +/// interface unit. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DropCapForm { + /// `none`, `dropped` or `margin`. + pub position: String, + /// The letter's font: its own, else the text's. + pub font: String, + /// The text's font: choosing it leaves the letter without a font of its own. + pub text_font: String, + pub lines: u32, + pub distance: f32, +} + +impl DropCapForm { + /// The paragraph's drop cap; `None` where it can't take one (Word greys the button out). + pub fn read(app: &WordApp) -> Option { + if !crate::widgets::enabled(app, "insert.dropCap") { + return None; + } + let st = wordcraft_engine::cmd::insert::drop_cap_state(&app.session); + Some(DropCapForm { + position: st.position.into(), + font: st.font.clone().unwrap_or_else(|| st.text_font.clone()), + text_font: st.text_font, + lines: u32::from(st.lines.clamp(1, 10)), + distance: st.distance.max(0.0) / crate::options::unit().pt_per_unit(), + }) + } + + /// `insert.dropCap` parameters. + pub fn params(&self) -> Value { + if self.position == "none" { + return json!({"position": "none"}); + } + let font = Some(self.font.trim()).filter(|f| !f.is_empty() && *f != self.text_font); + json!({ + "position": self.position, + "lines": self.lines.clamp(1, 10), + "distance": (self.distance * crate::options::unit().pt_per_unit()).max(0.0), + "font": font, + }) + } +} + +pub(super) fn drop_cap_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DropCapForm) -> bool { + ui.label(egui::RichText::new(tl!("Position")).font(semibold(12.5))); + ui.horizontal(|ui| { + for (v, l) in [("none", "None"), ("dropped", "Dropped"), ("margin", "In margin")] { + ui.radio_value(&mut f.position, v.to_string(), tl!(l)); + } + }); + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Options")).font(semibold(12.5))); + let fams = app.previews.families(); + let preview = app.previews.font_preview_fn(); + let unit = crate::options::unit(); + ui.add_enabled_ui(f.position != "none", |ui| { + egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Font:")); + if let Some(v) = crate::widgets::font_combo(ui, "dropcap_font", 190.0, &f.font, &fams, Some(&*preview)) + && v != crate::widgets::ADD_FONT + { + f.font = v; + } + ui.end_row(); + ui.label(tl!("Lines to drop:")); + ui.add(egui::DragValue::new(&mut f.lines).range(1..=10)); + ui.end_row(); + ui.label(tl!("Distance from text:")); + ui.add(egui::DragValue::new(&mut f.distance).speed(0.01).range(0.0..=22.0 * 72.0 / unit.pt_per_unit()).measure(unit).max_decimals(2)); + ui.end_row(); + }); + }); + let (ok, cancel) = buttons(ui, "OK", "Cancel", true); + if ok { + let _ = app.run("insert.dropCap", f.params()); + } + ok || cancel +} + +// --------------------------------------------------------------------------------------------- +// 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::().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; + } + } +} + +pub(super) 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 +} diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/field.rs b/crates/ui-egui/src/dialogs/dialogs_insert/field.rs new file mode 100644 index 000000000..6dbfdacff --- /dev/null +++ b/crates/ui-egui/src/dialogs/dialogs_insert/field.rs @@ -0,0 +1,415 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// 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"), + ("COMMENTS", "Document Information", "Comments from the document properties"), + ("FILENAME", "Document Information", "Name of the file this document is saved as"), + ("KEYWORDS", "Document Information", "Keywords from the document properties"), + ("LASTSAVEDBY", "Document Information", "Who last saved the document"), + ("NUMCHARS", "Document Information", "How many characters the document holds"), + ("NUMPAGES", "Document Information", "How many pages the whole document has"), + ("NUMWORDS", "Document Information", "How many words the document holds"), + ("REVNUM", "Document Information", "How many times the document has been saved"), + ("SUBJECT", "Document Information", "Subject from the document properties"), + ("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" | "NUMCHARS" | "REVNUM" => 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, + #[serde(skip)] + merge_fields: Vec, + /// Paragraph and character style names, for STYLEREF. + #[serde(skip)] + styles: Vec, +} + +impl FieldForm { + pub fn new(app: &WordApp) -> FieldForm { + let mut bookmarks: Vec = 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 = 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::>().join("_"))), + FieldKind::Sequence => s.push_str(&format!( + " {}", + if target.is_empty() { "Figure".to_string() } else { target.split_whitespace().collect::>().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 { + 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(), + } +} + +pub(super) fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> bool { + let t = Tokens::get(ui.ctx()); + ui.set_width(620.0); + ui.horizontal_top(|ui| { + // 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)); + } + } +} diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs new file mode 100644 index 000000000..7d97e8bdb --- /dev/null +++ b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs @@ -0,0 +1,462 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// 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, + /// 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, + /// The font `chars` was read from. + #[serde(skip)] + chars_of: Option, + /// Scroll the grid to this row on the next frame. + #[serde(skip)] + scroll_to: Option, +} + +/// 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 { + 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 { + 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 { + 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 { + 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 +} + +pub(super) 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 +}