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.
This commit is contained in:
Tim Culverhouse 2024-11-26 06:46:01 -06:00
parent 1346e3535d
commit ca4d0b6491
8 changed files with 67 additions and 74 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,7 +39,6 @@ test SizedBox {
pub fn widget(self: *@This()) vxfw.Widget {
return .{
.userdata = self,
.eventHandler = vxfw.noopEventHandler,
.drawFn = @This().typeErasedDrawFn,
};
}

View file

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

View file

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