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, ¶ms());
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, ¶ms());
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, ¶ms());
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, ¶ms());
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 }