mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
feat(kiosk): per-cell morph animation on layout swap
When the active layout switches, cells that exist in both old + new (same camera, same URL, same HTML) now slide + scale from their old screen position to the new one over 350ms (ease-out cubic). Fresh cells fade in; removed cells fade out where they were. Implementation: - Each cell widget gets a stable widget_name (cam:<id>:<selector>, web:<url>, html:<hash>) so old/new can be matched. - Before swap, capture each cell's bounds + a WidgetPaintable snapshot. - New grid wrapped in an Overlay; a Fixed ghost layer hosts the animated Picture widgets driven by add_tick_callback + ease-out cubic. - Once the window finishes the animation timer, the overlay is unwrapped back to a plain grid so subsequent renders don't accumulate layers.
This commit is contained in:
parent
70ecdd1b03
commit
6bad53da37
1 changed files with 217 additions and 1 deletions
218
kiosk/src/ui.rs
218
kiosk/src/ui.rs
|
|
@ -687,6 +687,14 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
}
|
||||
|
||||
for cell in &layout.cells {
|
||||
let cell_key: Option<String> = match cell.content_type.as_str() {
|
||||
"camera" => cell.camera_id.map(|id| {
|
||||
format!("cam:{id}:{}", cell.stream_selector.as_deref().unwrap_or("auto"))
|
||||
}),
|
||||
"web" => cell.web_url.as_deref().map(|u| format!("web:{}", u.trim())),
|
||||
"html" => cell.html_content.as_deref().filter(|h| !h.trim().is_empty()).map(html_key),
|
||||
_ => None,
|
||||
};
|
||||
let widget: gtk::Widget = match cell.content_type.as_str() {
|
||||
"camera" => {
|
||||
if let Some(cam_id) = cell.camera_id {
|
||||
|
|
@ -747,6 +755,12 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
_ => placeholder(Some("Unknown content")),
|
||||
};
|
||||
|
||||
// Tag the cell widget with a stable key for the layout-swap animation
|
||||
// (animate_layout_swap matches by widget_name across old + new grids).
|
||||
if let Some(k) = &cell_key {
|
||||
widget.set_widget_name(k);
|
||||
}
|
||||
|
||||
grid.attach(
|
||||
&widget,
|
||||
cell.col as i32,
|
||||
|
|
@ -758,11 +772,213 @@ fn render_layout(display_id: u32, layout_id: u32) {
|
|||
|
||||
DISPLAYS.with(|ds| {
|
||||
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
|
||||
st.window.set_child(Some(&grid));
|
||||
animate_layout_swap(&st.window, &grid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Swap the window's content to `new_grid` with a per-cell morph animation.
|
||||
///
|
||||
/// Matches cells by widget_name across old + new grids. Same-key cells slide +
|
||||
/// scale from their old screen position to the new one over 300ms (ease-out
|
||||
/// cubic). New cells fade in; removed cells fade out from their old spot.
|
||||
/// Cells with no widget_name (e.g. placeholders) just snap.
|
||||
const LAYOUT_ANIM_MS: u32 = 350;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CellSnap {
|
||||
paintable: gtk::gdk::Paintable,
|
||||
bounds: gtk::graphene::Rect,
|
||||
}
|
||||
|
||||
fn animate_layout_swap(window: &ApplicationWindow, new_grid: >k::Grid) {
|
||||
// Capture old cell snapshots BEFORE we drop the existing window child.
|
||||
let mut snaps: std::collections::HashMap<String, CellSnap> = std::collections::HashMap::new();
|
||||
if let Some(old_child) = window.child() {
|
||||
let mut child = old_child.first_child();
|
||||
while let Some(c) = child {
|
||||
let key = c.widget_name();
|
||||
if !key.is_empty() {
|
||||
if let Some(b) = c.compute_bounds(&old_child) {
|
||||
let paintable: gtk::gdk::Paintable =
|
||||
gtk::WidgetPaintable::new(Some(&c)).upcast();
|
||||
snaps.insert(key.to_string(), CellSnap { paintable, bounds: b });
|
||||
}
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
// Always wrap content in an Overlay so the ghost layer can sit on top of
|
||||
// the new grid without disturbing GTK's main layout pass.
|
||||
let overlay = gtk::Overlay::new();
|
||||
overlay.set_vexpand(true);
|
||||
overlay.set_hexpand(true);
|
||||
overlay.set_child(Some(new_grid));
|
||||
let ghost = gtk::Fixed::new();
|
||||
ghost.set_can_target(false);
|
||||
overlay.add_overlay(&ghost);
|
||||
|
||||
window.set_child(Some(&overlay));
|
||||
|
||||
if snaps.is_empty() {
|
||||
// First render of this display — nothing to animate from. Skip the
|
||||
// ghost layer entirely on the next idle tick to keep the tree clean.
|
||||
let overlay_weak = overlay.downgrade();
|
||||
let new_grid_weak = new_grid.downgrade();
|
||||
let window_weak = window.downgrade();
|
||||
gtk::glib::idle_add_local_once(move || {
|
||||
// Swap back to plain grid as window child (drop the overlay).
|
||||
if let (Some(grid), Some(win), Some(ov)) =
|
||||
(new_grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade())
|
||||
{
|
||||
if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) {
|
||||
ov.set_child(None::<>k::Widget>);
|
||||
win.set_child(Some(&grid));
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer one idle tick so the new_grid has computed its allocations.
|
||||
let new_grid_clone = new_grid.clone();
|
||||
let ghost_clone = ghost.clone();
|
||||
let overlay_clone = overlay.clone();
|
||||
let window_clone = window.clone();
|
||||
gtk::glib::idle_add_local_once(move || {
|
||||
let mut pairs: Vec<(gtk::Widget, gtk::graphene::Rect, CellSnap)> = Vec::new();
|
||||
let mut fresh: Vec<gtk::Widget> = Vec::new();
|
||||
let mut child = new_grid_clone.first_child();
|
||||
while let Some(c) = child {
|
||||
let key = c.widget_name();
|
||||
let new_bounds = c.compute_bounds(&new_grid_clone)
|
||||
.unwrap_or_else(gtk::graphene::Rect::zero);
|
||||
if !key.is_empty() {
|
||||
if let Some(snap) = snaps.remove(key.as_str()) {
|
||||
pairs.push((c.clone(), new_bounds, snap));
|
||||
} else {
|
||||
fresh.push(c.clone());
|
||||
}
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
|
||||
// Anything left in `snaps` was removed by this swap — fade ghosts out
|
||||
// in place so the transition visibly drops them.
|
||||
for (_key, snap) in &snaps {
|
||||
let pic = gtk::Picture::for_paintable(&snap.paintable);
|
||||
pic.set_can_target(false);
|
||||
pic.set_size_request(snap.bounds.width() as i32, snap.bounds.height() as i32);
|
||||
ghost_clone.put(&pic, snap.bounds.x() as f64, snap.bounds.y() as f64);
|
||||
fade_out_and_drop(&pic, &ghost_clone);
|
||||
}
|
||||
|
||||
// Matched cells: hide real widget, animate ghost from old bounds → new.
|
||||
for (target, new_bounds, snap) in pairs {
|
||||
target.set_opacity(0.0);
|
||||
let pic = gtk::Picture::for_paintable(&snap.paintable);
|
||||
pic.set_can_target(false);
|
||||
pic.set_size_request(snap.bounds.width() as i32, snap.bounds.height() as i32);
|
||||
ghost_clone.put(&pic, snap.bounds.x() as f64, snap.bounds.y() as f64);
|
||||
animate_picture_to_bounds(&pic, &target, &ghost_clone, snap.bounds, new_bounds);
|
||||
}
|
||||
|
||||
// Fresh cells (no match in old layout): fade in.
|
||||
for c in fresh {
|
||||
c.set_opacity(0.0);
|
||||
fade_in(&c);
|
||||
}
|
||||
|
||||
// After animation window, drop the overlay so we return to plain grid.
|
||||
let overlay_weak = overlay_clone.downgrade();
|
||||
let grid_weak = new_grid_clone.downgrade();
|
||||
let window_weak = window_clone.downgrade();
|
||||
gtk::glib::timeout_add_local_once(
|
||||
Duration::from_millis((LAYOUT_ANIM_MS + 50) as u64),
|
||||
move || {
|
||||
if let (Some(grid), Some(win), Some(ov)) =
|
||||
(grid_weak.upgrade(), window_weak.upgrade(), overlay_weak.upgrade())
|
||||
{
|
||||
if grid.parent().as_ref() == Some(ov.upcast_ref::<gtk::Widget>()) {
|
||||
ov.set_child(None::<>k::Widget>);
|
||||
win.set_child(Some(&grid));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn ease_out_cubic(t: f64) -> f64 {
|
||||
let inv = 1.0 - t.clamp(0.0, 1.0);
|
||||
1.0 - inv * inv * inv
|
||||
}
|
||||
|
||||
fn animate_picture_to_bounds(
|
||||
pic: >k::Picture,
|
||||
target: >k::Widget,
|
||||
fixed: >k::Fixed,
|
||||
from: gtk::graphene::Rect,
|
||||
to: gtk::graphene::Rect,
|
||||
) {
|
||||
let start = Instant::now();
|
||||
let pic_weak = pic.downgrade();
|
||||
let fixed_weak = fixed.downgrade();
|
||||
let target_weak = target.downgrade();
|
||||
pic.add_tick_callback(move |_, _| {
|
||||
let Some(pic) = pic_weak.upgrade() else { return gtk::glib::ControlFlow::Break; };
|
||||
let elapsed = start.elapsed().as_millis() as f64;
|
||||
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
|
||||
let e = ease_out_cubic(t);
|
||||
let x = from.x() as f64 + (to.x() - from.x()) as f64 * e;
|
||||
let y = from.y() as f64 + (to.y() - from.y()) as f64 * e;
|
||||
let w = from.width() as f64 + (to.width() - from.width()) as f64 * e;
|
||||
let h = from.height() as f64 + (to.height() - from.height()) as f64 * e;
|
||||
if let Some(fixed) = fixed_weak.upgrade() {
|
||||
fixed.move_(&pic, x, y);
|
||||
}
|
||||
pic.set_size_request(w as i32, h as i32);
|
||||
if t >= 1.0 {
|
||||
if let Some(target) = target_weak.upgrade() {
|
||||
target.set_opacity(1.0);
|
||||
}
|
||||
pic.unparent();
|
||||
return gtk::glib::ControlFlow::Break;
|
||||
}
|
||||
gtk::glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
fn fade_in(widget: >k::Widget) {
|
||||
let start = Instant::now();
|
||||
let weak = widget.downgrade();
|
||||
widget.add_tick_callback(move |_, _| {
|
||||
let Some(w) = weak.upgrade() else { return gtk::glib::ControlFlow::Break; };
|
||||
let elapsed = start.elapsed().as_millis() as f64;
|
||||
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
|
||||
w.set_opacity(t);
|
||||
if t >= 1.0 { gtk::glib::ControlFlow::Break } else { gtk::glib::ControlFlow::Continue }
|
||||
});
|
||||
}
|
||||
|
||||
fn fade_out_and_drop(pic: >k::Picture, fixed: >k::Fixed) {
|
||||
let start = Instant::now();
|
||||
let pic_weak = pic.downgrade();
|
||||
let fixed_weak = fixed.downgrade();
|
||||
pic.add_tick_callback(move |_, _| {
|
||||
let Some(p) = pic_weak.upgrade() else { return gtk::glib::ControlFlow::Break; };
|
||||
let elapsed = start.elapsed().as_millis() as f64;
|
||||
let t = (elapsed / LAYOUT_ANIM_MS as f64).min(1.0);
|
||||
p.set_opacity(1.0 - t);
|
||||
if t >= 1.0 {
|
||||
if let Some(_f) = fixed_weak.upgrade() { p.unparent(); }
|
||||
return gtk::glib::ControlFlow::Break;
|
||||
}
|
||||
gtk::glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
/// Default cooling timeout when a layout doesn't specify one (or specifies 0).
|
||||
const DEFAULT_COOLING_SECS: u32 = 30;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue