Implement Plasma app menu export, activation and local accelerators
This commit is contained in:
parent
fdbc99a274
commit
b0da270a67
|
|
@ -11,8 +11,8 @@ 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,
|
||||
and file watches, initially tested on KDE Plasma. Native menus and X11 remain
|
||||
unsupported. It vendors only ABI
|
||||
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
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -86,3 +86,10 @@ comptime {
|
|||
std.debug.assert(@sizeOf(c.dbus_uint64_t) == 8);
|
||||
std.debug.assert(@sizeOf(Iter) == if (@sizeOf(usize) == 8) 72 else 56);
|
||||
}
|
||||
|
||||
pub fn int(i: *Iter) ?i32 {
|
||||
if (kind(i) != c.DBUS_TYPE_INT32) return null;
|
||||
var value: i32 = 0;
|
||||
c.z_dbus_message_iter_get_basic.?(i, @ptrCast(&value));
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const n = @import("native");
|
|||
const native = @import("native.zig");
|
||||
const runtime = @import("../runtime.zig");
|
||||
const abi = @import("../abi.zig");
|
||||
const KdeAppMenu = @import("KdeAppMenu.zig");
|
||||
const Keyboard = @import("Keyboard.zig");
|
||||
const Data = @import("Data.zig");
|
||||
const Text = @import("Text.zig");
|
||||
|
|
@ -56,6 +57,7 @@ pub const State = struct {
|
|||
repeat_delay: u32 = 600,
|
||||
repeat_at: u64 = 0,
|
||||
held: [256]bool = @splat(false),
|
||||
menu_keys: [256]bool = @splat(false),
|
||||
axis: n.uhdk_point = .{ .x = 0, .y = 0 },
|
||||
wheel: n.uhdk_point = .{ .x = 0, .y = 0 },
|
||||
axis_source: u32 = c.WL_POINTER_AXIS_SOURCE_WHEEL,
|
||||
|
|
@ -236,6 +238,16 @@ fn emitKey(value: Key) void {
|
|||
event.type = n.UHDK_EVENT_KEY;
|
||||
event.input_id = id();
|
||||
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;
|
||||
return; // Consume the release/repeats even after flags or focus change.
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
send(w, &event);
|
||||
// Resolve symbols/text before consuming a latched modifier, while reporting
|
||||
// the compositor's post-event flags in the observational raw event.
|
||||
|
|
@ -294,6 +306,7 @@ fn leaveKeyboard() void {
|
|||
Text.cancel(w);
|
||||
}
|
||||
s.key_focus = null;
|
||||
s.menu_keys = @splat(false);
|
||||
s.modifiers = 0;
|
||||
s.serial = 0;
|
||||
if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, 0, 0, 0, 0, 0, 0);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,504 @@
|
|||
//! Optional Plasma appmenu adapter. The session bus transport is shared with
|
||||
//! portals; no toolkit, registrar, X11 window ID, or consumer process is required.
|
||||
//! All exports and menu references are owned by 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 Bus = @import("Bus.zig");
|
||||
const Portal = @import("Portal.zig");
|
||||
const Input = @import("Input.zig");
|
||||
const iface = "com.canonical.dbusmenu";
|
||||
const prefix = "/org/uhdk/menu/";
|
||||
const properties = [_][:0]const u8{ "type", "label", "enabled", "visible", "children-display", "toggle-type", "toggle-state", "shortcut" };
|
||||
const Node = struct {
|
||||
item: ?*const n.uhdk_menu_item = null,
|
||||
parent: usize = 0,
|
||||
first: usize = 0,
|
||||
next: usize = 0,
|
||||
label: [:0]const u8 = "",
|
||||
flags: u32 = 0,
|
||||
};
|
||||
pub const Export = struct {
|
||||
next: ?*Export = null, // Temporary transaction list, never an object registry.
|
||||
window: *native.Window,
|
||||
root: *Resource,
|
||||
arena: std.heap.ArenaAllocator,
|
||||
nodes: std.ArrayList(Node) = .empty,
|
||||
path: [:0]const u8,
|
||||
proxy: ?*c.struct_org_kde_kwin_appmenu = null,
|
||||
};
|
||||
pub const State = struct {
|
||||
manager: ?*c.struct_org_kde_kwin_appmenu_manager = null,
|
||||
global_name: u32 = 0,
|
||||
sequence: u64 = 0,
|
||||
app_menu: ?*Resource = null,
|
||||
};
|
||||
fn b() *native.Backend {
|
||||
return native.instance.?;
|
||||
}
|
||||
fn s() *State {
|
||||
return &b().menus;
|
||||
}
|
||||
fn same(a: []const u8, z: []const u8) bool {
|
||||
return std.mem.eql(u8, a, z);
|
||||
}
|
||||
|
||||
pub fn global(registry: ?*c.struct_wl_registry, name: u32, interface: []const u8, version: u32) void {
|
||||
if (!same(interface, "org_kde_kwin_appmenu_manager") or s().manager != null) return;
|
||||
s().manager = @ptrCast(c.wl_registry_bind(registry, name, &c.z_uhdk_org_kde_kwin_appmenu_manager_interface, @min(version, 2)));
|
||||
s().global_name = name;
|
||||
ready();
|
||||
}
|
||||
pub fn remove(name: u32) void {
|
||||
if (name != s().global_name) return;
|
||||
disconnected();
|
||||
destroyManager();
|
||||
}
|
||||
fn destroyManager() void {
|
||||
if (s().manager) |manager| {
|
||||
if (c.org_kde_kwin_appmenu_manager_get_version(manager) >= 2) c.org_kde_kwin_appmenu_manager_release(manager) else c.org_kde_kwin_appmenu_manager_destroy(manager);
|
||||
}
|
||||
s().manager = null;
|
||||
s().global_name = 0;
|
||||
}
|
||||
pub fn ready() void {
|
||||
native.setCapability(n.UHDK_CAP_APP_MENU, s().manager != null and b().portal.ready);
|
||||
var cursor = b().windows;
|
||||
while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| associate(e);
|
||||
}
|
||||
fn associate(e: *Export) void {
|
||||
if (e.proxy != null or s().manager == null or !b().portal.ready) return;
|
||||
// The export is already reachable by process() before advertising it.
|
||||
e.proxy = c.org_kde_kwin_appmenu_manager_create(s().manager, e.window.surface) orelse return;
|
||||
c.org_kde_kwin_appmenu_set_address(e.proxy, b().portal.unique.?.ptr, e.path.ptr);
|
||||
}
|
||||
pub fn disconnected() void {
|
||||
native.setCapability(n.UHDK_CAP_APP_MENU, false);
|
||||
var cursor = b().windows;
|
||||
while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| unassociate(e);
|
||||
}
|
||||
fn unassociate(e: *Export) void {
|
||||
if (e.proxy != null) c.org_kde_kwin_appmenu_release(e.proxy);
|
||||
e.proxy = null;
|
||||
}
|
||||
fn destroy(e: *Export) void {
|
||||
unassociate(e);
|
||||
runtime.release(e.root);
|
||||
e.arena.deinit();
|
||||
b().app.gpa.destroy(e);
|
||||
}
|
||||
fn add(e: *Export, menu: *Resource, parent: usize) !void {
|
||||
var previous: usize = 0;
|
||||
for (menu.payload.menu.items) |*item| {
|
||||
// Bound work and D-Bus message size even for repeatedly shared subtrees.
|
||||
if (e.nodes.items.len >= 65536) return error.OutOfMemory;
|
||||
const index = e.nodes.items.len;
|
||||
const allocator = e.arena.allocator();
|
||||
// D-BusMenu uses '_' as a mnemonic marker; UHDK labels are literal text.
|
||||
var label: std.ArrayList(u8) = .empty;
|
||||
for (abi.textBytes(item.label)) |byte| {
|
||||
try label.append(allocator, byte);
|
||||
if (byte == '_') try label.append(allocator, '_');
|
||||
}
|
||||
try label.append(allocator, 0);
|
||||
try e.nodes.append(allocator, .{ .item = item, .parent = parent, .label = label.items[0 .. label.items.len - 1 :0], .flags = item.flags });
|
||||
if (previous == 0) e.nodes.items[parent].first = index else e.nodes.items[previous].next = index;
|
||||
previous = index;
|
||||
if (item.submenu) |child| try add(e, @ptrCast(@alignCast(child)), index);
|
||||
}
|
||||
}
|
||||
fn create(w: *native.Window, root: *Resource) !*Export {
|
||||
const e = try b().app.gpa.create(Export);
|
||||
errdefer b().app.gpa.destroy(e);
|
||||
s().sequence += 1;
|
||||
e.* = .{ .window = w, .root = root, .arena = .init(b().app.gpa), .path = "" };
|
||||
errdefer e.arena.deinit();
|
||||
e.path = try std.fmt.allocPrintSentinel(e.arena.allocator(), prefix ++ "{d}", .{s().sequence}, 0);
|
||||
try e.nodes.append(e.arena.allocator(), .{});
|
||||
try add(e, root, 0);
|
||||
runtime.retain(root);
|
||||
return e;
|
||||
}
|
||||
fn replace(w: *native.Window, e: ?*Export) void {
|
||||
const old = w.menu_export;
|
||||
w.menu_export = e;
|
||||
if (old) |value| destroy(value);
|
||||
if (e) |value| associate(value);
|
||||
}
|
||||
pub fn inherit(w: *native.Window) !void {
|
||||
if (s().app_menu) |root| replace(w, try create(w, root));
|
||||
}
|
||||
pub fn attach(w: ?*native.Window, menu: ?*anyopaque) u32 {
|
||||
const root: ?*Resource = if (menu) |p| @ptrCast(@alignCast(p)) else null;
|
||||
if (root != null) {
|
||||
if (s().manager == null) return n.UHDK_STATUS_UNSUPPORTED;
|
||||
if (b().portal.connection == null) return abi.UNAVAILABLE;
|
||||
}
|
||||
if (w) |window| {
|
||||
const effective = root orelse s().app_menu;
|
||||
const e = if (effective) |r| create(window, r) catch return abi.OOM else null;
|
||||
if (root) |r| runtime.retain(r);
|
||||
if (window.menu_override) |r| runtime.release(r);
|
||||
window.menu_override = root;
|
||||
replace(window, e);
|
||||
} else {
|
||||
// Prepare every replacement before committing; OOM leaves attachments intact.
|
||||
var pending: ?*Export = null;
|
||||
defer while (pending) |e| {
|
||||
pending = e.next;
|
||||
destroy(e);
|
||||
};
|
||||
if (root) |r| {
|
||||
var cursor = b().windows;
|
||||
while (cursor) |window| : (cursor = window.next) if (window.menu_override == null) {
|
||||
const e = create(window, r) catch return abi.OOM;
|
||||
e.next = pending;
|
||||
pending = e;
|
||||
};
|
||||
runtime.retain(r);
|
||||
}
|
||||
if (s().app_menu) |r| runtime.release(r);
|
||||
s().app_menu = root;
|
||||
if (root == null) {
|
||||
var cursor = b().windows;
|
||||
while (cursor) |window| : (cursor = window.next) if (window.menu_override == null) {
|
||||
replace(window, null);
|
||||
};
|
||||
}
|
||||
while (pending) |e| {
|
||||
pending = e.next;
|
||||
e.next = null;
|
||||
replace(e.window, e);
|
||||
}
|
||||
}
|
||||
return abi.OK;
|
||||
}
|
||||
pub fn forget(w: *native.Window) void {
|
||||
replace(w, null);
|
||||
if (w.menu_override) |root| runtime.release(root);
|
||||
w.menu_override = null;
|
||||
}
|
||||
pub fn deinit() void {
|
||||
if (s().app_menu) |root| runtime.release(root);
|
||||
s().app_menu = null;
|
||||
destroyManager();
|
||||
}
|
||||
fn enabled(e: *Export, index: usize) bool {
|
||||
var i = index;
|
||||
while (i != 0) : (i = e.nodes.items[i].parent) if (e.nodes.items[i].item.?.flags & n.UHDK_MENU_DISABLED != 0) return false;
|
||||
return true;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
fn folded(key: u32) u32 {
|
||||
return if (key >= 'A' and key <= 'Z') key + ('a' - 'A') else key;
|
||||
}
|
||||
pub fn shortcut(w: *native.Window, key: u32, mods: u32, input_id: u64) bool {
|
||||
const e = w.menu_export orelse return false;
|
||||
if (key == 0) return false;
|
||||
const mask: u32 = n.UHDK_MOD_SHIFT | n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER;
|
||||
for (e.nodes.items[1..], 1..) |node, i| {
|
||||
const item = node.item.?;
|
||||
if (item.submenu != null or item.flags & n.UHDK_MENU_SEPARATOR != 0 or !enabled(e, i)) continue;
|
||||
if (folded(item.shortcut_key) == folded(key) and item.shortcut_modifiers & mask == mods & mask) {
|
||||
activate(e, i, input_id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fn nodeId(e: *Export, value: i32) ?usize {
|
||||
return if (value >= 0 and value < e.nodes.items.len) @intCast(value) else null;
|
||||
}
|
||||
fn variant(i: *Bus.Iter, comptime sig: [:0]const u8, value: anytype) !void {
|
||||
var v = try Bus.open(i, c.DBUS_TYPE_VARIANT, sig);
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(i, &v);
|
||||
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;
|
||||
const flags = if (item) |v| v.flags else 0;
|
||||
if (same(name, "type")) try variant(out, "s", @as([*:0]const u8, if (flags & n.UHDK_MENU_SEPARATOR != 0) "separator" else "standard")) else if (same(name, "label")) try variant(out, "s", node.label.ptr) else if (same(name, "enabled")) try variant(out, "b", @as(u32, @intFromBool(enabled(e, index)))) else if (same(name, "visible")) try variant(out, "b", @as(u32, 1)) else if (same(name, "children-display")) try variant(out, "s", @as([*:0]const u8, if (index == 0 or (item != null and item.?.submenu != null)) "submenu" else "")) else if (same(name, "toggle-type")) try variant(out, "s", @as([*:0]const u8, if (flags & n.UHDK_MENU_CHECKED != 0) "checkmark" else "")) else if (same(name, "toggle-state")) try variant(out, "i", @as(i32, if (flags & n.UHDK_MENU_CHECKED != 0) 1 else 0)) else if (same(name, "shortcut")) {
|
||||
var v = try Bus.open(out, c.DBUS_TYPE_VARIANT, "aas");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &v);
|
||||
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| {
|
||||
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| {
|
||||
if (value.shortcut_modifiers & mod[0] != 0) try Bus.string(&keys, mod[1]);
|
||||
}
|
||||
try Bus.string(&keys, key_name.ptr);
|
||||
try Bus.close(&arrays, &keys);
|
||||
};
|
||||
try Bus.close(&v, &arrays);
|
||||
try Bus.close(out, &v);
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
fn wanted(filter: ?Bus.Iter, name: []const u8) bool {
|
||||
var iter = filter orelse return true;
|
||||
if (Bus.kind(&iter) == c.DBUS_TYPE_INVALID) return true;
|
||||
while (Bus.str(&iter)) |value| {
|
||||
if (same(value, name)) return true;
|
||||
if (!Bus.next(&iter)) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fn props(e: *Export, index: usize, filter: ?Bus.Iter, out: *Bus.Iter) !void {
|
||||
var dict = try Bus.open(out, c.DBUS_TYPE_ARRAY, "{sv}");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &dict);
|
||||
for (properties) |name| if (wanted(filter, name)) {
|
||||
var pair = try Bus.open(&dict, c.DBUS_TYPE_DICT_ENTRY, null);
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&dict, &pair);
|
||||
try Bus.string(&pair, name.ptr);
|
||||
_ = try property(e, index, name, &pair);
|
||||
try Bus.close(&dict, &pair);
|
||||
};
|
||||
try Bus.close(out, &dict);
|
||||
}
|
||||
fn layout(e: *Export, index: usize, depth: i32, filter: Bus.Iter, out: *Bus.Iter) Bus.Error!void {
|
||||
var tuple = try Bus.open(out, c.DBUS_TYPE_STRUCT, null);
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &tuple);
|
||||
try Bus.basic(&tuple, c.DBUS_TYPE_INT32, @as(i32, @intCast(index)));
|
||||
try props(e, index, filter, &tuple);
|
||||
var children = try Bus.open(&tuple, c.DBUS_TYPE_ARRAY, "v");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&tuple, &children);
|
||||
if (depth != 0) {
|
||||
var cursor = e.nodes.items[index].first;
|
||||
while (cursor != 0) : (cursor = e.nodes.items[cursor].next) {
|
||||
var v = try Bus.open(&children, c.DBUS_TYPE_VARIANT, "(ia{sv}av)");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&children, &v);
|
||||
try layout(e, cursor, if (depth < 0) -1 else depth - 1, filter, &v);
|
||||
try Bus.close(&children, &v);
|
||||
}
|
||||
}
|
||||
try Bus.close(&tuple, &children);
|
||||
try Bus.close(out, &tuple);
|
||||
}
|
||||
fn groupItem(e: *Export, index: usize, filter: ?Bus.Iter, out: *Bus.Iter) !void {
|
||||
var tuple = try Bus.open(out, c.DBUS_TYPE_STRUCT, null);
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &tuple);
|
||||
try Bus.basic(&tuple, c.DBUS_TYPE_INT32, @as(i32, @intCast(index)));
|
||||
try props(e, index, filter, &tuple);
|
||||
try Bus.close(out, &tuple);
|
||||
}
|
||||
pub fn updateFlags() u32 {
|
||||
var cursor = b().windows;
|
||||
while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| {
|
||||
changed(e) catch return abi.OOM;
|
||||
};
|
||||
return abi.OK;
|
||||
}
|
||||
fn changed(e: *Export) !void {
|
||||
var dirty = false;
|
||||
for (e.nodes.items[1..]) |node| if (node.flags != node.item.?.flags) {
|
||||
dirty = true;
|
||||
break;
|
||||
};
|
||||
if (!dirty) return;
|
||||
if (b().portal.ready and e.proxy != null) {
|
||||
const message = c.z_dbus_message_new_signal.?(e.path.ptr, iface, "ItemsPropertiesUpdated") orelse return error.OutOfMemory;
|
||||
defer c.z_dbus_message_unref.?(message);
|
||||
var out = Bus.append(message);
|
||||
var updates = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "(ia{sv})");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &updates);
|
||||
// Include descendants: a disabled ancestor also disables their activation.
|
||||
for (e.nodes.items[1..], 1..) |_, i| try groupItem(e, i, null, &updates);
|
||||
try Bus.close(&out, &updates);
|
||||
var removed = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "(ias)");
|
||||
try Bus.close(&out, &removed);
|
||||
_ = try Portal.send(message);
|
||||
}
|
||||
for (e.nodes.items[1..]) |*node| node.flags = node.item.?.flags;
|
||||
}
|
||||
fn fail(message: *Bus.Message, name: [*:0]const u8) !void {
|
||||
if (c.z_dbus_message_get_no_reply.?(message) != 0) return;
|
||||
const reply = c.z_dbus_message_new_error.?(message, name, "Menu request could not be handled") orelse return error.OutOfMemory;
|
||||
defer c.z_dbus_message_unref.?(reply);
|
||||
_ = try Portal.send(reply);
|
||||
}
|
||||
const invalid = "org.freedesktop.DBus.Error.InvalidArgs";
|
||||
const missing = "com.canonical.dbusmenu.Error.InvalidMenuItem";
|
||||
fn callIs(message: *Bus.Message, interface: []const u8, member: []const u8) bool {
|
||||
const a = c.z_dbus_message_get_interface.?(message);
|
||||
const z = c.z_dbus_message_get_member.?(message);
|
||||
return a != null and z != null and same(std.mem.span(a), interface) and same(std.mem.span(z), member);
|
||||
}
|
||||
fn signature(message: *Bus.Message, sig: [*:0]const u8) bool {
|
||||
return c.z_dbus_message_has_signature.?(message, sig) != 0;
|
||||
}
|
||||
fn globalProperty(name: []const u8, out: *Bus.Iter) !bool {
|
||||
if (same(name, "Version")) try variant(out, "u", @as(u32, 3)) else if (same(name, "TextDirection")) try variant(out, "s", @as([*:0]const u8, "ltr")) else if (same(name, "Status")) try variant(out, "s", @as([*:0]const u8, "normal")) else if (same(name, "IconThemePath")) {
|
||||
var v = try Bus.open(out, c.DBUS_TYPE_VARIANT, "as");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(out, &v);
|
||||
var paths = try Bus.open(&v, c.DBUS_TYPE_ARRAY, "s");
|
||||
try Bus.close(&v, &paths);
|
||||
try Bus.close(out, &v);
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
pub fn process(message: *Bus.Message) !bool {
|
||||
if (c.z_dbus_message_get_type.?(message) != c.DBUS_MESSAGE_TYPE_METHOD_CALL) return false;
|
||||
const path = c.z_dbus_message_get_path.?(message);
|
||||
if (path == null or !std.mem.startsWith(u8, std.mem.span(path), prefix)) return false;
|
||||
var found: ?*Export = null;
|
||||
var cursor = b().windows;
|
||||
while (cursor) |w| : (cursor = w.next) if (w.menu_export) |e| {
|
||||
if (same(e.path, std.mem.span(path))) {
|
||||
found = e;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if (found) |e| try dispatch(e, message) else try fail(message, "org.freedesktop.DBus.Error.UnknownObject");
|
||||
return true;
|
||||
}
|
||||
fn dispatch(e: *Export, message: *Bus.Message) !void {
|
||||
const reply = c.z_dbus_message_new_method_return.?(message) orelse return error.OutOfMemory;
|
||||
defer c.z_dbus_message_unref.?(reply);
|
||||
var out = Bus.append(reply);
|
||||
var args = Bus.parse(message);
|
||||
if (callIs(message, "org.freedesktop.DBus.Introspectable", "Introspect")) {
|
||||
if (!signature(message, "")) return fail(message, invalid);
|
||||
try Bus.string(&out, @embedFile("dbusmenu.xml"));
|
||||
} else if (callIs(message, "org.freedesktop.DBus.Peer", "Ping")) {
|
||||
if (!signature(message, "")) return fail(message, invalid);
|
||||
} else if (callIs(message, "org.freedesktop.DBus.Properties", "Get") or callIs(message, "org.freedesktop.DBus.Properties", "GetAll")) {
|
||||
const single = callIs(message, "org.freedesktop.DBus.Properties", "Get");
|
||||
if (!signature(message, if (single) "ss" else "s")) return fail(message, invalid);
|
||||
if (!same(Bus.str(&args).?, iface)) return fail(message, "org.freedesktop.DBus.Error.UnknownInterface");
|
||||
if (single) {
|
||||
_ = Bus.next(&args);
|
||||
if (!try globalProperty(Bus.str(&args).?, &out)) return fail(message, "org.freedesktop.DBus.Error.UnknownProperty");
|
||||
} else {
|
||||
var dict = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "{sv}");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &dict);
|
||||
for ([_][:0]const u8{ "Version", "TextDirection", "Status", "IconThemePath" }) |name| {
|
||||
var pair = try Bus.open(&dict, c.DBUS_TYPE_DICT_ENTRY, null);
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&dict, &pair);
|
||||
try Bus.string(&pair, name.ptr);
|
||||
_ = try globalProperty(name, &pair);
|
||||
try Bus.close(&dict, &pair);
|
||||
}
|
||||
try Bus.close(&out, &dict);
|
||||
}
|
||||
} else if (callIs(message, iface, "GetLayout")) {
|
||||
if (!signature(message, "iias")) return fail(message, invalid);
|
||||
const index = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
|
||||
_ = Bus.next(&args);
|
||||
const depth = Bus.int(&args).?;
|
||||
if (depth < -1) return fail(message, invalid);
|
||||
_ = Bus.next(&args);
|
||||
try Bus.basic(&out, c.DBUS_TYPE_UINT32, @as(u32, 1));
|
||||
try layout(e, index, depth, Bus.child(&args), &out);
|
||||
} else if (callIs(message, iface, "GetGroupProperties")) {
|
||||
if (!signature(message, "aias")) return fail(message, invalid);
|
||||
var ids = Bus.child(&args);
|
||||
_ = Bus.next(&args);
|
||||
const filter = Bus.child(&args);
|
||||
var items = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "(ia{sv})");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &items);
|
||||
if (Bus.kind(&ids) == c.DBUS_TYPE_INVALID) {
|
||||
for (e.nodes.items, 0..) |_, i| try groupItem(e, i, filter, &items);
|
||||
} else while (Bus.int(&ids)) |value| {
|
||||
if (nodeId(e, value)) |i| try groupItem(e, i, filter, &items);
|
||||
if (!Bus.next(&ids)) break;
|
||||
}
|
||||
try Bus.close(&out, &items);
|
||||
} else if (callIs(message, iface, "GetProperty")) {
|
||||
if (!signature(message, "is")) return fail(message, invalid);
|
||||
const index = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
|
||||
_ = Bus.next(&args);
|
||||
if (!try property(e, index, Bus.str(&args).?, &out)) return fail(message, "org.freedesktop.DBus.Error.UnknownProperty");
|
||||
} else if (callIs(message, iface, "Event")) {
|
||||
if (!signature(message, "isvu")) return fail(message, invalid);
|
||||
const index = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
|
||||
_ = Bus.next(&args);
|
||||
if (same(Bus.str(&args).?, "clicked")) activate(e, index, Input.id());
|
||||
} else if (callIs(message, iface, "AboutToShow")) {
|
||||
if (!signature(message, "i")) return fail(message, invalid);
|
||||
_ = nodeId(e, Bus.int(&args).?) orelse return fail(message, missing);
|
||||
try Bus.basic(&out, c.DBUS_TYPE_BOOLEAN, @as(u32, 0));
|
||||
} else if (callIs(message, iface, "EventGroup") or callIs(message, iface, "AboutToShowGroup")) {
|
||||
const events = callIs(message, iface, "EventGroup");
|
||||
if (!signature(message, if (events) "a(isvu)" else "ai")) return fail(message, invalid);
|
||||
var ids = Bus.child(&args);
|
||||
if (!events) {
|
||||
var empty = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "i");
|
||||
try Bus.close(&out, &empty);
|
||||
}
|
||||
var errors = try Bus.open(&out, c.DBUS_TYPE_ARRAY, "i");
|
||||
errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&out, &errors);
|
||||
var valid_count: usize = 0;
|
||||
var error_count: usize = 0;
|
||||
while (Bus.kind(&ids) != c.DBUS_TYPE_INVALID) {
|
||||
var value = if (events) Bus.child(&ids) else ids;
|
||||
const number = Bus.int(&value).?;
|
||||
if (nodeId(e, number)) |index| {
|
||||
valid_count += 1;
|
||||
if (events) {
|
||||
_ = Bus.next(&value);
|
||||
if (same(Bus.str(&value).?, "clicked")) activate(e, index, Input.id());
|
||||
}
|
||||
} else {
|
||||
error_count += 1;
|
||||
try Bus.basic(&errors, c.DBUS_TYPE_INT32, number);
|
||||
}
|
||||
if (!Bus.next(&ids)) break;
|
||||
}
|
||||
try Bus.close(&out, &errors);
|
||||
if (valid_count == 0 and error_count != 0) return fail(message, missing);
|
||||
} else return fail(message, "org.freedesktop.DBus.Error.UnknownMethod");
|
||||
if (c.z_dbus_message_get_no_reply.?(message) == 0) _ = try Portal.send(reply);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ const std = @import("std");
|
|||
const c = @import("linux_c");
|
||||
const n = @import("native");
|
||||
const native = @import("native.zig");
|
||||
const KdeAppMenu = @import("KdeAppMenu.zig");
|
||||
const Bus = @import("Bus.zig");
|
||||
const Files = @import("Files.zig");
|
||||
const Input = @import("Input.zig");
|
||||
|
|
@ -49,7 +50,7 @@ fn b() *native.Backend {
|
|||
fn s() *State {
|
||||
return &b().portal;
|
||||
}
|
||||
fn send(message: *Bus.Message) !u32 {
|
||||
pub fn send(message: *Bus.Message) !u32 {
|
||||
var serial: u32 = 0;
|
||||
if (c.z_dbus_connection_send.?(s().connection, message, &serial) == 0) return error.OutOfMemory;
|
||||
return serial;
|
||||
|
|
@ -151,6 +152,7 @@ fn setOwner(message: *Bus.Message) !void {
|
|||
s().owner = try b().app.gpa.dupeZ(u8, std.mem.span(sender));
|
||||
}
|
||||
fn process(message: *Bus.Message) !void {
|
||||
if (try KdeAppMenu.process(message)) return;
|
||||
const serial = c.z_dbus_message_get_reply_serial.?(message);
|
||||
const failed = c.z_dbus_message_get_type.?(message) == c.DBUS_MESSAGE_TYPE_ERROR;
|
||||
var args = Bus.parse(message);
|
||||
|
|
@ -172,6 +174,7 @@ fn process(message: *Bus.Message) !void {
|
|||
match.* = 0;
|
||||
if (std.mem.allEqual(u32, &s().matches, 0)) {
|
||||
s().ready = true;
|
||||
KdeAppMenu.ready();
|
||||
try discover();
|
||||
}
|
||||
return;
|
||||
|
|
@ -513,6 +516,7 @@ fn destroy(p: *Pending) void {
|
|||
b().app.gpa.destroy(p);
|
||||
}
|
||||
fn disconnect() void {
|
||||
KdeAppMenu.disconnected();
|
||||
while (s().pending) |p| {
|
||||
complete(p, abi.UNAVAILABLE, null);
|
||||
destroy(p);
|
||||
|
|
|
|||
|
|
@ -6,9 +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, and inotify file watches are also implemented.
|
||||
application icons, inotify file watches, and Plasma app/window menu export are
|
||||
also implemented.
|
||||
|
||||
Native menus, a cursor-theme fallback, client-side window decorations, and X11
|
||||
Native context menus, 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.
|
||||
|
|
@ -150,6 +151,29 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified
|
|||
- Icons use xdg-toplevel-icon with square, premultiplied ARGB shared-memory
|
||||
buffers; rectangular public images receive transparent padding. App IDs and
|
||||
installed desktop entries still govern normal desktop application identity.
|
||||
- Plasma app/window menus use `com.canonical.dbusmenu` on the session bus and
|
||||
KDE's `org_kde_kwin_appmenu_manager` surface association. `APP_MENU` appears
|
||||
after bus discovery when the compositor offers that protocol; it does not
|
||||
promise a visible panel widget. Configure Plasma's Global Menu widget or an
|
||||
application-menu titlebar button to display the export. No Qt or libdbusmenu
|
||||
dependency is required. Other desktops can use this adapter if they implement
|
||||
the same protocols; otherwise the menu capability remains false.
|
||||
- An app menu supplies each window's default. A window menu overrides it;
|
||||
clearing the override restores the default. New windows inherit the app menu.
|
||||
Attachments retain the menu tree after callers release their references.
|
||||
Each replacement has a new object path; stale calls cannot activate a new
|
||||
menu. Enabled/checked changes are signalled to consumers, and disabled parents
|
||||
block their descendants. Labels are literal UTF-8, with mnemonic markers
|
||||
escaped. The current API has no separate checkable/radio or RTL setting.
|
||||
- Menu accelerators use logical keys and Shift/Control/Alt/Super, ignoring lock
|
||||
flags and ASCII letter case. They dispatch menu/edit/quit events before raw
|
||||
keys or text; matching key releases and repeats are consumed. They remain
|
||||
active without a menu consumer. COPY through REDO roles emit edit commands for
|
||||
the associated window; QUIT requests application quit. Ordinary commands keep
|
||||
their full 64-bit IDs. Menu export is bounded to 65,535 item occurrences per
|
||||
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.
|
||||
- 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
|
||||
|
|
@ -169,6 +193,9 @@ python3 src/linux/tests/disconnect.py zig-out/bin/linux-native-tests
|
|||
# In the optional nix-shell, or with dbus-next/dbus-daemon installed:
|
||||
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
|
||||
# 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
|
||||
```
|
||||
|
|
@ -184,7 +211,11 @@ The service fixtures own their compositor socket and D-Bus daemon. They exercise
|
|||
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. They never open a real chooser
|
||||
cancellation with a late alternate handle, parent destruction and portal restart.
|
||||
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
|
||||
association and D-Bus round trips, not visual panel/titlebar presentation. They never open a real chooser
|
||||
or browser or inject input into the user's desktop. The live service mode checks
|
||||
portal discovery, icon submission and atomic-save watch behavior without touching
|
||||
the user's clipboard. These fixtures supplement live desktop testing; they do
|
||||
|
|
|
|||
|
|
@ -67,8 +67,10 @@ extern void (*z_dbus_message_iter_abandon_container_if_open)(
|
|||
);
|
||||
extern dbus_bool_t (*z_dbus_threads_init_default)(void);
|
||||
extern DBusMessage *(*z_dbus_message_new_method_return)(DBusMessage *message);
|
||||
extern DBusMessage *(*z_dbus_message_new_error)(DBusMessage *message, const char *name, const char *text);
|
||||
extern DBusMessage *(*z_dbus_message_new_signal)(const char *path, const char *iface, const char *name);
|
||||
extern DBusMessage
|
||||
*(*z_dbus_message_new_error)(DBusMessage *message, const char *name, const char *text);
|
||||
extern DBusMessage
|
||||
*(*z_dbus_message_new_signal)(const char *path, const char *iface, const char *name);
|
||||
extern const char *(*z_dbus_message_get_interface)(DBusMessage *message);
|
||||
extern const char *(*z_dbus_message_get_member)(DBusMessage *message);
|
||||
extern dbus_bool_t (*z_dbus_message_get_no_reply)(DBusMessage *message);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
<!-- UHDK D-BusMenu exporter introspection. -->
|
||||
<node>
|
||||
<interface name="com.canonical.dbusmenu">
|
||||
<property name="Version" type="u" access="read" />
|
||||
<property name="TextDirection" type="s" access="read" />
|
||||
<property name="Status" type="s" access="read" />
|
||||
<property name="IconThemePath" type="as" access="read" />
|
||||
<method name="GetLayout">
|
||||
<arg type="i" direction="in" />
|
||||
<arg type="i" direction="in" />
|
||||
<arg type="as" direction="in" />
|
||||
<arg type="u" direction="out" />
|
||||
<arg type="(ia{sv}av)" direction="out" />
|
||||
</method>
|
||||
<method name="GetGroupProperties">
|
||||
<arg type="ai" direction="in" />
|
||||
<arg type="as" direction="in" />
|
||||
<arg type="a(ia{sv})" direction="out" />
|
||||
</method>
|
||||
<method name="GetProperty">
|
||||
<arg type="i" direction="in" />
|
||||
<arg type="s" direction="in" />
|
||||
<arg type="v" direction="out" />
|
||||
</method>
|
||||
<method name="Event">
|
||||
<arg type="i" direction="in" />
|
||||
<arg type="s" direction="in" />
|
||||
<arg type="v" direction="in" />
|
||||
<arg type="u" direction="in" />
|
||||
</method>
|
||||
<method name="EventGroup">
|
||||
<arg type="a(isvu)" direction="in" />
|
||||
<arg type="ai" direction="out" />
|
||||
</method>
|
||||
<method name="AboutToShow">
|
||||
<arg type="i" direction="in" />
|
||||
<arg type="b" direction="out" />
|
||||
</method>
|
||||
<method name="AboutToShowGroup">
|
||||
<arg type="ai" direction="in" />
|
||||
<arg type="ai" direction="out" />
|
||||
<arg type="ai" direction="out" />
|
||||
</method>
|
||||
<signal name="ItemsPropertiesUpdated">
|
||||
<arg type="a(ia{sv})" />
|
||||
<arg type="a(ias)" />
|
||||
</signal>
|
||||
<signal name="LayoutUpdated">
|
||||
<arg type="u" />
|
||||
<arg type="i" />
|
||||
</signal>
|
||||
</interface>
|
||||
<interface name="org.freedesktop.DBus.Introspectable">
|
||||
<method name="Introspect">
|
||||
<arg type="s" direction="out" />
|
||||
</method>
|
||||
</interface>
|
||||
<interface name="org.freedesktop.DBus.Peer">
|
||||
<method name="Ping" />
|
||||
</interface>
|
||||
<interface name="org.freedesktop.DBus.Properties">
|
||||
<method name="Get">
|
||||
<arg type="s" direction="in" />
|
||||
<arg type="s" direction="in" />
|
||||
<arg type="v" direction="out" />
|
||||
</method>
|
||||
<method name="GetAll">
|
||||
<arg type="s" direction="in" />
|
||||
<arg type="a{sv}" direction="out" />
|
||||
</method>
|
||||
</interface>
|
||||
</node>
|
||||
|
|
@ -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 KdeAppMenu = @import("KdeAppMenu.zig");
|
||||
const Text = @import("Text.zig");
|
||||
|
||||
pub const Window = struct {
|
||||
|
|
@ -45,6 +46,8 @@ pub const Window = struct {
|
|||
flags: u32,
|
||||
cursor: u32 = n.UHDK_CURSOR_ARROW,
|
||||
text: Text.State = .{},
|
||||
menu_override: ?*@import("../Resource.zig") = null,
|
||||
menu_export: ?*KdeAppMenu.Export = null,
|
||||
drop_enabled: bool = false,
|
||||
metrics: n.uhdk_window_metrics = std.mem.zeroes(n.uhdk_window_metrics),
|
||||
};
|
||||
|
|
@ -66,6 +69,7 @@ pub const Backend = struct {
|
|||
watches: ?*Watch.Watch = null,
|
||||
portal: Portal.State = .{},
|
||||
desktop: Desktop.State = .{},
|
||||
menus: KdeAppMenu.State = .{},
|
||||
capabilities: std.atomic.Value(u32) = .init(0),
|
||||
running: bool = true,
|
||||
failed: std.atomic.Value(bool) = .init(false),
|
||||
|
|
@ -179,6 +183,7 @@ export fn z_native_finish() void {
|
|||
std.debug.assert(b.windows == null);
|
||||
while (b.watches) |w| Watch.destroy(w);
|
||||
Desktop.deinit();
|
||||
KdeAppMenu.deinit();
|
||||
Portal.deinit();
|
||||
Data.deinit();
|
||||
Input.deinit();
|
||||
|
|
@ -309,6 +314,7 @@ fn registryGlobal(data: ?*anyopaque, registry: ?*c.struct_wl_registry, name: u32
|
|||
Input.global(registry, name, str, version);
|
||||
Data.global(registry, name, str, version);
|
||||
Portal.global(registry, name, str, version);
|
||||
KdeAppMenu.global(registry, name, str, version);
|
||||
Desktop.global(registry, name, str, version);
|
||||
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)));
|
||||
|
|
@ -325,6 +331,7 @@ fn registryGlobal(data: ?*anyopaque, registry: ?*c.struct_wl_registry, name: u32
|
|||
}
|
||||
fn registryRemove(_: ?*anyopaque, _: ?*c.struct_wl_registry, name: u32) callconv(.c) void {
|
||||
Input.remove(name);
|
||||
KdeAppMenu.remove(name);
|
||||
}
|
||||
const registry_listener: c.struct_wl_registry_listener = .{ .global = registryGlobal, .global_remove = registryRemove };
|
||||
fn ping(_: ?*anyopaque, shell: ?*c.struct_xdg_wm_base, serial: u32) callconv(.c) void {
|
||||
|
|
@ -481,6 +488,7 @@ fn setupWindow(b: *Backend, w: *Window, desc: n.uhdk_window_desc) u32 {
|
|||
c.xdg_toplevel_set_title(w.top, title);
|
||||
c.xdg_toplevel_set_app_id(w.top, app_id);
|
||||
Desktop.applyIcon(w);
|
||||
KdeAppMenu.inherit(w) catch return abi.OOM;
|
||||
if (desc.parent) |parent| {
|
||||
const p: *Window = @ptrCast(@alignCast(n.z_object_native(parent) orelse return abi.UNAVAILABLE));
|
||||
c.xdg_toplevel_set_parent(w.top, p.top);
|
||||
|
|
@ -515,6 +523,7 @@ fn setLimits(w: *Window) void {
|
|||
fn destroyWindow(w: *Window) void {
|
||||
const b = instance.?;
|
||||
std.debug.assert(w.egl == null);
|
||||
KdeAppMenu.forget(w);
|
||||
Desktop.forget(w);
|
||||
Data.forget(w);
|
||||
Input.forget(w);
|
||||
|
|
@ -547,6 +556,8 @@ 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_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.*);
|
||||
|
|
@ -555,6 +566,7 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64,
|
|||
const ptr = if (cmd.target != null) n.z_object_native(cmd.target) else null;
|
||||
const w: *Window = if (ptr) |p| @ptrCast(@alignCast(p)) else return n.UHDK_STATUS_UNSUPPORTED;
|
||||
switch (cmd.op) {
|
||||
n.Z_WINDOW_MENU => return KdeAppMenu.attach(w, cmd.other),
|
||||
n.Z_TITLE => {
|
||||
const title = b.app.gpa.dupeZ(u8, abi.textBytes(cmd.text)) catch return abi.OOM;
|
||||
defer b.app.gpa.free(title);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Plasma surface association and D-BusMenu on a private bus/compositor.
|
||||
|
||||
The consumer is replaced mid-test; menu exports must not depend on its lifetime.
|
||||
No input or menu actions are sent to the user's desktop.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from dbus_next import Message, MessageType, Variant
|
||||
from dbus_next.aio import MessageBus
|
||||
from wayland_fixture import Compositor, run
|
||||
|
||||
IFACE = 'com.canonical.dbusmenu'
|
||||
|
||||
|
||||
async def main(binary):
|
||||
config = tempfile.NamedTemporaryFile(mode='w', suffix='.conf')
|
||||
config.write('<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen><auth>EXTERNAL</auth><policy context="default"><allow send_destination="*"/><allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
|
||||
config.flush()
|
||||
daemon = subprocess.Popen(['dbus-daemon', '--config-file=' + config.name, '--nofork', '--print-address=1'], stdout=subprocess.PIPE, text=True)
|
||||
bus = None
|
||||
runner = None
|
||||
loop = asyncio.get_running_loop()
|
||||
notifications = asyncio.Queue()
|
||||
commands = queue.Queue()
|
||||
addresses = {}
|
||||
surfaces = {}
|
||||
signals = []
|
||||
|
||||
class MenuCompositor(Compositor):
|
||||
globals = Compositor.globals + [('org_kde_kwin_appmenu_manager', 2)]
|
||||
|
||||
def request(self, obj, iface, name, args):
|
||||
super().request(obj, iface, name, args)
|
||||
if iface == 'org_kde_kwin_appmenu_manager' and name == 'create':
|
||||
surfaces[args[0]] = args[1]
|
||||
elif iface == 'org_kde_kwin_appmenu' and name == 'set_address':
|
||||
loop.call_soon_threadsafe(notifications.put_nowait, ('address', surfaces[obj], tuple(args)))
|
||||
elif iface == 'xdg_toplevel' and name == 'set_title' and args[0].endswith('-ready'):
|
||||
loop.call_soon_threadsafe(notifications.put_nowait, ('phase', args[0]))
|
||||
|
||||
def timers(self):
|
||||
# Only inject explicitly requested keys into this client's private socket.
|
||||
while not commands.empty():
|
||||
surface, code, press, enter, future = commands.get_nowait()
|
||||
keyboard = self.ids['wl_keyboard']
|
||||
if enter:
|
||||
self.event(keyboard, 'enter', 200, surface, b'')
|
||||
self.event(keyboard, 'modifiers', 201, 4, 0, 0, 0)
|
||||
self.event(keyboard, 'key', 202, 1, code, int(press))
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
|
||||
async def phase(name):
|
||||
while True:
|
||||
item = await asyncio.wait_for(notifications.get(), 5)
|
||||
if item[0] == 'address': addresses[item[1]] = item[2]
|
||||
else:
|
||||
assert item == ('phase', name), item
|
||||
return
|
||||
|
||||
async def key(surface, code, press, enter=False):
|
||||
future = loop.create_future()
|
||||
commands.put((surface, code, press, enter, future))
|
||||
await future
|
||||
await asyncio.sleep(.06)
|
||||
|
||||
async def call(address, member, signature='', body=None, interface=IFACE, error=None):
|
||||
reply = await asyncio.wait_for(bus.call(Message(destination=address[0], path=address[1], interface=interface, member=member, signature=signature, body=body or [])), 3)
|
||||
if error:
|
||||
assert reply.message_type == MessageType.ERROR and reply.error_name.endswith(error), reply
|
||||
else:
|
||||
assert reply.message_type == MessageType.METHOD_RETURN, (reply.error_name, reply.body)
|
||||
return reply.body
|
||||
|
||||
async def layout(address, depth=-1, props=None):
|
||||
return (await call(address, 'GetLayout', 'iias', [0, depth, props or []]))[1]
|
||||
|
||||
async def click(address, item, kind='clicked'):
|
||||
await call(address, 'Event', 'isvu', [item, kind, Variant('i', 0), 0])
|
||||
await asyncio.sleep(.03)
|
||||
|
||||
def flatten(node):
|
||||
return [node] + [item for child in node[2] for item in flatten(child.value)]
|
||||
|
||||
async def connect(address):
|
||||
consumer = await MessageBus(bus_address=address).connect()
|
||||
consumer.add_message_handler(lambda m: signals.append(m) if m.message_type == MessageType.SIGNAL and m.interface == IFACE else None)
|
||||
reply = await consumer.call(Message(destination='org.freedesktop.DBus', path='/org/freedesktop/DBus', interface='org.freedesktop.DBus', member='AddMatch', signature='s', body=["type='signal',interface='com.canonical.dbusmenu'"]))
|
||||
assert reply.message_type == MessageType.METHOD_RETURN
|
||||
return consumer
|
||||
|
||||
try:
|
||||
address = daemon.stdout.readline().strip()
|
||||
bus = await connect(address)
|
||||
no_bus_env = dict(os.environ)
|
||||
no_bus_env.pop('DBUS_SESSION_BUS_ADDRESS', None)
|
||||
await asyncio.to_thread(run, binary, 'menu-noprotocol', no_bus_env)
|
||||
await asyncio.to_thread(run, binary, 'menu-nobus', no_bus_env, MenuCompositor)
|
||||
runner = asyncio.create_task(asyncio.to_thread(run, binary, 'menu', dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address), MenuCompositor))
|
||||
await phase('menus-ready')
|
||||
assert len(addresses) == 2
|
||||
a, b = list(addresses)
|
||||
first, override = addresses[a], addresses[b]
|
||||
tree = await layout(first)
|
||||
nodes = flatten(tree)
|
||||
by_label = {v[1]['label'].value: v[0] for v in nodes}
|
||||
assert by_label.keys() >= {'File', 'Open__file', 'Checked', 'Disabled', 'Copy', 'Quit', 'Next'}
|
||||
open_id, toggle, disabled, copy, quit_id, step = [by_label[k] for k in ['Open__file', 'Checked', 'Disabled', 'Copy', 'Quit', 'Next']]
|
||||
assert (await call(first, 'GetProperty', 'is', [open_id, 'shortcut']))[0].value == [['Control', 'O']]
|
||||
assert (await call(first, 'GetProperty', 'is', [toggle, 'toggle-state']))[0].value == 1
|
||||
assert len((await layout(first, 0))[2]) == 0
|
||||
shallow = await layout(first, 1, ['label'])
|
||||
assert all(set(v.value[1]) == {'label'} and not v.value[2] for v in shallow[2])
|
||||
groups = (await call(first, 'GetGroupProperties', 'aias', [[], ['label']]))[0]
|
||||
assert len(groups) == len(nodes)
|
||||
assert all(set(p) == {'label'} for _, p in groups)
|
||||
assert (await call(first, 'GetGroupProperties', 'aias', [[open_id, -1], ['enabled']]))[0] == [[open_id, {'enabled': Variant('b', True)}]]
|
||||
props = (await call(first, 'GetAll', 's', [IFACE], 'org.freedesktop.DBus.Properties'))[0]
|
||||
assert props['Version'].value == 3 and props['IconThemePath'].value == []
|
||||
xml = (await call(first, 'Introspect', interface='org.freedesktop.DBus.Introspectable'))[0]
|
||||
assert 'EventGroup' in xml
|
||||
await call(first, 'Ping', interface='org.freedesktop.DBus.Peer')
|
||||
await call(first, 'GetLayout', 'i', [0], error='InvalidArgs')
|
||||
await call(first, 'GetLayout', 'iias', [0, -2, []], error='InvalidArgs')
|
||||
await call(first, 'GetLayout', 'iias', [-1, -1, []], error='InvalidMenuItem')
|
||||
await call(first, 'GetProperty', 'is', [0, 'invented'], error='UnknownProperty')
|
||||
await call(first, 'MissingMethod', error='UnknownMethod')
|
||||
assert await call(first, 'AboutToShow', 'i', [0]) == [False]
|
||||
assert await call(first, 'AboutToShowGroup', 'ai', [[0, -1]]) == [[], [-1]]
|
||||
await call(first, 'AboutToShowGroup', 'ai', [[-1]], error='InvalidMenuItem')
|
||||
assert await call(first, 'EventGroup', 'a(isvu)', [[[0, 'opened', Variant('s', ''), 0], [-1, 'clicked', Variant('i', 0), 0]]]) == [[-1]]
|
||||
await call(first, 'EventGroup', 'a(isvu)', [[[-1, 'clicked', Variant('i', 0), 0]]], error='InvalidMenuItem')
|
||||
for i in [0, by_label['File'], disabled] + [v[0] for v in nodes if v[1]['type'].value == 'separator']:
|
||||
await click(first, i)
|
||||
await click(first, open_id, 'hovered')
|
||||
await click(first, open_id)
|
||||
await click(first, copy)
|
||||
await click(first, quit_id)
|
||||
await click(override, 1)
|
||||
await click(first, step)
|
||||
await phase('flags-ready')
|
||||
assert any(m.member == 'ItemsPropertiesUpdated' for m in signals)
|
||||
assert not (await call(first, 'GetProperty', 'is', [open_id, 'enabled']))[0].value
|
||||
assert (await call(first, 'GetProperty', 'is', [toggle, 'toggle-type']))[0].value == ''
|
||||
await click(first, open_id) # Disabled ancestor blocks activation.
|
||||
await key(a, 24, True, True)
|
||||
await key(a, 24, False) # Disabled shortcut must reach raw key handling.
|
||||
await click(first, step)
|
||||
await phase('restored-ready')
|
||||
inherited = addresses[b]
|
||||
assert inherited != override
|
||||
await call(override, 'GetLayout', 'iias', [0, -1, []], error='UnknownObject')
|
||||
# A newly started consumer can obtain complete state without registration.
|
||||
bus.disconnect()
|
||||
bus = await connect(address)
|
||||
assert (await layout(inherited))[1]['children-display'].value == 'submenu'
|
||||
await key(a, 24, True, True)
|
||||
await asyncio.sleep(.1) # Repeated accelerator downs remain consumed.
|
||||
await key(a, 24, False)
|
||||
await key(a, 46, True)
|
||||
await key(a, 46, False)
|
||||
await key(b, 24, True, True)
|
||||
await key(b, 24, False)
|
||||
await click(first, step)
|
||||
await phase('detached-ready')
|
||||
await call(first, 'GetLayout', 'iias', [0, -1, []], error='UnknownObject')
|
||||
await call(inherited, 'GetLayout', 'iias', [0, -1, []], error='UnknownObject')
|
||||
bus.disconnect()
|
||||
bus = None
|
||||
daemon.terminate()
|
||||
daemon.wait(timeout=5)
|
||||
await asyncio.sleep(.1)
|
||||
# Bus loss removes the capability/association, but local shortcuts survive.
|
||||
await key(b, 49, True, True) # Ctrl+N invokes the retained window override.
|
||||
await asyncio.wait_for(runner, 5)
|
||||
print('D-BusMenu layout/errors/state/consumer restart and KDE association: PASS')
|
||||
finally:
|
||||
if bus: bus.disconnect()
|
||||
daemon.terminate()
|
||||
daemon.wait(timeout=5)
|
||||
config.close()
|
||||
if runner:
|
||||
await runner
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main(str(Path(sys.argv[1]).resolve())))
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Open a supplied menu-enabled app on Plasma, query its export, then choose Quit.
|
||||
|
||||
The app must export a literal 'Quit' or 'Quit …' item. This checks real KWin association and
|
||||
session-bus transport; it does not assert panel or titlebar menu rendering.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from dbus_next import Message, MessageType, Variant
|
||||
from dbus_next.aio import MessageBus
|
||||
|
||||
|
||||
async def main(binary):
|
||||
process = await asyncio.create_subprocess_exec(binary, env=dict(os.environ, WAYLAND_DEBUG='client'), stderr=asyncio.subprocess.PIPE)
|
||||
bus = None
|
||||
try:
|
||||
async def address():
|
||||
while line := await process.stderr.readline():
|
||||
match = re.search(rb'set_address\("([^"]+)", "([^"]+)"\)', line)
|
||||
if match: return [part.decode() for part in match.groups()]
|
||||
raise AssertionError('Application exited without a KDE menu association')
|
||||
service, path = await asyncio.wait_for(address(), 10)
|
||||
# Keep draining Wayland diagnostics so a full pipe cannot stall the app.
|
||||
drain = asyncio.create_task(process.stderr.read())
|
||||
bus = await MessageBus().connect()
|
||||
reply = await asyncio.wait_for(bus.call(Message(destination=service, path=path, interface='com.canonical.dbusmenu', member='GetLayout', signature='iias', body=[0, -1, []])), 5)
|
||||
assert reply.message_type == MessageType.METHOD_RETURN, reply.body
|
||||
def flatten(node):
|
||||
return [node] + [item for child in node[2] for item in flatten(child.value)]
|
||||
nodes = flatten(reply.body[1])
|
||||
labels = [node[1]['label'].value for node in nodes]
|
||||
quit_id = next(node[0] for node in nodes if node[1]['label'].value == 'Quit' or node[1]['label'].value.startswith('Quit '))
|
||||
print('Live KWin association and menu layout:', labels)
|
||||
reply = await bus.call(Message(destination=service, path=path, interface='com.canonical.dbusmenu', member='Event', signature='isvu', body=[quit_id, 'clicked', Variant('i', 0), 0]))
|
||||
assert reply.message_type == MessageType.METHOD_RETURN
|
||||
assert await asyncio.wait_for(process.wait(), 5) == 0
|
||||
await drain
|
||||
print('Live Plasma menu export / Quit activation: PASS')
|
||||
finally:
|
||||
if bus: bus.disconnect()
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main(sys.argv[1]))
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/* Included by the service harness: all desktop traffic uses private fixtures. */
|
||||
#define OPEN_ID UINT64_C(0x1234567800000065)
|
||||
static void menu_phase(uhdk_app *app, uhdk_window *w, const char *phase) {
|
||||
uint64_t id;
|
||||
OK(uhdk_window_set_title_async(w, text(phase), &id));
|
||||
OK(wait_result(app, id).status);
|
||||
}
|
||||
static void menus(uhdk_app *app) {
|
||||
wait_capability(app, UHDK_CAP_APP_MENU);
|
||||
assert(!uhdk_app_has_capability(app, UHDK_CAP_CONTEXT_MENU));
|
||||
uhdk_menu_item children[] = {
|
||||
{.command_id = OPEN_ID,
|
||||
.label = {"Open_file", 9},
|
||||
.shortcut_key = 'o',
|
||||
.shortcut_modifiers = UHDK_MOD_CONTROL},
|
||||
{.command_id = 102, .label = {"Checked", 7}, .flags = UHDK_MENU_CHECKED},
|
||||
{.command_id = 103, .label = {"Disabled", 8}, .flags = UHDK_MENU_DISABLED},
|
||||
{.role = UHDK_MENU_ROLE_COPY,
|
||||
.label = {"Copy", 4},
|
||||
.shortcut_key = 'c',
|
||||
.shortcut_modifiers = UHDK_MOD_CONTROL},
|
||||
{.role = UHDK_MENU_ROLE_QUIT, .label = {"Quit", 4}},
|
||||
{.flags = UHDK_MENU_SEPARATOR},
|
||||
};
|
||||
uhdk_menu *child, *root;
|
||||
OK(uhdk_menu_create(app, children, sizeof(children) / sizeof(*children), &child));
|
||||
uhdk_menu_item roots[] = {
|
||||
{.command_id = 10, .label = {"File", 4}, .submenu = child},
|
||||
{.command_id = 900, .label = {"Next", 4}},
|
||||
};
|
||||
OK(uhdk_menu_create(app, roots, 2, &root));
|
||||
uhdk_menu_release(child);
|
||||
uint64_t id;
|
||||
OK(uhdk_app_set_menu_async(app, root, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
uhdk_window *a = window(app), *b = window(app);
|
||||
uhdk_menu_item overrides[] = {
|
||||
{.command_id = 200, .label = {"Window action", 13}},
|
||||
{.command_id = 900,
|
||||
.label = {"Next", 4},
|
||||
.shortcut_key = 'n',
|
||||
.shortcut_modifiers = UHDK_MOD_CONTROL},
|
||||
};
|
||||
uhdk_menu *override;
|
||||
OK(uhdk_menu_create(app, overrides, 2, &override));
|
||||
OK(uhdk_window_set_menu_async(b, override, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
uhdk_menu_release(override); // The attachment must keep its model alive.
|
||||
OK(uhdk_window_set_text_input_async(a, 1, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
OK(uhdk_menu_popup_async(root, a, (uhdk_point){0, 0}, &id));
|
||||
assert(wait_result(app, id).status == UHDK_STATUS_UNSUPPORTED);
|
||||
menu_phase(app, a, "menus-ready");
|
||||
unsigned stage = 0, opens_a = 0, opens_b = 0, copies = 0, quits = 0, overrides_seen = 0,
|
||||
raw_o = 0;
|
||||
uint64_t end = deadline(app);
|
||||
while (stage < 4 && uhdk_app_get_time_ns(app) < end) {
|
||||
uhdk_event_list events;
|
||||
OK(uhdk_app_poll_events(app, 50000000, &events));
|
||||
int next = 0;
|
||||
for (uint64_t i = 0; i < events.len; ++i) {
|
||||
const uhdk_event *e = &events.ptr[i];
|
||||
if (e->type == UHDK_EVENT_MENU_COMMAND) {
|
||||
uint64_t command = ((const uhdk_menu_event *)e->data)->command_id;
|
||||
if (command == OPEN_ID) {
|
||||
assert(stage == 0 || stage == 2);
|
||||
if (e->window == a)
|
||||
++opens_a;
|
||||
else {
|
||||
assert(e->window == b);
|
||||
++opens_b;
|
||||
}
|
||||
} else if (command == 200) {
|
||||
assert(e->window == b);
|
||||
++overrides_seen;
|
||||
} else {
|
||||
assert(command == 900);
|
||||
next = 1;
|
||||
}
|
||||
} else if (e->type == UHDK_EVENT_EDIT_COMMAND) {
|
||||
const uhdk_edit_event *edit = e->data;
|
||||
assert(edit->command == UHDK_EDIT_COPY && e->window == a && e->input_id != 0);
|
||||
++copies;
|
||||
} else if (e->type == UHDK_EVENT_QUIT_REQUESTED)
|
||||
++quits;
|
||||
else if (e->type == UHDK_EVENT_TEXT_COMMIT)
|
||||
assert(!"accelerator leaked text");
|
||||
else if (e->type == UHDK_EVENT_KEY) {
|
||||
const uhdk_key_event *key = e->data;
|
||||
if (key->logical_key == 'o' || key->logical_key == 'O') {
|
||||
assert(stage == 1);
|
||||
++raw_o;
|
||||
}
|
||||
assert(key->logical_key != 'c' && key->logical_key != 'C');
|
||||
}
|
||||
}
|
||||
if (!next)
|
||||
continue;
|
||||
++stage;
|
||||
if (stage == 1) {
|
||||
assert(
|
||||
opens_a == 1 && opens_b == 0 && copies == 1 && quits == 1 && overrides_seen == 1
|
||||
);
|
||||
OK(uhdk_menu_set_item_flags_async(root, 10, UHDK_MENU_DISABLED, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
OK(uhdk_menu_set_item_flags_async(root, 102, 0, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
menu_phase(app, a, "flags-ready");
|
||||
} else if (stage == 2) {
|
||||
assert(raw_o >= 2);
|
||||
OK(uhdk_menu_set_item_flags_async(root, 10, 0, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
OK(uhdk_window_set_menu_async(b, NULL, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
menu_phase(app, a, "restored-ready");
|
||||
} else if (stage == 3) {
|
||||
assert(opens_a == 2 && opens_b == 1 && copies == 2);
|
||||
// Override survives clearing the app default, then the first window dies.
|
||||
OK(uhdk_menu_create(app, overrides, 2, &override));
|
||||
OK(uhdk_window_set_menu_async(b, override, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
uhdk_menu_release(override);
|
||||
OK(uhdk_app_set_menu_async(app, NULL, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
uhdk_menu_release(root);
|
||||
uhdk_window_destroy(a);
|
||||
menu_phase(app, b, "detached-ready");
|
||||
}
|
||||
}
|
||||
assert(stage == 4);
|
||||
assert(!uhdk_app_has_capability(app, UHDK_CAP_APP_MENU));
|
||||
uhdk_window_destroy(b);
|
||||
puts("Plasma menu routing/state/lifetime/shortcut fixture: PASS");
|
||||
}
|
||||
#undef OPEN_ID
|
||||
|
||||
static void menus_unavailable(uhdk_app *app, int no_bus) {
|
||||
uhdk_window *w = window(app);
|
||||
uhdk_menu_item item = {.command_id = 1, .label = {"Unavailable", 11}};
|
||||
uhdk_menu *menu;
|
||||
OK(uhdk_menu_create(app, &item, 1, &menu));
|
||||
uint64_t id;
|
||||
OK(uhdk_app_set_menu_async(app, menu, &id));
|
||||
assert(
|
||||
wait_result(app, id).status == (no_bus ? UHDK_STATUS_UNAVAILABLE : UHDK_STATUS_UNSUPPORTED)
|
||||
);
|
||||
OK(uhdk_window_set_menu_async(w, menu, &id));
|
||||
assert(
|
||||
wait_result(app, id).status == (no_bus ? UHDK_STATUS_UNAVAILABLE : UHDK_STATUS_UNSUPPORTED)
|
||||
);
|
||||
assert(!uhdk_app_has_capability(app, UHDK_CAP_APP_MENU));
|
||||
OK(uhdk_menu_set_item_flags_async(menu, 1, UHDK_MENU_CHECKED, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
OK(uhdk_app_set_menu_async(app, NULL, &id));
|
||||
OK(wait_result(app, id).status);
|
||||
uhdk_menu_release(menu);
|
||||
uhdk_window_destroy(w);
|
||||
puts("Unavailable menu protocol/session bus: PASS");
|
||||
}
|
||||
|
|
@ -79,7 +79,6 @@ static uhdk_window *create_window(uhdk_app *app) {
|
|||
static void test_main(uhdk_app *app, void *user) {
|
||||
++entered;
|
||||
assert(uhdk_app_get_backend(app) == UHDK_BACKEND_WAYLAND);
|
||||
assert(!uhdk_app_has_capability(app, UHDK_CAP_APP_MENU));
|
||||
assert(!uhdk_app_has_capability(app, UHDK_CAP_FILE_DIALOGS));
|
||||
uhdk_event_list ready;
|
||||
OK(uhdk_app_poll_events(app, 0, &ready));
|
||||
|
|
|
|||
|
|
@ -363,7 +363,17 @@ static void input(uhdk_app *app) {
|
|||
uhdk_window_destroy(w);
|
||||
puts("input/IME/clipboard/drop/activation: PASS");
|
||||
}
|
||||
#include "menus.c"
|
||||
|
||||
static void run(uhdk_app *app, void *mode) {
|
||||
if (strcmp(mode, "menu-noprotocol") == 0 || strcmp(mode, "menu-nobus") == 0) {
|
||||
menus_unavailable(app, strcmp(mode, "menu-nobus") == 0);
|
||||
return;
|
||||
}
|
||||
if (strcmp(mode, "menu") == 0) {
|
||||
menus(app);
|
||||
return;
|
||||
}
|
||||
if (strcmp(mode, "input") == 0)
|
||||
input(app);
|
||||
else if (strcmp(mode, "portal") == 0)
|
||||
|
|
|
|||
|
|
@ -271,13 +271,13 @@ class Compositor:
|
|||
self.event(text, 'done', serial)
|
||||
|
||||
|
||||
def run(binary, mode, env=None):
|
||||
def run(binary, mode, env=None, compositor_type=Compositor):
|
||||
client, server = socket.socketpair()
|
||||
environment = dict(os.environ if env is None else env, WAYLAND_SOCKET=str(client.fileno()))
|
||||
if mode == 'input': environment.pop('DBUS_SESSION_BUS_ADDRESS', None)
|
||||
process = subprocess.Popen([str(Path(binary).resolve()), mode], env=environment, pass_fds=(client.fileno(),))
|
||||
client.close()
|
||||
compositor = Compositor(server, mode == 'input')
|
||||
compositor = compositor_type(server, mode == 'input')
|
||||
end = time.monotonic() + 20
|
||||
try:
|
||||
while process.poll() is None and time.monotonic() < end:
|
||||
|
|
|
|||
Loading…
Reference in New Issue