a modern tui library written in zig
1const std = @import("std");
2const vaxis = @import("../main.zig");
3
4const Allocator = std.mem.Allocator;
5
6const vxfw = @import("vxfw.zig");
7
8const SplitView = @This();
9
10lhs: vxfw.Widget,
11rhs: vxfw.Widget,
12constrain: enum { lhs, rhs } = .lhs,
13style: vaxis.Style = .{},
14/// min width for the constrained side
15min_width: u16 = 0,
16/// max width for the constrained side
17max_width: ?u16 = null,
18/// Target width to draw at
19width: u16,
20
21/// Used to calculate mouse events when our constraint is rhs
22last_max_width: ?u16 = null,
23
24// State
25pressed: bool = false,
26mouse_set: bool = false,
27
28pub fn widget(self: *const SplitView) vxfw.Widget {
29 return .{
30 .userdata = @constCast(self),
31 .eventHandler = typeErasedEventHandler,
32 .drawFn = typeErasedDrawFn,
33 };
34}
35
36fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
37 const self: *SplitView = @ptrCast(@alignCast(ptr));
38 switch (event) {
39 .mouse_leave => {
40 self.pressed = false;
41 return;
42 },
43 .mouse => {},
44 else => return,
45 }
46 const mouse = event.mouse;
47
48 const separator_col: u16 = switch (self.constrain) {
49 .lhs => self.width,
50 .rhs => if (self.last_max_width) |max|
51 max -| self.width -| 1
52 else {
53 ctx.redraw = true;
54 return;
55 },
56 };
57
58 // If we are on the separator, we always set the mouse shape
59 if (mouse.col == separator_col) {
60 try ctx.setMouseShape(.@"ew-resize");
61 self.mouse_set = true;
62 // Set pressed state if we are a left click
63 if (mouse.type == .press and mouse.button == .left) {
64 self.pressed = true;
65 }
66 } else if (self.mouse_set) {
67 // If we have set the mouse state and *aren't* over the separator, default the mouse state
68 try ctx.setMouseShape(.default);
69 self.mouse_set = false;
70 }
71
72 // On release, we reset state
73 if (mouse.type == .release) {
74 self.pressed = false;
75 self.mouse_set = false;
76 try ctx.setMouseShape(.default);
77 }
78
79 // If pressed, we always keep the mouse shape and we update the width
80 if (self.pressed) {
81 try ctx.setMouseShape(.@"ew-resize");
82 switch (self.constrain) {
83 .lhs => {
84 self.width = @max(self.min_width, mouse.col);
85 if (self.max_width) |max| {
86 self.width = @min(self.width, max);
87 }
88 },
89 .rhs => {
90 const last_max = self.last_max_width orelse return;
91 const mouse_col: u16 = if (mouse.col < 0) 0 else @intCast(mouse.col);
92 self.width = @min(last_max -| self.min_width, last_max -| mouse_col -| 1);
93 if (self.max_width) |max| {
94 self.width = @max(self.width, max);
95 }
96 },
97 }
98 ctx.consume_event = true;
99 }
100}
101
102fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
103 const self: *SplitView = @ptrCast(@alignCast(ptr));
104 // Fills entire space
105 const max = ctx.max.size();
106 // Constrain width to the max
107 self.width = @min(self.width, max.width);
108 self.last_max_width = max.width;
109
110 // The constrained side is equal to the width
111 const constrained_min: vxfw.Size = .{ .width = self.width, .height = max.height };
112 const constrained_max = vxfw.MaxSize.fromSize(constrained_min);
113
114 const unconstrained_min: vxfw.Size = .{ .width = max.width -| self.width -| 1, .height = max.height };
115 const unconstrained_max = vxfw.MaxSize.fromSize(unconstrained_min);
116
117 var children = try std.ArrayList(vxfw.SubSurface).initCapacity(ctx.arena, 2);
118
119 switch (self.constrain) {
120 .lhs => {
121 if (constrained_max.width.? > 0 and constrained_max.height.? > 0) {
122 const lhs_ctx = ctx.withConstraints(constrained_min, constrained_max);
123 const lhs_surface = try self.lhs.draw(lhs_ctx);
124 children.appendAssumeCapacity(.{
125 .surface = lhs_surface,
126 .origin = .{ .row = 0, .col = 0 },
127 });
128 }
129 if (unconstrained_max.width.? > 0 and unconstrained_max.height.? > 0) {
130 const rhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max);
131 const rhs_surface = try self.rhs.draw(rhs_ctx);
132 children.appendAssumeCapacity(.{
133 .surface = rhs_surface,
134 .origin = .{ .row = 0, .col = self.width + 1 },
135 });
136 }
137 var surface = try vxfw.Surface.initWithChildren(
138 ctx.arena,
139 self.widget(),
140 max,
141 children.items,
142 );
143 for (0..max.height) |row| {
144 surface.writeCell(self.width, @intCast(row), .{
145 .char = .{ .grapheme = "│", .width = 1 },
146 .style = self.style,
147 });
148 }
149 return surface;
150 },
151 .rhs => {
152 if (unconstrained_max.width.? > 0 and unconstrained_max.height.? > 0) {
153 const lhs_ctx = ctx.withConstraints(unconstrained_min, unconstrained_max);
154 const lhs_surface = try self.lhs.draw(lhs_ctx);
155 children.appendAssumeCapacity(.{
156 .surface = lhs_surface,
157 .origin = .{ .row = 0, .col = 0 },
158 });
159 }
160 if (constrained_max.width.? > 0 and constrained_max.height.? > 0) {
161 const rhs_ctx = ctx.withConstraints(constrained_min, constrained_max);
162 const rhs_surface = try self.rhs.draw(rhs_ctx);
163 children.appendAssumeCapacity(.{
164 .surface = rhs_surface,
165 .origin = .{ .row = 0, .col = unconstrained_max.width.? + 1 },
166 });
167 }
168 var surface = try vxfw.Surface.initWithChildren(
169 ctx.arena,
170 self.widget(),
171 max,
172 children.items,
173 );
174 for (0..max.height) |row| {
175 surface.writeCell(max.width -| self.width -| 1, @intCast(row), .{
176 .char = .{ .grapheme = "│", .width = 1 },
177 .style = self.style,
178 });
179 }
180 return surface;
181 },
182 }
183}
184
185test SplitView {
186 // Boiler plate draw context
187 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
188 defer arena.deinit();
189 vxfw.DrawContext.init(.unicode);
190
191 const draw_ctx: vxfw.DrawContext = .{
192 .arena = arena.allocator(),
193 .min = .{},
194 .max = .{ .width = 16, .height = 16 },
195 .cell_size = .{ .width = 10, .height = 20 },
196 };
197
198 // Create LHS and RHS widgets
199 const lhs: vxfw.Text = .{ .text = "Left hand side" };
200 const rhs: vxfw.Text = .{ .text = "Right hand side" };
201
202 var split_view: SplitView = .{
203 .lhs = lhs.widget(),
204 .rhs = rhs.widget(),
205 .width = 8,
206 };
207
208 const split_widget = split_view.widget();
209 {
210 const surface = try split_widget.draw(draw_ctx);
211 // SplitView expands to fill the space
212 try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 16, .height = 16 }), surface.size);
213 // It has two children
214 try std.testing.expectEqual(2, surface.children.len);
215 // The left child should have a width = SplitView.width
216 try std.testing.expectEqual(split_view.width, surface.children[0].surface.size.width);
217 }
218
219 // Send the widget a mouse press on the separator
220 var mouse: vaxis.Mouse = .{
221 // The separator is at width
222 .col = @intCast(split_view.width),
223 .row = 0,
224 .type = .press,
225 .button = .left,
226 .mods = .{},
227 };
228
229 var ctx: vxfw.EventContext = .{
230 .alloc = arena.allocator(),
231 .cmds = .empty,
232 };
233 try split_widget.handleEvent(&ctx, .{ .mouse = mouse });
234 // We should get a command to change the mouse shape
235 try std.testing.expect(ctx.cmds.items[0] == .set_mouse_shape);
236 try std.testing.expect(ctx.redraw);
237 try std.testing.expect(split_view.pressed);
238
239 // If we move the mouse, we should update the width
240 mouse.col = 2;
241 mouse.type = .drag;
242 try split_widget.handleEvent(&ctx, .{ .mouse = mouse });
243 try std.testing.expect(ctx.redraw);
244 try std.testing.expect(split_view.pressed);
245 const mouse_col: u16 = if (mouse.col < 0) 0 else @intCast(mouse.col);
246 try std.testing.expectEqual(mouse_col, split_view.width);
247}
248
249test "refAllDecls" {
250 std.testing.refAllDecls(@This());
251}