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    /// par-mux session name that was attached when this window was saved.
82    /// Distinct from `tmux_session_name`: restoring a mux name through the
83    /// tmux gateway spawns a real `tmux -CC` session of the same name
84    /// instead of reattaching to the daemon.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub mux_session_name: Option<String>,
87}
88
89/// A saved window arrangement
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct WindowArrangement {
92    /// Unique identifier
93    pub id: ArrangementId,
94
95    /// Display name for the arrangement
96    pub name: String,
97
98    /// All monitors present at capture time
99    pub monitor_layout: Vec<MonitorInfo>,
100
101    /// All windows in this arrangement
102    pub windows: Vec<WindowSnapshot>,
103
104    /// ISO 8601 timestamp when the arrangement was created
105    #[serde(default)]
106    pub created_at: String,
107
108    /// Display order
109    #[serde(default)]
110    pub order: usize,
111}
112
113/// Manages a collection of saved window arrangements
114#[derive(Debug, Clone, Default)]
115pub struct ArrangementManager {
116    /// All arrangements indexed by ID
117    arrangements: HashMap<ArrangementId, WindowArrangement>,
118
119    /// Ordered list of arrangement IDs for display
120    order: Vec<ArrangementId>,
121}
122
123impl ArrangementManager {
124    /// Create a new empty arrangement manager
125    pub fn new() -> Self {
126        Self {
127            arrangements: HashMap::new(),
128            order: Vec::new(),
129        }
130    }
131
132    /// Create a manager from a list of arrangements
133    pub fn from_arrangements(arrangements: Vec<WindowArrangement>) -> Self {
134        let mut manager = Self::new();
135        for arrangement in arrangements {
136            manager.add(arrangement);
137        }
138        manager.sort_by_order();
139        manager
140    }
141
142    /// Add an arrangement to the manager
143    pub fn add(&mut self, arrangement: WindowArrangement) {
144        let id = arrangement.id;
145        if !self.order.contains(&id) {
146            self.order.push(id);
147        }
148        self.arrangements.insert(id, arrangement);
149    }
150
151    /// Get an arrangement by ID
152    pub fn get(&self, id: &ArrangementId) -> Option<&WindowArrangement> {
153        self.arrangements.get(id)
154    }
155
156    /// Get a mutable reference to an arrangement by ID
157    pub fn get_mut(&mut self, id: &ArrangementId) -> Option<&mut WindowArrangement> {
158        self.arrangements.get_mut(id)
159    }
160
161    /// Update an arrangement (replaces if exists)
162    pub fn update(&mut self, arrangement: WindowArrangement) {
163        let id = arrangement.id;
164        if self.arrangements.contains_key(&id) {
165            self.arrangements.insert(id, arrangement);
166        }
167    }
168
169    /// Remove an arrangement by ID
170    pub fn remove(&mut self, id: &ArrangementId) -> Option<WindowArrangement> {
171        self.order.retain(|aid| aid != id);
172        self.arrangements.remove(id)
173    }
174
175    /// Get all arrangements in display order
176    pub fn arrangements_ordered(&self) -> Vec<&WindowArrangement> {
177        self.order
178            .iter()
179            .filter_map(|id| self.arrangements.get(id))
180            .collect()
181    }
182
183    /// Get all arrangements as a vector (for serialization)
184    pub fn to_vec(&self) -> Vec<WindowArrangement> {
185        self.arrangements_ordered().into_iter().cloned().collect()
186    }
187
188    /// Get the number of arrangements
189    pub fn len(&self) -> usize {
190        self.arrangements.len()
191    }
192
193    /// Check if there are no arrangements
194    pub fn is_empty(&self) -> bool {
195        self.arrangements.is_empty()
196    }
197
198    /// Find an arrangement by name (case-insensitive)
199    pub fn find_by_name(&self, name: &str) -> Option<&WindowArrangement> {
200        let lower = name.to_lowercase();
201        self.arrangements
202            .values()
203            .find(|a| a.name.to_lowercase() == lower)
204    }
205
206    /// Move an arrangement earlier in the order (towards index 0)
207    pub fn move_up(&mut self, id: &ArrangementId) {
208        if let Some(pos) = self.order.iter().position(|aid| aid == id)
209            && pos > 0
210        {
211            self.order.swap(pos, pos - 1);
212            self.update_orders();
213        }
214    }
215
216    /// Move an arrangement later in the order (towards the end)
217    pub fn move_down(&mut self, id: &ArrangementId) {
218        if let Some(pos) = self.order.iter().position(|aid| aid == id)
219            && pos < self.order.len() - 1
220        {
221            self.order.swap(pos, pos + 1);
222            self.update_orders();
223        }
224    }
225
226    /// Sort arrangements by their order field
227    fn sort_by_order(&mut self) {
228        self.order.sort_by_key(|id| {
229            self.arrangements
230                .get(id)
231                .map(|a| a.order)
232                .unwrap_or(usize::MAX)
233        });
234    }
235
236    /// Update the order field of all arrangements to match their position
237    fn update_orders(&mut self) {
238        for (i, id) in self.order.iter().enumerate() {
239            if let Some(arrangement) = self.arrangements.get_mut(id) {
240                arrangement.order = i;
241            }
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn make_arrangement(name: &str, order: usize) -> WindowArrangement {
251        WindowArrangement {
252            id: Uuid::new_v4(),
253            name: name.to_string(),
254            monitor_layout: Vec::new(),
255            windows: Vec::new(),
256            created_at: String::new(),
257            order,
258        }
259    }
260
261    #[test]
262    fn test_manager_basic_operations() {
263        let mut manager = ArrangementManager::new();
264        assert!(manager.is_empty());
265
266        let arr = make_arrangement("Test", 0);
267        let id = arr.id;
268        manager.add(arr);
269
270        assert_eq!(manager.len(), 1);
271        assert!(manager.get(&id).is_some());
272        assert_eq!(manager.get(&id).unwrap().name, "Test");
273
274        let removed = manager.remove(&id);
275        assert!(removed.is_some());
276        assert!(manager.is_empty());
277    }
278
279    #[test]
280    fn test_manager_ordering() {
281        let mut manager = ArrangementManager::new();
282
283        let a1 = make_arrangement("First", 0);
284        let a2 = make_arrangement("Second", 1);
285        let a3 = make_arrangement("Third", 2);
286
287        let id1 = a1.id;
288        let id2 = a2.id;
289        let id3 = a3.id;
290
291        manager.add(a1);
292        manager.add(a2);
293        manager.add(a3);
294
295        let ordered = manager.arrangements_ordered();
296        assert_eq!(ordered.len(), 3);
297        assert_eq!(ordered[0].id, id1);
298        assert_eq!(ordered[1].id, id2);
299        assert_eq!(ordered[2].id, id3);
300
301        // Move second to first position
302        manager.move_up(&id2);
303        let ordered = manager.arrangements_ordered();
304        assert_eq!(ordered[0].id, id2);
305        assert_eq!(ordered[1].id, id1);
306
307        // Move second (now first) down
308        manager.move_down(&id2);
309        let ordered = manager.arrangements_ordered();
310        assert_eq!(ordered[0].id, id1);
311        assert_eq!(ordered[1].id, id2);
312    }
313
314    #[test]
315    fn test_find_by_name() {
316        let mut manager = ArrangementManager::new();
317        manager.add(make_arrangement("Work Setup", 0));
318        manager.add(make_arrangement("Home Setup", 1));
319
320        assert!(manager.find_by_name("work setup").is_some());
321        assert!(manager.find_by_name("HOME SETUP").is_some());
322        assert!(manager.find_by_name("nonexistent").is_none());
323    }
324
325    #[test]
326    fn test_serialization() {
327        let arr = WindowArrangement {
328            id: Uuid::new_v4(),
329            name: "Test".to_string(),
330            monitor_layout: vec![MonitorInfo {
331                name: Some("DELL U2720Q".to_string()),
332                index: 0,
333                position: (0, 0),
334                size: (2560, 1440),
335                scale_factor: 1.0,
336            }],
337            windows: vec![WindowSnapshot {
338                monitor: MonitorInfo {
339                    name: Some("DELL U2720Q".to_string()),
340                    index: 0,
341                    position: (0, 0),
342                    size: (2560, 1440),
343                    scale_factor: 1.0,
344                },
345                position_relative: (100, 200),
346                size: (800, 600),
347                tabs: vec![TabSnapshot {
348                    cwd: Some("/home/user".to_string()),
349                    title: "bash".to_string(),
350                    custom_color: None,
351                    user_title: None,
352                    custom_icon: None,
353                }],
354                active_tab_index: 0,
355                tmux_session_name: None,
356                mux_session_name: None,
357            }],
358            created_at: "2024-01-01T00:00:00Z".to_string(),
359            order: 0,
360        };
361
362        let yaml = serde_yaml_ng::to_string(&arr).unwrap();
363        let deserialized: WindowArrangement = serde_yaml_ng::from_str(&yaml).unwrap();
364
365        assert_eq!(deserialized.id, arr.id);
366        assert_eq!(deserialized.name, arr.name);
367        assert_eq!(deserialized.windows.len(), 1);
368        assert_eq!(deserialized.windows[0].tabs.len(), 1);
369        assert_eq!(
370            deserialized.windows[0].tabs[0].cwd,
371            Some("/home/user".to_string())
372        );
373    }
374}