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