From 6d26a751f00dcfd94ec9bdbb0c058f96f89234a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:35:14 +0000 Subject: [PATCH] refactor(ui-egui): split ribbon.rs into ribbon/ --- crates/ui-egui/src/ribbon.rs | 2908 +---------------------- crates/ui-egui/src/ribbon/arrange.rs | 109 + crates/ui-egui/src/ribbon/charts.rs | 99 + crates/ui-egui/src/ribbon/common.rs | 143 ++ crates/ui-egui/src/ribbon/design.rs | 80 + crates/ui-egui/src/ribbon/draw.rs | 125 + crates/ui-egui/src/ribbon/home.rs | 285 +++ crates/ui-egui/src/ribbon/insert.rs | 175 ++ crates/ui-egui/src/ribbon/language.rs | 249 ++ crates/ui-egui/src/ribbon/layout.rs | 160 ++ crates/ui-egui/src/ribbon/mailings.rs | 89 + crates/ui-egui/src/ribbon/picture.rs | 177 ++ crates/ui-egui/src/ribbon/references.rs | 117 + crates/ui-egui/src/ribbon/review.rs | 132 + crates/ui-egui/src/ribbon/shape.rs | 209 ++ crates/ui-egui/src/ribbon/table.rs | 240 ++ crates/ui-egui/src/ribbon/tabs.rs | 252 ++ crates/ui-egui/src/ribbon/underline.rs | 96 + crates/ui-egui/src/ribbon/view.rs | 159 ++ 19 files changed, 2958 insertions(+), 2846 deletions(-) create mode 100644 crates/ui-egui/src/ribbon/arrange.rs create mode 100644 crates/ui-egui/src/ribbon/charts.rs create mode 100644 crates/ui-egui/src/ribbon/common.rs create mode 100644 crates/ui-egui/src/ribbon/design.rs create mode 100644 crates/ui-egui/src/ribbon/draw.rs create mode 100644 crates/ui-egui/src/ribbon/home.rs create mode 100644 crates/ui-egui/src/ribbon/insert.rs create mode 100644 crates/ui-egui/src/ribbon/language.rs create mode 100644 crates/ui-egui/src/ribbon/layout.rs create mode 100644 crates/ui-egui/src/ribbon/mailings.rs create mode 100644 crates/ui-egui/src/ribbon/picture.rs create mode 100644 crates/ui-egui/src/ribbon/references.rs create mode 100644 crates/ui-egui/src/ribbon/review.rs create mode 100644 crates/ui-egui/src/ribbon/shape.rs create mode 100644 crates/ui-egui/src/ribbon/table.rs create mode 100644 crates/ui-egui/src/ribbon/tabs.rs create mode 100644 crates/ui-egui/src/ribbon/underline.rs create mode 100644 crates/ui-egui/src/ribbon/view.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e9d27608a..59cd1471b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -9,256 +9,69 @@ use crate::theme::{Tokens, medium, regular, semibold}; use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, group, menu_button, small, split}; use crate::{WordApp, icons}; -pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS; +mod arrange; +mod charts; +mod common; +mod design; +mod draw; +mod home; +mod insert; +mod language; +mod layout; +mod mailings; +mod picture; +mod references; +mod review; +mod shape; +mod table; +mod tabs; +mod underline; +mod view; -/// True when the caret/selection touches a picture (#147). -pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool { - matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. }))) -} - -/// Whether the selection is a shape, text box or Drawing Canvas (so Shape Format is shown). -pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool { - match wordcraft_engine::cmd::objects::selected(s) { - Some((_, o)) => matches!(o, wordcraft_doc::para::InlineObject::Shape { .. }) || o.is_canvas(), - None => false, - } -} - -/// Reading controls replace editing tabs entirely. All settings are transient view controls. -fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) { - egui::Panel::top("reading_toolbar") - .exact_size(46.0) - .frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(12, 6)).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - ui.horizontal_centered(|ui| { - ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0))); - ui.separator(); - let w = app.session.view.read_column_width; - let active = match w { - None => "Auto", - Some(v) if v <= 180.0 => "Narrow", - Some(v) if v <= 240.0 => "Medium", - Some(v) if v <= 350.0 => "Wide", - _ => "Page", - }; - ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| { - for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] { - if ui.selectable_label(active == name, tl!(name)).clicked() { - let _ = app.run("view.readColumnWidth", json!({"value": value})); - ui.close(); - } - } - }); - ui.separator(); - let pct = (app.session.view.read_zoom * 100.0).round() as u32; - ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| { - for percent in [75, 90, 100, 110, 125, 150] { - if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() { - let _ = app.run("view.readTextSize", json!({"value": percent})); - ui.close(); - } - } - }); - ui.separator(); - let spacing = app.session.view.read_line_spacing; - let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×")); - ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| { - if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() { - let _ = app.run("view.readLineSpacing", json!({"value": "document"})); - ui.close(); - } - for value in [1.0, 1.25, 1.5, 2.0] { - if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() { - let _ = app.run("view.readLineSpacing", json!({"value": value})); - ui.close(); - } - } - }); - ui.separator(); - use wordcraft_engine::cmd::view::ReadPageColor; - let colors = - [(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")]; - let current = app.session.view.read_page_color; - let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1); - ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| { - for (color, name, value) in colors { - if ui.selectable_label(current == color, tl!(name)).clicked() { - let _ = app.run("view.readPageColor", json!({"value": value})); - ui.close(); - } - } - }); - ui.separator(); - // Headings to jump to, in the Navigation pane beside the columns (Outline view - // is an editing layout, so it isn't offered here). - if ui.button(tl!("Navigation Pane")).clicked() { - let _ = app.run("view.navigationPane", json!({"value": true})); - app.ui.nav_tab = "headings".into(); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!("Edit Document")).clicked() { - let _ = app.run("view.printLayout", json!({})); - } - }); - }); - }); -} - -/// Whether the selection is a SmartArt graphic WordCraft can edit (so SmartArt Design is shown). -pub fn has_smart_art_selected(s: &wordcraft_engine::Session) -> bool { - wordcraft_engine::cmd::smart_art::selected_smart_art(s).is_some() -} - -/// Whether the selection is a chart WordCraft can edit (so Chart Design is shown). -pub fn has_chart_selected(s: &wordcraft_engine::Session) -> bool { - wordcraft_engine::cmd::chart::selected_chart(s).is_some() -} - -/// Contextual tabs for the current selection (pure, tested). -pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> { - let mut tabs = Vec::new(); - if s.sel.focus.path.cell().is_some() { - tabs.push("Table Design"); - tabs.push("Table Layout"); - } - if has_picture_selected(s) { - tabs.push("Picture Format"); - } - if has_shape_selected(s) { - tabs.push("Shape Format"); - } - if has_smart_art_selected(s) { - tabs.push("SmartArt Design"); - } - if has_chart_selected(s) { - tabs.push("Chart Design"); - } - // Editing an equation. - if s.math.is_some() { - tabs.push("Equation"); - } - // Outline view (drawn first, next to File, as Word does). - if s.view.mode == wordcraft_layout::ViewMode::Outline { - tabs.push("Outlining"); - } - tabs -} - -/// Tab to show when the stored tab is no longer applicable (pure, tested). -pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str { - if available.contains(¤t) { current } else { crate::DEFAULT_TAB } -} - -/// Width of the » button that holds the tabs the strip has no room for. -const MORE_TABS_W: f32 = 24.0; - -/// Which tabs fit on a strip `avail` points wide (#897), from each tab's width and whether it must -/// stay. The others give way from the end, leaving `more` points for the » menu that holds them -/// (pure, tested). -pub fn fit_tabs(tabs: &[(f32, bool)], avail: f32, more: f32) -> Vec { - let mut fits = vec![true; tabs.len()]; - let mut total: f32 = tabs.iter().map(|(w, _)| w).sum(); - if total <= avail { - return fits; - } - for (fit, (w, keep)) in fits.iter_mut().zip(tabs).rev() { - if total + more <= avail { - break; - } - if !keep { - *fit = false; - total -= w; - } - } - fits -} - -/// One tab as the strip planner sees it (#2026). -#[derive(Debug, Clone, Copy)] -pub struct TabSpec { - /// Width of the label at full size. - pub text_w: f32, - /// File: always on the strip. - pub file: bool, - /// The tab being shown. - pub active: bool, - /// A contextual tab (Table Design, Picture Format…). - pub contextual: bool, -} - -/// How the tab strip is laid out (#2026). -#[derive(Debug, Clone, PartialEq)] -pub struct StripPlan { - /// Padding each side of a tab label. - pub pad: f32, - /// How much of Comments / Editing / Share is spelled out: 0 all, 1 Share only, 2 none. - pub buttons: usize, - /// Whether each tab is on the strip (the others are in the » menu). - pub fits: Vec, - /// Width each tab takes on the strip (a label may be cut with …). - pub widths: Vec, -} - -/// Smallest a tab label is cut down to before the tab goes into the » menu. -const MIN_TAB_W: f32 = 44.0; - -/// Lays the tabs and the buttons on their right out on `avail` points, giving way in this order: -/// the padding between tabs, the Comments / Editing labels, the Share label, the plain tabs (from -/// the end, into »), the labels of the active and contextual tabs (cut with …), and last those tabs -/// themselves. The buttons' room is always kept and File never leaves the strip. `buttons` is the -/// width of the three buttons at each level (pure, tested). -pub fn plan_strip(tabs: &[TabSpec], buttons: [f32; 3], avail: f32, more: f32) -> StripPlan { - const STEPS: [(f32, usize); 4] = [(18.0, 0), (10.0, 0), (10.0, 1), (10.0, 2)]; - let widths_at = |pad: f32| -> Vec { tabs.iter().map(|t| t.text_w.max(0.0) + pad + 2.0).collect() }; - for (pad, level) in STEPS { - let widths = widths_at(pad); - if widths.iter().sum::() <= avail - buttons[level] { - return StripPlan { pad, buttons: level, fits: vec![true; tabs.len()], widths }; - } - } - let (pad, level) = (10.0, 2); - let mut widths = widths_at(pad); - let room = avail - buttons[level]; - let specs: Vec<(f32, bool)> = widths.iter().zip(tabs).map(|(w, t)| (*w, t.file || t.active || t.contextual)).collect(); - let mut fits = fit_tabs(&specs, room, more); - // The tabs that must stay share what the plain ones left; each is cut down to its share. - let kept = |fits: &[bool], i: usize| fits.get(i).copied().unwrap_or(false) && tabs.get(i).is_some_and(|t| !t.file); - loop { - let n = (0..tabs.len()).filter(|&i| kept(&fits, i)).count(); - let others: f32 = - (0..tabs.len()).filter(|&i| fits.get(i) == Some(&true) && !kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum(); - let own: f32 = (0..tabs.len()).filter(|&i| kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum(); - let extra = if fits.iter().all(|f| *f) { 0.0 } else { more }; - let share_room = room - extra - others; - if n == 0 || own <= share_room { - break; - } - if share_room >= n as f32 * MIN_TAB_W { - let share = share_room / n as f32; - for (i, w) in widths.iter_mut().enumerate() { - if kept(&fits, i) { - *w = w.min(share); - } - } - break; - } - // Not even cut down: the last of them (the active tab last of all) goes into ». - let last = (0..tabs.len()) - .rev() - .find(|&i| kept(&fits, i) && tabs.get(i).is_some_and(|t| !t.active)) - .or_else(|| (0..tabs.len()).rev().find(|&i| kept(&fits, i))); - match last.and_then(|i| fits.get_mut(i)) { - Some(f) => *f = false, - None => break, - } - } - StripPlan { pad, buttons: level, fits, widths } -} - -/// Whether the caret is inside a table (so the contextual tabs and their badges are shown). -pub fn in_table_public(app: &WordApp) -> bool { - app.session.sel.focus.path.cell().is_some() -} +#[cfg(test)] +use arrange::align_menu; +pub(crate) use arrange::wrap_text_items; +use charts::{chart_design, smart_art_design}; +pub(crate) use common::mi; +#[cfg(test)] +pub(crate) use common::size_menu; +use common::{custom_groups, edge_cue}; +use design::design; +#[cfg(test)] +use design::style_set_box; +use draw::draw; +use home::home; +#[cfg(test)] +use home::painter_double_click; +use insert::insert; +#[cfg(test)] +use language::language_checks; +#[cfg(test)] +pub(crate) use language::{PROOFING_LANGUAGES, language_name}; +pub(crate) use language::{ + caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu, +}; +use layout::layout; +#[cfg(test)] +use layout::{columns_items, margins_items, orientation_items}; +use mailings::mailings; +use picture::picture_format; +use references::{references, zotero}; +use review::{developer, help, review}; +#[cfg(test)] +pub(crate) use shape::gradient_menu; +use shape::shape_format; +use table::{table_design, table_layout}; +#[cfg(test)] +use tabs::MIN_TAB_W; +use tabs::{MORE_TABS_W, reading_toolbar}; +pub use tabs::{ + StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected, + in_table_public, plan_strip, resolve_tab, +}; +#[cfg(test)] +use underline::{UNDERLINE_STYLES, underline_menu}; +use view::{outlining, view}; /// Draws the Read toolbar or the full ribbon, switching to and from the contextual Equation and /// Outlining tabs and showing the available tab strip. @@ -589,2602 +402,5 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); } -/// Custom groups (Customize Ribbon, #379): up to three commands as large buttons, more as small -/// labelled buttons in columns of three. Each shows the command's own icon and label. -fn custom_groups(app: &mut WordApp, ui: &mut Ui, groups: &[wordcraft_engine::ribbon::CustomGroup]) { - for g in groups { - let ids: Vec<(&str, String)> = - g.commands.iter().filter_map(|id| app.session.registry.get(id).map(|s| (s.id, tl!(s.label).to_string()))).collect(); - crate::widgets::group_titled(ui, &g.name, &g.name, None, app, |ui, app| { - if ids.len() <= 3 { - for (id, label) in &ids { - big(ui, app, crate::widgets::command_icon(id), &two_lines(label), id, json!({}), false); - } - return; - } - for column in ids.chunks(3) { - stack(ui, |ui| { - for (id, label) in column { - small(ui, app, crate::widgets::command_icon(id), Some(label), label, id, json!({}), false); - } - }); - } - }); - } -} - -/// A large button's label on two lines when it is long (broken at the space nearest the middle). -fn two_lines(label: &str) -> String { - let n = label.chars().count(); - if n <= 10 { - return label.to_string(); - } - let mid = n / 2; - let best = label.char_indices().filter(|(_, c)| *c == ' ').min_by_key(|(i, _)| label[..*i].chars().count().abs_diff(mid)).map(|(i, _)| i); - match best { - Some(i) => format!("{}\n{}", &label[..i], label[i..].trim_start()), - None => label.to_string(), - } -} - -/// A chevron over a ribbon edge that is also a button; `true` on the frame it is clicked. -fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool { - let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint); - let ink = if resp.hovered() { t.text } else { t.text_dim }; - ui.painter().rect_filled(rect, 0.0, t.ribbon); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink); - resp.clicked() -} - -/// Whether Esc just ended typing in a field (`r`, remembered under `id`). egui 0.36 keeps a -/// `DragValue`'s typed text after Esc and applies it a frame later, when the field still counts as -/// having just lost focus but Esc is no longer pressed: a change reported within two frames of the -/// Esc is that late value, to be dropped so that Esc cancels as in Word. -fn esc_cancelled(ui: &Ui, r: &egui::Response, id: egui::Id) -> bool { - let now = ui.ctx().cumulative_pass_nr(); - let (focus_key, esc_key) = (id.with("focused"), id.with("esc")); - // Read outside `data_mut`: both lock the context. - let focused = r.has_focus(); - let had_focus = ui.data(|d| d.get_temp::(focus_key)).unwrap_or(false); - ui.data_mut(|d| d.insert_temp(focus_key, focused)); - if had_focus && !focused && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(esc_key, now)); - } - ui.data(|d| d.get_temp::(esc_key)).is_some_and(|at| now.saturating_sub(at) <= 2) -} - -fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { - ui.vertical(|ui| { - ui.spacing_mut().item_spacing = vec2(1.0, 0.0); - add(ui); - }); -} - -/// Layout › Size: the named paper sizes, then More Paper Sizes… for a custom size in Page -/// Setup (#1433). -pub(crate) fn size_menu(ui: &mut Ui, app: &mut WordApp) { - // The current paper size is checked. - let x = wordcraft_engine::cmd::page::sect(&app.session); - let current = wordcraft_geom::paper_name(x.page_w, x.page_h); - for (n, w, h) in wordcraft_geom::PAPER_SIZES { - // Paper names are the industry's (Letter, A4, B5 (JIS)) and stay as they are; the - // envelopes say what they are. Sizes in the measurement unit, written the region's - // way (`21 cm × 29,7 cm`, #838). - let name = if n.starts_with("Envelope ") { tl!(n) } else { n }; - let (width, height) = (crate::options::length(*w), crate::options::length(*h)); - let label = crate::i18n::fmt(tl!("{paper} {width} × {height}"), &[("paper", name), ("width", &width), ("height", &height)]); - mi_check(ui, app, &label, current == Some(*n), "layout.size", json!({"name": n})); - } - ui.separator(); - mi(ui, app, crate::dialogs::PAPER_LABELS.more, "ui.dialog", json!({"name": "pageSetup"})); -} - -/// The underline styles Home › Underline offers as line samples: (`format.underline` style, name). -const UNDERLINE_STYLES: [(&str, &str); 8] = [ - ("single", "Single"), - ("double", "Double"), - ("thick", "Thick"), - ("dotted", "Dotted"), - ("dash", "Dashed"), - ("dotDash", "Dot-dash"), - ("dotDotDash", "Dot-dot-dash"), - ("wave", "Wave"), -]; - -/// Home › Font › Underline's drop-down (#2738): a sample of each line, Words only, None, More -/// Underlines… (the Font dialog) and Underline Color. -fn underline_menu(ui: &mut Ui, app: &mut WordApp) { - for (style, name) in UNDERLINE_STYLES { - underline_item(ui, app, style, name); - } - mi(ui, app, "Words only", "format.wordUnderline", json!({})); - // Not `mi`: Ctrl+U beside None would read as its shortcut, but Ctrl+U toggles a single underline. - let enabled = crate::widgets::enabled(app, "format.underline"); - if ui.add_enabled(enabled, egui::Button::new(tl!("None")).min_size(vec2(200.0, 0.0))).clicked() { - crate::widgets::run_from_ribbon(app, "format.underline", json!({"value": false})); - ui.close(); - } - ui.separator(); - mi(ui, app, "More Underlines…", "format.fontDialog", json!({})); - ui.menu_button(tl!("Underline Color"), |ui| { - mi(ui, app, "Automatic", "format.underlineColor", json!({"color": "auto"})); - let theme = app.session.doc.settings.theme_colors.clone(); - crate::colors::menu(ui, app, &theme, Target::cmd("format.underlineColor")); - }); -} - -/// A menu entry drawn as a sample of its underline; its name is the tooltip and what assistive -/// technology reads. -fn underline_item(ui: &mut Ui, app: &mut WordApp, style: &str, name: &str) { - let enabled = crate::widgets::enabled(app, "format.underline"); - let resp = ui.add_enabled(enabled, egui::Button::new("").min_size(vec2(200.0, 22.0))); - let label = tl!(name); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); - let t = Tokens::get(ui.ctx()); - let color = if enabled { t.text } else { t.text_disabled }; - paint_underline(ui.painter(), resp.rect.shrink2(vec2(10.0, 0.0)), style, color); - if resp.on_hover_text(label).clicked() { - crate::widgets::run_from_ribbon(app, "format.underline", json!({"style": style})); - ui.close(); - } -} - -/// A line in `style` across `r` at its middle: our own drawing of each underline kind. -fn paint_underline(p: &egui::Painter, r: Rect, style: &str, color: egui::Color32) { - let y = r.center().y.round() + 0.5; - let (x0, x1) = (r.left(), r.right()); - let line = |y: f32, w: f32| { - p.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(w, color)); - }; - // Repeats a pattern of (length, gap) pairs along the line; a length of 0 is a dot. - let pattern = |pat: &[(f32, f32)]| { - let mut x = x0; - 'run: loop { - for &(len, gap) in pat { - if x >= x1 { - break 'run; - } - if len == 0.0 { - p.circle_filled(pos2(x + 0.75, y), 0.75, color); - x += 1.5 + gap; - } else { - p.line_segment([pos2(x, y), pos2((x + len).min(x1), y)], Stroke::new(1.0, color)); - x += len + gap; - } - } - } - }; - match style { - "double" => { - line(y - 1.5, 1.0); - line(y + 1.5, 1.0); - } - "thick" => line(y, 2.5), - "dotted" => pattern(&[(0.0, 2.0)]), - "dash" => pattern(&[(6.0, 4.0)]), - "dotDash" => pattern(&[(6.0, 3.0), (0.0, 3.0)]), - "dotDotDash" => pattern(&[(6.0, 3.0), (0.0, 2.5), (0.0, 3.0)]), - "wave" => { - let pts: Vec<_> = - (0..=((x1 - x0) as usize)).map(|i| pos2(x0 + i as f32, y + 1.6 * (i as f32 * std::f32::consts::TAU / 6.0).sin())).collect(); - p.add(egui::Shape::line(pts, Stroke::new(1.0, color))); - } - _ => line(y, 1.0), - } -} - -pub(crate) fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { - mi_text(ui, app, tl!(label), id, params); -} - -/// [`mi`] with a drawn icon before the label. -fn mi_icon(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value) { - let sc = crate::widgets::shortcut_text(app, id); - let enabled = crate::widgets::enabled(app, id); - let resp = ui.add_enabled_ui(enabled, |ui| crate::widgets::icon_menu_item(ui, icon, tl!(label), &sc, 200.0)).inner; - if resp.clicked() { - crate::widgets::run_from_ribbon(app, id, params); - ui.close(); - } -} - -/// A menu item whose text is already in the interface language. -fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) { - let sc = crate::widgets::shortcut_text(app, id); - let enabled = crate::widgets::enabled(app, id); - let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0))); - if resp.clicked() { - crate::widgets::run_from_ribbon(app, id, params); - ui.close(); - } -} - -/// The proofing languages the Language menu offers: tag and English name (translated when drawn). -pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [ - ("en-US", "English (United States)"), - ("en-GB", "English (United Kingdom)"), - ("en-AU", "English (Australia)"), - ("en-CA", "English (Canada)"), - ("pl-PL", "Polish"), - ("pt-BR", "Portuguese (Brazil)"), - ("it-IT", "Italian"), - ("hr-HR", "Croatian"), - ("tr-TR", "Turkish"), - ("ru-RU", "Russian"), - ("cs-CZ", "Czech"), - ("id-ID", "Indonesian"), - ("es-ES", "Spanish (Spain)"), - ("es-MX", "Spanish (Mexico)"), - ("es-419", "Spanish (Latin America)"), - ("es-UY", "Spanish (Uruguay)"), -]; - -/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's -/// default, US English): a listed language by its name; another region of one by the language -/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag. -pub(crate) fn language_name(tag: Option<&str>) -> String { - let tag = tag.unwrap_or("en-US"); - let named = - |t: &str| PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n); - // Any language the Language dialog offers (French, German…) by its name, not its tag (#860). - if let Some(name) = named(tag) { - return name.to_string(); - } - let mut parts = tag.split(['-', '_']); - let primary = parts.next().unwrap_or(""); - // The region: the first two-letter or three-digit subtag after the language (and script). - let region = - parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))); - match region { - // A region the list doesn't have: the language's name without its own region or variant. - Some(r) => { - let language = crate::dialogs_layout::LANGUAGES - .iter() - .find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary))) - .map(|(_, n)| n.split(" (").next().unwrap_or(n)); - match language { - Some(language) => format!("{language} ({})", region_name(r)), - None => tag.to_string(), - } - } - // A bare language subtag ("pt", "pl"): the variant WordCraft proofs. - None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { - wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), - wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), - wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), - wordcraft_engine::proof::ProofLang::It => "Italian".into(), - wordcraft_engine::proof::ProofLang::Tr => "Turkish".into(), - wordcraft_engine::proof::ProofLang::Ru => "Russian".into(), - wordcraft_engine::proof::ProofLang::Cs => "Czech".into(), - wordcraft_engine::proof::ProofLang::Id => "Indonesian".into(), - wordcraft_engine::proof::ProofLang::Es => "Spanish (Spain)".into(), - wordcraft_engine::proof::ProofLang::De => "German (Germany)".into(), - wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), - _ => tag.to_string(), - }, - } -} - -/// The English name of a region subtag the language list has no entry for (else the subtag). -fn region_name(code: &str) -> String { - const REGIONS: [(&str, &str); 62] = [ - ("AE", "U.A.E."), - ("AO", "Angola"), - ("AR", "Argentina"), - ("AT", "Austria"), - ("AU", "Australia"), - ("BA", "Bosnia and Herzegovina"), - ("BE", "Belgium"), - ("BO", "Bolivia"), - ("BR", "Brazil"), - ("BY", "Belarus"), - ("CA", "Canada"), - ("CH", "Switzerland"), - ("CL", "Chile"), - ("CN", "China"), - ("CO", "Colombia"), - ("CR", "Costa Rica"), - ("CV", "Cabo Verde"), - ("CY", "Cyprus"), - ("DE", "Germany"), - ("DO", "Dominican Republic"), - ("EC", "Ecuador"), - ("EG", "Egypt"), - ("ES", "Spain"), - ("FR", "France"), - ("GB", "United Kingdom"), - ("GT", "Guatemala"), - ("HK", "Hong Kong SAR"), - ("IE", "Ireland"), - ("IN", "India"), - ("IQ", "Iraq"), - ("IT", "Italy"), - ("JM", "Jamaica"), - ("JO", "Jordan"), - ("KW", "Kuwait"), - ("KZ", "Kazakhstan"), - ("LB", "Lebanon"), - ("LI", "Liechtenstein"), - ("LU", "Luxembourg"), - ("MA", "Morocco"), - ("MC", "Monaco"), - ("ME", "Montenegro"), - ("MO", "Macao SAR"), - ("MX", "Mexico"), - ("MY", "Malaysia"), - ("MZ", "Mozambique"), - ("NL", "Netherlands"), - ("NZ", "New Zealand"), - ("PE", "Peru"), - ("PH", "Philippines"), - ("PR", "Puerto Rico"), - ("PT", "Portugal"), - ("PY", "Paraguay"), - ("QA", "Qatar"), - ("RU", "Russia"), - ("SG", "Singapore"), - ("SY", "Syria"), - ("TW", "Taiwan"), - ("UA", "Ukraine"), - ("US", "United States"), - ("UY", "Uruguay"), - ("VE", "Venezuela"), - ("ZA", "South Africa"), - ]; - REGIONS.iter().find(|(c, _)| c.eq_ignore_ascii_case(code)).map_or_else(|| code.to_ascii_uppercase(), |(_, n)| (*n).to_string()) -} - -/// The language at the caret (resolved through the styles), translated, for the status bar. -pub(crate) fn caret_language(app: &WordApp) -> String { - tl!(&language_name(caret_lang_tag(app).as_deref())).to_string() -} - -/// The formatting at the caret, resolved through the styles and the document default. -fn caret_char(app: &WordApp) -> wordcraft_doc::resolve::ResolvedChar { - let s = &app.session; - let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()); - s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()) -} - -/// The language tag at the caret, resolved through the styles and the document default. -fn caret_lang_tag(app: &WordApp) -> Option { - caret_char(app).lang -} - -/// Is the text at the caret itself marked Do not check spelling or grammar? (`format.state` -/// looks at the whole word around a caret, so a partly marked word would read as checked.) -pub(crate) fn caret_no_proof(app: &WordApp) -> bool { - caret_char(app).no_proof -} - -/// Does WordCraft check spelling and grammar in the caret's language at all (Bulgarian: no)? -pub(crate) fn caret_language_proofed(app: &WordApp) -> bool { - wordcraft_engine::proof::has_dictionary(caret_lang_tag(app).as_deref()) -} - -/// The Hunspell dictionary the caret's language needs but doesn't have (`pl_PL`), if any (#1167). -pub(crate) fn caret_missing_dictionary(app: &WordApp) -> Option<&'static str> { - wordcraft_engine::proof::missing_dictionary(caret_lang_tag(app).as_deref()) -} - -/// The dictionary file and reason when the caret's language has a dictionary installed that -/// couldn't be read, so "no dictionary" would send the user to reinstall it for nothing (#2830). -pub(crate) fn caret_unreadable_dictionary(app: &WordApp) -> Option<(std::path::PathBuf, String)> { - wordcraft_engine::proof::unreadable_dictionary(caret_lang_tag(app).as_deref()).map(|(_, file, reason)| (file, reason)) -} - -/// Review › Language (and the status bar's language): set the selection's proofing language, or -/// leave it unchecked. The current choice has a check mark; a language outside the short list -/// (French, German…) shows checked above it. Set Proofing Language… opens the Language dialog -/// with every language and Set As Default, and Language Preferences… opens Options › Language -/// (#860: the short list alone left no way to pick French). -pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { - let st = app.session.run("format.state", &json!({})).unwrap_or_default(); - // The selection's own language; else (nothing set on the text) the one it gets from its - // style or the document default, as the status bar shows. - let current = match st.get("lang") { - Some(Value::String(t)) => t.clone(), - Some(Value::Null) if app.session.sel.is_collapsed() => caret_lang_tag(app).unwrap_or_else(|| "en-US".into()), - _ => String::new(), - }; - let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); - let listed = |tag: &str| { - current.eq_ignore_ascii_case(tag) - || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")) - || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")) - || (tag == "it-IT" && current.eq_ignore_ascii_case("it")) - || (tag == "hr-HR" && current.eq_ignore_ascii_case("hr")) - || (tag == "tr-TR" && current.eq_ignore_ascii_case("tr")) - || (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Cs) - || (tag == "id-ID" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Id) - || (tag == "es-ES" && current.eq_ignore_ascii_case("es")) - || (tag == "en-US" && current.eq_ignore_ascii_case("en")) - }; - let checks = language_checks(¤t, listed); - if let Some(name) = checks.extra { - mi_check(ui, app, &name, true, "review.language", json!({"lang": current})); - } - for ((tag, name), on) in PROOFING_LANGUAGES.iter().zip(checks.listed) { - mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); - } - ui.separator(); - mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof})); - ui.separator(); - mi_check(ui, app, "Set Proofing Language…", false, "review.language", json!({})); - mi_check(ui, app, "Language Preferences…", false, "file.options", json!({"pane": "language"})); -} - -/// The Language menu's check marks for the `current` tag: which short-list entries are checked -/// (`listed` matches a tag), and the name of the current language when it is none of them, -/// shown checked above. A language named like a short-list entry checks that entry, so no name -/// shows twice. -struct LanguageChecks { - extra: Option, - listed: [bool; PROOFING_LANGUAGES.len()], -} - -fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChecks { - let name = (!current.is_empty()).then(|| language_name(Some(current))); - let on = PROOFING_LANGUAGES.map(|(t, n)| listed(t) || name.as_deref() == Some(n)); - LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on } -} - -/// A menu item with a check mark when `checked` (the current choice of several). -fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) { - // A vector mark needs no font glyph; the empty atom keeps labels aligned. - let mark = egui::Atom::paint(vec2(14.0, 14.0), move |ui, args| { - if checked { - let r = args.rect; - let points = vec![r.min + r.size() * vec2(0.15, 0.5), r.min + r.size() * vec2(0.4, 0.75), r.min + r.size() * vec2(0.85, 0.2)]; - ui.painter().add(egui::Shape::line(points, egui::Stroke::new(1.5, args.fallback_text_color))); - } - }); - let enabled = crate::widgets::enabled(app, id); - let resp = ui.add_enabled(enabled, egui::Button::new((mark, tl!(label))).selected(checked).min_size(vec2(200.0, 0.0))); - if resp.clicked() { - crate::widgets::run_from_ribbon(app, id, params); - ui.close(); - } -} - -/// Word's text wrapping styles in menu order: label, model value, `arrange.wrap` parameter. -pub(crate) const WRAP_STYLES: [(&str, wordcraft_doc::para::Wrap, &str); 7] = { - use wordcraft_doc::para::Wrap; - [ - ("In Line with Text", Wrap::Inline, "inline"), - ("Square", Wrap::Square, "square"), - ("Tight", Wrap::Tight, "tight"), - ("Through", Wrap::Through, "through"), - ("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"), - ("Behind Text", Wrap::BehindText, "behindText"), - ("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"), - ] -}; - -/// The Wrap Text menu for the selected picture, shape or text box: the seven styles (the -/// current one checked), then Move with Text / Fix Position on Page for a floating one. The -/// canvas context menu shows it; the ribbon's Wrap Text button can share it. -pub(crate) fn wrap_text_items(ui: &mut Ui, app: &mut WordApp) { - use wordcraft_engine::cmd::objects::{moves_with_text, selected}; - let state = selected(&app.session).and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, moves_with_text(f)))); - let wrap = state.map(|(w, _)| w); - for (label, w, v) in WRAP_STYLES { - mi_check(ui, app, label, wrap == Some(w), "arrange.wrap", json!({"wrap": v})); - } - ui.separator(); - // An inline object is neither: it's part of the text. - let with_text = state.filter(|(w, _)| *w != wordcraft_doc::para::Wrap::Inline).map(|(_, m)| m); - mi_check(ui, app, "Move with Text", with_text == Some(true), "arrange.moveWithText", json!({"value": true})); - mi_check(ui, app, "Fix Position on Page", with_text == Some(false), "arrange.moveWithText", json!({"value": false})); -} - -/// The Format Painter button. A click arms it for one use (blue border only); a double-click keeps -/// it on across several uses (full checked look), like Word. Esc or another click turns it off. -fn format_painter_button(ui: &mut Ui, app: &mut WordApp) { - let sticky = matches!(app.session.painter, Some((_, _, true))); - let armed = app.session.painter.is_some(); - let resp = small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), sticky); - if resp.double_clicked() { - painter_double_click(app); - } else if armed && !sticky { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect, egui::CornerRadius::same(4), Stroke::new(1.0, t.accent.linear_multiply(0.5)), egui::StrokeKind::Inside); - } -} - -/// A double-click fires `clicked()` twice, so by now the painter has been armed and disarmed again. -/// Arm it once more, this time sticky. -fn painter_double_click(app: &mut WordApp) { - if app.session.painter.is_some() { - let _ = app.run("edit.formatPainter", json!({})); - } - let _ = app.run("edit.formatPainter", json!({"sticky": true})); -} - -fn home(app: &mut WordApp, ui: &mut Ui) { - let st = app.session.run("format.state", &json!({})).unwrap_or_default(); - let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| { - menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, "edit.paste", |ui, app| { - mi(ui, app, "Paste", "edit.paste", json!({})); - mi(ui, app, "Keep Text Only", "edit.pasteText", json!({})); - mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({})); - mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({})); - }); - stack(ui, |ui| { - small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false); - small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false); - format_painter_button(ui, app); - }); - }); - group(ui, "Font", Some("format.fontDialog"), app, |ui, app| { - stack(ui, |ui| { - crate::widgets::row(ui, |ui| { - let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); - let font = app.previews.font_shown(&font); - let fams = app.previews.families(); - let recent: Vec = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect(); - let prev = app.previews.font_preview_fn(); - if let Some(f) = crate::widgets::font_combo_with_recent_hint(ui, "font", 150.0, &font, &fams, &recent, Some(&*prev), tl!("Font")) { - let _ = if f == crate::widgets::ADD_FONT { - app.run("font.add", json!({})) - } else if f == crate::widgets::LOAD_SYSTEM_FONTS { - app.run("font.loadSystem", json!({})) - } else { - let f = app.previews.font_stored(&f); - app.run("format.font", json!({"name": f})) - }; - } - let size = st - .get("size") - .and_then(Value::as_f64) - .map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") }) - .unwrap_or_default(); - let sizes: Vec = - wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect(); - if let Some(v) = crate::widgets::combo_hint(ui, "size", 52.0, &size, &sizes, None, tl!("Font Size")) - && let Ok(x) = v.trim().parse::() - { - let _ = app.run("format.size", json!({"size": x})); - } - ui.add_space(3.0); - small(ui, app, "grow", None, "Increase Font Size", "format.growFont", json!({}), false); - small(ui, app, "shrink", None, "Decrease Font Size", "format.shrinkFont", json!({}), false); - menu_button(ui, app, "case", None, "Change Case", false, "format.changeCase", |ui, app| { - mi(ui, app, "Sentence case.", "format.changeCase", json!({"mode": "sentence"})); - mi(ui, app, "lowercase", "format.changeCase", json!({"mode": "lower"})); - mi(ui, app, "UPPERCASE", "format.changeCase", json!({"mode": "upper"})); - mi(ui, app, "Capitalize Each Word", "format.changeCase", json!({"mode": "title"})); - mi(ui, app, "tOGGLE cASE", "format.changeCase", json!({"mode": "toggle"})); - }); - small(ui, app, "clear", None, "Clear All Formatting", "format.clear", json!({}), false); - small(ui, app, "phonetic", None, "Phonetic Guide", "format.phonetic", json!({}), false); - }); - ui.add_space(3.0); - crate::widgets::row(ui, |ui| { - small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold")); - small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic")); - split(ui, app, "underline", "Underline", "format.underline", json!({}), flag("underline"), None, underline_menu); - small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike")); - small(ui, app, "subscript", None, "Subscript", "format.subscript", json!({}), flag("subscript")); - small(ui, app, "superscript", None, "Superscript", "format.superscript", json!({}), flag("superscript")); - small(ui, app, "charborder", None, "Character Border", "format.border", json!({}), flag("border")); - small(ui, app, "enclose", None, "Enclose Characters", "format.enclose", json!({}), false); - ui.add_space(4.0); - menu_button(ui, app, "effects", None, "Text Effects and Typography", false, "", |ui, app| { - let theme = app.session.doc.settings.theme_colors.clone(); - crate::wordart_menus::style_gallery(ui, app, "format.textEffects"); - ui.separator(); - ui.menu_button(tl!("Text Fill"), |ui| crate::wordart_menus::fill_menu(ui, app, &theme)); - let outline = crate::wordart_menus::current_outline(app); - ui.menu_button(tl!("Text Outline"), |ui| crate::wordart_menus::outline_menu(ui, app, &theme, outline)); - crate::wordart_menus::effects_menu(ui, app, &theme, false); - ui.separator(); - mi(ui, app, "Outline", "format.outline", json!({})); - mi(ui, app, "Shadow", "format.shadow", json!({})); - mi(ui, app, "Small Caps", "format.smallCaps", json!({})); - mi(ui, app, "All Caps", "format.allCaps", json!({})); - mi(ui, app, "Double Strikethrough", "format.doubleStrikethrough", json!({})); - }); - let hl = app.canvas.last_highlight.clone(); - let (icon, sw, color) = crate::mini_toolbar::highlight_button_appearance(&hl); - split( - ui, - app, - icon, - "Text Highlight Color", - "format.highlight", - json!({"color": color}), - false, - sw, - crate::mini_toolbar::highlight_menu, - ); - let fc = app.canvas.last_font_color.clone(); - let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32); - split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| { - mi(ui, app, "Automatic", "format.color", json!({"color": "auto"})); - let theme = app.session.doc.settings.theme_colors.clone(); - crate::colors::menu(ui, app, &theme, Target::cmd("format.color").remember(Remember::FontColor)); - }); - }); - }); - }); - group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { - let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); - // The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start). - let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default(); - let rtl = rp.as_ref().is_some_and(|r| r.bidi); - let lists = wordcraft_engine::cmd::para::list_buttons(&app.session.doc, rp.as_ref().and_then(|r| r.numbering)); - stack(ui, |ui| { - crate::widgets::row(ui, |ui| { - split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), lists.bullets, None, |ui, app| { - ui.label(egui::RichText::new(tl!("Bullet Library")).small().weak()); - ui.horizontal(|ui| { - for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] { - // The interface fonts have no ➢, so its tile draws the arrowhead. - let label = if c == "➢" { " " } else { c }; - let resp = ui.button(egui::RichText::new(label).size(16.0)); - if c == "➢" { - let m = resp.rect.center(); - let pts = [(-4.0, -5.0), (5.0, 0.0), (-4.0, 5.0), (-1.5, 0.0)].map(|(x, y)| m + vec2(x, y)); - ui.painter().add(egui::Shape::convex_polygon(vec![pts[0], pts[1], pts[3]], Tokens::get(ui.ctx()).text, Stroke::NONE)); - ui.painter().add(egui::Shape::convex_polygon(vec![pts[3], pts[1], pts[2]], Tokens::get(ui.ctx()).text, Stroke::NONE)); - } - if resp.clicked() { - let _ = app.run("para.bullets", json!({"kind": c})); - ui.close(); - } - } - }); - mi(ui, app, "None", "para.bullets", json!({"off": true})); - }); - split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), lists.numbering, None, |ui, app| { - for (l, k) in [ - ("1. 2. 3.", "numbered"), - ("1) 2) 3)", "numberedParen"), - ("I. II. III.", "outline"), - ("A. B. C.", "upperLetter"), - ("a) b) c)", "lowerLetter"), - ("i. ii. iii.", "lowerRoman"), - ] { - mi(ui, app, l, "para.numbering", json!({"kind": k})); - } - ui.separator(); - mi(ui, app, "Restart at 1", "para.restartNumbering", json!({})); - mi(ui, app, "None", "para.numbering", json!({"off": true})); - }); - split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), lists.multilevel, None, |ui, app| { - mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"})); - mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"})); - ui.separator(); - // Lists linked to the heading styles: the headings carry the numbers. Each item - // reads as its first three levels, number then (translated) style name. - for (numbers, k) in [ - (["1", "1.1", "1.1.1"], "headings"), - (["Article I.", "Section 1.01", "(a)"], "article"), - (["I.", "A.", "1."], "outlineHeadings"), - (["Chapter 1", "", ""], "chapter"), - ] { - let text = numbers - .iter() - .zip(["Heading 1", "Heading 2", "Heading 3"]) - .map(|(n, h)| if n.is_empty() { tl!(h).to_string() } else { format!("{} {}", tl!(n), tl!(h)) }) - .collect::>() - .join(" / "); - mi_text(ui, app, &text, "list.multilevelPreset", json!({"preset": k})); - } - ui.separator(); - mi(ui, app, "Define New Multilevel List…", "list.define", json!({})); - ui.separator(); - for lv in 0..5u64 { - mi(ui, app, &format!("Change to Level {}", lv + 1), "para.listLevel", json!({"level": lv})); - } - }); - ui.add_space(4.0); - small(ui, app, "outdent", None, "Decrease Indent", "para.outdent", json!({}), false); - small(ui, app, "indent", None, "Increase Indent", "para.indent", json!({}), false); - ui.add_space(4.0); - small(ui, app, "sort", None, "Sort", "para.sort", json!({}), false); - let marks = app.session.view.marks; - small(ui, app, "pilcrow", None, "Show/Hide ¶", "view.marks", json!({}), marks); - }); - ui.add_space(3.0); - crate::widgets::row(ui, |ui| { - use wordcraft_doc::Align as A; - small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left); - small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center); - small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right); - small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify); - ui.add_space(4.0); - small(ui, app, "textLtr", None, "Left-to-Right Text Direction", "para.ltr", json!({}), !rtl); - small(ui, app, "textRtl", None, "Right-to-Left Text Direction", "para.rtl", json!({}), rtl); - ui.add_space(4.0); - menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, "para.lineSpacing", |ui, app| { - for v in [1.0, 1.15, 1.5, 2.0, 2.5, 3.0] { - mi(ui, app, &format!("{v}"), "para.lineSpacing", json!({"value": v})); - } - ui.separator(); - mi(ui, app, "Add Space Before Paragraph", "para.addSpaceBefore", json!({})); - mi(ui, app, "Remove Space After Paragraph", "para.removeSpaceAfter", json!({})); - mi(ui, app, "Line Spacing Options…", "para.dialog", json!({})); - }); - split(ui, app, "shading", "Shading", "para.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { - mi(ui, app, "No Color", "para.shading", json!({"color": null})); - let theme = app.session.doc.settings.theme_colors.clone(); - crate::colors::menu(ui, app, &theme, Target::cmd("para.shading").remember(Remember::Shading)); - }); - split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| { - for (l, k) in [ - ("Bottom Border", "bottom"), - ("Top Border", "top"), - ("Left Border", "left"), - ("Right Border", "right"), - ("No Border", "none"), - ("All Borders", "all"), - ("Outside Borders", "outside"), - ("Inside Borders", "inside"), - ] { - mi(ui, app, l, "para.borders", json!({"kind": k})); - } - ui.separator(); - mi(ui, app, "Horizontal Line", "insert.horizontalLine", json!({})); - mi(ui, app, "Borders and Shading…", "para.borders", json!({})); - }); - }); - }); - }); - group(ui, "Styles", Some("styles.pane"), app, |ui, app| { - crate::previews::style_gallery(app, ui, &st); - }); - group(ui, "Editing", None, app, |ui, app| { - stack(ui, |ui| { - menu_button(ui, app, "find", Some("Find"), "Find", false, "edit.find", |ui, app| { - // As in Word: Find searches in the Navigation pane; Advanced Find is the dialog (#712). - mi(ui, app, "Find", "edit.find", json!({})); - mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({})); - mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"})); - }); - small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false); - menu_button(ui, app, "select", Some("Select"), "Select", false, "select.all", |ui, app| { - mi(ui, app, "Select All", "select.all", json!({})); - mi(ui, app, "Select Paragraph", "select.paragraph", json!({})); - mi(ui, app, "Select Sentence", "select.sentence", json!({})); - }); - }); - }); - group(ui, "Voice", None, app, |ui, app| { - big(ui, app, "dictate", "Dictate", "tools.dictate", json!({}), false); - }); - group(ui, "Editor", None, app, |ui, app| { - big(ui, app, "editor", "Editor", "review.spelling", json!({}), false); - }); -} - -fn insert(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Pages", None, app, |ui, app| { - stack(ui, |ui| { - menu_button(ui, app, "coverPage", Some("Cover Page"), "Cover Page", false, "insert.coverPage", |ui, app| { - mi(ui, app, "Studio cover", "insert.coverPage", json!({})); - }); - small(ui, app, "blankPage", Some("Blank Page"), "Blank Page", "insert.blankPage", json!({}), false); - small(ui, app, "pageBreak", Some("Page Break"), "Page Break", "insert.pageBreak", json!({}), false); - }); - }); - group(ui, "Tables", None, app, |ui, app| { - menu_button(ui, app, "table", Some("Table"), "Add a Table", true, "insert.table", |ui, app| { - crate::dialogs::table_grid_picker(ui, app); - ui.separator(); - mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"})); - let drawing = app.canvas.table_tool == Some(crate::table_pen::TableTool::Draw); - mi_check(ui, app, "Draw Table", drawing, "table.draw", json!({})); - mi(ui, app, "Convert Text to Table…", "table.fromText", json!({})); - ui.menu_button(tl!("Quick Tables"), |ui| { - mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"})); - mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"})); - mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"})); - }); - }); - }); - group(ui, "Illustrations", None, app, |ui, app| { - big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false); - menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, "insert.shape", |ui, app| { - for (l, k) in [ - ("Rectangle", "rectangle"), - ("Rounded Rectangle", "roundedRectangle"), - ("Oval", "ellipse"), - ("Triangle", "triangle"), - ("Diamond", "diamond"), - ("Line", "line"), - ("Arrow", "arrow"), - ("Star", "star"), - ("Heart", "heart"), - ] { - mi_icon(ui, app, &format!("shape.{k}"), l, "insert.shape", json!({"kind": k})); - } - ui.separator(); - mi_icon(ui, app, "canvas", "New Drawing Canvas", "insert.canvas", json!({})); - // Connectors: glue their ends to shapes by dragging them (shape.connect). - for (l, k, arrow) in [ - ("Straight Connector", "straightConnector", "none"), - ("Straight Arrow Connector", "straightConnector", "end"), - ("Elbow Connector", "elbowConnector", "none"), - ("Elbow Arrow Connector", "elbowConnector", "end"), - ("Curved Connector", "curvedConnector", "none"), - ("Curved Arrow Connector", "curvedConnector", "end"), - ] { - let icon = if arrow == "end" { k.replace("Connector", "ArrowConnector") } else { k.to_string() }; - mi_icon(ui, app, &format!("shape.{icon}"), l, "insert.shape", json!({"kind": k, "arrow": arrow})); - } - }); - stack(ui, |ui| { - small(ui, app, "icons", Some("Icons"), "Icons", "insert.icon", json!({}), false); - small(ui, app, "smartArt", Some("SmartArt"), "SmartArt", "insert.smartArt", json!({}), false); - small(ui, app, "chart", Some("Chart"), "Chart", "insert.chart", json!({}), false); - }); - }); - group(ui, "Links", None, app, |ui, app| { - stack(ui, |ui| { - small(ui, app, "link", Some("Link"), "Link", "ui.dialog", json!({"name": "link"}), false); - small(ui, app, "bookmark", Some("Bookmark"), "Bookmark", "ui.dialog", json!({"name": "bookmark"}), false); - small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false); - }); - }); - group(ui, "Comments", None, app, |ui, app| { - big(ui, app, "newComment", "Comment", "review.newComment", json!({}), false); - }); - group(ui, "Header & Footer", None, app, |ui, app| { - menu_button(ui, app, "header", Some("Header"), "Header", true, "insert.header", |ui, app| { - mi(ui, app, "Blank", "insert.header", json!({"preset": "blank"})); - mi(ui, app, "Blank (Three Columns)", "insert.header", json!({"preset": "blankThree"})); - mi(ui, app, "Document Title", "insert.header", json!({"preset": "title"})); - ui.separator(); - mi(ui, app, "Edit Header", "insert.editHeader", json!({})); - mi(ui, app, "Remove Header", "insert.removeHeader", json!({})); - }); - menu_button(ui, app, "footer", Some("Footer"), "Footer", true, "insert.footer", |ui, app| { - mi(ui, app, "Blank", "insert.footer", json!({"preset": "blank"})); - mi(ui, app, "Blank (Three Columns)", "insert.footer", json!({"preset": "blankThree"})); - mi(ui, app, "Page Number", "insert.footer", json!({"preset": "pageNumber"})); - ui.separator(); - mi(ui, app, "Edit Footer", "insert.editFooter", json!({})); - mi(ui, app, "Remove Footer", "insert.removeFooter", json!({})); - }); - menu_button(ui, app, "pageNumber", Some("Page\nNumber"), "Page Number", true, "insert.pageNumber", |ui, app| { - mi(ui, app, "Top of Page", "insert.pageNumber", json!({"position": "top", "align": "right"})); - mi(ui, app, "Bottom of Page", "insert.pageNumber", json!({"position": "bottom", "align": "center"})); - mi(ui, app, "Page X of Y", "insert.pageNumber", json!({"position": "bottom", "format": "x of y"})); - mi(ui, app, "Current Position", "insert.pageNumber", json!({"position": "current"})); - mi(ui, app, "Format Page Numbers…", "ui.dialog", json!({"name": "pageNumbers"})); - }); - }); - group(ui, "Text", None, app, |ui, app| { - big(ui, app, "textBox", "Text\nBox", "insert.textBox", json!({}), false); - stack(ui, |ui| { - menu_button(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", false, "insert.quickParts", |ui, app| { - // This session's building blocks, then the document's own Quick Parts. - let mut parts: Vec = app.session.building_blocks.keys().cloned().collect(); - let mut seen: std::collections::HashSet = parts.iter().cloned().collect(); - let glossary = app.session.doc.glossary.iter().filter(|e| e.gallery == wordcraft_doc::QUICK_PARTS_GALLERY); - parts.extend(glossary.filter(|e| seen.insert(e.name.clone())).map(|e| e.name.clone())); - for name in parts { - if ui.button(&name).clicked() { - let _ = app.run("insert.quickParts", json!({"insert": name})); - ui.close(); - } - } - mi(ui, app, "Field…", "ui.dialog", json!({"name": "field"})); - }); - menu_button(ui, app, "wordArt", Some("WordArt"), "WordArt", false, "", |ui, app| { - crate::wordart_menus::style_gallery(ui, app, "insert.wordArt") - }); - menu_button(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", false, "insert.dropCap", drop_cap_items); - }); - stack(ui, |ui| { - small(ui, app, "signature", None, "Signature Line", "insert.signatureLine", json!({}), false); - small(ui, app, "dateTime", None, "Date & Time", "insert.dateTime", json!({}), false); - small(ui, app, "object", None, "Object", "insert.object", json!({}), false); - }); - }); - group(ui, "Symbols", None, app, |ui, app| { - crate::equation_tab::insert_button(ui, app); - menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, "insert.symbol", |ui, app| { - // Recently used symbols first (their font on hover), then common ones. - let mut items: Vec<(String, String)> = app.ui.recent_symbols.iter().map(|r| (r.ch.clone(), r.font.clone())).collect(); - for c in [ - "©", "®", "™", "§", "¶", "€", "£", "¥", "°", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Ω", "∑", "√", "→", "←", "✓", - "★", "♥", "—", "…", - ] { - if !items.iter().any(|(ch, f)| ch == c && f.is_empty()) { - items.push((c.to_string(), String::new())); - } - } - items.truncate(30); - egui::Grid::new("syms").show(ui, |ui| { - for (i, (c, font)) in items.iter().enumerate() { - let b = ui.button(egui::RichText::new(c).size(16.0)); - let b = if font.is_empty() { b } else { b.on_hover_text(font) }; - if b.clicked() { - let params = if font.is_empty() { json!({"char": c}) } else { json!({"char": c, "font": font}) }; - if app.run("insert.symbol", params).is_ok() { - crate::dialogs_insert::remember_symbol(app, c, font); - } - ui.close(); - } - if i % 6 == 5 { - ui.end_row(); - } - } - }); - ui.separator(); - mi(ui, app, "More Symbols…", "ui.dialog", json!({"name": "symbol"})); - }); - }); -} - -/// Insert › Drop Cap's menu (#2601): None, Dropped and In margin (the current one checked), -/// then the Drop Cap dialog. -fn drop_cap_items(ui: &mut Ui, app: &mut WordApp) { - let current = wordcraft_engine::cmd::insert::drop_cap_state(&app.session).position; - for (label, v) in [("None", "none"), ("Dropped", "dropped"), ("In margin", "margin")] { - mi_check(ui, app, label, current == v, "insert.dropCap", json!({"position": v})); - } - ui.separator(); - mi(ui, app, "Drop Cap Options…", "ui.dialog", json!({"name": "dropCap"})); -} - -fn draw(app: &mut WordApp, ui: &mut Ui) { - use wordcraft_doc::freeform::InkTool; - use wordcraft_engine::cmd::draw::{DrawMode, lasso_selection}; - let mode = app.session.view.draw.mode; - // The pen the colour and thickness menus change: the one in use, else the pen. - let tool = mode.pen().unwrap_or(InkTool::Pen); - let set = app.session.view.draw.settings(tool); - // The gallery pen in use, if any: changing the colour or thickness changes it too. - let custom = - app.session.view.draw.pens.iter().position(|p| mode == DrawMode::Pen(p.kind) && p.color == set.color && (p.width - set.width).abs() < 0.01); - let lasso_on = lasso_selection(&app.session).is_some(); - group(ui, "Drawing Tools", None, app, |ui, app| { - big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select); - big_toggle(ui, app, "lasso", "Lasso", "draw.lasso", mode == DrawMode::Lasso); - big_toggle(ui, app, "eraser", "Eraser", "draw.eraser", mode == DrawMode::Eraser); - big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen) && custom.is_none()); - big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil) && custom.is_none()); - big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter) && custom.is_none()); - // Pens added with Add Pen; right-click one to remove it. - let pens = app.session.view.draw.pens.clone(); - for (i, pen) in pens.iter().enumerate() { - gallery_pen(ui, app, i, *pen, custom == Some(i)); - } - menu_button(ui, app, "addPen", Some("Add\nPen"), "Add Pen", true, "", |ui, app| { - for (label, kind) in [("Pen", InkTool::Pen), ("Pencil", InkTool::Pencil), ("Highlighter", InkTool::Highlighter)] { - // A new pen starts as that kind's current colour and thickness. - let cur = app.session.view.draw.settings(kind); - mi(ui, app, label, "draw.addPen", json!({"kind": kind.name(), "color": cur.color.hex(), "width": cur.width})); - } - }); - stack(ui, |ui| { - let sw = Some(crate::theme::c32(set.color)); - split(ui, app, "fontcolor", "Color", pen_command(tool), json!({}), false, sw, |ui, app| { - let theme = app.session.doc.settings.theme_colors.clone(); - // With ink lasso-selected the colour goes to the selection, else to the pen. - let cmd = if lasso_on { "draw.lassoColor" } else { pen_command(tool) }; - crate::colors::menu(ui, app, &theme, Target::cmd(cmd)); - if !lasso_on { - update_gallery_pen(app, custom, tool); - } - }); - menu_button(ui, app, "thickness", None, "Thickness", false, "draw.pen", |ui, app| { - let widths: &[f32] = if tool == InkTool::Highlighter { &[4.0, 8.0, 12.0, 18.0, 24.0] } else { &[0.5, 1.0, 1.5, 2.5, 3.5, 5.0] }; - for w in widths { - let label = crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]); - mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_command(tool), json!({"width": w})); - } - update_gallery_pen(app, custom, tool); - }); - }); - }); - group(ui, "Convert", None, app, |ui, app| { - let on = app.session.view.draw.ink_to_shape; - big_toggle(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", on); - big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false); - }); - group(ui, "Insert", None, app, |ui, app| { - big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false); - }); - group(ui, "Replay", None, app, |ui, app| { - let on = app.session.view.draw.replay; - big_toggle(ui, app, "replay", "Ink\nReplay", "draw.replay", on); - }); -} - -/// The command that picks up `tool`. -fn pen_command(tool: wordcraft_doc::freeform::InkTool) -> &'static str { - use wordcraft_doc::freeform::InkTool; - match tool { - InkTool::Pen => "draw.pen", - InkTool::Pencil => "draw.pencil", - InkTool::Highlighter => "draw.highlighter", - } -} - -/// After the colour or thickness menu changed `tool`'s settings, the gallery pen that was in use -/// (`custom`) takes them too. -fn update_gallery_pen(app: &mut WordApp, custom: Option, tool: wordcraft_doc::freeform::InkTool) { - let set = app.session.view.draw.settings(tool); - if let Some(p) = custom.and_then(|i| app.session.view.draw.pens.get_mut(i)) - && p.kind == tool - { - p.color = set.color; - p.width = set.width; - } -} - -/// A pen of the gallery: its kind's icon over a bar of its colour and thickness; a click picks it -/// up, right-click offers Remove Pen. -fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::cmd::draw::CustomPen, active: bool) { - use wordcraft_doc::freeform::InkTool; - let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(34.0, CONTENT_H), Sense::click()); - crate::widgets::bg(ui, r, &resp, active, &t); - let icon = match pen.kind { - InkTool::Pen => "pen", - InkTool::Pencil => "pencil", - InkTool::Highlighter => "highlight", - }; - let color = crate::theme::c32(pen.color); - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(28.0, 28.0)), icon, t.icon, color); - let bar = (pen.width * 0.6).clamp(1.5, 8.0); - let y = r.min.y + 46.0; - ui.painter().line_segment([pos2(r.min.x + 6.0, y), pos2(r.max.x - 6.0, y)], Stroke::new(bar, color)); - let kind = tl!(match pen.kind { - InkTool::Pen => "Pen", - InkTool::Pencil => "Pencil", - InkTool::Highlighter => "Highlighter", - }); - let size = crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())]); - let resp = resp.on_hover_text(format!("{kind}: {size}, #{}", pen.color.hex())); - if resp.clicked() { - let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width})); - } - resp.context_menu(|ui| { - if ui.button(tl!("Remove Pen")).clicked() { - let _ = app.run("draw.removePen", json!({"index": i})); - ui.close(); - } - }); -} - -/// The Design tab's style set gallery in a box of its own, sized from its thumbnails and clipped -/// to it, with a gap on each side: the Colors and Fonts column can't be drawn over, or cut by, -/// the thumbnails (#638). Returns the box and the space the thumbnails used. -fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) { - ui.add_space(4.0); - let (gallery, _) = ui.allocate_exact_size(vec2(crate::previews::STYLE_SET_GALLERY_W, CONTENT_H), Sense::hover()); - let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(egui::Layout::left_to_right(egui::Align::Min))); - gui.set_clip_rect(gallery.intersect(ui.clip_rect())); - gui.spacing_mut().item_spacing = vec2(1.0, 1.0); - crate::previews::style_set_gallery(app, &mut gui); - ui.add_space(8.0); - (gallery, gui.min_rect()) -} - -fn design(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Themes", None, app, |ui, app| { - menu_button(ui, app, "themes", Some("Themes"), "Themes", true, "design.themes", |ui, app| { - for (name, ..) in wordcraft_engine::cmd::design::THEMES { - mi(ui, app, name, "design.theme", json!({"name": name})); - } - }); - }); - group(ui, "Document Formatting", None, app, |ui, app| { - style_set_box(app, ui); - stack(ui, |ui| { - menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, "design.themeColors", |ui, app| { - for (name, ..) in wordcraft_engine::cmd::design::THEMES { - mi(ui, app, name, "design.themeColors", json!({"name": name})); - } - }); - menu_button(ui, app, "fonts", Some("Fonts"), "Theme Fonts", false, "design.themeFonts", |ui, app| { - for (name, h, b, _) in wordcraft_engine::cmd::design::THEMES { - mi_text(ui, app, &format!("{}: {h} / {b}", tl!(name)), "design.themeFonts", json!({"heading": h, "body": b})); - } - }); - }); - stack(ui, |ui| { - menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, "design.paragraphSpacing", |ui, app| { - for (l, v) in [ - ("Default", "default"), - ("No Paragraph Space", "none"), - ("Compact", "compact"), - ("Tight", "tight"), - ("Open", "open"), - ("Relaxed", "relaxed"), - ("Double", "double"), - ] { - // "Open" here is roomy spacing, not the file verb: only a `spacing` row - // translates it, else it stays English. - if l == "Open" { - mi_text(ui, app, crate::i18n::get("spacing", l).unwrap_or(l), "design.paragraphSpacing", json!({"value": v})); - continue; - } - mi(ui, app, l, "design.paragraphSpacing", json!({"value": v})); - } - }); - small(ui, app, "effectsDesign", Some("Effects"), "Effects", "design.effects", json!({}), false); - small(ui, app, "setDefault", Some("Set as Default"), "Set as Default", "design.setDefault", json!({}), false); - }); - }); - group(ui, "Page Background", None, app, |ui, app| { - menu_button(ui, app, "watermark", Some("Watermark"), "Watermark", true, "design.watermark", |ui, app| { - for w in ["DRAFT", "CONFIDENTIAL", "DO NOT COPY", "SAMPLE", "ASAP", "URGENT"] { - mi(ui, app, w, "design.watermark", json!({"text": w})); - } - ui.separator(); - mi(ui, app, "Custom Watermark…", "ui.dialog", json!({"name": "watermark"})); - mi(ui, app, "Remove Watermark", "design.watermark", json!({"remove": true})); - }); - menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, "design.pageColor", |ui, app| { - mi(ui, app, "No Color", "design.pageColor", json!({"color": null})); - let theme = app.session.doc.settings.theme_colors.clone(); - crate::colors::menu(ui, app, &theme, Target::cmd("design.pageColor")); - }); - big(ui, app, "pageBorders", "Page\nBorders", "design.pageBorders", json!({}), false); - }); -} - -// Layout › Margins, Orientation, Size and Columns: the caret section's setting has a check -// mark, as Text Direction's does (#1206). -fn margins_items(ui: &mut Ui, app: &mut WordApp) { - let current = wordcraft_engine::cmd::page::margins_preset_at(&app.session); - // The two uniform presets name their margin in the measurement unit (`2,54 cm`, #838). - for (l, k, uniform) in [ - ("Normal {size} all", "normal", Some(72.0)), - ("Narrow {size} all", "narrow", Some(36.0)), - ("Moderate", "moderate", None), - ("Wide", "wide", None), - ("Mirrored", "mirrored", None), - ("Office 2003 Default", "office2003", None), - ] { - let label = match uniform { - Some(pt) => crate::i18n::fmt(tl!(l), &[("size", &crate::options::length(pt))]), - None => l.to_string(), - }; - // Mirrored margins come from the document as well as from the preset (#1181). - let on = current == Some(k) || (k == "mirrored" && app.session.doc.settings.mirror_margins); - mi_check(ui, app, &label, on, "layout.margins", json!({"preset": k})); - } - mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"})); -} - -fn orientation_items(ui: &mut Ui, app: &mut WordApp) { - let land = wordcraft_engine::cmd::page::sect(&app.session).landscape; - mi_check(ui, app, "Portrait", !land, "layout.orientation", json!({"value": "portrait"})); - mi_check(ui, app, "Landscape", land, "layout.orientation", json!({"value": "landscape"})); -} - -fn columns_items(ui: &mut Ui, app: &mut WordApp) { - let current = wordcraft_engine::cmd::page::columns_preset_at(&app.session); - for (l, k, params) in [ - ("One", "one", json!({"count": 1})), - ("Two", "two", json!({"count": 2})), - ("Three", "three", json!({"count": 3})), - ("Left", "left", json!({"preset": "left"})), - ("Right", "right", json!({"preset": "right"})), - ] { - mi_check(ui, app, l, current == Some(k), "layout.columns", params); - } - ui.separator(); - mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"})); -} - -fn layout(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| { - // Text Direction works on what the caret is in: table cells, a text box, else the section. - let dir = wordcraft_engine::cmd::page::text_direction_at(&app.session); - menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, "layout.textDirection", |ui, app| { - use wordcraft_doc::props::TextDirection; - for (label, d) in - [("Horizontal", TextDirection::Horizontal), ("Rotate all text 90°", TextDirection::Down), ("Rotate all text 270°", TextDirection::Up)] - { - mi_check(ui, app, label, dir == d, "layout.textDirection", json!({"value": d.ooxml()})); - } - }); - menu_button(ui, app, "margins", Some("Margins"), "Margins", true, "layout.margins", margins_items); - menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, "layout.orientation", orientation_items); - menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", size_menu); - menu_button(ui, app, "columns", Some("Columns"), "Columns", true, "layout.columns", columns_items); - stack(ui, |ui| { - menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, "layout.break", |ui, app| { - ui.label(egui::RichText::new(tl!("Page Breaks")).strong()); - mi(ui, app, "Page", "layout.break", json!({"kind": "page"})); - mi(ui, app, "Column", "layout.break", json!({"kind": "column"})); - mi(ui, app, "Text Wrapping", "layout.break", json!({"kind": "textWrapping"})); - ui.label(egui::RichText::new(tl!("Section Breaks")).strong()); - mi(ui, app, "Next Page", "layout.break", json!({"kind": "nextPage"})); - mi(ui, app, "Continuous", "layout.break", json!({"kind": "continuous"})); - mi(ui, app, "Even Page", "layout.break", json!({"kind": "evenPage"})); - mi(ui, app, "Odd Page", "layout.break", json!({"kind": "oddPage"})); - }); - menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, "layout.lineNumbers", |ui, app| { - for (l, v) in - [("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")] - { - mi(ui, app, l, "layout.lineNumbers", json!({"value": v})); - } - ui.separator(); - mi(ui, app, "Line Numbering Options…", "ui.dialog", json!({"name": "lineNumbers"})); - }); - let hy = app.session.doc.settings.auto_hyphenation; - menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, "layout.hyphenation", |ui, app| { - mi(ui, app, if hy { "✓ Automatic" } else { "Automatic" }, "layout.hyphenation", json!({"value": true})); - mi(ui, app, if hy { "None" } else { "✓ None" }, "layout.hyphenation", json!({"value": false})); - mi(ui, app, "Manual…", "ui.dialog", json!({"name": "manualHyphenation"})); - ui.separator(); - mi(ui, app, "Hyphenation Options…", "ui.dialog", json!({"name": "hyphenation"})); - }); - }); - }); - group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { - let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); - let Some(rp) = rp else { return }; - // #654: header + two rows of 20 px fields fit the content height above the group title - // (1.5 pt between rows; 3 ran 2.5 pt into it); labels and fields sit on shared columns, - // every field the same width. - const FIELD: egui::Vec2 = egui::Vec2::new(64.0, 20.0); - ui.spacing_mut().interact_size.y = FIELD.y; - // The gap before the Spacing column sits inside its cells: `add_space` in a grid moves the - // next cell in release builds but trips a debug assertion. - let spaced = |ui: &mut Ui, text: egui::RichText| { - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.label(text); - }); - }; - egui::Grid::new("layout_para").spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y).show(ui, |ui| { - ui.label(egui::RichText::new(tl!("Indent")).small().strong()); - ui.label(""); - spaced(ui, egui::RichText::new(tl!("Spacing")).small().strong()); - ui.end_row(); - // Indents in the File › Options unit. - let u = crate::options::unit(); - let k = u.pt_per_unit(); - let mut l = rp.indent_left / k; - let mut b = rp.space_before; - ui.label(egui::RichText::new(tl!("Left:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut l).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() { - let _ = app.run("para.indents", json!({"left": l * k})); - } - spaced(ui, egui::RichText::new(tl!("Before:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { - let _ = app.run("para.spacing", json!({"before": b})); - } - ui.end_row(); - let mut r = rp.indent_right / k; - let mut a = rp.space_after; - ui.label(egui::RichText::new(tl!("Right:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut r).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() { - let _ = app.run("para.indents", json!({"right": r * k})); - } - spaced(ui, egui::RichText::new(tl!("After:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { - let _ = app.run("para.spacing", json!({"after": a})); - } - ui.end_row(); - }); - }); - group(ui, "Arrange", None, app, |ui, app| { - big(ui, app, "position", "Position", "arrange.position", json!({}), false); - wrap_menu(ui, app); - stack(ui, |ui| { - small(ui, app, "bringForward", Some("Bring Forward"), "Bring Forward", "arrange.bringForward", json!({}), false); - small(ui, app, "sendBackward", Some("Send Backward"), "Send Backward", "arrange.sendBackward", json!({}), false); - small(ui, app, "selectionPane", Some("Selection Pane"), "Selection Pane", "arrange.selectionPane", json!({}), false); - }); - stack(ui, |ui| { - align_menu(ui, app, None); - group_menu(ui, app, None); - rotate_menu(ui, app, None); - }); - }); -} - -/// Arrange › Rotate: turn 90° either way or flip (the angle and mirroring, not the pixels). -/// Arrange › Wrap Text: how the selected object sits in the text, and (Square, Tight, Through) -/// which of its sides text flows on. -fn wrap_menu(ui: &mut Ui, app: &mut WordApp) { - let (wrap, side) = wordcraft_engine::cmd::objects::selected(&app.session) - .and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, f.wrap_text))) - .unwrap_or_default(); - menu_button(ui, app, "wrapText", Some("Wrap\nText"), "Wrap Text", true, "arrange.wrapText", |ui, app| { - use wordcraft_doc::para::{Wrap, WrapText}; - for (l, w, v) in [ - ("In Line with Text", Wrap::Inline, "inline"), - ("Square", Wrap::Square, "square"), - ("Tight", Wrap::Tight, "tight"), - ("Through", Wrap::Through, "through"), - ("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"), - ("Behind Text", Wrap::BehindText, "behindText"), - ("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"), - ] { - mi_check(ui, app, l, wrap == w, "arrange.wrap", json!({"wrap": v})); - } - ui.separator(); - ui.menu_button(tl!("Wrap Text Sides"), |ui| { - for (l, s, v) in [ - ("Both Sides", WrapText::BothSides, "bothSides"), - ("Left Only", WrapText::Left, "left"), - ("Right Only", WrapText::Right, "right"), - ("Largest Only", WrapText::Largest, "largest"), - ] { - mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v})); - } - }); - }); -} - -fn rotate_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { - menu_button(ui, app, "rotate", label, "Rotate", false, "arrange.rotate", |ui, app| { - mi(ui, app, "Rotate Right 90°", "arrange.rotate", json!({"direction": "right"})); - mi(ui, app, "Rotate Left 90°", "arrange.rotate", json!({"direction": "left"})); - mi(ui, app, "Flip Vertical", "arrange.rotate", json!({"direction": "flipVertical"})); - mi(ui, app, "Flip Horizontal", "arrange.rotate", json!({"direction": "flipHorizontal"})); - }); -} - -/// Size › Rotation: the selected object's exact angle (a drag of the field is one undo step). -fn rotation_field(ui: &mut Ui, app: &mut WordApp) { - let deg0 = wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(_, _, f)| f.spin().deg)).unwrap_or(0.0); - ui.label(egui::RichText::new(tl!("Rotation:")).small()); - let mut deg = deg0; - let r = ui.add(egui::DragValue::new(&mut deg).speed(1.0).range(-360.0..=360.0).max_decimals(1).suffix("°")); - if r.changed() { - if r.dragged() && !r.drag_started() { - app.session.join_next_undo(); - } - let _ = app.run("arrange.rotation", json!({"degrees": deg})); - } -} - -/// Arrange › Group: Group (Shift+click objects to select several) and Ungroup. -fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { - menu_button(ui, app, "group", label, "Group", false, "arrange.group", |ui, app| { - mi(ui, app, "Group", "arrange.group", json!({})); - mi(ui, app, "Ungroup", "arrange.ungroup", json!({})); - }); -} - -/// Arrange › Align: horizontal position relative to the margin (#3242 — a plain button ran -/// `arrange.align` with no `value`, which always centers; a menu reaches Left and Right too). -fn align_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { - menu_button(ui, app, "align", label, "Align", false, "arrange.align", |ui, app| { - mi(ui, app, "Align Left", "arrange.align", json!({"value": "left"})); - mi(ui, app, "Align Center", "arrange.align", json!({"value": "center"})); - mi(ui, app, "Align Right", "arrange.align", json!({"value": "right"})); - }); -} - -fn references(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Table of Contents", None, app, |ui, app| { - menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, "references.toc", |ui, app| { - mi(ui, app, "Automatic Table 1 (3 levels)", "references.toc", json!({"levels": 3})); - mi(ui, app, "Automatic Table 2 (2 levels)", "references.toc", json!({"levels": 2, "title": "Table of Contents"})); - ui.separator(); - mi(ui, app, "Custom Table of Contents…", "ui.dialog", json!({"name": "toc"})); - mi(ui, app, "Remove Table of Contents", "references.removeToc", json!({})); - }); - stack(ui, |ui| { - menu_button(ui, app, "addText", Some("Add Text"), "Add Text", false, "references.addText", |ui, app| { - mi(ui, app, "Do Not Show in TOC", "references.addText", json!({"level": 0})); - for l in 1..=3u64 { - mi(ui, app, &format!("Level {l}"), "references.addText", json!({"level": l})); - } - }); - small(ui, app, "updateTable", Some("Update Table"), "Update Table", "references.updateToc", json!({}), false); - }); - }); - group(ui, "Footnotes", Some("references.noteOptions"), app, |ui, app| { - big(ui, app, "footnote", "Insert\nFootnote", "references.footnote", json!({}), false); - stack(ui, |ui| { - small(ui, app, "endnote", Some("Insert Endnote"), "Insert Endnote", "references.endnote", json!({}), false); - small(ui, app, "nextFootnote", Some("Next Footnote"), "Next Footnote", "references.nextFootnote", json!({}), false); - small(ui, app, "showNotes", Some("Show Notes"), "Show Notes", "references.notes", json!({}), false); - }); - }); - group(ui, "Research", None, app, |ui, app| { - big(ui, app, "researcher", "Researcher", "references.researcher", json!({}), false); - }); - group(ui, "Citations & Bibliography", None, app, |ui, app| { - // The document's sources to cite, then a new one (Create Source, #402). - menu_button(ui, app, "citation", Some("Insert\nCitation"), "Insert Citation", true, "", |ui, app| { - let sources: Vec<(String, String)> = - app.session.doc.sources.iter().map(|s| (s.tag.clone(), format!("{} — {}", s.author, s.title))).collect(); - for (tag, label) in sources { - mi_text(ui, app, &label, "references.citation", json!({"tag": tag})); - } - if !app.session.doc.sources.is_empty() { - ui.separator(); - } - mi(ui, app, "Add New Source…", "ui.dialog", json!({"name": "createSource"})); - }); - stack(ui, |ui| { - small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false); - let current = app.session.bib_style.clone(); - let label = crate::i18n::fmt(tl!("Style: {style}"), &[("style", ¤t)]); - menu_button(ui, app, "styles", Some(&label), "Citation Style", false, "references.citationStyle", |ui, app| { - for st in &wordcraft_engine::cmd::citations::STYLES { - mi_check(ui, app, st.name, st.name == current, "references.citationStyle", json!({"style": st.name})); - } - }); - small(ui, app, "bibliography", Some("Bibliography"), "Bibliography", "references.bibliography", json!({}), false); - }); - }); - group(ui, "Captions", None, app, |ui, app| { - big(ui, app, "caption", "Insert\nCaption", "references.caption", json!({}), false); - stack(ui, |ui| { - small( - ui, - app, - "tableOfFigures", - Some("Insert Table of Figures"), - "Insert Table of Figures", - "references.tableOfFigures", - json!({}), - false, - ); - small(ui, app, "update", Some("Update Table"), "Update Table of Figures", "references.updateFigures", json!({}), false); - small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false); - }); - }); - group(ui, "Index", None, app, |ui, app| { - big(ui, app, "markEntry", "Mark\nEntry", "references.markEntry", json!({}), false); - stack(ui, |ui| { - small(ui, app, "index", Some("Insert Index"), "Insert Index", "references.index", json!({}), false); - small(ui, app, "update", Some("Update Index"), "Update Index", "references.updateIndex", json!({}), false); - }); - }); - group(ui, "Table of Authorities", None, app, |ui, app| { - big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false); - stack(ui, |ui| { - small( - ui, - app, - "tableOfAuthorities", - Some("Insert Table of Authorities"), - "Insert Table of Authorities", - "references.tableOfAuthorities", - json!({}), - false, - ); - small(ui, app, "update", Some("Update Table"), "Update Table", "references.updateAuthorities", json!({}), false); - }); - }); -} - -fn mailings(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Create", None, app, |ui, app| { - big(ui, app, "envelope", "Envelopes", "mailings.envelopes", json!({}), false); - big(ui, app, "labels", "Labels", "mailings.labels", json!({}), false); - }); - group(ui, "Start Mail Merge", None, app, |ui, app| { - // The kinds of merge document the engine makes, the current one checked. - menu_button(ui, app, "mailMerge", Some("Start Mail\nMerge"), "Start Mail Merge", true, "mailings.start", |ui, app| { - let kind = app.session.merge.kind.clone(); - for (label, k) in [ - ("Letters", "letters"), - ("E-mail Messages", "emails"), - ("Envelopes", "envelopes"), - ("Labels", "labels"), - ("Directory", "directory"), - ("Normal Word Document", "normal"), - ] { - let current = kind == k || (k == "normal" && kind.is_empty()); - mi_check(ui, app, label, current, "mailings.start", json!({"kind": k})); - } - }); - // The command needs data, so the button offers the two ways to give it (#240). - menu_button(ui, app, "recipients", Some("Select\nRecipients"), "Select Recipients", true, "mailings.recipients", |ui, app| { - mi(ui, app, "Type a New List…", "ui.dialog", json!({"name": "newRecipientList"})); - mi(ui, app, "Use an Existing List…", "ui.openRecipientList", json!({})); - }); - big(ui, app, "editRecipients", "Edit\nRecipient List", "mailings.editRecipients", json!({}), false); - }); - group(ui, "Write & Insert Fields", None, app, |ui, app| { - big(ui, app, "highlightFields", "Highlight\nMerge Fields", "mailings.highlightFields", json!({}), false); - big(ui, app, "addressBlock", "Address\nBlock", "mailings.addressBlock", json!({}), false); - big(ui, app, "greetingLine", "Greeting\nLine", "mailings.greetingLine", json!({}), false); - big(ui, app, "mergeField", "Insert Merge\nField", "mailings.insertField", json!({}), false); - stack(ui, |ui| { - // The merge rules the engine knows; If and Skip Record If ask for their condition. - menu_button(ui, app, "rules", Some("Rules"), "Rules", false, "mailings.rules", |ui, app| { - mi(ui, app, "If…Then…Else…", "mailings.rules", json!({"rule": "IF"})); - mi(ui, app, "Merge Record #", "mailings.rules", json!({"rule": "MERGEREC"})); - mi(ui, app, "Next Record", "mailings.rules", json!({"rule": "NEXT"})); - mi(ui, app, "Skip Record If…", "mailings.rules", json!({"rule": "SKIPIF"})); - }); - small(ui, app, "matchFields", Some("Match Fields"), "Match Fields", "mailings.matchFields", json!({}), false); - }); - }); - group(ui, "Preview Results", None, app, |ui, app| { - big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false); - stack(ui, |ui| { - crate::widgets::row(ui, |ui| { - small(ui, app, "first", None, "First Record", "mailings.first", json!({}), false); - small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false); - record_box(ui, app); - small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false); - small(ui, app, "last", None, "Last Record", "mailings.last", json!({}), false); - }); - small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false); - small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false); - }); - }); - group(ui, "Finish", None, app, |ui, app| { - menu_button(ui, app, "finish", Some("Finish &\nMerge"), "Finish & Merge", true, "mailings.finish", |ui, app| { - mi(ui, app, "Edit Individual Documents…", "mailings.finish", json!({})); - mi(ui, app, "Print Documents…", "mailings.printDocuments", json!({})); - }); - }); -} - -/// Preview Results' record number: shows the previewed record; a number typed and Enter go there. -fn record_box(ui: &mut Ui, app: &mut WordApp) { - let on = !app.session.merge.rows.is_empty(); - let shown = if on { (app.session.merge.record + 1).to_string() } else { String::new() }; - // The draft lives in temp memory while typing (frame-local text would lose keystrokes). - let key = egui::Id::unique("merge_record_box"); - let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| shown.clone()); - let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).align(egui::Align2::CENTER_TOP)); - let r = r.on_hover_text(tl!("Go to Record")); - if r.changed() { - ui.data_mut(|d| d.insert_temp(key, text.clone())); - } - if r.lost_focus() { - ui.data_mut(|d| d.remove::(key)); - if ui.input(|i| i.key_pressed(egui::Key::Enter)) && text.trim() != shown { - let _ = app.run("mailings.goToRecord", json!({"record": text.trim()})); - } - } -} - -/// Zotero (`docs/zotero.md`): the Zotero desktop app does the work in its own window. -fn zotero(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Citations", None, app, |ui, app| { - big(ui, app, "citation", "Add/Edit\nCitation", "ui.zotero.addEditCitation", json!({}), false); - big(ui, app, "addNote", "Add\nNote", "ui.zotero.addNote", json!({}), false); - big(ui, app, "pastCitation", "Move Past\nCitation", "caret.pastCitation", json!({}), false); - }); - group(ui, "Bibliography", None, app, |ui, app| { - big(ui, app, "bibliography", "Add/Edit\nBibliography", "ui.zotero.addEditBibliography", json!({}), false); - }); - group(ui, "Document", None, app, |ui, app| { - big(ui, app, "update", "Refresh", "ui.zotero.refresh", json!({}), false); - big(ui, app, "docPrefs", "Document\nPreferences", "ui.zotero.setDocPrefs", json!({}), false); - big(ui, app, "unlinkCitations", "Unlink\nCitations", "ui.zotero.removeCodes", json!({}), false); - }); -} - -fn review(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Proofing", None, app, |ui, app| { - big(ui, app, "spelling", "Spelling &\nGrammar", "review.spelling", json!({}), false); - stack(ui, |ui| { - small(ui, app, "thesaurus", Some("Thesaurus"), "Thesaurus", "review.thesaurus", json!({}), false); - small(ui, app, "wordCount", Some("Word Count"), "Word Count", "ui.dialog", json!({"name": "wordCount"}), false); - }); - }); - group(ui, "Speech", None, app, |ui, app| { - big(ui, app, "readAloud", "Read\nAloud", "review.readAloud", json!({}), false); - }); - group(ui, "Accessibility", None, app, |ui, app| { - big(ui, app, "accessibility", "Check\nAccessibility", "file.accessibility", json!({}), false); - }); - group(ui, "Language", None, app, |ui, app| { - big(ui, app, "translate", "Translate", "review.translate", json!({}), false); - menu_button(ui, app, "language", Some("Language"), "Language", true, "", language_menu); - }); - group(ui, "Comments", None, app, |ui, app| { - big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false); - stack(ui, |ui| { - small(ui, app, "deleteComment", Some("Delete"), "Delete Comment", "review.deleteComment", json!({}), false); - small(ui, app, "prevComment", Some("Previous"), "Previous Comment", "review.previousComment", json!({}), false); - small(ui, app, "nextComment", Some("Next"), "Next Comment", "review.nextComment", json!({}), false); - }); - let shown = app.session.view.comments_pane; - big(ui, app, "showComments", "Show\nComments", "view.commentsPane", json!({"value": !shown}), false); - }); - group(ui, "Tracking", Some("review.trackingOptions"), app, |ui, app| { - let on = app.session.doc.settings.track_changes; - let r = big(ui, app, "trackChanges", if on { "Track\nChanges ✓" } else { "Track\nChanges" }, "review.trackChanges", json!({}), false); - let _ = r; - stack(ui, |ui| { - menu_button( - ui, - app, - "markup", - Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }), - "Display for Review", - false, - "review.markup", - |ui, app| { - mi(ui, app, "All Markup", "review.markup", json!({"value": "all"})); - mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"})); - }, - ); - small(ui, app, "reviewingPane", Some("Reviewing Pane"), "Reviewing Pane", "review.changes", json!({}), false); - }); - }); - group(ui, "Changes", None, app, |ui, app| { - menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| { - mi(ui, app, "Accept and Move to Next", "review.accept", json!({"next": true})); - mi(ui, app, "Accept This Change", "review.accept", json!({})); - mi(ui, app, "Accept All Changes", "review.acceptAll", json!({})); - mi(ui, app, "Accept All Changes and Stop Tracking", "review.acceptAll", json!({"stopTracking": true})); - }); - menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| { - mi(ui, app, "Reject and Move to Next", "review.reject", json!({"next": true})); - mi(ui, app, "Reject Change", "review.reject", json!({})); - mi(ui, app, "Reject All Changes", "review.rejectAll", json!({})); - mi(ui, app, "Reject All Changes and Stop Tracking", "review.rejectAll", json!({"stopTracking": true})); - }); - stack(ui, |ui| { - small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false); - small(ui, app, "nextChange", Some("Next"), "Next Change", "review.nextChange", json!({}), false); - }); - }); - group(ui, "Compare", None, app, |ui, app| { - big(ui, app, "compare", "Compare", "review.compare", json!({}), false); - }); - group(ui, "Protect", None, app, |ui, app| { - big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false); - big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false); - }); - group(ui, "Ink", None, app, |ui, app| { - let hidden = app.session.view.hide_ink; - big_toggle(ui, app, "hideInk", "Hide\nInk", "review.hideInk", hidden); - }); -} - -/// Developer › Controls: content controls. -fn developer(app: &mut WordApp, ui: &mut Ui) { - let design = app.session.view.design_mode; - group(ui, "Controls", None, app, |ui, app| { - let rows: [[(&str, &str, &str); 3]; 3] = [ - [ - ("ccRichText", "Rich Text Content Control", "developer.richText"), - ("ccPlainText", "Plain Text Content Control", "developer.plainText"), - ("ccPicture", "Picture Content Control", "developer.picture"), - ], - [ - ("ccGallery", "Building Block Gallery Content Control", "developer.buildingBlock"), - ("ccCheckBox", "Check Box Content Control", "developer.checkBox"), - ("ccComboBox", "Combo Box Content Control", "developer.comboBox"), - ], - [ - ("ccDropDown", "Drop-Down List Content Control", "developer.dropDown"), - ("ccDate", "Date Picker Content Control", "developer.datePicker"), - ("ccRepeat", "Repeating Section Content Control", "developer.repeatingSection"), - ], - ]; - // Three rows of small buttons fill the group's height, like every other stack. - stack(ui, |ui| { - for row in rows { - crate::widgets::row(ui, |ui| { - for (icon, tip, id) in row { - small(ui, app, icon, None, tip, id, json!({}), false); - } - }); - } - }); - stack(ui, |ui| { - small(ui, app, "designMode", Some("Design Mode"), "Design Mode", "developer.designMode", json!({}), design); - small(ui, app, "properties", Some("Properties"), "Properties", "developer.properties", json!({}), false); - }); - }); -} - -/// Draws the View tab's Views, Immersive, Page Movement, Show, Zoom, Dark Mode, Window and Macros -/// and Templates groups. -fn view(app: &mut WordApp, ui: &mut Ui) { - let v = app.session.view.clone(); - group(ui, "Views", None, app, |ui, app| { - big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false); - big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false); - big(ui, app, "webLayout", "Web\nLayout", "view.webLayout", json!({}), false); - stack(ui, |ui| { - small(ui, app, "outline", Some("Outline"), "Outline", "view.outline", json!({}), false); - small(ui, app, "draft", Some("Draft"), "Draft", "view.draft", json!({}), false); - }); - }); - group(ui, "Immersive", None, app, |ui, app| { - big(ui, app, "focus", "Focus", "view.focus", json!({}), false); - big(ui, app, "immersive", "Immersive\nReader", "view.immersive", json!({}), false); - }); - group(ui, "Page Movement", None, app, |ui, app| { - big(ui, app, "vertical", "Vertical", "view.vertical", json!({}), false); - big(ui, app, "sideToSide", "Side\nto Side", "view.sideToSide", json!({}), false); - }); - group(ui, "Show", None, app, |ui, app| { - // Three rows fill the group's height; a fourth check box would run into its label. - stack(ui, |ui| { - let mut r = v.ruler; - if ui.checkbox(&mut r, tl!("Ruler")).changed() { - let _ = app.run("view.ruler", json!({"value": r})); - } - let mut g = v.gridlines; - if ui.checkbox(&mut g, tl!("Gridlines")).changed() { - let _ = app.run("view.gridlines", json!({"value": g})); - } - let mut n = v.nav_pane; - if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() { - let _ = app.run("view.navigationPane", json!({"value": n})); - } - }); - stack(ui, |ui| { - let mut w = v.hide_white_space; - if ui.checkbox(&mut w, tl!("Hide White Space")).changed() { - let _ = app.run("view.hideWhiteSpace", json!({"value": w})); - } - }); - }); - group(ui, "Zoom", None, app, |ui, app| { - big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false); - big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false); - // Step the zoom up and down by 10% (issue #67), from whatever the page shows now. - stack(ui, |ui| { - small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false); - small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false); - }); - stack(ui, |ui| { - small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage"); - small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page); - small(ui, app, "pageWidth", Some("Page Width"), "Page Width", "view.pageWidth", json!({}), v.fit == "pageWidth"); - }); - }); - group(ui, "Dark Mode", None, app, |ui, app| { - big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false); - menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, "", |ui, app| { - for a in crate::theme::Appearance::ALL { - if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() { - let _ = app.run("view.interfaceTheme", json!({"value": a.code()})); - ui.close(); - } - } - }); - }); - group(ui, "Window", None, app, |ui, app| { - big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); - big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); - big(ui, app, "split", "Split", "view.split", json!({}), false); - menu_button( - ui, - app, - "switchWindows", - Some("Switch\nWindows"), - "Switch Windows", - true, - "view.switchWindows", - crate::documents::switch_windows_menu, - ); - // The Full Screen button carries its name for assistive technology (and for the - // regression test that clicks it): big buttons are otherwise drawn unlabeled. Its - // enabled state is the command's own, as every other ribbon control reports: the - // browser has no full screen to ask for, and says so (#662). - let on = crate::widgets::enabled(app, "view.fullScreen"); - let resp = big_toggle(ui, app, "fullScreen", "Full\nScreen", "view.fullScreen", v.full_screen); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, v.full_screen, "Full Screen")); - }); - group(ui, "Macros", None, app, |ui, app| { - big(ui, app, "macros", "Macros", "tools.macros", json!({}), false); - }); - group(ui, "Templates", None, app, |ui, app| { - big(ui, app, "documentTemplate", "Templates", "tools.templates", json!({}), false); - }); -} - -/// The Outlining tab (Outline view): outline levels, moving, expanding and collapsing, what is -/// shown, and closing the view. -fn outlining(app: &mut WordApp, ui: &mut Ui) { - use wordcraft_engine::layout::outline::{BODY, para_level}; - let o = app.session.view.outline.clone(); - let level = app.session.doc.para_at(&app.session.sel.focus).map(|p| para_level(&app.session.doc, p)).unwrap_or(BODY); - let name = |l: u8| if l >= BODY { tl!("Body Text").to_string() } else { crate::i18n::fmt(tl!("Level {n}"), &[("n", &l.to_string())]) }; - group(ui, "Outline Tools", None, app, |ui, app| { - stack(ui, |ui| { - crate::widgets::row(ui, |ui| { - small(ui, app, "outlineTop", None, "Promote to Heading 1", "outline.promoteToHeading1", json!({}), false); - small(ui, app, "outlinePromote", None, "Promote", "outline.promote", json!({}), false); - let levels: Vec = (1..=BODY).map(name).collect(); - if let Some(v) = crate::widgets::combo_hint(ui, "outlineLevel", 100.0, &name(level), &levels, None, tl!("Outline Level")) - && let Some(i) = levels.iter().position(|x| *x == v) - { - let l = if i + 1 >= usize::from(BODY) { json!("body") } else { json!(i + 1) }; - let _ = app.run("outline.level", json!({ "level": l })); - } - small(ui, app, "outlineDemote", None, "Demote", "outline.demote", json!({}), false); - small(ui, app, "outlineBody", None, "Demote to Body Text", "outline.demoteToBody", json!({}), false); - }); - ui.add_space(3.0); - crate::widgets::row(ui, |ui| { - small(ui, app, "outlineUp", None, "Move Up", "outline.moveUp", json!({}), false); - small(ui, app, "outlineDown", None, "Move Down", "outline.moveDown", json!({}), false); - small(ui, app, "outlineExpand", None, "Expand", "outline.expand", json!({}), false); - small(ui, app, "outlineCollapse", None, "Collapse", "outline.collapse", json!({}), false); - ui.add_space(6.0); - ui.label(egui::RichText::new(tl!("Show Level")).size(11.5)); - let mut shown: Vec = vec![tl!("All Levels").to_string()]; - shown.extend((1..BODY).map(name)); - let current = shown.get(usize::from(o.show_level)).cloned().unwrap_or_default(); - if let Some(v) = crate::widgets::combo_hint(ui, "outlineShowLevel", 90.0, ¤t, &shown, None, tl!("Show Level")) - && let Some(i) = shown.iter().position(|x| *x == v) - { - let l = if i == 0 { json!("all") } else { json!(i) }; - let _ = app.run("outline.showLevel", json!({ "level": l })); - } - }); - ui.add_space(3.0); - crate::widgets::row(ui, |ui| { - let mut f = !o.hide_formatting; - if ui.checkbox(&mut f, tl!("Show Text Formatting")).changed() { - let _ = app.run("outline.showFormatting", json!({ "value": f })); - } - let mut first = o.first_line_only; - if ui.checkbox(&mut first, tl!("Show First Line Only")).changed() { - let _ = app.run("outline.firstLineOnly", json!({ "value": first })); - } - }); - }); - }); - group(ui, "Close", None, app, |ui, app| { - big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false); - }); -} - -fn help(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Help", None, app, |ui, app| { - big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); - big(ui, app, "checkForUpdates", "Check for\nUpdates…", "ui.checkForUpdates", json!({}), false); - big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false); - }); - group(ui, "Agents", None, app, |ui, app| { - big(ui, app, "macros", "Commands", "ui.dialog", json!({"name": "commands"}), false); - }); -} - -/// SmartArt Design: the selected graphic's items (Add Shape, Promote, Demote, Text Pane), its -/// layout (a gallery of previews), its colours and Reset. -fn smart_art_design(app: &mut WordApp, ui: &mut Ui) { - use wordcraft_doc::smart_art::{SmartArtColors, SmartArtLayout}; - let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return }; - let theme = app.session.doc.settings.theme_colors.clone(); - // The Text Pane's current item, else the last. - let current = app.smart_art_item.filter(|i| *i < g.items.len()); - group(ui, "Create Graphic", None, app, |ui, app| { - big(ui, app, "addShape", "Add\nShape", "smartArt.addShape", current.map_or(json!({}), |i| json!({"after": i})), false); - stack(ui, |ui| { - let idx = current.map_or(json!({}), |i| json!({"index": i})); - small(ui, app, "promote", Some("Promote"), "Promote", "smartArt.promote", idx.clone(), false); - small(ui, app, "demote", Some("Demote"), "Demote", "smartArt.demote", idx, false); - small(ui, app, "textPane", Some("Text Pane"), "Text Pane", "smartArt.textPane", json!({}), app.session.view.smart_art_pane); - }); - }); - group(ui, "Layouts", None, app, |ui, app| { - let t = Tokens::get(ui.ctx()); - egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)).show(ui, |ui| { - for (k, layout) in SmartArtLayout::ALL.into_iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click()); - let on = g.layout == layout; - let fill = if on { - t.checked - } else if resp.hovered() { - t.hover - } else { - t.ribbon - }; - ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside); - crate::dialogs_smart_art::preview(ui.painter(), r.shrink(2.0), layout, g.colors, &theme, t.text_dim); - if resp.on_hover_text(tl!(layout.label())).clicked() && !on { - let _ = app.run("smartArt.layout", json!({"layout": layout.id()})); - } - if k % 4 == 3 { - ui.end_row(); - } - } - }); - }); - group(ui, "SmartArt Styles", None, app, |ui, app| { - menu_button(ui, app, "smartArtColors", Some("Change\nColors"), "Change Colors", true, "", |ui, app| { - for colors in SmartArtColors::ALL { - let on = g.colors == colors; - let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0))); - let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0)); - crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme); - if resp.clicked() { - let _ = app.run("smartArt.colors", json!({"variant": colors.id()})); - ui.close(); - } - } - }); - }); - group(ui, "Reset", None, app, |ui, app| { - big(ui, app, "resetGraphic", "Reset\nGraphic", "smartArt.reset", json!({}), false); - }); - graphic_size(ui, app); -} - -/// Chart Design: the selected chart's elements (title, legend, data labels), its data and type, -/// and its size. -fn chart_design(app: &mut WordApp, ui: &mut Ui) { - let Some(chart) = wordcraft_engine::cmd::chart::selected_chart(&app.session) else { return }; - group(ui, "Chart Layouts", None, app, |ui, app| { - menu_button(ui, app, "chartTitle", Some("Chart Title"), "Chart Title", true, "", |ui, app| { - mi_check(ui, app, "None", chart.title.is_none(), "chart.title", json!({"text": null})); - let text = chart.title.clone().unwrap_or_else(|| tl!("Chart Title").to_string()); - mi_check(ui, app, "Above Chart", chart.title.is_some(), "chart.title", json!({"text": text})); - }); - menu_button(ui, app, "legend", Some("Legend"), "Legend", true, "", |ui, app| { - mi_check(ui, app, "None", chart.legend.is_none(), "chart.legend", json!({"pos": "none"})); - for (label, pos) in [ - ("Right", wordcraft_doc::chart::LegendPos::Right), - ("Top", wordcraft_doc::chart::LegendPos::Top), - ("Left", wordcraft_doc::chart::LegendPos::Left), - ("Bottom", wordcraft_doc::chart::LegendPos::Bottom), - ] { - mi_check(ui, app, label, chart.legend == Some(pos), "chart.legend", json!({"pos": pos.id()})); - } - }); - menu_button(ui, app, "dataLabels", Some("Data Labels"), "Data Labels", true, "", |ui, app| { - mi_check(ui, app, "None", !chart.data_labels, "chart.dataLabels", json!({"on": false})); - mi_check(ui, app, "Show", chart.data_labels, "chart.dataLabels", json!({"on": true})); - }); - }); - group(ui, "Data", None, app, |ui, app| { - big(ui, app, "chartData", "Edit\nData", "chart.editData", json!({}), false); - }); - group(ui, "Type", None, app, |ui, app| { - big(ui, app, "changeChartType", "Change\nChart Type", "chart.type", json!({}), false); - }); - graphic_size(ui, app); -} - -/// Chart Design and SmartArt Design › Size: the selected graphic's height and width. -fn graphic_size(ui: &mut Ui, app: &mut WordApp) { - group(ui, "Size", None, app, |ui, app| { - let (w0, h0) = - wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0)); - stack(ui, |ui| { - for (label, key, v0) in [("H:", "height", h0), ("W:", "width", w0)] { - crate::widgets::row(ui, |ui| { - ui.label(egui::RichText::new(tl!(label)).small()); - let mut v = v0; - // The range bounds what is typed or dragged; a size outside it is shown as it - // is (clamped, egui would report a change and resize the graphic unasked). - let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).clamp_existing_to_range(false).suffix(" pt")); - if r.changed() && v != v0 { - if r.dragged() && !r.drag_started() { - app.session.join_next_undo(); - } - let _ = app.run("picture.size", json!({key: v, "lockAspect": false})); - } - }); - } - }); - }); -} - -/// Shape Fill › Gradient: No Gradient, Light and Dark Variations of the shape's colour, More -/// Gradients… (the Format Shape pane). -pub(crate) fn gradient_menu(ui: &mut Ui, app: &mut WordApp, fill: Option<&wordcraft_doc::fill::Fill>) { - use wordcraft_engine::cmd::shape_fill; - let pos = wordcraft_engine::cmd::objects::selected(&app.session).map(|(p, _)| p); - let main = match (fill, &pos) { - (Some(f), Some(p)) => crate::format_shape::variation_base(ui.ctx(), p, f), - (Some(f), None) => f.main_color(), - _ => app.session.doc.settings.theme_colors.get(4).copied().unwrap_or(wordcraft_doc::Rgb(68, 114, 196)), - }; - // No Gradient: the shape's colour, solid. - ui.label(egui::RichText::new(tl!("No Gradient")).small().weak()); - if crate::format_shape::swatch(ui, &wordcraft_doc::fill::Fill::solid(main), "No Gradient") { - let _ = app.run("shape.fill", json!({"color": main.hex()})); - ui.close(); - } - for (title, tiles) in ["Light Variations", "Dark Variations"].into_iter().zip(shape_fill::variations(main)) { - ui.label(egui::RichText::new(tl!(title)).small().weak()); - egui::Grid::new(title).spacing(vec2(4.0, 4.0)).show(ui, |ui| { - for (i, (f, name)) in tiles.iter().enumerate() { - if crate::format_shape::swatch(ui, f, name) { - let _ = app.run("shape.fill", shape_fill::params(Some(f))); - if let (Some(p), Some(Some(now))) = (pos.clone(), crate::format_shape::current(app)) { - crate::format_shape::remember_variation(ui.ctx(), p, now, main); - } - ui.close(); - } - if i % 5 == 4 { - ui.end_row(); - } - } - }); - } - ui.separator(); - if ui.button(tl!("More Gradients…")).clicked() { - let _ = app.run("view.formatShapePane", json!({"value": true})); - ui.close(); - } -} - -/// Shape Format: fill, outline and effects of the selected shape, and its arrangement. -fn shape_format(app: &mut WordApp, ui: &mut Ui) { - let (stroke, glow_size, fill) = match wordcraft_engine::cmd::objects::selected(&app.session) { - Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, fill, .. })) => (stroke, effects.glow.map(|g| g.size), fill), - Some((_, o)) => (None, None, o.canvas_style().and_then(|c| c.fill.clone())), - _ => (None, None, None), - }; - let theme = app.session.doc.settings.theme_colors.clone(); - group(ui, "Shape Styles", None, app, |ui, app| { - stack(ui, |ui| { - menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, "shape.fill", |ui, app| { - mi(ui, app, "No Color", "shape.fill", json!({"color": null})); - crate::colors::menu(ui, app, &theme, Target::cmd("shape.fill")); - ui.separator(); - ui.menu_button(tl!("Gradient"), |ui| gradient_menu(ui, app, fill.as_ref())); - }); - menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, "shape.outline", |ui, app| { - mi(ui, app, "No Color", "shape.outline", json!({"color": null})); - crate::colors::menu(ui, app, &theme, Target::cmd("shape.outline")); - ui.separator(); - ui.menu_button(tl!("Weight"), |ui| { - let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex(); - for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w})); - } - }); - }); - menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, "shape.effects", |ui, app| { - shape_effects_menu(ui, app, &theme, glow_size) - }); - }); - }); - alt_text_group(ui, app); - if wordcraft_engine::cmd::canvas::selected_canvas(&app.session).is_some() { - group(ui, "Drawing Canvas", None, app, |ui, app| { - menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, "", |ui, app| { - mi(ui, app, "Fit", "canvas.fit", json!({})); - mi(ui, app, "Expand", "canvas.expand", json!({})); - ui.menu_button(tl!("Scale Drawing"), |ui| { - for pct in [50, 75, 125, 150, 200] { - mi(ui, app, &format!("{pct}%"), "canvas.scale", json!({"factor": pct as f32 / 100.0})); - } - }); - }); - }); - } - group(ui, "WordArt Styles", None, app, |ui, app| { - menu_button(ui, app, "wordArt", Some("Styles"), "WordArt Styles", true, "", |ui, app| { - crate::wordart_menus::style_gallery(ui, app, "wordArt.style") - }); - stack(ui, |ui| { - menu_button(ui, app, "textFill", Some("Text Fill"), "Text Fill", false, "", |ui, app| crate::wordart_menus::fill_menu(ui, app, &theme)); - let outline = crate::wordart_menus::current_outline(app); - menu_button(ui, app, "textOutline", Some("Text Outline"), "Text Outline", false, "", |ui, app| { - crate::wordart_menus::outline_menu(ui, app, &theme, outline) - }); - menu_button(ui, app, "transform", Some("Text Effects"), "Text Effects", false, "", |ui, app| { - crate::wordart_menus::effects_menu(ui, app, &theme, true) - }); - }); - }); - shape_text_group(app, ui); - group(ui, "Arrange", None, app, |ui, app| { - big(ui, app, "position", "Position", "arrange.position", json!({}), false); - wrap_menu(ui, app); - stack(ui, |ui| { - rotate_menu(ui, app, Some("Rotate")); - align_menu(ui, app, Some("Align")); - }); - }); - group(ui, "Size", None, app, |ui, app| { - let (w0, h0) = - wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0)); - stack(ui, |ui| { - crate::widgets::row(ui, |ui| { - for (label, key, v0) in [("W:", "width", w0), ("H:", "height", h0)] { - ui.label(egui::RichText::new(tl!(label)).small()); - let mut v = v0; - // `picture.size`'s own range; a size outside it (a thin line) is shown as it - // is (clamped, egui would report a change and resize the shape unasked). - let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(1.0..=4000.0).clamp_existing_to_range(false).suffix(" pt")); - if r.changed() && v != v0 { - if r.dragged() && !r.drag_started() { - app.session.join_next_undo(); - } - let _ = app.run("picture.size", json!({key: v, "lockAspect": false})); - } - } - }); - ui.add_space(2.0); - crate::widgets::row(ui, |ui| rotation_field(ui, app)); - }); - }); -} - -/// Shape Format › Text: the text box's Text Direction and Align Text, and Create Link (Break Link -/// once it is linked to another box). -fn shape_text_group(app: &mut WordApp, ui: &mut Ui) { - use wordcraft_doc::props::{TextVert, VAlign}; - let target = wordcraft_engine::cmd::objects::text_box_target(&app.session); - let body = target.and_then(|(_, id)| app.session.doc.parts.get(&id).map(|p| p.body)).unwrap_or_default(); - group(ui, "Text", None, app, |ui, app| { - stack(ui, |ui| { - menu_button(ui, app, "textDirection", Some("Text Direction"), "Text Direction", false, "", |ui, app| { - for (label, vert) in [ - ("Horizontal", TextVert::Horz), - ("Rotate all text 90°", TextVert::Vert), - ("Rotate all text 270°", TextVert::Vert270), - ("Stacked", TextVert::Stacked), - ] { - mi_check(ui, app, label, body.vert == vert, "shape.textDirection", json!({"dir": vert.ooxml()})); - } - }); - menu_button(ui, app, "alignText", Some("Align Text"), "Align Text", false, "", |ui, app| { - for (label, anchor) in [("Top", VAlign::Top), ("Middle", VAlign::Center), ("Bottom", VAlign::Bottom)] { - mi_check(ui, app, label, body.anchor == anchor, "shape.alignText", json!({"anchor": wordcraft_doc::props::anchor_ooxml(anchor)})); - } - }); - if body.next.is_some() { - small(ui, app, "textBoxUnlink", Some("Break Link"), "Break Link", "shape.breakLink", json!({}), false); - } else { - let waiting = app.canvas.link_from.is_some(); - small(ui, app, "textBoxLink", Some("Create Link"), "Create Link", "ui.linkTextBox", json!({}), waiting); - } - }); - }); -} - -/// Picture/Shape Format › Accessibility: Alt Text opens the Alt Text pane. -fn alt_text_group(ui: &mut Ui, app: &mut WordApp) { - group(ui, "Accessibility", None, app, |ui, app| { - big(ui, app, "altText", "Alt\nText", "view.altTextPane", json!({"value": true}), false); - }); -} - -/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry. -fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option) { - ui.menu_button(tl!("Shadow"), |ui| { - mi(ui, app, "No Shadow", "shape.effects", json!({"shadow": null})); - ui.separator(); - for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS { - mi(ui, app, label, "shape.effects", json!({"shadow": id})); - } - }); - ui.menu_button(tl!("Glow"), |ui| { - mi(ui, app, "No Glow", "shape.effects", json!({"glow": null})); - ui.separator(); - for size in [5.0, 8.0, 11.0, 18.0] { - mi(ui, app, &format!("{size} pt"), "shape.effects", json!({"glow": size})); - } - ui.separator(); - ui.menu_button(tl!("Glow Colors"), |ui| { - let target = Target::at("shape.effects", json!({"glow": {"color": null, "size": glow_size.unwrap_or(8.0)}}), "/glow/color"); - crate::colors::menu(ui, app, theme, target); - }); - }); - ui.menu_button(tl!("Soft Edges"), |ui| { - mi(ui, app, "No Soft Edges", "shape.effects", json!({"softEdge": null})); - ui.separator(); - for r in [1.0, 2.5, 5.0, 10.0, 25.0, 50.0] { - mi(ui, app, &format!("{r} pt"), "shape.effects", json!({"softEdge": r})); - } - }); -} - -/// Draws the Picture Format tab's Adjust, Picture Styles, Accessibility (Alt Text), Arrange and -/// Size groups. -fn picture_format(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Adjust", None, app, |ui, app| { - menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, "picture.corrections", |ui, app| { - for (l, b, c) in - [("Brighter +20%", 20.0, 0.0), ("Darker −20%", -20.0, 0.0), ("More Contrast +20%", 0.0, 20.0), ("Less Contrast −20%", 0.0, -20.0)] - { - mi(ui, app, l, "picture.corrections", json!({"brightness": b, "contrast": c})); - } - ui.separator(); - mi(ui, app, "Sharpen", "picture.corrections", json!({"sharpen": 40.0})); - mi(ui, app, "Soften", "picture.corrections", json!({"sharpen": -40.0})); - }); - menu_button(ui, app, "picture", Some("Color"), "Saturation and recolor", true, "picture.color", |ui, app| { - for (l, v) in [("Full Saturation 100%", 100.0), ("Muted 50%", 50.0), ("Gray 0%", 0.0), ("Vivid 200%", 200.0)] { - mi(ui, app, l, "picture.color", json!({"mode": "saturation", "saturation": v})); - } - ui.separator(); - mi(ui, app, "Grayscale", "picture.color", json!({"mode": "grayscale"})); - mi(ui, app, "Sepia", "picture.color", json!({"mode": "sepia"})); - mi(ui, app, "Washout", "picture.color", json!({"mode": "washout"})); - }); - menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, "picture.iconColor", |ui, app| { - let theme = app.session.doc.settings.theme_colors.clone(); - crate::colors::menu(ui, app, &theme, Target::cmd("picture.iconColor")); - }); - menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, "picture.transparency", |ui, app| { - for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] { - mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v})); - } - ui.separator(); - // An exact value, dragged with a live preview: absolute from the original - // pixels, one undo step for the whole drag (same pattern as Size › W/H). - // The current value isn't stored anywhere, so the slider reopens where it - // was last left for this picture. - let (media, off) = match wordcraft_engine::cmd::objects::selected(&app.session) { - Some((pos, wordcraft_doc::para::InlineObject::Image { media, .. })) => (media, pos.off), - _ => (String::new(), 0), - }; - let key = egui::Id::unique(("picture_transparency", media, off)); - let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or(0.0); - let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(0.0..=100.0).suffix("%")); - if r.changed() { - ui.data_mut(|d| d.insert_temp(key, v)); - if r.dragged() && !r.drag_started() { - app.session.join_next_undo(); - } - let _ = app.run("picture.transparency", json!({"percent": v})); - } - }); - stack(ui, |ui| { - small(ui, app, "picture", Some("Change"), "Change Picture", "ui.changePicture", json!({}), false); - small(ui, app, "picture", Some("Reset"), "Reset Picture", "picture.reset", json!({}), false); - }); - }); - group(ui, "Picture Styles", None, app, |ui, app| { - menu_button(ui, app, "picture", Some("Styles"), "Frames and effects", true, "picture.style", |ui, app| { - for (l, s) in [ - ("Simple Frame", "simpleFrame"), - ("Thick Frame", "thickFrame"), - ("Rounded", "rounded"), - ("Soft Edge", "softEdge"), - ("Shadow", "shadow"), - ] { - mi(ui, app, l, "picture.style", json!({"style": s})); - } - }); - menu_button(ui, app, "picture", Some("Border"), "Picture border", true, "picture.border", |ui, app| { - for (l, w) in [("Thin", 2), ("Medium", 4), ("Thick", 8)] { - mi(ui, app, l, "picture.border", json!({"width": w})); - } - }); - }); - alt_text_group(ui, app); - group(ui, "Arrange", None, app, |ui, app| { - big(ui, app, "position", "Position", "arrange.position", json!({}), false); - wrap_menu(ui, app); - stack(ui, |ui| { - rotate_menu(ui, app, Some("Rotate")); - align_menu(ui, app, Some("Align")); - group_menu(ui, app, Some("Group")); - }); - }); - // The launcher opens the Size dialog: scale, Lock aspect ratio, the original size (#2573). - group(ui, "Size", Some("picture.size"), app, |ui, app| { - let (media, off, w0, h0, crop0, alt0) = match wordcraft_engine::cmd::objects::selected(&app.session) { - Some((pos, wordcraft_doc::para::InlineObject::Image { media, w, h, alt, crop, .. })) => (media, pos.off, w, h, crop, alt), - _ => (String::new(), 0, 0.0, 0.0, [0.0; 4], String::new()), - }; - // `picture.size` locks the aspect ratio unless told otherwise, so one dimension suffices. - // The fields show the interface unit and take a size typed in any unit, pixels too - // (`300 px`, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels), - // never per keystroke; a drag applies as it goes, one undo step. - let unit = crate::options::unit(); - let k = unit.pt_per_unit(); - stack(ui, |ui| { - crate::widgets::row(ui, |ui| { - for (label, key, pt) in [("W:", "width", w0), ("H:", "height", h0)] { - let name = ui.label(egui::RichText::new(tl!(label)).small()); - let was = pt / k; - let mut v = was; - // Not `clamp_existing_to_range`: egui would clamp a size outside the range (a 1 px - // picture is 0.75 pt) and report it as a change, resizing the picture unasked. - let field = egui::DragValue::new(&mut v) - .speed(1.0 / k) - .range(1.0 / k..=4000.0 / k) - .clamp_existing_to_range(false) - .measure(unit) - .max_decimals(2); - let r = ui.add(field.update_while_editing(false)).labelled_by(name.id).on_hover_text(tl!( - "Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions." - )); - // While typing, the field reports each keystroke as a change; only a new - // value (Enter, Tab, a click elsewhere, a drag) resizes, and Esc cancels. - let cancelled = esc_cancelled(ui, &r, egui::Id::unique(("picture_size_field", key))); - if r.changed() && v != was && !cancelled { - if r.dragged() && !r.drag_started() { - app.session.join_next_undo(); - } - let _ = app.run("picture.size", json!({key: v * k})); - } - } - rotation_field(ui, app); - }); - ui.add_space(2.0); - crate::widgets::row(ui, |ui| { - let cropping = crate::crop::active(app).is_some(); - small(ui, app, "crop", Some("Crop"), "Crop", "ui.crop", json!({}), cropping); - for (i, side) in ["L", "T", "R", "B"].iter().enumerate() { - ui.label(egui::RichText::new(tl!(side)).small()); - let mut v = crop0[i] * 100.0; - let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(0.0..=95.0).suffix("%")); - if r.changed() { - if r.dragged() && !r.drag_started() { - app.session.join_next_undo(); - } - let mut c = crop0; - c[i] = (v / 100.0).clamp(0.0, 0.95); - // The two sides of an axis keep 5% of the source between them. - let other = c[if i < 2 { i + 2 } else { i - 2 }]; - c[i] = c[i].min((0.95 - other).max(0.0)); - let _ = app.run("picture.crop", json!({"left": c[0], "top": c[1], "right": c[2], "bottom": c[3]})); - } - } - if ui.button(egui::RichText::new(tl!("Reset")).small()).on_hover_text(tl!("Reset Crop")).clicked() { - let _ = app.run("picture.crop", json!({"left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0})); - } - }); - ui.add_space(2.0); - crate::widgets::row(ui, |ui| { - ui.label(egui::RichText::new(tl!("Alt:")).small()); - // Frame-local buffers lose keystrokes; keep the draft in egui temp memory keyed - // by picture (same pattern as the comment editor in panes.rs). - let key = egui::Id::unique(("picture_alt", media.clone(), off)); - let mut alt = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| alt0.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut alt).desired_width(120.0).hint_text(tl!("Alt text"))); - if r.changed() { - ui.data_mut(|d| d.insert_temp(key, alt.clone())); - } - if r.lost_focus() { - ui.data_mut(|d| d.remove::(key)); - // The click that took focus may have selected another picture; never commit - // one picture's draft onto another. - if alt != alt0 && app.selected_picture_media().as_deref() == Some(media.as_str()) { - let _ = app.run("picture.altText", json!({"text": alt})); - } - } - }); - }); - }); -} - -fn table_design(app: &mut WordApp, ui: &mut Ui) { - let look = app.session.sel.focus.path.cell().and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look)); - group(ui, "Table Style Options", None, app, |ui, app| { - let Some(l) = look else { return }; - egui::Grid::new("look").show(ui, |ui| { - for (row, items) in [ - [("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)], - [("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)], - [("Banded Rows", "bandedRows", l.banded_rows), ("Banded Columns", "bandedColumns", l.banded_columns)], - ] - .iter() - .enumerate() - { - let _ = row; - for (label, key, val) in items { - let mut v = *val; - if ui.checkbox(&mut v, tl!(label)).changed() { - let _ = app.run("table.look", json!({ *key: v })); - } - } - ui.end_row(); - } - }); - }); - group(ui, "Table Styles", None, app, |ui, app| { - // The document's own styles first, so a new one shows without scrolling. - let mut styles: Vec<(bool, String, String)> = app - .session - .doc - .styles - .styles - .iter() - .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden) - .map(|s| (s.builtin, s.id.clone(), crate::i18n::style_name(s))) - .collect(); - styles.sort_by_key(|(builtin, _, _)| *builtin); - egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| { - ui.horizontal(|ui| { - for (_, id, name) in styles { - if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() { - let _ = app.run("table.style", json!({"style": id})); - } - } - }); - }); - stack(ui, |ui| { - let current = look.and_then(|_| { - let (tp, _, _) = app.session.sel.focus.path.cell()?; - let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?; - app.session - .doc - .styles - .get(&id) - .filter(|st| st.kind == wordcraft_doc::StyleKind::Table) - .map(wordcraft_engine::cmd::table_style::is_builtin_table_style) - }); - let can_modify = current.is_some(); - // Built-in table styles can't be deleted. - let can_delete = current == Some(false); - menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, "table.style", |ui, app| { - mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"})); - if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() { - let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"})); - ui.close(); - } - if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() { - if let Err(e) = app.run("table.deleteStyle", json!({})) { - app.status(e); - } - ui.close(); - } - }); - split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { - mi(ui, app, "No Color", "table.shading", json!({"color": null})); - let theme = app.session.doc.settings.theme_colors.clone(); - crate::colors::menu(ui, app, &theme, Target::cmd("table.shading").remember(Remember::Shading)); - }); - }); - }); - group(ui, "Borders", None, app, |ui, app| { - menu_button(ui, app, "borders", Some("Borders"), "Borders", true, "table.borders", |ui, app| { - for (l, k) in [ - ("All Borders", "all"), - ("Outside Borders", "outside"), - ("Inside Borders", "inside"), - ("No Border", "none"), - ("Top Border", "top"), - ("Bottom Border", "bottom"), - ] { - mi(ui, app, l, "table.borders", json!({"kind": k})); - } - }); - big(ui, app, "borderPainter", "Border\nPainter", "table.borderPainter", json!({}), false); - }); -} - -/// Table Layout › Cell Size: the caret cell's row height and column width, in inches. They show -/// the size on the page (a row without a set height shows the height its text gives it) and -/// editing runs `table.rowHeight` / `table.columnWidth`; dragging one is a single Undo. -fn cell_size_boxes(ui: &mut Ui, app: &mut WordApp) { - let story = app.session.sel.focus.story; - let Some((tp, r, c)) = app.session.sel.focus.path.cell() else { return }; - let Some(t) = app.session.doc.table(story, &tp) else { return }; - let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default(); - let stored_w = t.grid.get(t.grid_col(r, c)).copied(); - let laid = app.session.layout().pages.iter().flat_map(|pg| pg.items.iter()).find_map(|it| match it { - wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st, .. } if *table == tp && *row == r && *cell == c && *st == story => { - Some(*rect) - } - _ => None, - }); - // The row's own height: a vertically merged cell's box spans several rows, so take the - // shortest box in this row. - let row_h = app - .session - .layout() - .pages - .iter() - .flat_map(|pg| pg.items.iter()) - .filter_map(|it| match it { - wordcraft_layout::Placed::Cell { rect, table, row, story: st, .. } if *table == tp && *row == r && *st == story => Some(rect.h), - _ => None, - }) - .reduce(f32::min); - let unit = crate::options::unit(); - let k = unit.pt_per_unit(); - let h0 = row.height.or(row_h).unwrap_or(0.0); - let w0 = laid.map(|x| x.w).or(stored_w).unwrap_or(0.0); - let exact = row.height_rule == wordcraft_doc::props::HeightRule::Exact; - // The edited value in points, and whether it continues a drag (joins the previous Undo step). - let boxed = |ui: &mut Ui, label: &str, pt: f32| -> Option<(f32, bool)> { - let mut out = None; - crate::widgets::row(ui, |ui| { - ui.add_sized(vec2(48.0, 18.0), egui::Label::new(egui::RichText::new(tl!(label)).small())); - let mut v = pt / k; - let r = ui.add_sized(vec2(72.0, 18.0), egui::DragValue::new(&mut v).speed(0.01).range(0.02..=22.0).measure(unit).max_decimals(2)); - if r.changed() { - out = Some((v * k, r.dragged() && !r.drag_started())); - } - }); - ui.add_space(2.0); - out - }; - stack(ui, |ui| { - if let Some((h, join)) = boxed(ui, "Height:", h0) { - if join { - app.session.join_next_undo(); - } - let _ = app.run("table.rowHeight", json!({"height": h, "rule": if exact { "exact" } else { "atLeast" }})); - } - if let Some((w, join)) = boxed(ui, "Width:", w0) { - if join { - app.session.join_next_undo(); - } - let _ = app.run("table.columnWidth", json!({"width": w})); - } - }); -} - -fn table_layout(app: &mut WordApp, ui: &mut Ui) { - group(ui, "Table", None, app, |ui, app| { - stack(ui, |ui| { - menu_button(ui, app, "selectTable", Some("Select"), "Select", false, "table.selectTable", |ui, app| { - mi(ui, app, "Select Cell", "table.selectCell", json!({})); - mi(ui, app, "Select Column", "table.selectColumn", json!({})); - mi(ui, app, "Select Row", "table.selectRow", json!({})); - mi(ui, app, "Select Table", "table.selectTable", json!({})); - }); - let g = app.session.view.table_gridlines; - small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g); - small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false); - }); - }); - group(ui, "Draw", None, app, |ui, app| { - use crate::table_pen::TableTool; - let tool = app.canvas.table_tool; - crate::widgets::big_toggle(ui, app, "drawTable", "Draw\nTable", "table.draw", tool == Some(TableTool::Draw)); - crate::widgets::big_toggle(ui, app, "eraser", "Eraser", "table.eraser", tool == Some(TableTool::Erase)); - }); - group(ui, "Rows & Columns", None, app, |ui, app| { - menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, "table.deleteTable", |ui, app| { - mi(ui, app, "Delete Cells", "table.deleteCells", json!({})); - mi(ui, app, "Delete Columns", "table.deleteColumn", json!({})); - mi(ui, app, "Delete Rows", "table.deleteRow", json!({})); - mi(ui, app, "Delete Table", "table.deleteTable", json!({})); - }); - big(ui, app, "insertAbove", "Insert\nAbove", "table.insertRowAbove", json!({}), false); - stack(ui, |ui| { - small(ui, app, "insertBelow", Some("Insert Below"), "Insert Below", "table.insertRowBelow", json!({}), false); - small(ui, app, "insertLeft", Some("Insert Left"), "Insert Left", "table.insertColumnLeft", json!({}), false); - small(ui, app, "insertRight", Some("Insert Right"), "Insert Right", "table.insertColumnRight", json!({}), false); - }); - }); - group(ui, "Merge", None, app, |ui, app| { - stack(ui, |ui| { - small(ui, app, "merge", Some("Merge Cells"), "Merge Cells", "table.merge", json!({}), false); - small(ui, app, "splitCells", Some("Split Cells"), "Split Cells", "table.split", json!({}), false); - small(ui, app, "splitTable", Some("Split Table"), "Split Table", "table.splitTable", json!({}), false); - }); - }); - group(ui, "Cell Size", None, app, |ui, app| { - menu_button(ui, app, "autofit", Some("AutoFit"), "AutoFit", true, "table.autofit", |ui, app| { - mi(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"})); - mi(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"})); - mi(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"})); - }); - cell_size_boxes(ui, app); - stack(ui, |ui| { - small(ui, app, "distributeRows", Some("Distribute Rows"), "Distribute Rows", "table.distributeRows", json!({}), false); - small(ui, app, "distributeCols", Some("Distribute Columns"), "Distribute Columns", "table.distributeColumns", json!({}), false); - }); - }); - group(ui, "Alignment", None, app, |ui, app| { - egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| { - for row in [ - [("topLeft", "Align Top Left"), ("topCenter", "Align Top Center"), ("topRight", "Align Top Right")], - [("centerLeft", "Align Center Left"), ("center", "Align Center"), ("centerRight", "Align Center Right")], - [("bottomLeft", "Align Bottom Left"), ("bottomCenter", "Align Bottom Center"), ("bottomRight", "Align Bottom Right")], - ] { - for (v, tip) in row { - small(ui, app, "cellAlign", None, tip, "table.cellAlign", json!({"value": v}), false); - } - ui.end_row(); - } - }); - big(ui, app, "textDirection", "Text\nDirection", "table.textDirection", json!({}), false); - big(ui, app, "cellMargins", "Cell\nMargins", "table.cellMargins", json!({}), false); - }); - group(ui, "Data", None, app, |ui, app| { - big(ui, app, "sort", "Sort", "table.sort", json!({}), false); - stack(ui, |ui| { - small(ui, app, "repeatHeader", Some("Repeat Header Rows"), "Repeat Header Rows", "table.repeatHeader", json!({}), false); - small(ui, app, "toText", Some("Convert to Text"), "Convert to Text", "table.toText", json!({}), false); - small(ui, app, "formula", Some("Formula"), "Formula", "table.formula", json!({}), false); - }); - }); -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs new file mode 100644 index 000000000..efd442564 --- /dev/null +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -0,0 +1,109 @@ +use super::common::mi; +use super::language::mi_check; +use super::*; + +/// Word's text wrapping styles in menu order: label, model value, `arrange.wrap` parameter. +pub(crate) const WRAP_STYLES: [(&str, wordcraft_doc::para::Wrap, &str); 7] = { + use wordcraft_doc::para::Wrap; + [ + ("In Line with Text", Wrap::Inline, "inline"), + ("Square", Wrap::Square, "square"), + ("Tight", Wrap::Tight, "tight"), + ("Through", Wrap::Through, "through"), + ("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"), + ("Behind Text", Wrap::BehindText, "behindText"), + ("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"), + ] +}; + +/// The Wrap Text menu for the selected picture, shape or text box: the seven styles (the +/// current one checked), then Move with Text / Fix Position on Page for a floating one. The +/// canvas context menu shows it; the ribbon's Wrap Text button can share it. +pub(crate) fn wrap_text_items(ui: &mut Ui, app: &mut WordApp) { + use wordcraft_engine::cmd::objects::{moves_with_text, selected}; + let state = selected(&app.session).and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, moves_with_text(f)))); + let wrap = state.map(|(w, _)| w); + for (label, w, v) in WRAP_STYLES { + mi_check(ui, app, label, wrap == Some(w), "arrange.wrap", json!({"wrap": v})); + } + ui.separator(); + // An inline object is neither: it's part of the text. + let with_text = state.filter(|(w, _)| *w != wordcraft_doc::para::Wrap::Inline).map(|(_, m)| m); + mi_check(ui, app, "Move with Text", with_text == Some(true), "arrange.moveWithText", json!({"value": true})); + mi_check(ui, app, "Fix Position on Page", with_text == Some(false), "arrange.moveWithText", json!({"value": false})); +} + +/// Arrange › Rotate: turn 90° either way or flip (the angle and mirroring, not the pixels). +/// Arrange › Wrap Text: how the selected object sits in the text, and (Square, Tight, Through) +/// which of its sides text flows on. +pub(super) fn wrap_menu(ui: &mut Ui, app: &mut WordApp) { + let (wrap, side) = wordcraft_engine::cmd::objects::selected(&app.session) + .and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, f.wrap_text))) + .unwrap_or_default(); + menu_button(ui, app, "wrapText", Some("Wrap\nText"), "Wrap Text", true, "arrange.wrapText", |ui, app| { + use wordcraft_doc::para::{Wrap, WrapText}; + for (l, w, v) in [ + ("In Line with Text", Wrap::Inline, "inline"), + ("Square", Wrap::Square, "square"), + ("Tight", Wrap::Tight, "tight"), + ("Through", Wrap::Through, "through"), + ("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"), + ("Behind Text", Wrap::BehindText, "behindText"), + ("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"), + ] { + mi_check(ui, app, l, wrap == w, "arrange.wrap", json!({"wrap": v})); + } + ui.separator(); + ui.menu_button(tl!("Wrap Text Sides"), |ui| { + for (l, s, v) in [ + ("Both Sides", WrapText::BothSides, "bothSides"), + ("Left Only", WrapText::Left, "left"), + ("Right Only", WrapText::Right, "right"), + ("Largest Only", WrapText::Largest, "largest"), + ] { + mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v})); + } + }); + }); +} + +pub(super) fn rotate_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { + menu_button(ui, app, "rotate", label, "Rotate", false, "arrange.rotate", |ui, app| { + mi(ui, app, "Rotate Right 90°", "arrange.rotate", json!({"direction": "right"})); + mi(ui, app, "Rotate Left 90°", "arrange.rotate", json!({"direction": "left"})); + mi(ui, app, "Flip Vertical", "arrange.rotate", json!({"direction": "flipVertical"})); + mi(ui, app, "Flip Horizontal", "arrange.rotate", json!({"direction": "flipHorizontal"})); + }); +} + +/// Size › Rotation: the selected object's exact angle (a drag of the field is one undo step). +pub(super) fn rotation_field(ui: &mut Ui, app: &mut WordApp) { + let deg0 = wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(_, _, f)| f.spin().deg)).unwrap_or(0.0); + ui.label(egui::RichText::new(tl!("Rotation:")).small()); + let mut deg = deg0; + let r = ui.add(egui::DragValue::new(&mut deg).speed(1.0).range(-360.0..=360.0).max_decimals(1).suffix("°")); + if r.changed() { + if r.dragged() && !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run("arrange.rotation", json!({"degrees": deg})); + } +} + +/// Arrange › Group: Group (Shift+click objects to select several) and Ungroup. +pub(super) fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { + menu_button(ui, app, "group", label, "Group", false, "arrange.group", |ui, app| { + mi(ui, app, "Group", "arrange.group", json!({})); + mi(ui, app, "Ungroup", "arrange.ungroup", json!({})); + }); +} + +/// Arrange › Align: horizontal position relative to the margin (#3242 — a plain button ran +/// `arrange.align` with no `value`, which always centers; a menu reaches Left and Right too). +pub(super) fn align_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { + menu_button(ui, app, "align", label, "Align", false, "arrange.align", |ui, app| { + mi(ui, app, "Align Left", "arrange.align", json!({"value": "left"})); + mi(ui, app, "Align Center", "arrange.align", json!({"value": "center"})); + mi(ui, app, "Align Right", "arrange.align", json!({"value": "right"})); + }); +} diff --git a/crates/ui-egui/src/ribbon/charts.rs b/crates/ui-egui/src/ribbon/charts.rs new file mode 100644 index 000000000..b0a7d6304 --- /dev/null +++ b/crates/ui-egui/src/ribbon/charts.rs @@ -0,0 +1,99 @@ +use super::common::{graphic_size, stack}; +use super::language::mi_check; +use super::*; + +/// SmartArt Design: the selected graphic's items (Add Shape, Promote, Demote, Text Pane), its +/// layout (a gallery of previews), its colours and Reset. +pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_doc::smart_art::{SmartArtColors, SmartArtLayout}; + let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return }; + let theme = app.session.doc.settings.theme_colors.clone(); + // The Text Pane's current item, else the last. + let current = app.smart_art_item.filter(|i| *i < g.items.len()); + group(ui, "Create Graphic", None, app, |ui, app| { + big(ui, app, "addShape", "Add\nShape", "smartArt.addShape", current.map_or(json!({}), |i| json!({"after": i})), false); + stack(ui, |ui| { + let idx = current.map_or(json!({}), |i| json!({"index": i})); + small(ui, app, "promote", Some("Promote"), "Promote", "smartArt.promote", idx.clone(), false); + small(ui, app, "demote", Some("Demote"), "Demote", "smartArt.demote", idx, false); + small(ui, app, "textPane", Some("Text Pane"), "Text Pane", "smartArt.textPane", json!({}), app.session.view.smart_art_pane); + }); + }); + group(ui, "Layouts", None, app, |ui, app| { + let t = Tokens::get(ui.ctx()); + egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)).show(ui, |ui| { + for (k, layout) in SmartArtLayout::ALL.into_iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click()); + let on = g.layout == layout; + let fill = if on { + t.checked + } else if resp.hovered() { + t.hover + } else { + t.ribbon + }; + ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside); + crate::dialogs_smart_art::preview(ui.painter(), r.shrink(2.0), layout, g.colors, &theme, t.text_dim); + if resp.on_hover_text(tl!(layout.label())).clicked() && !on { + let _ = app.run("smartArt.layout", json!({"layout": layout.id()})); + } + if k % 4 == 3 { + ui.end_row(); + } + } + }); + }); + group(ui, "SmartArt Styles", None, app, |ui, app| { + menu_button(ui, app, "smartArtColors", Some("Change\nColors"), "Change Colors", true, "", |ui, app| { + for colors in SmartArtColors::ALL { + let on = g.colors == colors; + let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0))); + let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0)); + crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme); + if resp.clicked() { + let _ = app.run("smartArt.colors", json!({"variant": colors.id()})); + ui.close(); + } + } + }); + }); + group(ui, "Reset", None, app, |ui, app| { + big(ui, app, "resetGraphic", "Reset\nGraphic", "smartArt.reset", json!({}), false); + }); + graphic_size(ui, app); +} + +/// Chart Design: the selected chart's elements (title, legend, data labels), its data and type, +/// and its size. +pub(super) fn chart_design(app: &mut WordApp, ui: &mut Ui) { + let Some(chart) = wordcraft_engine::cmd::chart::selected_chart(&app.session) else { return }; + group(ui, "Chart Layouts", None, app, |ui, app| { + menu_button(ui, app, "chartTitle", Some("Chart Title"), "Chart Title", true, "", |ui, app| { + mi_check(ui, app, "None", chart.title.is_none(), "chart.title", json!({"text": null})); + let text = chart.title.clone().unwrap_or_else(|| tl!("Chart Title").to_string()); + mi_check(ui, app, "Above Chart", chart.title.is_some(), "chart.title", json!({"text": text})); + }); + menu_button(ui, app, "legend", Some("Legend"), "Legend", true, "", |ui, app| { + mi_check(ui, app, "None", chart.legend.is_none(), "chart.legend", json!({"pos": "none"})); + for (label, pos) in [ + ("Right", wordcraft_doc::chart::LegendPos::Right), + ("Top", wordcraft_doc::chart::LegendPos::Top), + ("Left", wordcraft_doc::chart::LegendPos::Left), + ("Bottom", wordcraft_doc::chart::LegendPos::Bottom), + ] { + mi_check(ui, app, label, chart.legend == Some(pos), "chart.legend", json!({"pos": pos.id()})); + } + }); + menu_button(ui, app, "dataLabels", Some("Data Labels"), "Data Labels", true, "", |ui, app| { + mi_check(ui, app, "None", !chart.data_labels, "chart.dataLabels", json!({"on": false})); + mi_check(ui, app, "Show", chart.data_labels, "chart.dataLabels", json!({"on": true})); + }); + }); + group(ui, "Data", None, app, |ui, app| { + big(ui, app, "chartData", "Edit\nData", "chart.editData", json!({}), false); + }); + group(ui, "Type", None, app, |ui, app| { + big(ui, app, "changeChartType", "Change\nChart Type", "chart.type", json!({}), false); + }); + graphic_size(ui, app); +} diff --git a/crates/ui-egui/src/ribbon/common.rs b/crates/ui-egui/src/ribbon/common.rs new file mode 100644 index 000000000..651b9c90b --- /dev/null +++ b/crates/ui-egui/src/ribbon/common.rs @@ -0,0 +1,143 @@ +use super::language::mi_check; +use super::*; + +/// Custom groups (Customize Ribbon, #379): up to three commands as large buttons, more as small +/// labelled buttons in columns of three. Each shows the command's own icon and label. +pub(super) fn custom_groups(app: &mut WordApp, ui: &mut Ui, groups: &[wordcraft_engine::ribbon::CustomGroup]) { + for g in groups { + let ids: Vec<(&str, String)> = + g.commands.iter().filter_map(|id| app.session.registry.get(id).map(|s| (s.id, tl!(s.label).to_string()))).collect(); + crate::widgets::group_titled(ui, &g.name, &g.name, None, app, |ui, app| { + if ids.len() <= 3 { + for (id, label) in &ids { + big(ui, app, crate::widgets::command_icon(id), &two_lines(label), id, json!({}), false); + } + return; + } + for column in ids.chunks(3) { + stack(ui, |ui| { + for (id, label) in column { + small(ui, app, crate::widgets::command_icon(id), Some(label), label, id, json!({}), false); + } + }); + } + }); + } +} + +/// A large button's label on two lines when it is long (broken at the space nearest the middle). +fn two_lines(label: &str) -> String { + let n = label.chars().count(); + if n <= 10 { + return label.to_string(); + } + let mid = n / 2; + let best = label.char_indices().filter(|(_, c)| *c == ' ').min_by_key(|(i, _)| label[..*i].chars().count().abs_diff(mid)).map(|(i, _)| i); + match best { + Some(i) => format!("{}\n{}", &label[..i], label[i..].trim_start()), + None => label.to_string(), + } +} + +/// A chevron over a ribbon edge that is also a button; `true` on the frame it is clicked. +pub(super) fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool { + let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint); + let ink = if resp.hovered() { t.text } else { t.text_dim }; + ui.painter().rect_filled(rect, 0.0, t.ribbon); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink); + resp.clicked() +} + +/// Whether Esc just ended typing in a field (`r`, remembered under `id`). egui 0.36 keeps a +/// `DragValue`'s typed text after Esc and applies it a frame later, when the field still counts as +/// having just lost focus but Esc is no longer pressed: a change reported within two frames of the +/// Esc is that late value, to be dropped so that Esc cancels as in Word. +pub(super) fn esc_cancelled(ui: &Ui, r: &egui::Response, id: egui::Id) -> bool { + let now = ui.ctx().cumulative_pass_nr(); + let (focus_key, esc_key) = (id.with("focused"), id.with("esc")); + // Read outside `data_mut`: both lock the context. + let focused = r.has_focus(); + let had_focus = ui.data(|d| d.get_temp::(focus_key)).unwrap_or(false); + ui.data_mut(|d| d.insert_temp(focus_key, focused)); + if had_focus && !focused && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(esc_key, now)); + } + ui.data(|d| d.get_temp::(esc_key)).is_some_and(|at| now.saturating_sub(at) <= 2) +} + +pub(super) fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + add(ui); + }); +} + +/// Layout › Size: the named paper sizes, then More Paper Sizes… for a custom size in Page +/// Setup (#1433). +pub(crate) fn size_menu(ui: &mut Ui, app: &mut WordApp) { + // The current paper size is checked. + let x = wordcraft_engine::cmd::page::sect(&app.session); + let current = wordcraft_geom::paper_name(x.page_w, x.page_h); + for (n, w, h) in wordcraft_geom::PAPER_SIZES { + // Paper names are the industry's (Letter, A4, B5 (JIS)) and stay as they are; the + // envelopes say what they are. Sizes in the measurement unit, written the region's + // way (`21 cm × 29,7 cm`, #838). + let name = if n.starts_with("Envelope ") { tl!(n) } else { n }; + let (width, height) = (crate::options::length(*w), crate::options::length(*h)); + let label = crate::i18n::fmt(tl!("{paper} {width} × {height}"), &[("paper", name), ("width", &width), ("height", &height)]); + mi_check(ui, app, &label, current == Some(*n), "layout.size", json!({"name": n})); + } + ui.separator(); + mi(ui, app, crate::dialogs::PAPER_LABELS.more, "ui.dialog", json!({"name": "pageSetup"})); +} + +pub(crate) fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { + mi_text(ui, app, tl!(label), id, params); +} + +/// [`mi`] with a drawn icon before the label. +pub(super) fn mi_icon(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value) { + let sc = crate::widgets::shortcut_text(app, id); + let enabled = crate::widgets::enabled(app, id); + let resp = ui.add_enabled_ui(enabled, |ui| crate::widgets::icon_menu_item(ui, icon, tl!(label), &sc, 200.0)).inner; + if resp.clicked() { + crate::widgets::run_from_ribbon(app, id, params); + ui.close(); + } +} + +/// A menu item whose text is already in the interface language. +pub(super) fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) { + let sc = crate::widgets::shortcut_text(app, id); + let enabled = crate::widgets::enabled(app, id); + let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0))); + if resp.clicked() { + crate::widgets::run_from_ribbon(app, id, params); + ui.close(); + } +} + +/// Chart Design and SmartArt Design › Size: the selected graphic's height and width. +pub(super) fn graphic_size(ui: &mut Ui, app: &mut WordApp) { + group(ui, "Size", None, app, |ui, app| { + let (w0, h0) = + wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0)); + stack(ui, |ui| { + for (label, key, v0) in [("H:", "height", h0), ("W:", "width", w0)] { + crate::widgets::row(ui, |ui| { + ui.label(egui::RichText::new(tl!(label)).small()); + let mut v = v0; + // The range bounds what is typed or dragged; a size outside it is shown as it + // is (clamped, egui would report a change and resize the graphic unasked). + let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).clamp_existing_to_range(false).suffix(" pt")); + if r.changed() && v != v0 { + if r.dragged() && !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run("picture.size", json!({key: v, "lockAspect": false})); + } + }); + } + }); + }); +} diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs new file mode 100644 index 000000000..809eb6ce0 --- /dev/null +++ b/crates/ui-egui/src/ribbon/design.rs @@ -0,0 +1,80 @@ +use super::common::{mi, mi_text, stack}; +use super::*; + +/// The Design tab's style set gallery in a box of its own, sized from its thumbnails and clipped +/// to it, with a gap on each side: the Colors and Fonts column can't be drawn over, or cut by, +/// the thumbnails (#638). Returns the box and the space the thumbnails used. +pub(super) fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) { + ui.add_space(4.0); + let (gallery, _) = ui.allocate_exact_size(vec2(crate::previews::STYLE_SET_GALLERY_W, CONTENT_H), Sense::hover()); + let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(egui::Layout::left_to_right(egui::Align::Min))); + gui.set_clip_rect(gallery.intersect(ui.clip_rect())); + gui.spacing_mut().item_spacing = vec2(1.0, 1.0); + crate::previews::style_set_gallery(app, &mut gui); + ui.add_space(8.0); + (gallery, gui.min_rect()) +} + +pub(super) fn design(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Themes", None, app, |ui, app| { + menu_button(ui, app, "themes", Some("Themes"), "Themes", true, "design.themes", |ui, app| { + for (name, ..) in wordcraft_engine::cmd::design::THEMES { + mi(ui, app, name, "design.theme", json!({"name": name})); + } + }); + }); + group(ui, "Document Formatting", None, app, |ui, app| { + style_set_box(app, ui); + stack(ui, |ui| { + menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, "design.themeColors", |ui, app| { + for (name, ..) in wordcraft_engine::cmd::design::THEMES { + mi(ui, app, name, "design.themeColors", json!({"name": name})); + } + }); + menu_button(ui, app, "fonts", Some("Fonts"), "Theme Fonts", false, "design.themeFonts", |ui, app| { + for (name, h, b, _) in wordcraft_engine::cmd::design::THEMES { + mi_text(ui, app, &format!("{}: {h} / {b}", tl!(name)), "design.themeFonts", json!({"heading": h, "body": b})); + } + }); + }); + stack(ui, |ui| { + menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, "design.paragraphSpacing", |ui, app| { + for (l, v) in [ + ("Default", "default"), + ("No Paragraph Space", "none"), + ("Compact", "compact"), + ("Tight", "tight"), + ("Open", "open"), + ("Relaxed", "relaxed"), + ("Double", "double"), + ] { + // "Open" here is roomy spacing, not the file verb: only a `spacing` row + // translates it, else it stays English. + if l == "Open" { + mi_text(ui, app, crate::i18n::get("spacing", l).unwrap_or(l), "design.paragraphSpacing", json!({"value": v})); + continue; + } + mi(ui, app, l, "design.paragraphSpacing", json!({"value": v})); + } + }); + small(ui, app, "effectsDesign", Some("Effects"), "Effects", "design.effects", json!({}), false); + small(ui, app, "setDefault", Some("Set as Default"), "Set as Default", "design.setDefault", json!({}), false); + }); + }); + group(ui, "Page Background", None, app, |ui, app| { + menu_button(ui, app, "watermark", Some("Watermark"), "Watermark", true, "design.watermark", |ui, app| { + for w in ["DRAFT", "CONFIDENTIAL", "DO NOT COPY", "SAMPLE", "ASAP", "URGENT"] { + mi(ui, app, w, "design.watermark", json!({"text": w})); + } + ui.separator(); + mi(ui, app, "Custom Watermark…", "ui.dialog", json!({"name": "watermark"})); + mi(ui, app, "Remove Watermark", "design.watermark", json!({"remove": true})); + }); + menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, "design.pageColor", |ui, app| { + mi(ui, app, "No Color", "design.pageColor", json!({"color": null})); + let theme = app.session.doc.settings.theme_colors.clone(); + crate::colors::menu(ui, app, &theme, Target::cmd("design.pageColor")); + }); + big(ui, app, "pageBorders", "Page\nBorders", "design.pageBorders", json!({}), false); + }); +} diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs new file mode 100644 index 000000000..69877e542 --- /dev/null +++ b/crates/ui-egui/src/ribbon/draw.rs @@ -0,0 +1,125 @@ +use super::common::{mi, stack}; +use super::language::mi_check; +use super::*; + +pub(super) fn draw(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_doc::freeform::InkTool; + use wordcraft_engine::cmd::draw::{DrawMode, lasso_selection}; + let mode = app.session.view.draw.mode; + // The pen the colour and thickness menus change: the one in use, else the pen. + let tool = mode.pen().unwrap_or(InkTool::Pen); + let set = app.session.view.draw.settings(tool); + // The gallery pen in use, if any: changing the colour or thickness changes it too. + let custom = + app.session.view.draw.pens.iter().position(|p| mode == DrawMode::Pen(p.kind) && p.color == set.color && (p.width - set.width).abs() < 0.01); + let lasso_on = lasso_selection(&app.session).is_some(); + group(ui, "Drawing Tools", None, app, |ui, app| { + big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select); + big_toggle(ui, app, "lasso", "Lasso", "draw.lasso", mode == DrawMode::Lasso); + big_toggle(ui, app, "eraser", "Eraser", "draw.eraser", mode == DrawMode::Eraser); + big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen) && custom.is_none()); + big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil) && custom.is_none()); + big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter) && custom.is_none()); + // Pens added with Add Pen; right-click one to remove it. + let pens = app.session.view.draw.pens.clone(); + for (i, pen) in pens.iter().enumerate() { + gallery_pen(ui, app, i, *pen, custom == Some(i)); + } + menu_button(ui, app, "addPen", Some("Add\nPen"), "Add Pen", true, "", |ui, app| { + for (label, kind) in [("Pen", InkTool::Pen), ("Pencil", InkTool::Pencil), ("Highlighter", InkTool::Highlighter)] { + // A new pen starts as that kind's current colour and thickness. + let cur = app.session.view.draw.settings(kind); + mi(ui, app, label, "draw.addPen", json!({"kind": kind.name(), "color": cur.color.hex(), "width": cur.width})); + } + }); + stack(ui, |ui| { + let sw = Some(crate::theme::c32(set.color)); + split(ui, app, "fontcolor", "Color", pen_command(tool), json!({}), false, sw, |ui, app| { + let theme = app.session.doc.settings.theme_colors.clone(); + // With ink lasso-selected the colour goes to the selection, else to the pen. + let cmd = if lasso_on { "draw.lassoColor" } else { pen_command(tool) }; + crate::colors::menu(ui, app, &theme, Target::cmd(cmd)); + if !lasso_on { + update_gallery_pen(app, custom, tool); + } + }); + menu_button(ui, app, "thickness", None, "Thickness", false, "draw.pen", |ui, app| { + let widths: &[f32] = if tool == InkTool::Highlighter { &[4.0, 8.0, 12.0, 18.0, 24.0] } else { &[0.5, 1.0, 1.5, 2.5, 3.5, 5.0] }; + for w in widths { + let label = crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]); + mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_command(tool), json!({"width": w})); + } + update_gallery_pen(app, custom, tool); + }); + }); + }); + group(ui, "Convert", None, app, |ui, app| { + let on = app.session.view.draw.ink_to_shape; + big_toggle(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", on); + big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false); + }); + group(ui, "Insert", None, app, |ui, app| { + big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false); + }); + group(ui, "Replay", None, app, |ui, app| { + let on = app.session.view.draw.replay; + big_toggle(ui, app, "replay", "Ink\nReplay", "draw.replay", on); + }); +} + +/// The command that picks up `tool`. +fn pen_command(tool: wordcraft_doc::freeform::InkTool) -> &'static str { + use wordcraft_doc::freeform::InkTool; + match tool { + InkTool::Pen => "draw.pen", + InkTool::Pencil => "draw.pencil", + InkTool::Highlighter => "draw.highlighter", + } +} + +/// After the colour or thickness menu changed `tool`'s settings, the gallery pen that was in use +/// (`custom`) takes them too. +fn update_gallery_pen(app: &mut WordApp, custom: Option, tool: wordcraft_doc::freeform::InkTool) { + let set = app.session.view.draw.settings(tool); + if let Some(p) = custom.and_then(|i| app.session.view.draw.pens.get_mut(i)) + && p.kind == tool + { + p.color = set.color; + p.width = set.width; + } +} + +/// A pen of the gallery: its kind's icon over a bar of its colour and thickness; a click picks it +/// up, right-click offers Remove Pen. +fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::cmd::draw::CustomPen, active: bool) { + use wordcraft_doc::freeform::InkTool; + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(34.0, CONTENT_H), Sense::click()); + crate::widgets::bg(ui, r, &resp, active, &t); + let icon = match pen.kind { + InkTool::Pen => "pen", + InkTool::Pencil => "pencil", + InkTool::Highlighter => "highlight", + }; + let color = crate::theme::c32(pen.color); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(28.0, 28.0)), icon, t.icon, color); + let bar = (pen.width * 0.6).clamp(1.5, 8.0); + let y = r.min.y + 46.0; + ui.painter().line_segment([pos2(r.min.x + 6.0, y), pos2(r.max.x - 6.0, y)], Stroke::new(bar, color)); + let kind = tl!(match pen.kind { + InkTool::Pen => "Pen", + InkTool::Pencil => "Pencil", + InkTool::Highlighter => "Highlighter", + }); + let size = crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())]); + let resp = resp.on_hover_text(format!("{kind}: {size}, #{}", pen.color.hex())); + if resp.clicked() { + let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width})); + } + resp.context_menu(|ui| { + if ui.button(tl!("Remove Pen")).clicked() { + let _ = app.run("draw.removePen", json!({"index": i})); + ui.close(); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs new file mode 100644 index 000000000..d2d3c233d --- /dev/null +++ b/crates/ui-egui/src/ribbon/home.rs @@ -0,0 +1,285 @@ +use super::common::{mi, mi_text, stack}; +use super::underline::underline_menu; +use super::*; + +/// The Format Painter button. A click arms it for one use (blue border only); a double-click keeps +/// it on across several uses (full checked look), like Word. Esc or another click turns it off. +fn format_painter_button(ui: &mut Ui, app: &mut WordApp) { + let sticky = matches!(app.session.painter, Some((_, _, true))); + let armed = app.session.painter.is_some(); + let resp = small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), sticky); + if resp.double_clicked() { + painter_double_click(app); + } else if armed && !sticky { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect, egui::CornerRadius::same(4), Stroke::new(1.0, t.accent.linear_multiply(0.5)), egui::StrokeKind::Inside); + } +} + +/// A double-click fires `clicked()` twice, so by now the painter has been armed and disarmed again. +/// Arm it once more, this time sticky. +pub(super) fn painter_double_click(app: &mut WordApp) { + if app.session.painter.is_some() { + let _ = app.run("edit.formatPainter", json!({})); + } + let _ = app.run("edit.formatPainter", json!({"sticky": true})); +} + +pub(super) fn home(app: &mut WordApp, ui: &mut Ui) { + let st = app.session.run("format.state", &json!({})).unwrap_or_default(); + let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| { + menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, "edit.paste", |ui, app| { + mi(ui, app, "Paste", "edit.paste", json!({})); + mi(ui, app, "Keep Text Only", "edit.pasteText", json!({})); + mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({})); + mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({})); + }); + stack(ui, |ui| { + small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false); + small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false); + format_painter_button(ui, app); + }); + }); + group(ui, "Font", Some("format.fontDialog"), app, |ui, app| { + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let font = app.previews.font_shown(&font); + let fams = app.previews.families(); + let recent: Vec = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect(); + let prev = app.previews.font_preview_fn(); + if let Some(f) = crate::widgets::font_combo_with_recent_hint(ui, "font", 150.0, &font, &fams, &recent, Some(&*prev), tl!("Font")) { + let _ = if f == crate::widgets::ADD_FONT { + app.run("font.add", json!({})) + } else if f == crate::widgets::LOAD_SYSTEM_FONTS { + app.run("font.loadSystem", json!({})) + } else { + let f = app.previews.font_stored(&f); + app.run("format.font", json!({"name": f})) + }; + } + let size = st + .get("size") + .and_then(Value::as_f64) + .map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") }) + .unwrap_or_default(); + let sizes: Vec = + wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect(); + if let Some(v) = crate::widgets::combo_hint(ui, "size", 52.0, &size, &sizes, None, tl!("Font Size")) + && let Ok(x) = v.trim().parse::() + { + let _ = app.run("format.size", json!({"size": x})); + } + ui.add_space(3.0); + small(ui, app, "grow", None, "Increase Font Size", "format.growFont", json!({}), false); + small(ui, app, "shrink", None, "Decrease Font Size", "format.shrinkFont", json!({}), false); + menu_button(ui, app, "case", None, "Change Case", false, "format.changeCase", |ui, app| { + mi(ui, app, "Sentence case.", "format.changeCase", json!({"mode": "sentence"})); + mi(ui, app, "lowercase", "format.changeCase", json!({"mode": "lower"})); + mi(ui, app, "UPPERCASE", "format.changeCase", json!({"mode": "upper"})); + mi(ui, app, "Capitalize Each Word", "format.changeCase", json!({"mode": "title"})); + mi(ui, app, "tOGGLE cASE", "format.changeCase", json!({"mode": "toggle"})); + }); + small(ui, app, "clear", None, "Clear All Formatting", "format.clear", json!({}), false); + small(ui, app, "phonetic", None, "Phonetic Guide", "format.phonetic", json!({}), false); + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold")); + small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic")); + split(ui, app, "underline", "Underline", "format.underline", json!({}), flag("underline"), None, underline_menu); + small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike")); + small(ui, app, "subscript", None, "Subscript", "format.subscript", json!({}), flag("subscript")); + small(ui, app, "superscript", None, "Superscript", "format.superscript", json!({}), flag("superscript")); + small(ui, app, "charborder", None, "Character Border", "format.border", json!({}), flag("border")); + small(ui, app, "enclose", None, "Enclose Characters", "format.enclose", json!({}), false); + ui.add_space(4.0); + menu_button(ui, app, "effects", None, "Text Effects and Typography", false, "", |ui, app| { + let theme = app.session.doc.settings.theme_colors.clone(); + crate::wordart_menus::style_gallery(ui, app, "format.textEffects"); + ui.separator(); + ui.menu_button(tl!("Text Fill"), |ui| crate::wordart_menus::fill_menu(ui, app, &theme)); + let outline = crate::wordart_menus::current_outline(app); + ui.menu_button(tl!("Text Outline"), |ui| crate::wordart_menus::outline_menu(ui, app, &theme, outline)); + crate::wordart_menus::effects_menu(ui, app, &theme, false); + ui.separator(); + mi(ui, app, "Outline", "format.outline", json!({})); + mi(ui, app, "Shadow", "format.shadow", json!({})); + mi(ui, app, "Small Caps", "format.smallCaps", json!({})); + mi(ui, app, "All Caps", "format.allCaps", json!({})); + mi(ui, app, "Double Strikethrough", "format.doubleStrikethrough", json!({})); + }); + let hl = app.canvas.last_highlight.clone(); + let (icon, sw, color) = crate::mini_toolbar::highlight_button_appearance(&hl); + split( + ui, + app, + icon, + "Text Highlight Color", + "format.highlight", + json!({"color": color}), + false, + sw, + crate::mini_toolbar::highlight_menu, + ); + let fc = app.canvas.last_font_color.clone(); + let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32); + split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| { + mi(ui, app, "Automatic", "format.color", json!({"color": "auto"})); + let theme = app.session.doc.settings.theme_colors.clone(); + crate::colors::menu(ui, app, &theme, Target::cmd("format.color").remember(Remember::FontColor)); + }); + }); + }); + }); + group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { + let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); + // The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start). + let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default(); + let rtl = rp.as_ref().is_some_and(|r| r.bidi); + let lists = wordcraft_engine::cmd::para::list_buttons(&app.session.doc, rp.as_ref().and_then(|r| r.numbering)); + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), lists.bullets, None, |ui, app| { + ui.label(egui::RichText::new(tl!("Bullet Library")).small().weak()); + ui.horizontal(|ui| { + for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] { + // The interface fonts have no ➢, so its tile draws the arrowhead. + let label = if c == "➢" { " " } else { c }; + let resp = ui.button(egui::RichText::new(label).size(16.0)); + if c == "➢" { + let m = resp.rect.center(); + let pts = [(-4.0, -5.0), (5.0, 0.0), (-4.0, 5.0), (-1.5, 0.0)].map(|(x, y)| m + vec2(x, y)); + ui.painter().add(egui::Shape::convex_polygon(vec![pts[0], pts[1], pts[3]], Tokens::get(ui.ctx()).text, Stroke::NONE)); + ui.painter().add(egui::Shape::convex_polygon(vec![pts[3], pts[1], pts[2]], Tokens::get(ui.ctx()).text, Stroke::NONE)); + } + if resp.clicked() { + let _ = app.run("para.bullets", json!({"kind": c})); + ui.close(); + } + } + }); + mi(ui, app, "None", "para.bullets", json!({"off": true})); + }); + split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), lists.numbering, None, |ui, app| { + for (l, k) in [ + ("1. 2. 3.", "numbered"), + ("1) 2) 3)", "numberedParen"), + ("I. II. III.", "outline"), + ("A. B. C.", "upperLetter"), + ("a) b) c)", "lowerLetter"), + ("i. ii. iii.", "lowerRoman"), + ] { + mi(ui, app, l, "para.numbering", json!({"kind": k})); + } + ui.separator(); + mi(ui, app, "Restart at 1", "para.restartNumbering", json!({})); + mi(ui, app, "None", "para.numbering", json!({"off": true})); + }); + split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), lists.multilevel, None, |ui, app| { + mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"})); + mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"})); + ui.separator(); + // Lists linked to the heading styles: the headings carry the numbers. Each item + // reads as its first three levels, number then (translated) style name. + for (numbers, k) in [ + (["1", "1.1", "1.1.1"], "headings"), + (["Article I.", "Section 1.01", "(a)"], "article"), + (["I.", "A.", "1."], "outlineHeadings"), + (["Chapter 1", "", ""], "chapter"), + ] { + let text = numbers + .iter() + .zip(["Heading 1", "Heading 2", "Heading 3"]) + .map(|(n, h)| if n.is_empty() { tl!(h).to_string() } else { format!("{} {}", tl!(n), tl!(h)) }) + .collect::>() + .join(" / "); + mi_text(ui, app, &text, "list.multilevelPreset", json!({"preset": k})); + } + ui.separator(); + mi(ui, app, "Define New Multilevel List…", "list.define", json!({})); + ui.separator(); + for lv in 0..5u64 { + mi(ui, app, &format!("Change to Level {}", lv + 1), "para.listLevel", json!({"level": lv})); + } + }); + ui.add_space(4.0); + small(ui, app, "outdent", None, "Decrease Indent", "para.outdent", json!({}), false); + small(ui, app, "indent", None, "Increase Indent", "para.indent", json!({}), false); + ui.add_space(4.0); + small(ui, app, "sort", None, "Sort", "para.sort", json!({}), false); + let marks = app.session.view.marks; + small(ui, app, "pilcrow", None, "Show/Hide ¶", "view.marks", json!({}), marks); + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + use wordcraft_doc::Align as A; + small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left); + small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center); + small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right); + small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify); + ui.add_space(4.0); + small(ui, app, "textLtr", None, "Left-to-Right Text Direction", "para.ltr", json!({}), !rtl); + small(ui, app, "textRtl", None, "Right-to-Left Text Direction", "para.rtl", json!({}), rtl); + ui.add_space(4.0); + menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, "para.lineSpacing", |ui, app| { + for v in [1.0, 1.15, 1.5, 2.0, 2.5, 3.0] { + mi(ui, app, &format!("{v}"), "para.lineSpacing", json!({"value": v})); + } + ui.separator(); + mi(ui, app, "Add Space Before Paragraph", "para.addSpaceBefore", json!({})); + mi(ui, app, "Remove Space After Paragraph", "para.removeSpaceAfter", json!({})); + mi(ui, app, "Line Spacing Options…", "para.dialog", json!({})); + }); + split(ui, app, "shading", "Shading", "para.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { + mi(ui, app, "No Color", "para.shading", json!({"color": null})); + let theme = app.session.doc.settings.theme_colors.clone(); + crate::colors::menu(ui, app, &theme, Target::cmd("para.shading").remember(Remember::Shading)); + }); + split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| { + for (l, k) in [ + ("Bottom Border", "bottom"), + ("Top Border", "top"), + ("Left Border", "left"), + ("Right Border", "right"), + ("No Border", "none"), + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Inside Borders", "inside"), + ] { + mi(ui, app, l, "para.borders", json!({"kind": k})); + } + ui.separator(); + mi(ui, app, "Horizontal Line", "insert.horizontalLine", json!({})); + mi(ui, app, "Borders and Shading…", "para.borders", json!({})); + }); + }); + }); + }); + group(ui, "Styles", Some("styles.pane"), app, |ui, app| { + crate::previews::style_gallery(app, ui, &st); + }); + group(ui, "Editing", None, app, |ui, app| { + stack(ui, |ui| { + menu_button(ui, app, "find", Some("Find"), "Find", false, "edit.find", |ui, app| { + // As in Word: Find searches in the Navigation pane; Advanced Find is the dialog (#712). + mi(ui, app, "Find", "edit.find", json!({})); + mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({})); + mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"})); + }); + small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false); + menu_button(ui, app, "select", Some("Select"), "Select", false, "select.all", |ui, app| { + mi(ui, app, "Select All", "select.all", json!({})); + mi(ui, app, "Select Paragraph", "select.paragraph", json!({})); + mi(ui, app, "Select Sentence", "select.sentence", json!({})); + }); + }); + }); + group(ui, "Voice", None, app, |ui, app| { + big(ui, app, "dictate", "Dictate", "tools.dictate", json!({}), false); + }); + group(ui, "Editor", None, app, |ui, app| { + big(ui, app, "editor", "Editor", "review.spelling", json!({}), false); + }); +} diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs new file mode 100644 index 000000000..be84c1d67 --- /dev/null +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -0,0 +1,175 @@ +use super::common::{mi, mi_icon, stack}; +use super::language::mi_check; +use super::*; + +pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Pages", None, app, |ui, app| { + stack(ui, |ui| { + menu_button(ui, app, "coverPage", Some("Cover Page"), "Cover Page", false, "insert.coverPage", |ui, app| { + mi(ui, app, "Studio cover", "insert.coverPage", json!({})); + }); + small(ui, app, "blankPage", Some("Blank Page"), "Blank Page", "insert.blankPage", json!({}), false); + small(ui, app, "pageBreak", Some("Page Break"), "Page Break", "insert.pageBreak", json!({}), false); + }); + }); + group(ui, "Tables", None, app, |ui, app| { + menu_button(ui, app, "table", Some("Table"), "Add a Table", true, "insert.table", |ui, app| { + crate::dialogs::table_grid_picker(ui, app); + ui.separator(); + mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"})); + let drawing = app.canvas.table_tool == Some(crate::table_pen::TableTool::Draw); + mi_check(ui, app, "Draw Table", drawing, "table.draw", json!({})); + mi(ui, app, "Convert Text to Table…", "table.fromText", json!({})); + ui.menu_button(tl!("Quick Tables"), |ui| { + mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"})); + mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"})); + mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"})); + }); + }); + }); + group(ui, "Illustrations", None, app, |ui, app| { + big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false); + menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, "insert.shape", |ui, app| { + for (l, k) in [ + ("Rectangle", "rectangle"), + ("Rounded Rectangle", "roundedRectangle"), + ("Oval", "ellipse"), + ("Triangle", "triangle"), + ("Diamond", "diamond"), + ("Line", "line"), + ("Arrow", "arrow"), + ("Star", "star"), + ("Heart", "heart"), + ] { + mi_icon(ui, app, &format!("shape.{k}"), l, "insert.shape", json!({"kind": k})); + } + ui.separator(); + mi_icon(ui, app, "canvas", "New Drawing Canvas", "insert.canvas", json!({})); + // Connectors: glue their ends to shapes by dragging them (shape.connect). + for (l, k, arrow) in [ + ("Straight Connector", "straightConnector", "none"), + ("Straight Arrow Connector", "straightConnector", "end"), + ("Elbow Connector", "elbowConnector", "none"), + ("Elbow Arrow Connector", "elbowConnector", "end"), + ("Curved Connector", "curvedConnector", "none"), + ("Curved Arrow Connector", "curvedConnector", "end"), + ] { + let icon = if arrow == "end" { k.replace("Connector", "ArrowConnector") } else { k.to_string() }; + mi_icon(ui, app, &format!("shape.{icon}"), l, "insert.shape", json!({"kind": k, "arrow": arrow})); + } + }); + stack(ui, |ui| { + small(ui, app, "icons", Some("Icons"), "Icons", "insert.icon", json!({}), false); + small(ui, app, "smartArt", Some("SmartArt"), "SmartArt", "insert.smartArt", json!({}), false); + small(ui, app, "chart", Some("Chart"), "Chart", "insert.chart", json!({}), false); + }); + }); + group(ui, "Links", None, app, |ui, app| { + stack(ui, |ui| { + small(ui, app, "link", Some("Link"), "Link", "ui.dialog", json!({"name": "link"}), false); + small(ui, app, "bookmark", Some("Bookmark"), "Bookmark", "ui.dialog", json!({"name": "bookmark"}), false); + small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false); + }); + }); + group(ui, "Comments", None, app, |ui, app| { + big(ui, app, "newComment", "Comment", "review.newComment", json!({}), false); + }); + group(ui, "Header & Footer", None, app, |ui, app| { + menu_button(ui, app, "header", Some("Header"), "Header", true, "insert.header", |ui, app| { + mi(ui, app, "Blank", "insert.header", json!({"preset": "blank"})); + mi(ui, app, "Blank (Three Columns)", "insert.header", json!({"preset": "blankThree"})); + mi(ui, app, "Document Title", "insert.header", json!({"preset": "title"})); + ui.separator(); + mi(ui, app, "Edit Header", "insert.editHeader", json!({})); + mi(ui, app, "Remove Header", "insert.removeHeader", json!({})); + }); + menu_button(ui, app, "footer", Some("Footer"), "Footer", true, "insert.footer", |ui, app| { + mi(ui, app, "Blank", "insert.footer", json!({"preset": "blank"})); + mi(ui, app, "Blank (Three Columns)", "insert.footer", json!({"preset": "blankThree"})); + mi(ui, app, "Page Number", "insert.footer", json!({"preset": "pageNumber"})); + ui.separator(); + mi(ui, app, "Edit Footer", "insert.editFooter", json!({})); + mi(ui, app, "Remove Footer", "insert.removeFooter", json!({})); + }); + menu_button(ui, app, "pageNumber", Some("Page\nNumber"), "Page Number", true, "insert.pageNumber", |ui, app| { + mi(ui, app, "Top of Page", "insert.pageNumber", json!({"position": "top", "align": "right"})); + mi(ui, app, "Bottom of Page", "insert.pageNumber", json!({"position": "bottom", "align": "center"})); + mi(ui, app, "Page X of Y", "insert.pageNumber", json!({"position": "bottom", "format": "x of y"})); + mi(ui, app, "Current Position", "insert.pageNumber", json!({"position": "current"})); + mi(ui, app, "Format Page Numbers…", "ui.dialog", json!({"name": "pageNumbers"})); + }); + }); + group(ui, "Text", None, app, |ui, app| { + big(ui, app, "textBox", "Text\nBox", "insert.textBox", json!({}), false); + stack(ui, |ui| { + menu_button(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", false, "insert.quickParts", |ui, app| { + // This session's building blocks, then the document's own Quick Parts. + let mut parts: Vec = app.session.building_blocks.keys().cloned().collect(); + let mut seen: std::collections::HashSet = parts.iter().cloned().collect(); + let glossary = app.session.doc.glossary.iter().filter(|e| e.gallery == wordcraft_doc::QUICK_PARTS_GALLERY); + parts.extend(glossary.filter(|e| seen.insert(e.name.clone())).map(|e| e.name.clone())); + for name in parts { + if ui.button(&name).clicked() { + let _ = app.run("insert.quickParts", json!({"insert": name})); + ui.close(); + } + } + mi(ui, app, "Field…", "ui.dialog", json!({"name": "field"})); + }); + menu_button(ui, app, "wordArt", Some("WordArt"), "WordArt", false, "", |ui, app| { + crate::wordart_menus::style_gallery(ui, app, "insert.wordArt") + }); + menu_button(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", false, "insert.dropCap", drop_cap_items); + }); + stack(ui, |ui| { + small(ui, app, "signature", None, "Signature Line", "insert.signatureLine", json!({}), false); + small(ui, app, "dateTime", None, "Date & Time", "insert.dateTime", json!({}), false); + small(ui, app, "object", None, "Object", "insert.object", json!({}), false); + }); + }); + group(ui, "Symbols", None, app, |ui, app| { + crate::equation_tab::insert_button(ui, app); + menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, "insert.symbol", |ui, app| { + // Recently used symbols first (their font on hover), then common ones. + let mut items: Vec<(String, String)> = app.ui.recent_symbols.iter().map(|r| (r.ch.clone(), r.font.clone())).collect(); + for c in [ + "©", "®", "™", "§", "¶", "€", "£", "¥", "°", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Ω", "∑", "√", "→", "←", "✓", + "★", "♥", "—", "…", + ] { + if !items.iter().any(|(ch, f)| ch == c && f.is_empty()) { + items.push((c.to_string(), String::new())); + } + } + items.truncate(30); + egui::Grid::new("syms").show(ui, |ui| { + for (i, (c, font)) in items.iter().enumerate() { + let b = ui.button(egui::RichText::new(c).size(16.0)); + let b = if font.is_empty() { b } else { b.on_hover_text(font) }; + if b.clicked() { + let params = if font.is_empty() { json!({"char": c}) } else { json!({"char": c, "font": font}) }; + if app.run("insert.symbol", params).is_ok() { + crate::dialogs_insert::remember_symbol(app, c, font); + } + ui.close(); + } + if i % 6 == 5 { + ui.end_row(); + } + } + }); + ui.separator(); + mi(ui, app, "More Symbols…", "ui.dialog", json!({"name": "symbol"})); + }); + }); +} + +/// Insert › Drop Cap's menu (#2601): None, Dropped and In margin (the current one checked), +/// then the Drop Cap dialog. +fn drop_cap_items(ui: &mut Ui, app: &mut WordApp) { + let current = wordcraft_engine::cmd::insert::drop_cap_state(&app.session).position; + for (label, v) in [("None", "none"), ("Dropped", "dropped"), ("In margin", "margin")] { + mi_check(ui, app, label, current == v, "insert.dropCap", json!({"position": v})); + } + ui.separator(); + mi(ui, app, "Drop Cap Options…", "ui.dialog", json!({"name": "dropCap"})); +} diff --git a/crates/ui-egui/src/ribbon/language.rs b/crates/ui-egui/src/ribbon/language.rs new file mode 100644 index 000000000..56b5f182c --- /dev/null +++ b/crates/ui-egui/src/ribbon/language.rs @@ -0,0 +1,249 @@ +use super::*; + +/// The proofing languages the Language menu offers: tag and English name (translated when drawn). +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [ + ("en-US", "English (United States)"), + ("en-GB", "English (United Kingdom)"), + ("en-AU", "English (Australia)"), + ("en-CA", "English (Canada)"), + ("pl-PL", "Polish"), + ("pt-BR", "Portuguese (Brazil)"), + ("it-IT", "Italian"), + ("hr-HR", "Croatian"), + ("tr-TR", "Turkish"), + ("ru-RU", "Russian"), + ("cs-CZ", "Czech"), + ("id-ID", "Indonesian"), + ("es-ES", "Spanish (Spain)"), + ("es-MX", "Spanish (Mexico)"), + ("es-419", "Spanish (Latin America)"), + ("es-UY", "Spanish (Uruguay)"), +]; + +/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's +/// default, US English): a listed language by its name; another region of one by the language +/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag. +pub(crate) fn language_name(tag: Option<&str>) -> String { + let tag = tag.unwrap_or("en-US"); + let named = + |t: &str| PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n); + // Any language the Language dialog offers (French, German…) by its name, not its tag (#860). + if let Some(name) = named(tag) { + return name.to_string(); + } + let mut parts = tag.split(['-', '_']); + let primary = parts.next().unwrap_or(""); + // The region: the first two-letter or three-digit subtag after the language (and script). + let region = + parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))); + match region { + // A region the list doesn't have: the language's name without its own region or variant. + Some(r) => { + let language = crate::dialogs_layout::LANGUAGES + .iter() + .find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary))) + .map(|(_, n)| n.split(" (").next().unwrap_or(n)); + match language { + Some(language) => format!("{language} ({})", region_name(r)), + None => tag.to_string(), + } + } + // A bare language subtag ("pt", "pl"): the variant WordCraft proofs. + None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { + wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), + wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), + wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), + wordcraft_engine::proof::ProofLang::It => "Italian".into(), + wordcraft_engine::proof::ProofLang::Tr => "Turkish".into(), + wordcraft_engine::proof::ProofLang::Ru => "Russian".into(), + wordcraft_engine::proof::ProofLang::Cs => "Czech".into(), + wordcraft_engine::proof::ProofLang::Id => "Indonesian".into(), + wordcraft_engine::proof::ProofLang::Es => "Spanish (Spain)".into(), + wordcraft_engine::proof::ProofLang::De => "German (Germany)".into(), + wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), + _ => tag.to_string(), + }, + } +} + +/// The English name of a region subtag the language list has no entry for (else the subtag). +fn region_name(code: &str) -> String { + const REGIONS: [(&str, &str); 62] = [ + ("AE", "U.A.E."), + ("AO", "Angola"), + ("AR", "Argentina"), + ("AT", "Austria"), + ("AU", "Australia"), + ("BA", "Bosnia and Herzegovina"), + ("BE", "Belgium"), + ("BO", "Bolivia"), + ("BR", "Brazil"), + ("BY", "Belarus"), + ("CA", "Canada"), + ("CH", "Switzerland"), + ("CL", "Chile"), + ("CN", "China"), + ("CO", "Colombia"), + ("CR", "Costa Rica"), + ("CV", "Cabo Verde"), + ("CY", "Cyprus"), + ("DE", "Germany"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("ES", "Spain"), + ("FR", "France"), + ("GB", "United Kingdom"), + ("GT", "Guatemala"), + ("HK", "Hong Kong SAR"), + ("IE", "Ireland"), + ("IN", "India"), + ("IQ", "Iraq"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JO", "Jordan"), + ("KW", "Kuwait"), + ("KZ", "Kazakhstan"), + ("LB", "Lebanon"), + ("LI", "Liechtenstein"), + ("LU", "Luxembourg"), + ("MA", "Morocco"), + ("MC", "Monaco"), + ("ME", "Montenegro"), + ("MO", "Macao SAR"), + ("MX", "Mexico"), + ("MY", "Malaysia"), + ("MZ", "Mozambique"), + ("NL", "Netherlands"), + ("NZ", "New Zealand"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PR", "Puerto Rico"), + ("PT", "Portugal"), + ("PY", "Paraguay"), + ("QA", "Qatar"), + ("RU", "Russia"), + ("SG", "Singapore"), + ("SY", "Syria"), + ("TW", "Taiwan"), + ("UA", "Ukraine"), + ("US", "United States"), + ("UY", "Uruguay"), + ("VE", "Venezuela"), + ("ZA", "South Africa"), + ]; + REGIONS.iter().find(|(c, _)| c.eq_ignore_ascii_case(code)).map_or_else(|| code.to_ascii_uppercase(), |(_, n)| (*n).to_string()) +} + +/// The language at the caret (resolved through the styles), translated, for the status bar. +pub(crate) fn caret_language(app: &WordApp) -> String { + tl!(&language_name(caret_lang_tag(app).as_deref())).to_string() +} + +/// The formatting at the caret, resolved through the styles and the document default. +fn caret_char(app: &WordApp) -> wordcraft_doc::resolve::ResolvedChar { + let s = &app.session; + let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()); + s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()) +} + +/// The language tag at the caret, resolved through the styles and the document default. +fn caret_lang_tag(app: &WordApp) -> Option { + caret_char(app).lang +} + +/// Is the text at the caret itself marked Do not check spelling or grammar? (`format.state` +/// looks at the whole word around a caret, so a partly marked word would read as checked.) +pub(crate) fn caret_no_proof(app: &WordApp) -> bool { + caret_char(app).no_proof +} + +/// Does WordCraft check spelling and grammar in the caret's language at all (Bulgarian: no)? +pub(crate) fn caret_language_proofed(app: &WordApp) -> bool { + wordcraft_engine::proof::has_dictionary(caret_lang_tag(app).as_deref()) +} + +/// The Hunspell dictionary the caret's language needs but doesn't have (`pl_PL`), if any (#1167). +pub(crate) fn caret_missing_dictionary(app: &WordApp) -> Option<&'static str> { + wordcraft_engine::proof::missing_dictionary(caret_lang_tag(app).as_deref()) +} + +/// The dictionary file and reason when the caret's language has a dictionary installed that +/// couldn't be read, so "no dictionary" would send the user to reinstall it for nothing (#2830). +pub(crate) fn caret_unreadable_dictionary(app: &WordApp) -> Option<(std::path::PathBuf, String)> { + wordcraft_engine::proof::unreadable_dictionary(caret_lang_tag(app).as_deref()).map(|(_, file, reason)| (file, reason)) +} + +/// Review › Language (and the status bar's language): set the selection's proofing language, or +/// leave it unchecked. The current choice has a check mark; a language outside the short list +/// (French, German…) shows checked above it. Set Proofing Language… opens the Language dialog +/// with every language and Set As Default, and Language Preferences… opens Options › Language +/// (#860: the short list alone left no way to pick French). +pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { + let st = app.session.run("format.state", &json!({})).unwrap_or_default(); + // The selection's own language; else (nothing set on the text) the one it gets from its + // style or the document default, as the status bar shows. + let current = match st.get("lang") { + Some(Value::String(t)) => t.clone(), + Some(Value::Null) if app.session.sel.is_collapsed() => caret_lang_tag(app).unwrap_or_else(|| "en-US".into()), + _ => String::new(), + }; + let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); + let listed = |tag: &str| { + current.eq_ignore_ascii_case(tag) + || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")) + || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")) + || (tag == "it-IT" && current.eq_ignore_ascii_case("it")) + || (tag == "hr-HR" && current.eq_ignore_ascii_case("hr")) + || (tag == "tr-TR" && current.eq_ignore_ascii_case("tr")) + || (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Cs) + || (tag == "id-ID" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Id) + || (tag == "es-ES" && current.eq_ignore_ascii_case("es")) + || (tag == "en-US" && current.eq_ignore_ascii_case("en")) + }; + let checks = language_checks(¤t, listed); + if let Some(name) = checks.extra { + mi_check(ui, app, &name, true, "review.language", json!({"lang": current})); + } + for ((tag, name), on) in PROOFING_LANGUAGES.iter().zip(checks.listed) { + mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); + } + ui.separator(); + mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof})); + ui.separator(); + mi_check(ui, app, "Set Proofing Language…", false, "review.language", json!({})); + mi_check(ui, app, "Language Preferences…", false, "file.options", json!({"pane": "language"})); +} + +/// The Language menu's check marks for the `current` tag: which short-list entries are checked +/// (`listed` matches a tag), and the name of the current language when it is none of them, +/// shown checked above. A language named like a short-list entry checks that entry, so no name +/// shows twice. +pub(super) struct LanguageChecks { + pub(super) extra: Option, + pub(super) listed: [bool; PROOFING_LANGUAGES.len()], +} + +pub(super) fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChecks { + let name = (!current.is_empty()).then(|| language_name(Some(current))); + let on = PROOFING_LANGUAGES.map(|(t, n)| listed(t) || name.as_deref() == Some(n)); + LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on } +} + +/// A menu item with a check mark when `checked` (the current choice of several). +pub(super) fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) { + // A vector mark needs no font glyph; the empty atom keeps labels aligned. + let mark = egui::Atom::paint(vec2(14.0, 14.0), move |ui, args| { + if checked { + let r = args.rect; + let points = vec![r.min + r.size() * vec2(0.15, 0.5), r.min + r.size() * vec2(0.4, 0.75), r.min + r.size() * vec2(0.85, 0.2)]; + ui.painter().add(egui::Shape::line(points, egui::Stroke::new(1.5, args.fallback_text_color))); + } + }); + let enabled = crate::widgets::enabled(app, id); + let resp = ui.add_enabled(enabled, egui::Button::new((mark, tl!(label))).selected(checked).min_size(vec2(200.0, 0.0))); + if resp.clicked() { + crate::widgets::run_from_ribbon(app, id, params); + ui.close(); + } +} diff --git a/crates/ui-egui/src/ribbon/layout.rs b/crates/ui-egui/src/ribbon/layout.rs new file mode 100644 index 000000000..1d4484f67 --- /dev/null +++ b/crates/ui-egui/src/ribbon/layout.rs @@ -0,0 +1,160 @@ +use super::arrange::{align_menu, group_menu, rotate_menu, wrap_menu}; +use super::common::{mi, size_menu, stack}; +use super::language::mi_check; +use super::*; + +// Layout › Margins, Orientation, Size and Columns: the caret section's setting has a check +// mark, as Text Direction's does (#1206). +pub(super) fn margins_items(ui: &mut Ui, app: &mut WordApp) { + let current = wordcraft_engine::cmd::page::margins_preset_at(&app.session); + // The two uniform presets name their margin in the measurement unit (`2,54 cm`, #838). + for (l, k, uniform) in [ + ("Normal {size} all", "normal", Some(72.0)), + ("Narrow {size} all", "narrow", Some(36.0)), + ("Moderate", "moderate", None), + ("Wide", "wide", None), + ("Mirrored", "mirrored", None), + ("Office 2003 Default", "office2003", None), + ] { + let label = match uniform { + Some(pt) => crate::i18n::fmt(tl!(l), &[("size", &crate::options::length(pt))]), + None => l.to_string(), + }; + // Mirrored margins come from the document as well as from the preset (#1181). + let on = current == Some(k) || (k == "mirrored" && app.session.doc.settings.mirror_margins); + mi_check(ui, app, &label, on, "layout.margins", json!({"preset": k})); + } + mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"})); +} + +pub(super) fn orientation_items(ui: &mut Ui, app: &mut WordApp) { + let land = wordcraft_engine::cmd::page::sect(&app.session).landscape; + mi_check(ui, app, "Portrait", !land, "layout.orientation", json!({"value": "portrait"})); + mi_check(ui, app, "Landscape", land, "layout.orientation", json!({"value": "landscape"})); +} + +pub(super) fn columns_items(ui: &mut Ui, app: &mut WordApp) { + let current = wordcraft_engine::cmd::page::columns_preset_at(&app.session); + for (l, k, params) in [ + ("One", "one", json!({"count": 1})), + ("Two", "two", json!({"count": 2})), + ("Three", "three", json!({"count": 3})), + ("Left", "left", json!({"preset": "left"})), + ("Right", "right", json!({"preset": "right"})), + ] { + mi_check(ui, app, l, current == Some(k), "layout.columns", params); + } + ui.separator(); + mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"})); +} + +pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| { + // Text Direction works on what the caret is in: table cells, a text box, else the section. + let dir = wordcraft_engine::cmd::page::text_direction_at(&app.session); + menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, "layout.textDirection", |ui, app| { + use wordcraft_doc::props::TextDirection; + for (label, d) in + [("Horizontal", TextDirection::Horizontal), ("Rotate all text 90°", TextDirection::Down), ("Rotate all text 270°", TextDirection::Up)] + { + mi_check(ui, app, label, dir == d, "layout.textDirection", json!({"value": d.ooxml()})); + } + }); + menu_button(ui, app, "margins", Some("Margins"), "Margins", true, "layout.margins", margins_items); + menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, "layout.orientation", orientation_items); + menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", size_menu); + menu_button(ui, app, "columns", Some("Columns"), "Columns", true, "layout.columns", columns_items); + stack(ui, |ui| { + menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, "layout.break", |ui, app| { + ui.label(egui::RichText::new(tl!("Page Breaks")).strong()); + mi(ui, app, "Page", "layout.break", json!({"kind": "page"})); + mi(ui, app, "Column", "layout.break", json!({"kind": "column"})); + mi(ui, app, "Text Wrapping", "layout.break", json!({"kind": "textWrapping"})); + ui.label(egui::RichText::new(tl!("Section Breaks")).strong()); + mi(ui, app, "Next Page", "layout.break", json!({"kind": "nextPage"})); + mi(ui, app, "Continuous", "layout.break", json!({"kind": "continuous"})); + mi(ui, app, "Even Page", "layout.break", json!({"kind": "evenPage"})); + mi(ui, app, "Odd Page", "layout.break", json!({"kind": "oddPage"})); + }); + menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, "layout.lineNumbers", |ui, app| { + for (l, v) in + [("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")] + { + mi(ui, app, l, "layout.lineNumbers", json!({"value": v})); + } + ui.separator(); + mi(ui, app, "Line Numbering Options…", "ui.dialog", json!({"name": "lineNumbers"})); + }); + let hy = app.session.doc.settings.auto_hyphenation; + menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, "layout.hyphenation", |ui, app| { + mi(ui, app, if hy { "✓ Automatic" } else { "Automatic" }, "layout.hyphenation", json!({"value": true})); + mi(ui, app, if hy { "None" } else { "✓ None" }, "layout.hyphenation", json!({"value": false})); + mi(ui, app, "Manual…", "ui.dialog", json!({"name": "manualHyphenation"})); + ui.separator(); + mi(ui, app, "Hyphenation Options…", "ui.dialog", json!({"name": "hyphenation"})); + }); + }); + }); + group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { + let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); + let Some(rp) = rp else { return }; + // #654: header + two rows of 20 px fields fit the content height above the group title + // (1.5 pt between rows; 3 ran 2.5 pt into it); labels and fields sit on shared columns, + // every field the same width. + const FIELD: egui::Vec2 = egui::Vec2::new(64.0, 20.0); + ui.spacing_mut().interact_size.y = FIELD.y; + // The gap before the Spacing column sits inside its cells: `add_space` in a grid moves the + // next cell in release builds but trips a debug assertion. + let spaced = |ui: &mut Ui, text: egui::RichText| { + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label(text); + }); + }; + egui::Grid::new("layout_para").spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y).show(ui, |ui| { + ui.label(egui::RichText::new(tl!("Indent")).small().strong()); + ui.label(""); + spaced(ui, egui::RichText::new(tl!("Spacing")).small().strong()); + ui.end_row(); + // Indents in the File › Options unit. + let u = crate::options::unit(); + let k = u.pt_per_unit(); + let mut l = rp.indent_left / k; + let mut b = rp.space_before; + ui.label(egui::RichText::new(tl!("Left:")).small()); + if ui.add_sized(FIELD, egui::DragValue::new(&mut l).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() { + let _ = app.run("para.indents", json!({"left": l * k})); + } + spaced(ui, egui::RichText::new(tl!("Before:")).small()); + if ui.add_sized(FIELD, egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { + let _ = app.run("para.spacing", json!({"before": b})); + } + ui.end_row(); + let mut r = rp.indent_right / k; + let mut a = rp.space_after; + ui.label(egui::RichText::new(tl!("Right:")).small()); + if ui.add_sized(FIELD, egui::DragValue::new(&mut r).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() { + let _ = app.run("para.indents", json!({"right": r * k})); + } + spaced(ui, egui::RichText::new(tl!("After:")).small()); + if ui.add_sized(FIELD, egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { + let _ = app.run("para.spacing", json!({"after": a})); + } + ui.end_row(); + }); + }); + group(ui, "Arrange", None, app, |ui, app| { + big(ui, app, "position", "Position", "arrange.position", json!({}), false); + wrap_menu(ui, app); + stack(ui, |ui| { + small(ui, app, "bringForward", Some("Bring Forward"), "Bring Forward", "arrange.bringForward", json!({}), false); + small(ui, app, "sendBackward", Some("Send Backward"), "Send Backward", "arrange.sendBackward", json!({}), false); + small(ui, app, "selectionPane", Some("Selection Pane"), "Selection Pane", "arrange.selectionPane", json!({}), false); + }); + stack(ui, |ui| { + align_menu(ui, app, None); + group_menu(ui, app, None); + rotate_menu(ui, app, None); + }); + }); +} diff --git a/crates/ui-egui/src/ribbon/mailings.rs b/crates/ui-egui/src/ribbon/mailings.rs new file mode 100644 index 000000000..e76ed3833 --- /dev/null +++ b/crates/ui-egui/src/ribbon/mailings.rs @@ -0,0 +1,89 @@ +use super::common::{mi, stack}; +use super::language::mi_check; +use super::*; + +pub(super) fn mailings(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Create", None, app, |ui, app| { + big(ui, app, "envelope", "Envelopes", "mailings.envelopes", json!({}), false); + big(ui, app, "labels", "Labels", "mailings.labels", json!({}), false); + }); + group(ui, "Start Mail Merge", None, app, |ui, app| { + // The kinds of merge document the engine makes, the current one checked. + menu_button(ui, app, "mailMerge", Some("Start Mail\nMerge"), "Start Mail Merge", true, "mailings.start", |ui, app| { + let kind = app.session.merge.kind.clone(); + for (label, k) in [ + ("Letters", "letters"), + ("E-mail Messages", "emails"), + ("Envelopes", "envelopes"), + ("Labels", "labels"), + ("Directory", "directory"), + ("Normal Word Document", "normal"), + ] { + let current = kind == k || (k == "normal" && kind.is_empty()); + mi_check(ui, app, label, current, "mailings.start", json!({"kind": k})); + } + }); + // The command needs data, so the button offers the two ways to give it (#240). + menu_button(ui, app, "recipients", Some("Select\nRecipients"), "Select Recipients", true, "mailings.recipients", |ui, app| { + mi(ui, app, "Type a New List…", "ui.dialog", json!({"name": "newRecipientList"})); + mi(ui, app, "Use an Existing List…", "ui.openRecipientList", json!({})); + }); + big(ui, app, "editRecipients", "Edit\nRecipient List", "mailings.editRecipients", json!({}), false); + }); + group(ui, "Write & Insert Fields", None, app, |ui, app| { + big(ui, app, "highlightFields", "Highlight\nMerge Fields", "mailings.highlightFields", json!({}), false); + big(ui, app, "addressBlock", "Address\nBlock", "mailings.addressBlock", json!({}), false); + big(ui, app, "greetingLine", "Greeting\nLine", "mailings.greetingLine", json!({}), false); + big(ui, app, "mergeField", "Insert Merge\nField", "mailings.insertField", json!({}), false); + stack(ui, |ui| { + // The merge rules the engine knows; If and Skip Record If ask for their condition. + menu_button(ui, app, "rules", Some("Rules"), "Rules", false, "mailings.rules", |ui, app| { + mi(ui, app, "If…Then…Else…", "mailings.rules", json!({"rule": "IF"})); + mi(ui, app, "Merge Record #", "mailings.rules", json!({"rule": "MERGEREC"})); + mi(ui, app, "Next Record", "mailings.rules", json!({"rule": "NEXT"})); + mi(ui, app, "Skip Record If…", "mailings.rules", json!({"rule": "SKIPIF"})); + }); + small(ui, app, "matchFields", Some("Match Fields"), "Match Fields", "mailings.matchFields", json!({}), false); + }); + }); + group(ui, "Preview Results", None, app, |ui, app| { + big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false); + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + small(ui, app, "first", None, "First Record", "mailings.first", json!({}), false); + small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false); + record_box(ui, app); + small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false); + small(ui, app, "last", None, "Last Record", "mailings.last", json!({}), false); + }); + small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false); + small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false); + }); + }); + group(ui, "Finish", None, app, |ui, app| { + menu_button(ui, app, "finish", Some("Finish &\nMerge"), "Finish & Merge", true, "mailings.finish", |ui, app| { + mi(ui, app, "Edit Individual Documents…", "mailings.finish", json!({})); + mi(ui, app, "Print Documents…", "mailings.printDocuments", json!({})); + }); + }); +} + +/// Preview Results' record number: shows the previewed record; a number typed and Enter go there. +fn record_box(ui: &mut Ui, app: &mut WordApp) { + let on = !app.session.merge.rows.is_empty(); + let shown = if on { (app.session.merge.record + 1).to_string() } else { String::new() }; + // The draft lives in temp memory while typing (frame-local text would lose keystrokes). + let key = egui::Id::unique("merge_record_box"); + let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| shown.clone()); + let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).align(egui::Align2::CENTER_TOP)); + let r = r.on_hover_text(tl!("Go to Record")); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, text.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(key)); + if ui.input(|i| i.key_pressed(egui::Key::Enter)) && text.trim() != shown { + let _ = app.run("mailings.goToRecord", json!({"record": text.trim()})); + } + } +} diff --git a/crates/ui-egui/src/ribbon/picture.rs b/crates/ui-egui/src/ribbon/picture.rs new file mode 100644 index 000000000..e49831ca2 --- /dev/null +++ b/crates/ui-egui/src/ribbon/picture.rs @@ -0,0 +1,177 @@ +use super::arrange::{align_menu, group_menu, rotate_menu, rotation_field, wrap_menu}; +use super::common::{esc_cancelled, mi, stack}; +use super::shape::alt_text_group; +use super::*; + +/// Draws the Picture Format tab's Adjust, Picture Styles, Accessibility (Alt Text), Arrange and +/// Size groups. +pub(super) fn picture_format(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Adjust", None, app, |ui, app| { + menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, "picture.corrections", |ui, app| { + for (l, b, c) in + [("Brighter +20%", 20.0, 0.0), ("Darker −20%", -20.0, 0.0), ("More Contrast +20%", 0.0, 20.0), ("Less Contrast −20%", 0.0, -20.0)] + { + mi(ui, app, l, "picture.corrections", json!({"brightness": b, "contrast": c})); + } + ui.separator(); + mi(ui, app, "Sharpen", "picture.corrections", json!({"sharpen": 40.0})); + mi(ui, app, "Soften", "picture.corrections", json!({"sharpen": -40.0})); + }); + menu_button(ui, app, "picture", Some("Color"), "Saturation and recolor", true, "picture.color", |ui, app| { + for (l, v) in [("Full Saturation 100%", 100.0), ("Muted 50%", 50.0), ("Gray 0%", 0.0), ("Vivid 200%", 200.0)] { + mi(ui, app, l, "picture.color", json!({"mode": "saturation", "saturation": v})); + } + ui.separator(); + mi(ui, app, "Grayscale", "picture.color", json!({"mode": "grayscale"})); + mi(ui, app, "Sepia", "picture.color", json!({"mode": "sepia"})); + mi(ui, app, "Washout", "picture.color", json!({"mode": "washout"})); + }); + menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, "picture.iconColor", |ui, app| { + let theme = app.session.doc.settings.theme_colors.clone(); + crate::colors::menu(ui, app, &theme, Target::cmd("picture.iconColor")); + }); + menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, "picture.transparency", |ui, app| { + for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] { + mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v})); + } + ui.separator(); + // An exact value, dragged with a live preview: absolute from the original + // pixels, one undo step for the whole drag (same pattern as Size › W/H). + // The current value isn't stored anywhere, so the slider reopens where it + // was last left for this picture. + let (media, off) = match wordcraft_engine::cmd::objects::selected(&app.session) { + Some((pos, wordcraft_doc::para::InlineObject::Image { media, .. })) => (media, pos.off), + _ => (String::new(), 0), + }; + let key = egui::Id::unique(("picture_transparency", media, off)); + let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or(0.0); + let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(0.0..=100.0).suffix("%")); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, v)); + if r.dragged() && !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run("picture.transparency", json!({"percent": v})); + } + }); + stack(ui, |ui| { + small(ui, app, "picture", Some("Change"), "Change Picture", "ui.changePicture", json!({}), false); + small(ui, app, "picture", Some("Reset"), "Reset Picture", "picture.reset", json!({}), false); + }); + }); + group(ui, "Picture Styles", None, app, |ui, app| { + menu_button(ui, app, "picture", Some("Styles"), "Frames and effects", true, "picture.style", |ui, app| { + for (l, s) in [ + ("Simple Frame", "simpleFrame"), + ("Thick Frame", "thickFrame"), + ("Rounded", "rounded"), + ("Soft Edge", "softEdge"), + ("Shadow", "shadow"), + ] { + mi(ui, app, l, "picture.style", json!({"style": s})); + } + }); + menu_button(ui, app, "picture", Some("Border"), "Picture border", true, "picture.border", |ui, app| { + for (l, w) in [("Thin", 2), ("Medium", 4), ("Thick", 8)] { + mi(ui, app, l, "picture.border", json!({"width": w})); + } + }); + }); + alt_text_group(ui, app); + group(ui, "Arrange", None, app, |ui, app| { + big(ui, app, "position", "Position", "arrange.position", json!({}), false); + wrap_menu(ui, app); + stack(ui, |ui| { + rotate_menu(ui, app, Some("Rotate")); + align_menu(ui, app, Some("Align")); + group_menu(ui, app, Some("Group")); + }); + }); + // The launcher opens the Size dialog: scale, Lock aspect ratio, the original size (#2573). + group(ui, "Size", Some("picture.size"), app, |ui, app| { + let (media, off, w0, h0, crop0, alt0) = match wordcraft_engine::cmd::objects::selected(&app.session) { + Some((pos, wordcraft_doc::para::InlineObject::Image { media, w, h, alt, crop, .. })) => (media, pos.off, w, h, crop, alt), + _ => (String::new(), 0, 0.0, 0.0, [0.0; 4], String::new()), + }; + // `picture.size` locks the aspect ratio unless told otherwise, so one dimension suffices. + // The fields show the interface unit and take a size typed in any unit, pixels too + // (`300 px`, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels), + // never per keystroke; a drag applies as it goes, one undo step. + let unit = crate::options::unit(); + let k = unit.pt_per_unit(); + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + for (label, key, pt) in [("W:", "width", w0), ("H:", "height", h0)] { + let name = ui.label(egui::RichText::new(tl!(label)).small()); + let was = pt / k; + let mut v = was; + // Not `clamp_existing_to_range`: egui would clamp a size outside the range (a 1 px + // picture is 0.75 pt) and report it as a change, resizing the picture unasked. + let field = egui::DragValue::new(&mut v) + .speed(1.0 / k) + .range(1.0 / k..=4000.0 / k) + .clamp_existing_to_range(false) + .measure(unit) + .max_decimals(2); + let r = ui.add(field.update_while_editing(false)).labelled_by(name.id).on_hover_text(tl!( + "Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions." + )); + // While typing, the field reports each keystroke as a change; only a new + // value (Enter, Tab, a click elsewhere, a drag) resizes, and Esc cancels. + let cancelled = esc_cancelled(ui, &r, egui::Id::unique(("picture_size_field", key))); + if r.changed() && v != was && !cancelled { + if r.dragged() && !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run("picture.size", json!({key: v * k})); + } + } + rotation_field(ui, app); + }); + ui.add_space(2.0); + crate::widgets::row(ui, |ui| { + let cropping = crate::crop::active(app).is_some(); + small(ui, app, "crop", Some("Crop"), "Crop", "ui.crop", json!({}), cropping); + for (i, side) in ["L", "T", "R", "B"].iter().enumerate() { + ui.label(egui::RichText::new(tl!(side)).small()); + let mut v = crop0[i] * 100.0; + let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(0.0..=95.0).suffix("%")); + if r.changed() { + if r.dragged() && !r.drag_started() { + app.session.join_next_undo(); + } + let mut c = crop0; + c[i] = (v / 100.0).clamp(0.0, 0.95); + // The two sides of an axis keep 5% of the source between them. + let other = c[if i < 2 { i + 2 } else { i - 2 }]; + c[i] = c[i].min((0.95 - other).max(0.0)); + let _ = app.run("picture.crop", json!({"left": c[0], "top": c[1], "right": c[2], "bottom": c[3]})); + } + } + if ui.button(egui::RichText::new(tl!("Reset")).small()).on_hover_text(tl!("Reset Crop")).clicked() { + let _ = app.run("picture.crop", json!({"left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0})); + } + }); + ui.add_space(2.0); + crate::widgets::row(ui, |ui| { + ui.label(egui::RichText::new(tl!("Alt:")).small()); + // Frame-local buffers lose keystrokes; keep the draft in egui temp memory keyed + // by picture (same pattern as the comment editor in panes.rs). + let key = egui::Id::unique(("picture_alt", media.clone(), off)); + let mut alt = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| alt0.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut alt).desired_width(120.0).hint_text(tl!("Alt text"))); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, alt.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(key)); + // The click that took focus may have selected another picture; never commit + // one picture's draft onto another. + if alt != alt0 && app.selected_picture_media().as_deref() == Some(media.as_str()) { + let _ = app.run("picture.altText", json!({"text": alt})); + } + } + }); + }); + }); +} diff --git a/crates/ui-egui/src/ribbon/references.rs b/crates/ui-egui/src/ribbon/references.rs new file mode 100644 index 000000000..3ffc2d4a8 --- /dev/null +++ b/crates/ui-egui/src/ribbon/references.rs @@ -0,0 +1,117 @@ +use super::common::{mi, mi_text, stack}; +use super::language::mi_check; +use super::*; + +pub(super) fn references(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Table of Contents", None, app, |ui, app| { + menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, "references.toc", |ui, app| { + mi(ui, app, "Automatic Table 1 (3 levels)", "references.toc", json!({"levels": 3})); + mi(ui, app, "Automatic Table 2 (2 levels)", "references.toc", json!({"levels": 2, "title": "Table of Contents"})); + ui.separator(); + mi(ui, app, "Custom Table of Contents…", "ui.dialog", json!({"name": "toc"})); + mi(ui, app, "Remove Table of Contents", "references.removeToc", json!({})); + }); + stack(ui, |ui| { + menu_button(ui, app, "addText", Some("Add Text"), "Add Text", false, "references.addText", |ui, app| { + mi(ui, app, "Do Not Show in TOC", "references.addText", json!({"level": 0})); + for l in 1..=3u64 { + mi(ui, app, &format!("Level {l}"), "references.addText", json!({"level": l})); + } + }); + small(ui, app, "updateTable", Some("Update Table"), "Update Table", "references.updateToc", json!({}), false); + }); + }); + group(ui, "Footnotes", Some("references.noteOptions"), app, |ui, app| { + big(ui, app, "footnote", "Insert\nFootnote", "references.footnote", json!({}), false); + stack(ui, |ui| { + small(ui, app, "endnote", Some("Insert Endnote"), "Insert Endnote", "references.endnote", json!({}), false); + small(ui, app, "nextFootnote", Some("Next Footnote"), "Next Footnote", "references.nextFootnote", json!({}), false); + small(ui, app, "showNotes", Some("Show Notes"), "Show Notes", "references.notes", json!({}), false); + }); + }); + group(ui, "Research", None, app, |ui, app| { + big(ui, app, "researcher", "Researcher", "references.researcher", json!({}), false); + }); + group(ui, "Citations & Bibliography", None, app, |ui, app| { + // The document's sources to cite, then a new one (Create Source, #402). + menu_button(ui, app, "citation", Some("Insert\nCitation"), "Insert Citation", true, "", |ui, app| { + let sources: Vec<(String, String)> = + app.session.doc.sources.iter().map(|s| (s.tag.clone(), format!("{} — {}", s.author, s.title))).collect(); + for (tag, label) in sources { + mi_text(ui, app, &label, "references.citation", json!({"tag": tag})); + } + if !app.session.doc.sources.is_empty() { + ui.separator(); + } + mi(ui, app, "Add New Source…", "ui.dialog", json!({"name": "createSource"})); + }); + stack(ui, |ui| { + small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false); + let current = app.session.bib_style.clone(); + let label = crate::i18n::fmt(tl!("Style: {style}"), &[("style", ¤t)]); + menu_button(ui, app, "styles", Some(&label), "Citation Style", false, "references.citationStyle", |ui, app| { + for st in &wordcraft_engine::cmd::citations::STYLES { + mi_check(ui, app, st.name, st.name == current, "references.citationStyle", json!({"style": st.name})); + } + }); + small(ui, app, "bibliography", Some("Bibliography"), "Bibliography", "references.bibliography", json!({}), false); + }); + }); + group(ui, "Captions", None, app, |ui, app| { + big(ui, app, "caption", "Insert\nCaption", "references.caption", json!({}), false); + stack(ui, |ui| { + small( + ui, + app, + "tableOfFigures", + Some("Insert Table of Figures"), + "Insert Table of Figures", + "references.tableOfFigures", + json!({}), + false, + ); + small(ui, app, "update", Some("Update Table"), "Update Table of Figures", "references.updateFigures", json!({}), false); + small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false); + }); + }); + group(ui, "Index", None, app, |ui, app| { + big(ui, app, "markEntry", "Mark\nEntry", "references.markEntry", json!({}), false); + stack(ui, |ui| { + small(ui, app, "index", Some("Insert Index"), "Insert Index", "references.index", json!({}), false); + small(ui, app, "update", Some("Update Index"), "Update Index", "references.updateIndex", json!({}), false); + }); + }); + group(ui, "Table of Authorities", None, app, |ui, app| { + big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false); + stack(ui, |ui| { + small( + ui, + app, + "tableOfAuthorities", + Some("Insert Table of Authorities"), + "Insert Table of Authorities", + "references.tableOfAuthorities", + json!({}), + false, + ); + small(ui, app, "update", Some("Update Table"), "Update Table", "references.updateAuthorities", json!({}), false); + }); + }); +} + +/// Zotero (`docs/zotero.md`): the Zotero desktop app does the work in its own window. +pub(super) fn zotero(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Citations", None, app, |ui, app| { + big(ui, app, "citation", "Add/Edit\nCitation", "ui.zotero.addEditCitation", json!({}), false); + big(ui, app, "addNote", "Add\nNote", "ui.zotero.addNote", json!({}), false); + big(ui, app, "pastCitation", "Move Past\nCitation", "caret.pastCitation", json!({}), false); + }); + group(ui, "Bibliography", None, app, |ui, app| { + big(ui, app, "bibliography", "Add/Edit\nBibliography", "ui.zotero.addEditBibliography", json!({}), false); + }); + group(ui, "Document", None, app, |ui, app| { + big(ui, app, "update", "Refresh", "ui.zotero.refresh", json!({}), false); + big(ui, app, "docPrefs", "Document\nPreferences", "ui.zotero.setDocPrefs", json!({}), false); + big(ui, app, "unlinkCitations", "Unlink\nCitations", "ui.zotero.removeCodes", json!({}), false); + }); +} diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs new file mode 100644 index 000000000..3ae92576b --- /dev/null +++ b/crates/ui-egui/src/ribbon/review.rs @@ -0,0 +1,132 @@ +use super::common::{mi, stack}; +use super::language::language_menu; +use super::*; + +pub(super) fn review(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Proofing", None, app, |ui, app| { + big(ui, app, "spelling", "Spelling &\nGrammar", "review.spelling", json!({}), false); + stack(ui, |ui| { + small(ui, app, "thesaurus", Some("Thesaurus"), "Thesaurus", "review.thesaurus", json!({}), false); + small(ui, app, "wordCount", Some("Word Count"), "Word Count", "ui.dialog", json!({"name": "wordCount"}), false); + }); + }); + group(ui, "Speech", None, app, |ui, app| { + big(ui, app, "readAloud", "Read\nAloud", "review.readAloud", json!({}), false); + }); + group(ui, "Accessibility", None, app, |ui, app| { + big(ui, app, "accessibility", "Check\nAccessibility", "file.accessibility", json!({}), false); + }); + group(ui, "Language", None, app, |ui, app| { + big(ui, app, "translate", "Translate", "review.translate", json!({}), false); + menu_button(ui, app, "language", Some("Language"), "Language", true, "", language_menu); + }); + group(ui, "Comments", None, app, |ui, app| { + big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false); + stack(ui, |ui| { + small(ui, app, "deleteComment", Some("Delete"), "Delete Comment", "review.deleteComment", json!({}), false); + small(ui, app, "prevComment", Some("Previous"), "Previous Comment", "review.previousComment", json!({}), false); + small(ui, app, "nextComment", Some("Next"), "Next Comment", "review.nextComment", json!({}), false); + }); + let shown = app.session.view.comments_pane; + big(ui, app, "showComments", "Show\nComments", "view.commentsPane", json!({"value": !shown}), false); + }); + group(ui, "Tracking", Some("review.trackingOptions"), app, |ui, app| { + let on = app.session.doc.settings.track_changes; + let r = big(ui, app, "trackChanges", if on { "Track\nChanges ✓" } else { "Track\nChanges" }, "review.trackChanges", json!({}), false); + let _ = r; + stack(ui, |ui| { + menu_button( + ui, + app, + "markup", + Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }), + "Display for Review", + false, + "review.markup", + |ui, app| { + mi(ui, app, "All Markup", "review.markup", json!({"value": "all"})); + mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"})); + }, + ); + small(ui, app, "reviewingPane", Some("Reviewing Pane"), "Reviewing Pane", "review.changes", json!({}), false); + }); + }); + group(ui, "Changes", None, app, |ui, app| { + menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| { + mi(ui, app, "Accept and Move to Next", "review.accept", json!({"next": true})); + mi(ui, app, "Accept This Change", "review.accept", json!({})); + mi(ui, app, "Accept All Changes", "review.acceptAll", json!({})); + mi(ui, app, "Accept All Changes and Stop Tracking", "review.acceptAll", json!({"stopTracking": true})); + }); + menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| { + mi(ui, app, "Reject and Move to Next", "review.reject", json!({"next": true})); + mi(ui, app, "Reject Change", "review.reject", json!({})); + mi(ui, app, "Reject All Changes", "review.rejectAll", json!({})); + mi(ui, app, "Reject All Changes and Stop Tracking", "review.rejectAll", json!({"stopTracking": true})); + }); + stack(ui, |ui| { + small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false); + small(ui, app, "nextChange", Some("Next"), "Next Change", "review.nextChange", json!({}), false); + }); + }); + group(ui, "Compare", None, app, |ui, app| { + big(ui, app, "compare", "Compare", "review.compare", json!({}), false); + }); + group(ui, "Protect", None, app, |ui, app| { + big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false); + big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false); + }); + group(ui, "Ink", None, app, |ui, app| { + let hidden = app.session.view.hide_ink; + big_toggle(ui, app, "hideInk", "Hide\nInk", "review.hideInk", hidden); + }); +} + +/// Developer › Controls: content controls. +pub(super) fn developer(app: &mut WordApp, ui: &mut Ui) { + let design = app.session.view.design_mode; + group(ui, "Controls", None, app, |ui, app| { + let rows: [[(&str, &str, &str); 3]; 3] = [ + [ + ("ccRichText", "Rich Text Content Control", "developer.richText"), + ("ccPlainText", "Plain Text Content Control", "developer.plainText"), + ("ccPicture", "Picture Content Control", "developer.picture"), + ], + [ + ("ccGallery", "Building Block Gallery Content Control", "developer.buildingBlock"), + ("ccCheckBox", "Check Box Content Control", "developer.checkBox"), + ("ccComboBox", "Combo Box Content Control", "developer.comboBox"), + ], + [ + ("ccDropDown", "Drop-Down List Content Control", "developer.dropDown"), + ("ccDate", "Date Picker Content Control", "developer.datePicker"), + ("ccRepeat", "Repeating Section Content Control", "developer.repeatingSection"), + ], + ]; + // Three rows of small buttons fill the group's height, like every other stack. + stack(ui, |ui| { + for row in rows { + crate::widgets::row(ui, |ui| { + for (icon, tip, id) in row { + small(ui, app, icon, None, tip, id, json!({}), false); + } + }); + } + }); + stack(ui, |ui| { + small(ui, app, "designMode", Some("Design Mode"), "Design Mode", "developer.designMode", json!({}), design); + small(ui, app, "properties", Some("Properties"), "Properties", "developer.properties", json!({}), false); + }); + }); +} + +pub(super) fn help(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Help", None, app, |ui, app| { + big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); + big(ui, app, "checkForUpdates", "Check for\nUpdates…", "ui.checkForUpdates", json!({}), false); + big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false); + }); + group(ui, "Agents", None, app, |ui, app| { + big(ui, app, "macros", "Commands", "ui.dialog", json!({"name": "commands"}), false); + }); +} diff --git a/crates/ui-egui/src/ribbon/shape.rs b/crates/ui-egui/src/ribbon/shape.rs new file mode 100644 index 000000000..728e0187a --- /dev/null +++ b/crates/ui-egui/src/ribbon/shape.rs @@ -0,0 +1,209 @@ +use super::arrange::{align_menu, rotate_menu, rotation_field, wrap_menu}; +use super::common::{mi, stack}; +use super::language::mi_check; +use super::*; + +/// Shape Fill › Gradient: No Gradient, Light and Dark Variations of the shape's colour, More +/// Gradients… (the Format Shape pane). +pub(crate) fn gradient_menu(ui: &mut Ui, app: &mut WordApp, fill: Option<&wordcraft_doc::fill::Fill>) { + use wordcraft_engine::cmd::shape_fill; + let pos = wordcraft_engine::cmd::objects::selected(&app.session).map(|(p, _)| p); + let main = match (fill, &pos) { + (Some(f), Some(p)) => crate::format_shape::variation_base(ui.ctx(), p, f), + (Some(f), None) => f.main_color(), + _ => app.session.doc.settings.theme_colors.get(4).copied().unwrap_or(wordcraft_doc::Rgb(68, 114, 196)), + }; + // No Gradient: the shape's colour, solid. + ui.label(egui::RichText::new(tl!("No Gradient")).small().weak()); + if crate::format_shape::swatch(ui, &wordcraft_doc::fill::Fill::solid(main), "No Gradient") { + let _ = app.run("shape.fill", json!({"color": main.hex()})); + ui.close(); + } + for (title, tiles) in ["Light Variations", "Dark Variations"].into_iter().zip(shape_fill::variations(main)) { + ui.label(egui::RichText::new(tl!(title)).small().weak()); + egui::Grid::new(title).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, (f, name)) in tiles.iter().enumerate() { + if crate::format_shape::swatch(ui, f, name) { + let _ = app.run("shape.fill", shape_fill::params(Some(f))); + if let (Some(p), Some(Some(now))) = (pos.clone(), crate::format_shape::current(app)) { + crate::format_shape::remember_variation(ui.ctx(), p, now, main); + } + ui.close(); + } + if i % 5 == 4 { + ui.end_row(); + } + } + }); + } + ui.separator(); + if ui.button(tl!("More Gradients…")).clicked() { + let _ = app.run("view.formatShapePane", json!({"value": true})); + ui.close(); + } +} + +/// Shape Format: fill, outline and effects of the selected shape, and its arrangement. +pub(super) fn shape_format(app: &mut WordApp, ui: &mut Ui) { + let (stroke, glow_size, fill) = match wordcraft_engine::cmd::objects::selected(&app.session) { + Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, fill, .. })) => (stroke, effects.glow.map(|g| g.size), fill), + Some((_, o)) => (None, None, o.canvas_style().and_then(|c| c.fill.clone())), + _ => (None, None, None), + }; + let theme = app.session.doc.settings.theme_colors.clone(); + group(ui, "Shape Styles", None, app, |ui, app| { + stack(ui, |ui| { + menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, "shape.fill", |ui, app| { + mi(ui, app, "No Color", "shape.fill", json!({"color": null})); + crate::colors::menu(ui, app, &theme, Target::cmd("shape.fill")); + ui.separator(); + ui.menu_button(tl!("Gradient"), |ui| gradient_menu(ui, app, fill.as_ref())); + }); + menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, "shape.outline", |ui, app| { + mi(ui, app, "No Color", "shape.outline", json!({"color": null})); + crate::colors::menu(ui, app, &theme, Target::cmd("shape.outline")); + ui.separator(); + ui.menu_button(tl!("Weight"), |ui| { + let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex(); + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w})); + } + }); + }); + menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, "shape.effects", |ui, app| { + shape_effects_menu(ui, app, &theme, glow_size) + }); + }); + }); + alt_text_group(ui, app); + if wordcraft_engine::cmd::canvas::selected_canvas(&app.session).is_some() { + group(ui, "Drawing Canvas", None, app, |ui, app| { + menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, "", |ui, app| { + mi(ui, app, "Fit", "canvas.fit", json!({})); + mi(ui, app, "Expand", "canvas.expand", json!({})); + ui.menu_button(tl!("Scale Drawing"), |ui| { + for pct in [50, 75, 125, 150, 200] { + mi(ui, app, &format!("{pct}%"), "canvas.scale", json!({"factor": pct as f32 / 100.0})); + } + }); + }); + }); + } + group(ui, "WordArt Styles", None, app, |ui, app| { + menu_button(ui, app, "wordArt", Some("Styles"), "WordArt Styles", true, "", |ui, app| { + crate::wordart_menus::style_gallery(ui, app, "wordArt.style") + }); + stack(ui, |ui| { + menu_button(ui, app, "textFill", Some("Text Fill"), "Text Fill", false, "", |ui, app| crate::wordart_menus::fill_menu(ui, app, &theme)); + let outline = crate::wordart_menus::current_outline(app); + menu_button(ui, app, "textOutline", Some("Text Outline"), "Text Outline", false, "", |ui, app| { + crate::wordart_menus::outline_menu(ui, app, &theme, outline) + }); + menu_button(ui, app, "transform", Some("Text Effects"), "Text Effects", false, "", |ui, app| { + crate::wordart_menus::effects_menu(ui, app, &theme, true) + }); + }); + }); + shape_text_group(app, ui); + group(ui, "Arrange", None, app, |ui, app| { + big(ui, app, "position", "Position", "arrange.position", json!({}), false); + wrap_menu(ui, app); + stack(ui, |ui| { + rotate_menu(ui, app, Some("Rotate")); + align_menu(ui, app, Some("Align")); + }); + }); + group(ui, "Size", None, app, |ui, app| { + let (w0, h0) = + wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0)); + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + for (label, key, v0) in [("W:", "width", w0), ("H:", "height", h0)] { + ui.label(egui::RichText::new(tl!(label)).small()); + let mut v = v0; + // `picture.size`'s own range; a size outside it (a thin line) is shown as it + // is (clamped, egui would report a change and resize the shape unasked). + let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(1.0..=4000.0).clamp_existing_to_range(false).suffix(" pt")); + if r.changed() && v != v0 { + if r.dragged() && !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run("picture.size", json!({key: v, "lockAspect": false})); + } + } + }); + ui.add_space(2.0); + crate::widgets::row(ui, |ui| rotation_field(ui, app)); + }); + }); +} + +/// Shape Format › Text: the text box's Text Direction and Align Text, and Create Link (Break Link +/// once it is linked to another box). +fn shape_text_group(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_doc::props::{TextVert, VAlign}; + let target = wordcraft_engine::cmd::objects::text_box_target(&app.session); + let body = target.and_then(|(_, id)| app.session.doc.parts.get(&id).map(|p| p.body)).unwrap_or_default(); + group(ui, "Text", None, app, |ui, app| { + stack(ui, |ui| { + menu_button(ui, app, "textDirection", Some("Text Direction"), "Text Direction", false, "", |ui, app| { + for (label, vert) in [ + ("Horizontal", TextVert::Horz), + ("Rotate all text 90°", TextVert::Vert), + ("Rotate all text 270°", TextVert::Vert270), + ("Stacked", TextVert::Stacked), + ] { + mi_check(ui, app, label, body.vert == vert, "shape.textDirection", json!({"dir": vert.ooxml()})); + } + }); + menu_button(ui, app, "alignText", Some("Align Text"), "Align Text", false, "", |ui, app| { + for (label, anchor) in [("Top", VAlign::Top), ("Middle", VAlign::Center), ("Bottom", VAlign::Bottom)] { + mi_check(ui, app, label, body.anchor == anchor, "shape.alignText", json!({"anchor": wordcraft_doc::props::anchor_ooxml(anchor)})); + } + }); + if body.next.is_some() { + small(ui, app, "textBoxUnlink", Some("Break Link"), "Break Link", "shape.breakLink", json!({}), false); + } else { + let waiting = app.canvas.link_from.is_some(); + small(ui, app, "textBoxLink", Some("Create Link"), "Create Link", "ui.linkTextBox", json!({}), waiting); + } + }); + }); +} + +/// Picture/Shape Format › Accessibility: Alt Text opens the Alt Text pane. +pub(super) fn alt_text_group(ui: &mut Ui, app: &mut WordApp) { + group(ui, "Accessibility", None, app, |ui, app| { + big(ui, app, "altText", "Alt\nText", "view.altTextPane", json!({"value": true}), false); + }); +} + +/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry. +fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option) { + ui.menu_button(tl!("Shadow"), |ui| { + mi(ui, app, "No Shadow", "shape.effects", json!({"shadow": null})); + ui.separator(); + for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS { + mi(ui, app, label, "shape.effects", json!({"shadow": id})); + } + }); + ui.menu_button(tl!("Glow"), |ui| { + mi(ui, app, "No Glow", "shape.effects", json!({"glow": null})); + ui.separator(); + for size in [5.0, 8.0, 11.0, 18.0] { + mi(ui, app, &format!("{size} pt"), "shape.effects", json!({"glow": size})); + } + ui.separator(); + ui.menu_button(tl!("Glow Colors"), |ui| { + let target = Target::at("shape.effects", json!({"glow": {"color": null, "size": glow_size.unwrap_or(8.0)}}), "/glow/color"); + crate::colors::menu(ui, app, theme, target); + }); + }); + ui.menu_button(tl!("Soft Edges"), |ui| { + mi(ui, app, "No Soft Edges", "shape.effects", json!({"softEdge": null})); + ui.separator(); + for r in [1.0, 2.5, 5.0, 10.0, 25.0, 50.0] { + mi(ui, app, &format!("{r} pt"), "shape.effects", json!({"softEdge": r})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs new file mode 100644 index 000000000..39746e35f --- /dev/null +++ b/crates/ui-egui/src/ribbon/table.rs @@ -0,0 +1,240 @@ +use super::common::{mi, stack}; +use super::*; + +pub(super) fn table_design(app: &mut WordApp, ui: &mut Ui) { + let look = app.session.sel.focus.path.cell().and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look)); + group(ui, "Table Style Options", None, app, |ui, app| { + let Some(l) = look else { return }; + egui::Grid::new("look").show(ui, |ui| { + for (row, items) in [ + [("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)], + [("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)], + [("Banded Rows", "bandedRows", l.banded_rows), ("Banded Columns", "bandedColumns", l.banded_columns)], + ] + .iter() + .enumerate() + { + let _ = row; + for (label, key, val) in items { + let mut v = *val; + if ui.checkbox(&mut v, tl!(label)).changed() { + let _ = app.run("table.look", json!({ *key: v })); + } + } + ui.end_row(); + } + }); + }); + group(ui, "Table Styles", None, app, |ui, app| { + // The document's own styles first, so a new one shows without scrolling. + let mut styles: Vec<(bool, String, String)> = app + .session + .doc + .styles + .styles + .iter() + .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden) + .map(|s| (s.builtin, s.id.clone(), crate::i18n::style_name(s))) + .collect(); + styles.sort_by_key(|(builtin, _, _)| *builtin); + egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| { + ui.horizontal(|ui| { + for (_, id, name) in styles { + if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() { + let _ = app.run("table.style", json!({"style": id})); + } + } + }); + }); + stack(ui, |ui| { + let current = look.and_then(|_| { + let (tp, _, _) = app.session.sel.focus.path.cell()?; + let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?; + app.session + .doc + .styles + .get(&id) + .filter(|st| st.kind == wordcraft_doc::StyleKind::Table) + .map(wordcraft_engine::cmd::table_style::is_builtin_table_style) + }); + let can_modify = current.is_some(); + // Built-in table styles can't be deleted. + let can_delete = current == Some(false); + menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, "table.style", |ui, app| { + mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"})); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() { + let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"})); + ui.close(); + } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() { + if let Err(e) = app.run("table.deleteStyle", json!({})) { + app.status(e); + } + ui.close(); + } + }); + split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { + mi(ui, app, "No Color", "table.shading", json!({"color": null})); + let theme = app.session.doc.settings.theme_colors.clone(); + crate::colors::menu(ui, app, &theme, Target::cmd("table.shading").remember(Remember::Shading)); + }); + }); + }); + group(ui, "Borders", None, app, |ui, app| { + menu_button(ui, app, "borders", Some("Borders"), "Borders", true, "table.borders", |ui, app| { + for (l, k) in [ + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Inside Borders", "inside"), + ("No Border", "none"), + ("Top Border", "top"), + ("Bottom Border", "bottom"), + ] { + mi(ui, app, l, "table.borders", json!({"kind": k})); + } + }); + big(ui, app, "borderPainter", "Border\nPainter", "table.borderPainter", json!({}), false); + }); +} + +/// Table Layout › Cell Size: the caret cell's row height and column width, in inches. They show +/// the size on the page (a row without a set height shows the height its text gives it) and +/// editing runs `table.rowHeight` / `table.columnWidth`; dragging one is a single Undo. +fn cell_size_boxes(ui: &mut Ui, app: &mut WordApp) { + let story = app.session.sel.focus.story; + let Some((tp, r, c)) = app.session.sel.focus.path.cell() else { return }; + let Some(t) = app.session.doc.table(story, &tp) else { return }; + let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default(); + let stored_w = t.grid.get(t.grid_col(r, c)).copied(); + let laid = app.session.layout().pages.iter().flat_map(|pg| pg.items.iter()).find_map(|it| match it { + wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st, .. } if *table == tp && *row == r && *cell == c && *st == story => { + Some(*rect) + } + _ => None, + }); + // The row's own height: a vertically merged cell's box spans several rows, so take the + // shortest box in this row. + let row_h = app + .session + .layout() + .pages + .iter() + .flat_map(|pg| pg.items.iter()) + .filter_map(|it| match it { + wordcraft_layout::Placed::Cell { rect, table, row, story: st, .. } if *table == tp && *row == r && *st == story => Some(rect.h), + _ => None, + }) + .reduce(f32::min); + let unit = crate::options::unit(); + let k = unit.pt_per_unit(); + let h0 = row.height.or(row_h).unwrap_or(0.0); + let w0 = laid.map(|x| x.w).or(stored_w).unwrap_or(0.0); + let exact = row.height_rule == wordcraft_doc::props::HeightRule::Exact; + // The edited value in points, and whether it continues a drag (joins the previous Undo step). + let boxed = |ui: &mut Ui, label: &str, pt: f32| -> Option<(f32, bool)> { + let mut out = None; + crate::widgets::row(ui, |ui| { + ui.add_sized(vec2(48.0, 18.0), egui::Label::new(egui::RichText::new(tl!(label)).small())); + let mut v = pt / k; + let r = ui.add_sized(vec2(72.0, 18.0), egui::DragValue::new(&mut v).speed(0.01).range(0.02..=22.0).measure(unit).max_decimals(2)); + if r.changed() { + out = Some((v * k, r.dragged() && !r.drag_started())); + } + }); + ui.add_space(2.0); + out + }; + stack(ui, |ui| { + if let Some((h, join)) = boxed(ui, "Height:", h0) { + if join { + app.session.join_next_undo(); + } + let _ = app.run("table.rowHeight", json!({"height": h, "rule": if exact { "exact" } else { "atLeast" }})); + } + if let Some((w, join)) = boxed(ui, "Width:", w0) { + if join { + app.session.join_next_undo(); + } + let _ = app.run("table.columnWidth", json!({"width": w})); + } + }); +} + +pub(super) fn table_layout(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Table", None, app, |ui, app| { + stack(ui, |ui| { + menu_button(ui, app, "selectTable", Some("Select"), "Select", false, "table.selectTable", |ui, app| { + mi(ui, app, "Select Cell", "table.selectCell", json!({})); + mi(ui, app, "Select Column", "table.selectColumn", json!({})); + mi(ui, app, "Select Row", "table.selectRow", json!({})); + mi(ui, app, "Select Table", "table.selectTable", json!({})); + }); + let g = app.session.view.table_gridlines; + small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g); + small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false); + }); + }); + group(ui, "Draw", None, app, |ui, app| { + use crate::table_pen::TableTool; + let tool = app.canvas.table_tool; + crate::widgets::big_toggle(ui, app, "drawTable", "Draw\nTable", "table.draw", tool == Some(TableTool::Draw)); + crate::widgets::big_toggle(ui, app, "eraser", "Eraser", "table.eraser", tool == Some(TableTool::Erase)); + }); + group(ui, "Rows & Columns", None, app, |ui, app| { + menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, "table.deleteTable", |ui, app| { + mi(ui, app, "Delete Cells", "table.deleteCells", json!({})); + mi(ui, app, "Delete Columns", "table.deleteColumn", json!({})); + mi(ui, app, "Delete Rows", "table.deleteRow", json!({})); + mi(ui, app, "Delete Table", "table.deleteTable", json!({})); + }); + big(ui, app, "insertAbove", "Insert\nAbove", "table.insertRowAbove", json!({}), false); + stack(ui, |ui| { + small(ui, app, "insertBelow", Some("Insert Below"), "Insert Below", "table.insertRowBelow", json!({}), false); + small(ui, app, "insertLeft", Some("Insert Left"), "Insert Left", "table.insertColumnLeft", json!({}), false); + small(ui, app, "insertRight", Some("Insert Right"), "Insert Right", "table.insertColumnRight", json!({}), false); + }); + }); + group(ui, "Merge", None, app, |ui, app| { + stack(ui, |ui| { + small(ui, app, "merge", Some("Merge Cells"), "Merge Cells", "table.merge", json!({}), false); + small(ui, app, "splitCells", Some("Split Cells"), "Split Cells", "table.split", json!({}), false); + small(ui, app, "splitTable", Some("Split Table"), "Split Table", "table.splitTable", json!({}), false); + }); + }); + group(ui, "Cell Size", None, app, |ui, app| { + menu_button(ui, app, "autofit", Some("AutoFit"), "AutoFit", true, "table.autofit", |ui, app| { + mi(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"})); + mi(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"})); + mi(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"})); + }); + cell_size_boxes(ui, app); + stack(ui, |ui| { + small(ui, app, "distributeRows", Some("Distribute Rows"), "Distribute Rows", "table.distributeRows", json!({}), false); + small(ui, app, "distributeCols", Some("Distribute Columns"), "Distribute Columns", "table.distributeColumns", json!({}), false); + }); + }); + group(ui, "Alignment", None, app, |ui, app| { + egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| { + for row in [ + [("topLeft", "Align Top Left"), ("topCenter", "Align Top Center"), ("topRight", "Align Top Right")], + [("centerLeft", "Align Center Left"), ("center", "Align Center"), ("centerRight", "Align Center Right")], + [("bottomLeft", "Align Bottom Left"), ("bottomCenter", "Align Bottom Center"), ("bottomRight", "Align Bottom Right")], + ] { + for (v, tip) in row { + small(ui, app, "cellAlign", None, tip, "table.cellAlign", json!({"value": v}), false); + } + ui.end_row(); + } + }); + big(ui, app, "textDirection", "Text\nDirection", "table.textDirection", json!({}), false); + big(ui, app, "cellMargins", "Cell\nMargins", "table.cellMargins", json!({}), false); + }); + group(ui, "Data", None, app, |ui, app| { + big(ui, app, "sort", "Sort", "table.sort", json!({}), false); + stack(ui, |ui| { + small(ui, app, "repeatHeader", Some("Repeat Header Rows"), "Repeat Header Rows", "table.repeatHeader", json!({}), false); + small(ui, app, "toText", Some("Convert to Text"), "Convert to Text", "table.toText", json!({}), false); + small(ui, app, "formula", Some("Formula"), "Formula", "table.formula", json!({}), false); + }); + }); +} diff --git a/crates/ui-egui/src/ribbon/tabs.rs b/crates/ui-egui/src/ribbon/tabs.rs new file mode 100644 index 000000000..9ca7f5bce --- /dev/null +++ b/crates/ui-egui/src/ribbon/tabs.rs @@ -0,0 +1,252 @@ +use super::*; + +pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS; + +/// True when the caret/selection touches a picture (#147). +pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool { + matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. }))) +} + +/// Whether the selection is a shape, text box or Drawing Canvas (so Shape Format is shown). +pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool { + match wordcraft_engine::cmd::objects::selected(s) { + Some((_, o)) => matches!(o, wordcraft_doc::para::InlineObject::Shape { .. }) || o.is_canvas(), + None => false, + } +} + +/// Reading controls replace editing tabs entirely. All settings are transient view controls. +pub(super) fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) { + egui::Panel::top("reading_toolbar") + .exact_size(46.0) + .frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(12, 6)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0))); + ui.separator(); + let w = app.session.view.read_column_width; + let active = match w { + None => "Auto", + Some(v) if v <= 180.0 => "Narrow", + Some(v) if v <= 240.0 => "Medium", + Some(v) if v <= 350.0 => "Wide", + _ => "Page", + }; + ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| { + for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] { + if ui.selectable_label(active == name, tl!(name)).clicked() { + let _ = app.run("view.readColumnWidth", json!({"value": value})); + ui.close(); + } + } + }); + ui.separator(); + let pct = (app.session.view.read_zoom * 100.0).round() as u32; + ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| { + for percent in [75, 90, 100, 110, 125, 150] { + if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() { + let _ = app.run("view.readTextSize", json!({"value": percent})); + ui.close(); + } + } + }); + ui.separator(); + let spacing = app.session.view.read_line_spacing; + let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×")); + ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| { + if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() { + let _ = app.run("view.readLineSpacing", json!({"value": "document"})); + ui.close(); + } + for value in [1.0, 1.25, 1.5, 2.0] { + if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() { + let _ = app.run("view.readLineSpacing", json!({"value": value})); + ui.close(); + } + } + }); + ui.separator(); + use wordcraft_engine::cmd::view::ReadPageColor; + let colors = + [(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")]; + let current = app.session.view.read_page_color; + let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1); + ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| { + for (color, name, value) in colors { + if ui.selectable_label(current == color, tl!(name)).clicked() { + let _ = app.run("view.readPageColor", json!({"value": value})); + ui.close(); + } + } + }); + ui.separator(); + // Headings to jump to, in the Navigation pane beside the columns (Outline view + // is an editing layout, so it isn't offered here). + if ui.button(tl!("Navigation Pane")).clicked() { + let _ = app.run("view.navigationPane", json!({"value": true})); + app.ui.nav_tab = "headings".into(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Edit Document")).clicked() { + let _ = app.run("view.printLayout", json!({})); + } + }); + }); + }); +} + +/// Whether the selection is a SmartArt graphic WordCraft can edit (so SmartArt Design is shown). +pub fn has_smart_art_selected(s: &wordcraft_engine::Session) -> bool { + wordcraft_engine::cmd::smart_art::selected_smart_art(s).is_some() +} + +/// Whether the selection is a chart WordCraft can edit (so Chart Design is shown). +pub fn has_chart_selected(s: &wordcraft_engine::Session) -> bool { + wordcraft_engine::cmd::chart::selected_chart(s).is_some() +} + +/// Contextual tabs for the current selection (pure, tested). +pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> { + let mut tabs = Vec::new(); + if s.sel.focus.path.cell().is_some() { + tabs.push("Table Design"); + tabs.push("Table Layout"); + } + if has_picture_selected(s) { + tabs.push("Picture Format"); + } + if has_shape_selected(s) { + tabs.push("Shape Format"); + } + if has_smart_art_selected(s) { + tabs.push("SmartArt Design"); + } + if has_chart_selected(s) { + tabs.push("Chart Design"); + } + // Editing an equation. + if s.math.is_some() { + tabs.push("Equation"); + } + // Outline view (drawn first, next to File, as Word does). + if s.view.mode == wordcraft_layout::ViewMode::Outline { + tabs.push("Outlining"); + } + tabs +} + +/// Tab to show when the stored tab is no longer applicable (pure, tested). +pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str { + if available.contains(¤t) { current } else { crate::DEFAULT_TAB } +} + +/// Width of the » button that holds the tabs the strip has no room for. +pub(super) const MORE_TABS_W: f32 = 24.0; + +/// Which tabs fit on a strip `avail` points wide (#897), from each tab's width and whether it must +/// stay. The others give way from the end, leaving `more` points for the » menu that holds them +/// (pure, tested). +pub fn fit_tabs(tabs: &[(f32, bool)], avail: f32, more: f32) -> Vec { + let mut fits = vec![true; tabs.len()]; + let mut total: f32 = tabs.iter().map(|(w, _)| w).sum(); + if total <= avail { + return fits; + } + for (fit, (w, keep)) in fits.iter_mut().zip(tabs).rev() { + if total + more <= avail { + break; + } + if !keep { + *fit = false; + total -= w; + } + } + fits +} + +/// One tab as the strip planner sees it (#2026). +#[derive(Debug, Clone, Copy)] +pub struct TabSpec { + /// Width of the label at full size. + pub text_w: f32, + /// File: always on the strip. + pub file: bool, + /// The tab being shown. + pub active: bool, + /// A contextual tab (Table Design, Picture Format…). + pub contextual: bool, +} + +/// How the tab strip is laid out (#2026). +#[derive(Debug, Clone, PartialEq)] +pub struct StripPlan { + /// Padding each side of a tab label. + pub pad: f32, + /// How much of Comments / Editing / Share is spelled out: 0 all, 1 Share only, 2 none. + pub buttons: usize, + /// Whether each tab is on the strip (the others are in the » menu). + pub fits: Vec, + /// Width each tab takes on the strip (a label may be cut with …). + pub widths: Vec, +} + +/// Smallest a tab label is cut down to before the tab goes into the » menu. +pub(super) const MIN_TAB_W: f32 = 44.0; + +/// Lays the tabs and the buttons on their right out on `avail` points, giving way in this order: +/// the padding between tabs, the Comments / Editing labels, the Share label, the plain tabs (from +/// the end, into »), the labels of the active and contextual tabs (cut with …), and last those tabs +/// themselves. The buttons' room is always kept and File never leaves the strip. `buttons` is the +/// width of the three buttons at each level (pure, tested). +pub fn plan_strip(tabs: &[TabSpec], buttons: [f32; 3], avail: f32, more: f32) -> StripPlan { + const STEPS: [(f32, usize); 4] = [(18.0, 0), (10.0, 0), (10.0, 1), (10.0, 2)]; + let widths_at = |pad: f32| -> Vec { tabs.iter().map(|t| t.text_w.max(0.0) + pad + 2.0).collect() }; + for (pad, level) in STEPS { + let widths = widths_at(pad); + if widths.iter().sum::() <= avail - buttons[level] { + return StripPlan { pad, buttons: level, fits: vec![true; tabs.len()], widths }; + } + } + let (pad, level) = (10.0, 2); + let mut widths = widths_at(pad); + let room = avail - buttons[level]; + let specs: Vec<(f32, bool)> = widths.iter().zip(tabs).map(|(w, t)| (*w, t.file || t.active || t.contextual)).collect(); + let mut fits = fit_tabs(&specs, room, more); + // The tabs that must stay share what the plain ones left; each is cut down to its share. + let kept = |fits: &[bool], i: usize| fits.get(i).copied().unwrap_or(false) && tabs.get(i).is_some_and(|t| !t.file); + loop { + let n = (0..tabs.len()).filter(|&i| kept(&fits, i)).count(); + let others: f32 = + (0..tabs.len()).filter(|&i| fits.get(i) == Some(&true) && !kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum(); + let own: f32 = (0..tabs.len()).filter(|&i| kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum(); + let extra = if fits.iter().all(|f| *f) { 0.0 } else { more }; + let share_room = room - extra - others; + if n == 0 || own <= share_room { + break; + } + if share_room >= n as f32 * MIN_TAB_W { + let share = share_room / n as f32; + for (i, w) in widths.iter_mut().enumerate() { + if kept(&fits, i) { + *w = w.min(share); + } + } + break; + } + // Not even cut down: the last of them (the active tab last of all) goes into ». + let last = (0..tabs.len()) + .rev() + .find(|&i| kept(&fits, i) && tabs.get(i).is_some_and(|t| !t.active)) + .or_else(|| (0..tabs.len()).rev().find(|&i| kept(&fits, i))); + match last.and_then(|i| fits.get_mut(i)) { + Some(f) => *f = false, + None => break, + } + } + StripPlan { pad, buttons: level, fits, widths } +} + +/// Whether the caret is inside a table (so the contextual tabs and their badges are shown). +pub fn in_table_public(app: &WordApp) -> bool { + app.session.sel.focus.path.cell().is_some() +} diff --git a/crates/ui-egui/src/ribbon/underline.rs b/crates/ui-egui/src/ribbon/underline.rs new file mode 100644 index 000000000..d3c852107 --- /dev/null +++ b/crates/ui-egui/src/ribbon/underline.rs @@ -0,0 +1,96 @@ +use super::common::mi; +use super::*; + +/// The underline styles Home › Underline offers as line samples: (`format.underline` style, name). +pub(super) const UNDERLINE_STYLES: [(&str, &str); 8] = [ + ("single", "Single"), + ("double", "Double"), + ("thick", "Thick"), + ("dotted", "Dotted"), + ("dash", "Dashed"), + ("dotDash", "Dot-dash"), + ("dotDotDash", "Dot-dot-dash"), + ("wave", "Wave"), +]; + +/// Home › Font › Underline's drop-down (#2738): a sample of each line, Words only, None, More +/// Underlines… (the Font dialog) and Underline Color. +pub(super) fn underline_menu(ui: &mut Ui, app: &mut WordApp) { + for (style, name) in UNDERLINE_STYLES { + underline_item(ui, app, style, name); + } + mi(ui, app, "Words only", "format.wordUnderline", json!({})); + // Not `mi`: Ctrl+U beside None would read as its shortcut, but Ctrl+U toggles a single underline. + let enabled = crate::widgets::enabled(app, "format.underline"); + if ui.add_enabled(enabled, egui::Button::new(tl!("None")).min_size(vec2(200.0, 0.0))).clicked() { + crate::widgets::run_from_ribbon(app, "format.underline", json!({"value": false})); + ui.close(); + } + ui.separator(); + mi(ui, app, "More Underlines…", "format.fontDialog", json!({})); + ui.menu_button(tl!("Underline Color"), |ui| { + mi(ui, app, "Automatic", "format.underlineColor", json!({"color": "auto"})); + let theme = app.session.doc.settings.theme_colors.clone(); + crate::colors::menu(ui, app, &theme, Target::cmd("format.underlineColor")); + }); +} + +/// A menu entry drawn as a sample of its underline; its name is the tooltip and what assistive +/// technology reads. +fn underline_item(ui: &mut Ui, app: &mut WordApp, style: &str, name: &str) { + let enabled = crate::widgets::enabled(app, "format.underline"); + let resp = ui.add_enabled(enabled, egui::Button::new("").min_size(vec2(200.0, 22.0))); + let label = tl!(name); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); + let t = Tokens::get(ui.ctx()); + let color = if enabled { t.text } else { t.text_disabled }; + paint_underline(ui.painter(), resp.rect.shrink2(vec2(10.0, 0.0)), style, color); + if resp.on_hover_text(label).clicked() { + crate::widgets::run_from_ribbon(app, "format.underline", json!({"style": style})); + ui.close(); + } +} + +/// A line in `style` across `r` at its middle: our own drawing of each underline kind. +fn paint_underline(p: &egui::Painter, r: Rect, style: &str, color: egui::Color32) { + let y = r.center().y.round() + 0.5; + let (x0, x1) = (r.left(), r.right()); + let line = |y: f32, w: f32| { + p.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(w, color)); + }; + // Repeats a pattern of (length, gap) pairs along the line; a length of 0 is a dot. + let pattern = |pat: &[(f32, f32)]| { + let mut x = x0; + 'run: loop { + for &(len, gap) in pat { + if x >= x1 { + break 'run; + } + if len == 0.0 { + p.circle_filled(pos2(x + 0.75, y), 0.75, color); + x += 1.5 + gap; + } else { + p.line_segment([pos2(x, y), pos2((x + len).min(x1), y)], Stroke::new(1.0, color)); + x += len + gap; + } + } + } + }; + match style { + "double" => { + line(y - 1.5, 1.0); + line(y + 1.5, 1.0); + } + "thick" => line(y, 2.5), + "dotted" => pattern(&[(0.0, 2.0)]), + "dash" => pattern(&[(6.0, 4.0)]), + "dotDash" => pattern(&[(6.0, 3.0), (0.0, 3.0)]), + "dotDotDash" => pattern(&[(6.0, 3.0), (0.0, 2.5), (0.0, 3.0)]), + "wave" => { + let pts: Vec<_> = + (0..=((x1 - x0) as usize)).map(|i| pos2(x0 + i as f32, y + 1.6 * (i as f32 * std::f32::consts::TAU / 6.0).sin())).collect(); + p.add(egui::Shape::line(pts, Stroke::new(1.0, color))); + } + _ => line(y, 1.0), + } +} diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs new file mode 100644 index 000000000..730c97393 --- /dev/null +++ b/crates/ui-egui/src/ribbon/view.rs @@ -0,0 +1,159 @@ +use super::common::stack; +use super::*; + +/// Draws the View tab's Views, Immersive, Page Movement, Show, Zoom, Dark Mode, Window and Macros +/// and Templates groups. +pub(super) fn view(app: &mut WordApp, ui: &mut Ui) { + let v = app.session.view.clone(); + group(ui, "Views", None, app, |ui, app| { + big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false); + big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false); + big(ui, app, "webLayout", "Web\nLayout", "view.webLayout", json!({}), false); + stack(ui, |ui| { + small(ui, app, "outline", Some("Outline"), "Outline", "view.outline", json!({}), false); + small(ui, app, "draft", Some("Draft"), "Draft", "view.draft", json!({}), false); + }); + }); + group(ui, "Immersive", None, app, |ui, app| { + big(ui, app, "focus", "Focus", "view.focus", json!({}), false); + big(ui, app, "immersive", "Immersive\nReader", "view.immersive", json!({}), false); + }); + group(ui, "Page Movement", None, app, |ui, app| { + big(ui, app, "vertical", "Vertical", "view.vertical", json!({}), false); + big(ui, app, "sideToSide", "Side\nto Side", "view.sideToSide", json!({}), false); + }); + group(ui, "Show", None, app, |ui, app| { + // Three rows fill the group's height; a fourth check box would run into its label. + stack(ui, |ui| { + let mut r = v.ruler; + if ui.checkbox(&mut r, tl!("Ruler")).changed() { + let _ = app.run("view.ruler", json!({"value": r})); + } + let mut g = v.gridlines; + if ui.checkbox(&mut g, tl!("Gridlines")).changed() { + let _ = app.run("view.gridlines", json!({"value": g})); + } + let mut n = v.nav_pane; + if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() { + let _ = app.run("view.navigationPane", json!({"value": n})); + } + }); + stack(ui, |ui| { + let mut w = v.hide_white_space; + if ui.checkbox(&mut w, tl!("Hide White Space")).changed() { + let _ = app.run("view.hideWhiteSpace", json!({"value": w})); + } + }); + }); + group(ui, "Zoom", None, app, |ui, app| { + big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false); + big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false); + // Step the zoom up and down by 10% (issue #67), from whatever the page shows now. + stack(ui, |ui| { + small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false); + small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false); + }); + stack(ui, |ui| { + small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage"); + small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page); + small(ui, app, "pageWidth", Some("Page Width"), "Page Width", "view.pageWidth", json!({}), v.fit == "pageWidth"); + }); + }); + group(ui, "Dark Mode", None, app, |ui, app| { + big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false); + menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, "", |ui, app| { + for a in crate::theme::Appearance::ALL { + if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() { + let _ = app.run("view.interfaceTheme", json!({"value": a.code()})); + ui.close(); + } + } + }); + }); + group(ui, "Window", None, app, |ui, app| { + big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); + big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); + big(ui, app, "split", "Split", "view.split", json!({}), false); + menu_button( + ui, + app, + "switchWindows", + Some("Switch\nWindows"), + "Switch Windows", + true, + "view.switchWindows", + crate::documents::switch_windows_menu, + ); + // The Full Screen button carries its name for assistive technology (and for the + // regression test that clicks it): big buttons are otherwise drawn unlabeled. Its + // enabled state is the command's own, as every other ribbon control reports: the + // browser has no full screen to ask for, and says so (#662). + let on = crate::widgets::enabled(app, "view.fullScreen"); + let resp = big_toggle(ui, app, "fullScreen", "Full\nScreen", "view.fullScreen", v.full_screen); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, v.full_screen, "Full Screen")); + }); + group(ui, "Macros", None, app, |ui, app| { + big(ui, app, "macros", "Macros", "tools.macros", json!({}), false); + }); + group(ui, "Templates", None, app, |ui, app| { + big(ui, app, "documentTemplate", "Templates", "tools.templates", json!({}), false); + }); +} + +/// The Outlining tab (Outline view): outline levels, moving, expanding and collapsing, what is +/// shown, and closing the view. +pub(super) fn outlining(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_engine::layout::outline::{BODY, para_level}; + let o = app.session.view.outline.clone(); + let level = app.session.doc.para_at(&app.session.sel.focus).map(|p| para_level(&app.session.doc, p)).unwrap_or(BODY); + let name = |l: u8| if l >= BODY { tl!("Body Text").to_string() } else { crate::i18n::fmt(tl!("Level {n}"), &[("n", &l.to_string())]) }; + group(ui, "Outline Tools", None, app, |ui, app| { + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + small(ui, app, "outlineTop", None, "Promote to Heading 1", "outline.promoteToHeading1", json!({}), false); + small(ui, app, "outlinePromote", None, "Promote", "outline.promote", json!({}), false); + let levels: Vec = (1..=BODY).map(name).collect(); + if let Some(v) = crate::widgets::combo_hint(ui, "outlineLevel", 100.0, &name(level), &levels, None, tl!("Outline Level")) + && let Some(i) = levels.iter().position(|x| *x == v) + { + let l = if i + 1 >= usize::from(BODY) { json!("body") } else { json!(i + 1) }; + let _ = app.run("outline.level", json!({ "level": l })); + } + small(ui, app, "outlineDemote", None, "Demote", "outline.demote", json!({}), false); + small(ui, app, "outlineBody", None, "Demote to Body Text", "outline.demoteToBody", json!({}), false); + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + small(ui, app, "outlineUp", None, "Move Up", "outline.moveUp", json!({}), false); + small(ui, app, "outlineDown", None, "Move Down", "outline.moveDown", json!({}), false); + small(ui, app, "outlineExpand", None, "Expand", "outline.expand", json!({}), false); + small(ui, app, "outlineCollapse", None, "Collapse", "outline.collapse", json!({}), false); + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Show Level")).size(11.5)); + let mut shown: Vec = vec![tl!("All Levels").to_string()]; + shown.extend((1..BODY).map(name)); + let current = shown.get(usize::from(o.show_level)).cloned().unwrap_or_default(); + if let Some(v) = crate::widgets::combo_hint(ui, "outlineShowLevel", 90.0, ¤t, &shown, None, tl!("Show Level")) + && let Some(i) = shown.iter().position(|x| *x == v) + { + let l = if i == 0 { json!("all") } else { json!(i) }; + let _ = app.run("outline.showLevel", json!({ "level": l })); + } + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + let mut f = !o.hide_formatting; + if ui.checkbox(&mut f, tl!("Show Text Formatting")).changed() { + let _ = app.run("outline.showFormatting", json!({ "value": f })); + } + let mut first = o.first_line_only; + if ui.checkbox(&mut first, tl!("Show First Line Only")).changed() { + let _ = app.run("outline.firstLineOnly", json!({ "value": first })); + } + }); + }); + }); + group(ui, "Close", None, app, |ui, app| { + big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false); + }); +}