// SPDX-FileCopyrightText: © 2026 mtmn // // SPDX-License-Identifier: BSD-3-Clause // Module type aliases use PascalCase per Zig convention. const Window = @This(); const std = @import("std"); const river = @import("wayland").client.river; const Output = @import("Output.zig"); const WindowManager = @import("WindowManager.zig"); const gpa = std.heap.c_allocator; /// Never null while the window lives: river ignores every request but /// destroy after the closed event, so no request needs a guard. object: *river.WindowV1, manager: *WindowManager, node: ?*river.NodeV1 = null, closed: bool = false, tags: u32 = 1, placed: bool = false, output: ?*Output = null, float: bool = false, fullscreen: bool = false, /// Fullscreen change queued by a client request, applied at the start of /// the next manage sequence where the fullscreen request is legal. pending_fullscreen: ?bool = null, focused: bool = false, app_id: ?[]const u8 = null, title: ?[]const u8 = null, width: i32 = 800, height: i32 = 600, x: i32 = 0, y: i32 = 0, /// Last tiled state sent with set_tiled and inform_maximized, to avoid /// redundant requests. tiled_sent: ?bool = null, /// Last decoration mode sent with use_csd or use_ssd, so the same request is /// not sent twice. True means CSD. csd_sent: ?bool = null, /// app_id and title events arrive outside a manage sequence, where rule /// requests are a protocol error. The event handlers set these flags and /// manageStart applies them. rules_pending re-runs every rule. /// decorations_pending re-runs only csd rules. rules_pending: bool = false, decorations_pending: bool = false, /// Last border state sent to the compositor, to avoid redundant requests. rendered_border_width: i32 = -1, rendered_border_color: [4]u32 = .{ 0, 0, 0, 0 }, /// river considers new windows shown until the first hide request. shown: bool = true, /// True while a size proposal from the last manage sequence is awaiting the /// compositor's confirmation. The reclaim logic uses it to tell the /// compositor echoing our own proposal apart from the client resizing /// itself. proposal_outstanding: bool = false, /// Last size sent with propose_dimensions. Each proposal makes river send a /// dimensions event and reconfigure the client, so an unchanged size is not /// proposed again. Cleared when the size changes for any other reason. proposed: ?[2]i32 = null, /// Last position sent with set_position, to avoid redundant requests. rendered_position: ?[2]i32 = null, /// Size of the tile the layout assigned, or zero when the window is not /// tiled. Content is clipped to it, so a buffer larger than the tile, from /// a client that is slow to shrink or one with a larger minimum size, never /// covers its neighbours. tile_clip: [2]i32 = .{ 0, 0 }, /// Last content clip size sent, to avoid redundant requests. Zero means /// clipping is off, which is river's initial state. rendered_clip: [2]i32 = .{ 0, 0 }, /// How the client prefers to be presented. Games ask for async to tear /// rather than wait for vsync. Only honoured while tearing is allowed. presentation_hint: river.OutputV1.PresentationMode = .vsync, /// Set on creation, cleared after the first manage sequence that sees /// the window. Lets manageStart give new windows focus. just_created: bool = true, /// Creation order, counting up from 1. If several windows open in one /// manage sequence, the newest takes focus wherever it is in the stack. serial: u64 = 0, pub fn init(window: *Window, object: *river.WindowV1, manager: *WindowManager, serial: u64) void { window.* = .{ .object = object, .manager = manager, .serial = serial }; object.setListener(*Window, handleEvent, window); } pub fn deinit(window: *Window) void { if (window.node) |node| { node.destroy(); window.node = null; } window.object.destroy(); if (window.app_id) |app_id| gpa.free(app_id); if (window.title) |title| gpa.free(title); window.app_id = null; window.title = null; } pub fn configure(window: *Window, width: i32, height: i32) void { const size: [2]i32 = .{ @max(width, 1), @max(height, 1) }; if (window.proposed) |proposed| { if (proposed[0] == size[0] and proposed[1] == size[1]) return; } window.proposed = size; window.width = size[0]; window.height = size[1]; window.proposal_outstanding = true; window.object.proposeDimensions(size[0], size[1]); } /// Forget the last proposed size and rendered position so both are sent /// again. river leaves them undefined after a window exits fullscreen. pub fn resendGeometry(window: *Window) void { window.proposed = null; window.rendered_position = null; } /// A placed, tiled window is under layout control: the manager owns its /// geometry and must reclaim the tile if the client changes it. pub fn controllable(window: *const Window) bool { return !window.float and !window.fullscreen and window.placed; } /// Decide whether a reported size means the client resized itself outside the /// tile grid. True means the manager must request a re-arrange so the next /// manage sequence re-proposes the tile geometry. /// /// A report that confirms a size we just proposed is the compositor echoing /// our own arrange, not a client resize, so it never triggers a reclaim. A /// window the manager does not control (floating, fullscreen or unplaced) is /// exempt: its geometry is its own to choose. pub fn shouldReclaim( proposal_outstanding: bool, is_controllable: bool, width: i32, height: i32, reported_width: i32, reported_height: i32, ) bool { if (proposal_outstanding) return false; if (!is_controllable) return false; return width != reported_width or height != reported_height; } test "off-grid client resize is reclaimed" { const testing = std.testing; // A placed tiled window whose reported size differs from the stored one // was resized by the client: reclaim it. try testing.expect(shouldReclaim(false, true, 1920, 1080, 640, 480)); try testing.expect(shouldReclaim(false, true, 1920, 1080, 1921, 1080)); try testing.expect(shouldReclaim(false, true, 1920, 1080, 1920, 1079)); } test "confirmation of our own proposal is not reclaimed" { const testing = std.testing; // proposal_outstanding means this report echoes the size we proposed. try testing.expect(!shouldReclaim(true, true, 1920, 1080, 1920, 1080)); } test "unchanged size is not reclaimed" { const testing = std.testing; try testing.expect(!shouldReclaim(false, true, 1920, 1080, 1920, 1080)); } test "uncontrolled windows are not reclaimed" { const testing = std.testing; // Floating, fullscreen or unplaced windows keep their own size. try testing.expect(!shouldReclaim(false, false, 1920, 1080, 640, 480)); } /// Stacking is left to the caller, which restacks only when the order of /// visible windows changes. pub fn render(window: *Window, border_width: i32, border_color: [4]u32) void { const node = window.node orelse return; const position: [2]i32 = .{ window.x, window.y }; const moved = if (window.rendered_position) |rendered| rendered[0] != position[0] or rendered[1] != position[1] else true; if (moved) { node.setPosition(position[0], position[1]); window.rendered_position = position; } if (window.rendered_clip[0] != window.tile_clip[0] or window.rendered_clip[1] != window.tile_clip[1]) { // Borders follow the clipped content, so they stay on the tile. if (window.object.getVersion() >= river.WindowV1.set_content_clip_box_since_version) { window.object.setContentClipBox(0, 0, window.tile_clip[0], window.tile_clip[1]); } window.rendered_clip = window.tile_clip; } if (window.rendered_border_width != border_width or !std.mem.eql(u32, &window.rendered_border_color, &border_color)) { window.object.setBorders( .{ .top = true, .bottom = true, .left = true, .right = true }, border_width, border_color[0], border_color[1], border_color[2], border_color[3], ); window.rendered_border_width = border_width; window.rendered_border_color = border_color; } if (!window.shown) { window.object.show(); window.shown = true; } } pub fn hide(window: *Window) void { if (!window.shown) return; window.object.hide(); window.shown = false; } fn handleEvent(_: *river.WindowV1, event: river.WindowV1.Event, window: *Window) void { switch (event) { .closed => { window.closed = true; if (window.node) |node| { node.destroy(); window.node = null; } }, .dimensions => |dimensions| { const width = @max(dimensions.width, 1); const height = @max(dimensions.height, 1); const reclaim = shouldReclaim( window.proposal_outstanding, window.controllable(), window.width, window.height, width, height, ); // A size we did not propose, such as a fullscreen or // interactive resize, makes the last proposal stale. if (!window.proposal_outstanding) window.proposed = null; window.proposal_outstanding = false; window.width = width; window.height = height; if (reclaim) window.manager.requestRearrange(window); }, .app_id => |app_id| { const had_app_id = window.app_id != null; if (window.app_id) |old| gpa.free(old); window.app_id = if (app_id.app_id) |id| gpa.dupe(u8, std.mem.sliceTo(id, 0)) catch null else null; // The first manage sequence may run before the app_id arrives, // so rules evaluated then missed it. Re-apply once the identity // is known. Later changes only update decorations. Both run in // the manage sequence that follows this event. if (window.node != null) { if (!had_app_id and window.app_id != null) { window.rules_pending = true; } else { window.decorations_pending = true; } } }, .title => |title| { if (window.title) |old| gpa.free(old); window.title = if (title.title) |name| gpa.dupe(u8, std.mem.sliceTo(name, 0)) catch null else null; if (window.node != null) window.decorations_pending = true; }, .presentation_hint => |presentation| window.presentation_hint = presentation.hint, .fullscreen_requested => window.pending_fullscreen = true, .exit_fullscreen_requested => window.pending_fullscreen = false, .dimensions_hint, .parent, .decoration_hint, .pointer_move_requested, .pointer_resize_requested, .touch_move_requested, .touch_resize_requested, .show_window_menu_requested, .maximize_requested, .unmaximize_requested, .minimize_requested, .unreliable_pid, .identifier, .capture_sessions, => {}, } }