diff options
| -rw-r--r-- | README.md | 77 | ||||
| -rw-r--r-- | include/yasl/yasl.h | 23 | ||||
| -rw-r--r-- | meson.build | 11 | ||||
| -rw-r--r-- | meson.options | 1 | ||||
| -rw-r--r-- | src/cocoa/window.m | 78 | ||||
| -rw-r--r-- | src/wayland/internal.h | 1 | ||||
| -rw-r--r-- | src/wayland/pointer.c | 26 | ||||
| -rw-r--r-- | src/wayland/surface.c | 2 | ||||
| -rw-r--r-- | src/x11/window.c | 239 | ||||
| -rw-r--r-- | tests/common/test_backends.c | 17 | ||||
| -rw-r--r-- | tests/helpers/run_backends.py | 6 | ||||
| -rw-r--r-- | tests/meson.build | 44 |
12 files changed, 478 insertions, 47 deletions
@@ -33,6 +33,12 @@ with a negative timeout, or waits up to a positive number of milliseconds. Pending input or redraw work returns immediately; call `yasl_display_flush()` after processing input and scheduling redraws. +Call `yasl_surface_set_cursor(surface, YASL_CURSOR_HIDDEN)` to hide the system +mouse and tablet cursor over a surface while continuing to receive pointer +input. The choice persists across motion and re-entry; select another cursor, +such as `YASL_CURSOR_ARROW`, to show it again. `YASL_CURSOR_NONE` is a compatible +alias for `YASL_CURSOR_HIDDEN`. + Include `<yasl/loop.h>` to use the optional event loop, file watches, or timers. Timers use one callback model and nanoseconds throughout; use `YASL_MS_TO_NS(5)` for a five-millisecond interval. Add application timers to @@ -82,6 +88,7 @@ Input queues, frame clocks, backend entry points, and protocol helpers are priva Use `yasl_display_caps()` to check optional features. X11 supports CPU pixel buffers, keyboard and pointer input, and clipboard transfer. +Tablet pressure, tilt, and erasers use XInput 2 when the server exposes it. It supports the primary selection and window placement. It does not yet support YASL popups, drag operations, or IME preedit text. Those features return their normal unsupported result. @@ -117,8 +124,9 @@ are declared only in `<yasl/native.h>`; handles are borrowed until destruction. Copy a payload if it must live longer. - Input queues grow rather than silently dropping events at a fixed limit. If allocation fails, pending input is discarded and `YASL_INPUT_RESET` is - delivered before later events. Apps must clear held keys/buttons and input - composition state on this event. + delivered before later events. X11 also sends a reset when input devices are + removed or disabled. Apps must clear held keys/buttons and input composition + state on this event. - Clipboard requests retain their original target. Destroying that target cancels delivery, rather than redirecting the paste to a focused window. Unsupported primary selections are no-ops, never aliases for the clipboard. @@ -155,7 +163,7 @@ ninja -C build test Linux builds include each backend whose dependencies are installed. Wayland needs `wayland-client`, `wayland-cursor`, and `xkbcommon`. -X11 needs `x11` and `xkbcommon`. +X11 needs `x11`, `xi` (libXi), and `xkbcommon`. Use `-Dwayland=enabled` or `-Dx11=enabled` to require a backend. Use `-Dwayland=disabled` or `-Dx11=disabled` to omit a backend. At least one backend must be built. @@ -178,6 +186,69 @@ For memory and undefined-behavior checks, configure a separate build with On macOS, the same option runs native Cocoa window tests. These tests need a desktop session. +## Tablet drawing example + +Build and run the same C application on Wayland, X11, or Cocoa: + +```sh +meson setup build +meson compile -C build +./build/yasl-tablet +./build/yasl-tablet --backend wayland +./build/yasl-tablet --backend x11 +# On macOS: +./build/yasl-tablet --backend cocoa +``` + +Draw with the pen tip: pressure controls brush width. Flip to the eraser to +remove ink. The window shows the selected backend, tool, contact state, raw +pressure (0..65535), percentage, and tilt in degrees, with a live pressure bar, +brush outline, and tilt vector. The system cursor is hidden so the brush overlay +serves as the drawing cursor. Mouse drawing uses a fixed width. Press C or +Space to clear and Escape to quit. Resizing and display scale changes preserve +the drawing. `--smoke-test` presents one frame and exits for automated checks. + +[`examples/tablet.c`](examples/tablet.c) includes only `<yasl/yasl.h>` and the C +standard library. It uses CPU framebuffers and `yasl_surface_input_poll()` on +every backend. Tablet events arrive as `YASL_INPUT_MOUSE`, distinguished by +`mouse.device == YASL_PTR_DEV_PEN` or `YASL_PTR_DEV_ERASER`; `mouse.pen` contains +pressure and signed tilt. Button 0 is tip contact on every backend. Track tip +press/release events: zero pressure alone does not mean release. Hover and side +buttons do not paint, and focus loss, leave, or `YASL_INPUT_RESET` cancels a stroke. +Stationary pressure changes arrive as motion events and update the brush too. + +`YASL_CAP_TABLET` indicates backend/protocol support, not whether a tablet is +plugged in or which axes it has. Missing axes are zero. Wayland requires the +compositor's `zwp_tablet_manager_v2`; X11 requires XInput 2 and driver-provided +`Abs Pressure`/`Abs Tilt X`/`Abs Tilt Y` valuators. Cocoa handles both tablet mouse +subtypes and native tablet point/proximity events. X11 identifies erasers by +the Wacom tool property or the driver's device name. Hardware and driver +support determine which readings are available. The example displays a message +when the server has no tablet protocol and still allows mouse drawing. + +The test suite checks synthetic Wayland/XInput samples and the drawing behavior +without hardware, including pressure-only changes, sparse axes, eraser input, +hover, release, reset, and scale changes. Linux integration tests launch the +example on isolated Wayland and X11 servers; macOS integration tests also check +Cocoa tablet event translation. Physical tablet testing is still needed to +validate a particular device/driver combination. + +For an end-to-end X11 pressure test without hardware, install Xorg's `dummy` +video driver and `inputtest` input driver, then run: + +```sh +meson configure build -Dx11_tablet_tests=true +meson test -C build tablet-x11-native --print-errorlogs +``` + +This starts an isolated Xorg server with virtual pen and eraser devices, sends +real driver samples, checks YASL's shared events through the example, and verifies +its presented window pixels. It checks stationary pressure changes, release +without valuators, and eraser identification through the driver's tool property. +The test disables automatic physical input/GPU attachment and cleans up its +server. Set `YASL_XORG_MODULE_PATH` to a comma-separated module search path if +the test drivers are outside Xorg's default directories. + ## Quick Example ```c diff --git a/include/yasl/yasl.h b/include/yasl/yasl.h index 30bf591..77666f8 100644 --- a/include/yasl/yasl.h +++ b/include/yasl/yasl.h @@ -78,6 +78,8 @@ enum yasl_display_cap : uint32_t { YASL_CAP_CSD = 1 << 5, YASL_CAP_NATIVE_GRAPHICS = 1 << 6, YASL_CAP_FRAME_PACING = 1 << 7, /* compositor/display-driven draws */ + /* Backend/protocol supports tablet axes, independent of attached hardware. */ + YASL_CAP_TABLET = 1 << 8, }; enum yasl_selection : uint8_t { @@ -103,7 +105,8 @@ enum yasl_cursor { YASL_CURSOR_RESIZE_NESW, YASL_CURSOR_RESIZE_EW, YASL_CURSOR_RESIZE_NS, - YASL_CURSOR_NONE, + YASL_CURSOR_HIDDEN, /* Hide the system cursor; pointer input is unchanged. */ + YASL_CURSOR_NONE = YASL_CURSOR_HIDDEN, /* Compatibility alias. */ }; enum yasl_resize_edge : uint32_t { @@ -206,9 +209,9 @@ enum yasl_pointer_device { }; struct yasl_pointer_pen { - uint16_t pressure; - int8_t tilt_x; - int8_t tilt_y; + uint16_t pressure; /* 0..65535, normalized to the device's pressure range. */ + int8_t tilt_x; /* Degrees, -90..90; positive toward the right. */ + int8_t tilt_y; /* Degrees, -90..90; positive toward the bottom. */ }; struct yasl_key_event { @@ -224,12 +227,16 @@ struct yasl_key_event { struct yasl_mouse_event { struct yasl_vector2i pos; - int16_t button; + int16_t button; /* Press/release: 0 = primary/tip, 1 = middle, 2 = secondary. + * Motion/leave: informational; track presses and releases. */ int16_t scroll_lines; enum yasl_pointer_kind kind; enum yasl_pointer_device device; uint32_t mods; float scroll_delta; + /* Pen/eraser samples include the current axes, also on pressure-only + * motion. Missing axes and non-tablet devices report zero. A primary + * release or leave ends contact; zero pressure alone is not a release. */ struct yasl_pointer_pen pen; }; @@ -240,8 +247,8 @@ enum yasl_input_kind { YASL_INPUT_PASTE, YASL_INPUT_DROP_FILES, YASL_INPUT_TEXT, - /* Allocation failure broke the input stream. Clear all held keys/buttons - * and composition state before processing subsequent events. */ + /* The input stream was interrupted (e.g. allocation failure or device + * removal). Clear held keys/buttons and composition state. */ YASL_INPUT_RESET, }; @@ -394,6 +401,8 @@ YASL_API uint32_t yasl_surface_state(const struct yasl_surface *s); * Tearing may bypass pacing even when the capability is present. */ YASL_API struct yasl_timing yasl_surface_timing(const struct yasl_surface *s); YASL_API uint64_t yasl_surface_frame_time_ns(struct yasl_surface *s, uint64_t now); +/* Set the mouse and tablet cursor for this surface, including future entries. + * YASL_CURSOR_HIDDEN stays hidden until another cursor is selected. */ YASL_API void yasl_surface_set_cursor(struct yasl_surface *s, enum yasl_cursor cursor); YASL_API void yasl_surface_set_background_color(struct yasl_surface *s, uint32_t bg); diff --git a/meson.build b/meson.build index 2bcad87..c258765 100644 --- a/meson.build +++ b/meson.build @@ -28,10 +28,11 @@ if system == 'linux' xkbcommon = dependency('xkbcommon', required: get_option('wayland')) has_wayland = wl_client.found() and wl_cursor.found() and xkbcommon.found() x11 = dependency('x11', required: get_option('x11')) + xi = dependency('xi', required: get_option('x11')) x11_xkb = dependency('xkbcommon', required: get_option('x11')) - has_x11 = x11.found() and x11_xkb.found() + has_x11 = x11.found() and xi.found() and x11_xkb.found() if has_x11 - deps += [x11, x11_xkb] + deps += [x11, xi, x11_xkb] endif threads = dependency('threads') m_dep = cc.find_library('m', required: false) @@ -106,4 +107,10 @@ executable( dependencies: deps, ) +tablet_example = executable( + 'yasl-tablet', + 'examples/tablet.c', + dependencies: yasl_dep, +) + subdir('tests') diff --git a/meson.options b/meson.options index 1498b4e..5bf2334 100644 --- a/meson.options +++ b/meson.options @@ -1,3 +1,4 @@ option('wayland', type: 'feature', value: 'auto', description: 'Build the Wayland backend.') option('x11', type: 'feature', value: 'auto', description: 'Build the X11 backend.') option('integration_tests', type: 'boolean', value: false, description: 'Test windows with Linux display servers or the native macOS display.') +option('x11_tablet_tests', type: 'boolean', value: false, description: 'Test native pressure drawing with Xorg dummy and inputtest drivers (no tablet hardware required).') diff --git a/src/cocoa/window.m b/src/cocoa/window.m index 6e1e457..6235b77 100644 --- a/src/cocoa/window.m +++ b/src/cocoa/window.m @@ -91,6 +91,8 @@ struct cocoa_surface { uint32_t state; uint32_t mods; enum yasl_cursor cursor; + enum yasl_pointer_device tablet_device; + bool tablet_down; char title[160]; struct yasl_frame_clock clock; @@ -106,6 +108,7 @@ struct cocoa_display { char *app_id; struct yasl_loop *loop; id key_up_monitor; + NSCursor *hidden_cursor; YaslAppDelegate *delegate; NSPoint cascade; struct cocoa_surface *dead; @@ -284,6 +287,18 @@ static void push_pointer(struct cocoa_surface *w, NSEvent *ev, enum yasl_pointer { if (!w || w->closed || button < 0) return; + bool proximity = ev.type == NSEventTypeTabletProximity || + ev.subtype == NSEventSubtypeTabletProximity; + if (proximity) { + if (ev.isEnteringProximity) { + w->tablet_device = ev.pointingDeviceType == NSPointingDeviceTypeEraser ? + YASL_PTR_DEV_ERASER : ev.pointingDeviceType == NSPointingDeviceTypePen ? + YASL_PTR_DEV_PEN : YASL_PTR_DEV_MOUSE; + return; + } + kind = YASL_PTR_LEAVE; + button = 3; + } w->mods = cocoa_mods(ev.modifierFlags); if (kind != YASL_PTR_LEAVE) w->pointer = event_pos(w, ev); @@ -291,20 +306,26 @@ static void push_pointer(struct cocoa_surface *w, NSEvent *ev, enum yasl_pointer enum yasl_pointer_device device = YASL_PTR_DEV_MOUSE; struct yasl_pointer_pen pen = {0}; - float press = (float)ev.pressure; - if (ev.subtype == NSEventSubtypeTabletPoint || press > 0.0f) { - if (ev.pointingDeviceType == NSPointingDeviceTypeEraser) - device = YASL_PTR_DEV_ERASER; - else if (ev.pointingDeviceType == NSPointingDeviceTypePen) - device = YASL_PTR_DEV_PEN; - + if (kind == YASL_PTR_LEAVE && w->tablet_device != YASL_PTR_DEV_MOUSE) { + device = w->tablet_device; + w->tablet_down = false; + } else if (ev.type == NSEventTypeTabletPoint || ev.subtype == NSEventSubtypeTabletPoint) { + /* pointingDeviceType belongs to proximity events. Remember that + * event's tool so erasers and zero-pressure hover stay classified. */ + device = w->tablet_device == YASL_PTR_DEV_ERASER ? YASL_PTR_DEV_ERASER : YASL_PTR_DEV_PEN; + float press = (float)ev.pressure; if (press > 1.0f) press = 1.0f; if (press > 0.0f) pen.pressure = (uint16_t)(press * 65535.0f); NSPoint tilt = ev.tilt; - pen.tilt_x = (int8_t)(tilt.x * 90.0); - pen.tilt_y = (int8_t)(tilt.y * 90.0); + pen.tilt_x = (int8_t)lround(fmax(-1, fmin(1, tilt.x)) * 90.0); + pen.tilt_y = (int8_t)lround(fmax(-1, fmin(1, -tilt.y)) * 90.0); + if (button == 0 && kind == YASL_PTR_PRESS) w->tablet_down = true; + if (button == 0 && kind == YASL_PTR_RELEASE) { + w->tablet_down = false; + pen.pressure = 0; + } } struct yasl_input_event input_ev = { @@ -320,6 +341,10 @@ static void push_pointer(struct cocoa_surface *w, NSEvent *ev, enum yasl_pointer }, }; input_queue_push(&w->input, &input_ev); + if (proximity) { + w->tablet_device = YASL_PTR_DEV_MOUSE; + w->tablet_down = false; + } } static NSCursor *cocoa_cursor(enum yasl_cursor cursor) @@ -328,6 +353,17 @@ static NSCursor *cocoa_cursor(enum yasl_cursor cursor) case YASL_CURSOR_TEXT: return [NSCursor IBeamCursor]; case YASL_CURSOR_LINK: return [NSCursor pointingHandCursor]; case YASL_CURSOR_CROSSHAIR: return [NSCursor crosshairCursor]; + case YASL_CURSOR_HIDDEN: + if (!darwin_ctx.hidden_cursor) { + NSImage *image = [[NSImage alloc] initWithSize:NSMakeSize(1, 1)]; + [image lockFocus]; + [[NSColor clearColor] set]; + NSRectFillUsingOperation(NSMakeRect(0, 0, 1, 1), NSCompositingOperationCopy); + [image unlockFocus]; + darwin_ctx.hidden_cursor = [[NSCursor alloc] initWithImage:image hotSpot:NSZeroPoint]; + [image release]; + } + return darwin_ctx.hidden_cursor; case YASL_CURSOR_ARROW: default: return [NSCursor arrowCursor]; } @@ -566,6 +602,21 @@ static NSCursor *cocoa_cursor(enum yasl_cursor cursor) push_pointer(self.surface, event, YASL_PTR_LEAVE, 3); } +- (void)tabletProximity:(NSEvent *)event +{ + push_pointer(self.surface, event, YASL_PTR_MOTION, 3); +} + +- (void)tabletPoint:(NSEvent *)event +{ + struct cocoa_surface *w = self.surface; + if (!w) return; + bool down = (event.buttonMask & NSEventButtonMaskPenTip) != 0; + enum yasl_pointer_kind kind = down == w->tablet_down ? YASL_PTR_MOTION : + down ? YASL_PTR_PRESS : YASL_PTR_RELEASE; + push_pointer(w, event, kind, 0); +} + - (void)scrollWheel:(NSEvent *)event { struct cocoa_surface *w = self.surface; @@ -1042,6 +1093,7 @@ void cocoa_display_close(struct cocoa_display *d) NSApp.delegate = nil; [d->delegate release]; d->delegate = nil; + [d->hidden_cursor release]; free(d->app_id); free(d->windows); *d = (struct cocoa_display){0}; @@ -1050,7 +1102,7 @@ void cocoa_display_close(struct cocoa_display *d) uint32_t cocoa_display_caps(const struct cocoa_display *d) { return d ? (YASL_CAP_PLACE_WINDOW | YASL_CAP_CSD | YASL_CAP_NATIVE_GRAPHICS | - YASL_CAP_FRAME_PACING) : 0; + YASL_CAP_FRAME_PACING | YASL_CAP_TABLET) : 0; } struct cocoa_surface *cocoa_surface_create_toplevel(struct cocoa_display *d, @@ -1230,12 +1282,8 @@ void cocoa_surface_set_background_color(struct cocoa_surface *w, uint32_t bg) void cocoa_surface_set_cursor(struct cocoa_surface *w, enum yasl_cursor cursor) { - if (!w || w->closed || cursor == w->cursor) return; + if (!w || w->closed || cursor == w->cursor || (unsigned)cursor > YASL_CURSOR_HIDDEN) return; w->cursor = cursor; - if (cursor == YASL_CURSOR_NONE) { - [NSCursor setHiddenUntilMouseMoves:YES]; - return; - } NSPoint p = [w->view convertPoint:w->window.mouseLocationOutsideOfEventStream fromView:nil]; if (w->window.isKeyWindow && NSPointInRect(p, w->view.bounds)) [cocoa_cursor(cursor) set]; diff --git a/src/wayland/internal.h b/src/wayland/internal.h index 249fc8d..04142f7 100644 --- a/src/wayland/internal.h +++ b/src/wayland/internal.h @@ -284,6 +284,7 @@ struct wayland_surface { struct input_queue input; /* 4-byte integers */ + enum yasl_cursor cursor; int ime_cursor_end; int ime_shown_begin, ime_shown_end; int floating_width, floating_height; diff --git a/src/wayland/pointer.c b/src/wayland/pointer.c index 3b3ad73..3341d35 100644 --- a/src/wayland/pointer.c +++ b/src/wayland/pointer.c @@ -164,7 +164,7 @@ static void set_cursor_theme_fallback(struct seat *s, struct wayland_surface *w, {"ew-resize", "ew-resize"}, {"ns-resize", "ns-resize"}, }; - int idx = shape < YASL_CURSOR_NONE ? (int)shape : 0; + int idx = (unsigned)shape < YASL_CURSOR_HIDDEN ? (int)shape : 0; struct wl_cursor *cursor = wl_cursor_theme_get_cursor(c->cursor_theme, names[idx][0]); if (!cursor) cursor = wl_cursor_theme_get_cursor(c->cursor_theme, names[idx][1]); if (!cursor || !cursor->image_count) return; @@ -211,7 +211,7 @@ static void seat_set_cursor(struct seat *s, struct wayland_surface *w, enum yasl if (!s->pointer || !s->pointer_serial || s->pointer_focus != w || s->cursor_shape == (int)shape) return; - if (shape == YASL_CURSOR_NONE) + if (shape == YASL_CURSOR_HIDDEN) wl_pointer_set_cursor(s->pointer, s->pointer_serial, NULL, 0, 0); else if (s->cursor_shape_dev) wp_cursor_shape_device_v1_set_shape(s->cursor_shape_dev, @@ -225,7 +225,7 @@ static void tool_set_cursor(struct tablet_tool *t, enum yasl_cursor shape) { if (!t->proximity_serial) return; - if (shape == YASL_CURSOR_NONE) + if (shape == YASL_CURSOR_HIDDEN) zwp_tablet_tool_v2_set_cursor(t->proxy, t->proximity_serial, NULL, 0, 0); else if (t->shape_dev) wp_cursor_shape_device_v1_set_shape(t->shape_dev, t->proximity_serial, @@ -234,7 +234,8 @@ static void tool_set_cursor(struct tablet_tool *t, enum yasl_cursor shape) void wayland_surface_set_cursor(struct wayland_surface *w, enum yasl_cursor shape) { - if (!w || !w->conn) return; + if (!w || !w->conn || w->destroyed || (unsigned)shape > YASL_CURSOR_HIDDEN) return; + w->cursor = shape; for (struct seat *s = w->conn->seats; s; s = s->next) { seat_set_cursor(s, w, shape); if (s->tool && s->tablet_focus == w) @@ -325,7 +326,7 @@ static void pointer_enter(void *data, struct wl_pointer *pointer, s->pointer_pos = device_from_fixed(w, surface_x, surface_y); s->pointer_serial = serial; s->cursor_shape = -1; - seat_set_cursor(s, w, w->parent ? YASL_CURSOR_ARROW : YASL_CURSOR_TEXT); + seat_set_cursor(s, w, w->cursor); s->motion_pending = true; pointer_event_done(s); } @@ -741,7 +742,7 @@ static void tablet_tool_proximity_in(void *data, if (s->conn->cursor_shape_mgr && !t->shape_dev) t->shape_dev = wp_cursor_shape_manager_v1_get_tablet_tool_v2( s->conn->cursor_shape_mgr, t->proxy); - tool_set_cursor(t, w->parent ? YASL_CURSOR_ARROW : YASL_CURSOR_TEXT); + tool_set_cursor(t, w->cursor); s->tool_motion = true; } @@ -753,6 +754,7 @@ static void tablet_tool_proximity_out(void *data, struct zwp_tablet_tool_v2 *too struct wayland_surface *w = s->tablet_focus; if (w) { press_queue_flush(s, &s->tool_presses, NULL, &s->tool_buttons, tool_event(s)); + s->pen.pressure = 0; struct yasl_mouse_event ev = tool_event(s); ev.pos = s->tool_pos; ev.button = 3; @@ -763,6 +765,8 @@ static void tablet_tool_proximity_out(void *data, struct zwp_tablet_tool_v2 *too s->tablet_focus = NULL; s->tool = NULL; s->tool_motion = false; + s->tool_wheel = 0; + s->tool_buttons = 0; t->proximity_serial = 0; } @@ -867,7 +871,11 @@ static void tool_frame(void *d, struct zwp_tablet_tool_v2 *t, uint32_t time) } press_queue_flush(s, &s->tool_presses, NULL, &s->tool_buttons, like); if (s->tool_wheel) { - report_wheel(s, w, s->tool_pos, s->tool_wheel > 0 ? 5 : 4, -s->tool_wheel, 0.0f); + struct yasl_mouse_event ev = like; + ev.pos = s->tool_pos; + ev.kind = YASL_PTR_SCROLL; + ev.scroll_lines = -s->tool_wheel; + push_mouse(s, w, ev); s->tool_wheel = 0; } } @@ -877,9 +885,7 @@ static void tablet_tool_removed(void *data, struct zwp_tablet_tool_v2 *tool) struct tablet_tool *t = data; struct seat *s = t->seat; if (s->tool == t) { - s->tool = NULL; - s->tablet_focus = NULL; - s->tool_presses.n = 0; + tablet_tool_proximity_out(t, tool); } struct tablet_tool **link = &s->tablet_tools; while (*link && *link != t) diff --git a/src/wayland/surface.c b/src/wayland/surface.c index f291190..ce1764b 100644 --- a/src/wayland/surface.c +++ b/src/wayland/surface.c @@ -284,6 +284,7 @@ uint32_t wayland_display_caps(const struct wayland_display *c) { if (!c) return 0; uint32_t caps = YASL_CAP_POPUPS | YASL_CAP_CSD | YASL_CAP_FRAME_PACING; + if (c->tablet_mgr) caps |= YASL_CAP_TABLET; struct seat *s = conn_active_seat(c); const struct wl_data_device *device = s ? clipboard_data_device(s->clipboard) : NULL; if (device && wl_data_device_get_version((struct wl_data_device *)device) >= 3) @@ -346,6 +347,7 @@ struct wayland_surface *wayland_surface_create_toplevel(struct wayland_display * display_enter(c); conn_link(c, w); wayland_surface_set_callbacks(w, NULL, NULL); + w->cursor = YASL_CURSOR_TEXT; w->req_width = width; w->req_height = height; w->logical_width = width; diff --git a/src/x11/window.c b/src/x11/window.c index 67276cf..85f1d22 100644 --- a/src/x11/window.c +++ b/src/x11/window.c @@ -9,14 +9,17 @@ #include <X11/Xutil.h> #include <X11/cursorfont.h> #include <X11/keysym.h> +#include <X11/extensions/XInput2.h> #include <xkbcommon/xkbcommon.h> #include <assert.h> #include <errno.h> #include <limits.h> +#include <math.h> #include <poll.h> #include <stdlib.h> #include <string.h> +#include <strings.h> #define TEXT_LIMIT (16 * 1024 * 1024) @@ -53,6 +56,18 @@ struct paste_request { Window window; }; +struct tablet_axis { + int number; + double min, max, value; +}; + +struct x11_pointer { + struct x11_pointer *next; + int id; + enum yasl_pointer_device device; + struct tablet_axis pressure, tilt_x, tilt_y; +}; + struct x11_display { struct yasl_display base; Display *native; @@ -70,6 +85,10 @@ struct x11_display { uint32_t mods; char *app_id; bool owns_loop; + int xi_opcode; + bool xi2; + Atom abs_pressure, abs_tilt_x, abs_tilt_y, tool_type, eraser; + struct x11_pointer *pointers; }; static struct x11_display *display_of(const struct yasl_surface *s) @@ -113,6 +132,176 @@ static uint32_t modifiers(unsigned state) ((state & LockMask) ? YASL_MOD_CAPS : 0); } +static void pointer_info(struct x11_display *d, struct x11_pointer *p, + const XIDeviceInfo *info) +{ + p->id = info->deviceid; + p->device = YASL_PTR_DEV_MOUSE; + p->pressure.number = p->tilt_x.number = p->tilt_y.number = -1; + bool touch = false; + for (int i = 0; i < info->num_classes; i++) { + XIAnyClassInfo *c = info->classes[i]; + if (c->type == XITouchClass) touch = true; + if (c->type != XIValuatorClass) continue; + XIValuatorClassInfo *v = (XIValuatorClassInfo *)c; + if (v->mode != XIModeAbsolute) continue; + struct tablet_axis *axis = v->label == d->abs_pressure ? &p->pressure : + v->label == d->abs_tilt_x ? &p->tilt_x : + v->label == d->abs_tilt_y ? &p->tilt_y : NULL; + if (axis) *axis = (struct tablet_axis){v->number, v->min, v->max, v->value}; + } + if (!touch && p->pressure.number >= 0) { + p->device = YASL_PTR_DEV_PEN; + /* Xorg's Wacom and libinput drivers expose separate eraser devices. + * Use the tool property when available, with the device name as a + * fallback for servers (including Xwayland) without that property. */ + if (info->name) { + for (const char *s = info->name; *s; s++) + if (!strncasecmp(s, "eraser", 6)) { p->device = YASL_PTR_DEV_ERASER; break; } + } + } +} + +static struct x11_pointer *pointer_for(struct x11_display *d, int id) +{ + for (struct x11_pointer *p = d->pointers; p; p = p->next) + if (p->id == id) return p; + int count = 0; + XIDeviceInfo *info = XIQueryDevice(d->native, id, &count); + if (!info) return NULL; + struct x11_pointer *p = count ? calloc(1, sizeof(*p)) : NULL; + if (p) { + pointer_info(d, p, info); + if (p->device == YASL_PTR_DEV_PEN || p->device == YASL_PTR_DEV_ERASER) { + Atom type; + int format; + unsigned long n, after; + unsigned char *value = NULL; + if (XIGetProperty(d->native, id, d->tool_type, 0, 1, False, + XA_ATOM, &type, &format, &n, &after, &value) == Success && + type == XA_ATOM && format == 32 && n == 1 && value) + /* XIGetProperty packs format-32 values as uint32_t, unlike + * XGetWindowProperty's array of native-sized longs. */ + p->device = *(uint32_t *)value == d->eraser ? YASL_PTR_DEV_ERASER : p->device; + if (value) XFree(value); + } + p->next = d->pointers; + d->pointers = p; + } + XIFreeDeviceInfo(info); + return p; +} + +static void clear_pointers(struct x11_display *d) +{ + while (d->pointers) { + struct x11_pointer *p = d->pointers; + d->pointers = p->next; + free(p); + } +} + +static void update_axes(struct x11_pointer *p, const XIValuatorState *state) +{ + const double *value = state->values; + for (int i = 0; i < state->mask_len * 8; i++) { + if (!XIMaskIsSet(state->mask, i)) continue; + struct tablet_axis *axes[] = {&p->pressure, &p->tilt_x, &p->tilt_y}; + for (unsigned a = 0; a < sizeof(axes) / sizeof(axes[0]); a++) + if (axes[a]->number == i && isfinite(*value)) axes[a]->value = *value; + value++; + } +} + +static uint16_t pressure_value(const struct tablet_axis *a) +{ + if (a->number < 0 || !(a->max > a->min) || !isfinite(a->value)) return 0; + double v = (a->value - a->min) / (a->max - a->min); + return (uint16_t)lround(fmax(0, fmin(1, v)) * 65535); +} + +static int8_t tilt_value(const struct tablet_axis *a) +{ + /* Xorg tablet tilt valuators are already in degrees. */ + return a->number < 0 || !isfinite(a->value) ? 0 : + (int8_t)lround(fmax(-90, fmin(90, a->value))); +} + +static int pointer_button(unsigned button) +{ + return button == 1 ? 0 : button == 2 ? 1 : button == 3 ? 2 : + button >= 8 && button <= INT16_MAX ? (int)button - 5 : 3; +} + +static void xi_pointer_event(struct x11_surface *s, const XIDeviceEvent *ev) +{ + struct x11_display *d = display_of(&s->base); + struct x11_pointer *p = pointer_for(d, ev->sourceid); + struct yasl_mouse_event mouse = { + .pos = {yasl_scale_to_device(ev->event_x, YASL_SCALE_UNIT), + yasl_scale_to_device(ev->event_y, YASL_SCALE_UNIT)}, + .device = p ? p->device : YASL_PTR_DEV_MOUSE, + .mods = modifiers(ev->mods.effective), + .button = 3, + }; + if (p && (p->device == YASL_PTR_DEV_PEN || p->device == YASL_PTR_DEV_ERASER)) { + update_axes(p, &ev->valuators); + if (ev->evtype == XI_ButtonRelease && ev->detail == 1) + p->pressure.value = p->pressure.min; + mouse.pen = (struct yasl_pointer_pen){ + pressure_value(&p->pressure), tilt_value(&p->tilt_x), tilt_value(&p->tilt_y)}; + } + if (ev->evtype == XI_Motion) mouse.kind = YASL_PTR_MOTION; + else if (ev->detail >= 4 && ev->detail <= 7) { + if (ev->evtype == XI_ButtonRelease) return; + mouse.kind = YASL_PTR_SCROLL; + mouse.scroll_lines = (ev->detail == 4 || ev->detail == 6) ? -1 : 1; + } else { + mouse.kind = ev->evtype == XI_ButtonPress ? YASL_PTR_PRESS : YASL_PTR_RELEASE; + mouse.button = pointer_button(ev->detail); + } + d->mods = mouse.mods; + push(s, (struct yasl_input_event){.kind = YASL_INPUT_MOUSE, .mouse = mouse}, NULL, 0); +} + +static void xi_event(struct x11_display *d, XGenericEventCookie *cookie) +{ + if (cookie->evtype == XI_HierarchyChanged || cookie->evtype == XI_DeviceChanged) { + if (cookie->evtype == XI_DeviceChanged && + ((XIDeviceChangedEvent *)cookie->data)->reason == XISlaveSwitch) return; + clear_pointers(d); + if (cookie->evtype == XI_HierarchyChanged && + (((XIHierarchyEvent *)cookie->data)->flags & + (XISlaveRemoved | XIDeviceDisabled | XISlaveDetached | XIMasterRemoved))) { + /* Removal/disable may interrupt a drag before a release arrives. */ + for (struct x11_surface *s = d->surfaces; s; s = s->next) + push(s, (struct yasl_input_event){.kind = YASL_INPUT_RESET}, NULL, 0); + } + return; + } + bool crossing = cookie->evtype == XI_Enter || cookie->evtype == XI_Leave; + if (!crossing && cookie->evtype != XI_Motion && cookie->evtype != XI_ButtonPress && + cookie->evtype != XI_ButtonRelease) return; + Window window = crossing ? ((XIEnterEvent *)cookie->data)->event : + ((XIDeviceEvent *)cookie->data)->event; + struct x11_surface *s = d->surfaces; + while (s && s->window != window) s = s->next; + if (!s) return; + if (!crossing) xi_pointer_event(s, cookie->data); + else { + const XIEnterEvent *ev = cookie->data; + struct x11_pointer *p = pointer_for(d, ev->sourceid); + if (p) p->pressure.value = p->pressure.min; + push(s, (struct yasl_input_event){.kind = YASL_INPUT_MOUSE, .mouse = { + .pos = {yasl_scale_to_device(ev->event_x, YASL_SCALE_UNIT), + yasl_scale_to_device(ev->event_y, YASL_SCALE_UNIT)}, + .device = p ? p->device : YASL_PTR_DEV_MOUSE, + .button = 3, .mods = modifiers(ev->mods.effective), + .kind = cookie->evtype == XI_Leave ? YASL_PTR_LEAVE : YASL_PTR_MOTION, + }}, NULL, 0); + } +} + static bool resize(struct x11_surface *s, struct yasl_vector2i size) { if (size.x <= 0 || size.y <= 0 || size.x > 32767 || size.y > 32767 || @@ -233,8 +422,19 @@ static struct yasl_surface *create_toplevel(struct yasl_display *base, 0, 0, (unsigned)spec->size.x, (unsigned)spec->size.y, 0, 0, 0); s->gc = XCreateGC(d->native, s->window, 0, NULL); XSelectInput(d->native, s->window, ExposureMask | StructureNotifyMask | - FocusChangeMask | KeyPressMask | KeyReleaseMask | ButtonPressMask | - ButtonReleaseMask | PointerMotionMask | LeaveWindowMask | PropertyChangeMask); + FocusChangeMask | KeyPressMask | KeyReleaseMask | PropertyChangeMask | + (d->xi2 ? 0 : ButtonPressMask | ButtonReleaseMask | + PointerMotionMask | LeaveWindowMask)); + if (d->xi2) { + unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0}; + XISetMask(bits, XI_Motion); + XISetMask(bits, XI_ButtonPress); + XISetMask(bits, XI_ButtonRelease); + XISetMask(bits, XI_Enter); + XISetMask(bits, XI_Leave); + XIEventMask mask = {XIAllMasterDevices, sizeof(bits), bits}; + XISelectEvents(d->native, s->window, &mask, 1); + } XSetWMProtocols(d->native, s->window, &d->delete_window, 1); XClassHint hint = { .res_name = d->app_id, .res_class = d->app_id }; XSetClassHint(d->native, s->window, &hint); @@ -382,6 +582,12 @@ static void dispatch(struct yasl_display *base) while (XPending(d->native)) { XEvent event; XNextEvent(d->native, &event); + if (event.type == GenericEvent && d->xi2 && + event.xcookie.extension == d->xi_opcode && XGetEventData(d->native, &event.xcookie)) { + xi_event(d, &event.xcookie); + XFreeEventData(d->native, &event.xcookie); + continue; + } if (XFilterEvent(&event, None)) continue; if (event.type == SelectionRequest) { selection_request(d, &event.xselectionrequest); @@ -456,8 +662,7 @@ static void dispatch(struct yasl_display *base) mouse.scroll_lines = (button == 4 || button == 6) ? -1 : 1; } else { mouse.kind = event.type == ButtonPress ? YASL_PTR_PRESS : YASL_PTR_RELEASE; - mouse.button = button == 1 ? 0x110 : button == 2 ? 0x112 : - button == 3 ? 0x111 : (int)button + 0x10b; + mouse.button = pointer_button(button); } } d->mods = mouse.mods = modifiers(state); @@ -577,6 +782,23 @@ static struct yasl_display *display_open(struct yasl_loop *loop, d->native = XOpenDisplay(NULL); if (!d->native) { free(d); errno = ENODEV; return NULL; } d->screen = DefaultScreen(d->native); + int xi_event_base, xi_error_base; + int major = 2, minor = 0; + d->xi2 = XQueryExtension(d->native, "XInputExtension", &d->xi_opcode, + &xi_event_base, &xi_error_base) && + XIQueryVersion(d->native, &major, &minor) == Success && major >= 2; + if (d->xi2) { + d->abs_pressure = XInternAtom(d->native, "Abs Pressure", False); + d->abs_tilt_x = XInternAtom(d->native, "Abs Tilt X", False); + d->abs_tilt_y = XInternAtom(d->native, "Abs Tilt Y", False); + d->tool_type = XInternAtom(d->native, "Wacom Tool Type", False); + d->eraser = XInternAtom(d->native, "ERASER", False); + unsigned char bits[XIMaskLen(XI_LASTEVENT)] = {0}; + XISetMask(bits, XI_HierarchyChanged); + XISetMask(bits, XI_DeviceChanged); + XIEventMask mask = {XIAllDevices, sizeof(bits), bits}; + XISelectEvents(d->native, RootWindow(d->native, d->screen), &mask, 1); + } if (!loop) { d->loop = yasl_loop_create(); d->owns_loop = true; @@ -635,6 +857,7 @@ static void display_close(struct yasl_display *base) d->base.loop = NULL; } if (d->im) XCloseIM(d->im); + clear_pointers(d); XDestroyWindow(d->native, d->selection_window); XCloseDisplay(d->native); free(d->selection[0]); @@ -645,8 +868,8 @@ static void display_close(struct yasl_display *base) static uint32_t caps(const struct yasl_display *d) { - (void)d; - return YASL_CAP_PLACE_WINDOW | YASL_CAP_PRIMARY_SELECTION; + return YASL_CAP_PLACE_WINDOW | YASL_CAP_PRIMARY_SELECTION | + (((const struct x11_display *)d)->xi2 ? YASL_CAP_TABLET : 0); } static void copy(struct yasl_display *base, enum yasl_selection sel, const char *text) @@ -734,10 +957,10 @@ static void cursor(struct yasl_surface *base, enum yasl_cursor cursor) XC_fleur, XC_bottom_right_corner, XC_bottom_left_corner, XC_sb_h_double_arrow, XC_sb_v_double_arrow}; struct x11_surface *s = (struct x11_surface *)base; - if (s->destroyed || (unsigned)cursor > YASL_CURSOR_NONE) return; + if (s->destroyed || (unsigned)cursor > YASL_CURSOR_HIDDEN) return; struct x11_display *d = display_of(base); Cursor native; - if (cursor == YASL_CURSOR_NONE) { + if (cursor == YASL_CURSOR_HIDDEN) { char bits[] = {0}; Pixmap pixmap = XCreateBitmapFromData(d->native, s->window, bits, 1, 1); XColor color = {0}; diff --git a/tests/common/test_backends.c b/tests/common/test_backends.c index 4f49765..605dac0 100644 --- a/tests/common/test_backends.c +++ b/tests/common/test_backends.c @@ -160,6 +160,23 @@ static void x11_behavior(struct yasl_loop *loop, struct yasl_display *d, struct assert(w->configures > 0); struct yasl_vector2i size = yasl_surface_geometry(w->surface).size; assert(size.x == 200 && size.y == 120); + /* A server-generated motion verifies the XI2 subscription and cookie + * dispatch path, including source-device lookup, rather than SendEvent. */ + struct yasl_input_event pointer_event; + while (yasl_surface_input_poll(w->surface, &pointer_event)) {} + XWarpPointer(native, None, window, 0, 0, 0, 0, 80, 50); + XFlush(native); + pump(loop, d, NULL); + bool saw_motion = false; + while (yasl_surface_input_poll(w->surface, &pointer_event)) { + if (pointer_event.kind == YASL_INPUT_MOUSE && pointer_event.mouse.kind == YASL_PTR_MOTION) { + assert(pointer_event.mouse.device == YASL_PTR_DEV_MOUSE); + assert(!pointer_event.mouse.pen.pressure); + if (pointer_event.mouse.pos.x == 80 && pointer_event.mouse.pos.y == 50) + saw_motion = true; + } + } + assert(saw_motion); XEvent key = {0}; key.xkey = (XKeyEvent){.type = KeyPress, .display = native, .window = window, .root = DefaultRootWindow(native), .same_screen = True, diff --git a/tests/helpers/run_backends.py b/tests/helpers/run_backends.py index 8c5428a..57cc9ab 100644 --- a/tests/helpers/run_backends.py +++ b/tests/helpers/run_backends.py @@ -21,7 +21,7 @@ def wait_for(process, ready): def main(): - weston, xwayland, test, timers = sys.argv[1:] + weston, xwayland, test, timers, tablet = sys.argv[1:] servers = [] with tempfile.TemporaryDirectory(prefix="yasl-backends-") as directory: root = Path(directory) @@ -80,6 +80,10 @@ def main(): x_env["XDG_SESSION_TYPE"] = "x11" print("Test: X11 surface timers", flush=True) subprocess.run([timers], env=x_env, check=True, timeout=8) + for backend in ("wayland", "x11"): + print("Test: tablet example on", backend, flush=True) + subprocess.run([tablet, "--backend", backend, "--smoke-test"], + env=env, check=True, timeout=8) except BaseException: for log in (wl_log, x_log): log.flush() diff --git a/tests/meson.build b/tests/meson.build index 0f28c39..177e400 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -84,12 +84,43 @@ selection_test = executable( ) test('selection', selection_test) +tablet_example_test = executable('yasl-tablet-example-test', 'common/test_tablet_example.c', + dependencies: yasl_dep, +) +test('tablet-example', tablet_example_test) + +if has_x11 + tablet_x11_test = executable('yasl-tablet-x11-test', 'x11/test_tablet.c', + include_directories: inc, dependencies: yasl_dep, + ) + test('tablet-x11', tablet_x11_test) + if get_option('x11_tablet_tests') + xorg = find_program('/usr/lib/Xorg', '/usr/lib/xorg/Xorg', 'Xorg') + native_tablet_test = executable('yasl-tablet-x11-native-test', 'x11/test_tablet_native.c', + dependencies: yasl_dep, + ) + test('tablet-x11-native', python, + args: [files('helpers/run_x11_tablet.py'), xorg, native_tablet_test], timeout: 30, + ) + endif +elif get_option('x11_tablet_tests') + error('Native X11 tablet tests require the X11 backend.') +endif + if has_wayland ime_test = executable('yasl-ime-test', 'wayland/test_input.c', include_directories: inc, dependencies: yasl_dep, ) test('ime-ownership', ime_test) + tablet_wayland_test = executable('yasl-tablet-wayland-test', 'wayland/test_tablet.c', + include_directories: inc, dependencies: yasl_dep, + ) + test('tablet-wayland', tablet_wayland_test) + cursor_wayland_test = executable('yasl-cursor-wayland-test', 'wayland/test_cursor.c', + include_directories: inc, dependencies: yasl_dep, + ) + test('cursor-wayland', cursor_wayland_test) endif if get_option('integration_tests') @@ -102,6 +133,17 @@ if get_option('integration_tests') dependencies: [shared_dep, deps], ) test('native-ownership', ownership_test, timeout: 20) + tablet_cocoa_test = executable('yasl-tablet-cocoa-test', 'cocoa/test_tablet.m', + dependencies: [shared_dep, deps], + ) + test('tablet-cocoa', tablet_cocoa_test, timeout: 20) + cursor_cocoa_test = executable('yasl-cursor-cocoa-test', 'cocoa/test_cursor.m', + dependencies: [shared_dep, deps], + ) + test('cursor-cocoa', cursor_cocoa_test, timeout: 20) + test('tablet-cocoa-window', tablet_example, + args: ['--backend', 'cocoa', '--smoke-test'], timeout: 20, + ) else if not has_wayland or not has_x11 error('Linux integration tests require Wayland and X11.') @@ -115,7 +157,7 @@ if get_option('integration_tests') xwayland = find_program('Xwayland') test('backends', python, args: [files('helpers/run_backends.py'), weston, xwayland, - backend_test, timer_test], + backend_test, timer_test, tablet_example], timeout: 60, ) endif |
