src/wayland.zig
Ref: Size: 38.3 KiB
const std = @import("std");
const posix = std.posix;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const xdg = wayland.client.xdg;
const ScaleTracker = @import("scale_tracker").ScaleTracker;
const c = @cImport({
@cInclude("xkbcommon/xkbcommon.h");
@cInclude("sys/mman.h");
@cInclude("unistd.h");
});
pub const Output = struct {
wl_output: *wl.Output,
name: u32,
tracker: *ScaleTracker,
pending_scale: i32 = 1,
};
pub const KeyboardEvent = struct {
keysym: u32,
modifiers: Modifiers,
action: Action,
utf8: [8]u8,
utf8_len: u8,
serial: u32 = 0,
pub const Action = enum { press, release, repeat };
};
pub const Modifiers = struct {
ctrl: bool = false,
shift: bool = false,
alt: bool = false,
super: bool = false,
};
pub const PointerEvent = union(enum) {
enter: struct {
serial: u32,
x: f64,
y: f64,
},
leave: struct {
serial: u32,
},
motion: struct {
time: u32,
x: f64,
y: f64,
},
button_press: struct {
serial: u32,
time: u32,
button: u32,
},
button_release: struct {
serial: u32,
time: u32,
button: u32,
},
};
const PointerState = struct {
has_focus: bool = false,
surface_x: f64 = 0,
surface_y: f64 = 0,
last_serial: u32 = 0,
fn pushEnter(
self: *PointerState,
alloc: std.mem.Allocator,
queue: *std.ArrayList(PointerEvent),
serial: u32,
x: f64,
y: f64,
) !void {
self.has_focus = true;
self.surface_x = x;
self.surface_y = y;
self.last_serial = serial;
try queue.append(alloc, .{
.enter = .{
.serial = serial,
.x = x,
.y = y,
},
});
}
fn pushLeave(
self: *PointerState,
alloc: std.mem.Allocator,
queue: *std.ArrayList(PointerEvent),
serial: u32,
) !void {
self.has_focus = false;
self.last_serial = serial;
try queue.append(alloc, .{
.leave = .{
.serial = serial,
},
});
}
fn pushMotion(
self: *PointerState,
alloc: std.mem.Allocator,
queue: *std.ArrayList(PointerEvent),
time: u32,
x: f64,
y: f64,
) !void {
self.surface_x = x;
self.surface_y = y;
try queue.append(alloc, .{
.motion = .{
.time = time,
.x = x,
.y = y,
},
});
}
fn pushButton(
self: *PointerState,
alloc: std.mem.Allocator,
queue: *std.ArrayList(PointerEvent),
serial: u32,
time: u32,
button: u32,
state: wl.Pointer.ButtonState,
) !void {
self.last_serial = serial;
try queue.append(alloc, switch (state) {
.pressed => .{
.button_press = .{
.serial = serial,
.time = time,
.button = button,
},
},
.released => .{
.button_release = .{
.serial = serial,
.time = time,
.button = button,
},
},
else => unreachable,
});
}
};
pub const Pointer = struct {
alloc: std.mem.Allocator,
wl_pointer: *wl.Pointer,
event_queue: std.ArrayList(PointerEvent),
state: PointerState = .{},
pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Pointer {
const pointer = try alloc.create(Pointer);
errdefer alloc.destroy(pointer);
var event_queue: std.ArrayList(PointerEvent) = .empty;
errdefer event_queue.deinit(alloc);
try event_queue.ensureTotalCapacity(alloc, 64);
pointer.* = .{
.alloc = alloc,
.wl_pointer = try seat.getPointer(),
.event_queue = event_queue,
};
pointer.wl_pointer.setListener(*Pointer, pointerListener, pointer);
return pointer;
}
pub fn deinit(self: *Pointer) void {
self.wl_pointer.release();
self.event_queue.deinit(self.alloc);
self.alloc.destroy(self);
}
};
pub const Keyboard = struct {
alloc: std.mem.Allocator,
wl_keyboard: *wl.Keyboard,
xkb_ctx: ?*c.xkb_context,
xkb_keymap: ?*c.xkb_keymap = null,
xkb_state: ?*c.xkb_state = null,
event_queue: std.ArrayList(KeyboardEvent),
current_mods: Modifiers = .{},
// repeat info
repeat_rate: u32 = 25,
repeat_delay: u32 = 500,
last_keycode: ?u32 = null,
last_key_time_ns: i128 = 0,
next_repeat_time_ns: i128 = 0,
has_focus: bool = false,
pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Keyboard {
const kb = try alloc.create(Keyboard);
errdefer alloc.destroy(kb);
var event_queue: std.ArrayList(KeyboardEvent) = .empty;
errdefer event_queue.deinit(alloc);
try event_queue.ensureTotalCapacity(alloc, 64);
kb.* = .{
.alloc = alloc,
.wl_keyboard = try seat.getKeyboard(),
.xkb_ctx = c.xkb_context_new(c.XKB_CONTEXT_NO_FLAGS),
.event_queue = event_queue,
};
kb.wl_keyboard.setListener(*Keyboard, keyboardListener, kb);
return kb;
}
pub fn deinit(self: *Keyboard) void {
if (self.xkb_state) |s| c.xkb_state_unref(s);
if (self.xkb_keymap) |m| c.xkb_keymap_unref(m);
if (self.xkb_ctx) |ctx| c.xkb_context_unref(ctx);
self.wl_keyboard.release();
self.event_queue.deinit(self.alloc);
self.alloc.destroy(self);
}
/// Called from the main loop. If a key is currently held and the repeat timer
/// has expired, push synthetic repeat events to the queue.
pub fn tickRepeat(self: *Keyboard) void {
const keycode = self.last_keycode orelse return;
const state = self.xkb_state orelse return;
const now = std.time.nanoTimestamp();
if (now < self.next_repeat_time_ns) return;
const keysym = c.xkb_state_key_get_one_sym(state, keycode);
var utf8: [8]u8 = undefined;
const len = c.xkb_state_key_get_utf8(state, keycode, &utf8, utf8.len);
const ev = KeyboardEvent{
.keysym = keysym,
.modifiers = self.current_mods,
.action = .repeat,
.utf8 = utf8,
.utf8_len = @intCast(@min(len, 8)),
};
self.event_queue.append(self.alloc, ev) catch |err| {
std.log.err("keyboard repeat append failed: {any}", .{err});
return;
};
// Schedule next repeat at 1/rate seconds from now
const interval_ms: i128 = if (self.repeat_rate > 0)
@divTrunc(1000, @as(i128, self.repeat_rate))
else
40;
self.next_repeat_time_ns = now + interval_ms * std.time.ns_per_ms;
}
pub fn nextRepeatDeadlineNs(self: *const Keyboard) ?i128 {
if (!self.has_focus) return null;
_ = self.xkb_state orelse return null;
_ = self.last_keycode orelse return null;
if (self.repeat_rate == 0) return null;
return self.next_repeat_time_ns;
}
};
pub const Globals = struct {
compositor: ?*wl.Compositor = null,
wm_base: ?*xdg.WmBase = null,
seat: ?*wl.Seat = null,
data_device_manager: ?*wl.DataDeviceManager = null,
};
const ClipboardOffer = struct {
offer: *wl.DataOffer,
mime_types: std.ArrayList([:0]u8),
fn init(offer: *wl.DataOffer) ClipboardOffer {
return .{
.offer = offer,
.mime_types = .empty,
};
}
fn deinit(self: *ClipboardOffer, alloc: std.mem.Allocator) void {
for (self.mime_types.items) |mime| alloc.free(mime);
self.mime_types.deinit(alloc);
self.offer.destroy();
}
};
const OwnedSelectionState = struct {
text: ?[]u8 = null,
fn deinit(self: *OwnedSelectionState, alloc: std.mem.Allocator) void {
self.clear(alloc);
}
fn replaceText(
self: *OwnedSelectionState,
alloc: std.mem.Allocator,
text: []const u8,
) !void {
const dup = try alloc.dupe(u8, text);
self.clear(alloc);
self.text = dup;
}
fn clear(self: *OwnedSelectionState, alloc: std.mem.Allocator) void {
if (self.text) |text| alloc.free(text);
self.text = null;
}
fn canServeMime(self: *const OwnedSelectionState, mime: []const u8) bool {
return self.text != null and
(std.mem.eql(u8, mime, "text/plain;charset=utf-8") or
std.mem.eql(u8, mime, "text/plain"));
}
};
const CancelSelectionDecision = struct {
destroy_source: *wl.DataSource,
cleared_active_selection: bool,
};
fn replaceSelectionSource(
slot: *?*wl.DataSource,
new_source: *wl.DataSource,
) ?*wl.DataSource {
const old_source = slot.*;
slot.* = new_source;
return old_source;
}
fn cancelSelectionSource(
slot: *?*wl.DataSource,
source: *wl.DataSource,
) CancelSelectionDecision {
if (slot.* == source) {
slot.* = null;
return .{
.destroy_source = source,
.cleared_active_selection = true,
};
}
return .{
.destroy_source = source,
.cleared_active_selection = false,
};
}
fn writeClipboardTransfer(payload: []const u8, fd: i32) !void {
defer _ = c.close(fd);
var written: usize = 0;
while (written < payload.len) {
const n = try posix.write(fd, payload[written..]);
if (n == 0) return error.BrokenPipe;
written += n;
}
}
const ClipboardTransfer = struct {
alloc: std.mem.Allocator,
payload: []u8,
fd: i32,
fn init(alloc: std.mem.Allocator, payload: []const u8, fd: i32) !ClipboardTransfer {
return .{
.alloc = alloc,
.payload = try alloc.dupe(u8, payload),
.fd = fd,
};
}
fn run(self: ClipboardTransfer) void {
defer self.alloc.free(self.payload);
writeClipboardTransfer(self.payload, self.fd) catch |err| {
std.log.err("clipboard transfer failed: {any}", .{err});
};
}
};
fn serveOwnedSelectionRequest(
alloc: std.mem.Allocator,
text: ?[]const u8,
mime_type: []const u8,
fd: i32,
) !bool {
var handed_off = false;
defer {
if (!handed_off) _ = c.close(fd);
}
if (text == null) return false;
if (!(std.mem.eql(u8, mime_type, "text/plain;charset=utf-8") or
std.mem.eql(u8, mime_type, "text/plain"))) return false;
const transfer = try ClipboardTransfer.init(alloc, text.?, fd);
errdefer alloc.free(transfer.payload);
const thread = try std.Thread.spawn(.{}, ClipboardTransfer.run, .{transfer});
handed_off = true;
thread.detach();
return true;
}
pub const Clipboard = struct {
alloc: std.mem.Allocator,
display: *wl.Display,
data_device_manager: *wl.DataDeviceManager,
data_device: *wl.DataDevice,
pending_offer: ?ClipboardOffer = null,
selection_offer: ?ClipboardOffer = null,
owned_selection: OwnedSelectionState = .{},
selection_source: ?*wl.DataSource = null,
pub fn init(
alloc: std.mem.Allocator,
display: *wl.Display,
manager: *wl.DataDeviceManager,
seat: *wl.Seat,
) !*Clipboard {
const clipboard = try alloc.create(Clipboard);
errdefer alloc.destroy(clipboard);
clipboard.* = .{
.alloc = alloc,
.display = display,
.data_device_manager = manager,
.data_device = try manager.getDataDevice(seat),
};
clipboard.data_device.setListener(*Clipboard, dataDeviceListener, clipboard);
return clipboard;
}
pub fn deinit(self: *Clipboard) void {
if (self.pending_offer) |*offer| offer.deinit(self.alloc);
if (self.selection_offer) |*offer| offer.deinit(self.alloc);
if (self.selection_source) |source| source.destroy();
self.owned_selection.deinit(self.alloc);
self.data_device.release();
self.alloc.destroy(self);
}
pub fn receiveSelectionText(self: *Clipboard, alloc: std.mem.Allocator) !?[]u8 {
const offer = &(self.selection_offer orelse return null);
const mime = choosePreferredMime(offer.mime_types.items) orelse return null;
const pipefds = try std.posix.pipe();
defer std.posix.close(pipefds[0]);
var write_fd_closed = false;
defer if (!write_fd_closed) std.posix.close(pipefds[1]);
offer.offer.receive(mime.ptr, pipefds[1]);
_ = self.display.flush();
std.posix.close(pipefds[1]);
write_fd_closed = true;
var roundtrip_ctx = struct {
display: *wl.Display,
fn roundtrip(ctx: *@This()) !void {
_ = ctx.display.roundtrip();
}
}{ .display = self.display };
return try drainSelectionPipeThenRoundtrip(alloc, pipefds[0], &roundtrip_ctx);
}
pub fn setSelectionText(self: *Clipboard, text: []const u8, serial: u32) !void {
const source = try self.data_device_manager.createDataSource();
errdefer source.destroy();
try self.owned_selection.replaceText(self.alloc, text);
source.setListener(*Clipboard, dataSourceListener, self);
source.offer("text/plain;charset=utf-8");
source.offer("text/plain");
if (replaceSelectionSource(&self.selection_source, source)) |old_source| {
old_source.destroy();
}
self.data_device.setSelection(source, serial);
_ = self.display.flush();
}
fn handleCancelledSource(self: *Clipboard, source: *wl.DataSource) void {
const decision = cancelSelectionSource(&self.selection_source, source);
if (decision.cleared_active_selection) {
self.owned_selection.clear(self.alloc);
}
decision.destroy_source.destroy();
}
fn sendOwnedSelection(self: *Clipboard, mime_type: [*:0]const u8, fd: i32) void {
_ = serveOwnedSelectionRequest(
self.alloc,
self.owned_selection.text,
std.mem.span(mime_type),
fd,
) catch |err| {
std.log.err("clipboard transfer setup failed: {any}", .{err});
return;
};
}
};
pub fn drainSelectionPipeThenRoundtrip(
alloc: std.mem.Allocator,
read_fd: std.posix.fd_t,
roundtrip_ctx: anytype,
) ![]u8 {
var out = std.ArrayList(u8).empty;
defer out.deinit(alloc);
var buf: [4096]u8 = undefined;
while (true) {
const n = std.posix.read(read_fd, &buf) catch |err| switch (err) {
error.WouldBlock => break,
else => return err,
};
if (n == 0) break;
try out.appendSlice(alloc, buf[0..n]);
}
try roundtrip_ctx.roundtrip();
return try out.toOwnedSlice(alloc);
}
fn choosePreferredMime(mime_types: []const [:0]const u8) ?[:0]const u8 {
for (mime_types) |mime| {
if (std.mem.eql(u8, mime, "text/plain;charset=utf-8")) return mime;
}
for (mime_types) |mime| {
if (std.mem.eql(u8, mime, "text/plain")) return mime;
}
return null;
}
pub const Window = struct {
alloc: std.mem.Allocator,
surface: *wl.Surface,
xdg_surface: *xdg.Surface,
xdg_toplevel: *xdg.Toplevel,
tracker: *ScaleTracker,
outputs: *std.ArrayListUnmanaged(*Output),
scale_generation: u64 = 0,
applied_buffer_scale: i32 = 1,
configured: bool = false,
should_close: bool = false,
width: u32 = 800,
height: u32 = 600,
pub fn deinit(self: *Window) void {
self.xdg_toplevel.destroy();
self.xdg_surface.destroy();
self.surface.destroy();
self.alloc.destroy(self);
}
pub fn setTitle(self: *Window, title: ?[:0]const u8) void {
self.xdg_toplevel.setTitle((title orelse "waystty"));
}
pub fn bufferScale(self: *const Window) i32 {
return self.tracker.bufferScale();
}
pub fn handleSurfaceEnter(self: *Window, wl_out: *wl.Output) void {
for (self.outputs.items) |out| {
if (out.wl_output == wl_out) {
self.tracker.enterOutput(out.name) catch {};
return;
}
}
}
pub fn handleSurfaceLeave(self: *Window, wl_out: *wl.Output) void {
for (self.outputs.items) |out| {
if (out.wl_output == wl_out) {
self.tracker.leaveOutput(out.name);
return;
}
}
}
};
pub const Connection = struct {
display: *wl.Display,
registry: *wl.Registry,
globals: Globals,
alloc: std.mem.Allocator,
scale_tracker: ScaleTracker,
outputs: std.ArrayListUnmanaged(*Output),
// Heap-allocated so the pointer passed to `registry.setListener` remains stable
// across the init call boundary. With wl_output hotplug support the listener
// can fire after init returns, so we need a stable address — a stack-local
// Connection would dangle.
pub fn init(alloc: std.mem.Allocator) !*Connection {
const display = try wl.Display.connect(null);
errdefer display.disconnect();
const registry = try display.getRegistry();
errdefer registry.destroy();
const conn = try alloc.create(Connection);
errdefer alloc.destroy(conn);
conn.* = .{
.display = display,
.registry = registry,
.globals = Globals{},
.alloc = alloc,
.scale_tracker = ScaleTracker.init(alloc),
.outputs = .empty,
};
errdefer {
for (conn.outputs.items) |out| {
out.wl_output.release();
alloc.destroy(out);
}
conn.outputs.deinit(alloc);
conn.scale_tracker.deinit();
}
registry.setListener(*Connection, registryListener, conn);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
// Second roundtrip so each wl_output's initial scale/done events are
// received after the output's own listener is attached in the first pass.
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
if (conn.globals.compositor == null) return error.NoCompositor;
if (conn.globals.wm_base == null) return error.NoXdgWmBase;
if (conn.globals.seat == null) return error.NoSeat;
return conn;
}
pub fn deinit(self: *Connection) void {
const alloc = self.alloc;
for (self.outputs.items) |out| {
out.wl_output.release();
alloc.destroy(out);
}
self.outputs.deinit(alloc);
self.scale_tracker.deinit();
self.display.disconnect();
alloc.destroy(self);
}
pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window {
const compositor = self.globals.compositor orelse return error.NoCompositor;
const wm_base = self.globals.wm_base orelse return error.NoXdgWmBase;
const window = try alloc.create(Window);
errdefer alloc.destroy(window);
window.* = .{
.alloc = alloc,
.surface = try compositor.createSurface(),
.xdg_surface = undefined,
.xdg_toplevel = undefined,
.tracker = &self.scale_tracker,
.outputs = &self.outputs,
};
errdefer window.surface.destroy();
window.surface.setListener(*Window, surfaceListener, window);
window.xdg_surface = try wm_base.getXdgSurface(window.surface);
errdefer window.xdg_surface.destroy();
window.xdg_toplevel = try window.xdg_surface.getToplevel();
window.xdg_toplevel.setTitle(title);
window.xdg_toplevel.setAppId("waystty");
window.xdg_surface.setListener(*Window, xdgSurfaceListener, window);
window.xdg_toplevel.setListener(*Window, xdgToplevelListener, window);
wm_base.setListener(*xdg.WmBase, wmBaseListener, wm_base);
window.surface.commit();
_ = self.display.roundtrip();
return window;
}
};
fn keyboardListener(_: *wl.Keyboard, event: wl.Keyboard.Event, kb: *Keyboard) void {
switch (event) {
.keymap => |k| {
if (k.format != .xkb_v1) {
_ = c.close(k.fd);
return;
}
const map_mem = c.mmap(null, k.size, c.PROT_READ, c.MAP_PRIVATE, k.fd, 0);
if (map_mem == c.MAP_FAILED) {
_ = c.close(k.fd);
return;
}
defer _ = c.munmap(map_mem, k.size);
defer _ = c.close(k.fd);
const new_keymap = c.xkb_keymap_new_from_string(
kb.xkb_ctx,
@ptrCast(@alignCast(map_mem.?)),
c.XKB_KEYMAP_FORMAT_TEXT_V1,
c.XKB_KEYMAP_COMPILE_NO_FLAGS,
) orelse return;
const new_state = c.xkb_state_new(new_keymap) orelse {
c.xkb_keymap_unref(new_keymap);
return;
};
if (kb.xkb_state) |s| c.xkb_state_unref(s);
if (kb.xkb_keymap) |m| c.xkb_keymap_unref(m);
kb.xkb_keymap = new_keymap;
kb.xkb_state = new_state;
},
.enter => {
kb.has_focus = true;
},
.leave => {
kb.has_focus = false;
kb.last_keycode = null;
},
.key => |k| {
const state = kb.xkb_state orelse return;
const keycode: u32 = k.key + 8; // evdev -> xkb offset
const keysym = c.xkb_state_key_get_one_sym(state, keycode);
var utf8: [8]u8 = undefined;
const len = c.xkb_state_key_get_utf8(state, keycode, &utf8, utf8.len);
const action: KeyboardEvent.Action = if (k.state == .pressed) .press else .release;
const ev = KeyboardEvent{
.keysym = keysym,
.modifiers = kb.current_mods,
.action = action,
.utf8 = utf8,
.utf8_len = @intCast(@min(len, 8)),
.serial = k.serial,
};
kb.event_queue.append(kb.alloc, ev) catch |err| {
std.log.err("keyboard event queue append failed: {any}", .{err});
return;
};
if (action == .press) {
kb.last_keycode = keycode;
kb.last_key_time_ns = std.time.nanoTimestamp();
kb.next_repeat_time_ns = kb.last_key_time_ns + @as(i128, kb.repeat_delay) * std.time.ns_per_ms;
} else if (kb.last_keycode == keycode) {
kb.last_keycode = null;
}
},
.modifiers => |m| {
const state = kb.xkb_state orelse return;
_ = c.xkb_state_update_mask(
state,
m.mods_depressed,
m.mods_latched,
m.mods_locked,
0,
0,
m.group,
);
kb.current_mods = .{
.ctrl = c.xkb_state_mod_name_is_active(state, "Control", c.XKB_STATE_MODS_EFFECTIVE) > 0,
.shift = c.xkb_state_mod_name_is_active(state, "Shift", c.XKB_STATE_MODS_EFFECTIVE) > 0,
.alt = c.xkb_state_mod_name_is_active(state, "Mod1", c.XKB_STATE_MODS_EFFECTIVE) > 0,
.super = c.xkb_state_mod_name_is_active(state, "Mod4", c.XKB_STATE_MODS_EFFECTIVE) > 0,
};
},
.repeat_info => |r| {
kb.repeat_rate = @intCast(r.rate);
kb.repeat_delay = @intCast(r.delay);
},
}
}
fn pointerListener(_: *wl.Pointer, event: wl.Pointer.Event, pointer: *Pointer) void {
switch (event) {
.enter => |e| {
pointer.state.pushEnter(
pointer.alloc,
&pointer.event_queue,
e.serial,
e.surface_x.toDouble(),
e.surface_y.toDouble(),
) catch |err| {
std.log.err("pointer enter queue append failed: {any}", .{err});
};
},
.leave => |e| {
pointer.state.pushLeave(pointer.alloc, &pointer.event_queue, e.serial) catch |err| {
std.log.err("pointer leave queue append failed: {any}", .{err});
};
},
.motion => |m| {
pointer.state.pushMotion(
pointer.alloc,
&pointer.event_queue,
m.time,
m.surface_x.toDouble(),
m.surface_y.toDouble(),
) catch |err| {
std.log.err("pointer motion queue append failed: {any}", .{err});
};
},
.button => |b| {
pointer.state.pushButton(
pointer.alloc,
&pointer.event_queue,
b.serial,
b.time,
b.button,
b.state,
) catch |err| {
std.log.err("pointer button queue append failed: {any}", .{err});
};
},
.axis, .frame, .axis_source, .axis_stop, .axis_discrete, .axis_value120, .axis_relative_direction => {},
}
}
fn dataOfferListener(_: *wl.DataOffer, event: wl.DataOffer.Event, clipboard: *Clipboard) void {
switch (event) {
.offer => |mime| {
var offer = &(clipboard.pending_offer orelse return);
const dup = clipboard.alloc.dupeZ(u8, std.mem.span(mime.mime_type)) catch return;
offer.mime_types.append(clipboard.alloc, dup) catch {
clipboard.alloc.free(dup);
};
},
.source_actions => {},
.action => {},
}
}
fn dataSourceListener(source: *wl.DataSource, event: wl.DataSource.Event, clipboard: *Clipboard) void {
switch (event) {
.send => |send| clipboard.sendOwnedSelection(send.mime_type, send.fd),
.cancelled => clipboard.handleCancelledSource(source),
.target, .dnd_drop_performed, .dnd_finished, .action => {},
}
}
fn dataDeviceListener(_: *wl.DataDevice, event: wl.DataDevice.Event, clipboard: *Clipboard) void {
switch (event) {
.data_offer => |offer| {
if (clipboard.pending_offer) |*old| old.deinit(clipboard.alloc);
clipboard.pending_offer = ClipboardOffer.init(offer.id);
clipboard.pending_offer.?.offer.setListener(*Clipboard, dataOfferListener, clipboard);
},
.selection => |selection| {
if (clipboard.selection_offer) |*old| old.deinit(clipboard.alloc);
clipboard.selection_offer = null;
if (selection.id) |offer| {
if (clipboard.pending_offer) |pending| {
if (pending.offer == offer) {
clipboard.selection_offer = pending;
clipboard.pending_offer = null;
}
}
}
},
.enter => {},
.leave => {},
.motion => {},
.drop => {},
}
}
test "choosePreferredMime preserves sentinel-terminated mime type" {
const mime_types = [_][:0]const u8{
"text/html",
"text/plain;charset=utf-8",
"text/plain",
};
const mime = choosePreferredMime(&mime_types) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("text/plain;charset=utf-8", mime);
try std.testing.expect(mime[mime.len] == 0);
}
test "PointerState queues enter motion and button transitions" {
var state = PointerState{};
var queue: std.ArrayList(PointerEvent) = .empty;
defer queue.deinit(std.testing.allocator);
try state.pushEnter(std.testing.allocator, &queue, 11, 12.5, 9.25);
try state.pushMotion(std.testing.allocator, &queue, 27, 18.0, 4.0);
try state.pushButton(std.testing.allocator, &queue, 11, 33, 0x110, .pressed);
try state.pushButton(std.testing.allocator, &queue, 11, 34, 0x110, .released);
try state.pushLeave(std.testing.allocator, &queue, 12);
try std.testing.expectEqual(@as(usize, 5), queue.items.len);
try std.testing.expect(!state.has_focus);
try std.testing.expectEqual(@as(u32, 12), state.last_serial);
try std.testing.expectApproxEqAbs(18.0, state.surface_x, 0.001);
try std.testing.expectApproxEqAbs(4.0, state.surface_y, 0.001);
switch (queue.items[0]) {
.enter => |ev| {
try std.testing.expectEqual(@as(u32, 11), ev.serial);
try std.testing.expectApproxEqAbs(12.5, ev.x, 0.001);
try std.testing.expectApproxEqAbs(9.25, ev.y, 0.001);
},
else => return error.TestUnexpectedResult,
}
switch (queue.items[1]) {
.motion => |ev| {
try std.testing.expectEqual(@as(u32, 27), ev.time);
try std.testing.expectApproxEqAbs(18.0, ev.x, 0.001);
try std.testing.expectApproxEqAbs(4.0, ev.y, 0.001);
},
else => return error.TestUnexpectedResult,
}
try std.testing.expect(queue.items[2] == .button_press);
try std.testing.expect(queue.items[3] == .button_release);
try std.testing.expect(queue.items[4] == .leave);
}
test "OwnedSelectionState replaces text and recognizes offered mime types" {
var state = OwnedSelectionState{};
defer state.deinit(std.testing.allocator);
try state.replaceText(std.testing.allocator, "first");
try std.testing.expectEqualStrings("first", state.text.?);
try std.testing.expect(state.canServeMime("text/plain;charset=utf-8"));
try std.testing.expect(state.canServeMime("text/plain"));
try std.testing.expect(!state.canServeMime("text/html"));
try state.replaceText(std.testing.allocator, "second");
try std.testing.expectEqualStrings("second", state.text.?);
state.clear(std.testing.allocator);
try std.testing.expect(state.text == null);
}
test "replaceSelectionSource returns prior source to destroy" {
const old_source: *wl.DataSource = @ptrFromInt(0x1000);
const new_source: *wl.DataSource = @ptrFromInt(0x2000);
var slot: ?*wl.DataSource = null;
try std.testing.expect(replaceSelectionSource(&slot, old_source) == null);
try std.testing.expect(slot == old_source);
const replaced = replaceSelectionSource(&slot, new_source);
try std.testing.expect(replaced == old_source);
try std.testing.expect(slot == new_source);
}
test "cancelledSelectionSource clears active source and ignores stale source" {
const stale_source: *wl.DataSource = @ptrFromInt(0x1000);
const active_source: *wl.DataSource = @ptrFromInt(0x2000);
var slot: ?*wl.DataSource = active_source;
const stale = cancelSelectionSource(&slot, stale_source);
try std.testing.expect(stale.destroy_source == stale_source);
try std.testing.expect(!stale.cleared_active_selection);
try std.testing.expect(slot == active_source);
const active = cancelSelectionSource(&slot, active_source);
try std.testing.expect(active.destroy_source == active_source);
try std.testing.expect(active.cleared_active_selection);
try std.testing.expect(slot == null);
}
test "writeClipboardTransfer writes payload and closes fd" {
const pipefds = try std.posix.pipe();
defer std.posix.close(pipefds[0]);
try writeClipboardTransfer("clipboard payload", pipefds[1]);
var out = std.ArrayList(u8).empty;
defer out.deinit(std.testing.allocator);
var buf: [64]u8 = undefined;
while (true) {
const n = try std.posix.read(pipefds[0], &buf);
if (n == 0) break;
try out.appendSlice(std.testing.allocator, buf[0..n]);
}
try std.testing.expectEqualStrings("clipboard payload", out.items);
}
test "serveOwnedSelectionRequest closes fd for unsupported mime and missing text" {
{
const pipefds = try std.posix.pipe();
defer std.posix.close(pipefds[0]);
try std.testing.expect(!try serveOwnedSelectionRequest(
std.testing.allocator,
null,
"text/plain",
pipefds[1],
));
var buf: [1]u8 = undefined;
const n = try std.posix.read(pipefds[0], &buf);
try std.testing.expectEqual(@as(usize, 0), n);
}
{
const pipefds = try std.posix.pipe();
defer std.posix.close(pipefds[0]);
try std.testing.expect(!try serveOwnedSelectionRequest(
std.testing.allocator,
"payload",
"text/html",
pipefds[1],
));
var buf: [1]u8 = undefined;
const n = try std.posix.read(pipefds[0], &buf);
try std.testing.expectEqual(@as(usize, 0), n);
}
}
fn wmBaseListener(wm_base: *xdg.WmBase, event: xdg.WmBase.Event, _: *xdg.WmBase) void {
switch (event) {
.ping => |p| wm_base.pong(p.serial),
}
}
fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
switch (event) {
.configure => |cfg| {
surface.ackConfigure(cfg.serial);
window.configured = true;
},
}
}
fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void {
switch (event) {
.enter => |e| {
const wl_out = e.output orelse return;
window.handleSurfaceEnter(wl_out);
window.scale_generation += 1;
},
.leave => |e| {
const wl_out = e.output orelse return;
window.handleSurfaceLeave(wl_out);
window.scale_generation += 1;
},
.preferred_buffer_scale => {},
.preferred_buffer_transform => {},
}
}
fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
switch (event) {
.configure => |cfg| {
if (cfg.width > 0) window.width = @intCast(cfg.width);
if (cfg.height > 0) window.height = @intCast(cfg.height);
},
.close => window.should_close = true,
.configure_bounds => {},
.wm_capabilities => {},
}
}
fn registryListener(
registry: *wl.Registry,
event: wl.Registry.Event,
conn: *Connection,
) void {
switch (event) {
.global => |g| {
const iface = std.mem.span(g.interface);
if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) {
conn.globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
} else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) {
conn.globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return;
} else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
} else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) {
conn.globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
} else if (std.mem.eql(u8, iface, std.mem.span(wl.Output.interface.name))) {
const wl_out = registry.bind(g.name, wl.Output, 4) catch return;
const out = conn.alloc.create(Output) catch {
wl_out.release();
return;
};
out.* = .{
.wl_output = wl_out,
.name = g.name,
.tracker = &conn.scale_tracker,
};
conn.outputs.append(conn.alloc, out) catch {
wl_out.release();
conn.alloc.destroy(out);
return;
};
conn.scale_tracker.addOutput(g.name) catch {};
wl_out.setListener(*Output, outputListener, out);
}
},
.global_remove => |g| {
var i: usize = 0;
while (i < conn.outputs.items.len) : (i += 1) {
const out = conn.outputs.items[i];
if (out.name == g.name) {
conn.scale_tracker.removeOutput(out.name);
out.wl_output.release();
conn.alloc.destroy(out);
_ = conn.outputs.swapRemove(i);
return;
}
}
},
}
}
fn outputListener(
_: *wl.Output,
event: wl.Output.Event,
out: *Output,
) void {
switch (event) {
.scale => |s| {
out.pending_scale = s.factor;
},
.done => {
out.tracker.setOutputScale(out.name, out.pending_scale);
},
.geometry, .mode, .name, .description => {},
}
}
test "choosePreferredMime prefers UTF-8 plain text" {
try std.testing.expectEqualStrings(
"text/plain;charset=utf-8",
choosePreferredMime(&.{
"text/plain",
"text/plain;charset=utf-8",
}).?,
);
try std.testing.expectEqualStrings(
"text/plain",
choosePreferredMime(&.{
"text/plain",
"application/octet-stream",
}).?,
);
try std.testing.expect(choosePreferredMime(&.{"application/octet-stream"}) == null);
}
test "drainSelectionPipeThenRoundtrip drains large payload before roundtrip" {
const pipefds = try std.posix.pipe();
defer std.posix.close(pipefds[0]);
const payload = "clip" ** 20_000;
const Writer = struct {
fd: std.posix.fd_t,
fn run(self: @This()) !void {
defer std.posix.close(self.fd);
var written: usize = 0;
while (written < payload.len) {
written += try std.posix.write(self.fd, payload[written..]);
}
}
};
var roundtrip_called = false;
const RoundtripCtx = struct {
called: *bool,
pub fn roundtrip(self: *@This()) !void {
self.called.* = true;
}
};
const writer = Writer{ .fd = pipefds[1] };
const thread = try std.Thread.spawn(.{}, Writer.run, .{writer});
defer thread.join();
var roundtrip_ctx = RoundtripCtx{ .called = &roundtrip_called };
const text = try drainSelectionPipeThenRoundtrip(std.testing.allocator, pipefds[0], &roundtrip_ctx);
defer std.testing.allocator.free(text);
try std.testing.expect(roundtrip_called);
try std.testing.expectEqualStrings(payload, text);
}
test "Window.bufferScale reflects ScaleTracker entered outputs" {
var tracker = ScaleTracker.init(std.testing.allocator);
defer tracker.deinit();
try tracker.addOutput(1);
try tracker.addOutput(2);
tracker.setOutputScale(1, 1);
tracker.setOutputScale(2, 2);
// Simulate the bits Window.bufferScale delegates to.
try tracker.enterOutput(2);
try std.testing.expectEqual(@as(i32, 2), tracker.bufferScale());
tracker.leaveOutput(2);
try std.testing.expectEqual(@as(i32, 1), tracker.bufferScale());
}