wordcraft/crates/ui-egui/src/ribbon.rs

2531 lines
135 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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(&current) { 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<String> =
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::<f64>()
{
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<String> = 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<usize>, 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", &current)]);
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<String> = (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<String> = 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, &current, &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<f32>) {
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::<String>(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::<String>(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");
}
}