Skip to content

Spatial Domain Model: Zones, Dimensions, and Assignment Policies

The Portal spatial compositor organises every Wayland surface into a structured three-layer abstraction: zones partition the virtual canvas into focus regions, dimensions bundle zone configurations into coherent display modes, and assignment policies govern which surface lands in which zone. This page dissects all three layers — from the Rust domain types that define them, through the Wayfire plugin C++ shim that applies them to real toplevel windows, to the IPC protocol that lets external processes query and manipulate them at runtime.

Zone Taxonomy: Four Canonical Spatial Regions

Section titled “Zone Taxonomy: Four Canonical Spatial Regions”

A zone is the atomic unit of spatial placement. The system defines exactly four zone types via the ZoneId enum, which carries a #[repr(u32)] representation for FFI stability and is Copy, Hash, and serde-serializable for protocol use:

Zone ID Value Peripheral Priority Default Scale Display Name
Center 0 No 100 1.0 Center
LeftPeripheral 1 Yes 50 0.8 Left Peripheral
RightPeripheral 2 Yes 50 0.8 Right Peripheral
Overlay 3 No 200 1.0 Overlay

The Center zone is the primary focal area — directly in the user’s line of sight. LeftPeripheral and RightPeripheral flank it, reserved for utility windows that benefit from glance-based access. The Overlay zone floats above all others at the highest display priority (200), housing notifications, OSD elements, and popup launchers.

The physical geometry of these zones is defined by the compositor’s output resolution. The canvas is a 2560×1080 ultrawide framebuffer split into three equal horizontal thirds:

┌──────────────┬──────────────┬──────────────┐
│ LeftPeriph │ Center │ RightPeriph │
│ x: 0-853 │ x: 853-1706 │ x: 1706-2560 │
│ width: 853 │ width: 853 │ width: 854 │
└──────────────┴──────────────┴──────────────┘
│<──────────── 2560px total ──────────────>│
height: 1080px

The Overlay zone is dimensionless — it spans the entire canvas and layers above the three zones. The 1px discrepancy in RightPeripheral width (854 vs 853) is integer-rounding artefact from 2560 ÷ 3 = 853.33, documented explicitly in the config header as “visually imperceptible.”

Sources: portal/spatial/src/zone.rs#L19-L100, portal/wayfire-plugins/portal-spatial-config.h#L26-L41, portal/compositor/spatial-warp.toml#L1-L32

Zone Metadata: Properties and the ZoneRegistry

Section titled “Zone Metadata: Properties and the ZoneRegistry”

Each zone carries metadata beyond its identifier. The Zone struct packages five fields: id, a human-readable name, a boolean is_peripheral flag, a display_priority integer (higher wins compositing order), and a default_scale factor applied to content rendered within the zone. Peripheral zones default to a 0.8 scale factor, making utility windows visually subordinate to center-zone content — a deliberate perceptual hierarchy for AR headsets where screen real estate maps to optical FOV.

The Zone::from_id() factory encodes the canonical defaults for each zone. This is the single source of truth for zone properties; the four factory arms produce (false, 100, 1.0) for Center, (true, 50, 0.8) for each peripheral, and (false, 200, 1.0) for Overlay. Custom zones can be created via Zone::new() but require a unique ZoneId — the registry rejects duplicates.

The ZoneRegistry manages the authoritative zone collection. It wraps a RwLock<HashMap<ZoneId, Zone>>, populated at construction with all four canonical zones. Clients read zones via get_zone(id) (returns a cloned Zone), enumerate via get_all_zones(), or filter peripherals via get_peripheral_zones(). New zones can be registered through register_zone(), which returns SpatialError::ZoneAlreadyRegistered if the ID slot is occupied — preventing accidental shadowing of a canonical zone.

// Creating a zone registry with default zones
let registry = ZoneRegistry::new();
assert_eq!(registry.get_all_zones().len(), 4);
// Peripheral filtering
let peripherals = registry.get_peripheral_zones();
assert_eq!(peripherals.len(), 2); // LeftPeripheral + RightPeripheral

Sources: portal/spatial/src/zone.rs#L102-L301, portal/spatial/src/error.rs#L26-L27

Dimensions: Zone Bundles for Display Modes

Section titled “Dimensions: Zone Bundles for Display Modes”

A dimension is a named configuration that defines which zones exist and are available in a given operating context. Four preset dimensions are registered by default:

Dimension Zones Included Description Use Case
Desktop Center, LeftPeripheral, RightPeripheral, Overlay All zones active, standard priorities Full spatial computing with peripheral awareness
VR Center, LeftPeripheral, RightPeripheral, Overlay All zones active, enhanced peripheral emphasis Immersive virtual reality mode
AR Center, Overlay Peripheral zones removed Distraction-free augmented reality, focus on primary content
Tablet Center, Overlay Peripheral zones removed Simplified tablet/touch layout

The critical architectural distinction is that AR and Tablet dimensions omit peripheral zones entirely. When the dimension’s zones HashMap does not contain ZoneId::LeftPeripheral, position-based zone resolution calls that would return a peripheral zone instead fail to find a matching zone — effectively disabling peripheral placement in those modes.

A Dimension carries its own copy of the zone set as HashMap<ZoneId, Zone>, making each dimension self-contained. The get_zone_for_position(x, y) method performs coordinate-based zone lookup using a ±0.3 threshold: x.abs() < 0.3 resolves to Center, negative x to LeftPeripheral, positive x to RightPeripheral. This normalised coordinate space is independent of pixel geometry.

The DimensionRegistry stores all available dimensions in a RwLock<HashMap<String, Dimension>>, keyed by name. The ActiveDimension tracker wraps an Arc<RwLock<Option<Dimension>>> — initially None, set via switch_dimension(name, &registry) which performs a registry lookup, validates availability, and atomically swaps the active dimension. Unavailable dimensions (available: false) produce SpatialError::DimensionUnavailable; unknown names produce SpatialError::DimensionNotFound.

let dim_registry = DimensionRegistry::new();
let active = ActiveDimension::new();
active.switch_dimension("VR", &dim_registry).unwrap();
// Now: active.get_active().unwrap().name == "VR"

Sources: portal/spatial/src/dimension.rs#L14-L404, portal/spatial/src/dimension/tests.rs#L1-L121

Assignment Policies: Two-Layer Decision Architecture

Section titled “Assignment Policies: Two-Layer Decision Architecture”

Zone assignment operates through two distinct policy layers, each serving a different consumer. This separation is deliberate: the spatial core library (portal-spatial) provides a position-based policy suitable for programmatic placement, while the Wayfire plugin (portal-spatial-plugin) provides a heuristic policy tailored to real window-class identification.

Layer 1: Position-Based Policy (portal-spatial Core)

Section titled “Layer 1: Position-Based Policy (portal-spatial Core)”

The AssignmentPolicy trait defines three methods: assign_zone_for_position(x, y) for coordinate-based resolution, is_assignment_allowed(zone_id) for gating, and get_zone_priority(zone_id) for ordering. The default implementation, DefaultAssignmentPolicy, uses the same ±0.3 x-threshold as Dimension::get_zone_for_position:

x ∈ [-0.3, +0.3] → ZoneId::Center
x < -0.3 → ZoneId::LeftPeripheral
x > +0.3 → ZoneId::RightPeripheral

When peripherals_enabled is false, peripheral positions are redirected to Center and is_assignment_allowed() rejects peripheral zones outright — producing AssignmentResult::Denied. This policy carries explicit priority weights: Overlay=200, Center=100, LeftPeripheral=50, RightPeripheral=50.

The AssignmentEngine applies a policy to manage view-to-zone assignments. It maintains two concurrent data structures behind RwLock: a HashMap<String, ZoneAssignment> for view→zone lookups and a HashMap<ZoneId, Vec<String>> for reverse (zone→views) queries. The engine supports atomic re-assignment via reassign_all(), which clears both maps — used when dimensions change and all views must be re-evaluated.

Layer 2: Heuristic Classification (portal-spatial-plugin)

Section titled “Layer 2: Heuristic Classification (portal-spatial-plugin)”

The ZonePolicy struct in the plugin crate takes a fundamentally different approach. Instead of coordinates, it classifies surfaces by app_id and title string matching against curated application lists:

Classification Match Targets Assigned Zone Rationale
Utility apps Terminals (foot, alacritty, kitty, wezterm, …), file managers (nautilus, thunar, dolphin, …), system tools (htop, btop, pavucontrol, …) Left or Right Peripheral (load-balanced) Glance-accessible tools don’t need center focus
Overlay apps Notifications, OSD, volume/brightness, mako, wob, rofi, wofi, fuzzel, … Overlay Float above all content
Main content Browsers (firefox, chromium, …), editors, unknown apps Center Primary user focus area

Browser detection is explicit and pre-emptive: browsers are checked first and forced to Center, overriding any incidental match against other classification lists. This prevents a browser whose title contains a utility keyword from being misrouted.

Peripheral assignment uses load balancing: zone_counts (current surface counts per zone) are passed to ZonePolicy::assign(), and utility apps are routed to whichever peripheral has fewer surfaces. With equal counts, left is preferred (left <= right comparison).

let policy = ZonePolicy::new();
// First terminal goes left (0 == 0, left preferred)
assert_eq!(policy.assign("", "foot", [0, 0, 0]), ZoneId::LeftPeripheral);
// Second terminal goes right (left=2 > right=1)
assert_eq!(policy.assign("", "foot", [0, 2, 1]), ZoneId::RightPeripheral);
// Browser always center
assert_eq!(policy.assign("Firefox", "firefox", [0, 0, 0]), ZoneId::Center);

Sources: portal/spatial/src/assignment.rs#L24-L448, portal/spatial-plugin/src/zone_policy.rs#L1-L174, portal/spatial/src/assignment/tests.rs#L1-L173

Wayfire Plugin Integration: From Policy to Pixels

Section titled “Wayfire Plugin Integration: From Policy to Pixels”

The C++ Wayfire plugin (plugin.cpp) is the bridge between abstract zone assignment and concrete window geometry. At initialisation, it dlopens libportal_spatial_plugin.so after verifying a SHA-256 sidecar manifest, then resolves four FFI symbols: portal_spatial_init, portal_spatial_fini, portal_spatial_view_mapped, and portal_spatial_view_unmapped.

When a Wayland view maps, the plugin extracts the app_id and surface pointer (cast to u64 as surface ID), calls portal_spatial_view_mapped(), and receives a zone ID integer. The Rust side applies ZonePolicy::assign() against the app_id string and current zone_counts, registers the assignment in the plugin’s ZoneRegistry, and returns the zone. The C++ side then translates the zone ID to a pixel offset:

Zone ID X Offset Pixel Range
0 (Center) ZONE_WIDTH (853) 853–1706
1 (LeftPeripheral) 0 0–853
2 (RightPeripheral) ZONE_WIDTH * 2 (1706) 1706–2560

Window dimensions are clamped to ZONE_WIDTH (853) horizontally and MAX_WINDOW_HEIGHT (900) vertically, then centred within the zone. The 900px height ceiling — deliberately below the 1080px screen — reserves ~180px for future shell chrome (status bar, notifications) while keeping windows visually comfortable in the AR headset’s optical FOV.

// From plugin.cpp — zone offset calculation
static int zone_x_offset(int zone_id) {
switch (zone_id) {
case 1: return 0; // LeftPeripheral
case 2: return ZONE_WIDTH * 2; // RightPeripheral (1706)
default: return ZONE_WIDTH; // Center (853)
}
}

On unmap, portal_spatial_view_unmapped() removes the surface from the registry, freeing its zone slot for load-balancing calculations.

Sources: portal/wayfire-plugins/portal-spatial/plugin.cpp#L36-L151, portal/spatial-plugin/src/ffi.rs#L207-L288, portal/wayfire-plugins/portal-spatial-config.h#L43-L53

IPC Protocol: External Zone and Dimension Control

Section titled “IPC Protocol: External Zone and Dimension Control”

Two independent IPC transports allow external processes (notably the window manager daemon) to manipulate zone assignments and dimension state.

The primary runtime interface is a Unix socket at /run/portal/spatial.sock with length-prefixed JSON framing. The IpcRequest enum defines three operations:

Request Variant Fields Effect
AssignZone surface_id: u32, zone: ZoneId Directly assign a surface to a zone, bypassing policy
SwitchDimension surface_id: u32, dimension: u8 Set the dimension tag for a specific surface
GetAssignments Return all current (surface_id, ZoneId) tuples

The response types are Ok, Error { message }, or Assignments { assignments: Vec<(u32, ZoneId)> }. The server enforces per-connection rate limiting (100 req/sec token bucket), peer credential verification (must be portal user or root), and a 64 KiB maximum payload size.

This protocol is what the window manager daemon uses for runtime zone overrides — when the WM needs to move a window to a specific zone regardless of the heuristic policy, it sends an AssignZone request directly.

A second transport layer exists for lower-level compositor integration. The IpcMessage enum uses postcard serialization with a fixed-layout IpcHeader (20 bytes: msg_type:u32, payload_size:u32, seq_id:u32, timestamp:u64). Message types include AssignZone { view_id, zone_id, priority }, SwitchDimension { dimension_name }, QueryStatus, and UpdateHomography.

Sources: portal/spatial/src/ipc/plugin_protocol.rs#L1-L61, portal/spatial/src/ipc/message.rs#L1-L152, portal/spatial-plugin/src/ipc_server.rs#L323-L355, portal/spatial/src/ipc/mod.rs#L1-L36

Wayland Protocol Extension: portal-spatial-v1

Section titled “Wayland Protocol Extension: portal-spatial-v1”

The system defines a custom Wayland protocol extension (portal-spatial-v1.xml) for client-facing surface management. The portal_spatial_manager_v1 global exposes the zone_id enum (mirroring ZoneId values 0–3) and a get_spatial_surface request that binds a wl_surface to a portal_spatial_surface_v1 object.

Each spatial surface supports:

  • switch_dimension request — client requests a dimension change by uint index
  • dimension_changed event — compositor notifies the client that the dimension has changed
  • zone_assigned event — compositor notifies the client which zone the surface was assigned to

This protocol allows Wayland clients to be dimension-aware — for example, an application can request a dimension transition and respond to zone reassignment by adjusting its content layout.

Sources: portal/protocols/portal-spatial-v1.xml#L1-L56

Architecture Flow: End-to-End Assignment Pipeline

Section titled “Architecture Flow: End-to-End Assignment Pipeline”

The following diagram traces the complete lifecycle of a window from Wayland map event to final pixel placement:

flowchart TD
    A["Wayfire: view_mapped signal"] --> B["plugin.cpp: extract app_id + surface_id"]
    B --> C["FFI: portal_spatial_view_mapped()"]
    C --> D{"ZonePolicy::assign()"}
    D -->|"Terminal/FileMgr/Util"| E["Load-balance: Left or Right Peripheral"]
    D -->|"Notification/OSD/Launcher"| F["Overlay zone"]
    D -->|"Browser/Editor/Unknown"| G["Center zone"]
    E --> H["Registry: assign surface_id → zone"]
    F --> H
    G --> H
    H --> I["Return zone_id to C++"]
    I --> J["zone_x_offset: compute pixel x"]
    J --> K["Clamp: max(853w × 900h)"]
    K --> L["Centre within zone bounds"]
    L --> M["set_geometry: position window"]
    
    N["portal-wm daemon"] -->|"JSON over Unix socket"| O["IpcServer: handle_request()"]
    O -->|"AssignZone override"| H
    O -->|"SwitchDimension"| P["Registry: set_dimension(surface_id, dim)"]
    O -->|"GetAssignments"| Q["Return all zone assignments"]

Runtime Registry: Surface-Level State Tracking

Section titled “Runtime Registry: Surface-Level State Tracking”

The plugin-level ZoneRegistry (distinct from the core library’s ZoneRegistry) is the live state machine for surface-to-zone and surface-to-dimension mappings. It maintains two parallel parking_lot::RwLock<HashMap<u64, _>> structures: one mapping surface_id → ZoneId and another mapping surface_id → dimension:u8.

Key operations include zone_counts() which returns [usize; 3] for load balancing — note that Overlay assignments (zone ID 3) are excluded from the count array since only indices 0–2 are tracked, preventing out-of-bounds access. The all_assignments() method returns a flat Vec<(u64, ZoneId)> suitable for IPC serialisation.

let registry = ZoneRegistry::new();
registry.assign(1, ZoneId::Center);
registry.assign(2, ZoneId::LeftPeripheral);
registry.assign(3, ZoneId::RightPeripheral);
registry.assign(4, ZoneId::Overlay); // NOT counted in zone_counts()
assert_eq!(registry.zone_counts(), [1, 1, 1]); // Overlay excluded

Sources: portal/spatial-plugin/src/zone_registry.rs#L1-L146

Error Handling: Typed SpatialError Surface

Section titled “Error Handling: Typed SpatialError Surface”

The domain model uses a single SpatialError enum (backed by thiserror) across all modules. Domain-specific variants include ZoneAlreadyRegistered, ZoneNotFound, DimensionAlreadyRegistered, DimensionNotFound, InvalidDimensionTransition, DimensionUnavailable, ViewAlreadyAssigned, ViewNotAssigned, and AssignmentRejected. Infrastructure variants cover IPC (IpcDecode, IpcEncode), concurrency (LockPoisoned), and I/O. The enum is Clone and non_exhaustive, allowing callers to cache errors for retry queues and new variants to be added without breaking exhaustive match arms.

The FFI layer translates these into a C-compatible SpatialErrorCode enum: Success=0, InvalidArgument=-1, NotFound=-2, AlreadyExists=-3, AllocationFailed=-4, OperationFailed=-5, NullPointer=-6, InvalidZoneId=-7, InvalidDimension=-8, IpcError=-9.

Sources: portal/spatial/src/error.rs#L1-L109, portal/spatial/src/ffi/types.rs#L10-L32