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

src/arrange.rs (86.1K)

   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 and battery in one bubble, 2026-09-16); the five single names stay
 596 // valid for a bar that still runs them separately.
 597 const RIGHT_ORDER: &[&str] = &["tray", "stats", "cpu", "memory", "brightness", "volume", "battery", "clock"];
 598 
 599 fn left_sort_key(app_id: &str) -> usize {
 600     let name = app_id.strip_prefix("cce-status-interface-left-")
 601         .or_else(|| app_id.strip_prefix("cce-status-left-"))
 602         .unwrap_or(app_id);
 603     LEFT_ORDER.iter().position(|&m| m == name).unwrap_or(99)
 604 }
 605 
 606 fn right_sort_key(app_id: &str) -> usize {
 607     let name = app_id.strip_prefix("cce-status-interface-right-")
 608         .or_else(|| app_id.strip_prefix("cce-status-right-"))
 609         .unwrap_or(app_id);
 610     RIGHT_ORDER.iter().position(|&m| m == name).unwrap_or(99)
 611 }
 612 
 613 /// Bar length to use: previous major length, or 100 for a fresh bar.
 614 fn bar_len(prev_len: i32) -> u32 {
 615     if prev_len > 0 { prev_len as u32 } else { 100 }
 616 }
 617 
 618 fn is_light_source(app_id: &str) -> bool {
 619     app_id.ends_with("light_source")
 620 }
 621 
 622 /// A point on the output-rect perimeter for the traveling light_source
 623 /// segment, plus the edge it lies on (0 top, 1 left, 2 bottom, 3 right).
 624 /// `day` is the local day fraction (0 = midnight); the path runs
 625 /// counterclockwise from top-center at noon.
 626 fn sun_perimeter_point(output: Rect, day: f64) -> (f64, f64, u8) {
 627     let bw = output.width as f64;
 628     let bh = output.height as f64;
 629     // Perimeter fraction from top-center: noon → 0, 18:00 → ¼ (mid left
 630     // edge), midnight → ½ (bottom center), 06:00 → ¾ (mid right edge).
 631     let f = (day + 0.5).fract();
 632     let mut s = f * 2.0 * (bw + bh);
 633     if s < bw / 2.0 {
 634         return (bw / 2.0 - s, 0.0, 0);
 635     }
 636     s -= bw / 2.0;
 637     if s < bh {
 638         return (0.0, s, 1);
 639     }
 640     s -= bh;
 641     if s < bw {
 642         return (s, bh, 2);
 643     }
 644     s -= bw;
 645     if s < bh {
 646         return (bw, bh - s, 3);
 647     }
 648     s -= bh;
 649     (bw - s, 0.0, 0)
 650 }
 651 
 652 /// Lay out status-bar windows on one output: horizontal groups on the top and
 653 /// bottom edges (left/center/right within each), vertical stacks on the left
 654 /// and right edges, and full-width bars across the top.
 655 ///
 656 /// Returns one placement per item, index-aligned with `items`.
 657 pub fn layout_status_bars(
 658     items: &[StatusBarItem],
 659     p: &StatusBarLayoutParams,
 660 ) -> Vec<Option<StatusBarPlacement>> {
 661     let wlr_box = p.output;
 662     let bar_h = p.bar_height;
 663     let spacing = p.spacing;
 664     let margin = MARGIN;
 665 
 666     let mut top_left: Vec<usize> = Vec::new();
 667     let mut top_center: Vec<usize> = Vec::new();
 668     let mut top_right: Vec<usize> = Vec::new();
 669     let mut bottom_left: Vec<usize> = Vec::new();
 670     let mut bottom_center: Vec<usize> = Vec::new();
 671     let mut bottom_right: Vec<usize> = Vec::new();
 672     let mut left_side: Vec<usize> = Vec::new();
 673     let mut right_side: Vec<usize> = Vec::new();
 674     let mut full_top: Vec<usize> = Vec::new();
 675     let mut sun_track: Vec<usize> = Vec::new();
 676 
 677     for (idx, item) in items.iter().enumerate() {
 678         // The light_source segment ignores edge groups: it travels the
 679         // screen perimeter with the time of day (see sun_perimeter_point).
 680         if p.day_fraction.is_some() && is_light_source(&item.app_id) {
 681             sun_track.push(idx);
 682             continue;
 683         }
 684         let edge = if item.edge == StatusEdge::Unspecified {
 685             StatusEdge::TopLeft
 686         } else {
 687             item.edge
 688         };
 689         match edge {
 690             StatusEdge::TopLeft => top_left.push(idx),
 691             StatusEdge::TopCenter => top_center.push(idx),
 692             StatusEdge::TopRight => top_right.push(idx),
 693             StatusEdge::BottomLeft => bottom_left.push(idx),
 694             StatusEdge::BottomCenter => bottom_center.push(idx),
 695             StatusEdge::BottomRight => bottom_right.push(idx),
 696             StatusEdge::Left => left_side.push(idx),
 697             StatusEdge::Right => right_side.push(idx),
 698             _ => full_top.push(idx),
 699         }
 700     }
 701 
 702     if arrange_debug() {
 703         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());
 704     }
 705 
 706     let sort_left = |list: &mut Vec<usize>| {
 707         list.sort_by_key(|&i| left_sort_key(&items[i].app_id));
 708     };
 709     let sort_right = |list: &mut Vec<usize>| {
 710         list.sort_by_key(|&i| right_sort_key(&items[i].app_id));
 711     };
 712 
 713     sort_left(&mut top_left);
 714     sort_left(&mut top_center);
 715     sort_right(&mut top_right);
 716     sort_left(&mut bottom_left);
 717     sort_left(&mut bottom_center);
 718     sort_right(&mut bottom_right);
 719 
 720     let mut placements: Vec<Option<StatusBarPlacement>> = vec![None; items.len()];
 721 
 722     // 1. Top Edge
 723     let status_y_top = if p.hide_mode {
 724         wlr_box.y - (bar_h as i32 - p.hide_mode_preview)
 725     } else {
 726         wlr_box.y
 727     };
 728 
 729     let mut top_right_width_needed = 0;
 730     for &i in &top_right {
 731         let w = bar_len(items[i].prev_len);
 732         top_right_width_needed += w as i32 + spacing;
 733     }
 734     let top_right_boundary = wlr_box.x + wlr_box.width - margin - top_right_width_needed;
 735 
 736     // 1a. Left Group (TopLeft / nw)
 737     let mut cur_left_x = wlr_box.x + margin;
 738     for &i in &top_left {
 739         let mut w = bar_len(items[i].prev_len);
 740         let max_allowed_w = top_right_boundary - cur_left_x - spacing;
 741         if w as i32 > max_allowed_w {
 742             w = std::cmp::max(max_allowed_w, 20) as u32;
 743         }
 744         if arrange_debug() {
 745             log::debug!("[TopLeftLayout] app_id={} x={}, w={}", items[i].app_id, cur_left_x, w);
 746         }
 747         placements[i] = Some(StatusBarPlacement { x: cur_left_x, y: status_y_top, width: w, height: bar_h, enforce_size: !items[i].expanded });
 748         cur_left_x += w as i32 + spacing;
 749     }
 750 
 751     // 1b. Center Group (TopCenter / n)
 752     let mut top_center_width_needed = 0;
 753     for &i in &top_center {
 754         let w = bar_len(items[i].prev_len);
 755         top_center_width_needed += w as i32 + spacing;
 756     }
 757     if top_center_width_needed > 0 {
 758         top_center_width_needed -= spacing;
 759     }
 760     let center_start_x = wlr_box.x + (wlr_box.width - top_center_width_needed) / 2;
 761     let mut cur_center_x = std::cmp::max(center_start_x, cur_left_x + spacing);
 762 
 763     for &i in &top_center {
 764         let mut w = bar_len(items[i].prev_len);
 765         let max_allowed_w = top_right_boundary - cur_center_x - spacing;
 766         if w as i32 > max_allowed_w {
 767             w = std::cmp::max(max_allowed_w, 20) as u32;
 768         }
 769         log::info!("[TopCenterLayout] app_id={} x={}, w={}", items[i].app_id, cur_center_x, w);
 770         placements[i] = Some(StatusBarPlacement { x: cur_center_x, y: status_y_top, width: w, height: bar_h, enforce_size: !items[i].expanded });
 771         cur_center_x += w as i32 + spacing;
 772     }
 773 
 774     // 1c. Right Group (TopRight / ne)
 775     let mut cur_right_x = wlr_box.x + wlr_box.width - margin;
 776     for &i in top_right.iter().rev() {
 777         let w = bar_len(items[i].prev_len);
 778         let x = cur_right_x - w as i32;
 779         placements[i] = Some(StatusBarPlacement { x, y: status_y_top, width: w, height: bar_h, enforce_size: !items[i].expanded });
 780         cur_right_x = x - spacing;
 781     }
 782 
 783     for &i in &full_top {
 784         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 });
 785     }
 786 
 787     // 2. Bottom Edge
 788     let status_y_bottom = wlr_box.y + wlr_box.height - bar_h as i32;
 789 
 790     let mut bottom_right_width_needed = 0;
 791     for &i in &bottom_right {
 792         let w = bar_len(items[i].prev_len);
 793         bottom_right_width_needed += w as i32 + spacing;
 794     }
 795     let bottom_right_boundary = wlr_box.x + wlr_box.width - margin - bottom_right_width_needed;
 796 
 797     // 2a. Left Group (BottomLeft / sw)
 798     let mut cur_left_x = wlr_box.x + margin;
 799     for &i in &bottom_left {
 800         let mut w = bar_len(items[i].prev_len);
 801         let max_allowed_w = bottom_right_boundary - cur_left_x - spacing;
 802         if w as i32 > max_allowed_w {
 803             w = std::cmp::max(max_allowed_w, 20) as u32;
 804         }
 805         placements[i] = Some(StatusBarPlacement { x: cur_left_x, y: status_y_bottom, width: w, height: bar_h, enforce_size: !items[i].expanded });
 806         cur_left_x += w as i32 + spacing;
 807     }
 808 
 809     // 2b. Center Group (BottomCenter / s)
 810     let mut bottom_center_width_needed = 0;
 811     for &i in &bottom_center {
 812         let w = bar_len(items[i].prev_len);
 813         bottom_center_width_needed += w as i32 + spacing;
 814     }
 815     if bottom_center_width_needed > 0 {
 816         bottom_center_width_needed -= spacing;
 817     }
 818     let center_start_x = wlr_box.x + (wlr_box.width - bottom_center_width_needed) / 2;
 819     let mut cur_center_x = std::cmp::max(center_start_x, cur_left_x + spacing);
 820 
 821     for &i in &bottom_center {
 822         let mut w = bar_len(items[i].prev_len);
 823         let max_allowed_w = bottom_right_boundary - cur_center_x - spacing;
 824         if w as i32 > max_allowed_w {
 825             w = std::cmp::max(max_allowed_w, 20) as u32;
 826         }
 827         placements[i] = Some(StatusBarPlacement { x: cur_center_x, y: status_y_bottom, width: w, height: bar_h, enforce_size: !items[i].expanded });
 828         cur_center_x += w as i32 + spacing;
 829     }
 830 
 831     // 2c. Right Group (BottomRight / se)
 832     let mut cur_right_x = wlr_box.x + wlr_box.width - margin;
 833     for &i in bottom_right.iter().rev() {
 834         let w = bar_len(items[i].prev_len);
 835         let x = cur_right_x - w as i32;
 836         placements[i] = Some(StatusBarPlacement { x, y: status_y_bottom, width: w, height: bar_h, enforce_size: !items[i].expanded });
 837         cur_right_x = x - spacing;
 838     }
 839 
 840     // 3. Left Edge (Vertical stacking)
 841     let mut left_total_height = 0;
 842     for &i in &left_side {
 843         let actual_h = bar_len(items[i].prev_len);
 844         left_total_height += actual_h as i32;
 845     }
 846     if !left_side.is_empty() {
 847         left_total_height += (left_side.len() as i32 - 1) * spacing;
 848     }
 849     let mut cur_left_y = wlr_box.y + (wlr_box.height - left_total_height) / 2;
 850 
 851     for &i in &left_side {
 852         let actual_h = bar_len(items[i].prev_len);
 853         placements[i] = Some(StatusBarPlacement { x: wlr_box.x, y: cur_left_y, width: bar_h, height: actual_h, enforce_size: !items[i].expanded });
 854         cur_left_y += actual_h as i32 + spacing;
 855     }
 856 
 857     // 4. Right Edge (Vertical stacking)
 858     let mut right_total_height = 0;
 859     for &i in &right_side {
 860         let actual_h = bar_len(items[i].prev_len);
 861         right_total_height += actual_h as i32;
 862     }
 863     if !right_side.is_empty() {
 864         right_total_height += (right_side.len() as i32 - 1) * spacing;
 865     }
 866     let mut cur_right_y = wlr_box.y + (wlr_box.height - right_total_height) / 2;
 867 
 868     for &i in &right_side {
 869         let actual_h = bar_len(items[i].prev_len);
 870         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 });
 871         cur_right_y += actual_h as i32 + spacing;
 872     }
 873 
 874     // 4. The traveling light_source segment: center the (always-horizontal)
 875     // segment box on the day-fraction perimeter point, clamped inside the
 876     // output so the corners are turned smoothly. In hide mode it slips off
 877     // its current edge like every other segment, leaving the same preview.
 878     if let Some(day) = p.day_fraction {
 879         for &i in &sun_track {
 880             let w = bar_len(items[i].prev_len) ;
 881             let (px, py, edge) = sun_perimeter_point(wlr_box, day);
 882             let mut x = (wlr_box.x as f64 + px - w as f64 / 2.0).round() as i32;
 883             let mut y = (wlr_box.y as f64 + py - bar_h as f64 / 2.0).round() as i32;
 884             x = x.clamp(wlr_box.x, wlr_box.x + wlr_box.width - w as i32);
 885             y = y.clamp(wlr_box.y, wlr_box.y + wlr_box.height - bar_h as i32);
 886             if p.hide_mode {
 887                 match edge {
 888                     0 => y = wlr_box.y - (bar_h as i32 - p.hide_mode_preview),
 889                     1 => x = wlr_box.x - (w as i32 - p.hide_mode_preview),
 890                     2 => y = wlr_box.y + wlr_box.height - p.hide_mode_preview,
 891                     _ => x = wlr_box.x + wlr_box.width - p.hide_mode_preview,
 892                 }
 893             }
 894             placements[i] = Some(StatusBarPlacement { x, y, width: w, height: bar_h, enforce_size: !items[i].expanded });
 895         }
 896     }
 897 
 898     placements
 899 }
 900 
 901 /// One enabled output, as `arrange` needs it.
 902 #[derive(Debug, Clone, Copy)]
 903 pub struct OutputSnapshot {
 904     /// The output's layout box.
 905     pub layout_box: Rect,
 906     /// Layer-shell non-exclusive area, relative to the layout box; a
 907     /// zero-sized rect means no exclusion.
 908     pub non_exclusive: Rect,
 909 }
 910 
 911 /// Everything `arrange` reads about one window. Built once per frame by the
 912 /// mechanism side; seat-dependent answers (`being_moved`, `active_resize`)
 913 /// are captured here so the pure pass never queries mid-computation.
 914 #[derive(Debug, Clone)]
 915 pub struct WindowSnapshot {
 916     pub app_id: Option<String>,
 917     /// Only used verbatim in log lines.
 918     pub title: Option<String>,
 919     pub role: WindowRole,
 920     pub minimized: bool,
 921     /// Window state is Closing or Init.
 922     pub closing_or_init: bool,
 923     /// Resolved tiling mode (`get_mode_for_window`).
 924     pub mode: TilingMode,
 925     /// Status segments only: the along-bar length last committed while the
 926     /// segment was at bar thickness, tracked by the mechanism. Keeps the
 927     /// segment's slot stable while it is EXPANDED (surface grown into an
 928     /// in-surface menu); 0 when never collapsed-committed (fall back to the
 929     /// live box).
 930     pub status_collapsed_len: i32,
 931     /// Mode-rule SSD override; pre-gated on `!mode_locked`.
 932     pub rule_ssd: Option<bool>,
 933     /// A seat is interactively moving this window.
 934     pub being_moved: bool,
 935     pub status_edge: StatusEdge,
 936     pub is_focused: bool,
 937     pub box_geom: Rect,
 938     /// (min_width, min_height) size hints.
 939     pub min_size: (i32, i32),
 940     pub virtual_pos: (f64, f64),
 941     pub active_resize: Option<(u32, u32)>,
 942     /// Current `wm_requested.ssd`.
 943     pub ssd: bool,
 944     /// Raw decoration measurement (`measure_decorations`), not gated on
 945     /// `ssd` — placement applies it only when the effective SSD is off.
 946     pub decorations_size: (i32, i32),
 947     pub was_tiled: bool,
 948     pub saved_floating_size: (i32, i32),
 949     pub saved_floating_virtual: (f64, f64),
 950     /// Grid-role windows only: the latched world-anchored patch the current
 951     /// buffer covers. `None` until the first rendered patch arrives (the
 952     /// window stays out of the scene until then).
 953     pub grid_patch: Option<crate::api::GridPatch>,
 954 }
 955 
 956 /// Frame-wide inputs: config knobs plus the desktop viewport.
 957 pub struct ArrangeParams {
 958     pub bar_height: i32,
 959     pub status_hide_mode: bool,
 960     pub hide_mode_preview: i32,
 961     /// Gap between adjacent status segments (see `StatusBarLayoutParams::spacing`).
 962     pub status_module_spacing: i32,
 963     /// Local day fraction for the traveling light_source segment (see
 964     /// `StatusBarLayoutParams::day_fraction`).
 965     pub day_fraction: Option<f64>,
 966     /// `status_background_blur > 0.001`.
 967     pub status_blur: bool,
 968     pub window_blur: bool,
 969     /// `layout.window_opacity` — unfocused windows dim when set.
 970     pub opacity_enabled: bool,
 971     /// The configured server-side decoration, applied to every placed window.
 972     pub decoration: DecorationSpec,
 973     /// Border color for the focused window (falls back to the unfocused
 974     /// color in config when unset).
 975     pub border_color_focused: crate::api::Rgba,
 976     pub overlay: OverlayParams,
 977     pub normal: NormalParams,
 978     pub pan_x: f64,
 979     pub pan_y: f64,
 980     pub zoom: f64,
 981 }
 982 
 983 /// Write instructions for one window. `None` leaves the field untouched, so
 984 /// the mechanism apply loop is a flat sequence of `if let Some` writes.
 985 #[derive(Debug, Clone, Default, PartialEq)]
 986 pub struct WindowPlan {
 987     /// Enable/disable the window's scene tree.
 988     pub scene_enabled: Option<bool>,
 989     pub hidden: Option<bool>,
 990     pub tiling_mode: Option<TilingMode>,
 991     /// `wm_requested.tiled` edge bitmask.
 992     pub tiled: Option<u32>,
 993     pub ssd: Option<bool>,
 994     pub scale: Option<f64>,
 995     /// `rendering_requested.{x,y}`.
 996     pub pos: Option<(i32, i32)>,
 997     /// `wm_requested.dimensions` and `.bounds`.
 998     pub size: Option<(u32, u32)>,
 999     /// Persistent geometry write-back.
1000     pub box_geom: Option<Rect>,
1001     pub virtual_pos: Option<(f64, f64)>,
1002     pub blur: Option<bool>,
1003     pub decoration: Option<DecorationSpec>,
1004     pub opacity: Option<f32>,
1005     pub was_tiled: Option<bool>,
1006     /// Tiled-enter save: (restore size, restore virtual position).
1007     pub saved_floating: Option<((i32, i32), (f64, f64))>,
1008 }
1009 
1010 pub struct ArrangePlan {
1011     /// Index-aligned with the input snapshots.
1012     pub windows: Vec<WindowPlan>,
1013     /// Whether each output's fallback background rect should be shown
1014     /// (false while a wallpaper window exists).
1015     pub background_rect_enabled: bool,
1016     /// Whether the compositor-drawn cell lattice should be shown: false
1017     /// while a live grid client (role Grid, mapped, with a latched patch)
1018     /// covers the desktop. The gap-colored backdrop stays either way.
1019     pub grid_cells_enabled: bool,
1020 }
1021 
1022 fn is_cloud_app(app_id: Option<&str>) -> bool {
1023     app_id.map_or(false, |id| id.starts_with("cce-cloud"))
1024 }
1025 
1026 /// The decoration one placed window gets: the configured spec with the
1027 /// focused border color swapped in, and no border at all when fullscreen.
1028 fn decoration_for(p: &ArrangeParams, is_focused: bool, mode: TilingMode) -> DecorationSpec {
1029     if mode == TilingMode::Fullscreen {
1030         return DecorationSpec {
1031             border_width: 0,
1032             corner_radius: 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                 corner_radius: 0,
1905             },
1906             border_color_focused: crate::api::Rgba([0.9, 0.1, 0.1, 1.0]),
1907             overlay: OverlayParams {
1908                 overlay_width: 400,
1909                 border_gap: 8,
1910                 border_width: 0,
1911                 position_right: true,
1912                 cloud_position_default: None,
1913             },
1914             normal: NormalParams {
1915                 gap_right: 10,
1916                 gap_top: 6,
1917                 cloud_position_default: None,
1918                 desktop_cell_w: 100.0,
1919             desktop_cell_h: 100.0,
1920                 desktop_gap_width: 0.0,
1921                 desktop_cell_inset: 0.0,
1922             },
1923             pan_x: 0.0,
1924             pan_y: 0.0,
1925             zoom: 1.0,
1926         }
1927     }
1928 
1929     fn one_output() -> Vec<OutputSnapshot> {
1930         vec![OutputSnapshot {
1931             layout_box: Rect { x: 0, y: 0, width: 1920, height: 1080 },
1932             non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
1933         }]
1934     }
1935 
1936     #[test]
1937     fn arrange_background_covers_output_and_disables_fallback_rect() {
1938         let windows = vec![snap("cce-wallpaper"), snap("firefox")];
1939         let plan = arrange(&windows, &one_output(), &arrange_params());
1940 
1941         assert!(!plan.background_rect_enabled);
1942         let wp = &plan.windows[0];
1943         assert_eq!(wp.tiling_mode, Some(TilingMode::Status));
1944         assert_eq!(wp.tiled, Some(0));
1945         assert_eq!(wp.ssd, Some(false));
1946         assert_eq!(wp.scene_enabled, Some(true));
1947         assert_eq!(wp.hidden, Some(false));
1948         assert_eq!(wp.blur, Some(false));
1949         assert_eq!(wp.pos, Some((0, 0)));
1950         assert_eq!(wp.size, Some((1920, 1080)));
1951 
1952         // Without a wallpaper window, the fallback rect stays on.
1953         let plan = arrange(&windows[1..], &one_output(), &arrange_params());
1954         assert!(plan.background_rect_enabled);
1955     }
1956 
1957     #[test]
1958     fn arrange_normal_window_pans_with_border_blur_opacity() {
1959         let mut w = snap("firefox");
1960         w.is_focused = false;
1961         let plan = arrange(&[w], &one_output(), &arrange_params());
1962 
1963         let wp = &plan.windows[0];
1964         assert_eq!(wp.scene_enabled, Some(true));
1965         assert_eq!(wp.tiling_mode, Some(TilingMode::Floating));
1966         assert_eq!(wp.pos, Some((100, 200)));
1967         assert_eq!(wp.scale, Some(1.0));
1968         assert_eq!(wp.size, Some((640, 480)));
1969         assert_eq!(wp.hidden, Some(false));
1970         assert_eq!(wp.tiled, None);
1971         assert_eq!(wp.decoration, Some(arrange_params().decoration));
1972         assert_eq!(wp.blur, Some(true));
1973         assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
1974     }
1975 
1976     #[test]
1977     fn arrange_focused_window_gets_focused_border_color() {
1978         let mut focused = snap("firefox");
1979         focused.is_focused = true;
1980         let unfocused = snap("terminal");
1981         let p = arrange_params();
1982         let plan = arrange(&[focused, unfocused], &one_output(), &p);
1983 
1984         assert_eq!(plan.windows[0].decoration.unwrap().border_color, p.border_color_focused);
1985         assert_eq!(plan.windows[1].decoration.unwrap().border_color, p.decoration.border_color);
1986     }
1987 
1988     #[test]
1989     fn arrange_fullscreen_window_gets_no_border() {
1990         let mut w = snap("mpv");
1991         w.mode = TilingMode::Fullscreen;
1992         let mut p = arrange_params();
1993         p.decoration.border_width = 4;
1994         p.decoration.corner_radius = 12;
1995         let plan = arrange(&[w], &one_output(), &p);
1996 
1997         let dec = plan.windows[0].decoration.unwrap();
1998         assert_eq!(dec.border_width, 0);
1999         assert_eq!(dec.corner_radius, 0);
2000     }
2001 
2002     #[test]
2003     fn arrange_hidden_window_disabled_in_scene() {
2004         let mut w = snap("firefox");
2005         w.minimized = true;
2006         let plan = arrange(&[w], &one_output(), &arrange_params());
2007 
2008         let wp = &plan.windows[0];
2009         assert_eq!(wp.scene_enabled, Some(false));
2010         assert_eq!(wp.hidden, Some(true));
2011         assert_eq!(wp.pos, None);
2012         assert_eq!(wp.size, None);
2013     }
2014 
2015     #[test]
2016     fn arrange_first_overlay_gets_slot_second_demotes_to_normal() {
2017         let mut first = snap("scratchpad");
2018         first.mode = TilingMode::Overlay;
2019         first.box_geom = Rect { x: 0, y: 0, width: 0, height: 0 };
2020         let mut second = snap("other-overlay");
2021         second.mode = TilingMode::Overlay;
2022 
2023         let plan = arrange(&[first, second], &one_output(), &arrange_params());
2024 
2025         // Fresh overlay: right slot (no bars, so the full output is usable),
2026         // box_geom written back.
2027         let wp = &plan.windows[0];
2028         assert_eq!(wp.pos, Some((1512, 24)));
2029         assert_eq!(wp.size, Some((400, 1048)));
2030         assert_eq!(wp.box_geom, Some(Rect { x: 1512, y: 24, width: 400, height: 1048 }));
2031         assert_eq!(wp.tiled, Some(15));
2032         assert_eq!(wp.opacity, Some(OVERLAY_UNFOCUSED_OPACITY));
2033 
2034         // Second overlay arranges as a pannable normal window.
2035         let wp = &plan.windows[1];
2036         assert_eq!(wp.tiling_mode, Some(TilingMode::Overlay));
2037         assert_eq!(wp.pos, Some((100, 200)));
2038         assert_eq!(wp.size, Some((640, 480)));
2039         assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
2040     }
2041 
2042     #[test]
2043     fn arrange_rule_ssd_reaches_overlay_sizing() {
2044         let mut w = snap("scratchpad");
2045         w.mode = TilingMode::Overlay;
2046         w.ssd = true;
2047         w.rule_ssd = Some(false);
2048         w.decorations_size = (2, 18);
2049 
2050         let plan = arrange(&[w], &one_output(), &arrange_params());
2051         let wp = &plan.windows[0];
2052         // The rule override is planned and the client size shrinks by the
2053         // decorations, proving the override was visible to placement.
2054         assert_eq!(wp.ssd, Some(false));
2055         assert_eq!(wp.size, Some((638, 462)));
2056     }
2057 
2058     #[test]
2059     fn arrange_tiled_enter_saves_geometry() {
2060         let mut w = snap("firefox");
2061         w.mode = TilingMode::Tiled;
2062         w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
2063         w.virtual_pos = (150.0, 120.0);
2064 
2065         let plan = arrange(&[w], &one_output(), &arrange_params());
2066         let wp = &plan.windows[0];
2067         assert_eq!(wp.was_tiled, Some(true));
2068         assert_eq!(wp.saved_floating, Some(((150, 50), (150.0, 120.0))));
2069         // Grid snap: spans columns 1-2, row 1 of the 100px grid.
2070         assert_eq!(wp.virtual_pos, Some((100.0, 100.0)));
2071         assert_eq!(wp.pos, Some((100, 100)));
2072         assert_eq!(wp.size, Some((200, 100)));
2073     }
2074 
2075     #[test]
2076     fn arrange_tiled_exit_restores_saved_geometry() {
2077         let mut w = snap("firefox");
2078         w.mode = TilingMode::Floating;
2079         w.was_tiled = true;
2080         w.saved_floating_size = (500, 400);
2081         w.saved_floating_virtual = (10.0, 20.0);
2082 
2083         let plan = arrange(&[w], &one_output(), &arrange_params());
2084         let wp = &plan.windows[0];
2085         assert_eq!(wp.was_tiled, Some(false));
2086         assert_eq!(wp.box_geom, Some(Rect { x: 0, y: 0, width: 500, height: 400 }));
2087         // The restored geometry flows into the pannable placement.
2088         assert_eq!(wp.virtual_pos, Some((10.0, 20.0)));
2089         assert_eq!(wp.pos, Some((10, 20)));
2090         assert_eq!(wp.size, Some((500, 400)));
2091     }
2092 
2093     #[test]
2094     fn arrange_grid_client_world_anchored() {
2095         let mut g = snap("cce-grid");
2096         assert_eq!(g.role, WindowRole::Grid);
2097         // No patch yet: hidden, and the compositor cells stay on.
2098         let plan = arrange(&[g.clone()], &one_output(), &arrange_params());
2099         assert_eq!(plan.windows[0].scene_enabled, Some(false));
2100         assert!(plan.grid_cells_enabled);
2101 
2102         // With a latched patch: placed at the patch's virtual origin, scaled
2103         // by zoom/patch.scale, and the compositor cells yield.
2104         g.grid_patch = Some(crate::api::GridPatch {
2105             x: -1000.0,
2106             y: 500.0,
2107             w: 4000.0,
2108             h: 3000.0,
2109             scale: 0.5,
2110         });
2111         let mut p = arrange_params();
2112         p.pan_x = -1500.0;
2113         p.pan_y = 0.0;
2114         p.zoom = 1.0;
2115         let plan = arrange(&[g.clone()], &one_output(), &p);
2116         let wp = &plan.windows[0];
2117         assert_eq!(wp.scene_enabled, Some(true));
2118         // Screen pos = (virtual - pan) * zoom: (-1000 - -1500, 500 - 0).
2119         assert_eq!(wp.pos, Some((500, 500)));
2120         // Buffer at 0.5 px per unit shown at zoom 1 → display scale 2.
2121         assert_eq!(wp.scale, Some(2.0));
2122         assert_eq!(wp.tiled, Some(0));
2123         assert!(!plan.grid_cells_enabled);
2124 
2125         // A minimized or closing grid client gives the cells back.
2126         g.minimized = true;
2127         let plan = arrange(&[g], &one_output(), &p);
2128         assert!(plan.grid_cells_enabled);
2129     }
2130 
2131     #[test]
2132     fn arrange_status_bar_placed_unless_dragged() {
2133         let mut bar = snap("cce-status-left-viewport");
2134         bar.status_edge = StatusEdge::TopLeft;
2135         bar.box_geom = Rect { x: 0, y: 0, width: 200, height: 30 };
2136 
2137         let plan = arrange(&[bar.clone()], &one_output(), &arrange_params());
2138         let wp = &plan.windows[0];
2139         assert_eq!(wp.tiling_mode, Some(TilingMode::Status));
2140         assert_eq!(wp.blur, Some(true));
2141         assert_eq!(wp.pos, Some((12, 0)));
2142         assert_eq!(wp.size, Some((200, 30)));
2143 
2144         // A dragged bar keeps whatever geometry it has: no placement writes.
2145         bar.being_moved = true;
2146         let plan = arrange(&[bar], &one_output(), &arrange_params());
2147         let wp = &plan.windows[0];
2148         assert_eq!(wp.pos, None);
2149         assert_eq!(wp.size, None);
2150     }
2151 
2152     #[test]
2153     fn arrange_multi_output_is_last_wins() {
2154         let outputs = vec![
2155             OutputSnapshot {
2156                 layout_box: Rect { x: 0, y: 0, width: 1920, height: 1080 },
2157                 non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
2158             },
2159             OutputSnapshot {
2160                 layout_box: Rect { x: 1920, y: 0, width: 1280, height: 720 },
2161                 non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
2162             },
2163         ];
2164         let windows = vec![snap("cce-wallpaper"), snap("firefox")];
2165         let plan = arrange(&windows, &outputs, &arrange_params());
2166 
2167         // The background covers the second output — the last pass wins.
2168         assert_eq!(plan.windows[0].pos, Some((1920, 0)));
2169         assert_eq!(plan.windows[0].size, Some((1280, 720)));
2170 
2171         // Tiled state machine only fires once across passes: entering on
2172         // pass one must not re-enter (and re-save) on pass two.
2173         let mut w = snap("firefox");
2174         w.mode = TilingMode::Tiled;
2175         w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
2176         w.virtual_pos = (150.0, 120.0);
2177         let plan = arrange(&[w], &outputs, &arrange_params());
2178         // Saved from the original geometry, not the pass-one grid snap.
2179         assert_eq!(plan.windows[0].saved_floating, Some(((150, 50), (150.0, 120.0))));
2180     }
2181 
2182     #[test]
2183     fn side_stacks_center_vertically() {
2184         let items = vec![
2185             item("cce-status-a", StatusEdge::Left, 200),
2186             item("cce-status-b", StatusEdge::Left, 100),
2187         ];
2188         let p = layout_status_bars(&items, &params());
2189         // Total stack: 200 + 12 + 100 = 312, centered in 1080 → starts at 384.
2190         assert_eq!(p[0], Some(StatusBarPlacement { x: 0, y: 384, width: 30, height: 200, enforce_size: true }));
2191         assert_eq!(p[1], Some(StatusBarPlacement { x: 0, y: 596, width: 30, height: 100, enforce_size: true }));
2192     }
2193 }