Files
wordcraft/crates/ui-egui/src/dialogs/chrome.rs
T
8548b33be1 MacOS: the window moves by its title bar behind a modal dialog (#5124) (#5125)
* #5124: the window moves by its title bar behind a modal dialog

On macOS WordCraft draws its own title bar, and since modal dialogs
(#3859, #4076) the dialog's backdrop covers it, so the window couldn't be
dragged while a dialog was open. Word for Mac lets you drag the document
window by its title bar behind its Font dialog (and beeps on the click).

- The title bar (and the Backstage's top band on macOS) notes the part
  that drags the window each frame. When the modal backdrop's drag starts
  there, it moves the window; the press still shakes the dialog, as Word
  beeps.
- Only the backdrop's own drag counts, so a drag on the dialog, its tabs
  or its popups, where it reaches over the title bar, moves only the
  dialog.

Tested by the developer on a Mac.

Fixes #5124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: only the macOS title bar moves the window behind a dialog

Review (CodeRabbit):
- Elsewhere the system's title bar already moves the window; WordCraft's
  own bar under it now doesn't, behind a modal dialog either, so nothing
  changes on Linux and Windows.
- A drag from just inside the dialog's edge, where it reaches over the
  title bar, moves only the dialog, as the nudge already tells it apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the window moves by its title bar behind Colors too; modal_open tests

Colors is an egui Modal, not a window with the backdrop of block_behind, so
a drag on the macOS title bar did nothing behind it. It now shares the check
with the other modal windows.

The new tests pin modal_open with nothing open, a modeless dialog, a modal
dialog, the file browser and Colors, and that the title bar moves the window
in each of those while the dialog stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the modal drag test covers the top of File's side menu

On macOS File's side menu covers the title bar, and its top band moves the
window. The test now drags there too, so losing the band's note_title_drag
call fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 14:04:45 +01:00

313 lines
15 KiB
Rust

use super::dispatch::body;
use super::{Dialog, Sense, Tokens, Ui, WordApp, vec2};
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
color_field(ui, theme, value, "No Color", "");
}
/// A colour field: a swatch and a drop-down with `none_label` (which sets `none_value`), the
/// colour grid, Recent Colors and More Colors… (#606). `value` is hex or `none_value`.
pub(crate) fn color_field(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, none_value: &str) {
ui.horizontal(|ui| {
let id = ui.scope_id().with("colorField");
crate::colors::take_field(ui.ctx(), id, value);
let c = wordcraft_doc::Rgb::parse(value);
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
let t = Tokens::get(ui.ctx());
match c {
Some(c) => {
ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));
}
None => {
ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));
}
}
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let label = if c.is_some() { value.clone() } else { tl!(none_label).to_string() };
ui.menu_button(label, |ui| {
if ui.button(tl!(none_label)).clicked() {
*value = none_value.to_string();
ui.close();
}
crate::colors::field_menu(ui, theme, id, value);
});
});
}
/// The open dialog, as (window id, frames it has been centred for).
fn dialog_top_key() -> egui::Id {
egui::Id::unique("dialog_top")
}
/// Frames a dialog stays centred: the first one measures it, the second centres that size.
const DIALOG_CENTRED_FRAMES: u8 = 2;
/// Places a dialog: centred for its first frames, then left where it is with its top-centre point
/// as the pivot, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves
/// the bottom edge and the tab strip stays under the mouse (#507). It is no longer anchored after
/// that, so dragging the title bar moves it (#732). egui keeps it on screen if it grows past the
/// bottom.
fn place_dialog<'a>(ctx: &egui::Context, id: egui::Id, w: egui::Window<'a>) -> egui::Window<'a> {
let w = w.pivot(egui::Align2::CENTER_TOP);
match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) {
Some((open, frames)) if open == id && frames >= DIALOG_CENTRED_FRAMES => w,
_ => w.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)),
}
}
/// After a frame of a dialog: counts the frames it has been centred for.
fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) {
let frames = match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) {
Some((open, frames)) if open == id => frames,
_ => 0,
};
if frames < DIALOG_CENTRED_FRAMES {
ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, frames + 1)));
}
}
/// Behind a modal window, whose `backdrop` has the pointer, the app still moves by its title bar,
/// as in Word for Mac (#5124); not from just inside the window's edge, where it reaches over the
/// title bar.
pub(crate) fn drag_by_title_bar(ctx: &egui::Context, backdrop: &egui::Response, window: egui::Rect) {
let origin = ctx.input(|i| i.pointer.press_origin()).filter(|p| !window.contains(*p));
if backdrop.drag_started() && origin.is_some_and(|p| crate::chrome::title_drag_rect(ctx).is_some_and(|r| r.contains(p))) {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
}
/// Makes `window` modal (#3859): the document gives up the keyboard now, and from the next frame
/// nothing beneath the window takes the pointer or Tab. A press beside it nudges it ([`nudge`]).
pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Response) {
let layer = window.layer_id;
// egui's hit test doesn't look at the modal layer, so a transparent backdrop just under the
// window takes the clicks; nothing is dimmed, as in Word.
let screen = ctx.content_rect();
let backdrop = egui::Area::new(layer.id.with("modal_backdrop"))
.order(layer.order)
.fixed_pos(screen.min)
.show(ctx, |ui| ui.allocate_rect(screen, Sense::CLICK | Sense::DRAG))
.inner;
// The backdrop also has the pointer just inside the window's edge (egui's hit radius).
let beside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !window.rect.contains(p)));
drag_by_title_bar(ctx, &backdrop, window.rect);
ctx.move_to_top(layer);
ctx.memory_mut(|m| m.set_modal_layer(layer));
ctx.accesskit_node_builder(window.id, |node| node.set_modal());
release_keyboard(app, ctx);
nudge(ctx, window, backdrop.contains_pointer() && beside, app.animations_on());
}
/// How long a modal window shakes or flashes after a press beside it, in seconds.
const NUDGE_SECS: f64 = 0.4;
/// The `ctx.data` key for when the window `id` was last nudged, in this document window: the data
/// is shared by all of them.
pub(super) fn nudge_id(ctx: &egui::Context, id: egui::Id) -> egui::Id {
id.with(("nudge", ctx.viewport_id()))
}
/// Forgets a nudge of the window `id` as it closes, so it doesn't go on when the window reopens.
pub(crate) fn forget_nudge(ctx: &egui::Context, id: egui::Id) {
let key = nudge_id(ctx, id);
ctx.data_mut(|d| d.remove::<f64>(key));
}
/// The shake's sideways offset at `t` (0 to 1) through it: three swings either way of up to 6
/// points, dying away.
pub(super) fn shake_offset(t: f32) -> f32 {
6.0 * (t * 3.0 * std::f32::consts::TAU).sin() * (1.0 - t)
}
/// A press beside a modal window shakes it a little (#3859), so the user sees why nothing behind
/// it reacts; with animation effects off, its border flashes twice instead. A press while it runs
/// doesn't start it over.
fn nudge(ctx: &egui::Context, window: &egui::Response, pressed: bool, animate: bool) {
let id = nudge_id(ctx, window.layer_id.id);
let now = ctx.input(|i| i.time);
let start = match ctx.data(|d| d.get_temp::<f64>(id)).filter(|s| (0.0..NUDGE_SECS).contains(&(now - s))) {
Some(s) => s,
None if pressed => {
ctx.data_mut(|d| d.insert_temp(id, now));
now
}
None => return,
};
ctx.request_repaint();
let t = ((now - start) / NUDGE_SECS).clamp(0.0, 1.0) as f32;
if animate {
ctx.transform_layer_shapes(window.layer_id, egui::emath::TSTransform::from_translation(vec2(shake_offset(t), 0.0)));
} else if ((t * 4.0) as u32).is_multiple_of(2) {
let radius = ctx.global_style().visuals.window_corner_radius;
let stroke = egui::Stroke::new(2.0, Tokens::get(ctx).accent);
ctx.layer_painter(window.layer_id).rect_stroke(window.rect, radius, stroke, egui::StrokeKind::Inside);
}
}
/// Takes the keyboard from the document while a modal window is open.
pub(crate) fn release_keyboard(app: &WordApp, ctx: &egui::Context) {
if let Some((id, _)) = app.canvas.widget_id {
ctx.memory_mut(|m| m.surrender_focus(id));
}
}
/// Takes the active dialog, resolves its translated title, hosts it as a non-resizable window, draws
/// its body, keeps it open when it stays, and gives the Save Changes prompt the AccessKit
/// alert-dialog role.
pub fn show(app: &mut WordApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else {
ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key()));
return;
};
let mut close = false;
let title = match &d {
Dialog::RenameDocument { .. } => "Rename Document",
Dialog::Font { .. } => "Font",
Dialog::Paragraph { .. } => "Paragraph",
Dialog::Tabs { .. } => "Tabs",
Dialog::Borders { .. } => "Borders and Shading",
Dialog::Find { .. } => "Find and Replace",
Dialog::InsertTable { .. } => "Insert Table",
Dialog::PageSetup { .. } => "Page Setup",
Dialog::Link { .. } => "Insert Hyperlink",
Dialog::Bookmark { .. } => "Bookmark",
Dialog::WordCount { .. } => "Word Count",
Dialog::Zoom { .. } => "Zoom",
Dialog::Watermark { .. } => "Custom Watermark",
Dialog::Phonetic { .. } => "Phonetic Guide",
Dialog::NewStyle { .. } => "Create New Style",
Dialog::ModifyStyle { .. } => "Modify Style",
Dialog::ManageStyles { .. } => "Manage Styles",
Dialog::TableStyle { id: None, .. } => "New Table Style",
Dialog::TableStyle { .. } => "Modify Table Style",
Dialog::Commands { .. } => "Search Commands",
Dialog::Macros { .. } => "Macros",
Dialog::Insert(d) => d.title(),
Dialog::Refs(d) => d.title(),
Dialog::Icons { .. } => "Icons",
Dialog::PasteSpecial { .. } => "Paste Special",
Dialog::About { .. } => "About WordCraft",
Dialog::SaveChanges { .. } => "WordCraft",
Dialog::DiscardRecovery { .. } => "Discard recovery copy",
Dialog::RecipientList { .. } => "Recipient List",
Dialog::SelectTable { .. } => "Select Table",
Dialog::InsertMergeField { .. } => "Insert Merge Field",
Dialog::FindRecipient { .. } => "Find Recipient",
Dialog::MergeRecords { .. } => "Finish & Merge",
Dialog::FieldCode { edit: false, .. } => "Field",
Dialog::FieldCode { .. } => "Edit Field Code",
Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "Skip Record If",
Dialog::MergeRule { .. } => "If…Then…Else",
Dialog::MatchFields { .. } => "Match Fields",
Dialog::CheckErrors { .. } => "Check for Errors",
Dialog::TableProperties { .. } => "Table Properties",
Dialog::CellMargins { .. } => "Cell Margins",
Dialog::Password { .. } => "Password",
Dialog::EncryptPassword { .. } => "Encrypt with Password",
Dialog::DefineList { .. } => "Define New Multilevel List",
Dialog::TrackOptions { .. } => "Track Changes Options",
Dialog::Review(d) => d.title(),
Dialog::CustomizeKeyboard { .. } => "Customize Keyboard",
Dialog::CustomizeRibbon { .. } => "Customize Ribbon",
Dialog::Control { .. } => "Content Control Properties",
Dialog::Enclose { .. } => "Enclose Characters",
Dialog::NoteOptions { .. } => "Footnote and Endnote",
Dialog::Templates { .. } => "Templates and Add-ins",
Dialog::Organizer { .. } => "Organizer",
Dialog::CustomProperties { .. } => "Custom Document Properties",
Dialog::Layout(d) => d.title(),
};
let id = egui::Id::unique(("dialog", title));
let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false));
// A file browser or Colors dialog it opened is above it and takes the keys: Escape or Enter
// there doesn't also cancel or OK this one.
let held = (app.file_browser_open() || app.colors.is_some()).then(|| ctx.input_mut(|i| std::mem::take(&mut i.events)));
let shown = place_dialog(ctx, id, window).show(ctx, |ui| {
ui.set_min_width(340.0);
close = crate::rtl::root(ui, |ui| body(app, ui, &mut d));
});
if let Some(events) = held {
ctx.input_mut(|i| i.events = events);
}
if let Some(shown) = &shown {
if let Dialog::SaveChanges { name, message, .. } = &d {
let question = crate::i18n::fmt(tl!("Do you want to save changes to {name}?"), &[("name", name)]);
let mut description = tl!("Your changes will be lost if you don't save them.").to_string();
if !message.is_empty() {
description.push('\n');
description.push_str(message);
}
// This is an urgent decision, not another application window. Keep its context with
// the focused choice, including when a failed Save brings the same prompt back.
ctx.accesskit_node_builder(shown.response.id, |node| {
node.set_role(egui::accesskit::Role::AlertDialog);
node.set_label(question);
node.set_description(description);
});
}
count_dialog_frame(ctx, id);
close |= crate::window_close::show(ctx, &shown.response, &Tokens::get(ctx));
// A file browser it opened (Attach…, Browse…) stays above it.
if d.is_modal() && !close && !app.file_browser_open() {
block_behind(app, ctx, &shown.response);
}
}
if close {
forget_nudge(ctx, id);
// Not from a browser it opened as it closed (Save As): that one keeps the keyboard.
if !app.file_browser_open() {
app.canvas.want_focus = true;
}
} else {
app.dialog = Some(d);
}
}
/// Cancel key for dialogs: Escape everywhere, plus ⌘. (Command+Period) on macOS.
pub(crate) fn cancel_key_pressed(ui: &Ui) -> bool {
ui.input(|i| i.key_pressed(egui::Key::Escape) || (cfg!(target_os = "macos") && i.modifiers.command && i.key_pressed(egui::Key::Period)))
}
/// Lays out an OK and a Cancel button right-to-left, in the platform's order:
/// macOS puts Cancel left of the default OK button; Windows and Linux put OK left of Cancel.
/// Returns `(ok, cancel)` responses. Call inside a right-to-left layout.
pub(crate) fn ok_cancel_responses(ui: &mut Ui, draw_ok: impl FnOnce(&mut Ui) -> egui::Response, cancel: &str) -> (egui::Response, egui::Response) {
if cfg!(target_os = "macos") {
let okb = draw_ok(ui);
(okb, ui.button(cancel))
} else {
let cancelb = ui.button(cancel);
(draw_ok(ui), cancelb)
}
}
pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
let mut r = (false, false);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
// Enter and Escape belong to the Colors dialog while it is open above this one.
let keys = !crate::colors::is_open(ui.ctx());
let (okb, cancelb) = ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, ok)), tl!("Cancel"));
if cancelb.clicked() || (keys && cancel_key_pressed(ui)) {
r.1 = true;
}
if okb.clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
r.0 = true;
}
});
});
r
}
/// A dialog's single Close button (Escape too).
pub(super) fn close_button(ui: &mut Ui) -> bool {
ui.add_space(8.0);
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| {
close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked();
});
close
}