From c0d923cbeddb82f2b034b96a16a4f392ecd83860 Mon Sep 17 00:00:00 2001 From: Peter Li Date: Fri, 18 Sep 2026 16:30:35 -0700 Subject: [PATCH] macos inputs --- README.md | 37 +++ include/uhdk.h | 71 +++++- src/core_tests.zig | 37 +++ src/edit_keys.h | 100 ++++++++ src/linux/Input.zig | 2 +- src/linux/Keyboard.zig | 26 +- src/linux/Menu.zig | 7 +- src/linux/README.md | 15 +- src/linux/Text.zig | 39 ++- src/linux/WindowState.zig | 60 +++++ src/linux/native.zig | 45 ++-- src/linux/tests/editing_fixture.py | 32 +++ src/linux/tests/services.c | 131 ++++++++++ src/linux/tests/window_state_fixture.py | 54 +++++ src/macos/native.m | 307 ++++++++++++++++++------ src/native.h | 1 + src/windows/native.c | 85 +++---- 17 files changed, 862 insertions(+), 187 deletions(-) create mode 100644 src/edit_keys.h create mode 100644 src/linux/WindowState.zig create mode 100644 src/linux/tests/editing_fixture.py create mode 100644 src/linux/tests/window_state_fixture.py diff --git a/README.md b/README.md index 18b1369..153b5b8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,43 @@ ABI headers/protocol bindings and runtime-loaded system libraries. Normal builds need Zig alone; the optional `shell.nix` supplies maintainer tools and NixOS runtime library paths. See [Linux support and limitations](src/linux/README.md). +## Experimental desktop input and window observations + +macOS uses AppKit editing selectors. Windows and Wayland share Ctrl-based defaults +for word deletion, paragraph/page movement, clipboard actions, undo/redo, Backtab, +Shift+Enter and cancellation. Shift extends selection only for movement. Alt/Super +combinations are not interpreted as Ctrl editing shortcuts. Clipboard shortcuts +work without a menu, with menu actions taking precedence when available. +Applications own execution, Unicode boundaries, layout and undo history. Commands +without customary shortcuts (such as line-boundary deletion on Windows) have no +invented default binding. + +Publish an acknowledged text snapshot after consuming text/edit events. On macOS, +further native text input waits after semantic edits, including ignored commands; +acknowledge asynchronous paste after applying its result. Disabling text input or +losing window focus drops that deferred input. Windows IMM emits ordered text +without surrounding-text replacement ranges. Wayland invalidates old IME snapshot +serials after document/selection-changing commands; new surrounding text is +published after acknowledgment. It does not apply AppKit's input-queue mechanism. + +`WINDOW_METRICS` is the resize and state-change hook. Geometry state is independent +of minimization. Observation bits indicate which facts are known: + +| Backend | Known observations | +| --- | --- | +| macOS | Minimized, live resize, occluded | +| Windows | Minimized, interactive size/move loop | +| Wayland | Resizing; rendering suspension with xdg-shell v6+ | + +A clear observation bit means unknown, not false. Wayland cannot report minimized +state or directly undo minimization, and suspension is not proof of occlusion. +Explicitly unavailable Wayland window-manager actions complete with `UNSUPPORTED`. +Render hints do not replace presentation pacing or event processing. + +This library is unreleased and experimental; the changed C struct layout requires +rebuilding consumers, without ABI compatibility guarantees. Windows and Linux +runtime qualification must accompany cross-compilation checks. + ## Build ```sh diff --git a/include/uhdk.h b/include/uhdk.h index 4cebc35..63d583a 100644 --- a/include/uhdk.h +++ b/include/uhdk.h @@ -324,8 +324,25 @@ typedef struct uhdk_window_desc { uhdk_window *parent; /* Optional; must outlive child. */ } uhdk_window_desc; -/* On Wayland, visible is application show/hide intent, not compositor visibility. - * State is the last observable state; Wayland does not report minimization. +/* Observation bits say which independent window facts are known. A clear bit + * means unknown, not false. Support depends on the backend/protocol version. */ +typedef uint32_t uhdk_window_observation_flags; +enum { + UHDK_WINDOW_OBSERVE_MINIMIZED = 1u << 0, + UHDK_WINDOW_OBSERVE_RESIZING = 1u << 1, + UHDK_WINDOW_OBSERVE_OCCLUDED = 1u << 2, + UHDK_WINDOW_OBSERVE_SUSPENDED = 1u << 3 +}; + +/* WINDOW_METRICS is the resize/state hook; compare snapshots for transitions. + * State describes geometry (normal/maximized/fullscreen), independently of + * minimized. Wayland does not report minimization; its observation bit is clear. + * visible is show/hide visibility, not an occlusion or minimization guarantee; + * on Wayland it is application intent. occluded is an optional rendering hint, + * not permission to stop processing events. suspended is a separate compositor + * hint that content is not ordinarily repainted, not proof of occlusion or + * minimization. Resizing is an advisory interactive size/move-loop + * hint, not a guaranteed transaction around every size change. * Async size/state completion acknowledges submission; geometry arrives here. */ typedef struct uhdk_window_metrics { uint32_t struct_size; @@ -336,6 +353,11 @@ typedef struct uhdk_window_metrics { float scale; uhdk_bool focused; uhdk_bool visible; + uhdk_window_observation_flags observations; + uhdk_bool minimized; + uhdk_bool resizing; + uhdk_bool occluded; + uhdk_bool suspended; } uhdk_window_metrics; UHDK_API uhdk_status UHDK_CALL uhdk_window_create_async( @@ -545,6 +567,11 @@ UHDK_API uhdk_status UHDK_CALL uhdk_window_set_pointer_mode_async( * revision; do not discard a burst just because its first edit changed revision. * acknowledged_input_id is the last applied text/edit input ID. Snapshots behind * outstanding native edits are ignored; publish again after consuming the batch. + * On macOS, a semantic edit invalidates the native snapshot. Subsequent text + * input waits for an acknowledging snapshot, including when an edit is ignored. + * Acknowledge asynchronous edits (such as paste) only once their result is applied. + * Disabling text input discards deferred input. Raw key IDs are observational; + * acknowledge applied text/edit events, not raw keys that may precede them. * Commit/preedit events carry text + an optional replacement range. A preedit * updates temporary composition, never the permanent document by itself. */ @@ -870,6 +897,19 @@ enum { UHDK_REQUEST_FILE_INFO }; +/* Semantic requests, not edits performed by UHDK. The application owns text, + * grapheme/word boundaries, bidi/layout, selection, clipboard actions and history. + * LEFT/RIGHT are visual; BACKWARD/FORWARD follow logical document order. + * LINE uses visual lines; LEFT_END/RIGHT_END explicitly select their visual edge. + * LINE_START/END follow writing direction; PARAGRAPH uses document boundaries. + * PAGE moves the caret by a viewport; SCROLL moves the viewport without the caret. + * Deletion first removes the selection; otherwise it removes the indicated unit. + * NEWLINE requests a paragraph separator; LINE_BREAK requests a soft line break. + * TAB/BACKTAB may insert text or traverse focus, as decided by the application. + * CANCEL is delivered only after native composition handling declines/finishes it. + * Native default gestures vary: macOS uses AppKit selectors, Windows/Wayland + * use Ctrl-based editing defaults. Commands without customary platform gestures + * remain application-bindable intents, not invented global shortcuts. */ typedef uint32_t uhdk_edit_command; enum { UHDK_EDIT_COPY = 1, @@ -891,7 +931,30 @@ enum { UHDK_EDIT_WORD_LEFT, UHDK_EDIT_WORD_RIGHT, UHDK_EDIT_NEWLINE, - UHDK_EDIT_TAB + UHDK_EDIT_TAB, + UHDK_EDIT_BACKTAB, + UHDK_EDIT_LINE_BREAK, + UHDK_EDIT_CANCEL, + UHDK_EDIT_DELETE_WORD_BACKWARD, + UHDK_EDIT_DELETE_WORD_FORWARD, + UHDK_EDIT_DELETE_LINE_START, + UHDK_EDIT_DELETE_LINE_END, + UHDK_EDIT_DELETE_PARAGRAPH_START, + UHDK_EDIT_DELETE_PARAGRAPH_END, + UHDK_EDIT_MOVE_BACKWARD, + UHDK_EDIT_MOVE_FORWARD, + UHDK_EDIT_WORD_BACKWARD, + UHDK_EDIT_WORD_FORWARD, + UHDK_EDIT_PARAGRAPH_START, + UHDK_EDIT_PARAGRAPH_END, + UHDK_EDIT_PAGE_UP, + UHDK_EDIT_PAGE_DOWN, + UHDK_EDIT_SCROLL_PAGE_UP, + UHDK_EDIT_SCROLL_PAGE_DOWN, + UHDK_EDIT_SCROLL_DOCUMENT_START, + UHDK_EDIT_SCROLL_DOCUMENT_END, + UHDK_EDIT_LINE_LEFT_END, + UHDK_EDIT_LINE_RIGHT_END }; /* Event data is a pointer to the named payload type selected by type. @@ -939,7 +1002,7 @@ typedef struct uhdk_text_event { typedef struct uhdk_edit_event { uhdk_edit_command command; - uhdk_bool extend_selection; + uhdk_bool extend_selection; /* Only meaningful for caret movement. */ } uhdk_edit_event; typedef struct uhdk_drop_files_event { diff --git a/src/core_tests.zig b/src/core_tests.zig index 040f40a..9851f66 100644 --- a/src/core_tests.zig +++ b/src/core_tests.zig @@ -182,3 +182,40 @@ test "READY precedes native startup events without being published on failed sta try std.testing.expectEqual(@as(u32, c.UHDK_EVENT_WAKE), preferences.next.?.event.type); try std.testing.expect(application.pending_tail == preferences.next); } + +test { + _ = @import("linux/WindowState.zig"); +} + +test "desktop edit defaults preserve modifiers and selection intent" { + const ctrl = c.UHDK_MOD_CONTROL; + const shift = c.UHDK_MOD_SHIFT; + const cases = [_][3]u32{ + .{ c.UHDK_KEY_BACKSPACE, ctrl, c.UHDK_EDIT_DELETE_WORD_BACKWARD }, + .{ c.UHDK_KEY_DELETE, ctrl, c.UHDK_EDIT_DELETE_WORD_FORWARD }, + .{ c.UHDK_KEY_DELETE, shift, c.UHDK_EDIT_CUT }, + .{ c.UHDK_KEY_INSERT, ctrl, c.UHDK_EDIT_COPY }, + .{ c.UHDK_KEY_INSERT, shift, c.UHDK_EDIT_PASTE }, + .{ 'v', ctrl, c.UHDK_EDIT_PASTE }, + .{ 'z', ctrl | shift, c.UHDK_EDIT_REDO }, + .{ c.UHDK_KEY_UP, ctrl | shift, c.UHDK_EDIT_PARAGRAPH_START }, + .{ c.UHDK_KEY_DOWN, ctrl, c.UHDK_EDIT_PARAGRAPH_END }, + .{ c.UHDK_KEY_PAGE_UP, shift, c.UHDK_EDIT_PAGE_UP }, + .{ c.UHDK_KEY_PAGE_DOWN, 0, c.UHDK_EDIT_PAGE_DOWN }, + .{ c.UHDK_KEY_TAB, shift, c.UHDK_EDIT_BACKTAB }, + .{ c.UHDK_KEY_ENTER, shift, c.UHDK_EDIT_LINE_BREAK }, + .{ c.UHDK_KEY_ESCAPE, 0, c.UHDK_EDIT_CANCEL }, + .{ c.UHDK_KEY_TAB, ctrl, 0 }, + .{ c.UHDK_KEY_PAGE_DOWN, ctrl, 0 }, + .{ 'v', ctrl | shift, 0 }, + }; + for (cases) |item| { + try std.testing.expectEqual(item[2], c.z_edit_key(item[0], item[1])); + try std.testing.expectEqual(@as(u32, 0), c.z_edit_key(item[0], item[1] | c.UHDK_MOD_ALT)); + try std.testing.expectEqual(@as(u32, 0), c.z_edit_key(item[0], item[1] | c.UHDK_MOD_SUPER)); + } + try std.testing.expect(c.z_edit_extends_selection(c.UHDK_EDIT_WORD_LEFT, shift) != 0); + try std.testing.expect(c.z_edit_extends_selection(c.UHDK_EDIT_PAGE_DOWN, shift) != 0); + for ([_]u32{ c.UHDK_EDIT_BACKTAB, c.UHDK_EDIT_LINE_BREAK, c.UHDK_EDIT_CUT, c.UHDK_EDIT_DELETE_WORD_FORWARD, c.UHDK_EDIT_REDO }) |command| + try std.testing.expectEqual(@as(c_int, 0), c.z_edit_extends_selection(command, shift)); +} diff --git a/src/edit_keys.h b/src/edit_keys.h new file mode 100644 index 0000000..e2c3aba --- /dev/null +++ b/src/edit_keys.h @@ -0,0 +1,100 @@ +#ifndef UHDK_PRIVATE_EDIT_KEYS_H +#define UHDK_PRIVATE_EDIT_KEYS_H +#include "uhdk.h" + +/* Shared Windows/Wayland defaults. Alt (including AltGr) and Super belong to + * text/layout or desktop shortcuts, never to these Ctrl editing bindings. */ +static inline uint32_t z_edit_key(uint32_t key, uint32_t mods) { + int ctrl = (mods & UHDK_MOD_CONTROL) != 0; + int shift = (mods & UHDK_MOD_SHIFT) != 0; + if (mods & (UHDK_MOD_ALT | UHDK_MOD_SUPER)) + return 0; + if (ctrl) { + switch (key) { + case 'a': + case 'A': + return shift ? 0 : UHDK_EDIT_SELECT_ALL; + case 'c': + case 'C': + return shift ? 0 : UHDK_EDIT_COPY; + case 'x': + case 'X': + return shift ? 0 : UHDK_EDIT_CUT; + case 'v': + case 'V': + return shift ? 0 : UHDK_EDIT_PASTE; + case 'z': + case 'Z': + return shift ? UHDK_EDIT_REDO : UHDK_EDIT_UNDO; + case 'y': + case 'Y': + return shift ? 0 : UHDK_EDIT_REDO; + default: + break; + } + } + switch (key) { + case UHDK_KEY_BACKSPACE: + return ctrl ? UHDK_EDIT_DELETE_WORD_BACKWARD : UHDK_EDIT_DELETE_BACKWARD; + case UHDK_KEY_DELETE: + return ctrl ? UHDK_EDIT_DELETE_WORD_FORWARD + : shift ? UHDK_EDIT_CUT + : UHDK_EDIT_DELETE_FORWARD; + case UHDK_KEY_INSERT: + return ctrl && !shift ? UHDK_EDIT_COPY : shift && !ctrl ? UHDK_EDIT_PASTE : 0; + case UHDK_KEY_LEFT: + return ctrl ? UHDK_EDIT_WORD_LEFT : UHDK_EDIT_MOVE_LEFT; + case UHDK_KEY_RIGHT: + return ctrl ? UHDK_EDIT_WORD_RIGHT : UHDK_EDIT_MOVE_RIGHT; + case UHDK_KEY_UP: + return ctrl ? UHDK_EDIT_PARAGRAPH_START : UHDK_EDIT_MOVE_UP; + case UHDK_KEY_DOWN: + return ctrl ? UHDK_EDIT_PARAGRAPH_END : UHDK_EDIT_MOVE_DOWN; + case UHDK_KEY_HOME: + return ctrl ? UHDK_EDIT_DOCUMENT_START : UHDK_EDIT_LINE_START; + case UHDK_KEY_END: + return ctrl ? UHDK_EDIT_DOCUMENT_END : UHDK_EDIT_LINE_END; + case UHDK_KEY_PAGE_UP: + return ctrl ? 0 : UHDK_EDIT_PAGE_UP; + case UHDK_KEY_PAGE_DOWN: + return ctrl ? 0 : UHDK_EDIT_PAGE_DOWN; + case UHDK_KEY_ENTER: + return ctrl ? 0 : shift ? UHDK_EDIT_LINE_BREAK : UHDK_EDIT_NEWLINE; + case UHDK_KEY_TAB: + return ctrl ? 0 : shift ? UHDK_EDIT_BACKTAB : UHDK_EDIT_TAB; + case UHDK_KEY_ESCAPE: + return ctrl || shift ? 0 : UHDK_EDIT_CANCEL; + default: + return 0; + } +} +static inline int z_edit_extends_selection(uint32_t command, uint32_t mods) { + if (!(mods & UHDK_MOD_SHIFT)) + return 0; + switch (command) { + case UHDK_EDIT_MOVE_LEFT: + case UHDK_EDIT_MOVE_RIGHT: + case UHDK_EDIT_MOVE_UP: + case UHDK_EDIT_MOVE_DOWN: + case UHDK_EDIT_MOVE_BACKWARD: + case UHDK_EDIT_MOVE_FORWARD: + case UHDK_EDIT_WORD_LEFT: + case UHDK_EDIT_WORD_RIGHT: + case UHDK_EDIT_WORD_BACKWARD: + case UHDK_EDIT_WORD_FORWARD: + case UHDK_EDIT_LINE_START: + case UHDK_EDIT_LINE_END: + case UHDK_EDIT_LINE_LEFT_END: + case UHDK_EDIT_LINE_RIGHT_END: + case UHDK_EDIT_DOCUMENT_START: + case UHDK_EDIT_DOCUMENT_END: + case UHDK_EDIT_PARAGRAPH_START: + case UHDK_EDIT_PARAGRAPH_END: + case UHDK_EDIT_PAGE_UP: + case UHDK_EDIT_PAGE_DOWN: + return 1; + default: + return 0; + } +} +#endif diff --git a/src/linux/Input.zig b/src/linux/Input.zig index fd426b7..68855bf 100644 --- a/src/linux/Input.zig +++ b/src/linux/Input.zig @@ -260,7 +260,7 @@ fn emitKey(value: Key) void { 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 !w.text.composing and KdeAppMenu.shortcut(w, event.data.key.logical_key, value.modifiers, event.input_id)) { s.menu_keys[value.code] = true; return; } diff --git a/src/linux/Keyboard.zig b/src/linux/Keyboard.zig index 57e2e45..60f747f 100644 --- a/src/linux/Keyboard.zig +++ b/src/linux/Keyboard.zig @@ -48,31 +48,9 @@ pub fn logical(sym: u32) u32 { }; } pub fn edit(key: u32, mods: u32) u32 { - const ctrl = mods & n.UHDK_MOD_CONTROL != 0; - if (mods & (n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return 0; - if (ctrl) switch (key) { - 'c', 'C' => return n.UHDK_EDIT_COPY, - 'x', 'X' => return n.UHDK_EDIT_CUT, - 'v', 'V' => return n.UHDK_EDIT_PASTE, - 'a', 'A' => return n.UHDK_EDIT_SELECT_ALL, - 'z', 'Z' => return if (mods & n.UHDK_MOD_SHIFT != 0) n.UHDK_EDIT_REDO else n.UHDK_EDIT_UNDO, - 'y', 'Y' => return n.UHDK_EDIT_REDO, - else => {}, - }; - return switch (key) { - n.UHDK_KEY_BACKSPACE => n.UHDK_EDIT_DELETE_BACKWARD, - n.UHDK_KEY_DELETE => n.UHDK_EDIT_DELETE_FORWARD, - n.UHDK_KEY_LEFT => if (ctrl) n.UHDK_EDIT_WORD_LEFT else n.UHDK_EDIT_MOVE_LEFT, - n.UHDK_KEY_RIGHT => if (ctrl) n.UHDK_EDIT_WORD_RIGHT else n.UHDK_EDIT_MOVE_RIGHT, - n.UHDK_KEY_UP => n.UHDK_EDIT_MOVE_UP, - n.UHDK_KEY_DOWN => n.UHDK_EDIT_MOVE_DOWN, - n.UHDK_KEY_HOME => if (ctrl) n.UHDK_EDIT_DOCUMENT_START else n.UHDK_EDIT_LINE_START, - n.UHDK_KEY_END => if (ctrl) n.UHDK_EDIT_DOCUMENT_END else n.UHDK_EDIT_LINE_END, - n.UHDK_KEY_ENTER => if (ctrl) 0 else n.UHDK_EDIT_NEWLINE, - n.UHDK_KEY_TAB => if (ctrl) 0 else n.UHDK_EDIT_TAB, - else => 0, - }; + return n.z_edit_key(key, mods); } + test "physical positions and editor shortcuts" { try std.testing.expectEqual(@as(u32, 4), physical(30)); try std.testing.expectEqual(@as(u32, 29), physical(44)); diff --git a/src/linux/Menu.zig b/src/linux/Menu.zig index acbee72..276c9a2 100644 --- a/src/linux/Menu.zig +++ b/src/linux/Menu.zig @@ -4,17 +4,14 @@ const n = @import("native"); const native = @import("native.zig"); const runtime = @import("../runtime.zig"); const Input = @import("Input.zig"); +const Text = @import("Text.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); + Text.editCommand(window, @as(u32, n.UHDK_EDIT_COPY) + item.role - n.UHDK_MENU_ROLE_COPY, 0, input_id); } else { event.type = n.UHDK_EVENT_MENU_COMMAND; event.data.menu.command_id = item.command_id; diff --git a/src/linux/README.md b/src/linux/README.md index ef7c9d0..1ec77c9 100644 --- a/src/linux/README.md +++ b/src/linux/README.md @@ -93,7 +93,11 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified application's show/hide intent, not proof that the compositor displays it. - Size/state commands acknowledge submission. Confirmed geometry and focus arrive through metrics. Wayland cannot report minimized state, force focus, or directly - undo minimization. Activation requests use a launch token or a recent focused + undo minimization. Metrics expose known resizing and (xdg-shell v6+) suspension + observations. Suspension is a rendering hint, not minimized/occluded state; + those two observation bits remain unset. Advertised unsupported window-manager + requests complete with `UNSUPPORTED`; older protocol versions remain best effort. + Activation requests use a launch token or a recent focused input serial; the compositor can decline to focus the window. - Fractional scale uses fractional-scale-v1 plus viewporter; otherwise preferred integer buffer scale is used when wl_surface v6 is available. Older compositors @@ -123,7 +127,12 @@ NixOS with Plasma 6.5.6 and Mesa 25.2.6; other systems are not runtime-qualified 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 limit; selections too large to fit disable surrounding-text edits for that - snapshot. Ctrl editing shortcuts produce native edit events. + snapshot. Ctrl editing shortcuts produce native edit events, including word + deletion, paragraph/page navigation, Backtab, Shift+Enter, and menu-independent + clipboard actions. Shift extends only movement; Alt/Super combinations are left + to text/layout or desktop handling. Document/selection-changing commands retire + old IME snapshot serials, and an acknowledged snapshot publishes the new basis. + Escape first cancels composition before becoming an application action. - Pointer motion, buttons, wheel/touchpad frames and cursor-shape-v1 are supported. Capture acknowledges Wayland's implicit button grab during a selection drag; requesting it without a held button/focused pointer returns `UNAVAILABLE`. @@ -215,6 +224,8 @@ python3 src/linux/tests/startup.py zig-out/bin/linux-native-tests 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/editing_fixture.py zig-out/bin/linux-service-tests +python3 src/linux/tests/window_state_fixture.py zig-out/bin/linux-service-tests python3 src/linux/tests/portal_fixture.py zig-out/bin/linux-service-tests python3 src/linux/tests/menu_fixture.py zig-out/bin/linux-service-tests python3 src/linux/tests/popup_fixture.py zig-out/bin/linux-service-tests diff --git a/src/linux/Text.zig b/src/linux/Text.zig index 5ac1bc4..f2efa29 100644 --- a/src/linux/Text.zig +++ b/src/linux/Text.zig @@ -158,6 +158,10 @@ fn publish(w: *native.Window) u32 { const s = Input.state(); const t = &w.text; if (s.text_input == null or s.text_focus != w or !t.enabled) return abi.OK; + if (t.snapshot.acknowledged_input_id < t.last_edit) { + t.dirty = true; + return abi.OK; + } // Bound unacknowledged snapshots without discarding snapshots a late IME // batch may still refer to. Keep the newest application state for later. if (t.sent_count >= 32) { @@ -277,20 +281,41 @@ pub fn deinit(w: *native.Window) void { forget(w); w.owner.gpa.free(w.text.surrounding); } +pub fn editCommand(w: *native.Window, command: u32, modifiers: u32, input_id: u64) void { + if (w.text.ime_composing) return; + // A mutation or movement makes old surrounding-text ranges unsafe. Copy + // leaves the document/selection untouched, so delayed IME batches may retain + // their previously published basis. New state is published only on app ack. + if (command != n.UHDK_EDIT_COPY and command != n.UHDK_EDIT_CANCEL) { + clearPending(w); + clearSent(w); + } + var event = std.mem.zeroes(n.z_event); + event.type = n.UHDK_EVENT_EDIT_COMMAND; + event.input_id = input_id; + event.data.edit = .{ .command = command, .extend_selection = @intCast(n.z_edit_extends_selection(command, modifiers)) }; + w.text.last_edit = input_id; + Input.send(w, &event); +} 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; + if (s.state == null) return; + if (t.ime_composing) { + if (Keyboard.edit(logical, modifiers) == n.UHDK_EDIT_CANCEL) cancel(w); + return; + } const edit = Keyboard.edit(logical, modifiers); if (edit != 0) { + // Escape cancels an XKB compose sequence before becoming an app action. + if (edit == n.UHDK_EDIT_CANCEL and t.composing) { + if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose); + endAt(w, input_id); + return; + } 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(modifiers & n.UHDK_MOD_SHIFT != 0) }; - t.last_edit = input_id; - Input.send(w, &e); + editCommand(w, edit, modifiers, input_id); return; } if (modifiers & (n.UHDK_MOD_CONTROL | n.UHDK_MOD_ALT | n.UHDK_MOD_SUPER) != 0) return; diff --git a/src/linux/WindowState.zig b/src/linux/WindowState.zig new file mode 100644 index 0000000..5a72a6b --- /dev/null +++ b/src/linux/WindowState.zig @@ -0,0 +1,60 @@ +//! xdg-shell state values from protocols/xml/xdg-shell.xml. Kept separate from +//! Wayland objects so version/unknown-state behavior can be tested on any host. +const std = @import("std"); +const n = @import("native"); + +pub const Observation = struct { + geometry: u32 = n.UHDK_WINDOW_NORMAL, + focused: bool = false, + resizing: bool = false, + suspended: bool = false, + known: u32 = n.UHDK_WINDOW_OBSERVE_RESIZING, +}; +pub fn observe(version: u32, states: []const u32) Observation { + var result: Observation = .{}; + if (version >= 6) result.known |= n.UHDK_WINDOW_OBSERVE_SUSPENDED; + for (states) |state| switch (state) { + 1 => if (result.geometry != n.UHDK_WINDOW_FULLSCREEN) { + result.geometry = n.UHDK_WINDOW_MAXIMIZED; + }, + 2 => result.geometry = n.UHDK_WINDOW_FULLSCREEN, + 3 => result.resizing = true, + 4 => result.focused = true, + 9 => result.suspended = version >= 6, + else => {}, + }; + return result; +} +pub const Capabilities = struct { + // Before v5 there is no advertisement; preserve best-effort requests. + minimize: bool = true, + maximize: bool = true, + fullscreen: bool = true, + + pub fn announced(values: []const u32) Capabilities { + var result: Capabilities = .{ .minimize = false, .maximize = false, .fullscreen = false }; + for (values) |value| switch (value) { + 2 => result.maximize = true, + 3 => result.fullscreen = true, + 4 => result.minimize = true, + else => {}, + }; + return result; + } +}; +test "Wayland observations do not invent minimization or occlusion" { + const old = observe(5, &.{ 1, 2, 3, 4, 9, 999 }); + try std.testing.expectEqual(@as(u32, n.UHDK_WINDOW_FULLSCREEN), old.geometry); + try std.testing.expect(old.focused and old.resizing and !old.suspended); + try std.testing.expectEqual(@as(u32, n.UHDK_WINDOW_OBSERVE_RESIZING), old.known); + const modern = observe(6, &.{ 9, 2, 1 }); + try std.testing.expect(modern.suspended and !modern.resizing); + try std.testing.expectEqual(@as(u32, n.UHDK_WINDOW_FULLSCREEN), modern.geometry); + try std.testing.expectEqual(@as(u32, n.UHDK_WINDOW_OBSERVE_RESIZING | n.UHDK_WINDOW_OBSERVE_SUSPENDED), modern.known); + const resumed = observe(6, &.{}); + try std.testing.expect(!resumed.suspended and !resumed.focused and !resumed.resizing); + try std.testing.expectEqual(@as(u32, n.UHDK_WINDOW_NORMAL), resumed.geometry); + const caps = Capabilities.announced(&.{ 2, 999 }); + try std.testing.expect(caps.maximize and !caps.minimize and !caps.fullscreen); + try std.testing.expect(!Capabilities.announced(&.{}).maximize); +} diff --git a/src/linux/native.zig b/src/linux/native.zig index e333b3b..7be2ab7 100644 --- a/src/linux/native.zig +++ b/src/linux/native.zig @@ -9,6 +9,7 @@ const Application = @import("../Application.zig"); const Egl = @import("Egl.zig"); const Input = @import("Input.zig"); const Desktop = @import("Desktop.zig"); +const WindowState = @import("WindowState.zig"); const Portal = @import("Portal.zig"); const Watch = @import("Watch.zig"); const Data = @import("Data.zig"); @@ -44,6 +45,8 @@ pub const Window = struct { scale: u32 = 120, focused: bool = false, state: u32 = n.UHDK_WINDOW_NORMAL, + observation: WindowState.Observation = .{}, + wm_caps: WindowState.Capabilities = .{}, flags: u32, cursor: u32 = n.UHDK_CURSOR_ARROW, text: Text.State = .{}, @@ -358,20 +361,10 @@ fn topConfigure(data: ?*anyopaque, _: ?*c.struct_xdg_toplevel, width: i32, heigh const w = window(data); if (width > 0) w.width = width; if (height > 0) w.height = height; - w.focused = false; - w.state = n.UHDK_WINDOW_NORMAL; - if (states.*.size == 0) return; - const values: [*]const u32 = @ptrCast(@alignCast(states.*.data)); - for (values[0 .. states.*.size / @sizeOf(u32)]) |value| { - switch (value) { - c.XDG_TOPLEVEL_STATE_ACTIVATED => w.focused = true, - c.XDG_TOPLEVEL_STATE_MAXIMIZED => if (w.state != n.UHDK_WINDOW_FULLSCREEN) { - w.state = n.UHDK_WINDOW_MAXIMIZED; - }, - c.XDG_TOPLEVEL_STATE_FULLSCREEN => w.state = n.UHDK_WINDOW_FULLSCREEN, - else => {}, - } - } + const values: []const u32 = if (states.*.size == 0) &.{} else @as([*]const u32, @ptrCast(@alignCast(states.*.data)))[0 .. states.*.size / @sizeOf(u32)]; + w.observation = WindowState.observe(c.xdg_toplevel_get_version(w.top), values); + w.focused = w.observation.focused; + w.state = w.observation.geometry; } fn emit(w: *Window, kind: u32) void { var event = std.mem.zeroes(n.z_event); @@ -383,7 +376,10 @@ fn topClose(data: ?*anyopaque, _: ?*c.struct_xdg_toplevel) callconv(.c) void { emit(window(data), n.UHDK_EVENT_WINDOW_CLOSE_REQUESTED); } fn bounds(_: ?*anyopaque, _: ?*c.struct_xdg_toplevel, _: i32, _: i32) callconv(.c) void {} -fn wmCaps(_: ?*anyopaque, _: ?*c.struct_xdg_toplevel, _: [*c]c.struct_wl_array) callconv(.c) void {} +fn wmCaps(data: ?*anyopaque, _: ?*c.struct_xdg_toplevel, caps: [*c]c.struct_wl_array) callconv(.c) void { + const values: []const u32 = if (caps.*.size == 0) &.{} else @as([*]const u32, @ptrCast(@alignCast(caps.*.data)))[0 .. caps.*.size / @sizeOf(u32)]; + window(data).wm_caps = WindowState.Capabilities.announced(values); +} const top_listener: c.struct_xdg_toplevel_listener = .{ .configure = topConfigure, .close = topClose, .configure_bounds = bounds, .wm_capabilities = wmCaps }; fn decorationConfigure(data: ?*anyopaque, _: ?*c.struct_zxdg_toplevel_decoration_v1, mode: u32) callconv(.c) void { const w = window(data); @@ -454,7 +450,7 @@ fn apply(w: *Window) void { w.configured = false; } w.visible = w.want_visible; - w.metrics = .{ .struct_size = @sizeOf(n.uhdk_window_metrics), .framebuffer_width = pixel_width, .framebuffer_height = pixel_height, .logical_size = .{ .width = @floatFromInt(width), .height = @floatFromInt(height) }, .scale = @as(f32, @floatFromInt(w.scale)) / 120, .state = w.state, .focused = @intFromBool(w.focused), .visible = @intFromBool(w.visible) }; + w.metrics = .{ .struct_size = @sizeOf(n.uhdk_window_metrics), .framebuffer_width = pixel_width, .framebuffer_height = pixel_height, .logical_size = .{ .width = @floatFromInt(width), .height = @floatFromInt(height) }, .scale = @as(f32, @floatFromInt(w.scale)) / 120, .state = w.state, .focused = @intFromBool(w.focused), .visible = @intFromBool(w.visible), .observations = w.observation.known, .resizing = @intFromBool(w.observation.resizing), .suspended = @intFromBool(w.observation.suspended), .minimized = 0, .occluded = 0 }; const metrics = w.metrics; w.dirty = false; w.mutex.unlock(w.owner.io); @@ -602,15 +598,22 @@ export fn z_native_execute(_: ?*n.uhdk_app, raw: [*c]const n.z_command, id: u64, w.dirty = true; }, n.Z_STATE => switch (cmd.number) { - n.UHDK_WINDOW_MINIMIZED => c.xdg_toplevel_set_minimized(w.top), + n.UHDK_WINDOW_MINIMIZED => { + if (!w.wm_caps.minimize) return n.UHDK_STATUS_UNSUPPORTED; + c.xdg_toplevel_set_minimized(w.top); + }, n.UHDK_WINDOW_MAXIMIZED => { - c.xdg_toplevel_unset_fullscreen(w.top); + if (!w.wm_caps.maximize) return n.UHDK_STATUS_UNSUPPORTED; + if (w.wm_caps.fullscreen) c.xdg_toplevel_unset_fullscreen(w.top); c.xdg_toplevel_set_maximized(w.top); }, - n.UHDK_WINDOW_FULLSCREEN => c.xdg_toplevel_set_fullscreen(w.top, null), + n.UHDK_WINDOW_FULLSCREEN => { + if (!w.wm_caps.fullscreen) return n.UHDK_STATUS_UNSUPPORTED; + c.xdg_toplevel_set_fullscreen(w.top, null); + }, else => { - c.xdg_toplevel_unset_fullscreen(w.top); - c.xdg_toplevel_unset_maximized(w.top); + if (w.wm_caps.fullscreen) c.xdg_toplevel_unset_fullscreen(w.top); + if (w.wm_caps.maximize) c.xdg_toplevel_unset_maximized(w.top); }, }, n.Z_VISIBLE => { diff --git a/src/linux/tests/editing_fixture.py b/src/linux/tests/editing_fixture.py new file mode 100644 index 0000000..d5945eb --- /dev/null +++ b/src/linux/tests/editing_fixture.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Forward isolated XKB keys to verify edit defaults without an application menu.""" +import sys +from wayland_fixture import Compositor, run + + +class EditingCompositor(Compositor): + # evdev code, XKB depressed-mask (Shift=1, Control=4, Mod1=8). + gestures = [(14, 4), (111, 4), (103, 4), (108, 5), (104, 1), (109, 0), + (15, 1), (28, 1), (111, 1), (110, 4), (110, 1), (46, 4), + (44, 5), (14, 12), (15, 4)] + + def timers(self): + # The fixture tests forwarded keys, not compositor-owned IME input. + pass + + def request(self, obj, iface, name, args): + if iface == 'xdg_toplevel' and name == 'set_title' and args[0].startswith('edit:'): + index = int(args[0].split(':')[1]) + code, mask = self.gestures[index] + keyboard = self.ids['wl_keyboard'] + if index == 0: + self.event(keyboard, 'enter', 200, next(iter(self.surfaces)), b'') + self.event(keyboard, 'modifiers', 201, mask, 0, 0, 0) + self.event(keyboard, 'key', 202, index * 2, code, 1) + self.event(keyboard, 'key', 203, index * 2 + 1, code, 0) + self.event(keyboard, 'modifiers', 204, 0, 0, 0, 0) + super().request(obj, iface, name, args) + + +if __name__ == '__main__': + run(sys.argv[1], 'editing', compositor_type=EditingCompositor) diff --git a/src/linux/tests/services.c b/src/linux/tests/services.c index 1fe7845..4826822 100644 --- a/src/linux/tests/services.c +++ b/src/linux/tests/services.c @@ -212,6 +212,129 @@ static void portals(uhdk_app *app) { assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); puts("portal results/cancellation/parent/restart: PASS"); } +static void window_observations(uhdk_app *app, int version) { + uhdk_window *w = window(app); + uhdk_window_metrics metrics = {0}; + metrics.struct_size = sizeof(metrics); + OK(uhdk_window_get_metrics(w, &metrics)); + uint32_t known = + UHDK_WINDOW_OBSERVE_RESIZING | (version >= 6 ? UHDK_WINDOW_OBSERVE_SUSPENDED : 0); + assert(metrics.observations == known); + assert(!metrics.minimized && !metrics.occluded && !metrics.suspended && !metrics.resizing); + uint64_t request; + OK(uhdk_window_set_state_async(w, UHDK_WINDOW_MINIMIZED, &request)); + assert(wait_result(app, request).status == UHDK_STATUS_UNSUPPORTED); + OK(uhdk_window_set_state_async(w, UHDK_WINDOW_FULLSCREEN, &request)); + assert(wait_result(app, request).status == UHDK_STATUS_UNSUPPORTED); + for (int phase = 0; phase < 2; ++phase) { + OK(uhdk_window_set_title_async( + w, text(phase ? "fixture:resume" : "fixture:suspend"), &request + )); + int matched = 0, completed = 0; + uint64_t end = deadline(app); + while (uhdk_app_get_time_ns(app) < end && !(matched && completed)) { + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 50000000, &events)); + for (uint64_t i = 0; i < events.len; ++i) { + if (events.ptr[i].type == UHDK_EVENT_WINDOW_METRICS) { + const uhdk_window_metrics *m = events.ptr[i].data; + assert(m->observations == known); + assert(!m->minimized && !m->occluded); + if (m->state == (phase ? UHDK_WINDOW_NORMAL : UHDK_WINDOW_FULLSCREEN) && + !!m->resizing == !phase && !!m->focused == !phase && + !!m->suspended == (!phase && version >= 6)) + matched = 1; + } else if (events.ptr[i].type == UHDK_EVENT_REQUEST_DONE) { + const uhdk_request_result *r = events.ptr[i].data; + if (r->id == request) { + OK(r->status); + completed = 1; + } + } + } + } + assert(matched && completed); + } + OK(uhdk_window_set_state_async(w, UHDK_WINDOW_MINIMIZED, &request)); + OK(wait_result(app, request).status); + OK(uhdk_window_get_metrics(w, &metrics)); + assert(!(metrics.observations & UHDK_WINDOW_OBSERVE_MINIMIZED)); + assert(metrics.state == UHDK_WINDOW_NORMAL); // Submission is not observation. + uhdk_window_destroy(w); + puts("Wayland versioned window observations/capabilities: PASS"); +} + +static void editing(uhdk_app *app) { + uhdk_window *w = window(app); + uint64_t request; + OK(uhdk_window_set_text_input_async(w, 1, &request)); + OK(wait_result(app, request).status); + const struct { + uint32_t command; + int extend; + } cases[] = { + {UHDK_EDIT_DELETE_WORD_BACKWARD, 0}, + {UHDK_EDIT_DELETE_WORD_FORWARD, 0}, + {UHDK_EDIT_PARAGRAPH_START, 0}, + {UHDK_EDIT_PARAGRAPH_END, 1}, + {UHDK_EDIT_PAGE_UP, 1}, + {UHDK_EDIT_PAGE_DOWN, 0}, + {UHDK_EDIT_BACKTAB, 0}, + {UHDK_EDIT_LINE_BREAK, 0}, + {UHDK_EDIT_CUT, 0}, + {UHDK_EDIT_COPY, 0}, + {UHDK_EDIT_PASTE, 0}, + {UHDK_EDIT_COPY, 0}, + {UHDK_EDIT_REDO, 0}, + {0, 0}, + {0, 0} + }; + uint64_t acknowledged = 0; + for (unsigned i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + uhdk_text_state state = {0}; + state.struct_size = sizeof(state); + state.surrounding_text = text("one two"); + state.selection_start = 7; + state.revision = i + 1; + state.acknowledged_input_id = acknowledged; + OK(uhdk_window_set_text_state(w, &state)); + char title[32]; + snprintf(title, sizeof(title), "edit:%u", i); + OK(uhdk_window_set_title_async(w, text(title), &request)); + int released = 0, completed = 0; + unsigned commands = 0; + uint64_t end = deadline(app); + while (uhdk_app_get_time_ns(app) < end && !(released && completed)) { + uhdk_event_list events; + OK(uhdk_app_poll_events(app, 50000000, &events)); + for (uint64_t j = 0; j < events.len; ++j) { + const uhdk_event *event = &events.ptr[j]; + assert(event->type != UHDK_EVENT_TEXT_COMMIT); + if (event->type == UHDK_EVENT_KEY) { + const uhdk_key_event *key = event->data; + if (!key->pressed) + released = 1; + } else if (event->type == UHDK_EVENT_EDIT_COMMAND) { + const uhdk_edit_event *edit = event->data; + assert(edit->command == cases[i].command); + assert(!!edit->extend_selection == cases[i].extend); + acknowledged = event->input_id; + ++commands; + } else if (event->type == UHDK_EVENT_REQUEST_DONE) { + const uhdk_request_result *r = event->data; + if (r->id == request) { + OK(r->status); + completed = 1; + } + } + } + } + assert(released && completed && commands == (cases[i].command ? 1u : 0u)); + } + uhdk_window_destroy(w); + puts("Wayland editing defaults/selection/AltGr/no duplicate text: PASS"); +} + static void input(uhdk_app *app) { uhdk_window *w = window(app); uint64_t id; @@ -379,6 +502,14 @@ static void run(uhdk_app *app, void *mode) { menus(app); return; } + if (strncmp(mode, "window-state-v", 14) == 0) { + window_observations(app, atoi(mode + 14)); + return; + } + if (strcmp(mode, "editing") == 0) { + editing(app); + return; + } if (strcmp(mode, "input") == 0) input(app); else if (strcmp(mode, "portal") == 0) diff --git a/src/linux/tests/window_state_fixture.py b/src/linux/tests/window_state_fixture.py new file mode 100644 index 0000000..4463f76 --- /dev/null +++ b/src/linux/tests/window_state_fixture.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Exercise xdg-shell versioned observations and capability changes in isolation.""" +import struct +import sys +from wayland_fixture import Compositor, run + + +class WindowStateCompositor(Compositor): + version = 6 + + def __init__(self, sock, inject=False): + super().__init__(sock, inject) + self.top = None + self.states = [] + self.caps = [2] # Maximize only; minimize/fullscreen must be rejected. + self.minimized = False + + def configure(self): + self.event(self.top, 'wm_capabilities', struct.pack('=' + 'I' * len(self.caps), *self.caps)) + self.event(self.top, 'configure', 320, 240, struct.pack('=' + 'I' * len(self.states), *self.states)) + self.serial += 1 + self.event(next(iter(self.surfaces.values())), 'configure', self.serial) + + def request(self, obj, iface, name, args): + if iface == 'xdg_surface' and name == 'get_toplevel': + self.top = args[0] + elif iface == 'wl_surface' and name == 'commit' and obj in self.surfaces: + self.configure() + return + elif iface == 'xdg_toplevel' and name == 'set_title': + if args[0] == 'fixture:suspend': + self.states = [2, 3, 4] + ([9] if self.version >= 6 else []) + self.configure() + elif args[0] == 'fixture:resume': + self.states = [] + self.caps = [2, 3, 4] + self.configure() + elif iface == 'xdg_toplevel' and name == 'set_minimized': + assert 4 in self.caps, 'client sent an unavailable minimize request' + self.minimized = True + elif iface == 'xdg_toplevel' and name == 'destroy': + assert self.minimized, 'accepted minimize request was not submitted' + elif iface == 'xdg_toplevel' and name == 'set_fullscreen': + assert 3 in self.caps, 'client sent an unavailable fullscreen request' + super().request(obj, iface, name, args) + + +if __name__ == '__main__': + for version in (5, 6): + class Versioned(WindowStateCompositor): + globals = [(name, version if name == 'xdg_wm_base' else v) + for name, v in Compositor.globals] + Versioned.version = version + run(sys.argv[1], 'window-state-v' + str(version), compositor_type=Versioned) diff --git a/src/macos/native.m b/src/macos/native.m index f40d779..21817a5 100644 --- a/src/macos/native.m +++ b/src/macos/native.m @@ -56,6 +56,11 @@ static uhdk_modifier_flags mods(NSEventModifierFlags f) { @property(nonatomic, strong) ZGL *gl; @property(nonatomic) uhdk_menu *menu; @property(nonatomic) uint32_t flags; +@property(nonatomic) BOOL resizing; +@property(nonatomic) BOOL fullscreenTransition; +@property(nonatomic) BOOL hasPendingState; +@property(nonatomic) uint32_t pendingState; +- (void)applyPendingState; - (void)metrics; @end @interface ZView : NSView @@ -75,6 +80,8 @@ static uhdk_modifier_flags mods(NSEventModifierFlags f) { @property(nonatomic) uint64_t currentInput; @property(nonatomic) uint64_t lastEdit; @property(nonatomic) uhdk_rect caret; +@property(nonatomic) BOOL awaitingSnapshot; +@property(nonatomic, strong) NSMutableArray *deferredInput; - (void)emit:(z_event *)event; - (void)cancelMarked; - (void)applyState:(const uhdk_text_state *)state; @@ -183,6 +190,7 @@ static uhdk_key logical(NSEvent *e) { _marked = NSMakeRange(NSNotFound, 0); _cursor = NSCursor.arrowCursor; _pressedModifiers = [NSMutableIndexSet indexSet]; + _deferredInput = [NSMutableArray array]; self.wantsBestResolutionOpenGLSurface = YES; } return self; @@ -211,6 +219,16 @@ static uhdk_key logical(NSEvent *e) { [super setFrameSize:size]; [self.owner metrics]; } +- (void)viewWillStartLiveResize { + [super viewWillStartLiveResize]; + self.owner.resizing = YES; + [self.owner metrics]; +} +- (void)viewDidEndLiveResize { + [super viewDidEndLiveResize]; + self.owner.resizing = NO; + [self.owner metrics]; +} - (void)viewDidChangeBackingProperties { [super viewDidChangeBackingProperties]; [self.owner metrics]; @@ -235,6 +253,13 @@ static uhdk_key logical(NSEvent *e) { [self.cursor set]; } - (void)key:(NSEvent *)event pressed:(BOOL)pressed { + if (self.textEnabled && self.awaitingSnapshot) { + __weak ZView *view = self; + [self.deferredInput addObject:^{ + [view key:event pressed:pressed]; + }]; + return; + } self.currentInput = ++self.nextInput; z_event e = {.type = UHDK_EVENT_KEY}; e.data.key.physical_key = physical(event.keyCode); @@ -243,10 +268,55 @@ static uhdk_key logical(NSEvent *e) { e.data.key.pressed = pressed; e.data.key.repeat = event.isARepeat; [self emit:&e]; - if (pressed && self.textEnabled && !(event.modifierFlags & NSEventModifierFlagCommand)) + if (pressed && self.textEnabled) [self interpretKeyEvents:@[ event ]]; self.currentInput = 0; } +// The menu gets first refusal. Provide standard editing even without an Edit menu. +- (BOOL)performKeyEquivalent:(NSEvent *)event { + if (!self.textEnabled || !(event.modifierFlags & NSEventModifierFlagCommand)) + return [super performKeyEquivalent:event]; + if ([NSApp.mainMenu performKeyEquivalent:event]) + return YES; + uint32_t key = logical(event); + NSEventModifierFlags flags = + event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask; + BOOL plain = !(flags & (NSEventModifierFlagOption | NSEventModifierFlagControl)); + SEL selector = NULL; + if (plain) { + switch (key) { + case 'c': + if (!(flags & NSEventModifierFlagShift)) + selector = @selector(copy:); + break; + case 'x': + if (!(flags & NSEventModifierFlagShift)) + selector = @selector(cut:); + break; + case 'v': + if (!(flags & NSEventModifierFlagShift)) + selector = @selector(paste:); + break; + case 'a': + if (!(flags & NSEventModifierFlagShift)) + selector = @selector(selectAll:); + break; + case 'z': + selector = flags & NSEventModifierFlagShift ? @selector(redo:) : @selector(undo:); + break; + } + } + if (selector) { + [self doCommandBySelector:selector]; + return YES; + } + if (key == UHDK_KEY_LEFT || key == UHDK_KEY_RIGHT || key == UHDK_KEY_UP || + key == UHDK_KEY_DOWN || key == UHDK_KEY_BACKSPACE || key == UHDK_KEY_DELETE) { + [self key:event pressed:YES]; + return YES; + } + return [super performKeyEquivalent:event]; +} - (void)keyDown:(NSEvent *)event { [self key:event pressed:YES]; } @@ -376,6 +446,12 @@ static uhdk_key logical(NSEvent *e) { end = utf16Offset(self.textBuffer, state->selection_start + state->selection_len); self.selection = NSMakeRange(start, end - start); [self.inputContext invalidateCharacterCoordinates]; + self.awaitingSnapshot = NO; + while (!self.awaitingSnapshot && self.deferredInput.count) { + void (^input)(void) = self.deferredInput.firstObject; + [self.deferredInput removeObjectAtIndex:0]; + input(); + } } - (void)emitText:(NSString *)s type:(uint32_t)type @@ -395,6 +471,14 @@ static uhdk_key logical(NSEvent *e) { [self emit:&e]; } - (void)insertText:(id)value replacementRange:(NSRange)range { + if (self.awaitingSnapshot) { + __weak ZView *view = self; + id copy = [value copy]; + [self.deferredInput addObject:^{ + [view insertText:copy replacementRange:range]; + }]; + return; + } BOOL ownInput = self.currentInput == 0; if (ownInput) self.currentInput = ++self.nextInput; @@ -425,6 +509,14 @@ static uhdk_key logical(NSEvent *e) { self.currentInput = 0; } - (void)setMarkedText:(id)value selectedRange:(NSRange)selected replacementRange:(NSRange)range { + if (self.awaitingSnapshot) { + __weak ZView *view = self; + id copy = [value copy]; + [self.deferredInput addObject:^{ + [view setMarkedText:copy selectedRange:selected replacementRange:range]; + }]; + return; + } BOOL ownInput = self.currentInput == 0; if (ownInput) self.currentInput = ++self.nextInput; @@ -477,14 +569,14 @@ static uhdk_key logical(NSEvent *e) { return self.marked; } - (NSRange)selectedRange { - return self.selection; + return self.awaitingSnapshot ? NSMakeRange(NSNotFound, 0) : self.selection; } - (NSArray *)validAttributesForMarkedText { return @[]; } - (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)r actualRange:(NSRangePointer)actual { - if (r.location == NSNotFound || r.location > self.textBuffer.length) { + if (self.awaitingSnapshot || r.location == NSNotFound || r.location > self.textBuffer.length) { if (actual) *actual = NSMakeRange(NSNotFound, 0); return nil; @@ -501,33 +593,72 @@ static uhdk_key logical(NSEvent *e) { - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actual { (void)range; if (actual) - *actual = self.selection; + *actual = [self selectedRange]; NSRect rect = NSMakeRect(self.caret.x, self.caret.y, self.caret.width, self.caret.height); return [self.window convertRectToScreen:[self convertRect:rect toView:nil]]; } - (void)doCommandBySelector:(SEL)selector { + if (!self.textEnabled) + return; + if (self.awaitingSnapshot) { + __weak ZView *view = self; + [self.deferredInput addObject:^{ + [view doCommandBySelector:selector]; + }]; + return; + } + if ([self hasMarkedText]) { + if (selector == @selector(cancelOperation:)) + [self cancelMarked]; + return; + } + BOOL ownInput = self.currentInput == 0; NSString *s = NSStringFromSelector(selector); BOOL extend = [s containsString:@"AndModifySelection"]; s = [s stringByReplacingOccurrencesOfString:@"AndModifySelection" withString:@""]; NSDictionary *commands = @{ @"deleteBackward:" : @(UHDK_EDIT_DELETE_BACKWARD), @"deleteForward:" : @(UHDK_EDIT_DELETE_FORWARD), + @"deleteWordBackward:" : @(UHDK_EDIT_DELETE_WORD_BACKWARD), + @"deleteWordForward:" : @(UHDK_EDIT_DELETE_WORD_FORWARD), + @"deleteToBeginningOfLine:" : @(UHDK_EDIT_DELETE_LINE_START), + @"deleteToEndOfLine:" : @(UHDK_EDIT_DELETE_LINE_END), + @"deleteToBeginningOfParagraph:" : @(UHDK_EDIT_DELETE_PARAGRAPH_START), + @"deleteToEndOfParagraph:" : @(UHDK_EDIT_DELETE_PARAGRAPH_END), @"moveLeft:" : @(UHDK_EDIT_MOVE_LEFT), - @"moveBackward:" : @(UHDK_EDIT_MOVE_LEFT), + @"moveBackward:" : @(UHDK_EDIT_MOVE_BACKWARD), @"moveRight:" : @(UHDK_EDIT_MOVE_RIGHT), - @"moveForward:" : @(UHDK_EDIT_MOVE_RIGHT), + @"moveForward:" : @(UHDK_EDIT_MOVE_FORWARD), @"moveUp:" : @(UHDK_EDIT_MOVE_UP), @"moveDown:" : @(UHDK_EDIT_MOVE_DOWN), + @"moveToLeftEndOfLine:" : @(UHDK_EDIT_LINE_LEFT_END), + @"moveToRightEndOfLine:" : @(UHDK_EDIT_LINE_RIGHT_END), @"moveToBeginningOfLine:" : @(UHDK_EDIT_LINE_START), @"moveToEndOfLine:" : @(UHDK_EDIT_LINE_END), @"moveToBeginningOfDocument:" : @(UHDK_EDIT_DOCUMENT_START), @"moveToEndOfDocument:" : @(UHDK_EDIT_DOCUMENT_END), @"moveWordLeft:" : @(UHDK_EDIT_WORD_LEFT), - @"moveWordBackward:" : @(UHDK_EDIT_WORD_LEFT), + @"moveWordBackward:" : @(UHDK_EDIT_WORD_BACKWARD), @"moveWordRight:" : @(UHDK_EDIT_WORD_RIGHT), - @"moveWordForward:" : @(UHDK_EDIT_WORD_RIGHT), + @"moveWordForward:" : @(UHDK_EDIT_WORD_FORWARD), + @"insertNewlineIgnoringFieldEditor:" : @(UHDK_EDIT_NEWLINE), + @"insertTabIgnoringFieldEditor:" : @(UHDK_EDIT_TAB), @"insertNewline:" : @(UHDK_EDIT_NEWLINE), @"insertTab:" : @(UHDK_EDIT_TAB), + @"insertBacktab:" : @(UHDK_EDIT_BACKTAB), + @"insertLineBreak:" : @(UHDK_EDIT_LINE_BREAK), + @"insertParagraphSeparator:" : @(UHDK_EDIT_NEWLINE), + @"cancelOperation:" : @(UHDK_EDIT_CANCEL), + @"moveParagraphBackward:" : @(UHDK_EDIT_PARAGRAPH_START), + @"moveParagraphForward:" : @(UHDK_EDIT_PARAGRAPH_END), + @"moveToBeginningOfParagraph:" : @(UHDK_EDIT_PARAGRAPH_START), + @"moveToEndOfParagraph:" : @(UHDK_EDIT_PARAGRAPH_END), + @"pageUp:" : @(UHDK_EDIT_PAGE_UP), + @"pageDown:" : @(UHDK_EDIT_PAGE_DOWN), + @"scrollPageUp:" : @(UHDK_EDIT_SCROLL_PAGE_UP), + @"scrollPageDown:" : @(UHDK_EDIT_SCROLL_PAGE_DOWN), + @"scrollToBeginningOfDocument:" : @(UHDK_EDIT_SCROLL_DOCUMENT_START), + @"scrollToEndOfDocument:" : @(UHDK_EDIT_SCROLL_DOCUMENT_END), @"copy:" : @(UHDK_EDIT_COPY), @"cut:" : @(UHDK_EDIT_CUT), @"paste:" : @(UHDK_EDIT_PASTE), @@ -536,49 +667,20 @@ static uhdk_key logical(NSEvent *e) { @"redo:" : @(UHDK_EDIT_REDO) }; NSNumber *command = commands[s]; - if (!command) { - if ([s isEqualToString:@"cancelOperation:"]) - [self cancelMarked]; + if (!command) return; - } [self editID]; z_event e = {.type = UHDK_EVENT_EDIT_COMMAND}; e.data.edit.command = command.unsignedIntValue; - e.data.edit.extend_selection = extend; + e.data.edit.extend_selection = + extend && ([s hasPrefix:@"move"] || [s isEqualToString:@"pageUp:"] || + [s isEqualToString:@"pageDown:"]); + // The application owns layout and edit policy. Never predict its selection + // or document changes using a second, potentially different text engine. + self.awaitingSnapshot = YES; [self emit:&e]; - // Keep common edit operations coherent for subsequent input before an ack. - NSRange r = self.selection; - if (!extend) { - if (command.unsignedIntValue == UHDK_EDIT_SELECT_ALL) - self.selection = NSMakeRange(0, self.textBuffer.length); - else if (command.unsignedIntValue == UHDK_EDIT_DELETE_BACKWARD || - command.unsignedIntValue == UHDK_EDIT_DELETE_FORWARD) { - if (!r.length && command.unsignedIntValue == UHDK_EDIT_DELETE_BACKWARD && r.location) - r = [self.textBuffer rangeOfComposedCharacterSequenceAtIndex:r.location - 1]; - else if (!r.length && r.location < self.textBuffer.length && - command.unsignedIntValue == UHDK_EDIT_DELETE_FORWARD) - r = [self.textBuffer rangeOfComposedCharacterSequenceAtIndex:r.location]; - [self.textBuffer deleteCharactersInRange:r]; - self.selection = NSMakeRange(r.location, 0); - } else if (command.unsignedIntValue == UHDK_EDIT_NEWLINE || - command.unsignedIntValue == UHDK_EDIT_TAB) { - [self.textBuffer - replaceCharactersInRange:r - withString:command.unsignedIntValue == UHDK_EDIT_NEWLINE ? @"\n" - : @"\t"]; - self.selection = NSMakeRange(r.location + 1, 0); - } else if (command.unsignedIntValue == UHDK_EDIT_MOVE_LEFT) { - NSUInteger end = r.location; - if (!r.length && end) - end = [self.textBuffer rangeOfComposedCharacterSequenceAtIndex:end - 1].location; - self.selection = NSMakeRange(end, 0); - } else if (command.unsignedIntValue == UHDK_EDIT_MOVE_RIGHT) { - NSUInteger end = NSMaxRange(r); - if (!r.length && end < self.textBuffer.length) - end = NSMaxRange([self.textBuffer rangeOfComposedCharacterSequenceAtIndex:end]); - self.selection = NSMakeRange(end, 0); - } - } + if (ownInput) + self.currentInput = 0; } - (NSDragOperation)draggingEntered:(id)sender { return ([sender.draggingPasteboard @@ -618,6 +720,30 @@ static uhdk_key logical(NSEvent *e) { } @end @implementation ZWindow +- (void)applyPendingState { + if (!self.hasPendingState || self.fullscreenTransition) + return; + uint32_t state = self.pendingState; + self.hasPendingState = NO; + BOOL full = (self.window.styleMask & NSWindowStyleMaskFullScreen) != 0; + if ((state == UHDK_WINDOW_FULLSCREEN) != full) { + if (self.window.isMiniaturized) + [self.window deminiaturize:nil]; + self.hasPendingState = full; // Apply minimize/zoom after leaving fullscreen. + self.fullscreenTransition = YES; + [self.window toggleFullScreen:nil]; + return; + } + if (state == UHDK_WINDOW_MINIMIZED) + [self.window miniaturize:nil]; + else { + if (self.window.isMiniaturized) + [self.window deminiaturize:nil]; + if (!full && ((state == UHDK_WINDOW_MAXIMIZED) != self.window.isZoomed)) + [self.window zoom:nil]; + } + [self metrics]; +} - (void)metrics { if (!self.window || !self.view) return; @@ -628,17 +754,23 @@ static uhdk_key logical(NSEvent *e) { CGLUnlockContext(gl); } NSRect bounds = self.view.bounds, pixels = [self.view convertRectToBacking:bounds]; - uhdk_window_metrics m = {.struct_size = sizeof(m), - .logical_size = {bounds.size.width, bounds.size.height}, - .framebuffer_width = (uint32_t)MAX(0, pixels.size.width), - .framebuffer_height = (uint32_t)MAX(0, pixels.size.height), - .scale = self.window.backingScaleFactor, - .focused = self.window.isKeyWindow, - .visible = self.window.isVisible}; - m.state = self.window.isMiniaturized ? UHDK_WINDOW_MINIMIZED - : (self.window.styleMask & NSWindowStyleMaskFullScreen) ? UHDK_WINDOW_FULLSCREEN - : self.window.isZoomed ? UHDK_WINDOW_MAXIMIZED - : UHDK_WINDOW_NORMAL; + uhdk_window_metrics m = { + .struct_size = sizeof(m), + .logical_size = {bounds.size.width, bounds.size.height}, + .framebuffer_width = (uint32_t)MAX(0, pixels.size.width), + .framebuffer_height = (uint32_t)MAX(0, pixels.size.height), + .scale = self.window.backingScaleFactor, + .focused = self.window.isKeyWindow, + .visible = self.window.isVisible, + .observations = UHDK_WINDOW_OBSERVE_MINIMIZED | UHDK_WINDOW_OBSERVE_RESIZING | + UHDK_WINDOW_OBSERVE_OCCLUDED, + .minimized = self.window.isMiniaturized, + .resizing = self.resizing, + .occluded = !(self.window.occlusionState & NSWindowOcclusionStateVisible) + }; + m.state = (self.window.styleMask & NSWindowStyleMaskFullScreen) ? UHDK_WINDOW_FULLSCREEN + : self.window.isZoomed ? UHDK_WINDOW_MAXIMIZED + : UHDK_WINDOW_NORMAL; z_metrics(self.handle, &m); z_event e = {.type = UHDK_EVENT_WINDOW_REDRAW, .window = self.handle}; z_emit(host.app, &e); @@ -653,6 +785,10 @@ static uhdk_key logical(NSEvent *e) { (void)n; [self metrics]; } +- (void)windowDidChangeOcclusionState:(NSNotification *)n { + (void)n; + [self metrics]; +} - (void)windowDidChangeBackingProperties:(NSNotification *)n { (void)n; [self metrics]; @@ -665,6 +801,8 @@ static uhdk_key logical(NSEvent *e) { - (void)windowDidResignKey:(NSNotification *)n { (void)n; [self.view.pressedModifiers removeAllIndexes]; + [self.view.deferredInput removeAllObjects]; + self.view.awaitingSnapshot = NO; [self.view cancelMarked]; if (self.view.capture) { self.view.capture = NO; @@ -683,13 +821,34 @@ static uhdk_key logical(NSEvent *e) { (void)n; [self metrics]; } +- (void)windowWillEnterFullScreen:(NSNotification *)n { + (void)n; + self.fullscreenTransition = YES; +} +- (void)windowWillExitFullScreen:(NSNotification *)n { + (void)n; + self.fullscreenTransition = YES; +} +- (void)windowDidFailToEnterFullScreen:(NSWindow *)window { + (void)window; + self.fullscreenTransition = NO; + self.hasPendingState = NO; + [self metrics]; +} +- (void)windowDidFailToExitFullScreen:(NSWindow *)window { + [self windowDidFailToEnterFullScreen:window]; +} - (void)windowDidEnterFullScreen:(NSNotification *)n { (void)n; + self.fullscreenTransition = NO; [self metrics]; + [self applyPendingState]; } - (void)windowDidExitFullScreen:(NSNotification *)n { (void)n; + self.fullscreenTransition = NO; [self metrics]; + [self applyPendingState]; } @end @interface ZMenuAction : NSObject @@ -715,12 +874,11 @@ static uhdk_key logical(NSEvent *e) { e.type = UHDK_EVENT_QUIT_REQUESTED; e.window = NULL; } else if (self.role >= UHDK_MENU_ROLE_COPY && self.role <= UHDK_MENU_ROLE_REDO) { - static const uint32_t edit[] = {UHDK_EDIT_COPY, UHDK_EDIT_CUT, UHDK_EDIT_PASTE, - UHDK_EDIT_SELECT_ALL, UHDK_EDIT_UNDO, UHDK_EDIT_REDO}; - e.type = UHDK_EVENT_EDIT_COMMAND; - e.data.edit.command = edit[self.role - UHDK_MENU_ROLE_COPY]; - e.input_id = ++w.view.nextInput; - w.view.lastEdit = e.input_id; + static const char *selectors[] = { + "copy:", "cut:", "paste:", "selectAll:", "undo:", "redo:" + }; + [w.view doCommandBySelector:sel_registerName(selectors[self.role - UHDK_MENU_ROLE_COPY])]; + return; } z_emit(host.app, &e); } @@ -1316,21 +1474,11 @@ uhdk_status z_native_execute(uhdk_app *app, const z_command *cmd, uint64_t reque [w.window setContentSize:NSMakeSize(cmd->size.width, cmd->size.height)]; [w metrics]; break; - case Z_STATE: { - BOOL full = (w.window.styleMask & NSWindowStyleMaskFullScreen) != 0; - if ((cmd->number == UHDK_WINDOW_FULLSCREEN) != full) - [w.window toggleFullScreen:nil]; - else if (cmd->number == UHDK_WINDOW_MINIMIZED) - [w.window miniaturize:nil]; - else { - if (w.window.isMiniaturized) - [w.window deminiaturize:nil]; - if ((cmd->number == UHDK_WINDOW_MAXIMIZED) != w.window.isZoomed) - [w.window zoom:nil]; - } - [w metrics]; + case Z_STATE: + w.pendingState = (uint32_t)cmd->number; + w.hasPendingState = YES; + [w applyPendingState]; break; - } case Z_VISIBLE: if (cmd->number) [w.window orderFront:nil]; @@ -1436,8 +1584,11 @@ uhdk_status z_native_execute(uhdk_app *app, const z_command *cmd, uint64_t reque break; case Z_TEXT_ENABLE: w.view.textEnabled = cmd->number != 0; - if (!cmd->number) + if (!cmd->number) { + [w.view.deferredInput removeAllObjects]; + w.view.awaitingSnapshot = NO; [w.view cancelMarked]; + } break; case Z_TEXT_STATE: [w.view applyState:&cmd->text_state]; diff --git a/src/native.h b/src/native.h index 4252c00..4be8f03 100644 --- a/src/native.h +++ b/src/native.h @@ -3,6 +3,7 @@ #define UHDK_ENABLE_NATIVE #include "uhdk.h" #include +#include "edit_keys.h" /* Private producer storage. Converted to public pointer payloads at poll. */ typedef struct z_request_result { uhdk_request_id id; diff --git a/src/windows/native.c b/src/windows/native.c index f294611..6bee622 100644 --- a/src/windows/native.c +++ b/src/windows/native.c @@ -57,7 +57,7 @@ struct Window { uint32_t flags; uhdk_size min_size, max_size; float scale; - BOOL text_enabled, tracking, fullscreen, composing, suppress_char; + BOOL text_enabled, tracking, fullscreen, composing, suppress_char, resizing; WCHAR high_surrogate; uint64_t input_id, last_edit; uhdk_text_state text_state; @@ -180,10 +180,16 @@ static void metrics(Window *w) { m.framebuffer_width = (uint32_t)(r.right - r.left); m.framebuffer_height = (uint32_t)(r.bottom - r.top); m.logical_size = (uhdk_size){m.framebuffer_width / w->scale, m.framebuffer_height / w->scale}; - m.state = w->fullscreen ? UHDK_WINDOW_FULLSCREEN - : IsIconic(w->hwnd) ? UHDK_WINDOW_MINIMIZED - : IsZoomed(w->hwnd) ? UHDK_WINDOW_MAXIMIZED - : UHDK_WINDOW_NORMAL; + WINDOWPLACEMENT placement = {0}; + placement.length = sizeof(placement); + GetWindowPlacement(w->hwnd, &placement); + m.observations = UHDK_WINDOW_OBSERVE_MINIMIZED | UHDK_WINDOW_OBSERVE_RESIZING; + m.minimized = IsIconic(w->hwnd); + m.resizing = w->resizing; + m.state = w->fullscreen ? UHDK_WINDOW_FULLSCREEN + : (IsZoomed(w->hwnd) || (m.minimized && (placement.flags & WPF_RESTORETOMAXIMIZED))) + ? UHDK_WINDOW_MAXIMIZED + : UHDK_WINDOW_NORMAL; m.focused = GetFocus() == w->hwnd; m.visible = IsWindowVisible(w->hwnd); z_metrics(w->handle, &m); @@ -228,13 +234,15 @@ static void textEvent(Window *w, uint32_t type, const WCHAR *text, int length, i profileFree(s); } static void editEvent(Window *w, uint32_t command) { + if (w->composing) + return; z_event e = {0}; e.type = UHDK_EVENT_EDIT_COMMAND; e.window = w->handle; e.input_id = w->input_id = ++host.input_id; w->last_edit = e.input_id; e.data.edit.command = command; - e.data.edit.extend_selection = !!(modifiers() & UHDK_MOD_SHIFT); + e.data.edit.extend_selection = (uhdk_bool)z_edit_extends_selection(command, modifiers()); z_emit(host.app, &e); } static void menuEvent(Window *w, const uhdk_menu_item *item) { @@ -366,29 +374,6 @@ static uint32_t physicalKey(LPARAM param) { } return keys[scan]; } -static uint32_t editKey(WPARAM key, uint32_t mods) { - BOOL ctrl = !!(mods & UHDK_MOD_CONTROL); - switch (key) { - case VK_BACK: - return UHDK_EDIT_DELETE_BACKWARD; - case VK_DELETE: - return UHDK_EDIT_DELETE_FORWARD; - case VK_LEFT: - return ctrl ? UHDK_EDIT_WORD_LEFT : UHDK_EDIT_MOVE_LEFT; - case VK_RIGHT: - return ctrl ? UHDK_EDIT_WORD_RIGHT : UHDK_EDIT_MOVE_RIGHT; - case VK_UP: - return UHDK_EDIT_MOVE_UP; - case VK_DOWN: - return UHDK_EDIT_MOVE_DOWN; - case VK_HOME: - return ctrl ? UHDK_EDIT_DOCUMENT_START : UHDK_EDIT_LINE_START; - case VK_END: - return ctrl ? UHDK_EDIT_DOCUMENT_END : UHDK_EDIT_LINE_END; - default: - return 0; - } -} static void imePosition(Window *w) { HIMC ime = ImmGetContext(w->hwnd); if (!ime) @@ -497,6 +482,14 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } case WM_ERASEBKGND: return 1; + case WM_ENTERSIZEMOVE: + w->resizing = TRUE; + metrics(w); + break; + case WM_EXITSIZEMOVE: + w->resizing = FALSE; + metrics(w); + break; case WM_SIZE: case WM_SHOWWINDOW: metrics(w); @@ -657,7 +650,7 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { w->suppress_char = FALSE; const uhdk_menu_item *item = shortcut(w->menu_source ? w->menu_source : host.menu, key, mods & 15); - if (item) { + if (item && !w->composing) { w->suppress_char = TRUE; menuEvent(w, item); return 0; @@ -674,9 +667,12 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { e.data.key.repeat = down && !!(lp & (1u << 30)); z_emit(host.app, &e); if (down && w->text_enabled && !w->composing) { - uint32_t command = editKey(wp, mods); - if (command) + uint32_t command = z_edit_key(key, mods); + if (command) { + w->suppress_char = TRUE; editEvent(w, command); + return 0; + } } break; } @@ -686,14 +682,8 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return TRUE; if (!w->text_enabled || w->composing || w->suppress_char) return 0; - if (wp == '\r') { - editEvent(w, UHDK_EDIT_NEWLINE); - return 0; - } - if (wp == '\t') { - editEvent(w, UHDK_EDIT_TAB); - return 0; - } + // Enter/Tab are semantic keydown commands. Ignore their translated + // WM_CHAR messages even if another queued key changed suppress_char. if (wp < 32 || wp == 127) return 0; WCHAR chars[2]; @@ -1703,8 +1693,11 @@ uhdk_status z_native_execute(uhdk_app *app, const z_command *cmd, uint64_t reque break; } case Z_STATE: { - BOOL full = cmd->number == UHDK_WINDOW_FULLSCREEN; + BOOL minimize = cmd->number == UHDK_WINDOW_MINIMIZED; + BOOL full = cmd->number == UHDK_WINDOW_FULLSCREEN || (minimize && w->fullscreen); if (full && !w->fullscreen) { + if (IsIconic(w->hwnd)) + ShowWindow(w->hwnd, SW_RESTORE); w->placement.length = sizeof(w->placement); GetWindowPlacement(w->hwnd, &w->placement); w->saved_style = GetWindowLongPtrW(w->hwnd, GWL_STYLE); @@ -1724,10 +1717,12 @@ uhdk_status z_native_execute(uhdk_app *app, const z_command *cmd, uint64_t reque SetWindowPos(w->hwnd, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); } - if (!full) - ShowWindow(w->hwnd, cmd->number == UHDK_WINDOW_MINIMIZED ? SW_MINIMIZE - : cmd->number == UHDK_WINDOW_MAXIMIZED ? SW_MAXIMIZE - : SW_RESTORE); + if (minimize) + ShowWindow(w->hwnd, SW_MINIMIZE); + else if (!full) + ShowWindow(w->hwnd, cmd->number == UHDK_WINDOW_MAXIMIZED ? SW_MAXIMIZE : SW_RESTORE); + else if (IsIconic(w->hwnd)) + ShowWindow(w->hwnd, SW_RESTORE); metrics(w); break; }