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!("Revealer animation failed: no source widget texture, using child snapshot as fallback");
131                obj.snapshot_child(&child, snapshot);
132                return;
133            };
134
135            if progress > 0.0 {
136                // We are in the middle of the transition, so do the cross fade transition.
137                snapshot.push_cross_fade(progress);
138
139                source_widget_texture.snapshot(snapshot, obj.width().into(), obj.height().into());
140                snapshot.pop();
141
142                obj.snapshot_child(&child, snapshot);
143                snapshot.pop();
144            } else if progress <= 0.0 {
145                // We are at the beginning of the transition, show the source widget.
146                source_widget_texture.snapshot(snapshot, obj.width().into(), obj.height().into());
147            }
148        }
149    }
150
151    impl BinImpl for ScaleRevealer {}
152
153    impl ScaleRevealer {
154        /// The API to keep track of the animation.
155        fn animation(&self) -> &adw::TimedAnimation {
156            self.animation.get().expect("animation is initialized")
157        }
158
159        /// Set whether the child should be revealed or not.
160        ///
161        /// This will start the scale animation.
162        fn set_reveal_child(&self, reveal_child: bool) {
163            if self.reveal_child.get() == reveal_child {
164                return;
165            }
166            let obj = self.obj();
167
168            let animation = self.animation();
169            animation.set_value_from(animation.value());
170
171            if reveal_child {
172                animation.set_value_to(1.0);
173                obj.set_visible(true);
174
175                if let Some(source_widget) = self.source_widget.upgrade() {
176                    // Render the current state of the source widget to a texture.
177                    // This will be needed for the transition.
178                    let texture = render_widget_to_texture(&source_widget);
179                    self.source_widget_texture.replace(texture);
180
181                    // Hide the source widget.
182                    // We use opacity here so that the widget will stay allocated.
183                    source_widget.set_opacity(0.0);
184                } else {
185                    self.source_widget_texture.replace(None);
186                }
187            } else {
188                animation.set_value_to(0.0);
189            }
190
191            self.reveal_child.set(reveal_child);
192
193            animation.play();
194
195            obj.notify_reveal_child();
196        }
197
198        /// Set the source widget this revealer should transition from to show
199        /// the child.
200        fn set_source_widget(&self, source_widget: Option<&gtk::Widget>) {
201            if self.source_widget.upgrade().as_ref() == source_widget {
202                return;
203            }
204
205            self.source_widget.set(source_widget);
206            self.obj().notify_source_widget();
207        }
208    }
209}
210
211glib::wrapper! {
212    /// A widget to reveal a child with a scaling animation.
213    pub struct ScaleRevealer(ObjectSubclass<imp::ScaleRevealer>)
214        @extends gtk::Widget, adw::Bin;
215}
216
217impl ScaleRevealer {
218    pub fn new() -> Self {
219        glib::Object::new()
220    }
221
222    /// Connect to the signal emitted when the transition is done.
223    pub fn connect_transition_done<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
224        self.connect_closure(
225            "transition-done",
226            true,
227            closure_local!(move |obj: Self| {
228                f(&obj);
229            }),
230        )
231    }
232}
233
234impl Default for ScaleRevealer {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240/// Render the given widget to a `GdkTexture`.
241fn render_widget_to_texture(widget: &impl IsA<gtk::Widget>) -> Option<gdk::Texture> {
242    let widget_paintable = gtk::WidgetPaintable::new(Some(widget));
243    let snapshot = gtk::Snapshot::new();
244
245    widget_paintable.snapshot(
246        &snapshot,
247        widget_paintable.intrinsic_width().into(),
248        widget_paintable.intrinsic_height().into(),
249    );
250
251    let node = snapshot.to_node()?;
252    let native = widget.native()?;
253
254    Some(native.renderer()?.render_texture(node, None))
255}