mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 08:53:27 -06:00
## What Builds on #3131 (the contextual tab glide); this PR is based on that branch. - Every ribbon button and tab fades its hover in and out. Before, only some buttons did. - Disabled buttons do not answer hover or press. - A ribbon group glows from its bottom edge while the pointer is anywhere in it. The glow is about 9 pt tall and fades out upwards and towards both sides. It fades in and out with the pointer. - The tab underline slides to the tab you choose, with the same ease as the contextual tab glide. - The ribbon content fades and slides in (14 pt, 0.16 s) from the side the new tab lies on. All motion follows the animation setting: with animations off, nothing moves and nothing fades. ## Screenshots <img width="298" height="115" alt="image" src="https://github.com/user-attachments/assets/9b5ba886-1dcd-4260-a2dd-cecdf199e111" /> <img width="498" height="192" alt="ribbon-animated-dark" src="https://github.com/user-attachments/assets/bc43168b-183a-4ec0-875c-a9550f22fb36" /> <img width="548" height="158" alt="ribbon-animated" src="https://github.com/user-attachments/assets/4d50427e-376d-4a6a-a0bd-b1ed42fe0c9c" /> ## Tests - Pure tests for the underline slide and the tab change direction. - `cargo test -p wordcraft-ui-egui` and clippy `-D warnings` pass. ## Related - Overlaps with #3256 and #1420 (issue #1419): both also slide the underline and fade the content on a tab change. The hover fade and the group glow are new here. Whichever lands first, the other needs a small merge in `ribbon.rs`. - Possibly related: issue #585. - The Search box text centring is its own PR, #3802 (issue #3779). - Based on #3131. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Ribbon tabs animate as they appear and disappear, with a sliding active-tab indicator and fading, directional transitions between tab content. Departing contextual tabs remain visible briefly but cannot be selected or appear in the overflow menu. * Tab and overflow-button hover feedback is animated. Ribbon groups display a soft accent glow when hovered. * **Bug Fixes** * Disabled buttons no longer show hover or pressed backgrounds. With animations disabled, tabs and content appear immediately. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
314 lines
15 KiB
Rust
314 lines
15 KiB
Rust
#[cfg(test)]
|
|
use super::TALL_GROUPS;
|
|
use super::buttons::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, DROPDOWN, LABEL_H, bg, label_line};
|
|
use super::{Align2, Color32, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
|
|
|
|
/// A ribbon group: content, a centred label below and a divider on the right.
|
|
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
|
|
// The English title keys the launcher's id; the drawn title is translated.
|
|
group_titled(ui, title, tl!(title), launcher, app, add);
|
|
}
|
|
|
|
/// A ribbon group whose title is shown as is (a custom group's name, Customize Ribbon).
|
|
pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
|
|
let t = Tokens::get(ui.ctx());
|
|
// Groups follow the ribbon's direction: in a right-to-left interface the content starts at
|
|
// the right, and the launcher and the divider sit on the left.
|
|
let rtl = crate::rtl::flows(ui);
|
|
let start_cursor = ui.cursor();
|
|
let start = start_cursor.min;
|
|
// #662: the window may be too narrow for the tab; the plan says how this group fits.
|
|
let position = crate::ribbon_fit::position(ui.ctx());
|
|
let level = crate::ribbon_fit::level(ui.ctx(), id_title);
|
|
if level == crate::ribbon_fit::COLLAPSED {
|
|
collapsed_group(ui, id_title, position, title, launcher, app, add);
|
|
} else {
|
|
// Not folded: a drop-down left open while it was folded doesn't come back by itself.
|
|
ui.data_mut(|d| d.remove::<bool>(collapsed_open_id(ui, id_title, position)));
|
|
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
|
|
// Reserve the paint slot before children so the glow stays beneath the controls.
|
|
let glow = ui.painter().add(egui::Shape::Noop);
|
|
let inner = crate::rtl::vertical(ui, |ui| {
|
|
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
|
|
crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT);
|
|
let content = ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), crate::rtl::row_layout(ui, egui::Align::Min), |ui| {
|
|
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
|
|
add(ui, app);
|
|
});
|
|
crate::ribbon_fit::end_compact(ui.ctx());
|
|
#[cfg(test)]
|
|
if content.response.rect.height() > CONTENT_H + 0.5 {
|
|
TALL_GROUPS.with(|g| g.borrow_mut().push(format!("{id_title}: {:.1}", content.response.rect.height())));
|
|
}
|
|
#[cfg(not(test))]
|
|
let _ = content;
|
|
});
|
|
let r = inner.response.rect;
|
|
let area = group_area(rtl, start, r);
|
|
let over = ui.rect_contains_pointer(area);
|
|
// Position is part of identity because duplicate custom group names can appear in tabs.
|
|
let lit = ui.ctx().animate_bool_responsive(ui.scope_id().with(("group_glow", id_title, position)), over);
|
|
if lit > 0.0 {
|
|
ui.painter().set(glow, group_glow(area, t.accent, lit));
|
|
}
|
|
title_row(
|
|
ui,
|
|
Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
|
|
id_title,
|
|
title,
|
|
launcher,
|
|
app,
|
|
);
|
|
}
|
|
ui.add_space(5.0);
|
|
let x = if rtl { ui.cursor().max.x } else { ui.cursor().min.x };
|
|
ui.painter().line_segment([pos2(x, start.y + 4.0), pos2(x, start.y + CONTENT_H + LABEL_H - 4.0)], Stroke::new(1.0, t.border));
|
|
ui.add_space(6.0);
|
|
crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor()));
|
|
}
|
|
|
|
/// The hover area of a group. A right-to-left row has no finite left edge at the cursor, so the
|
|
/// area starts at the content's own left edge there.
|
|
fn group_area(rtl: bool, start: egui::Pos2, content: Rect) -> Rect {
|
|
let left = if rtl { content.min.x } else { start.x };
|
|
Rect::from_min_max(pos2(left, start.y), pos2(content.max.x, start.y + CONTENT_H + LABEL_H))
|
|
}
|
|
|
|
/// The width a group took: the cursor's way along the row, from `before` to `after`. A
|
|
/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity,
|
|
/// so there the width is how far the right edge moved.
|
|
fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 {
|
|
if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x }
|
|
}
|
|
|
|
/// A soft accent glow along the bottom of a hovered ribbon group.
|
|
fn group_glow(area: Rect, accent: Color32, strength: f32) -> egui::Shape {
|
|
let color = |alpha: f32| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (alpha * strength * 255.0) as u8);
|
|
let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 7.0), pos2(area.max.x, area.max.y + 2.0));
|
|
let soft = (r.width() * 0.3).min(22.0);
|
|
let xs = [r.min.x, r.min.x + soft, r.max.x - soft, r.max.x];
|
|
let mut mesh = egui::epaint::Mesh::default();
|
|
for (y, alpha) in [(r.max.y, 0.3), (r.min.y, 0.0)] {
|
|
for (i, x) in xs.iter().enumerate() {
|
|
mesh.colored_vertex(pos2(*x, y), color(if i == 0 || i == 3 { 0.0 } else { alpha }));
|
|
}
|
|
}
|
|
for i in 0..3u32 {
|
|
mesh.add_triangle(i, i + 1, i + 5);
|
|
mesh.add_triangle(i, i + 5, i + 4);
|
|
}
|
|
egui::Shape::mesh(mesh)
|
|
}
|
|
|
|
/// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher.
|
|
fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let title_y = r.center().y;
|
|
let rtl = crate::rtl::flows(ui);
|
|
ui.painter().text(pos2(r.center().x, title_y), Align2::CENTER_CENTER, title, regular(11.0), t.group_label);
|
|
if let Some(cmd) = launcher {
|
|
let lr = Rect::from_center_size(pos2(crate::rtl::trail_x(rtl, r, 6.0), title_y), vec2(11.0, 11.0));
|
|
let resp = ui.interact(lr, ui.scope_id().with(("launch", id_title)), Sense::click());
|
|
if resp.hovered() {
|
|
ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover);
|
|
}
|
|
icons::paint(ui.painter(), lr, "launcher", t.text_dim, t.accent);
|
|
if resp.on_hover_text(crate::i18n::fmt(tl!("{group} settings"), &[("group", title)])).clicked() {
|
|
let _ = app.run(cmd, serde_json::json!({}));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a collapsed group remembers whether its drop-down is open.
|
|
fn collapsed_open_id(ui: &Ui, id_title: &str, position: usize) -> egui::Id {
|
|
ui.scope_id().with(("collapsed_group", id_title, position))
|
|
}
|
|
|
|
/// A group folded into one drop-down button (the window is too narrow, #662): the button, as tall
|
|
/// as the group with its title, shows the group's name, and its drop-down holds the whole group as
|
|
/// designed with its title and launcher. The drop-down stays open while it is used (#1285): only a
|
|
/// click outside it, or on the button, closes it; clicks in the menus and lists its controls open
|
|
/// (font and size lists, Change Case, colours) count as inside.
|
|
fn collapsed_group(
|
|
ui: &mut Ui,
|
|
id_title: &str,
|
|
position: usize,
|
|
title: &str,
|
|
launcher: Option<&str>,
|
|
app: &mut WordApp,
|
|
add: impl FnOnce(&mut Ui, &mut WordApp),
|
|
) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let text_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.5), t.text).size().x);
|
|
let (r, resp) = ui.allocate_exact_size(vec2((text_w + DROPDOWN + 14.0).max(48.0), CONTENT_H + LABEL_H), Sense::click());
|
|
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
|
|
let open_id = collapsed_open_id(ui, id_title, position);
|
|
let was_open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
|
|
bg(ui, r, &resp, was_open, &t);
|
|
icons::paint(
|
|
ui.painter(),
|
|
Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)),
|
|
group_icon(id_title),
|
|
t.icon,
|
|
t.accent,
|
|
);
|
|
label_line(ui, r.center().x, r.min.y + BIG_LABEL_Y, title, regular(11.5), t.text, true);
|
|
let resp = resp.on_hover_text(title);
|
|
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
|
|
let mut open = was_open;
|
|
if resp.clicked() {
|
|
open = !open;
|
|
} else if open && ui.input(|i| i.pointer.any_click()) {
|
|
// Popups (the drop-down, its controls' menus and lists) sit above the ribbon; a click on
|
|
// the window below them is outside.
|
|
let pos = ui.input(|i| i.pointer.interact_pos());
|
|
let layer = pos.and_then(|p| ui.ctx().layer_id_at(p));
|
|
if !layer.is_some_and(|l| matches!(l.order, egui::Order::Foreground | egui::Order::Tooltip)) {
|
|
open = false;
|
|
}
|
|
}
|
|
// Esc closes an open list inside first, then the drop-down.
|
|
let child_open = egui::Popup::is_any_open(ui.ctx());
|
|
let mut shown = open;
|
|
if open {
|
|
// This popup isn't counted by `Popup::is_any_open`, so keep the same Esc from also leaving
|
|
// full screen.
|
|
app.escape_free = false;
|
|
crate::rtl::menu(&resp).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(|ui| {
|
|
crate::rtl::vertical(ui, |ui| {
|
|
crate::rtl::horizontal_top(ui, |ui| {
|
|
ui.set_min_height(CONTENT_H);
|
|
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
|
|
add(ui, app);
|
|
});
|
|
let (row, _) = ui.allocate_exact_size(vec2(ui.min_rect().width(), LABEL_H), Sense::hover());
|
|
title_row(ui, row, id_title, title, launcher, app);
|
|
});
|
|
});
|
|
if !shown && child_open && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
|
shown = true;
|
|
}
|
|
}
|
|
ui.data_mut(|d| d.insert_temp(open_id, shown));
|
|
}
|
|
|
|
/// The icon a collapsed group's button shows.
|
|
fn group_icon(title: &str) -> &'static str {
|
|
match title {
|
|
"Clipboard" => "paste",
|
|
"Font" => "bold",
|
|
"Paragraph" => "alignLeft",
|
|
"Styles" => "styles",
|
|
"Editing" => "find",
|
|
"Voice" => "dictate",
|
|
"Tables" => "table",
|
|
"Links" => "link",
|
|
_ => "more",
|
|
}
|
|
}
|
|
|
|
/// The icon a command's button shows outside its own ribbon group (custom groups, the Quick
|
|
/// Access Toolbar): the one its ribbon button uses, which is usually named after the id's last
|
|
/// part. Names the icon set doesn't have draw a lettered tile ([`icons::paint`]).
|
|
pub fn command_icon(id: &str) -> &str {
|
|
const ICONS: &[(&str, &str)] = &[
|
|
("arrange.wrap", "wrapText"),
|
|
("draw.highlighter", "highlight"),
|
|
("format.border", "charborder"),
|
|
("format.color", "fontcolor"),
|
|
("format.growFont", "grow"),
|
|
("format.shrinkFont", "shrink"),
|
|
("format.strikethrough", "strike"),
|
|
("format.highlight", "highlight"),
|
|
("insert.signatureLine", "signature"),
|
|
("mailings.envelopes", "envelope"),
|
|
("mailings.insertField", "mergeField"),
|
|
("para.ltr", "textLtr"),
|
|
("para.rtl", "textRtl"),
|
|
("para.bullets", "bullets"),
|
|
("para.numbering", "numbering"),
|
|
("table.insertRowAbove", "insertAbove"),
|
|
("view.commentsPane", "showComments"),
|
|
("view.marks", "pilcrow"),
|
|
("edit.repeat", "redo"),
|
|
("file.new", "blankPage"),
|
|
("tools.customizeRibbon", "more"),
|
|
];
|
|
ICONS.iter().find(|(c, _)| *c == id).map_or_else(|| id.rsplit('.').next().unwrap_or(id), |(_, icon)| *icon)
|
|
}
|
|
|
|
/// Right-click on a command's button: put it on (or take it off) the Quick Access Toolbar, or
|
|
/// open Customize Ribbon (#379).
|
|
pub fn command_menu(resp: &Response, app: &mut WordApp, id: &str) {
|
|
crate::rtl::context_menu(resp, |ui| {
|
|
let known = app.session.registry.get(id).is_some();
|
|
let on_qat = app.session.ribbon.qat().contains(&id);
|
|
if known && !on_qat && ui.button(tl!("Add to Quick Access Toolbar")).clicked() {
|
|
let _ = app.run("tools.customizeRibbon", serde_json::json!({"qatAdd": id}));
|
|
ui.close();
|
|
}
|
|
if on_qat && ui.button(tl!("Remove from Quick Access Toolbar")).clicked() {
|
|
let _ = app.run("tools.customizeRibbon", serde_json::json!({"qatRemove": id}));
|
|
ui.close();
|
|
}
|
|
if known {
|
|
ui.separator();
|
|
}
|
|
customize_menu_items(ui, app);
|
|
});
|
|
}
|
|
|
|
/// "Customize the Ribbon…" and "Customize Quick Access Toolbar…" (context menus), and "Show
|
|
/// Quick Access Toolbar" while the toolbar is hidden (#1945).
|
|
pub fn customize_menu_items(ui: &mut Ui, app: &mut WordApp) {
|
|
if !app.ui.quick_access_toolbar && ui.button(tl!("Show Quick Access Toolbar")).clicked() {
|
|
let _ = app.run("ui.quickAccessToolbar", serde_json::json!({"value": true}));
|
|
ui.close();
|
|
}
|
|
if ui.button(tl!("Customize the Ribbon…")).clicked() {
|
|
crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::Ribbon);
|
|
ui.close();
|
|
}
|
|
if ui.button(tl!("Customize Quick Access Toolbar…")).clicked() {
|
|
crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::QuickAccess);
|
|
ui.close();
|
|
}
|
|
}
|
|
|
|
/// A row of small buttons (used inside a vertical stack).
|
|
pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
|
ui.horizontal(|ui| {
|
|
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
|
add(ui);
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A right-to-left row's cursor reaches to negative infinity on the left, so the width of a
|
|
/// group in it comes from the right edge: finite and positive, as the ribbon planner needs.
|
|
#[test]
|
|
fn a_group_in_a_right_to_left_row_has_a_finite_width() {
|
|
let before = Rect::from_min_max(pos2(f32::NEG_INFINITY, 0.0), pos2(900.0, 90.0));
|
|
let after = Rect::from_min_max(pos2(f32::NEG_INFINITY, 0.0), pos2(740.0, 90.0));
|
|
assert_eq!(group_width(true, before, after), 160.0);
|
|
assert!(!(before.min.x - after.min.x).is_finite(), "the left edge cannot measure it");
|
|
let before = Rect::from_min_max(pos2(20.0, 0.0), pos2(f32::INFINITY, 90.0));
|
|
let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0));
|
|
assert_eq!(group_width(false, before, after), 160.0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_group_in_a_right_to_left_row_has_a_finite_hover_area() {
|
|
let start = pos2(f32::NEG_INFINITY, 10.0);
|
|
let content = Rect::from_min_max(pos2(600.0, 10.0), pos2(740.0, 80.0));
|
|
let area = group_area(true, start, content);
|
|
assert!(area.min.x.is_finite() && area.max.x.is_finite());
|
|
assert_eq!((area.min.x, area.max.x), (600.0, 740.0));
|
|
let next = group_area(true, start, Rect::from_min_max(pos2(450.0, 10.0), pos2(590.0, 80.0)));
|
|
assert!(!area.intersects(next), "neighbouring groups do not overlap");
|
|
}
|
|
}
|