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: 1080pxThe 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 zoneslet registry = ZoneRegistry::new();assert_eq!(registry.get_all_zones().len(), 4);
// Peripheral filteringlet peripherals = registry.get_peripheral_zones();assert_eq!(peripherals.len(), 2); // LeftPeripheral + RightPeripheralSources: 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, ®istry) 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::Centerx < -0.3 → ZoneId::LeftPeripheralx > +0.3 → ZoneId::RightPeripheralWhen 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 centerassert_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 calculationstatic 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.
Plugin IPC: JSON over Unix Socket
Section titled “Plugin IPC: JSON over Unix Socket”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.
Binary IPC: Postcard-Encoded Messages
Section titled “Binary IPC: Postcard-Encoded Messages”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_dimensionrequest — client requests a dimension change by uint indexdimension_changedevent — compositor notifies the client that the dimension has changedzone_assignedevent — 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 excludedSources: 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
Further Reading
Section titled “Further Reading”- Wayfire Plugin Integration: Rust FFI via C++ Shims — Deep dive into the dlopen/FFI boundary, SHA-256 manifest verification, and the
catch_unwindsafety pattern - Spatial Warp Rendering: Homography-Based Head Tracking Correction — How zone geometry feeds into perspective warp matrices
- Window Manager Daemon: Wayland Toplevel Tracking and IPC Protocol — The
portal-wmdaemon that issues runtimeAssignZoneandSwitchDimensionIPC requests - Dual-Display Wayland Architecture: eDP-1 and Virtual GLASSES-1 Output — How the 2560×1080 canvas maps to physical and virtual outputs