Compare commits

..
1 Commits
Author SHA1 Message Date
Martin Ballandfluorine d1179f138a Ribbon: a contextual tab that appears glides in so the eye finds it (#3131)
Lands #3131 by @fluorine on current main. Only the docs (ROADMAP.md, docs/*.md) were re-resolved; the code is the PR's own.

Co-Authored-By: fluorine <1832604+fluorine@users.noreply.github.com>
2026-11-06 08:03:01 +00:00
2 changed files with 143 additions and 14 deletions
+51 -14
View File
@@ -70,7 +70,7 @@ use shape::shape_format;
use table::{table_design, table_layout};
#[cfg(test)]
use tabs::MIN_TAB_W;
use tabs::{MORE_TABS_W, reading_toolbar};
use tabs::{MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, tab_entry};
pub use tabs::{
StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected,
in_table_public, plan_strip, resolve_tab,
@@ -130,6 +130,25 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.ui.tab = next.to_string();
}
}
let (now, animate) = (ui.input(|i| i.time), app.animations_on());
let leaving = {
let user_names: Vec<&str> = user_tabs.iter().filter(|e| e.custom).map(|e| e.name.as_str()).collect();
let contextual: Vec<(&str, usize)> = tabs
.iter()
.enumerate()
.map(|(i, (n, _))| (n.as_str(), i))
.filter(|(n, _)| wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(n) && !user_names.contains(n))
.collect();
note_contextual_tabs(ui.ctx(), &contextual, now, animate)
};
// A tab that just left stays in its former slot while it slides and fades away.
let ghosts: Vec<(String, usize, f64)> = leaving.iter().filter(|(n, ..)| !tabs.iter().any(|(t, _)| t == n)).cloned().collect();
if !ghosts.is_empty() {
ui.ctx().request_repaint();
}
for (name, at, _) in &ghosts {
tabs.insert((*at).min(tabs.len()), (name.clone(), false));
}
// Tab strip.
egui::Panel::top("tabs")
.exact_size(30.0)
@@ -236,26 +255,42 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
};
ui.with_layout(tabs_layout, |ui| {
// The tabs that don't fit go into a » menu at the end of the strip (#897, #2026).
for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) {
for (i, ((tab, custom), _)) in tabs
.iter()
.zip(&plan.fits)
.enumerate()
.filter(|(_, ((tab, _), fits))| **fits || ghosts.iter().any(|(n, ..)| n == tab))
{
let (tab, custom) = (tab.as_str(), *custom);
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab);
// A custom tab's name is the user's own text: shown as typed.
let shown = if custom { tab } else { tl!(tab) };
let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0;
let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
if app.ui.keytips == crate::keytips::Phase::Tabs {
let ghost = ghosts.iter().find(|(n, ..)| n == tab);
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), if ghost.is_some() { Sense::hover() } else { Sense::click() });
if app.ui.keytips == crate::keytips::Phase::Tabs && ghost.is_none() {
crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects);
} else if app.ui.keytips == crate::keytips::Phase::Legacy {
} else if app.ui.keytips == crate::keytips::Phase::Legacy && ghost.is_none() {
// Every tab: they only place the legacy keys' hint (#1697).
crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects);
}
let active = app.ui.tab == tab && !app.ui.backstage;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
if resp.hovered() && !active {
if resp.hovered() && !active && ghost.is_none() {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
}
let color = if contextual || active { t.accent_text } else { t.text };
// Entering and departing contextual tabs move and fade without changing layout.
let entry = match ghost {
Some((_, _, left)) => {
ui.ctx().request_repaint();
1.0 - ease_out_cubic(((now - left) / TAB_ENTRY_SECS) as f32)
}
None if contextual => tab_entry(ui.ctx(), tab, now, animate),
None => 1.0,
};
let shift = vec2(-(1.0 - entry) * TAB_ENTRY_SLIDE, 0.0);
let color = (if contextual || active { t.accent_text } else { t.text }).gamma_multiply(entry);
if cut {
let mut job = egui::text::LayoutJob::single_section(
shown.to_string(),
@@ -268,20 +303,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
overflow_character: Some('…'),
};
let galley = ui.painter().layout_job(job);
ui.painter().galley(r.center() - galley.size() / 2.0, galley, color);
ui.painter().galley(r.center() + shift - galley.size() / 2.0, galley, color);
} else {
ui.painter().text(
r.center(),
r.center() + shift,
Align2::CENTER_CENTER,
shown,
if active { semibold(12.5) } else { medium(12.5) },
color,
);
}
let resp = if cut { resp.on_hover_text(shown) } else { resp };
let resp = if cut && ghost.is_none() { resp.on_hover_text(shown) } else { resp };
if active {
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
ui.painter().rect_filled(u, 2.0, t.accent);
let u = Rect::from_center_size(pos2(r.center().x + shift.x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
ui.painter().rect_filled(u, 2.0, t.accent.gamma_multiply(entry));
}
if resp.clicked() {
let _ = app.run("ui.tab", json!({"tab": tab}));
@@ -289,13 +324,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if resp.double_clicked() && tab != "File" {
let _ = app.run("ui.collapseRibbon", json!({}));
}
resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
if ghost.is_none() {
resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app));
}
}
let left_out: Vec<(&str, &str)> = tabs
.iter()
.zip(&labels)
.zip(&plan.fits)
.filter(|(_, fits)| !**fits)
.filter(|(((tab, _), _), fits)| !**fits && !ghosts.iter().any(|(n, ..)| n == tab))
.map(|(((tab, _), shown), _)| (tab.as_str(), *shown))
.collect();
if !left_out.is_empty() {
+92
View File
@@ -148,6 +148,58 @@ pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str {
/// Width of the » button that holds the tabs the strip has no room for.
pub(super) const MORE_TABS_W: f32 = 24.0;
/// How long a contextual tab takes to settle into place after it appears, seconds.
pub(super) const TAB_ENTRY_SECS: f64 = 0.35;
/// How far, in points, a new contextual tab starts to the left of its place.
pub(super) const TAB_ENTRY_SLIDE: f32 = 18.0;
/// Fast at first, then slowing to rest: 0 → 1.
pub(super) fn ease_out_cubic(p: f32) -> f32 {
1.0 - (1.0 - p.clamp(0.0, 1.0)).powi(3)
}
/// Track contextual tabs between frames and return those still animating out.
pub(super) fn note_contextual_tabs(ctx: &egui::Context, shown: &[(&str, usize)], now: f64, animate: bool) -> Vec<(String, usize, f64)> {
let (shown_id, leaving_id) = (egui::Id::unique("contextual_tabs_shown"), egui::Id::unique("contextual_tabs_leaving"));
let prev: Option<Vec<(String, usize)>> = ctx.data(|d| d.get_temp(shown_id));
let mut leaving: Vec<(String, usize, f64)> = ctx.data(|d| d.get_temp(leaving_id)).unwrap_or_default();
leaving.retain(|(n, _, start)| animate && now - start < TAB_ENTRY_SECS && !shown.iter().any(|(s, _)| s == n));
// Count retained ghosts when storing positions, so exits from different frames share one layout.
let shown_with_ghosts: Vec<(&str, usize)> = shown
.iter()
.map(|(tab, at)| {
let before = leaving.iter().filter(|(_, ghost_at, _)| ghost_at <= at).count();
(*tab, at + before)
})
.collect();
if let Some(prev) = prev {
for (tab, _) in shown_with_ghosts.iter().filter(|(t, _)| !prev.iter().any(|(p, _)| p == t)) {
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("contextual_tab_entry", *tab)), now));
}
for (tab, at) in prev.iter().filter(|(p, _)| animate && !shown_with_ghosts.iter().any(|(s, _)| s == p)) {
if !leaving.iter().any(|(n, _, _)| n == tab) {
leaving.push((tab.clone(), *at, now));
}
}
}
ctx.data_mut(|d| {
d.insert_temp(shown_id, shown_with_ghosts.iter().map(|(t, at)| (t.to_string(), *at)).collect::<Vec<_>>());
d.insert_temp(leaving_id, leaving.clone());
});
leaving
}
/// Return a newly shown contextual tab's progress, requesting frames until it settles.
pub(super) fn tab_entry(ctx: &egui::Context, tab: &str, now: f64, animate: bool) -> f32 {
let start: Option<f64> = ctx.data(|d| d.get_temp(egui::Id::unique(("contextual_tab_entry", tab))));
let Some(start) = start.filter(|_| animate) else { return 1.0 };
let p = ((now - start) / TAB_ENTRY_SECS).clamp(0.0, 1.0) as f32;
if p < 1.0 {
ctx.request_repaint();
}
ease_out_cubic(p)
}
/// 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).
@@ -255,3 +307,43 @@ pub fn plan_strip(tabs: &[TabSpec], buttons: [f32; 3], avail: f32, more: f32) ->
pub fn in_table_public(app: &WordApp) -> bool {
app.session.sel.focus.path.cell().is_some()
}
#[cfg(test)]
mod transition_tests {
use super::*;
/// Contextual tabs absent on the first frame do not animate; a newly shown one eases to rest.
#[test]
fn a_new_contextual_tab_eases_in() {
let ctx = egui::Context::default();
note_contextual_tabs(&ctx, &[("Table Design", 1)], 1.0, true);
assert_eq!(tab_entry(&ctx, "Table Design", 1.0, true), 1.0, "tabs already there do not slide");
note_contextual_tabs(&ctx, &[("Table Design", 1), ("Picture Format", 2)], 2.0, true);
let at = |secs: f64| tab_entry(&ctx, "Picture Format", 2.0 + secs, true);
assert_eq!(at(0.0), 0.0);
let (a, b, c) = (at(0.1), at(0.2), at(TAB_ENTRY_SECS));
assert!(0.0 < a && a < b && b < c && c == 1.0, "{a} {b} {c}");
assert!(a > 0.1 / TAB_ENTRY_SECS as f32, "faster than linear at the start: {a}");
assert!(c - b < b - a, "slower at the end: {a} {b} {c}");
assert_eq!(tab_entry(&ctx, "Picture Format", 2.0, false), 1.0, "animations off");
note_contextual_tabs(&ctx, &[("Table Design", 1), ("Picture Format", 2)], 3.0, true);
assert_eq!(tab_entry(&ctx, "Picture Format", 3.0, true), 1.0, "staying shown does not restart it");
}
/// A departing contextual tab remains for one exit and stops when motion is disabled.
#[test]
fn a_contextual_tab_that_goes_away_leaves_gently() {
let ctx = egui::Context::default();
let both = [("Table Design", 1), ("Picture Format", 2)];
assert!(note_contextual_tabs(&ctx, &both, 1.0, true).is_empty());
let leaving = note_contextual_tabs(&ctx, &both[..1], 2.0, true);
assert_eq!(leaving, vec![("Picture Format".to_string(), 2, 2.0)]);
assert_eq!(note_contextual_tabs(&ctx, &both[..1], 2.2, true).len(), 1, "still leaving");
assert!(note_contextual_tabs(&ctx, &both[..1], 2.0 + TAB_ENTRY_SECS + 0.01, true).is_empty(), "gone");
assert!(note_contextual_tabs(&ctx, &both, 4.0, true).is_empty());
assert!(note_contextual_tabs(&ctx, &both[..1], 5.0, false).is_empty(), "animations off");
note_contextual_tabs(&ctx, &both, 6.0, true);
note_contextual_tabs(&ctx, &both[..1], 7.0, true);
assert!(note_contextual_tabs(&ctx, &both, 7.1, true).is_empty(), "back again");
}
}