Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/layout/Layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
2 changes: 2 additions & 0 deletions backend/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
63 changes: 45 additions & 18 deletions backend/src/SDLBackend.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
},
Expand Down
123 changes: 123 additions & 0 deletions backend/src/touch_slots.zig
Original file line number Diff line number Diff line change
@@ -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));
}
12 changes: 12 additions & 0 deletions core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions core/sizing_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 5 additions & 1 deletion core/widgets/FloatingWindowWidget.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 => {},
Expand Down
5 changes: 4 additions & 1 deletion core/widgets/Split.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {},
}
}
Expand Down
Loading
Loading