refactor(ui-egui): split ribbon.rs into ribbon/

This commit is contained in:
aiongg
2026-10-26 21:16:48 +00:00
parent 095e1941e3
commit 6d26a751f0
19 changed files with 2958 additions and 2846 deletions
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
use super::common::mi;
use super::language::mi_check;
use super::*;
/// Word's text wrapping styles in menu order: label, model value, `arrange.wrap` parameter.
pub(crate) const WRAP_STYLES: [(&str, wordcraft_doc::para::Wrap, &str); 7] = {
use wordcraft_doc::para::Wrap;
[
("In Line with Text", Wrap::Inline, "inline"),
("Square", Wrap::Square, "square"),
("Tight", Wrap::Tight, "tight"),
("Through", Wrap::Through, "through"),
("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"),
("Behind Text", Wrap::BehindText, "behindText"),
("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"),
]
};
/// The Wrap Text menu for the selected picture, shape or text box: the seven styles (the
/// current one checked), then Move with Text / Fix Position on Page for a floating one. The
/// canvas context menu shows it; the ribbon's Wrap Text button can share it.
pub(crate) fn wrap_text_items(ui: &mut Ui, app: &mut WordApp) {
use wordcraft_engine::cmd::objects::{moves_with_text, selected};
let state = selected(&app.session).and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, moves_with_text(f))));
let wrap = state.map(|(w, _)| w);
for (label, w, v) in WRAP_STYLES {
mi_check(ui, app, label, wrap == Some(w), "arrange.wrap", json!({"wrap": v}));
}
ui.separator();
// An inline object is neither: it's part of the text.
let with_text = state.filter(|(w, _)| *w != wordcraft_doc::para::Wrap::Inline).map(|(_, m)| m);
mi_check(ui, app, "Move with Text", with_text == Some(true), "arrange.moveWithText", json!({"value": true}));
mi_check(ui, app, "Fix Position on Page", with_text == Some(false), "arrange.moveWithText", json!({"value": false}));
}
/// Arrange › Rotate: turn 90° either way or flip (the angle and mirroring, not the pixels).
/// Arrange › Wrap Text: how the selected object sits in the text, and (Square, Tight, Through)
/// which of its sides text flows on.
pub(super) fn wrap_menu(ui: &mut Ui, app: &mut WordApp) {
let (wrap, side) = wordcraft_engine::cmd::objects::selected(&app.session)
.and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, f.wrap_text)))
.unwrap_or_default();
menu_button(ui, app, "wrapText", Some("Wrap\nText"), "Wrap Text", true, "arrange.wrapText", |ui, app| {
use wordcraft_doc::para::{Wrap, WrapText};
for (l, w, v) in [
("In Line with Text", Wrap::Inline, "inline"),
("Square", Wrap::Square, "square"),
("Tight", Wrap::Tight, "tight"),
("Through", Wrap::Through, "through"),
("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"),
("Behind Text", Wrap::BehindText, "behindText"),
("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"),
] {
mi_check(ui, app, l, wrap == w, "arrange.wrap", json!({"wrap": v}));
}
ui.separator();
ui.menu_button(tl!("Wrap Text Sides"), |ui| {
for (l, s, v) in [
("Both Sides", WrapText::BothSides, "bothSides"),
("Left Only", WrapText::Left, "left"),
("Right Only", WrapText::Right, "right"),
("Largest Only", WrapText::Largest, "largest"),
] {
mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v}));
}
});
});
}
pub(super) fn rotate_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) {
menu_button(ui, app, "rotate", label, "Rotate", false, "arrange.rotate", |ui, app| {
mi(ui, app, "Rotate Right 90°", "arrange.rotate", json!({"direction": "right"}));
mi(ui, app, "Rotate Left 90°", "arrange.rotate", json!({"direction": "left"}));
mi(ui, app, "Flip Vertical", "arrange.rotate", json!({"direction": "flipVertical"}));
mi(ui, app, "Flip Horizontal", "arrange.rotate", json!({"direction": "flipHorizontal"}));
});
}
/// Size › Rotation: the selected object's exact angle (a drag of the field is one undo step).
pub(super) fn rotation_field(ui: &mut Ui, app: &mut WordApp) {
let deg0 = wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(_, _, f)| f.spin().deg)).unwrap_or(0.0);
ui.label(egui::RichText::new(tl!("Rotation:")).small());
let mut deg = deg0;
let r = ui.add(egui::DragValue::new(&mut deg).speed(1.0).range(-360.0..=360.0).max_decimals(1).suffix("°"));
if r.changed() {
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("arrange.rotation", json!({"degrees": deg}));
}
}
/// Arrange › Group: Group (Shift+click objects to select several) and Ungroup.
pub(super) fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) {
menu_button(ui, app, "group", label, "Group", false, "arrange.group", |ui, app| {
mi(ui, app, "Group", "arrange.group", json!({}));
mi(ui, app, "Ungroup", "arrange.ungroup", json!({}));
});
}
/// Arrange › Align: horizontal position relative to the margin (#3242 — a plain button ran
/// `arrange.align` with no `value`, which always centers; a menu reaches Left and Right too).
pub(super) fn align_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) {
menu_button(ui, app, "align", label, "Align", false, "arrange.align", |ui, app| {
mi(ui, app, "Align Left", "arrange.align", json!({"value": "left"}));
mi(ui, app, "Align Center", "arrange.align", json!({"value": "center"}));
mi(ui, app, "Align Right", "arrange.align", json!({"value": "right"}));
});
}
+99
View File
@@ -0,0 +1,99 @@
use super::common::{graphic_size, stack};
use super::language::mi_check;
use super::*;
/// SmartArt Design: the selected graphic's items (Add Shape, Promote, Demote, Text Pane), its
/// layout (a gallery of previews), its colours and Reset.
pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_doc::smart_art::{SmartArtColors, SmartArtLayout};
let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return };
let theme = app.session.doc.settings.theme_colors.clone();
// The Text Pane's current item, else the last.
let current = app.smart_art_item.filter(|i| *i < g.items.len());
group(ui, "Create Graphic", None, app, |ui, app| {
big(ui, app, "addShape", "Add\nShape", "smartArt.addShape", current.map_or(json!({}), |i| json!({"after": i})), false);
stack(ui, |ui| {
let idx = current.map_or(json!({}), |i| json!({"index": i}));
small(ui, app, "promote", Some("Promote"), "Promote", "smartArt.promote", idx.clone(), false);
small(ui, app, "demote", Some("Demote"), "Demote", "smartArt.demote", idx, false);
small(ui, app, "textPane", Some("Text Pane"), "Text Pane", "smartArt.textPane", json!({}), app.session.view.smart_art_pane);
});
});
group(ui, "Layouts", None, app, |ui, app| {
let t = Tokens::get(ui.ctx());
egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
for (k, layout) in SmartArtLayout::ALL.into_iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click());
let on = g.layout == layout;
let fill = if on {
t.checked
} else if resp.hovered() {
t.hover
} else {
t.ribbon
};
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside);
crate::dialogs_smart_art::preview(ui.painter(), r.shrink(2.0), layout, g.colors, &theme, t.text_dim);
if resp.on_hover_text(tl!(layout.label())).clicked() && !on {
let _ = app.run("smartArt.layout", json!({"layout": layout.id()}));
}
if k % 4 == 3 {
ui.end_row();
}
}
});
});
group(ui, "SmartArt Styles", None, app, |ui, app| {
menu_button(ui, app, "smartArtColors", Some("Change\nColors"), "Change Colors", true, "", |ui, app| {
for colors in SmartArtColors::ALL {
let on = g.colors == colors;
let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0)));
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme);
if resp.clicked() {
let _ = app.run("smartArt.colors", json!({"variant": colors.id()}));
ui.close();
}
}
});
});
group(ui, "Reset", None, app, |ui, app| {
big(ui, app, "resetGraphic", "Reset\nGraphic", "smartArt.reset", json!({}), false);
});
graphic_size(ui, app);
}
/// Chart Design: the selected chart's elements (title, legend, data labels), its data and type,
/// and its size.
pub(super) fn chart_design(app: &mut WordApp, ui: &mut Ui) {
let Some(chart) = wordcraft_engine::cmd::chart::selected_chart(&app.session) else { return };
group(ui, "Chart Layouts", None, app, |ui, app| {
menu_button(ui, app, "chartTitle", Some("Chart Title"), "Chart Title", true, "", |ui, app| {
mi_check(ui, app, "None", chart.title.is_none(), "chart.title", json!({"text": null}));
let text = chart.title.clone().unwrap_or_else(|| tl!("Chart Title").to_string());
mi_check(ui, app, "Above Chart", chart.title.is_some(), "chart.title", json!({"text": text}));
});
menu_button(ui, app, "legend", Some("Legend"), "Legend", true, "", |ui, app| {
mi_check(ui, app, "None", chart.legend.is_none(), "chart.legend", json!({"pos": "none"}));
for (label, pos) in [
("Right", wordcraft_doc::chart::LegendPos::Right),
("Top", wordcraft_doc::chart::LegendPos::Top),
("Left", wordcraft_doc::chart::LegendPos::Left),
("Bottom", wordcraft_doc::chart::LegendPos::Bottom),
] {
mi_check(ui, app, label, chart.legend == Some(pos), "chart.legend", json!({"pos": pos.id()}));
}
});
menu_button(ui, app, "dataLabels", Some("Data Labels"), "Data Labels", true, "", |ui, app| {
mi_check(ui, app, "None", !chart.data_labels, "chart.dataLabels", json!({"on": false}));
mi_check(ui, app, "Show", chart.data_labels, "chart.dataLabels", json!({"on": true}));
});
});
group(ui, "Data", None, app, |ui, app| {
big(ui, app, "chartData", "Edit\nData", "chart.editData", json!({}), false);
});
group(ui, "Type", None, app, |ui, app| {
big(ui, app, "changeChartType", "Change\nChart Type", "chart.type", json!({}), false);
});
graphic_size(ui, app);
}
+143
View File
@@ -0,0 +1,143 @@
use super::language::mi_check;
use super::*;
/// Custom groups (Customize Ribbon, #379): up to three commands as large buttons, more as small
/// labelled buttons in columns of three. Each shows the command's own icon and label.
pub(super) fn custom_groups(app: &mut WordApp, ui: &mut Ui, groups: &[wordcraft_engine::ribbon::CustomGroup]) {
for g in groups {
let ids: Vec<(&str, String)> =
g.commands.iter().filter_map(|id| app.session.registry.get(id).map(|s| (s.id, tl!(s.label).to_string()))).collect();
crate::widgets::group_titled(ui, &g.name, &g.name, None, app, |ui, app| {
if ids.len() <= 3 {
for (id, label) in &ids {
big(ui, app, crate::widgets::command_icon(id), &two_lines(label), id, json!({}), false);
}
return;
}
for column in ids.chunks(3) {
stack(ui, |ui| {
for (id, label) in column {
small(ui, app, crate::widgets::command_icon(id), Some(label), label, id, json!({}), false);
}
});
}
});
}
}
/// A large button's label on two lines when it is long (broken at the space nearest the middle).
fn two_lines(label: &str) -> String {
let n = label.chars().count();
if n <= 10 {
return label.to_string();
}
let mid = n / 2;
let best = label.char_indices().filter(|(_, c)| *c == ' ').min_by_key(|(i, _)| label[..*i].chars().count().abs_diff(mid)).map(|(i, _)| i);
match best {
Some(i) => format!("{}\n{}", &label[..i], label[i..].trim_start()),
None => label.to_string(),
}
}
/// A chevron over a ribbon edge that is also a button; `true` on the frame it is clicked.
pub(super) fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool {
let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint);
let ink = if resp.hovered() { t.text } else { t.text_dim };
ui.painter().rect_filled(rect, 0.0, t.ribbon);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink);
resp.clicked()
}
/// Whether Esc just ended typing in a field (`r`, remembered under `id`). egui 0.36 keeps a
/// `DragValue`'s typed text after Esc and applies it a frame later, when the field still counts as
/// having just lost focus but Esc is no longer pressed: a change reported within two frames of the
/// Esc is that late value, to be dropped so that Esc cancels as in Word.
pub(super) fn esc_cancelled(ui: &Ui, r: &egui::Response, id: egui::Id) -> bool {
let now = ui.ctx().cumulative_pass_nr();
let (focus_key, esc_key) = (id.with("focused"), id.with("esc"));
// Read outside `data_mut`: both lock the context.
let focused = r.has_focus();
let had_focus = ui.data(|d| d.get_temp::<bool>(focus_key)).unwrap_or(false);
ui.data_mut(|d| d.insert_temp(focus_key, focused));
if had_focus && !focused && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(esc_key, now));
}
ui.data(|d| d.get_temp::<u64>(esc_key)).is_some_and(|at| now.saturating_sub(at) <= 2)
}
pub(super) fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
add(ui);
});
}
/// Layout › Size: the named paper sizes, then More Paper Sizes… for a custom size in Page
/// Setup (#1433).
pub(crate) fn size_menu(ui: &mut Ui, app: &mut WordApp) {
// The current paper size is checked.
let x = wordcraft_engine::cmd::page::sect(&app.session);
let current = wordcraft_geom::paper_name(x.page_w, x.page_h);
for (n, w, h) in wordcraft_geom::PAPER_SIZES {
// Paper names are the industry's (Letter, A4, B5 (JIS)) and stay as they are; the
// envelopes say what they are. Sizes in the measurement unit, written the region's
// way (`21 cm × 29,7 cm`, #838).
let name = if n.starts_with("Envelope ") { tl!(n) } else { n };
let (width, height) = (crate::options::length(*w), crate::options::length(*h));
let label = crate::i18n::fmt(tl!("{paper} {width} × {height}"), &[("paper", name), ("width", &width), ("height", &height)]);
mi_check(ui, app, &label, current == Some(*n), "layout.size", json!({"name": n}));
}
ui.separator();
mi(ui, app, crate::dialogs::PAPER_LABELS.more, "ui.dialog", json!({"name": "pageSetup"}));
}
pub(crate) fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) {
mi_text(ui, app, tl!(label), id, params);
}
/// [`mi`] with a drawn icon before the label.
pub(super) fn mi_icon(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value) {
let sc = crate::widgets::shortcut_text(app, id);
let enabled = crate::widgets::enabled(app, id);
let resp = ui.add_enabled_ui(enabled, |ui| crate::widgets::icon_menu_item(ui, icon, tl!(label), &sc, 200.0)).inner;
if resp.clicked() {
crate::widgets::run_from_ribbon(app, id, params);
ui.close();
}
}
/// A menu item whose text is already in the interface language.
pub(super) fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) {
let sc = crate::widgets::shortcut_text(app, id);
let enabled = crate::widgets::enabled(app, id);
let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0)));
if resp.clicked() {
crate::widgets::run_from_ribbon(app, id, params);
ui.close();
}
}
/// Chart Design and SmartArt Design › Size: the selected graphic's height and width.
pub(super) fn graphic_size(ui: &mut Ui, app: &mut WordApp) {
group(ui, "Size", None, app, |ui, app| {
let (w0, h0) =
wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0));
stack(ui, |ui| {
for (label, key, v0) in [("H:", "height", h0), ("W:", "width", w0)] {
crate::widgets::row(ui, |ui| {
ui.label(egui::RichText::new(tl!(label)).small());
let mut v = v0;
// The range bounds what is typed or dragged; a size outside it is shown as it
// is (clamped, egui would report a change and resize the graphic unasked).
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).clamp_existing_to_range(false).suffix(" pt"));
if r.changed() && v != v0 {
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("picture.size", json!({key: v, "lockAspect": false}));
}
});
}
});
});
}
+80
View File
@@ -0,0 +1,80 @@
use super::common::{mi, mi_text, stack};
use super::*;
/// The Design tab's style set gallery in a box of its own, sized from its thumbnails and clipped
/// to it, with a gap on each side: the Colors and Fonts column can't be drawn over, or cut by,
/// the thumbnails (#638). Returns the box and the space the thumbnails used.
pub(super) fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) {
ui.add_space(4.0);
let (gallery, _) = ui.allocate_exact_size(vec2(crate::previews::STYLE_SET_GALLERY_W, CONTENT_H), Sense::hover());
let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(egui::Layout::left_to_right(egui::Align::Min)));
gui.set_clip_rect(gallery.intersect(ui.clip_rect()));
gui.spacing_mut().item_spacing = vec2(1.0, 1.0);
crate::previews::style_set_gallery(app, &mut gui);
ui.add_space(8.0);
(gallery, gui.min_rect())
}
pub(super) fn design(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Themes", None, app, |ui, app| {
menu_button(ui, app, "themes", Some("Themes"), "Themes", true, "design.themes", |ui, app| {
for (name, ..) in wordcraft_engine::cmd::design::THEMES {
mi(ui, app, name, "design.theme", json!({"name": name}));
}
});
});
group(ui, "Document Formatting", None, app, |ui, app| {
style_set_box(app, ui);
stack(ui, |ui| {
menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, "design.themeColors", |ui, app| {
for (name, ..) in wordcraft_engine::cmd::design::THEMES {
mi(ui, app, name, "design.themeColors", json!({"name": name}));
}
});
menu_button(ui, app, "fonts", Some("Fonts"), "Theme Fonts", false, "design.themeFonts", |ui, app| {
for (name, h, b, _) in wordcraft_engine::cmd::design::THEMES {
mi_text(ui, app, &format!("{}: {h} / {b}", tl!(name)), "design.themeFonts", json!({"heading": h, "body": b}));
}
});
});
stack(ui, |ui| {
menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, "design.paragraphSpacing", |ui, app| {
for (l, v) in [
("Default", "default"),
("No Paragraph Space", "none"),
("Compact", "compact"),
("Tight", "tight"),
("Open", "open"),
("Relaxed", "relaxed"),
("Double", "double"),
] {
// "Open" here is roomy spacing, not the file verb: only a `spacing` row
// translates it, else it stays English.
if l == "Open" {
mi_text(ui, app, crate::i18n::get("spacing", l).unwrap_or(l), "design.paragraphSpacing", json!({"value": v}));
continue;
}
mi(ui, app, l, "design.paragraphSpacing", json!({"value": v}));
}
});
small(ui, app, "effectsDesign", Some("Effects"), "Effects", "design.effects", json!({}), false);
small(ui, app, "setDefault", Some("Set as Default"), "Set as Default", "design.setDefault", json!({}), false);
});
});
group(ui, "Page Background", None, app, |ui, app| {
menu_button(ui, app, "watermark", Some("Watermark"), "Watermark", true, "design.watermark", |ui, app| {
for w in ["DRAFT", "CONFIDENTIAL", "DO NOT COPY", "SAMPLE", "ASAP", "URGENT"] {
mi(ui, app, w, "design.watermark", json!({"text": w}));
}
ui.separator();
mi(ui, app, "Custom Watermark…", "ui.dialog", json!({"name": "watermark"}));
mi(ui, app, "Remove Watermark", "design.watermark", json!({"remove": true}));
});
menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, "design.pageColor", |ui, app| {
mi(ui, app, "No Color", "design.pageColor", json!({"color": null}));
let theme = app.session.doc.settings.theme_colors.clone();
crate::colors::menu(ui, app, &theme, Target::cmd("design.pageColor"));
});
big(ui, app, "pageBorders", "Page\nBorders", "design.pageBorders", json!({}), false);
});
}
+125
View File
@@ -0,0 +1,125 @@
use super::common::{mi, stack};
use super::language::mi_check;
use super::*;
pub(super) fn draw(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_doc::freeform::InkTool;
use wordcraft_engine::cmd::draw::{DrawMode, lasso_selection};
let mode = app.session.view.draw.mode;
// The pen the colour and thickness menus change: the one in use, else the pen.
let tool = mode.pen().unwrap_or(InkTool::Pen);
let set = app.session.view.draw.settings(tool);
// The gallery pen in use, if any: changing the colour or thickness changes it too.
let custom =
app.session.view.draw.pens.iter().position(|p| mode == DrawMode::Pen(p.kind) && p.color == set.color && (p.width - set.width).abs() < 0.01);
let lasso_on = lasso_selection(&app.session).is_some();
group(ui, "Drawing Tools", None, app, |ui, app| {
big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select);
big_toggle(ui, app, "lasso", "Lasso", "draw.lasso", mode == DrawMode::Lasso);
big_toggle(ui, app, "eraser", "Eraser", "draw.eraser", mode == DrawMode::Eraser);
big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen) && custom.is_none());
big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil) && custom.is_none());
big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter) && custom.is_none());
// Pens added with Add Pen; right-click one to remove it.
let pens = app.session.view.draw.pens.clone();
for (i, pen) in pens.iter().enumerate() {
gallery_pen(ui, app, i, *pen, custom == Some(i));
}
menu_button(ui, app, "addPen", Some("Add\nPen"), "Add Pen", true, "", |ui, app| {
for (label, kind) in [("Pen", InkTool::Pen), ("Pencil", InkTool::Pencil), ("Highlighter", InkTool::Highlighter)] {
// A new pen starts as that kind's current colour and thickness.
let cur = app.session.view.draw.settings(kind);
mi(ui, app, label, "draw.addPen", json!({"kind": kind.name(), "color": cur.color.hex(), "width": cur.width}));
}
});
stack(ui, |ui| {
let sw = Some(crate::theme::c32(set.color));
split(ui, app, "fontcolor", "Color", pen_command(tool), json!({}), false, sw, |ui, app| {
let theme = app.session.doc.settings.theme_colors.clone();
// With ink lasso-selected the colour goes to the selection, else to the pen.
let cmd = if lasso_on { "draw.lassoColor" } else { pen_command(tool) };
crate::colors::menu(ui, app, &theme, Target::cmd(cmd));
if !lasso_on {
update_gallery_pen(app, custom, tool);
}
});
menu_button(ui, app, "thickness", None, "Thickness", false, "draw.pen", |ui, app| {
let widths: &[f32] = if tool == InkTool::Highlighter { &[4.0, 8.0, 12.0, 18.0, 24.0] } else { &[0.5, 1.0, 1.5, 2.5, 3.5, 5.0] };
for w in widths {
let label = crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]);
mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_command(tool), json!({"width": w}));
}
update_gallery_pen(app, custom, tool);
});
});
});
group(ui, "Convert", None, app, |ui, app| {
let on = app.session.view.draw.ink_to_shape;
big_toggle(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", on);
big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false);
});
group(ui, "Insert", None, app, |ui, app| {
big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false);
});
group(ui, "Replay", None, app, |ui, app| {
let on = app.session.view.draw.replay;
big_toggle(ui, app, "replay", "Ink\nReplay", "draw.replay", on);
});
}
/// The command that picks up `tool`.
fn pen_command(tool: wordcraft_doc::freeform::InkTool) -> &'static str {
use wordcraft_doc::freeform::InkTool;
match tool {
InkTool::Pen => "draw.pen",
InkTool::Pencil => "draw.pencil",
InkTool::Highlighter => "draw.highlighter",
}
}
/// After the colour or thickness menu changed `tool`'s settings, the gallery pen that was in use
/// (`custom`) takes them too.
fn update_gallery_pen(app: &mut WordApp, custom: Option<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();
}
});
}
+285
View File
@@ -0,0 +1,285 @@
use super::common::{mi, mi_text, stack};
use super::underline::underline_menu;
use super::*;
/// The Format Painter button. A click arms it for one use (blue border only); a double-click keeps
/// it on across several uses (full checked look), like Word. Esc or another click turns it off.
fn format_painter_button(ui: &mut Ui, app: &mut WordApp) {
let sticky = matches!(app.session.painter, Some((_, _, true)));
let armed = app.session.painter.is_some();
let resp = small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), sticky);
if resp.double_clicked() {
painter_double_click(app);
} else if armed && !sticky {
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(resp.rect, egui::CornerRadius::same(4), Stroke::new(1.0, t.accent.linear_multiply(0.5)), egui::StrokeKind::Inside);
}
}
/// A double-click fires `clicked()` twice, so by now the painter has been armed and disarmed again.
/// Arm it once more, this time sticky.
pub(super) fn painter_double_click(app: &mut WordApp) {
if app.session.painter.is_some() {
let _ = app.run("edit.formatPainter", json!({}));
}
let _ = app.run("edit.formatPainter", json!({"sticky": true}));
}
pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| {
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, "edit.paste", |ui, app| {
mi(ui, app, "Paste", "edit.paste", json!({}));
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({}));
mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({}));
});
stack(ui, |ui| {
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false);
format_painter_button(ui, app);
});
});
group(ui, "Font", Some("format.fontDialog"), app, |ui, app| {
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string();
let font = app.previews.font_shown(&font);
let fams = app.previews.families();
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
let prev = app.previews.font_preview_fn();
if let Some(f) = crate::widgets::font_combo_with_recent_hint(ui, "font", 150.0, &font, &fams, &recent, Some(&*prev), tl!("Font")) {
let _ = if f == crate::widgets::ADD_FONT {
app.run("font.add", json!({}))
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
app.run("font.loadSystem", json!({}))
} else {
let f = app.previews.font_stored(&f);
app.run("format.font", json!({"name": f}))
};
}
let size = st
.get("size")
.and_then(Value::as_f64)
.map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") })
.unwrap_or_default();
let sizes: Vec<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) = crate::widgets::combo_hint(ui, "size", 52.0, &size, &sizes, None, tl!("Font Size"))
&& 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, underline_menu);
small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike"));
small(ui, app, "subscript", None, "Subscript", "format.subscript", json!({}), flag("subscript"));
small(ui, app, "superscript", None, "Superscript", "format.superscript", json!({}), flag("superscript"));
small(ui, app, "charborder", None, "Character Border", "format.border", json!({}), flag("border"));
small(ui, app, "enclose", None, "Enclose Characters", "format.enclose", json!({}), false);
ui.add_space(4.0);
menu_button(ui, app, "effects", None, "Text Effects and Typography", false, "", |ui, app| {
let theme = app.session.doc.settings.theme_colors.clone();
crate::wordart_menus::style_gallery(ui, app, "format.textEffects");
ui.separator();
ui.menu_button(tl!("Text Fill"), |ui| crate::wordart_menus::fill_menu(ui, app, &theme));
let outline = crate::wordart_menus::current_outline(app);
ui.menu_button(tl!("Text Outline"), |ui| crate::wordart_menus::outline_menu(ui, app, &theme, outline));
crate::wordart_menus::effects_menu(ui, app, &theme, false);
ui.separator();
mi(ui, app, "Outline", "format.outline", json!({}));
mi(ui, app, "Shadow", "format.shadow", json!({}));
mi(ui, app, "Small Caps", "format.smallCaps", json!({}));
mi(ui, app, "All Caps", "format.allCaps", json!({}));
mi(ui, app, "Double Strikethrough", "format.doubleStrikethrough", json!({}));
});
let hl = app.canvas.last_highlight.clone();
let (icon, sw, color) = crate::mini_toolbar::highlight_button_appearance(&hl);
split(
ui,
app,
icon,
"Text Highlight Color",
"format.highlight",
json!({"color": color}),
false,
sw,
crate::mini_toolbar::highlight_menu,
);
let fc = app.canvas.last_font_color.clone();
let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32);
split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| {
mi(ui, app, "Automatic", "format.color", json!({"color": "auto"}));
let theme = app.session.doc.settings.theme_colors.clone();
crate::colors::menu(ui, app, &theme, Target::cmd("format.color").remember(Remember::FontColor));
});
});
});
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
// The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start).
let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default();
let rtl = rp.as_ref().is_some_and(|r| r.bidi);
let lists = wordcraft_engine::cmd::para::list_buttons(&app.session.doc, rp.as_ref().and_then(|r| r.numbering));
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), lists.bullets, None, |ui, app| {
ui.label(egui::RichText::new(tl!("Bullet Library")).small().weak());
ui.horizontal(|ui| {
for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] {
// The interface fonts have no ➢, so its tile draws the arrowhead.
let label = if c == "➢" { " " } else { c };
let resp = ui.button(egui::RichText::new(label).size(16.0));
if c == "➢" {
let m = resp.rect.center();
let pts = [(-4.0, -5.0), (5.0, 0.0), (-4.0, 5.0), (-1.5, 0.0)].map(|(x, y)| m + vec2(x, y));
ui.painter().add(egui::Shape::convex_polygon(vec![pts[0], pts[1], pts[3]], Tokens::get(ui.ctx()).text, Stroke::NONE));
ui.painter().add(egui::Shape::convex_polygon(vec![pts[3], pts[1], pts[2]], Tokens::get(ui.ctx()).text, Stroke::NONE));
}
if resp.clicked() {
let _ = app.run("para.bullets", json!({"kind": c}));
ui.close();
}
}
});
mi(ui, app, "None", "para.bullets", json!({"off": true}));
});
split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), lists.numbering, None, |ui, app| {
for (l, k) in [
("1. 2. 3.", "numbered"),
("1) 2) 3)", "numberedParen"),
("I. II. III.", "outline"),
("A. B. C.", "upperLetter"),
("a) b) c)", "lowerLetter"),
("i. ii. iii.", "lowerRoman"),
] {
mi(ui, app, l, "para.numbering", json!({"kind": k}));
}
ui.separator();
mi(ui, app, "Restart at 1", "para.restartNumbering", json!({}));
mi(ui, app, "None", "para.numbering", json!({"off": true}));
});
split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), lists.multilevel, None, |ui, app| {
mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"}));
mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"}));
ui.separator();
// Lists linked to the heading styles: the headings carry the numbers. Each item
// reads as its first three levels, number then (translated) style name.
for (numbers, k) in [
(["1", "1.1", "1.1.1"], "headings"),
(["Article I.", "Section 1.01", "(a)"], "article"),
(["I.", "A.", "1."], "outlineHeadings"),
(["Chapter 1", "", ""], "chapter"),
] {
let text = numbers
.iter()
.zip(["Heading 1", "Heading 2", "Heading 3"])
.map(|(n, h)| if n.is_empty() { tl!(h).to_string() } else { format!("{} {}", tl!(n), tl!(h)) })
.collect::<Vec<_>>()
.join(" / ");
mi_text(ui, app, &text, "list.multilevelPreset", json!({"preset": k}));
}
ui.separator();
mi(ui, app, "Define New Multilevel List…", "list.define", json!({}));
ui.separator();
for lv in 0..5u64 {
mi(ui, app, &format!("Change to Level {}", lv + 1), "para.listLevel", json!({"level": lv}));
}
});
ui.add_space(4.0);
small(ui, app, "outdent", None, "Decrease Indent", "para.outdent", json!({}), false);
small(ui, app, "indent", None, "Increase Indent", "para.indent", json!({}), false);
ui.add_space(4.0);
small(ui, app, "sort", None, "Sort", "para.sort", json!({}), false);
let marks = app.session.view.marks;
small(ui, app, "pilcrow", None, "Show/Hide ¶", "view.marks", json!({}), marks);
});
ui.add_space(3.0);
crate::widgets::row(ui, |ui| {
use wordcraft_doc::Align as A;
small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left);
small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center);
small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right);
small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify);
ui.add_space(4.0);
small(ui, app, "textLtr", None, "Left-to-Right Text Direction", "para.ltr", json!({}), !rtl);
small(ui, app, "textRtl", None, "Right-to-Left Text Direction", "para.rtl", json!({}), rtl);
ui.add_space(4.0);
menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, "para.lineSpacing", |ui, app| {
for v in [1.0, 1.15, 1.5, 2.0, 2.5, 3.0] {
mi(ui, app, &format!("{v}"), "para.lineSpacing", json!({"value": v}));
}
ui.separator();
mi(ui, app, "Add Space Before Paragraph", "para.addSpaceBefore", json!({}));
mi(ui, app, "Remove Space After Paragraph", "para.removeSpaceAfter", json!({}));
mi(ui, app, "Line Spacing Options…", "para.dialog", json!({}));
});
split(ui, app, "shading", "Shading", "para.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
mi(ui, app, "No Color", "para.shading", json!({"color": null}));
let theme = app.session.doc.settings.theme_colors.clone();
crate::colors::menu(ui, app, &theme, Target::cmd("para.shading").remember(Remember::Shading));
});
split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| {
for (l, k) in [
("Bottom Border", "bottom"),
("Top Border", "top"),
("Left Border", "left"),
("Right Border", "right"),
("No Border", "none"),
("All Borders", "all"),
("Outside Borders", "outside"),
("Inside Borders", "inside"),
] {
mi(ui, app, l, "para.borders", json!({"kind": k}));
}
ui.separator();
mi(ui, app, "Horizontal Line", "insert.horizontalLine", json!({}));
mi(ui, app, "Borders and Shading…", "para.borders", json!({}));
});
});
});
});
group(ui, "Styles", Some("styles.pane"), app, |ui, app| {
crate::previews::style_gallery(app, ui, &st);
});
group(ui, "Editing", None, app, |ui, app| {
stack(ui, |ui| {
menu_button(ui, app, "find", Some("Find"), "Find", false, "edit.find", |ui, app| {
// As in Word: Find searches in the Navigation pane; Advanced Find is the dialog (#712).
mi(ui, app, "Find", "edit.find", json!({}));
mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({}));
mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"}));
});
small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false);
menu_button(ui, app, "select", Some("Select"), "Select", false, "select.all", |ui, app| {
mi(ui, app, "Select All", "select.all", json!({}));
mi(ui, app, "Select Paragraph", "select.paragraph", json!({}));
mi(ui, app, "Select Sentence", "select.sentence", json!({}));
});
});
});
group(ui, "Voice", None, app, |ui, app| {
big(ui, app, "dictate", "Dictate", "tools.dictate", json!({}), false);
});
group(ui, "Editor", None, app, |ui, app| {
big(ui, app, "editor", "Editor", "review.spelling", json!({}), false);
});
}
+175
View File
@@ -0,0 +1,175 @@
use super::common::{mi, mi_icon, stack};
use super::language::mi_check;
use super::*;
pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Pages", None, app, |ui, app| {
stack(ui, |ui| {
menu_button(ui, app, "coverPage", Some("Cover Page"), "Cover Page", false, "insert.coverPage", |ui, app| {
mi(ui, app, "Studio cover", "insert.coverPage", json!({}));
});
small(ui, app, "blankPage", Some("Blank Page"), "Blank Page", "insert.blankPage", json!({}), false);
small(ui, app, "pageBreak", Some("Page Break"), "Page Break", "insert.pageBreak", json!({}), false);
});
});
group(ui, "Tables", None, app, |ui, app| {
menu_button(ui, app, "table", Some("Table"), "Add a Table", true, "insert.table", |ui, app| {
crate::dialogs::table_grid_picker(ui, app);
ui.separator();
mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"}));
let drawing = app.canvas.table_tool == Some(crate::table_pen::TableTool::Draw);
mi_check(ui, app, "Draw Table", drawing, "table.draw", json!({}));
mi(ui, app, "Convert Text to Table…", "table.fromText", json!({}));
ui.menu_button(tl!("Quick Tables"), |ui| {
mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"}));
mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"}));
mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"}));
});
});
});
group(ui, "Illustrations", None, app, |ui, app| {
big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false);
menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, "insert.shape", |ui, app| {
for (l, k) in [
("Rectangle", "rectangle"),
("Rounded Rectangle", "roundedRectangle"),
("Oval", "ellipse"),
("Triangle", "triangle"),
("Diamond", "diamond"),
("Line", "line"),
("Arrow", "arrow"),
("Star", "star"),
("Heart", "heart"),
] {
mi_icon(ui, app, &format!("shape.{k}"), l, "insert.shape", json!({"kind": k}));
}
ui.separator();
mi_icon(ui, app, "canvas", "New Drawing Canvas", "insert.canvas", json!({}));
// Connectors: glue their ends to shapes by dragging them (shape.connect).
for (l, k, arrow) in [
("Straight Connector", "straightConnector", "none"),
("Straight Arrow Connector", "straightConnector", "end"),
("Elbow Connector", "elbowConnector", "none"),
("Elbow Arrow Connector", "elbowConnector", "end"),
("Curved Connector", "curvedConnector", "none"),
("Curved Arrow Connector", "curvedConnector", "end"),
] {
let icon = if arrow == "end" { k.replace("Connector", "ArrowConnector") } else { k.to_string() };
mi_icon(ui, app, &format!("shape.{icon}"), l, "insert.shape", json!({"kind": k, "arrow": arrow}));
}
});
stack(ui, |ui| {
small(ui, app, "icons", Some("Icons"), "Icons", "insert.icon", json!({}), false);
small(ui, app, "smartArt", Some("SmartArt"), "SmartArt", "insert.smartArt", json!({}), false);
small(ui, app, "chart", Some("Chart"), "Chart", "insert.chart", json!({}), false);
});
});
group(ui, "Links", None, app, |ui, app| {
stack(ui, |ui| {
small(ui, app, "link", Some("Link"), "Link", "ui.dialog", json!({"name": "link"}), false);
small(ui, app, "bookmark", Some("Bookmark"), "Bookmark", "ui.dialog", json!({"name": "bookmark"}), false);
small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false);
});
});
group(ui, "Comments", None, app, |ui, app| {
big(ui, app, "newComment", "Comment", "review.newComment", json!({}), false);
});
group(ui, "Header & Footer", None, app, |ui, app| {
menu_button(ui, app, "header", Some("Header"), "Header", true, "insert.header", |ui, app| {
mi(ui, app, "Blank", "insert.header", json!({"preset": "blank"}));
mi(ui, app, "Blank (Three Columns)", "insert.header", json!({"preset": "blankThree"}));
mi(ui, app, "Document Title", "insert.header", json!({"preset": "title"}));
ui.separator();
mi(ui, app, "Edit Header", "insert.editHeader", json!({}));
mi(ui, app, "Remove Header", "insert.removeHeader", json!({}));
});
menu_button(ui, app, "footer", Some("Footer"), "Footer", true, "insert.footer", |ui, app| {
mi(ui, app, "Blank", "insert.footer", json!({"preset": "blank"}));
mi(ui, app, "Blank (Three Columns)", "insert.footer", json!({"preset": "blankThree"}));
mi(ui, app, "Page Number", "insert.footer", json!({"preset": "pageNumber"}));
ui.separator();
mi(ui, app, "Edit Footer", "insert.editFooter", json!({}));
mi(ui, app, "Remove Footer", "insert.removeFooter", json!({}));
});
menu_button(ui, app, "pageNumber", Some("Page\nNumber"), "Page Number", true, "insert.pageNumber", |ui, app| {
mi(ui, app, "Top of Page", "insert.pageNumber", json!({"position": "top", "align": "right"}));
mi(ui, app, "Bottom of Page", "insert.pageNumber", json!({"position": "bottom", "align": "center"}));
mi(ui, app, "Page X of Y", "insert.pageNumber", json!({"position": "bottom", "format": "x of y"}));
mi(ui, app, "Current Position", "insert.pageNumber", json!({"position": "current"}));
mi(ui, app, "Format Page Numbers…", "ui.dialog", json!({"name": "pageNumbers"}));
});
});
group(ui, "Text", None, app, |ui, app| {
big(ui, app, "textBox", "Text\nBox", "insert.textBox", json!({}), false);
stack(ui, |ui| {
menu_button(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", false, "insert.quickParts", |ui, app| {
// This session's building blocks, then the document's own Quick Parts.
let mut parts: Vec<String> = app.session.building_blocks.keys().cloned().collect();
let mut seen: std::collections::HashSet<String> = parts.iter().cloned().collect();
let glossary = app.session.doc.glossary.iter().filter(|e| e.gallery == wordcraft_doc::QUICK_PARTS_GALLERY);
parts.extend(glossary.filter(|e| seen.insert(e.name.clone())).map(|e| e.name.clone()));
for name in parts {
if ui.button(&name).clicked() {
let _ = app.run("insert.quickParts", json!({"insert": name}));
ui.close();
}
}
mi(ui, app, "Field…", "ui.dialog", json!({"name": "field"}));
});
menu_button(ui, app, "wordArt", Some("WordArt"), "WordArt", false, "", |ui, app| {
crate::wordart_menus::style_gallery(ui, app, "insert.wordArt")
});
menu_button(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", false, "insert.dropCap", drop_cap_items);
});
stack(ui, |ui| {
small(ui, app, "signature", None, "Signature Line", "insert.signatureLine", json!({}), false);
small(ui, app, "dateTime", None, "Date & Time", "insert.dateTime", json!({}), false);
small(ui, app, "object", None, "Object", "insert.object", json!({}), false);
});
});
group(ui, "Symbols", None, app, |ui, app| {
crate::equation_tab::insert_button(ui, app);
menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, "insert.symbol", |ui, app| {
// Recently used symbols first (their font on hover), then common ones.
let mut items: Vec<(String, String)> = app.ui.recent_symbols.iter().map(|r| (r.ch.clone(), r.font.clone())).collect();
for c in [
"©", "®", "™", "§", "", "€", "£", "¥", "°", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Ω", "∑", "√", "→", "←", "✓",
"★", "♥", "—", "…",
] {
if !items.iter().any(|(ch, f)| ch == c && f.is_empty()) {
items.push((c.to_string(), String::new()));
}
}
items.truncate(30);
egui::Grid::new("syms").show(ui, |ui| {
for (i, (c, font)) in items.iter().enumerate() {
let b = ui.button(egui::RichText::new(c).size(16.0));
let b = if font.is_empty() { b } else { b.on_hover_text(font) };
if b.clicked() {
let params = if font.is_empty() { json!({"char": c}) } else { json!({"char": c, "font": font}) };
if app.run("insert.symbol", params).is_ok() {
crate::dialogs_insert::remember_symbol(app, c, font);
}
ui.close();
}
if i % 6 == 5 {
ui.end_row();
}
}
});
ui.separator();
mi(ui, app, "More Symbols…", "ui.dialog", json!({"name": "symbol"}));
});
});
}
/// Insert › Drop Cap's menu (#2601): None, Dropped and In margin (the current one checked),
/// then the Drop Cap dialog.
fn drop_cap_items(ui: &mut Ui, app: &mut WordApp) {
let current = wordcraft_engine::cmd::insert::drop_cap_state(&app.session).position;
for (label, v) in [("None", "none"), ("Dropped", "dropped"), ("In margin", "margin")] {
mi_check(ui, app, label, current == v, "insert.dropCap", json!({"position": v}));
}
ui.separator();
mi(ui, app, "Drop Cap Options…", "ui.dialog", json!({"name": "dropCap"}));
}
+249
View File
@@ -0,0 +1,249 @@
use super::*;
/// The proofing languages the Language menu offers: tag and English name (translated when drawn).
pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [
("en-US", "English (United States)"),
("en-GB", "English (United Kingdom)"),
("en-AU", "English (Australia)"),
("en-CA", "English (Canada)"),
("pl-PL", "Polish"),
("pt-BR", "Portuguese (Brazil)"),
("it-IT", "Italian"),
("hr-HR", "Croatian"),
("tr-TR", "Turkish"),
("ru-RU", "Russian"),
("cs-CZ", "Czech"),
("id-ID", "Indonesian"),
("es-ES", "Spanish (Spain)"),
("es-MX", "Spanish (Mexico)"),
("es-419", "Spanish (Latin America)"),
("es-UY", "Spanish (Uruguay)"),
];
/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's
/// default, US English): a listed language by its name; another region of one by the language
/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag.
pub(crate) fn language_name(tag: Option<&str>) -> String {
let tag = tag.unwrap_or("en-US");
let named =
|t: &str| PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n);
// Any language the Language dialog offers (French, German…) by its name, not its tag (#860).
if let Some(name) = named(tag) {
return name.to_string();
}
let mut parts = tag.split(['-', '_']);
let primary = parts.next().unwrap_or("");
// The region: the first two-letter or three-digit subtag after the language (and script).
let region =
parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())));
match region {
// A region the list doesn't have: the language's name without its own region or variant.
Some(r) => {
let language = crate::dialogs_layout::LANGUAGES
.iter()
.find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary)))
.map(|(_, n)| n.split(" (").next().unwrap_or(n));
match language {
Some(language) => format!("{language} ({})", region_name(r)),
None => tag.to_string(),
}
}
// A bare language subtag ("pt", "pl"): the variant WordCraft proofs.
None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
wordcraft_engine::proof::ProofLang::Pl => "Polish".into(),
wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(),
wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(),
wordcraft_engine::proof::ProofLang::It => "Italian".into(),
wordcraft_engine::proof::ProofLang::Tr => "Turkish".into(),
wordcraft_engine::proof::ProofLang::Ru => "Russian".into(),
wordcraft_engine::proof::ProofLang::Cs => "Czech".into(),
wordcraft_engine::proof::ProofLang::Id => "Indonesian".into(),
wordcraft_engine::proof::ProofLang::Es => "Spanish (Spain)".into(),
wordcraft_engine::proof::ProofLang::De => "German (Germany)".into(),
wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(),
_ => tag.to_string(),
},
}
}
/// The English name of a region subtag the language list has no entry for (else the subtag).
fn region_name(code: &str) -> String {
const REGIONS: [(&str, &str); 62] = [
("AE", "U.A.E."),
("AO", "Angola"),
("AR", "Argentina"),
("AT", "Austria"),
("AU", "Australia"),
("BA", "Bosnia and Herzegovina"),
("BE", "Belgium"),
("BO", "Bolivia"),
("BR", "Brazil"),
("BY", "Belarus"),
("CA", "Canada"),
("CH", "Switzerland"),
("CL", "Chile"),
("CN", "China"),
("CO", "Colombia"),
("CR", "Costa Rica"),
("CV", "Cabo Verde"),
("CY", "Cyprus"),
("DE", "Germany"),
("DO", "Dominican Republic"),
("EC", "Ecuador"),
("EG", "Egypt"),
("ES", "Spain"),
("FR", "France"),
("GB", "United Kingdom"),
("GT", "Guatemala"),
("HK", "Hong Kong SAR"),
("IE", "Ireland"),
("IN", "India"),
("IQ", "Iraq"),
("IT", "Italy"),
("JM", "Jamaica"),
("JO", "Jordan"),
("KW", "Kuwait"),
("KZ", "Kazakhstan"),
("LB", "Lebanon"),
("LI", "Liechtenstein"),
("LU", "Luxembourg"),
("MA", "Morocco"),
("MC", "Monaco"),
("ME", "Montenegro"),
("MO", "Macao SAR"),
("MX", "Mexico"),
("MY", "Malaysia"),
("MZ", "Mozambique"),
("NL", "Netherlands"),
("NZ", "New Zealand"),
("PE", "Peru"),
("PH", "Philippines"),
("PR", "Puerto Rico"),
("PT", "Portugal"),
("PY", "Paraguay"),
("QA", "Qatar"),
("RU", "Russia"),
("SG", "Singapore"),
("SY", "Syria"),
("TW", "Taiwan"),
("UA", "Ukraine"),
("US", "United States"),
("UY", "Uruguay"),
("VE", "Venezuela"),
("ZA", "South Africa"),
];
REGIONS.iter().find(|(c, _)| c.eq_ignore_ascii_case(code)).map_or_else(|| code.to_ascii_uppercase(), |(_, n)| (*n).to_string())
}
/// The language at the caret (resolved through the styles), translated, for the status bar.
pub(crate) fn caret_language(app: &WordApp) -> String {
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
}
/// The formatting at the caret, resolved through the styles and the document default.
fn caret_char(app: &WordApp) -> wordcraft_doc::resolve::ResolvedChar {
let s = &app.session;
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone());
s.doc.styles.resolve_char(style.as_deref(), &s.typing_props())
}
/// The language tag at the caret, resolved through the styles and the document default.
fn caret_lang_tag(app: &WordApp) -> Option<String> {
caret_char(app).lang
}
/// Is the text at the caret itself marked Do not check spelling or grammar? (`format.state`
/// looks at the whole word around a caret, so a partly marked word would read as checked.)
pub(crate) fn caret_no_proof(app: &WordApp) -> bool {
caret_char(app).no_proof
}
/// Does WordCraft check spelling and grammar in the caret's language at all (Bulgarian: no)?
pub(crate) fn caret_language_proofed(app: &WordApp) -> bool {
wordcraft_engine::proof::has_dictionary(caret_lang_tag(app).as_deref())
}
/// The Hunspell dictionary the caret's language needs but doesn't have (`pl_PL`), if any (#1167).
pub(crate) fn caret_missing_dictionary(app: &WordApp) -> Option<&'static str> {
wordcraft_engine::proof::missing_dictionary(caret_lang_tag(app).as_deref())
}
/// The dictionary file and reason when the caret's language has a dictionary installed that
/// couldn't be read, so "no dictionary" would send the user to reinstall it for nothing (#2830).
pub(crate) fn caret_unreadable_dictionary(app: &WordApp) -> Option<(std::path::PathBuf, String)> {
wordcraft_engine::proof::unreadable_dictionary(caret_lang_tag(app).as_deref()).map(|(_, file, reason)| (file, reason))
}
/// Review › Language (and the status bar's language): set the selection's proofing language, or
/// leave it unchecked. The current choice has a check mark; a language outside the short list
/// (French, German…) shows checked above it. Set Proofing Language… opens the Language dialog
/// with every language and Set As Default, and Language Preferences… opens Options › Language
/// (#860: the short list alone left no way to pick French).
pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
// The selection's own language; else (nothing set on the text) the one it gets from its
// style or the document default, as the status bar shows.
let current = match st.get("lang") {
Some(Value::String(t)) => t.clone(),
Some(Value::Null) if app.session.sel.is_collapsed() => caret_lang_tag(app).unwrap_or_else(|| "en-US".into()),
_ => String::new(),
};
let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false);
let listed = |tag: &str| {
current.eq_ignore_ascii_case(tag)
|| (tag == "pl-PL" && current.eq_ignore_ascii_case("pl"))
|| (tag == "pt-BR" && current.eq_ignore_ascii_case("pt"))
|| (tag == "it-IT" && current.eq_ignore_ascii_case("it"))
|| (tag == "hr-HR" && current.eq_ignore_ascii_case("hr"))
|| (tag == "tr-TR" && current.eq_ignore_ascii_case("tr"))
|| (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(&current)) == wordcraft_engine::proof::ProofLang::Cs)
|| (tag == "id-ID" && wordcraft_engine::proof::ProofLang::from_tag(Some(&current)) == wordcraft_engine::proof::ProofLang::Id)
|| (tag == "es-ES" && current.eq_ignore_ascii_case("es"))
|| (tag == "en-US" && current.eq_ignore_ascii_case("en"))
};
let checks = language_checks(&current, listed);
if let Some(name) = checks.extra {
mi_check(ui, app, &name, true, "review.language", json!({"lang": current}));
}
for ((tag, name), on) in PROOFING_LANGUAGES.iter().zip(checks.listed) {
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
}
ui.separator();
mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof}));
ui.separator();
mi_check(ui, app, "Set Proofing Language…", false, "review.language", json!({}));
mi_check(ui, app, "Language Preferences…", false, "file.options", json!({"pane": "language"}));
}
/// The Language menu's check marks for the `current` tag: which short-list entries are checked
/// (`listed` matches a tag), and the name of the current language when it is none of them,
/// shown checked above. A language named like a short-list entry checks that entry, so no name
/// shows twice.
pub(super) struct LanguageChecks {
pub(super) extra: Option<String>,
pub(super) listed: [bool; PROOFING_LANGUAGES.len()],
}
pub(super) fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChecks {
let name = (!current.is_empty()).then(|| language_name(Some(current)));
let on = PROOFING_LANGUAGES.map(|(t, n)| listed(t) || name.as_deref() == Some(n));
LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on }
}
/// A menu item with a check mark when `checked` (the current choice of several).
pub(super) fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) {
// A vector mark needs no font glyph; the empty atom keeps labels aligned.
let mark = egui::Atom::paint(vec2(14.0, 14.0), move |ui, args| {
if checked {
let r = args.rect;
let points = vec![r.min + r.size() * vec2(0.15, 0.5), r.min + r.size() * vec2(0.4, 0.75), r.min + r.size() * vec2(0.85, 0.2)];
ui.painter().add(egui::Shape::line(points, egui::Stroke::new(1.5, args.fallback_text_color)));
}
});
let enabled = crate::widgets::enabled(app, id);
let resp = ui.add_enabled(enabled, egui::Button::new((mark, tl!(label))).selected(checked).min_size(vec2(200.0, 0.0)));
if resp.clicked() {
crate::widgets::run_from_ribbon(app, id, params);
ui.close();
}
}
+160
View File
@@ -0,0 +1,160 @@
use super::arrange::{align_menu, group_menu, rotate_menu, wrap_menu};
use super::common::{mi, size_menu, stack};
use super::language::mi_check;
use super::*;
// Layout › Margins, Orientation, Size and Columns: the caret section's setting has a check
// mark, as Text Direction's does (#1206).
pub(super) fn margins_items(ui: &mut Ui, app: &mut WordApp) {
let current = wordcraft_engine::cmd::page::margins_preset_at(&app.session);
// The two uniform presets name their margin in the measurement unit (`2,54 cm`, #838).
for (l, k, uniform) in [
("Normal {size} all", "normal", Some(72.0)),
("Narrow {size} all", "narrow", Some(36.0)),
("Moderate", "moderate", None),
("Wide", "wide", None),
("Mirrored", "mirrored", None),
("Office 2003 Default", "office2003", None),
] {
let label = match uniform {
Some(pt) => crate::i18n::fmt(tl!(l), &[("size", &crate::options::length(pt))]),
None => l.to_string(),
};
// Mirrored margins come from the document as well as from the preset (#1181).
let on = current == Some(k) || (k == "mirrored" && app.session.doc.settings.mirror_margins);
mi_check(ui, app, &label, on, "layout.margins", json!({"preset": k}));
}
mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"}));
}
pub(super) fn orientation_items(ui: &mut Ui, app: &mut WordApp) {
let land = wordcraft_engine::cmd::page::sect(&app.session).landscape;
mi_check(ui, app, "Portrait", !land, "layout.orientation", json!({"value": "portrait"}));
mi_check(ui, app, "Landscape", land, "layout.orientation", json!({"value": "landscape"}));
}
pub(super) fn columns_items(ui: &mut Ui, app: &mut WordApp) {
let current = wordcraft_engine::cmd::page::columns_preset_at(&app.session);
for (l, k, params) in [
("One", "one", json!({"count": 1})),
("Two", "two", json!({"count": 2})),
("Three", "three", json!({"count": 3})),
("Left", "left", json!({"preset": "left"})),
("Right", "right", json!({"preset": "right"})),
] {
mi_check(ui, app, l, current == Some(k), "layout.columns", params);
}
ui.separator();
mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"}));
}
pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| {
// Text Direction works on what the caret is in: table cells, a text box, else the section.
let dir = wordcraft_engine::cmd::page::text_direction_at(&app.session);
menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, "layout.textDirection", |ui, app| {
use wordcraft_doc::props::TextDirection;
for (label, d) in
[("Horizontal", TextDirection::Horizontal), ("Rotate all text 90°", TextDirection::Down), ("Rotate all text 270°", TextDirection::Up)]
{
mi_check(ui, app, label, dir == d, "layout.textDirection", json!({"value": d.ooxml()}));
}
});
menu_button(ui, app, "margins", Some("Margins"), "Margins", true, "layout.margins", margins_items);
menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, "layout.orientation", orientation_items);
menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", size_menu);
menu_button(ui, app, "columns", Some("Columns"), "Columns", true, "layout.columns", columns_items);
stack(ui, |ui| {
menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, "layout.break", |ui, app| {
ui.label(egui::RichText::new(tl!("Page Breaks")).strong());
mi(ui, app, "Page", "layout.break", json!({"kind": "page"}));
mi(ui, app, "Column", "layout.break", json!({"kind": "column"}));
mi(ui, app, "Text Wrapping", "layout.break", json!({"kind": "textWrapping"}));
ui.label(egui::RichText::new(tl!("Section Breaks")).strong());
mi(ui, app, "Next Page", "layout.break", json!({"kind": "nextPage"}));
mi(ui, app, "Continuous", "layout.break", json!({"kind": "continuous"}));
mi(ui, app, "Even Page", "layout.break", json!({"kind": "evenPage"}));
mi(ui, app, "Odd Page", "layout.break", json!({"kind": "oddPage"}));
});
menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, "layout.lineNumbers", |ui, app| {
for (l, v) in
[("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")]
{
mi(ui, app, l, "layout.lineNumbers", json!({"value": v}));
}
ui.separator();
mi(ui, app, "Line Numbering Options…", "ui.dialog", json!({"name": "lineNumbers"}));
});
let hy = app.session.doc.settings.auto_hyphenation;
menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, "layout.hyphenation", |ui, app| {
mi(ui, app, if hy { "✓ Automatic" } else { "Automatic" }, "layout.hyphenation", json!({"value": true}));
mi(ui, app, if hy { "None" } else { "✓ None" }, "layout.hyphenation", json!({"value": false}));
mi(ui, app, "Manual…", "ui.dialog", json!({"name": "manualHyphenation"}));
ui.separator();
mi(ui, app, "Hyphenation Options…", "ui.dialog", json!({"name": "hyphenation"}));
});
});
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let Some(rp) = rp else { return };
// #654: header + two rows of 20 px fields fit the content height above the group title
// (1.5 pt between rows; 3 ran 2.5 pt into it); labels and fields sit on shared columns,
// every field the same width.
const FIELD: egui::Vec2 = egui::Vec2::new(64.0, 20.0);
ui.spacing_mut().interact_size.y = FIELD.y;
// The gap before the Spacing column sits inside its cells: `add_space` in a grid moves the
// next cell in release builds but trips a debug assertion.
let spaced = |ui: &mut Ui, text: egui::RichText| {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(text);
});
};
egui::Grid::new("layout_para").spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y).show(ui, |ui| {
ui.label(egui::RichText::new(tl!("Indent")).small().strong());
ui.label("");
spaced(ui, egui::RichText::new(tl!("Spacing")).small().strong());
ui.end_row();
// Indents in the File › Options unit.
let u = crate::options::unit();
let k = u.pt_per_unit();
let mut l = rp.indent_left / k;
let mut b = rp.space_before;
ui.label(egui::RichText::new(tl!("Left:")).small());
if ui.add_sized(FIELD, egui::DragValue::new(&mut l).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() {
let _ = app.run("para.indents", json!({"left": l * k}));
}
spaced(ui, egui::RichText::new(tl!("Before:")).small());
if ui.add_sized(FIELD, egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() {
let _ = app.run("para.spacing", json!({"before": b}));
}
ui.end_row();
let mut r = rp.indent_right / k;
let mut a = rp.space_after;
ui.label(egui::RichText::new(tl!("Right:")).small());
if ui.add_sized(FIELD, egui::DragValue::new(&mut r).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() {
let _ = app.run("para.indents", json!({"right": r * k}));
}
spaced(ui, egui::RichText::new(tl!("After:")).small());
if ui.add_sized(FIELD, egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() {
let _ = app.run("para.spacing", json!({"after": a}));
}
ui.end_row();
});
});
group(ui, "Arrange", None, app, |ui, app| {
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
wrap_menu(ui, app);
stack(ui, |ui| {
small(ui, app, "bringForward", Some("Bring Forward"), "Bring Forward", "arrange.bringForward", json!({}), false);
small(ui, app, "sendBackward", Some("Send Backward"), "Send Backward", "arrange.sendBackward", json!({}), false);
small(ui, app, "selectionPane", Some("Selection Pane"), "Selection Pane", "arrange.selectionPane", json!({}), false);
});
stack(ui, |ui| {
align_menu(ui, app, None);
group_menu(ui, app, None);
rotate_menu(ui, app, None);
});
});
}
+89
View File
@@ -0,0 +1,89 @@
use super::common::{mi, stack};
use super::language::mi_check;
use super::*;
pub(super) fn mailings(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Create", None, app, |ui, app| {
big(ui, app, "envelope", "Envelopes", "mailings.envelopes", json!({}), false);
big(ui, app, "labels", "Labels", "mailings.labels", json!({}), false);
});
group(ui, "Start Mail Merge", None, app, |ui, app| {
// The kinds of merge document the engine makes, the current one checked.
menu_button(ui, app, "mailMerge", Some("Start Mail\nMerge"), "Start Mail Merge", true, "mailings.start", |ui, app| {
let kind = app.session.merge.kind.clone();
for (label, k) in [
("Letters", "letters"),
("E-mail Messages", "emails"),
("Envelopes", "envelopes"),
("Labels", "labels"),
("Directory", "directory"),
("Normal Word Document", "normal"),
] {
let current = kind == k || (k == "normal" && kind.is_empty());
mi_check(ui, app, label, current, "mailings.start", json!({"kind": k}));
}
});
// The command needs data, so the button offers the two ways to give it (#240).
menu_button(ui, app, "recipients", Some("Select\nRecipients"), "Select Recipients", true, "mailings.recipients", |ui, app| {
mi(ui, app, "Type a New List…", "ui.dialog", json!({"name": "newRecipientList"}));
mi(ui, app, "Use an Existing List…", "ui.openRecipientList", json!({}));
});
big(ui, app, "editRecipients", "Edit\nRecipient List", "mailings.editRecipients", json!({}), false);
});
group(ui, "Write & Insert Fields", None, app, |ui, app| {
big(ui, app, "highlightFields", "Highlight\nMerge Fields", "mailings.highlightFields", json!({}), false);
big(ui, app, "addressBlock", "Address\nBlock", "mailings.addressBlock", json!({}), false);
big(ui, app, "greetingLine", "Greeting\nLine", "mailings.greetingLine", json!({}), false);
big(ui, app, "mergeField", "Insert Merge\nField", "mailings.insertField", json!({}), false);
stack(ui, |ui| {
// The merge rules the engine knows; If and Skip Record If ask for their condition.
menu_button(ui, app, "rules", Some("Rules"), "Rules", false, "mailings.rules", |ui, app| {
mi(ui, app, "If…Then…Else…", "mailings.rules", json!({"rule": "IF"}));
mi(ui, app, "Merge Record #", "mailings.rules", json!({"rule": "MERGEREC"}));
mi(ui, app, "Next Record", "mailings.rules", json!({"rule": "NEXT"}));
mi(ui, app, "Skip Record If…", "mailings.rules", json!({"rule": "SKIPIF"}));
});
small(ui, app, "matchFields", Some("Match Fields"), "Match Fields", "mailings.matchFields", json!({}), false);
});
});
group(ui, "Preview Results", None, app, |ui, app| {
big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false);
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
small(ui, app, "first", None, "First Record", "mailings.first", json!({}), false);
small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false);
record_box(ui, app);
small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false);
small(ui, app, "last", None, "Last Record", "mailings.last", json!({}), false);
});
small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false);
small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false);
});
});
group(ui, "Finish", None, app, |ui, app| {
menu_button(ui, app, "finish", Some("Finish &\nMerge"), "Finish & Merge", true, "mailings.finish", |ui, app| {
mi(ui, app, "Edit Individual Documents…", "mailings.finish", json!({}));
mi(ui, app, "Print Documents…", "mailings.printDocuments", json!({}));
});
});
}
/// Preview Results' record number: shows the previewed record; a number typed and Enter go there.
fn record_box(ui: &mut Ui, app: &mut WordApp) {
let on = !app.session.merge.rows.is_empty();
let shown = if on { (app.session.merge.record + 1).to_string() } else { String::new() };
// The draft lives in temp memory while typing (frame-local text would lose keystrokes).
let key = egui::Id::unique("merge_record_box");
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| shown.clone());
let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).align(egui::Align2::CENTER_TOP));
let r = r.on_hover_text(tl!("Go to Record"));
if r.changed() {
ui.data_mut(|d| d.insert_temp(key, text.clone()));
}
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(key));
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && text.trim() != shown {
let _ = app.run("mailings.goToRecord", json!({"record": text.trim()}));
}
}
}
+177
View File
@@ -0,0 +1,177 @@
use super::arrange::{align_menu, group_menu, rotate_menu, rotation_field, wrap_menu};
use super::common::{esc_cancelled, mi, stack};
use super::shape::alt_text_group;
use super::*;
/// Draws the Picture Format tab's Adjust, Picture Styles, Accessibility (Alt Text), Arrange and
/// Size groups.
pub(super) fn picture_format(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Adjust", None, app, |ui, app| {
menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, "picture.corrections", |ui, app| {
for (l, b, c) in
[("Brighter +20%", 20.0, 0.0), ("Darker −20%", -20.0, 0.0), ("More Contrast +20%", 0.0, 20.0), ("Less Contrast −20%", 0.0, -20.0)]
{
mi(ui, app, l, "picture.corrections", json!({"brightness": b, "contrast": c}));
}
ui.separator();
mi(ui, app, "Sharpen", "picture.corrections", json!({"sharpen": 40.0}));
mi(ui, app, "Soften", "picture.corrections", json!({"sharpen": -40.0}));
});
menu_button(ui, app, "picture", Some("Color"), "Saturation and recolor", true, "picture.color", |ui, app| {
for (l, v) in [("Full Saturation 100%", 100.0), ("Muted 50%", 50.0), ("Gray 0%", 0.0), ("Vivid 200%", 200.0)] {
mi(ui, app, l, "picture.color", json!({"mode": "saturation", "saturation": v}));
}
ui.separator();
mi(ui, app, "Grayscale", "picture.color", json!({"mode": "grayscale"}));
mi(ui, app, "Sepia", "picture.color", json!({"mode": "sepia"}));
mi(ui, app, "Washout", "picture.color", json!({"mode": "washout"}));
});
menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, "picture.iconColor", |ui, app| {
let theme = app.session.doc.settings.theme_colors.clone();
crate::colors::menu(ui, app, &theme, Target::cmd("picture.iconColor"));
});
menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, "picture.transparency", |ui, app| {
for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] {
mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v}));
}
ui.separator();
// An exact value, dragged with a live preview: absolute from the original
// pixels, one undo step for the whole drag (same pattern as Size › W/H).
// The current value isn't stored anywhere, so the slider reopens where it
// was last left for this picture.
let (media, off) = match wordcraft_engine::cmd::objects::selected(&app.session) {
Some((pos, wordcraft_doc::para::InlineObject::Image { media, .. })) => (media, pos.off),
_ => (String::new(), 0),
};
let key = egui::Id::unique(("picture_transparency", media, off));
let mut v = ui.data(|d| d.get_temp::<f64>(key)).unwrap_or(0.0);
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(0.0..=100.0).suffix("%"));
if r.changed() {
ui.data_mut(|d| d.insert_temp(key, v));
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("picture.transparency", json!({"percent": v}));
}
});
stack(ui, |ui| {
small(ui, app, "picture", Some("Change"), "Change Picture", "ui.changePicture", json!({}), false);
small(ui, app, "picture", Some("Reset"), "Reset Picture", "picture.reset", json!({}), false);
});
});
group(ui, "Picture Styles", None, app, |ui, app| {
menu_button(ui, app, "picture", Some("Styles"), "Frames and effects", true, "picture.style", |ui, app| {
for (l, s) in [
("Simple Frame", "simpleFrame"),
("Thick Frame", "thickFrame"),
("Rounded", "rounded"),
("Soft Edge", "softEdge"),
("Shadow", "shadow"),
] {
mi(ui, app, l, "picture.style", json!({"style": s}));
}
});
menu_button(ui, app, "picture", Some("Border"), "Picture border", true, "picture.border", |ui, app| {
for (l, w) in [("Thin", 2), ("Medium", 4), ("Thick", 8)] {
mi(ui, app, l, "picture.border", json!({"width": w}));
}
});
});
alt_text_group(ui, app);
group(ui, "Arrange", None, app, |ui, app| {
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
wrap_menu(ui, app);
stack(ui, |ui| {
rotate_menu(ui, app, Some("Rotate"));
align_menu(ui, app, Some("Align"));
group_menu(ui, app, Some("Group"));
});
});
// The launcher opens the Size dialog: scale, Lock aspect ratio, the original size (#2573).
group(ui, "Size", Some("picture.size"), app, |ui, app| {
let (media, off, w0, h0, crop0, alt0) = match wordcraft_engine::cmd::objects::selected(&app.session) {
Some((pos, wordcraft_doc::para::InlineObject::Image { media, w, h, alt, crop, .. })) => (media, pos.off, w, h, crop, alt),
_ => (String::new(), 0, 0.0, 0.0, [0.0; 4], String::new()),
};
// `picture.size` locks the aspect ratio unless told otherwise, so one dimension suffices.
// The fields show the interface unit and take a size typed in any unit, pixels too
// (`300 px`, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels),
// never per keystroke; a drag applies as it goes, one undo step.
let unit = crate::options::unit();
let k = unit.pt_per_unit();
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
for (label, key, pt) in [("W:", "width", w0), ("H:", "height", h0)] {
let name = ui.label(egui::RichText::new(tl!(label)).small());
let was = pt / k;
let mut v = was;
// Not `clamp_existing_to_range`: egui would clamp a size outside the range (a 1 px
// picture is 0.75 pt) and report it as a change, resizing the picture unasked.
let field = egui::DragValue::new(&mut v)
.speed(1.0 / k)
.range(1.0 / k..=4000.0 / k)
.clamp_existing_to_range(false)
.measure(unit)
.max_decimals(2);
let r = ui.add(field.update_while_editing(false)).labelled_by(name.id).on_hover_text(tl!(
"Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions."
));
// While typing, the field reports each keystroke as a change; only a new
// value (Enter, Tab, a click elsewhere, a drag) resizes, and Esc cancels.
let cancelled = esc_cancelled(ui, &r, egui::Id::unique(("picture_size_field", key)));
if r.changed() && v != was && !cancelled {
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("picture.size", json!({key: v * k}));
}
}
rotation_field(ui, app);
});
ui.add_space(2.0);
crate::widgets::row(ui, |ui| {
let cropping = crate::crop::active(app).is_some();
small(ui, app, "crop", Some("Crop"), "Crop", "ui.crop", json!({}), cropping);
for (i, side) in ["L", "T", "R", "B"].iter().enumerate() {
ui.label(egui::RichText::new(tl!(side)).small());
let mut v = crop0[i] * 100.0;
let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(0.0..=95.0).suffix("%"));
if r.changed() {
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
}
let mut c = crop0;
c[i] = (v / 100.0).clamp(0.0, 0.95);
// The two sides of an axis keep 5% of the source between them.
let other = c[if i < 2 { i + 2 } else { i - 2 }];
c[i] = c[i].min((0.95 - other).max(0.0));
let _ = app.run("picture.crop", json!({"left": c[0], "top": c[1], "right": c[2], "bottom": c[3]}));
}
}
if ui.button(egui::RichText::new(tl!("Reset")).small()).on_hover_text(tl!("Reset Crop")).clicked() {
let _ = app.run("picture.crop", json!({"left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0}));
}
});
ui.add_space(2.0);
crate::widgets::row(ui, |ui| {
ui.label(egui::RichText::new(tl!("Alt:")).small());
// Frame-local buffers lose keystrokes; keep the draft in egui temp memory keyed
// by picture (same pattern as the comment editor in panes.rs).
let key = egui::Id::unique(("picture_alt", media.clone(), off));
let mut alt = ui.data(|d| d.get_temp::<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}));
}
}
});
});
});
}
+117
View File
@@ -0,0 +1,117 @@
use super::common::{mi, mi_text, stack};
use super::language::mi_check;
use super::*;
pub(super) fn references(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Table of Contents", None, app, |ui, app| {
menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, "references.toc", |ui, app| {
mi(ui, app, "Automatic Table 1 (3 levels)", "references.toc", json!({"levels": 3}));
mi(ui, app, "Automatic Table 2 (2 levels)", "references.toc", json!({"levels": 2, "title": "Table of Contents"}));
ui.separator();
mi(ui, app, "Custom Table of Contents…", "ui.dialog", json!({"name": "toc"}));
mi(ui, app, "Remove Table of Contents", "references.removeToc", json!({}));
});
stack(ui, |ui| {
menu_button(ui, app, "addText", Some("Add Text"), "Add Text", false, "references.addText", |ui, app| {
mi(ui, app, "Do Not Show in TOC", "references.addText", json!({"level": 0}));
for l in 1..=3u64 {
mi(ui, app, &format!("Level {l}"), "references.addText", json!({"level": l}));
}
});
small(ui, app, "updateTable", Some("Update Table"), "Update Table", "references.updateToc", json!({}), false);
});
});
group(ui, "Footnotes", Some("references.noteOptions"), app, |ui, app| {
big(ui, app, "footnote", "Insert\nFootnote", "references.footnote", json!({}), false);
stack(ui, |ui| {
small(ui, app, "endnote", Some("Insert Endnote"), "Insert Endnote", "references.endnote", json!({}), false);
small(ui, app, "nextFootnote", Some("Next Footnote"), "Next Footnote", "references.nextFootnote", json!({}), false);
small(ui, app, "showNotes", Some("Show Notes"), "Show Notes", "references.notes", json!({}), false);
});
});
group(ui, "Research", None, app, |ui, app| {
big(ui, app, "researcher", "Researcher", "references.researcher", json!({}), false);
});
group(ui, "Citations & Bibliography", None, app, |ui, app| {
// The document's sources to cite, then a new one (Create Source, #402).
menu_button(ui, app, "citation", Some("Insert\nCitation"), "Insert Citation", true, "", |ui, app| {
let sources: Vec<(String, String)> =
app.session.doc.sources.iter().map(|s| (s.tag.clone(), format!("{} — {}", s.author, s.title))).collect();
for (tag, label) in sources {
mi_text(ui, app, &label, "references.citation", json!({"tag": tag}));
}
if !app.session.doc.sources.is_empty() {
ui.separator();
}
mi(ui, app, "Add New Source…", "ui.dialog", json!({"name": "createSource"}));
});
stack(ui, |ui| {
small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false);
let current = app.session.bib_style.clone();
let label = crate::i18n::fmt(tl!("Style: {style}"), &[("style", &current)]);
menu_button(ui, app, "styles", Some(&label), "Citation Style", false, "references.citationStyle", |ui, app| {
for st in &wordcraft_engine::cmd::citations::STYLES {
mi_check(ui, app, st.name, st.name == current, "references.citationStyle", json!({"style": st.name}));
}
});
small(ui, app, "bibliography", Some("Bibliography"), "Bibliography", "references.bibliography", json!({}), false);
});
});
group(ui, "Captions", None, app, |ui, app| {
big(ui, app, "caption", "Insert\nCaption", "references.caption", json!({}), false);
stack(ui, |ui| {
small(
ui,
app,
"tableOfFigures",
Some("Insert Table of Figures"),
"Insert Table of Figures",
"references.tableOfFigures",
json!({}),
false,
);
small(ui, app, "update", Some("Update Table"), "Update Table of Figures", "references.updateFigures", json!({}), false);
small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false);
});
});
group(ui, "Index", None, app, |ui, app| {
big(ui, app, "markEntry", "Mark\nEntry", "references.markEntry", json!({}), false);
stack(ui, |ui| {
small(ui, app, "index", Some("Insert Index"), "Insert Index", "references.index", json!({}), false);
small(ui, app, "update", Some("Update Index"), "Update Index", "references.updateIndex", json!({}), false);
});
});
group(ui, "Table of Authorities", None, app, |ui, app| {
big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false);
stack(ui, |ui| {
small(
ui,
app,
"tableOfAuthorities",
Some("Insert Table of Authorities"),
"Insert Table of Authorities",
"references.tableOfAuthorities",
json!({}),
false,
);
small(ui, app, "update", Some("Update Table"), "Update Table", "references.updateAuthorities", json!({}), false);
});
});
}
/// Zotero (`docs/zotero.md`): the Zotero desktop app does the work in its own window.
pub(super) fn zotero(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Citations", None, app, |ui, app| {
big(ui, app, "citation", "Add/Edit\nCitation", "ui.zotero.addEditCitation", json!({}), false);
big(ui, app, "addNote", "Add\nNote", "ui.zotero.addNote", json!({}), false);
big(ui, app, "pastCitation", "Move Past\nCitation", "caret.pastCitation", json!({}), false);
});
group(ui, "Bibliography", None, app, |ui, app| {
big(ui, app, "bibliography", "Add/Edit\nBibliography", "ui.zotero.addEditBibliography", json!({}), false);
});
group(ui, "Document", None, app, |ui, app| {
big(ui, app, "update", "Refresh", "ui.zotero.refresh", json!({}), false);
big(ui, app, "docPrefs", "Document\nPreferences", "ui.zotero.setDocPrefs", json!({}), false);
big(ui, app, "unlinkCitations", "Unlink\nCitations", "ui.zotero.removeCodes", json!({}), false);
});
}
+132
View File
@@ -0,0 +1,132 @@
use super::common::{mi, stack};
use super::language::language_menu;
use super::*;
pub(super) fn review(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Proofing", None, app, |ui, app| {
big(ui, app, "spelling", "Spelling &\nGrammar", "review.spelling", json!({}), false);
stack(ui, |ui| {
small(ui, app, "thesaurus", Some("Thesaurus"), "Thesaurus", "review.thesaurus", json!({}), false);
small(ui, app, "wordCount", Some("Word Count"), "Word Count", "ui.dialog", json!({"name": "wordCount"}), false);
});
});
group(ui, "Speech", None, app, |ui, app| {
big(ui, app, "readAloud", "Read\nAloud", "review.readAloud", json!({}), false);
});
group(ui, "Accessibility", None, app, |ui, app| {
big(ui, app, "accessibility", "Check\nAccessibility", "file.accessibility", json!({}), false);
});
group(ui, "Language", None, app, |ui, app| {
big(ui, app, "translate", "Translate", "review.translate", json!({}), false);
menu_button(ui, app, "language", Some("Language"), "Language", true, "", language_menu);
});
group(ui, "Comments", None, app, |ui, app| {
big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false);
stack(ui, |ui| {
small(ui, app, "deleteComment", Some("Delete"), "Delete Comment", "review.deleteComment", json!({}), false);
small(ui, app, "prevComment", Some("Previous"), "Previous Comment", "review.previousComment", json!({}), false);
small(ui, app, "nextComment", Some("Next"), "Next Comment", "review.nextComment", json!({}), false);
});
let shown = app.session.view.comments_pane;
big(ui, app, "showComments", "Show\nComments", "view.commentsPane", json!({"value": !shown}), false);
});
group(ui, "Tracking", Some("review.trackingOptions"), app, |ui, app| {
let on = app.session.doc.settings.track_changes;
let r = big(ui, app, "trackChanges", if on { "Track\nChanges ✓" } else { "Track\nChanges" }, "review.trackChanges", json!({}), false);
let _ = r;
stack(ui, |ui| {
menu_button(
ui,
app,
"markup",
Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }),
"Display for Review",
false,
"review.markup",
|ui, app| {
mi(ui, app, "All Markup", "review.markup", json!({"value": "all"}));
mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"}));
},
);
small(ui, app, "reviewingPane", Some("Reviewing Pane"), "Reviewing Pane", "review.changes", json!({}), false);
});
});
group(ui, "Changes", None, app, |ui, app| {
menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| {
mi(ui, app, "Accept and Move to Next", "review.accept", json!({"next": true}));
mi(ui, app, "Accept This Change", "review.accept", json!({}));
mi(ui, app, "Accept All Changes", "review.acceptAll", json!({}));
mi(ui, app, "Accept All Changes and Stop Tracking", "review.acceptAll", json!({"stopTracking": true}));
});
menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| {
mi(ui, app, "Reject and Move to Next", "review.reject", json!({"next": true}));
mi(ui, app, "Reject Change", "review.reject", json!({}));
mi(ui, app, "Reject All Changes", "review.rejectAll", json!({}));
mi(ui, app, "Reject All Changes and Stop Tracking", "review.rejectAll", json!({"stopTracking": true}));
});
stack(ui, |ui| {
small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false);
small(ui, app, "nextChange", Some("Next"), "Next Change", "review.nextChange", json!({}), false);
});
});
group(ui, "Compare", None, app, |ui, app| {
big(ui, app, "compare", "Compare", "review.compare", json!({}), false);
});
group(ui, "Protect", None, app, |ui, app| {
big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false);
big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false);
});
group(ui, "Ink", None, app, |ui, app| {
let hidden = app.session.view.hide_ink;
big_toggle(ui, app, "hideInk", "Hide\nInk", "review.hideInk", hidden);
});
}
/// Developer › Controls: content controls.
pub(super) fn developer(app: &mut WordApp, ui: &mut Ui) {
let design = app.session.view.design_mode;
group(ui, "Controls", None, app, |ui, app| {
let rows: [[(&str, &str, &str); 3]; 3] = [
[
("ccRichText", "Rich Text Content Control", "developer.richText"),
("ccPlainText", "Plain Text Content Control", "developer.plainText"),
("ccPicture", "Picture Content Control", "developer.picture"),
],
[
("ccGallery", "Building Block Gallery Content Control", "developer.buildingBlock"),
("ccCheckBox", "Check Box Content Control", "developer.checkBox"),
("ccComboBox", "Combo Box Content Control", "developer.comboBox"),
],
[
("ccDropDown", "Drop-Down List Content Control", "developer.dropDown"),
("ccDate", "Date Picker Content Control", "developer.datePicker"),
("ccRepeat", "Repeating Section Content Control", "developer.repeatingSection"),
],
];
// Three rows of small buttons fill the group's height, like every other stack.
stack(ui, |ui| {
for row in rows {
crate::widgets::row(ui, |ui| {
for (icon, tip, id) in row {
small(ui, app, icon, None, tip, id, json!({}), false);
}
});
}
});
stack(ui, |ui| {
small(ui, app, "designMode", Some("Design Mode"), "Design Mode", "developer.designMode", json!({}), design);
small(ui, app, "properties", Some("Properties"), "Properties", "developer.properties", json!({}), false);
});
});
}
pub(super) fn help(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Help", None, app, |ui, app| {
big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false);
big(ui, app, "checkForUpdates", "Check for\nUpdates…", "ui.checkForUpdates", json!({}), false);
big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false);
});
group(ui, "Agents", None, app, |ui, app| {
big(ui, app, "macros", "Commands", "ui.dialog", json!({"name": "commands"}), false);
});
}
+209
View File
@@ -0,0 +1,209 @@
use super::arrange::{align_menu, rotate_menu, rotation_field, wrap_menu};
use super::common::{mi, stack};
use super::language::mi_check;
use super::*;
/// Shape Fill › Gradient: No Gradient, Light and Dark Variations of the shape's colour, More
/// Gradients… (the Format Shape pane).
pub(crate) fn gradient_menu(ui: &mut Ui, app: &mut WordApp, fill: Option<&wordcraft_doc::fill::Fill>) {
use wordcraft_engine::cmd::shape_fill;
let pos = wordcraft_engine::cmd::objects::selected(&app.session).map(|(p, _)| p);
let main = match (fill, &pos) {
(Some(f), Some(p)) => crate::format_shape::variation_base(ui.ctx(), p, f),
(Some(f), None) => f.main_color(),
_ => app.session.doc.settings.theme_colors.get(4).copied().unwrap_or(wordcraft_doc::Rgb(68, 114, 196)),
};
// No Gradient: the shape's colour, solid.
ui.label(egui::RichText::new(tl!("No Gradient")).small().weak());
if crate::format_shape::swatch(ui, &wordcraft_doc::fill::Fill::solid(main), "No Gradient") {
let _ = app.run("shape.fill", json!({"color": main.hex()}));
ui.close();
}
for (title, tiles) in ["Light Variations", "Dark Variations"].into_iter().zip(shape_fill::variations(main)) {
ui.label(egui::RichText::new(tl!(title)).small().weak());
egui::Grid::new(title).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
for (i, (f, name)) in tiles.iter().enumerate() {
if crate::format_shape::swatch(ui, f, name) {
let _ = app.run("shape.fill", shape_fill::params(Some(f)));
if let (Some(p), Some(Some(now))) = (pos.clone(), crate::format_shape::current(app)) {
crate::format_shape::remember_variation(ui.ctx(), p, now, main);
}
ui.close();
}
if i % 5 == 4 {
ui.end_row();
}
}
});
}
ui.separator();
if ui.button(tl!("More Gradients…")).clicked() {
let _ = app.run("view.formatShapePane", json!({"value": true}));
ui.close();
}
}
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
pub(super) fn shape_format(app: &mut WordApp, ui: &mut Ui) {
let (stroke, glow_size, fill) = match wordcraft_engine::cmd::objects::selected(&app.session) {
Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, fill, .. })) => (stroke, effects.glow.map(|g| g.size), fill),
Some((_, o)) => (None, None, o.canvas_style().and_then(|c| c.fill.clone())),
_ => (None, None, None),
};
let theme = app.session.doc.settings.theme_colors.clone();
group(ui, "Shape Styles", None, app, |ui, app| {
stack(ui, |ui| {
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, "shape.fill", |ui, app| {
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
crate::colors::menu(ui, app, &theme, Target::cmd("shape.fill"));
ui.separator();
ui.menu_button(tl!("Gradient"), |ui| gradient_menu(ui, app, fill.as_ref()));
});
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, "shape.outline", |ui, app| {
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
crate::colors::menu(ui, app, &theme, Target::cmd("shape.outline"));
ui.separator();
ui.menu_button(tl!("Weight"), |ui| {
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w}));
}
});
});
menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, "shape.effects", |ui, app| {
shape_effects_menu(ui, app, &theme, glow_size)
});
});
});
alt_text_group(ui, app);
if wordcraft_engine::cmd::canvas::selected_canvas(&app.session).is_some() {
group(ui, "Drawing Canvas", None, app, |ui, app| {
menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, "", |ui, app| {
mi(ui, app, "Fit", "canvas.fit", json!({}));
mi(ui, app, "Expand", "canvas.expand", json!({}));
ui.menu_button(tl!("Scale Drawing"), |ui| {
for pct in [50, 75, 125, 150, 200] {
mi(ui, app, &format!("{pct}%"), "canvas.scale", json!({"factor": pct as f32 / 100.0}));
}
});
});
});
}
group(ui, "WordArt Styles", None, app, |ui, app| {
menu_button(ui, app, "wordArt", Some("Styles"), "WordArt Styles", true, "", |ui, app| {
crate::wordart_menus::style_gallery(ui, app, "wordArt.style")
});
stack(ui, |ui| {
menu_button(ui, app, "textFill", Some("Text Fill"), "Text Fill", false, "", |ui, app| crate::wordart_menus::fill_menu(ui, app, &theme));
let outline = crate::wordart_menus::current_outline(app);
menu_button(ui, app, "textOutline", Some("Text Outline"), "Text Outline", false, "", |ui, app| {
crate::wordart_menus::outline_menu(ui, app, &theme, outline)
});
menu_button(ui, app, "transform", Some("Text Effects"), "Text Effects", false, "", |ui, app| {
crate::wordart_menus::effects_menu(ui, app, &theme, true)
});
});
});
shape_text_group(app, ui);
group(ui, "Arrange", None, app, |ui, app| {
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
wrap_menu(ui, app);
stack(ui, |ui| {
rotate_menu(ui, app, Some("Rotate"));
align_menu(ui, app, Some("Align"));
});
});
group(ui, "Size", None, app, |ui, app| {
let (w0, h0) =
wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0));
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
for (label, key, v0) in [("W:", "width", w0), ("H:", "height", h0)] {
ui.label(egui::RichText::new(tl!(label)).small());
let mut v = v0;
// `picture.size`'s own range; a size outside it (a thin line) is shown as it
// is (clamped, egui would report a change and resize the shape unasked).
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(1.0..=4000.0).clamp_existing_to_range(false).suffix(" pt"));
if r.changed() && v != v0 {
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("picture.size", json!({key: v, "lockAspect": false}));
}
}
});
ui.add_space(2.0);
crate::widgets::row(ui, |ui| rotation_field(ui, app));
});
});
}
/// Shape Format › Text: the text box's Text Direction and Align Text, and Create Link (Break Link
/// once it is linked to another box).
fn shape_text_group(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_doc::props::{TextVert, VAlign};
let target = wordcraft_engine::cmd::objects::text_box_target(&app.session);
let body = target.and_then(|(_, id)| app.session.doc.parts.get(&id).map(|p| p.body)).unwrap_or_default();
group(ui, "Text", None, app, |ui, app| {
stack(ui, |ui| {
menu_button(ui, app, "textDirection", Some("Text Direction"), "Text Direction", false, "", |ui, app| {
for (label, vert) in [
("Horizontal", TextVert::Horz),
("Rotate all text 90°", TextVert::Vert),
("Rotate all text 270°", TextVert::Vert270),
("Stacked", TextVert::Stacked),
] {
mi_check(ui, app, label, body.vert == vert, "shape.textDirection", json!({"dir": vert.ooxml()}));
}
});
menu_button(ui, app, "alignText", Some("Align Text"), "Align Text", false, "", |ui, app| {
for (label, anchor) in [("Top", VAlign::Top), ("Middle", VAlign::Center), ("Bottom", VAlign::Bottom)] {
mi_check(ui, app, label, body.anchor == anchor, "shape.alignText", json!({"anchor": wordcraft_doc::props::anchor_ooxml(anchor)}));
}
});
if body.next.is_some() {
small(ui, app, "textBoxUnlink", Some("Break Link"), "Break Link", "shape.breakLink", json!({}), false);
} else {
let waiting = app.canvas.link_from.is_some();
small(ui, app, "textBoxLink", Some("Create Link"), "Create Link", "ui.linkTextBox", json!({}), waiting);
}
});
});
}
/// Picture/Shape Format › Accessibility: Alt Text opens the Alt Text pane.
pub(super) fn alt_text_group(ui: &mut Ui, app: &mut WordApp) {
group(ui, "Accessibility", None, app, |ui, app| {
big(ui, app, "altText", "Alt\nText", "view.altTextPane", json!({"value": true}), false);
});
}
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<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}));
}
});
}
+240
View File
@@ -0,0 +1,240 @@
use super::common::{mi, stack};
use super::*;
pub(super) fn table_design(app: &mut WordApp, ui: &mut Ui) {
let look = app.session.sel.focus.path.cell().and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look));
group(ui, "Table Style Options", None, app, |ui, app| {
let Some(l) = look else { return };
egui::Grid::new("look").show(ui, |ui| {
for (row, items) in [
[("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)],
[("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)],
[("Banded Rows", "bandedRows", l.banded_rows), ("Banded Columns", "bandedColumns", l.banded_columns)],
]
.iter()
.enumerate()
{
let _ = row;
for (label, key, val) in items {
let mut v = *val;
if ui.checkbox(&mut v, tl!(label)).changed() {
let _ = app.run("table.look", json!({ *key: v }));
}
}
ui.end_row();
}
});
});
group(ui, "Table Styles", None, app, |ui, app| {
// The document's own styles first, so a new one shows without scrolling.
let mut styles: Vec<(bool, String, String)> = app
.session
.doc
.styles
.styles
.iter()
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
.map(|s| (s.builtin, s.id.clone(), crate::i18n::style_name(s)))
.collect();
styles.sort_by_key(|(builtin, _, _)| *builtin);
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
ui.horizontal(|ui| {
for (_, id, name) in styles {
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
let _ = app.run("table.style", json!({"style": id}));
}
}
});
});
stack(ui, |ui| {
let current = look.and_then(|_| {
let (tp, _, _) = app.session.sel.focus.path.cell()?;
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
app.session
.doc
.styles
.get(&id)
.filter(|st| st.kind == wordcraft_doc::StyleKind::Table)
.map(wordcraft_engine::cmd::table_style::is_builtin_table_style)
});
let can_modify = current.is_some();
// Built-in table styles can't be deleted.
let can_delete = current == Some(false);
menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, "table.style", |ui, app| {
mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"}));
if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() {
let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"}));
ui.close();
}
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
if let Err(e) = app.run("table.deleteStyle", json!({})) {
app.status(e);
}
ui.close();
}
});
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
let theme = app.session.doc.settings.theme_colors.clone();
crate::colors::menu(ui, app, &theme, Target::cmd("table.shading").remember(Remember::Shading));
});
});
});
group(ui, "Borders", None, app, |ui, app| {
menu_button(ui, app, "borders", Some("Borders"), "Borders", true, "table.borders", |ui, app| {
for (l, k) in [
("All Borders", "all"),
("Outside Borders", "outside"),
("Inside Borders", "inside"),
("No Border", "none"),
("Top Border", "top"),
("Bottom Border", "bottom"),
] {
mi(ui, app, l, "table.borders", json!({"kind": k}));
}
});
big(ui, app, "borderPainter", "Border\nPainter", "table.borderPainter", json!({}), false);
});
}
/// Table Layout › Cell Size: the caret cell's row height and column width, in inches. They show
/// the size on the page (a row without a set height shows the height its text gives it) and
/// editing runs `table.rowHeight` / `table.columnWidth`; dragging one is a single Undo.
fn cell_size_boxes(ui: &mut Ui, app: &mut WordApp) {
let story = app.session.sel.focus.story;
let Some((tp, r, c)) = app.session.sel.focus.path.cell() else { return };
let Some(t) = app.session.doc.table(story, &tp) else { return };
let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default();
let stored_w = t.grid.get(t.grid_col(r, c)).copied();
let laid = app.session.layout().pages.iter().flat_map(|pg| pg.items.iter()).find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st, .. } if *table == tp && *row == r && *cell == c && *st == story => {
Some(*rect)
}
_ => None,
});
// The row's own height: a vertically merged cell's box spans several rows, so take the
// shortest box in this row.
let row_h = app
.session
.layout()
.pages
.iter()
.flat_map(|pg| pg.items.iter())
.filter_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, table, row, story: st, .. } if *table == tp && *row == r && *st == story => Some(rect.h),
_ => None,
})
.reduce(f32::min);
let unit = crate::options::unit();
let k = unit.pt_per_unit();
let h0 = row.height.or(row_h).unwrap_or(0.0);
let w0 = laid.map(|x| x.w).or(stored_w).unwrap_or(0.0);
let exact = row.height_rule == wordcraft_doc::props::HeightRule::Exact;
// The edited value in points, and whether it continues a drag (joins the previous Undo step).
let boxed = |ui: &mut Ui, label: &str, pt: f32| -> Option<(f32, bool)> {
let mut out = None;
crate::widgets::row(ui, |ui| {
ui.add_sized(vec2(48.0, 18.0), egui::Label::new(egui::RichText::new(tl!(label)).small()));
let mut v = pt / k;
let r = ui.add_sized(vec2(72.0, 18.0), egui::DragValue::new(&mut v).speed(0.01).range(0.02..=22.0).measure(unit).max_decimals(2));
if r.changed() {
out = Some((v * k, r.dragged() && !r.drag_started()));
}
});
ui.add_space(2.0);
out
};
stack(ui, |ui| {
if let Some((h, join)) = boxed(ui, "Height:", h0) {
if join {
app.session.join_next_undo();
}
let _ = app.run("table.rowHeight", json!({"height": h, "rule": if exact { "exact" } else { "atLeast" }}));
}
if let Some((w, join)) = boxed(ui, "Width:", w0) {
if join {
app.session.join_next_undo();
}
let _ = app.run("table.columnWidth", json!({"width": w}));
}
});
}
pub(super) fn table_layout(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Table", None, app, |ui, app| {
stack(ui, |ui| {
menu_button(ui, app, "selectTable", Some("Select"), "Select", false, "table.selectTable", |ui, app| {
mi(ui, app, "Select Cell", "table.selectCell", json!({}));
mi(ui, app, "Select Column", "table.selectColumn", json!({}));
mi(ui, app, "Select Row", "table.selectRow", json!({}));
mi(ui, app, "Select Table", "table.selectTable", json!({}));
});
let g = app.session.view.table_gridlines;
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g);
small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false);
});
});
group(ui, "Draw", None, app, |ui, app| {
use crate::table_pen::TableTool;
let tool = app.canvas.table_tool;
crate::widgets::big_toggle(ui, app, "drawTable", "Draw\nTable", "table.draw", tool == Some(TableTool::Draw));
crate::widgets::big_toggle(ui, app, "eraser", "Eraser", "table.eraser", tool == Some(TableTool::Erase));
});
group(ui, "Rows & Columns", None, app, |ui, app| {
menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, "table.deleteTable", |ui, app| {
mi(ui, app, "Delete Cells", "table.deleteCells", json!({}));
mi(ui, app, "Delete Columns", "table.deleteColumn", json!({}));
mi(ui, app, "Delete Rows", "table.deleteRow", json!({}));
mi(ui, app, "Delete Table", "table.deleteTable", json!({}));
});
big(ui, app, "insertAbove", "Insert\nAbove", "table.insertRowAbove", json!({}), false);
stack(ui, |ui| {
small(ui, app, "insertBelow", Some("Insert Below"), "Insert Below", "table.insertRowBelow", json!({}), false);
small(ui, app, "insertLeft", Some("Insert Left"), "Insert Left", "table.insertColumnLeft", json!({}), false);
small(ui, app, "insertRight", Some("Insert Right"), "Insert Right", "table.insertColumnRight", json!({}), false);
});
});
group(ui, "Merge", None, app, |ui, app| {
stack(ui, |ui| {
small(ui, app, "merge", Some("Merge Cells"), "Merge Cells", "table.merge", json!({}), false);
small(ui, app, "splitCells", Some("Split Cells"), "Split Cells", "table.split", json!({}), false);
small(ui, app, "splitTable", Some("Split Table"), "Split Table", "table.splitTable", json!({}), false);
});
});
group(ui, "Cell Size", None, app, |ui, app| {
menu_button(ui, app, "autofit", Some("AutoFit"), "AutoFit", true, "table.autofit", |ui, app| {
mi(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"}));
mi(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
mi(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
});
cell_size_boxes(ui, app);
stack(ui, |ui| {
small(ui, app, "distributeRows", Some("Distribute Rows"), "Distribute Rows", "table.distributeRows", json!({}), false);
small(ui, app, "distributeCols", Some("Distribute Columns"), "Distribute Columns", "table.distributeColumns", json!({}), false);
});
});
group(ui, "Alignment", None, app, |ui, app| {
egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| {
for row in [
[("topLeft", "Align Top Left"), ("topCenter", "Align Top Center"), ("topRight", "Align Top Right")],
[("centerLeft", "Align Center Left"), ("center", "Align Center"), ("centerRight", "Align Center Right")],
[("bottomLeft", "Align Bottom Left"), ("bottomCenter", "Align Bottom Center"), ("bottomRight", "Align Bottom Right")],
] {
for (v, tip) in row {
small(ui, app, "cellAlign", None, tip, "table.cellAlign", json!({"value": v}), false);
}
ui.end_row();
}
});
big(ui, app, "textDirection", "Text\nDirection", "table.textDirection", json!({}), false);
big(ui, app, "cellMargins", "Cell\nMargins", "table.cellMargins", json!({}), false);
});
group(ui, "Data", None, app, |ui, app| {
big(ui, app, "sort", "Sort", "table.sort", json!({}), false);
stack(ui, |ui| {
small(ui, app, "repeatHeader", Some("Repeat Header Rows"), "Repeat Header Rows", "table.repeatHeader", json!({}), false);
small(ui, app, "toText", Some("Convert to Text"), "Convert to Text", "table.toText", json!({}), false);
small(ui, app, "formula", Some("Formula"), "Formula", "table.formula", json!({}), false);
});
});
}
+252
View File
@@ -0,0 +1,252 @@
use super::*;
pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS;
/// True when the caret/selection touches a picture (#147).
pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool {
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. })))
}
/// Whether the selection is a shape, text box or Drawing Canvas (so Shape Format is shown).
pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
match wordcraft_engine::cmd::objects::selected(s) {
Some((_, o)) => matches!(o, wordcraft_doc::para::InlineObject::Shape { .. }) || o.is_canvas(),
None => false,
}
}
/// Reading controls replace editing tabs entirely. All settings are transient view controls.
pub(super) fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) {
egui::Panel::top("reading_toolbar")
.exact_size(46.0)
.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(12, 6)).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0)));
ui.separator();
let w = app.session.view.read_column_width;
let active = match w {
None => "Auto",
Some(v) if v <= 180.0 => "Narrow",
Some(v) if v <= 240.0 => "Medium",
Some(v) if v <= 350.0 => "Wide",
_ => "Page",
};
ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| {
for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] {
if ui.selectable_label(active == name, tl!(name)).clicked() {
let _ = app.run("view.readColumnWidth", json!({"value": value}));
ui.close();
}
}
});
ui.separator();
let pct = (app.session.view.read_zoom * 100.0).round() as u32;
ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| {
for percent in [75, 90, 100, 110, 125, 150] {
if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() {
let _ = app.run("view.readTextSize", json!({"value": percent}));
ui.close();
}
}
});
ui.separator();
let spacing = app.session.view.read_line_spacing;
let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×"));
ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| {
if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() {
let _ = app.run("view.readLineSpacing", json!({"value": "document"}));
ui.close();
}
for value in [1.0, 1.25, 1.5, 2.0] {
if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() {
let _ = app.run("view.readLineSpacing", json!({"value": value}));
ui.close();
}
}
});
ui.separator();
use wordcraft_engine::cmd::view::ReadPageColor;
let colors =
[(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")];
let current = app.session.view.read_page_color;
let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1);
ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| {
for (color, name, value) in colors {
if ui.selectable_label(current == color, tl!(name)).clicked() {
let _ = app.run("view.readPageColor", json!({"value": value}));
ui.close();
}
}
});
ui.separator();
// Headings to jump to, in the Navigation pane beside the columns (Outline view
// is an editing layout, so it isn't offered here).
if ui.button(tl!("Navigation Pane")).clicked() {
let _ = app.run("view.navigationPane", json!({"value": true}));
app.ui.nav_tab = "headings".into();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(tl!("Edit Document")).clicked() {
let _ = app.run("view.printLayout", json!({}));
}
});
});
});
}
/// Whether the selection is a SmartArt graphic WordCraft can edit (so SmartArt Design is shown).
pub fn has_smart_art_selected(s: &wordcraft_engine::Session) -> bool {
wordcraft_engine::cmd::smart_art::selected_smart_art(s).is_some()
}
/// Whether the selection is a chart WordCraft can edit (so Chart Design is shown).
pub fn has_chart_selected(s: &wordcraft_engine::Session) -> bool {
wordcraft_engine::cmd::chart::selected_chart(s).is_some()
}
/// Contextual tabs for the current selection (pure, tested).
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
let mut tabs = Vec::new();
if s.sel.focus.path.cell().is_some() {
tabs.push("Table Design");
tabs.push("Table Layout");
}
if has_picture_selected(s) {
tabs.push("Picture Format");
}
if has_shape_selected(s) {
tabs.push("Shape Format");
}
if has_smart_art_selected(s) {
tabs.push("SmartArt Design");
}
if has_chart_selected(s) {
tabs.push("Chart Design");
}
// Editing an equation.
if s.math.is_some() {
tabs.push("Equation");
}
// Outline view (drawn first, next to File, as Word does).
if s.view.mode == wordcraft_layout::ViewMode::Outline {
tabs.push("Outlining");
}
tabs
}
/// Tab to show when the stored tab is no longer applicable (pure, tested).
pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str {
if available.contains(&current) { current } else { crate::DEFAULT_TAB }
}
/// Width of the » button that holds the tabs the strip has no room for.
pub(super) const MORE_TABS_W: f32 = 24.0;
/// Which tabs fit on a strip `avail` points wide (#897), from each tab's width and whether it must
/// stay. The others give way from the end, leaving `more` points for the » menu that holds them
/// (pure, tested).
pub fn fit_tabs(tabs: &[(f32, bool)], avail: f32, more: f32) -> Vec<bool> {
let mut fits = vec![true; tabs.len()];
let mut total: f32 = tabs.iter().map(|(w, _)| w).sum();
if total <= avail {
return fits;
}
for (fit, (w, keep)) in fits.iter_mut().zip(tabs).rev() {
if total + more <= avail {
break;
}
if !keep {
*fit = false;
total -= w;
}
}
fits
}
/// One tab as the strip planner sees it (#2026).
#[derive(Debug, Clone, Copy)]
pub struct TabSpec {
/// Width of the label at full size.
pub text_w: f32,
/// File: always on the strip.
pub file: bool,
/// The tab being shown.
pub active: bool,
/// A contextual tab (Table Design, Picture Format…).
pub contextual: bool,
}
/// How the tab strip is laid out (#2026).
#[derive(Debug, Clone, PartialEq)]
pub struct StripPlan {
/// Padding each side of a tab label.
pub pad: f32,
/// How much of Comments / Editing / Share is spelled out: 0 all, 1 Share only, 2 none.
pub buttons: usize,
/// Whether each tab is on the strip (the others are in the » menu).
pub fits: Vec<bool>,
/// Width each tab takes on the strip (a label may be cut with …).
pub widths: Vec<f32>,
}
/// Smallest a tab label is cut down to before the tab goes into the » menu.
pub(super) const MIN_TAB_W: f32 = 44.0;
/// Lays the tabs and the buttons on their right out on `avail` points, giving way in this order:
/// the padding between tabs, the Comments / Editing labels, the Share label, the plain tabs (from
/// the end, into »), the labels of the active and contextual tabs (cut with …), and last those tabs
/// themselves. The buttons' room is always kept and File never leaves the strip. `buttons` is the
/// width of the three buttons at each level (pure, tested).
pub fn plan_strip(tabs: &[TabSpec], buttons: [f32; 3], avail: f32, more: f32) -> StripPlan {
const STEPS: [(f32, usize); 4] = [(18.0, 0), (10.0, 0), (10.0, 1), (10.0, 2)];
let widths_at = |pad: f32| -> Vec<f32> { tabs.iter().map(|t| t.text_w.max(0.0) + pad + 2.0).collect() };
for (pad, level) in STEPS {
let widths = widths_at(pad);
if widths.iter().sum::<f32>() <= avail - buttons[level] {
return StripPlan { pad, buttons: level, fits: vec![true; tabs.len()], widths };
}
}
let (pad, level) = (10.0, 2);
let mut widths = widths_at(pad);
let room = avail - buttons[level];
let specs: Vec<(f32, bool)> = widths.iter().zip(tabs).map(|(w, t)| (*w, t.file || t.active || t.contextual)).collect();
let mut fits = fit_tabs(&specs, room, more);
// The tabs that must stay share what the plain ones left; each is cut down to its share.
let kept = |fits: &[bool], i: usize| fits.get(i).copied().unwrap_or(false) && tabs.get(i).is_some_and(|t| !t.file);
loop {
let n = (0..tabs.len()).filter(|&i| kept(&fits, i)).count();
let others: f32 =
(0..tabs.len()).filter(|&i| fits.get(i) == Some(&true) && !kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum();
let own: f32 = (0..tabs.len()).filter(|&i| kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum();
let extra = if fits.iter().all(|f| *f) { 0.0 } else { more };
let share_room = room - extra - others;
if n == 0 || own <= share_room {
break;
}
if share_room >= n as f32 * MIN_TAB_W {
let share = share_room / n as f32;
for (i, w) in widths.iter_mut().enumerate() {
if kept(&fits, i) {
*w = w.min(share);
}
}
break;
}
// Not even cut down: the last of them (the active tab last of all) goes into ».
let last = (0..tabs.len())
.rev()
.find(|&i| kept(&fits, i) && tabs.get(i).is_some_and(|t| !t.active))
.or_else(|| (0..tabs.len()).rev().find(|&i| kept(&fits, i)));
match last.and_then(|i| fits.get_mut(i)) {
Some(f) => *f = false,
None => break,
}
}
StripPlan { pad, buttons: level, fits, widths }
}
/// Whether the caret is inside a table (so the contextual tabs and their badges are shown).
pub fn in_table_public(app: &WordApp) -> bool {
app.session.sel.focus.path.cell().is_some()
}
+96
View File
@@ -0,0 +1,96 @@
use super::common::mi;
use super::*;
/// The underline styles Home › Underline offers as line samples: (`format.underline` style, name).
pub(super) const UNDERLINE_STYLES: [(&str, &str); 8] = [
("single", "Single"),
("double", "Double"),
("thick", "Thick"),
("dotted", "Dotted"),
("dash", "Dashed"),
("dotDash", "Dot-dash"),
("dotDotDash", "Dot-dot-dash"),
("wave", "Wave"),
];
/// Home › Font › Underline's drop-down (#2738): a sample of each line, Words only, None, More
/// Underlines… (the Font dialog) and Underline Color.
pub(super) fn underline_menu(ui: &mut Ui, app: &mut WordApp) {
for (style, name) in UNDERLINE_STYLES {
underline_item(ui, app, style, name);
}
mi(ui, app, "Words only", "format.wordUnderline", json!({}));
// Not `mi`: Ctrl+U beside None would read as its shortcut, but Ctrl+U toggles a single underline.
let enabled = crate::widgets::enabled(app, "format.underline");
if ui.add_enabled(enabled, egui::Button::new(tl!("None")).min_size(vec2(200.0, 0.0))).clicked() {
crate::widgets::run_from_ribbon(app, "format.underline", json!({"value": false}));
ui.close();
}
ui.separator();
mi(ui, app, "More Underlines…", "format.fontDialog", json!({}));
ui.menu_button(tl!("Underline Color"), |ui| {
mi(ui, app, "Automatic", "format.underlineColor", json!({"color": "auto"}));
let theme = app.session.doc.settings.theme_colors.clone();
crate::colors::menu(ui, app, &theme, Target::cmd("format.underlineColor"));
});
}
/// A menu entry drawn as a sample of its underline; its name is the tooltip and what assistive
/// technology reads.
fn underline_item(ui: &mut Ui, app: &mut WordApp, style: &str, name: &str) {
let enabled = crate::widgets::enabled(app, "format.underline");
let resp = ui.add_enabled(enabled, egui::Button::new("").min_size(vec2(200.0, 22.0)));
let label = tl!(name);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
let t = Tokens::get(ui.ctx());
let color = if enabled { t.text } else { t.text_disabled };
paint_underline(ui.painter(), resp.rect.shrink2(vec2(10.0, 0.0)), style, color);
if resp.on_hover_text(label).clicked() {
crate::widgets::run_from_ribbon(app, "format.underline", json!({"style": style}));
ui.close();
}
}
/// A line in `style` across `r` at its middle: our own drawing of each underline kind.
fn paint_underline(p: &egui::Painter, r: Rect, style: &str, color: egui::Color32) {
let y = r.center().y.round() + 0.5;
let (x0, x1) = (r.left(), r.right());
let line = |y: f32, w: f32| {
p.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(w, color));
};
// Repeats a pattern of (length, gap) pairs along the line; a length of 0 is a dot.
let pattern = |pat: &[(f32, f32)]| {
let mut x = x0;
'run: loop {
for &(len, gap) in pat {
if x >= x1 {
break 'run;
}
if len == 0.0 {
p.circle_filled(pos2(x + 0.75, y), 0.75, color);
x += 1.5 + gap;
} else {
p.line_segment([pos2(x, y), pos2((x + len).min(x1), y)], Stroke::new(1.0, color));
x += len + gap;
}
}
}
};
match style {
"double" => {
line(y - 1.5, 1.0);
line(y + 1.5, 1.0);
}
"thick" => line(y, 2.5),
"dotted" => pattern(&[(0.0, 2.0)]),
"dash" => pattern(&[(6.0, 4.0)]),
"dotDash" => pattern(&[(6.0, 3.0), (0.0, 3.0)]),
"dotDotDash" => pattern(&[(6.0, 3.0), (0.0, 2.5), (0.0, 3.0)]),
"wave" => {
let pts: Vec<_> =
(0..=((x1 - x0) as usize)).map(|i| pos2(x0 + i as f32, y + 1.6 * (i as f32 * std::f32::consts::TAU / 6.0).sin())).collect();
p.add(egui::Shape::line(pts, Stroke::new(1.0, color)));
}
_ => line(y, 1.0),
}
}
+159
View File
@@ -0,0 +1,159 @@
use super::common::stack;
use super::*;
/// Draws the View tab's Views, Immersive, Page Movement, Show, Zoom, Dark Mode, Window and Macros
/// and Templates groups.
pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
let v = app.session.view.clone();
group(ui, "Views", None, app, |ui, app| {
big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false);
big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false);
big(ui, app, "webLayout", "Web\nLayout", "view.webLayout", json!({}), false);
stack(ui, |ui| {
small(ui, app, "outline", Some("Outline"), "Outline", "view.outline", json!({}), false);
small(ui, app, "draft", Some("Draft"), "Draft", "view.draft", json!({}), false);
});
});
group(ui, "Immersive", None, app, |ui, app| {
big(ui, app, "focus", "Focus", "view.focus", json!({}), false);
big(ui, app, "immersive", "Immersive\nReader", "view.immersive", json!({}), false);
});
group(ui, "Page Movement", None, app, |ui, app| {
big(ui, app, "vertical", "Vertical", "view.vertical", json!({}), false);
big(ui, app, "sideToSide", "Side\nto Side", "view.sideToSide", json!({}), false);
});
group(ui, "Show", None, app, |ui, app| {
// Three rows fill the group's height; a fourth check box would run into its label.
stack(ui, |ui| {
let mut r = v.ruler;
if ui.checkbox(&mut r, tl!("Ruler")).changed() {
let _ = app.run("view.ruler", json!({"value": r}));
}
let mut g = v.gridlines;
if ui.checkbox(&mut g, tl!("Gridlines")).changed() {
let _ = app.run("view.gridlines", json!({"value": g}));
}
let mut n = v.nav_pane;
if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() {
let _ = app.run("view.navigationPane", json!({"value": n}));
}
});
stack(ui, |ui| {
let mut w = v.hide_white_space;
if ui.checkbox(&mut w, tl!("Hide White Space")).changed() {
let _ = app.run("view.hideWhiteSpace", json!({"value": w}));
}
});
});
group(ui, "Zoom", None, app, |ui, app| {
big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false);
big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false);
// Step the zoom up and down by 10% (issue #67), from whatever the page shows now.
stack(ui, |ui| {
small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false);
small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false);
});
stack(ui, |ui| {
small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage");
small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page);
small(ui, app, "pageWidth", Some("Page Width"), "Page Width", "view.pageWidth", json!({}), v.fit == "pageWidth");
});
});
group(ui, "Dark Mode", None, app, |ui, app| {
big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false);
menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, "", |ui, app| {
for a in crate::theme::Appearance::ALL {
if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() {
let _ = app.run("view.interfaceTheme", json!({"value": a.code()}));
ui.close();
}
}
});
});
group(ui, "Window", None, app, |ui, app| {
big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false);
big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false);
big(ui, app, "split", "Split", "view.split", json!({}), false);
menu_button(
ui,
app,
"switchWindows",
Some("Switch\nWindows"),
"Switch Windows",
true,
"view.switchWindows",
crate::documents::switch_windows_menu,
);
// The Full Screen button carries its name for assistive technology (and for the
// regression test that clicks it): big buttons are otherwise drawn unlabeled. Its
// enabled state is the command's own, as every other ribbon control reports: the
// browser has no full screen to ask for, and says so (#662).
let on = crate::widgets::enabled(app, "view.fullScreen");
let resp = big_toggle(ui, app, "fullScreen", "Full\nScreen", "view.fullScreen", v.full_screen);
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, v.full_screen, "Full Screen"));
});
group(ui, "Macros", None, app, |ui, app| {
big(ui, app, "macros", "Macros", "tools.macros", json!({}), false);
});
group(ui, "Templates", None, app, |ui, app| {
big(ui, app, "documentTemplate", "Templates", "tools.templates", json!({}), false);
});
}
/// The Outlining tab (Outline view): outline levels, moving, expanding and collapsing, what is
/// shown, and closing the view.
pub(super) fn outlining(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_engine::layout::outline::{BODY, para_level};
let o = app.session.view.outline.clone();
let level = app.session.doc.para_at(&app.session.sel.focus).map(|p| para_level(&app.session.doc, p)).unwrap_or(BODY);
let name = |l: u8| if l >= BODY { tl!("Body Text").to_string() } else { crate::i18n::fmt(tl!("Level {n}"), &[("n", &l.to_string())]) };
group(ui, "Outline Tools", None, app, |ui, app| {
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
small(ui, app, "outlineTop", None, "Promote to Heading 1", "outline.promoteToHeading1", json!({}), false);
small(ui, app, "outlinePromote", None, "Promote", "outline.promote", json!({}), false);
let levels: Vec<String> = (1..=BODY).map(name).collect();
if let Some(v) = crate::widgets::combo_hint(ui, "outlineLevel", 100.0, &name(level), &levels, None, tl!("Outline Level"))
&& let Some(i) = levels.iter().position(|x| *x == v)
{
let l = if i + 1 >= usize::from(BODY) { json!("body") } else { json!(i + 1) };
let _ = app.run("outline.level", json!({ "level": l }));
}
small(ui, app, "outlineDemote", None, "Demote", "outline.demote", json!({}), false);
small(ui, app, "outlineBody", None, "Demote to Body Text", "outline.demoteToBody", json!({}), false);
});
ui.add_space(3.0);
crate::widgets::row(ui, |ui| {
small(ui, app, "outlineUp", None, "Move Up", "outline.moveUp", json!({}), false);
small(ui, app, "outlineDown", None, "Move Down", "outline.moveDown", json!({}), false);
small(ui, app, "outlineExpand", None, "Expand", "outline.expand", json!({}), false);
small(ui, app, "outlineCollapse", None, "Collapse", "outline.collapse", json!({}), false);
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Show Level")).size(11.5));
let mut shown: Vec<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) = crate::widgets::combo_hint(ui, "outlineShowLevel", 90.0, &current, &shown, None, tl!("Show Level"))
&& let Some(i) = shown.iter().position(|x| *x == v)
{
let l = if i == 0 { json!("all") } else { json!(i) };
let _ = app.run("outline.showLevel", json!({ "level": l }));
}
});
ui.add_space(3.0);
crate::widgets::row(ui, |ui| {
let mut f = !o.hide_formatting;
if ui.checkbox(&mut f, tl!("Show Text Formatting")).changed() {
let _ = app.run("outline.showFormatting", json!({ "value": f }));
}
let mut first = o.first_line_only;
if ui.checkbox(&mut first, tl!("Show First Line Only")).changed() {
let _ = app.run("outline.firstLineOnly", json!({ "value": first }));
}
});
});
});
group(ui, "Close", None, app, |ui, app| {
big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false);
});
}