window management library
git clone https://git.lucas.co/cce-window-manager.git
feat(state): record the grid the saved geometries were measured under
Saved window state stored pixel geometry with no record of the grid it
was measured under. Tiled-ness is geometric, so a window closed under one
grid and restored under another landed misaligned — its box poked into
neighbouring cells (8px per column of origin distance after a gap change)
and the tiled snap grew it by a cell per axis. The live remap
(retile_for_grid_change) never sees these windows: they are not mapped.
SavedState now carries SavedGrid (cell_w/cell_h/gap_width/cell_inset),
serde-defaulted so pre-field files still load — with nothing to remap
from, they restore as-is, which is exactly the old behavior. to_params
borrows the non-geometric threshold from the current params, since
remapping needs geometry only.
The compositor writes it on save and remaps Tiled entries through
cells::remap_block on load and on grid-config changes (its commit).
Co-Authored-By: Claude Fable 5 <[email protected]>
CLAUDE.md | 7 +++--
src/state.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 96 insertions(+), 2 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 7ba62d1..bc868ae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -216,8 +216,11 @@ The crate owns what a binding *means*; the compositor owns the physical half
then matched case-insensitively against app_ids with exact beating
substring. The mechanism supplies the candidates (mapped windows, in
window order); this module owns only the matching rules.
-- `state.rs` — `SavedState` / `SavedWindowState` serde types. New fields need
- `#[serde(default)]` to keep old state files loadable.
+- `state.rs` — `SavedState` / `SavedWindowState` serde types, plus
+ `SavedGrid`: the grid the file's geometries were measured under, so a
+ session under a different grid re-tiles Tiled entries onto their squares
+ (`cells::remap_block`) instead of growing them from misaligned pixels.
+ New fields need `#[serde(default)]` to keep old state files loadable.
- `slotmap.rs` — generational-index map (river-derived, 0BSD-licensed — keep the
SPDX header). `api::WindowId` wraps its `Key`.
diff --git a/src/state.rs b/src/state.rs
index 57dfc73..46d1211 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -4,6 +4,47 @@
use super::tiling::TilingMode;
+/// The desktop-grid geometry the geometries in this file were measured
+/// under. Saved so a LATER session under a different grid can re-tile a
+/// Tiled entry onto the same block of squares (`cells::remap_block`) instead
+/// of re-deriving its span from stale pixels — a box saved under one grid
+/// lands misaligned on another, touches extra cells, and the tiled snap
+/// then grows the window by a cell. Absent in files written before this
+/// field existed; the loader then has nothing to remap from and applies the
+/// geometry as-is.
+#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq)]
+pub struct SavedGrid {
+ pub cell_w: f64,
+ pub cell_h: f64,
+ pub gap_width: f64,
+ pub cell_inset: f64,
+}
+
+impl SavedGrid {
+ pub fn from_params(p: &crate::snap::SnapParams) -> Self {
+ Self { cell_w: p.cell_w, cell_h: p.cell_h, gap_width: p.gap_width, cell_inset: p.cell_inset }
+ }
+
+ /// The saved grid as snap params, borrowing everything non-geometric
+ /// (threshold) from `current` — remapping needs geometry only.
+ pub fn to_params(&self, current: &crate::snap::SnapParams) -> crate::snap::SnapParams {
+ crate::snap::SnapParams {
+ cell_w: self.cell_w,
+ cell_h: self.cell_h,
+ gap_width: self.gap_width,
+ cell_inset: self.cell_inset,
+ threshold: current.threshold,
+ }
+ }
+
+ pub fn matches(&self, p: &crate::snap::SnapParams) -> bool {
+ self.cell_w == p.cell_w
+ && self.cell_h == p.cell_h
+ && self.gap_width == p.gap_width
+ && self.cell_inset == p.cell_inset
+ }
+}
+
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct SavedWindowState {
pub app_id: String,
@@ -28,4 +69,54 @@ pub struct SavedState {
pub windows: Vec<SavedWindowState>,
#[serde(default)]
pub last_window_states: Vec<SavedWindowState>,
+ /// See [`SavedGrid`]. `None` in pre-field files.
+ #[serde(default)]
+ pub grid: Option<SavedGrid>,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A state file from before the grid field must still load — and one
+ /// with it must round-trip.
+ #[test]
+ fn legacy_state_without_grid_still_loads() {
+ let legacy = r#"{
+ "desk_pan_x": 0.0, "desk_pan_y": 0.0, "desk_zoom": 1.0,
+ "windows": [{
+ "app_id": "cce-terminal", "title": "t", "tiling_mode": "Tiled",
+ "minimized": false, "virtual_x": 4.0, "virtual_y": 4.0,
+ "scale": 1.0, "width": 1560, "height": 504, "cmdline": "cce-terminal"
+ }]
+ }"#;
+ let s: SavedState = serde_json::from_str(legacy).unwrap();
+ assert!(s.grid.is_none());
+ assert_eq!(s.windows.len(), 1);
+
+ let with_grid = SavedState {
+ grid: Some(SavedGrid { cell_w: 512.0, cell_h: 512.0, gap_width: 16.0, cell_inset: 4.0 }),
+ ..s
+ };
+ let json = serde_json::to_string(&with_grid).unwrap();
+ let back: SavedState = serde_json::from_str(&json).unwrap();
+ assert_eq!(back.grid, with_grid.grid);
+ }
+
+ /// The saved-grid remap keeps a Tiled entry on ITS SQUARES across a gap
+ /// change: 3 columns at gap 16 stays 3 columns at gap 8, where a raw
+ /// pixel restore would misalign and span 4.
+ #[test]
+ fn saved_grid_remap_keeps_the_block() {
+ let old = SavedGrid { cell_w: 512.0, cell_h: 512.0, gap_width: 16.0, cell_inset: 4.0 };
+ let new = crate::snap::SnapParams {
+ cell_w: 512.0, cell_h: 512.0, gap_width: 8.0, cell_inset: 4.0, threshold: 24.0,
+ };
+ // Column -5, 3 cells wide, 1 tall, under the old grid.
+ let (x, y, w, h) = (-5.0 * 528.0 + 4.0, 4.0, 2.0 * 528.0 + 512.0 - 8.0, 504.0);
+ let (nx, ny, nw, nh) =
+ crate::cells::remap_block(x, y, w, h, &old.to_params(&new), &new);
+ assert_eq!((nx, ny), (-5.0 * 520.0 + 4.0, 4.0));
+ assert_eq!((nw, nh), (2.0 * 520.0 + 512.0 - 8.0, 504.0));
+ }
}