diff --git a/src/linux/Data.zig b/src/linux/Data.zig index 486831b..a5f941d 100644 --- a/src/linux/Data.zig +++ b/src/linux/Data.zig @@ -323,10 +323,6 @@ pub fn forget(w: *native.Window) void { } } } -pub fn focusLost() void { - if (s().selection) |offer_| unref(offer_); - s().selection = null; -} pub fn releaseSeat() void { while (s().transfers) |t| finish(t, abi.CANCELLED); s().selection = null; diff --git a/src/linux/Input.zig b/src/linux/Input.zig index 80f12f5..0132457 100644 --- a/src/linux/Input.zig +++ b/src/linux/Input.zig @@ -9,6 +9,15 @@ const Keyboard = @import("Keyboard.zig"); const Data = @import("Data.zig"); const Text = @import("Text.zig"); +const Key = struct { + code: u32, + down: bool, + repeated: bool, + sym: u32, + modifiers: u32, + text: [256]u8 = undefined, + len: usize = 0, +}; pub const State = struct { seat: ?*c.struct_wl_seat = null, seat_name: u32 = 0, @@ -29,6 +38,8 @@ pub const State = struct { compose: ?*c.struct_xkb_compose_state = null, modifiers: u32 = 0, active: bool = false, + pending_key: ?Key = null, + key_sync: ?*c.struct_wl_callback = null, serial: u32 = 0, serial_time: u64 = 0, pointer_serial: u32 = 0, @@ -137,6 +148,7 @@ fn seatCapabilities(_: ?*anyopaque, seat: ?*c.struct_wl_seat, caps: u32) callcon fn seatName(_: ?*anyopaque, _: ?*c.struct_wl_seat, _: [*c]const u8) callconv(.c) void {} const seat_listener: c.struct_wl_seat_listener = .{ .capabilities = seatCapabilities, .name = seatName }; fn keymap(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, format: u32, fd: i32, size: u32) callconv(.c) void { + flushKey(); defer _ = c.close(fd); const s = state(); s.repeat_key = null; @@ -182,31 +194,66 @@ fn modifiers(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, _: u32, depressed: u32, const s = state(); if (s.state != null) _ = c.z_xkb_state_update_mask.?(s.state, depressed, latched, locked, 0, 0, group); updateModifiers(); + flushKey(); } fn key(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, serial: u32, _: u32, code: u32, pressed: u32) callconv(.c) void { const s = state(); + flushKey(); if (code > 0xffff) return; provenance(serial); if (code < s.held.len) s.held[code] = pressed != 0; // The compositor supplies the authoritative modifier/group mask. Running // XKB key actions locally as well would apply latch/lock actions twice. - deliverKey(code, pressed != 0, false); + s.pending_key = prepareKey(code, pressed != 0, false); + // wl_keyboard has no frame event. A sync boundary lets the following + // authoritative modifiers event supply the public post-event flags even + // when the key and modifier messages arrive in separate socket reads. + if (s.key_sync == null) { + s.key_sync = c.wl_display_sync(b().display); + if (s.key_sync != null) _ = c.wl_callback_add_listener(s.key_sync, &key_sync_listener, null) else flushKey(); + } if (pressed != 0 and s.repeat_rate > 0 and s.keymap != null and c.z_xkb_keymap_key_repeats.?(s.keymap, code + 8) != 0) { s.repeat_key = code; s.repeat_at = runtime.now(b().app.io) + @as(u64, s.repeat_delay) * std.time.ns_per_ms; } else if (s.repeat_key == code) s.repeat_key = null; } +fn prepareKey(code: u32, down: bool, repeated: bool) Key { + const s = state(); + var value: Key = .{ .code = code, .down = down, .repeated = repeated, .sym = if (s.state != null) c.z_xkb_state_key_get_one_sym.?(s.state, code + 8) else 0, .modifiers = s.modifiers }; + if (down and s.state != null) { + const length = c.z_xkb_state_key_get_utf8.?(s.state, code + 8, &value.text, value.text.len); + if (length > 0 and length < value.text.len) value.len = @intCast(length); + } + return value; +} fn deliverKey(code: u32, down: bool, repeated: bool) void { + emitKey(prepareKey(code, down, repeated)); +} +fn emitKey(value: Key) void { const s = state(); const w = s.key_focus orelse return; - const sym = if (s.state != null) c.z_xkb_state_key_get_one_sym.?(s.state, code + 8) else 0; var event = std.mem.zeroes(n.z_event); event.type = n.UHDK_EVENT_KEY; event.input_id = id(); - event.data.key = .{ .physical_key = Keyboard.physical(code), .logical_key = if (s.state != null) Keyboard.logical(sym) else 0, .modifiers = s.modifiers, .pressed = @intFromBool(down), .repeat = @intFromBool(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) }; send(w, &event); - if (down and w.text.enabled) Text.key(w, code, sym, event.data.key.logical_key, event.input_id); + // Resolve symbols/text before consuming a latched modifier, while reporting + // the compositor's post-event flags in the observational raw event. + if (value.down and w.text.enabled) Text.key(w, value.sym, event.data.key.logical_key, event.input_id, value.modifiers, value.text[0..value.len]); } +pub fn flushKey() void { + if (state().pending_key) |value| { + state().pending_key = null; + emitKey(value); + } +} +fn keySynced(_: ?*anyopaque, callback: ?*c.struct_wl_callback, _: u32) callconv(.c) void { + c.wl_callback_destroy(callback); + state().key_sync = null; + flushKey(); +} +const key_sync_listener: c.struct_wl_callback_listener = .{ .done = keySynced }; + fn repeatInfo(_: ?*anyopaque, _: ?*c.struct_wl_keyboard, rate: i32, delay: i32) callconv(.c) void { state().repeat_rate = @intCast(std.math.clamp(rate, 0, 1000)); state().repeat_delay = @intCast(@max(0, delay)); @@ -216,7 +263,6 @@ const keyboard_listener: c.struct_wl_keyboard_listener = .{ .keymap = keymap, .e pub fn tick() i32 { const s = state(); const active = s.key_focus != null; - if (!active) Data.focusLost(); if (active != s.active) { s.active = active; var event = std.mem.zeroes(n.z_event); @@ -224,6 +270,7 @@ pub fn tick() i32 { event.data.active = @intFromBool(active); n.z_emit(runtime.toAppHandle(b().app), &event); } + if (s.pending_key != null) return -1; if (s.repeat_key) |code| { const now = runtime.now(b().app.io); if (now >= s.repeat_at) { @@ -235,7 +282,9 @@ pub fn tick() i32 { return -1; } fn leaveKeyboard() void { + flushKey(); const s = state(); + s.modifiers = 0; s.repeat_key = null; if (s.key_focus) |w| { for (&s.held, 0..) |*held, code| if (held.*) { @@ -402,6 +451,8 @@ pub fn remove(name: u32) void { fn releaseSeat() void { Data.releaseSeat(); const s = state(); + if (s.key_sync != null) c.wl_callback_destroy(s.key_sync); + s.key_sync = null; leaveKeyboard(); leavePointer(); if (s.text_focus) |w| Text.forget(w); diff --git a/src/linux/Portal.zig b/src/linux/Portal.zig index 59e1838..a1ceffa 100644 --- a/src/linux/Portal.zig +++ b/src/linux/Portal.zig @@ -6,6 +6,7 @@ const n = @import("native"); const native = @import("native.zig"); const Bus = @import("Bus.zig"); const Files = @import("Files.zig"); +const Input = @import("Input.zig"); const runtime = @import("../runtime.zig"); const abi = @import("../abi.zig"); const desktop = "org.freedesktop.portal.Desktop"; @@ -24,6 +25,7 @@ const Pending = struct { handle: [:0]u8, token: [:0]u8, exported: ?*c.struct_zxdg_exported_v2 = null, + activation: ?*c.struct_xdg_activation_token_v1 = null, deadline: u64, }; pub const State = struct { @@ -309,7 +311,18 @@ pub fn request(cmd: *const n.z_command, id: u64) u32 { return abi.OOM; }; _ = c.zxdg_exported_v2_add_listener(p.exported, &export_listener, p); - } else submit(p, "") catch { + } else if (cmd.op == n.Z_URL and b().desktop.activation != null and Input.state().key_focus != null and + Input.state().serial != 0 and runtime.now(b().app.io) -| Input.state().serial_time <= 5 * std.time.ns_per_s) + { + p.activation = c.xdg_activation_v1_get_activation_token(b().desktop.activation) orelse { + destroy(p); + return abi.OOM; + }; + _ = c.xdg_activation_token_v1_add_listener(p.activation, &activation_listener, p); + c.xdg_activation_token_v1_set_serial(p.activation, Input.state().serial, Input.state().seat); + c.xdg_activation_token_v1_set_surface(p.activation, Input.state().key_focus.?.surface); + c.xdg_activation_token_v1_commit(p.activation); + } else submit(p, "", null) catch { destroy(p); return abi.OOM; }; @@ -336,13 +349,21 @@ fn exported(raw: ?*anyopaque, _: ?*c.struct_zxdg_exported_v2, handle: [*c]const return; }; defer b().app.gpa.free(parent); - submit(p, parent) catch { + submit(p, parent, null) catch { complete(p, abi.OOM, null); destroy(p); }; } const export_listener: c.struct_zxdg_exported_v2_listener = .{ .handle = exported }; -fn submit(p: *Pending, parent: [*:0]const u8) !void { +fn activated(raw: ?*anyopaque, _: ?*c.struct_xdg_activation_token_v1, token: [*c]const u8) callconv(.c) void { + const p: *Pending = @ptrCast(@alignCast(raw.?)); + submit(p, "", token) catch { + complete(p, abi.OOM, null); + destroy(p); + }; +} +const activation_listener: c.struct_xdg_activation_token_v1_listener = .{ .done = activated }; +fn submit(p: *Pending, parent: [*:0]const u8, activation_token: ?[*:0]const u8) !void { const cmd = p.command.?; const dialog = cmd.op == n.Z_DIALOG; const message = try Bus.method(desktop, path, if (dialog) chooser else "org.freedesktop.portal.OpenURI", if (!dialog) "OpenURI" else if (cmd.dialog.kind == n.UHDK_DIALOG_SAVE_FILE) "SaveFile" else "OpenFile"); @@ -356,6 +377,7 @@ fn submit(p: *Pending, parent: [*:0]const u8) !void { var options = try Bus.open(&args, c.DBUS_TYPE_ARRAY, "{sv}"); errdefer c.z_dbus_message_iter_abandon_container_if_open.?(&args, &options); try Bus.option(&options, "handle_token", "s", p.token.ptr); + if (activation_token) |token| try Bus.option(&options, "activation_token", "s", token); if (dialog) { try Bus.option(&options, "modal", "b", @as(u32, @intFromBool(cmd.dialog.parent != null))); if (cmd.dialog.kind != n.UHDK_DIALOG_SAVE_FILE) { @@ -485,6 +507,7 @@ fn destroy(p: *Pending) void { while (link.* != p) link = &link.*.?.next; link.* = p.next; if (p.exported != null) c.zxdg_exported_v2_destroy(p.exported); + if (p.activation != null) c.xdg_activation_token_v1_destroy(p.activation); b().app.gpa.free(p.handle); b().app.gpa.free(p.token); b().app.gpa.destroy(p); diff --git a/src/linux/README.md b/src/linux/README.md index b26a106..0bc54c5 100644 --- a/src/linux/README.md +++ b/src/linux/README.md @@ -114,7 +114,10 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified - One active seat owns keyboard, pointer, and clipboard state. The keyboard uses the compositor's XKB layout/masks, USB HID physical usages, client-side repeat, - and locale-aware compose tables. Focus loss releases tracked keys and stops repeat. + and locale-aware compose tables. Raw events use the post-event modifier mask; + text lookup retains the pre-event state for latched modifiers. A Wayland sync + boundary flushes keys without modifier changes. Focus loss releases tracked + keys and stops repeat. - Text input uses text-input-v3 when available and local XKB composition for forwarded keys. IME batches retain their surrounding-text revision and UTF-8 offsets until `done`. Surrounding text is cropped to the protocol's 4000-byte @@ -138,7 +141,8 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified A private D-Bus connection subscribes before submission, correlates early responses, closes cancelled requests, and handles portal-owner changes. Portal discovery is asynchronous, so its capability can appear after `READY`. - Connection/method setup is bounded; human interaction has no timeout. + Connection/method setup is bounded; human interaction has no timeout. URL + handoff includes an activation token when recent focused input permits it. - Portal Settings supplies appearance, contrast and reduced-motion preferences and live changes. Unknown/unavailable values retain neutral defaults. See the [Settings contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Settings.html) @@ -178,9 +182,9 @@ does not stop or modify the desktop compositor. 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 and early reader exit, URI drops, activation, portal -filters/results, response-before-reply, cancellation with a late alternate -handle, parent destruction and portal restart. They never open a real chooser +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 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 diff --git a/src/linux/Text.zig b/src/linux/Text.zig index 289c600..5ac1bc4 100644 --- a/src/linux/Text.zig +++ b/src/linux/Text.zig @@ -80,6 +80,7 @@ fn deleted(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, before: u32, after: w.text.after = after; } fn done(_: ?*anyopaque, _: ?*c.struct_zwp_text_input_v3, serial: u32) callconv(.c) void { + Input.flushKey(); const w = Input.state().text_focus orelse return; const t = &w.text; defer clearPending(w); @@ -276,25 +277,26 @@ pub fn deinit(w: *native.Window) void { forget(w); w.owner.gpa.free(w.text.surrounding); } -pub fn key(w: *native.Window, code: u32, sym: u32, logical: u32, input_id: u64) void { +pub fn key(w: *native.Window, sym: u32, logical: u32, input_id: u64, modifiers: u32, plain: []const u8) void { const s = Input.state(); const t = &w.text; if (t.ime_composing or s.state == null) return; - const edit = Keyboard.edit(logical, s.modifiers); + const edit = Keyboard.edit(logical, modifiers); if (edit != 0) { if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose); endAt(w, input_id); var e = std.mem.zeroes(n.z_event); e.type = n.UHDK_EVENT_EDIT_COMMAND; e.input_id = input_id; - e.data.edit = .{ .command = edit, .extend_selection = @intFromBool(s.modifiers & n.UHDK_MOD_SHIFT != 0) }; + e.data.edit = .{ .command = edit, .extend_selection = @intFromBool(modifiers & n.UHDK_MOD_SHIFT != 0) }; t.last_edit = input_id; Input.send(w, &e); return; } - if (s.modifiers & (n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return; + if (modifiers & (n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return; var buffer: [256]u8 = undefined; - var length: c_int = 0; + var length: c_int = @intCast(plain.len); + @memcpy(buffer[0..plain.len], plain); if (s.compose != null) { _ = c.z_xkb_compose_state_feed.?(s.compose, sym); switch (c.z_xkb_compose_state_get_status.?(s.compose)) { @@ -312,9 +314,9 @@ pub fn key(w: *native.Window, code: u32, sym: u32, logical: u32, input_id: u64) c.z_xkb_compose_state_reset.?(s.compose); endAt(w, input_id); }, - else => length = c.z_xkb_state_key_get_utf8.?(s.state, code + 8, &buffer, buffer.len), + else => {}, } - } else length = c.z_xkb_state_key_get_utf8.?(s.state, code + 8, &buffer, buffer.len); + } if (length <= 0 or length >= buffer.len or buffer[0] < 32 or buffer[0] == 127) return; var e = std.mem.zeroes(n.z_event); e.type = n.UHDK_EVENT_TEXT_COMMIT; diff --git a/src/linux/bindings.h b/src/linux/bindings.h index bc57576..bb40f17 100644 --- a/src/linux/bindings.h +++ b/src/linux/bindings.h @@ -8,7 +8,6 @@ #include #include #include -#include #include #include "wayland-client-core.h" #include "wayland-egl-core.h" diff --git a/src/linux/tests/portal_fixture.py b/src/linux/tests/portal_fixture.py index c4fd5e4..bc5a834 100644 --- a/src/linux/tests/portal_fixture.py +++ b/src/linux/tests/portal_fixture.py @@ -37,7 +37,10 @@ async def main(binary): async def delayed_reply(message, handle, delay, is_url=False): await asyncio.sleep(delay) bus.send(Message.new_method_return(message, 'o', [handle])) - if is_url: response(handle) + if is_url: + for key, value in [('color-scheme', 2), ('contrast', 0), ('reduced-motion', 0)]: + bus.send(Message.new_signal(PATH, 'org.freedesktop.portal.Settings', 'SettingChanged', 'ssv', ['org.freedesktop.appearance', key, Variant('u', value)])) + response(handle) async def restart(): await bus.release_name(DESKTOP) @@ -65,12 +68,14 @@ async def main(binary): handle_path = PATH + '/request/' + message.sender[1:].replace('.', '_') + '/' + token if message.member == 'OpenURI': assert title == 'https://example.org/test' + assert options['activation_token'].value == 'fixture-token' schedule(delayed_reply(message, handle_path, .35, True)) seen.add('url') return True seen.add(title) assert message.signature == 'ssa{sv}' - assert options['multiple'].value and not options['directory'].value + if message.member == 'OpenFile': + assert options['multiple'].value and not options['directory'].value assert options['filters'].value == [['Text', [[0, '*.txt'], [0, '*.md']]]] if title == 'success': assert parent == 'wayland:fixture-parent' @@ -79,6 +84,13 @@ async def main(binary): # must already be subscribed using its chosen handle token. response(handle_path, results={'uris': Variant('as', ['file:///tmp/a%20b%25%FF.txt', 'file:///tmp/second'])}) bus.send(Message.new_method_return(message, 'o', [handle_path])) + elif title == 'save': + assert message.member == 'SaveFile' + assert options['current_name'].value == 'book.txt' + assert options['accept_label'].value == 'Store' + assert bytes(options['current_folder'].value) == b'/tmp\0' + bus.send(Message.new_method_return(message, 'o', [handle_path])) + response(handle_path, results={'uris': Variant('as', ['file:///tmp/save.txt'])}) elif title == 'cancel': # Return a different handle after local cancellation; both # the predicted and returned paths need Close calls. @@ -102,7 +114,7 @@ async def main(binary): bus.add_message_handler(handle) await asyncio.to_thread(run, binary, 'portal', dict(os.environ, DBUS_SESSION_BUS_ADDRESS=address)) await asyncio.sleep(.1) - assert {'success', 'cancel', 'url', 'restart', 'dismiss'} <= seen, seen + assert {'success', 'save', 'cancel', 'url', 'restart', 'dismiss'} <= seen, seen assert any(p.endswith('_late') for p in closed), closed assert len(closed) >= 2, closed print('private portal protocol/cancellation/restart fixture: PASS') diff --git a/src/linux/tests/services.c b/src/linux/tests/services.c index b42f68f..2ccc0de 100644 --- a/src/linux/tests/services.c +++ b/src/linux/tests/services.c @@ -162,6 +162,28 @@ static void portals(uhdk_app *app) { files->ptr[0], UHDK_FILE_NATIVE_PATH, bytes, sizeof(bytes), &len )); assert(len == 14 && memcmp(bytes, "/tmp/a b%\xff.txt", 14) == 0); + uhdk_preferences prefs = {0}; + prefs.struct_size = sizeof(prefs); + OK(uhdk_app_get_preferences(app, &prefs)); + assert(prefs.appearance == UHDK_APPEARANCE_DARK && prefs.reduced_motion && prefs.high_contrast); + uhdk_file *directory; + OK( + uhdk_file_create_from_native_path(app, (uhdk_bytes){(const uint8_t *)"/tmp", 4}, &directory) + ); + desc.title = text("save"); + desc.kind = UHDK_DIALOG_SAVE_FILE; + desc.suggested_name = text("book.txt"); + desc.accept_label = text("Store"); + desc.initial_directory = directory; + OK(uhdk_file_dialog_show_async(app, &desc, &id)); + r = wait_result(app, id); + OK(r.status); + assert(((const uhdk_file_list *)r.data)->len == 1); + uhdk_file_release(directory); + desc.kind = UHDK_DIALOG_OPEN_FILES; + desc.initial_directory = NULL; + desc.suggested_name = text(""); + desc.accept_label = text(""); desc.title = text("cancel"); OK(uhdk_file_dialog_show_async(app, &desc, &id)); // Allow method submission, then cancel while the mock withholds its reply. @@ -169,12 +191,17 @@ static void portals(uhdk_app *app) { OK(uhdk_app_poll_events(app, 100000000, &events)); OK(uhdk_request_cancel(app, id)); assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); + OK(uhdk_shell_open_url_async(app, text("https://example.org/test"), &id)); + OK(wait_result(app, id).status); + OK(uhdk_app_get_preferences(app, &prefs)); + assert( + prefs.appearance == UHDK_APPEARANCE_LIGHT && !prefs.reduced_motion && !prefs.high_contrast + ); desc.title = text("parent"); OK(uhdk_file_dialog_show_async(app, &desc, &id)); uhdk_window_destroy(w); assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); - OK(uhdk_shell_open_url_async(app, text("https://example.org/test"), &id)); - OK(wait_result(app, id).status); + desc.parent = NULL; desc.title = text("restart"); OK(uhdk_file_dialog_show_async(app, &desc, &id)); @@ -200,19 +227,28 @@ static void input(uhdk_app *app) { state.selection_len = 1; OK(uhdk_window_set_text_state(w, &state)); unsigned key = 0, repeat = 0, commit = 0, preedit = 0, pointer = 0, scroll = 0, dropped = 0, - copy = 0, compose = 0; + copy = 0, compose = 0, captured = 0, released = 0; + uint64_t acknowledged = 0, capture_request = 0; + int published = 0; uint64_t end = deadline(app); - while (uhdk_app_get_time_ns(app) < end && !(key && repeat && commit >= 2 && preedit && - pointer && scroll && dropped && copy && compose)) { + while (uhdk_app_get_time_ns(app) < end && + !(key && repeat && commit >= 2 && preedit && pointer && scroll && dropped && copy && + compose && captured && released)) { uhdk_event_list events; OK(uhdk_app_poll_events(app, 50000000, &events)); for (uint64_t i = 0; i < events.len; ++i) { const uhdk_event *e = &events.ptr[i]; + if (e->input_id > acknowledged) + acknowledged = e->input_id; switch (e->type) { case UHDK_EVENT_KEY: { const uhdk_key_event *k = e->data; if (k->physical_key == 4 && k->logical_key == 'a' && k->pressed) key++; + if (k->physical_key == 224) + assert(!!(k->modifiers & UHDK_MOD_CONTROL) == !!k->pressed); + if (k->logical_key == 'A' && k->pressed) + assert(!(k->modifiers & UHDK_MOD_SHIFT)); if (k->repeat) repeat++; break; @@ -241,6 +277,22 @@ static void input(uhdk_app *app) { preedit++; break; } + case UHDK_EVENT_POINTER_BUTTON: + if (((const uhdk_pointer_event *)e->data)->pressed) + OK(uhdk_window_set_pointer_mode_async( + w, UHDK_POINTER_CAPTURE, &capture_request + )); + break; + case UHDK_EVENT_POINTER_MODE_CHANGED: + if (((const uhdk_pointer_mode_event *)e->data)->mode == UHDK_POINTER_CAPTURE) + captured++; + else + released++; + break; + case UHDK_EVENT_REQUEST_DONE: + if (((const uhdk_request_result *)e->data)->id == capture_request) + OK(((const uhdk_request_result *)e->data)->status); + break; case UHDK_EVENT_POINTER_MOVE: { const uhdk_pointer_event *p = e->data; if (p->position.x == 12 && p->position.y == 18) @@ -263,6 +315,13 @@ static void input(uhdk_app *app) { break; } } + if (repeat && !published) { + state.surrounding_text = text("abcd"); + state.revision = 78; + state.acknowledged_input_id = acknowledged; + OK(uhdk_window_set_text_state(w, &state)); + published = 1; + } } fprintf( stderr, @@ -286,6 +345,17 @@ static void input(uhdk_app *app) { OK(r.status); const uhdk_text *t = r.data; assert(t->len == 11 && memcmp(t->ptr, "hello café", 11) == 0); + OK(uhdk_clipboard_read_text_async(app, 100, &id)); + uhdk_event_list pause; + OK(uhdk_app_poll_events(app, 100000000, &pause)); + OK(uhdk_request_cancel(app, id)); + assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); + OK(uhdk_clipboard_read_text_async(app, 100, &id)); + assert(wait_result(app, id).status == UHDK_STATUS_INVALID_ARGUMENT); + OK(uhdk_clipboard_read_text_async(app, 0, &id)); + r = wait_result(app, id); + OK(r.status); + assert(((const uhdk_text *)r.data)->len == 0); OK(uhdk_clipboard_write_text_async(app, text("local copy"), &id)); OK(wait_result(app, id).status); OK(uhdk_window_request_focus_async(w, &id)); diff --git a/src/linux/tests/wayland_fixture.py b/src/linux/tests/wayland_fixture.py index c0dcc10..0815317 100644 --- a/src/linux/tests/wayland_fixture.py +++ b/src/linux/tests/wayland_fixture.py @@ -73,6 +73,8 @@ class Compositor: self.tokens = False self.finished_drop = False self.outgoing = None + self.receives = 0 + self.held = [] def event(self, obj, name, *values): interface = INTERFACES[self.objects[obj]] @@ -176,7 +178,15 @@ class Compositor: elif iface == 'wl_data_offer' and name == 'receive': mime, fd = args assert not fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_NONBLOCK - os.write(fd, b'file:///tmp/drop%20file\r\n' if mime == 'text/uri-list' else 'hello café'.encode()) + if mime == 'text/uri-list': + payload = b'file:///tmp/drop%20file\r\n' + else: + self.receives += 1 + if self.receives == 3: + self.held.append(fd) + return + payload = b'\xff' if self.receives == 4 else b'' if self.receives == 5 else 'hello café'.encode() + os.write(fd, payload) os.close(fd) elif iface == 'wl_data_offer' and name == 'finish': self.finished_drop = True @@ -207,6 +217,7 @@ class Compositor: self.event(keyboard, 'key', 203, 3, 48, 1) self.event(pointer, 'enter', 204, surface, 10 * 256, 10 * 256) self.event(pointer, 'motion', 4, 12 * 256, 18 * 256) + self.event(pointer, 'button', 204, 4, 0x110, 1) self.event(pointer, 'axis_source', 0) self.event(pointer, 'axis', 5, 0, 5 * 256) self.event(pointer, 'axis_value120', 0, 60) @@ -225,15 +236,29 @@ class Compositor: text = self.ids.get('zwp_text_input_v3') if text and self.surfaces and not getattr(self, 'text_entered', False): self.text_entered = True - self.event(text, 'enter', next(iter(self.surfaces))) + surface = next(iter(self.surfaces)) + self.event(text, 'enter', surface) + if not self.inject: + keyboard = self.ids['wl_keyboard'] + self.event(keyboard, 'enter', 190, surface, b'') + self.event(keyboard, 'key', 191, 1, 30, 1) + self.event(keyboard, 'key', 192, 2, 30, 0) if self.later and time.monotonic() >= self.later[0]: _, text, serial = self.later; self.later = None + assert self.text_serial > serial, (self.text_serial, serial) + self.event(self.ids['wl_pointer'], 'button', 206, 6, 0x110, 0) keyboard = self.ids['wl_keyboard'] self.event(keyboard, 'key', 206, 6, 48, 0) + self.event(keyboard, 'key', 207, 7, 29, 1) self.event(keyboard, 'modifiers', 207, 4, 0, 0, 0) self.event(keyboard, 'key', 208, 7, 46, 1) self.event(keyboard, 'key', 209, 8, 46, 0) + self.event(keyboard, 'key', 210, 9, 29, 0) self.event(keyboard, 'modifiers', 210, 0, 0, 0, 0) + self.event(keyboard, 'modifiers', 210, 0, 1, 0, 0) + self.event(keyboard, 'key', 210, 9, 30, 1) + self.event(keyboard, 'modifiers', 210, 0, 0, 0, 0) + self.event(keyboard, 'key', 210, 9, 30, 0) self.event(keyboard, 'key', 211, 9, 40, 1) self.event(keyboard, 'key', 212, 10, 40, 0) self.event(keyboard, 'key', 213, 11, 18, 1) @@ -266,7 +291,7 @@ def run(binary, mode, env=None): finally: if process.poll() is None: process.kill(); process.wait() server.close() - for fd in compositor.fds: os.close(fd) + for fd in compositor.fds + compositor.held: os.close(fd) if compositor.outgoing is not None: os.close(compositor.outgoing)