mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 02:09:46 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1179f138a |
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user