Implement Linux software context menus on Wayland

This commit is contained in:
Peterino2 2026-09-18 15:32:04 -07:00
parent 0d2c8eedf9
commit 85acfb5b67
10 changed files with 959 additions and 54 deletions

View File

@ -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 The Linux Wayland preview implements windows, EGL/OpenGL, keyboard/pointer and
text input, clipboard/file drops, portal dialogs/preferences, activation, icons, text input, clipboard/file drops, portal dialogs/preferences, activation, icons,
file watches, and Plasma app/window menu export, initially tested on KDE Plasma. file watches, and Plasma app/window menu export, initially tested on KDE Plasma.
Native context menus and X11 remain unsupported. It vendors only ABI Linux context menus use a small software renderer and a bundled 16px Roboto
headers and protocol bindings and loads system libraries at runtime. Normal 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 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). NixOS runtime library paths. See [Linux support and limitations](src/linux/README.md).

View File

@ -5,6 +5,7 @@ const n = @import("native");
const native = @import("native.zig"); const native = @import("native.zig");
const runtime = @import("../runtime.zig"); const runtime = @import("../runtime.zig");
const abi = @import("../abi.zig"); const abi = @import("../abi.zig");
const Popup = @import("Popup.zig");
const KdeAppMenu = @import("KdeAppMenu.zig"); const KdeAppMenu = @import("KdeAppMenu.zig");
const Keyboard = @import("Keyboard.zig"); const Keyboard = @import("Keyboard.zig");
const Data = @import("Data.zig"); const Data = @import("Data.zig");
@ -43,6 +44,10 @@ pub const State = struct {
key_sync: ?*c.struct_wl_callback = null, key_sync: ?*c.struct_wl_callback = null,
serial: u32 = 0, serial: u32 = 0,
serial_time: u64 = 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, pointer_serial: u32 = 0,
next_id: u64 = 0, next_id: u64 = 0,
position: n.uhdk_point = .{ .x = 0, .y = 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); if (s.text_input != null) Text.listen(s.text_input);
} }
Data.setup(); Data.setup();
Popup.capability();
native.setCapability(n.UHDK_CAP_POINTER_CAPTURE, s.pointer != null); native.setCapability(n.UHDK_CAP_POINTER_CAPTURE, s.pointer != null);
native.setCapability(n.UHDK_CAP_TEXT_INPUT, s.context != null and s.keyboard != 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 { fn keyEnter(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, _: u32, surface: ?*c.struct_wl_surface, keys: [*c]c.struct_wl_array) callconv(.c) void {
leaveKeyboard(); leaveKeyboard();
const s = state(); 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) { if (s.state != null and keys.*.size != 0) {
const held: [*]const u32 = @ptrCast(@alignCast(keys.*.data)); const held: [*]const u32 = @ptrCast(@alignCast(keys.*.data));
for (held[0 .. keys.*.size / 4]) |code| { 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(); flushKey();
if (code > 0xffff) return; if (code > 0xffff) return;
provenance(serial); provenance(serial);
if (pressed != 0 and !Popup.active()) popupTrigger(s.key_focus, serial);
if (code < s.held.len) s.held[code] = pressed != 0; if (code < s.held.len) s.held[code] = pressed != 0;
// The compositor supplies the authoritative modifier/group mask. Running // The compositor supplies the authoritative modifier/group mask. Running
// XKB key actions locally as well would apply latch/lock actions twice. // 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) }; 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 (value.code < s.menu_keys.len) {
if (s.menu_keys[value.code]) { 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. 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)) { 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; s.menu_keys[value.code] = true;
return; return;
} }
} }
if (Popup.active()) return;
send(w, &event); send(w, &event);
// Resolve symbols/text before consuming a latched modifier, while reporting // Resolve symbols/text before consuming a latched modifier, while reporting
// the compositor's post-event flags in the observational raw event. // the compositor's post-event flags in the observational raw event.
@ -299,20 +317,22 @@ fn leaveKeyboard() void {
s.modifiers = 0; s.modifiers = 0;
s.repeat_key = null; s.repeat_key = null;
if (s.key_focus) |w| { if (s.key_focus) |w| {
if (!Popup.active()) clearPopupTrigger(w);
for (&s.held, 0..) |*held, code| if (held.*) { for (&s.held, 0..) |*held, code| if (held.*) {
held.* = false; held.* = false;
deliverKey(@intCast(code), false, false); if (!s.menu_keys[code]) deliverKey(@intCast(code), false, false);
}; };
Text.cancel(w); Text.cancel(w);
} }
s.key_focus = null; 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.modifiers = 0;
s.serial = 0; s.serial = 0;
if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, 0, 0, 0, 0, 0, 0); if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, 0, 0, 0, 0, 0, 0);
normal(); normal();
} }
fn pointerEvent(kind: u32, button_: u32, pressed: bool, delta: n.uhdk_point) void { fn pointerEvent(kind: u32, button_: u32, pressed: bool, delta: n.uhdk_point) void {
if (Popup.active()) return;
const s = state(); const s = state();
const w = s.pointer_focus orelse return; const w = s.pointer_focus orelse return;
var e = std.mem.zeroes(n.z_event); 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_focus = find(surface);
s.pointer_serial = serial; s.pointer_serial = serial;
s.position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; 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 }); pointerEvent(n.UHDK_EVENT_POINTER_ENTER, 0, false, .{ .x = 0, .y = 0 });
if (s.pointer_focus) |w| _ = cursor(w, w.cursor); if (s.pointer_focus) |w| _ = cursor(w, w.cursor);
} }
@ -334,6 +355,7 @@ fn pointerLeave(_: ?*anyopaque, _: ?*c.struct_wl_pointer, _: u32, _: ?*c.struct_
leavePointer(); leavePointer();
} }
fn leavePointer() void { fn leavePointer() void {
Popup.pointerLeave();
const s = state(); const s = state();
normal(); normal();
pointerEvent(n.UHDK_EVENT_POINTER_LEAVE, 0, false, .{ .x = 0, .y = 0 }); 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 s = state();
const previous = s.position; const previous = s.position;
s.position = .{ .x = @as(f32, @floatFromInt(x)) / 256, .y = @as(f32, @floatFromInt(y)) / 256 }; 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 }); 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 { 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; if (mapped == 0) return;
const mask = @as(u32, 1) << @intCast(mapped); 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) { if (pressed != 0) {
s.buttons |= mask; s.buttons |= mask;
const now = runtime.now(b().app.io); 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.wheel = .{ .x = 0, .y = 0 };
s.axis_stop = false; 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 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 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; const has_wheel = s.wheel.x != 0 or s.wheel.y != 0;
@ -453,7 +495,25 @@ fn normal() void {
send(w, &e); 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 { pub fn forget(w: *native.Window) void {
clearPopupTrigger(w);
if (state().key_focus == w) leaveKeyboard(); if (state().key_focus == w) leaveKeyboard();
if (state().pointer_focus == w) leavePointer(); if (state().pointer_focus == w) leavePointer();
Text.forget(w); Text.forget(w);
@ -462,6 +522,9 @@ pub fn remove(name: u32) void {
if (name == state().seat_name) releaseSeat(); if (name == state().seat_name) releaseSeat();
} }
fn releaseSeat() void { fn releaseSeat() void {
Popup.deinit();
state().popup_serial = 0;
state().popup_window = null;
Data.releaseSeat(); Data.releaseSeat();
const s = state(); const s = state();
if (s.key_sync != null) c.wl_callback_destroy(s.key_sync); if (s.key_sync != null) c.wl_callback_destroy(s.key_sync);

View File

@ -10,6 +10,7 @@ const Resource = @import("../Resource.zig");
const abi = @import("../abi.zig"); const abi = @import("../abi.zig");
const Bus = @import("Bus.zig"); const Bus = @import("Bus.zig");
const Portal = @import("Portal.zig"); const Portal = @import("Portal.zig");
const Menu = @import("Menu.zig");
const Input = @import("Input.zig"); const Input = @import("Input.zig");
const iface = "com.canonical.dbusmenu"; const iface = "com.canonical.dbusmenu";
const prefix = "/org/uhdk/menu/"; 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 { fn activate(e: *Export, index: usize, input_id: u64) void {
const item = e.nodes.items[index].item orelse return; 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; 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); Menu.activate(e.window, item, input_id);
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);
}
} }
fn folded(key: u32) u32 { fn folded(key: u32) u32 {
return if (key >= 'A' and key <= 'Z') key + ('a' - 'A') else key; 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.basic(&v, sig[0], value);
try Bus.close(i, &v); 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 { fn property(e: *Export, index: usize, name: []const u8, out: *Bus.Iter) !bool {
const node = e.nodes.items[index]; const node = e.nodes.items[index];
const item = node.item; 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"); var arrays = try Bus.open(&v, c.DBUS_TYPE_ARRAY, "as");
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&v, &arrays); errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&v, &arrays);
var buffer: [32]u8 = undefined; 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"); var keys = try Bus.open(&arrays, c.DBUS_TYPE_ARRAY, "s");
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&arrays, &keys); 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| { inline for (.{ .{ n.UHDK_MOD_CONTROL, "Control" }, .{ n.UHDK_MOD_ALT, "Alt" }, .{ n.UHDK_MOD_SHIFT, "Shift" }, .{ n.UHDK_MOD_SUPER, "Super" } }) |mod| {

64
src/linux/Menu.zig Normal file
View File

@ -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];
}

464
src/linux/Popup.zig Normal file
View File

@ -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;
}

View File

@ -6,10 +6,10 @@ constraints, maximize/fullscreen/minimize requests, show/hide, close requests,
logical/pixel metrics, EGL desktop OpenGL, and local POSIX file references. logical/pixel metrics, EGL desktop OpenGL, and local POSIX file references.
Keyboard/pointer input, cursor shapes, XKB composition, text-input-v3 IME, Keyboard/pointer input, cursor shapes, XKB composition, text-input-v3 IME,
clipboard, incoming file drops, portal dialogs/URLs/preferences, activation, clipboard, incoming file drops, portal dialogs/URLs/preferences, activation,
application icons, inotify file watches, and Plasma app/window menu export are application icons, inotify file watches, Plasma app/window menu export, and
also implemented. 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 remain unsupported. Protocol tests cover IME batching; a live Fcitx/IBus CJK
session and other desktops still need qualification. Missing optional protocols session and other desktops still need qualification. Missing optional protocols
or libraries disable their features without a desktop-name whitelist. 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; window. Session-bus loss removes exports/capability but keeps local shortcuts;
reconnecting to a replaced session bus requires restarting the application. reconnecting to a replaced session bus requires restarting the application.
Restarting just the menu consumer does not require reattachment. 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 - File watches use inotify. A document watch follows its named path through
atomic replacement by observing its parent. Directory watches observe immediate atomic replacement by observing its parent. Directory watches observe immediate
children. Queue overflow requests a rescan; lost roots are invalidated. Each 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/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/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/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: # 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 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 zig-out/bin/linux-service-tests desktop # live portal discovery/icons/file watches
python3 src/linux/tools/protocols.py --check python3 src/linux/tools/protocols.py --check
python3 src/linux/tools/bitmap-font.py --check
``` ```
The native harness exercises repeated application/window lifetimes, cancellation, 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, clipboard size limits/cancellation/invalid UTF-8/early reader exit, URI drops,
activation, portal open/save options, live settings, response-before-reply, activation, portal open/save options, live settings, response-before-reply,
cancellation with a late alternate handle, parent destruction and portal restart. 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 The menu fixture covers D-BusMenu layout/properties/errors, state signals, window
overrides/defaults, retained trees, stale paths, consumer restarts, 64-bit command 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 IDs, role dispatch, shortcut consumption, missing protocol/bus and bus loss. The live menu check verifies KWin

View File

@ -12,6 +12,7 @@ const Desktop = @import("Desktop.zig");
const Portal = @import("Portal.zig"); const Portal = @import("Portal.zig");
const Watch = @import("Watch.zig"); const Watch = @import("Watch.zig");
const Data = @import("Data.zig"); const Data = @import("Data.zig");
const Popup = @import("Popup.zig");
const KdeAppMenu = @import("KdeAppMenu.zig"); const KdeAppMenu = @import("KdeAppMenu.zig");
const Text = @import("Text.zig"); const Text = @import("Text.zig");
@ -70,6 +71,7 @@ pub const Backend = struct {
portal: Portal.State = .{}, portal: Portal.State = .{},
desktop: Desktop.State = .{}, desktop: Desktop.State = .{},
menus: KdeAppMenu.State = .{}, menus: KdeAppMenu.State = .{},
popup: Popup.State = .{},
capabilities: std.atomic.Value(u32) = .init(0), capabilities: std.atomic.Value(u32) = .init(0),
running: bool = true, running: bool = true,
failed: std.atomic.Value(bool) = .init(false), failed: std.atomic.Value(bool) = .init(false),
@ -182,6 +184,7 @@ export fn z_native_finish() void {
const b = instance orelse return; const b = instance orelse return;
std.debug.assert(b.windows == null); std.debug.assert(b.windows == null);
while (b.watches) |w| Watch.destroy(w); while (b.watches) |w| Watch.destroy(w);
Popup.deinit();
Desktop.deinit(); Desktop.deinit();
KdeAppMenu.deinit(); KdeAppMenu.deinit();
Portal.deinit(); Portal.deinit();
@ -226,6 +229,7 @@ fn fail(b: *Backend) void {
b.app.lock(); b.app.lock();
b.app.stopping = true; b.app.stopping = true;
b.app.unlock(); b.app.unlock();
Popup.deinit();
runtime.simpleEvent(b.app, n.UHDK_EVENT_QUIT_REQUESTED); runtime.simpleEvent(b.app, n.UHDK_EVENT_QUIT_REQUESTED);
} }
export fn z_native_run() void { export fn z_native_run() void {
@ -238,6 +242,8 @@ export fn z_native_run() void {
var cursor = b.windows; var cursor = b.windows;
var timeout: c_int = Input.tick(); var timeout: c_int = Input.tick();
Watch.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(); const desktop_timeout = Desktop.tick();
if (desktop_timeout >= 0) timeout = if (timeout < 0) desktop_timeout else @min(timeout, desktop_timeout); if (desktop_timeout >= 0) timeout = if (timeout < 0) desktop_timeout else @min(timeout, desktop_timeout);
const portal_timeout = Portal.tick(); 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); Portal.global(registry, name, str, version);
KdeAppMenu.global(registry, name, str, version); KdeAppMenu.global(registry, name, str, version);
Desktop.global(registry, name, str, version); Desktop.global(registry, name, str, version);
Popup.capability();
if (std.mem.eql(u8, str, "wl_compositor") and version >= 4) { 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))); 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")) { } else if (std.mem.eql(u8, str, "xdg_wm_base")) {
@ -523,6 +530,7 @@ fn setLimits(w: *Window) void {
fn destroyWindow(w: *Window) void { fn destroyWindow(w: *Window) void {
const b = instance.?; const b = instance.?;
std.debug.assert(w.egl == null); std.debug.assert(w.egl == null);
Popup.forget(w);
KdeAppMenu.forget(w); KdeAppMenu.forget(w);
Desktop.forget(w); Desktop.forget(w);
Data.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 (b.failed.load(.acquire)) return abi.UNAVAILABLE;
if (cmd.op == n.Z_WINDOW) return createWindow(b, cmd, id); 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_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_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_DIALOG or cmd.op == n.Z_URL) return Portal.request(cmd, id);
if (cmd.op == n.Z_WATCH) return Watch.create(cmd, &result.*); 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 => { n.Z_VISIBLE => {
w.want_visible = cmd.number != 0; 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); if (w.want_visible and !w.configured) c.wl_surface_commit(w.surface);
w.dirty = true; 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 { export fn z_native_cancel(id: u64) void {
const b = instance orelse return; 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; var cursor = b.windows;
while (cursor) |w| : (cursor = w.next) { while (cursor) |w| : (cursor = w.next) {
if (w.request == id) { if (w.request == id) {

View File

@ -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')

113
src/linux/tests/popups.c Normal file
View File

@ -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

View File

@ -364,8 +364,13 @@ static void input(uhdk_app *app) {
puts("input/IME/clipboard/drop/activation: PASS"); puts("input/IME/clipboard/drop/activation: PASS");
} }
#include "menus.c" #include "menus.c"
#include "popups.c"
static void run(uhdk_app *app, void *mode) { 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) { if (strcmp(mode, "menu-noprotocol") == 0 || strcmp(mode, "menu-nobus") == 0) {
menus_unavailable(app, strcmp(mode, "menu-nobus") == 0); menus_unavailable(app, strcmp(mode, "menu-nobus") == 0);
return; return;