Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ pub fn build(b: *std.Build) void {
// Upstream linker issue: Zig's self-hosted linker can't handle the .sframe relocations
// (R_X86_64_PC64) coming from recent GCC16/glibc updates.
// Issue: https://codeberg.org/ziglang/zig/issues/30959
//
// Workaroun: bypass the internal linker using `zig build test -Duse-llvm=true`
const use_llvm = b.option(bool, "use-llvm", "force LLVM backend (GCC16/glibc2.43+ sframe workaround)") orelse null;

Expand Down Expand Up @@ -520,4 +519,25 @@ pub fn build(b: *std.Build) void {
const run_list = b.addRunArtifact(list_exe);
const example_list_step = b.step("example-list", "Run examples/03_list");
example_list_step.dependOn(&run_list.step);

// minimal example >
const minimal_exe = b.addExecutable(.{
.name = "minimal",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/00_minimal/main.zig"),
.target = target,
.optimize = optimize,
}),
});
minimal_exe.root_module.addImport("fern_ansi", ansi_mod);
minimal_exe.root_module.addImport("fern_style", style_mod);
minimal_exe.root_module.addImport("fern_app", app_mod);
minimal_exe.root_module.addImport("fern_widget", widget_mod);

if (needs_libc) minimal_exe.root_module.link_libc = true;
b.installArtifact(minimal_exe);

const run_minimal = b.addRunArtifact(minimal_exe);
const example_minimal_step = b.step("example-minimal", "Run examples/00_minimal");
example_minimal_step.dependOn(&run_minimal.step);
}
48 changes: 48 additions & 0 deletions examples/00_minimal/main.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT

// minimal fern app. a spinner that runs until you press q.
// shows the runSimple entry point
// zig build example-minimal
const std = @import("std");
const ansi = @import("fern_ansi");
const style = @import("fern_style");
const app = @import("fern_app");
const widget = @import("fern_widget");
const Msg = union(enum) {
key: ansi.KeyEvent,
spinner_tick: widget.spinner.TickMsg,
};
const State = struct { spinner: widget.Spinner };
const SPIN_STYLE = style.Style.init().fg_(.{ .ansi16 = .cyan });

fn init(alloc: std.mem.Allocator) !struct { State, ?app.Cmd(Msg) } {
_ = alloc;
var sp = widget.Spinner.initPreset(widget.spinner.DOT);
sp.setStyle(SPIN_STYLE);
return .{ .{ .spinner = sp }, sp.tick(Msg) };
}
fn update(state: *State, msg: Msg, alloc: std.mem.Allocator) !?app.Cmd(Msg) {
_ = alloc;
return switch (msg) {
.key => |k| if (widget.key.isQuit(k)) .quit else null,
.spinner_tick => |t| blk: {
const r = state.spinner.update(t, Msg);
state.spinner = r.s;
break :blk r.cmd;
},
};
}
fn view(state: *const State, alloc: std.mem.Allocator) ![]u8 {
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
try out.appendSlice(alloc, " ");
const frame = try state.spinner.view(alloc);
defer alloc.free(frame);
try out.appendSlice(alloc, frame);
try out.appendSlice(alloc, " Loading... press q to quit");
return out.toOwnedSlice(alloc);
}

pub fn main(_: std.process.Init) !void {
try app.runSimple(State, Msg, .{ .init = init, .update = update, .view = view }, .{});
}
100 changes: 92 additions & 8 deletions src/app/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,11 @@ fn flushOut(out_aw: *std.Io.Writer.Allocating, fd: std.posix.fd_t) void {

// Main event loop. Blocks until Cmd.quit or a fatal error.
// Always cleans up and restores the terminal before returning.
pub fn run(
fn runImpl(
comptime State: type,
comptime MsgT: type,
handlers: Handlers(State, MsgT),
opts: RunOptions,
alloc: std.mem.Allocator,
) !void {
// Comptime guard: MsgT must be a tagged union.
Expand Down Expand Up @@ -325,9 +326,14 @@ pub fn run(
out_aw.writer.writeAll("\x1B[?2026$p") catch {};
flushOut(&out_aw, stdout_fd);

// event loop
// v1 does not use the alternate screen.
const using_alt_screen = false;
// startup sequences
if (opts.hide_cursor) out_aw.writer.writeAll("\x1B[?25l") catch {};
if (opts.mouse) out_aw.writer.writeAll("\x1B[?1000h\x1B[?1006h") catch {};
if (opts.alt_screen) out_aw.writer.writeAll("\x1B[?1049h") catch {};
flushOut(&out_aw, stdout_fd);

// poll timeout derived from requested fps
const poll_timeout_ms: i32 = @intCast(1000 / opts.fps);

try eventLoop(
State,
Expand All @@ -341,7 +347,8 @@ pub fn run(
cmd_pipe[0],
cmd_pipe[1],
stdout_fd,
using_alt_screen,
opts.alt_screen,
poll_timeout_ms,
alloc,
);

Expand All @@ -354,6 +361,7 @@ pub fn run(
// 5. Close pipes

std.posix.tcsetattr(stdin_fd, .FLUSH, orig_termios) catch {};
if (opts.alt_screen) out_aw.writer.writeAll("\x1B[?1049l") catch {}; // leave alt screen
out_aw.writer.writeAll("\x1B[?25h") catch {}; // show cursor
out_aw.writer.writeAll("\x1B[?1000l\x1B[?1006l") catch {}; // disable mouse
flushOut(&out_aw, stdout_fd);
Expand All @@ -363,7 +371,46 @@ pub fn run(
sys.closePipe(&cmd_pipe);
}

// eventLoop -- inner loop, separated from run() for length discipline
// TODO: have to fix the run enterface laterr...
// Main event loop. Blocks until Cmd.quit or a fatal error.
// Always cleans up and restores the terminal before returning.
pub fn run(
comptime State: type,
comptime MsgT: type,
handlers: Handlers(State, MsgT),
alloc: std.mem.Allocator,
) !void {
return runImpl(State, MsgT, handlers, .{}, alloc);
}

/// like run() but accepts RunOptions to configure terminal behaviour.
pub fn runOpts(
comptime State: type,
comptime MsgT: type,
handlers: Handlers(State, MsgT),
opts: RunOptions,
alloc: std.mem.Allocator,
) !void {
return runImpl(State, MsgT, handlers, opts, alloc);
}

/// Zero-boilerplate application bootstrap...
/// Wraps the run loop in an Arena, managges terminal state, and handles clean
/// exits. Keeps your `main()` completely free of memory and context plumbeng.
pub fn runSimple(
comptime State: type,
comptime MsgT: type,
handlers: Handlers(State, MsgT),
opts: RunOptions,
) !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
try runImpl(State, MsgT, handlers, opts, arena.allocator());
_ = sys.write(std.posix.STDOUT_FILENO, "\n".ptr, 1);
std.process.exit(0);
}

// inner loop, separated from runImpl() for length discipline
fn eventLoop(
comptime State: type,
comptime MsgT: type,
Expand All @@ -377,6 +424,7 @@ fn eventLoop(
cmd_pipe_w: std.posix.fd_t,
stdout_fd: std.posix.fd_t,
using_alt_screen: bool,
poll_timeout_ms: i32,
alloc: std.mem.Allocator,
) !void {
var fds: [2]std.posix.pollfd = .{
Expand All @@ -398,7 +446,7 @@ fn eventLoop(
should_render = false;
}

_ = std.posix.poll(&fds, POLL_TIMEOUT_MS) catch break;
_ = std.posix.poll(&fds, poll_timeout_ms) catch break;

// SIGWINCH (resize)
if (fds[0].revents & std.posix.POLL.IN != 0) {
Expand All @@ -417,7 +465,7 @@ fn eventLoop(
}
}

// handleResize -- SIGWINCH processing
// SIGWINCH processing
fn handleResize(
comptime MsgT: type,
state: anytype,
Expand Down Expand Up @@ -850,3 +898,39 @@ test "queryTerminalSize returns nonzero cols and rows on a TTY" {
try std.testing.expect(cols > 0);
try std.testing.expect(rows > 0);
}

test "RunOptions defaults match FPS_DEFAULT and all flags off" {
const opts = RunOptions{};
try std.testing.expectEqual(FPS_DEFAULT, opts.fps);
try std.testing.expect(!opts.alt_screen);
try std.testing.expect(!opts.mouse);
try std.testing.expect(!opts.hide_cursor);
}

test "RunOptions all fields can be overridden" {
const opts: RunOptions = .{
.alt_screen = true,
.fps = 30,
.mouse = true,
.hide_cursor = true,
};
try std.testing.expectEqual(@as(u32, 30), opts.fps);
try std.testing.expect(opts.alt_screen);
try std.testing.expect(opts.mouse);
try std.testing.expect(opts.hide_cursor);
}

test "poll_timeout_ms formula matches expected durations for common fps values" {
// mirrors the computation in runImpl: @intCast(1000 / opts.fps)
const Case = struct { fps: u32, ms: i32 };
const cases = [_]Case{
.{ .fps = 60, .ms = 16 },
.{ .fps = 30, .ms = 33 },
.{ .fps = 120, .ms = 8 },
.{ .fps = 1, .ms = 1000 },
};
for (cases) |c| {
const got: i32 = @intCast(1000 / c.fps);
try std.testing.expectEqual(c.ms, got);
}
}
4 changes: 4 additions & 0 deletions src/app/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ pub const run = @import("app.zig").run;

/// runtime options for the event loop.
pub const RunOptions = @import("app.zig").RunOptions;
pub const runOpts = @import("app.zig").runOpts;

/// convenience entry point: manages arena and process exit. no init_ctx needed.
pub const runSimple = @import("app.zig").runSimple;

// re-exported here because app.zig already owns sys.zig; callers can't import it directly.
/// TIOCGWINSZ ioctl - writes cols and rows for the given fd
Expand Down
Loading