fractal/components/
scale_revealer.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gtk::{gdk, glib, glib::closure_local, graphene};
3use tracing::warn;
4
5/// The duration of the animation, in ms.
6const ANIMATION_DURATION: u32 = 250;
7
8mod imp {
9    use std::{
10        cell::{Cell, OnceCell, RefCell},
11        sync::LazyLock,
12    };
13
14    use glib::{clone, subclass::Signal};
15
16    use super::*;
17
18    #[derive(Debug, Default, glib::Properties)]
19    #[properties(wrapper_type = super::ScaleRevealer)]
20    pub struct ScaleRevealer {
21        /// Whether the child is revealed.
22        #[property(get, set = Self::set_reveal_child, explicit_notify)]
23        reveal_child: Cell<bool>,
24        /// The source widget this revealer is transitioning from.
25        #[property(get, set = Self::set_source_widget, explicit_notify, nullable)]
26        source_widget: glib::WeakRef<gtk::Widget>,
27        /// A snapshot of the source widget as a `GdkTexture`.
28        source_widget_texture: RefCell<Option<gdk::Texture>>,
29        /// The API to keep track of the animation.
30        animation: OnceCell<adw::TimedAnimation>,
31    }
32
33    #[glib::object_subclass]
34    impl ObjectSubclass for ScaleRevealer {
35        const NAME: &'static str = "ScaleRevealer";
36        type Type = super::ScaleRevealer;
37        type ParentType = adw::Bin;
38    }
39
40    #[glib::derived_properties]
41    impl ObjectImpl for ScaleRevealer {
42        fn signals() -> &'static [Signal] {
43            static SIGNALS: LazyLock<Vec<Signal>> =
44                LazyLock::new(|| vec![Signal::builder("transition-done").build()]);
45            SIGNALS.as_ref()
46        }
47
48        fn constructed(&self) {
49            self.parent_constructed();
50
51            // Initialize the animation.
52            let obj = self.obj();
53            let target = adw::CallbackAnimationTarget::new(clone!(
54                #[weak]
55                obj,
56                move |_| {
57                    // Redraw the widget.
58                    obj.queue_draw();
59                }
60            ));
61            let animation = adw::TimedAnimation::new(&*obj, 0.0, 1.0, ANIMATION_DURATION, target);
62
63            animation.set_easing(adw::Easing::EaseOutQuart);
64            animation.connect_done(clone!(
65                #[weak(rename_to =imp)]
66                self,
67                move |_| {
68                    let obj = imp.obj();
69
70                    if !imp.reveal_child.get() {
71                        if let Some(source_widget) = imp.source_widget.upgrade() {
72                            // Show the original source widget now that the
73                            // transition is over.
74                            source_widget.set_opacity(1.0);
75                        }
76                        obj.set_visible(false);
77                    }
78
79                    obj.emit_by_name::<()>("transition-done", &[]);
80                }
81            ));
82
83            self.animation
84                .set(animation)
85                .expect("animation is uninitialized");
86            obj.set_visible(false);
87        }
88    }
89
90    impl WidgetImpl for ScaleRevealer {
91        fn snapshot(&self, snapshot: &gtk::Snapshot) {
92            let obj = self.obj();
93            let Some(child) = obj.child() else {
94                return;
95            };
96
97            let progress = self.animation().value();
98            if (progress - 1.0).abs() < 0.0001 {
99                // The transition progress is at 100%, so just show the child.
100                obj.snapshot_child(&child, snapshot);
101                return;
102            }
103
104            let source_bounds = self
105                    .source_widget
106                    .upgrade()
107                    .and_then(|s| s.compute_bounds(&*obj))
108                    .unwrap_or_else(|| {
109                        warn!(
110                            "The source widget bounds could not be calculated, using default bounds as fallback"
111                        );
112                        graphene::Rect::new(0.0, 0.0, 100.0, 100.0)
113                    });
114            let rev_progress = (1.0 - progress).abs();
115
116            let x_scale = source_bounds.width() / obj.width() as f32;
117            let y_scale = source_bounds.height() / obj.height() as f32;
118
119            let x_scale = 1.0 + (x_scale - 1.0) * rev_progress as f32;
120            let y_scale = 1.0 + (y_scale - 1.0) * rev_progress as f32;
121
122            let x = source_bounds.x() * rev_progress as f32;
123            let y = source_bounds.y() * rev_progress as f32;
124
125            snapshot.translate(&graphene::Point::new(x, y));
126            snapshot.scale(x_scale, y_scale);
127
128            let borrowed_source_widget_texture = self.source_widget_texture.borrow();
129            let Some(source_widget_texture) = borrowed_source_widget_texture.as_ref() else {
130                warn!(
131                    "Revealer animation failed: no source widget texture, using child snapshot as fallback"
132                );
133                obj.snapshot_child(&child, snapshot);
134                return;
135            };
136
137            if progress > 0.0 {
138                // We are in the middle of the transition, so do the cross fade transition.
139                snapshot.push_cross_fade(progress);
140
141                source_widget_texture.snapshot(snapshot, obj.width().into(), obj.height().into());
142                snapshot.pop();
143
144                obj.snapshot_child(&child, snapshot);
145                snapshot.pop();
146            } else if progress <= 0.0 {
147                // We are at the beginning of the transition, show the source widget.
148                source_widget_texture.snapshot(snapshot, obj.width().into(), obj.height().into());
149            }
150        }
151    }
152
153    impl BinImpl for ScaleRevealer {}
154
155    impl ScaleRevealer {
156        /// The API to keep track of the animation.
157        fn animation(&self) -> &adw::TimedAnimation {
158            self.animation.get().expect("animation is initialized")
159        }
160
161        /// Set whether the child should be revealed or not.
162        ///
163        /// This will start the scale animation.
164        fn set_reveal_child(&self, reveal_child: bool) {
165            if self.reveal_child.get() == reveal_child {
166                return;
167            }
168            let obj = self.obj();
169
170            let animation = self.animation();
171            animation.set_value_from(animation.value());
172
173            if reveal_child {
174                animation.set_value_to(1.0);
175                obj.set_visible(true);
176
177                if let Some(source_widget) = self.source_widget.upgrade() {
178                    // Render the current state of the source widget to a texture.
179                    // This will be needed for the transition.
180                    let texture = render_widget_to_texture(&source_widget);
181                    self.source_widget_texture.replace(texture);
182
183                    // Hide the source widget.
184                    // We use opacity here so that the widget will stay allocated.
185                    source_widget.set_opacity(0.0);
186                } else {
187                    self.source_widget_texture.replace(None);
188                }
189            } else {
190                animation.set_value_to(0.0);
191            }
192
193            self.reveal_child.set(reveal_child);
194
195            animation.play();
196
197            obj.notify_reveal_child();
198        }
199
200        /// Set the source widget this revealer should transition from to show
201        /// the child.
202        fn set_source_widget(&self, source_widget: Option<&gtk::Widget>) {
203            if self.source_widget.upgrade().as_ref() == source_widget {
204                return;
205            }
206
207            self.source_widget.set(source_widget);
208            self.obj().notify_source_widget();
209        }
210    }
211}
212
213glib::wrapper! {
214    /// A widget to reveal a child with a scaling animation.
215    pub struct ScaleRevealer(ObjectSubclass<imp::ScaleRevealer>)
216        @extends gtk::Widget, adw::Bin,
217        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
218}
219
220impl ScaleRevealer {
221    pub fn new() -> Self {
222        glib::Object::new()
223    }
224
225    /// Connect to the signal emitted when the transition is done.
226    pub fn connect_transition_done<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
227        self.connect_closure(
228            "transition-done",
229            true,
230            closure_local!(move |obj: Self| {
231                f(&obj);
232            }),
233        )
234    }
235}
236
237impl Default for ScaleRevealer {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243/// Render the given widget to a `GdkTexture`.
244fn render_widget_to_texture(widget: &impl IsA<gtk::Widget>) -> Option<gdk::Texture> {
245    let widget_paintable = gtk::WidgetPaintable::new(Some(widget));
246    let snapshot = gtk::Snapshot::new();
247
248    widget_paintable.snapshot(
249        &snapshot,
250        widget_paintable.intrinsic_width().into(),
251        widget_paintable.intrinsic_height().into(),
252    );
253
254    let node = snapshot.to_node()?;
255    let native = widget.native()?;
256
257    Some(native.renderer()?.render_texture(node, None))
258}