Skip to main content

par_term_settings_ui/
arrangements.rs

1//! Window arrangement types and manager for saving/restoring window layouts
2//!
3//! Arrangements capture the positions, sizes, and tab CWDs of all windows
4//! so they can be restored later. Monitor-aware to handle external monitor
5//! disconnect/reconnect scenarios.
6//!
7//! # Shared types
8//!
9//! [`TabSnapshot`] is defined in `par-term-config::snapshot_types` and re-exported
10//! here so that callers using `par_term_settings_ui::arrangements::TabSnapshot` see
11//! no change. The session module (`src/session`) also imports the same type directly
12//! from `par_term_config`, eliminating the previous duplication.
13
14// Re-export TabSnapshot from par-term-config so existing
15// `use arrangements::TabSnapshot` paths keep working unchanged.
16pub use par_term_config::snapshot_types::TabSnapshot;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use uuid::Uuid;
20
21/// Unique identifier for an arrangement
22pub type ArrangementId = Uuid;
23
24/// Information about a monitor at capture time
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct MonitorInfo {
27    /// Monitor name (primary matching key, e.g. "DELL U2720Q")
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub name: Option<String>,
30
31    /// Monitor index (fallback matching)
32    #[serde(default)]
33    pub index: usize,
34
35    /// Monitor position in virtual screen coordinates (physical pixels)
36    #[serde(default)]
37    pub position: (i32, i32),
38
39    /// Monitor size in physical pixels
40    #[serde(default)]
41    pub size: (u32, u32),
42
43    /// DPI scale factor at capture time (e.g. 2.0 for Retina/HiDPI)
44    /// Used to interpret position_relative and window size in WindowSnapshot.
45    #[serde(default = "default_scale_factor", skip_serializing_if = "is_one")]
46    pub scale_factor: f64,
47}
48
49fn default_scale_factor() -> f64 {
50    1.0
51}
52
53fn is_one(v: &f64) -> bool {
54    (*v - 1.0).abs() < f64::EPSILON
55}
56
57/// Snapshot of a single window's state
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct WindowSnapshot {
60    /// Monitor this window was on
61    pub monitor: MonitorInfo,
62
63    /// Position relative to monitor origin (portable across setups)
64    pub position_relative: (i32, i32),
65
66    /// Inner window size in logical pixels (scale-factor-independent)
67    pub size: (u32, u32),
68
69    /// Tabs in this window
70    pub tabs: Vec<TabSnapshot>,
71
72    /// Index of the active tab
73    #[serde(default)]
74    pub active_tab_index: usize,
75
76    /// tmux session name that was active when this window was saved.
77    /// On restore, the window will auto-connect to this session (control mode only).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub tmux_session_name: Option<String>,
80}
81
82/// A saved window arrangement
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct WindowArrangement {
85    /// Unique identifier
86    pub id: ArrangementId,
87
88    /// Display name for the arrangement
89    pub name: String,
90
91    /// All monitors present at capture time
92    pub monitor_layout: Vec<MonitorInfo>,
93
94    /// All windows in this arrangement
95    pub windows: Vec<WindowSnapshot>,
96
97    /// ISO 8601 timestamp when the arrangement was created
98    #[serde(default)]
99    pub created_at: String,
100
101    /// Display order
102    #[serde(default)]
103    pub order: usize,
104}
105
106/// Manages a collection of saved window arrangements
107#[derive(Debug, Clone, Default)]
108pub struct ArrangementManager {
109    /// All arrangements indexed by ID
110    arrangements: HashMap<ArrangementId, WindowArrangement>,
111
112    /// Ordered list of arrangement IDs for display
113    order: Vec<ArrangementId>,
114}
115
116impl ArrangementManager {
117    /// Create a new empty arrangement manager
118    pub fn new() -> Self {
119        Self {
120            arrangements: HashMap::new(),
121            order: Vec::new(),
122        }
123    }
124
125    /// Create a manager from a list of arrangements
126    pub fn from_arrangements(arrangements: Vec<WindowArrangement>) -> Self {
127        let mut manager = Self::new();
128        for arrangement in arrangements {
129            manager.add(arrangement);
130        }
131        manager.sort_by_order();
132        manager
133    }
134
135    /// Add an arrangement to the manager
136    pub fn add(&mut self, arrangement: WindowArrangement) {
137        let id = arrangement.id;
138        if !self.order.contains(&id) {
139            self.order.push(id);
140        }
141        self.arrangements.insert(id, arrangement);
142    }
143
144    /// Get an arrangement by ID
145    pub fn get(&self, id: &ArrangementId) -> Option<&WindowArrangement> {
146        self.arrangements.get(id)
147    }
148
149    /// Get a mutable reference to an arrangement by ID
150    pub fn get_mut(&mut self, id: &ArrangementId) -> Option<&mut WindowArrangement> {
151        self.arrangements.get_mut(id)
152    }
153
154    /// Update an arrangement (replaces if exists)
155    pub fn update(&mut self, arrangement: WindowArrangement) {
156        let id = arrangement.id;
157        if self.arrangements.contains_key(&id) {
158            self.arrangements.insert(id, arrangement);
159        }
160    }
161
162    /// Remove an arrangement by ID
163    pub fn remove(&mut self, id: &ArrangementId) -> Option<WindowArrangement> {
164        self.order.retain(|aid| aid != id);
165        self.arrangements.remove(id)
166    }
167
168    /// Get all arrangements in display order
169    pub fn arrangements_ordered(&self) -> Vec<&WindowArrangement> {
170        self.order
171            .iter()
172            .filter_map(|id| self.arrangements.get(id))
173            .collect()
174    }
175
176    /// Get all arrangements as a vector (for serialization)
177    pub fn to_vec(&self) -> Vec<WindowArrangement> {
178        self.arrangements_ordered().into_iter().cloned().collect()
179    }
180
181    /// Get the number of arrangements
182    pub fn len(&self) -> usize {
183        self.arrangements.len()
184    }
185
186    /// Check if there are no arrangements
187    pub fn is_empty(&self) -> bool {
188        self.arrangements.is_empty()
189    }
190
191    /// Find an arrangement by name (case-insensitive)
192    pub fn find_by_name(&self, name: &str) -> Option<&WindowArrangement> {
193        let lower = name.to_lowercase();
194        self.arrangements
195            .values()
196            .find(|a| a.name.to_lowercase() == lower)
197    }
198
199    /// Move an arrangement earlier in the order (towards index 0)
200    pub fn move_up(&mut self, id: &ArrangementId) {
201        if let Some(pos) = self.order.iter().position(|aid| aid == id)
202            && pos > 0
203        {
204            self.order.swap(pos, pos - 1);
205            self.update_orders();
206        }
207    }
208
209    /// Move an arrangement later in the order (towards the end)
210    pub fn move_down(&mut self, id: &ArrangementId) {
211        if let Some(pos) = self.order.iter().position(|aid| aid == id)
212            && pos < self.order.len() - 1
213        {
214            self.order.swap(pos, pos + 1);
215            self.update_orders();
216        }
217    }
218
219    /// Sort arrangements by their order field
220    fn sort_by_order(&mut self) {
221        self.order.sort_by_key(|id| {
222            self.arrangements
223                .get(id)
224                .map(|a| a.order)
225                .unwrap_or(usize::MAX)
226        });
227    }
228
229    /// Update the order field of all arrangements to match their position
230    fn update_orders(&mut self) {
231        for (i, id) in self.order.iter().enumerate() {
232            if let Some(arrangement) = self.arrangements.get_mut(id) {
233                arrangement.order = i;
234            }
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn make_arrangement(name: &str, order: usize) -> WindowArrangement {
244        WindowArrangement {
245            id: Uuid::new_v4(),
246            name: name.to_string(),
247            monitor_layout: Vec::new(),
248            windows: Vec::new(),
249            created_at: String::new(),
250            order,
251        }
252    }
253
254    #[test]
255    fn test_manager_basic_operations() {
256        let mut manager = ArrangementManager::new();
257        assert!(manager.is_empty());
258
259        let arr = make_arrangement("Test", 0);
260        let id = arr.id;
261        manager.add(arr);
262
263        assert_eq!(manager.len(), 1);
264        assert!(manager.get(&id).is_some());
265        assert_eq!(manager.get(&id).unwrap().name, "Test");
266
267        let removed = manager.remove(&id);
268        assert!(removed.is_some());
269        assert!(manager.is_empty());
270    }
271
272    #[test]
273    fn test_manager_ordering() {
274        let mut manager = ArrangementManager::new();
275
276        let a1 = make_arrangement("First", 0);
277        let a2 = make_arrangement("Second", 1);
278        let a3 = make_arrangement("Third", 2);
279
280        let id1 = a1.id;
281        let id2 = a2.id;
282        let id3 = a3.id;
283
284        manager.add(a1);
285        manager.add(a2);
286        manager.add(a3);
287
288        let ordered = manager.arrangements_ordered();
289        assert_eq!(ordered.len(), 3);
290        assert_eq!(ordered[0].id, id1);
291        assert_eq!(ordered[1].id, id2);
292        assert_eq!(ordered[2].id, id3);
293
294        // Move second to first position
295        manager.move_up(&id2);
296        let ordered = manager.arrangements_ordered();
297        assert_eq!(ordered[0].id, id2);
298        assert_eq!(ordered[1].id, id1);
299
300        // Move second (now first) down
301        manager.move_down(&id2);
302        let ordered = manager.arrangements_ordered();
303        assert_eq!(ordered[0].id, id1);
304        assert_eq!(ordered[1].id, id2);
305    }
306
307    #[test]
308    fn test_find_by_name() {
309        let mut manager = ArrangementManager::new();
310        manager.add(make_arrangement("Work Setup", 0));
311        manager.add(make_arrangement("Home Setup", 1));
312
313        assert!(manager.find_by_name("work setup").is_some());
314        assert!(manager.find_by_name("HOME SETUP").is_some());
315        assert!(manager.find_by_name("nonexistent").is_none());
316    }
317
318    #[test]
319    fn test_serialization() {
320        let arr = WindowArrangement {
321            id: Uuid::new_v4(),
322            name: "Test".to_string(),
323            monitor_layout: vec![MonitorInfo {
324                name: Some("DELL U2720Q".to_string()),
325                index: 0,
326                position: (0, 0),
327                size: (2560, 1440),
328                scale_factor: 1.0,
329            }],
330            windows: vec![WindowSnapshot {
331                monitor: MonitorInfo {
332                    name: Some("DELL U2720Q".to_string()),
333                    index: 0,
334                    position: (0, 0),
335                    size: (2560, 1440),
336                    scale_factor: 1.0,
337                },
338                position_relative: (100, 200),
339                size: (800, 600),
340                tabs: vec![TabSnapshot {
341                    cwd: Some("/home/user".to_string()),
342                    title: "bash".to_string(),
343                    custom_color: None,
344                    user_title: None,
345                    custom_icon: None,
346                }],
347                active_tab_index: 0,
348                tmux_session_name: None,
349            }],
350            created_at: "2024-01-01T00:00:00Z".to_string(),
351            order: 0,
352        };
353
354        let yaml = serde_yaml_ng::to_string(&arr).unwrap();
355        let deserialized: WindowArrangement = serde_yaml_ng::from_str(&yaml).unwrap();
356
357        assert_eq!(deserialized.id, arr.id);
358        assert_eq!(deserialized.name, arr.name);
359        assert_eq!(deserialized.windows.len(), 1);
360        assert_eq!(deserialized.windows[0].tabs.len(), 1);
361        assert_eq!(
362            deserialized.windows[0].tabs[0].cwd,
363            Some("/home/user".to_string())
364        );
365    }
366}