//! The ribbon: tab strip and every tab's groups. Each control runs a command by id. use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; use crate::colors::{Remember, Target}; use crate::theme::{Tokens, medium, regular, semibold}; use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo, group, menu_button, small, split}; use crate::{WordApp, icons}; 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. 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(); // 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 { "Home" } } /// 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() } pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if app.session.view.read_mode { reading_toolbar(app, ui, t); return; } // Editing an equation brings up the Equation tab; leaving it goes back. if app.session.math.is_some() { if app.equation_prev_tab.is_none() { app.equation_prev_tab = Some(app.ui.tab.clone()); app.ui.tab = "Equation".into(); } } else if let Some(prev) = app.equation_prev_tab.take() && app.ui.tab == "Equation" { app.ui.tab = prev; } // Outline view brings up the Outlining tab; closing it goes back. if app.session.view.mode == wordcraft_layout::ViewMode::Outline { if app.outline_prev_tab.is_none() { app.outline_prev_tab = Some(app.ui.tab.clone()); app.ui.tab = "Outlining".into(); } } else if let Some(prev) = app.outline_prev_tab.take() && app.ui.tab == "Outlining" { app.ui.tab = prev; } // The tabs shown: File, the user's tabs in their order (Customize Ribbon, #379), then the // contextual ones (the Outlining tab right after File). A contextual tab (Table, Picture // Format) may be stored while the selection moved away, and a tab may have been hidden. let user_tabs = app.session.ribbon.tabs().into_owned(); let mut tabs: Vec<(String, bool)> = vec![("File".to_string(), false)]; tabs.extend(user_tabs.iter().filter(|e| !e.hidden).map(|e| (e.name.clone(), e.custom))); for ct in contextual_tabs(&app.session) { if ct == "Outlining" { tabs.insert(1.min(tabs.len()), (ct.to_string(), false)); } else if !tabs.iter().any(|(n, _)| n == ct) { tabs.push((ct.to_string(), false)); } } { let names: Vec<&str> = tabs.iter().map(|(n, _)| n.as_str()).collect(); let next = resolve_tab(&app.ui.tab, &names); let next = if names.contains(&next) { next } else { names.iter().copied().find(|n| *n != "File").unwrap_or(next) }; if next != app.ui.tab { app.ui.tab = next.to_string(); } } // Tab strip. egui::Panel::top("tabs") .exact_size(30.0) .frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 0.0); for (tab, custom) in &tabs { let (tab, custom) = (tab.as_str(), *custom); let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab); // A custom tab's name is the user's own text: shown as typed. let shown = if custom { tab } else { tl!(tab) }; let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0; let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); if app.ui.keytips == crate::keytips::Phase::Tabs && app.ui.keytip_prefix.is_empty() { crate::keytips::record_tab(r, tab, &mut app.keytip_rects); } let active = app.ui.tab == tab && !app.ui.backstage; if resp.hovered() && !active { ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); } let color = if contextual || active { t.accent_text } else { t.text }; ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, if active { semibold(12.5) } else { medium(12.5) }, color); if active { let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); ui.painter().rect_filled(u, 2.0, t.accent); } if resp.clicked() { let _ = app.run("ui.tab", json!({"tab": tab})); } if resp.double_clicked() && tab != "File" { let _ = app.run("ui.collapseRibbon", json!({})); } resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Share, Editing mode, Comments. let share = tl!("Share"); let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x); let (r, resp) = ui.allocate_exact_size(vec2((share_w + 36.0).max(74.0), 24.0), Sense::click()); ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent }); icons::paint( ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), "share", t.on_accent, t.on_accent, ); ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent); if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); } ui.add_space(6.0); let track = app.session.doc.settings.track_changes; let mode = crate::widgets::icon_text_menu_button(ui, "pencil", if track { tl!("Reviewing") } else { tl!("Editing") }, regular(12.0)); egui::Popup::menu(&mode).show(|ui| { if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { let _ = app.run("review.trackChanges", json!({"value": false})); ui.close(); } if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() { let _ = app.run("review.trackChanges", json!({"value": true})); ui.close(); } }); ui.add_space(4.0); if crate::widgets::icon_text_button(ui, "comment", tl!("Comments"), regular(12.0)).clicked() { let _ = app.run("view.commentsPane", json!({})); } }); }); }); if app.ui.ribbon_collapsed { return; } egui::Panel::top("ribbon") .exact_size(CONTENT_H + LABEL_H + 10.0) .frame( egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)), ) .show(ui, |ui| { // Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so // the buttons drawn over it keep their own clicks. let bg = ui.interact(ui.max_rect(), ui.id().with("ribbon_background"), Sense::click()); bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); let entry = user_tabs.iter().find(|e| e.name == app.ui.tab); let scroll = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); match entry { // Every tab hidden: nothing to show. Some(e) if e.hidden => return, Some(e) if e.custom => return custom_groups(app, ui, &e.groups), _ => {} } match app.ui.tab.as_str() { "Home" => home(app, ui), "Insert" => insert(app, ui), "Draw" => draw(app, ui), "Design" => design(app, ui), "Layout" => layout(app, ui), "References" => references(app, ui), "Mailings" => mailings(app, ui), "Review" => review(app, ui), "View" => view(app, ui), "Developer" => developer(app, ui), "Zotero" => zotero(app, ui), "Help" => help(app, ui), "Table Design" => table_design(app, ui), "Table Layout" => table_layout(app, ui), "Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui), "Picture Format" => picture_format(app, ui), "Shape Format" => shape_format(app, ui), "SmartArt Design" => smart_art_design(app, ui), "Outlining" => outlining(app, ui), "Chart Design" => chart_design(app, ui), _ => home(app, ui), } if let Some(e) = entry { custom_groups(app, ui, &e.groups); } }); }); // The bar is hidden, so a row wider than the window would be clipped with no sign. Edge chevrons show // where more is and scroll it on click. Every command also stays reachable through Search Commands. let r = scroll.inner_rect; let overflow = scroll.content_size.x - r.width(); let offset = scroll.state.offset.x; let step = 160.0; let mut next = None; if offset > 1.0 { let left = Rect::from_min_max(r.left_top(), pos2(r.left() + 18.0, r.bottom())); if edge_cue(ui, left, "‹", tl!("Scroll ribbon left"), &t) { next = Some(offset - step); } } if overflow - offset > 1.0 { let right = Rect::from_min_max(pos2(r.right() - 18.0, r.top()), r.right_bottom()); if edge_cue(ui, right, "›", tl!("Scroll ribbon right"), &t) { next = Some(offset + step); } } if let Some(x) = next { let mut state = scroll.state; state.offset.x = x.clamp(0.0, overflow.max(0.0)); state.store(ui.ctx(), scroll.id); } }); } /// 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.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() } 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); }); } pub(crate) fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { mi_text(ui, app, tl!(label), id, params); } /// 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); 4] = [("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish"), ("pt-BR", "Portuguese (Brazil)")]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's /// default, US English; a tag without a name here: the tag). pub(crate) fn language_name(tag: Option<&str>) -> String { let tag = tag.unwrap_or("en-US"); if let Some((_, name)) = PROOFING_LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)) { return (*name).to_string(); } match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), _ => tag.to_string(), } } /// The language at the caret (resolved through the styles), translated, for the status bar. pub(crate) fn caret_language(app: &WordApp) -> String { let s = &app.session; let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()); let rc = s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()); tl!(&language_name(rc.lang.as_deref())).to_string() } /// 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. pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { let st = app.session.run("format.state", &json!({})).unwrap_or_default(); let current = st.get("lang").and_then(Value::as_str).unwrap_or("").to_string(); let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); for (tag, name) in PROOFING_LANGUAGES { let on = current.eq_ignore_ascii_case(tag) || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")) || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")); 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})); } /// 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) { // An invisible mark keeps the unchecked labels aligned with the checked one. let mark = egui::RichText::new("✓"); let mark = if checked { mark } else { mark.color(egui::Color32::TRANSPARENT) }; 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})); } 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); let r = small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false); if r.clicked() { ui.ctx().copy_text(app.session.clipboard_text.clone()); } let active = app.session.painter.is_some(); small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), active); }); }); 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 prev = app.previews.font_preview_fn(); if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) { let _ = if f == crate::widgets::ADD_FONT { app.run("font.add", 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) = combo(ui, "size", 52.0, &size, &sizes, None) && 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, |ui, app| { for (l, s) in [ ("Single", "single"), ("Double", "double"), ("Thick", "thick"), ("Dotted", "dotted"), ("Dashed", "dash"), ("Dot-dash", "dotDash"), ("Wave", "wave"), ("Words only", "words"), ] { mi(ui, app, l, "format.underline", json!({"style": s})); } }); 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(); split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| { let grid = wordcraft_doc::props::Highlight::ALL; egui::Grid::new("hl").spacing(vec2(3.0, 3.0)).show(ui, |ui| { for (i, h) in grid.iter().enumerate().skip(1) { let c = h.rgb().unwrap_or(wordcraft_doc::Rgb::WHITE); let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2)); if resp.on_hover_text(h.name()).clicked() { app.canvas.last_highlight = h.ooxml().to_string(); let _ = app.run("format.highlight", json!({"color": h.ooxml()})); ui.close(); } if i % 5 == 0 { ui.end_row(); } } }); mi(ui, app, "No Color", "format.highlight", json!({"color": "none"})); }); 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(); 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| { mi(ui, app, "Find", "ui.dialog", json!({"name": "find"})); 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(ui, app, l, "insert.shape", json!({"kind": k})); } ui.separator(); mi(ui, app, "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"), ] { mi(ui, app, 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…", "layout.pageNumberFormat", json!({})); }); }); 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| { let parts: Vec = app.session.building_blocks.keys().cloned().collect(); 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") }); small(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", "insert.dropCap", json!({}), false); }); 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"})); }); }); } 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"), ] { 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); }); } 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", |ui, app| { for (l, k) in [ ("Normal 1\" all", "normal"), ("Narrow 0.5\" all", "narrow"), ("Moderate", "moderate"), ("Wide", "wide"), ("Mirrored", "mirrored"), ("Office 2003 Default", "office2003"), ] { mi(ui, app, l, "layout.margins", json!({"preset": k})); } mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"})); }); menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, "layout.orientation", |ui, app| { mi(ui, app, "Portrait", "layout.orientation", json!({"value": "portrait"})); mi(ui, app, "Landscape", "layout.orientation", json!({"value": "landscape"})); }); menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", |ui, app| { for (n, w, h) in wordcraft_geom::PAPER_SIZES { mi(ui, app, &format!("{n} {:.2}\" × {:.2}\"", w / 72.0, h / 72.0), "layout.size", json!({"name": n})); } }); menu_button(ui, app, "columns", Some("Columns"), "Columns", true, "layout.columns", |ui, app| { mi(ui, app, "One", "layout.columns", json!({"count": 1})); mi(ui, app, "Two", "layout.columns", json!({"count": 2})); mi(ui, app, "Three", "layout.columns", json!({"count": 3})); mi(ui, app, "Left", "layout.columns", json!({"preset": "left"})); mi(ui, app, "Right", "layout.columns", json!({"preset": "right"})); ui.separator(); mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"})); }); 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; // 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; egui::Grid::new("layout_para").spacing(vec2(4.0, 3.0)).min_row_height(FIELD.y).show(ui, |ui| { ui.label(egui::RichText::new(tl!("Indent")).small().strong()); ui.label(""); ui.add_space(8.0); ui.label(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).suffix(u.suffix()).max_decimals(2)) .changed() { let _ = app.run("para.indents", json!({"left": l * k})); } ui.add_space(8.0); ui.label(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).suffix(u.suffix()).max_decimals(2)) .changed() { let _ = app.run("para.indents", json!({"right": r * k})); } ui.add_space(8.0); ui.label(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| { small(ui, app, "align", None, "Align", "arrange.align", json!({}), false); 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!({})); }); } 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, "previous", None, "Previous Record", "mailings.previous", json!({}), false); small(ui, app, "next", None, "Next Record", "mailings.next", 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| { big(ui, app, "finish", "Finish &\nMerge", "mailings.finish", json!({}), false); }); } /// 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 This Change", "review.accept", json!({})); mi(ui, app, "Accept All Changes", "review.acceptAll", json!({})); }); menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| { mi(ui, app, "Reject Change", "review.reject", json!({})); mi(ui, app, "Reject All Changes", "review.rejectAll", json!({})); }); 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"), ], ]; 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); } }); ui.add_space(3.0); } }); 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); }); }); } 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| { 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})); } 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("ui.theme", 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); }); 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) = combo(ui, "outlineLevel", 100.0, &name(level), &levels, None) && 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) = combo(ui, "outlineShowLevel", 90.0, ¤t, &shown, None) && 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, "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); }); } /// 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); }); 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; let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).suffix(" pt")); if r.changed() { if r.dragged() && !r.drag_started() { app.session.join_next_undo(); } let _ = app.run("picture.size", json!({key: v, "lockAspect": false})); } }); } }); }); } /// 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) = match wordcraft_engine::cmd::objects::selected(&app.session) { Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, .. })) => (stroke, effects.glow.map(|g| g.size)), _ => (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")); }); 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")); small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false); }); }); 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; let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(4.0..=2000.0).suffix(" pt")); if r.changed() { 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})); } }); } 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})); } }); 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")); small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false); group_menu(ui, app, Some("Group")); }); }); group(ui, "Size", None, 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. stack(ui, |ui| { crate::widgets::row(ui, |ui| { ui.label(egui::RichText::new(tl!("W:")).small()); let mut w = w0; let r = ui.add(egui::DragValue::new(&mut w).speed(1.0).range(4.0..=2000.0).suffix(format!(" {}", tl!("pt")))); if r.changed() { if r.dragged() && !r.drag_started() { app.session.join_next_undo(); } let _ = app.run("picture.size", json!({"width": w})); } ui.label(egui::RichText::new(tl!("H:")).small()); let mut h = h0; let r = ui.add(egui::DragValue::new(&mut h).speed(1.0).range(4.0..=2000.0).suffix(format!(" {}", tl!("pt")))); if r.changed() { if r.dragged() && !r.drag_started() { app.session.join_next_undo(); } let _ = app.run("picture.size", json!({"height": h})); } rotation_field(ui, app); }); ui.add_space(2.0); crate::widgets::row(ui, |ui| { 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..=45.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.45); 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::new(("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).suffix(unit.suffix()).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 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 { use super::*; use wordcraft_engine::Session; fn png_data() -> String { let img = image::RgbaImage::from_fn(20, 10, |x, y| { if x > 2 && x < 17 && y > 2 && y < 7 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([255, 255, 255, 255]) } }); let mut b = Vec::new(); image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png).unwrap(); wordcraft_engine::cmd::insert::base64_encode(&b) } /// The style set gallery fills exactly its own box and the next control starts after a gap, /// so Colors and Fonts can't overlap the thumbnails (#638). #[test] fn design_style_set_gallery_stays_in_its_box() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); let mut seen = None; let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 200.0)).build_ui(|ui| { ui.horizontal_top(|ui| { let (gallery, used) = style_set_box(&mut app, ui); seen = Some((gallery, used, ui.cursor().min.x)); }); }); h.run_steps(2); drop(h); let (gallery, used, next_x) = seen.unwrap(); assert!((gallery.width() - crate::previews::STYLE_SET_GALLERY_W).abs() < 0.01); assert!(used.right() <= gallery.right() + 0.01, "{used:?} {gallery:?}"); assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}"); } /// #658: menu buttons grey out (and don't open) while the command their menu is about is /// disabled: the Table Layout menus outside a table; menus with no command stay on. #[test] fn menu_buttons_follow_their_command() { use crate::widgets::menu_enabled; let mut a = crate::WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default()); for id in ["table.selectTable", "table.deleteTable", "table.autofit"] { assert!(!menu_enabled(&a, id), "{id} is off outside a table"); } assert!(menu_enabled(&a, ""), "a menu without a command is always on"); assert!(menu_enabled(&a, "insert.header")); a.run("insert.table", json!({"rows": 2, "cols": 2})).unwrap(); for id in ["table.selectTable", "table.deleteTable", "table.autofit"] { assert!(menu_enabled(&a, id), "{id} is on inside a table"); } } #[test] fn portuguese_has_a_name_in_the_language_menu() { assert!(PROOFING_LANGUAGES.iter().any(|(t, _)| *t == "pt-BR")); assert_eq!(language_name(Some("pt-BR")), "Portuguese (Brazil)"); assert_eq!(language_name(Some("pt")), "Portuguese (Brazil)"); assert_eq!(language_name(Some("PT-BR".to_string().as_str())), "Portuguese (Brazil)"); } #[test] fn picture_format_tab_appears_when_picture_selected() { let mut s = Session::new(wordcraft_doc::Document::new()); assert!(!has_picture_selected(&s)); assert!(!contextual_tabs(&s).contains(&"Picture Format")); s.run("insert.picture", &json!({"data": png_data()})).unwrap(); assert!(has_picture_selected(&s)); assert!(contextual_tabs(&s).contains(&"Picture Format")); s.run("select.collapse", &json!({"end": true})).unwrap(); s.run("text.insert", &json!({"text": "x"})).unwrap(); assert!(!has_picture_selected(&s)); } #[test] fn shape_format_tab_appears_when_shape_selected() { let mut s = Session::new(wordcraft_doc::Document::new()); s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap(); assert!(contextual_tabs(&s).contains(&"Shape Format")); assert!(!contextual_tabs(&s).contains(&"Picture Format")); s.run("select.collapse", &json!({"end": true})).unwrap(); s.run("text.insert", &json!({"text": "x"})).unwrap(); assert!(!contextual_tabs(&s).contains(&"Shape Format")); } #[test] fn page_setup_launcher_opens_the_dialog() { // A group launcher runs its command with no parameters (#471). let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); app.run("layout.pageSetup", json!({})).unwrap(); assert!(matches!(app.dialog, Some(crate::dialogs::Dialog::PageSetup { .. }))); // `ui.dialog` needs a dialog name, so it can't be a launcher. let launchers: Vec<&str> = include_str!("ribbon.rs") .split("group(ui, \"") .filter_map(|g| g.split_once('"')?.1.strip_prefix(", Some(\"")?.split('"').next()) .collect(); assert!(launchers.contains(&"layout.pageSetup"), "{launchers:?}"); assert!(!launchers.contains(&"ui.dialog"), "{launchers:?}"); } #[test] fn smart_art_picker_inserts_and_design_tab_follows_the_selection() { let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), Default::default()); // Insert › SmartArt without a layout opens the picker. let _ = app.run("insert.smartArt", json!({})); assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("insertSmartArt")); app.dialog = None; app.run("insert.smartArt", json!({"layout": "cycle"})).unwrap(); assert!(contextual_tabs(&app.session).contains(&"SmartArt Design")); app.run("smartArt.textPane", json!({})).unwrap(); assert!(app.session.view.smart_art_pane); app.session.run("select.collapse", &json!({"end": true})).unwrap(); app.session.run("text.insert", &json!({"text": "x"})).unwrap(); assert!(!contextual_tabs(&app.session).contains(&"SmartArt Design")); } #[test] fn chart_design_tab_appears_when_chart_selected_and_buttons_open_dialogs() { let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), Default::default()); // Insert › Chart without a type opens the gallery; choosing inserts. let _ = app.run("insert.chart", json!({})); assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("insertChart")); app.dialog = None; app.run("insert.chart", json!({"type": "doughnut"})).unwrap(); assert!(contextual_tabs(&app.session).contains(&"Chart Design")); let _ = app.run("chart.editData", json!({})); assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("chartData")); let _ = app.run("chart.type", json!({})); assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("changeChartType")); app.session.run("select.collapse", &json!({"end": true})).unwrap(); app.session.run("text.insert", &json!({"text": "x"})).unwrap(); assert!(!contextual_tabs(&app.session).contains(&"Chart Design")); } #[test] fn resolve_tab_falls_back_when_contextual_tab_expires() { let all = ["Home", "Table Design", "Picture Format"]; assert_eq!(resolve_tab("Picture Format", &all), "Picture Format"); assert_eq!(resolve_tab("Picture Format", &["Home"]), "Home"); assert_eq!(resolve_tab("Table Design", &["Home"]), "Home"); assert_eq!(resolve_tab("Home", &["Home"]), "Home"); } }