Skip to main content

lingxia_shell/
sidebar_chrome.rs

1//! Desktop window state remembered across launches.
2//!
3//! Only the user's own sidebar choice lives here. The adaptive projection — the
4//! icon rail a narrow window forces — is derived from the window every launch
5//! and must never be written down, or a window that was briefly narrow would
6//! teach the app to open as a rail forever.
7
8use serde::{Deserialize, Serialize};
9
10/// Width a first launch opens the sidebar at. The platforms agree on expanded
11/// content geometry, not on the exact column width: macOS packs its window
12/// chrome tighter, so the same 184 there reads as a slab next to native apps.
13#[cfg(target_os = "macos")]
14pub const DEFAULT_EXPANDED_SIDEBAR_WIDTH: f64 = 148.0;
15#[cfg(not(target_os = "macos"))]
16pub const DEFAULT_EXPANDED_SIDEBAR_WIDTH: f64 = 184.0;
17
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct SidebarChrome {
21    pub expanded: bool,
22    pub expanded_width: f64,
23}
24
25impl Default for SidebarChrome {
26    fn default() -> Self {
27        Self {
28            expanded: true,
29            expanded_width: DEFAULT_EXPANDED_SIDEBAR_WIDTH,
30        }
31    }
32}
33
34impl SidebarChrome {
35    pub fn rail(&self) -> bool {
36        !self.expanded
37    }
38
39    pub fn with_expanded(expanded: bool, expanded_width: f64) -> Self {
40        Self {
41            expanded,
42            expanded_width,
43        }
44        .normalized()
45    }
46
47    pub(crate) fn normalized(self) -> Self {
48        let expanded_width = if self.expanded_width.is_finite() && self.expanded_width > 0.0 {
49            self.expanded_width
50        } else {
51            DEFAULT_EXPANDED_SIDEBAR_WIDTH
52        };
53        Self {
54            expanded: self.expanded,
55            expanded_width,
56        }
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct WindowFrame {
63    pub x: f64,
64    pub y: f64,
65    pub width: f64,
66    pub height: f64,
67}
68
69impl WindowFrame {
70    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Option<Self> {
71        let frame = Self {
72            x,
73            y,
74            width,
75            height,
76        };
77        frame.valid().then_some(frame)
78    }
79
80    pub fn valid(&self) -> bool {
81        self.x.is_finite()
82            && self.y.is_finite()
83            && self.width.is_finite()
84            && self.height.is_finite()
85            && self.width > 0.0
86            && self.height > 0.0
87    }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
91#[serde(rename_all = "camelCase")]
92pub struct ShellWindowState {
93    pub sidebar: SidebarChrome,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub window: Option<WindowFrame>,
96}
97
98impl ShellWindowState {
99    pub(crate) fn normalized(mut self) -> Self {
100        self.sidebar = self.sidebar.normalized();
101        self.window = self.window.filter(WindowFrame::valid);
102        self
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    /// Never having chosen is the expanded case — a first launch shows the
111    /// whole sidebar rather than a rail nobody asked for.
112    #[test]
113    fn the_default_is_expanded() {
114        assert!(SidebarChrome::default().expanded);
115        assert_eq!(
116            SidebarChrome::default().expanded_width,
117            DEFAULT_EXPANDED_SIDEBAR_WIDTH
118        );
119        assert!(!SidebarChrome::default().rail());
120    }
121
122    #[test]
123    fn window_state_round_trips_through_json() {
124        for expanded in [false, true] {
125            let saved = ShellWindowState {
126                sidebar: SidebarChrome::with_expanded(expanded, 252.5),
127                window: WindowFrame::new(40.0, 60.0, 1200.0, 800.0),
128            };
129            let raw = serde_json::to_string(&saved).expect("serialize");
130            let loaded: ShellWindowState = serde_json::from_str(&raw).expect("deserialize");
131            assert_eq!(loaded, saved);
132            assert_eq!(loaded.sidebar.expanded, expanded);
133        }
134    }
135
136    #[test]
137    fn invalid_geometry_is_dropped_and_width_uses_the_default() {
138        let state = ShellWindowState {
139            sidebar: SidebarChrome {
140                expanded: false,
141                expanded_width: -10.0,
142            },
143            window: Some(WindowFrame {
144                x: 10.0,
145                y: 20.0,
146                width: 0.0,
147                height: 600.0,
148            }),
149        }
150        .normalized();
151
152        assert!(!state.sidebar.expanded);
153        assert_eq!(state.sidebar.expanded_width, DEFAULT_EXPANDED_SIDEBAR_WIDTH);
154        assert_eq!(state.window, None);
155    }
156}