From 4c8af492f31c36b79baed23da19826207eef6d98 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 11:10:41 -0500 Subject: [PATCH] core: tabs show the hand, and a tapped item is left unlit One answer per frame to "what kind of pointer is this, and does a resize handle claim the cursor here", from dvui (foxnne/dvui-dev fizzy-sdk-0.2.30-6): - `dvui.pointerKind()` (`.touch` or `.mouse`) is the window's, set as the backend adds events, so the app and every plugin dylib or wasm side module read the same answer. After a touch with no finger down, dvui puts the frame's `.position` event off every window: a lifted finger hovers nothing. - `dvui.cursorSetPriority`: the highest asked in a frame wins. Splits and a float's bottom-right corner ask at `.resize`, the view drag at `.drag`. Presses are routed as before. Its first users: panel tabs, rail icons (`core.widgets.Tabs`) and document tabs show the hand, held too (`Tabs.handCursor`); a split's arrow still wins beside its sash. The native backend gives only fingers on the screen a touch button, one per finger (`backend/src/touch_slots.zig`): a macOS trackpad's fingers, a pen's and SDL's mouse touches no longer reach dvui as `touch0` presses, and fingers the window never hears lift (focus lost, background, close) are released. Fixes #547 Co-Authored-By: Claude Opus 5.5 --- app/layout/Layout.zig | 8 +- backend/build.zig | 2 + backend/src/SDLBackend.zig | 63 ++++++++---- backend/src/touch_slots.zig | 123 ++++++++++++++++++++++++ core/AGENTS.md | 12 +++ core/sizing_tests.zig | 2 + core/widgets/FloatingWindowWidget.zig | 6 +- core/widgets/Split.zig | 5 +- core/widgets/Tabs.zig | 133 ++++++++++++++++++++++++++ docs/DEPENDENCIES.md | 24 ++++- plugins/workbench/src/Workspace.zig | 1 + sdk/build.zig.zon | 6 +- sdk/src/version.zig | 2 +- 13 files changed, 358 insertions(+), 29 deletions(-) create mode 100644 backend/src/touch_slots.zig diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index 4b9cfd225..a202af997 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -240,11 +240,11 @@ pub const slot_keywords: []const []const u8 = &.{"slot"}; // timing, because a sidebar and a document pane travelling at different speeds reads as broken // without ever looking wrong in a screenshot. -/// A frame's layout. Made at the top of the frame, before any place draws — which is why a view -/// being carried asks for its cursor here: dvui gives the cursor to the first to ask, and the -/// text under the pointer asking for a caret comes later. +/// A frame's layout. Made at the top of the frame, before any place draws. A view being carried +/// asks for its cursor at `.drag`, above the caret of the text under the pointer, a tab's hand or +/// a split's arrow. pub fn init(host: *sdk.Host, state: *State, gpa: std.mem.Allocator, arena: std.mem.Allocator) Layout { - if (state.view_drag.active()) dvui.cursorSet(ViewDrag.cursor); + if (state.view_drag.active()) dvui.cursorSetPriority(ViewDrag.cursor, .drag); return .{ .host = host, .state = state, .gpa = gpa, .arena = arena }; } diff --git a/backend/build.zig b/backend/build.zig index a9070c44c..86ad7433e 100644 --- a/backend/build.zig +++ b/backend/build.zig @@ -72,6 +72,8 @@ pub const unit_tests = [_]struct { name: []const u8, root: []const u8 }{ // The backend's health counters: frame-time percentiles, per-frame counts, SDL's log counted // from any thread, a snapshot as ZON. .{ .name = "backend-health-tests", .root = "src/Health.zig" }, + // Which touch button each finger on the screen presses; a trackpad's fingers, none. + .{ .name = "backend-touch-slots-tests", .root = "src/touch_slots.zig" }, }; /// Where the macOS SDK is, for a macOS target that is not the host's own (`-Dtarget=aarch64-macos` diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index 925b969ff..87b57fd29 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -16,6 +16,7 @@ const dvui = @import("dvui"); const GpuRenderer = @import("GpuRenderer.zig"); pub const viewport_map = @import("viewport_map.zig"); +const TouchSlots = @import("touch_slots.zig"); /// The OS windows besides the main one an app draws with (`Viewport`). pub const viewports = @import("viewports.zig"); /// `dvui.osWindow` as a window of its own from the one frame (`osWindowFloating`). @@ -68,6 +69,8 @@ begin_hook: ?*const fn (*SDLBackend) void = null, present_hook: ?*const fn (*SDLBackend) void = null, touch_mouse_events: bool = false, +/// The touch button each finger on the screen presses. +touch_slots: TouchSlots = .{}, log_events: bool = false, last_pixel_size: dvui.Size.Physical = .{ .w = 800, .h = 600 }, last_window_size: dvui.Size.Natural = .{ .w = 800, .h = 600 }, @@ -2650,6 +2653,24 @@ fn trackGeometry(self: *SDLBackend) void { self.window_geometry = .{ .x = x, .y = y, .w = w, .h = h }; } +comptime { + std.debug.assert(TouchSlots.mouse_touch_id == c.SDL_MOUSE_TOUCHID); + std.debug.assert(TouchSlots.pen_touch_id == c.SDL_PEN_TOUCHID); +} + +fn touchButton(slot: u4) dvui.enums.Button { + return @enumFromInt(@intFromEnum(dvui.enums.Button.touch0) + @as(u8, slot)); +} + +/// Lift every finger the window will not hear lift: it lost focus, is going to the background or +/// is closing. A held slot would stay held for good, and dvui would keep a finger down. +fn releaseTouches(self: *SDLBackend, win: *dvui.Window) !void { + const freed = self.touch_slots.releaseAll(); + for (0..10) |i| if (freed & (@as(u10, 1) << @intCast(i)) != 0) { + _ = try win.addEventPointer(.{ .button = touchButton(@intCast(i)), .action = .release }); + }; +} + /// Send an SDL_Event to a dvui.Window /// /// Return true if the event is to be handled by a subwindow. @@ -2829,26 +2850,30 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool } return ret; }, - if (sdl3) c.SDL_EVENT_FINGER_DOWN else c.SDL_FINGERDOWN => { - if (self.log_events) { - log.debug("event FINGERDOWN {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y }); - } - - return try win.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = .{ .x = event.tfinger.x, .y = event.tfinger.y } }); + // A finger on the screen presses the touch button of its slot; a trackpad's, a pen's and + // SDL's mouse touches have none (`touch_slots.zig`). + c.SDL_EVENT_FINGER_DOWN => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERDOWN {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y }); + const device = TouchSlots.deviceOf(f.touchID, c.SDL_GetTouchDeviceType(f.touchID) == c.SDL_TOUCH_DEVICE_DIRECT); + const slot = self.touch_slots.down(device, f.touchID, f.fingerID) orelse return false; + return try win.addEventPointer(.{ .button = touchButton(slot), .action = .press, .xynorm = .{ .x = f.x, .y = f.y } }); }, - if (sdl3) c.SDL_EVENT_FINGER_UP else c.SDL_FINGERUP => { - if (self.log_events) { - log.debug("event FINGERUP {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y }); - } - - return try win.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = .{ .x = event.tfinger.x, .y = event.tfinger.y } }); + c.SDL_EVENT_FINGER_MOTION => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERMOTION {d} {d} {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y, f.dx, f.dy }); + const slot = self.touch_slots.find(f.touchID, f.fingerID) orelse return false; + return try win.addEventTouchMotion(touchButton(slot), f.x, f.y, f.dx, f.dy); }, - if (sdl3) c.SDL_EVENT_FINGER_MOTION else c.SDL_FINGERMOTION => { - if (self.log_events) { - log.debug("event FINGERMOTION {d} {d} {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y, event.tfinger.dx, event.tfinger.dy }); - } - - return try win.addEventTouchMotion(.touch0, event.tfinger.x, event.tfinger.y, event.tfinger.dx, event.tfinger.dy); + c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERUP {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y }); + const slot = self.touch_slots.up(f.touchID, f.fingerID) orelse return false; + return try win.addEventPointer(.{ .button = touchButton(slot), .action = .release, .xynorm = .{ .x = f.x, .y = f.y } }); + }, + c.SDL_EVENT_WILL_ENTER_BACKGROUND => { + try self.releaseTouches(win); + return false; }, if (sdl3) c.SDL_EVENT_WINDOW_FOCUS_GAINED else c.SDL_WINDOWEVENT_FOCUS_GAINED => { if (self.log_events) { @@ -2868,6 +2893,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("event FOCUS_LOST\n", .{}); } + try self.releaseTouches(win); if (dvui.accesskit_enabled and builtin.target.os.tag == .linux) { dvui.AccessKit.c.accesskit_unix_adapter_update_window_focus_state(win.accesskit.adapter, false); } else if (dvui.accesskit_enabled and builtin.target.os.tag == .macos) { @@ -2908,6 +2934,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("SDL event window close\n", .{}); } + try self.releaseTouches(win); try win.addEventWindow(.{ .action = .close }); return false; }, diff --git a/backend/src/touch_slots.zig b/backend/src/touch_slots.zig new file mode 100644 index 000000000..269744d5e --- /dev/null +++ b/backend/src/touch_slots.zig @@ -0,0 +1,123 @@ +//! Which of dvui's touch buttons (`touch0` to `touch9`) each finger on the screen presses. +//! +//! dvui reads a touch button as a finger on the screen at the event's point: it sets +//! `dvui.pointerKind()` to `.touch`, and a lifted finger hovers nothing. Only a direct touch is +//! that. SDL also reports a trackpad's fingers as touches (on macOS, with a device of type +//! `SDL_TOUCH_DEVICE_INDIRECT_*`), at a point on the pad rather than in the window; the trackpad +//! already moves the mouse and clicks, so those get no slot and reach dvui as nothing. Nothing in +//! fizzy or its plugins read them: they arrived as `touch0` presses at the pad's point, mapped +//! into the window. +//! +//! SDL also registers two touch devices of its own as direct: `SDL_MOUSE_TOUCHID`, for the touches +//! it synthesizes from a mouse, and `SDL_PEN_TOUCHID`, for a pen's (`SDL_HINT_PEN_TOUCH_EVENTS`, +//! on by default). A pen already reaches dvui as the mouse; read as a finger too, a tablet stroke +//! would flip `dvui.pointerKind()` with every press. Neither is a finger (`deviceOf`). +//! +//! Each finger keeps its slot from down to up, so two fingers are two buttons (a pinch), and one +//! lifting releases its own button, not another finger's. A lift the window never hears of (it +//! lost focus, went to the background or closed mid-touch) would hold its slot for good, and +//! dvui would think a finger is still down: the backend releases every held slot then +//! (`releaseAll`). +const std = @import("std"); + +pub const Device = enum { direct, indirect }; + +/// `SDL_MOUSE_TOUCHID` and `SDL_PEN_TOUCHID` (`(SDL_TouchID)-1` and `-2`). +pub const mouse_touch_id: u64 = std.math.maxInt(u64); +pub const pen_touch_id: u64 = std.math.maxInt(u64) - 1; + +/// A touch device as dvui is to see it: a finger on the screen (`direct`, as SDL reports its type) +/// unless it is SDL's own mouse or pen device. +pub fn deviceOf(touch: u64, sdl_direct: bool) Device { + if (touch == mouse_touch_id or touch == pen_touch_id) return .indirect; + return if (sdl_direct) .direct else .indirect; +} + +const Finger = struct { touch: u64, finger: u64 }; + +fingers: [10]?Finger = @splat(null), + +const TouchSlots = @This(); + +/// A finger landed: its slot, or null for a finger dvui is not to see (not on the screen, or an +/// eleventh). +pub fn down(self: *TouchSlots, device: Device, touch: u64, finger: u64) ?u4 { + if (device != .direct) return null; + if (self.find(touch, finger)) |i| return i; + for (&self.fingers, 0..) |*slot, i| if (slot.* == null) { + slot.* = .{ .touch = touch, .finger = finger }; + return @intCast(i); + }; + return null; +} + +/// A finger moved: the slot it landed in, if it was given one. +pub fn find(self: *const TouchSlots, touch: u64, finger: u64) ?u4 { + for (self.fingers, 0..) |slot, i| if (slot) |f| { + if (f.touch == touch and f.finger == finger) return @intCast(i); + }; + return null; +} + +/// A finger lifted (or was cancelled): the slot it frees, if it had one. +pub fn up(self: *TouchSlots, touch: u64, finger: u64) ?u4 { + const i = self.find(touch, finger) orelse return null; + self.fingers[i] = null; + return i; +} + +/// Free every held slot, for a window that will not hear their lifts: a bit per slot freed, from +/// slot 0. +pub fn releaseAll(self: *TouchSlots) u10 { + var freed: u10 = 0; + for (&self.fingers, 0..) |*slot, i| if (slot.* != null) { + slot.* = null; + freed |= @as(u10, 1) << @intCast(i); + }; + return freed; +} + +test "SDL's mouse and pen touch devices are not fingers" { + try std.testing.expectEqual(.indirect, deviceOf(mouse_touch_id, true)); + try std.testing.expectEqual(.indirect, deviceOf(pen_touch_id, true)); + try std.testing.expectEqual(.direct, deviceOf(42, true)); + try std.testing.expectEqual(.indirect, deviceOf(42, false)); +} + +test "lifts the window never hears of free every slot" { + var slots: TouchSlots = .{}; + for (0..10) |i| _ = slots.down(.direct, 1, i); + try std.testing.expectEqual(null, slots.down(.direct, 1, 10)); + _ = slots.up(1, 3); + try std.testing.expectEqual(@as(u10, 0b11_1111_0111), slots.releaseAll()); + try std.testing.expectEqual(@as(u10, 0), slots.releaseAll()); + // Touch works again after. + try std.testing.expectEqual(0, slots.down(.direct, 1, 99)); +} + +test "a trackpad's fingers get no slot" { + var slots: TouchSlots = .{}; + try std.testing.expectEqual(null, slots.down(.indirect, 1, 7)); + try std.testing.expectEqual(null, slots.find(1, 7)); + try std.testing.expectEqual(null, slots.up(1, 7)); +} + +test "each finger on the screen keeps its own slot until it lifts" { + var slots: TouchSlots = .{}; + try std.testing.expectEqual(0, slots.down(.direct, 1, 100)); + try std.testing.expectEqual(1, slots.down(.direct, 1, 200)); + try std.testing.expectEqual(1, slots.find(1, 200)); + // The first lifts: its own slot is freed, the second keeps its. + try std.testing.expectEqual(0, slots.up(1, 100)); + try std.testing.expectEqual(1, slots.find(1, 200)); + // A new finger takes the free slot. + try std.testing.expectEqual(0, slots.down(.direct, 1, 300)); + // The same finger id on another device is another finger. + try std.testing.expectEqual(2, slots.down(.direct, 2, 100)); +} + +test "an eleventh finger is not seen" { + var slots: TouchSlots = .{}; + for (0..10) |i| try std.testing.expect(slots.down(.direct, 1, i) != null); + try std.testing.expectEqual(null, slots.down(.direct, 1, 10)); +} diff --git a/core/AGENTS.md b/core/AGENTS.md index e9d0574f1..d81662b95 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -28,6 +28,18 @@ paths, fuzzy matching, the LSP client, the profiler. selects instead of expanding, until you type. Fizzy's backends set `win.modifiers` from the OS before each mouse event (`backend/src/SDLBackend.zig`, `src/backend/WebBackend.zig`); a new backend must too. +- **Touch or mouse: ask dvui, never infer it.** `dvui.pointerKind()` is the window's answer, + set as the backend adds events, the same in the app, a plugin dylib and a wasm side module. + State kept by scanning events (a global, a per-frame key) differs per module and per frame. + A lifted finger hovers nothing: dvui puts the frame's `.position` event off every window, so a + hover look needs no touch check of its own. The native backend gives only fingers on the screen + touch buttons (`backend/src/touch_slots.zig`); a trackpad's fingers move the mouse. +- **A cursor that must win asks with a priority.** `dvui.cursorSet` keeps the first asked in a + frame; `dvui.cursorSetPriority` the highest. Splits and a float's corner ask at `.resize`, a + carried view at `.drag`, so a hover cursor (a tab's hand) never takes their arrow. The cursor + only: which widget takes a press is still drawing order, so ask at `.resize` only where the + handle takes the press before what is under it. A float's edges, handled after its children, + stay at `.normal`. - **Very long lists:** dvui clamps every min size at `max_float_safe` (2e6, about 93k rows at 21.5 pt), so the scroll range silently ends early. A widget not drawn on a frame loses its min size, so a row scrolled back into view is zero-height for a frame. A virtualized list must diff --git a/core/sizing_tests.zig b/core/sizing_tests.zig index 4b488c3a7..b5dca3bda 100644 --- a/core/sizing_tests.zig +++ b/core/sizing_tests.zig @@ -12,6 +12,8 @@ test { _ = @import("widgets/Split.zig"); _ = @import("widgets/ContextWidget.zig"); + // A tab's hand beside a split's arrow, and a tapped tab's hover look. + _ = @import("widgets/Tabs.zig"); // The local copies of upstream's docking + blur (see `widgets.zig`): referenced here so they // are analysed — and their own tests run — even before anything in the app draws them. _ = @import("widgets/DockingWidget.zig"); diff --git a/core/widgets/FloatingWindowWidget.zig b/core/widgets/FloatingWindowWidget.zig index d72612aa5..2f088fcc8 100644 --- a/core/widgets/FloatingWindowWidget.zig +++ b/core/widgets/FloatingWindowWidget.zig @@ -813,7 +813,7 @@ pub fn processEventsBefore(self: *FloatingWindowWidget) void { } if (me.action == .position) { e.handle(@src(), self.data()); // don't want any widgets under this to see a hover - dvui.cursorSet(.arrow_nw_se); + dvui.cursorSetPriority(.arrow_nw_se, .resize); continue; } } @@ -890,6 +890,10 @@ pub fn processEventsAfter(self: *FloatingWindowWidget) void { } else { if (self.init_options.resize == .none) continue; } + // At `.normal`: this runs after the children, which take a press here + // first, so the edge's arrow must not outrank their cursors. The corner, + // which takes its press before them (`processEventsBefore`), asks at + // `.resize`. dvui.cursorSet(self.cursorFor(dp)); }, else => {}, diff --git a/core/widgets/Split.zig b/core/widgets/Split.zig index b1c5b5038..5d302410d 100644 --- a/core/widgets/Split.zig +++ b/core/widgets/Split.zig @@ -435,7 +435,10 @@ pub fn grab(self: *Split, container: *dvui.WidgetData) Grab { }; } }, - .position => dvui.cursorSet(cursor), + // Over the edge of a neighbour asking for a cursor of its own (a tab's hand), the + // split's arrow says what a press beside the sash does: it outranks a hover cursor + // whichever was drawn first. The cursor only: which widget takes a press is unchanged. + .position => dvui.cursorSetPriority(cursor, .resize), else => {}, } } diff --git a/core/widgets/Tabs.zig b/core/widgets/Tabs.zig index 1c7d22ce3..56dc86666 100644 --- a/core/widgets/Tabs.zig +++ b/core/widgets/Tabs.zig @@ -204,10 +204,18 @@ pub const Tab = struct { pub fn deinit(self: *Tab) void { _ = self.clicked(); + handCursor(self.box.data()); self.box.deinit(); } }; +/// A tab's cursor, for every tab strip (this one's, and one that draws its own tabs, as the +/// workbench's document tabs): the hand under the pointer, and while the tab is held. A split +/// beside the strip outranks it where their reaches overlap (`Split.grab`). +pub fn handCursor(wd: *dvui.WidgetData) void { + if (dvui.captured(wd.id) or @import("../widgets.zig").hovered(wd)) dvui.cursorSet(.hand); +} + /// One tab, keyed by `key`. `name` is what it is called — the view's title — for a screen reader /// or a script, which a rail of icons does not otherwise show; it must outlive the frame. pub fn tab(self: *Tabs, src: std.builtin.SourceLocation, key: usize, selected: bool, name: []const u8) Tab { @@ -260,3 +268,128 @@ pub fn deinit(self: *Tabs) void { } self.outer.deinit(); } + +// ── Tests ─────────────────────────────────────────────────────────────────────────────────── +// +// Against dvui's testing backend: the cursor and the hover look are decided by dvui's event +// routing (`cursorSetPriority`, the `.position` event a lifted finger leaves off every window). + +const Split = @import("Split.zig"); + +var t_cursor: dvui.enums.Cursor = .arrow; +var t_hovered = false; +var t_tab_captured = false; +var t_split_captured = false; +var t_tab_rect: dvui.Rect.Physical = .{}; +var t_sep_x: f32 = 0; +var t_scale: f32 = 1; + +/// A rail beside a split: the sidebar's icons next to the explorer's edge, where a cell runs the +/// rail's full width up to the split. +fn railFrame() !dvui.App.Result { + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + defer row.deinit(); + { + var left = dvui.box(@src(), .{ .dir = .vertical }, .{ .min_size_content = .{ .w = 100 }, .expand = .vertical }); + defer left.deinit(); + var strip: Tabs = .init(@src(), .{ .dir = .vertical, .scroll = false, .outer = .{ .expand = .both } }); + defer strip.deinit(); + var t = strip.tab(@src(), 1, false, "one"); + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 20, .h = 40 } }); + _ = t.clicked(); + t_tab_rect = t.box.data().borderRectScale().r; + t_tab_captured = dvui.captured(t.box.data().id); + t_hovered = @import("../widgets.zig").hovered(t.box.data()); + t.deinit(); + } + var divider = Split.init(@src(), .horizontal, 0, null); + { + const srs = divider.box.data().borderRectScale(); + t_sep_x = srs.r.x + srs.r.w / 2; + t_scale = srs.s; + _ = divider.grab(row.data()); + t_split_captured = dvui.captured(divider.box.data().id); + } + divider.deinit(); + { + var right = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer right.deinit(); + } + t_cursor = dvui.currentWindow().cursorRequested(); + return .ok; +} + +test "a tab shows the hand" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = t_tab_rect.x + 10 * t_scale, .y = t_tab_rect.center().y } }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.hand, t_cursor); + try std.testing.expect(t_hovered); + + // Held, it keeps the hand (its hover look gives way to the press). + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_tab_captured); + try std.testing.expectEqual(.hand, t_cursor); +} + +test "beside a split, the split's arrow wins over the hand, and a press goes where it did" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + // Over the tab's last few points, inside the split's reach (`Split.handle_size` from its + // centre line). + const p: dvui.Point.Physical = .{ .x = t_sep_x - (Split.handle_size - 2) * t_scale, .y = t_tab_rect.center().y }; + try std.testing.expect(t_tab_rect.contains(p)); + + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = p }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.arrow_w_e, t_cursor); + + // The cursor only: the tab, drawn first, still takes the press there, as it did before it + // asked for a cursor at all. + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_tab_captured); + try std.testing.expect(!t_split_captured); + _ = try cw.addEventMouseButton(.left, .release); + _ = try dvui.testing.step(railFrame); + + // On the sash itself, past the tab, the split takes it. + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = t_sep_x, .y = p.y } }); + _ = try dvui.testing.step(railFrame); + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_split_captured); + try std.testing.expect(!t_tab_captured); +} + +test "a tapped tab is left without a hover look" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + const cw = dvui.currentWindow(); + const p: dvui.Point.Physical = .{ .x = t_tab_rect.x + 10 * t_scale, .y = t_tab_rect.center().y }; + const xynorm: dvui.Point = .{ .x = p.x / cw.rect_pixels.w, .y = p.y / cw.rect_pixels.h }; + _ = try cw.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = xynorm }); + _ = try dvui.testing.step(railFrame); + _ = try cw.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = xynorm }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.touch, dvui.pointerKind()); + try std.testing.expect(!t_hovered); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(!t_hovered); + + // A mouse over the same tab lights it. + _ = try cw.addEventMouseMotion(.{ .pt = p }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.mouse, dvui.pointerKind()); + try std.testing.expect(t_hovered); +} diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 66dcdc99b..a99ad043a 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -110,6 +110,26 @@ backend and never link SDL, so an SDL bump is never an SDK release and never mov proposing. Test: dvui's `zig build test -Dbackend=testing`, "closed RenderFrontToBack blocks are drawn after draw-last strokes, before or after they closed". +11. **Window: the pointer kind, and a lifted finger hovers nothing** (`e04bccc`). + `dvui.pointerKind()` is `.touch` or `.mouse`: what the last pointer event came from, set as the + backend adds events (a touch button is `.touch`; mouse motion, a mouse button or the wheel is + `.mouse`). It is the window's, so the app and every plugin dylib or wasm side module read the + same answer. The window keeps which touch buttons are down; after a touch with none, the + frame's `.position` event is off every window and targets no widget, so a tapped button, menu + row or tab is not left lit. Fizzy's hover looks and the corner button's tap (#555) read it. + Upstream: worth proposing. Test: "a finger that lifts hovers nothing, and a mouse hovers again". +12. **cursorSet: a priority, so a resize handle's cursor wins** (`20e56f1`). + `dvui.cursorSetPriority(cursor, priority)` keeps the highest asked in a frame (between + equals, the first); `cursorSet` asks at `.normal`. Resize handles ask at `.resize` (dvui's + `PanedWidget`, `GridWidget` columns, a floating window's bottom-right corner; fizzy's + `Split`), a carried thing at `.drag` (fizzy's view drag), so a hover cursor such as a tab's + hand never takes a split's arrow. Which widget takes a press is unchanged. Upstream: worth + proposing. Test: "cursorSetPriority: the higher asked wins, whichever asked first". +13. **FloatingWindowWidget: edges ask for their arrow at normal priority** (`40e664c`). Its + edges are handled after the window's children, which take a press there first, so their arrow + stays at `.normal` and a child under the band keeps both its cursor and its press. Only the + bottom-right corner, handled before the children, asks at `.resize`. Upstream: with 12. + Each of 5 and 6 is also a branch of its own on upstream `main` (`window-draw-retained`, `screens-set`), from which the upstream PR is opened. @@ -121,7 +141,9 @@ moves, so the second pin of one version takes a suffix, as the SDL fork's tags d (never pinned on `main`: its counter was reworked before merge); `fizzy-sdk-0.2.30-1` → `e2c9a85` (item 9 alone, never pinned); `fizzy-sdk-0.2.30-2` → `fdc03d4` (pinned by #540); `fizzy-sdk-0.2.30-3` → `cc60612` (never -pinned); `fizzy-sdk-0.2.30-4` → `44010a3`, what `sdk/build.zig.zon` pins now. +pinned); `fizzy-sdk-0.2.30-4` → `44010a3`; `fizzy-sdk-0.2.30-5` → +`20e56f1` (never pinned on `main`); `fizzy-sdk-0.2.30-6` → `40e664c`, what `sdk/build.zig.zon` +pins now. Fizzy builds dvui in its `custom` backend mode on every target and links its own backends (`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web` diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 2de84b1a1..56b2feb2b 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -311,6 +311,7 @@ fn drawTabs(self: *Workspace, region: sdk.Host.Region, tabs: []const *sdk.Surfac // Not under a carried tab: what is under it is where it would go in, not a tab to close. const tab_hovered = carried == null and core.widgets.hovered(hbox.data()); + core.widgets.Tabs.handCursor(hbox.data()); // A tab not on show lifts to the window's hover fill under the pointer. hbox.data().borderAndBackground(.{ .fill_color = if (tab_hovered and !is_selected) diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index 20b9c4980..bd572d9fe 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -39,9 +39,9 @@ // `fizzy-sdk-` tag in the fork before the pin lands. `fizzy-dev` itself stays a // working branch and can be rebased freely — the tag, never. .dvui = .{ - // fizzy-sdk-0.2.30-4 - .url = "https://github.com/foxnne/dvui-dev/archive/44010a31274fa49ce56c9cf96dc2706dffd09a30.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmdtlBAF4FZgHirDe3ghMmGMS46WlRhlT0Sx_TmEc", + // fizzy-sdk-0.2.30-6 + .url = "https://github.com/foxnne/dvui-dev/archive/40e664ce21d4b6d2018b13ec36f0567d33e25498.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmSKDBAFEplBpuAqF3iL0FKaES5SrmKYLRWRyyWLJ", //.path = "../../dvui-dev", }, .zf = .{ diff --git a/sdk/src/version.zig b/sdk/src/version.zig index a9aac1872..d04e2fc42 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x70dba3df7455c34f; +pub const recorded_sdk_shape_fingerprint: u64 = 0x35bac8377071cd23; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) {