graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the dialog turns in and out of the menu it was opened from, animated
A dialog turned to from a menu row grows out of that menu's plate, and a
mode turned to while it is up slides its rows in from the side it came
from (dialog::DialogTurn, cce-ui's TURN_MS). The render arm replays the
content moved and clipped, its text faded and bounded by the plate as
drawn, which the dialog also claims as its occluder meanwhile. A swipe back
shrinks the menu out of the dialog's size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 13 ++++++++++++
src/dialog.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++----
src/menu_page.rs | 8 ++++++--
src/render.rs | 40 ++++++++++++++++++++++++++++++++++--
4 files changed, 115 insertions(+), 8 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ab5cd2c..8b970c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2674,6 +2674,19 @@ Markers and Attribute Visualizers, a visualizer and Add Visualizer.
the palette still up, so it is on the trail. Opened afresh, the dialog
has neither. A swipe back with neither does nothing.
+- **Every turn is animated** (cce-ui's `TURN_MS`, 180 ms): a page of rows
+ by cce-ui itself; the dialog by `dialog::DialogTurn` the same way — the
+ plate grows from the menu just put down (`open_dialog_from` reads its
+ size off the hidden context menu), and a mode turned to while it is up
+ (Group Markers, a visualizer and back) slides its rows in from the side
+ it came from at the plate's own size. The render arm paints a turning
+ dialog's content aside and replays it moved and clipped, its text faded
+ and bounded by the plate as drawn, which is also the occluder the dialog
+ claims meanwhile (`Dialog::drawn_rect` in `popover`), so the clamp still
+ lets the labels through. A swipe back from the dialog shrinks the menu
+ out of the dialog's size (`context_menu::turn_from_size`). Checked in a
+ shadow session under `CCE_UI_TURN_MS=2000`.
+
`the_viewport_menu_turns_into_its_pages_and_back` drives the viewport
menu's pages, the back band and both swipes, into the dialog and back.
diff --git a/src/dialog.rs b/src/dialog.rs
index 7eb40d4..b82574b 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -257,8 +257,23 @@ pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
(list_rect(Rect { x, y, width: w, height: h }).height / ROW_H).floor().max(0.0) as usize
}
+/// The dialog turning into what it now shows — from the menu it replaced,
+/// or from the list it was in another mode — animated as the context
+/// menu's page turns are (`context_menu::TURN_MS`): the plate on its way
+/// from `from` to its own rect, the rows sliding in and coming up.
+#[derive(Debug, Clone, Copy)]
+pub struct DialogTurn {
+ /// The plate it turned from, when that was another size: a menu.
+ pub from: Option<Rect>,
+ pub start: std::time::Instant,
+ /// +1 forward (the rows come in from the right), -1 back.
+ pub dir: f32,
+}
+
pub struct Dialog {
pub mode: Mode,
+ /// The turn being animated, if one is.
+ pub turning: Option<DialogTurn>,
/// Where the plate's top-left corner goes, when it opened in place of
/// something else (see [`layout_at`]); `None` centres it.
pub anchor: Option<(f32, f32)>,
@@ -372,6 +387,7 @@ impl Dialog {
slider_stamp.set_scroll(false);
let mut d = Adapted::new(Dialog {
mode: Mode::Commands,
+ turning: None,
anchor: None,
query: String::new(),
rows: Vec::new(),
@@ -524,6 +540,27 @@ impl Dialog {
Some(Rect { x: list.x, y: list.y + offset, width: list.width, height: ROW_H })
}
+ /// How far the turn in progress has gone, eased; `None` when none is.
+ pub fn turn_progress(&self) -> Option<f32> {
+ let t = self.turning.as_ref()?;
+ let raw = t.start.elapsed().as_secs_f32() * 1000.0 / cce_ui::widget::context_menu::turn_ms();
+ (raw < 1.0).then(|| cce_ui::widget::context_menu::turn_ease(raw))
+ }
+
+ /// The plate as it is drawn on `rect`: there, or on its way there from
+ /// the plate it turned from.
+ pub fn drawn_rect(&self, rect: Rect) -> Rect {
+ match (self.turn_progress(), self.turning.and_then(|t| t.from)) {
+ (Some(e), Some(f)) => Rect {
+ x: f.x + (rect.x - f.x) * e,
+ y: f.y + (rect.y - f.y) * e,
+ width: f.width + (rect.width - f.width) * e,
+ height: f.height + (rect.height - f.height) * e,
+ },
+ _ => rect,
+ }
+ }
+
/// The row under `(x, y)` in a dialog laid out on `rect`.
pub fn row_index_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
self.row_at(rect, x, y)
@@ -902,7 +939,9 @@ impl Paint for Dialog {
/// `State::dispatch_uncovered` lowers the flag for the length of a
/// dispatch into the dialog and puts it straight back.
fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
- self.occluding.then_some((rect.x, rect.y, rect.width, rect.height))
+ // While it turns, the plate as it is drawn: the labels are cut there.
+ let r = self.drawn_rect(rect);
+ self.occluding.then_some((r.x, r.y, r.width, r.height))
}
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
@@ -1209,7 +1248,8 @@ impl Input for Dialog {
// landed close settled back to a closed trigger.
let band = self.dropdown_trigger(rect).unwrap_or(rect);
let unfolding = self.dropdown.open && Input::tick(self.dropdown.inner_mut(), dt, band);
- moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored || unfolding
+ let turning = self.turn_progress().is_some();
+ moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored || unfolding || turning
}
/// The whole rect, always — this is what makes the dialog modal over what
@@ -1606,10 +1646,16 @@ impl State {
/// The dialog in `mode`, turned to from the menu `origin` by one of its
/// page rows: its top-left where the menu's was, and a swipe back shows
/// the menu again (see `crate::menu_page`).
+ ///
+ /// The plate grows out of the menu just put down: its size is what the
+ /// context menu still holds after the hide.
pub fn open_dialog_from(&mut self, mode: Mode, origin: crate::menu_page::MenuOrigin, at: (f32, f32)) {
+ use cce_ui::widget::context_menu;
+ let from = Rect { x: at.0, y: at.1, width: context_menu::w(), height: context_menu::h() };
self.close_dialog();
self.open_dialog_anchored(mode, Some(at));
self.dialog_from = Some(origin);
+ self.slots.dialog.turning = Some(DialogTurn { from: Some(from), start: std::time::Instant::now(), dir: 1.0 });
}
/// Turn the open dialog to `mode`, as the row or key that leads there
@@ -1720,13 +1766,21 @@ impl State {
// the trail's last, it is going back, and comes off. Opened afresh
// it has neither, and whoever opened it from a menu says so after.
let was = self.dialog_visible().then_some(self.slots.dialog.mode);
+ self.slots.dialog.turning = None;
match was {
Some(prev) if anchor.is_none() => {
- if self.dialog_trail.last() == Some(&mode) {
+ // The same plate turned to another list: the rows slide in,
+ // from the right going on, from the left coming back.
+ let dir = if self.dialog_trail.last() == Some(&mode) {
self.dialog_trail.pop();
+ Some(-1.0)
} else if prev != mode {
self.dialog_trail.push(prev);
- }
+ Some(1.0)
+ } else {
+ None
+ };
+ self.slots.dialog.turning = dir.map(|dir| DialogTurn { from: None, start: std::time::Instant::now(), dir });
}
_ => {
self.slots.dialog.anchor = anchor;
diff --git a/src/menu_page.rs b/src/menu_page.rs
index 2cc42f5..fd32600 100644
--- a/src/menu_page.rs
+++ b/src/menu_page.rs
@@ -251,10 +251,14 @@ impl State {
return true;
}
let Some(origin) = self.dialog_from else { return false };
- let (x, y, _, _) = self.positions[DIALOG_IDX];
+ // The menu turns back out of the plate as it is drawn: it shrinks
+ // from the dialog's size into its own.
+ let (x, y, w, h) = self.positions[DIALOG_IDX];
+ let drawn = self.slots.dialog.drawn_rect(cce_ui::scene::layout::Rect { x, y, width: w, height: h });
self.close_dialog();
self.close_open_menu();
- self.reopen_menu(origin, x, y);
+ self.reopen_menu(origin, drawn.x, drawn.y);
+ context_menu::turn_from_size(drawn.width, drawn.height, false);
true
}
}
diff --git a/src/render.rs b/src/render.rs
index 047b27c..e534f96 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -307,8 +307,44 @@ impl State {
// backdrop as the config says rather than folding that into
// opacity as the hosted menus must.
let (x, y, ww, h) = w.rect();
- cce_ui::widget::context_menu::paint_menu_plate(pc, rect(x, y, ww, h), false);
- w.paint_self(&self.ui_context, pc);
+ let full = rect(x, y, ww, h);
+ match (self.slots.dialog.turn_progress(), self.slots.dialog.turning) {
+ (Some(e), Some(turn)) => {
+ // Turning (see `dialog::DialogTurn`): the plate on its way
+ // from the one it replaced, and what it shows sliding in
+ // and coming up inside it. Everything is painted aside
+ // and replayed moved; the text is cut at the plate as it
+ // is drawn, which is the occluder the dialog claims
+ // meanwhile, so the clamp still lets it through.
+ let now = self.slots.dialog.drawn_rect(full);
+ cce_ui::widget::context_menu::paint_menu_plate(pc, now, false);
+ let mut scratch = PaintCtx::new();
+ w.paint_self(&self.ui_context, &mut scratch);
+ let dx = turn.dir * (1.0 - e) * cce_ui::widget::context_menu::TURN_SLIDE;
+ // Bounds are offset by the translate; these land on `now`.
+ let bounds = Some([now.x - dx, now.y, now.x + now.width - dx, now.y + now.height]);
+ let radius = cce_ui::layout::menu_corner_radius();
+ pc.clip_rounded(now, radius, |pc| {
+ pc.translate(dx, 0.0, |pc| {
+ for item in scratch.finish().items {
+ if let Some(c) = item.clip {
+ pc.push_clip(c);
+ }
+ if let Some(Prim::Text { text, x, y, font_size, color, alpha, font, .. }) = pc.replay(item.prim) {
+ pc.text_faded(text, x, y, font_size, color, alpha * e * e, font, bounds);
+ }
+ if item.clip.is_some() {
+ pc.pop_clip();
+ }
+ }
+ });
+ });
+ }
+ _ => {
+ cce_ui::widget::context_menu::paint_menu_plate(pc, full, false);
+ w.paint_self(&self.ui_context, pc);
+ }
+ }
} else if idx == PLAYBAR_IDX {
// Modern-paint pane: the plate from the legacy views like the other
// panes, then paint_self emits the transport controls — geometry AND