Skip to content
Closed
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
3 changes: 3 additions & 0 deletions core/form_factor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
//! per frame, since a 2-in-1's pointer changes at runtime. `core.platform` holds the live value.
const std = @import("std");

/// Minimum touch hit width and height in points.
pub const min_hit: f32 = 44;

pub const FormFactor = enum { desktop, tablet, mobile };

/// The person's Layout setting (`settings.zon`'s `layout_mode`): `auto` follows what was detected,
Expand Down
1 change: 1 addition & 0 deletions core/sizing_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! nothing else: every bug this pair has had was a dvui event-routing or layout-settle rule rather
//! than arithmetic, and reading the code caught none of them.
test {
_ = @import("widgets/Button.zig");
_ = @import("glass.zig");
_ = @import("widgets/Split.zig");
_ = @import("widgets/ContextWidget.zig");
Expand Down
71 changes: 70 additions & 1 deletion core/widgets.zig
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ const dvui = @import("dvui");
const builtin = @import("builtin");
const icons = @import("icons");
const platform = @import("platform.zig");
const keyboard = @import("keyboard.zig");
const dialogs = @import("dialogs.zig");
const draw = @import("draw.zig");
const anchor = @import("replay").anchor;
const icon_tex = @import("gfx/icon.zig");

pub const Button = @import("widgets/Button.zig");

pub const CanvasWidget = @import("widgets/CanvasWidget.zig");
pub const ReorderWidget = @import("widgets/ReorderWidget.zig");
pub const FloatingWindowWidget = @import("widgets/FloatingWindowWidget.zig");
Expand Down Expand Up @@ -226,7 +229,7 @@ pub fn textEntryDeinit(te: *dvui.TextEntryWidget) void {
const focused = wd.id == dvui.focusedWidgetId();
te.deinit();
// The entry's own scroll area must be closed before scrolling the enclosing area.
@import("keyboard.zig").scrollIntoView(&wd, focused);
keyboard.scrollIntoView(&wd, focused);
}

/// A context menu's surface, opened at the point the pointer was pressed. Every right-click
Expand Down Expand Up @@ -476,3 +479,69 @@ pub fn tabCloseButtonOptions(over: dvui.Options) dvui.Options {
.ninepatch_press = &dvui.Ninepatch.none,
}));
}

// Keep these button helpers in step with dvui's helpers when updating the dvui pin.
/// Draw a labelled button and return whether it was clicked this frame.
/// Uses a centred minimum 44 pt touch target; mouse, drawing and layout follow dvui.
/// The label, interaction options and styling have the same contract as `dvui.button`.
pub fn button(src: std.builtin.SourceLocation, label_str: []const u8, init_opts: Button.InitOptions, opts: dvui.Options) bool {
var bw: Button = undefined;
bw.init(src, init_opts, opts);
bw.processEvents();
bw.drawBackground();
const click = bw.clicked();
dvui.labelNoFmt(@src(), label_str, .{ .align_x = 0.5, .align_y = 0.5 }, opts.strip().override(bw.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 }));
bw.drawFocus();
bw.deinit();

return click;
}

/// Draw the TinyVG icon in `tvg_bytes`, identified by `name`, and return a click this frame.
/// Uses a centred minimum 44 pt touch target and the cached icon renderer.
/// Interaction, icon and styling options follow `dvui.buttonIcon`.
pub fn buttonIcon(src: std.builtin.SourceLocation, name: []const u8, tvg_bytes: []const u8, init_opts: Button.InitOptions, icon_opts: dvui.IconRenderOptions, opts: dvui.Options) bool {
const defaults = dvui.Options{ .padding = dvui.Rect.all(4), .label = .{ .text = name } };
var bw: Button = undefined;
bw.init(src, init_opts, defaults.override(opts));
bw.processEvents();
bw.drawBackground();
icon_tex.icon(
@src(),
name,
tvg_bytes,
icon_opts,
opts.strip().override(bw.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5, .min_size_content = opts.min_size_content, .expand = .ratio, .color_text = opts.color_text, .role = .none }),
);

const click = bw.clicked();
bw.drawFocus();
bw.deinit();
return click;
}

pub const ButtonLabelAndIconOptions = dvui.ButtonLabelAndIconOptions;

/// Draw the label and icon described by `combined_opts` and return a click this frame.
/// Uses a centred minimum 44 pt touch target and the cached icon renderer.
/// Combined and styling options follow `dvui.buttonLabelAndIcon`.
pub fn buttonLabelAndIcon(src: std.builtin.SourceLocation, combined_opts: ButtonLabelAndIconOptions, opts: dvui.Options) bool {
var bw: Button = undefined;
bw.init(src, combined_opts.button_opts, opts);
bw.processEvents();
const options = opts.strip().override(bw.style()).override(.{ .gravity_y = 0.5 });
bw.drawBackground();
{
var outer_hbox = dvui.box(src, .{ .dir = .horizontal }, .{ .expand = .horizontal, .gravity_y = 0.5 });
defer outer_hbox.deinit();
icon_tex.icon(@src(), combined_opts.icon_label orelse combined_opts.label, combined_opts.tvg_bytes, .{}, options.strip().override(.{ .gravity_x = if (combined_opts.icon_first) 0.0 else 1.0, .color_text = opts.color_text }));
dvui.labelEx(@src(), "{s}", .{combined_opts.label}, .{ .align_x = 0.5 }, options.strip().override(.{ .expand = .both }));
}

const click = bw.clicked();

bw.drawFocus();

bw.deinit();
return click;
}
149 changes: 149 additions & 0 deletions core/widgets/Button.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! dvui's button with a minimum touch hit area independent of its drawn and layout rectangles.
const std = @import("std");
const dvui = @import("dvui");
const form_factor = @import("../form_factor.zig");
const Button = @This();

inner: dvui.ButtonWidget,

pub const InitOptions = dvui.ButtonWidget.InitOptions;

/// Install a button using dvui interaction and layout options; pair with `deinit`.
/// Call during a frame, then process events before drawing its background and children.
pub fn init(self: *Button, src: std.builtin.SourceLocation, init_opts: InitOptions, opts: dvui.Options) void {
self.inner.init(src, init_opts, opts);
}

/// The physical click rectangle, centred on the border and never smaller than it.
pub fn hitRect(self: *Button) dvui.Rect.Physical {
const rs = self.data().borderRectScale();
var rect = rs.r;
if (dvui.pointerKind() == .touch) {
const minimum = form_factor.min_hit * rs.s;
const w = @max(rect.w, minimum);
const h = @max(rect.h, minimum);
rect.x -= (w - rect.w) / 2;
rect.y -= (h - rect.h) / 2;
rect.w = w;
rect.h = h;
}
return rect;
}

/// Consume this frame's clicks using the touch hit rectangle; call once before drawing.
pub fn processEvents(self: *Button) void {
// TODO(touch-target arbitration): choose the nearest border before consuming overlapping
// targets; processing order wins today, including over a neighbour's border. Resolve before
// migrating tightly packed toolbars.
// dvui's ButtonWidget uses this same click path; only the hit rectangle differs.
self.inner.click = dvui.clicked(self.data(), .{
.rect = self.hitRect(),
.hovered = &self.inner.hover,
.touch_drag = self.inner.init_options.touch_drag,
});
}

/// Draw the styled border and background without expanding them to the touch target.
pub fn drawBackground(self: *Button) void {
self.inner.drawBackground();
}

/// Draw the keyboard focus indicator after drawing the button's children.
pub fn drawFocus(self: *Button) void {
self.inner.drawFocus();
}

/// The current dvui style, including hover and press state, for styling children.
pub fn style(self: *Button) dvui.Options {
return self.inner.style();
}

/// Whether event processing activated the button this frame.
pub fn clicked(self: *Button) bool {
return self.inner.clicked();
}

/// Borrow the inner widget data; valid until `deinit`.
pub fn data(self: *Button) *dvui.WidgetData {
return self.inner.data();
}

/// Finish layout and restore the parent; releases a dvui-allocated widget if applicable.
pub fn deinit(self: *Button) void {
self.inner.deinit();
}

test "touch clicks six points outside a 32 point border without changing its drawn rect" {
const Frame = struct {
// The test callback writes these on the testing window's thread.
var border: dvui.Rect.Physical = .{};
var click: bool = false;

fn draw() !dvui.App.Result {
var button: Button = undefined;
button.init(@src(), .{}, .{
.rect = .{ .x = 50, .y = 50, .w = 32, .h = 32 },
.margin = dvui.Rect.all(0),
});
defer button.deinit();
button.processEvents();
button.drawBackground();
border = button.data().borderRectScale().r;
click = button.clicked();
return .ok;
}
};
var t = try dvui.testing.init(.{ .window_size = .{ .w = 200, .h = 200 } });
defer t.deinit();
try dvui.testing.settle(Frame.draw);
const original = Frame.border;
const cw = dvui.currentWindow();
const scale = cw.natural_scale;
const point: dvui.Point.Physical = .{ .x = original.x - 6 * scale, .y = original.center().y };
const norm: dvui.Point = .{ .x = point.x / (200 * scale), .y = point.y / (200 * scale) };
_ = try cw.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = norm });
_ = try dvui.testing.step(Frame.draw);
_ = try cw.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = norm });
_ = try dvui.testing.step(Frame.draw);
try std.testing.expectEqual(.touch, dvui.pointerKind());
try std.testing.expect(Frame.click);
try std.testing.expectEqual(original, Frame.border);

_ = try cw.addEventMouseMotion(.{ .pt = point });
_ = try cw.addEventMouseButton(.left, .press);
_ = try dvui.testing.step(Frame.draw);
_ = try cw.addEventMouseButton(.left, .release);
_ = try dvui.testing.step(Frame.draw);
try std.testing.expectEqual(.mouse, dvui.pointerKind());
try std.testing.expect(!Frame.click);
try std.testing.expectEqual(original, Frame.border);
}

test "button verbs draw and accept a touch outside their borders" {
const widgets = @import("../widgets.zig");
const Frame = struct {
// The draw callback writes the click results on the testing window's thread.
var clicks: [3]bool = .{ false, false, false };

fn draw() !dvui.App.Result {
const opts: dvui.Options = .{ .rect = .{ .x = 50, .y = 50, .w = 32, .h = 32 }, .margin = dvui.Rect.all(0) };
clicks[0] = widgets.button(@src(), "Label", .{}, opts);
clicks[1] = widgets.buttonIcon(@src(), "Icon", dvui.entypo.cross, .{}, .{}, opts.override(.{ .rect = .{ .x = 50, .y = 100, .w = 32, .h = 32 } }));
clicks[2] = widgets.buttonLabelAndIcon(@src(), .{ .label = "Both", .tvg_bytes = dvui.entypo.cross }, opts.override(.{ .rect = .{ .x = 50, .y = 150, .w = 32, .h = 32 } }));
return .ok;
}
};
var t = try dvui.testing.init(.{ .window_size = .{ .w = 200, .h = 200 } });
defer t.deinit();
try dvui.testing.settle(Frame.draw);
const cw = dvui.currentWindow();
for (0..3) |i| {
// Six points left of each border, at its vertical centre.
const norm: dvui.Point = .{ .x = 44.0 / 200.0, .y = (66.0 + 50.0 * @as(f32, @floatFromInt(i))) / 200.0 };
_ = try cw.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = norm });
_ = try dvui.testing.step(Frame.draw);
_ = try cw.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = norm });
_ = try dvui.testing.step(Frame.draw);
for (Frame.clicks, 0..) |activated, j| try std.testing.expectEqual(i == j, activated);
}
}
30 changes: 30 additions & 0 deletions scripts/lint-allow.zon
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,34 @@
.{ .rule = .@"raw-scroll-area", .file = "src/editor/dialogs/FileTypeDefaults.zig", .count = 1 },
.{ .rule = .@"raw-scroll-area", .file = "src/editor/dialogs/PluginUpdates.zig", .count = 1 },
.{ .rule = .@"raw-scroll-area", .file = "tests/integration.zig", .count = 3 },
.{ .rule = .@"raw-button", .file = "app/ActivityFeed.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "app/layout/Picker.zig", .count = 3 },
.{ .rule = .@"raw-button", .file = "app/layout/Region.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "app/store/PluginStore.zig", .count = 17 },
.{ .rule = .@"raw-button", .file = "app/update/update_notify.zig", .count = 4 },
.{ .rule = .@"raw-button", .file = "core/dialogs.zig", .count = 4 },
.{ .rule = .@"raw-button", .file = "core/widgets/ContextWidget.zig", .count = 2 },
.{ .rule = .@"raw-button", .file = "core/widgets/DockingWidget.zig", .count = 2 },
.{ .rule = .@"raw-button", .file = "core/widgets/TreeWidget.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "core/widgets/glass_button.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "plugins/dashboard/src/View.zig", .count = 2 },
.{ .rule = .@"raw-button", .file = "plugins/devtools/src/Dashboard.zig", .count = 2 },
.{ .rule = .@"raw-button", .file = "plugins/text/src/TextEditor.zig", .count = 2 },
.{ .rule = .@"raw-button", .file = "plugins/text/src/widgets/TextEntryWidget.zig", .count = 4 },
.{ .rule = .@"raw-button", .file = "plugins/workbench/src/Workspace.zig", .count = 4 },
.{ .rule = .@"raw-button", .file = "plugins/workbench/src/files.zig", .count = 3 },
.{ .rule = .@"raw-button", .file = "sdk/replay/Snapshot.zig", .count = 4 },
.{ .rule = .@"raw-button", .file = "sdk/src/Host.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/Accounts.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/Editor.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/FileTypeSettings.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/Infobar.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/KeybindSettings.zig", .count = 2 },
.{ .rule = .@"raw-button", .file = "src/editor/LayoutSettings.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/OutputPanel.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/dialogs/SaveQuestion.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "src/editor/explorer/settings.zig", .count = 2 },
.{ .rule = .@"raw-button", .file = "src/editor/layout_mobile.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "tests/bench/bench_replay.zig", .count = 1 },
.{ .rule = .@"raw-button", .file = "tests/integration.zig", .count = 2 },
}
32 changes: 28 additions & 4 deletions scripts/lint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const Rule = enum {
@"changelog-words",
@"raw-transient-surface",
@"raw-scroll-area",
@"raw-button",
};
const Finding = struct { rule: Rule, line: usize, message: []const u8 };
const Allow = struct { rule: Rule, file: []const u8, count: usize };
Expand Down Expand Up @@ -249,15 +250,15 @@ const RawCall = struct {
name: []const u8,
rule: Rule,
message: []const u8,
call_only: bool = false,
next: ?std.zig.Token.Tag = null,
};
const raw_calls = blk: {
const surfaces = [_][]const u8{
"tooltip", "FloatingTooltipWidget", "floatingMenu", "FloatingMenuWidget",
"popup", "PopupWidget", "floatingWindow", "FloatingWindowWidget",
"dialog", "toast", "contextMenu", "dropdown",
};
var calls: [surfaces.len + 1]RawCall = undefined;
var calls: [surfaces.len + 5]RawCall = undefined;
for (surfaces, 0..) |name, i| calls[i] = .{
.name = name,
.rule = .@"raw-transient-surface",
Expand All @@ -267,7 +268,13 @@ const raw_calls = blk: {
.name = "scrollArea",
.rule = .@"raw-scroll-area",
.message = "dvui.scrollArea bypasses touch scrolling; use core.widgets.ScrollArea.init",
.call_only = true,
.next = .l_paren,
};
for ([_][]const u8{ "button", "buttonIcon", "buttonLabelAndIcon", "ButtonWidget" }, 0..) |name, i| calls[surfaces.len + 1 + i] = .{
.name = name,
.rule = .@"raw-button",
.message = "raw dvui button bypasses touch hit targets; use core.widgets.Button or its verbs",
.next = if (i == 3) .equal else .l_paren,
};
break :blk calls;
};
Expand All @@ -286,7 +293,9 @@ fn rawDvuiCalls(
const name = tree.tokenSlice(@intCast(i + 2));
for (raw_calls) |banned| {
if (!std.mem.eql(u8, name, banned.name)) continue;
if (banned.call_only and (i + 3 >= tags.len or tags[i + 3] != .l_paren)) continue;
if (banned.next) |next| {
if (i + 3 >= tags.len or tags[i + 3] != next) continue;
}
const line = tree.tokenLocation(0, @intCast(i)).line + 1;
if (silenced(suppressions, banned.rule, line)) break;
try findings.append(allocator, .{
Expand Down Expand Up @@ -893,3 +902,18 @@ test "a file over its raw-scroll-area budget fails" {
try std.testing.expect(!try reportFile(&output.writer, "over.zig", findings[0..1], &allowances, &seen, false));
try std.testing.expectEqualStrings("", output.written());
}

test "raw-button catches verbs and declarations but allows type references" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const findings = try analyze(
arena.allocator(),
"fn f() void { _ = dvui.button(a,b,c,d); _ = dvui.buttonIcon(a,b,c,d,e,f); _ = dvui.buttonLabelAndIcon(a,b,c); var b: dvui.ButtonWidget = undefined; }\n" ++
"const InitOptions = dvui.ButtonWidget.InitOptions;\n" ++
"fn g(b: *dvui.ButtonWidget) void {}\n" ++
"// lint-allow(raw-button): wrapper owns the dvui implementation\n" ++
"var b: dvui.ButtonWidget = undefined;",
);
try std.testing.expectEqual(@as(usize, 4), findings.len);
for (findings) |finding| try std.testing.expectEqual(Rule.@"raw-button", finding.rule);
}
2 changes: 1 addition & 1 deletion src/editor/explorer/settings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ fn drawFloatWindows() void {
drawToggle(&editor.app.settings.float_windows, "Floats in their own windows");
// Taken at launch: changed, it waits for a restart, offered here and in the info bar.
if (editor.restartPending()) {
if (dvui.button(@src(), "Restart", .{}, .{ .gravity_y = 0.5, .margin = .{ .x = 6 }, .padding = .{ .x = 8, .w = 8, .y = 2, .h = 2 } })) {
if (core.widgets.button(@src(), "Restart", .{}, .{ .gravity_y = 0.5, .margin = .{ .x = 6 }, .padding = .{ .x = 8, .w = 8, .y = 2, .h = 2 } })) {
restart.request();
}
}
Expand Down
Loading