macos inputs

This commit is contained in:
Peter Li 2026-09-18 16:30:35 -07:00
parent 85acfb5b67
commit c0d923cbed
17 changed files with 862 additions and 187 deletions

View File

@ -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 builds need Zig alone; the optional `shell.nix` supplies maintainer tools and
NixOS runtime library paths. See [Linux support and limitations](src/linux/README.md). NixOS runtime library paths. See [Linux support and limitations](src/linux/README.md).
## 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 ## Build
```sh ```sh

View File

@ -324,8 +324,25 @@ typedef struct uhdk_window_desc {
uhdk_window *parent; /* Optional; must outlive child. */ uhdk_window *parent; /* Optional; must outlive child. */
} uhdk_window_desc; } uhdk_window_desc;
/* On Wayland, visible is application show/hide intent, not compositor visibility. /* Observation bits say which independent window facts are known. A clear bit
* State is the last observable state; Wayland does not report minimization. * 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. */ * Async size/state completion acknowledges submission; geometry arrives here. */
typedef struct uhdk_window_metrics { typedef struct uhdk_window_metrics {
uint32_t struct_size; uint32_t struct_size;
@ -336,6 +353,11 @@ typedef struct uhdk_window_metrics {
float scale; float scale;
uhdk_bool focused; uhdk_bool focused;
uhdk_bool visible; uhdk_bool visible;
uhdk_window_observation_flags observations;
uhdk_bool minimized;
uhdk_bool resizing;
uhdk_bool occluded;
uhdk_bool suspended;
} uhdk_window_metrics; } uhdk_window_metrics;
UHDK_API uhdk_status UHDK_CALL uhdk_window_create_async( 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. * 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 * acknowledged_input_id is the last applied text/edit input ID. Snapshots behind
* outstanding native edits are ignored; publish again after consuming the batch. * 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 * Commit/preedit events carry text + an optional replacement range. A preedit
* updates temporary composition, never the permanent document by itself. * updates temporary composition, never the permanent document by itself.
*/ */
@ -870,6 +897,19 @@ enum {
UHDK_REQUEST_FILE_INFO 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; typedef uint32_t uhdk_edit_command;
enum { enum {
UHDK_EDIT_COPY = 1, UHDK_EDIT_COPY = 1,
@ -891,7 +931,30 @@ enum {
UHDK_EDIT_WORD_LEFT, UHDK_EDIT_WORD_LEFT,
UHDK_EDIT_WORD_RIGHT, UHDK_EDIT_WORD_RIGHT,
UHDK_EDIT_NEWLINE, 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. /* 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 { typedef struct uhdk_edit_event {
uhdk_edit_command command; uhdk_edit_command command;
uhdk_bool extend_selection; uhdk_bool extend_selection; /* Only meaningful for caret movement. */
} uhdk_edit_event; } uhdk_edit_event;
typedef struct uhdk_drop_files_event { typedef struct uhdk_drop_files_event {

View File

@ -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.expectEqual(@as(u32, c.UHDK_EVENT_WAKE), preferences.next.?.event.type);
try std.testing.expect(application.pending_tail == preferences.next); 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));
}

100
src/edit_keys.h Normal file
View File

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

View File

@ -260,7 +260,7 @@ fn emitKey(value: Key) void {
Popup.key(event.data.key.logical_key, value.down, s.serial); Popup.key(event.data.key.logical_key, value.down, s.serial);
return; 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; s.menu_keys[value.code] = true;
return; return;
} }

View File

@ -48,31 +48,9 @@ pub fn logical(sym: u32) u32 {
}; };
} }
pub fn edit(key: u32, mods: u32) u32 { pub fn edit(key: u32, mods: u32) u32 {
const ctrl = mods & n.UHDK_MOD_CONTROL != 0; return n.z_edit_key(key, mods);
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,
};
} }
test "physical positions and editor shortcuts" { test "physical positions and editor shortcuts" {
try std.testing.expectEqual(@as(u32, 4), physical(30)); try std.testing.expectEqual(@as(u32, 4), physical(30));
try std.testing.expectEqual(@as(u32, 29), physical(44)); try std.testing.expectEqual(@as(u32, 29), physical(44));

View File

@ -4,17 +4,14 @@ const n = @import("native");
const native = @import("native.zig"); const native = @import("native.zig");
const runtime = @import("../runtime.zig"); const runtime = @import("../runtime.zig");
const Input = @import("Input.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 { pub fn activate(window: *native.Window, item: *const n.uhdk_menu_item, input_id: u64) void {
var event = std.mem.zeroes(n.z_event); var event = std.mem.zeroes(n.z_event);
if (item.role == n.UHDK_MENU_ROLE_QUIT) { if (item.role == n.UHDK_MENU_ROLE_QUIT) {
event.type = n.UHDK_EVENT_QUIT_REQUESTED; event.type = n.UHDK_EVENT_QUIT_REQUESTED;
n.z_emit(runtime.toAppHandle(window.owner), &event); 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) { } else if (item.role >= n.UHDK_MENU_ROLE_COPY and item.role <= n.UHDK_MENU_ROLE_REDO) {
event.type = n.UHDK_EVENT_EDIT_COMMAND; Text.editCommand(window, @as(u32, n.UHDK_EDIT_COPY) + item.role - n.UHDK_MENU_ROLE_COPY, 0, input_id);
event.input_id = input_id;
event.data.edit.command = @as(u32, n.UHDK_EDIT_COPY) + item.role - n.UHDK_MENU_ROLE_COPY;
window.text.last_edit = input_id;
Input.send(window, &event);
} else { } else {
event.type = n.UHDK_EVENT_MENU_COMMAND; event.type = n.UHDK_EVENT_MENU_COMMAND;
event.data.menu.command_id = item.command_id; event.data.menu.command_id = item.command_id;

View File

@ -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. application's show/hide intent, not proof that the compositor displays it.
- Size/state commands acknowledge submission. Confirmed geometry and focus arrive - Size/state commands acknowledge submission. Confirmed geometry and focus arrive
through metrics. Wayland cannot report minimized state, force focus, or directly 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. input serial; the compositor can decline to focus the window.
- Fractional scale uses fractional-scale-v1 plus viewporter; otherwise preferred - Fractional scale uses fractional-scale-v1 plus viewporter; otherwise preferred
integer buffer scale is used when wl_surface v6 is available. Older compositors 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 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 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 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. - Pointer motion, buttons, wheel/touchpad frames and cursor-shape-v1 are supported.
Capture acknowledges Wayland's implicit button grab during a selection drag; Capture acknowledges Wayland's implicit button grab during a selection drag;
requesting it without a held button/focused pointer returns `UNAVAILABLE`. 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 python3 src/linux/tests/disconnect.py zig-out/bin/linux-native-tests
# In the optional nix-shell, or with dbus-next/dbus-daemon installed: # 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/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/portal_fixture.py zig-out/bin/linux-service-tests
python3 src/linux/tests/menu_fixture.py zig-out/bin/linux-service-tests python3 src/linux/tests/menu_fixture.py zig-out/bin/linux-service-tests
python3 src/linux/tests/popup_fixture.py zig-out/bin/linux-service-tests python3 src/linux/tests/popup_fixture.py zig-out/bin/linux-service-tests

View File

@ -158,6 +158,10 @@ fn publish(w: *native.Window) u32 {
const s = Input.state(); const s = Input.state();
const t = &w.text; const t = &w.text;
if (s.text_input == null or s.text_focus != w or !t.enabled) return abi.OK; 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 // Bound unacknowledged snapshots without discarding snapshots a late IME
// batch may still refer to. Keep the newest application state for later. // batch may still refer to. Keep the newest application state for later.
if (t.sent_count >= 32) { if (t.sent_count >= 32) {
@ -277,20 +281,41 @@ pub fn deinit(w: *native.Window) void {
forget(w); forget(w);
w.owner.gpa.free(w.text.surrounding); 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 { pub fn key(w: *native.Window, sym: u32, logical: u32, input_id: u64, modifiers: u32, plain: []const u8) void {
const s = Input.state(); const s = Input.state();
const t = &w.text; 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); const edit = Keyboard.edit(logical, modifiers);
if (edit != 0) { 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); if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose);
endAt(w, input_id); endAt(w, input_id);
var e = std.mem.zeroes(n.z_event); return;
e.type = n.UHDK_EVENT_EDIT_COMMAND; }
e.input_id = input_id; if (s.compose != null) c.z_xkb_compose_state_reset.?(s.compose);
e.data.edit = .{ .command = edit, .extend_selection = @intFromBool(modifiers & n.UHDK_MOD_SHIFT != 0) }; endAt(w, input_id);
t.last_edit = input_id; editCommand(w, edit, modifiers, input_id);
Input.send(w, &e);
return; return;
} }
if (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;

60
src/linux/WindowState.zig Normal file
View File

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

View File

@ -9,6 +9,7 @@ const Application = @import("../Application.zig");
const Egl = @import("Egl.zig"); const Egl = @import("Egl.zig");
const Input = @import("Input.zig"); const Input = @import("Input.zig");
const Desktop = @import("Desktop.zig"); const Desktop = @import("Desktop.zig");
const WindowState = @import("WindowState.zig");
const Portal = @import("Portal.zig"); const Portal = @import("Portal.zig");
const Watch = @import("Watch.zig"); const Watch = @import("Watch.zig");
const Data = @import("Data.zig"); const Data = @import("Data.zig");
@ -44,6 +45,8 @@ pub const Window = struct {
scale: u32 = 120, scale: u32 = 120,
focused: bool = false, focused: bool = false,
state: u32 = n.UHDK_WINDOW_NORMAL, state: u32 = n.UHDK_WINDOW_NORMAL,
observation: WindowState.Observation = .{},
wm_caps: WindowState.Capabilities = .{},
flags: u32, flags: u32,
cursor: u32 = n.UHDK_CURSOR_ARROW, cursor: u32 = n.UHDK_CURSOR_ARROW,
text: Text.State = .{}, text: Text.State = .{},
@ -358,20 +361,10 @@ fn topConfigure(data: ?*anyopaque, _: ?*c.struct_xdg_toplevel, width: i32, heigh
const w = window(data); const w = window(data);
if (width > 0) w.width = width; if (width > 0) w.width = width;
if (height > 0) w.height = height; if (height > 0) w.height = height;
w.focused = false; const values: []const u32 = if (states.*.size == 0) &.{} else @as([*]const u32, @ptrCast(@alignCast(states.*.data)))[0 .. states.*.size / @sizeOf(u32)];
w.state = n.UHDK_WINDOW_NORMAL; w.observation = WindowState.observe(c.xdg_toplevel_get_version(w.top), values);
if (states.*.size == 0) return; w.focused = w.observation.focused;
const values: [*]const u32 = @ptrCast(@alignCast(states.*.data)); w.state = w.observation.geometry;
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 => {},
}
}
} }
fn emit(w: *Window, kind: u32) void { fn emit(w: *Window, kind: u32) void {
var event = std.mem.zeroes(n.z_event); 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); emit(window(data), n.UHDK_EVENT_WINDOW_CLOSE_REQUESTED);
} }
fn bounds(_: ?*anyopaque, _: ?*c.struct_xdg_toplevel, _: i32, _: i32) callconv(.c) void {} 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 }; 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 { fn decorationConfigure(data: ?*anyopaque, _: ?*c.struct_zxdg_toplevel_decoration_v1, mode: u32) callconv(.c) void {
const w = window(data); const w = window(data);
@ -454,7 +450,7 @@ fn apply(w: *Window) void {
w.configured = false; w.configured = false;
} }
w.visible = w.want_visible; 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; const metrics = w.metrics;
w.dirty = false; w.dirty = false;
w.mutex.unlock(w.owner.io); 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; w.dirty = true;
}, },
n.Z_STATE => switch (cmd.number) { 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 => { 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); 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 => { else => {
c.xdg_toplevel_unset_fullscreen(w.top); if (w.wm_caps.fullscreen) c.xdg_toplevel_unset_fullscreen(w.top);
c.xdg_toplevel_unset_maximized(w.top); if (w.wm_caps.maximize) c.xdg_toplevel_unset_maximized(w.top);
}, },
}, },
n.Z_VISIBLE => { n.Z_VISIBLE => {

View File

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

View File

@ -212,6 +212,129 @@ static void portals(uhdk_app *app) {
assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED); assert(wait_result(app, id).status == UHDK_STATUS_CANCELLED);
puts("portal results/cancellation/parent/restart: PASS"); 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) { static void input(uhdk_app *app) {
uhdk_window *w = window(app); uhdk_window *w = window(app);
uint64_t id; uint64_t id;
@ -379,6 +502,14 @@ static void run(uhdk_app *app, void *mode) {
menus(app); menus(app);
return; 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) if (strcmp(mode, "input") == 0)
input(app); input(app);
else if (strcmp(mode, "portal") == 0) else if (strcmp(mode, "portal") == 0)

View File

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

View File

@ -56,6 +56,11 @@ static uhdk_modifier_flags mods(NSEventModifierFlags f) {
@property(nonatomic, strong) ZGL *gl; @property(nonatomic, strong) ZGL *gl;
@property(nonatomic) uhdk_menu *menu; @property(nonatomic) uhdk_menu *menu;
@property(nonatomic) uint32_t flags; @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; - (void)metrics;
@end @end
@interface ZView : NSView <NSTextInputClient> @interface ZView : NSView <NSTextInputClient>
@ -75,6 +80,8 @@ static uhdk_modifier_flags mods(NSEventModifierFlags f) {
@property(nonatomic) uint64_t currentInput; @property(nonatomic) uint64_t currentInput;
@property(nonatomic) uint64_t lastEdit; @property(nonatomic) uint64_t lastEdit;
@property(nonatomic) uhdk_rect caret; @property(nonatomic) uhdk_rect caret;
@property(nonatomic) BOOL awaitingSnapshot;
@property(nonatomic, strong) NSMutableArray<void (^)(void)> *deferredInput;
- (void)emit:(z_event *)event; - (void)emit:(z_event *)event;
- (void)cancelMarked; - (void)cancelMarked;
- (void)applyState:(const uhdk_text_state *)state; - (void)applyState:(const uhdk_text_state *)state;
@ -183,6 +190,7 @@ static uhdk_key logical(NSEvent *e) {
_marked = NSMakeRange(NSNotFound, 0); _marked = NSMakeRange(NSNotFound, 0);
_cursor = NSCursor.arrowCursor; _cursor = NSCursor.arrowCursor;
_pressedModifiers = [NSMutableIndexSet indexSet]; _pressedModifiers = [NSMutableIndexSet indexSet];
_deferredInput = [NSMutableArray array];
self.wantsBestResolutionOpenGLSurface = YES; self.wantsBestResolutionOpenGLSurface = YES;
} }
return self; return self;
@ -211,6 +219,16 @@ static uhdk_key logical(NSEvent *e) {
[super setFrameSize:size]; [super setFrameSize:size];
[self.owner metrics]; [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 { - (void)viewDidChangeBackingProperties {
[super viewDidChangeBackingProperties]; [super viewDidChangeBackingProperties];
[self.owner metrics]; [self.owner metrics];
@ -235,6 +253,13 @@ static uhdk_key logical(NSEvent *e) {
[self.cursor set]; [self.cursor set];
} }
- (void)key:(NSEvent *)event pressed:(BOOL)pressed { - (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; self.currentInput = ++self.nextInput;
z_event e = {.type = UHDK_EVENT_KEY}; z_event e = {.type = UHDK_EVENT_KEY};
e.data.key.physical_key = physical(event.keyCode); 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.pressed = pressed;
e.data.key.repeat = event.isARepeat; e.data.key.repeat = event.isARepeat;
[self emit:&e]; [self emit:&e];
if (pressed && self.textEnabled && !(event.modifierFlags & NSEventModifierFlagCommand)) if (pressed && self.textEnabled)
[self interpretKeyEvents:@[ event ]]; [self interpretKeyEvents:@[ event ]];
self.currentInput = 0; 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 { - (void)keyDown:(NSEvent *)event {
[self key:event pressed:YES]; [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); end = utf16Offset(self.textBuffer, state->selection_start + state->selection_len);
self.selection = NSMakeRange(start, end - start); self.selection = NSMakeRange(start, end - start);
[self.inputContext invalidateCharacterCoordinates]; [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 - (void)emitText:(NSString *)s
type:(uint32_t)type type:(uint32_t)type
@ -395,6 +471,14 @@ static uhdk_key logical(NSEvent *e) {
[self emit:&e]; [self emit:&e];
} }
- (void)insertText:(id)value replacementRange:(NSRange)range { - (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; BOOL ownInput = self.currentInput == 0;
if (ownInput) if (ownInput)
self.currentInput = ++self.nextInput; self.currentInput = ++self.nextInput;
@ -425,6 +509,14 @@ static uhdk_key logical(NSEvent *e) {
self.currentInput = 0; self.currentInput = 0;
} }
- (void)setMarkedText:(id)value selectedRange:(NSRange)selected replacementRange:(NSRange)range { - (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; BOOL ownInput = self.currentInput == 0;
if (ownInput) if (ownInput)
self.currentInput = ++self.nextInput; self.currentInput = ++self.nextInput;
@ -477,14 +569,14 @@ static uhdk_key logical(NSEvent *e) {
return self.marked; return self.marked;
} }
- (NSRange)selectedRange { - (NSRange)selectedRange {
return self.selection; return self.awaitingSnapshot ? NSMakeRange(NSNotFound, 0) : self.selection;
} }
- (NSArray<NSAttributedStringKey> *)validAttributesForMarkedText { - (NSArray<NSAttributedStringKey> *)validAttributesForMarkedText {
return @[]; return @[];
} }
- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)r - (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)r
actualRange:(NSRangePointer)actual { 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) if (actual)
*actual = NSMakeRange(NSNotFound, 0); *actual = NSMakeRange(NSNotFound, 0);
return nil; return nil;
@ -501,33 +593,72 @@ static uhdk_key logical(NSEvent *e) {
- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actual { - (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actual {
(void)range; (void)range;
if (actual) if (actual)
*actual = self.selection; *actual = [self selectedRange];
NSRect rect = NSMakeRect(self.caret.x, self.caret.y, self.caret.width, self.caret.height); NSRect rect = NSMakeRect(self.caret.x, self.caret.y, self.caret.width, self.caret.height);
return [self.window convertRectToScreen:[self convertRect:rect toView:nil]]; return [self.window convertRectToScreen:[self convertRect:rect toView:nil]];
} }
- (void)doCommandBySelector:(SEL)selector { - (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); NSString *s = NSStringFromSelector(selector);
BOOL extend = [s containsString:@"AndModifySelection"]; BOOL extend = [s containsString:@"AndModifySelection"];
s = [s stringByReplacingOccurrencesOfString:@"AndModifySelection" withString:@""]; s = [s stringByReplacingOccurrencesOfString:@"AndModifySelection" withString:@""];
NSDictionary *commands = @{ NSDictionary *commands = @{
@"deleteBackward:" : @(UHDK_EDIT_DELETE_BACKWARD), @"deleteBackward:" : @(UHDK_EDIT_DELETE_BACKWARD),
@"deleteForward:" : @(UHDK_EDIT_DELETE_FORWARD), @"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), @"moveLeft:" : @(UHDK_EDIT_MOVE_LEFT),
@"moveBackward:" : @(UHDK_EDIT_MOVE_LEFT), @"moveBackward:" : @(UHDK_EDIT_MOVE_BACKWARD),
@"moveRight:" : @(UHDK_EDIT_MOVE_RIGHT), @"moveRight:" : @(UHDK_EDIT_MOVE_RIGHT),
@"moveForward:" : @(UHDK_EDIT_MOVE_RIGHT), @"moveForward:" : @(UHDK_EDIT_MOVE_FORWARD),
@"moveUp:" : @(UHDK_EDIT_MOVE_UP), @"moveUp:" : @(UHDK_EDIT_MOVE_UP),
@"moveDown:" : @(UHDK_EDIT_MOVE_DOWN), @"moveDown:" : @(UHDK_EDIT_MOVE_DOWN),
@"moveToLeftEndOfLine:" : @(UHDK_EDIT_LINE_LEFT_END),
@"moveToRightEndOfLine:" : @(UHDK_EDIT_LINE_RIGHT_END),
@"moveToBeginningOfLine:" : @(UHDK_EDIT_LINE_START), @"moveToBeginningOfLine:" : @(UHDK_EDIT_LINE_START),
@"moveToEndOfLine:" : @(UHDK_EDIT_LINE_END), @"moveToEndOfLine:" : @(UHDK_EDIT_LINE_END),
@"moveToBeginningOfDocument:" : @(UHDK_EDIT_DOCUMENT_START), @"moveToBeginningOfDocument:" : @(UHDK_EDIT_DOCUMENT_START),
@"moveToEndOfDocument:" : @(UHDK_EDIT_DOCUMENT_END), @"moveToEndOfDocument:" : @(UHDK_EDIT_DOCUMENT_END),
@"moveWordLeft:" : @(UHDK_EDIT_WORD_LEFT), @"moveWordLeft:" : @(UHDK_EDIT_WORD_LEFT),
@"moveWordBackward:" : @(UHDK_EDIT_WORD_LEFT), @"moveWordBackward:" : @(UHDK_EDIT_WORD_BACKWARD),
@"moveWordRight:" : @(UHDK_EDIT_WORD_RIGHT), @"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), @"insertNewline:" : @(UHDK_EDIT_NEWLINE),
@"insertTab:" : @(UHDK_EDIT_TAB), @"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), @"copy:" : @(UHDK_EDIT_COPY),
@"cut:" : @(UHDK_EDIT_CUT), @"cut:" : @(UHDK_EDIT_CUT),
@"paste:" : @(UHDK_EDIT_PASTE), @"paste:" : @(UHDK_EDIT_PASTE),
@ -536,49 +667,20 @@ static uhdk_key logical(NSEvent *e) {
@"redo:" : @(UHDK_EDIT_REDO) @"redo:" : @(UHDK_EDIT_REDO)
}; };
NSNumber *command = commands[s]; NSNumber *command = commands[s];
if (!command) { if (!command)
if ([s isEqualToString:@"cancelOperation:"])
[self cancelMarked];
return; return;
}
[self editID]; [self editID];
z_event e = {.type = UHDK_EVENT_EDIT_COMMAND}; z_event e = {.type = UHDK_EVENT_EDIT_COMMAND};
e.data.edit.command = command.unsignedIntValue; 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]; [self emit:&e];
// Keep common edit operations coherent for subsequent input before an ack. if (ownInput)
NSRange r = self.selection; self.currentInput = 0;
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);
}
}
} }
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender { - (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
return ([sender.draggingPasteboard return ([sender.draggingPasteboard
@ -618,6 +720,30 @@ static uhdk_key logical(NSEvent *e) {
} }
@end @end
@implementation ZWindow @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 { - (void)metrics {
if (!self.window || !self.view) if (!self.window || !self.view)
return; return;
@ -628,15 +754,21 @@ static uhdk_key logical(NSEvent *e) {
CGLUnlockContext(gl); CGLUnlockContext(gl);
} }
NSRect bounds = self.view.bounds, pixels = [self.view convertRectToBacking:bounds]; NSRect bounds = self.view.bounds, pixels = [self.view convertRectToBacking:bounds];
uhdk_window_metrics m = {.struct_size = sizeof(m), uhdk_window_metrics m = {
.struct_size = sizeof(m),
.logical_size = {bounds.size.width, bounds.size.height}, .logical_size = {bounds.size.width, bounds.size.height},
.framebuffer_width = (uint32_t)MAX(0, pixels.size.width), .framebuffer_width = (uint32_t)MAX(0, pixels.size.width),
.framebuffer_height = (uint32_t)MAX(0, pixels.size.height), .framebuffer_height = (uint32_t)MAX(0, pixels.size.height),
.scale = self.window.backingScaleFactor, .scale = self.window.backingScaleFactor,
.focused = self.window.isKeyWindow, .focused = self.window.isKeyWindow,
.visible = self.window.isVisible}; .visible = self.window.isVisible,
m.state = self.window.isMiniaturized ? UHDK_WINDOW_MINIMIZED .observations = UHDK_WINDOW_OBSERVE_MINIMIZED | UHDK_WINDOW_OBSERVE_RESIZING |
: (self.window.styleMask & NSWindowStyleMaskFullScreen) ? UHDK_WINDOW_FULLSCREEN 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 : self.window.isZoomed ? UHDK_WINDOW_MAXIMIZED
: UHDK_WINDOW_NORMAL; : UHDK_WINDOW_NORMAL;
z_metrics(self.handle, &m); z_metrics(self.handle, &m);
@ -653,6 +785,10 @@ static uhdk_key logical(NSEvent *e) {
(void)n; (void)n;
[self metrics]; [self metrics];
} }
- (void)windowDidChangeOcclusionState:(NSNotification *)n {
(void)n;
[self metrics];
}
- (void)windowDidChangeBackingProperties:(NSNotification *)n { - (void)windowDidChangeBackingProperties:(NSNotification *)n {
(void)n; (void)n;
[self metrics]; [self metrics];
@ -665,6 +801,8 @@ static uhdk_key logical(NSEvent *e) {
- (void)windowDidResignKey:(NSNotification *)n { - (void)windowDidResignKey:(NSNotification *)n {
(void)n; (void)n;
[self.view.pressedModifiers removeAllIndexes]; [self.view.pressedModifiers removeAllIndexes];
[self.view.deferredInput removeAllObjects];
self.view.awaitingSnapshot = NO;
[self.view cancelMarked]; [self.view cancelMarked];
if (self.view.capture) { if (self.view.capture) {
self.view.capture = NO; self.view.capture = NO;
@ -683,13 +821,34 @@ static uhdk_key logical(NSEvent *e) {
(void)n; (void)n;
[self metrics]; [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)windowDidEnterFullScreen:(NSNotification *)n {
(void)n; (void)n;
self.fullscreenTransition = NO;
[self metrics]; [self metrics];
[self applyPendingState];
} }
- (void)windowDidExitFullScreen:(NSNotification *)n { - (void)windowDidExitFullScreen:(NSNotification *)n {
(void)n; (void)n;
self.fullscreenTransition = NO;
[self metrics]; [self metrics];
[self applyPendingState];
} }
@end @end
@interface ZMenuAction : NSObject @interface ZMenuAction : NSObject
@ -715,12 +874,11 @@ static uhdk_key logical(NSEvent *e) {
e.type = UHDK_EVENT_QUIT_REQUESTED; e.type = UHDK_EVENT_QUIT_REQUESTED;
e.window = NULL; e.window = NULL;
} else if (self.role >= UHDK_MENU_ROLE_COPY && self.role <= UHDK_MENU_ROLE_REDO) { } 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, static const char *selectors[] = {
UHDK_EDIT_SELECT_ALL, UHDK_EDIT_UNDO, UHDK_EDIT_REDO}; "copy:", "cut:", "paste:", "selectAll:", "undo:", "redo:"
e.type = UHDK_EVENT_EDIT_COMMAND; };
e.data.edit.command = edit[self.role - UHDK_MENU_ROLE_COPY]; [w.view doCommandBySelector:sel_registerName(selectors[self.role - UHDK_MENU_ROLE_COPY])];
e.input_id = ++w.view.nextInput; return;
w.view.lastEdit = e.input_id;
} }
z_emit(host.app, &e); 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.window setContentSize:NSMakeSize(cmd->size.width, cmd->size.height)];
[w metrics]; [w metrics];
break; break;
case Z_STATE: { case Z_STATE:
BOOL full = (w.window.styleMask & NSWindowStyleMaskFullScreen) != 0; w.pendingState = (uint32_t)cmd->number;
if ((cmd->number == UHDK_WINDOW_FULLSCREEN) != full) w.hasPendingState = YES;
[w.window toggleFullScreen:nil]; [w applyPendingState];
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];
break; break;
}
case Z_VISIBLE: case Z_VISIBLE:
if (cmd->number) if (cmd->number)
[w.window orderFront:nil]; [w.window orderFront:nil];
@ -1436,8 +1584,11 @@ uhdk_status z_native_execute(uhdk_app *app, const z_command *cmd, uint64_t reque
break; break;
case Z_TEXT_ENABLE: case Z_TEXT_ENABLE:
w.view.textEnabled = cmd->number != 0; w.view.textEnabled = cmd->number != 0;
if (!cmd->number) if (!cmd->number) {
[w.view.deferredInput removeAllObjects];
w.view.awaitingSnapshot = NO;
[w.view cancelMarked]; [w.view cancelMarked];
}
break; break;
case Z_TEXT_STATE: case Z_TEXT_STATE:
[w.view applyState:&cmd->text_state]; [w.view applyState:&cmd->text_state];

View File

@ -3,6 +3,7 @@
#define UHDK_ENABLE_NATIVE #define UHDK_ENABLE_NATIVE
#include "uhdk.h" #include "uhdk.h"
#include <stddef.h> #include <stddef.h>
#include "edit_keys.h"
/* Private producer storage. Converted to public pointer payloads at poll. */ /* Private producer storage. Converted to public pointer payloads at poll. */
typedef struct z_request_result { typedef struct z_request_result {
uhdk_request_id id; uhdk_request_id id;

View File

@ -57,7 +57,7 @@ struct Window {
uint32_t flags; uint32_t flags;
uhdk_size min_size, max_size; uhdk_size min_size, max_size;
float scale; float scale;
BOOL text_enabled, tracking, fullscreen, composing, suppress_char; BOOL text_enabled, tracking, fullscreen, composing, suppress_char, resizing;
WCHAR high_surrogate; WCHAR high_surrogate;
uint64_t input_id, last_edit; uint64_t input_id, last_edit;
uhdk_text_state text_state; uhdk_text_state text_state;
@ -180,9 +180,15 @@ static void metrics(Window *w) {
m.framebuffer_width = (uint32_t)(r.right - r.left); m.framebuffer_width = (uint32_t)(r.right - r.left);
m.framebuffer_height = (uint32_t)(r.bottom - r.top); 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.logical_size = (uhdk_size){m.framebuffer_width / w->scale, m.framebuffer_height / w->scale};
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 m.state = w->fullscreen ? UHDK_WINDOW_FULLSCREEN
: IsIconic(w->hwnd) ? UHDK_WINDOW_MINIMIZED : (IsZoomed(w->hwnd) || (m.minimized && (placement.flags & WPF_RESTORETOMAXIMIZED)))
: IsZoomed(w->hwnd) ? UHDK_WINDOW_MAXIMIZED ? UHDK_WINDOW_MAXIMIZED
: UHDK_WINDOW_NORMAL; : UHDK_WINDOW_NORMAL;
m.focused = GetFocus() == w->hwnd; m.focused = GetFocus() == w->hwnd;
m.visible = IsWindowVisible(w->hwnd); m.visible = IsWindowVisible(w->hwnd);
@ -228,13 +234,15 @@ static void textEvent(Window *w, uint32_t type, const WCHAR *text, int length, i
profileFree(s); profileFree(s);
} }
static void editEvent(Window *w, uint32_t command) { static void editEvent(Window *w, uint32_t command) {
if (w->composing)
return;
z_event e = {0}; z_event e = {0};
e.type = UHDK_EVENT_EDIT_COMMAND; e.type = UHDK_EVENT_EDIT_COMMAND;
e.window = w->handle; e.window = w->handle;
e.input_id = w->input_id = ++host.input_id; e.input_id = w->input_id = ++host.input_id;
w->last_edit = e.input_id; w->last_edit = e.input_id;
e.data.edit.command = command; 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); z_emit(host.app, &e);
} }
static void menuEvent(Window *w, const uhdk_menu_item *item) { static void menuEvent(Window *w, const uhdk_menu_item *item) {
@ -366,29 +374,6 @@ static uint32_t physicalKey(LPARAM param) {
} }
return keys[scan]; 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) { static void imePosition(Window *w) {
HIMC ime = ImmGetContext(w->hwnd); HIMC ime = ImmGetContext(w->hwnd);
if (!ime) if (!ime)
@ -497,6 +482,14 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
} }
case WM_ERASEBKGND: case WM_ERASEBKGND:
return 1; 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_SIZE:
case WM_SHOWWINDOW: case WM_SHOWWINDOW:
metrics(w); metrics(w);
@ -657,7 +650,7 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
w->suppress_char = FALSE; w->suppress_char = FALSE;
const uhdk_menu_item *item = const uhdk_menu_item *item =
shortcut(w->menu_source ? w->menu_source : host.menu, key, mods & 15); shortcut(w->menu_source ? w->menu_source : host.menu, key, mods & 15);
if (item) { if (item && !w->composing) {
w->suppress_char = TRUE; w->suppress_char = TRUE;
menuEvent(w, item); menuEvent(w, item);
return 0; 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)); e.data.key.repeat = down && !!(lp & (1u << 30));
z_emit(host.app, &e); z_emit(host.app, &e);
if (down && w->text_enabled && !w->composing) { if (down && w->text_enabled && !w->composing) {
uint32_t command = editKey(wp, mods); uint32_t command = z_edit_key(key, mods);
if (command) if (command) {
w->suppress_char = TRUE;
editEvent(w, command); editEvent(w, command);
return 0;
}
} }
break; break;
} }
@ -686,14 +682,8 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
return TRUE; return TRUE;
if (!w->text_enabled || w->composing || w->suppress_char) if (!w->text_enabled || w->composing || w->suppress_char)
return 0; return 0;
if (wp == '\r') { // Enter/Tab are semantic keydown commands. Ignore their translated
editEvent(w, UHDK_EDIT_NEWLINE); // WM_CHAR messages even if another queued key changed suppress_char.
return 0;
}
if (wp == '\t') {
editEvent(w, UHDK_EDIT_TAB);
return 0;
}
if (wp < 32 || wp == 127) if (wp < 32 || wp == 127)
return 0; return 0;
WCHAR chars[2]; WCHAR chars[2];
@ -1703,8 +1693,11 @@ uhdk_status z_native_execute(uhdk_app *app, const z_command *cmd, uint64_t reque
break; break;
} }
case Z_STATE: { 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 (full && !w->fullscreen) {
if (IsIconic(w->hwnd))
ShowWindow(w->hwnd, SW_RESTORE);
w->placement.length = sizeof(w->placement); w->placement.length = sizeof(w->placement);
GetWindowPlacement(w->hwnd, &w->placement); GetWindowPlacement(w->hwnd, &w->placement);
w->saved_style = GetWindowLongPtrW(w->hwnd, GWL_STYLE); 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, SetWindowPos(w->hwnd, NULL, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
} }
if (!full) if (minimize)
ShowWindow(w->hwnd, cmd->number == UHDK_WINDOW_MINIMIZED ? SW_MINIMIZE ShowWindow(w->hwnd, SW_MINIMIZE);
: cmd->number == UHDK_WINDOW_MAXIMIZED ? SW_MAXIMIZE else if (!full)
: SW_RESTORE); ShowWindow(w->hwnd, cmd->number == UHDK_WINDOW_MAXIMIZED ? SW_MAXIMIZE : SW_RESTORE);
else if (IsIconic(w->hwnd))
ShowWindow(w->hwnd, SW_RESTORE);
metrics(w); metrics(w);
break; break;
} }