vxfw: add SplitView widget

This commit is contained in:
Tim Culverhouse 2024-11-01 12:42:40 -05:00
parent b3e6157130
commit 6318b06653
3 changed files with 201 additions and 1 deletions

View file

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

198
src/vxfw/SplitView.zig Normal file
View file

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

View file

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