window management library
git clone https://git.lucas.co/cce-window-manager.git
Initial commit: window-management policy layer extracted from cce
Extracted from cce.git at e8eaa2d (src/server/policy/{api,arrange,state,
tiling}.rs and src/server/slotmap.rs — see that repo for prior history).
The Action enum moved here from cce's config.rs into api.rs; otherwise
pure code motion. Pure Rust, no FFI: the cce compositor depends on this
crate and re-exports it as crate::policy / crate::tiling / crate::slotmap.
Co-Authored-By: Claude Fable 5 <[email protected]>
.gitignore | 1 +
Cargo.lock | 82 +++
Cargo.toml | 12 +
src/api.rs | 183 ++++++
src/arrange.rs | 1756 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/lib.rs | 25 +
src/slotmap.rs | 195 +++++++
src/state.rs | 32 ++
src/tiling.rs | 113 ++++
9 files changed, 2399 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..0f444a4
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,82 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "cce-window-manager"
+version = "0.1.0"
+dependencies = [
+ "log",
+ "serde",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..5e572a2
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "cce-window-manager"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+name = "cce_window_manager"
+path = "src/lib.rs"
+
+[dependencies]
+log = "0.4"
+serde = { version = "1", features = ["derive"] }
diff --git a/src/api.rs b/src/api.rs
new file mode 100644
index 0000000..0a2f0ee
--- /dev/null
+++ b/src/api.rs
@@ -0,0 +1,183 @@
+// The Policy / Compositor trait boundary.
+//
+// `Policy` is implemented by the window-management side: it receives events
+// and decides placement, focus, decoration, and background — using only the
+// plain-data types in this file, never FFI. `Compositor` is implemented by
+// the mechanism side (`window_manager.rs` and friends): it executes those
+// decisions against the wlroots/scenefx scene graph.
+//
+// Skeleton status: nothing implements these traits yet. The migration plan is
+// to split `arrange_views()` into a policy half (compute placements) and a
+// mechanism half (apply to scene), then route window lifecycle, input actions,
+// and the animation tick through `Policy`. Effects are declarative on purpose:
+// new scenefx capabilities extend `EffectSpec` without changing either trait.
+
+use super::state::SavedState;
+
+/// Opaque handle to a window. Wraps the `SlotMap` key that the mechanism side
+/// uses internally; policy code never sees a pointer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct WindowId(pub crate::slotmap::Key);
+
+/// What a surface is for. Assigned once at map time, this replaces scattered
+/// app_id string-matching (`"cce-wallpaper"`, `"cce-status*"`) in mechanism code.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WindowRole {
+ Normal,
+ StatusBar,
+ Background,
+ Overlay,
+}
+
+impl WindowRole {
+ /// The single place the special app_id conventions are interpreted.
+ /// `Overlay` is never derived from an app_id — it comes from tiling mode.
+ pub fn from_app_id(app_id: Option<&str>) -> Self {
+ match app_id {
+ Some("cce-wallpaper") => WindowRole::Background,
+ Some(id) if id.starts_with("cce-status") => WindowRole::StatusBar,
+ _ => WindowRole::Normal,
+ }
+ }
+}
+
+/// User-triggered window-management actions, bound to keys/pointers/gestures
+/// by the compositor's config and dispatched into policy code.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Action {
+ None,
+ Spawn,
+ Toggle,
+ Close,
+ FocusNext,
+ FocusPrev,
+ WindowSwitcher,
+ Move,
+ Resize,
+ Exit,
+ Reload,
+ Fullscreen,
+ LayoutNext,
+ ModeNext,
+ ModeNextShared,
+ View1,
+ View2,
+ View3,
+ View4,
+ SetViewport1,
+ SetViewport2,
+ SetViewport3,
+ SetViewport4,
+ Expose,
+ Minimize,
+ OverlayLeft,
+ OverlayRight,
+ ZoomIn,
+ ZoomOut,
+ ZoomReset,
+ PanLeft,
+ PanRight,
+ PanUp,
+ PanDown,
+}
+
+#[derive(Debug, Clone)]
+pub struct WindowInfo {
+ pub app_id: String,
+ pub title: String,
+ pub role: WindowRole,
+ pub cmdline: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Rect {
+ pub x: i32,
+ pub y: i32,
+ pub width: i32,
+ pub height: i32,
+}
+
+/// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Rgba(pub [f32; 4]);
+
+/// Server-side decoration for one window: borders now, titlebars later.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct DecorationSpec {
+ pub border_width: i32,
+ pub border_color: Rgba,
+ pub corner_radius: i32,
+}
+
+/// Declarative per-window scenefx effects.
+#[derive(Debug, Clone, PartialEq)]
+pub struct EffectSpec {
+ pub opacity: f32,
+ pub blur: bool,
+ pub shadow: Option<ShadowSpec>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ShadowSpec {
+ pub color: Rgba,
+ pub blur_sigma: f32,
+}
+
+/// What the background layer shows. Absorbs cce-wallpaper (Solid) and the
+/// grid_tree drawing in `output.rs` (Grid).
+#[derive(Debug, Clone, PartialEq)]
+pub enum BackgroundSpec {
+ Solid(Rgba),
+ Grid(GridSpec),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct GridSpec {
+ pub background: Rgba,
+ pub line_color: Rgba,
+ pub cell_size: i32,
+ pub line_width: i32,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct OutputInfo {
+ pub width: i32,
+ pub height: i32,
+ pub scale: f32,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum PointerEvent {
+ Press { window: Option<WindowId>, x: f64, y: f64, button: u32 },
+ Release { window: Option<WindowId>, x: f64, y: f64, button: u32 },
+ Motion { x: f64, y: f64 },
+}
+
+/// Commands from policy to mechanism. Implemented by the compositor side;
+/// every method maps onto existing `WindowManager` / scene operations.
+pub trait Compositor {
+ fn place(&mut self, window: WindowId, rect: Rect);
+ fn focus(&mut self, window: Option<WindowId>);
+ fn raise(&mut self, window: WindowId);
+ fn close(&mut self, window: WindowId);
+ /// Pans/zooms windows and the background in the same frame.
+ fn set_viewport(&mut self, pan_x: f64, pan_y: f64, zoom: f64);
+ fn set_decoration(&mut self, window: WindowId, spec: DecorationSpec);
+ fn set_effects(&mut self, window: WindowId, spec: EffectSpec);
+ fn set_background(&mut self, spec: BackgroundSpec);
+ fn spawn(&mut self, cmdline: &str);
+}
+
+/// Events from mechanism to policy. Implemented by the window-management side.
+pub trait Policy {
+ fn window_mapped(&mut self, c: &mut dyn Compositor, window: WindowId, info: &WindowInfo);
+ fn window_unmapped(&mut self, c: &mut dyn Compositor, window: WindowId);
+ fn window_meta_changed(&mut self, c: &mut dyn Compositor, window: WindowId, info: &WindowInfo);
+ fn action(&mut self, c: &mut dyn Compositor, action: &Action, arg: Option<&str>);
+ fn pointer(&mut self, c: &mut dyn Compositor, event: PointerEvent);
+ fn output_changed(&mut self, c: &mut dyn Compositor, outputs: &[OutputInfo]);
+ /// Animation driver: easing for pan/zoom targets, effect transitions.
+ fn tick(&mut self, c: &mut dyn Compositor, dt: f64);
+ fn save_state(&self) -> SavedState;
+ fn restore_state(&mut self, c: &mut dyn Compositor, state: SavedState);
+}
diff --git a/src/arrange.rs b/src/arrange.rs
new file mode 100644
index 0000000..f7ec534
--- /dev/null
+++ b/src/arrange.rs
@@ -0,0 +1,1756 @@
+// Pure layout computation extracted from `WindowManager::arrange_views()`.
+//
+// Functions here take plain-data snapshots and return placement plans; the
+// mechanism side (`window_manager.rs`) builds the snapshots from FFI state and
+// applies the plans to the scene graph. `arrange()` is the whole frame in one
+// call — the future `Policy::arrange` entry point — composed from the
+// per-section functions below it.
+
+use super::api::{DecorationSpec, Rect, WindowRole};
+use super::tiling::TilingMode;
+
+/// Which screen edge/region a status-bar window docks to.
+/// Set from the app_id suffix or config; `Unspecified` resolves to `TopLeft`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StatusEdge {
+ Unspecified,
+ TopLeft,
+ TopCenter,
+ TopRight,
+ BottomLeft,
+ BottomCenter,
+ BottomRight,
+ Left,
+ Right,
+}
+
+/// Snapshot of one status-bar window, in `self.windows` iteration order.
+/// Windows being interactively dragged are excluded before layout.
+pub struct StatusBarItem {
+ pub app_id: String,
+ pub edge: StatusEdge,
+ /// max(box_geom.width, box_geom.height) — the bar's previous major length.
+ pub prev_len: i32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct StatusBarPlacement {
+ pub x: i32,
+ pub y: i32,
+ pub width: u32,
+ pub height: u32,
+}
+
+pub struct StatusBarLayoutParams {
+ /// The output's layout box.
+ pub output: Rect,
+ pub bar_height: u32,
+ pub hide_mode: bool,
+ /// Pixels of bar left peeking when hide_mode pushes top bars offscreen.
+ pub hide_mode_preview: i32,
+}
+
+/// The usable (tileable) area of an output: the layout box, shrunk by the
+/// layer-shell non-exclusive area and by one bar-height per screen edge that
+/// has a status bar docked to it. Top bars reserve no space in hide mode.
+///
+/// `non_exclusive` is relative to the output box; a zero-sized rect means no
+/// layer-shell exclusion. `status_edges` holds the raw edge of every live
+/// status-bar window (`Unspecified` resolves to `TopLeft`).
+pub fn compute_usable_area(
+ output: Rect,
+ non_exclusive: Rect,
+ bar_height: i32,
+ status_hide_mode: bool,
+ status_edges: &[StatusEdge],
+) -> Rect {
+ let mut usable = output;
+
+ if non_exclusive.width > 0 && non_exclusive.height > 0 {
+ usable.x = output.x + non_exclusive.x;
+ usable.y = output.y + non_exclusive.y;
+ usable.width = non_exclusive.width;
+ usable.height = non_exclusive.height;
+ }
+
+ let mut has_top = false;
+ let mut has_bottom = false;
+ let mut has_left = false;
+ let mut has_right = false;
+
+ for &edge in status_edges {
+ let edge = if edge == StatusEdge::Unspecified { StatusEdge::TopLeft } else { edge };
+ match edge {
+ StatusEdge::Unspecified | StatusEdge::TopLeft | StatusEdge::TopCenter | StatusEdge::TopRight => {
+ if !status_hide_mode {
+ has_top = true;
+ }
+ }
+ StatusEdge::BottomLeft | StatusEdge::BottomCenter | StatusEdge::BottomRight => {
+ has_bottom = true;
+ }
+ StatusEdge::Left => {
+ has_left = true;
+ }
+ StatusEdge::Right => {
+ has_right = true;
+ }
+ }
+ }
+
+ if has_top {
+ usable.y += bar_height;
+ usable.height -= bar_height;
+ }
+ if has_bottom {
+ usable.height -= bar_height;
+ }
+ if has_left {
+ usable.x += bar_height;
+ usable.width -= bar_height;
+ }
+ if has_right {
+ usable.width -= bar_height;
+ }
+
+ usable
+}
+
+/// How `arrange_views` treats a window this frame. `Background`/`StatusBar`
+/// windows get fixed geometry regardless of visibility; `Hidden` windows are
+/// disabled in the scene; `Overlay` windows get the overlay slot unless they
+/// are mid-drag (then they arrange as `Normal`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WindowClass {
+ Background,
+ StatusBar,
+ Hidden,
+ Overlay,
+ Normal,
+}
+
+pub fn classify_window(
+ role: WindowRole,
+ minimized: bool,
+ closing_or_init: bool,
+ mode: TilingMode,
+ is_moving: bool,
+) -> WindowClass {
+ match role {
+ WindowRole::Background => WindowClass::Background,
+ WindowRole::StatusBar => WindowClass::StatusBar,
+ _ => {
+ if minimized || closing_or_init {
+ WindowClass::Hidden
+ } else if mode == TilingMode::Overlay && !is_moving {
+ WindowClass::Overlay
+ } else {
+ WindowClass::Normal
+ }
+ }
+ }
+}
+
+/// Overlay windows keep 16px clear above for decorations.
+const OVERLAY_DEC_H: i32 = 16;
+
+/// Windows further than this many pixels outside the output are culled.
+const OFFSCREEN_MARGIN: f64 = 50.0;
+
+pub const OVERLAY_UNFOCUSED_OPACITY: f32 = 0.85;
+pub const NORMAL_UNFOCUSED_OPACITY: f32 = 0.90;
+
+pub fn window_opacity(is_focused: bool, opacity_enabled: bool, unfocused: f32) -> f32 {
+ if is_focused || !opacity_enabled { 1.0 } else { unfocused }
+}
+
+/// Per-output placement context: the physical box, the usable area from
+/// `compute_usable_area`, and the desktop viewport (pan/zoom).
+pub struct PlacementCtx {
+ pub phys: Rect,
+ pub usable: Rect,
+ pub pan_x: f64,
+ pub pan_y: f64,
+ pub zoom: f64,
+}
+
+impl PlacementCtx {
+ fn virtual_to_screen(&self, vx: f64, vy: f64) -> (i32, i32) {
+ (
+ self.phys.x + ((vx - self.pan_x) * self.zoom) as i32,
+ self.phys.y + ((vy - self.pan_y) * self.zoom) as i32,
+ )
+ }
+
+ fn is_offscreen(&self, x: i32, y: i32, scaled_w: f64, scaled_h: f64) -> bool {
+ let viewport_w = self.phys.width as f64;
+ let viewport_h = self.phys.height as f64;
+ (x as f64 + scaled_w + OFFSCREEN_MARGIN) < self.phys.x as f64
+ || (x as f64 - OFFSCREEN_MARGIN) > (self.phys.x as f64 + viewport_w)
+ || (y as f64 + scaled_h + OFFSCREEN_MARGIN) < self.phys.y as f64
+ || (y as f64 - OFFSCREEN_MARGIN) > (self.phys.y as f64 + viewport_h)
+ }
+}
+
+pub struct OverlaySnapshot {
+ pub box_geom: Rect,
+ pub min_width: i32,
+ pub is_cloud: bool,
+ pub ssd: bool,
+ pub decorations_size: (i32, i32),
+}
+
+pub struct OverlayParams {
+ pub overlay_width: i32,
+ pub border_gap: i32,
+ /// Server-side border width; the fresh overlay slot is border-inclusive.
+ pub border_width: i32,
+ pub position_right: bool,
+ pub cloud_position_default: Option<[i32; 2]>,
+}
+
+pub struct OverlayPlacement {
+ pub pos: (i32, i32),
+ /// Persistent geometry to store back on the window, if placement chose it.
+ pub box_geom_write: Option<Rect>,
+ pub virtual_pos: (f64, f64),
+ pub size: (u32, u32),
+}
+
+/// Place the primary overlay window: fresh windows get the configured overlay
+/// slot (left or right edge, full usable height); cloud windows snap to their
+/// configured default position; anything else keeps its stored geometry.
+///
+/// The fresh slot is border-inclusive: since borders draw outside the content
+/// box, the content is inset by the border width so slot + border stays inside
+/// the configured gaps. Stored geometry is treated like a floating window (the
+/// border extends beyond it).
+pub fn place_overlay_window(
+ snap: &OverlaySnapshot,
+ p: &OverlayParams,
+ ctx: &PlacementCtx,
+) -> OverlayPlacement {
+ let bw = p.border_width.max(0);
+ let g = p.border_gap;
+ let dec_h = std::cmp::max(bw, OVERLAY_DEC_H);
+
+ let mut sp_x = snap.box_geom.x;
+ let mut sp_y = snap.box_geom.y;
+ let mut sp_w = snap.box_geom.width;
+ let mut sp_h = snap.box_geom.height;
+ let mut box_geom_write = None;
+
+ if sp_w == 0 || sp_h == 0 {
+ sp_w = if snap.min_width > 32 {
+ std::cmp::max(p.overlay_width, snap.min_width)
+ } else {
+ p.overlay_width
+ };
+ sp_h = (ctx.usable.height - dec_h - 2 * g - 2 * bw).max(1);
+
+ sp_x = if p.position_right {
+ ctx.usable.x + ctx.usable.width - sp_w - g - bw
+ } else {
+ ctx.usable.x + g + bw
+ };
+ sp_y = ctx.usable.y + dec_h + g + bw;
+
+ box_geom_write = Some(Rect { x: sp_x, y: sp_y, width: sp_w, height: sp_h });
+ } else if snap.is_cloud {
+ if let Some(pos) = p.cloud_position_default {
+ sp_x = ctx.usable.x + pos[0];
+ sp_y = ctx.usable.y + pos[1];
+ box_geom_write = Some(Rect { x: sp_x, y: sp_y, width: sp_w, height: sp_h });
+ }
+ }
+
+ let vx = ctx.pan_x + (sp_x - ctx.phys.x) as f64 / ctx.zoom;
+ let vy = ctx.pan_y + (sp_y - ctx.phys.y) as f64 / ctx.zoom;
+
+ let mut target_w = sp_w;
+ let mut target_h = sp_h;
+ if !snap.ssd {
+ let (dec_w, dec_h) = snap.decorations_size;
+ target_w = (sp_w - dec_w).max(1);
+ target_h = (sp_h - dec_h).max(1);
+ }
+
+ OverlayPlacement {
+ pos: (sp_x, sp_y),
+ box_geom_write,
+ virtual_pos: (vx, vy),
+ size: (target_w as u32, target_h as u32),
+ }
+}
+
+/// State-machine step for entering/leaving Maximized mode. `Enter` tells the
+/// mechanism to save the given restore size (plus the window's current virtual
+/// position) and set `was_maximized`; `Exit` restores the saved geometry.
+/// Leaving with an invalid saved size does nothing (`was_maximized` stays set).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum MaximizedTransition {
+ Enter { width: i32, height: i32 },
+ Exit { width: i32, height: i32, virtual_x: f64, virtual_y: f64 },
+}
+
+pub fn maximized_transition(
+ is_maximized_mode: bool,
+ was_maximized: bool,
+ box_size: (i32, i32),
+ min_size: (i32, i32),
+ saved_size: (i32, i32),
+ saved_virtual: (f64, f64),
+) -> Option<MaximizedTransition> {
+ if is_maximized_mode && !was_maximized {
+ let mut w = box_size.0;
+ let mut h = box_size.1;
+ if w <= 0 {
+ w = if min_size.0 > 32 { min_size.0 } else { 800 };
+ }
+ if h <= 0 {
+ h = if min_size.1 > 32 { min_size.1 } else { 600 };
+ }
+ Some(MaximizedTransition::Enter { width: w, height: h })
+ } else if !is_maximized_mode && was_maximized {
+ if saved_size.0 > 0 && saved_size.1 > 0 {
+ Some(MaximizedTransition::Exit {
+ width: saved_size.0,
+ height: saved_size.1,
+ virtual_x: saved_virtual.0,
+ virtual_y: saved_virtual.1,
+ })
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+}
+
+pub struct NormalSnapshot {
+ pub mode: TilingMode,
+ pub box_geom: Rect,
+ pub min_size: (i32, i32),
+ pub virtual_pos: (f64, f64),
+ /// Live interactive-resize dimensions, if a resize op is in progress.
+ pub active_resize: Option<(u32, u32)>,
+ pub is_cloud: bool,
+ pub saved_maximized_size: (i32, i32),
+ pub saved_maximized_virtual: (f64, f64),
+}
+
+pub struct NormalParams {
+ pub gap_right: i32,
+ pub gap_top: i32,
+ /// Server-side border width; docked and grid-snapped placements are
+ /// border-inclusive.
+ pub border_width: i32,
+ pub cloud_position_default: Option<[i32; 2]>,
+ pub desktop_grid_scale: f64,
+}
+
+pub struct NormalPlacement {
+ pub pos: (i32, i32),
+ pub scale: f64,
+ pub size: (u32, u32),
+ /// Fullscreen windows report all edges tiled to the client.
+ pub tiled_all_edges: bool,
+ /// Offscreen-culling result; `None` leaves the window's flag untouched.
+ pub hidden: Option<bool>,
+ /// Maximized grid-snap moves the window's virtual position.
+ pub virtual_write: Option<(f64, f64)>,
+}
+
+/// Place a non-overlay window according to its tiling mode: `Popup` docks to
+/// the usable area's top-right (or the cloud default position), `Fullscreen`
+/// covers the physical output, `Maximized` snaps to cover every desktop-grid
+/// cell its saved geometry touches, and everything else pans on the virtual
+/// surface under the current viewport.
+pub fn place_normal_window(
+ snap: &NormalSnapshot,
+ p: &NormalParams,
+ ctx: &PlacementCtx,
+) -> NormalPlacement {
+ match snap.mode {
+ TilingMode::Popup => {
+ let fw = if snap.box_geom.width > 0 {
+ snap.box_geom.width
+ } else if snap.min_size.0 > 32 {
+ snap.min_size.0
+ } else {
+ 360
+ };
+ let fh = if snap.box_geom.height > 0 {
+ snap.box_geom.height
+ } else if snap.min_size.1 > 32 {
+ snap.min_size.1
+ } else {
+ 100
+ };
+
+ // The dock slot is border-inclusive: inset so the border stays
+ // within the configured gaps. Explicit cloud positions are taken
+ // as content positions verbatim.
+ let bw = p.border_width.max(0);
+ let (fx, fy) = if snap.is_cloud && p.cloud_position_default.is_some() {
+ let pos = p.cloud_position_default.unwrap();
+ (ctx.usable.x + pos[0], ctx.usable.y + pos[1])
+ } else {
+ (ctx.usable.x + ctx.usable.width - fw - p.gap_right - bw, ctx.usable.y + p.gap_top + bw)
+ };
+
+ NormalPlacement {
+ pos: (fx, fy),
+ scale: 1.0,
+ size: (fw as u32, fh as u32),
+ tiled_all_edges: false,
+ hidden: None,
+ virtual_write: None,
+ }
+ }
+ TilingMode::Fullscreen => NormalPlacement {
+ pos: (ctx.phys.x, ctx.phys.y),
+ scale: 1.0,
+ size: (ctx.phys.width as u32, ctx.phys.height as u32),
+ tiled_all_edges: true,
+ hidden: None,
+ virtual_write: None,
+ },
+ TilingMode::Maximized => {
+ // Resize to fully fill all desktop-grid cells the saved geometry
+ // is fully/partially inside of.
+ let scale = p.desktop_grid_scale;
+
+ let x1 = snap.saved_maximized_virtual.0;
+ let y1 = snap.saved_maximized_virtual.1;
+ let w = snap.saved_maximized_size.0 as f64;
+ let h = snap.saved_maximized_size.1 as f64;
+ let x2 = x1 + w;
+ let y2 = y1 + h;
+
+ let col_min = (x1 / scale).floor() as i32;
+ let col_max = ((x2 / scale).ceil() as i32 - 1).max(col_min);
+ let row_min = (y1 / scale).floor() as i32;
+ let row_max = ((y2 / scale).ceil() as i32 - 1).max(row_min);
+
+ let snapped_x1 = col_min as f64 * scale;
+ let snapped_x2 = (col_max + 1) as f64 * scale;
+ let snapped_y1 = row_min as f64 * scale;
+ let snapped_y2 = (row_max + 1) as f64 * scale;
+
+ // The snapped cell span is border-inclusive: the content is inset
+ // so the border stays inside the covered cells. Idempotent across
+ // frames because it re-derives from the saved geometry.
+ let bw = p.border_width.max(0) as f64;
+ let content_x = snapped_x1 + bw;
+ let content_y = snapped_y1 + bw;
+ let fw = (snapped_x2 - snapped_x1 - 2.0 * bw).max(1.0);
+ let fh = (snapped_y2 - snapped_y1 - 2.0 * bw).max(1.0);
+
+ let (final_x, final_y) = ctx.virtual_to_screen(content_x, content_y);
+
+ NormalPlacement {
+ pos: (final_x, final_y),
+ scale: ctx.zoom,
+ size: (fw as u32, fh as u32),
+ tiled_all_edges: false,
+ hidden: Some(ctx.is_offscreen(final_x, final_y, fw * ctx.zoom, fh * ctx.zoom)),
+ virtual_write: Some((content_x, content_y)),
+ }
+ }
+ _ => {
+ // Regular pannable window on the virtual surface.
+ let fw = if let Some(resize_size) = snap.active_resize {
+ resize_size.0 as i32
+ } else if snap.box_geom.width > 0 {
+ snap.box_geom.width
+ } else if snap.min_size.0 > 32 {
+ snap.min_size.0
+ } else {
+ 800
+ };
+ let fh = if let Some(resize_size) = snap.active_resize {
+ resize_size.1 as i32
+ } else if snap.box_geom.height > 0 {
+ snap.box_geom.height
+ } else if snap.min_size.1 > 32 {
+ snap.min_size.1
+ } else {
+ 600
+ };
+
+ let (final_x, final_y) = ctx.virtual_to_screen(snap.virtual_pos.0, snap.virtual_pos.1);
+
+ NormalPlacement {
+ pos: (final_x, final_y),
+ scale: ctx.zoom,
+ size: (fw as u32, fh as u32),
+ tiled_all_edges: false,
+ hidden: Some(ctx.is_offscreen(final_x, final_y, fw as f64 * ctx.zoom, fh as f64 * ctx.zoom)),
+ virtual_write: None,
+ }
+ }
+ }
+}
+
+const SPACING: i32 = 12;
+const MARGIN: i32 = 12;
+
+const LEFT_ORDER: &[&str] = &["viewport", "window"];
+const RIGHT_ORDER: &[&str] = &["tray", "cpu", "memory", "brightness", "volume", "battery", "clock"];
+
+fn left_sort_key(app_id: &str) -> usize {
+ let name = app_id.strip_prefix("cce-status-interface-left-")
+ .or_else(|| app_id.strip_prefix("cce-status-left-"))
+ .unwrap_or(app_id);
+ LEFT_ORDER.iter().position(|&m| m == name).unwrap_or(99)
+}
+
+fn right_sort_key(app_id: &str) -> usize {
+ let name = app_id.strip_prefix("cce-status-interface-right-")
+ .or_else(|| app_id.strip_prefix("cce-status-right-"))
+ .unwrap_or(app_id);
+ RIGHT_ORDER.iter().position(|&m| m == name).unwrap_or(99)
+}
+
+/// Bar length to use: previous major length, or 100 for a fresh bar.
+fn bar_len(prev_len: i32) -> u32 {
+ if prev_len > 0 { prev_len as u32 } else { 100 }
+}
+
+/// Lay out status-bar windows on one output: horizontal groups on the top and
+/// bottom edges (left/center/right within each), vertical stacks on the left
+/// and right edges, and full-width bars across the top.
+///
+/// Returns one placement per item, index-aligned with `items`.
+pub fn layout_status_bars(
+ items: &[StatusBarItem],
+ p: &StatusBarLayoutParams,
+) -> Vec<Option<StatusBarPlacement>> {
+ let wlr_box = p.output;
+ let bar_h = p.bar_height;
+ let spacing = SPACING;
+ let margin = MARGIN;
+
+ let mut top_left: Vec<usize> = Vec::new();
+ let mut top_center: Vec<usize> = Vec::new();
+ let mut top_right: Vec<usize> = Vec::new();
+ let mut bottom_left: Vec<usize> = Vec::new();
+ let mut bottom_center: Vec<usize> = Vec::new();
+ let mut bottom_right: Vec<usize> = Vec::new();
+ let mut left_side: Vec<usize> = Vec::new();
+ let mut right_side: Vec<usize> = Vec::new();
+ let mut full_top: Vec<usize> = Vec::new();
+
+ for (idx, item) in items.iter().enumerate() {
+ let edge = if item.edge == StatusEdge::Unspecified {
+ StatusEdge::TopLeft
+ } else {
+ item.edge
+ };
+ match edge {
+ StatusEdge::TopLeft => top_left.push(idx),
+ StatusEdge::TopCenter => top_center.push(idx),
+ StatusEdge::TopRight => top_right.push(idx),
+ StatusEdge::BottomLeft => bottom_left.push(idx),
+ StatusEdge::BottomCenter => bottom_center.push(idx),
+ StatusEdge::BottomRight => bottom_right.push(idx),
+ StatusEdge::Left => left_side.push(idx),
+ StatusEdge::Right => right_side.push(idx),
+ _ => full_top.push(idx),
+ }
+ }
+
+ log::info!("[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());
+
+ let sort_left = |list: &mut Vec<usize>| {
+ list.sort_by_key(|&i| left_sort_key(&items[i].app_id));
+ };
+ let sort_right = |list: &mut Vec<usize>| {
+ list.sort_by_key(|&i| right_sort_key(&items[i].app_id));
+ };
+
+ sort_left(&mut top_left);
+ sort_left(&mut top_center);
+ sort_right(&mut top_right);
+ sort_left(&mut bottom_left);
+ sort_left(&mut bottom_center);
+ sort_right(&mut bottom_right);
+
+ let mut placements: Vec<Option<StatusBarPlacement>> = vec![None; items.len()];
+
+ // 1. Top Edge
+ let status_y_top = if p.hide_mode {
+ wlr_box.y - (bar_h as i32 - p.hide_mode_preview)
+ } else {
+ wlr_box.y
+ };
+
+ let mut top_right_width_needed = 0;
+ for &i in &top_right {
+ let w = bar_len(items[i].prev_len);
+ top_right_width_needed += w as i32 + spacing;
+ }
+ let top_right_boundary = wlr_box.x + wlr_box.width - margin - top_right_width_needed;
+
+ // 1a. Left Group (TopLeft / nw)
+ let mut cur_left_x = wlr_box.x + margin;
+ for &i in &top_left {
+ let mut w = bar_len(items[i].prev_len);
+ let max_allowed_w = top_right_boundary - cur_left_x - spacing;
+ if w as i32 > max_allowed_w {
+ w = std::cmp::max(max_allowed_w, 20) as u32;
+ }
+ log::info!("[TopLeftLayout] app_id={} x={}, w={}", items[i].app_id, cur_left_x, w);
+ placements[i] = Some(StatusBarPlacement { x: cur_left_x, y: status_y_top, width: w, height: bar_h });
+ cur_left_x += w as i32 + spacing;
+ }
+
+ // 1b. Center Group (TopCenter / n)
+ let mut top_center_width_needed = 0;
+ for &i in &top_center {
+ let w = bar_len(items[i].prev_len);
+ top_center_width_needed += w as i32 + spacing;
+ }
+ if top_center_width_needed > 0 {
+ top_center_width_needed -= spacing;
+ }
+ let center_start_x = wlr_box.x + (wlr_box.width - top_center_width_needed) / 2;
+ let mut cur_center_x = std::cmp::max(center_start_x, cur_left_x + spacing);
+
+ for &i in &top_center {
+ let mut w = bar_len(items[i].prev_len);
+ let max_allowed_w = top_right_boundary - cur_center_x - spacing;
+ if w as i32 > max_allowed_w {
+ w = std::cmp::max(max_allowed_w, 20) as u32;
+ }
+ log::info!("[TopCenterLayout] app_id={} x={}, w={}", items[i].app_id, cur_center_x, w);
+ placements[i] = Some(StatusBarPlacement { x: cur_center_x, y: status_y_top, width: w, height: bar_h });
+ cur_center_x += w as i32 + spacing;
+ }
+
+ // 1c. Right Group (TopRight / ne)
+ let mut cur_right_x = wlr_box.x + wlr_box.width - margin;
+ for &i in top_right.iter().rev() {
+ let w = bar_len(items[i].prev_len);
+ let x = cur_right_x - w as i32;
+ placements[i] = Some(StatusBarPlacement { x, y: status_y_top, width: w, height: bar_h });
+ cur_right_x = x - spacing;
+ }
+
+ for &i in &full_top {
+ placements[i] = Some(StatusBarPlacement { x: wlr_box.x, y: status_y_top, width: wlr_box.width as u32, height: bar_h });
+ }
+
+ // 2. Bottom Edge
+ let status_y_bottom = wlr_box.y + wlr_box.height - bar_h as i32;
+
+ let mut bottom_right_width_needed = 0;
+ for &i in &bottom_right {
+ let w = bar_len(items[i].prev_len);
+ bottom_right_width_needed += w as i32 + spacing;
+ }
+ let bottom_right_boundary = wlr_box.x + wlr_box.width - margin - bottom_right_width_needed;
+
+ // 2a. Left Group (BottomLeft / sw)
+ let mut cur_left_x = wlr_box.x + margin;
+ for &i in &bottom_left {
+ let mut w = bar_len(items[i].prev_len);
+ let max_allowed_w = bottom_right_boundary - cur_left_x - spacing;
+ if w as i32 > max_allowed_w {
+ w = std::cmp::max(max_allowed_w, 20) as u32;
+ }
+ placements[i] = Some(StatusBarPlacement { x: cur_left_x, y: status_y_bottom, width: w, height: bar_h });
+ cur_left_x += w as i32 + spacing;
+ }
+
+ // 2b. Center Group (BottomCenter / s)
+ let mut bottom_center_width_needed = 0;
+ for &i in &bottom_center {
+ let w = bar_len(items[i].prev_len);
+ bottom_center_width_needed += w as i32 + spacing;
+ }
+ if bottom_center_width_needed > 0 {
+ bottom_center_width_needed -= spacing;
+ }
+ let center_start_x = wlr_box.x + (wlr_box.width - bottom_center_width_needed) / 2;
+ let mut cur_center_x = std::cmp::max(center_start_x, cur_left_x + spacing);
+
+ for &i in &bottom_center {
+ let mut w = bar_len(items[i].prev_len);
+ let max_allowed_w = bottom_right_boundary - cur_center_x - spacing;
+ if w as i32 > max_allowed_w {
+ w = std::cmp::max(max_allowed_w, 20) as u32;
+ }
+ placements[i] = Some(StatusBarPlacement { x: cur_center_x, y: status_y_bottom, width: w, height: bar_h });
+ cur_center_x += w as i32 + spacing;
+ }
+
+ // 2c. Right Group (BottomRight / se)
+ let mut cur_right_x = wlr_box.x + wlr_box.width - margin;
+ for &i in bottom_right.iter().rev() {
+ let w = bar_len(items[i].prev_len);
+ let x = cur_right_x - w as i32;
+ placements[i] = Some(StatusBarPlacement { x, y: status_y_bottom, width: w, height: bar_h });
+ cur_right_x = x - spacing;
+ }
+
+ // 3. Left Edge (Vertical stacking)
+ let mut left_total_height = 0;
+ for &i in &left_side {
+ let actual_h = bar_len(items[i].prev_len);
+ left_total_height += actual_h as i32;
+ }
+ if !left_side.is_empty() {
+ left_total_height += (left_side.len() as i32 - 1) * spacing;
+ }
+ let mut cur_left_y = wlr_box.y + (wlr_box.height - left_total_height) / 2;
+
+ for &i in &left_side {
+ let actual_h = bar_len(items[i].prev_len);
+ placements[i] = Some(StatusBarPlacement { x: wlr_box.x, y: cur_left_y, width: bar_h, height: actual_h });
+ cur_left_y += actual_h as i32 + spacing;
+ }
+
+ // 4. Right Edge (Vertical stacking)
+ let mut right_total_height = 0;
+ for &i in &right_side {
+ let actual_h = bar_len(items[i].prev_len);
+ right_total_height += actual_h as i32;
+ }
+ if !right_side.is_empty() {
+ right_total_height += (right_side.len() as i32 - 1) * spacing;
+ }
+ let mut cur_right_y = wlr_box.y + (wlr_box.height - right_total_height) / 2;
+
+ for &i in &right_side {
+ let actual_h = bar_len(items[i].prev_len);
+ 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 });
+ cur_right_y += actual_h as i32 + spacing;
+ }
+
+ placements
+}
+
+/// One enabled output, as `arrange` needs it.
+#[derive(Debug, Clone, Copy)]
+pub struct OutputSnapshot {
+ /// The output's layout box.
+ pub layout_box: Rect,
+ /// Layer-shell non-exclusive area, relative to the layout box; a
+ /// zero-sized rect means no exclusion.
+ pub non_exclusive: Rect,
+}
+
+/// Everything `arrange` reads about one window. Built once per frame by the
+/// mechanism side; seat-dependent answers (`being_moved`, `active_resize`)
+/// are captured here so the pure pass never queries mid-computation.
+#[derive(Debug, Clone)]
+pub struct WindowSnapshot {
+ pub app_id: Option<String>,
+ /// Only used verbatim in log lines.
+ pub title: Option<String>,
+ pub role: WindowRole,
+ pub minimized: bool,
+ /// Window state is Closing or Init.
+ pub closing_or_init: bool,
+ /// Resolved tiling mode (`get_mode_for_window`).
+ pub mode: TilingMode,
+ /// Mode-rule SSD override; pre-gated on `!mode_locked`.
+ pub rule_ssd: Option<bool>,
+ /// A seat is interactively moving this window.
+ pub being_moved: bool,
+ pub status_edge: StatusEdge,
+ pub is_focused: bool,
+ pub box_geom: Rect,
+ /// (min_width, min_height) size hints.
+ pub min_size: (i32, i32),
+ pub virtual_pos: (f64, f64),
+ pub active_resize: Option<(u32, u32)>,
+ /// Current `wm_requested.ssd`.
+ pub ssd: bool,
+ /// Raw decoration measurement (`measure_decorations`), not gated on
+ /// `ssd` — placement applies it only when the effective SSD is off.
+ pub decorations_size: (i32, i32),
+ pub was_maximized: bool,
+ pub saved_maximized_size: (i32, i32),
+ pub saved_maximized_virtual: (f64, f64),
+}
+
+/// Frame-wide inputs: config knobs plus the desktop viewport.
+pub struct ArrangeParams {
+ pub bar_height: i32,
+ pub status_hide_mode: bool,
+ pub hide_mode_preview: i32,
+ /// `status_background_blur > 0.001`.
+ pub status_blur: bool,
+ pub window_blur: bool,
+ /// `layout.window_opacity` — unfocused windows dim when set.
+ pub opacity_enabled: bool,
+ /// The configured server-side decoration, applied to every placed window.
+ pub decoration: DecorationSpec,
+ /// Border color for the focused window (falls back to the unfocused
+ /// color in config when unset).
+ pub border_color_focused: crate::api::Rgba,
+ pub overlay: OverlayParams,
+ pub normal: NormalParams,
+ pub pan_x: f64,
+ pub pan_y: f64,
+ pub zoom: f64,
+}
+
+/// Write instructions for one window. `None` leaves the field untouched, so
+/// the mechanism apply loop is a flat sequence of `if let Some` writes.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct WindowPlan {
+ /// Enable/disable the window's scene tree.
+ pub scene_enabled: Option<bool>,
+ pub hidden: Option<bool>,
+ pub tiling_mode: Option<TilingMode>,
+ /// `wm_requested.tiled` edge bitmask.
+ pub tiled: Option<u32>,
+ pub ssd: Option<bool>,
+ pub scale: Option<f64>,
+ /// `rendering_requested.{x,y}`.
+ pub pos: Option<(i32, i32)>,
+ /// `wm_requested.dimensions` and `.bounds`.
+ pub size: Option<(u32, u32)>,
+ /// Persistent geometry write-back.
+ pub box_geom: Option<Rect>,
+ pub virtual_pos: Option<(f64, f64)>,
+ pub blur: Option<bool>,
+ pub decoration: Option<DecorationSpec>,
+ pub opacity: Option<f32>,
+ pub was_maximized: Option<bool>,
+ /// Maximized-enter save: (restore size, restore virtual position).
+ pub saved_maximized: Option<((i32, i32), (f64, f64))>,
+}
+
+pub struct ArrangePlan {
+ /// Index-aligned with the input snapshots.
+ pub windows: Vec<WindowPlan>,
+ /// Whether each output's fallback background rect should be shown
+ /// (false while a wallpaper window exists).
+ pub background_rect_enabled: bool,
+}
+
+fn is_cloud_app(app_id: Option<&str>) -> bool {
+ app_id.map_or(false, |id| id.starts_with("cce-cloud"))
+}
+
+/// The decoration one placed window gets: the configured spec with the
+/// focused border color swapped in, and no border at all when fullscreen.
+fn decoration_for(p: &ArrangeParams, is_focused: bool, mode: TilingMode) -> DecorationSpec {
+ if mode == TilingMode::Fullscreen {
+ return DecorationSpec {
+ border_width: 0,
+ corner_radius: 0,
+ ..p.decoration
+ };
+ }
+ DecorationSpec {
+ border_color: if is_focused {
+ p.border_color_focused
+ } else {
+ p.decoration.border_color
+ },
+ ..p.decoration
+ }
+}
+
+/// The whole arrange pass as one pure function: classify every window, place
+/// overlays/normals/status bars per output, and return per-window write
+/// instructions.
+///
+/// The output loop is last-wins, like the mechanism loop it replaces: every
+/// output pass re-plans every window, so with several outputs the final plan
+/// reflects the last one. State that arranging itself evolves (box geometry,
+/// virtual position, SSD overrides, the maximized save/restore machine) is
+/// tracked on a working copy of the snapshots so later sections and later
+/// output passes read what earlier ones wrote — exactly as the mutating
+/// original did.
+pub fn arrange(
+ windows: &[WindowSnapshot],
+ outputs: &[OutputSnapshot],
+ p: &ArrangeParams,
+) -> ArrangePlan {
+ let mut state: Vec<WindowSnapshot> = windows.to_vec();
+ let mut plan: Vec<WindowPlan> = vec![WindowPlan::default(); windows.len()];
+
+ let has_wallpaper = state.iter().any(|w| w.role == WindowRole::Background);
+
+ for out in outputs {
+ let phys = out.layout_box;
+
+ let status_edges: Vec<StatusEdge> = state
+ .iter()
+ .filter(|w| w.role == WindowRole::StatusBar)
+ .map(|w| w.status_edge)
+ .collect();
+ let usable = compute_usable_area(
+ phys,
+ out.non_exclusive,
+ p.bar_height,
+ p.status_hide_mode,
+ &status_edges,
+ );
+ let ctx = PlacementCtx {
+ phys,
+ usable,
+ pan_x: p.pan_x,
+ pan_y: p.pan_y,
+ zoom: p.zoom,
+ };
+
+ let mut overlay_windows: Vec<usize> = Vec::new();
+ let mut normal_windows: Vec<usize> = Vec::new();
+
+ for (i, w) in state.iter_mut().enumerate() {
+ let class = classify_window(w.role, w.minimized, w.closing_or_init, w.mode, w.being_moved);
+ let wp = &mut plan[i];
+ match class {
+ WindowClass::Background => {
+ wp.tiling_mode = Some(TilingMode::Status);
+ wp.tiled = Some(0);
+ wp.ssd = Some(false);
+ w.ssd = false;
+ wp.scale = Some(1.0);
+ wp.scene_enabled = Some(true);
+ wp.hidden = Some(false);
+ wp.blur = Some(false);
+ wp.pos = Some((phys.x, phys.y));
+ wp.size = Some((phys.width as u32, phys.height as u32));
+ }
+ WindowClass::StatusBar => {
+ wp.tiling_mode = Some(TilingMode::Status);
+ wp.tiled = Some(0);
+ wp.ssd = Some(false);
+ w.ssd = false;
+ wp.scale = Some(1.0);
+ wp.scene_enabled = Some(true);
+ wp.hidden = Some(false);
+ wp.blur = Some(p.status_blur);
+ }
+ WindowClass::Hidden => {
+ wp.scene_enabled = Some(false);
+ wp.hidden = Some(true);
+ }
+ WindowClass::Overlay | WindowClass::Normal => {
+ wp.scene_enabled = Some(true);
+ wp.hidden = Some(false);
+ wp.tiling_mode = Some(w.mode);
+ if let Some(rule_ssd) = w.rule_ssd {
+ wp.ssd = Some(rule_ssd);
+ w.ssd = rule_ssd;
+ }
+ if class == WindowClass::Overlay {
+ overlay_windows.push(i);
+ } else {
+ normal_windows.push(i);
+ }
+ }
+ }
+ }
+
+ for (sp_idx, &i) in overlay_windows.iter().enumerate() {
+ if sp_idx == 0 {
+ let w = &state[i];
+ let placement = place_overlay_window(
+ &OverlaySnapshot {
+ box_geom: w.box_geom,
+ min_width: w.min_size.0,
+ is_cloud: is_cloud_app(w.app_id.as_deref()),
+ ssd: w.ssd,
+ decorations_size: w.decorations_size,
+ },
+ &p.overlay,
+ &ctx,
+ );
+
+ if let Some(bg) = placement.box_geom_write {
+ state[i].box_geom = bg;
+ }
+ state[i].virtual_pos = placement.virtual_pos;
+
+ let wp = &mut plan[i];
+ if let Some(bg) = placement.box_geom_write {
+ wp.box_geom = Some(bg);
+ }
+ wp.pos = Some(placement.pos);
+ wp.scale = Some(1.0);
+ wp.virtual_pos = Some(placement.virtual_pos);
+ wp.size = Some(placement.size);
+ wp.tiled = Some(1 | 2 | 4 | 8);
+ wp.decoration = Some(decoration_for(p, state[i].is_focused, state[i].mode));
+ wp.blur = Some(p.window_blur);
+ wp.opacity = Some(window_opacity(
+ state[i].is_focused,
+ p.opacity_enabled,
+ OVERLAY_UNFOCUSED_OPACITY,
+ ));
+ } else {
+ normal_windows.push(i);
+ }
+ }
+
+ // Manage entering/exiting Maximized state for normal windows.
+ for &i in &normal_windows {
+ let w = &state[i];
+ let transition = maximized_transition(
+ w.mode == TilingMode::Maximized,
+ w.was_maximized,
+ (w.box_geom.width, w.box_geom.height),
+ w.min_size,
+ w.saved_maximized_size,
+ w.saved_maximized_virtual,
+ );
+ match transition {
+ Some(MaximizedTransition::Enter { width, height }) => {
+ let w = &mut state[i];
+ w.saved_maximized_size = (width, height);
+ w.saved_maximized_virtual = w.virtual_pos;
+ w.was_maximized = true;
+ let wp = &mut plan[i];
+ wp.saved_maximized = Some(((width, height), w.saved_maximized_virtual));
+ wp.was_maximized = Some(true);
+ log::info!("[Maximized] Saved window {:?} geometry: {}x{} at ({}, {})",
+ w.title.as_deref().unwrap_or(""),
+ width, height,
+ w.saved_maximized_virtual.0, w.saved_maximized_virtual.1
+ );
+ }
+ Some(MaximizedTransition::Exit { width, height, virtual_x, virtual_y }) => {
+ let w = &mut state[i];
+ w.box_geom.width = width;
+ w.box_geom.height = height;
+ w.virtual_pos = (virtual_x, virtual_y);
+ w.was_maximized = false;
+ let wp = &mut plan[i];
+ wp.box_geom = Some(w.box_geom);
+ wp.virtual_pos = Some((virtual_x, virtual_y));
+ wp.was_maximized = Some(false);
+ wp.size = Some((width as u32, height as u32));
+ log::info!("[Maximized] Restored window {:?} geometry: {}x{} at ({}, {})",
+ w.title.as_deref().unwrap_or(""),
+ width, height, virtual_x, virtual_y
+ );
+ }
+ None => {}
+ }
+ }
+
+ // Arrange normal windows on the virtual surface.
+ for &i in &normal_windows {
+ let w = &state[i];
+ let mode = w.mode;
+ let placement = place_normal_window(
+ &NormalSnapshot {
+ mode: w.mode,
+ box_geom: w.box_geom,
+ min_size: w.min_size,
+ virtual_pos: w.virtual_pos,
+ active_resize: w.active_resize,
+ is_cloud: is_cloud_app(w.app_id.as_deref()),
+ saved_maximized_size: w.saved_maximized_size,
+ saved_maximized_virtual: w.saved_maximized_virtual,
+ },
+ &p.normal,
+ &ctx,
+ );
+
+ let is_focused = w.is_focused;
+ if let Some(v) = placement.virtual_write {
+ state[i].virtual_pos = v;
+ }
+
+ let wp = &mut plan[i];
+ wp.pos = Some(placement.pos);
+ wp.scale = Some(placement.scale);
+ if let Some(v) = placement.virtual_write {
+ wp.virtual_pos = Some(v);
+ }
+ if let Some(hidden) = placement.hidden {
+ wp.hidden = Some(hidden);
+ }
+ wp.size = Some(placement.size);
+ if placement.tiled_all_edges {
+ wp.tiled = Some(1 | 2 | 4 | 8);
+ }
+ wp.decoration = Some(decoration_for(p, is_focused, mode));
+ wp.blur = Some(p.window_blur);
+ wp.opacity = Some(window_opacity(is_focused, p.opacity_enabled, NORMAL_UNFOCUSED_OPACITY));
+ }
+
+ // Position status bar windows on this output, excluding any being
+ // interactively dragged.
+ let mut status_items: Vec<StatusBarItem> = Vec::new();
+ let mut status_idxs: Vec<usize> = Vec::new();
+ for (i, w) in state.iter().enumerate() {
+ if w.closing_or_init {
+ continue;
+ }
+ if let Some(app_id) = &w.app_id {
+ if app_id.starts_with("cce-status") {
+ if w.being_moved {
+ continue;
+ }
+ log::info!("[ArrangeStatus] app_id={} status_edge={:?}", app_id, w.status_edge);
+ status_items.push(StatusBarItem {
+ app_id: app_id.clone(),
+ edge: w.status_edge,
+ prev_len: std::cmp::max(w.box_geom.width, w.box_geom.height),
+ });
+ status_idxs.push(i);
+ }
+ }
+ }
+
+ let placements = layout_status_bars(
+ &status_items,
+ &StatusBarLayoutParams {
+ output: phys,
+ bar_height: p.bar_height as u32,
+ hide_mode: p.status_hide_mode,
+ hide_mode_preview: p.hide_mode_preview,
+ },
+ );
+
+ for (&i, placement) in status_idxs.iter().zip(placements.iter()) {
+ if let Some(pl) = placement {
+ plan[i].pos = Some((pl.x, pl.y));
+ plan[i].size = Some((pl.width, pl.height));
+ }
+ }
+ }
+
+ ArrangePlan {
+ windows: plan,
+ background_rect_enabled: !has_wallpaper,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn params() -> StatusBarLayoutParams {
+ StatusBarLayoutParams {
+ output: Rect { x: 0, y: 0, width: 1920, height: 1080 },
+ bar_height: 30,
+ hide_mode: false,
+ hide_mode_preview: 5,
+ }
+ }
+
+ fn item(app_id: &str, edge: StatusEdge, prev_len: i32) -> StatusBarItem {
+ StatusBarItem { app_id: app_id.to_string(), edge, prev_len }
+ }
+
+ #[test]
+ fn top_groups_flow_from_edges() {
+ let items = vec![
+ item("cce-status-left-viewport", StatusEdge::TopLeft, 0),
+ item("cce-status-left-window", StatusEdge::TopLeft, 0),
+ item("cce-status-right-clock", StatusEdge::TopRight, 200),
+ ];
+ let p = layout_status_bars(&items, ¶ms());
+ // Left group flows right from the margin; fresh bars default to width 100.
+ assert_eq!(p[0], Some(StatusBarPlacement { x: 12, y: 0, width: 100, height: 30 }));
+ assert_eq!(p[1], Some(StatusBarPlacement { x: 124, y: 0, width: 100, height: 30 }));
+ // Right group is placed from the right edge inward.
+ assert_eq!(p[2], Some(StatusBarPlacement { x: 1908 - 200, y: 0, width: 200, height: 30 }));
+ }
+
+ #[test]
+ fn right_group_sorted_by_module_order() {
+ let items = vec![
+ item("cce-status-right-clock", StatusEdge::TopRight, 100),
+ item("cce-status-right-battery", StatusEdge::TopRight, 100),
+ ];
+ let p = layout_status_bars(&items, ¶ms());
+ // RIGHT_ORDER puts battery before clock left-to-right, so clock hugs the edge.
+ assert_eq!(p[0].unwrap().x, 1808);
+ assert_eq!(p[1].unwrap().x, 1808 - 12 - 100);
+ }
+
+ #[test]
+ fn hide_mode_pushes_top_bars_offscreen_with_preview() {
+ let mut prm = params();
+ prm.hide_mode = true;
+ let items = vec![item("cce-status-left-viewport", StatusEdge::Unspecified, 0)];
+ let p = layout_status_bars(&items, &prm);
+ // Unspecified resolves to TopLeft; y = 0 - (30 - 5).
+ assert_eq!(p[0].unwrap().y, -25);
+ }
+
+ #[test]
+ fn usable_area_reserves_bar_edges() {
+ let output = Rect { x: 0, y: 0, width: 1920, height: 1080 };
+ let no_excl = Rect { x: 0, y: 0, width: 0, height: 0 };
+
+ // No bars, no exclusion: the full output.
+ assert_eq!(compute_usable_area(output, no_excl, 30, false, &[]), output);
+
+ // Top + left bars each reserve one bar-height.
+ let edges = [StatusEdge::TopLeft, StatusEdge::Left];
+ assert_eq!(
+ compute_usable_area(output, no_excl, 30, false, &edges),
+ Rect { x: 30, y: 30, width: 1890, height: 1050 }
+ );
+
+ // Hide mode releases the top reservation but not the others.
+ assert_eq!(
+ compute_usable_area(output, no_excl, 30, true, &edges),
+ Rect { x: 30, y: 0, width: 1890, height: 1080 }
+ );
+
+ // Layer-shell non-exclusive area applies before bar reservations.
+ let excl = Rect { x: 10, y: 20, width: 1900, height: 1040 };
+ assert_eq!(
+ compute_usable_area(output, excl, 30, false, &[StatusEdge::BottomCenter]),
+ Rect { x: 10, y: 20, width: 1900, height: 1010 }
+ );
+ }
+
+ #[test]
+ fn window_roles_from_app_id() {
+ assert_eq!(WindowRole::from_app_id(Some("cce-wallpaper")), WindowRole::Background);
+ assert_eq!(WindowRole::from_app_id(Some("cce-status-interface-right-clock")), WindowRole::StatusBar);
+ assert_eq!(WindowRole::from_app_id(Some("firefox")), WindowRole::Normal);
+ assert_eq!(WindowRole::from_app_id(None), WindowRole::Normal);
+ }
+
+ #[test]
+ fn classification_precedence() {
+ // Role wins over visibility: a minimized wallpaper still arranges as background.
+ assert_eq!(
+ classify_window(WindowRole::Background, true, true, TilingMode::Floating, false),
+ WindowClass::Background
+ );
+ assert_eq!(
+ classify_window(WindowRole::StatusBar, false, false, TilingMode::Floating, false),
+ WindowClass::StatusBar
+ );
+ // Minimized or closing/init normal windows are hidden.
+ assert_eq!(
+ classify_window(WindowRole::Normal, true, false, TilingMode::Grid, false),
+ WindowClass::Hidden
+ );
+ // Overlay mode gets the overlay slot — unless mid-drag.
+ assert_eq!(
+ classify_window(WindowRole::Normal, false, false, TilingMode::Overlay, false),
+ WindowClass::Overlay
+ );
+ assert_eq!(
+ classify_window(WindowRole::Normal, false, false, TilingMode::Overlay, true),
+ WindowClass::Normal
+ );
+ assert_eq!(
+ classify_window(WindowRole::Normal, false, false, TilingMode::Cascade, false),
+ WindowClass::Normal
+ );
+ }
+
+ fn ctx() -> PlacementCtx {
+ PlacementCtx {
+ phys: Rect { x: 0, y: 0, width: 1920, height: 1080 },
+ usable: Rect { x: 0, y: 30, width: 1920, height: 1050 },
+ pan_x: 0.0,
+ pan_y: 0.0,
+ zoom: 1.0,
+ }
+ }
+
+ #[test]
+ fn fresh_overlay_gets_configured_slot() {
+ let placement = place_overlay_window(
+ &OverlaySnapshot {
+ box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
+ min_width: 0,
+ is_cloud: false,
+ ssd: true,
+ decorations_size: (0, 16),
+ },
+ &OverlayParams {
+ overlay_width: 400,
+ border_gap: 8,
+ border_width: 0,
+ position_right: true,
+ cloud_position_default: None,
+ },
+ &ctx(),
+ );
+ // Right slot: x = usable right edge - width - gap; height fills the
+ // usable area minus the 16px decoration strip and both gaps.
+ assert_eq!(placement.pos, (1512, 54));
+ assert_eq!(placement.size, (400, 1018));
+ assert_eq!(placement.box_geom_write, Some(Rect { x: 1512, y: 54, width: 400, height: 1018 }));
+ assert_eq!(placement.virtual_pos, (1512.0, 54.0));
+ }
+
+ #[test]
+ fn fresh_overlay_slot_insets_by_border_width() {
+ let placement = place_overlay_window(
+ &OverlaySnapshot {
+ box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
+ min_width: 0,
+ is_cloud: false,
+ ssd: true,
+ decorations_size: (0, 16),
+ },
+ &OverlayParams {
+ overlay_width: 400,
+ border_gap: 8,
+ border_width: 4,
+ position_right: true,
+ cloud_position_default: None,
+ },
+ &ctx(),
+ );
+ // Content sits one border width inside the zero-width slot on every
+ // side, so the border lands within the gaps.
+ assert_eq!(placement.pos, (1512 - 4, 54 + 4));
+ assert_eq!(placement.size, (400, 1018 - 8));
+ }
+
+ #[test]
+ fn overlay_without_ssd_shrinks_by_decorations() {
+ let placement = place_overlay_window(
+ &OverlaySnapshot {
+ box_geom: Rect { x: 100, y: 100, width: 400, height: 500 },
+ min_width: 0,
+ is_cloud: false,
+ ssd: false,
+ decorations_size: (2, 18),
+ },
+ &OverlayParams { overlay_width: 400, border_gap: 8, border_width: 0, position_right: false, cloud_position_default: None },
+ &ctx(),
+ );
+ // Existing geometry is kept; the client is sized minus decorations.
+ assert_eq!(placement.pos, (100, 100));
+ assert_eq!(placement.size, (398, 482));
+ assert_eq!(placement.box_geom_write, None);
+ }
+
+ #[test]
+ fn maximized_transitions() {
+ // Entering with no usable geometry falls back to 800x600.
+ assert_eq!(
+ maximized_transition(true, false, (0, 0), (0, 0), (0, 0), (0.0, 0.0)),
+ Some(MaximizedTransition::Enter { width: 800, height: 600 })
+ );
+ // Entering keeps real geometry.
+ assert_eq!(
+ maximized_transition(true, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)),
+ Some(MaximizedTransition::Enter { width: 640, height: 480 })
+ );
+ // Steady states do nothing.
+ assert_eq!(maximized_transition(true, true, (640, 480), (0, 0), (640, 480), (0.0, 0.0)), None);
+ assert_eq!(maximized_transition(false, false, (640, 480), (0, 0), (0, 0), (0.0, 0.0)), None);
+ // Exit restores the saved geometry; invalid saved size is a no-op.
+ assert_eq!(
+ maximized_transition(false, true, (0, 0), (0, 0), (640, 480), (10.0, 20.0)),
+ Some(MaximizedTransition::Exit { width: 640, height: 480, virtual_x: 10.0, virtual_y: 20.0 })
+ );
+ assert_eq!(maximized_transition(false, true, (0, 0), (0, 0), (0, 480), (10.0, 20.0)), None);
+ }
+
+ #[test]
+ fn maximized_snaps_to_grid_cells() {
+ let snap = NormalSnapshot {
+ mode: TilingMode::Maximized,
+ box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
+ min_size: (0, 0),
+ virtual_pos: (150.0, 120.0),
+ active_resize: None,
+ is_cloud: false,
+ saved_maximized_size: (100, 50),
+ saved_maximized_virtual: (150.0, 120.0),
+ };
+ let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
+ let placement = place_normal_window(&snap, &p, &ctx());
+ // Saved geometry spans grid columns 1-2 and row 1 → snapped to
+ // (100,100) with size 200x100.
+ assert_eq!(placement.virtual_write, Some((100.0, 100.0)));
+ assert_eq!(placement.pos, (100, 100));
+ assert_eq!(placement.size, (200, 100));
+ assert_eq!(placement.hidden, Some(false));
+ assert_eq!(placement.scale, 1.0);
+ }
+
+ #[test]
+ fn maximized_insets_by_border_width() {
+ let snap = NormalSnapshot {
+ mode: TilingMode::Maximized,
+ box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
+ min_size: (0, 0),
+ virtual_pos: (150.0, 120.0),
+ active_resize: None,
+ is_cloud: false,
+ saved_maximized_size: (100, 50),
+ saved_maximized_virtual: (150.0, 120.0),
+ };
+ let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 4, cloud_position_default: None, desktop_grid_scale: 100.0 };
+ let placement = place_normal_window(&snap, &p, &ctx());
+ // Same cell span as without borders (100,100)+200x100, with the
+ // content inset so the border stays inside the covered cells.
+ assert_eq!(placement.virtual_write, Some((104.0, 104.0)));
+ assert_eq!(placement.pos, (104, 104));
+ assert_eq!(placement.size, (192, 92));
+ }
+
+ #[test]
+ fn popup_docks_top_right_of_usable_area() {
+ let snap = NormalSnapshot {
+ mode: TilingMode::Popup,
+ box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
+ min_size: (0, 0),
+ virtual_pos: (0.0, 0.0),
+ active_resize: None,
+ is_cloud: false,
+ saved_maximized_size: (0, 0),
+ saved_maximized_virtual: (0.0, 0.0),
+ };
+ let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
+ let placement = place_normal_window(&snap, &p, &ctx());
+ // Defaults to 360x100, docked inside the usable area (below the bar).
+ assert_eq!(placement.pos, (1920 - 360 - 10, 30 + 6));
+ assert_eq!(placement.size, (360, 100));
+ assert_eq!(placement.hidden, None);
+
+ // With a border, the dock position insets so the border stays inside
+ // the gaps; the popup keeps its size.
+ let p = NormalParams { border_width: 4, ..p };
+ let placement = place_normal_window(&snap, &p, &ctx());
+ assert_eq!(placement.pos, (1920 - 360 - 10 - 4, 30 + 6 + 4));
+ assert_eq!(placement.size, (360, 100));
+ }
+
+ #[test]
+ fn pannable_window_follows_viewport() {
+ let snap = NormalSnapshot {
+ mode: TilingMode::Cascade,
+ box_geom: Rect { x: 0, y: 0, width: 640, height: 480 },
+ min_size: (0, 0),
+ virtual_pos: (100.0, 200.0),
+ active_resize: None,
+ is_cloud: false,
+ saved_maximized_size: (0, 0),
+ saved_maximized_virtual: (0.0, 0.0),
+ };
+ let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0 };
+ let mut c = ctx();
+ c.pan_x = 50.0;
+ c.pan_y = 100.0;
+ c.zoom = 2.0;
+ let placement = place_normal_window(&snap, &p, &c);
+ assert_eq!(placement.pos, (100, 200));
+ assert_eq!(placement.scale, 2.0);
+ assert_eq!(placement.size, (640, 480));
+ assert_eq!(placement.hidden, Some(false));
+
+ // Pan far enough away and the window is culled.
+ c.pan_x = 5000.0;
+ let placement = place_normal_window(&snap, &p, &c);
+ assert_eq!(placement.hidden, Some(true));
+
+ // Interactive resize dimensions override stored geometry.
+ let resizing = NormalSnapshot { active_resize: Some((800, 600)), ..snap };
+ c.pan_x = 50.0;
+ let placement = place_normal_window(&resizing, &p, &c);
+ assert_eq!(placement.size, (800, 600));
+ }
+
+ #[test]
+ fn opacity_policy() {
+ assert_eq!(window_opacity(true, true, OVERLAY_UNFOCUSED_OPACITY), 1.0);
+ assert_eq!(window_opacity(false, false, OVERLAY_UNFOCUSED_OPACITY), 1.0);
+ assert_eq!(window_opacity(false, true, OVERLAY_UNFOCUSED_OPACITY), 0.85);
+ assert_eq!(window_opacity(false, true, NORMAL_UNFOCUSED_OPACITY), 0.90);
+ }
+
+ fn snap(app_id: &str) -> WindowSnapshot {
+ WindowSnapshot {
+ app_id: Some(app_id.to_string()),
+ title: None,
+ role: WindowRole::from_app_id(Some(app_id)),
+ minimized: false,
+ closing_or_init: false,
+ mode: TilingMode::Floating,
+ rule_ssd: None,
+ being_moved: false,
+ status_edge: StatusEdge::Unspecified,
+ is_focused: false,
+ box_geom: Rect { x: 0, y: 0, width: 640, height: 480 },
+ min_size: (0, 0),
+ virtual_pos: (100.0, 200.0),
+ active_resize: None,
+ ssd: true,
+ decorations_size: (0, 16),
+ was_maximized: false,
+ saved_maximized_size: (0, 0),
+ saved_maximized_virtual: (0.0, 0.0),
+ }
+ }
+
+ fn arrange_params() -> ArrangeParams {
+ ArrangeParams {
+ bar_height: 30,
+ status_hide_mode: false,
+ hide_mode_preview: 5,
+ status_blur: true,
+ window_blur: true,
+ opacity_enabled: true,
+ decoration: DecorationSpec {
+ border_width: 0,
+ border_color: crate::api::Rgba([0.1, 0.2, 0.3, 0.4]),
+ corner_radius: 0,
+ },
+ border_color_focused: crate::api::Rgba([0.9, 0.1, 0.1, 1.0]),
+ overlay: OverlayParams {
+ overlay_width: 400,
+ border_gap: 8,
+ border_width: 0,
+ position_right: true,
+ cloud_position_default: None,
+ },
+ normal: NormalParams {
+ gap_right: 10,
+ gap_top: 6,
+ border_width: 0,
+ cloud_position_default: None,
+ desktop_grid_scale: 100.0,
+ },
+ pan_x: 0.0,
+ pan_y: 0.0,
+ zoom: 1.0,
+ }
+ }
+
+ fn one_output() -> Vec<OutputSnapshot> {
+ vec![OutputSnapshot {
+ layout_box: Rect { x: 0, y: 0, width: 1920, height: 1080 },
+ non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
+ }]
+ }
+
+ #[test]
+ fn arrange_background_covers_output_and_disables_fallback_rect() {
+ let windows = vec![snap("cce-wallpaper"), snap("firefox")];
+ let plan = arrange(&windows, &one_output(), &arrange_params());
+
+ assert!(!plan.background_rect_enabled);
+ let wp = &plan.windows[0];
+ assert_eq!(wp.tiling_mode, Some(TilingMode::Status));
+ assert_eq!(wp.tiled, Some(0));
+ assert_eq!(wp.ssd, Some(false));
+ assert_eq!(wp.scene_enabled, Some(true));
+ assert_eq!(wp.hidden, Some(false));
+ assert_eq!(wp.blur, Some(false));
+ assert_eq!(wp.pos, Some((0, 0)));
+ assert_eq!(wp.size, Some((1920, 1080)));
+
+ // Without a wallpaper window, the fallback rect stays on.
+ let plan = arrange(&windows[1..], &one_output(), &arrange_params());
+ assert!(plan.background_rect_enabled);
+ }
+
+ #[test]
+ fn arrange_normal_window_pans_with_border_blur_opacity() {
+ let mut w = snap("firefox");
+ w.is_focused = false;
+ let plan = arrange(&[w], &one_output(), &arrange_params());
+
+ let wp = &plan.windows[0];
+ assert_eq!(wp.scene_enabled, Some(true));
+ assert_eq!(wp.tiling_mode, Some(TilingMode::Floating));
+ assert_eq!(wp.pos, Some((100, 200)));
+ assert_eq!(wp.scale, Some(1.0));
+ assert_eq!(wp.size, Some((640, 480)));
+ assert_eq!(wp.hidden, Some(false));
+ assert_eq!(wp.tiled, None);
+ assert_eq!(wp.decoration, Some(arrange_params().decoration));
+ assert_eq!(wp.blur, Some(true));
+ assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
+ }
+
+ #[test]
+ fn arrange_focused_window_gets_focused_border_color() {
+ let mut focused = snap("firefox");
+ focused.is_focused = true;
+ let unfocused = snap("terminal");
+ let p = arrange_params();
+ let plan = arrange(&[focused, unfocused], &one_output(), &p);
+
+ assert_eq!(plan.windows[0].decoration.unwrap().border_color, p.border_color_focused);
+ assert_eq!(plan.windows[1].decoration.unwrap().border_color, p.decoration.border_color);
+ }
+
+ #[test]
+ fn arrange_fullscreen_window_gets_no_border() {
+ let mut w = snap("mpv");
+ w.mode = TilingMode::Fullscreen;
+ let mut p = arrange_params();
+ p.decoration.border_width = 4;
+ p.decoration.corner_radius = 12;
+ let plan = arrange(&[w], &one_output(), &p);
+
+ let dec = plan.windows[0].decoration.unwrap();
+ assert_eq!(dec.border_width, 0);
+ assert_eq!(dec.corner_radius, 0);
+ }
+
+ #[test]
+ fn arrange_hidden_window_disabled_in_scene() {
+ let mut w = snap("firefox");
+ w.minimized = true;
+ let plan = arrange(&[w], &one_output(), &arrange_params());
+
+ let wp = &plan.windows[0];
+ assert_eq!(wp.scene_enabled, Some(false));
+ assert_eq!(wp.hidden, Some(true));
+ assert_eq!(wp.pos, None);
+ assert_eq!(wp.size, None);
+ }
+
+ #[test]
+ fn arrange_first_overlay_gets_slot_second_demotes_to_normal() {
+ let mut first = snap("scratchpad");
+ first.mode = TilingMode::Overlay;
+ first.box_geom = Rect { x: 0, y: 0, width: 0, height: 0 };
+ let mut second = snap("other-overlay");
+ second.mode = TilingMode::Overlay;
+
+ let plan = arrange(&[first, second], &one_output(), &arrange_params());
+
+ // Fresh overlay: right slot (no bars, so the full output is usable),
+ // box_geom written back.
+ let wp = &plan.windows[0];
+ assert_eq!(wp.pos, Some((1512, 24)));
+ assert_eq!(wp.size, Some((400, 1048)));
+ assert_eq!(wp.box_geom, Some(Rect { x: 1512, y: 24, width: 400, height: 1048 }));
+ assert_eq!(wp.tiled, Some(15));
+ assert_eq!(wp.opacity, Some(OVERLAY_UNFOCUSED_OPACITY));
+
+ // Second overlay arranges as a pannable normal window.
+ let wp = &plan.windows[1];
+ assert_eq!(wp.tiling_mode, Some(TilingMode::Overlay));
+ assert_eq!(wp.pos, Some((100, 200)));
+ assert_eq!(wp.size, Some((640, 480)));
+ assert_eq!(wp.opacity, Some(NORMAL_UNFOCUSED_OPACITY));
+ }
+
+ #[test]
+ fn arrange_rule_ssd_reaches_overlay_sizing() {
+ let mut w = snap("scratchpad");
+ w.mode = TilingMode::Overlay;
+ w.ssd = true;
+ w.rule_ssd = Some(false);
+ w.decorations_size = (2, 18);
+
+ let plan = arrange(&[w], &one_output(), &arrange_params());
+ let wp = &plan.windows[0];
+ // The rule override is planned and the client size shrinks by the
+ // decorations, proving the override was visible to placement.
+ assert_eq!(wp.ssd, Some(false));
+ assert_eq!(wp.size, Some((638, 462)));
+ }
+
+ #[test]
+ fn arrange_maximized_enter_saves_geometry() {
+ let mut w = snap("firefox");
+ w.mode = TilingMode::Maximized;
+ w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
+ w.virtual_pos = (150.0, 120.0);
+
+ let plan = arrange(&[w], &one_output(), &arrange_params());
+ let wp = &plan.windows[0];
+ assert_eq!(wp.was_maximized, Some(true));
+ assert_eq!(wp.saved_maximized, Some(((150, 50), (150.0, 120.0))));
+ // Grid snap: spans columns 1-2, row 1 of the 100px grid.
+ assert_eq!(wp.virtual_pos, Some((100.0, 100.0)));
+ assert_eq!(wp.pos, Some((100, 100)));
+ assert_eq!(wp.size, Some((200, 100)));
+ }
+
+ #[test]
+ fn arrange_maximized_exit_restores_saved_geometry() {
+ let mut w = snap("firefox");
+ w.mode = TilingMode::Floating;
+ w.was_maximized = true;
+ w.saved_maximized_size = (500, 400);
+ w.saved_maximized_virtual = (10.0, 20.0);
+
+ let plan = arrange(&[w], &one_output(), &arrange_params());
+ let wp = &plan.windows[0];
+ assert_eq!(wp.was_maximized, Some(false));
+ assert_eq!(wp.box_geom, Some(Rect { x: 0, y: 0, width: 500, height: 400 }));
+ // The restored geometry flows into the pannable placement.
+ assert_eq!(wp.virtual_pos, Some((10.0, 20.0)));
+ assert_eq!(wp.pos, Some((10, 20)));
+ assert_eq!(wp.size, Some((500, 400)));
+ }
+
+ #[test]
+ fn arrange_status_bar_placed_unless_dragged() {
+ let mut bar = snap("cce-status-left-viewport");
+ bar.status_edge = StatusEdge::TopLeft;
+ bar.box_geom = Rect { x: 0, y: 0, width: 200, height: 30 };
+
+ let plan = arrange(&[bar.clone()], &one_output(), &arrange_params());
+ let wp = &plan.windows[0];
+ assert_eq!(wp.tiling_mode, Some(TilingMode::Status));
+ assert_eq!(wp.blur, Some(true));
+ assert_eq!(wp.pos, Some((12, 0)));
+ assert_eq!(wp.size, Some((200, 30)));
+
+ // A dragged bar keeps whatever geometry it has: no placement writes.
+ bar.being_moved = true;
+ let plan = arrange(&[bar], &one_output(), &arrange_params());
+ let wp = &plan.windows[0];
+ assert_eq!(wp.pos, None);
+ assert_eq!(wp.size, None);
+ }
+
+ #[test]
+ fn arrange_multi_output_is_last_wins() {
+ let outputs = vec![
+ OutputSnapshot {
+ layout_box: Rect { x: 0, y: 0, width: 1920, height: 1080 },
+ non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
+ },
+ OutputSnapshot {
+ layout_box: Rect { x: 1920, y: 0, width: 1280, height: 720 },
+ non_exclusive: Rect { x: 0, y: 0, width: 0, height: 0 },
+ },
+ ];
+ let windows = vec![snap("cce-wallpaper"), snap("firefox")];
+ let plan = arrange(&windows, &outputs, &arrange_params());
+
+ // The background covers the second output — the last pass wins.
+ assert_eq!(plan.windows[0].pos, Some((1920, 0)));
+ assert_eq!(plan.windows[0].size, Some((1280, 720)));
+
+ // Maximize state machine only fires once across passes: entering on
+ // pass one must not re-enter (and re-save) on pass two.
+ let mut w = snap("firefox");
+ w.mode = TilingMode::Maximized;
+ w.box_geom = Rect { x: 0, y: 0, width: 150, height: 50 };
+ w.virtual_pos = (150.0, 120.0);
+ let plan = arrange(&[w], &outputs, &arrange_params());
+ // Saved from the original geometry, not the pass-one grid snap.
+ assert_eq!(plan.windows[0].saved_maximized, Some(((150, 50), (150.0, 120.0))));
+ }
+
+ #[test]
+ fn side_stacks_center_vertically() {
+ let items = vec![
+ item("cce-status-a", StatusEdge::Left, 200),
+ item("cce-status-b", StatusEdge::Left, 100),
+ ];
+ let p = layout_status_bars(&items, ¶ms());
+ // Total stack: 200 + 12 + 100 = 312, centered in 1080 → starts at 384.
+ assert_eq!(p[0], Some(StatusBarPlacement { x: 0, y: 384, width: 30, height: 200 }));
+ assert_eq!(p[1], Some(StatusBarPlacement { x: 0, y: 596, width: 30, height: 100 }));
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..26e6a1e
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,25 @@
+// cce-window-manager — the window-management policy layer of the cce
+// desktop, split out of the compositor crate (`cce-fx`).
+//
+// Everything here is pure Rust — no FFI, no raw wlroots pointers. The
+// mechanism side (scene graph, seats, shells, sockets) lives in the `cce`
+// compositor crate, which depends on this one and talks to policy code only
+// through the types in `api`.
+//
+// Modules:
+// - `api`: the `Policy` / `Compositor` trait boundary and the plain-data
+// vocabulary (WindowId, WindowRole, DecorationSpec, Action, …). Traits
+// are a skeleton — defined but not yet driven by the compositor.
+// - `arrange`: the whole arrange pass as pure functions
+// (snapshot → plan → apply instructions).
+// - `tiling`: `TilingMode` and pure layout formulas.
+// - `state`: persisted session state (serialization/matching only; the
+// save/load I/O stays in the compositor).
+// - `slotmap`: generational-index map (river-derived, 0BSD); `api::WindowId`
+// wraps its `Key`.
+
+pub mod api;
+pub mod arrange;
+pub mod slotmap;
+pub mod state;
+pub mod tiling;
diff --git a/src/slotmap.rs b/src/slotmap.rs
new file mode 100644
index 0000000..5bab508
--- /dev/null
+++ b/src/slotmap.rs
@@ -0,0 +1,195 @@
+// SPDX-FileCopyrightText: © 2025 Isaac Freund
+// SPDX-License-Identifier: 0BSD
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Key {
+ pub generation: u32,
+ pub index: u32,
+}
+
+enum SlotData<T> {
+ Value(T),
+ NextFree(u32),
+}
+
+struct Slot<T> {
+ generation: u32,
+ data: SlotData<T>,
+}
+
+pub struct SlotMap<T> {
+ slots: Vec<Slot<T>>,
+ count: u32,
+ first_free: u32,
+}
+
+impl<T> SlotMap<T> {
+ pub fn new() -> Self {
+ Self {
+ slots: Vec::new(),
+ count: 0,
+ first_free: 1,
+ }
+ }
+}
+
+impl<T> Default for SlotMap<T> {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl<T> SlotMap<T> {
+
+ pub fn put(&mut self, value: T) -> Key {
+ let index = self.first_free as usize;
+ if index < self.slots.len() {
+ let slot = &mut self.slots[index];
+ if let SlotData::NextFree(next) = slot.data {
+ self.first_free = next;
+ } else {
+ panic!("SlotMap state corrupted: expected NextFree slot");
+ }
+ slot.data = SlotData::Value(value);
+ self.count += 1;
+ Key {
+ generation: slot.generation,
+ index: index as u32,
+ }
+ } else {
+ self.slots.push(Slot {
+ generation: 0,
+ data: SlotData::Value(value),
+ });
+ let new_index = self.slots.len() - 1;
+ self.count += 1;
+ self.first_free += 1;
+ Key {
+ generation: 0,
+ index: new_index as u32,
+ }
+ }
+ }
+
+ pub fn get(&self, key: Key) -> Option<&T> {
+ let idx = key.index as usize;
+ if idx < self.slots.len() {
+ let slot = &self.slots[idx];
+ if slot.generation == key.generation {
+ if let SlotData::Value(ref val) = slot.data {
+ return Some(val);
+ }
+ }
+ }
+ None
+ }
+
+ pub fn get_mut(&mut self, key: Key) -> Option<&mut T> {
+ let idx = key.index as usize;
+ if idx < self.slots.len() {
+ let slot = &mut self.slots[idx];
+ if slot.generation == key.generation {
+ if let SlotData::Value(ref mut val) = slot.data {
+ return Some(val);
+ }
+ }
+ }
+ None
+ }
+
+ pub fn remove(&mut self, key: Key) -> Option<T> {
+ let idx = key.index as usize;
+ if idx < self.slots.len() {
+ let slot = &mut self.slots[idx];
+ if slot.generation == key.generation {
+ if let SlotData::Value(_) = slot.data {
+ let old_data = std::mem::replace(&mut slot.data, SlotData::NextFree(self.first_free));
+ slot.generation = slot.generation.wrapping_add(1);
+ self.count -= 1;
+ self.first_free = key.index;
+ if let SlotData::Value(val) = old_data {
+ return Some(val);
+ }
+ }
+ }
+ }
+ None
+ }
+
+ pub fn count(&self) -> u32 {
+ self.count
+ }
+
+ pub fn iter(&self) -> Iter<'_, T> {
+ Iter {
+ slots: &self.slots,
+ index: 0,
+ }
+ }
+}
+
+pub struct Iter<'a, T> {
+ slots: &'a [Slot<T>],
+ index: usize,
+}
+
+impl<'a, T> Iterator for Iter<'a, T> {
+ type Item = &'a T;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ while self.index < self.slots.len() {
+ let slot = &self.slots[self.index];
+ self.index += 1;
+ if let SlotData::Value(ref val) = slot.data {
+ return Some(val);
+ }
+ }
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_basic() {
+ let mut map = SlotMap::new();
+
+ let key5 = map.put(5);
+ assert_eq!(map.get(key5), Some(&5));
+
+ assert_eq!(map.remove(key5), Some(5));
+ assert_eq!(map.get(key5), None);
+
+ let key6 = map.put(6);
+ assert_eq!(map.get(key6), Some(&6));
+ assert_eq!(map.get(key5), None);
+
+ let key7 = map.put(7);
+ let key8 = map.put(8);
+ let key9 = map.put(9);
+
+ assert_eq!(map.get(key6), Some(&6));
+ assert_eq!(map.get(key7), Some(&7));
+ assert_eq!(map.get(key8), Some(&8));
+ assert_eq!(map.get(key9), Some(&9));
+
+ map.remove(key8);
+ assert_eq!(map.get(key8), None);
+ assert_eq!(map.get(key9), Some(&9));
+ }
+
+ #[test]
+ fn test_iteration() {
+ let mut map = SlotMap::new();
+ map.put(5);
+ map.put(6);
+ map.put(7);
+ map.put(8);
+ map.put(9);
+
+ let vals: Vec<&i32> = map.iter().collect();
+ assert_eq!(vals, vec![&5, &6, &7, &8, &9]);
+ }
+}
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..7f677cd
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,32 @@
+// Persisted session state, saved to $XDG_STATE_HOME/cce/state.json on shutdown
+// and restored on startup. Pure data — serialization and matching logic only;
+// the save/load I/O lives in `window_manager.rs`.
+
+use super::tiling::TilingMode;
+
+#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
+pub struct SavedWindowState {
+ pub app_id: String,
+ pub title: String,
+ pub tiling_mode: TilingMode,
+ pub minimized: bool,
+ pub virtual_x: f64,
+ pub virtual_y: f64,
+ pub scale: f64,
+ pub width: u32,
+ pub height: u32,
+ pub cmdline: String,
+ #[serde(default)]
+ pub focused: bool,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
+pub struct SavedState {
+ pub desk_pan_x: f64,
+ pub desk_pan_y: f64,
+ pub desk_zoom: f64,
+ pub global_layout: TilingMode,
+ pub windows: Vec<SavedWindowState>,
+ #[serde(default)]
+ pub last_window_states: Vec<SavedWindowState>,
+}
diff --git a/src/tiling.rs b/src/tiling.rs
new file mode 100644
index 0000000..043e6f8
--- /dev/null
+++ b/src/tiling.rs
@@ -0,0 +1,113 @@
+// Tiling formulas ported from cce-client
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+pub enum TilingMode {
+ Floating,
+ Cascade,
+ Grid,
+ Fullscreen,
+ Popup,
+ Overlay,
+ Status,
+ Maximized,
+}
+
+impl TilingMode {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ TilingMode::Floating => "Floating",
+ TilingMode::Cascade => "Cascade",
+ TilingMode::Grid => "Grid",
+ TilingMode::Fullscreen => "Fullscreen",
+ TilingMode::Popup => "Popup",
+ TilingMode::Overlay => "Overlay",
+ TilingMode::Status => "Status",
+ TilingMode::Maximized => "Maximized",
+ }
+ }
+}
+
+/// Cascade depth factor: each depth step multiplies channels by this
+pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
+
+/// Full alpha, byte-replicated for River's color format
+pub const CASCADE_ALPHA: u32 = 0xFFFFFFFFu32;
+
+/// Tile a window in cascade mode.
+pub fn tile_cascade(
+ screen_w: i32,
+ screen_h: i32,
+ _gap: i32,
+ gap_top: i32,
+ gap_left: i32,
+ gap_right: i32,
+ gap_bottom: i32,
+ bw: i32,
+ dec_h: i32,
+ cascade_offset: i32,
+ bar_height: i32,
+ n_cascade: i32,
+ idx: i32,
+) -> (i32, i32, i32, i32) {
+ let max_offsets = 5;
+ let eff_cascade = n_cascade.min(max_offsets);
+ let width = screen_w - gap_left - gap_right - bw * 2 - cascade_offset * (eff_cascade - 1);
+ let height = screen_h - bar_height - gap_top - gap_bottom - (dec_h + bw) - cascade_offset * (eff_cascade - 1);
+ let width = if width < 1 { 1 } else { width };
+ let height = if height < 1 { 1 } else { height };
+ let pos_idx = idx.min(max_offsets - 1);
+ let x = gap_left + bw + pos_idx * cascade_offset;
+ let y = bar_height + gap_top + dec_h + pos_idx * cascade_offset;
+ (x, y, width, height)
+}
+
+/// Tile a window in grid mode.
+pub fn tile_grid(
+ screen_w: i32,
+ screen_h: i32,
+ gap: i32,
+ gap_top: i32,
+ gap_left: i32,
+ gap_right: i32,
+ gap_bottom: i32,
+ bw: i32,
+ dec_h: i32,
+ bar_height: i32,
+ n_grid: i32,
+ idx: i32,
+) -> (i32, i32, i32, i32) {
+ let cols = if n_grid == 1 { 1i32 } else { 2i32 };
+ let row = idx / cols;
+ let col = idx % cols;
+ let rows = (n_grid + cols - 1) / cols;
+ let width = (screen_w - gap_left - gap_right - (cols - 1) * gap) / cols - 2 * bw;
+ let height = (screen_h - bar_height - gap_top - gap_bottom - (rows - 1) * gap) / rows - (dec_h + bw);
+ let width = if width < 1 { 1 } else { width };
+ let height = if height < 1 { 1 } else { height };
+ let x = gap_left + bw + col * (width + 2 * bw + gap);
+ let y = bar_height + gap_top + dec_h + row * (height + (dec_h + bw) + gap);
+ (x, y, width, height)
+}
+
+/// Tile a window in fullscreen mode.
+pub fn tile_fullscreen(
+ screen_w: i32,
+ screen_h: i32,
+ _gap_top: i32,
+ _gap_left: i32,
+ _gap_right: i32,
+ _gap_bottom: i32,
+ _bw: i32,
+ _bar_height: i32,
+) -> (i32, i32, i32, i32) {
+ (0, 0, screen_w, screen_h)
+}
+
+/// Interpolate a byte-replicated 32-bit channel (0xVVVVVVVV) by factor^depth.
+pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
+ let base = (fp_channel & 0xFF) as u8;
+ let f = factor.powi(depth);
+ let val = ((base as f64) * f) as u8;
+ val as u32 * 0x01010101
+}
+