gardesk/garnotify / b105cb6

Browse files

feat(ui): add slide animations and stack reflow

- Add animation module with state machine (Appearing, Visible, Reflowing, Disappearing, Hidden)
- Slide-in animation when notifications appear
- Slide-out animation when notifications close
- Stack reflow: remaining notifications animate up when one closes
- Configurable animation duration and slide direction
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b105cb689032dc7d033b14dc4b8cb5e29cd98cd3
Parents
ef08537
Tree
9c7dfd8

6 changed files

StatusFile+-
M garnotify/src/daemon.rs 5 0
A garnotify/src/ui/animation.rs 346 0
M garnotify/src/ui/layout.rs 25 0
M garnotify/src/ui/mod.rs 2 0
M garnotify/src/ui/popup.rs 95 10
M garnotify/src/ui/popup_manager.rs 86 7
garnotify/src/daemon.rsmodified
@@ -667,6 +667,11 @@ fn run_ui_thread(
667667
         if let Err(e) = manager.poll_events() {
668668
             error!("Failed to poll X11 events: {}", e);
669669
         }
670
+
671
+        // Update animations
672
+        if let Err(e) = manager.update_animations() {
673
+            error!("Failed to update animations: {}", e);
674
+        }
670675
     }
671676
 
672677
     Ok(())
garnotify/src/ui/animation.rsadded
@@ -0,0 +1,346 @@
1
+//! Animation system for notification popups
2
+//!
3
+//! Handles slide and fade animations for popup appearance/disappearance.
4
+
5
+use std::time::{Duration, Instant};
6
+
7
+/// Animation state
8
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+pub enum AnimationState {
10
+    /// Popup is animating in (appearing)
11
+    Appearing,
12
+    /// Popup is fully visible
13
+    Visible,
14
+    /// Popup is animating to a new position (reflow)
15
+    Reflowing,
16
+    /// Popup is animating out (disappearing)
17
+    Disappearing,
18
+    /// Popup is hidden (animation complete)
19
+    Hidden,
20
+}
21
+
22
+/// Slide direction for animations
23
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24
+pub enum SlideDirection {
25
+    Up,
26
+    Down,
27
+    Left,
28
+    Right,
29
+    None,
30
+}
31
+
32
+impl SlideDirection {
33
+    pub fn from_str(s: &str) -> Self {
34
+        match s.to_lowercase().as_str() {
35
+            "up" => SlideDirection::Up,
36
+            "down" => SlideDirection::Down,
37
+            "left" => SlideDirection::Left,
38
+            "right" => SlideDirection::Right,
39
+            _ => SlideDirection::None,
40
+        }
41
+    }
42
+
43
+    /// Get the offset multiplier for x and y based on direction
44
+    /// Returns (dx, dy) where values are -1, 0, or 1
45
+    pub fn offset_multiplier(&self) -> (i32, i32) {
46
+        match self {
47
+            SlideDirection::Up => (0, -1),
48
+            SlideDirection::Down => (0, 1),
49
+            SlideDirection::Left => (-1, 0),
50
+            SlideDirection::Right => (1, 0),
51
+            SlideDirection::None => (0, 0),
52
+        }
53
+    }
54
+}
55
+
56
+/// Animation configuration
57
+#[derive(Debug, Clone)]
58
+pub struct AnimationConfig {
59
+    /// Whether animations are enabled
60
+    pub enabled: bool,
61
+    /// Duration for fade-in animation
62
+    pub fade_in_duration: Duration,
63
+    /// Duration for fade-out animation
64
+    pub fade_out_duration: Duration,
65
+    /// Slide direction
66
+    pub slide_direction: SlideDirection,
67
+    /// Slide distance in pixels
68
+    pub slide_distance: i32,
69
+}
70
+
71
+impl Default for AnimationConfig {
72
+    fn default() -> Self {
73
+        Self {
74
+            enabled: true,
75
+            fade_in_duration: Duration::from_millis(150),
76
+            fade_out_duration: Duration::from_millis(150),
77
+            slide_direction: SlideDirection::Down,
78
+            slide_distance: 20,
79
+        }
80
+    }
81
+}
82
+
83
+impl AnimationConfig {
84
+    /// Create from the app's AnimationConfig
85
+    pub fn from_config(config: &crate::config::AnimationConfig) -> Self {
86
+        Self {
87
+            enabled: config.enabled,
88
+            fade_in_duration: Duration::from_millis(config.fade_in as u64),
89
+            fade_out_duration: Duration::from_millis(config.fade_out as u64),
90
+            slide_direction: SlideDirection::from_str(&config.slide),
91
+            slide_distance: config.slide_distance as i32,
92
+        }
93
+    }
94
+}
95
+
96
+/// Animator for a single popup
97
+#[derive(Debug)]
98
+pub struct Animator {
99
+    /// Current animation state
100
+    state: AnimationState,
101
+    /// Animation configuration
102
+    config: AnimationConfig,
103
+    /// When the current animation started
104
+    start_time: Instant,
105
+    /// Target position (final x, y when animation completes)
106
+    target_x: i32,
107
+    target_y: i32,
108
+    /// Start position for reflow animation
109
+    reflow_start_x: i32,
110
+    reflow_start_y: i32,
111
+}
112
+
113
+impl Animator {
114
+    /// Create a new animator
115
+    pub fn new(config: AnimationConfig, target_x: i32, target_y: i32) -> Self {
116
+        Self {
117
+            state: AnimationState::Hidden,
118
+            config,
119
+            start_time: Instant::now(),
120
+            target_x,
121
+            target_y,
122
+            reflow_start_x: target_x,
123
+            reflow_start_y: target_y,
124
+        }
125
+    }
126
+
127
+    /// Start the appear animation
128
+    pub fn start_appear(&mut self) {
129
+        if !self.config.enabled {
130
+            self.state = AnimationState::Visible;
131
+            return;
132
+        }
133
+        self.state = AnimationState::Appearing;
134
+        self.start_time = Instant::now();
135
+    }
136
+
137
+    /// Start the disappear animation
138
+    pub fn start_disappear(&mut self) {
139
+        if !self.config.enabled {
140
+            self.state = AnimationState::Hidden;
141
+            return;
142
+        }
143
+        self.state = AnimationState::Disappearing;
144
+        self.start_time = Instant::now();
145
+    }
146
+
147
+    /// Start a reflow animation to a new position
148
+    pub fn start_reflow(&mut self, current_x: i32, current_y: i32, new_target_x: i32, new_target_y: i32) {
149
+        if !self.config.enabled {
150
+            self.target_x = new_target_x;
151
+            self.target_y = new_target_y;
152
+            return;
153
+        }
154
+        self.reflow_start_x = current_x;
155
+        self.reflow_start_y = current_y;
156
+        self.target_x = new_target_x;
157
+        self.target_y = new_target_y;
158
+        self.state = AnimationState::Reflowing;
159
+        self.start_time = Instant::now();
160
+    }
161
+
162
+    /// Update the animation state, returns true if animation is still in progress
163
+    pub fn update(&mut self) -> bool {
164
+        match self.state {
165
+            AnimationState::Appearing => {
166
+                let elapsed = self.start_time.elapsed();
167
+                if elapsed >= self.config.fade_in_duration {
168
+                    self.state = AnimationState::Visible;
169
+                    false
170
+                } else {
171
+                    true
172
+                }
173
+            }
174
+            AnimationState::Reflowing => {
175
+                let elapsed = self.start_time.elapsed();
176
+                // Use fade_in duration for reflow animation
177
+                if elapsed >= self.config.fade_in_duration {
178
+                    self.state = AnimationState::Visible;
179
+                    false
180
+                } else {
181
+                    true
182
+                }
183
+            }
184
+            AnimationState::Disappearing => {
185
+                let elapsed = self.start_time.elapsed();
186
+                if elapsed >= self.config.fade_out_duration {
187
+                    self.state = AnimationState::Hidden;
188
+                    false
189
+                } else {
190
+                    true
191
+                }
192
+            }
193
+            _ => false,
194
+        }
195
+    }
196
+
197
+    /// Get the current animation state
198
+    pub fn state(&self) -> AnimationState {
199
+        self.state
200
+    }
201
+
202
+    /// Check if the popup should be visible (not hidden)
203
+    pub fn is_visible(&self) -> bool {
204
+        self.state != AnimationState::Hidden
205
+    }
206
+
207
+    /// Check if animation is in progress
208
+    pub fn is_animating(&self) -> bool {
209
+        matches!(self.state, AnimationState::Appearing | AnimationState::Reflowing | AnimationState::Disappearing)
210
+    }
211
+
212
+    /// Get the current animation progress (0.0 to 1.0)
213
+    pub fn progress(&self) -> f64 {
214
+        match self.state {
215
+            AnimationState::Appearing | AnimationState::Reflowing => {
216
+                let elapsed = self.start_time.elapsed();
217
+                let duration = self.config.fade_in_duration;
218
+                if duration.is_zero() {
219
+                    1.0
220
+                } else {
221
+                    (elapsed.as_secs_f64() / duration.as_secs_f64()).min(1.0)
222
+                }
223
+            }
224
+            AnimationState::Disappearing => {
225
+                let elapsed = self.start_time.elapsed();
226
+                let duration = self.config.fade_out_duration;
227
+                if duration.is_zero() {
228
+                    1.0
229
+                } else {
230
+                    (elapsed.as_secs_f64() / duration.as_secs_f64()).min(1.0)
231
+                }
232
+            }
233
+            AnimationState::Visible => 1.0,
234
+            AnimationState::Hidden => 0.0,
235
+        }
236
+    }
237
+
238
+    /// Get the current opacity (0.0 to 1.0)
239
+    pub fn opacity(&self) -> f64 {
240
+        match self.state {
241
+            AnimationState::Appearing => ease_out_cubic(self.progress()),
242
+            AnimationState::Disappearing => 1.0 - ease_out_cubic(self.progress()),
243
+            AnimationState::Visible | AnimationState::Reflowing => 1.0,
244
+            AnimationState::Hidden => 0.0,
245
+        }
246
+    }
247
+
248
+    /// Get the current position offset from target (for slide animation)
249
+    pub fn position_offset(&self) -> (i32, i32) {
250
+        let (dx, dy) = self.config.slide_direction.offset_multiplier();
251
+        let distance = self.config.slide_distance;
252
+
253
+        match self.state {
254
+            AnimationState::Appearing => {
255
+                // Start offset, move toward target
256
+                let progress = ease_out_cubic(self.progress());
257
+                let remaining = 1.0 - progress;
258
+                (
259
+                    (dx as f64 * distance as f64 * remaining) as i32,
260
+                    (dy as f64 * distance as f64 * remaining) as i32,
261
+                )
262
+            }
263
+            AnimationState::Disappearing => {
264
+                // Move away from target
265
+                let progress = ease_out_cubic(self.progress());
266
+                (
267
+                    (dx as f64 * distance as f64 * progress) as i32,
268
+                    (dy as f64 * distance as f64 * progress) as i32,
269
+                )
270
+            }
271
+            _ => (0, 0),
272
+        }
273
+    }
274
+
275
+    /// Get the current position (target + offset)
276
+    pub fn current_position(&self) -> (i32, i32) {
277
+        match self.state {
278
+            AnimationState::Reflowing => {
279
+                // Interpolate between start and target positions
280
+                let progress = ease_out_cubic(self.progress());
281
+                let x = self.reflow_start_x + ((self.target_x - self.reflow_start_x) as f64 * progress) as i32;
282
+                let y = self.reflow_start_y + ((self.target_y - self.reflow_start_y) as f64 * progress) as i32;
283
+                (x, y)
284
+            }
285
+            _ => {
286
+                let (offset_x, offset_y) = self.position_offset();
287
+                (self.target_x + offset_x, self.target_y + offset_y)
288
+            }
289
+        }
290
+    }
291
+
292
+    /// Update the target position (e.g., when stack reflows)
293
+    pub fn set_target_position(&mut self, x: i32, y: i32) {
294
+        self.target_x = x;
295
+        self.target_y = y;
296
+    }
297
+
298
+    /// Get target position
299
+    pub fn target_position(&self) -> (i32, i32) {
300
+        (self.target_x, self.target_y)
301
+    }
302
+}
303
+
304
+/// Ease-out cubic easing function for smooth animations
305
+fn ease_out_cubic(t: f64) -> f64 {
306
+    1.0 - (1.0 - t).powi(3)
307
+}
308
+
309
+#[cfg(test)]
310
+mod tests {
311
+    use super::*;
312
+
313
+    #[test]
314
+    fn test_slide_direction() {
315
+        assert_eq!(SlideDirection::from_str("up"), SlideDirection::Up);
316
+        assert_eq!(SlideDirection::from_str("DOWN"), SlideDirection::Down);
317
+        assert_eq!(SlideDirection::from_str("invalid"), SlideDirection::None);
318
+    }
319
+
320
+    #[test]
321
+    fn test_offset_multiplier() {
322
+        assert_eq!(SlideDirection::Up.offset_multiplier(), (0, -1));
323
+        assert_eq!(SlideDirection::Down.offset_multiplier(), (0, 1));
324
+        assert_eq!(SlideDirection::Left.offset_multiplier(), (-1, 0));
325
+        assert_eq!(SlideDirection::Right.offset_multiplier(), (1, 0));
326
+    }
327
+
328
+    #[test]
329
+    fn test_animator_disabled() {
330
+        let config = AnimationConfig {
331
+            enabled: false,
332
+            ..Default::default()
333
+        };
334
+        let mut animator = Animator::new(config, 100, 100);
335
+        animator.start_appear();
336
+        assert_eq!(animator.state(), AnimationState::Visible);
337
+    }
338
+
339
+    #[test]
340
+    fn test_ease_out_cubic() {
341
+        assert!((ease_out_cubic(0.0) - 0.0).abs() < 0.001);
342
+        assert!((ease_out_cubic(1.0) - 1.0).abs() < 0.001);
343
+        // Should be > linear at midpoint
344
+        assert!(ease_out_cubic(0.5) > 0.5);
345
+    }
346
+}
garnotify/src/ui/layout.rsmodified
@@ -234,6 +234,31 @@ impl LayoutManager {
234234
         }
235235
     }
236236
 
237
+    /// Compact slots after a notification is removed.
238
+    /// Returns a list of (notification_id, old_slot, new_slot) for notifications that moved.
239
+    pub fn compact_slots(&mut self) -> Vec<(u32, usize, usize)> {
240
+        let mut moves = Vec::new();
241
+        let mut write_idx = 0;
242
+
243
+        for read_idx in 0..self.slots.len() {
244
+            if let Some(notification_id) = self.slots[read_idx] {
245
+                if write_idx != read_idx {
246
+                    // This notification needs to move
247
+                    moves.push((notification_id, read_idx, write_idx));
248
+                    self.slots[write_idx] = Some(notification_id);
249
+                    self.slots[read_idx] = None;
250
+                }
251
+                write_idx += 1;
252
+            }
253
+        }
254
+
255
+        if !moves.is_empty() {
256
+            debug!("Compacted slots: {:?}", moves);
257
+        }
258
+
259
+        moves
260
+    }
261
+
237262
     /// Get the slot index for a notification
238263
     pub fn get_slot(&self, notification_id: u32) -> Option<usize> {
239264
         self.slots
garnotify/src/ui/mod.rsmodified
@@ -3,11 +3,13 @@
33
 //! Handles X11 window creation, Cairo rendering, and mouse interaction
44
 //! for notification popups.
55
 
6
+mod animation;
67
 mod icons;
78
 mod layout;
89
 mod popup;
910
 mod popup_manager;
1011
 
12
+pub use animation::{AnimationConfig, AnimationState, Animator, SlideDirection};
1113
 pub use icons::{load_notification_icon, LoadedIcon};
1214
 pub use layout::{LayoutManager, NotificationPosition, StackDirection};
1315
 pub use popup::NotificationPopup;
garnotify/src/ui/popup.rsmodified
@@ -12,6 +12,7 @@ use x11rb::protocol::xproto::{ConnectionExt, ImageFormat};
1212
 
1313
 use crate::config::AppearanceConfig;
1414
 use crate::notification::{Notification, Urgency};
15
+use super::animation::{AnimationConfig, AnimationState, Animator};
1516
 use super::icons::{load_notification_icon, LoadedIcon};
1617
 
1718
 /// Default notification height (will be calculated based on content)
@@ -57,6 +58,8 @@ pub struct NotificationPopup {
5758
     action_bounds: Vec<Rect>,
5859
     /// Index of currently hovered action button (None if no button hovered)
5960
     hovered_action: Option<usize>,
61
+    /// Animation controller
62
+    animator: Animator,
6063
 }
6164
 
6265
 impl NotificationPopup {
@@ -66,6 +69,7 @@ impl NotificationPopup {
6669
         notification: Notification,
6770
         rect: Rect,
6871
         appearance: &AppearanceConfig,
72
+        animation_config: AnimationConfig,
6973
     ) -> Result<Self> {
7074
         // Create ARGB window for transparency
7175
         let window = Window::create(
@@ -93,6 +97,9 @@ impl NotificationPopup {
9397
         // Load icon
9498
         let icon = load_notification_icon(&notification, appearance.icon_size);
9599
 
100
+        // Create animator with target position
101
+        let animator = Animator::new(animation_config, rect.x, rect.y);
102
+
96103
         info!(
97104
             "Created popup window {} for notification {} at ({}, {}) with icon: {}",
98105
             window.id(),
@@ -114,6 +121,7 @@ impl NotificationPopup {
114121
             icon,
115122
             action_bounds: Vec::new(),
116123
             hovered_action: None,
124
+            animator,
117125
         })
118126
     }
119127
 
@@ -142,8 +150,15 @@ impl NotificationPopup {
142150
         Ok(())
143151
     }
144152
 
145
-    /// Show the popup window
153
+    /// Show the popup window with appear animation
146154
     pub fn show(&mut self) -> Result<()> {
155
+        // Start appear animation
156
+        self.animator.start_appear();
157
+
158
+        // Get initial animated position
159
+        let (x, y) = self.animator.current_position();
160
+        self.move_to_internal(x, y)?;
161
+
147162
         self.render()?;
148163
         self.window.map()?;
149164
         self.present()?;
@@ -152,7 +167,7 @@ impl NotificationPopup {
152167
         Ok(())
153168
     }
154169
 
155
-    /// Hide the popup window
170
+    /// Hide the popup window (immediately, no animation)
156171
     pub fn hide(&self) -> Result<()> {
157172
         self.window.unmap()?;
158173
         self.conn.flush()?;
@@ -160,15 +175,85 @@ impl NotificationPopup {
160175
         Ok(())
161176
     }
162177
 
163
-    /// Move the popup to a new position
178
+    /// Start the disappear animation
179
+    pub fn start_disappear(&mut self) {
180
+        self.animator.start_disappear();
181
+        debug!("Starting disappear animation for notification {}", self.notification.id);
182
+    }
183
+
184
+    /// Update the animation state, returns true if animation is still in progress
185
+    /// Also updates window position based on animation
186
+    pub fn update_animation(&mut self) -> Result<bool> {
187
+        let animating = self.animator.update();
188
+
189
+        if self.animator.is_animating() {
190
+            // Update window position based on animation
191
+            let (x, y) = self.animator.current_position();
192
+            self.move_to_internal(x, y)?;
193
+        }
194
+
195
+        // If animation just finished appearing, make sure we're at target
196
+        if self.animator.state() == AnimationState::Visible {
197
+            let (target_x, target_y) = self.animator.target_position();
198
+            if self.rect.x != target_x || self.rect.y != target_y {
199
+                self.move_to_internal(target_x, target_y)?;
200
+            }
201
+        }
202
+
203
+        Ok(animating)
204
+    }
205
+
206
+    /// Get current animation state
207
+    pub fn animation_state(&self) -> AnimationState {
208
+        self.animator.state()
209
+    }
210
+
211
+    /// Check if popup is currently animating
212
+    pub fn is_animating(&self) -> bool {
213
+        self.animator.is_animating()
214
+    }
215
+
216
+    /// Move the popup to a new target position (updates animator target)
164217
     pub fn move_to(&mut self, x: i32, y: i32) -> Result<()> {
165
-        self.rect.x = x;
166
-        self.rect.y = y;
167
-        self.conn.inner().configure_window(
168
-            self.window.id(),
169
-            &x11rb::protocol::xproto::ConfigureWindowAux::new().x(x).y(y),
170
-        )?;
171
-        self.conn.flush()?;
218
+        self.animator.set_target_position(x, y);
219
+
220
+        // If not animating, move immediately
221
+        if !self.animator.is_animating() {
222
+            self.move_to_internal(x, y)?;
223
+        }
224
+        Ok(())
225
+    }
226
+
227
+    /// Start a reflow animation to move to a new position
228
+    pub fn start_reflow(&mut self, new_x: i32, new_y: i32) {
229
+        self.animator.start_reflow(self.rect.x, self.rect.y, new_x, new_y);
230
+        debug!(
231
+            "Starting reflow animation for notification {} from ({}, {}) to ({}, {})",
232
+            self.notification.id, self.rect.x, self.rect.y, new_x, new_y
233
+        );
234
+    }
235
+
236
+    /// Get current position
237
+    pub fn position(&self) -> (i32, i32) {
238
+        (self.rect.x, self.rect.y)
239
+    }
240
+
241
+    /// Get notification height
242
+    pub fn height(&self) -> u32 {
243
+        self.rect.height
244
+    }
245
+
246
+    /// Internal move without updating animator
247
+    fn move_to_internal(&mut self, x: i32, y: i32) -> Result<()> {
248
+        if self.rect.x != x || self.rect.y != y {
249
+            self.rect.x = x;
250
+            self.rect.y = y;
251
+            self.conn.inner().configure_window(
252
+                self.window.id(),
253
+                &x11rb::protocol::xproto::ConfigureWindowAux::new().x(x).y(y),
254
+            )?;
255
+            self.conn.flush()?;
256
+        }
172257
         Ok(())
173258
     }
174259
 
garnotify/src/ui/popup_manager.rsmodified
@@ -13,6 +13,7 @@ use x11rb::protocol::Event as X11Event;
1313
 
1414
 use crate::config::Config;
1515
 use crate::notification::{CloseReason, Notification};
16
+use crate::ui::animation::{AnimationConfig, AnimationState};
1617
 use crate::ui::layout::{LayoutManager, NotificationPosition};
1718
 use crate::ui::popup::{calculate_notification_height, NotificationPopup};
1819
 use crate::ui::{PopupCommand, PopupEvent};
@@ -31,6 +32,8 @@ pub struct PopupManager {
3132
     event_tx: mpsc::Sender<PopupEvent>,
3233
     /// Window ID to notification ID mapping
3334
     window_to_notification: HashMap<u32, u32>,
35
+    /// Pending close reasons for popups with disappear animations
36
+    pending_close: HashMap<u32, CloseReason>,
3437
 }
3538
 
3639
 impl PopupManager {
@@ -59,6 +62,7 @@ impl PopupManager {
5962
             popups: HashMap::new(),
6063
             event_tx,
6164
             window_to_notification: HashMap::new(),
65
+            pending_close: HashMap::new(),
6266
         })
6367
     }
6468
 
@@ -111,12 +115,16 @@ impl PopupManager {
111115
         // Get geometry from layout
112116
         let rect = self.layout.calculate_geometry(slot, height);
113117
 
118
+        // Create animation config from app config
119
+        let animation_config = AnimationConfig::from_config(&self.config.animation);
120
+
114121
         // Create popup window
115122
         let mut popup = NotificationPopup::new(
116123
             self.conn.clone(),
117124
             notification,
118125
             rect,
119126
             &self.config.appearance,
127
+            animation_config,
120128
         )?;
121129
 
122130
         // Track window ID mapping
@@ -149,24 +157,69 @@ impl PopupManager {
149157
         Ok(())
150158
     }
151159
 
152
-    /// Close a notification popup
160
+    /// Close a notification popup (starts disappear animation)
153161
     fn close_notification(&mut self, id: u32, reason: CloseReason) -> Result<()> {
162
+        if let Some(popup) = self.popups.get_mut(&id) {
163
+            // Check if already closing
164
+            if self.pending_close.contains_key(&id) {
165
+                return Ok(());
166
+            }
167
+
168
+            // Release the slot immediately so other notifications can use it
169
+            self.layout.release_slot(id);
170
+
171
+            // Start disappear animation
172
+            popup.start_disappear();
173
+
174
+            // Track the close reason
175
+            self.pending_close.insert(id, reason);
176
+
177
+            info!("Closing notification {}: reason={:?}", id, reason);
178
+        }
179
+        Ok(())
180
+    }
181
+
182
+    /// Immediately remove a popup (after animation completes)
183
+    fn finish_close(&mut self, id: u32) {
154184
         if let Some(popup) = self.popups.remove(&id) {
155185
             // Remove window mapping
156186
             self.window_to_notification.remove(&popup.window_id());
157187
 
158
-            // Release the slot
159
-            self.layout.release_slot(id);
188
+            // Get the close reason
189
+            let reason = self.pending_close.remove(&id).unwrap_or(CloseReason::Closed);
160190
 
161
-            // Hide and drop the popup (window destroyed on drop)
162
-            popup.hide()?;
191
+            // Hide the popup
192
+            let _ = popup.hide();
163193
 
164
-            info!("Closed notification {}: reason={:?}", id, reason);
194
+            info!("Finished closing notification {}: reason={:?}", id, reason);
165195
 
166196
             // Send event back to daemon
167197
             let _ = self.event_tx.try_send(PopupEvent::Closed { id, reason });
198
+
199
+            // Compact slots and trigger reflow animations for moved notifications
200
+            self.reflow_stack();
201
+        }
202
+    }
203
+
204
+    /// Compact the notification stack and animate notifications to new positions
205
+    fn reflow_stack(&mut self) {
206
+        let moves = self.layout.compact_slots();
207
+
208
+        for (notification_id, _old_slot, new_slot) in moves {
209
+            if let Some(popup) = self.popups.get_mut(&notification_id) {
210
+                // Calculate new geometry for the new slot
211
+                let height = popup.height();
212
+                let new_rect = self.layout.calculate_geometry(new_slot, height);
213
+
214
+                // Start reflow animation
215
+                popup.start_reflow(new_rect.x, new_rect.y);
216
+
217
+                debug!(
218
+                    "Reflowing notification {} to slot {} at ({}, {})",
219
+                    notification_id, new_slot, new_rect.x, new_rect.y
220
+                );
221
+            }
168222
         }
169
-        Ok(())
170223
     }
171224
 
172225
     /// Close all notification popups
@@ -275,4 +328,30 @@ impl PopupManager {
275328
     pub fn popup_count(&self) -> usize {
276329
         self.popups.len()
277330
     }
331
+
332
+    /// Update all animations, returns true if any are still animating
333
+    pub fn update_animations(&mut self) -> Result<bool> {
334
+        use crate::ui::animation::AnimationState;
335
+
336
+        let mut any_animating = false;
337
+        let mut to_finish = Vec::new();
338
+
339
+        for (&id, popup) in &mut self.popups {
340
+            if popup.update_animation()? {
341
+                any_animating = true;
342
+            }
343
+
344
+            // Check if disappear animation finished
345
+            if popup.animation_state() == AnimationState::Hidden {
346
+                to_finish.push(id);
347
+            }
348
+        }
349
+
350
+        // Clean up popups that finished disappearing
351
+        for id in to_finish {
352
+            self.finish_close(id);
353
+        }
354
+
355
+        Ok(any_animating)
356
+    }
278357
 }