From 6318b06653e117c650e835e86c2ebf288252a526 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Fri, 1 Nov 2024 12:42:40 -0500 Subject: [PATCH] vxfw: add SplitView widget --- src/vxfw/App.zig | 3 +- src/vxfw/SplitView.zig | 198 +++++++++++++++++++++++++++++++++++++++++ src/vxfw/vxfw.zig | 1 + 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/vxfw/SplitView.zig diff --git a/src/vxfw/App.zig b/src/vxfw/App.zig index 68f8c68..f2441bd 100644 --- a/src/vxfw/App.zig +++ b/src/vxfw/App.zig @@ -63,7 +63,7 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void { if (!vx.state.in_band_resize) try loop.init(); } - // HACK: Ghostty is reporting incorrect pixel screen size + // NOTE: We don't use pixel mouse anywhere vx.caps.sgr_pixels = false; try vx.setMouseMode(tty.anyWriter(), true); @@ -402,6 +402,7 @@ const FocusHandler = struct { } fn deinit(self: *FocusHandler) void { + self.path_to_focused.deinit(); self.arena.deinit(); } diff --git a/src/vxfw/SplitView.zig b/src/vxfw/SplitView.zig new file mode 100644 index 0000000..9030657 --- /dev/null +++ b/src/vxfw/SplitView.zig @@ -0,0 +1,198 @@ +const std = @import("std"); +const vaxis = @import("../main.zig"); + +const Allocator = std.mem.Allocator; + +const vxfw = @import("vxfw.zig"); + +const SplitView = @This(); + +lhs: vxfw.Widget, +rhs: vxfw.Widget, +constrain: enum { lhs, rhs } = .lhs, +style: vaxis.Style = .{}, +/// min width for the constrained side +min_width: u16 = 0, +/// max width for the constrained side +max_width: ?u16 = null, +/// Target width to draw at +width: u16, + +/// Statically allocated children +children: [2]vxfw.SubSurface = undefined, + +// State +pressed: bool = false, +mouse_set: bool = false, + +pub fn widget(self: *const SplitView) vxfw.Widget { + return .{ + .userdata = @constCast(self), + .eventHandler = typeErasedEventHandler, + .drawFn = typeErasedDrawFn, + }; +} + +fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { + const self: *SplitView = @ptrCast(@alignCast(ptr)); + if (event != .mouse) return; + const mouse = event.mouse; + + const separator_col: u16 = switch (self.constrain) { + .lhs => self.width + 1, + .rhs => self.width -| 1, + }; + + // If we are on the separator, we always set the mouse shape + if (mouse.col == separator_col) { + try ctx.setMouseShape(.@"ew-resize"); + self.mouse_set = true; + // Set pressed state if we are a left click + if (mouse.type == .press and mouse.button == .left) { + self.pressed = true; + } + } else if (self.mouse_set) { + // If we have set the mouse state and *aren't* over the separator, default the mouse state + try ctx.setMouseShape(.default); + self.mouse_set = false; + } + + // On release, we reset state + if (mouse.type == .release) { + self.pressed = false; + self.mouse_set = false; + try ctx.setMouseShape(.default); + } + + // If pressed, we always keep the mouse shape and we update the width + if (self.pressed) { + try ctx.setMouseShape(.@"ew-resize"); + self.width = @max(self.min_width, mouse.col -| 1); + if (self.max_width) |max| { + self.width = @min(self.width, max); + } + } +} + +fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { + const self: *SplitView = @ptrCast(@alignCast(ptr)); + // Fills entire space + const max = ctx.max.size(); + // Constrain width to the max + self.width = @min(self.width, max.width); + + // The constrained side is equal to the width + const constrained_min: vxfw.Size = .{ .width = self.width, .height = max.height }; + const constrained_max: vxfw.MaxSize = .{ .width = self.width, .height = max.height }; + + const unconstrained_min: vxfw.Size = .{ .width = max.width - self.width - 2, .height = max.height }; + const unconstrained_max: vxfw.MaxSize = .{ .width = max.width - self.width - 2, .height = max.height }; + + switch (self.constrain) { + .lhs => { + const lhs_ctx = ctx.withConstraints(constrained_min, constrained_max); + const lhs_surface = try self.lhs.draw(lhs_ctx); + + self.children[0] = .{ + .surface = lhs_surface, + .origin = .{ .row = 0, .col = 0 }, + }; + const rhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max); + const rhs_surface = try self.rhs.draw(rhs_ctx); + self.children[1] = .{ + .surface = rhs_surface, + .origin = .{ .row = 0, .col = self.width + 2 }, + }; + }, + .rhs => { + const lhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max); + const lhs_surface = try self.lhs.draw(lhs_ctx); + self.children[0] = .{ + .surface = lhs_surface, + .origin = .{ .row = 0, .col = 0 }, + }; + const rhs_ctx = ctx.withConstraints(constrained_min, constrained_max); + const rhs_surface = try self.rhs.draw(rhs_ctx); + self.children[1] = .{ + .surface = rhs_surface, + .origin = .{ .row = 0, .col = self.width + 2 }, + }; + }, + } + + var surface = try vxfw.Surface.initWithChildren(ctx.arena, self.widget(), max, &self.children); + surface.handles_mouse = true; + for (0..max.height) |row| { + surface.writeCell(self.width + 1, @intCast(row), .{ + .char = .{ .grapheme = "│", .width = 1 }, + .style = self.style, + }); + } + return surface; +} + +test SplitView { + // Boiler plate draw context + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const ucd = try vaxis.Unicode.init(arena.allocator()); + vxfw.DrawContext.init(&ucd, .unicode); + + const draw_ctx: vxfw.DrawContext = .{ + .arena = arena.allocator(), + .min = .{}, + .max = .{ .width = 16, .height = 16 }, + }; + + // Create LHS and RHS widgets + const lhs: vxfw.Text = .{ .text = "Left hand side" }; + const rhs: vxfw.Text = .{ .text = "Right hand side" }; + + var split_view: SplitView = .{ + .lhs = lhs.widget(), + .rhs = rhs.widget(), + .width = 8, + }; + + const split_widget = split_view.widget(); + { + const surface = try split_widget.draw(draw_ctx); + // SplitView expands to fill the space + try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 16, .height = 16 }), surface.size); + // It has two children + try std.testing.expectEqual(2, surface.children.len); + // The left child should have a width = SplitView.width + try std.testing.expectEqual(split_view.width, surface.children[0].surface.size.width); + } + + // Send the widget a mouse press on the separator + var mouse: vaxis.Mouse = .{ + // The separator is width + 1 + .col = split_view.width + 1, + .row = 0, + .type = .press, + .button = .left, + .mods = .{}, + }; + + var ctx: vxfw.EventContext = .{ + .cmds = std.ArrayList(vxfw.Command).init(arena.allocator()), + }; + try split_widget.handleEvent(&ctx, .{ .mouse = mouse }); + // We should get a command to change the mouse shape + try std.testing.expect(ctx.cmds.items[0] == .set_mouse_shape); + try std.testing.expect(ctx.redraw); + try std.testing.expect(split_view.pressed); + + // If we move the mouse, we should update the width + mouse.col = 2; + mouse.type = .drag; + try split_widget.handleEvent(&ctx, .{ .mouse = mouse }); + try std.testing.expect(ctx.redraw); + try std.testing.expect(split_view.pressed); + try std.testing.expectEqual(mouse.col - 1, split_view.width); +} + +test "refAllDecls" { + std.testing.refAllDecls(@This()); +} diff --git a/src/vxfw/vxfw.zig b/src/vxfw/vxfw.zig index f57656a..0708285 100644 --- a/src/vxfw/vxfw.zig +++ b/src/vxfw/vxfw.zig @@ -19,6 +19,7 @@ pub const ListView = @import("ListView.zig"); pub const Padding = @import("Padding.zig"); pub const RichText = @import("RichText.zig"); pub const SizedBox = @import("SizedBox.zig"); +pub const SplitView = @import("SplitView.zig"); pub const Spinner = @import("Spinner.zig"); pub const Text = @import("Text.zig"); pub const TextField = @import("TextField.zig");