diff --git a/README.md b/README.md index 4e44e0d..18b1369 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Windows 10 version 1703 or later and an OpenGL 3.3-capable driver. The Linux Wayland preview implements windows, EGL/OpenGL, keyboard/pointer and text input, clipboard/file drops, portal dialogs/preferences, activation, icons, file watches, and Plasma app/window menu export, initially tested on KDE Plasma. -Native context menus and X11 remain unsupported. It vendors only ABI -headers and protocol bindings and loads system libraries at runtime. Normal +Linux context menus use a small software renderer and a bundled 16px Roboto +bitmap; X11 remains unsupported. Desktop dependencies are limited to vendored +ABI headers/protocol bindings and runtime-loaded system libraries. Normal builds need Zig alone; the optional `shell.nix` supplies maintainer tools and NixOS runtime library paths. See [Linux support and limitations](src/linux/README.md). diff --git a/src/linux/Input.zig b/src/linux/Input.zig index cad7ba2..fd426b7 100644 --- a/src/linux/Input.zig +++ b/src/linux/Input.zig @@ -5,6 +5,7 @@ const n = @import("native"); const native = @import("native.zig"); const runtime = @import("../runtime.zig"); const abi = @import("../abi.zig"); +const Popup = @import("Popup.zig"); const KdeAppMenu = @import("KdeAppMenu.zig"); const Keyboard = @import("Keyboard.zig"); const Data = @import("Data.zig"); @@ -43,6 +44,10 @@ pub const State = struct { key_sync: ?*c.struct_wl_callback = null, serial: u32 = 0, serial_time: u64 = 0, + popup_serial: u32 = 0, + popup_time: u64 = 0, + popup_window: ?*native.Window = null, + popup_buttons: u32 = 0, pointer_serial: u32 = 0, next_id: u64 = 0, position: n.uhdk_point = .{ .x = 0, .y = 0 }, @@ -122,6 +127,7 @@ pub fn setup() void { if (s.text_input != null) Text.listen(s.text_input); } Data.setup(); + Popup.capability(); native.setCapability(n.UHDK_CAP_POINTER_CAPTURE, s.pointer != null); native.setCapability(n.UHDK_CAP_TEXT_INPUT, s.context != null and s.keyboard != null); } @@ -172,11 +178,16 @@ fn keymap(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, format: u32, fd: i32, size: fn keyEnter(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, _: u32, surface: ?*c.struct_wl_surface, keys: [*c]c.struct_wl_array) callconv(.c) void { leaveKeyboard(); const s = state(); - s.key_focus = find(surface); + s.key_focus = Popup.owner(surface) orelse find(surface); + const suppressed = s.menu_keys; + s.menu_keys = @splat(false); if (s.state != null and keys.*.size != 0) { const held: [*]const u32 = @ptrCast(@alignCast(keys.*.data)); for (held[0 .. keys.*.size / 4]) |code| { - if (code < s.held.len) s.held[code] = true; + if (code < s.held.len) { + s.held[code] = true; + s.menu_keys[code] = suppressed[code]; + } } } } @@ -203,6 +214,7 @@ fn key(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, serial: u32, _: u32, code: u32 flushKey(); if (code > 0xffff) return; provenance(serial); + if (pressed != 0 and !Popup.active()) popupTrigger(s.key_focus, serial); if (code < s.held.len) s.held[code] = pressed != 0; // The compositor supplies the authoritative modifier/group mask. Running // XKB key actions locally as well would apply latch/lock actions twice. @@ -240,14 +252,20 @@ fn emitKey(value: Key) void { event.data.key = .{ .physical_key = Keyboard.physical(value.code), .logical_key = if (s.state != null) Keyboard.logical(value.sym) else 0, .modifiers = s.modifiers, .pressed = @intFromBool(value.down), .repeat = @intFromBool(value.repeated) }; if (value.code < s.menu_keys.len) { if (s.menu_keys[value.code]) { - if (!value.down) s.menu_keys[value.code] = false; + if (!value.down) s.menu_keys[value.code] = false else if (Popup.active()) Popup.key(event.data.key.logical_key, true, s.serial); return; // Consume the release/repeats even after flags or focus change. } + if (Popup.active()) { + s.menu_keys[value.code] = value.down; + Popup.key(event.data.key.logical_key, value.down, s.serial); + return; + } if (value.down and !value.repeated and KdeAppMenu.shortcut(w, event.data.key.logical_key, value.modifiers, event.input_id)) { s.menu_keys[value.code] = true; return; } } + if (Popup.active()) return; send(w, &event); // Resolve symbols/text before consuming a latched modifier, while reporting // the compositor's post-event flags in the observational raw event. @@ -299,20 +317,22 @@ fn leaveKeyboard() void { s.modifiers = 0; s.repeat_key = null; if (s.key_focus) |w| { + if (!Popup.active()) clearPopupTrigger(w); for (&s.held, 0..) |*held, code| if (held.*) { held.* = false; - deliverKey(@intCast(code), false, false); + if (!s.menu_keys[code]) deliverKey(@intCast(code), false, false); }; Text.cancel(w); } s.key_focus = null; - s.menu_keys = @splat(false); + // Preserve consumed held keys until the next enter supplies its held-key set. s.modifiers = 0; s.serial = 0; if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, 0, 0, 0, 0, 0, 0); normal(); } fn pointerEvent(kind: u32, button_: u32, pressed: bool, delta: n.uhdk_point) void { + if (Popup.active()) return; const s = state(); const w = s.pointer_focus orelse return; var e = std.mem.zeroes(n.z_event); @@ -327,6 +347,7 @@ fn pointerEnter(_: ?*anyopaque, _: ?*c.struct_wl_pointer, serial: u32, surface: s.pointer_focus = find(surface); s.pointer_serial = serial; s.position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; + if (Popup.pointerEnter(surface, s.position.x, s.position.y, serial)) return; pointerEvent(n.UHDK_EVENT_POINTER_ENTER, 0, false, .{ .x = 0, .y = 0 }); if (s.pointer_focus) |w| _ = cursor(w, w.cursor); } @@ -334,6 +355,7 @@ fn pointerLeave(_: ?*anyopaque, _: ?*c.struct_wl_pointer, _: u32, _: ?*c.struct_ leavePointer(); } fn leavePointer() void { + Popup.pointerLeave(); const s = state(); normal(); pointerEvent(n.UHDK_EVENT_POINTER_LEAVE, 0, false, .{ .x = 0, .y = 0 }); @@ -350,6 +372,10 @@ fn motion(_: ?*anyopaque, _: ?*c.struct_wl_pointer, _: u32, x: i32, y: i32) call const s = state(); const previous = s.position; s.position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; + if (Popup.active()) { + Popup.motion(s.position.x, s.position.y); + return; + } pointerEvent(n.UHDK_EVENT_POINTER_MOVE, 0, false, .{ .x = s.position.x - previous.x, .y = s.position.y - previous.y }); } fn button(_: ?*anyopaque, _: ?*c.struct_wl_pointer, serial: u32, _: u32, code: u32, pressed: u32) callconv(.c) void { @@ -365,6 +391,17 @@ fn button(_: ?*anyopaque, _: ?*c.struct_wl_pointer, serial: u32, _: u32, code: u }; if (mapped == 0) return; const mask = @as(u32, 1) << @intCast(mapped); + if (Popup.active()) { + if (pressed != 0) s.popup_buttons |= mask else s.popup_buttons &= ~mask; + Popup.button(code, pressed != 0, serial); + return; + } + if (pressed == 0 and s.popup_buttons & mask != 0) { + s.popup_buttons &= ~mask; + return; + } + if (pressed != 0) popupTrigger(s.pointer_focus, serial); + if (pressed != 0) { s.buttons |= mask; const now = runtime.now(b().app.io); @@ -402,6 +439,11 @@ fn frame(_: ?*anyopaque, _: ?*c.struct_wl_pointer) callconv(.c) void { s.wheel = .{ .x = 0, .y = 0 }; s.axis_stop = false; } + if (Popup.active()) { + const delta = if (s.wheel.y != 0) s.wheel.y * 26 else s.axis.y; + if (delta != 0) Popup.scroll(delta); + return; + } const w = s.pointer_focus orelse return; const discrete = s.axis_source == c.WL_POINTER_AXIS_SOURCE_WHEEL or s.axis_source == c.WL_POINTER_AXIS_SOURCE_WHEEL_TILT; const has_wheel = s.wheel.x != 0 or s.wheel.y != 0; @@ -453,7 +495,25 @@ fn normal() void { send(w, &e); } } +fn popupTrigger(w: ?*native.Window, serial: u32) void { + const s = state(); + s.popup_window = w; + s.popup_serial = serial; + s.popup_time = runtime.now(b().app.io); +} +pub fn beginPopup() void { + normal(); + state().buttons = 0; + state().scrolling = false; +} +pub fn clearPopupTrigger(w: *native.Window) void { + if (state().popup_window == w) { + state().popup_window = null; + state().popup_serial = 0; + } +} pub fn forget(w: *native.Window) void { + clearPopupTrigger(w); if (state().key_focus == w) leaveKeyboard(); if (state().pointer_focus == w) leavePointer(); Text.forget(w); @@ -462,6 +522,9 @@ pub fn remove(name: u32) void { if (name == state().seat_name) releaseSeat(); } fn releaseSeat() void { + Popup.deinit(); + state().popup_serial = 0; + state().popup_window = null; Data.releaseSeat(); const s = state(); if (s.key_sync != null) c.wl_callback_destroy(s.key_sync); diff --git a/src/linux/KdeAppMenu.zig b/src/linux/KdeAppMenu.zig index ad6147f..3d92176 100644 --- a/src/linux/KdeAppMenu.zig +++ b/src/linux/KdeAppMenu.zig @@ -10,6 +10,7 @@ const Resource = @import("../Resource.zig"); const abi = @import("../abi.zig"); const Bus = @import("Bus.zig"); const Portal = @import("Portal.zig"); +const Menu = @import("Menu.zig"); const Input = @import("Input.zig"); const iface = "com.canonical.dbusmenu"; const prefix = "/org/uhdk/menu/"; @@ -195,21 +196,7 @@ fn enabled(e: *Export, index: usize) bool { fn activate(e: *Export, index: usize, input_id: u64) void { const item = e.nodes.items[index].item orelse return; if (!enabled(e, index) or item.flags & n.UHDK_MENU_SEPARATOR != 0 or item.submenu != null) return; - var event = std.mem.zeroes(n.z_event); - if (item.role == n.UHDK_MENU_ROLE_QUIT) { - event.type = n.UHDK_EVENT_QUIT_REQUESTED; - n.z_emit(runtime.toAppHandle(b().app), &event); - } else if (item.role >= n.UHDK_MENU_ROLE_COPY and item.role <= n.UHDK_MENU_ROLE_REDO) { - event.type = n.UHDK_EVENT_EDIT_COMMAND; - event.input_id = input_id; - event.data.edit.command = @as(u32, n.UHDK_EDIT_COPY) + item.role - n.UHDK_MENU_ROLE_COPY; - e.window.text.last_edit = input_id; - Input.send(e.window, &event); - } else { - event.type = n.UHDK_EVENT_MENU_COMMAND; - event.data.menu.command_id = item.command_id; - Input.send(e.window, &event); - } + Menu.activate(e.window, item, input_id); } fn folded(key: u32) u32 { return if (key >= 'A' and key <= 'Z') key + ('a' - 'A') else key; @@ -237,32 +224,6 @@ fn variant(i: *Bus.Iter, comptime sig: [:0]const u8, value: anytype) !void { try Bus.basic(&v, sig[0], value); try Bus.close(i, &v); } -fn keyName(key: u32, buffer: *[32]u8) ?[:0]const u8 { - if (key >= n.UHDK_KEY_F1 and key < n.UHDK_KEY_F1 + 24) return std.fmt.bufPrintZ(buffer, "F{d}", .{key - n.UHDK_KEY_F1 + 1}) catch unreachable; - switch (key) { - n.UHDK_KEY_ESCAPE => return "Escape", - n.UHDK_KEY_ENTER => return "Return", - n.UHDK_KEY_TAB => return "Tab", - n.UHDK_KEY_BACKSPACE => return "BackSpace", - n.UHDK_KEY_DELETE => return "Delete", - n.UHDK_KEY_INSERT => return "Insert", - n.UHDK_KEY_LEFT => return "Left", - n.UHDK_KEY_RIGHT => return "Right", - n.UHDK_KEY_UP => return "Up", - n.UHDK_KEY_DOWN => return "Down", - n.UHDK_KEY_HOME => return "Home", - n.UHDK_KEY_END => return "End", - n.UHDK_KEY_PAGE_UP => return "Page_Up", - n.UHDK_KEY_PAGE_DOWN => return "Page_Down", - ' ' => return "space", - else => {}, - } - if (key < 0x21 or key > 0x10ffff) return null; - const k = if (key >= 'a' and key <= 'z') key - ('a' - 'A') else key; - const len = std.unicode.utf8Encode(@intCast(k), buffer) catch return null; - buffer[len] = 0; - return buffer[0..len :0]; -} fn property(e: *Export, index: usize, name: []const u8, out: *Bus.Iter) !bool { const node = e.nodes.items[index]; const item = node.item; @@ -273,7 +234,7 @@ fn property(e: *Export, index: usize, name: []const u8, out: *Bus.Iter) !bool { var arrays = try Bus.open(&v, c.DBUS_TYPE_ARRAY, "as"); errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&v, &arrays); var buffer: [32]u8 = undefined; - if (item) |value| if (keyName(value.shortcut_key, &buffer)) |key_name| { + if (item) |value| if (Menu.keyName(value.shortcut_key, &buffer)) |key_name| { var keys = try Bus.open(&arrays, c.DBUS_TYPE_ARRAY, "s"); errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&arrays, &keys); inline for (.{ .{ n.UHDK_MOD_CONTROL, "Control" }, .{ n.UHDK_MOD_ALT, "Alt" }, .{ n.UHDK_MOD_SHIFT, "Shift" }, .{ n.UHDK_MOD_SUPER, "Super" } }) |mod| { diff --git a/src/linux/Menu.zig b/src/linux/Menu.zig new file mode 100644 index 0000000..acbee72 --- /dev/null +++ b/src/linux/Menu.zig @@ -0,0 +1,64 @@ +//! Linux menu role dispatch and shortcut names shared by desktop exports/popups. +const std = @import("std"); +const n = @import("native"); +const native = @import("native.zig"); +const runtime = @import("../runtime.zig"); +const Input = @import("Input.zig"); +pub fn activate(window: *native.Window, item: *const n.uhdk_menu_item, input_id: u64) void { + var event = std.mem.zeroes(n.z_event); + if (item.role == n.UHDK_MENU_ROLE_QUIT) { + event.type = n.UHDK_EVENT_QUIT_REQUESTED; + n.z_emit(runtime.toAppHandle(window.owner), &event); + } else if (item.role >= n.UHDK_MENU_ROLE_COPY and item.role <= n.UHDK_MENU_ROLE_REDO) { + event.type = n.UHDK_EVENT_EDIT_COMMAND; + event.input_id = input_id; + event.data.edit.command = @as(u32, n.UHDK_EDIT_COPY) + item.role - n.UHDK_MENU_ROLE_COPY; + window.text.last_edit = input_id; + Input.send(window, &event); + } else { + event.type = n.UHDK_EVENT_MENU_COMMAND; + event.data.menu.command_id = item.command_id; + Input.send(window, &event); + } +} +pub fn keyName(key: u32, buffer: *[32]u8) ?[:0]const u8 { + if (key >= n.UHDK_KEY_F1 and key < n.UHDK_KEY_F1 + 24) return std.fmt.bufPrintZ(buffer, "F{d}", .{key - n.UHDK_KEY_F1 + 1}) catch unreachable; + switch (key) { + n.UHDK_KEY_ESCAPE => return "Escape", + n.UHDK_KEY_ENTER => return "Return", + n.UHDK_KEY_TAB => return "Tab", + n.UHDK_KEY_BACKSPACE => return "BackSpace", + n.UHDK_KEY_DELETE => return "Delete", + n.UHDK_KEY_INSERT => return "Insert", + n.UHDK_KEY_LEFT => return "Left", + n.UHDK_KEY_RIGHT => return "Right", + n.UHDK_KEY_UP => return "Up", + n.UHDK_KEY_DOWN => return "Down", + n.UHDK_KEY_HOME => return "Home", + n.UHDK_KEY_END => return "End", + n.UHDK_KEY_PAGE_UP => return "Page_Up", + n.UHDK_KEY_PAGE_DOWN => return "Page_Down", + ' ' => return "space", + else => {}, + } + if (key < 0x21 or key > 0x10ffff) return null; + const k = if (key >= 'a' and key <= 'z') key - ('a' - 'A') else key; + const len = std.unicode.utf8Encode(@intCast(k), buffer) catch return null; + buffer[len] = 0; + return buffer[0..len :0]; +} + +pub fn shortcut(item: n.uhdk_menu_item, buffer: *[96]u8) []const u8 { + var key_buffer: [32]u8 = undefined; + const key = keyName(item.shortcut_key, &key_buffer) orelse return ""; + var end: usize = 0; + inline for (.{ .{ n.UHDK_MOD_CONTROL, "Ctrl+" }, .{ n.UHDK_MOD_ALT, "Alt+" }, .{ n.UHDK_MOD_SHIFT, "Shift+" }, .{ n.UHDK_MOD_SUPER, "Super+" } }) |mod| { + if (item.shortcut_modifiers & mod[0] != 0) { + @memcpy(buffer[end..][0..mod[1].len], mod[1]); + end += mod[1].len; + } + } + @memcpy(buffer[end..][0..key.len], key); + end += key.len; + return buffer[0..end]; +} diff --git a/src/linux/Popup.zig b/src/linux/Popup.zig new file mode 100644 index 0000000..1fa8aed --- /dev/null +++ b/src/linux/Popup.zig @@ -0,0 +1,464 @@ +//! Small software-rendered xdg_popup menus. One active request owns a stack of +//! panels, with two wl_shm buffers per panel. Everything runs on the native thread. +const std = @import("std"); +const c = @import("linux_c"); +const n = @import("native"); +const native = @import("native.zig"); +const runtime = @import("../runtime.zig"); +const Resource = @import("../Resource.zig"); +const abi = @import("../abi.zig"); +const Input = @import("Input.zig"); +const Menu = @import("Menu.zig"); +const Bitmap = @import("Bitmap.zig"); +const Buffer = struct { proxy: ?*c.struct_wl_buffer = null, mapping: ?*anyopaque = null, size: usize = 0, busy: bool = false }; +const Panel = struct { + parent: ?*Panel, + menu: *Resource, + surface: ?*c.struct_wl_surface = null, + xdg: ?*c.struct_xdg_surface = null, + popup: ?*c.struct_xdg_popup = null, + buffers: [2]Buffer = .{ .{}, .{} }, + width: i32 = 180, + height: i32 = 32, + scale: i32 = 1, + pending_width: i32 = 180, + pending_height: i32 = 32, + total: i32 = 8, + scroll: i32 = 0, + selected: ?usize = null, + pressed: ?usize = null, + configured: bool = false, + dirty: bool = true, + deadline: u64 = 0, +}; +pub const State = struct { + window: ?*native.Window = null, + root: ?*Resource = null, + top: ?*Panel = null, + pointer: ?*Panel = null, + request: u64 = 0, + serial: u32 = 0, + hover_at: u64 = 0, +}; +fn b() *native.Backend { + return native.instance.?; +} +fn s() *State { + return &b().popup; +} +pub fn active() bool { + return s().request != 0; +} +pub fn capability() void { + native.setCapability(n.UHDK_CAP_CONTEXT_MENU, b().desktop.shm != null and Input.state().seat != null); +} +fn panel(raw: ?*anyopaque) *Panel { + return @ptrCast(@alignCast(raw.?)); +} +fn rowHeight(item: n.uhdk_menu_item) i32 { + return if (item.flags & n.UHDK_MENU_SEPARATOR != 0) 9 else 26; +} +fn usable(item: n.uhdk_menu_item) bool { + return item.flags & (n.UHDK_MENU_DISABLED | n.UHDK_MENU_SEPARATOR) == 0; +} +fn rowY(p: *Panel, index: usize) i32 { + var y: i32 = 4; + for (p.menu.payload.menu.items[0..index]) |item| y += rowHeight(item); + return y - p.scroll; +} +fn lookup(surface: ?*c.struct_wl_surface) ?*Panel { + var cursor = s().top; + while (cursor) |p| : (cursor = p.parent) if (p.surface == surface) return p; + return null; +} +pub fn owner(surface: ?*c.struct_wl_surface) ?*native.Window { + return if (lookup(surface) != null) s().window else null; +} +fn freeBuffer(buf: *Buffer) void { + if (buf.proxy != null) c.wl_buffer_destroy(buf.proxy); + if (buf.mapping) |mapping| _ = c.munmap(mapping, buf.size); + buf.* = .{}; +} +fn destroy(p: *Panel) void { + if (s().pointer == p) s().pointer = null; + if (p.popup != null) c.xdg_popup_destroy(p.popup); + if (p.xdg != null) c.xdg_surface_destroy(p.xdg); + if (p.surface != null) c.wl_surface_destroy(p.surface); + for (&p.buffers) |*buf| freeBuffer(buf); + b().app.gpa.destroy(p); +} +fn closeChildren(p: *Panel) void { + while (s().top != p) { + const top = s().top orelse return; + s().top = top.parent; + destroy(top); + } +} +fn finish(status: u32) void { + if (!active()) return; + const request_id = s().request; + while (s().top) |top| { + s().top = top.parent; + destroy(top); + } + const root = s().root.?; + s().* = .{}; + runtime.release(root); + n.z_complete(runtime.toAppHandle(b().app), request_id, status, null); +} +pub fn forget(w: *native.Window) void { + if (s().window == w) finish(abi.CANCELLED); +} +pub fn cancel(id: u64) bool { + if (s().request != id or !active()) return false; + finish(abi.CANCELLED); + return true; +} +pub fn deinit() void { + finish(abi.CANCELLED); +} +pub fn request(cmd: *const n.z_command, id: u64) u32 { + if (active()) return n.UHDK_STATUS_BUSY; + if (b().desktop.shm == null or Input.state().seat == null) return n.UHDK_STATUS_UNSUPPORTED; + const w: *native.Window = @ptrCast(@alignCast(n.z_object_native(cmd.other) orelse return abi.UNAVAILABLE)); + const input = Input.state(); + if (!w.visible or !w.configured or input.popup_window != w or input.popup_serial == 0 or runtime.now(b().app.io) -| input.popup_time > 5 * std.time.ns_per_s) return abi.UNAVAILABLE; + const root: *Resource = @ptrCast(@alignCast(cmd.target.?)); + if (root.payload.menu.items.len == 0) return abi.CANCELLED; + s().window = w; + s().root = root; + s().serial = input.popup_serial; + runtime.retain(root); + const point: n.uhdk_point = .{ .x = std.math.clamp(cmd.point.x, 0, @as(f32, @floatFromInt(@max(0, w.width - 1)))), .y = std.math.clamp(cmd.point.y, 0, @as(f32, @floatFromInt(@max(0, w.height - 1)))) }; + const p = create(root, null, @intFromFloat(point.x), @intFromFloat(point.y), 1) catch { + runtime.release(root); + s().* = .{}; + return abi.OOM; + }; + s().top = p; + s().request = id; + Input.beginPopup(); + input.popup_serial = 0; // A root grab consumes its triggering action. + return n.Z_DEFERRED; +} +fn create(menu: *Resource, parent: ?*Panel, x: i32, y: i32, anchor_height: i32) !*Panel { + const p = try b().app.gpa.create(Panel); + p.* = .{ .parent = parent, .menu = menu, .scale = @intCast(std.math.clamp((s().window.?.scale + 119) / 120, 1, 4)), .deadline = runtime.now(b().app.io) + 5 * std.time.ns_per_s }; + errdefer destroy(p); + for (menu.payload.menu.items) |item| { + var buffer: [96]u8 = undefined; + p.width = @max(p.width, 60 + Bitmap.width(abi.textBytes(item.label)) + Bitmap.width(Menu.shortcut(item, &buffer))); + p.total += rowHeight(item); + } + p.width = @min(p.width, 480); + p.height = @min(p.total, 480); + p.pending_width = p.width; + p.pending_height = p.height; + p.surface = c.wl_compositor_create_surface(b().compositor) orelse return error.OutOfMemory; + if (c.wl_surface_add_listener(p.surface, &surface_listener, p) != 0) return error.OutOfMemory; + p.xdg = c.xdg_wm_base_get_xdg_surface(b().shell, p.surface) orelse return error.OutOfMemory; + if (c.xdg_surface_add_listener(p.xdg, &xdg_listener, p) != 0) return error.OutOfMemory; + const pos = c.xdg_wm_base_create_positioner(b().shell) orelse return error.OutOfMemory; + defer c.xdg_positioner_destroy(pos); + c.xdg_positioner_set_size(pos, p.width, p.height); + c.xdg_positioner_set_anchor_rect(pos, x, y, 1, @max(1, anchor_height)); + c.xdg_positioner_set_anchor(pos, if (parent != null) c.XDG_POSITIONER_ANCHOR_TOP_RIGHT else c.XDG_POSITIONER_ANCHOR_BOTTOM_LEFT); + c.xdg_positioner_set_gravity(pos, c.XDG_POSITIONER_GRAVITY_BOTTOM_RIGHT); + c.xdg_positioner_set_constraint_adjustment(pos, c.XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_FLIP_X | c.XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_SLIDE_X | c.XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_SLIDE_Y | c.XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_RESIZE_X | c.XDG_POSITIONER_CONSTRAINT_ADJUSTMENT_RESIZE_Y); + p.popup = c.xdg_surface_get_popup(p.xdg, if (parent) |v| v.xdg else s().window.?.xdg, pos) orelse return error.OutOfMemory; + if (c.xdg_popup_add_listener(p.popup, &popup_listener, p) != 0) return error.OutOfMemory; + c.xdg_popup_grab(p.popup, Input.state().seat, s().serial); + c.wl_surface_commit(p.surface); + return p; +} +fn configure(raw: ?*anyopaque, _: ?*c.struct_xdg_popup, _: i32, _: i32, width: i32, height: i32) callconv(.c) void { + const p = panel(raw); + if (width < 1 or height < 1 or width > 1024 or height > 1024) { + finish(n.UHDK_STATUS_OS_ERROR); + return; + } + // Geometry belongs to the next xdg_surface.configure, which can arrive in + // another socket read. Do not render/commit it before acknowledging that. + p.pending_width = width; + p.pending_height = height; + p.configured = false; + p.deadline = runtime.now(b().app.io) + 5 * std.time.ns_per_s; +} +fn configured(raw: ?*anyopaque, _: ?*c.struct_xdg_surface, serial: u32) callconv(.c) void { + const p = panel(raw); + c.xdg_surface_ack_configure(p.xdg, serial); + if (p.width != p.pending_width or p.height != p.pending_height) { + for (&p.buffers) |*buf| freeBuffer(buf); + p.width = p.pending_width; + p.height = p.pending_height; + p.scroll = std.math.clamp(p.scroll, 0, @max(0, p.total - p.height)); + } + p.configured = true; + p.deadline = 0; + p.dirty = true; + render(p) catch finish(abi.OOM); +} +fn done(raw: ?*anyopaque, _: ?*c.struct_xdg_popup) callconv(.c) void { + const p = panel(raw); + if (p.parent) |parent| { + closeChildren(parent); + s().hover_at = 0; + } else finish(abi.CANCELLED); +} +fn repositioned(_: ?*anyopaque, _: ?*c.struct_xdg_popup, _: u32) callconv(.c) void {} +const popup_listener: c.struct_xdg_popup_listener = .{ .configure = configure, .popup_done = done, .repositioned = repositioned }; +const xdg_listener: c.struct_xdg_surface_listener = .{ .configure = configured }; +fn enter(_: ?*anyopaque, _: ?*c.struct_wl_surface, _: ?*c.struct_wl_output) callconv(.c) void {} +fn scale(raw: ?*anyopaque, _: ?*c.struct_wl_surface, value: i32) callconv(.c) void { + const p = panel(raw); + const next = std.math.clamp(value, 1, 4); + if (next == p.scale) return; + p.scale = next; + for (&p.buffers) |*buf| freeBuffer(buf); + p.dirty = true; +} +fn transform(_: ?*anyopaque, _: ?*c.struct_wl_surface, _: u32) callconv(.c) void {} +const surface_listener: c.struct_wl_surface_listener = .{ .enter = enter, .leave = enter, .preferred_buffer_scale = scale, .preferred_buffer_transform = transform }; +fn released(raw: ?*anyopaque, proxy: ?*c.struct_wl_buffer) callconv(.c) void { + const p = panel(raw); + for (&p.buffers) |*buf| if (buf.proxy == proxy) { + buf.busy = false; + }; +} +const buffer_listener: c.struct_wl_buffer_listener = .{ .release = released }; +fn allocateBuffer(p: *Panel, buf: *Buffer) !void { + const size: usize = @intCast(p.width * p.height * p.scale * p.scale * 4); + const fd = c.memfd_create("uhdk-menu", c.MFD_CLOEXEC); + if (fd < 0) return error.OutOfMemory; + defer _ = c.close(fd); + if (c.ftruncate(fd, @intCast(size)) != 0) return error.OutOfMemory; + const mapping = c.mmap(null, size, c.PROT_READ | c.PROT_WRITE, c.MAP_SHARED, fd, 0); + if (mapping == c.MAP_FAILED) return error.OutOfMemory; + buf.mapping = mapping; + buf.size = size; + errdefer freeBuffer(buf); + const pool = c.wl_shm_create_pool(b().desktop.shm, fd, @intCast(size)) orelse return error.OutOfMemory; + defer c.wl_shm_pool_destroy(pool); + buf.proxy = c.wl_shm_pool_create_buffer(pool, 0, p.width * p.scale, p.height * p.scale, p.width * p.scale * 4, c.WL_SHM_FORMAT_ARGB8888) orelse return error.OutOfMemory; + if (c.wl_buffer_add_listener(buf.proxy, &buffer_listener, p) != 0) return error.OutOfMemory; +} +fn render(p: *Panel) !void { + if (!p.configured or !p.dirty) return; + const buf: *Buffer = for (&p.buffers) |*value| { + if (!value.busy) break value; + } else return; + if (buf.proxy == null) try allocateBuffer(p, buf); + const words: [*]u32 = @ptrCast(@alignCast(buf.mapping.?)); + const canvas: Bitmap.Canvas = .{ .pixels = words[0 .. buf.size / 4], .width = p.width, .height = p.height, .scale = p.scale }; + paint(canvas, p.menu.payload.menu.items, p.selected, p.scroll, b().portal.preferences.appearance == n.UHDK_APPEARANCE_LIGHT); + c.wl_surface_set_buffer_scale(p.surface, p.scale); + c.xdg_surface_set_window_geometry(p.xdg, 0, 0, p.width, p.height); + c.wl_surface_attach(p.surface, buf.proxy, 0, 0); + c.wl_surface_damage_buffer(p.surface, 0, 0, p.width * p.scale, p.height * p.scale); + c.wl_surface_commit(p.surface); + buf.busy = true; + p.dirty = false; +} +pub fn paint(canvas: Bitmap.Canvas, items: []const n.uhdk_menu_item, selected: ?usize, scroll_offset: i32, light: bool) void { + const background: u32 = if (light) 0xf6f6f6 else 0x292c30; + const foreground: u32 = if (light) 0x202124 else 0xf1f2f3; + const border: u32 = if (light) 0xc4c7ca else 0x53585e; + canvas.rect(0, 0, canvas.width, canvas.height, background); + var y: i32 = 4 - scroll_offset; + for (items, 0..) |item, i| { + const height = rowHeight(item); + defer y += height; + if (y + height <= 4 or y >= canvas.height - 4) continue; + if (item.flags & n.UHDK_MENU_SEPARATOR != 0) { + canvas.rect(8, y + 4, canvas.width - 16, 1, border); + continue; + } + const color: u32 = if (item.flags & n.UHDK_MENU_DISABLED != 0) (if (light) @as(u32, 0x85888c) else 0x8d939a) else if (selected == i) 0xffffff else foreground; + if (selected == i and usable(item)) canvas.rect(3, y, canvas.width - 6, height, 0x2969b0); + if (item.flags & n.UHDK_MENU_CHECKED != 0) { + for (0..4) |j| canvas.rect(10 + @as(i32, @intCast(j)), y + 12 + @as(i32, @intCast(j)), 2, 2, color); + for (0..7) |j| canvas.rect(13 + @as(i32, @intCast(j)), y + 15 - @as(i32, @intCast(j)), 2, 2, color); + } + var storage: [96]u8 = undefined; + const shortcut = Menu.shortcut(item, &storage); + const shortcut_width = @min(Bitmap.width(shortcut), @max(0, @divTrunc(canvas.width, 2) - 30)); + const label_width = @max(0, canvas.width - 52 - (if (shortcut.len != 0) shortcut_width + 18 else 0)); + canvas.text(abi.textBytes(item.label), 28, y + 18, label_width, color); + if (shortcut.len != 0) canvas.text(shortcut, canvas.width - 22 - shortcut_width, y + 18, shortcut_width, color); + if (item.submenu != null) for (0..5) |j| { + const offset: i32 = @intCast(j); + canvas.rect(canvas.width - 13 + offset, y + 8 + offset, 1, 9 - 2 * offset, color); + }; + } + // Padding masks partially scrolled rows and keeps the border intact. + canvas.rect(0, 0, canvas.width, 4, background); + canvas.rect(0, canvas.height - 4, canvas.width, 4, background); + canvas.rect(0, 0, canvas.width, 1, border); + canvas.rect(0, canvas.height - 1, canvas.width, 1, border); + canvas.rect(0, 0, 1, canvas.height, border); + canvas.rect(canvas.width - 1, 0, 1, canvas.height, border); + if (scroll_offset > 0) canvas.rect(@divTrunc(canvas.width, 2) - 5, 1, 10, 2, foreground); + if (y > canvas.height - 4) canvas.rect(@divTrunc(canvas.width, 2) - 5, canvas.height - 3, 10, 2, foreground); +} +fn choose(p: *Panel, index: ?usize) void { + if (p.selected == index) return; + closeChildren(p); + p.selected = index; + p.pressed = null; + p.dirty = true; + s().hover_at = if (index != null and p.menu.payload.menu.items[index.?].submenu != null) runtime.now(b().app.io) + 200 * std.time.ns_per_ms else 0; +} +fn hit(p: *Panel, x: f32, y: f32) ?usize { + if (x < 3 or x >= @as(f32, @floatFromInt(p.width - 3)) or y < 4 or y >= @as(f32, @floatFromInt(p.height - 4))) return null; + var top: i32 = 4 - p.scroll; + for (p.menu.payload.menu.items, 0..) |item, i| { + const bottom = top + rowHeight(item); + if (y >= @as(f32, @floatFromInt(top)) and y < @as(f32, @floatFromInt(bottom))) return if (usable(item)) i else null; + top = bottom; + } + return null; +} +pub fn pointerEnter(surface: ?*c.struct_wl_surface, x: f32, y: f32, serial: u32) bool { + s().pointer = lookup(surface); + const p = s().pointer orelse return false; + const input = Input.state(); + if (input.cursor_device != null) c.wp_cursor_shape_device_v1_set_shape(input.cursor_device, serial, c.WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_DEFAULT); + choose(p, hit(p, x, y)); + return true; +} +pub fn pointerLeave() void { + s().pointer = null; + s().hover_at = 0; +} +pub fn motion(x: f32, y: f32) void { + if (s().pointer) |p| choose(p, hit(p, x, y)); +} +pub fn button(code: u32, down: bool, serial: u32) void { + if (down) s().serial = serial; + const p = s().pointer orelse { + if (down) finish(abi.CANCELLED); + return; + }; + if (code != 0x110) return; + if (down) p.pressed = p.selected else if (p.pressed != null and p.pressed == p.selected) select(p); +} +fn openChild(p: *Panel) void { + s().hover_at = 0; + const index = p.selected orelse return; + const item = p.menu.payload.menu.items[index]; + if (!usable(item) or item.submenu == null) return; + closeChildren(p); + const menu: *Resource = @ptrCast(@alignCast(item.submenu.?)); + if (menu.payload.menu.items.len == 0) return; + const y = std.math.clamp(rowY(p, index), 0, p.height - 1); + const child = create(menu, p, p.width - 1, y, @min(26, p.height - y)) catch { + finish(abi.OOM); + return; + }; + s().top = child; +} +fn select(p: *Panel) void { + const index = p.selected orelse return; + const item = &p.menu.payload.menu.items[index]; + if (!usable(item.*)) return; + if (item.submenu != null) { + openChild(p); + return; + } + Menu.activate(s().window.?, item, Input.id()); + finish(abi.OK); // Command event must precede request completion. +} +fn reveal(p: *Panel) void { + const index = p.selected orelse return; + const y = rowY(p, index); + if (y < 4) p.scroll -= 4 - y; + if (y + 26 > p.height - 4) p.scroll += y + 26 - (p.height - 4); + p.scroll = std.math.clamp(p.scroll, 0, @max(0, p.total - p.height)); + p.dirty = true; +} +fn move(p: *Panel, forward: bool) void { + const items = p.menu.payload.menu.items; + if (items.len == 0) return; + var index = p.selected orelse (if (forward) items.len - 1 else 0); + for (0..items.len) |_| { + index = if (forward) (index + 1) % items.len else (index + items.len - 1) % items.len; + if (usable(items[index])) { + choose(p, index); + s().hover_at = 0; + reveal(p); + return; + } + } +} +pub fn key(logical: u32, down: bool, serial: u32) void { + if (!down) return; + const p = s().top orelse return; + s().serial = serial; + switch (logical) { + n.UHDK_KEY_ESCAPE => finish(abi.CANCELLED), + n.UHDK_KEY_UP => move(p, false), + n.UHDK_KEY_DOWN, n.UHDK_KEY_TAB => move(p, true), + n.UHDK_KEY_HOME => { + choose(p, null); + move(p, true); + }, + n.UHDK_KEY_END => { + choose(p, null); + move(p, false); + }, + n.UHDK_KEY_RIGHT => { + openChild(p); + if (s().top != p and s().top != null) move(s().top.?, true); + }, + n.UHDK_KEY_LEFT => if (p.parent) |parent| { + closeChildren(parent); + s().hover_at = 0; + }, + n.UHDK_KEY_ENTER, ' ' => select(p), + else => {}, + } +} +pub fn scroll(delta: f32) void { + const p = s().pointer orelse return; + closeChildren(p); + p.scroll = std.math.clamp(p.scroll + @as(i32, @intFromFloat(std.math.clamp(delta, -480, 480))), 0, @max(0, p.total - p.height)); + p.selected = null; + p.pressed = null; + p.dirty = true; + s().hover_at = 0; +} +pub fn flags() void { + var cursor = s().top; + while (cursor) |p| : (cursor = p.parent) { + p.dirty = true; + if (p.selected) |index| if (!usable(p.menu.payload.menu.items[index])) { + closeChildren(p); + p.selected = null; + p.pressed = null; + }; + } +} +pub fn tick() i32 { + if (!active()) return -1; + const now = runtime.now(b().app.io); + if (s().hover_at != 0 and now >= s().hover_at) { + if (s().pointer) |p| openChild(p); + s().hover_at = 0; + } + var cursor = s().top; + var timeout: i32 = if (s().hover_at != 0) 25 else -1; + while (cursor) |p| : (cursor = p.parent) { + if (!p.configured) { + if (now >= p.deadline) { + finish(abi.UNAVAILABLE); + return -1; + } + timeout = 100; + } + render(p) catch { + finish(abi.OOM); + return -1; + }; + } + return timeout; +} diff --git a/src/linux/README.md b/src/linux/README.md index 7eccbf7..ef7c9d0 100644 --- a/src/linux/README.md +++ b/src/linux/README.md @@ -6,10 +6,10 @@ constraints, maximize/fullscreen/minimize requests, show/hide, close requests, logical/pixel metrics, EGL desktop OpenGL, and local POSIX file references. Keyboard/pointer input, cursor shapes, XKB composition, text-input-v3 IME, clipboard, incoming file drops, portal dialogs/URLs/preferences, activation, -application icons, inotify file watches, and Plasma app/window menu export are -also implemented. +application icons, inotify file watches, Plasma app/window menu export, and +software-rendered context menus are also implemented. -Native context menus, a cursor-theme fallback, client-side window decorations, and X11 +A cursor-theme fallback, client-side window decorations, and X11 remain unsupported. Protocol tests cover IME batching; a live Fcitx/IBus CJK session and other desktops still need qualification. Missing optional protocols or libraries disable their features without a desktop-name whitelist. @@ -174,6 +174,29 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified window. Session-bus loss removes exports/capability but keeps local shortcuts; reconnecting to a replaced session bus requires restarting the application. Restarting just the menu consumer does not require reattachment. +- Context menus use an internal software renderer and `xdg_popup`, with a + checked-in 16px Roboto bitmap (about 24 KB). They need `wl_shm` and a seat, + independently of KDE appmenu export or a session bus. No Qt/GTK, runtime font + library, image decoder, or application GL context is used for menu drawing. + `CONTEXT_MENU` reports protocol availability; opening also requires a visible, + configured parent and a recent button/key press from that window. Call the + popup API while handling that input event; missing provenance returns + `UNAVAILABLE`. One popup request can be active; another returns `BUSY`. +- Context menus display labels, shortcut hints, checked/disabled items and + separators. Pointer hover or Right opens submenus, Left closes a submenu, + Up/Down/Home/End navigate, Enter/Space select, and Escape dismisses. Tall menus + scroll with the wheel or keyboard. Positioners let the compositor adjust menus + at screen edges. Popup input is consumed; activation emits its menu/edit/quit + event before successful request completion. Dismissal, cancellation, parent + hiding/destruction and seat removal complete as `CANCELLED`. +- This small menu has its own light/dark palette, selected from portal appearance + preferences when available. It does not reproduce arbitrary KDE widget themes + or expose an accessibility tree. The bitmap covers printable ASCII, Latin-1 + and selected punctuation/arrows, with a replacement glyph for other text; no + shaping or bidi is implemented. Output scales enlarge the 16px bitmap instead + of rerasterizing it. Font provenance, license and maintainer regeneration + instructions are in [font/README.md](font/README.md). The renderer and font stay + entirely inside the Linux backend. - File watches use inotify. A document watch follows its named path through atomic replacement by observing its parent. Directory watches observe immediate children. Queue overflow requests a rescan; lost roots are invalidated. Each @@ -194,10 +217,12 @@ python3 src/linux/tests/disconnect.py zig-out/bin/linux-native-tests python3 src/linux/tests/wayland_fixture.py zig-out/bin/linux-service-tests python3 src/linux/tests/portal_fixture.py zig-out/bin/linux-service-tests python3 src/linux/tests/menu_fixture.py zig-out/bin/linux-service-tests +python3 src/linux/tests/popup_fixture.py zig-out/bin/linux-service-tests # Opens a supplied app that exports a Quit or Quit … item, then activates that item: python3 src/linux/tests/menu_live.py /path/to/menu-enabled-app zig-out/bin/linux-service-tests desktop # live portal discovery/icons/file watches python3 src/linux/tools/protocols.py --check +python3 src/linux/tools/bitmap-font.py --check ``` The native harness exercises repeated application/window lifetimes, cancellation, @@ -212,6 +237,12 @@ keys/repeat/dead-key composition, IME revisions/deletion/preedit, scrolling, clipboard size limits/cancellation/invalid UTF-8/early reader exit, URI drops, activation, portal open/save options, live settings, response-before-reply, cancellation with a late alternate handle, parent destruction and portal restart. +The popup fixture checks shared-memory pixels, configure/scale handling, +press-serial grabs, nested teardown order, disabled items, pointer/keyboard +selection, scrolling, live flags, cancellation, outside clicks, parent destruction +and input suppression across focus restoration. It saves a rendered menu to +`/tmp/uhdk-popup.ppm` for inspection. Live right-click behavior still needs a +manual compositor check; the fixture does not establish that by itself. The menu fixture covers D-BusMenu layout/properties/errors, state signals, window overrides/defaults, retained trees, stale paths, consumer restarts, 64-bit command IDs, role dispatch, shortcut consumption, missing protocol/bus and bus loss. The live menu check verifies KWin diff --git a/src/linux/native.zig b/src/linux/native.zig index f3389f3..e333b3b 100644 --- a/src/linux/native.zig +++ b/src/linux/native.zig @@ -12,6 +12,7 @@ const Desktop = @import("Desktop.zig"); const Portal = @import("Portal.zig"); const Watch = @import("Watch.zig"); const Data = @import("Data.zig"); +const Popup = @import("Popup.zig"); const KdeAppMenu = @import("KdeAppMenu.zig"); const Text = @import("Text.zig"); @@ -70,6 +71,7 @@ pub const Backend = struct { portal: Portal.State = .{}, desktop: Desktop.State = .{}, menus: KdeAppMenu.State = .{}, + popup: Popup.State = .{}, capabilities: std.atomic.Value(u32) = .init(0), running: bool = true, failed: std.atomic.Value(bool) = .init(false), @@ -182,6 +184,7 @@ export fn z_native_finish() void { const b = instance orelse return; std.debug.assert(b.windows == null); while (b.watches) |w| Watch.destroy(w); + Popup.deinit(); Desktop.deinit(); KdeAppMenu.deinit(); Portal.deinit(); @@ -226,6 +229,7 @@ fn fail(b: *Backend) void { b.app.lock(); b.app.stopping = true; b.app.unlock(); + Popup.deinit(); runtime.simpleEvent(b.app, n.UHDK_EVENT_QUIT_REQUESTED); } export fn z_native_run() void { @@ -238,6 +242,8 @@ export fn z_native_run() void { var cursor = b.windows; var timeout: c_int = Input.tick(); Watch.tick(); + const popup_timeout = Popup.tick(); + if (popup_timeout >= 0) timeout = if (timeout < 0) popup_timeout else @min(timeout, popup_timeout); const desktop_timeout = Desktop.tick(); if (desktop_timeout >= 0) timeout = if (timeout < 0) desktop_timeout else @min(timeout, desktop_timeout); const portal_timeout = Portal.tick(); @@ -316,6 +322,7 @@ fn registryGlobal(data: ?*anyopaque, registry: ?*c.struct_wl_registry, name: u32 Portal.global(registry, name, str, version); KdeAppMenu.global(registry, name, str, version); Desktop.global(registry, name, str, version); + Popup.capability(); if (std.mem.eql(u8, str, "wl_compositor") and version >= 4) { b.compositor = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_wl_compositor_interface, @min(version, 6))); } else if (std.mem.eql(u8, str, "xdg_wm_base")) { @@ -523,6 +530,7 @@ fn setLimits(w: *Window) void { fn destroyWindow(w: *Window) void { const b = instance.?; std.debug.assert(w.egl == null); + Popup.forget(w); KdeAppMenu.forget(w); Desktop.forget(w); Data.forget(w); @@ -557,7 +565,11 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64, if (b.failed.load(.acquire)) return abi.UNAVAILABLE; if (cmd.op == n.Z_WINDOW) return createWindow(b, cmd, id); if (cmd.op == n.Z_APP_MENU) return KdeAppMenu.attach(null, cmd.other); - if (cmd.op == n.Z_MENU_FLAGS) return KdeAppMenu.updateFlags(); + if (cmd.op == n.Z_MENU_FLAGS) { + Popup.flags(); + return KdeAppMenu.updateFlags(); + } + if (cmd.op == n.Z_POPUP) return Popup.request(cmd, id); if (cmd.op == n.Z_ICON) return Desktop.setIcon(cmd.image); if (cmd.op == n.Z_DIALOG or cmd.op == n.Z_URL) return Portal.request(cmd, id); if (cmd.op == n.Z_WATCH) return Watch.create(cmd, &result.*); @@ -603,6 +615,10 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64, }, n.Z_VISIBLE => { w.want_visible = cmd.number != 0; + if (!w.want_visible) { + Popup.forget(w); + Input.clearPopupTrigger(w); + } if (w.want_visible and !w.configured) c.wl_surface_commit(w.surface); w.dirty = true; }, @@ -621,7 +637,7 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64, } export fn z_native_cancel(id: u64) void { const b = instance orelse return; - if (Data.cancel(id) or Portal.cancel(id) or Desktop.cancel(id)) return; + if (Popup.cancel(id) or Data.cancel(id) or Portal.cancel(id) or Desktop.cancel(id)) return; var cursor = b.windows; while (cursor) |w| : (cursor = w.next) { if (w.request == id) { diff --git a/src/linux/tests/popup_fixture.py b/src/linux/tests/popup_fixture.py new file mode 100644 index 0000000..7467216 --- /dev/null +++ b/src/linux/tests/popup_fixture.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Strict private Wayland popup peer; captures actual pixels to /tmp/uhdk-popup.ppm. + +No session bus, EGL rendering, or desktop input injection is used. +""" +import mmap +import os +from pathlib import Path +import struct +import sys +import time +from wayland_fixture import Compositor, run + + +class Popups(Compositor): + globals = Compositor.globals + [('wl_shm', 1)] + + def __init__(self, sock, inject=False): + super().__init__(sock, False) + self.stage = -1 + self.tasks = [] + self.positions = {} + self.popup_surfaces = {} + self.xdg_surfaces = {} + self.stack = [] + self.pools = {} + self.buffers = {} + self.attached = {} + self.trigger_serial = 500 + + def schedule(self, delay, action): + self.tasks.append((time.monotonic() + delay, action)) + + def timers(self): + ready = [x for x in self.tasks if x[0] <= time.monotonic()] + self.tasks = [x for x in self.tasks if x[0] > time.monotonic()] + for _, action in ready: action() + + def keyboard(self, code): + keyboard = self.ids['wl_keyboard'] + self.event(keyboard, 'key', 551, 1, code, 1) + self.event(keyboard, 'key', 552, 2, code, 0) + + def pointer(self, surface, x, y, click=False): + pointer = self.ids['wl_pointer'] + self.event(pointer, 'enter', 550, surface, x * 256, y * 256) + if click: + self.event(pointer, 'button', 551, 1, 0x110, 1) + self.event(pointer, 'button', 552, 2, 0x110, 0) + + def trigger(self): + surface = next(iter(self.surfaces)) + self.event(self.ids['wl_keyboard'], 'enter', 499, surface, b'') + self.event(self.ids['wl_keyboard'], 'modifiers', 499, 0, 0, 0, 0) + self.event(self.ids['wl_pointer'], 'enter', 499, surface, 12 * 256, 16 * 256) + self.trigger_serial += 10 + self.event(self.ids['wl_pointer'], 'button', self.trigger_serial, 1, 0x111, 1) + self.event(self.ids['wl_pointer'], 'button', self.trigger_serial + 1, 2, 0x111, 0) + + def actions(self, surface, p): + self.event(self.ids['wl_keyboard'], 'enter', 549, surface, b'') + self.event(self.ids['wl_keyboard'], 'modifiers', 549, 0, 0, 0, 0) + if p['child']: + if self.stage == 10: self.schedule(.05, lambda: self.pointer(surface, 35, 15, True)) + else: self.schedule(.05, lambda: self.keyboard(28)) + elif self.stage == 0: + # Split popup geometry from the surface configure. Hover dirties the + # menu during the gap; the client must wait for the configure serial. + width, height = p['size'] + self.event(p['popup'], 'configure', 10, 20, width + 4, height) + p['pending_configure'] = True + def acknowledge(): + p['pending_configure'] = False + self.serial += 1 + self.event(p['xdg'], 'configure', self.serial) + self.schedule(.06, acknowledge) + self.pointer(surface, 35, 15) + self.schedule(.12, lambda: self.pointer(surface, 35, 15, True)) + elif self.stage == 1: + self.keyboard(108) + self.keyboard(108) # Skip disabled/separator; select Checked. + self.schedule(.05, lambda: self.event(self.ids['wl_keyboard'], 'key', 551, 1, 28, 1)) + elif self.stage == 2: + self.keyboard(107) # End = Copy. + self.keyboard(103) # Up = More. + self.schedule(.05, lambda: self.keyboard(106)) + elif self.stage == 3: + self.keyboard(1) + elif self.stage == 4: + self.event(p['popup'], 'popup_done') + elif self.stage == 6: + self.schedule(.2, lambda: self.pointer(surface, 35, 15, True)) + self.schedule(.3, lambda: self.keyboard(1)) + elif self.stage in (7, 8): + self.keyboard(107) # Copy, or final item in the tall scrolling menu. + self.schedule(.05, lambda: self.keyboard(28)) + elif self.stage == 9: + self.pointer(next(iter(self.surfaces)), 15, 15, True) + elif self.stage == 10: + self.pointer(surface, 35, 102) # Hover opens More after a short delay. + # Stages 5/11 are cancelled by the application after 100ms. + + def request(self, obj, iface, name, args): + if iface == 'wl_surface' and name == 'commit' and obj in self.popup_surfaces: + p = self.popup_surfaces[obj] + assert not p.get('pending_configure', False), 'Commit before xdg_surface.configure' + if not p['configured']: + p['configured'] = True + width, height = p['size'] + if self.stage == 8: height = 120 + if self.stage == 1: self.event(obj, 'preferred_buffer_scale', 2) + self.event(p['popup'], 'configure', 10, 20, width, height) + self.serial += 1 + self.event(p['xdg'], 'configure', self.serial) + elif obj in self.attached: + buf = self.attached[obj] + meta = self.buffers[buf] + pixels = bytes(meta['mapping'][meta['offset']:meta['offset'] + meta['stride'] * meta['height']]) + words = struct.unpack('=' + 'I' * (len(pixels) // 4), pixels) + colors = set(words) + assert len(colors) > 15, 'Expected antialiased bitmap text in wl_shm pixels' + assert all((color >> 24) == 255 for color in colors) + if self.stage == 0 and not p['child']: + rgb = bytes(channel for color in words for channel in [(color >> 16) & 255, (color >> 8) & 255, color & 255]) + Path('/tmp/uhdk-popup.ppm').write_bytes(f"P6\n{meta['width']} {meta['height']}\n255\n".encode() + rgb) + if self.stage == 1: assert meta['width'] == p['size'][0] * 2 + def release(): + if buf in self.objects: self.event(buf, 'release') + self.schedule(.03, release) + if not p['mapped']: + p['mapped'] = True + self.schedule(.03, lambda: self.actions(obj, p)) + return + super().request(obj, iface, name, args) + if iface == 'xdg_toplevel' and name == 'set_title' and args[0].startswith('popup-stage-'): + assert not self.stack + self.stage = int(args[0].split('-')[-1]) + self.schedule(.03, self.trigger) + elif iface == 'xdg_wm_base' and name == 'get_xdg_surface': + self.xdg_surfaces[args[0]] = args[1] + elif iface == 'xdg_wm_base' and name == 'create_positioner': + self.positions[args[0]] = {} + elif iface == 'xdg_positioner': + if name == 'set_size': self.positions[obj]['size'] = tuple(args) + elif name == 'set_anchor_rect': self.positions[obj]['anchor'] = tuple(args) + elif iface == 'xdg_surface' and name == 'get_popup': + popup, parent, position = args + surface = self.xdg_surfaces[obj] + self.popup_surfaces[surface] = dict(popup=popup, xdg=obj, configured=False, mapped=False, child=bool(self.stack), **self.positions[position]) + if self.stack: assert parent == self.stack[-1][1] + self.stack.append((popup, obj, surface)) + elif iface == 'xdg_popup' and name == 'grab': + assert args[0] == self.ids['wl_seat'] and args[1] != 0 + assert self.stack[-1][0] == obj + if len(self.stack) == 1: assert args[1] == self.trigger_serial, 'Must use press serial' + elif iface == 'xdg_popup' and name == 'destroy': + assert self.stack[-1][0] == obj, 'Destroy nested popups from the top down' + _, _, surface = self.stack.pop() + del self.popup_surfaces[surface] + if self.stage == 1: + keyboard = self.ids['wl_keyboard'] + parent = next(iter(self.surfaces)) + self.event(keyboard, 'leave', 552, surface) + self.event(keyboard, 'enter', 553, parent, struct.pack('=I', 28)) + self.event(keyboard, 'modifiers', 553, 0, 0, 0, 0) + self.event(keyboard, 'key', 554, 2, 28, 0) # Consumed key's release stays consumed. + elif iface == 'wl_shm' and name == 'create_pool': + pool, fd, size = args + self.pools[pool] = mmap.mmap(fd, size, access=mmap.ACCESS_READ) + os.close(fd) + elif iface == 'wl_shm_pool' and name == 'create_buffer': + buf, offset, width, height, stride, fmt = args + assert fmt == 0 and stride == width * 4 + self.buffers[buf] = dict(mapping=self.pools[obj], offset=offset, width=width, height=height, stride=stride) + elif iface == 'wl_surface' and name == 'attach': + self.attached[obj] = args[0] + elif iface == 'wl_buffer' and name == 'destroy': + self.buffers.pop(obj, None) + elif iface == 'wl_surface' and name == 'destroy': + self.surfaces.pop(obj, None) + + +if __name__ == '__main__': + environment = dict(os.environ) + environment.pop('DBUS_SESSION_BUS_ADDRESS', None) + run(sys.argv[1], 'popup', environment, Popups) + print('Wayland popup grab/stack/configure/scale and software pixels: PASS') diff --git a/src/linux/tests/popups.c b/src/linux/tests/popups.c new file mode 100644 index 0000000..29ac85f --- /dev/null +++ b/src/linux/tests/popups.c @@ -0,0 +1,113 @@ +/* Included by services.c; driven by popup_fixture.py on a private compositor. */ +#define POPUP_OPEN UINT64_C(0x1234567800000001) +#define POPUP_CHILD UINT64_C(0xfedcba9800000002) +static void popups(uhdk_app *app) { + uhdk_window *w = window(app); + wait_capability(app, UHDK_CAP_CONTEXT_MENU); + assert(!uhdk_app_has_capability(app, UHDK_CAP_APP_MENU)); + uint64_t id; + OK(uhdk_window_set_visible_async(w, 1, &id)); + OK(wait_result(app, id).status); + OK(uhdk_window_set_text_input_async(w, 1, &id)); + OK(wait_result(app, id).status); + uhdk_menu_item child_items[] = {{.command_id = POPUP_CHILD, .label = {"Child café", 11}}}; + uhdk_menu *child, *root; + OK(uhdk_menu_create(app, child_items, 1, &child)); + uhdk_menu_item items[] = { + {.command_id = POPUP_OPEN, + .label = {"Open…", 7}, + .shortcut_key = 'o', + .shortcut_modifiers = UHDK_MOD_CONTROL}, + {.command_id = 102, .label = {"Disabled", 8}, .flags = UHDK_MENU_DISABLED}, + {.flags = UHDK_MENU_SEPARATOR}, + {.command_id = 103, .label = {"Checked", 7}, .flags = UHDK_MENU_CHECKED}, + {.command_id = 104, .label = {"More", 4}, .submenu = child}, + {.label = {"Copy", 4}, .role = UHDK_MENU_ROLE_COPY}, + }; + OK(uhdk_menu_create(app, items, 6, &root)); + uhdk_menu_release(child); + OK(uhdk_menu_popup_async(root, w, (uhdk_point){4, 4}, &id)); + assert(wait_result(app, id).status == UHDK_STATUS_UNAVAILABLE); // No input action yet. + for (unsigned stage = 0; stage < 12; ++stage) { + uhdk_menu *menu = root; + if (stage == 8) { + uhdk_menu_item many[32] = {0}; + for (unsigned j = 0; j < 32; ++j) { + many[j].command_id = 1000 + j; + many[j].label = text("Scrollable item"); + } + OK(uhdk_menu_create(app, many, 32, &menu)); + } + char title[64]; + snprintf(title, sizeof(title), "popup-stage-%u", stage); + OK(uhdk_window_set_title_async(w, text(title), &id)); + // Do not discard the input event that may arrive with title completion. + uint64_t popup = 0, busy = 0, started = 0; + unsigned commands = 0, complete = 0, busy_done = 0, changed = 0; + uint64_t end = deadline(app); + while (!complete && uhdk_app_get_time_ns(app) < end) { + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 20000000, &events)); + for (uint64_t j = 0; j < events.len; ++j) { + const uhdk_event *e = &events.ptr[j]; + if (e->type == UHDK_EVENT_POINTER_BUTTON) { + const uhdk_pointer_event *p = e->data; + assert(p->button == UHDK_BUTTON_SECONDARY); + if (p->pressed) { + assert(popup == 0); + OK(uhdk_menu_popup_async(menu, w, p->position, &popup)); + started = uhdk_app_get_time_ns(app); + if (stage == 0) + OK(uhdk_menu_popup_async(menu, w, p->position, &busy)); + } + } else if (e->type == UHDK_EVENT_MENU_COMMAND) { + uint64_t command = ((const uhdk_menu_event *)e->data)->command_id; + uint64_t expected = stage == 0 ? POPUP_OPEN + : stage == 1 ? 103 + : (stage == 2 || stage == 10) ? POPUP_CHILD + : 1031; + assert(stage == 0 || stage == 1 || stage == 2 || stage == 8 || stage == 10); + assert(command == expected && e->window == w); + ++commands; + } else if (e->type == UHDK_EVENT_EDIT_COMMAND) { + assert(stage == 7 && e->window == w && e->input_id != 0); + assert(((const uhdk_edit_event *)e->data)->command == UHDK_EDIT_COPY); + ++commands; + } else if (e->type == UHDK_EVENT_REQUEST_DONE) { + const uhdk_request_result *r = e->data; + if (r->id == popup && popup) { + int selected = stage <= 2 || stage == 7 || stage == 8 || stage == 10; + assert(r->status == (selected ? UHDK_STATUS_OK : UHDK_STATUS_CANCELLED)); + assert(commands == (unsigned)selected); // Ordering and exactly one action. + ++complete; + } else if (r->id == busy && busy) { + assert(r->status == UHDK_STATUS_BUSY); + ++busy_done; + } else + OK(r->status); + } else if (e->type == UHDK_EVENT_KEY || e->type == UHDK_EVENT_TEXT_COMMIT) + assert(!"popup input leaked to application"); + } + if (popup && !changed && uhdk_app_get_time_ns(app) - started > 100000000ull) { + if (stage == 5) + OK(uhdk_request_cancel(app, popup)); + if (stage == 6) { + OK(uhdk_menu_set_item_flags_async(root, POPUP_OPEN, UHDK_MENU_DISABLED, &id)); + OK(uhdk_menu_set_item_flags_async(root, 103, 0, &id)); + } + if (stage == 11) + uhdk_window_destroy(w); + changed = 1; + } + } + assert(complete == 1); + if (stage == 0) + assert(busy_done == 1); + if (stage == 8) + uhdk_menu_release(menu); + } + uhdk_menu_release(root); + puts("software popup selection/roles/submenus/cancellation/input/scroll/lifetime: PASS"); +} +#undef POPUP_OPEN +#undef POPUP_CHILD diff --git a/src/linux/tests/services.c b/src/linux/tests/services.c index 89a81b2..1fe7845 100644 --- a/src/linux/tests/services.c +++ b/src/linux/tests/services.c @@ -364,8 +364,13 @@ static void input(uhdk_app *app) { puts("input/IME/clipboard/drop/activation: PASS"); } #include "menus.c" +#include "popups.c" static void run(uhdk_app *app, void *mode) { + if (strcmp(mode, "popup") == 0) { + popups(app); + return; + } if (strcmp(mode, "menu-noprotocol") == 0 || strcmp(mode, "menu-nobus") == 0) { menus_unavailable(app, strcmp(mode, "menu-nobus") == 0); return;