git.lucas.co / cce-window-manager
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 }