window management library
git clone https://git.lucas.co/cce-window-manager.git
src/state.rs (5.8K)
1 // Persisted session state, saved to $XDG_STATE_HOME/cce/state.json on shutdown
2 // and restored on startup. Pure data — serialization and matching logic only;
3 // the save/load I/O lives in `window_manager.rs`.
4
5 use super::tiling::TilingMode;
6
7 /// The desktop-grid geometry the geometries in this file were measured
8 /// under. Saved so a LATER session under a different grid can re-tile a
9 /// Tiled entry onto the same block of squares (`cells::remap_block`) instead
10 /// of re-deriving its span from stale pixels — a box saved under one grid
11 /// lands misaligned on another, touches extra cells, and the tiled snap
12 /// then grows the window by a cell. Absent in files written before this
13 /// field existed; the loader then has nothing to remap from and applies the
14 /// geometry as-is.
15 #[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq)]
16 pub struct SavedGrid {
17 pub cell_w: f64,
18 pub cell_h: f64,
19 pub gap_width: f64,
20 pub cell_inset: f64,
21 }
22
23 impl SavedGrid {
24 pub fn from_params(p: &crate::snap::SnapParams) -> Self {
25 Self { cell_w: p.cell_w, cell_h: p.cell_h, gap_width: p.gap_width, cell_inset: p.cell_inset }
26 }
27
28 /// The saved grid as snap params, borrowing everything non-geometric
29 /// (threshold) from `current` — remapping needs geometry only.
30 pub fn to_params(&self, current: &crate::snap::SnapParams) -> crate::snap::SnapParams {
31 crate::snap::SnapParams {
32 cell_w: self.cell_w,
33 cell_h: self.cell_h,
34 gap_width: self.gap_width,
35 cell_inset: self.cell_inset,
36 threshold: current.threshold,
37 }
38 }
39
40 pub fn matches(&self, p: &crate::snap::SnapParams) -> bool {
41 self.cell_w == p.cell_w
42 && self.cell_h == p.cell_h
43 && self.gap_width == p.gap_width
44 && self.cell_inset == p.cell_inset
45 }
46 }
47
48 #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
49 pub struct SavedWindowState {
50 pub app_id: String,
51 pub title: String,
52 pub tiling_mode: TilingMode,
53 pub minimized: bool,
54 pub virtual_x: f64,
55 pub virtual_y: f64,
56 pub scale: f64,
57 pub width: u32,
58 pub height: u32,
59 /// argv joined with spaces: what restore MATCHES on (program, app), and
60 /// what a state file from before `argv` relaunches. Not a shell command:
61 /// an argument holding a space, `$`, a quote or `;` reads differently
62 /// once joined.
63 pub cmdline: String,
64 #[serde(default)]
65 pub focused: bool,
66 /// The exact argv the window's process ran, which is what the session
67 /// restore relaunches (each argument quoted for the `sh -c` it runs
68 /// through). `None` in files saved before 2026-10-02, when the restore
69 /// ran `cmdline` itself through the shell: a file named `$(…).pdf` open
70 /// in a viewer then ran its command at the next login.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub argv: Option<Vec<String>>,
73 /// A fullscreen window's desk spot — the virtual origin of the output
74 /// rect it covers — which `virtual_x/y` cannot hold: those are where
75 /// the window goes when it LEAVES fullscreen. Restored, the window's
76 /// next fullscreen enter lands here rather than wherever the camera
77 /// is. `None` for a window that was not fullscreen, and in older files.
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub fullscreen_at: Option<(f64, f64)>,
80 }
81
82 #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
83 pub struct SavedState {
84 pub desk_pan_x: f64,
85 pub desk_pan_y: f64,
86 pub desk_zoom: f64,
87 pub windows: Vec<SavedWindowState>,
88 #[serde(default)]
89 pub last_window_states: Vec<SavedWindowState>,
90 /// See [`SavedGrid`]. `None` in pre-field files.
91 #[serde(default)]
92 pub grid: Option<SavedGrid>,
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98
99 /// A state file from before the grid field must still load — and one
100 /// with it must round-trip.
101 #[test]
102 fn legacy_state_without_grid_still_loads() {
103 let legacy = r#"{
104 "desk_pan_x": 0.0, "desk_pan_y": 0.0, "desk_zoom": 1.0,
105 "windows": [{
106 "app_id": "cce-terminal", "title": "t", "tiling_mode": "Tiled",
107 "minimized": false, "virtual_x": 4.0, "virtual_y": 4.0,
108 "scale": 1.0, "width": 1560, "height": 504, "cmdline": "cce-terminal"
109 }]
110 }"#;
111 let s: SavedState = serde_json::from_str(legacy).unwrap();
112 assert!(s.grid.is_none());
113 assert!(s.windows[0].argv.is_none(), "a file from before argv loads without one");
114 assert!(s.windows[0].fullscreen_at.is_none());
115 assert_eq!(s.windows.len(), 1);
116
117 let with_grid = SavedState {
118 grid: Some(SavedGrid { cell_w: 512.0, cell_h: 512.0, gap_width: 16.0, cell_inset: 4.0 }),
119 ..s
120 };
121 let json = serde_json::to_string(&with_grid).unwrap();
122 let back: SavedState = serde_json::from_str(&json).unwrap();
123 assert_eq!(back.grid, with_grid.grid);
124 }
125
126 /// The saved-grid remap keeps a Tiled entry on ITS SQUARES across a gap
127 /// change: 3 columns at gap 16 stays 3 columns at gap 8, where a raw
128 /// pixel restore would misalign and span 4.
129 #[test]
130 fn saved_grid_remap_keeps_the_block() {
131 let old = SavedGrid { cell_w: 512.0, cell_h: 512.0, gap_width: 16.0, cell_inset: 4.0 };
132 let new = crate::snap::SnapParams {
133 cell_w: 512.0, cell_h: 512.0, gap_width: 8.0, cell_inset: 4.0, threshold: 24.0,
134 };
135 // Column -5, 3 cells wide, 1 tall, under the old grid.
136 let (x, y, w, h) = (-5.0 * 528.0 + 4.0, 4.0, 2.0 * 528.0 + 512.0 - 8.0, 504.0);
137 let (nx, ny, nw, nh) =
138 crate::cells::remap_block(x, y, w, h, &old.to_params(&new), &new);
139 assert_eq!((nx, ny), (-5.0 * 520.0 + 4.0, 4.0));
140 assert_eq!((nw, nh), (2.0 * 520.0 + 512.0 - 8.0, 504.0));
141 }
142 }