git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

src/arrange.rs (86K)

   1 // Pure layout computation extracted from `WindowManager::arrange_views()`.
   2 //
   3 // Functions here take plain-data snapshots and return placement plans; the
   4 // mechanism side (`window_manager.rs`) builds the snapshots from FFI state and
   5 // applies the plans to the scene graph. `arrange()` is the whole frame in one
   6 // call — the future `Policy::arrange` entry point — composed from the
   7 // per-section functions below it.
   8 
   9 use super::api::{DecorationSpec, Rect, WindowRole};
  10 use super::tiling::TilingMode;
  11 
  12 /// `CCE_ARRANGE_DEBUG=1` — status-bar placement tracing. These sites were
  13 /// `info!`, so they wrote on every arrange regardless of log level, and a
  14 /// status-bar commit runs an arrange every second. Mirrors the mechanism-side
  15 /// switch of the same name in the compositor's `window_manager.rs`.
  16 fn arrange_debug() -> bool {
  17     static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
  18     *FLAG.get_or_init(|| std::env::var_os("CCE_ARRANGE_DEBUG").is_some())
  19 }
  20 
  21 /// Which screen edge/region a status-bar window docks to.
  22 /// Set from the app_id suffix or config; `Unspecified` resolves to `TopLeft`.
  23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  24 pub enum StatusEdge {
  25     Unspecified,
  26     TopLeft,
  27     TopCenter,
  28     TopRight,
  29     BottomLeft,
  30     BottomCenter,
  31     BottomRight,
  32     Left,
  33     Right,
  34 }
  35 
  36 /// Snapshot of one status-bar window, in `self.windows` iteration order.
  37 /// Windows being interactively dragged are excluded before layout.
  38 pub struct StatusBarItem {
  39     pub app_id: String,
  40     pub edge: StatusEdge,
  41     /// The segment's length along the bar axis. While a segment is expanded
  42     /// (see [`Self::expanded`]) the mechanism supplies the FROZEN collapsed
  43     /// length, so the slot the segment occupies — and every neighbor —
  44     /// stays put while the surface itself grows.
  45     pub prev_len: i32,
  46     /// The segment's committed thickness (perpendicular to the bar axis)
  47     /// exceeds the bar height: the client has grown its surface into an
  48     /// in-surface menu. The layout keeps the segment's slot but stops
  49     /// enforcing its size ([`StatusBarPlacement::enforce_size`]).
  50     pub expanded: bool,
  51 }
  52 
  53 #[derive(Debug, Clone, Copy, PartialEq)]
  54 pub struct StatusBarPlacement {
  55     pub x: i32,
  56     pub y: i32,
  57     /// Slot size. Only applied when [`Self::enforce_size`] is set.
  58     pub width: u32,
  59     pub height: u32,
  60     /// False for an expanded segment: position it, but leave its size to the
  61     /// client (the surface currently IS an open menu; configuring it back to
  62     /// the slot size would fight the client every commit).
  63     pub enforce_size: bool,
  64 }
  65 
  66 pub struct StatusBarLayoutParams {
  67     /// The output's layout box.
  68     pub output: Rect,
  69     pub bar_height: u32,
  70     pub hide_mode: bool,
  71     /// Pixels of bar left peeking when hide_mode pushes top bars offscreen.
  72     pub hide_mode_preview: i32,
  73     /// Gap between adjacent status segments (the bar's `module { spacing }`;
  74     /// [`DEFAULT_STATUS_MODULE_SPACING`] when unconfigured).
  75     pub spacing: i32,
  76     /// Local time as a fraction of the day (0 = midnight, 0.5 = noon).
  77     /// Some(_) sends the light_source segment traveling the screen
  78     /// perimeter — noon at top-center, counterclockwise (the sun's path:
  79     /// morning up the right edge, evening down the left), midnight at
  80     /// bottom-center. None keeps it in its configured edge group.
  81     pub day_fraction: Option<f64>,
  82 }
  83 
  84 /// The usable (tileable) area of an output: the layout box, shrunk by the
  85 /// layer-shell non-exclusive area and by one bar-height per screen edge that
  86 /// has a status bar docked to it. Top bars reserve no space in hide mode.
  87 ///
  88 /// `non_exclusive` is relative to the output box; a zero-sized rect means no
  89 /// layer-shell exclusion. `status_edges` holds the raw edge of every live
  90 /// status-bar window (`Unspecified` resolves to `TopLeft`).
  91 pub fn compute_usable_area(
  92     output: Rect,
  93     non_exclusive: Rect,
  94     bar_height: i32,
  95     status_hide_mode: bool,
  96     status_edges: &[StatusEdge],
  97 ) -> Rect {
  98     let mut usable = output;
  99 
 100     if non_exclusive.width > 0 && non_exclusive.height > 0 {
 101         usable.x = output.x + non_exclusive.x;
 102         usable.y = output.y + non_exclusive.y;
 103         usable.width = non_exclusive.width;
 104         usable.height = non_exclusive.height;
 105     }
 106 
 107     let mut has_top = false;
 108     let mut has_bottom = false;
 109     let mut has_left = false;
 110     let mut has_right = false;
 111 
 112     for &edge in status_edges {
 113         let edge = if edge == StatusEdge::Unspecified { StatusEdge::TopLeft } else { edge };
 114         match edge {
 115             StatusEdge::Unspecified | StatusEdge::TopLeft | StatusEdge::TopCenter | StatusEdge::TopRight => {
 116                 if !status_hide_mode {
 117                     has_top = true;
 118                 }
 119             }
 120             StatusEdge::BottomLeft | StatusEdge::BottomCenter | StatusEdge::BottomRight => {
 121                 has_bottom = true;
 122             }
 123             StatusEdge::Left => {
 124                 has_left = true;
 125             }
 126             StatusEdge::Right => {
 127                 has_right = true;
 128             }
 129         }
 130     }
 131 
 132     if has_top {
 133         usable.y += bar_height;
 134         usable.height -= bar_height;
 135     }
 136     if has_bottom {
 137         usable.height -= bar_height;
 138     }
 139     if has_left {
 140         usable.x += bar_height;
 141         usable.width -= bar_height;
 142     }
 143     if has_right {
 144         usable.width -= bar_height;
 145     }
 146 
 147     usable
 148 }
 149 
 150 /// How `arrange_views` treats a window this frame. `Background`/`StatusBar`
 151 /// windows get fixed geometry regardless of visibility; `Hidden` windows are
 152 /// disabled in the scene; `Overlay` windows get the overlay slot unless they
 153 /// are mid-drag (then they arrange as `Normal`).
 154 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 155 pub enum WindowClass {
 156     Background,
 157     StatusBar,
 158     /// The world-anchored grid client (role `Grid`): placed at its patch's
 159     /// virtual origin with scale `zoom / patch.scale`.
 160     Grid,
 161     Hidden,
 162     Overlay,
 163     Normal,
 164 }
 165 
 166 pub fn classify_window(
 167     role: WindowRole,
 168     minimized: bool,
 169     closing_or_init: bool,
 170     mode: TilingMode,
 171     is_moving: bool,
 172 ) -> WindowClass {
 173     match role {
 174         WindowRole::Background => WindowClass::Background,
 175         WindowRole::StatusBar => WindowClass::StatusBar,
 176         WindowRole::Grid => {
 177             if closing_or_init {
 178                 WindowClass::Hidden
 179             } else {
 180                 WindowClass::Grid
 181             }
 182         }
 183         _ => {
 184             if minimized || closing_or_init {
 185                 WindowClass::Hidden
 186             } else if mode == TilingMode::Overlay && !is_moving {
 187                 WindowClass::Overlay
 188             } else {
 189                 WindowClass::Normal
 190             }
 191         }
 192     }
 193 }
 194 
 195 /// Overlay windows keep 16px clear above for decorations.
 196 const OVERLAY_DEC_H: i32 = 16;
 197 
 198 /// Windows further than this many pixels outside the output are culled.
 199 const OFFSCREEN_MARGIN: f64 = 50.0;
 200 
 201 /// Opacity of an unfocused window's scene tree. Both are 1.0 — the same as
 202 /// focused — since 2026-09-03. They used to be 0.85 (overlay) and 0.90
 203 /// (normal), and on a translucent root plate that dimming did not read as
 204 /// "dimmer": it thinned the plate's tint, so the blurred backdrop showed
 205 /// through with more contrast and the window looked LESS frosted than the
 206 /// focused one, which then appeared to gain blur on every focus click. The
 207 /// blur radius never changed. Keep the knob (and `opacity_enabled`) so the
 208 /// level can be re-tuned without re-plumbing.
 209 pub const OVERLAY_UNFOCUSED_OPACITY: f32 = 1.0;
 210 pub const NORMAL_UNFOCUSED_OPACITY: f32 = 1.0;
 211 
 212 pub fn window_opacity(is_focused: bool, opacity_enabled: bool, unfocused: f32) -> f32 {
 213     if is_focused || !opacity_enabled { 1.0 } else { unfocused }
 214 }
 215 
 216 /// Per-output placement context: the physical box, the usable area from
 217 /// `compute_usable_area`, and the desktop viewport (pan/zoom).
 218 pub struct PlacementCtx {
 219     pub phys: Rect,
 220     pub usable: Rect,
 221     pub pan_x: f64,
 222     pub pan_y: f64,
 223     pub zoom: f64,
 224 }
 225 
 226 impl PlacementCtx {
 227     /// Rounds to the nearest pixel, the same way the background grid places
 228     /// its lattice (`background::grid_frame`). It used to truncate toward
 229     /// zero: a systematic half-pixel disagreement with the grid, flipping
 230     /// sign across the origin, so the lattice slid a pixel against window
 231     /// edges as the camera moved — and the client grid patch (placed through
 232     /// this same function) landed a pixel off the fallback lattice at every
 233     /// swap.
 234     fn virtual_to_screen(&self, vx: f64, vy: f64) -> (i32, i32) {
 235         (
 236             self.phys.x + ((vx - self.pan_x) * self.zoom).round() as i32,
 237             self.phys.y + ((vy - self.pan_y) * self.zoom).round() as i32,
 238         )
 239     }
 240 
 241     fn is_offscreen(&self, x: i32, y: i32, scaled_w: f64, scaled_h: f64) -> bool {
 242         let viewport_w = self.phys.width as f64;
 243         let viewport_h = self.phys.height as f64;
 244         (x as f64 + scaled_w + OFFSCREEN_MARGIN) < self.phys.x as f64
 245             || (x as f64 - OFFSCREEN_MARGIN) > (self.phys.x as f64 + viewport_w)
 246             || (y as f64 + scaled_h + OFFSCREEN_MARGIN) < self.phys.y as f64
 247             || (y as f64 - OFFSCREEN_MARGIN) > (self.phys.y as f64 + viewport_h)
 248     }
 249 }
 250 
 251 pub struct OverlaySnapshot {
 252     pub box_geom: Rect,
 253     pub min_width: i32,
 254     pub is_cloud: bool,
 255     pub ssd: bool,
 256     pub decorations_size: (i32, i32),
 257 }
 258 
 259 pub struct OverlayParams {
 260     pub overlay_width: i32,
 261     pub border_gap: i32,
 262     /// Server-side border width. Placement no longer insets by it (the border
 263     /// overhangs the content box); it only sets a floor on the decoration
 264     /// strip height reserved above the overlay slot.
 265     pub border_width: i32,
 266     pub position_right: bool,
 267     pub cloud_position_default: Option<[i32; 2]>,
 268 }
 269 
 270 pub struct OverlayPlacement {
 271     pub pos: (i32, i32),
 272     /// Persistent geometry to store back on the window, if placement chose it.
 273     pub box_geom_write: Option<Rect>,
 274     pub virtual_pos: (f64, f64),
 275     pub size: (u32, u32),
 276 }
 277 
 278 /// Place the primary overlay window: fresh windows get the configured overlay
 279 /// slot (left or right edge, full usable height); cloud windows snap to their
 280 /// configured default position; anything else keeps its stored geometry.
 281 ///
 282 /// The fresh slot is content-aligned: the content box sits directly on the
 283 /// configured gaps and the border, which draws outside it, overhangs them.
 284 /// Stored geometry behaves the same way.
 285 pub fn place_overlay_window(
 286     snap: &OverlaySnapshot,
 287     p: &OverlayParams,
 288     ctx: &PlacementCtx,
 289 ) -> OverlayPlacement {
 290     let bw = p.border_width.max(0);
 291     let g = p.border_gap;
 292     let dec_h = std::cmp::max(bw, OVERLAY_DEC_H);
 293 
 294     let mut sp_x = snap.box_geom.x;
 295     let mut sp_y = snap.box_geom.y;
 296     let mut sp_w = snap.box_geom.width;
 297     let mut sp_h = snap.box_geom.height;
 298     let mut box_geom_write = None;
 299 
 300     if sp_w == 0 || sp_h == 0 {
 301         sp_w = if snap.min_width > 32 {
 302             std::cmp::max(p.overlay_width, snap.min_width)
 303         } else {
 304             p.overlay_width
 305         };
 306         sp_h = (ctx.usable.height - dec_h - 2 * g).max(1);
 307 
 308         sp_x = if p.position_right {
 309             ctx.usable.x + ctx.usable.width - sp_w - g
 310         } else {
 311             ctx.usable.x + g
 312         };
 313         sp_y = ctx.usable.y + dec_h + g;
 314 
 315         box_geom_write = Some(Rect { x: sp_x, y: sp_y, width: sp_w, height: sp_h });
 316     } else if snap.is_cloud {
 317         if let Some(pos) = p.cloud_position_default {
 318             sp_x = ctx.usable.x + pos[0];
 319             sp_y = ctx.usable.y + pos[1];
 320             box_geom_write = Some(Rect { x: sp_x, y: sp_y, width: sp_w, height: sp_h });
 321         }
 322     }
 323 
 324     let vx = ctx.pan_x + (sp_x - ctx.phys.x) as f64 / ctx.zoom;
 325     let vy = ctx.pan_y + (sp_y - ctx.phys.y) as f64 / ctx.zoom;
 326 
 327     let mut target_w = sp_w;
 328     let mut target_h = sp_h;
 329     if !snap.ssd {
 330         let (dec_w, dec_h) = snap.decorations_size;
 331         target_w = (sp_w - dec_w).max(1);
 332         target_h = (sp_h - dec_h).max(1);
 333     }
 334 
 335     OverlayPlacement {
 336         pos: (sp_x, sp_y),
 337         box_geom_write,
 338         virtual_pos: (vx, vy),
 339         size: (target_w as u32, target_h as u32),
 340     }
 341 }
 342 
 343 /// State-machine step for entering/leaving Tiled mode. `Enter` tells the
 344 /// mechanism to save the given restore size (plus the window's current virtual
 345 /// position) and set `was_tiled`; `Exit` restores the saved geometry — the
 346 /// client-unmaximize path. (A geometric demotion — dragging a tiled window
 347 /// off-grid — clears `was_tiled` mechanism-side without an Exit, so the
 348 /// window stays where it was dropped.) Leaving with an invalid saved size
 349 /// does nothing (`was_tiled` stays set).
 350 #[derive(Debug, Clone, Copy, PartialEq)]
 351 pub enum TiledTransition {
 352     Enter { width: i32, height: i32 },
 353     Exit { width: i32, height: i32, virtual_x: f64, virtual_y: f64 },
 354 }
 355 
 356 pub fn tiled_transition(
 357     is_tiled_mode: bool,
 358     was_tiled: bool,
 359     box_size: (i32, i32),
 360     min_size: (i32, i32),
 361     saved_size: (i32, i32),
 362     saved_virtual: (f64, f64),
 363 ) -> Option<TiledTransition> {
 364     if is_tiled_mode && !was_tiled {
 365         let mut w = box_size.0;
 366         let mut h = box_size.1;
 367         if w <= 0 {
 368             w = if min_size.0 > 32 { min_size.0 } else { 800 };
 369         }
 370         if h <= 0 {
 371             h = if min_size.1 > 32 { min_size.1 } else { 600 };
 372         }
 373         Some(TiledTransition::Enter { width: w, height: h })
 374     } else if !is_tiled_mode && was_tiled {
 375         if saved_size.0 > 0 && saved_size.1 > 0 {
 376             Some(TiledTransition::Exit {
 377                 width: saved_size.0,
 378                 height: saved_size.1,
 379                 virtual_x: saved_virtual.0,
 380                 virtual_y: saved_virtual.1,
 381             })
 382         } else {
 383             None
 384         }
 385     } else {
 386         None
 387     }
 388 }
 389 
 390 pub struct NormalSnapshot {
 391     pub mode: TilingMode,
 392     pub box_geom: Rect,
 393     pub min_size: (i32, i32),
 394     pub virtual_pos: (f64, f64),
 395     /// Live interactive-resize dimensions, if a resize op is in progress.
 396     pub active_resize: Option<(u32, u32)>,
 397     pub is_cloud: bool,
 398 }
 399 
 400 pub struct NormalParams {
 401     pub gap_right: i32,
 402     pub gap_top: i32,
 403     pub cloud_position_default: Option<[i32; 2]>,
 404     /// Desktop grid cell width/height (each axis's period is cell + gap).
 405     pub desktop_cell_w: f64,
 406     pub desktop_cell_h: f64,
 407     /// Desktop grid gap between cells.
 408     pub desktop_gap_width: f64,
 409     /// Visual inset of a cell's edge (the fade inset); Tiled windows
 410     /// snap to the visible cell edges like interactive snapping does.
 411     pub desktop_cell_inset: f64,
 412 }
 413 
 414 pub struct NormalPlacement {
 415     pub pos: (i32, i32),
 416     pub scale: f64,
 417     pub size: (u32, u32),
 418     /// Fullscreen windows report all edges tiled to the client.
 419     pub tiled_all_edges: bool,
 420     /// Offscreen-culling result; `None` leaves the window's flag untouched.
 421     pub hidden: Option<bool>,
 422     /// Tiled grid-snap moves the window's virtual position.
 423     pub virtual_write: Option<(f64, f64)>,
 424 }
 425 
 426 /// Place a non-overlay window according to its tiling mode: `Popup` docks to
 427 /// the usable area's top-right (or the cloud default position), `Fullscreen`
 428 /// covers the physical output, `Tiled` snaps to cover every desktop-grid
 429 /// cell its current geometry touches, and everything else pans on the virtual
 430 /// surface under the current viewport.
 431 pub fn place_normal_window(
 432     snap: &NormalSnapshot,
 433     p: &NormalParams,
 434     ctx: &PlacementCtx,
 435 ) -> NormalPlacement {
 436     match snap.mode {
 437         TilingMode::Popup => {
 438             let fw = if snap.box_geom.width > 0 {
 439                 snap.box_geom.width
 440             } else if snap.min_size.0 > 32 {
 441                 snap.min_size.0
 442             } else {
 443                 360
 444             };
 445             let fh = if snap.box_geom.height > 0 {
 446                 snap.box_geom.height
 447             } else if snap.min_size.1 > 32 {
 448                 snap.min_size.1
 449             } else {
 450                 100
 451             };
 452 
 453             // The dock slot is content-aligned: the content box sits on the
 454             // configured gaps and the border overhangs them. Explicit cloud
 455             // positions are taken as content positions verbatim.
 456             let (fx, fy) = if snap.is_cloud && p.cloud_position_default.is_some() {
 457                 let pos = p.cloud_position_default.unwrap();
 458                 (ctx.usable.x + pos[0], ctx.usable.y + pos[1])
 459             } else {
 460                 (ctx.usable.x + ctx.usable.width - fw - p.gap_right, ctx.usable.y + p.gap_top)
 461             };
 462 
 463             NormalPlacement {
 464                 pos: (fx, fy),
 465                 scale: 1.0,
 466                 size: (fw as u32, fh as u32),
 467                 tiled_all_edges: false,
 468                 hidden: None,
 469                 virtual_write: None,
 470             }
 471         }
 472         TilingMode::Fullscreen => NormalPlacement {
 473             pos: (ctx.phys.x, ctx.phys.y),
 474             scale: 1.0,
 475             size: (ctx.phys.width as u32, ctx.phys.height as u32),
 476             tiled_all_edges: true,
 477             hidden: None,
 478             virtual_write: None,
 479         },
 480         TilingMode::Tiled => {
 481             // Cover the VISIBLE edges of every desktop-grid cell the current
 482             // geometry touches — the same cell-edge geometry as interactive
 483             // grid snapping (snap::tiled_span). A tiled window's geometry
 484             // is already cell-aligned (that's what makes it tiled), so this
 485             // is normally the identity; it aligns a client-requested maximize
 486             // and absorbs drift.
 487             let x1 = snap.virtual_pos.0;
 488             let y1 = snap.virtual_pos.1;
 489             let x2 = x1 + snap.box_geom.width.max(1) as f64;
 490             let y2 = y1 + snap.box_geom.height.max(1) as f64;
 491 
 492             let (low_x, high_x) = crate::snap::tiled_span(
 493                 x1, x2, p.desktop_cell_w, p.desktop_gap_width, p.desktop_cell_inset,
 494             );
 495             let (low_y, high_y) = crate::snap::tiled_span(
 496                 y1, y2, p.desktop_cell_h, p.desktop_gap_width, p.desktop_cell_inset,
 497             );
 498 
 499             // The content fills the covered cells edge to edge; the border
 500             // draws outside it and overhangs into the grid gap.
 501             let content_x = low_x;
 502             let content_y = low_y;
 503             let fw = (high_x - low_x).max(1.0);
 504             let fh = (high_y - low_y).max(1.0);
 505 
 506             let (final_x, final_y) = ctx.virtual_to_screen(content_x, content_y);
 507 
 508             NormalPlacement {
 509                 pos: (final_x, final_y),
 510                 scale: ctx.zoom,
 511                 size: (fw as u32, fh as u32),
 512                 tiled_all_edges: false,
 513                 hidden: Some(ctx.is_offscreen(final_x, final_y, fw * ctx.zoom, fh * ctx.zoom)),
 514                 virtual_write: Some((content_x, content_y)),
 515             }
 516         }
 517         _ => {
 518             // A fresh floating spawn (no established box, no resize in
 519             // flight) gets the xdg "you choose" size 0x0 instead of a guess:
 520             // the client maps at its natural size and the acked-commit path
 521             // adopts that as the box. Guessing from the min-size hint locked
 522             // self-sizing clients (every cce-ui app) to their minimum — they
 523             // obey any nonzero configure, so the guess became the box forever.
 524             if matches!(snap.mode, TilingMode::Floating | TilingMode::Utility)
 525                 && snap.active_resize.is_none()
 526                 && snap.box_geom.width <= 0
 527                 && snap.box_geom.height <= 0
 528             {
 529                 let (final_x, final_y) = ctx.virtual_to_screen(snap.virtual_pos.0, snap.virtual_pos.1);
 530                 return NormalPlacement {
 531                     pos: (final_x, final_y),
 532                     scale: ctx.zoom,
 533                     size: (0, 0),
 534                     tiled_all_edges: false,
 535                     // Unmapped until its first buffer; sizeless culling would
 536                     // be meaningless, so leave the flag untouched.
 537                     hidden: None,
 538                     virtual_write: None,
 539                 };
 540             }
 541 
 542             // Regular pannable window on the virtual surface.
 543             let fw = if let Some(resize_size) = snap.active_resize {
 544                 resize_size.0 as i32
 545             } else if snap.box_geom.width > 0 {
 546                 snap.box_geom.width
 547             } else if snap.min_size.0 > 32 {
 548                 snap.min_size.0
 549             } else {
 550                 800
 551             };
 552             let fh = if let Some(resize_size) = snap.active_resize {
 553                 resize_size.1 as i32
 554             } else if snap.box_geom.height > 0 {
 555                 snap.box_geom.height
 556             } else if snap.min_size.1 > 32 {
 557                 snap.min_size.1
 558             } else {
 559                 600
 560             };
 561 
 562             let (final_x, final_y) = ctx.virtual_to_screen(snap.virtual_pos.0, snap.virtual_pos.1);
 563 
 564             // A Utility window's size is the client's alone: the plan restates
 565             // the "you choose" 0x0 on EVERY pass, so the compositor never
 566             // dictates a size to it — not from a restore, not after an output
 567             // or scale change (the client re-commits at the new scale and the
 568             // commit path adopts that as the box). The cull still uses the
 569             // real box; only the configure size is withheld.
 570             let size = if snap.mode == TilingMode::Utility {
 571                 (0, 0)
 572             } else {
 573                 (fw as u32, fh as u32)
 574             };
 575 
 576             NormalPlacement {
 577                 pos: (final_x, final_y),
 578                 scale: ctx.zoom,
 579                 size,
 580                 tiled_all_edges: false,
 581                 hidden: Some(ctx.is_offscreen(final_x, final_y, fw as f64 * ctx.zoom, fh as f64 * ctx.zoom)),
 582                 virtual_write: None,
 583             }
 584         }
 585     }
 586 }
 587 
 588 /// Default gap between adjacent status segments — the fallback for
 589 /// [`StatusBarLayoutParams::spacing`] / [`ArrangeParams::status_module_spacing`].
 590 pub const DEFAULT_STATUS_MODULE_SPACING: i32 = 12;
 591 const MARGIN: i32 = 12;
 592 
 593 const LEFT_ORDER: &[&str] = &["viewport", "window"];
 594 // `stats` is the bar's combined readout segment (cpu, memory, brightness,
 595 // volume, wifi and battery in one bubble, 2026-09-16; wifi joined
 596 // 2026-10-02); the six single names stay valid for a bar that still runs
 597 // them separately.
 598 const RIGHT_ORDER: &[&str] = &["tray", "stats", "cpu", "memory", "brightness", "volume", "wifi", "battery", "clock"];
 599 
 600 fn left_sort_key(app_id: &str) -> usize {
 601     let name = app_id.strip_prefix("cce-status-interface-left-")
 602         .or_else(|| app_id.strip_prefix("cce-status-left-"))
 603         .unwrap_or(app_id);
 604     LEFT_ORDER.iter().position(|&m| m == name).unwrap_or(99)
 605 }
 606 
 607 fn right_sort_key(app_id: &str) -> usize {
 608     let name = app_id.strip_prefix("cce-status-interface-right-")
 609         .or_else(|| app_id.strip_prefix("cce-status-right-"))
 610         .unwrap_or(app_id);
 611     RIGHT_ORDER.iter().position(|&m| m == name).unwrap_or(99)
 612 }
 613 
 614 /// Bar length to use: previous major length, or 100 for a fresh bar.
 615 fn bar_len(prev_len: i32) -> u32 {
 616     if prev_len > 0 { prev_len as u32 } else { 100 }
 617 }
 618 
 619 fn is_light_source(app_id: &str) -> bool {
 620     app_id.ends_with("light_source")
 621 }
 622 
 623 /// A point on the output-rect perimeter for the traveling light_source
 624 /// segment, plus the edge it lies on (0 top, 1 left, 2 bottom, 3 right).
 625 /// `day` is the local day fraction (0 = midnight); the path runs
 626 /// counterclockwise from top-center at noon.
 627 fn sun_perimeter_point(output: Rect, day: f64) -> (f64, f64, u8) {
 628     let bw = output.width as f64;
 629     let bh = output.height as f64;
 630     // Perimeter fraction from top-center: noon → 0, 18:00 → ¼ (mid left
 631     // edge), midnight → ½ (bottom center), 06:00 → ¾ (mid right edge).
 632     let f = (day + 0.5).fract();
 633     let mut s = f * 2.0 * (bw + bh);
 634     if s < bw / 2.0 {
 635         return (bw / 2.0 - s, 0.0, 0);
 636     }
 637     s -= bw / 2.0;
 638     if s < bh {
 639         return (0.0, s, 1);
 640     }
 641     s -= bh;
 642     if s < bw {
 643         return (s, bh, 2);
 644     }
 645     s -= bw;
 646     if s < bh {
 647         return (bw, bh - s, 3);
 648     }
 649     s -= bh;
 650     (bw - s, 0.0, 0)
 651 }
 652 
 653 /// Lay out status-bar windows on one output: horizontal groups on the top and
 654 /// bottom edges (left/center/right within each), vertical stacks on the left
 655 /// and right edges, and full-width bars across the top.
 656 ///
 657 /// Returns one placement per item, index-aligned with `items`.
 658 pub fn layout_status_bars(
 659     items: &[StatusBarItem],
 660     p: &StatusBarLayoutParams,
 661 ) -> Vec<Option<StatusBarPlacement>> {
 662     let wlr_box = p.output;
 663     let bar_h = p.bar_height;
 664     let spacing = p.spacing;
 665     let margin = MARGIN;
 666 
 667     let mut top_left: Vec<usize> = Vec::new();
 668     let mut top_center: Vec<usize> = Vec::new();
 669     let mut top_right: Vec<usize> = Vec::new();
 670     let mut bottom_left: Vec<usize> = Vec::new();
 671     let mut bottom_center: Vec<usize> = Vec::new();
 672     let mut bottom_right: Vec<usize> = Vec::new();
 673     let mut left_side: Vec<usize> = Vec::new();
 674     let mut right_side: Vec<usize> = Vec::new();
 675     let mut full_top: Vec<usize> = Vec::new();
 676     let mut sun_track: Vec<usize> = Vec::new();
 677 
 678     for (idx, item) in items.iter().enumerate() {
 679         // The light_source segment ignores edge groups: it travels the
 680         // screen perimeter with the time of day (see sun_perimeter_point).
 681         if p.day_fraction.is_some() && is_light_source(&item.app_id) {
 682             sun_track.push(idx);
 683             continue;
 684         }
 685         let edge = if item.edge == StatusEdge::Unspecified {
 686             StatusEdge::TopLeft
 687         } else {
 688             item.edge
 689         };
 690         match edge {
 691             StatusEdge::TopLeft => top_left.push(idx),
 692             StatusEdge::TopCenter => top_center.push(idx),
 693             StatusEdge::TopRight => top_right.push(idx),
 694             StatusEdge::BottomLeft => bottom_left.push(idx),
 695             StatusEdge::BottomCenter => bottom_center.push(idx),
 696             StatusEdge::BottomRight => bottom_right.push(idx),
 697             StatusEdge::Left => left_side.push(idx),
 698             StatusEdge::Right => right_side.push(idx),
 699             _ => full_top.push(idx),
 700         }
 701     }
 702 
 703     if arrange_debug() {
 704         log::debug!("[ArrangeStatus] top_left_len={}, top_center_len={}, top_right_len={}, left_side_len={}", top_left.len(), top_center.len(), top_right.len(), left_side.len());
 705     }
 706 
 707     let sort_left = |list: &mut Vec<usize>| {
 708         list.sort_by_key(|&i| left_sort_key(&items[i].app_id));
 709     };
 710     let sort_right = |list: &mut Vec<usize>| {
 711         list.sort_by_key(|&i| right_sort_key(&items[i].app_id));
 712     };
 713 
 714     sort_left(&mut top_left);
 715     sort_left(&mut top_center);
 716     sort_right(&mut top_right);
 717     sort_left(&mut bottom_left);
 718     sort_left(&mut bottom_center);
 719     sort_right(&mut bottom_right);
 720 
 721     let mut placements: Vec<Option<StatusBarPlacement>> = vec![None; items.len()];
 722 
 723     // 1. Top Edge
 724     let status_y_top = if p.hide_mode {
 725         wlr_box.y - (bar_h as i32 - p.hide_mode_preview)
 726     } else {
 727         wlr_box.y
 728     };
 729 
 730     let mut top_right_width_needed = 0;
 731     for &i in &top_right {
 732         let w = bar_len(items[i].prev_len);
 733         top_right_width_needed += w as i32 + spacing;
 734     }
 735     let top_right_boundary = wlr_box.x + wlr_box.width - margin - top_right_width_needed;
 736 
 737     // 1a. Left Group (TopLeft / nw)
 738     let mut cur_left_x = wlr_box.x + margin;
 739     for &i in &top_left {
 740         let mut w = bar_len(items[i].prev_len);
 741         let max_allowed_w = top_right_boundary - cur_left_x - spacing;
 742         if w as i32 > max_allowed_w {
 743             w = std::cmp::max(max_allowed_w, 20) as u32;
 744         }
 745         if arrange_debug() {
 746             log::debug!("[TopLeftLayout] app_id={} x={}, w={}", items[i].app_id, cur_left_x, w);
 747         }
 748         placements[i] = Some(StatusBarPlacement { x: cur_left_x, y: status_y_top, width: w, height: bar_h, enforce_size: !items[i].expanded });
 749         cur_left_x += w as i32 + spacing;
 750     }
 751 
 752     // 1b. Center Group (TopCenter / n)
 753     let mut top_center_width_needed = 0;
 754     for &i in &top_center {
 755         let w = bar_len(items[i].prev_len);
 756         top_center_width_needed += w as i32 + spacing;
 757     }
 758     if top_center_width_needed > 0 {
 759         top_center_width_needed -= spacing;
 760     }
 761     let center_start_x = wlr_box.x + (wlr_box.width - top_center_width_needed) / 2;
 762     let mut cur_center_x = std::cmp::max(center_start_x, cur_left_x + spacing);
 763 
 764     for &i in &top_center {
 765         let mut w = bar_len(items[i].prev_len);
 766         let max_allowed_w = top_right_boundary - cur_center_x - spacing;
 767         if w as i32 > max_allowed_w {
 768             w = std::cmp::max(max_allowed_w, 20) as u32;
 769         }
 770         log::info!("[TopCenterLayout] app_id={} x={}, w={}", items[i].app_id, cur_center_x, w);
 771         placements[i] = Some(StatusBarPlacement { x: cur_center_x, y: status_y_top, width: w, height: bar_h, enforce_size: !items[i].expanded });
 772         cur_center_x += w as i32 + spacing;
 773     }
 774 
 775     // 1c. Right Group (TopRight / ne)
 776     let mut cur_right_x = wlr_box.x + wlr_box.width - margin;
 777     for &i in top_right.iter().rev() {
 778         let w = bar_len(items[i].prev_len);
 779         let x = cur_right_x - w as i32;
 780         placements[i] = Some(StatusBarPlacement { x, y: status_y_top, width: w, height: bar_h, enforce_size: !items[i].expanded });
 781         cur_right_x = x - spacing;
 782     }
 783 
 784     for &i in &full_top {
 785         placements[i] = Some(StatusBarPlacement { x: wlr_box.x, y: status_y_top, width: wlr_box.width as u32, height: bar_h, enforce_size: !items[i].expanded });
 786     }
 787 
 788     // 2. Bottom Edge
 789     let status_y_bottom = wlr_box.y + wlr_box.height - bar_h as i32;
 790 
 791     let mut bottom_right_width_needed = 0;
 792     for &i in &bottom_right {
 793         let w = bar_len(items[i].prev_len);
 794         bottom_right_width_needed += w as i32 + spacing;
 795     }
 796     let bottom_right_boundary = wlr_box.x + wlr_box.width - margin - bottom_right_width_needed;
 797 
 798     // 2a. Left Group (BottomLeft / sw)
 799     let mut cur_left_x = wlr_box.x + margin;
 800     for &i in &bottom_left {
 801         let mut w = bar_len(items[i].prev_len);
 802         let max_allowed_w = bottom_right_boundary - cur_left_x - spacing;
 803         if w as i32 > max_allowed_w {
 804             w = std::cmp::max(max_allowed_w, 20) as u32;
 805         }
 806         placements[i] = Some(StatusBarPlacement { x: cur_left_x, y: status_y_bottom, width: w, height: bar_h, enforce_size: !items[i].expanded });
 807         cur_left_x += w as i32 + spacing;
 808     }
 809 
 810     // 2b. Center Group (BottomCenter / s)
 811     let mut bottom_center_width_needed = 0;
 812     for &i in &bottom_center {
 813         let w = bar_len(items[i].prev_len);
 814         bottom_center_width_needed += w as i32 + spacing;
 815     }
 816     if bottom_center_width_needed > 0 {
 817         bottom_center_width_needed -= spacing;
 818     }
 819     let center_start_x = wlr_box.x + (wlr_box.width - bottom_center_width_needed) / 2;
 820     let mut cur_center_x = std::cmp::max(center_start_x, cur_left_x + spacing);
 821 
 822     for &i in &bottom_center {
 823         let mut w = bar_len(items[i].prev_len);
 824         let max_allowed_w = bottom_right_boundary - cur_center_x - spacing;
 825         if w as i32 > max_allowed_w {
 826             w = std::cmp::max(max_allowed_w, 20) as u32;
 827         }
 828         placements[i] = Some(StatusBarPlacement { x: cur_center_x, y: status_y_bottom, width: w, height: bar_h, enforce_size: !items[i].expanded });
 829         cur_center_x += w as i32 + spacing;
 830     }
 831 
 832     // 2c. Right Group (BottomRight / se)
 833     let mut cur_right_x = wlr_box.x + wlr_box.width - margin;
 834     for &i in bottom_right.iter().rev() {
 835         let w = bar_len(items[i].prev_len);
 836         let x = cur_right_x - w as i32;
 837         placements[i] = Some(StatusBarPlacement { x, y: status_y_bottom, width: w, height: bar_h, enforce_size: !items[i].expanded });
 838         cur_right_x = x - spacing;
 839     }
 840 
 841     // 3. Left Edge (Vertical stacking)
 842     let mut left_total_height = 0;
 843     for &i in &left_side {
 844         let actual_h = bar_len(items[i].prev_len);
 845         left_total_height += actual_h as i32;
 846     }
 847     if !left_side.is_empty() {
 848         left_total_height += (left_side.len() as i32 - 1) * spacing;
 849     }
 850     let mut cur_left_y = wlr_box.y + (wlr_box.height - left_total_height) / 2;
 851 
 852     for &i in &left_side {
 853         let actual_h = bar_len(items[i].prev_len);
 854         placements[i] = Some(StatusBarPlacement { x: wlr_box.x, y: cur_left_y, width: bar_h, height: actual_h, enforce_size: !items[i].expanded });
 855         cur_left_y += actual_h as i32 + spacing;
 856     }
 857 
 858     // 4. Right Edge (Vertical stacking)
 859     let mut right_total_height = 0;
 860     for &i in &right_side {
 861         let actual_h = bar_len(items[i].prev_len);
 862         right_total_height += actual_h as i32;
 863     }
 864     if !right_side.is_empty() {
 865         right_total_height += (right_side.len() as i32 - 1) * spacing;
 866     }
 867     let mut cur_right_y = wlr_box.y + (wlr_box.height - right_total_height) / 2;
 868 
 869     for &i in &right_side {
 870         let actual_h = bar_len(items[i].prev_len);
 871         placements[i] = Some(StatusBarPlacement { x: wlr_box.x + wlr_box.width - bar_h as i32, y: cur_right_y, width: bar_h, height: actual_h, enforce_size: !items[i].expanded });
 872         cur_right_y += actual_h as i32 + spacing;
 873     }
 874 
 875     // 4. The traveling light_source segment: center the (always-horizontal)
 876     // segment box on the day-fraction perimeter point, clamped inside the
 877     // output so the corners are turned smoothly. In hide mode it slips off
 878     // its current edge like every other segment, leaving the same preview.
 879     if let Some(day) = p.day_fraction {
 880         for &i in &sun_track {
 881             let w = bar_len(items[i].prev_len) ;
 882             let (px, py, edge) = sun_perimeter_point(wlr_box, day);
 883             let mut x = (wlr_box.x as f64 + px - w as f64 / 2.0).round() as i32;
 884             let mut y = (wlr_box.y as f64 + py - bar_h as f64 / 2.0).round() as i32;
 885             x = x.clamp(wlr_box.x, wlr_box.x + wlr_box.width - w as i32);
 886             y = y.clamp(wlr_box.y, wlr_box.y + wlr_box.height - bar_h as i32);
 887             if p.hide_mode {
 888                 match edge {
 889                     0 => y = wlr_box.y - (bar_h as i32 - p.hide_mode_preview),
 890                     1 => x = wlr_box.x - (w as i32 - p.hide_mode_preview),
 891                     2 => y = wlr_box.y + wlr_box.height - p.hide_mode_preview,
 892                     _ => x = wlr_box.x + wlr_box.width - p.hide_mode_preview,
 893                 }
 894             }
 895             placements[i] = Some(StatusBarPlacement { x, y, width: w, height: bar_h, enforce_size: !items[i].expanded });
 896         }
 897     }
 898 
 899     placements
 900 }
 901 
 902 /// One enabled output, as `arrange` needs it.
 903 #[derive(Debug, Clone, Copy)]
 904 pub struct OutputSnapshot {
 905     /// The output's layout box.
 906     pub layout_box: Rect,
 907     /// Layer-shell non-exclusive area, relative to the layout box; a
 908     /// zero-sized rect means no exclusion.
 909     pub non_exclusive: Rect,
 910 }
 911 
 912 /// Everything `arrange` reads about one window. Built once per frame by the
 913 /// mechanism side; seat-dependent answers (`being_moved`, `active_resize`)
 914 /// are captured here so the pure pass never queries mid-computation.
 915 #[derive(Debug, Clone)]
 916 pub struct WindowSnapshot {
 917     pub app_id: Option<String>,
 918     /// Only used verbatim in log lines.
 919     pub title: Option<String>,
 920     pub role: WindowRole,
 921     pub minimized: bool,
 922     /// Window state is Closing or Init.
 923     pub closing_or_init: bool,
 924     /// Resolved tiling mode (`get_mode_for_window`).
 925     pub mode: TilingMode,
 926     /// Status segments only: the along-bar length last committed while the
 927     /// segment was at bar thickness, tracked by the mechanism. Keeps the
 928     /// segment's slot stable while it is EXPANDED (surface grown into an
 929     /// in-surface menu); 0 when never collapsed-committed (fall back to the
 930     /// live box).
 931     pub status_collapsed_len: i32,
 932     /// Mode-rule SSD override; pre-gated on `!mode_locked`.
 933     pub rule_ssd: Option<bool>,
 934     /// A seat is interactively moving this window.
 935     pub being_moved: bool,
 936     pub status_edge: StatusEdge,
 937     pub is_focused: bool,
 938     pub box_geom: Rect,
 939     /// (min_width, min_height) size hints.
 940     pub min_size: (i32, i32),
 941     pub virtual_pos: (f64, f64),
 942     pub active_resize: Option<(u32, u32)>,
 943     /// Current `wm_requested.ssd`.
 944     pub ssd: bool,
 945     /// Raw decoration measurement (`measure_decorations`), not gated on
 946     /// `ssd` — placement applies it only when the effective SSD is off.
 947     pub decorations_size: (i32, i32),
 948     pub was_tiled: bool,
 949     pub saved_floating_size: (i32, i32),
 950     pub saved_floating_virtual: (f64, f64),
 951     /// Grid-role windows only: the latched world-anchored patch the current
 952     /// buffer covers. `None` until the first rendered patch arrives (the
 953     /// window stays out of the scene until then).
 954     pub grid_patch: Option<crate::api::GridPatch>,
 955 }
 956 
 957 /// Frame-wide inputs: config knobs plus the desktop viewport.
 958 pub struct ArrangeParams {
 959     pub bar_height: i32,
 960     pub status_hide_mode: bool,
 961     pub hide_mode_preview: i32,
 962     /// Gap between adjacent status segments (see `StatusBarLayoutParams::spacing`).
 963     pub status_module_spacing: i32,
 964     /// Local day fraction for the traveling light_source segment (see
 965     /// `StatusBarLayoutParams::day_fraction`).
 966     pub day_fraction: Option<f64>,
 967     /// `status_background_blur > 0.001`.
 968     pub status_blur: bool,
 969     pub window_blur: bool,
 970     /// `layout.window_opacity` — unfocused windows dim when set.
 971     pub opacity_enabled: bool,
 972     /// The configured server-side decoration, applied to every placed window.
 973     pub decoration: DecorationSpec,
 974     /// Border color for the focused window (falls back to the unfocused
 975     /// color in config when unset).
 976     pub border_color_focused: crate::api::Rgba,
 977     pub overlay: OverlayParams,
 978     pub normal: NormalParams,
 979     pub pan_x: f64,
 980     pub pan_y: f64,
 981     pub zoom: f64,
 982 }
 983 
 984 /// Write instructions for one window. `None` leaves the field untouched, so
 985 /// the mechanism apply loop is a flat sequence of `if let Some` writes.
 986 #[derive(Debug, Clone, Default, PartialEq)]
 987 pub struct WindowPlan {
 988     /// Enable/disable the window's scene tree.
 989     pub scene_enabled: Option<bool>,
 990     pub hidden: Option<bool>,
 991     pub tiling_mode: Option<TilingMode>,
 992     /// `wm_requested.tiled` edge bitmask.
 993     pub tiled: Option<u32>,
 994     pub ssd: Option<bool>,
 995     pub scale: Option<f64>,
 996     /// `rendering_requested.{x,y}`.
 997     pub pos: Option<(i32, i32)>,
 998     /// `wm_requested.dimensions` and `.bounds`.
 999     pub size: Option<(u32, u32)>,
1000     /// Persistent geometry write-back.
1001     pub box_geom: Option<Rect>,
1002     pub virtual_pos: Option<(f64, f64)>,
1003     pub blur: Option<bool>,
1004     pub decoration: Option<DecorationSpec>,
1005     pub opacity: Option<f32>,
1006     pub was_tiled: Option<bool>,
1007     /// Tiled-enter save: (restore size, restore virtual position).
1008     pub saved_floating: Option<((i32, i32), (f64, f64))>,
1009 }
1010 
1011 pub struct ArrangePlan {
1012     /// Index-aligned with the input snapshots.
1013     pub windows: Vec<WindowPlan>,
1014     /// Whether each output's fallback background rect should be shown
1015     /// (false while a wallpaper window exists).
1016     pub background_rect_enabled: bool,
1017     /// Whether the compositor-drawn cell lattice should be shown: false
1018     /// while a live grid client (role Grid, mapped, with a latched patch)
1019     /// covers the desktop. The gap-colored backdrop stays either way.
1020     pub grid_cells_enabled: bool,
1021 }
1022 
1023 fn is_cloud_app(app_id: Option<&str>) -> bool {
1024     app_id.map_or(false, |id| id.starts_with("cce-cloud"))
1025 }
1026 
1027 /// The decoration one placed window gets: the configured spec with the
1028 /// focused border color swapped in, and no border at all when fullscreen.
1029 fn decoration_for(p: &ArrangeParams, is_focused: bool, mode: TilingMode) -> DecorationSpec {
1030     if mode == TilingMode::Fullscreen {
1031         return DecorationSpec {
1032             border_width: 0,
1033             ..p.decoration
1034         };
1035     }
1036     DecorationSpec {
1037         border_color: if is_focused {
1038             p.border_color_focused
1039         } else {
1040             p.decoration.border_color
1041         },
1042         ..p.decoration
1043     }
1044 }
1045 
1046 /// The whole arrange pass as one pure function: classify every window, place
1047 /// overlays/normals/status bars per output, and return per-window write
1048 /// instructions.
1049 ///
1050 /// The output loop is last-wins, like the mechanism loop it replaces: every
1051 /// output pass re-plans every window, so with several outputs the final plan
1052 /// reflects the last one. State that arranging itself evolves (box geometry,
1053 /// virtual position, SSD overrides, the tiled save/restore machine) is
1054 /// tracked on a working copy of the snapshots so later sections and later
1055 /// output passes read what earlier ones wrote — exactly as the mutating
1056 /// original did.
1057 pub fn arrange(
1058     windows: &[WindowSnapshot],
1059     outputs: &[OutputSnapshot],
1060     p: &ArrangeParams,
1061 ) -> ArrangePlan {
1062     let mut state: Vec<WindowSnapshot> = windows.to_vec();
1063     let mut plan: Vec<WindowPlan> = vec![WindowPlan::default(); windows.len()];
1064 
1065     let has_wallpaper = state.iter().any(|w| w.role == WindowRole::Background);
1066     let has_grid_client = state.iter().any(|w| {
1067         w.role == WindowRole::Grid && !w.closing_or_init && !w.minimized && w.grid_patch.is_some()
1068     });
1069 
1070     for out in outputs {
1071         let phys = out.layout_box;
1072 
1073         let status_edges: Vec<StatusEdge> = state
1074             .iter()
1075             .filter(|w| w.role == WindowRole::StatusBar)
1076             .map(|w| w.status_edge)
1077             .collect();
1078         let usable = compute_usable_area(
1079             phys,
1080             out.non_exclusive,
1081             p.bar_height,
1082             p.status_hide_mode,
1083             &status_edges,
1084         );
1085         let ctx = PlacementCtx {
1086             phys,
1087             usable,
1088             pan_x: p.pan_x,
1089             pan_y: p.pan_y,
1090             zoom: p.zoom,
1091         };
1092 
1093         let mut overlay_windows: Vec<usize> = Vec::new();
1094         let mut normal_windows: Vec<usize> = Vec::new();
1095 
1096         for (i, w) in state.iter_mut().enumerate() {
1097             let class = classify_window(w.role, w.minimized, w.closing_or_init, w.mode, w.being_moved);
1098             let wp = &mut plan[i];
1099             match class {
1100                 WindowClass::Background => {
1101                     wp.tiling_mode = Some(TilingMode::Status);
1102                     wp.tiled = Some(0);
1103                     wp.ssd = Some(false);
1104                     w.ssd = false;
1105                     wp.scale = Some(1.0);
1106                     wp.scene_enabled = Some(true);
1107                     wp.hidden = Some(false);
1108                     wp.blur = Some(false);
1109                     wp.pos = Some((phys.x, phys.y));
1110                     wp.size = Some((phys.width as u32, phys.height as u32));
1111                 }
1112                 WindowClass::StatusBar => {
1113                     wp.tiling_mode = Some(TilingMode::Status);
1114                     wp.tiled = Some(0);
1115                     wp.ssd = Some(false);
1116                     w.ssd = false;
1117                     wp.scale = Some(1.0);
1118                     wp.scene_enabled = Some(true);
1119                     wp.hidden = Some(false);
1120                     wp.blur = Some(p.status_blur);
1121                 }
1122                 WindowClass::Grid => {
1123                     match w.grid_patch {
1124                         Some(patch) if patch.scale > 0.0 => {
1125                             wp.scene_enabled = Some(true);
1126                             wp.hidden = Some(false);
1127                             wp.tiled = Some(0);
1128                             wp.ssd = Some(false);
1129                             w.ssd = false;
1130                             let (sx, sy) = ctx.virtual_to_screen(patch.x, patch.y);
1131                             wp.pos = Some((sx, sy));
1132                             // The buffer holds patch.scale px per virtual
1133                             // unit; displaying it at zoom/patch.scale puts
1134                             // it in per-frame lockstep with window content.
1135                             wp.scale = Some(ctx.zoom / patch.scale);
1136                             // The content box IS the buffer: dest sizing and
1137                             // culling read box_geom, which nothing else
1138                             // maintains for a window the compositor never
1139                             // configures.
1140                             wp.box_geom = Some(Rect {
1141                                 x: sx,
1142                                 y: sy,
1143                                 width: (patch.w * patch.scale).round() as i32,
1144                                 height: (patch.h * patch.scale).round() as i32,
1145                             });
1146                             wp.blur = Some(false);
1147                             wp.opacity = Some(1.0);
1148                         }
1149                         _ => {
1150                             // No rendered patch yet: keep it out of the
1151                             // scene — no flash of an unanchored buffer. The
1152                             // xdg "you choose" size unblocks the map state
1153                             // machine (a window with NO planned dimensions
1154                             // can never leave Ready — same trick as Utility)
1155                             // but is planned ONLY in this pre-patch phase:
1156                             // once a patch is latched the client owns its
1157                             // size, and re-sending 0x0 per arrange bounced
1158                             // the surface between the settings size and the
1159                             // patch size — a swapchain-thrash that ate GBs.
1160                             wp.size = Some((0, 0));
1161                             wp.scene_enabled = Some(false);
1162                             wp.hidden = Some(true);
1163                         }
1164                     }
1165                 }
1166                 WindowClass::Hidden => {
1167                     wp.scene_enabled = Some(false);
1168                     wp.hidden = Some(true);
1169                 }
1170                 WindowClass::Overlay | WindowClass::Normal => {
1171                     wp.scene_enabled = Some(true);
1172                     wp.hidden = Some(false);
1173                     wp.tiling_mode = Some(w.mode);
1174                     if let Some(rule_ssd) = w.rule_ssd {
1175                         wp.ssd = Some(rule_ssd);
1176                         w.ssd = rule_ssd;
1177                     }
1178                     if class == WindowClass::Overlay {
1179                         overlay_windows.push(i);
1180                     } else {
1181                         normal_windows.push(i);
1182                     }
1183                 }
1184             }
1185         }
1186 
1187         for (sp_idx, &i) in overlay_windows.iter().enumerate() {
1188             if sp_idx == 0 {
1189                 let w = &state[i];
1190                 let placement = place_overlay_window(
1191                     &OverlaySnapshot {
1192                         box_geom: w.box_geom,
1193                         min_width: w.min_size.0,
1194                         is_cloud: is_cloud_app(w.app_id.as_deref()),
1195                         ssd: w.ssd,
1196                         decorations_size: w.decorations_size,
1197                     },
1198                     &p.overlay,
1199                     &ctx,
1200                 );
1201 
1202                 if let Some(bg) = placement.box_geom_write {
1203                     state[i].box_geom = bg;
1204                 }
1205                 state[i].virtual_pos = placement.virtual_pos;
1206 
1207                 let wp = &mut plan[i];
1208                 if let Some(bg) = placement.box_geom_write {
1209                     wp.box_geom = Some(bg);
1210                 }
1211                 wp.pos = Some(placement.pos);
1212                 wp.scale = Some(1.0);
1213                 wp.virtual_pos = Some(placement.virtual_pos);
1214                 wp.size = Some(placement.size);
1215                 wp.tiled = Some(1 | 2 | 4 | 8);
1216                 wp.decoration = Some(decoration_for(p, state[i].is_focused, state[i].mode));
1217                 wp.blur = Some(p.window_blur);
1218                 wp.opacity = Some(window_opacity(
1219                     state[i].is_focused,
1220                     p.opacity_enabled,
1221                     OVERLAY_UNFOCUSED_OPACITY,
1222                 ));
1223             } else {
1224                 normal_windows.push(i);
1225             }
1226         }
1227 
1228         // Manage entering/exiting Tiled state for normal windows.
1229         for &i in &normal_windows {
1230             let w = &state[i];
1231             let transition = tiled_transition(
1232                 w.mode == TilingMode::Tiled,
1233                 w.was_tiled,
1234                 (w.box_geom.width, w.box_geom.height),
1235                 w.min_size,
1236                 w.saved_floating_size,
1237                 w.saved_floating_virtual,
1238             );
1239             match transition {
1240                 Some(TiledTransition::Enter { width, height }) => {
1241                     let w = &mut state[i];
1242                     w.saved_floating_size = (width, height);
1243                     w.saved_floating_virtual = w.virtual_pos;
1244                     w.was_tiled = true;
1245                     let wp = &mut plan[i];
1246                     wp.saved_floating = Some(((width, height), w.saved_floating_virtual));
1247                     wp.was_tiled = Some(true);
1248                     log::info!("[Tiled] Saved window {:?} geometry: {}x{} at ({}, {})",
1249                         w.title.as_deref().unwrap_or(""),
1250                         width, height,
1251                         w.saved_floating_virtual.0, w.saved_floating_virtual.1
1252                     );
1253                 }
1254                 Some(TiledTransition::Exit { width, height, virtual_x, virtual_y }) => {
1255                     let w = &mut state[i];
1256                     w.box_geom.width = width;
1257                     w.box_geom.height = height;
1258                     w.virtual_pos = (virtual_x, virtual_y);
1259                     w.was_tiled = false;
1260                     let wp = &mut plan[i];
1261                     wp.box_geom = Some(w.box_geom);
1262                     wp.virtual_pos = Some((virtual_x, virtual_y));
1263                     wp.was_tiled = Some(false);
1264                     wp.size = Some((width as u32, height as u32));
1265                     log::info!("[Tiled] Restored window {:?} geometry: {}x{} at ({}, {})",
1266                         w.title.as_deref().unwrap_or(""),
1267                         width, height, virtual_x, virtual_y
1268                     );
1269                 }
1270                 None => {}
1271             }
1272         }
1273 
1274         // Arrange normal windows on the virtual surface.
1275         for &i in &normal_windows {
1276             let w = &state[i];
1277             let mode = w.mode;
1278             let placement = place_normal_window(
1279                 &NormalSnapshot {
1280                     mode: w.mode,
1281                     box_geom: w.box_geom,
1282                     min_size: w.min_size,
1283                     virtual_pos: w.virtual_pos,
1284                     active_resize: w.active_resize,
1285                     is_cloud: is_cloud_app(w.app_id.as_deref()),
1286                 },
1287                 &p.normal,
1288                 &ctx,
1289             );
1290 
1291             let is_focused = w.is_focused;
1292             if let Some(v) = placement.virtual_write {
1293                 state[i].virtual_pos = v;
1294             }
1295 
1296             let wp = &mut plan[i];
1297             wp.pos = Some(placement.pos);
1298             wp.scale = Some(placement.scale);
1299             if let Some(v) = placement.virtual_write {
1300                 wp.virtual_pos = Some(v);
1301             }
1302             if let Some(hidden) = placement.hidden {
1303                 wp.hidden = Some(hidden);
1304             }
1305             wp.size = Some(placement.size);
1306             if placement.tiled_all_edges {
1307                 wp.tiled = Some(1 | 2 | 4 | 8);
1308             }
1309             wp.decoration = Some(decoration_for(p, is_focused, mode));
1310             wp.blur = Some(p.window_blur);
1311             wp.opacity = Some(window_opacity(is_focused, p.opacity_enabled, NORMAL_UNFOCUSED_OPACITY));
1312         }
1313 
1314         // Position status bar windows on this output, excluding any being
1315         // interactively dragged.
1316         let mut status_items: Vec<StatusBarItem> = Vec::new();
1317         let mut status_idxs: Vec<usize> = Vec::new();
1318         for (i, w) in state.iter().enumerate() {
1319             if w.closing_or_init {
1320                 continue;
1321             }
1322             if let Some(app_id) = &w.app_id {
1323                 if app_id.starts_with("cce-status") {
1324                     if w.being_moved {
1325                         continue;
1326                     }
1327                     if arrange_debug() {
1328                         log::debug!("[ArrangeStatus] app_id={} status_edge={:?}", app_id, w.status_edge);
1329                     }
1330                     // Thickness = the axis perpendicular to the segment's
1331                     // edge; a segment thicker than the bar has grown an
1332                     // in-surface menu (expanded) and keeps its FROZEN
1333                     // collapsed slot length instead of the live box.
1334                     let (len, thickness) = match w.status_edge {
1335                         StatusEdge::Left | StatusEdge::Right => (w.box_geom.height, w.box_geom.width),
1336                         _ => (w.box_geom.width, w.box_geom.height),
1337                     };
1338                     let expanded = thickness > p.bar_height && w.status_collapsed_len > 0;
1339                     status_items.push(StatusBarItem {
1340                         app_id: app_id.clone(),
1341                         edge: w.status_edge,
1342                         prev_len: if expanded { w.status_collapsed_len } else { len },
1343                         expanded,
1344                     });
1345                     status_idxs.push(i);
1346                 }
1347             }
1348         }
1349 
1350         let placements = layout_status_bars(
1351             &status_items,
1352             &StatusBarLayoutParams {
1353                 output: phys,
1354                 bar_height: p.bar_height as u32,
1355                 hide_mode: p.status_hide_mode,
1356                 hide_mode_preview: p.hide_mode_preview,
1357                 spacing: p.status_module_spacing,
1358                 day_fraction: p.day_fraction,
1359             },
1360         );
1361 
1362         for (&i, placement) in status_idxs.iter().zip(placements.iter()) {
1363             if let Some(pl) = placement {
1364                 plan[i].pos = Some((pl.x, pl.y));
1365                 // An expanded segment keeps client-owned sizing: scheduling
1366                 // the slot size would fight the open menu every commit.
1367                 plan[i].size = pl.enforce_size.then_some((pl.width, pl.height));
1368             }
1369         }
1370     }
1371 
1372     ArrangePlan {
1373         windows: plan,
1374         background_rect_enabled: !has_wallpaper,
1375         grid_cells_enabled: !has_grid_client,
1376     }
1377 }
1378 
1379 #[cfg(test)]
1380 mod tests {
1381     use super::*;
1382 
1383     fn params() -> StatusBarLayoutParams {
1384         StatusBarLayoutParams {
1385             output: Rect { x: 0, y: 0, width: 1920, height: 1080 },
1386             bar_height: 30,
1387             hide_mode: false,
1388             hide_mode_preview: 5,
1389             spacing: DEFAULT_STATUS_MODULE_SPACING,
1390             day_fraction: None,
1391         }
1392     }
1393 
1394     fn item(app_id: &str, edge: StatusEdge, prev_len: i32) -> StatusBarItem {
1395         StatusBarItem { app_id: app_id.to_string(), edge, prev_len, expanded: false }
1396     }
1397 
1398     #[test]
1399     fn light_source_travels_the_perimeter() {
1400         // 1920x1080 output, bar 30, segment len 36. Noon → top center,
1401         // 18:00 → mid left edge, midnight → bottom center, 06:00 → mid
1402         // right edge; counterclockwise in between.
1403         let items = vec![item("cce-status-left-light_source", StatusEdge::TopLeft, 36)];
1404         let mut p = params();
1405 
1406         p.day_fraction = Some(0.5); // noon
1407         let pl = layout_status_bars(&items, &p)[0].unwrap();
1408         assert_eq!((pl.x, pl.y), (1920 / 2 - 18, 0));
1409 
1410         p.day_fraction = Some(0.75); // 18:00 — mid left edge
1411         let pl = layout_status_bars(&items, &p)[0].unwrap();
1412         assert_eq!((pl.x, pl.y), (0, 1080 / 2 - 15));
1413 
1414         p.day_fraction = Some(0.0); // midnight — bottom center
1415         let pl = layout_status_bars(&items, &p)[0].unwrap();
1416         assert_eq!((pl.x, pl.y), (1920 / 2 - 18, 1080 - 30));
1417 
1418         p.day_fraction = Some(0.25); // 06:00 — mid right edge
1419         let pl = layout_status_bars(&items, &p)[0].unwrap();
1420         assert_eq!((pl.x, pl.y), (1920 - 36, 1080 / 2 - 15));
1421 
1422         // Shortly after noon the segment is still on the top edge, left of
1423         // center (counterclockwise = leftward along the top).
1424         p.day_fraction = Some(0.51);
1425         let pl = layout_status_bars(&items, &p)[0].unwrap();
1426         assert_eq!(pl.y, 0);
1427         assert!(pl.x < 1920 / 2 - 18);
1428 
1429         // Without a day fraction it stays in its configured edge group.
1430         p.day_fraction = None;
1431         let pl = layout_status_bars(&items, &p)[0].unwrap();
1432         assert_eq!((pl.x, pl.y), (12, 0));
1433     }
1434 
1435     #[test]
1436     fn top_groups_flow_from_edges() {
1437         let items = vec![
1438             item("cce-status-left-viewport", StatusEdge::TopLeft, 0),
1439             item("cce-status-left-window", StatusEdge::TopLeft, 0),
1440             item("cce-status-right-clock", StatusEdge::TopRight, 200),
1441         ];
1442         let p = layout_status_bars(&items, &params());
1443         // Left group flows right from the margin; fresh bars default to width 100.
1444         assert_eq!(p[0], Some(StatusBarPlacement { x: 12, y: 0, width: 100, height: 30, enforce_size: true }));
1445         assert_eq!(p[1], Some(StatusBarPlacement { x: 124, y: 0, width: 100, height: 30, enforce_size: true }));
1446         // Right group is placed from the right edge inward.
1447         assert_eq!(p[2], Some(StatusBarPlacement { x: 1908 - 200, y: 0, width: 200, height: 30, enforce_size: true }));
1448     }
1449 
1450     #[test]
1451     fn expanded_segment_keeps_slot_and_client_size() {
1452         // battery expanded (in-surface menu open): its slot still consumes the
1453         // frozen collapsed length (100), so the neighbor (memory, left of it)
1454         // does not shift — and its placement stops enforcing size.
1455         let mut battery = item("cce-status-right-battery", StatusEdge::TopRight, 100);
1456         battery.expanded = true;
1457         let items = vec![
1458             item("cce-status-right-clock", StatusEdge::TopRight, 200),
1459             battery,
1460             item("cce-status-right-memory", StatusEdge::TopRight, 150),
1461         ];
1462         let p = layout_status_bars(&items, &params());
1463         // Right-to-left: clock at the edge, battery next, memory after —
1464         // identical x positions to the collapsed layout.
1465         assert_eq!(p[0].unwrap().x, 1908 - 200);
1466         let bat = p[1].unwrap();
1467         assert_eq!(bat.x, 1908 - 200 - 12 - 100);
1468         assert!(!bat.enforce_size, "expanded segment is position-only");
1469         let mem = p[2].unwrap();
1470         assert_eq!(mem.x, 1908 - 200 - 12 - 100 - 12 - 150);
1471         assert!(mem.enforce_size);
1472     }
1473 
1474     #[test]
1475     fn right_group_sorted_by_module_order() {
1476         let items = vec![
1477             item("cce-status-right-clock", StatusEdge::TopRight, 100),
1478             item("cce-status-right-battery", StatusEdge::TopRight, 100),
1479         ];
1480         let p = layout_status_bars(&items, &params());
1481         // RIGHT_ORDER puts battery before clock left-to-right, so clock hugs the edge.
1482         assert_eq!(p[0].unwrap().x, 1808);
1483         assert_eq!(p[1].unwrap().x, 1808 - 12 - 100);
1484     }
1485 
1486     #[test]
1487     fn hide_mode_pushes_top_bars_offscreen_with_preview() {
1488         let mut prm = params();
1489         prm.hide_mode = true;
1490         let items = vec![item("cce-status-left-viewport", StatusEdge::Unspecified, 0)];
1491         let p = layout_status_bars(&items, &prm);
1492         // Unspecified resolves to TopLeft; y = 0 - (30 - 5).
1493         assert_eq!(p[0].unwrap().y, -25);
1494     }
1495 
1496     #[test]
1497     fn usable_area_reserves_bar_edges() {
1498         let output = Rect { x: 0, y: 0, width: 1920, height: 1080 };
1499         let no_excl = Rect { x: 0, y: 0, width: 0, height: 0 };
1500 
1501         // No bars, no exclusion: the full output.
1502         assert_eq!(compute_usable_area(output, no_excl, 30, false, &[]), output);
1503 
1504         // Top + left bars each reserve one bar-height.
1505         let edges = [StatusEdge::TopLeft, StatusEdge::Left];
1506         assert_eq!(
1507             compute_usable_area(output, no_excl, 30, false, &edges),
1508             Rect { x: 30, y: 30, width: 1890, height: 1050 }
1509         );
1510 
1511         // Hide mode releases the top reservation but not the others.
1512         assert_eq!(
1513             compute_usable_area(output, no_excl, 30, true, &edges),
1514             Rect { x: 30, y: 0, width: 1890, height: 1080 }
1515         );
1516 
1517         // Layer-shell non-exclusive area applies before bar reservations.
1518         let excl = Rect { x: 10, y: 20, width: 1900, height: 1040 };
1519         assert_eq!(
1520             compute_usable_area(output, excl, 30, false, &[StatusEdge::BottomCenter]),
1521             Rect { x: 10, y: 20, width: 1900, height: 1010 }
1522         );
1523     }
1524 
1525     #[test]
1526     fn window_roles_from_app_id() {
1527         assert_eq!(WindowRole::from_app_id(Some("cce-wallpaper")), WindowRole::Background);
1528         assert_eq!(WindowRole::from_app_id(Some("cce-status-interface-right-clock")), WindowRole::StatusBar);
1529         assert_eq!(WindowRole::from_app_id(Some("firefox")), WindowRole::Normal);
1530         assert_eq!(WindowRole::from_app_id(None), WindowRole::Normal);
1531     }
1532 
1533     #[test]
1534     fn classification_precedence() {
1535         // Role wins over visibility: a minimized wallpaper still arranges as background.
1536         assert_eq!(
1537             classify_window(WindowRole::Background, true, true, TilingMode::Floating, false),
1538             WindowClass::Background
1539         );
1540         assert_eq!(
1541             classify_window(WindowRole::StatusBar, false, false, TilingMode::Floating, false),
1542             WindowClass::StatusBar
1543         );
1544         // Minimized or closing/init normal windows are hidden.
1545         assert_eq!(
1546             classify_window(WindowRole::Normal, true, false, TilingMode::Floating, false),
1547             WindowClass::Hidden
1548         );
1549         // Overlay mode gets the overlay slot — unless mid-drag.
1550         assert_eq!(
1551             classify_window(WindowRole::Normal, false, false, TilingMode::Overlay, false),
1552             WindowClass::Overlay
1553         );
1554         assert_eq!(
1555             classify_window(WindowRole::Normal, false, false, TilingMode::Overlay, true),
1556             WindowClass::Normal
1557         );
1558         assert_eq!(
1559             classify_window(WindowRole::Normal, false, false, TilingMode::Tiled, false),
1560             WindowClass::Normal
1561         );
1562     }
1563 
1564     fn ctx() -> PlacementCtx {
1565         PlacementCtx {
1566             phys: Rect { x: 0, y: 0, width: 1920, height: 1080 },
1567             usable: Rect { x: 0, y: 30, width: 1920, height: 1050 },
1568             pan_x: 0.0,
1569             pan_y: 0.0,
1570             zoom: 1.0,
1571         }
1572     }
1573 
1574     #[test]
1575     fn fresh_overlay_gets_configured_slot() {
1576         let placement = place_overlay_window(
1577             &OverlaySnapshot {
1578                 box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
1579                 min_width: 0,
1580                 is_cloud: false,
1581                 ssd: true,
1582                 decorations_size: (0, 16),
1583             },
1584             &OverlayParams {
1585                 overlay_width: 400,
1586                 border_gap: 8,
1587                 border_width: 0,
1588                 position_right: true,
1589                 cloud_position_default: None,
1590             },
1591             &ctx(),
1592         );
1593         // Right slot: x = usable right edge - width - gap; height fills the
1594         // usable area minus the 16px decoration strip and both gaps.
1595         assert_eq!(placement.pos, (1512, 54));
1596         assert_eq!(placement.size, (400, 1018));
1597         assert_eq!(placement.box_geom_write, Some(Rect { x: 1512, y: 54, width: 400, height: 1018 }));
1598         assert_eq!(placement.virtual_pos, (1512.0, 54.0));
1599     }
1600 
1601     #[test]
1602     fn fresh_overlay_slot_ignores_border_width() {
1603         let placement = place_overlay_window(
1604             &OverlaySnapshot {
1605                 box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
1606                 min_width: 0,
1607                 is_cloud: false,
1608                 ssd: true,
1609                 decorations_size: (0, 16),
1610             },
1611             &OverlayParams {
1612                 overlay_width: 400,
1613                 border_gap: 8,
1614                 border_width: 4,
1615                 position_right: true,
1616                 cloud_position_default: None,
1617             },
1618             &ctx(),
1619         );
1620         // Placement no longer insets by the border: the content box lands on
1621         // the gaps exactly as it does with no border, and the border overhangs
1622         // outward. (bw 4 < OVERLAY_DEC_H 16, so the decoration strip is
1623         // unaffected too.)
1624         assert_eq!(placement.pos, (1512, 54));
1625         assert_eq!(placement.size, (400, 1018));
1626     }
1627 
1628     #[test]
1629     fn overlay_without_ssd_shrinks_by_decorations() {
1630         let placement = place_overlay_window(
1631             &OverlaySnapshot {
1632                 box_geom: Rect { x: 100, y: 100, width: 400, height: 500 },
1633                 min_width: 0,
1634                 is_cloud: false,
1635                 ssd: false,
1636                 decorations_size: (2, 18),
1637             },
1638             &OverlayParams { overlay_width: 400, border_gap: 8, border_width: 0, position_right: false, cloud_position_default: None },
1639             &ctx(),
1640         );
1641         // Existing geometry is kept; the client is sized minus decorations.
1642         assert_eq!(placement.pos, (100, 100));
1643         assert_eq!(placement.size, (398, 482));
1644         assert_eq!(placement.box_geom_write, None);
1645     }
1646 
1647     #[test]
1648     fn tiled_transitions() {
1649         // Entering with no usable geometry falls back to 800x600.
1650         assert_eq!(
1651             tiled_transition(true, false, (0, 0), (0, 0), (0, 0), (0.0, 0.0)),
1652             Some(TiledTransition::Enter { width: 800, height: 600 })
1653         );
1654         // Entering keeps real geometry.
1655         assert_eq!(
1656             tiled_transition(true, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)),
1657             Some(TiledTransition::Enter { width: 640, height: 480 })
1658         );
1659         // Steady states do nothing.
1660         assert_eq!(tiled_transition(true, true, (640, 480), (0, 0), (640, 480), (0.0, 0.0)), None);
1661         assert_eq!(tiled_transition(false, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)), None);
1662         // Exit restores the saved geometry; invalid saved size is a no-op.
1663         assert_eq!(
1664             tiled_transition(false, true, (0, 0), (0, 0), (640, 480), (10.0, 20.0)),
1665             Some(TiledTransition::Exit { width: 640, height: 480, virtual_x: 10.0, virtual_y: 20.0 })
1666         );
1667         assert_eq!(tiled_transition(false, true, (0, 0), (0, 0), (0, 480), (10.0, 20.0)), None);
1668     }
1669 
1670     #[test]
1671     fn tiled_snaps_to_grid_cells() {
1672         let snap = NormalSnapshot {
1673             mode: TilingMode::Tiled,
1674             box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
1675             min_size: (0, 0),
1676             virtual_pos: (150.0, 120.0),
1677             active_resize: None,
1678             is_cloud: false,
1679         };
1680         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
1681         let placement = place_normal_window(&snap, &p, &ctx());
1682         // Current geometry spans grid columns 1-2 and row 1 → snapped to
1683         // (100,100) with size 200x100.
1684         assert_eq!(placement.virtual_write, Some((100.0, 100.0)));
1685         assert_eq!(placement.pos, (100, 100));
1686         assert_eq!(placement.size, (200, 100));
1687         assert_eq!(placement.hidden, Some(false));
1688         assert_eq!(placement.scale, 1.0);
1689     }
1690 
1691     #[test]
1692     fn tiled_ignores_border_width() {
1693         let snap = NormalSnapshot {
1694             mode: TilingMode::Tiled,
1695             box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
1696             min_size: (0, 0),
1697             virtual_pos: (150.0, 120.0),
1698             active_resize: None,
1699             is_cloud: false,
1700         };
1701         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
1702         let placement = place_normal_window(&snap, &p, &ctx());
1703         // The content fills the covered cells (100,100)+200x100 exactly; the
1704         // border draws outside that and overhangs into the grid gap.
1705         assert_eq!(placement.virtual_write, Some((100.0, 100.0)));
1706         assert_eq!(placement.pos, (100, 100));
1707         assert_eq!(placement.size, (200, 100));
1708     }
1709 
1710     #[test]
1711     fn tiled_snaps_to_visible_cell_edges() {
1712         let snap = NormalSnapshot {
1713             mode: TilingMode::Tiled,
1714             box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
1715             min_size: (0, 0),
1716             virtual_pos: (150.0, 120.0),
1717             active_resize: None,
1718             is_cloud: false,
1719         };
1720         // period 110 (gap 10), inset 5: cells x 1-2 visibly span [115, 315],
1721         // row y 1 spans [115, 205]; the content fills them edge to edge.
1722         let p = NormalParams {
1723             gap_right: 10,
1724             gap_top: 6,
1725             cloud_position_default: None,
1726             desktop_cell_w: 100.0,
1727             desktop_cell_h: 100.0,
1728             desktop_gap_width: 10.0,
1729             desktop_cell_inset: 5.0,
1730         };
1731         let placement = place_normal_window(&snap, &p, &ctx());
1732         assert_eq!(placement.virtual_write, Some((115.0, 115.0)));
1733         assert_eq!(placement.pos, (115, 115));
1734         assert_eq!(placement.size, (200, 90));
1735     }
1736 
1737     #[test]
1738     fn popup_docks_top_right_of_usable_area() {
1739         let snap = NormalSnapshot {
1740             mode: TilingMode::Popup,
1741             box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
1742             min_size: (0, 0),
1743             virtual_pos: (0.0, 0.0),
1744             active_resize: None,
1745             is_cloud: false,
1746         };
1747         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
1748         let placement = place_normal_window(&snap, &p, &ctx());
1749         // Defaults to 360x100, docked inside the usable area (below the bar).
1750         assert_eq!(placement.pos, (1920 - 360 - 10, 30 + 6));
1751         assert_eq!(placement.size, (360, 100));
1752         assert_eq!(placement.hidden, None);
1753     }
1754 
1755     #[test]
1756     fn fresh_floating_window_gets_client_chosen_size() {
1757         // No established box, no resize in flight: the placement is the xdg
1758         // "you choose" 0x0, NOT the min-size hint — a self-sizing client
1759         // obeys any nonzero configure, so a min-size guess would become the
1760         // box forever.
1761         let snap = NormalSnapshot {
1762             mode: TilingMode::Floating,
1763             box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
1764             min_size: (320, 240),
1765             virtual_pos: (100.0, 200.0),
1766             active_resize: None,
1767             is_cloud: false,
1768         };
1769         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
1770         let placement = place_normal_window(&snap, &p, &ctx());
1771         assert_eq!(placement.size, (0, 0));
1772         assert_eq!(placement.hidden, None);
1773 
1774         // Once the box is established (the acked commit adopted the client's
1775         // geometry), the placement keeps it.
1776         let established = NormalSnapshot {
1777             box_geom: Rect { x: 0, y: 0, width: 900, height: 700 },
1778             ..snap
1779         };
1780         let placement = place_normal_window(&established, &p, &ctx());
1781         assert_eq!(placement.size, (900, 700));
1782 
1783         // Non-floating modes keep the min-size fallback: their sizes are
1784         // dictated by tiling, not chosen by the client.
1785         let other = NormalSnapshot { mode: TilingMode::Overlay, ..established };
1786         let other = NormalSnapshot { box_geom: Rect { x: 0, y: 0, width: 0, height: 0 }, ..other };
1787         let placement = place_normal_window(&other, &p, &ctx());
1788         assert_eq!(placement.size, (320, 240));
1789     }
1790 
1791     #[test]
1792     fn utility_window_size_is_always_client_chosen() {
1793         // A Utility window restates the "you choose" 0x0 on EVERY pass — even
1794         // with an established box — so the compositor can never dictate a
1795         // size to it (a restored size, an output change). The established box
1796         // still drives the offscreen cull.
1797         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
1798         let fresh = NormalSnapshot {
1799             mode: TilingMode::Utility,
1800             box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
1801             min_size: (320, 240),
1802             virtual_pos: (100.0, 200.0),
1803             active_resize: None,
1804             is_cloud: false,
1805         };
1806         let placement = place_normal_window(&fresh, &p, &ctx());
1807         assert_eq!(placement.size, (0, 0));
1808         assert_eq!(placement.hidden, None);
1809 
1810         let established = NormalSnapshot {
1811             box_geom: Rect { x: 0, y: 0, width: 520, height: 896 },
1812             ..fresh
1813         };
1814         let placement = place_normal_window(&established, &p, &ctx());
1815         assert_eq!(placement.size, (0, 0));
1816         // The cull is computed (from the real box), unlike the unmapped case.
1817         assert!(placement.hidden.is_some());
1818     }
1819 
1820     #[test]
1821     fn pannable_window_follows_viewport() {
1822         let snap = NormalSnapshot {
1823             mode: TilingMode::Overlay,
1824             box_geom: Rect { x: 0, y: 0, width: 640, height: 480 },
1825             min_size: (0, 0),
1826             virtual_pos: (100.0, 200.0),
1827             active_resize: None,
1828             is_cloud: false,
1829         };
1830         let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_cell_w: 100.0, desktop_cell_h: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
1831         let mut c = ctx();
1832         c.pan_x = 50.0;
1833         c.pan_y = 100.0;
1834         c.zoom = 2.0;
1835         let placement = place_normal_window(&snap, &p, &c);
1836         assert_eq!(placement.pos, (100, 200));
1837         assert_eq!(placement.scale, 2.0);
1838         assert_eq!(placement.size, (640, 480));
1839         assert_eq!(placement.hidden, Some(false));
1840 
1841         // Pan far enough away and the window is culled.
1842         c.pan_x = 5000.0;
1843         let placement = place_normal_window(&snap, &p, &c);
1844         assert_eq!(placement.hidden, Some(true));
1845 
1846         // Interactive resize dimensions override stored geometry.
1847         let resizing = NormalSnapshot { active_resize: Some((800, 600)), ..snap };
1848         c.pan_x = 50.0;
1849         let placement = place_normal_window(&resizing, &p, &c);
1850         assert_eq!(placement.size, (800, 600));
1851     }
1852 
1853     #[test]
1854     fn opacity_policy() {
1855         // Focused, or dimming disabled: always fully opaque.
1856         assert_eq!(window_opacity(true, true, 0.5), 1.0);
1857         assert_eq!(window_opacity(false, false, 0.5), 1.0);
1858         // Unfocused with dimming enabled: the given level passes through.
1859         assert_eq!(window_opacity(false, true, 0.5), 0.5);
1860         // The DE levels: an unfocused window looks exactly like a focused one.
1861         assert_eq!(window_opacity(false, true, OVERLAY_UNFOCUSED_OPACITY), 1.0);
1862         assert_eq!(window_opacity(false, true, NORMAL_UNFOCUSED_OPACITY), 1.0);
1863     }
1864 
1865     fn snap(app_id: &str) -> WindowSnapshot {
1866         WindowSnapshot {
1867             app_id: Some(app_id.to_string()),
1868             title: None,
1869             role: WindowRole::from_app_id(Some(app_id)),
1870             minimized: false,
1871             closing_or_init: false,
1872             mode: TilingMode::Floating,
1873             status_collapsed_len: 0,
1874             rule_ssd: None,
1875             being_moved: false,
1876             status_edge: StatusEdge::Unspecified,
1877             is_focused: false,
1878             box_geom: Rect { x: 0, y: 0, width: 640, height: 480 },
1879             min_size: (0, 0),
1880             virtual_pos: (100.0, 200.0),
1881             active_resize: None,
1882             ssd: true,
1883             decorations_size: (0, 16),
1884             was_tiled: false,
1885             saved_floating_size: (0, 0),
1886             saved_floating_virtual: (0.0, 0.0),
1887             grid_patch: None,
1888         }
1889     }
1890 
1891     fn arrange_params() -> ArrangeParams {
1892         ArrangeParams {
1893             bar_height: 30,
1894             status_hide_mode: false,
1895             hide_mode_preview: 5,
1896             status_module_spacing: DEFAULT_STATUS_MODULE_SPACING,
1897             day_fraction: None,
1898             status_blur: true,
1899             window_blur: true,
1900             opacity_enabled: true,
1901             decoration: DecorationSpec {
1902                 border_width: 0,
1903                 border_color: crate::api::Rgba([0.1, 0.2, 0.3, 0.4]),
1904             },
1905             border_color_focused: crate::api::Rgba([0.9, 0.1, 0.1, 1.0]),
1906             overlay: OverlayParams {
1907                 overlay_width: 400,
1908                 border_gap: 8,
1909                 border_width: 0,
1910                 position_right: true,
1911                 cloud_position_default: None,
1912             },
1913             normal: NormalParams {
1914                 gap_right: 10,
1915                 gap_top: 6,
1916                 cloud_position_default: None,
1917                 desktop_cell_w: 100.0,
1918             desktop_cell_h: 100.0,
1919                 desktop_gap_width: 0.0,
1920                 desktop_cell_inset: 0.0,
1921             },
1922             pan_x: 0.0,
1923             pan_y: 0.0,
1924             zoom: 1.0,
1925         }
1926     }
1927 
1928     fn one_output() -> Vec<OutputSnapshot> {
1929         vec![OutputSnapshot {
1930             layout_box: Rect { x: 0, y: 0, width: 1920, height: 1080 },
1931             non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
1932         }]
1933     }
1934 
1935     #[test]
1936     fn arrange_background_covers_output_and_disables_fallback_rect() {
1937         let windows = vec![snap("cce-wallpaper"), snap("firefox")];
1938         let plan = arrange(&windows, &one_output(), &arrange_params());
1939 
1940         assert!(!plan.background_rect_enabled);
1941         let wp = &plan.windows[0];
1942         assert_eq!(wp.tiling_mode, Some(TilingMode::Status));
1943         assert_eq!(wp.tiled, Some(0));
1944         assert_eq!(wp.ssd, Some(false));
1945         assert_eq!(wp.scene_enabled, Some(true));
1946         assert_eq!(wp.hidden, Some(false));
1947         assert_eq!(wp.blur, Some(false));
1948         assert_eq!(wp.pos, Some((0, 0)));
1949         assert_eq!(wp.size, Some((1920, 1080)));
1950 
1951         // Without a wallpaper window, the fallback rect stays on.
1952         let plan = arrange(&windows[1..], &one_output(), &arrange_params());
1953         assert!(plan.background_rect_enabled);
1954     }
1955 
1956     #[test]
1957     fn arrange_normal_window_pans_with_border_blur_opacity() {
1958         let mut w = snap("firefox");
1959         w.is_focused = false;
1960         let plan = arrange(&[w], &one_output(), &arrange_params());
1961 
1962         let wp = &plan.windows[0];
1963         assert_eq!(wp.scene_enabled, Some(true));
1964         assert_eq!(wp.tiling_mode, Some(TilingMode::Floating));
1965         assert_eq!(wp.pos, Some((100, 200)));
1966         assert_eq!(wp.scale, Some(1.0));
1967         assert_eq!(wp.size, Some((640, 480)));
1968         assert_eq!(wp.hidden, Some(false));
1969         assert_eq!(wp.tiled, None);
1970         assert_eq!(wp.decoration, Some(arrange_params().decoration));
1971         assert_eq!(wp.blur, Some(true));
1972         assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
1973     }
1974 
1975     #[test]
1976     fn arrange_focused_window_gets_focused_border_color() {
1977         let mut focused = snap("firefox");
1978         focused.is_focused = true;
1979         let unfocused = snap("terminal");
1980         let p = arrange_params();
1981         let plan = arrange(&[focused, unfocused], &one_output(), &p);
1982 
1983         assert_eq!(plan.windows[0].decoration.unwrap().border_color, p.border_color_focused);
1984         assert_eq!(plan.windows[1].decoration.unwrap().border_color, p.decoration.border_color);
1985     }
1986 
1987     #[test]
1988     fn arrange_fullscreen_window_gets_no_border() {
1989         let mut w = snap("mpv");
1990         w.mode = TilingMode::Fullscreen;
1991         let mut p = arrange_params();
1992         p.decoration.border_width = 4;
1993         let plan = arrange(&[w], &one_output(), &p);
1994 
1995         let dec = plan.windows[0].decoration.unwrap();
1996         assert_eq!(dec.border_width, 0);
1997     }
1998 
1999     #[test]
2000     fn arrange_hidden_window_disabled_in_scene() {
2001         let mut w = snap("firefox");
2002         w.minimized = true;
2003         let plan = arrange(&[w], &one_output(), &arrange_params());
2004 
2005         let wp = &plan.windows[0];
2006         assert_eq!(wp.scene_enabled, Some(false));
2007         assert_eq!(wp.hidden, Some(true));
2008         assert_eq!(wp.pos, None);
2009         assert_eq!(wp.size, None);
2010     }
2011 
2012     #[test]
2013     fn arrange_first_overlay_gets_slot_second_demotes_to_normal() {
2014         let mut first = snap("scratchpad");
2015         first.mode = TilingMode::Overlay;
2016         first.box_geom = Rect { x: 0, y: 0, width: 0, height: 0 };
2017         let mut second = snap("other-overlay");
2018         second.mode = TilingMode::Overlay;
2019 
2020         let plan = arrange(&[first, second], &one_output(), &arrange_params());
2021 
2022         // Fresh overlay: right slot (no bars, so the full output is usable),
2023         // box_geom written back.
2024         let wp = &plan.windows[0];
2025         assert_eq!(wp.pos, Some((1512, 24)));
2026         assert_eq!(wp.size, Some((400, 1048)));
2027         assert_eq!(wp.box_geom, Some(Rect { x: 1512, y: 24, width: 400, height: 1048 }));
2028         assert_eq!(wp.tiled, Some(15));
2029         assert_eq!(wp.opacity, Some(OVERLAY_UNFOCUSED_OPACITY));
2030 
2031         // Second overlay arranges as a pannable normal window.
2032         let wp = &plan.windows[1];
2033         assert_eq!(wp.tiling_mode, Some(TilingMode::Overlay));
2034         assert_eq!(wp.pos, Some((100, 200)));
2035         assert_eq!(wp.size, Some((640, 480)));
2036         assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
2037     }
2038 
2039     #[test]
2040     fn arrange_rule_ssd_reaches_overlay_sizing() {
2041         let mut w = snap("scratchpad");
2042         w.mode = TilingMode::Overlay;
2043         w.ssd = true;
2044         w.rule_ssd = Some(false);
2045         w.decorations_size = (2, 18);
2046 
2047         let plan = arrange(&[w], &one_output(), &arrange_params());
2048         let wp = &plan.windows[0];
2049         // The rule override is planned and the client size shrinks by the
2050         // decorations, proving the override was visible to placement.
2051         assert_eq!(wp.ssd, Some(false));
2052         assert_eq!(wp.size, Some((638, 462)));
2053     }
2054 
2055     #[test]
2056     fn arrange_tiled_enter_saves_geometry() {
2057         let mut w = snap("firefox");
2058         w.mode = TilingMode::Tiled;
2059         w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
2060         w.virtual_pos = (150.0, 120.0);
2061 
2062         let plan = arrange(&[w], &one_output(), &arrange_params());
2063         let wp = &plan.windows[0];
2064         assert_eq!(wp.was_tiled, Some(true));
2065         assert_eq!(wp.saved_floating, Some(((150, 50), (150.0, 120.0))));
2066         // Grid snap: spans columns 1-2, row 1 of the 100px grid.
2067         assert_eq!(wp.virtual_pos, Some((100.0, 100.0)));
2068         assert_eq!(wp.pos, Some((100, 100)));
2069         assert_eq!(wp.size, Some((200, 100)));
2070     }
2071 
2072     #[test]
2073     fn arrange_tiled_exit_restores_saved_geometry() {
2074         let mut w = snap("firefox");
2075         w.mode = TilingMode::Floating;
2076         w.was_tiled = true;
2077         w.saved_floating_size = (500, 400);
2078         w.saved_floating_virtual = (10.0, 20.0);
2079 
2080         let plan = arrange(&[w], &one_output(), &arrange_params());
2081         let wp = &plan.windows[0];
2082         assert_eq!(wp.was_tiled, Some(false));
2083         assert_eq!(wp.box_geom, Some(Rect { x: 0, y: 0, width: 500, height: 400 }));
2084         // The restored geometry flows into the pannable placement.
2085         assert_eq!(wp.virtual_pos, Some((10.0, 20.0)));
2086         assert_eq!(wp.pos, Some((10, 20)));
2087         assert_eq!(wp.size, Some((500, 400)));
2088     }
2089 
2090     #[test]
2091     fn arrange_grid_client_world_anchored() {
2092         let mut g = snap("cce-grid");
2093         assert_eq!(g.role, WindowRole::Grid);
2094         // No patch yet: hidden, and the compositor cells stay on.
2095         let plan = arrange(&[g.clone()], &one_output(), &arrange_params());
2096         assert_eq!(plan.windows[0].scene_enabled, Some(false));
2097         assert!(plan.grid_cells_enabled);
2098 
2099         // With a latched patch: placed at the patch's virtual origin, scaled
2100         // by zoom/patch.scale, and the compositor cells yield.
2101         g.grid_patch = Some(crate::api::GridPatch {
2102             x: -1000.0,
2103             y: 500.0,
2104             w: 4000.0,
2105             h: 3000.0,
2106             scale: 0.5,
2107         });
2108         let mut p = arrange_params();
2109         p.pan_x = -1500.0;
2110         p.pan_y = 0.0;
2111         p.zoom = 1.0;
2112         let plan = arrange(&[g.clone()], &one_output(), &p);
2113         let wp = &plan.windows[0];
2114         assert_eq!(wp.scene_enabled, Some(true));
2115         // Screen pos = (virtual - pan) * zoom: (-1000 - -1500, 500 - 0).
2116         assert_eq!(wp.pos, Some((500, 500)));
2117         // Buffer at 0.5 px per unit shown at zoom 1 → display scale 2.
2118         assert_eq!(wp.scale, Some(2.0));
2119         assert_eq!(wp.tiled, Some(0));
2120         assert!(!plan.grid_cells_enabled);
2121 
2122         // A minimized or closing grid client gives the cells back.
2123         g.minimized = true;
2124         let plan = arrange(&[g], &one_output(), &p);
2125         assert!(plan.grid_cells_enabled);
2126     }
2127 
2128     #[test]
2129     fn arrange_status_bar_placed_unless_dragged() {
2130         let mut bar = snap("cce-status-left-viewport");
2131         bar.status_edge = StatusEdge::TopLeft;
2132         bar.box_geom = Rect { x: 0, y: 0, width: 200, height: 30 };
2133 
2134         let plan = arrange(&[bar.clone()], &one_output(), &arrange_params());
2135         let wp = &plan.windows[0];
2136         assert_eq!(wp.tiling_mode, Some(TilingMode::Status));
2137         assert_eq!(wp.blur, Some(true));
2138         assert_eq!(wp.pos, Some((12, 0)));
2139         assert_eq!(wp.size, Some((200, 30)));
2140 
2141         // A dragged bar keeps whatever geometry it has: no placement writes.
2142         bar.being_moved = true;
2143         let plan = arrange(&[bar], &one_output(), &arrange_params());
2144         let wp = &plan.windows[0];
2145         assert_eq!(wp.pos, None);
2146         assert_eq!(wp.size, None);
2147     }
2148 
2149     #[test]
2150     fn arrange_multi_output_is_last_wins() {
2151         let outputs = vec![
2152             OutputSnapshot {
2153                 layout_box: Rect { x: 0, y: 0, width: 1920, height: 1080 },
2154                 non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
2155             },
2156             OutputSnapshot {
2157                 layout_box: Rect { x: 1920, y: 0, width: 1280, height: 720 },
2158                 non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
2159             },
2160         ];
2161         let windows = vec![snap("cce-wallpaper"), snap("firefox")];
2162         let plan = arrange(&windows, &outputs, &arrange_params());
2163 
2164         // The background covers the second output — the last pass wins.
2165         assert_eq!(plan.windows[0].pos, Some((1920, 0)));
2166         assert_eq!(plan.windows[0].size, Some((1280, 720)));
2167 
2168         // Tiled state machine only fires once across passes: entering on
2169         // pass one must not re-enter (and re-save) on pass two.
2170         let mut w = snap("firefox");
2171         w.mode = TilingMode::Tiled;
2172         w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
2173         w.virtual_pos = (150.0, 120.0);
2174         let plan = arrange(&[w], &outputs, &arrange_params());
2175         // Saved from the original geometry, not the pass-one grid snap.
2176         assert_eq!(plan.windows[0].saved_floating, Some(((150, 50), (150.0, 120.0))));
2177     }
2178 
2179     #[test]
2180     fn side_stacks_center_vertically() {
2181         let items = vec![
2182             item("cce-status-a", StatusEdge::Left, 200),
2183             item("cce-status-b", StatusEdge::Left, 100),
2184         ];
2185         let p = layout_status_bars(&items, &params());
2186         // Total stack: 200 + 12 + 100 = 312, centered in 1080 → starts at 384.
2187         assert_eq!(p[0], Some(StatusBarPlacement { x: 0, y: 384, width: 30, height: 200, enforce_size: true }));
2188         assert_eq!(p[1], Some(StatusBarPlacement { x: 0, y: 596, width: 30, height: 100, enforce_size: true }));
2189     }
2190 }