Files
wordcraft/crates/ui-egui/src/chrome.rs
T

1056 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Title bar (Quick Access Toolbar, title, search, account) and status bar.
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use wordcraft_doc::{PartKind, StoryRef};
use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width};
use crate::{WordApp, icons};
/// The Quick Access Toolbar's button for command `id`.
pub(crate) fn qat_button_id(id: &str) -> egui::Id {
egui::Id::new(("qat_button", id))
}
fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::hover());
let resp = ui.interact(r, qat_button_id(id), Sense::click());
if resp.hovered() && enabled {
ui.painter().rect_filled(r, 4.0, t.hover);
}
let c = if enabled { t.icon } else { t.text_disabled };
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, c, if enabled { t.accent } else { c });
let sc = crate::widgets::shortcut_text(app, id);
let resp = resp.on_hover_text(if sc.is_empty() { tip.to_string() } else { format!("{tip} ({sc})") });
if resp.clicked() && enabled {
let _ = app.run(id, json!({}));
}
resp
}
/// Commands the Quick Access Toolbar's menu offers to show or hide with one click (#376).
const QAT_MENU: [&str; 10] =
["file.new", "file.open", "file.save", "file.print", "edit.undo", "edit.redo", "edit.repeat", "review.spelling", "view.readMode", "insert.table"];
/// Match the toolbar's enabled state for keyboard activation too.
pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool {
match id {
"edit.undo" => app.session.can_undo(),
"edit.redo" => app.session.can_redo(),
"file.save" => app.has_document(),
_ => crate::widgets::enabled(app, id),
}
}
/// The Quick Access Toolbar (#379): the user's commands (Save, Undo, Redo until customized), each
/// with a right-click menu, then the menu button that customizes it (#376).
fn quick_access(ui: &mut Ui, app: &mut WordApp) {
let ids: Vec<String> = app.session.ribbon.qat().into_iter().map(str::to_string).collect();
for (index, id) in ids.iter().enumerate() {
let id = id.as_str();
let tip = match id {
"edit.undo" => crate::i18n::prefixed("Undo", app.session.undo_label().unwrap_or("")),
"edit.redo" => tl!("Redo").to_string(),
"file.save" => tl!("Save").to_string(),
_ => app.session.registry.get(id).map(|s| tl!(s.label).to_string()).unwrap_or_default(),
};
let enabled = qat_enabled(app, id);
let resp = qat_button(ui, app, crate::widgets::command_icon(id), &tip, id, enabled);
if app.ui.keytips == crate::keytips::Phase::Tabs && ui.is_rect_visible(resp.rect) {
let label = crate::keytips::qat_label(index, ids.len());
if label.starts_with(&app.ui.keytip_prefix) {
app.keytip_rects.push((resp.rect, label));
}
}
crate::widgets::command_menu(&resp, app, id);
if id == "edit.undo" {
undo_list_button(ui, app, enabled);
}
}
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::hover());
let resp = ui.interact(r, qat_menu_button_id(), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), "more", t.icon, t.accent);
let resp = resp.on_hover_text(tl!("Customize Quick Access Toolbar"));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(220.0);
qat_menu(ui, app);
});
}
/// The Undo button's drop-down arrow (#701), which opens [`undo_list`].
pub(crate) fn undo_list_button_id() -> egui::Id {
egui::Id::new("qat_undo_list")
}
/// The status bar's message, the one that says what went wrong (#2402).
pub(crate) fn status_message_id() -> egui::Id {
egui::Id::new("status_message")
}
/// Row `i` of the Undo list (0 = the newest step).
fn undo_list_row_id(i: usize) -> egui::Id {
undo_list_button_id().with(("row", i))
}
/// The footer Cancel row's id (#1903).
pub(crate) fn undo_list_cancel_id() -> egui::Id {
undo_list_button_id().with("cancel")
}
/// The Undo list's rows, newest first: each step named as the Undo button's tooltip names it.
pub(crate) fn undo_list_rows(app: &WordApp) -> Vec<String> {
app.session.undo_history().into_iter().map(|l| tl!(l).to_string()).collect()
}
/// The Undo list's footer: how many steps a click undoes, or Cancel while none is highlighted.
pub(crate) fn undo_list_footer(n: usize) -> String {
match n {
0 => tl!("Cancel").to_string(),
1 => tl!("Undo 1 Action").to_string(),
n => crate::i18n::fmt(tl!("Undo {n} Actions"), &[("n", &n.to_string())]),
}
}
/// The small arrow beside Undo: opens the list of steps to undo several at once.
fn undo_list_button(ui: &mut Ui, app: &mut WordApp, enabled: bool) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(12.0, 24.0), Sense::hover());
let resp = ui.interact(r, undo_list_button_id(), if enabled { Sense::click() } else { Sense::hover() });
if resp.hovered() && enabled {
ui.painter().rect_filled(r, 4.0, t.hover);
}
let c = if enabled { t.icon } else { t.text_disabled };
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(9.0, 9.0)), "dropdown", c, c);
let resp = resp.on_hover_text(tl!("Undo History"));
egui::Popup::menu(&resp).show(|ui| undo_list(ui, app));
}
/// The Undo list (#701): recent steps, newest first. Hovering a row highlights it and every newer
/// one; clicking it undoes them all in one go (`edit.undoTo`).
fn undo_list(ui: &mut Ui, app: &mut WordApp) {
const W: f32 = 210.0;
const ROW: f32 = 20.0;
let t = Tokens::get(ui.ctx());
let rows = undo_list_rows(app);
ui.set_width(W);
let (mut hovered, mut clicked) = (None, None);
egui::ScrollArea::vertical().max_height(ROW * 18.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let rects: Vec<Rect> = (0..rows.len())
.map(|i| {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::hover());
let resp = ui.interact(r, undo_list_row_id(i), Sense::click());
if resp.hovered() {
hovered = Some(i);
}
if resp.clicked() {
clicked = Some(i);
}
r
})
.collect();
for (i, (r, label)) in rects.iter().zip(&rows).enumerate() {
if hovered.is_some_and(|h| i <= h) {
ui.painter().rect_filled(*r, 0.0, t.selection);
}
// Clipped to its row: a long label never runs into the scroll bar.
ui.painter_at(r.shrink2(vec2(6.0, 0.0))).text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), t.text);
}
});
ui.separator();
let n = hovered.map_or(0, |h| h + 1);
if n == 0 {
// Cancel is a real row: it closes the popup without undoing anything (#1903).
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::hover());
let resp = ui.interact(r, undo_list_cancel_id(), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.selection);
}
ui.painter().text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, undo_list_footer(0), regular(12.0), t.text);
if resp.clicked() {
ui.close();
}
} else {
ui.vertical_centered(|ui| ui.label(egui::RichText::new(undo_list_footer(n)).font(regular(12.0)).color(t.text)));
}
if let Some(i) = clicked {
crate::widgets::run_from_ribbon(app, "edit.undoTo", json!({"count": i + 1}));
ui.close();
}
}
/// The Quick Access Toolbar's menu button (…), which opens [`qat_menu`].
pub(crate) fn qat_menu_button_id() -> egui::Id {
egui::Id::new("qat_menu_button")
}
/// The Quick Access Toolbar's menu: common commands to show or hide, and More Commands…
pub(crate) fn qat_menu(ui: &mut Ui, app: &mut WordApp) {
ui.label(egui::RichText::new(tl!("Customize Quick Access Toolbar")).font(semibold(12.0)));
ui.separator();
for id in QAT_MENU {
let Some(label) = app.session.registry.get(id).map(|s| tl!(s.label).to_string()) else { continue };
let on = app.session.ribbon.qat().contains(&id);
let mark = egui::RichText::new("✓");
let mark = if on { mark } else { mark.color(egui::Color32::TRANSPARENT) };
if ui.add(egui::Button::new((mark, label)).selected(on)).clicked() {
let key = if on { "qatRemove" } else { "qatAdd" };
let _ = app.run("tools.customizeRibbon", json!({ key: id }));
}
}
ui.separator();
if ui.button(tl!("More Commands…")).clicked() {
crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::QuickAccess);
ui.close();
}
ui.separator();
// Last, as in Word (#1945); the ribbon's right-click menu brings the toolbar back.
if ui.button(tl!("Hide Quick Access Toolbar")).clicked() {
let _ = app.run("ui.quickAccessToolbar", json!({"value": false}));
ui.close();
}
}
/// Height of the macOS integrated title bar (#225). AppKit draws the traffic lights itself, centred
/// in the standard 28 pt title-bar band at the top of the window; moving them would take AppKit
/// calls (`unsafe`, which the workspace forbids). So the title bar is that band: the lights sit
/// vertically centred in it, with the controls beside them.
pub const MAC_TITLE_BAR: f32 = 28.0;
/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the
/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the
/// gap after them from shrinking).
const MAC_LIGHT: f32 = 12.0;
const MAC_LIGHT_PITCH: f32 = 20.0;
const MAC_LIGHTS_LEFT: f32 = 10.0;
/// Where title-bar content starts to the right of the traffic lights (the title bar here, the
/// Backstage back button, #222): the end of the three lights plus the same gap the lights keep
/// from the top of the window.
pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
/// The macOS title bar as laid out around the traffic lights: its height and where content starts
/// after the lights. The app measures the real buttons (they grew in macOS 26); until then, or if it
/// can't, the standard layout above.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MacTitleBar {
pub height: f32,
pub content_left: f32,
}
impl Default for MacTitleBar {
fn default() -> Self {
Self { height: MAC_TITLE_BAR, content_left: MAC_CONTENT_LEFT }
}
}
impl MacTitleBar {
/// The bar for traffic lights whose right edge is `lights_right` and whose centre is
/// `lights_centre_y` below the top of the window: the lights centred in it, and content the
/// same gap after them as they keep from the top. Never smaller than the standard layout.
pub fn around_lights(lights_right: f32, lights_centre_y: f32, light: f32) -> Self {
let height = (2.0 * lights_centre_y).max(MAC_TITLE_BAR);
let gap = (height - light) / 2.0;
Self { height, content_left: (lights_right + gap).clamp(MAC_CONTENT_LEFT, f32::from(i8::MAX)) }
}
}
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// macOS: shorter, so the system's traffic lights are centred in it; the tallest controls
// shrink to keep a margin above and below them.
let mac = app.integrated_titlebar;
let bar = app.mac_titlebar;
let left = if mac { bar.content_left as i8 } else { 8 };
let control_h = if mac { 22.0 } else { 26.0 };
egui::Panel::top("title_bar")
.exact_size(if mac { bar.height } else { 38.0 })
.frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
// Drag the window by the title bar (integrated title bar on macOS).
let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
let mut qat_end = full.min.x;
let mut right_start = full.max.x;
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
// AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't
// cover the document (#196, #176); clicking it then offers Save As where that helps.
let block = app.autosave_block();
let on = app.autosaves();
let color = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim };
// The text is part of the switch (#3370): clicking it toggles AutoSave, and chrome
// text is not document text to select (double-clicking it copied "AutoSave").
let text = ui.add(
egui::Label::new(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(color)).selectable(false).sense(Sense::click()),
);
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
let (fill, stroke, knob_fill) = match (&block, on) {
(Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)),
(None, true) => (t.accent, t.accent, t.on_accent),
(None, false) => (t.input, t.border_strong, t.text_dim),
};
ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 };
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill);
let tip = block
.as_ref()
.map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason());
let text = text.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
let resp = resp.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand);
if text.clicked() || resp.clicked() {
match block {
None => {
let _ = app.run("file.autosave", json!({}));
}
// AutoSave comes on once Save As has saved (maybe on a later frame, #94).
Some(b) if b.save_as_helps() => {
let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave);
}
Some(b) => app.status(b.reason()),
}
}
ui.add_space(8.0);
// Hidden (#1945): neither the buttons nor the … menu; the title centres as before.
if app.ui.quick_access_toolbar {
quick_access(ui, app);
}
qat_end = ui.min_rect().max.x;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Account / community.
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Account")));
let initials: String = app.session.user_initials().chars().take(2).collect();
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() {
let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"}));
}
ui.add_space(8.0);
if app.ui.show_discord {
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent });
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)),
"discord",
t.on_accent,
t.on_accent,
);
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, tl!("Discord"), medium(11.5), t.on_accent);
if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() {
let _ = app.run("ui.discord", json!({}));
}
ui.add_space(8.0);
}
search_box(app, ui, control_h);
right_start = ui.min_rect().min.x;
});
});
// Centred title.
let title = if !app.has_document() {
// No document open (#1129): the app's name alone.
"WordCraft".to_string()
} else {
format!(
"{} — {}",
app.title_stem(),
if app.session.dirty {
tl!("Edited")
} else if app.session.path.is_some() {
tl!("Saved")
} else {
tl!("Not saved")
}
)
};
let font = TypeRung::Chrome.semibold();
let track = TypeRung::Chrome.tracking();
let cx = full.center().x;
let available = 2.0 * (cx - qat_end - 10.0).min(right_start - 10.0 - cx);
let mut shown: String = title.chars().take(240).collect();
if text_width(ui.painter(), &shown, &font, track, t.text) > available {
while !shown.is_empty() && text_width(ui.painter(), &format!("{shown}…"), &font, track, t.text) > available {
shown.pop();
}
shown.push('…');
}
let w = text_width(ui.painter(), &shown, &font, track, t.text);
if available > 24.0 {
let rect = Rect::from_center_size(pos2(cx, full.center().y), vec2(w + 8.0, control_h));
let response = ui.interact(rect, ui.id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &title));
if response.clicked() {
let _ = app.run("file.rename", json!({}));
}
paint_text(
ui.painter(),
pos2(cx - w / 2.0, full.center().y - TypeRung::Chrome.line_height() / 2.0),
Align2::LEFT_TOP,
&shown,
&font,
track,
t.text,
);
}
});
}
/// Search ("Tell me"): a box that opens command search. In the title bar, and in the Commands bar
/// of Full Screen (#1678).
fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
let ph = tl!("Search commands and help");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, ph));
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
let pfont = TypeRung::Control.regular();
let ptrack = TypeRung::Control.tracking();
paint_text(ui.painter(), pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, ph, &pfont, ptrack, t.text_dim);
if app.ui.keytips == crate::keytips::Phase::Tabs {
app.keytip_rects.push((r, crate::keytips::SEARCH_LETTER.to_string()));
}
let tip = match crate::widgets::shortcut_text(app, "tools.search") {
k if k.is_empty() => tl!("Search").to_string(),
k => format!("{} ({k})", tl!("Search")),
};
if resp.on_hover_text(tip).clicked() {
let _ = app.run("tools.search", json!({}));
}
}
/// What fills the screen (#1678): the usual window, Focus (the document on black, a slim bar to
/// drop the ribbon down) or Full Screen (the pages alone, a slim bar with Search). Both of the
/// latter take the whole screen and hide the title bar, ribbon, status bar, panes and rulers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Screen {
Normal,
Focus,
FullScreen,
}
impl Screen {
pub fn of(v: &wordcraft_engine::ViewState) -> Self {
if v.focus_mode {
Screen::Focus
} else if v.full_screen_view {
Screen::FullScreen
} else {
Screen::Normal
}
}
/// The command that leaves this screen, if it isn't the usual window.
pub fn exit_command(self) -> Option<&'static str> {
match self {
Screen::Normal => None,
Screen::Focus => Some("view.focus"),
Screen::FullScreen => Some("view.fullScreenView"),
}
}
}
/// Focus's background around the page, in both themes (our own value, #1678).
pub const FOCUS_CANVAS: egui::Color32 = egui::Color32::BLACK;
/// Height of the slim bar Focus and Full Screen View show in place of the chrome (#1678).
pub const SCREEN_BAR_HEIGHT: f32 = 30.0;
/// The slim bar across the top in Focus ("⋯" drops the ribbon down over the page) and Full
/// Screen View (Search, which finds and runs any command).
pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) {
let t = Tokens::get(ui.ctx());
let fill = if screen == Screen::Focus { FOCUS_CANVAS } else { t.title_bar };
egui::Panel::top("screen_bar").exact_size(SCREEN_BAR_HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| {
let full = ui.max_rect();
if screen == Screen::Focus {
let r = Rect::from_center_size(full.center(), vec2(40.0, 20.0));
let resp = ui.allocate_rect(r, Sense::click());
let shade = if resp.hovered() || app.focus_ribbon { egui::Color32::from_gray(70) } else { egui::Color32::from_gray(40) };
ui.painter().rect_filled(r, 4.0, shade);
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), "more", egui::Color32::from_gray(220), t.accent);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Show the ribbon")));
if resp.on_hover_text(tl!("Show the ribbon")).clicked() {
let _ = app.run("ui.focusRibbon", json!({}));
}
} else {
ui.scope_builder(egui::UiBuilder::new().max_rect(Rect::from_center_size(full.center(), vec2(260.0, 22.0))), |ui| {
search_box(app, ui, 22.0);
});
}
});
}
/// Focus: the ribbon dropped down from the bar, drawn over the page so the page doesn't move. A
/// click on the page puts it away; Focus stays on.
pub fn focus_ribbon(app: &mut WordApp, ctx: &egui::Context) {
let width = ctx.content_rect().width();
egui::Area::new(egui::Id::new("focus_ribbon")).fixed_pos(pos2(0.0, SCREEN_BAR_HEIGHT)).order(egui::Order::Middle).show(ctx, |ui| {
ui.set_width(width);
ui.set_max_width(width);
crate::ribbon::show(app, ui);
});
let click = ctx.input(|i| if i.pointer.any_click() { i.pointer.interact_pos() } else { None });
if let Some(pos) = click
&& app.canvas.viewport.is_some_and(|v| v.contains(pos))
&& ctx.layer_id_at(pos).is_none_or(|l| l.order == egui::Order::Background)
{
let _ = app.run("ui.focusRibbon", json!({"value": false}));
}
}
pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("status")
.exact_size(26.0)
.frame(
egui::Frame::NONE
.fill(t.status_bar)
.inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 })
.stroke(Stroke::new(1.0, t.border)),
)
.show(ui, |ui| {
// No document open (#1129): an empty status bar.
if !app.has_document() {
return;
}
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
let l = app.session.layout();
let page = if app.session.selects_whole_story() {
app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1
} else {
l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1)
};
let st = |ui: &mut Ui, s: &str| {
let font = TypeRung::Caption.regular();
let track = TypeRung::Caption.tracking();
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s));
resp
};
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
.on_hover_text(tl!("Go To (⌘⌥G)"))
.clicked()
{
let _ = app.run("ui.dialog", json!({"name": "goto"}));
}
let words = app.cached_word_count();
// A selected picture/shape/text box isn't a text selection: show the total.
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
} else {
let sw = wordcraft_doc::count_words(&app.session.selected_text());
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
};
if st(ui, &wtxt).clicked() {
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
}
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
let label = match crate::canvas::part_kind(app, app.session.sel.focus.story) {
Some(PartKind::Footnote) => tl!("Editing footnote"),
Some(PartKind::Endnote) => tl!("Editing endnote"),
_ => tl!("Editing header/footer"),
};
st(ui, label);
}
// The language at the caret; a click sets the proofing language (Review › Language).
let lang = crate::ribbon::caret_language(app);
let missing = crate::ribbon::caret_missing_dictionary(app);
let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
let shown = if unreadable.is_some() {
crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)])
} else if missing.is_some() {
crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)])
} else {
lang.clone()
};
let tip = match (&unreadable, missing) {
(Some((file, reason)), _) => crate::i18n::fmt(
tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"),
&[("language", &lang), ("file", &file.to_string_lossy()), ("reason", reason)],
),
(None, Some(name)) => crate::i18n::fmt(
tl!("No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations."),
&[("language", &lang), ("name", name)],
),
(None, None) => tl!("Language").to_string(),
};
let resp = st(ui, &shown).on_hover_text(tip);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(200.0);
crate::ribbon::language_menu(ui, app);
});
if app.session.doc.settings.track_changes {
st(ui, tl!("Track Changes: On"));
}
if app.session.prefs.overtype && st(ui, tl!("Overtype")).on_hover_text(tl!("Click to switch back to inserting")).clicked() {
let _ = app.run("text.overtype", json!({"value": false}));
}
// The message is drawn where the row is now; the controls on the right are laid
// out first, so the message can be shortened to the space they leave (#2402): a
// long one (the path of a file that isn't there) must not be drawn over the
// Focus and view buttons.
let msg_at = ui.cursor().min;
let mut controls_left = f32::INFINITY;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
let reading = app.session.view.read_mode;
let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round();
let ztxt = format!("{z}%");
let zfont = TypeRung::Caption.regular();
let ztrack = TypeRung::Caption.tracking();
let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim);
// A fixed width for three digits plus a small reserve, so the controls to the
// left don't shift as the percentage gains or loses digits (#1975).
let zslot = text_width(ui.painter(), "000%", &zfont, ztrack, t.text_dim) + 4.0;
let (zr, zresp) = ui.allocate_exact_size(vec2(zw.max(zslot) + 1.0, TypeRung::Caption.line_height()), Sense::click());
paint_text(ui.painter(), pos2(zr.max.x - 1.0, zr.min.y), Align2::RIGHT_TOP, &ztxt, &zfont, ztrack, t.text_dim);
if zresp.clicked() && !reading {
let _ = app.run("ui.dialog", json!({ "name": "zoom" }));
}
if small_icon(ui, "plus", tl!("Zoom In")) {
if reading {
let _ = app.run("view.readTextSize", json!({"value": (z + 10.0).min(150.0)}));
} else {
let _ = app.run("view.zoomIn", json!({}));
}
}
// Reading zoom changes only reading presentation, never the normal view zoom.
let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT };
let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false);
ui.spacing_mut().slider_width = 110.0;
if ui.add(slider).changed() {
let command = if reading { "view.readTextSize" } else { "view.zoom" };
let _ = app.run(command, json!({"value": (v * 100.0).round()}));
}
if small_icon(ui, "minus", tl!("Zoom Out")) {
if reading {
let _ = app.run("view.readTextSize", json!({"value": (z - 10.0).max(75.0)}));
} else {
let _ = app.run("view.zoomOut", json!({}));
}
}
ui.add_space(10.0);
let mode = app.session.view.mode;
let read = app.session.view.read_mode;
for (icon, id, name, on) in [
("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web),
("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read),
("readMode", "view.readMode", tl!("Read Mode"), read),
] {
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
if on {
ui.painter().rect_filled(r, 3.0, t.checked);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, name));
if resp.on_hover_text(name).clicked() {
let _ = app.run(id, json!({}));
}
}
ui.add_space(8.0);
let focus = tl!("Focus");
let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x);
let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click());
// The Focus button is the last of the controls, so it is the leftmost one: the
// message has to stop before it (#2402).
controls_left = r.min.x;
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent);
let ffont = TypeRung::Caption.regular();
let ftrack = TypeRung::Caption.tracking();
paint_text(
ui.painter(),
pos2(r.min.x + 22.0, r.center().y - TypeRung::Caption.line_height() / 2.0),
Align2::LEFT_TOP,
focus,
&ffont,
ftrack,
t.text_dim,
);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, focus));
if resp.clicked() {
let _ = app.run("view.focus", json!({}));
}
});
if let Some((msg, at)) = &app.status_msg
&& crate::now_ms() - at < 6000.0
{
let font = TypeRung::Caption.regular();
let track = TypeRung::Caption.tracking();
// The room the message has: what is left between here and the controls.
let room = controls_left - ui.spacing().item_spacing.x - msg_at.x;
let msg_shown = fit_text(ui.painter(), msg, &font, track, t.accent_text, room);
if !msg_shown.is_empty() {
let w = text_width(ui.painter(), &msg_shown, &font, track, t.accent_text);
let rect = Rect::from_min_size(msg_at, vec2(w + 1.0, TypeRung::Caption.line_height()));
// The whole message is in the tooltip; the bar only has room for the
// beginning of it (#2402).
let _ = ui.interact(rect, status_message_id(), Sense::hover()).on_hover_text(msg.clone());
paint_text(ui.painter(), rect.min, Align2::LEFT_TOP, &msg_shown, &font, track, t.accent_text);
ui.allocate_space(vec2(w + 1.0, TypeRung::Caption.line_height()));
}
}
});
});
}
fn small_icon(ui: &mut Ui, icon: &str, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
resp.on_hover_text(tip).clicked()
}
impl WordApp {
/// Word count, recomputed only when the document changes.
pub fn cached_word_count(&mut self) -> usize {
// The count option changes the count without a document change.
let key = self.session.rev().wrapping_mul(2) | u64::from(self.session.prefs.count_notes);
if self.word_count.0 != key {
self.word_count = (key, self.session.word_count());
}
self.word_count.1
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
ctx
}
fn frame(ctx: &egui::Context, n: &mut u32, events: Vec<egui::Event>, a: &mut WordApp) {
*n += 1;
let input = egui::RawInput { time: Some(f64::from(*n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
ctx.run_ui(input, |ui| title_bar(a, ui)).drop_without_applying_deltas();
}
fn click(ctx: &egui::Context, n: &mut u32, pos: egui::Pos2, a: &mut WordApp) {
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(ctx, n, vec![egui::Event::PointerMoved(pos), button(true)], a);
frame(ctx, n, vec![button(false)], a);
}
/// #701: the arrow beside Undo lists the session's undo steps, newest first; clicking the third
/// row undoes three steps at once.
#[test]
fn undo_list_matches_the_session_and_undoes_up_to_the_clicked_row() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("text.insert", json!({"text": "Hello"})).unwrap();
a.run("select.all", json!({})).unwrap();
a.run("format.bold", json!({})).unwrap();
a.run("format.italic", json!({})).unwrap();
a.run("caret.docEnd", json!({})).unwrap();
a.run("text.insert", json!({"text": " world"})).unwrap();
let session: Vec<String> = a.session.undo_history().into_iter().map(str::to_string).collect();
assert_eq!(session, ["Typing", "Italic", "Bold", "Typing"]);
assert_eq!(undo_list_rows(&a), session);
assert_eq!(undo_list_footer(0), "Cancel");
assert_eq!(undo_list_footer(1), "Undo 1 Action");
assert_eq!(undo_list_footer(3), "Undo 3 Actions");
let (ctx, mut n) = (ctx(), 0);
frame(&ctx, &mut n, vec![], &mut a);
let arrow = ctx.read_response(undo_list_button_id()).unwrap();
let popup = egui::Popup::default_response_id(&arrow);
click(&ctx, &mut n, arrow.rect.center(), &mut a);
assert!(egui::Popup::is_id_open(&ctx, popup), "the arrow opens the Undo list");
frame(&ctx, &mut n, vec![], &mut a);
for i in 0..4 {
assert!(ctx.read_response(undo_list_row_id(i)).is_some(), "row {i} is drawn");
}
assert!(ctx.read_response(undo_list_row_id(4)).is_none(), "one row per step");
let third = ctx.read_response(undo_list_row_id(2)).unwrap();
click(&ctx, &mut n, third.rect.center(), &mut a);
assert_eq!(a.session.undo_history(), ["Typing"], "three steps undone in one go");
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Hello");
frame(&ctx, &mut n, vec![], &mut a);
assert!(!egui::Popup::is_id_open(&ctx, popup), "the list closes");
}
/// #1975: the zoom percentage gains and loses digits (9% → 100% → 500%); the controls to its
/// left must not shift with it.
#[test]
fn status_bar_controls_hold_still_across_zoom_levels() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let ctx = ctx();
ctx.enable_accesskit();
let names = ["Focus", "Print Layout", "Web Layout"];
let mut seen: Vec<(f32, Vec<f64>)> = Vec::new();
for (n, zoom) in [10.0_f32, 9.0, 100.0, 500.0].into_iter().enumerate() {
a.canvas.scale = zoom / 100.0 * crate::canvas::PX_PER_PT;
let input = egui::RawInput {
time: Some(n as f64 / 60.0),
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| status_bar(&mut a, ui));
out.textures_delta.clear();
let update = out.platform_output.accesskit_update.take().unwrap();
let xs: Vec<f64> = names
.iter()
.map(|name| {
let node =
update.nodes.iter().find(|(_, node)| node.label() == Some(*name)).unwrap_or_else(|| panic!("{name} is drawn")).1.clone();
node.bounds().unwrap().x0
})
.collect();
seen.push((zoom, xs));
}
let first = &seen[0].1;
for (zoom, xs) in &seen {
assert_eq!(xs, first, "status bar controls moved at {zoom}% zoom");
}
}
/// #2402: a long message (the path of a file that isn't there) is shortened so that it stops
/// before the controls on the right, instead of being drawn over the Focus button.
#[test]
fn a_long_status_message_stops_before_the_controls() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let long = "C:\\Test\\Documents\\Reports\\Final individual test folder\\Second level folder\\Project - Gadgets and sleep of teenagers.docx: The system cannot find the path specified. (os error 3)";
a.status_msg = Some((long.to_string(), crate::now_ms()));
let ctx = ctx();
ctx.enable_accesskit();
let input = egui::RawInput {
time: Some(0.0),
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0))),
..Default::default()
};
let mut whole = 0.0;
let mut out = ctx.run_ui(input, |ui| {
status_bar(&mut a, ui);
// The width the whole message would take, unshortened.
let t = Tokens::get(ui.ctx());
let font = TypeRung::Caption.regular();
let track = TypeRung::Caption.tracking();
whole = text_width(ui.painter(), long, &font, track, t.accent_text);
});
out.textures_delta.clear();
let update = out.platform_output.accesskit_update.take().unwrap();
let focus =
update.nodes.iter().find(|(_, node)| node.label() == Some("Focus")).unwrap_or_else(|| panic!("Focus is drawn")).1.bounds().unwrap();
let msg = ctx.read_response(status_message_id()).expect("the message is drawn");
assert!(msg.rect.width() < whole, "the message is shortened: {:?} of {whole}", msg.rect);
assert!(f64::from(msg.rect.right()) <= focus.x0, "the message stops before the Focus button: {:?} vs {}", msg.rect, focus.x0);
}
/// The status bar's "editing a non-body story" line named every one of them "Editing
/// header/footer", footnotes and endnotes included — it only ever checked "not the body,
/// not a text box", with no regard for which kind of part it actually was.
#[test]
fn status_bar_names_the_actual_part_being_edited() {
let label = |a: &mut WordApp| {
let ctx = ctx();
ctx.enable_accesskit();
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| status_bar(a, ui));
out.textures_delta.clear();
let update = out.platform_output.accesskit_update.take().unwrap();
for prefix in ["Editing footnote", "Editing endnote", "Editing header/footer"] {
if update.nodes.iter().any(|(_, node)| node.label().is_some_and(|l| l == prefix)) {
return prefix.to_string();
}
}
"(none shown)".to_string()
};
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("text.insert", json!({"text": "Body text"})).unwrap();
a.run("references.footnote", json!({})).unwrap();
assert_eq!(label(&mut a), "Editing footnote");
a.run("caret.docEnd", json!({})).unwrap();
a.run("references.endnote", json!({})).unwrap();
assert_eq!(label(&mut a), "Editing endnote");
a.run("insert.createHeader", json!({})).unwrap();
assert_eq!(label(&mut a), "Editing header/footer");
}
/// #1903: the footer's Cancel row closes the Undo list without undoing anything.
#[test]
fn undo_list_cancel_closes_without_undoing() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("text.insert", json!({"text": "Hello"})).unwrap();
let before: Vec<String> = a.session.undo_history().into_iter().map(str::to_string).collect();
assert!(!before.is_empty());
let (ctx, mut n) = (ctx(), 0);
frame(&ctx, &mut n, vec![], &mut a);
let arrow = ctx.read_response(undo_list_button_id()).unwrap();
let popup = egui::Popup::default_response_id(&arrow);
click(&ctx, &mut n, arrow.rect.center(), &mut a);
assert!(egui::Popup::is_id_open(&ctx, popup), "the arrow opens the Undo list");
frame(&ctx, &mut n, vec![], &mut a);
let cancel = ctx.read_response(undo_list_cancel_id()).expect("Cancel is drawn");
click(&ctx, &mut n, cancel.rect.center(), &mut a);
let after: Vec<String> = a.session.undo_history().into_iter().map(str::to_string).collect();
assert_eq!(after, before, "nothing undone");
frame(&ctx, &mut n, vec![], &mut a);
assert!(!egui::Popup::is_id_open(&ctx, popup), "Cancel closes the list");
}
#[test]
fn mac_title_bar_centres_and_clears_the_traffic_lights() {
// The lights are centred in the bar, and content keeps the same gap after them.
let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0;
let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT;
assert_eq!(MAC_CONTENT_LEFT - lights_end, margin);
// `egui::Margin` is in `i8`.
assert!(MAC_CONTENT_LEFT <= f32::from(i8::MAX));
}
#[test]
fn mac_title_bar_follows_the_measured_lights() {
// The standard lights give the standard bar.
let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT;
assert_eq!(MacTitleBar::around_lights(lights_end, MAC_TITLE_BAR / 2.0, MAC_LIGHT), MacTitleBar::default());
// Larger, lower lights (macOS 26): a taller bar, and content clear of the green button.
let bar = MacTitleBar::around_lights(78.0, 18.0, 14.0);
assert_eq!(bar.height, 36.0);
assert_eq!(bar.content_left, 89.0);
// Never tighter than the standard layout.
assert_eq!(MacTitleBar::around_lights(40.0, 10.0, 12.0), MacTitleBar::default());
}
/// #1945: Hide Quick Access Toolbar, the last item of the toolbar's menu, takes the toolbar's
/// buttons and its … button out of the title bar, which keeps its title; the command brings
/// them back.
#[test]
fn hide_quick_access_toolbar_takes_the_toolbar_out_of_the_title_bar() {
use egui_kittest::kittest::Queryable;
let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
// The fonts `logic` installs take effect on the next frame, as `WordApp::ui` waits.
if app.fonts_frames < 2 {
app.fonts_frames += 1;
return;
}
title_bar(app, ui);
},
a,
);
h.run_steps(4);
let drawn = |h: &egui_kittest::Harness<'static, WordApp>, id: &str| h.ctx.read_response(qat_button_id(id)).is_some();
for id in ["file.save", "edit.undo", "edit.redo"] {
assert!(drawn(&h, id), "{id} is on the toolbar");
}
let at = h.ctx.read_response(qat_menu_button_id()).expect("the … button").rect.center();
h.event(egui::Event::PointerMoved(at));
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(2);
h.get_by_label("Hide Quick Access Toolbar").click();
// `read_response` also reads the frame before last, so run two frames past the change.
h.run_steps(3);
assert!(!h.state().ui.quick_access_toolbar, "the menu's last item hides the toolbar");
assert!(h.query_by_label("Hide Quick Access Toolbar").is_none(), "and closes the menu");
for id in ["file.save", "edit.undo", "edit.redo"] {
assert!(!drawn(&h, id), "{id} is gone");
}
assert!(h.ctx.read_response(qat_menu_button_id()).is_none(), "so is the … button");
assert!(h.query_by_label_contains("Not saved").is_some(), "the title is still drawn");
h.state_mut().run("ui.quickAccessToolbar", json!({"value": true})).unwrap();
h.run_steps(2);
for id in ["file.save", "edit.undo", "edit.redo"] {
assert!(drawn(&h, id), "{id} is back");
}
assert!(h.ctx.read_response(qat_menu_button_id()).is_some());
}
/// #3040: clicking a row in the Undo list hands the keyboard back to the document.
#[test]
fn undo_list_row_returns_focus_to_the_document() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("text.insert", json!({"text": "Hello"})).unwrap();
assert_eq!(a.session.undo_history().len(), 1);
let (ctx, mut n) = (ctx(), 0);
frame(&ctx, &mut n, vec![], &mut a);
let arrow = ctx.read_response(undo_list_button_id()).unwrap();
let popup = egui::Popup::default_response_id(&arrow);
click(&ctx, &mut n, arrow.rect.center(), &mut a);
assert!(egui::Popup::is_id_open(&ctx, popup), "the arrow opens the Undo list");
frame(&ctx, &mut n, vec![], &mut a);
let row = ctx.read_response(undo_list_row_id(0)).unwrap();
a.canvas.want_focus = false;
click(&ctx, &mut n, row.rect.center(), &mut a);
assert!(a.canvas.want_focus, "the row click returns focus to the document");
assert!(a.session.undo_history().is_empty(), "the step was undone");
}
/// A saved .docx (so AutoSave can cover it) with the title bar drawn over it.
fn autosave_harness() -> (egui_kittest::Harness<'static, WordApp>, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("wordcraft-ui-autosave-label-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("draft.docx");
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("text.insert", json!({"text": "Hello"})).unwrap();
a.run("file.save", json!({"path": path.to_string_lossy()})).unwrap();
assert!(a.autosaves(), "a saved .docx is covered by AutoSave");
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
// The fonts `logic` installs take effect on the next frame, as `WordApp::ui` waits.
if app.fonts_frames < 2 {
app.fonts_frames += 1;
return;
}
title_bar(app, ui);
},
a,
);
h.run_steps(4);
(h, dir)
}
/// #3370: the AutoSave text is part of the switch, not a caption beside it: clicking the text
/// toggles AutoSave, as clicking the switch does.
#[test]
fn clicking_the_autosave_label_toggles_autosave() {
use egui_kittest::kittest::Queryable;
let (mut h, dir) = autosave_harness();
assert!(h.state().autosaves());
h.get_by_label("AutoSave").click();
h.run_steps(2);
assert!(!h.state().autosaves(), "clicking the text turns AutoSave off");
h.get_by_label("AutoSave").click();
h.run_steps(2);
assert!(h.state().autosaves(), "and clicking it again turns it back on");
let _ = std::fs::remove_dir_all(dir);
}
}