14 Commits
0.0.3 ... main

Author SHA1 Message Date
abd56b75d3 add: search text field in preparation for fuzzy search feature
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 59s
2026-01-18 00:02:32 +01:00
9559aa974d mod: remove stack reference of unnecessary intermediate Container 2026-01-17 22:51:50 +01:00
1f93e24a37 fix: free Document only if allocated otherwise treat as static
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m10s
2026-01-17 21:42:00 +01:00
0ec9839cc7 mod: bump zlog dependency 2026-01-17 21:41:43 +01:00
9ae9dcfce2 mod(nav): button automatically resizes as necessary 2026-01-17 21:39:51 +01:00
db8485d88e lint: correct formatting
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 59s
2026-01-17 13:27:57 +01:00
8199a23566 ref: move Model manipulation logic in own Element *website*
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 47s
This way no `Element` that is only there for rendering requires
any internal state.
2026-01-17 13:25:26 +01:00
83d83c17fa mod: make Element's for rendering stateless
The elements derive their corresponding content to render from
the `Model` without any local state.
2026-01-17 13:11:26 +01:00
1b4c1bbab8 mod(ci): update release process to also include spell-checking 2026-01-17 12:42:09 +01:00
361623d0f1 doc: remove warning about required zig version
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 54s
2026-01-17 12:31:23 +01:00
6bf4c98d44 mod(ci): adapt to use zig latest release version
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m13s
2026-01-17 12:28:29 +01:00
bfdaf82626 mod: bump zterm dependency with required code adaptations
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m16s
2026-01-17 12:26:36 +01:00
b4836b8d2b mod: bump to version 0.0.4
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 55s
Release Zig Application / Release zig project (release) Successful in 1m36s
2025-11-08 12:54:20 +01:00
5ee2c6662e feat: invalid page for unavailable resources
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m46s
Accessing resources which are not available, the invalid page will be
rendered instead. The original attempt for access will be logged.
2025-11-08 12:50:25 +01:00
8 changed files with 196 additions and 106 deletions

View File

@@ -15,9 +15,15 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup zig installation - name: Setup zig installation
uses: mlugg/setup-zig@v2 uses: https://codeberg.org/mlugg/setup-zig@v2
with: with:
version: master version: latest
- name: Lint check
run: zig fmt --check .
- name: Spell checking
uses: crate-ci/typos@v1.39.0
with:
config: ./.typos-config
- name: Run tests - name: Run tests
run: zig build --release=fast run: zig build --release=fast
- name: Release build artifacts - name: Release build artifacts

View File

@@ -14,9 +14,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup zig installation - name: Setup zig installation
uses: mlugg/setup-zig@v2 uses: https://codeberg.org/mlugg/setup-zig@v2
with: with:
version: master version: latest
- name: Lint check - name: Lint check
run: zig fmt --check . run: zig fmt --check .
- name: Spell checking - name: Spell checking

View File

@@ -4,9 +4,6 @@ This is my terminal based website. It is served as a tui application via ssh and
It contains information about me and my projects as well as blog entries about something I feel like writing something about. It contains information about me and my projects as well as blog entries about something I feel like writing something about.
> [!caution]
> Only builds using the zig master version are tested to work.
## zterm ## zterm
This tui application also serves as an example implementation of the [zterm](https://gitea.yves-biener.de/yves-biener/zterm) library. This tui application also serves as an example implementation of the [zterm](https://gitea.yves-biener.de/yves-biener/zterm) library.

View File

@@ -1,17 +1,17 @@
.{ .{
.name = .tui_website, .name = .tui_website,
// This is a [Semantic Version](https://semver.org/). // This is a [Semantic Version](https://semver.org/).
.version = "0.0.3", .version = "0.0.4",
.fingerprint = 0x93d98a4d9d000e9c, // Changing this has security and trust implications. .fingerprint = 0x93d98a4d9d000e9c, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0-dev.463+f624191f9", .minimum_zig_version = "0.16.0-dev.463+f624191f9",
.dependencies = .{ .dependencies = .{
.zterm = .{ .zterm = .{
.url = "git+https://gitea.yves-biener.de/yves-biener/zterm#ad32e46bc9fd6d1d9b7a3615979a3b80877c9300", .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#4874252e8c3a5d645ac2c37bbfd0e669964912fc",
.hash = "zterm-0.3.0-1xmmEO8PHABP4tE6BnEZllJTh6Hpza_5C9wS8s2vjwou", .hash = "zterm-0.3.0-1xmmEH82HABfsn5imAK8lw93LevIaqNV10g6xfkNj_OT",
}, },
.zlog = .{ .zlog = .{
.url = "git+https://gitea.yves-biener.de/yves-biener/zlog#f43034cea9a0863e618c3d0a43706ce38c8791cf", .url = "git+https://gitea.yves-biener.de/yves-biener/zlog#b3c8a3ab1ccae1ba50efbdf26d68a18da8594d2c",
.hash = "zlog-0.16.0-6JSlRx1JAADasbK5FS6Qaf0Iq1SCQaH5VZRxT2SRE2xs", .hash = "zlog-0.16.0-6JSlRwNJAACVd89xnhXRHzCnCs7qab9Jcmx11VMS2ZpL",
}, },
}, },
.paths = .{ .paths = .{

View File

@@ -1,74 +1,74 @@
pub fn Content(App: type) type { pub fn Website(App: type) type {
return struct { return struct {
allocator: Allocator, gpa: Allocator,
document: *const App.Model.Document,
pub fn init(allocator: Allocator, document: *const App.Model.Document) @This() { pub fn init(gpa: Allocator) @This() {
return .{ return .{ .gpa = gpa };
.allocator = allocator,
.document = document,
};
} }
pub fn element(this: *@This()) App.Element { pub fn element(this: *@This()) App.Element {
return .{ return .{
.ptr = this, .ptr = this,
.vtable = &.{ .vtable = &.{
.minSize = minSize,
.handle = handle, .handle = handle,
.content = content,
}, },
}; };
} }
fn minSize(ctx: *anyopaque, size: zterm.Point) zterm.Point {
const this: *const @This() = @ptrCast(@alignCast(ctx));
const text = this.document.content;
var index: usize = 0;
var new_size: zterm.Point = .{ .x = size.x };
for (0..text.len) |_| rows: {
for (0..size.x - 1) |_| { // assume that there is one cell reserved for the scrollbar
if (index == text.len) break :rows;
const cp = text[index];
index += 1;
switch (cp) {
'\n' => break,
else => {},
}
}
new_size.y += 1;
}
return new_size;
}
fn handle(ctx: *anyopaque, model: *App.Model, event: App.Event) !void { fn handle(ctx: *anyopaque, model: *App.Model, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx)); const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) { switch (event) {
.init => this.document = &model.document, .page => |page| switch (page) {
.about => { .about => {
model.page.deinit(this.allocator); model.page.deinit(this.gpa);
model.page = .about; model.page = .about;
model.document.deinit(this.allocator); model.document.deinit(this.gpa);
model.document = .init(try std.fs.cwd().readFileAlloc("./doc/about.md", this.allocator, .unlimited)); model.document = .init(try std.fs.cwd().readFileAlloc(this.gpa, "./doc/about.md", std.math.maxInt(usize)));
this.document = &model.document;
}, },
.blog => |path| { .blog => |path| {
model.page.deinit(this.allocator); model.page.deinit(this.gpa);
model.page = .{ .blog = path }; model.document.deinit(this.gpa);
errdefer {
if (path) |p| this.gpa.free(p);
model.document = .invalidPage;
model.page = .{ .blog = null };
}
model.document.deinit(this.allocator); model.document = .init(try std.fs.cwd().readFileAlloc(this.gpa, if (path) |p| p else "./doc/blog.md", std.math.maxInt(usize)));
model.document = .init(try std.fs.cwd().readFileAlloc(if (path) |p| p else "./doc/blog.md", this.allocator, .unlimited)); model.page = .{ .blog = path };
this.document = &model.document; },
},
.key => |key| {
if (key.eql(.{ .cp = '/' })) model.searching = true;
if (key.eql(.{ .cp = zterm.input.Escape })) model.searching = false;
}, },
else => {}, else => {},
} }
} }
};
}
fn content(ctx: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void { pub fn Content(App: type) type {
_ = ctx; return struct {
pub fn element(this: *@This()) App.Element {
return .{
.ptr = this,
.vtable = &.{
.minSize = minSize,
.content = content,
},
};
}
fn minSize(_: *anyopaque, model: *const App.Model, _: zterm.Point) zterm.Point {
// NOTE currently the contents are assumed to fit into the viewport, excessive lines are cut off
// (there currently no horizontal scrolling)
return .{ .y = @intCast(std.mem.count(u8, model.document.content, "\n") + 1) };
}
fn content(_: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const text = model.document.content; const text = model.document.content;
@@ -87,6 +87,10 @@ pub fn Content(App: type) type {
else => cp, else => cp,
}; };
} }
if (text[index - 1] != '\n') {
// go to the end of the line
while (index < text.len and text[index] != '\n') index += 1;
}
} }
} }
}; };
@@ -103,9 +107,7 @@ pub fn Title(App: type) type {
}; };
} }
fn content(ctx: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void { fn content(_: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
const this: *const @This() = @ptrCast(@alignCast(ctx));
_ = this;
assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
if (model.document.title) |text| { if (model.document.title) |text| {
@@ -123,24 +125,69 @@ pub fn Title(App: type) type {
} }
pub fn InfoBanner(App: type) type { pub fn InfoBanner(App: type) type {
const left_text: []const u8 = "Build using zig";
const right_text: []const u8 = "Yves Biener";
return struct { return struct {
left_text: []const u8 = "Build with zig", gpa: Allocator,
right_text: []const u8 = "Yves Biener (@yves-biener)", search: App.TextField,
queue: *App.Queue,
pub fn init(gpa: Allocator, queue: *App.Queue) @This() {
return .{
.gpa = gpa,
.search = .init(gpa, .init(.default, .default)),
.queue = queue,
};
}
pub fn element(this: *@This()) App.Element { pub fn element(this: *@This()) App.Element {
return .{ return .{
.ptr = this, .ptr = this,
.vtable = &.{ .vtable = &.{
.deinit = deinit,
.minSize = minSize,
.handle = handle,
.content = content, .content = content,
}, },
}; };
} }
fn deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.search.deinit();
}
fn minSize(ctx: *anyopaque, model: *const App.Model, _: zterm.Point) zterm.Point {
const this: *@This() = @ptrCast(@alignCast(ctx));
if (!model.searching) this.search.clear();
return .{ .y = if (model.searching) 2 else 1 };
}
fn handle(ctx: *anyopaque, model: *App.Model, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.key => if (model.searching) {
const prev_len = this.search.input.items.len;
try this.search.element().handle(model, event);
const next_len = this.search.input.items.len;
if (next_len == 0) {
model.searching = false;
return;
}
// NOTE do not include the leading '/' in the `.search` event payload!
if (prev_len != next_len) this.queue.push(.{ .search = this.search.input.items[1..] });
},
else => {},
}
}
fn content(ctx: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void { fn content(ctx: *anyopaque, model: *const App.Model, cells: []zterm.Cell, size: zterm.Point) !void {
const this: *const @This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
for (0.., this.left_text) |idx, cp| { for (0.., left_text) |idx, cp| {
// NOTE do not write over the contents of this `Container`'s `Size` // NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len) break; if (idx == cells.len) break;
@@ -153,7 +200,7 @@ pub fn InfoBanner(App: type) type {
.about => {}, .about => {},
.blog => |path| if (path) |p| page: { .blog => |path| if (path) |p| page: {
const start_idx = (size.x -| p.len) / 2; const start_idx = (size.x -| p.len) / 2;
if (start_idx <= this.left_text.len) break :page; if (start_idx <= left_text.len) break :page;
for (start_idx.., p) |idx, cp| { for (start_idx.., p) |idx, cp| {
// NOTE do not write over the contents of this `Container`'s `Size` // NOTE do not write over the contents of this `Container`'s `Size`
@@ -165,10 +212,10 @@ pub fn InfoBanner(App: type) type {
}, },
} }
var start_idx = size.x -| this.right_text.len; var start_idx = size.x -| right_text.len;
if (start_idx <= this.left_text.len) start_idx = this.left_text.len + 1; if (start_idx <= left_text.len) start_idx = left_text.len + 1;
for (start_idx.., this.right_text) |idx, cp| { for (start_idx.., right_text) |idx, cp| {
// NOTE do not write over the contents of this `Container`'s `Size` // NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len) break; if (idx == cells.len) break;
@@ -176,6 +223,11 @@ pub fn InfoBanner(App: type) type {
cells[idx].style.emphasis = &.{.dim}; cells[idx].style.emphasis = &.{.dim};
cells[idx].cp = cp; cells[idx].cp = cp;
} }
if (model.searching) {
const this: *@This() = @ptrCast(@alignCast(ctx));
try this.search.element().content(model, cells[size.x..], .{ .x = size.x, .y = 1 });
}
} }
}; };
} }
@@ -184,3 +236,4 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const assert = std.debug.assert; const assert = std.debug.assert;
const zterm = @import("zterm"); const zterm = @import("zterm");
const Model = @import("model.zig");

View File

@@ -1,4 +1,11 @@
// usage: tui_website <path> // TODO
// - should the path containing the contents of the tui-website reside in the
// hardcode `./doc` directory or shall this become a compilation argument to
// allow for custom directory locations?
// usage: tui_website <location>
// <location>: partial path to the document without the file extension and the beginning `./doc` path
// ending `/` (as used from the web-world) are trimmed automatically
pub fn main() !void { pub fn main() !void {
errdefer |err| log.err("Application Error: {any}", .{err}); errdefer |err| log.err("Application Error: {any}", .{err});
@@ -13,25 +20,24 @@ pub fn main() !void {
// skip own executable name // skip own executable name
_ = arg_it.skip(); _ = arg_it.skip();
var app: App = .init(.{ var app: App = .init(.{}, .{
.page = .about, .page = .about,
.document = .init(try std.fs.cwd().readFileAlloc("./doc/about.md", allocator, .unlimited)), .document = .init(try std.fs.cwd().readFileAlloc(allocator, "./doc/about.md", std.math.maxInt(usize))),
}); });
defer app.model.deinit(allocator); defer app.model.deinit(allocator);
var renderer = zterm.Renderer.Buffered.init(allocator); var renderer = zterm.Renderer.Buffered.init(allocator);
defer renderer.deinit(); defer renderer.deinit();
var website: Website = .init(allocator);
var container = try App.Container.init(allocator, .{ var container = try App.Container.init(allocator, .{
.layout = .{ .layout = .{
.padding = .horizontal(2), .padding = .horizontal(2),
.direction = .vertical, .direction = .vertical,
.separator = .{ .enabled = true }, .separator = .{ .enabled = true },
}, },
}, .{}); }, website.element());
defer container.deinit(); defer container.deinit();
var content_container: App.Container = undefined;
defer content_container.deinit();
// header with navigation buttons and content's title // header with navigation buttons and content's title
{ {
@@ -54,7 +60,6 @@ pub fn main() !void {
var button: NavigationButton(.about) = .init(&app.model, &app.queue); var button: NavigationButton(.about) = .init(&app.model, &app.queue);
try header.append(try .init(allocator, .{ try header.append(try .init(allocator, .{
.size = .{ .size = .{
.dim = .{ .x = 5 + 2 },
.grow = .vertical, .grow = .vertical,
}, },
}, button.element())); }, button.element()));
@@ -64,34 +69,30 @@ pub fn main() !void {
var button: NavigationButton(.blog) = .init(&app.model, &app.queue); var button: NavigationButton(.blog) = .init(&app.model, &app.queue);
try header.append(try .init(allocator, .{ try header.append(try .init(allocator, .{
.size = .{ .size = .{
.dim = .{ .x = 4 + 2 },
.grow = .vertical, .grow = .vertical,
}, },
}, button.element())); }, button.element()));
} }
try container.append(header); try container.append(header);
} }
// main actual tui_website page content // page contents
{ {
var content: Content = .init(allocator, &app.model.document); var content: Content = .{};
content_container = try .init(allocator, .{}, content.element()); var scrollable: App.Scrollable = .init(try .init(allocator, .{}, content.element()), .enabled(.green, false));
var scrollable: App.Scrollable = .init(content_container, .enabled(.green, false));
// intermediate container for *padding* containing the scrollable `Content` // intermediate container for *padding* containing the scrollable `Content`
var scrollable_container: App.Container = try .init(allocator, .{ var scrollable_container: App.Container = try .init(allocator, .{
.layout = .{ .padding = .horizontal(2) }, .layout = .{ .padding = .horizontal(2) },
}, .{}); }, .{});
try scrollable_container.append(try .init(allocator, .{}, scrollable.element())); try scrollable_container.append(try .init(allocator, .{}, scrollable.element()));
try container.append(scrollable_container); try container.append(scrollable_container);
} }
// footer // footer
{ {
var info_banner: InfoBanner = .{}; var info_banner: InfoBanner = .init(allocator, &app.queue);
const footer: App.Container = try .init(allocator, .{ const footer: App.Container = try .init(allocator, .{
.size = .{ .size = .{ .grow = .horizontal },
.dim = .{ .y = 1 },
.grow = .horizontal,
},
}, info_banner.element()); }, info_banner.element());
try container.append(footer); try container.append(footer);
} }
@@ -106,7 +107,15 @@ pub fn main() !void {
// -> enforce a specific structure? // -> enforce a specific structure?
// -> in case an invalid path is provided the 404 error page shall be shown // -> in case an invalid path is provided the 404 error page shall be shown
// -> reporte an corresponding error! (such that I can see that in the log) // -> reporte an corresponding error! (such that I can see that in the log)
app.postEvent(.{ .blog = try allocator.dupe(u8, path) }); var blog = path[0..path.len];
blog = std.mem.trimStart(u8, blog, "./");
blog = std.mem.trimEnd(u8, blog, ".md");
blog = std.mem.trimEnd(u8, blog, "/");
app.postEvent(.{
.page = .{
.blog = try std.fmt.allocPrint(allocator, "./doc/{s}.md", .{blog}),
},
});
} }
arg_it.deinit(); arg_it.deinit();
@@ -127,9 +136,17 @@ pub fn main() !void {
// pre event handling // pre event handling
switch (event) { switch (event) {
.key => |key| { .key => |key| {
if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit(); if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = 'q' })) app.quit();
// test if the event handling is working correctly // test if the event handling is working correctly
if (key.eql(.{ .cp = zterm.input.Space })) app.postEvent(.{ .blog = allocator.dupe(u8, "./doc/test.md") catch unreachable }); if (key.eql(.{ .cp = zterm.input.Space })) app.postEvent(.{
.page = .{
.blog = try allocator.dupe(u8, "./doc/test.md"),
},
});
},
.page => |page| switch (page) {
.about => log.info(ResourceRequestFormat, .{"./doc/about.md"}),
.blog => |path| log.info(ResourceRequestFormat, .{if (path) |p| p else "./doc/blog.md"}),
}, },
.err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }), .err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }),
else => {}, else => {},
@@ -149,9 +166,8 @@ pub fn main() !void {
} }
} }
container.resize(try renderer.resize()); container.resize(&app.model, try renderer.resize());
container.reposition(.{}); container.reposition(&app.model, .{});
try renderer.render(@TypeOf(container), &container, App.Model, &app.model); try renderer.render(@TypeOf(container), &container, App.Model, &app.model);
try renderer.flush(); try renderer.flush();
} }
@@ -161,17 +177,23 @@ pub const panic = App.panic_handler;
pub const std_options = zlog.std_options; pub const std_options = zlog.std_options;
const log = std.log.scoped(.default); const log = std.log.scoped(.default);
const ResourceRequestFormat = "Requesting resource: `{s}`";
const std = @import("std"); const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const zlog = @import("zlog"); const zlog = @import("zlog");
const zterm = @import("zterm"); const zterm = @import("zterm");
const App = zterm.App( const App = zterm.App(
Model, Model,
Model.Pages, union(enum) {
search: []const u21,
page: Model.Pages,
},
); );
const contents = @import("content.zig"); const contents = @import("content.zig");
const Model = @import("model.zig"); const Model = @import("model.zig");
const Website = contents.Website(App);
const Content = contents.Content(App); const Content = contents.Content(App);
const Title = contents.Title(App); const Title = contents.Title(App);
const InfoBanner = contents.InfoBanner(App); const InfoBanner = contents.InfoBanner(App);

View File

@@ -1,6 +1,8 @@
page: Pages, page: Pages,
document: Document, document: Document,
searching: bool = false,
pub fn deinit(this: *@This(), allocator: Allocator) void { pub fn deinit(this: *@This(), allocator: Allocator) void {
this.page.deinit(allocator); this.page.deinit(allocator);
this.document.deinit(allocator); this.document.deinit(allocator);
@@ -26,13 +28,19 @@ pub const Document = struct {
title: ?[]const u8 = null, title: ?[]const u8 = null,
date: ?[]const u8 = null, date: ?[]const u8 = null,
content: []const u8 = undefined, content: []const u8 = undefined,
ptr: []const u8, ptr: ?[]const u8,
const Preemble = enum { const Preemble = enum {
title, title,
date, date,
}; };
pub const invalidPage: @This() = .{
.title = "Page not found",
.content = "Requested page does not exist",
.ptr = null,
};
pub fn init(content: []const u8) @This() { pub fn init(content: []const u8) @This() {
if (std.mem.startsWith(u8, content, "---\n")) { if (std.mem.startsWith(u8, content, "---\n")) {
var document: @This() = .{ .ptr = content }; var document: @This() = .{ .ptr = content };
@@ -61,8 +69,8 @@ pub const Document = struct {
} }
pub fn deinit(this: *@This(), allocator: Allocator) void { pub fn deinit(this: *@This(), allocator: Allocator) void {
allocator.free(this.ptr); if (this.ptr) |ptr| allocator.free(ptr);
this.* = .{ .ptr = undefined }; this.* = .{ .ptr = null };
} }
}; };

View File

@@ -1,6 +1,6 @@
pub fn NavigationButton(App: type) fn (std.meta.FieldEnum(App.Event)) type { pub fn NavigationButton(App: type) fn (std.meta.FieldEnum(App.Model.Pages)) type {
const navigation_struct = struct { const navigation_struct = struct {
fn navigation_fn(page: std.meta.FieldEnum(App.Event)) type { fn navigation_fn(page: std.meta.FieldEnum(App.Model.Pages)) type {
return struct { return struct {
text: []const u8, text: []const u8,
highlight: bool, highlight: bool,
@@ -12,7 +12,6 @@ pub fn NavigationButton(App: type) fn (std.meta.FieldEnum(App.Event)) type {
.highlight = switch (page) { .highlight = switch (page) {
.about => model.page == .about, .about => model.page == .about,
.blog => model.page == .blog, .blog => model.page == .blog,
else => @compileError("NavigationButton initiated with non page `App.Event` variant"),
}, },
.queue = queue, .queue = queue,
}; };
@@ -22,21 +21,27 @@ pub fn NavigationButton(App: type) fn (std.meta.FieldEnum(App.Event)) type {
return .{ return .{
.ptr = this, .ptr = this,
.vtable = &.{ .vtable = &.{
.minSize = minSize,
.handle = handle, .handle = handle,
.content = content, .content = content,
}, },
}; };
} }
fn minSize(_: *anyopaque, _: *const App.Model, _: zterm.Point) zterm.Point {
return .{ .x = @tagName(page).len + 2 };
}
fn handle(ctx: *anyopaque, _: *App.Model, event: App.Event) !void { fn handle(ctx: *anyopaque, _: *App.Model, event: App.Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx)); const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) { switch (event) {
.about, .blog => this.highlight = event == page, .page => |p| this.highlight = p == page,
// TODO accept key input too? // TODO accept key input too?
.mouse => |mouse| if (mouse.button == .left and mouse.kind == .release) this.queue.push(switch (page) { .mouse => |mouse| if (mouse.button == .left and mouse.kind == .release) this.queue.push(switch (page) {
.about => .about, .about => .{ .page = .about },
.blog => .{ .blog = null }, .blog => .{
else => @compileError("The provided navigation button event to trigger is not a `App.Model.Pages` enum variation."), .page = .{ .blog = null },
},
}), }),
else => {}, else => {},
} }
@@ -49,8 +54,7 @@ pub fn NavigationButton(App: type) fn (std.meta.FieldEnum(App.Event)) type {
assert(size.y == 1); assert(size.y == 1);
for (1.., this.text) |idx, cp| { for (1.., this.text) |idx, cp| {
cells[idx].style.fg = if (this.highlight) .black else .default; if (this.highlight) cells[idx].style.ul = .green;
cells[idx].style.bg = if (this.highlight) .green else .default;
cells[idx].style.emphasis = &.{.underline}; cells[idx].style.emphasis = &.{.underline};
cells[idx].cp = cp; cells[idx].cp = cp;
} }