add(layout/tab): Tab layout implementation
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 9m46s

This commit is contained in:
2024-11-21 23:27:00 +01:00
parent 6cd78d0418
commit c0c7b9f925
5 changed files with 440 additions and 0 deletions

View File

@@ -73,6 +73,14 @@ pub fn build(b: *std.Build) void {
}); });
tui_example.root_module.addImport("zterm", lib); tui_example.root_module.addImport("zterm", lib);
const tabs_example = b.addExecutable(.{
.name = "tabs",
.root_source_file = b.path("examples/tabs.zig"),
.target = target,
.optimize = optimize,
});
tabs_example.root_module.addImport("zterm", lib);
// This declares intent for the executable to be installed into the // This declares intent for the executable to be installed into the
// standard location when the user invokes the "install" step (the default // standard location when the user invokes the "install" step (the default
// step when running `zig build`). // step when running `zig build`).
@@ -81,6 +89,7 @@ pub fn build(b: *std.Build) void {
b.installArtifact(padding_example); b.installArtifact(padding_example);
b.installArtifact(exec_example); b.installArtifact(exec_example);
b.installArtifact(tui_example); b.installArtifact(tui_example);
b.installArtifact(tabs_example);
// Creates a step for unit testing. This only builds the test executable // Creates a step for unit testing. This only builds the test executable
// but does not run it. // but does not run it.

119
examples/tabs.zig Normal file
View File

@@ -0,0 +1,119 @@
const std = @import("std");
const zterm = @import("zterm");
const App = zterm.App(
union(enum) {},
zterm.Renderer.Direct,
true,
);
const Key = zterm.Key;
const Cell = zterm.Cell;
const Layout = App.Layout;
const Widget = App.Widget;
const log = std.log.scoped(.tabs);
pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err});
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer {
const deinit_status = gpa.deinit();
// fail test; can't try in defer as defer is executed after we return
if (deinit_status == .leak) {
log.err("memory leak", .{});
}
}
const allocator = gpa.allocator();
var app: App = .{};
var renderer: App.Renderer = .{};
// TODO: when not running fullscreen, the application needs to screen down accordingly to display the contents
// -> size hint how much should it use?
var layout = Layout.createFrom(layout: {
var tabs = Layout.Tab.init(allocator, .{}, .{
.{
Layout.createFrom(margin: {
var margin = Layout.Margin.init(allocator, .{ .margin = 10 }, .{
.widget = Widget.createFrom(blk: {
const file = try std.fs.cwd().openFile("./examples/tabs.zig", .{});
defer file.close();
var widget = Widget.RawText.init(allocator, file);
break :blk &widget;
}),
});
break :margin &margin;
}),
"Tab 2",
Cell.Style.Color{ .index = 6 },
},
.{
Layout.createFrom(framing: {
var framing = Layout.Framing.init(
allocator,
.{
.frame = .round,
.title = .{
.str = "Content in Margin",
.style = .{
.ul_style = .single,
.ul = .{ .index = 4 },
.bold = true,
},
},
},
.{
.layout = Layout.createFrom(margin: {
var margin = Layout.Margin.init(allocator, .{ .margin = 10 }, .{
.widget = Widget.createFrom(blk: {
const file = try std.fs.cwd().openFile("./examples/padding.zig", .{});
defer file.close();
var widget = Widget.RawText.init(allocator, file);
break :blk &widget;
}),
});
break :margin &margin;
}),
},
);
break :framing &framing;
}),
"Tab 1",
Cell.Style.Color{ .index = 4 },
},
});
break :layout &tabs;
});
defer layout.deinit();
try app.start();
defer app.stop() catch unreachable;
// App.Event loop
while (true) {
const event = app.nextEvent();
log.debug("received event: {s}", .{@tagName(event)});
switch (event) {
.quit => break,
.resize => |size| {
renderer.resize(size);
},
.key => |key| {
// ctrl+c to quit
if (Key.matches(key, .{ .cp = 'c', .mod = .{ .ctrl = true } })) {
app.quit();
}
},
.err => |err| {
log.err("Received {any} with message: {s}", .{ err.err, err.msg });
},
}
const events = try layout.handle(event);
for (events.items) |e| {
app.postEvent(e);
}
try layout.render(&renderer);
}
}

View File

@@ -171,6 +171,7 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
if (buf[0] == 0x1b and read_bytes > 1) { if (buf[0] == 0x1b and read_bytes > 1) {
switch (buf[1]) { switch (buf[1]) {
// TODO: parse corresponding codes // TODO: parse corresponding codes
// 0x5B => parseCsi(input, &self.buf), // CSI see https://github.com/rockorager/libvaxis/blob/main/src/Parser.zig
else => {}, else => {},
} }
} else { } else {

View File

@@ -91,6 +91,7 @@ pub fn Layout(comptime Event: type, comptime Renderer: type) type {
pub const Padding = @import("layout/Padding.zig").Layout(Event, Element, Renderer); pub const Padding = @import("layout/Padding.zig").Layout(Event, Element, Renderer);
pub const Margin = @import("layout/Margin.zig").Layout(Event, Element, Renderer); pub const Margin = @import("layout/Margin.zig").Layout(Event, Element, Renderer);
pub const Framing = @import("layout/Framing.zig").Layout(Event, Element, Renderer); pub const Framing = @import("layout/Framing.zig").Layout(Event, Element, Renderer);
pub const Tab = @import("layout/Tab.zig").Layout(Event, Element, Renderer);
}; };
// test layout implementation satisfies the interface // test layout implementation satisfies the interface
comptime Type.Interface.satisfiedBy(Type); comptime Type.Interface.satisfiedBy(Type);
@@ -101,5 +102,6 @@ pub fn Layout(comptime Event: type, comptime Renderer: type) type {
comptime Type.Interface.satisfiedBy(Type.Padding); comptime Type.Interface.satisfiedBy(Type.Padding);
comptime Type.Interface.satisfiedBy(Type.Margin); comptime Type.Interface.satisfiedBy(Type.Margin);
comptime Type.Interface.satisfiedBy(Type.Framing); comptime Type.Interface.satisfiedBy(Type.Framing);
comptime Type.Interface.satisfiedBy(Type.Tab);
return Type; return Type;
} }

309
src/layout/Tab.zig Normal file
View File

@@ -0,0 +1,309 @@
//! Tab layout for a nested `Layout`s or `Widget`s.
//!
//! # Example
//! ...
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Error = @import("../event.zig").Error;
const Key = terminal.Key;
const Cell = terminal.Cell;
const Style = Cell.Style;
const Color = Style.Color;
const log = std.log.scoped(.layout_tab);
pub fn Layout(comptime Event: type, comptime Element: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!isTaggedUnion(Element)) {
@compileError("Provided type `Element` for `Layout(comptime Event: type, comptime Element: type, comptime Renderer: type)` is not of type `union(enum)`.");
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[0].name, "layout")) {
@compileError("Expected `layout: Layout` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[0].name);
}
if (!std.mem.eql(u8, @typeInfo(Element).@"union".fields[1].name, "widget")) {
@compileError("Expected `widget: Widget` to be the first union element, but has name: " ++ @typeInfo(Element).@"union".fields[1].name);
}
const Events = std.ArrayList(Event);
const LayoutType = @typeInfo(Element).@"union".fields[0].type;
const WidgetType = @typeInfo(Element).@"union".fields[1].type;
const Tab = struct {
element: Element,
title: []const u8,
color: Color,
};
const Tabs = std.ArrayList(Tab);
return struct {
size: terminal.Size = undefined,
require_render: bool = true,
tabs: Tabs = undefined,
active_tab: usize = 0,
events: Events = undefined,
config: Config = undefined,
const Config = struct {
frame: Frame = .round,
const Frame = enum {
round,
square,
};
};
pub fn init(allocator: std.mem.Allocator, config: Config, children: anytype) @This() {
const ArgsType = @TypeOf(children);
const args_type_info = @typeInfo(ArgsType);
if (args_type_info != .@"struct") {
@compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
}
const fields_info = args_type_info.@"struct".fields;
var tabs = Tabs.initCapacity(allocator, fields_info.len) catch @panic("Tab.zig: out of memory");
inline for (comptime fields_info) |field| {
const child = @field(children, field.name);
const ChildType = @TypeOf(child);
const child_type_info = @typeInfo(ChildType);
if (child_type_info != .@"struct") {
@compileError("expected tuple or struct as child type, found " ++ @typeName(ChildType));
}
const child_fields = child_type_info.@"struct".fields;
if (child_fields.len != 3) {
@compileError("expected nested tuple or struct to have exactly 3 fields, but found " ++ child_fields.len);
}
const element = @field(child, child_fields[0].name);
const ElementType = @TypeOf(element);
const tab_title = @field(child, child_fields[1].name);
const TabTitleType = @TypeOf(tab_title);
const tab_title_type_info = @typeInfo(TabTitleType);
const tab_color = @field(child, child_fields[2].name);
const TabColorType = @TypeOf(tab_color);
if (tab_title_type_info != .array and tab_title_type_info != .pointer) {
// TODO: check for inner type of the title to be u8
@compileError("expected an u8 array second argument of nested tuple or struct child, but found " ++ @tagName(tab_title_type_info));
}
if (TabColorType != Color) {
@compileError("expected an Color typed third argument of nested tuple or struct child, but found " ++ @typeName(TabColorType));
}
if (ElementType == WidgetType) {
tabs.append(.{
.element = .{ .widget = element },
.title = tab_title,
.color = tab_color,
}) catch {};
continue;
}
if (ElementType == LayoutType) {
tabs.append(.{
.element = .{ .layout = element },
.title = tab_title,
.color = tab_color,
}) catch {};
continue;
}
@compileError("nested child: " ++ field.name ++ " is not of type " ++ @typeName(WidgetType) ++ " or " ++ @typeName(LayoutType) ++ " but " ++ @typeName(ChildType));
}
return .{
.config = config,
.tabs = tabs,
.events = Events.init(allocator),
};
}
pub fn deinit(this: *@This()) void {
this.events.deinit();
for (this.tabs.items) |*tab| {
switch (tab.element) {
.layout => |*layout| {
layout.deinit();
},
.widget => |*widget| {
widget.deinit();
},
}
}
this.tabs.deinit();
}
fn resize_active_tab(this: *@This()) !void {
const sub_event: Event = .{
.resize = .{
.anchor = .{
.col = this.size.anchor.col + 1,
.row = this.size.anchor.row + 1,
},
.cols = this.size.cols -| 2,
.rows = this.size.rows -| 2,
},
};
// resize active tab to re-render the widget in the following render loop
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
const events = try layout.handle(sub_event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(sub_event)) |e| {
try this.events.append(e);
}
},
}
}
pub fn handle(this: *@This(), event: Event) !*Events {
this.events.clearRetainingCapacity();
if (this.tabs.items.len == 0) {
return &this.events;
}
// order is important
switch (event) {
.resize => |size| {
this.size = size;
this.require_render = true;
log.debug("Event .resize: {{ .anchor = {{ .col = {d}, .row = {d} }}, .cols = {d}, .rows = {d} }}", .{
size.anchor.col,
size.anchor.row,
size.cols,
size.rows,
});
try this.resize_active_tab();
},
.key => |key| {
// tab -> cycle forward
// back-tab -> cycle backward
if (key.matches(.{ .cp = Key.tab })) {
this.active_tab += 1;
this.active_tab %= this.tabs.items.len;
this.require_render = true;
try this.resize_active_tab();
} else if (key.matches(.{ .cp = Key.tab, .mod = .{ .shift = true } })) { // backtab / shift + tab
if (this.active_tab > 0) {
this.active_tab -|= 1;
} else {
this.active_tab = this.tabs.items.len - 1;
}
this.require_render = true;
try this.resize_active_tab();
} else {
// TODO: absorb tab key or send key down too?
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
}
},
else => {
// NOTE: should this only send the event to the 'active_tab'
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
const events = try layout.handle(event);
try this.events.appendSlice(events.items);
},
.widget => |*widget| {
if (widget.handle(event)) |e| {
try this.events.append(e);
}
},
}
},
}
return &this.events;
}
const round_frame = .{ "", "", "", "", "", "" };
const square_frame = .{ "", "", "", "", "", "" };
fn renderFrame(this: *@This(), renderer: *Renderer) !void {
// FIXME: use renderer instead!
_ = renderer;
const frame = switch (this.config.frame) {
.round => round_frame,
.square => square_frame,
};
std.debug.assert(frame.len == 6);
// render top: +---+
try terminal.setCursorPosition(this.size.anchor);
const writer = terminal.writer();
var style: Style = .{ .fg = this.tabs.items[this.active_tab].color };
try style.value(writer, frame[0]);
var tab_title_len: usize = 0;
for (this.tabs.items, 0..) |tab, idx| {
var tab_style: Cell.Style = .{
.fg = tab.color,
.bg = .default,
};
if (idx == this.active_tab) {
tab_style.fg = .default;
tab_style.bg = tab.color;
}
const cell: Cell = .{
.content = tab.title,
.style = tab_style,
};
try cell.value(writer, 0, tab.title.len);
tab_title_len += tab.title.len;
}
for (0..this.size.cols -| 2 -| tab_title_len) |_| {
try style.value(writer, frame[1]);
}
try style.value(writer, frame[2]);
// render left: |
for (1..this.size.rows -| 1) |r| {
const row: u16 = @truncate(r);
try terminal.setCursorPosition(.{
.col = this.size.anchor.col,
.row = this.size.anchor.row + row,
});
try style.value(writer, frame[3]);
}
// render right: |
for (1..this.size.rows -| 1) |r| {
const row: u16 = @truncate(r);
try terminal.setCursorPosition(.{
.col = this.size.anchor.col + this.size.cols -| 1,
.row = this.size.anchor.row + row,
});
try style.value(writer, frame[3]);
}
// render bottom: +---+
try terminal.setCursorPosition(.{
.col = this.size.anchor.col,
.row = this.size.anchor.row + this.size.rows - 1,
});
try style.value(writer, frame[4]);
for (0..this.size.cols -| 2) |_| {
try style.value(writer, frame[1]);
}
try style.value(writer, frame[5]);
}
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (this.require_render) {
try renderer.clear(this.size);
try this.renderFrame(renderer);
this.require_render = false;
}
var tab = this.tabs.items[this.active_tab];
switch ((&tab.element).*) {
.layout => |*layout| {
try layout.render(renderer);
},
.widget => |*widget| {
try widget.render(renderer);
},
}
}
};
}