From ca4d0b6491f98e93b9513f6b5c300faebd94f841 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Tue, 26 Nov 2024 06:46:01 -0600 Subject: [PATCH] vxfw: improve mouse handling and hit testing Modify the handling and hit testing: - Only widgets which have event handlers or capture handlers are considered for the hit list - The topmost widget is always the target. We used to consider the last widget which handled the mouse as the target. Now we consider the topmost the target. This lets us generate an explicit mouse_enter event since we can determine this before sending events - Modify relevant widgets to remove noopEventHandler and remove this function entirely - mouse_enter and mouse_leave events are based on how browsers determine these events. Any widget hit this frame that was not hit last frame gets a mouse_enter. Any widget which was hit last_frame but not this frame gets a mouse_leave. --- src/vxfw/App.zig | 111 ++++++++++++++++++++-------------------- src/vxfw/Button.zig | 13 +++-- src/vxfw/FlexColumn.zig | 1 - src/vxfw/FlexRow.zig | 1 - src/vxfw/RichText.zig | 1 - src/vxfw/SizedBox.zig | 1 - src/vxfw/Text.zig | 1 - src/vxfw/vxfw.zig | 12 ++--- 8 files changed, 67 insertions(+), 74 deletions(-) diff --git a/src/vxfw/App.zig b/src/vxfw/App.zig index 797e49d..61f89f5 100644 --- a/src/vxfw/App.zig +++ b/src/vxfw/App.zig @@ -81,6 +81,7 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void { var buffered = tty.bufferedWriter(); var mouse_handler = MouseHandler.init(widget); + defer mouse_handler.deinit(self.allocator); var focus_handler = FocusHandler.init(self.allocator, widget); focus_handler.intrusiveInit(); try focus_handler.path_to_focused.append(widget); @@ -208,7 +209,7 @@ fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void { const MouseHandler = struct { last_frame: vxfw.Surface, - maybe_last_handler: ?vxfw.Widget = null, + last_hit_list: []vxfw.HitResult, fn init(root: Widget) MouseHandler { return .{ @@ -218,14 +219,18 @@ const MouseHandler = struct { .buffer = &.{}, .children = &.{}, }, - .maybe_last_handler = null, + .last_hit_list = &.{}, }; } + fn deinit(self: MouseHandler, gpa: Allocator) void { + gpa.free(self.last_hit_list); + } + fn handleMouse(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) anyerror!void { + // For mouse events we store the last frame and use that for hit testing const last_frame = self.last_frame; - // For mouse events we store the last frame and use that for hit testing var hits = std.ArrayList(vxfw.HitResult).init(app.allocator); defer hits.deinit(); const sub: vxfw.SubSurface = .{ @@ -241,19 +246,48 @@ const MouseHandler = struct { try last_frame.hitTest(&hits, mouse_point); } - // See if our new hit test contains our last handler. If it doesn't we'll send a mouse_leave - // event - if (self.maybe_last_handler) |last_handler| { - for (hits.items) |item| { - if (item.widget.eql(last_handler)) break; - } else { - try last_handler.handleEvent(ctx, .mouse_leave); - self.maybe_last_handler = null; - try app.handleCommand(&ctx.cmds); + // Handle mouse_enter and mouse_leave events + { + // We store the hit list from the last mouse event to determine mouse_enter and mouse_leave + // events. If list a is the previous hit list, and list b is the current hit list: + // - Widgets in a but not in b get a mouse_leave event + // - Widgets in b but not in a get a mouse_enter event + // - Widgets in both receive nothing + const a = self.last_hit_list; + const b = hits.items; + + // Find widgets in a but not b + for (a) |a_item| { + const a_widget = a_item.widget; + for (b) |b_item| { + const b_widget = b_item.widget; + if (a_widget.eql(b_widget)) break; + } else { + // a_item is not in b + try a_widget.handleEvent(ctx, .mouse_leave); + try app.handleCommand(&ctx.cmds); + } } + + // Widgets in b but not in a + for (b) |b_item| { + const b_widget = b_item.widget; + for (a) |a_item| { + const a_widget = a_item.widget; + if (b_widget.eql(a_widget)) break; + } else { + // b_item is not in a. + try b_widget.handleEvent(ctx, .mouse_enter); + try app.handleCommand(&ctx.cmds); + } + } + + // Store a copy of this hit list for next frame + app.allocator.free(self.last_hit_list); + self.last_hit_list = try app.allocator.dupe(vxfw.HitResult, hits.items); } - const maybe_target = hits.popOrNull(); + const target = hits.popOrNull() orelse return; // capturing phase ctx.phase = .capturing; @@ -264,40 +298,19 @@ const MouseHandler = struct { try item.widget.captureEvent(ctx, .{ .mouse = m_local }); try app.handleCommand(&ctx.cmds); - // If the event was consumed, we check if we need to send a mouse_leave and return - if (ctx.consume_event) { - if (self.maybe_last_handler) |last_handler| { - if (!last_handler.eql(item.widget)) { - try last_handler.handleEvent(ctx, .mouse_leave); - self.maybe_last_handler = item.widget; - try app.handleCommand(&ctx.cmds); - } - } - self.maybe_last_handler = item.widget; - return; - } + if (ctx.consume_event) return; } // target phase ctx.phase = .at_target; - if (maybe_target) |target| { + { var m_local = mouse; m_local.col = target.local.col; m_local.row = target.local.row; try target.widget.handleEvent(ctx, .{ .mouse = m_local }); try app.handleCommand(&ctx.cmds); - // If the event was consumed, we check if we need to send a mouse_leave and return - if (ctx.consume_event) { - if (self.maybe_last_handler) |last_handler| { - if (!last_handler.eql(target.widget)) { - try last_handler.handleEvent(ctx, .mouse_leave); - self.maybe_last_handler = target.widget; - try app.handleCommand(&ctx.cmds); - } - } - self.maybe_last_handler = target.widget; - return; - } + + if (ctx.consume_event) return; } // Bubbling phase @@ -309,29 +322,15 @@ const MouseHandler = struct { try item.widget.handleEvent(ctx, .{ .mouse = m_local }); try app.handleCommand(&ctx.cmds); - // If the event was consumed, we check if we need to send a mouse_leave and return - if (ctx.consume_event) { - if (self.maybe_last_handler) |last_handler| { - if (!last_handler.eql(item.widget)) { - try last_handler.handleEvent(ctx, .mouse_leave); - self.maybe_last_handler = item.widget; - try app.handleCommand(&ctx.cmds); - } - } - self.maybe_last_handler = item.widget; - return; - } + if (ctx.consume_event) return; } - - // If no one handled the mouse, we assume it exited - return self.mouseExit(app, ctx); } + /// sends .mouse_leave to all of the widgets from the last_hit_list fn mouseExit(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext) anyerror!void { - if (self.maybe_last_handler) |last_handler| { - try last_handler.handleEvent(ctx, .mouse_leave); + for (self.last_hit_list) |item| { + try item.widget.handleEvent(ctx, .mouse_leave); try app.handleCommand(&ctx.cmds); - self.maybe_last_handler = null; } } }; diff --git a/src/vxfw/Button.zig b/src/vxfw/Button.zig index 70c3c8d..978acce 100644 --- a/src/vxfw/Button.zig +++ b/src/vxfw/Button.zig @@ -57,15 +57,14 @@ pub fn handleEvent(self: *Button, ctx: *vxfw.EventContext, event: vxfw.Event) an self.mouse_down = true; return ctx.consumeAndRedraw(); } - if (!self.has_mouse) { - self.has_mouse = true; - - // implicit redraw - try ctx.setMouseShape(.pointer); - return ctx.consumeAndRedraw(); - } return ctx.consumeEvent(); }, + .mouse_enter => { + // implicit redraw + self.has_mouse = true; + try ctx.setMouseShape(.pointer); + return ctx.consumeAndRedraw(); + }, .mouse_leave => { self.has_mouse = false; self.mouse_down = false; diff --git a/src/vxfw/FlexColumn.zig b/src/vxfw/FlexColumn.zig index 55a8890..219f7cc 100644 --- a/src/vxfw/FlexColumn.zig +++ b/src/vxfw/FlexColumn.zig @@ -12,7 +12,6 @@ children: []const vxfw.FlexItem, pub fn widget(self: *const FlexColumn) vxfw.Widget { return .{ .userdata = @constCast(self), - .eventHandler = vxfw.noopEventHandler, .drawFn = typeErasedDrawFn, }; } diff --git a/src/vxfw/FlexRow.zig b/src/vxfw/FlexRow.zig index 238f818..6ad9fd9 100644 --- a/src/vxfw/FlexRow.zig +++ b/src/vxfw/FlexRow.zig @@ -12,7 +12,6 @@ children: []const vxfw.FlexItem, pub fn widget(self: *const FlexRow) vxfw.Widget { return .{ .userdata = @constCast(self), - .eventHandler = vxfw.noopEventHandler, .drawFn = typeErasedDrawFn, }; } diff --git a/src/vxfw/RichText.zig b/src/vxfw/RichText.zig index c287c3b..8878f65 100644 --- a/src/vxfw/RichText.zig +++ b/src/vxfw/RichText.zig @@ -22,7 +22,6 @@ width_basis: enum { parent, longest_line } = .longest_line, pub fn widget(self: *const RichText) vxfw.Widget { return .{ .userdata = @constCast(self), - .eventHandler = vxfw.noopEventHandler, .drawFn = typeErasedDrawFn, }; } diff --git a/src/vxfw/SizedBox.zig b/src/vxfw/SizedBox.zig index b65e4ca..730c436 100644 --- a/src/vxfw/SizedBox.zig +++ b/src/vxfw/SizedBox.zig @@ -39,7 +39,6 @@ test SizedBox { pub fn widget(self: *@This()) vxfw.Widget { return .{ .userdata = self, - .eventHandler = vxfw.noopEventHandler, .drawFn = @This().typeErasedDrawFn, }; } diff --git a/src/vxfw/Text.zig b/src/vxfw/Text.zig index 8ed2aa0..6345e93 100644 --- a/src/vxfw/Text.zig +++ b/src/vxfw/Text.zig @@ -17,7 +17,6 @@ width_basis: enum { parent, longest_line } = .longest_line, pub fn widget(self: *const Text) vxfw.Widget { return .{ .userdata = @constCast(self), - .eventHandler = vxfw.noopEventHandler, .drawFn = typeErasedDrawFn, }; } diff --git a/src/vxfw/vxfw.zig b/src/vxfw/vxfw.zig index bb98731..0ea01f2 100644 --- a/src/vxfw/vxfw.zig +++ b/src/vxfw/vxfw.zig @@ -47,6 +47,7 @@ pub const Event = union(enum) { tick, // An event from a Tick command init, // sent when the application starts mouse_leave, // The mouse has left the widget + mouse_enter, // The mouse has enterred the widget }; pub const Tick = struct { @@ -216,10 +217,10 @@ pub const Widget = struct { return self.drawFn(self.userdata, ctx); } - /// Returns true if the Widgets point to the same widget instance + /// Returns true if the Widgets point to the same widget instance. To be considered the same, + /// the userdata and drawFn fields must point to the same values in both widgets pub fn eql(self: Widget, other: Widget) bool { return @intFromPtr(self.userdata) == @intFromPtr(other.userdata) and - @intFromPtr(self.eventHandler) == @intFromPtr(other.eventHandler) and @intFromPtr(self.drawFn) == @intFromPtr(other.drawFn); } }; @@ -337,7 +338,9 @@ pub const Surface = struct { /// always be translated to local Surface coordinates. Asserts that this Surface does contain Point pub fn hitTest(self: Surface, list: *std.ArrayList(HitResult), point: Point) Allocator.Error!void { assert(point.col < self.size.width and point.row < self.size.height); - try list.append(.{ .local = point, .widget = self.widget }); + // Add this widget to the hit list if it has an event or capture handler + if (self.widget.eventHandler != null or self.widget.captureHandler != null) + try list.append(.{ .local = point, .widget = self.widget }); for (self.children) |child| { if (!child.containsPoint(point)) continue; const child_point: Point = .{ @@ -412,9 +415,6 @@ pub const SubSurface = struct { } }; -/// A noop event handler for widgets which don't require any event handling -pub fn noopEventHandler(_: *anyopaque, _: *EventContext, _: Event) anyerror!void {} - test { std.testing.refAllDecls(@This()); }