Files
att_wm/src/Window.zig
T
2026-08-29 15:49:02 +02:00

305 lines
10 KiB
Zig

//! A single managed window.
//!
//! Event handlers here only ever mutate plain fields. Every protocol request
//! that changes window management or rendering state is issued from Wm's
//! manage/render sequence handlers, because river only permits those requests
//! between manage_start/manage_finish and render_start/render_finish.
const Window = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const wayland = @import("wayland");
const river = wayland.client.river;
const config = @import("config");
const Wm = @import("Wm.zig");
const Output = @import("Output.zig");
const layout = @import("layout.zig");
const Box = layout.Box;
wm: *Wm,
window: *river.WindowV1,
/// Created lazily: the protocol allows get_node exactly once per window.
node: ?*river.NodeV1 = null,
title: ?[]u8 = null,
app_id: ?[]u8 = null,
identifier: ?[]u8 = null,
parent: ?*Window = null,
tags: u32 = 0,
output: ?*Output = null,
floating: bool = false,
/// Set by rules or by the user; distinguishes "floating because it is a
/// dialog" from "floating because it was asked to be".
floating_forced: bool = false,
fullscreen: bool = false,
/// What we last told the window, so we only send changes.
informed_fullscreen: bool = false,
/// Target rectangle including the border, computed by the layout.
cell: Box = .{},
/// Geometry to restore when a floating window stops being fullscreen.
float_box: Box = .{},
/// The content size river last reported.
content_width: i32 = 0,
content_height: i32 = 0,
/// The last size we proposed, so we do not re-propose every manage sequence.
proposed_width: i32 = -1,
proposed_height: i32 = -1,
min_width: i32 = 0,
min_height: i32 = 0,
max_width: i32 = 0,
max_height: i32 = 0,
/// True once river has sent dimensions, i.e. the window is on screen.
mapped: bool = false,
/// True once we have sent the one-time setup requests.
configured: bool = false,
/// Computed each manage sequence.
visible: bool = false,
/// Whether the window is currently hidden, so we only send changes.
hidden: bool = false,
/// river has closed this window; it must be reaped and not touched again.
closed: bool = false,
/// A close was requested. `close` modifies window management state, so it has
/// to wait for the next manage sequence like everything else.
pending_close: bool = false,
/// Bumped whenever the window takes focus, giving a cheap "most recently
/// focused" ordering without maintaining dwm's second linked list.
focus_serial: u64 = 0,
/// Where this window's family of parents and children sits in the floating
/// stack, and how far down that family the window is. Recomputed each render
/// sequence; see `Wm.computeStacking`.
stack_serial: u64 = 0,
stack_depth: u32 = 0,
/// Cleared once the window has had its one chance at taking focus as it maps.
wants_initial_focus: bool = true,
pub fn create(wm: *Wm, window: *river.WindowV1) !*Window {
const self = try wm.gpa.create(Window);
self.* = .{ .wm = wm, .window = window };
window.setListener(*Window, onEvent, self);
return self;
}
pub fn destroy(self: *Window) void {
const gpa = self.wm.gpa;
if (self.node) |node| node.destroy();
self.window.destroy();
if (self.title) |t| gpa.free(t);
if (self.app_id) |a| gpa.free(a);
if (self.identifier) |i| gpa.free(i);
gpa.destroy(self);
}
/// The node is needed to position and stack the window; create it on demand.
pub fn getNode(self: *Window) ?*river.NodeV1 {
if (self.node) |node| return node;
self.node = self.window.getNode() catch |err| {
std.log.err("failed to create node for window: {s}", .{@errorName(err)});
return null;
};
return self.node;
}
/// The window at the top of this window's parent chain, and how many parents
/// away it is. A window with no parent is its own root, at depth zero.
///
/// The protocol promises the parent links form a tree, but a client that
/// breaks that promise must not hang the compositor, so the walk is bounded.
pub fn ancestry(self: *Window) struct { root: *Window, depth: u32 } {
var root = self;
var depth: u32 = 0;
while (root.parent) |p| {
if (depth >= max_parent_depth) break;
root = p;
depth += 1;
}
return .{ .root = root, .depth = depth };
}
/// Whether this window belongs to a window that is currently fullscreen. Such
/// a dialog has to be drawn above the fullscreen layer: its parent covers the
/// whole output, and everything below it with it.
pub fn hasFullscreenAncestor(self: *Window) bool {
var next = self.parent;
var depth: u32 = 0;
while (next) |p| : (depth += 1) {
if (depth >= max_parent_depth) break;
if (p.fullscreen and p.visible and !p.closed) return true;
next = p.parent;
}
return false;
}
const max_parent_depth = 32;
/// Clamp a proposed size to the window's advertised limits. These are hints,
/// but respecting them avoids pointless configure round-trips with windows
/// that will refuse the size anyway.
pub fn clampSize(self: *const Window, width: i32, height: i32) struct { i32, i32 } {
var w = width;
var h = height;
if (self.min_width > 0) w = @max(w, self.min_width);
if (self.min_height > 0) h = @max(h, self.min_height);
if (self.max_width > 0) w = @min(w, self.max_width);
if (self.max_height > 0) h = @min(h, self.max_height);
return .{ @max(1, w), @max(1, h) };
}
/// Apply matching rules from config to a newly created window.
pub fn applyRules(self: *Window) void {
for (config.rules) |rule| {
if (rule.app_id) |want| {
const have = self.app_id orelse continue;
if (!std.mem.eql(u8, want, have)) continue;
}
if (rule.title) |want| {
const have = self.title orelse continue;
if (std.mem.indexOf(u8, have, want) == null) continue;
}
if (rule.tags) |t| self.tags = t;
if (rule.floating) |f| {
self.floating = f;
self.floating_forced = f;
}
}
}
/// Replace an owned string field, returning whether the value actually
/// changed. Clients re-send strings that have not moved — a terminal animating
/// a spinner in its title re-sets the same text several times a second — and
/// the caller can use the answer to skip the IPC broadcast and the tab bar
/// redraw those no-op updates would otherwise trigger.
fn setString(self: *Window, field: *?[]u8, value: ?[*:0]const u8) bool {
const gpa = self.wm.gpa;
const new: ?[]const u8 = if (value) |v| std.mem.span(v) else null;
const same = if (field.*) |old|
if (new) |n| std.mem.eql(u8, old, n) else false
else
new == null;
if (same) return false;
if (field.*) |old| gpa.free(old);
field.* = null;
if (new) |n| {
// A failed dupe leaves the field null, which compares unequal to the
// next event carrying the same text, so the copy is retried then.
field.* = gpa.dupe(u8, n) catch null;
}
return true;
}
fn onEvent(_: *river.WindowV1, event: river.WindowV1.Event, self: *Window) void {
switch (event) {
.closed => {
self.closed = true;
self.wm.needsManage();
},
.dimensions => |ev| {
self.content_width = ev.width;
self.content_height = ev.height;
if (!self.mapped) {
self.mapped = true;
// Focus and stacking for a new window are settled in the
// manage sequence, and only once it is mapped.
self.wm.needsManage();
self.wm.ipcDirty();
}
// A window may resize itself; if it is floating its cell must
// follow, otherwise the border is drawn around the wrong area.
if (self.floating and !self.fullscreen) {
const bw = config.border_width;
self.cell.width = ev.width + 2 * bw;
self.cell.height = ev.height + 2 * bw;
self.float_box = self.cell;
}
},
.dimensions_hint => |ev| {
self.min_width = ev.min_width;
self.min_height = ev.min_height;
self.max_width = ev.max_width;
self.max_height = ev.max_height;
},
.app_id => |ev| {
if (self.setString(&self.app_id, ev.app_id)) {
self.applyRules();
self.wm.ipcDirty();
}
},
.title => |ev| {
if (self.setString(&self.title, ev.title)) {
self.applyRules();
self.wm.ipcDirty();
// No needsManage: river sent this event, so it already knows
// the title moved and starts a manage sequence of its own.
// Asking for one here bought a second, identical manage and
// render pass for every retitle — and terminals retitle
// themselves constantly.
}
},
.identifier => |ev| {
_ = self.setString(&self.identifier, ev.identifier);
},
.parent => |ev| {
self.parent = if (ev.parent) |p| Wm.windowFromProxy(p) else null;
// Dialogs and file pickers float, as in dwm.
if (config.float_children and self.parent != null and !self.floating_forced) {
self.floating = true;
}
// A parent may be set after the window has already been mapped and
// laid out — Vivado's dialogs do exactly that — so the change of
// both float state and stacking has to be arranged for.
self.wm.needsManage();
self.wm.ipcDirty();
},
.fullscreen_requested => |ev| {
self.fullscreen = true;
if (ev.output) |o| {
if (Wm.outputFromProxy(o)) |out| self.output = out;
}
self.wm.needsManage();
},
.exit_fullscreen_requested => {
self.fullscreen = false;
self.wm.needsManage();
},
.pointer_move_requested => |ev| {
if (Wm.seatFromProxy(ev.seat)) |seat| seat.startMove(self);
},
.pointer_resize_requested => |ev| {
if (Wm.seatFromProxy(ev.seat)) |seat| seat.startResize(self, ev.edges);
},
// We advertise only the fullscreen capability, so these should not
// arrive; ignoring them is the documented option either way.
.maximize_requested,
.unmaximize_requested,
.minimize_requested,
.show_window_menu_requested,
.decoration_hint,
.unreliable_pid,
.presentation_hint,
=> {},
}
}