2531 lines
135 KiB
Rust
2531 lines
135 KiB
Rust
//! The ribbon: tab strip and every tab's groups. Each control runs a command by id.
|
||
|
||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::colors::{Remember, Target};
|
||
use crate::theme::{Tokens, medium, regular, semibold};
|
||
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo, group, menu_button, small, split};
|
||
use crate::{WordApp, icons};
|
||
|
||
pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS;
|
||
|
||
/// True when the caret/selection touches a picture (#147).
|
||
pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool {
|
||
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. })))
|
||
}
|
||
|
||
/// Whether the selection is a shape, text box or Drawing Canvas (so Shape Format is shown).
|
||
pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
|
||
match wordcraft_engine::cmd::objects::selected(s) {
|
||
Some((_, o)) => matches!(o, wordcraft_doc::para::InlineObject::Shape { .. }) || o.is_canvas(),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// Reading controls replace editing tabs entirely. All settings are transient view controls.
|
||
fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) {
|
||
egui::Panel::top("reading_toolbar")
|
||
.exact_size(46.0)
|
||
.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(12, 6)).stroke(Stroke::new(1.0, t.border)))
|
||
.show(ui, |ui| {
|
||
ui.horizontal_centered(|ui| {
|
||
ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0)));
|
||
ui.separator();
|
||
let w = app.session.view.read_column_width;
|
||
let active = match w {
|
||
None => "Auto",
|
||
Some(v) if v <= 180.0 => "Narrow",
|
||
Some(v) if v <= 240.0 => "Medium",
|
||
Some(v) if v <= 350.0 => "Wide",
|
||
_ => "Page",
|
||
};
|
||
ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| {
|
||
for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] {
|
||
if ui.selectable_label(active == name, tl!(name)).clicked() {
|
||
let _ = app.run("view.readColumnWidth", json!({"value": value}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
let pct = (app.session.view.read_zoom * 100.0).round() as u32;
|
||
ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| {
|
||
for percent in [75, 90, 100, 110, 125, 150] {
|
||
if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() {
|
||
let _ = app.run("view.readTextSize", json!({"value": percent}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
let spacing = app.session.view.read_line_spacing;
|
||
let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×"));
|
||
ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| {
|
||
if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() {
|
||
let _ = app.run("view.readLineSpacing", json!({"value": "document"}));
|
||
ui.close();
|
||
}
|
||
for value in [1.0, 1.25, 1.5, 2.0] {
|
||
if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() {
|
||
let _ = app.run("view.readLineSpacing", json!({"value": value}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
// Headings to jump to, in the Navigation pane beside the columns (Outline view
|
||
// is an editing layout, so it isn't offered here).
|
||
if ui.button(tl!("Navigation Pane")).clicked() {
|
||
let _ = app.run("view.navigationPane", json!({"value": true}));
|
||
app.ui.nav_tab = "headings".into();
|
||
}
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui.button(tl!("Edit Document")).clicked() {
|
||
let _ = app.run("view.printLayout", json!({}));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Whether the selection is a SmartArt graphic WordCraft can edit (so SmartArt Design is shown).
|
||
pub fn has_smart_art_selected(s: &wordcraft_engine::Session) -> bool {
|
||
wordcraft_engine::cmd::smart_art::selected_smart_art(s).is_some()
|
||
}
|
||
|
||
/// Whether the selection is a chart WordCraft can edit (so Chart Design is shown).
|
||
pub fn has_chart_selected(s: &wordcraft_engine::Session) -> bool {
|
||
wordcraft_engine::cmd::chart::selected_chart(s).is_some()
|
||
}
|
||
|
||
/// Contextual tabs for the current selection (pure, tested).
|
||
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
|
||
let mut tabs = Vec::new();
|
||
if s.sel.focus.path.cell().is_some() {
|
||
tabs.push("Table Design");
|
||
tabs.push("Table Layout");
|
||
}
|
||
if has_picture_selected(s) {
|
||
tabs.push("Picture Format");
|
||
}
|
||
if has_shape_selected(s) {
|
||
tabs.push("Shape Format");
|
||
}
|
||
if has_smart_art_selected(s) {
|
||
tabs.push("SmartArt Design");
|
||
}
|
||
if has_chart_selected(s) {
|
||
tabs.push("Chart Design");
|
||
}
|
||
// Editing an equation.
|
||
if s.math.is_some() {
|
||
tabs.push("Equation");
|
||
}
|
||
// Outline view (drawn first, next to File, as Word does).
|
||
if s.view.mode == wordcraft_layout::ViewMode::Outline {
|
||
tabs.push("Outlining");
|
||
}
|
||
tabs
|
||
}
|
||
|
||
/// Tab to show when the stored tab is no longer applicable (pure, tested).
|
||
pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str {
|
||
if available.contains(¤t) { current } else { "Home" }
|
||
}
|
||
|
||
/// Whether the caret is inside a table (so the contextual tabs and their badges are shown).
|
||
pub fn in_table_public(app: &WordApp) -> bool {
|
||
app.session.sel.focus.path.cell().is_some()
|
||
}
|
||
|
||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
if app.session.view.read_mode {
|
||
reading_toolbar(app, ui, t);
|
||
return;
|
||
}
|
||
// Editing an equation brings up the Equation tab; leaving it goes back.
|
||
if app.session.math.is_some() {
|
||
if app.equation_prev_tab.is_none() {
|
||
app.equation_prev_tab = Some(app.ui.tab.clone());
|
||
app.ui.tab = "Equation".into();
|
||
}
|
||
} else if let Some(prev) = app.equation_prev_tab.take()
|
||
&& app.ui.tab == "Equation"
|
||
{
|
||
app.ui.tab = prev;
|
||
}
|
||
// Outline view brings up the Outlining tab; closing it goes back.
|
||
if app.session.view.mode == wordcraft_layout::ViewMode::Outline {
|
||
if app.outline_prev_tab.is_none() {
|
||
app.outline_prev_tab = Some(app.ui.tab.clone());
|
||
app.ui.tab = "Outlining".into();
|
||
}
|
||
} else if let Some(prev) = app.outline_prev_tab.take()
|
||
&& app.ui.tab == "Outlining"
|
||
{
|
||
app.ui.tab = prev;
|
||
}
|
||
// The tabs shown: File, the user's tabs in their order (Customize Ribbon, #379), then the
|
||
// contextual ones (the Outlining tab right after File). A contextual tab (Table, Picture
|
||
// Format) may be stored while the selection moved away, and a tab may have been hidden.
|
||
let user_tabs = app.session.ribbon.tabs().into_owned();
|
||
let mut tabs: Vec<(String, bool)> = vec![("File".to_string(), false)];
|
||
tabs.extend(user_tabs.iter().filter(|e| !e.hidden).map(|e| (e.name.clone(), e.custom)));
|
||
for ct in contextual_tabs(&app.session) {
|
||
if ct == "Outlining" {
|
||
tabs.insert(1.min(tabs.len()), (ct.to_string(), false));
|
||
} else if !tabs.iter().any(|(n, _)| n == ct) {
|
||
tabs.push((ct.to_string(), false));
|
||
}
|
||
}
|
||
{
|
||
let names: Vec<&str> = tabs.iter().map(|(n, _)| n.as_str()).collect();
|
||
let next = resolve_tab(&app.ui.tab, &names);
|
||
let next = if names.contains(&next) { next } else { names.iter().copied().find(|n| *n != "File").unwrap_or(next) };
|
||
if next != app.ui.tab {
|
||
app.ui.tab = next.to_string();
|
||
}
|
||
}
|
||
// Tab strip.
|
||
egui::Panel::top("tabs")
|
||
.exact_size(30.0)
|
||
.frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 }))
|
||
.show(ui, |ui| {
|
||
ui.horizontal_centered(|ui| {
|
||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||
for (tab, custom) in &tabs {
|
||
let (tab, custom) = (tab.as_str(), *custom);
|
||
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab);
|
||
// A custom tab's name is the user's own text: shown as typed.
|
||
let shown = if custom { tab } else { tl!(tab) };
|
||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||
if app.ui.keytips == crate::keytips::Phase::Tabs && app.ui.keytip_prefix.is_empty() {
|
||
crate::keytips::record_tab(r, tab, &mut app.keytip_rects);
|
||
}
|
||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||
if resp.hovered() && !active {
|
||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||
}
|
||
let color = if contextual || active { t.accent_text } else { t.text };
|
||
ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, if active { semibold(12.5) } else { medium(12.5) }, color);
|
||
if active {
|
||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||
ui.painter().rect_filled(u, 2.0, t.accent);
|
||
}
|
||
if resp.clicked() {
|
||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||
}
|
||
if resp.double_clicked() && tab != "File" {
|
||
let _ = app.run("ui.collapseRibbon", json!({}));
|
||
}
|
||
resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
|
||
}
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
// Share, Editing mode, Comments.
|
||
let share = tl!("Share");
|
||
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
|
||
let (r, resp) = ui.allocate_exact_size(vec2((share_w + 36.0).max(74.0), 24.0), Sense::click());
|
||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||
icons::paint(
|
||
ui.painter(),
|
||
Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)),
|
||
"share",
|
||
t.on_accent,
|
||
t.on_accent,
|
||
);
|
||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent);
|
||
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
|
||
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
|
||
}
|
||
ui.add_space(6.0);
|
||
let track = app.session.doc.settings.track_changes;
|
||
let mode =
|
||
crate::widgets::icon_text_menu_button(ui, "pencil", if track { tl!("Reviewing") } else { tl!("Editing") }, regular(12.0));
|
||
egui::Popup::menu(&mode).show(|ui| {
|
||
if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() {
|
||
let _ = app.run("review.trackChanges", json!({"value": false}));
|
||
ui.close();
|
||
}
|
||
if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() {
|
||
let _ = app.run("review.trackChanges", json!({"value": true}));
|
||
ui.close();
|
||
}
|
||
});
|
||
ui.add_space(4.0);
|
||
if crate::widgets::icon_text_button(ui, "comment", tl!("Comments"), regular(12.0)).clicked() {
|
||
let _ = app.run("view.commentsPane", json!({}));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
if app.ui.ribbon_collapsed {
|
||
return;
|
||
}
|
||
egui::Panel::top("ribbon")
|
||
.exact_size(CONTENT_H + LABEL_H + 10.0)
|
||
.frame(
|
||
egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)),
|
||
)
|
||
.show(ui, |ui| {
|
||
// Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so
|
||
// the buttons drawn over it keep their own clicks.
|
||
let bg = ui.interact(ui.max_rect(), ui.id().with("ribbon_background"), Sense::click());
|
||
bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
|
||
let entry = user_tabs.iter().find(|e| e.name == app.ui.tab);
|
||
let scroll = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| {
|
||
ui.horizontal_top(|ui| {
|
||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||
match entry {
|
||
// Every tab hidden: nothing to show.
|
||
Some(e) if e.hidden => return,
|
||
Some(e) if e.custom => return custom_groups(app, ui, &e.groups),
|
||
_ => {}
|
||
}
|
||
match app.ui.tab.as_str() {
|
||
"Home" => home(app, ui),
|
||
"Insert" => insert(app, ui),
|
||
"Draw" => draw(app, ui),
|
||
"Design" => design(app, ui),
|
||
"Layout" => layout(app, ui),
|
||
"References" => references(app, ui),
|
||
"Mailings" => mailings(app, ui),
|
||
"Review" => review(app, ui),
|
||
"View" => view(app, ui),
|
||
"Developer" => developer(app, ui),
|
||
"Zotero" => zotero(app, ui),
|
||
"Help" => help(app, ui),
|
||
"Table Design" => table_design(app, ui),
|
||
"Table Layout" => table_layout(app, ui),
|
||
"Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui),
|
||
"Picture Format" => picture_format(app, ui),
|
||
"Shape Format" => shape_format(app, ui),
|
||
"SmartArt Design" => smart_art_design(app, ui),
|
||
"Outlining" => outlining(app, ui),
|
||
"Chart Design" => chart_design(app, ui),
|
||
_ => home(app, ui),
|
||
}
|
||
if let Some(e) = entry {
|
||
custom_groups(app, ui, &e.groups);
|
||
}
|
||
});
|
||
});
|
||
// The bar is hidden, so a row wider than the window would be clipped with no sign. Edge chevrons show
|
||
// where more is and scroll it on click. Every command also stays reachable through Search Commands.
|
||
let r = scroll.inner_rect;
|
||
let overflow = scroll.content_size.x - r.width();
|
||
let offset = scroll.state.offset.x;
|
||
let step = 160.0;
|
||
let mut next = None;
|
||
if offset > 1.0 {
|
||
let left = Rect::from_min_max(r.left_top(), pos2(r.left() + 18.0, r.bottom()));
|
||
if edge_cue(ui, left, "‹", tl!("Scroll ribbon left"), &t) {
|
||
next = Some(offset - step);
|
||
}
|
||
}
|
||
if overflow - offset > 1.0 {
|
||
let right = Rect::from_min_max(pos2(r.right() - 18.0, r.top()), r.right_bottom());
|
||
if edge_cue(ui, right, "›", tl!("Scroll ribbon right"), &t) {
|
||
next = Some(offset + step);
|
||
}
|
||
}
|
||
if let Some(x) = next {
|
||
let mut state = scroll.state;
|
||
state.offset.x = x.clamp(0.0, overflow.max(0.0));
|
||
state.store(ui.ctx(), scroll.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Custom groups (Customize Ribbon, #379): up to three commands as large buttons, more as small
|
||
/// labelled buttons in columns of three. Each shows the command's own icon and label.
|
||
fn custom_groups(app: &mut WordApp, ui: &mut Ui, groups: &[wordcraft_engine::ribbon::CustomGroup]) {
|
||
for g in groups {
|
||
let ids: Vec<(&str, String)> =
|
||
g.commands.iter().filter_map(|id| app.session.registry.get(id).map(|s| (s.id, tl!(s.label).to_string()))).collect();
|
||
crate::widgets::group_titled(ui, &g.name, &g.name, None, app, |ui, app| {
|
||
if ids.len() <= 3 {
|
||
for (id, label) in &ids {
|
||
big(ui, app, crate::widgets::command_icon(id), &two_lines(label), id, json!({}), false);
|
||
}
|
||
return;
|
||
}
|
||
for column in ids.chunks(3) {
|
||
stack(ui, |ui| {
|
||
for (id, label) in column {
|
||
small(ui, app, crate::widgets::command_icon(id), Some(label), label, id, json!({}), false);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// A large button's label on two lines when it is long (broken at the space nearest the middle).
|
||
fn two_lines(label: &str) -> String {
|
||
let n = label.chars().count();
|
||
if n <= 10 {
|
||
return label.to_string();
|
||
}
|
||
let mid = n / 2;
|
||
let best = label.char_indices().filter(|(_, c)| *c == ' ').min_by_key(|(i, _)| label[..*i].chars().count().abs_diff(mid)).map(|(i, _)| i);
|
||
match best {
|
||
Some(i) => format!("{}\n{}", &label[..i], label[i..].trim_start()),
|
||
None => label.to_string(),
|
||
}
|
||
}
|
||
|
||
/// A chevron over a ribbon edge that is also a button; `true` on the frame it is clicked.
|
||
fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool {
|
||
let resp = ui.interact(rect, ui.id().with(glyph), Sense::click()).on_hover_text(hint);
|
||
let ink = if resp.hovered() { t.text } else { t.text_dim };
|
||
ui.painter().rect_filled(rect, 0.0, t.ribbon);
|
||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink);
|
||
resp.clicked()
|
||
}
|
||
|
||
fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||
ui.vertical(|ui| {
|
||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||
add(ui);
|
||
});
|
||
}
|
||
|
||
pub(crate) fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) {
|
||
mi_text(ui, app, tl!(label), id, params);
|
||
}
|
||
|
||
/// A menu item whose text is already in the interface language.
|
||
fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) {
|
||
let sc = crate::widgets::shortcut_text(app, id);
|
||
let enabled = crate::widgets::enabled(app, id);
|
||
let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0)));
|
||
if resp.clicked() {
|
||
crate::widgets::run_from_ribbon(app, id, params);
|
||
ui.close();
|
||
}
|
||
}
|
||
|
||
/// The proofing languages the Language menu offers: tag and English name (translated when drawn).
|
||
pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 4] =
|
||
[("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish"), ("pt-BR", "Portuguese (Brazil)")];
|
||
|
||
/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's
|
||
/// default, US English; a tag without a name here: the tag).
|
||
pub(crate) fn language_name(tag: Option<&str>) -> String {
|
||
let tag = tag.unwrap_or("en-US");
|
||
if let Some((_, name)) = PROOFING_LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)) {
|
||
return (*name).to_string();
|
||
}
|
||
match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
|
||
wordcraft_engine::proof::ProofLang::Pl => "Polish".into(),
|
||
wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(),
|
||
wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(),
|
||
_ => tag.to_string(),
|
||
}
|
||
}
|
||
|
||
/// The language at the caret (resolved through the styles), translated, for the status bar.
|
||
pub(crate) fn caret_language(app: &WordApp) -> String {
|
||
let s = &app.session;
|
||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone());
|
||
let rc = s.doc.styles.resolve_char(style.as_deref(), &s.typing_props());
|
||
tl!(&language_name(rc.lang.as_deref())).to_string()
|
||
}
|
||
|
||
/// Review › Language (and the status bar's language): set the selection's proofing language, or
|
||
/// leave it unchecked. The current choice has a check mark.
|
||
pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
|
||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||
let current = st.get("lang").and_then(Value::as_str).unwrap_or("").to_string();
|
||
let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false);
|
||
for (tag, name) in PROOFING_LANGUAGES {
|
||
let on = current.eq_ignore_ascii_case(tag)
|
||
|| (tag == "pl-PL" && current.eq_ignore_ascii_case("pl"))
|
||
|| (tag == "pt-BR" && current.eq_ignore_ascii_case("pt"));
|
||
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
|
||
}
|
||
ui.separator();
|
||
mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof}));
|
||
}
|
||
|
||
/// A menu item with a check mark when `checked` (the current choice of several).
|
||
fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) {
|
||
// An invisible mark keeps the unchecked labels aligned with the checked one.
|
||
let mark = egui::RichText::new("✓");
|
||
let mark = if checked { mark } else { mark.color(egui::Color32::TRANSPARENT) };
|
||
let enabled = crate::widgets::enabled(app, id);
|
||
let resp = ui.add_enabled(enabled, egui::Button::new((mark, tl!(label))).selected(checked).min_size(vec2(200.0, 0.0)));
|
||
if resp.clicked() {
|
||
crate::widgets::run_from_ribbon(app, id, params);
|
||
ui.close();
|
||
}
|
||
}
|
||
|
||
/// Word's text wrapping styles in menu order: label, model value, `arrange.wrap` parameter.
|
||
pub(crate) const WRAP_STYLES: [(&str, wordcraft_doc::para::Wrap, &str); 7] = {
|
||
use wordcraft_doc::para::Wrap;
|
||
[
|
||
("In Line with Text", Wrap::Inline, "inline"),
|
||
("Square", Wrap::Square, "square"),
|
||
("Tight", Wrap::Tight, "tight"),
|
||
("Through", Wrap::Through, "through"),
|
||
("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"),
|
||
("Behind Text", Wrap::BehindText, "behindText"),
|
||
("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"),
|
||
]
|
||
};
|
||
|
||
/// The Wrap Text menu for the selected picture, shape or text box: the seven styles (the
|
||
/// current one checked), then Move with Text / Fix Position on Page for a floating one. The
|
||
/// canvas context menu shows it; the ribbon's Wrap Text button can share it.
|
||
pub(crate) fn wrap_text_items(ui: &mut Ui, app: &mut WordApp) {
|
||
use wordcraft_engine::cmd::objects::{moves_with_text, selected};
|
||
let state = selected(&app.session).and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, moves_with_text(f))));
|
||
let wrap = state.map(|(w, _)| w);
|
||
for (label, w, v) in WRAP_STYLES {
|
||
mi_check(ui, app, label, wrap == Some(w), "arrange.wrap", json!({"wrap": v}));
|
||
}
|
||
ui.separator();
|
||
// An inline object is neither: it's part of the text.
|
||
let with_text = state.filter(|(w, _)| *w != wordcraft_doc::para::Wrap::Inline).map(|(_, m)| m);
|
||
mi_check(ui, app, "Move with Text", with_text == Some(true), "arrange.moveWithText", json!({"value": true}));
|
||
mi_check(ui, app, "Fix Position on Page", with_text == Some(false), "arrange.moveWithText", json!({"value": false}));
|
||
}
|
||
|
||
fn home(app: &mut WordApp, ui: &mut Ui) {
|
||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||
group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| {
|
||
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, "edit.paste", |ui, app| {
|
||
mi(ui, app, "Paste", "edit.paste", json!({}));
|
||
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
|
||
mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({}));
|
||
mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({}));
|
||
});
|
||
stack(ui, |ui| {
|
||
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
|
||
let r = small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false);
|
||
if r.clicked() {
|
||
ui.ctx().copy_text(app.session.clipboard_text.clone());
|
||
}
|
||
let active = app.session.painter.is_some();
|
||
small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), active);
|
||
});
|
||
});
|
||
group(ui, "Font", Some("format.fontDialog"), app, |ui, app| {
|
||
stack(ui, |ui| {
|
||
crate::widgets::row(ui, |ui| {
|
||
let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string();
|
||
let font = app.previews.font_shown(&font);
|
||
let fams = app.previews.families();
|
||
let prev = app.previews.font_preview_fn();
|
||
if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) {
|
||
let _ = if f == crate::widgets::ADD_FONT {
|
||
app.run("font.add", json!({}))
|
||
} else {
|
||
let f = app.previews.font_stored(&f);
|
||
app.run("format.font", json!({"name": f}))
|
||
};
|
||
}
|
||
let size = st
|
||
.get("size")
|
||
.and_then(Value::as_f64)
|
||
.map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") })
|
||
.unwrap_or_default();
|
||
let sizes: Vec<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", ¤t)]);
|
||
menu_button(ui, app, "styles", Some(&label), "Citation Style", false, "references.citationStyle", |ui, app| {
|
||
for st in &wordcraft_engine::cmd::citations::STYLES {
|
||
mi_check(ui, app, st.name, st.name == current, "references.citationStyle", json!({"style": st.name}));
|
||
}
|
||
});
|
||
small(ui, app, "bibliography", Some("Bibliography"), "Bibliography", "references.bibliography", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Captions", None, app, |ui, app| {
|
||
big(ui, app, "caption", "Insert\nCaption", "references.caption", json!({}), false);
|
||
stack(ui, |ui| {
|
||
small(
|
||
ui,
|
||
app,
|
||
"tableOfFigures",
|
||
Some("Insert Table of Figures"),
|
||
"Insert Table of Figures",
|
||
"references.tableOfFigures",
|
||
json!({}),
|
||
false,
|
||
);
|
||
small(ui, app, "update", Some("Update Table"), "Update Table of Figures", "references.updateFigures", json!({}), false);
|
||
small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Index", None, app, |ui, app| {
|
||
big(ui, app, "markEntry", "Mark\nEntry", "references.markEntry", json!({}), false);
|
||
stack(ui, |ui| {
|
||
small(ui, app, "index", Some("Insert Index"), "Insert Index", "references.index", json!({}), false);
|
||
small(ui, app, "update", Some("Update Index"), "Update Index", "references.updateIndex", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Table of Authorities", None, app, |ui, app| {
|
||
big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false);
|
||
stack(ui, |ui| {
|
||
small(
|
||
ui,
|
||
app,
|
||
"tableOfAuthorities",
|
||
Some("Insert Table of Authorities"),
|
||
"Insert Table of Authorities",
|
||
"references.tableOfAuthorities",
|
||
json!({}),
|
||
false,
|
||
);
|
||
small(ui, app, "update", Some("Update Table"), "Update Table", "references.updateAuthorities", json!({}), false);
|
||
});
|
||
});
|
||
}
|
||
|
||
fn mailings(app: &mut WordApp, ui: &mut Ui) {
|
||
group(ui, "Create", None, app, |ui, app| {
|
||
big(ui, app, "envelope", "Envelopes", "mailings.envelopes", json!({}), false);
|
||
big(ui, app, "labels", "Labels", "mailings.labels", json!({}), false);
|
||
});
|
||
group(ui, "Start Mail Merge", None, app, |ui, app| {
|
||
// The kinds of merge document the engine makes, the current one checked.
|
||
menu_button(ui, app, "mailMerge", Some("Start Mail\nMerge"), "Start Mail Merge", true, "mailings.start", |ui, app| {
|
||
let kind = app.session.merge.kind.clone();
|
||
for (label, k) in [
|
||
("Letters", "letters"),
|
||
("E-mail Messages", "emails"),
|
||
("Envelopes", "envelopes"),
|
||
("Labels", "labels"),
|
||
("Directory", "directory"),
|
||
("Normal Word Document", "normal"),
|
||
] {
|
||
let current = kind == k || (k == "normal" && kind.is_empty());
|
||
mi_check(ui, app, label, current, "mailings.start", json!({"kind": k}));
|
||
}
|
||
});
|
||
// The command needs data, so the button offers the two ways to give it (#240).
|
||
menu_button(ui, app, "recipients", Some("Select\nRecipients"), "Select Recipients", true, "mailings.recipients", |ui, app| {
|
||
mi(ui, app, "Type a New List…", "ui.dialog", json!({"name": "newRecipientList"}));
|
||
mi(ui, app, "Use an Existing List…", "ui.openRecipientList", json!({}));
|
||
});
|
||
big(ui, app, "editRecipients", "Edit\nRecipient List", "mailings.editRecipients", json!({}), false);
|
||
});
|
||
group(ui, "Write & Insert Fields", None, app, |ui, app| {
|
||
big(ui, app, "highlightFields", "Highlight\nMerge Fields", "mailings.highlightFields", json!({}), false);
|
||
big(ui, app, "addressBlock", "Address\nBlock", "mailings.addressBlock", json!({}), false);
|
||
big(ui, app, "greetingLine", "Greeting\nLine", "mailings.greetingLine", json!({}), false);
|
||
big(ui, app, "mergeField", "Insert Merge\nField", "mailings.insertField", json!({}), false);
|
||
stack(ui, |ui| {
|
||
// The merge rules the engine knows; If and Skip Record If ask for their condition.
|
||
menu_button(ui, app, "rules", Some("Rules"), "Rules", false, "mailings.rules", |ui, app| {
|
||
mi(ui, app, "If…Then…Else…", "mailings.rules", json!({"rule": "IF"}));
|
||
mi(ui, app, "Merge Record #", "mailings.rules", json!({"rule": "MERGEREC"}));
|
||
mi(ui, app, "Next Record", "mailings.rules", json!({"rule": "NEXT"}));
|
||
mi(ui, app, "Skip Record If…", "mailings.rules", json!({"rule": "SKIPIF"}));
|
||
});
|
||
small(ui, app, "matchFields", Some("Match Fields"), "Match Fields", "mailings.matchFields", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Preview Results", None, app, |ui, app| {
|
||
big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false);
|
||
stack(ui, |ui| {
|
||
crate::widgets::row(ui, |ui| {
|
||
small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false);
|
||
small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false);
|
||
});
|
||
small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false);
|
||
small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Finish", None, app, |ui, app| {
|
||
big(ui, app, "finish", "Finish &\nMerge", "mailings.finish", json!({}), false);
|
||
});
|
||
}
|
||
|
||
/// Zotero (`docs/zotero.md`): the Zotero desktop app does the work in its own window.
|
||
fn zotero(app: &mut WordApp, ui: &mut Ui) {
|
||
group(ui, "Citations", None, app, |ui, app| {
|
||
big(ui, app, "citation", "Add/Edit\nCitation", "ui.zotero.addEditCitation", json!({}), false);
|
||
big(ui, app, "addNote", "Add\nNote", "ui.zotero.addNote", json!({}), false);
|
||
big(ui, app, "pastCitation", "Move Past\nCitation", "caret.pastCitation", json!({}), false);
|
||
});
|
||
group(ui, "Bibliography", None, app, |ui, app| {
|
||
big(ui, app, "bibliography", "Add/Edit\nBibliography", "ui.zotero.addEditBibliography", json!({}), false);
|
||
});
|
||
group(ui, "Document", None, app, |ui, app| {
|
||
big(ui, app, "update", "Refresh", "ui.zotero.refresh", json!({}), false);
|
||
big(ui, app, "docPrefs", "Document\nPreferences", "ui.zotero.setDocPrefs", json!({}), false);
|
||
big(ui, app, "unlinkCitations", "Unlink\nCitations", "ui.zotero.removeCodes", json!({}), false);
|
||
});
|
||
}
|
||
|
||
fn review(app: &mut WordApp, ui: &mut Ui) {
|
||
group(ui, "Proofing", None, app, |ui, app| {
|
||
big(ui, app, "spelling", "Spelling &\nGrammar", "review.spelling", json!({}), false);
|
||
stack(ui, |ui| {
|
||
small(ui, app, "thesaurus", Some("Thesaurus"), "Thesaurus", "review.thesaurus", json!({}), false);
|
||
small(ui, app, "wordCount", Some("Word Count"), "Word Count", "ui.dialog", json!({"name": "wordCount"}), false);
|
||
});
|
||
});
|
||
group(ui, "Speech", None, app, |ui, app| {
|
||
big(ui, app, "readAloud", "Read\nAloud", "review.readAloud", json!({}), false);
|
||
});
|
||
group(ui, "Accessibility", None, app, |ui, app| {
|
||
big(ui, app, "accessibility", "Check\nAccessibility", "file.accessibility", json!({}), false);
|
||
});
|
||
group(ui, "Language", None, app, |ui, app| {
|
||
big(ui, app, "translate", "Translate", "review.translate", json!({}), false);
|
||
menu_button(ui, app, "language", Some("Language"), "Language", true, "", language_menu);
|
||
});
|
||
group(ui, "Comments", None, app, |ui, app| {
|
||
big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false);
|
||
stack(ui, |ui| {
|
||
small(ui, app, "deleteComment", Some("Delete"), "Delete Comment", "review.deleteComment", json!({}), false);
|
||
small(ui, app, "prevComment", Some("Previous"), "Previous Comment", "review.previousComment", json!({}), false);
|
||
small(ui, app, "nextComment", Some("Next"), "Next Comment", "review.nextComment", json!({}), false);
|
||
});
|
||
let shown = app.session.view.comments_pane;
|
||
big(ui, app, "showComments", "Show\nComments", "view.commentsPane", json!({"value": !shown}), false);
|
||
});
|
||
group(ui, "Tracking", Some("review.trackingOptions"), app, |ui, app| {
|
||
let on = app.session.doc.settings.track_changes;
|
||
let r = big(ui, app, "trackChanges", if on { "Track\nChanges ✓" } else { "Track\nChanges" }, "review.trackChanges", json!({}), false);
|
||
let _ = r;
|
||
stack(ui, |ui| {
|
||
menu_button(
|
||
ui,
|
||
app,
|
||
"markup",
|
||
Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }),
|
||
"Display for Review",
|
||
false,
|
||
"review.markup",
|
||
|ui, app| {
|
||
mi(ui, app, "All Markup", "review.markup", json!({"value": "all"}));
|
||
mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"}));
|
||
},
|
||
);
|
||
small(ui, app, "reviewingPane", Some("Reviewing Pane"), "Reviewing Pane", "review.changes", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Changes", None, app, |ui, app| {
|
||
menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| {
|
||
mi(ui, app, "Accept This Change", "review.accept", json!({}));
|
||
mi(ui, app, "Accept All Changes", "review.acceptAll", json!({}));
|
||
});
|
||
menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| {
|
||
mi(ui, app, "Reject Change", "review.reject", json!({}));
|
||
mi(ui, app, "Reject All Changes", "review.rejectAll", json!({}));
|
||
});
|
||
stack(ui, |ui| {
|
||
small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false);
|
||
small(ui, app, "nextChange", Some("Next"), "Next Change", "review.nextChange", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Compare", None, app, |ui, app| {
|
||
big(ui, app, "compare", "Compare", "review.compare", json!({}), false);
|
||
});
|
||
group(ui, "Protect", None, app, |ui, app| {
|
||
big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false);
|
||
big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false);
|
||
});
|
||
group(ui, "Ink", None, app, |ui, app| {
|
||
let hidden = app.session.view.hide_ink;
|
||
big_toggle(ui, app, "hideInk", "Hide\nInk", "review.hideInk", hidden);
|
||
});
|
||
}
|
||
|
||
/// Developer › Controls: content controls.
|
||
fn developer(app: &mut WordApp, ui: &mut Ui) {
|
||
let design = app.session.view.design_mode;
|
||
group(ui, "Controls", None, app, |ui, app| {
|
||
let rows: [[(&str, &str, &str); 3]; 3] = [
|
||
[
|
||
("ccRichText", "Rich Text Content Control", "developer.richText"),
|
||
("ccPlainText", "Plain Text Content Control", "developer.plainText"),
|
||
("ccPicture", "Picture Content Control", "developer.picture"),
|
||
],
|
||
[
|
||
("ccGallery", "Building Block Gallery Content Control", "developer.buildingBlock"),
|
||
("ccCheckBox", "Check Box Content Control", "developer.checkBox"),
|
||
("ccComboBox", "Combo Box Content Control", "developer.comboBox"),
|
||
],
|
||
[
|
||
("ccDropDown", "Drop-Down List Content Control", "developer.dropDown"),
|
||
("ccDate", "Date Picker Content Control", "developer.datePicker"),
|
||
("ccRepeat", "Repeating Section Content Control", "developer.repeatingSection"),
|
||
],
|
||
];
|
||
stack(ui, |ui| {
|
||
for row in rows {
|
||
crate::widgets::row(ui, |ui| {
|
||
for (icon, tip, id) in row {
|
||
small(ui, app, icon, None, tip, id, json!({}), false);
|
||
}
|
||
});
|
||
ui.add_space(3.0);
|
||
}
|
||
});
|
||
stack(ui, |ui| {
|
||
small(ui, app, "designMode", Some("Design Mode"), "Design Mode", "developer.designMode", json!({}), design);
|
||
small(ui, app, "properties", Some("Properties"), "Properties", "developer.properties", json!({}), false);
|
||
});
|
||
});
|
||
}
|
||
|
||
fn view(app: &mut WordApp, ui: &mut Ui) {
|
||
let v = app.session.view.clone();
|
||
group(ui, "Views", None, app, |ui, app| {
|
||
big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false);
|
||
big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false);
|
||
big(ui, app, "webLayout", "Web\nLayout", "view.webLayout", json!({}), false);
|
||
stack(ui, |ui| {
|
||
small(ui, app, "outline", Some("Outline"), "Outline", "view.outline", json!({}), false);
|
||
small(ui, app, "draft", Some("Draft"), "Draft", "view.draft", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Immersive", None, app, |ui, app| {
|
||
big(ui, app, "focus", "Focus", "view.focus", json!({}), false);
|
||
big(ui, app, "immersive", "Immersive\nReader", "view.immersive", json!({}), false);
|
||
});
|
||
group(ui, "Page Movement", None, app, |ui, app| {
|
||
big(ui, app, "vertical", "Vertical", "view.vertical", json!({}), false);
|
||
big(ui, app, "sideToSide", "Side\nto Side", "view.sideToSide", json!({}), false);
|
||
});
|
||
group(ui, "Show", None, app, |ui, app| {
|
||
stack(ui, |ui| {
|
||
let mut r = v.ruler;
|
||
if ui.checkbox(&mut r, tl!("Ruler")).changed() {
|
||
let _ = app.run("view.ruler", json!({"value": r}));
|
||
}
|
||
let mut g = v.gridlines;
|
||
if ui.checkbox(&mut g, tl!("Gridlines")).changed() {
|
||
let _ = app.run("view.gridlines", json!({"value": g}));
|
||
}
|
||
let mut n = v.nav_pane;
|
||
if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() {
|
||
let _ = app.run("view.navigationPane", json!({"value": n}));
|
||
}
|
||
let mut w = v.hide_white_space;
|
||
if ui.checkbox(&mut w, tl!("Hide White Space")).changed() {
|
||
let _ = app.run("view.hideWhiteSpace", json!({"value": w}));
|
||
}
|
||
});
|
||
});
|
||
group(ui, "Zoom", None, app, |ui, app| {
|
||
big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false);
|
||
big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false);
|
||
// Step the zoom up and down by 10% (issue #67), from whatever the page shows now.
|
||
stack(ui, |ui| {
|
||
small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false);
|
||
small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false);
|
||
});
|
||
stack(ui, |ui| {
|
||
small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage");
|
||
small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page);
|
||
small(ui, app, "pageWidth", Some("Page Width"), "Page Width", "view.pageWidth", json!({}), v.fit == "pageWidth");
|
||
});
|
||
});
|
||
group(ui, "Dark Mode", None, app, |ui, app| {
|
||
big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false);
|
||
menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, "", |ui, app| {
|
||
for a in crate::theme::Appearance::ALL {
|
||
if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() {
|
||
let _ = app.run("ui.theme", json!({"value": a.code()}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
group(ui, "Window", None, app, |ui, app| {
|
||
big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false);
|
||
big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false);
|
||
big(ui, app, "split", "Split", "view.split", json!({}), false);
|
||
});
|
||
group(ui, "Macros", None, app, |ui, app| {
|
||
big(ui, app, "macros", "Macros", "tools.macros", json!({}), false);
|
||
});
|
||
group(ui, "Templates", None, app, |ui, app| {
|
||
big(ui, app, "documentTemplate", "Templates", "tools.templates", json!({}), false);
|
||
});
|
||
}
|
||
|
||
/// The Outlining tab (Outline view): outline levels, moving, expanding and collapsing, what is
|
||
/// shown, and closing the view.
|
||
fn outlining(app: &mut WordApp, ui: &mut Ui) {
|
||
use wordcraft_engine::layout::outline::{BODY, para_level};
|
||
let o = app.session.view.outline.clone();
|
||
let level = app.session.doc.para_at(&app.session.sel.focus).map(|p| para_level(&app.session.doc, p)).unwrap_or(BODY);
|
||
let name = |l: u8| if l >= BODY { tl!("Body Text").to_string() } else { crate::i18n::fmt(tl!("Level {n}"), &[("n", &l.to_string())]) };
|
||
group(ui, "Outline Tools", None, app, |ui, app| {
|
||
stack(ui, |ui| {
|
||
crate::widgets::row(ui, |ui| {
|
||
small(ui, app, "outlineTop", None, "Promote to Heading 1", "outline.promoteToHeading1", json!({}), false);
|
||
small(ui, app, "outlinePromote", None, "Promote", "outline.promote", json!({}), false);
|
||
let levels: Vec<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, ¤t, &shown, None)
|
||
&& let Some(i) = shown.iter().position(|x| *x == v)
|
||
{
|
||
let l = if i == 0 { json!("all") } else { json!(i) };
|
||
let _ = app.run("outline.showLevel", json!({ "level": l }));
|
||
}
|
||
});
|
||
ui.add_space(3.0);
|
||
crate::widgets::row(ui, |ui| {
|
||
let mut f = !o.hide_formatting;
|
||
if ui.checkbox(&mut f, tl!("Show Text Formatting")).changed() {
|
||
let _ = app.run("outline.showFormatting", json!({ "value": f }));
|
||
}
|
||
let mut first = o.first_line_only;
|
||
if ui.checkbox(&mut first, tl!("Show First Line Only")).changed() {
|
||
let _ = app.run("outline.firstLineOnly", json!({ "value": first }));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
group(ui, "Close", None, app, |ui, app| {
|
||
big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false);
|
||
});
|
||
}
|
||
|
||
fn help(app: &mut WordApp, ui: &mut Ui) {
|
||
group(ui, "Help", None, app, |ui, app| {
|
||
big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false);
|
||
big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false);
|
||
});
|
||
group(ui, "Agents", None, app, |ui, app| {
|
||
big(ui, app, "macros", "Commands", "ui.dialog", json!({"name": "commands"}), false);
|
||
});
|
||
}
|
||
|
||
/// SmartArt Design: the selected graphic's items (Add Shape, Promote, Demote, Text Pane), its
|
||
/// layout (a gallery of previews), its colours and Reset.
|
||
fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
|
||
use wordcraft_doc::smart_art::{SmartArtColors, SmartArtLayout};
|
||
let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return };
|
||
let theme = app.session.doc.settings.theme_colors.clone();
|
||
// The Text Pane's current item, else the last.
|
||
let current = app.smart_art_item.filter(|i| *i < g.items.len());
|
||
group(ui, "Create Graphic", None, app, |ui, app| {
|
||
big(ui, app, "addShape", "Add\nShape", "smartArt.addShape", current.map_or(json!({}), |i| json!({"after": i})), false);
|
||
stack(ui, |ui| {
|
||
let idx = current.map_or(json!({}), |i| json!({"index": i}));
|
||
small(ui, app, "promote", Some("Promote"), "Promote", "smartArt.promote", idx.clone(), false);
|
||
small(ui, app, "demote", Some("Demote"), "Demote", "smartArt.demote", idx, false);
|
||
small(ui, app, "textPane", Some("Text Pane"), "Text Pane", "smartArt.textPane", json!({}), app.session.view.smart_art_pane);
|
||
});
|
||
});
|
||
group(ui, "Layouts", None, app, |ui, app| {
|
||
let t = Tokens::get(ui.ctx());
|
||
egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||
for (k, layout) in SmartArtLayout::ALL.into_iter().enumerate() {
|
||
let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click());
|
||
let on = g.layout == layout;
|
||
let fill = if on {
|
||
t.checked
|
||
} else if resp.hovered() {
|
||
t.hover
|
||
} else {
|
||
t.ribbon
|
||
};
|
||
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside);
|
||
crate::dialogs_smart_art::preview(ui.painter(), r.shrink(2.0), layout, g.colors, &theme, t.text_dim);
|
||
if resp.on_hover_text(tl!(layout.label())).clicked() && !on {
|
||
let _ = app.run("smartArt.layout", json!({"layout": layout.id()}));
|
||
}
|
||
if k % 4 == 3 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
group(ui, "SmartArt Styles", None, app, |ui, app| {
|
||
menu_button(ui, app, "smartArtColors", Some("Change\nColors"), "Change Colors", true, "", |ui, app| {
|
||
for colors in SmartArtColors::ALL {
|
||
let on = g.colors == colors;
|
||
let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0)));
|
||
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
|
||
crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme);
|
||
if resp.clicked() {
|
||
let _ = app.run("smartArt.colors", json!({"variant": colors.id()}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
group(ui, "Reset", None, app, |ui, app| {
|
||
big(ui, app, "resetGraphic", "Reset\nGraphic", "smartArt.reset", json!({}), false);
|
||
});
|
||
}
|
||
|
||
/// Chart Design: the selected chart's elements (title, legend, data labels), its data and type,
|
||
/// and its size.
|
||
fn chart_design(app: &mut WordApp, ui: &mut Ui) {
|
||
let Some(chart) = wordcraft_engine::cmd::chart::selected_chart(&app.session) else { return };
|
||
group(ui, "Chart Layouts", None, app, |ui, app| {
|
||
menu_button(ui, app, "chartTitle", Some("Chart Title"), "Chart Title", true, "", |ui, app| {
|
||
mi_check(ui, app, "None", chart.title.is_none(), "chart.title", json!({"text": null}));
|
||
let text = chart.title.clone().unwrap_or_else(|| tl!("Chart Title").to_string());
|
||
mi_check(ui, app, "Above Chart", chart.title.is_some(), "chart.title", json!({"text": text}));
|
||
});
|
||
menu_button(ui, app, "legend", Some("Legend"), "Legend", true, "", |ui, app| {
|
||
mi_check(ui, app, "None", chart.legend.is_none(), "chart.legend", json!({"pos": "none"}));
|
||
for (label, pos) in [
|
||
("Right", wordcraft_doc::chart::LegendPos::Right),
|
||
("Top", wordcraft_doc::chart::LegendPos::Top),
|
||
("Left", wordcraft_doc::chart::LegendPos::Left),
|
||
("Bottom", wordcraft_doc::chart::LegendPos::Bottom),
|
||
] {
|
||
mi_check(ui, app, label, chart.legend == Some(pos), "chart.legend", json!({"pos": pos.id()}));
|
||
}
|
||
});
|
||
menu_button(ui, app, "dataLabels", Some("Data Labels"), "Data Labels", true, "", |ui, app| {
|
||
mi_check(ui, app, "None", !chart.data_labels, "chart.dataLabels", json!({"on": false}));
|
||
mi_check(ui, app, "Show", chart.data_labels, "chart.dataLabels", json!({"on": true}));
|
||
});
|
||
});
|
||
group(ui, "Data", None, app, |ui, app| {
|
||
big(ui, app, "chartData", "Edit\nData", "chart.editData", json!({}), false);
|
||
});
|
||
group(ui, "Type", None, app, |ui, app| {
|
||
big(ui, app, "changeChartType", "Change\nChart Type", "chart.type", json!({}), false);
|
||
});
|
||
group(ui, "Size", None, app, |ui, app| {
|
||
let (w0, h0) =
|
||
wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0));
|
||
stack(ui, |ui| {
|
||
for (label, key, v0) in [("H:", "height", h0), ("W:", "width", w0)] {
|
||
crate::widgets::row(ui, |ui| {
|
||
ui.label(egui::RichText::new(tl!(label)).small());
|
||
let mut v = v0;
|
||
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).suffix(" pt"));
|
||
if r.changed() {
|
||
if r.dragged() && !r.drag_started() {
|
||
app.session.join_next_undo();
|
||
}
|
||
let _ = app.run("picture.size", json!({key: v, "lockAspect": false}));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
|
||
fn shape_format(app: &mut WordApp, ui: &mut Ui) {
|
||
let (stroke, glow_size) = match wordcraft_engine::cmd::objects::selected(&app.session) {
|
||
Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, .. })) => (stroke, effects.glow.map(|g| g.size)),
|
||
_ => (None, None),
|
||
};
|
||
let theme = app.session.doc.settings.theme_colors.clone();
|
||
group(ui, "Shape Styles", None, app, |ui, app| {
|
||
stack(ui, |ui| {
|
||
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, "shape.fill", |ui, app| {
|
||
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
|
||
crate::colors::menu(ui, app, &theme, Target::cmd("shape.fill"));
|
||
});
|
||
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, "shape.outline", |ui, app| {
|
||
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
|
||
crate::colors::menu(ui, app, &theme, Target::cmd("shape.outline"));
|
||
ui.separator();
|
||
ui.menu_button(tl!("Weight"), |ui| {
|
||
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
|
||
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
|
||
mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w}));
|
||
}
|
||
});
|
||
});
|
||
menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, "shape.effects", |ui, app| {
|
||
shape_effects_menu(ui, app, &theme, glow_size)
|
||
});
|
||
});
|
||
});
|
||
alt_text_group(ui, app);
|
||
if wordcraft_engine::cmd::canvas::selected_canvas(&app.session).is_some() {
|
||
group(ui, "Drawing Canvas", None, app, |ui, app| {
|
||
menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, "", |ui, app| {
|
||
mi(ui, app, "Fit", "canvas.fit", json!({}));
|
||
mi(ui, app, "Expand", "canvas.expand", json!({}));
|
||
ui.menu_button(tl!("Scale Drawing"), |ui| {
|
||
for pct in [50, 75, 125, 150, 200] {
|
||
mi(ui, app, &format!("{pct}%"), "canvas.scale", json!({"factor": pct as f32 / 100.0}));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
group(ui, "WordArt Styles", None, app, |ui, app| {
|
||
menu_button(ui, app, "wordArt", Some("Styles"), "WordArt Styles", true, "", |ui, app| {
|
||
crate::wordart_menus::style_gallery(ui, app, "wordArt.style")
|
||
});
|
||
stack(ui, |ui| {
|
||
menu_button(ui, app, "textFill", Some("Text Fill"), "Text Fill", false, "", |ui, app| crate::wordart_menus::fill_menu(ui, app, &theme));
|
||
let outline = crate::wordart_menus::current_outline(app);
|
||
menu_button(ui, app, "textOutline", Some("Text Outline"), "Text Outline", false, "", |ui, app| {
|
||
crate::wordart_menus::outline_menu(ui, app, &theme, outline)
|
||
});
|
||
menu_button(ui, app, "transform", Some("Text Effects"), "Text Effects", false, "", |ui, app| {
|
||
crate::wordart_menus::effects_menu(ui, app, &theme, true)
|
||
});
|
||
});
|
||
});
|
||
shape_text_group(app, ui);
|
||
group(ui, "Arrange", None, app, |ui, app| {
|
||
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
|
||
wrap_menu(ui, app);
|
||
stack(ui, |ui| {
|
||
rotate_menu(ui, app, Some("Rotate"));
|
||
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
|
||
});
|
||
});
|
||
group(ui, "Size", None, app, |ui, app| {
|
||
let (w0, h0) =
|
||
wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0));
|
||
stack(ui, |ui| {
|
||
crate::widgets::row(ui, |ui| {
|
||
for (label, key, v0) in [("W:", "width", w0), ("H:", "height", h0)] {
|
||
ui.label(egui::RichText::new(tl!(label)).small());
|
||
let mut v = v0;
|
||
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(4.0..=2000.0).suffix(" pt"));
|
||
if r.changed() {
|
||
if r.dragged() && !r.drag_started() {
|
||
app.session.join_next_undo();
|
||
}
|
||
let _ = app.run("picture.size", json!({key: v, "lockAspect": false}));
|
||
}
|
||
}
|
||
});
|
||
ui.add_space(2.0);
|
||
crate::widgets::row(ui, |ui| rotation_field(ui, app));
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Shape Format › Text: the text box's Text Direction and Align Text, and Create Link (Break Link
|
||
/// once it is linked to another box).
|
||
fn shape_text_group(app: &mut WordApp, ui: &mut Ui) {
|
||
use wordcraft_doc::props::{TextVert, VAlign};
|
||
let target = wordcraft_engine::cmd::objects::text_box_target(&app.session);
|
||
let body = target.and_then(|(_, id)| app.session.doc.parts.get(&id).map(|p| p.body)).unwrap_or_default();
|
||
group(ui, "Text", None, app, |ui, app| {
|
||
stack(ui, |ui| {
|
||
menu_button(ui, app, "textDirection", Some("Text Direction"), "Text Direction", false, "", |ui, app| {
|
||
for (label, vert) in [
|
||
("Horizontal", TextVert::Horz),
|
||
("Rotate all text 90°", TextVert::Vert),
|
||
("Rotate all text 270°", TextVert::Vert270),
|
||
("Stacked", TextVert::Stacked),
|
||
] {
|
||
mi_check(ui, app, label, body.vert == vert, "shape.textDirection", json!({"dir": vert.ooxml()}));
|
||
}
|
||
});
|
||
menu_button(ui, app, "alignText", Some("Align Text"), "Align Text", false, "", |ui, app| {
|
||
for (label, anchor) in [("Top", VAlign::Top), ("Middle", VAlign::Center), ("Bottom", VAlign::Bottom)] {
|
||
mi_check(ui, app, label, body.anchor == anchor, "shape.alignText", json!({"anchor": wordcraft_doc::props::anchor_ooxml(anchor)}));
|
||
}
|
||
});
|
||
if body.next.is_some() {
|
||
small(ui, app, "textBoxUnlink", Some("Break Link"), "Break Link", "shape.breakLink", json!({}), false);
|
||
} else {
|
||
let waiting = app.canvas.link_from.is_some();
|
||
small(ui, app, "textBoxLink", Some("Create Link"), "Create Link", "ui.linkTextBox", json!({}), waiting);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Picture/Shape Format › Accessibility: Alt Text opens the Alt Text pane.
|
||
fn alt_text_group(ui: &mut Ui, app: &mut WordApp) {
|
||
group(ui, "Accessibility", None, app, |ui, app| {
|
||
big(ui, app, "altText", "Alt\nText", "view.altTextPane", json!({"value": true}), false);
|
||
});
|
||
}
|
||
|
||
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
|
||
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<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");
|
||
}
|
||
}
|