diff --git a/blush/Border.zig b/blush/Border.zig index 94f44db..9a5d83e 100644 --- a/blush/Border.zig +++ b/blush/Border.zig @@ -1,4 +1,4 @@ -const std = @import("std"); +const expectEqualStrings = @import("std").testing.expectEqualStrings; const Border = @This(); top: []const u8 = "─", @@ -64,9 +64,9 @@ pub const hidden: Border = .{ pub const normal: Border = .{}; test "predefined borders" { - try std.testing.expectEqualStrings("─", normal.top); - try std.testing.expectEqualStrings("╭", rounded.top_left); - try std.testing.expectEqualStrings("┃", thick.left); - try std.testing.expectEqualStrings("═", double.top); - try std.testing.expectEqualStrings("+", ascii.top_left); + try expectEqualStrings("─", normal.top); + try expectEqualStrings("╭", rounded.top_left); + try expectEqualStrings("┃", thick.left); + try expectEqualStrings("═", double.top); + try expectEqualStrings("+", ascii.top_left); } diff --git a/blush/LineSlicer.zig b/blush/LineSlicer.zig index a094b23..0a5dd9c 100644 --- a/blush/LineSlicer.zig +++ b/blush/LineSlicer.zig @@ -1,6 +1,7 @@ -const LineSlicer = @This(); const measure = @import("measure.zig"); const util = @import("util.zig"); +const LineSlicer = @This(); +const std = @import("std"); data: []const u8, pos: usize = 0, diff --git a/build.zig b/build.zig index 1adec20..54d1565 100644 --- a/build.zig +++ b/build.zig @@ -28,6 +28,29 @@ pub fn build(b: *std.Build) void { }, }); + // dream — SSH server library (wish port). Wraps libssh via translateC. + // System dependency: pkg-config-discoverable libssh (>= 0.9). On NixOS: + // nix-shell -p libssh pkg-config + const libssh_translate = b.addTranslateC(.{ + .root_source_file = b.path("dream/libssh.h"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + libssh_translate.linkSystemLibrary("ssh", .{ .use_pkg_config = .yes }); + const libssh_c = libssh_translate.createModule(); + + const dream = b.addModule("dream", .{ + .root_source_file = b.path("dream/root.zig"), + .target = target, + .link_libc = true, + .imports = &.{ + .{ .name = "matcha", .module = matcha }, + .{ .name = "libssh_c", .module = libssh_c }, + }, + }); + dream.linkSystemLibrary("ssh", .{ .use_pkg_config = .yes }); + const mod = b.addModule("hex", .{ .root_source_file = b.path("src/root.zig"), .target = target, @@ -89,6 +112,15 @@ pub fn build(b: *std.Build) void { }), ).step, ); + test_step.dependOn( + &b.addRunArtifact( + b.addTest(.{ + .name = "dream", + .filters = test_filters, + .root_module = dream, + }), + ).step, + ); test_step.dependOn( &b.addRunArtifact(b.addTest(.{ .name = "module", @@ -119,6 +151,9 @@ pub fn build(b: *std.Build) void { "pager", "table", "textarea", + "progress-animated", + "list-default", + "file-picker", }; for (examples) |name| { const path = b.fmt("examples/{s}/main.zig", .{name}); @@ -139,4 +174,33 @@ pub fn build(b: *std.Build) void { &b.addInstallArtifact(example_exe, .{}).step, ); } + + // DREAM EXAMPLES — separate group so blush/matcha/leaves users don't need libssh installed. + const dream_examples_step = b.step("dream-examples", "Build dream (SSH) example binaries"); + const dream_examples = [_][]const u8{ + "dream-simple", + }; + for (dream_examples) |name| { + const path = b.fmt("examples/{s}/main.zig", .{name}); + const example_mod = b.createModule(.{ + .root_source_file = b.path(path), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "matcha", .module = matcha }, + .{ .name = "blush", .module = blush }, + .{ .name = "leaves", .module = leaves }, + .{ .name = "dream", .module = dream }, + }, + }); + example_mod.linkSystemLibrary("ssh", .{ .use_pkg_config = .yes }); + const example_exe = b.addExecutable(.{ + .name = b.fmt("{s}", .{name}), + .root_module = example_mod, + }); + dream_examples_step.dependOn( + &b.addInstallArtifact(example_exe, .{}).step, + ); + } } diff --git a/build.zig.zon b/build.zig.zon index 340f259..83faf45 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -10,6 +10,8 @@ "src", "blush", "matcha", + "leaves", + "dream", //"LICENSE", //"README.md", }, diff --git a/dream/Context.zig b/dream/Context.zig new file mode 100644 index 0000000..63277a4 --- /dev/null +++ b/dream/Context.zig @@ -0,0 +1,35 @@ +//! Per-connection context shared across middleware layers. Mirrors +//! charmbracelet/ssh.Context — a place to stash request-scoped values that +//! outlive a single middleware frame. +//! +//! Phase 0 keeps this minimal: just the auth state and a small kv map. +//! Public-key, certificate, and cancel-channel surface land alongside the +//! middleware framework in Phase 2. + +const std = @import("std"); + +const Context = @This(); + +allocator: std.mem.Allocator, +authenticated: bool = false, +user_name: []const u8 = "", +remote_addr: []const u8 = "", +client_version: []const u8 = "", +values: std.StringHashMapUnmanaged([]const u8) = .empty, + +pub fn init(allocator: std.mem.Allocator) Context { + return .{ .allocator = allocator }; +} + +pub fn deinit(self: *Context) void { + self.values.deinit(self.allocator); + self.* = undefined; +} + +pub fn set(self: *Context, key: []const u8, value: []const u8) !void { + try self.values.put(self.allocator, key, value); +} + +pub fn get(self: *const Context, key: []const u8) ?[]const u8 { + return self.values.get(key); +} diff --git a/dream/Handler.zig b/dream/Handler.zig new file mode 100644 index 0000000..b743d29 --- /dev/null +++ b/dream/Handler.zig @@ -0,0 +1,16 @@ +//! Handler vtable. Mirrors charmbracelet/ssh.Handler — a function invoked once +//! per accepted session, after the channel has been opened and a shell/exec +//! request received. +//! +//! Middleware composition lives in dream/Middleware.zig (Phase 2). + +const Session = @import("Session.zig"); + +const Handler = @This(); + +ctx: ?*anyopaque = null, +handle: *const fn (ctx: ?*anyopaque, sess: *Session) anyerror!void, + +pub fn call(self: Handler, sess: *Session) anyerror!void { + return self.handle(self.ctx, sess); +} diff --git a/dream/Options.zig b/dream/Options.zig new file mode 100644 index 0000000..fb29c1d --- /dev/null +++ b/dream/Options.zig @@ -0,0 +1,28 @@ +//! Server configuration. Phase 0 uses a plain struct rather than wish's +//! functional `WithX` options — zig has struct defaults so the option-fn +//! dance is unnecessary. The names still mirror wish for parity. + +const std = @import("std"); +const Handler = @import("Handler.zig"); + +const Options = @This(); + +/// Listen address (host:port). Default mirrors wish's port 22 fallback shifted +/// up to an unprivileged default so examples run without root. +addr: [:0]const u8 = "0.0.0.0:2222", + +/// Path to an ed25519 host key on disk. If null and `host_key_pem` is also +/// null, the server uses a temporary in-memory key generated at startup. +host_key_path: ?[:0]const u8 = null, + +/// In-memory PEM-encoded host key. Overrides `host_key_path` if both are set. +host_key_pem: ?[]const u8 = null, + +/// Banner string sent before authentication. +banner: ?[:0]const u8 = null, + +/// Server version string. Default "SSH-2.0-dream". +version: ?[:0]const u8 = null, + +/// Handler invoked once per accepted session. +handler: Handler, diff --git a/dream/Pty.zig b/dream/Pty.zig new file mode 100644 index 0000000..2647e25 --- /dev/null +++ b/dream/Pty.zig @@ -0,0 +1,22 @@ +//! PTY metadata for an SSH session. +//! +//! Mirrors charmbracelet/ssh.Pty: a `Window` struct carrying terminal +//! geometry plus the requested `term` name and raw mode string. + +const std = @import("std"); + +pub const Window = struct { + width: u32, + height: u32, + /// Pixel dimensions if the client supplied them. Most clients send 0/0. + pixel_width: u32 = 0, + pixel_height: u32 = 0, +}; + +pub const Pty = struct { + term: []const u8 = "", + window: Window = .{ .width = 0, .height = 0 }, + /// Raw `pty-req` modes payload as defined in RFC 4254 §8. Opaque here; + /// callers that need to parse should walk the byte stream themselves. + modes: []const u8 = "", +}; diff --git a/dream/Server.zig b/dream/Server.zig new file mode 100644 index 0000000..b7f268b --- /dev/null +++ b/dream/Server.zig @@ -0,0 +1,465 @@ +//! SSH server. Mirrors charmbracelet/ssh.Server. +//! +//! Phase 1 architecture: per accepted connection a "session-fiber" runs a +//! libssh callback-driven event loop. The handler runs on its own fiber +//! (spawned via std.Io.async) and communicates with the session-fiber through +//! the Session's buffered I/O — it never calls libssh directly. PTY requests +//! and window-change events arrive through libssh callbacks and are pushed +//! into the Session for the handler to consume. + +const std = @import("std"); +const c = @import("libssh_c"); + +const Handler = @import("Handler.zig"); +const Options = @import("Options.zig"); +const Pty = @import("Pty.zig").Pty; +const Session = @import("Session.zig"); + +const Server = @This(); + +const log = std.log.scoped(.dream); + +allocator: std.mem.Allocator, +options: Options, +bind: *c.ssh_bind_struct, +running: std.atomic.Value(bool) = .init(false), + +pub fn init(allocator: std.mem.Allocator, options: Options) !*Server { + if (c.ssh_init() != 0) return error.LibsshInit; + + const bind = c.ssh_bind_new() orelse return error.OutOfMemory; + errdefer c.ssh_bind_free(bind); + + var host_buf: [256]u8 = undefined; + const split = splitHostPort(options.addr) catch |e| { + log.err("invalid addr {s}: {t}", .{ options.addr, e }); + return e; + }; + const host_z = std.fmt.bufPrintZ(&host_buf, "{s}", .{split.host}) catch return error.AddrTooLong; + try setBindOption(bind, c.SSH_BIND_OPTIONS_BINDADDR, host_z); + try setBindOption(bind, c.SSH_BIND_OPTIONS_BINDPORT_STR, split.port); + + if (options.banner) |b| try setBindOption(bind, c.SSH_BIND_OPTIONS_BANNER, b); + + if (options.host_key_pem) |pem| { + try setBindOptionRaw(bind, c.SSH_BIND_OPTIONS_IMPORT_KEY_STR, pem.ptr); + } else if (options.host_key_path) |path| { + try setBindOption(bind, c.SSH_BIND_OPTIONS_HOSTKEY, path); + } else { + return error.HostKeyRequired; + } + + const self = try allocator.create(Server); + self.* = .{ + .allocator = allocator, + .options = options, + .bind = bind, + }; + return self; +} + +pub fn deinit(self: *Server) void { + c.ssh_bind_free(self.bind); + self.allocator.destroy(self); +} + +pub fn listenAndServe(self: *Server, io: std.Io) !void { + if (c.ssh_bind_listen(self.bind) < 0) { + log.err("ssh_bind_listen: {s}", .{c.ssh_get_error(self.bind)}); + return error.ListenFailed; + } + self.running.store(true, .release); + + var group: std.Io.Group = .init; + defer group.await(io) catch {}; + + while (self.running.load(.acquire)) { + const ssh_session = c.ssh_new() orelse return error.OutOfMemory; + if (c.ssh_bind_accept(self.bind, ssh_session) != c.SSH_OK) { + log.warn("ssh_bind_accept: {s}", .{c.ssh_get_error(self.bind)}); + c.ssh_free(ssh_session); + continue; + } + group.concurrent(io, handleConnection, .{ self, io, ssh_session }) catch |e| { + log.err("dispatch failed: {t}", .{e}); + c.ssh_free(ssh_session); + }; + } +} + +pub fn shutdown(self: *Server) void { + self.running.store(false, .release); +} + +// ─── per-connection state shared with libssh callbacks ────────────────────── + +const Phase = enum { authenticating, await_channel, await_shell, running, done }; + +const ConnState = struct { + server: *Server, + allocator: std.mem.Allocator, + io: std.Io, + + ssh_session: *c.ssh_session_struct, + channel: ?*c.ssh_channel_struct = null, + user_name: []const u8 = "", + + /// Owned copies of request-scoped strings that libssh only keeps valid for + /// the duration of a callback. Freed when the connection ends. + pty_term: ?[]u8 = null, + command_line: ?[]u8 = null, + command_slots: ?[][]const u8 = null, + + phase: Phase = .authenticating, + + /// Lifetime-tied to ConnState. Populated once we accept the channel. + session: ?Session = null, + + /// Lifetime-tied to ConnState. Provides callback storage that libssh + /// requires to outlive the session (it stores raw pointers, not copies). + server_cb: c.ssh_server_callbacks_struct = std.mem.zeroes(c.ssh_server_callbacks_struct), + channel_cb: c.ssh_channel_callbacks_struct = std.mem.zeroes(c.ssh_channel_callbacks_struct), +}; + +// ─── connection handler ───────────────────────────────────────────────────── + +fn handleConnection(self: *Server, io: std.Io, ssh_session: *c.ssh_session_struct) std.Io.Cancelable!void { + defer { + c.ssh_disconnect(ssh_session); + c.ssh_free(ssh_session); + } + + runConnection(self, io, ssh_session) catch |e| { + log.warn("connection error: {t}", .{e}); + }; +} + +fn runConnection(self: *Server, io: std.Io, ssh_session: *c.ssh_session_struct) !void { + var state: ConnState = .{ + .server = self, + .allocator = self.allocator, + .io = io, + .ssh_session = ssh_session, + }; + defer if (state.user_name.len > 0) self.allocator.free(state.user_name); + defer if (state.pty_term) |t| self.allocator.free(t); + defer if (state.command_line) |cmd| self.allocator.free(cmd); + defer if (state.command_slots) |slots| self.allocator.free(slots); + defer if (state.session) |*s| s.deinit(); + + // Server callbacks must be installed *before* key exchange — libssh wires + // the auth/channel handlers during the transport handshake. + initServerCallbacks(&state); + if (c.ssh_set_server_callbacks(ssh_session, &state.server_cb) != c.SSH_OK) + return error.CallbacksFailed; + + if (c.ssh_handle_key_exchange(ssh_session) != c.SSH_OK) { + log.warn("key exchange failed: {s}", .{c.ssh_get_error(ssh_session)}); + return error.KeyExchangeFailed; + } + + c.ssh_set_auth_methods( + ssh_session, + c.SSH_AUTH_METHOD_PASSWORD | c.SSH_AUTH_METHOD_PUBLICKEY | c.SSH_AUTH_METHOD_NONE, + ); + + const event = c.ssh_event_new() orelse return error.OutOfMemory; + defer c.ssh_event_free(event); + if (c.ssh_event_add_session(event, ssh_session) != c.SSH_OK) return error.EventAddFailed; + + // Phase a: drive the event loop until auth + channel + shell-or-exec ready. + while (state.phase != .running and state.phase != .done) { + const rv = c.ssh_event_dopoll(event, 200); + if (rv == c.SSH_ERROR) { + log.warn("ssh_event_dopoll: {s}", .{c.ssh_get_error(ssh_session)}); + return error.PollError; + } + } + if (state.phase == .done) return; + + // Phase b: spawn handler, then keep pumping the event loop + draining + // output while it runs. + var handler_args: HandlerArgs = .{ + .future_done = .init(false), + .server = self, + .session = &state.session.?, + }; + var handler_future = io.concurrent(runHandler, .{&handler_args}) catch |e| { + log.err("failed to dispatch handler: {t}", .{e}); + return e; + }; + + while (!handler_args.future_done.load(.acquire) or + c.ssh_channel_is_open(state.session.?.channel) != 0) + { + state.session.?.drainOutput(); + const rv = c.ssh_event_dopoll(event, 50); + if (rv == c.SSH_ERROR) { + log.warn("ssh_event_dopoll mid-session: {s}", .{c.ssh_get_error(ssh_session)}); + // Wake the handler so it can return. + state.session.?.close(); + break; + } + if (handler_args.future_done.load(.acquire)) { + // Handler done — drain any final output and break. + state.session.?.drainOutput(); + break; + } + } + + // Wait for the handler fiber to actually return, then finalize. + handler_future.await(io); + + if (state.session) |*s| { + s.drainOutput(); + _ = c.ssh_channel_request_send_exit_status(s.channel, @intCast(s.exit_status)); + _ = c.ssh_channel_send_eof(s.channel); + _ = c.ssh_channel_close(s.channel); + } +} + +const HandlerArgs = struct { + future_done: std.atomic.Value(bool), + server: *Server, + session: *Session, +}; + +fn runHandler(args: *HandlerArgs) void { + defer args.future_done.store(true, .release); + args.server.options.handler.call(args.session) catch |e| { + log.warn("handler returned error: {t}", .{e}); + }; +} + +// ─── callback installation ────────────────────────────────────────────────── + +fn initServerCallbacks(state: *ConnState) void { + state.server_cb.size = @sizeOf(c.ssh_server_callbacks_struct); + state.server_cb.userdata = state; + state.server_cb.auth_password_function = cbAuthPassword; + state.server_cb.auth_pubkey_function = cbAuthPubkey; + state.server_cb.auth_none_function = cbAuthNone; + state.server_cb.channel_open_request_session_function = cbChannelOpen; + + state.channel_cb.size = @sizeOf(c.ssh_channel_callbacks_struct); + state.channel_cb.userdata = state; + state.channel_cb.channel_data_function = cbChannelData; + state.channel_cb.channel_eof_function = cbChannelEof; + state.channel_cb.channel_close_function = cbChannelClose; + state.channel_cb.channel_pty_request_function = cbPtyRequest; + state.channel_cb.channel_shell_request_function = cbShellRequest; + state.channel_cb.channel_exec_request_function = cbExecRequest; + state.channel_cb.channel_pty_window_change_function = cbWindowChange; +} + +// ─── callbacks (C ABI) ────────────────────────────────────────────────────── + +fn castState(userdata: ?*anyopaque) *ConnState { + return @ptrCast(@alignCast(userdata.?)); +} + +fn cbAuthNone(_: ?*c.ssh_session_struct, user: [*c]const u8, userdata: ?*anyopaque) callconv(.c) c_int { + const state = castState(userdata); + rememberUser(state, user); + // Reject none-auth — forces clients to attempt password/pubkey, which we + // unconditionally accept. Matches OpenSSH default behavior. + return c.SSH_AUTH_DENIED; +} + +fn cbAuthPassword( + _: ?*c.ssh_session_struct, + user: [*c]const u8, + _: [*c]const u8, + userdata: ?*anyopaque, +) callconv(.c) c_int { + const state = castState(userdata); + rememberUser(state, user); + state.phase = .await_channel; + return c.SSH_AUTH_SUCCESS; +} + +fn cbAuthPubkey( + _: ?*c.ssh_session_struct, + user: [*c]const u8, + _: ?*c.ssh_key_struct, + sig_state: u8, + userdata: ?*anyopaque, +) callconv(.c) c_int { + const state = castState(userdata); + rememberUser(state, user); + if (sig_state == c.SSH_PUBLICKEY_STATE_NONE) { + // Initial probe — tell client to actually sign with the key. + return c.SSH_AUTH_SUCCESS; + } + if (sig_state == c.SSH_PUBLICKEY_STATE_VALID) { + state.phase = .await_channel; + return c.SSH_AUTH_SUCCESS; + } + return c.SSH_AUTH_DENIED; +} + +fn cbChannelOpen(session: ?*c.ssh_session_struct, userdata: ?*anyopaque) callconv(.c) ?*c.ssh_channel_struct { + const state = castState(userdata); + if (state.channel != null) return null; + const ch = c.ssh_channel_new(session) orelse return null; + state.channel = ch; + state.phase = .await_shell; + + // Initialize the Session now so its allocator/io are valid before any + // pty/exec/shell callback fires. + state.session = Session.init(state.allocator, state.io, state.ssh_session, ch) catch { + c.ssh_channel_free(ch); + state.channel = null; + return null; + }; + state.session.?.user_name = state.user_name; + + if (c.ssh_set_channel_callbacks(ch, &state.channel_cb) != c.SSH_OK) { + log.warn("ssh_set_channel_callbacks failed", .{}); + } + return ch; +} + +fn cbChannelData( + _: ?*c.ssh_session_struct, + _: ?*c.ssh_channel_struct, + data: ?*anyopaque, + len: u32, + is_stderr: c_int, + userdata: ?*anyopaque, +) callconv(.c) c_int { + _ = is_stderr; // server-side: client data is always stdin + const state = castState(userdata); + const sess = &(state.session orelse return 0); + const bytes: [*]const u8 = @ptrCast(data.?); + const n = sess.pushInput(bytes[0..len]) catch return 0; + return @intCast(n); +} + +fn cbChannelEof(_: ?*c.ssh_session_struct, _: ?*c.ssh_channel_struct, userdata: ?*anyopaque) callconv(.c) void { + const state = castState(userdata); + if (state.session) |*s| s.markEof(); +} + +fn cbChannelClose(_: ?*c.ssh_session_struct, _: ?*c.ssh_channel_struct, userdata: ?*anyopaque) callconv(.c) void { + const state = castState(userdata); + if (state.session) |*s| s.close(); + state.phase = .done; +} + +fn cbPtyRequest( + _: ?*c.ssh_session_struct, + _: ?*c.ssh_channel_struct, + term: [*c]const u8, + width: c_int, + height: c_int, + pxwidth: c_int, + pxheight: c_int, + userdata: ?*anyopaque, +) callconv(.c) c_int { + const state = castState(userdata); + if (state.session) |*s| { + // libssh only keeps `term` valid for this callback — own a copy. + const term_src = stringOrEmpty(term); + if (term_src.len > 0) { + state.pty_term = state.allocator.dupe(u8, term_src) catch null; + } + s.pty_value = .{ + .term = state.pty_term orelse "", + .window = .{ + .width = @intCast(@max(0, width)), + .height = @intCast(@max(0, height)), + .pixel_width = @intCast(@max(0, pxwidth)), + .pixel_height = @intCast(@max(0, pxheight)), + }, + }; + s.last_window = s.pty_value.?.window; + } + return c.SSH_OK; +} + +fn cbWindowChange( + _: ?*c.ssh_session_struct, + _: ?*c.ssh_channel_struct, + width: c_int, + height: c_int, + pxwidth: c_int, + pxheight: c_int, + userdata: ?*anyopaque, +) callconv(.c) c_int { + const state = castState(userdata); + if (state.session) |*s| { + const win: @TypeOf(s.last_window) = .{ + .width = @intCast(@max(0, width)), + .height = @intCast(@max(0, height)), + .pixel_width = @intCast(@max(0, pxwidth)), + .pixel_height = @intCast(@max(0, pxheight)), + }; + s.setWindow(win); + } + return c.SSH_OK; +} + +fn cbShellRequest(_: ?*c.ssh_session_struct, _: ?*c.ssh_channel_struct, userdata: ?*anyopaque) callconv(.c) c_int { + const state = castState(userdata); + state.phase = .running; + return c.SSH_OK; +} + +fn cbExecRequest( + _: ?*c.ssh_session_struct, + _: ?*c.ssh_channel_struct, + command: [*c]const u8, + userdata: ?*anyopaque, +) callconv(.c) c_int { + const state = castState(userdata); + if (state.session) |*s| { + const cmd = stringOrEmpty(command); + if (cmd.len > 0) { + const owned = state.allocator.dupe(u8, cmd) catch return c.SSH_ERROR; + const slots = state.allocator.alloc([]const u8, 1) catch { + state.allocator.free(owned); + return c.SSH_ERROR; + }; + slots[0] = owned; + state.command_line = owned; + state.command_slots = slots; + s.command_argv = slots; + } + } + state.phase = .running; + return c.SSH_OK; +} + +// ─── tiny helpers ─────────────────────────────────────────────────────────── + +fn rememberUser(state: *ConnState, user: [*c]const u8) void { + if (state.user_name.len != 0) return; + const u = stringOrEmpty(user); + if (u.len == 0) return; + state.user_name = state.allocator.dupe(u8, u) catch ""; + if (state.session) |*s| s.user_name = state.user_name; +} + +fn setBindOption(bind: *c.ssh_bind_struct, opt: c_uint, value: [:0]const u8) !void { + return setBindOptionRaw(bind, opt, value.ptr); +} + +fn setBindOptionRaw(bind: *c.ssh_bind_struct, opt: c_uint, value: ?*const anyopaque) !void { + if (c.ssh_bind_options_set(bind, opt, value) < 0) { + log.err("ssh_bind_options_set({}): {s}", .{ opt, c.ssh_get_error(bind) }); + return error.BindOptionFailed; + } +} + +fn stringOrEmpty(s: ?[*:0]const u8) []const u8 { + const p = s orelse return ""; + return std.mem.span(p); +} + +const HostPort = struct { host: []const u8, port: [:0]const u8 }; + +fn splitHostPort(addr: [:0]const u8) !HostPort { + const colon = std.mem.lastIndexOfScalar(u8, addr, ':') orelse return error.MissingPort; + return .{ .host = addr[0..colon], .port = addr[colon + 1 ..] }; +} diff --git a/dream/Session.zig b/dream/Session.zig new file mode 100644 index 0000000..96b9356 --- /dev/null +++ b/dream/Session.zig @@ -0,0 +1,207 @@ +//! An accepted SSH session. Mirrors charmbracelet/ssh.Session. +//! +//! Phase 1 architecture: the session-fiber owns the underlying libssh session +//! (calls `ssh_event_dopoll`, drains output, etc). The handler runs on a +//! separate fiber and never touches libssh directly — it reads from this +//! Session's input buffer and writes into its output buffer. Coordination is +//! via std.Io.Mutex + Condition; window-change events arrive through a +//! bounded queue with drop-oldest semantics. + +const std = @import("std"); +const c = @import("libssh_c"); +const Pty = @import("Pty.zig").Pty; +const Window = @import("Pty.zig").Window; +const Queue = @import("queue.zig").Queue; + +const Session = @This(); + +const log = std.log.scoped(.dream); + +pub const window_queue_capacity: usize = 8; + +allocator: std.mem.Allocator, +io: std.Io, + +ssh_session: *c.ssh_session_struct, +channel: *c.ssh_channel_struct, + +user_name: []const u8 = "", +command_argv: []const []const u8 = &.{}, +pty_value: ?Pty = null, +last_window: Window = .{ .width = 0, .height = 0 }, + +/// Window-change events. Capacity is small; oldest values are dropped if the +/// handler is slow — only the latest size matters anyway. +window_changes: Queue(Window), + +exit_status: u32 = 0, + +// ─── input (server → handler) ─────────────────────────────────────────────── +in_mutex: std.Io.Mutex = .init, +in_cond: std.Io.Condition = .init, +in_buf: std.ArrayList(u8) = .empty, +in_eof: bool = false, + +// ─── output (handler → server-fiber → libssh) ─────────────────────────────── +out_mutex: std.Io.Mutex = .init, +out_buf: std.ArrayList(u8) = .empty, +err_buf: std.ArrayList(u8) = .empty, +closed: bool = false, + +pub fn init( + allocator: std.mem.Allocator, + io: std.Io, + ssh_session: *c.ssh_session_struct, + channel: *c.ssh_channel_struct, +) !Session { + return .{ + .allocator = allocator, + .io = io, + .ssh_session = ssh_session, + .channel = channel, + .window_changes = try .init(allocator, io, window_queue_capacity), + }; +} + +pub fn deinit(self: *Session) void { + self.in_buf.deinit(self.allocator); + self.out_buf.deinit(self.allocator); + self.err_buf.deinit(self.allocator); + self.window_changes.deinit(); + self.* = undefined; +} + +// ─── accessors (charmbracelet/ssh.Session parity) ─────────────────────────── + +pub fn user(self: *const Session) []const u8 { + return self.user_name; +} + +pub fn command(self: *const Session) []const []const u8 { + return self.command_argv; +} + +/// Returns the PTY metadata if the client requested one, else null. +pub fn pty(self: *const Session) ?Pty { + return self.pty_value; +} + +/// Latest window size known to the server. Updated as window-change events +/// arrive; `window_changes` queue also yields each event. +pub fn window(self: *const Session) Window { + return self.last_window; +} + +/// Block until the next window-change event. Returns null once window events +/// have been stopped (handler finishing) or the session has closed. +pub fn nextWindow(self: *Session) ?Window { + return self.window_changes.pop(); +} + +/// Stop delivering window-change events; unblocks any fiber parked in +/// `nextWindow`. Idempotent. A handler that spawned a window watcher should +/// call this before awaiting that watcher. +pub fn stopWindowEvents(self: *Session) void { + self.window_changes.close(); +} + +// ─── handler-side I/O ─────────────────────────────────────────────────────── + +/// Read up to `buf.len` bytes from the channel. Blocks until at least one +/// byte is available or EOF is received. Returns 0 on EOF. +pub fn read(self: *Session, buf: []u8) !usize { + self.in_mutex.lockUncancelable(self.io); + defer self.in_mutex.unlock(self.io); + while (self.in_buf.items.len == 0 and !self.in_eof) { + self.in_cond.waitUncancelable(self.io, &self.in_mutex); + } + if (self.in_buf.items.len == 0) return 0; + const n = @min(buf.len, self.in_buf.items.len); + @memcpy(buf[0..n], self.in_buf.items[0..n]); + // Shift the remainder forward. Cheap enough; ring buffer if profiling + // later says it matters. + const rest = self.in_buf.items.len - n; + if (rest > 0) std.mem.copyForwards(u8, self.in_buf.items[0..rest], self.in_buf.items[n..]); + self.in_buf.shrinkRetainingCapacity(rest); + return n; +} + +pub fn write(self: *Session, bytes: []const u8) !usize { + self.out_mutex.lockUncancelable(self.io); + defer self.out_mutex.unlock(self.io); + if (self.closed) return error.SessionClosed; + try self.out_buf.appendSlice(self.allocator, bytes); + return bytes.len; +} + +pub fn writeStderr(self: *Session, bytes: []const u8) !usize { + self.out_mutex.lockUncancelable(self.io); + defer self.out_mutex.unlock(self.io); + if (self.closed) return error.SessionClosed; + try self.err_buf.appendSlice(self.allocator, bytes); + return bytes.len; +} + +pub fn exit(self: *Session, status: u32) void { + self.exit_status = status; +} + +// ─── server-side helpers (called only from session-fiber) ─────────────────── + +/// Append bytes received from the client to the input buffer. Returns the +/// number of bytes consumed (always all of them at the moment). +pub fn pushInput(self: *Session, bytes: []const u8) !usize { + self.in_mutex.lockUncancelable(self.io); + defer self.in_mutex.unlock(self.io); + try self.in_buf.appendSlice(self.allocator, bytes); + self.in_cond.broadcast(self.io); + return bytes.len; +} + +pub fn markEof(self: *Session) void { + self.in_mutex.lockUncancelable(self.io); + defer self.in_mutex.unlock(self.io); + self.in_eof = true; + self.in_cond.broadcast(self.io); +} + +/// Drain queued output to libssh. Called by the session-fiber between event +/// polls. Caller owns the libssh session (no internal lock here). +pub fn drainOutput(self: *Session) void { + var stdout_owned: []u8 = &.{}; + var stderr_owned: []u8 = &.{}; + { + self.out_mutex.lockUncancelable(self.io); + defer self.out_mutex.unlock(self.io); + if (self.out_buf.items.len > 0) { + stdout_owned = self.out_buf.toOwnedSlice(self.allocator) catch &.{}; + } + if (self.err_buf.items.len > 0) { + stderr_owned = self.err_buf.toOwnedSlice(self.allocator) catch &.{}; + } + } + defer if (stdout_owned.len > 0) self.allocator.free(stdout_owned); + defer if (stderr_owned.len > 0) self.allocator.free(stderr_owned); + + if (stdout_owned.len > 0) { + const w = c.ssh_channel_write(self.channel, stdout_owned.ptr, @intCast(stdout_owned.len)); + if (w < 0) log.warn("ssh_channel_write failed", .{}); + } + if (stderr_owned.len > 0) { + const w = c.ssh_channel_write_stderr(self.channel, stderr_owned.ptr, @intCast(stderr_owned.len)); + if (w < 0) log.warn("ssh_channel_write_stderr failed", .{}); + } +} + +pub fn setWindow(self: *Session, win: Window) void { + self.last_window = win; + self.window_changes.pushLatest(win) catch {}; +} + +pub fn close(self: *Session) void { + self.out_mutex.lockUncancelable(self.io); + self.closed = true; + self.out_mutex.unlock(self.io); + self.markEof(); + self.window_changes.close(); +} diff --git a/dream/libssh.h b/dream/libssh.h new file mode 100644 index 0000000..8186ca6 --- /dev/null +++ b/dream/libssh.h @@ -0,0 +1,8 @@ +/* + * Umbrella header consumed by build.zig addTranslateC. + * Pulls in everything dream needs from libssh's public C API. + */ + +#include +#include +#include diff --git a/dream/queue.zig b/dream/queue.zig new file mode 100644 index 0000000..f95aa0a --- /dev/null +++ b/dream/queue.zig @@ -0,0 +1,106 @@ +//! Bounded MPMC queue. Mirrors matcha/queue.zig but lives in dream so the +//! Server doesn't drag matcha into non-tea use cases. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +pub fn Queue(comptime T: type) type { + return struct { + const Self = @This(); + + allocator: Allocator, + io: Io, + buffer: []T, + head: usize = 0, + tail: usize = 0, + len: usize = 0, + closed: bool = false, + mutex: Io.Mutex = .init, + not_empty: Io.Condition = .init, + not_full: Io.Condition = .init, + + pub const Error = error{Closed} || Allocator.Error; + + pub fn init(allocator: Allocator, io: Io, capacity: usize) Allocator.Error!Self { + std.debug.assert(capacity > 0); + return .{ + .allocator = allocator, + .io = io, + .buffer = try allocator.alloc(T, capacity), + }; + } + + pub fn deinit(self: *Self) void { + self.allocator.free(self.buffer); + self.* = undefined; + } + + pub fn close(self: *Self) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + self.closed = true; + self.not_empty.broadcast(self.io); + self.not_full.broadcast(self.io); + } + + /// Drops the oldest item if full so the newest arrival is always + /// kept. For window-change events this is the right policy — clients + /// only care about the latest size. + pub fn pushLatest(self: *Self, item: T) error{Closed}!void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + if (self.closed) return error.Closed; + if (self.len == self.buffer.len) { + self.head = (self.head + 1) % self.buffer.len; + self.len -= 1; + } + self.buffer[self.tail] = item; + self.tail = (self.tail + 1) % self.buffer.len; + self.len += 1; + self.not_empty.signal(self.io); + } + + /// Blocks until space is available or the queue is closed. + pub fn push(self: *Self, item: T) error{Closed}!void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + while (self.len == self.buffer.len and !self.closed) { + self.not_full.waitUncancelable(self.io, &self.mutex); + } + if (self.closed) return error.Closed; + self.buffer[self.tail] = item; + self.tail = (self.tail + 1) % self.buffer.len; + self.len += 1; + self.not_empty.signal(self.io); + } + + /// Blocks until an item is available. Returns null only if the queue + /// is closed and drained. + pub fn pop(self: *Self) ?T { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + while (self.len == 0 and !self.closed) { + self.not_empty.waitUncancelable(self.io, &self.mutex); + } + if (self.len == 0) return null; + const item = self.buffer[self.head]; + self.head = (self.head + 1) % self.buffer.len; + self.len -= 1; + self.not_full.signal(self.io); + return item; + } + + /// Non-blocking variant of `pop`. + pub fn tryPop(self: *Self) ?T { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + if (self.len == 0) return null; + const item = self.buffer[self.head]; + self.head = (self.head + 1) % self.buffer.len; + self.len -= 1; + self.not_full.signal(self.io); + return item; + } + }; +} diff --git a/dream/root.zig b/dream/root.zig index e69de29..cf65102 100644 --- a/dream/root.zig +++ b/dream/root.zig @@ -0,0 +1,42 @@ +//! dream — SSH server library. Zig port of charmbracelet/wish. +//! +//! Phase 0: bind/accept loop, any-auth, single Handler. PTY metadata is +//! plumbed through but no PTY allocation is performed server-side yet. +//! +//! Build requirement: libssh (>= 0.9) must be discoverable via pkg-config. +//! On NixOS: `nix-shell -p libssh pkg-config`. + +const std = @import("std"); + +pub const Server = @import("Server.zig"); +pub const Session = @import("Session.zig"); +pub const Handler = @import("Handler.zig"); +pub const Options = @import("Options.zig"); +pub const Context = @import("Context.zig"); +pub const Pty = @import("Pty.zig").Pty; +pub const Window = @import("Pty.zig").Window; + +// ─── helpers (mirror wish.Println / Errorln / Fatal) ──────────────────────── + +pub fn print(s: *Session, bytes: []const u8) !void { + _ = try s.write(bytes); +} + +pub fn println(s: *Session, bytes: []const u8) !void { + _ = try s.write(bytes); + _ = try s.write("\r\n"); +} + +pub fn errorln(s: *Session, bytes: []const u8) !void { + _ = try s.writeStderr(bytes); + _ = try s.writeStderr("\r\n"); +} + +pub fn fatal(s: *Session, bytes: []const u8) !void { + try errorln(s, bytes); + s.exit(1); +} + +test { + std.testing.refAllDecls(@This()); +} diff --git a/examples/dream-simple/main.zig b/examples/dream-simple/main.zig new file mode 100644 index 0000000..6964ac4 --- /dev/null +++ b/examples/dream-simple/main.zig @@ -0,0 +1,71 @@ +//! Minimal echo SSH server. Echoes input back, reports the client's PTY, and +//! prints live window-resize events. Build and run: +//! +//! nix-shell -p libssh pkg-config --run 'zig build dream-examples --summary all' +//! ssh-keygen -t ed25519 -N "" -f /tmp/dream_host +//! DREAM_HOST_KEY=/tmp/dream_host ./zig-out/bin/dream-simple +//! +//! Then in another terminal: +//! ssh -p 2222 anyone@localhost +//! +//! Type lines; they echo back. Resize the window to see resize events. +//! Ctrl-D / "exit" terminates the session. + +const std = @import("std"); +const dream = @import("dream"); + +/// Background fiber: drains window-change events and reports them to the +/// client. Exits when the session closes (nextWindow returns null). +fn watchWindow(sess: *dream.Session) void { + while (sess.nextWindow()) |win| { + var buf: [64]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "[resize {d}x{d}]\r\n", .{ win.width, win.height }) catch continue; + _ = sess.write(msg) catch return; + } +} + +fn echo(_: ?*anyopaque, sess: *dream.Session) !void { + try dream.println(sess, "welcome to dream — echoing your input"); + + if (sess.pty()) |pty| { + var buf: [128]u8 = undefined; + const msg = try std.fmt.bufPrint(&buf, "pty: term={s} {d}x{d}", .{ + pty.term, pty.window.width, pty.window.height, + }); + try dream.println(sess, msg); + } else { + try dream.println(sess, "no pty allocated"); + } + + var watcher = try sess.io.concurrent(watchWindow, .{sess}); + // Stop window events when we're done so the watcher fiber can exit. + defer { + sess.stopWindowEvents(); + watcher.await(sess.io); + } + + var buf: [4096]u8 = undefined; + while (true) { + const n = sess.read(&buf) catch break; + if (n == 0) break; + _ = sess.write(buf[0..n]) catch break; + } +} + +pub fn main(init: std.process.Init) !void { + const host_key = init.minimal.environ.getPosix("DREAM_HOST_KEY") orelse { + std.debug.print("set DREAM_HOST_KEY to a private-key path\n", .{}); + return error.MissingHostKey; + }; + const addr = init.minimal.environ.getPosix("DREAM_ADDR") orelse "0.0.0.0:2222"; + + const server = try dream.Server.init(init.gpa, .{ + .addr = addr, + .host_key_path = host_key, + .handler = .{ .handle = echo }, + }); + defer server.deinit(); + + std.log.info("dream-simple listening on {s}", .{addr}); + try server.listenAndServe(init.io); +} diff --git a/examples/file-picker/main.zig b/examples/file-picker/main.zig new file mode 100644 index 0000000..c54cc6c --- /dev/null +++ b/examples/file-picker/main.zig @@ -0,0 +1,92 @@ +//! Mirrors bubbletea's `examples/file-picker`: browse the user's home +//! directory restricted to .mod/.sum/.zig/.txt/.md files. Enter selects. +const std = @import("std"); +const matcha = @import("matcha"); +const blush = @import("blush"); +const leaves = @import("leaves"); + +const Writer = std.Io.Writer; + +const allowed_exts = [_][]const u8{ ".mod", ".sum", ".zig", ".txt", ".md" }; + +const Model = struct { + model: matcha.Model = .{ .vtable = &.{ + .init = init, + .update = update, + .view = view, + } }, + fp: leaves.FilePicker = .{ + .allowed_types = &allowed_exts, + }, + selected_file: []const u8 = "", + err_text: []const u8 = "", + quitting: bool = false, + + start_dir: []const u8 = ".", + + fn init(m: *matcha.Model) ?matcha.Cmd { + const self: *Model = @fieldParentPtr("model", m); + self.fp.setCurrentDirectory(m.gpa, m.io, self.start_dir) catch {}; + return null; + } + + fn update(m: *matcha.Model, msg: matcha.Msg) ?matcha.Cmd { + const self: *Model = @fieldParentPtr("model", m); + switch (msg) { + .key_press => |k| { + if (k.kind == .rune and k.rune == 'q') { + self.quitting = true; + return .quit; + } + if (k.kind == .rune and k.rune == 'c' and k.mods.ctrl) { + self.quitting = true; + return .quit; + } + // Check for select before forwarding (enter triggers Open + // which would mutate state for directories). + if (self.fp.key_map.select.matches(k) and self.fp.entries.items.len > 0) { + const entry = self.fp.entries.items[self.fp.selected_index]; + if (entry.kind != .directory and !self.fp.canSelect(entry.name)) { + self.err_text = "not a valid type"; + self.selected_file = ""; + } + } + }, + .interrupt => return .quit, + else => {}, + } + _ = self.fp.update(m.gpa, m.io, msg); + if (self.fp.path.len > 0) self.selected_file = self.fp.path; + return null; + } + + fn view(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { + const self: *Model = @fieldParentPtr("model", m); + if (self.quitting) { + try w.print("\n You selected: {s}\n\n", .{self.selected_file}); + return .{}; + } + try w.writeAll("\n "); + if (self.err_text.len > 0) { + try w.writeAll(self.err_text); + } else if (self.selected_file.len == 0) { + try w.writeAll("Pick a file:"); + } else { + try w.print("Selected file: \x1b[38;5;212m{s}\x1b[0m", .{self.selected_file}); + } + try w.writeAll("\n\n"); + try self.fp.view(w); + try w.writeByte('\n'); + return .{ .alt_screen = true }; + } +}; + +pub fn main(init: std.process.Init) !void { + const home = init.environ_map.get("HOME") orelse "."; + var m: Model = .{ .start_dir = home }; + defer m.fp.deinit(init.gpa); + + var program: matcha.Program = try .init(init, &m.model, .{}); + defer program.deinit(); + try program.run(); +} diff --git a/examples/help/main.zig b/examples/help/main.zig index 24daac8..d42bc34 100644 --- a/examples/help/main.zig +++ b/examples/help/main.zig @@ -119,7 +119,7 @@ const Model = struct { pub fn main(init: std.process.Init) !void { var m: Model = .{}; - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/list-default/main.zig b/examples/list-default/main.zig new file mode 100644 index 0000000..b3ee055 --- /dev/null +++ b/examples/list-default/main.zig @@ -0,0 +1,111 @@ +//! Mirrors bubbletea's `examples/list-default`: paginated list of "fave +//! things" with title + description per item, DefaultDelegate styling, +//! substring filter via `/`. +const std = @import("std"); +const matcha = @import("matcha"); +const leaves = @import("leaves"); + +const Writer = std.Io.Writer; + +const Entry = struct { + title_text: []const u8, + desc_text: []const u8, + + var vt: leaves.List.Item.VTable = .{ + .filter_value = filterValueFn, + .title = titleFn, + .description = descFn, + }; + + fn filterValueFn(ctx: *anyopaque) []const u8 { + const self: *const Entry = @ptrCast(@alignCast(ctx)); + return self.title_text; + } + fn titleFn(ctx: *anyopaque) []const u8 { + const self: *const Entry = @ptrCast(@alignCast(ctx)); + return self.title_text; + } + fn descFn(ctx: *anyopaque) []const u8 { + const self: *const Entry = @ptrCast(@alignCast(ctx)); + return self.desc_text; + } + + fn item(self: *Entry) leaves.List.Item { + return .{ .ctx = self, .vtable = &vt }; + } +}; + +var entries = [_]Entry{ + .{ .title_text = "Raspberry Pi's", .desc_text = "I have 'em all over my house" }, + .{ .title_text = "Nutella", .desc_text = "It's good on toast" }, + .{ .title_text = "Bitter melon", .desc_text = "It cools you down" }, + .{ .title_text = "Nice socks", .desc_text = "And by that I mean socks without holes" }, + .{ .title_text = "Eight hours of sleep", .desc_text = "I had this once" }, + .{ .title_text = "Cats", .desc_text = "Usually" }, + .{ .title_text = "Plantasia, the album", .desc_text = "My plants love it too" }, + .{ .title_text = "Pour over coffee", .desc_text = "It takes forever to make though" }, + .{ .title_text = "VR", .desc_text = "Virtual reality...what is there to say?" }, + .{ .title_text = "Noguchi Lamps", .desc_text = "Such pleasing organic forms" }, + .{ .title_text = "Linux", .desc_text = "Pretty much the best OS" }, + .{ .title_text = "Business school", .desc_text = "Just kidding" }, + .{ .title_text = "Pottery", .desc_text = "Wet clay is a great feeling" }, + .{ .title_text = "Shampoo", .desc_text = "Nothing like clean hair" }, + .{ .title_text = "Table tennis", .desc_text = "It's surprisingly exhausting" }, + .{ .title_text = "Milk crates", .desc_text = "Great for packing in your extra stuff" }, + .{ .title_text = "Afternoon tea", .desc_text = "Especially the tea sandwich part" }, + .{ .title_text = "Stickers", .desc_text = "The thicker the vinyl the better" }, + .{ .title_text = "20° Weather", .desc_text = "Celsius, not Fahrenheit" }, + .{ .title_text = "Warm light", .desc_text = "Like around 2700 Kelvin" }, + .{ .title_text = "The vernal equinox", .desc_text = "The autumnal equinox is pretty good too" }, + .{ .title_text = "Gaffer's tape", .desc_text = "Basically sticky fabric" }, + .{ .title_text = "Terrycloth", .desc_text = "In other words, towel fabric" }, +}; + +const Model = struct { + model: matcha.Model = .{ .vtable = &.{ + .init = init, + .update = update, + .view = view, + } }, + list: leaves.List = .{ .title_text = "My Fave Things" }, + delegate: leaves.List.DefaultDelegate = .{}, + item_slots: [entries.len]leaves.List.Item = undefined, + + fn init(m: *matcha.Model) ?matcha.Cmd { + const self: *Model = @fieldParentPtr("model", m); + for (&entries, 0..) |*e, i| self.item_slots[i] = e.item(); + self.list.setItems(&self.item_slots); + self.list.setDelegate(self.delegate.delegate()); + return null; + } + + fn update(m: *matcha.Model, msg: matcha.Msg) ?matcha.Cmd { + const self: *Model = @fieldParentPtr("model", m); + switch (msg) { + .key_press => |k| if (k.kind == .rune and k.rune == 'c' and k.mods.ctrl) return .quit, + .interrupt => return .quit, + .window_size => |ws| { + self.list.setSize(ws.width -| 4, ws.height -| 2); + return null; + }, + else => {}, + } + return self.list.update(m.gpa, msg); + } + + fn view(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { + const self: *Model = @fieldParentPtr("model", m); + try w.writeAll("\n "); + try self.list.view(w); + return .{ .alt_screen = true }; + } +}; + +pub fn main(init: std.process.Init) !void { + var m: Model = .{}; + defer m.list.deinit(init.gpa); + + var program: matcha.Program = try .init(init, &m.model, .{}); + defer program.deinit(); + try program.run(); +} diff --git a/examples/pager/main.zig b/examples/pager/main.zig index 5321164..dde8d97 100644 --- a/examples/pager/main.zig +++ b/examples/pager/main.zig @@ -119,7 +119,7 @@ pub fn main(init: std.process.Init) !void { var m: Model = .{ .content = content }; defer m.viewport.deinit(init.gpa); - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/paginator/main.zig b/examples/paginator/main.zig index 56a9aa6..ebc264b 100644 --- a/examples/paginator/main.zig +++ b/examples/paginator/main.zig @@ -61,7 +61,7 @@ const Model = struct { pub fn main(init: std.process.Init) !void { var m: Model = .{}; - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/progress-animated/main.zig b/examples/progress-animated/main.zig new file mode 100644 index 0000000..0516391 --- /dev/null +++ b/examples/progress-animated/main.zig @@ -0,0 +1,85 @@ +//! Mirrors bubbletea's `examples/progress-animated`: bumps progress by +//! 25% every second; bar springs toward each new target. +const std = @import("std"); +const matcha = @import("matcha"); +const blush = @import("blush"); +const leaves = @import("leaves"); + +const Writer = std.Io.Writer; + +const padding: u16 = 2; +const max_width: u16 = 80; + +const Model = struct { + model: matcha.Model = .{ .vtable = &.{ + .init = init, + .update = update, + .view = view, + } }, + // Default blueberry → neon pink blend matches bubbletea's + // `WithDefaultBlend()`. + progress: leaves.Progress = .{ + .full_colors = &.{ + .{ .rgb = .{ .r = 0x5A, .g = 0x56, .b = 0xE0 } }, + .{ .rgb = .{ .r = 0xEE, .g = 0x6F, .b = 0xF8 } }, + }, + .scale_blend = false, + }, + + fn init(m: *matcha.Model) ?matcha.Cmd { + return matcha.Cmd.tick(m.gpa, std.time.ns_per_s, .tick) catch null; + } + + fn update(m: *matcha.Model, msg: matcha.Msg) ?matcha.Cmd { + const self: *Model = @fieldParentPtr("model", m); + switch (msg) { + .key_press => return .quit, + .window_size => |ws| { + const desired: u16 = ws.width -| (padding * 2 + 4); + self.progress.setWidth(@min(desired, max_width)); + return null; + }, + .tick => { + if (self.progress.target_percent >= 1.0) return .quit; + var cmds: [2]matcha.Cmd = undefined; + var n: usize = 0; + if (matcha.Cmd.tick(m.gpa, std.time.ns_per_s, .tick) catch null) |c| { + cmds[n] = c; + n += 1; + } + if (self.progress.incrPercent(m.gpa, 0.25)) |c| { + cmds[n] = c; + n += 1; + } + if (n == 0) return null; + if (n == 1) return cmds[0]; + return matcha.Cmd.batch(m.gpa, cmds[0..n]) catch cmds[0]; + }, + else => {}, + } + return self.progress.update(m.gpa, msg); + } + + fn view(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { + const self: *Model = @fieldParentPtr("model", m); + try w.writeByte('\n'); + try writeSpaces(w, padding); + try self.progress.view(w); + try w.writeAll("\n\n"); + try writeSpaces(w, padding); + try w.writeAll("\x1b[38;5;243mPress any key to quit\x1b[0m\n"); + return .{}; + } +}; + +fn writeSpaces(w: *Writer, n: u16) Writer.Error!void { + var i: u16 = 0; + while (i < n) : (i += 1) try w.writeByte(' '); +} + +pub fn main(init: std.process.Init) !void { + var m: Model = .{}; + var program: matcha.Program = try .init(init, &m.model, .{}); + defer program.deinit(); + try program.run(); +} diff --git a/examples/progress-static/main.zig b/examples/progress-static/main.zig index d7cd8ce..24ac620 100644 --- a/examples/progress-static/main.zig +++ b/examples/progress-static/main.zig @@ -75,7 +75,7 @@ fn writeSpaces(w: *Writer, n: u16) Writer.Error!void { pub fn main(init: std.process.Init) !void { var m: Model = .{}; - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/spinner/main.zig b/examples/spinner/main.zig index 1c6f774..5ae6c26 100644 --- a/examples/spinner/main.zig +++ b/examples/spinner/main.zig @@ -61,7 +61,7 @@ const Model = struct { pub fn main(init: std.process.Init) !void { var m: Model = .{}; - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/stopwatch/main.zig b/examples/stopwatch/main.zig index 9e6610f..d700970 100644 --- a/examples/stopwatch/main.zig +++ b/examples/stopwatch/main.zig @@ -89,7 +89,7 @@ const Model = struct { pub fn main(init: std.process.Init) !void { var m: Model = .{}; - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/table/main.zig b/examples/table/main.zig index 0647b4d..c8dada0 100644 --- a/examples/table/main.zig +++ b/examples/table/main.zig @@ -136,7 +136,7 @@ pub fn main(init: std.process.Init) !void { m.table.setColumns(&columns); m.table.setRows(&rows); - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/textarea/main.zig b/examples/textarea/main.zig index b4387c1..34fd3a2 100644 --- a/examples/textarea/main.zig +++ b/examples/textarea/main.zig @@ -63,7 +63,7 @@ pub fn main(init: std.process.Init) !void { var m: Model = .{}; defer m.textarea.deinit(init.gpa); - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/textinput/main.zig b/examples/textinput/main.zig index 897223c..a9f43df 100644 --- a/examples/textinput/main.zig +++ b/examples/textinput/main.zig @@ -56,7 +56,7 @@ const Model = struct { pub fn main(init: std.process.Init) !void { var m: Model = .{}; defer m.text_input.deinit(init.gpa); - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/examples/timer/main.zig b/examples/timer/main.zig index 3d97979..b098b22 100644 --- a/examples/timer/main.zig +++ b/examples/timer/main.zig @@ -105,7 +105,7 @@ const Model = struct { pub fn main(init: std.process.Init) !void { var m: Model = .{}; - var program: matcha.Program = try .init(init.gpa, init.io, &m.model, .{}); + var program: matcha.Program = try .init(init, &m.model, .{}); defer program.deinit(); try program.run(); } diff --git a/leaves/FilePicker.zig b/leaves/FilePicker.zig new file mode 100644 index 0000000..cd96af7 --- /dev/null +++ b/leaves/FilePicker.zig @@ -0,0 +1,474 @@ +//! Browse the local filesystem. Renders one entry per line with +//! permissions, size, and name; cursor selects an entry; Open enters +//! a directory or marks a file as selected; Back pops up a level. +//! +//! Directory reads are synchronous via `setCurrentDirectory` — caller +//! supplies `Allocator` + `std.Io`. Entries are owned by the picker; +//! `deinit` frees them. +//! +//! Skipped from bubbles for v1: symlink target resolution (kind shown +//! but not the `→ /target` suffix), AutoHeight wiring (call `setHeight` +//! from your WindowSize handler), hidden-detection by platform attribute +//! (only `.`-prefix is treated as hidden), humanize unit prefix beyond +//! KiB/MiB/GiB/TiB. +const std = @import("std"); +const matcha = @import("matcha"); +const blush = @import("blush"); +const key = @import("key.zig"); +const util = @import("util.zig"); + +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; +const Cmd = matcha.Cmd; +const Msg = matcha.Msg; +const Io = std.Io; +const ArrayList = std.ArrayList; + +const FilePicker = @This(); + +pub const KeyMap = struct { + goto_top: key.Binding = .{ .keys = &.{key.rune('g')}, .help = .{ .key = "g", .desc = "first" } }, + goto_last: key.Binding = .{ .keys = &.{key.shift('G')}, .help = .{ .key = "G", .desc = "last" } }, + down: key.Binding = .{ + .keys = &.{ key.rune('j'), key.special(.down), key.ctrl('n') }, + .help = .{ .key = "j/↓", .desc = "down" }, + }, + up: key.Binding = .{ + .keys = &.{ key.rune('k'), key.special(.up), key.ctrl('p') }, + .help = .{ .key = "k/↑", .desc = "up" }, + }, + page_up: key.Binding = .{ + .keys = &.{ key.shift('K'), key.special(.page_up) }, + .help = .{ .key = "pgup", .desc = "page up" }, + }, + page_down: key.Binding = .{ + .keys = &.{ key.shift('J'), key.special(.page_down) }, + .help = .{ .key = "pgdn", .desc = "page down" }, + }, + back: key.Binding = .{ + .keys = &.{ key.rune('h'), key.special(.backspace), key.special(.left), key.special(.escape) }, + .help = .{ .key = "h/←", .desc = "back" }, + }, + open: key.Binding = .{ + .keys = &.{ key.rune('l'), key.special(.right), key.special(.enter) }, + .help = .{ .key = "l/→", .desc = "open" }, + }, + select: key.Binding = .{ + .keys = &.{key.special(.enter)}, + .help = .{ .key = "enter", .desc = "select" }, + }, +}; + +pub const Styles = struct { + cursor: ?blush.Style = null, + disabled_cursor: ?blush.Style = null, + symlink: ?blush.Style = null, + directory: ?blush.Style = null, + file: ?blush.Style = null, + disabled_file: ?blush.Style = null, + permission: ?blush.Style = null, + selected: ?blush.Style = null, + disabled_selected: ?blush.Style = null, + file_size: ?blush.Style = null, + empty_directory: ?blush.Style = null, +}; + +pub const default_styles: Styles = .{ + .cursor = .{ .foreground = .{ .ansi256 = 212 } }, + .disabled_cursor = .{ .foreground = .{ .ansi256 = 247 } }, + .symlink = .{ .foreground = .{ .ansi256 = 36 } }, + .directory = .{ .foreground = .{ .ansi256 = 99 } }, + .file = null, + .disabled_file = .{ .foreground = .{ .ansi256 = 243 } }, + .disabled_selected = .{ .foreground = .{ .ansi256 = 247 } }, + .permission = .{ .foreground = .{ .ansi256 = 244 } }, + .selected = .{ .foreground = .{ .ansi256 = 212 }, .bold = true }, + .file_size = .{ .foreground = .{ .ansi256 = 240 } }, + .empty_directory = .{ .foreground = .{ .ansi256 = 240 } }, +}; + +pub const Kind = enum { file, directory, sym_link, other }; + +pub const Entry = struct { + name: []u8, + kind: Kind, + size: u64, + mode: u32, +}; + +const StackFrame = struct { selected: u16, min: u16, max: u16 }; + +const file_size_width: u16 = 7; + +model: matcha.Model = .{ .vtable = &vtable }, + +/// Path the user has selected via `Select`. Empty until they pick a file. +path: []u8 = "", +/// Directory currently being browsed. +current_directory: []u8 = "", + +key_map: KeyMap = .{}, +styles: Styles = default_styles, +cursor_text: []const u8 = ">", +allowed_types: []const []const u8 = &.{}, + +show_permissions: bool = true, +show_size: bool = true, +show_hidden: bool = false, +dir_allowed: bool = false, +file_allowed: bool = true, +height: u16 = 10, + +selected_index: u16 = 0, +min_idx: u16 = 0, +max_idx: u16 = 0, + +entries: ArrayList(Entry) = .empty, +stack: ArrayList(StackFrame) = .empty, + +const vtable: matcha.Model.VTable = .{ + .init = vtableInit, + .update = vtableUpdate, + .view = vtableView, +}; + +fn vtableInit(_: *matcha.Model) ?Cmd { + return null; +} + +fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { + const self: *FilePicker = @fieldParentPtr("model", m); + return self.update(m.gpa, m.io, msg); +} + +fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { + const self: *FilePicker = @fieldParentPtr("model", m); + try self.view(w); + return .{}; +} + +pub fn deinit(self: *FilePicker, allocator: Allocator) void { + self.freeEntries(allocator); + self.entries.deinit(allocator); + self.stack.deinit(allocator); + if (self.current_directory.len > 0) allocator.free(self.current_directory); + if (self.path.len > 0) allocator.free(self.path); +} + +fn freeEntries(self: *FilePicker, allocator: Allocator) void { + for (self.entries.items) |e| allocator.free(e.name); + self.entries.clearRetainingCapacity(); +} + +// ---- public API ---- + +pub fn setCurrentDirectory( + self: *FilePicker, + allocator: Allocator, + io: Io, + new_path: []const u8, +) !void { + const dup = try allocator.dupe(u8, new_path); + if (self.current_directory.len > 0) allocator.free(self.current_directory); + self.current_directory = dup; + try self.readDir(allocator, io); + self.selected_index = 0; + self.min_idx = 0; + self.max_idx = if (self.height > 0) self.height - 1 else 0; +} + +pub fn setHeight(self: *FilePicker, h: u16) void { + self.height = h; + if (self.max_idx > h -| 1) self.max_idx = self.min_idx + (h -| 1); +} + +/// Current cursor's path joined onto `current_directory`. Caller owns +/// the returned buffer; free with `allocator.free`. +pub fn highlightedPathAlloc(self: *const FilePicker, allocator: Allocator) Allocator.Error!?[]u8 { + if (self.entries.items.len == 0) return null; + const entry = self.entries.items[self.selected_index]; + return try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); +} + +// ---- update ---- + +pub fn update(self: *FilePicker, allocator: Allocator, io: Io, msg: Msg) ?Cmd { + switch (msg) { + .key_press => |k| { + const total: u16 = @intCast(@min(self.entries.items.len, std.math.maxInt(u16))); + if (self.key_map.goto_top.matches(k)) { + self.selected_index = 0; + self.min_idx = 0; + self.max_idx = self.height -| 1; + } else if (self.key_map.goto_last.matches(k)) { + if (total == 0) return null; + self.selected_index = total - 1; + self.max_idx = total - 1; + self.min_idx = if (total > self.height) total - self.height else 0; + } else if (self.key_map.down.matches(k)) { + if (total == 0) return null; + if (self.selected_index + 1 >= total) return null; + self.selected_index += 1; + if (self.selected_index > self.max_idx) { + self.min_idx += 1; + self.max_idx += 1; + } + } else if (self.key_map.up.matches(k)) { + if (self.selected_index == 0) return null; + self.selected_index -= 1; + if (self.selected_index < self.min_idx) { + self.min_idx -= 1; + self.max_idx -= 1; + } + } else if (self.key_map.page_down.matches(k)) { + if (total == 0) return null; + self.selected_index +|= self.height; + if (self.selected_index >= total) self.selected_index = total - 1; + self.min_idx +|= self.height; + self.max_idx +|= self.height; + if (self.max_idx >= total) { + self.max_idx = total - 1; + self.min_idx = if (total > self.height) total - self.height else 0; + } + } else if (self.key_map.page_up.matches(k)) { + self.selected_index = if (self.selected_index > self.height) self.selected_index - self.height else 0; + self.min_idx = if (self.min_idx > self.height) self.min_idx - self.height else 0; + self.max_idx = self.min_idx + self.height -| 1; + } else if (self.key_map.back.matches(k)) { + self.goBack(allocator, io) catch {}; + return null; + } else if (self.key_map.open.matches(k)) { + self.openSelected(allocator, io) catch {}; + return null; + } + }, + else => {}, + } + return null; +} + +fn goBack(self: *FilePicker, allocator: Allocator, io: Io) !void { + const parent = std.fs.path.dirname(self.current_directory) orelse "/"; + const dup = try allocator.dupe(u8, parent); + allocator.free(self.current_directory); + self.current_directory = dup; + try self.readDir(allocator, io); + if (self.stack.items.len > 0) { + const frame = self.stack.pop().?; + self.selected_index = frame.selected; + self.min_idx = frame.min; + self.max_idx = frame.max; + } else { + self.selected_index = 0; + self.min_idx = 0; + self.max_idx = self.height -| 1; + } +} + +fn openSelected(self: *FilePicker, allocator: Allocator, io: Io) !void { + if (self.entries.items.len == 0) return; + const entry = self.entries.items[self.selected_index]; + const is_dir = entry.kind == .directory; + const can_select = (!is_dir and self.file_allowed and self.canSelect(entry.name)) or + (is_dir and self.dir_allowed); + if (can_select) { + if (self.path.len > 0) allocator.free(self.path); + self.path = try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); + } + if (!is_dir) return; + try self.stack.append(allocator, .{ + .selected = self.selected_index, + .min = self.min_idx, + .max = self.max_idx, + }); + const new_dir = try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); + defer allocator.free(new_dir); + try self.setCurrentDirectory(allocator, io, new_dir); +} + +// ---- directory read ---- + +fn readDir(self: *FilePicker, allocator: Allocator, io: Io) !void { + self.freeEntries(allocator); + + var dir = std.Io.Dir.cwd().openDir(io, self.current_directory, .{}) catch return; + defer dir.close(io); + + var iter = dir.iterate(); + while (true) { + const maybe = iter.next(io) catch break; + const e = maybe orelse break; + if (!self.show_hidden and e.name.len > 0 and e.name[0] == '.') continue; + const name_owned = try allocator.dupe(u8, e.name); + const kind = switch (e.kind) { + .file => Kind.file, + .directory => Kind.directory, + .sym_link => Kind.sym_link, + else => Kind.other, + }; + var size: u64 = 0; + var mode: u32 = 0; + if (dir.statFile(io, e.name, .{ .follow_symlinks = false })) |s| { + size = s.size; + mode = @intCast(@intFromEnum(s.permissions)); + } else |_| {} + try self.entries.append(allocator, .{ + .name = name_owned, + .kind = kind, + .size = size, + .mode = mode, + }); + } + std.mem.sort(Entry, self.entries.items, {}, entryLessThan); +} + +fn entryLessThan(_: void, a: Entry, b: Entry) bool { + const ad = a.kind == .directory; + const bd = b.kind == .directory; + if (ad != bd) return ad; + return std.mem.lessThan(u8, a.name, b.name); +} + +pub fn canSelect(self: *const FilePicker, name: []const u8) bool { + if (self.allowed_types.len == 0) return true; + for (self.allowed_types) |ext| { + if (std.mem.endsWith(u8, name, ext)) return true; + } + return false; +} + +// ---- view ---- + +pub fn view(self: *FilePicker, w: *Writer) Writer.Error!void { + if (self.entries.items.len == 0) { + try util.writeInline(w, self.styles.empty_directory, " Bummer. No Files Found."); + return; + } + var rendered: u16 = 0; + var i: u16 = 0; + while (i < self.entries.items.len) : (i += 1) { + if (i < self.min_idx or i > self.max_idx) continue; + if (rendered > 0) try w.writeByte('\n'); + try self.renderEntry(w, i); + rendered += 1; + } + while (rendered <= self.height) : (rendered += 1) { + try w.writeByte('\n'); + } +} + +fn renderEntry(self: *FilePicker, w: *Writer, idx: u16) Writer.Error!void { + const e = self.entries.items[idx]; + const is_selected = idx == self.selected_index; + const disabled = !self.canSelect(e.name) and e.kind != .directory; + + var size_buf: [16]u8 = undefined; + const size_text = formatSize(e.size, &size_buf); + var perm_buf: [16]u8 = undefined; + const perm_text = formatMode(e.mode, e.kind, &perm_buf); + + if (is_selected) { + // Cursor + selected row in one styled blob. + try util.writeInline(w, if (disabled) self.styles.disabled_cursor else self.styles.cursor, self.cursor_text); + const sel_style = if (disabled) self.styles.disabled_selected else self.styles.selected; + if (self.show_permissions) { + try util.writeInline(w, sel_style, " "); + try util.writeInline(w, sel_style, perm_text); + } + if (self.show_size) { + try util.writeInline(w, sel_style, " "); + try writePaddedSize(w, sel_style, size_text); + } + try util.writeInline(w, sel_style, " "); + try util.writeInline(w, sel_style, e.name); + return; + } + + // Unselected: leading space for the cursor column, then perms/size/name. + try w.writeByte(' '); + if (self.show_permissions) { + try w.writeByte(' '); + try util.writeInline(w, self.styles.permission, perm_text); + } + if (self.show_size) { + try w.writeByte(' '); + try writePaddedSize(w, self.styles.file_size, size_text); + } + try w.writeByte(' '); + const name_style: ?blush.Style = switch (e.kind) { + .directory => self.styles.directory, + .sym_link => self.styles.symlink, + else => if (disabled) self.styles.disabled_file else self.styles.file, + }; + try util.writeInline(w, name_style, e.name); +} + +fn writePaddedSize(w: *Writer, style: ?blush.Style, size_text: []const u8) Writer.Error!void { + const w_cells: u16 = file_size_width; + const cells = blush.measure.cellWidth(size_text); + var pad: u16 = if (w_cells > cells) w_cells - cells else 0; + while (pad > 0) : (pad -= 1) try w.writeByte(' '); + try util.writeInline(w, style, size_text); +} + +fn formatSize(size: u64, buf: []u8) []const u8 { + const units = [_][]const u8{ "B", "K", "M", "G", "T" }; + var value: f64 = @floatFromInt(size); + var unit: usize = 0; + while (value >= 1024.0 and unit + 1 < units.len) { + value /= 1024.0; + unit += 1; + } + if (unit == 0) return std.fmt.bufPrint(buf, "{d}B", .{size}) catch buf[0..0]; + return std.fmt.bufPrint(buf, "{d:.1}{s}", .{ value, units[unit] }) catch buf[0..0]; +} + +fn formatMode(mode: u32, kind: Kind, buf: []u8) []const u8 { + if (buf.len < 10) return buf[0..0]; + buf[0] = switch (kind) { + .directory => 'd', + .sym_link => 'l', + else => '-', + }; + const bits = [_]struct { mask: u32, ch: u8 }{ + .{ .mask = 0o400, .ch = 'r' }, + .{ .mask = 0o200, .ch = 'w' }, + .{ .mask = 0o100, .ch = 'x' }, + .{ .mask = 0o040, .ch = 'r' }, + .{ .mask = 0o020, .ch = 'w' }, + .{ .mask = 0o010, .ch = 'x' }, + .{ .mask = 0o004, .ch = 'r' }, + .{ .mask = 0o002, .ch = 'w' }, + .{ .mask = 0o001, .ch = 'x' }, + }; + for (bits, 0..) |b, i| { + buf[i + 1] = if (mode & b.mask != 0) b.ch else '-'; + } + return buf[0..10]; +} + +// ---- tests ---- + +const testing = std.testing; + +test "formatSize humanizes bytes" { + var buf: [16]u8 = undefined; + try testing.expectEqualStrings("512B", formatSize(512, &buf)); + try testing.expectEqualStrings("1.0K", formatSize(1024, &buf)); + try testing.expectEqualStrings("1.5K", formatSize(1536, &buf)); + try testing.expectEqualStrings("2.0M", formatSize(2 * 1024 * 1024, &buf)); +} + +test "formatMode renders rwx" { + var buf: [16]u8 = undefined; + try testing.expectEqualStrings("drwxr-xr-x", formatMode(0o755, .directory, &buf)); + try testing.expectEqualStrings("-rw-r--r--", formatMode(0o644, .file, &buf)); + try testing.expectEqualStrings("lrwxrwxrwx", formatMode(0o777, .sym_link, &buf)); +} + +test "entryLessThan: dirs first then name" { + const a: Entry = .{ .name = @constCast("zeta"), .kind = .directory, .size = 0, .mode = 0 }; + const b: Entry = .{ .name = @constCast("alpha"), .kind = .file, .size = 0, .mode = 0 }; + try testing.expect(entryLessThan({}, a, b)); + try testing.expect(!entryLessThan({}, b, a)); +} diff --git a/leaves/List.zig b/leaves/List.zig new file mode 100644 index 0000000..278014e --- /dev/null +++ b/leaves/List.zig @@ -0,0 +1,1035 @@ +//! Feature-rich list of items: pagination, filtering (substring), help, +//! status bar, optional spinner, status messages. +//! +//! Items expose themselves via the `Item` vtable; how each item renders is +//! controlled by the `Delegate` vtable. `DefaultDelegate` (in this file) +//! is provided for items that also implement `title()` / `description()`. +//! +//! Skipped from bubbles for v1: fuzzy filtering (this port does a simple +//! case-insensitive substring match), per-character match highlight +//! indices, AdditionalShortHelpKeys / AdditionalFullHelpKeys, the spinner +//! placement-on-the-left logic (the spinner always renders on the right +//! when shown). +const std = @import("std"); +const matcha = @import("matcha"); +const blush = @import("blush"); +const key = @import("key.zig"); +const util = @import("util.zig"); +const Paginator = @import("Paginator.zig"); +const Spinner = @import("Spinner.zig"); +const TextInput = @import("TextInput.zig"); +const Help = @import("Help.zig"); + +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; +const Cmd = matcha.Cmd; +const Msg = matcha.Msg; +const ArrayList = std.ArrayList; + +const List = @This(); + +// ---- Item / Delegate ---- + +pub const Item = struct { + ctx: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + filter_value: *const fn (ctx: *anyopaque) []const u8, + title: ?*const fn (ctx: *anyopaque) []const u8 = null, + description: ?*const fn (ctx: *anyopaque) []const u8 = null, + }; + + pub fn filterValue(self: Item) []const u8 { + return self.vtable.filter_value(self.ctx); + } + pub fn title(self: Item) ?[]const u8 { + const f = self.vtable.title orelse return null; + return f(self.ctx); + } + pub fn description(self: Item) ?[]const u8 { + const f = self.vtable.description orelse return null; + return f(self.ctx); + } +}; + +pub const Delegate = struct { + ctx: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + render: *const fn ( + ctx: *anyopaque, + w: *Writer, + list: *const List, + idx: usize, + item: Item, + ) Writer.Error!void, + height: *const fn (ctx: *anyopaque) u16, + spacing: *const fn (ctx: *anyopaque) u16, + update: ?*const fn ( + ctx: *anyopaque, + allocator: Allocator, + msg: Msg, + list: *List, + ) ?Cmd = null, + }; + + pub fn render( + self: Delegate, + w: *Writer, + list: *const List, + idx: usize, + item: Item, + ) Writer.Error!void { + try self.vtable.render(self.ctx, w, list, idx, item); + } + pub fn height(self: Delegate) u16 { + return self.vtable.height(self.ctx); + } + pub fn spacing(self: Delegate) u16 { + return self.vtable.spacing(self.ctx); + } +}; + +// ---- Keymap ---- + +pub const KeyMap = struct { + cursor_up: key.Binding = .{ + .keys = &.{ key.special(.up), key.rune('k') }, + .help = .{ .key = "↑/k", .desc = "up" }, + }, + cursor_down: key.Binding = .{ + .keys = &.{ key.special(.down), key.rune('j') }, + .help = .{ .key = "↓/j", .desc = "down" }, + }, + prev_page: key.Binding = .{ + .keys = &.{ key.special(.left), key.rune('h'), key.special(.page_up), key.rune('b'), key.rune('u') }, + .help = .{ .key = "←/h/pgup", .desc = "prev page" }, + }, + next_page: key.Binding = .{ + .keys = &.{ key.special(.right), key.rune('l'), key.special(.page_down), key.rune('f'), key.rune('d') }, + .help = .{ .key = "→/l/pgdn", .desc = "next page" }, + }, + goto_start: key.Binding = .{ + .keys = &.{ key.special(.home), key.rune('g') }, + .help = .{ .key = "g/home", .desc = "go to start" }, + }, + goto_end: key.Binding = .{ + .keys = &.{ key.special(.end), key.shift('G') }, + .help = .{ .key = "G/end", .desc = "go to end" }, + }, + filter: key.Binding = .{ + .keys = &.{key.rune('/')}, + .help = .{ .key = "/", .desc = "filter" }, + }, + clear_filter: key.Binding = .{ + .keys = &.{key.special(.escape)}, + .help = .{ .key = "esc", .desc = "clear filter" }, + }, + cancel_while_filtering: key.Binding = .{ + .keys = &.{key.special(.escape)}, + .help = .{ .key = "esc", .desc = "cancel" }, + .disabled = true, + }, + accept_while_filtering: key.Binding = .{ + .keys = &.{ key.special(.enter), key.special(.tab), key.special(.up), key.special(.down) }, + .help = .{ .key = "enter", .desc = "apply filter" }, + .disabled = true, + }, + show_full_help: key.Binding = .{ + .keys = &.{key.rune('?')}, + .help = .{ .key = "?", .desc = "more" }, + }, + close_full_help: key.Binding = .{ + .keys = &.{key.rune('?')}, + .help = .{ .key = "?", .desc = "close help" }, + }, + quit: key.Binding = .{ + .keys = &.{ key.rune('q'), key.special(.escape) }, + .help = .{ .key = "q", .desc = "quit" }, + }, + force_quit: key.Binding = .{ .keys = &.{key.ctrl('c')} }, +}; + +// ---- Styles ---- + +pub const Styles = struct { + title_bar: ?blush.Style = null, + title: ?blush.Style = null, + spinner: ?blush.Style = null, + status_bar: ?blush.Style = null, + status_empty: ?blush.Style = null, + status_filter_count: ?blush.Style = null, + no_items: ?blush.Style = null, + pagination: ?blush.Style = null, + help: ?blush.Style = null, + divider_dot: ?blush.Style = null, + filter_match: ?blush.Style = null, +}; + +pub const default_styles: Styles = .{ + .title = .{ + .background = .{ .ansi256 = 62 }, + .foreground = .{ .ansi256 = 230 }, + }, + .spinner = .{ .foreground = .{ .rgb = .{ .r = 0x74, .g = 0x73, .b = 0x73 } } }, + .status_bar = .{ .foreground = .{ .rgb = .{ .r = 0x77, .g = 0x77, .b = 0x77 } } }, + .status_empty = .{ .foreground = .{ .rgb = .{ .r = 0x5C, .g = 0x5C, .b = 0x5C } } }, + .status_filter_count = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, + .no_items = .{ .foreground = .{ .rgb = .{ .r = 0x62, .g = 0x62, .b = 0x62 } } }, + .divider_dot = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, + .filter_match = .{ .underline = true }, +}; + +// ---- Filter ---- + +pub const FilterState = enum { unfiltered, filtering, filter_applied }; + +pub const FilteredItem = struct { + index: usize, + /// Codepoint index in `filterValue` where the match starts. + match_start: usize, + /// Codepoint length of the match. + match_len: usize, +}; + +pub const Filter = enum { substring }; + +// ---- Status timeout ---- + +var status_timeout_marker: u8 = 0; +fn statusTimeoutTypeId() usize { + return @intFromPtr(&status_timeout_marker); +} + +// ---- Model ---- + +model: matcha.Model = .{ .vtable = &vtable }, + +title_text: []const u8 = "List", +item_name_singular: []const u8 = "item", +item_name_plural: []const u8 = "items", + +show_title: bool = true, +show_filter: bool = true, +show_status_bar: bool = true, +show_pagination: bool = true, +show_help: bool = true, +filtering_enabled: bool = true, +infinite_scrolling: bool = false, +disable_quit_keybindings: bool = false, + +filter_state: FilterState = .unfiltered, +filter_kind: Filter = .substring, + +key_map: KeyMap = .{}, +styles: Styles = default_styles, + +width: u16 = 0, +height: u16 = 0, + +items: []const Item = &.{}, +filtered: ArrayList(FilteredItem) = .empty, + +cursor: u16 = 0, + +paginator: Paginator = .{ .type = .dots, .per_page = 1 }, +spinner: Spinner = .{}, +show_spinner: bool = false, + +filter_input: TextInput = .{ .prompt = "Filter: ", .char_limit = 64 }, + +help: Help = .{}, + +status_message: []const u8 = "", +/// How long status messages stay visible. Default 1 second. +status_message_lifetime_ns: u64 = std.time.ns_per_s, +/// Generation counter so stale timeouts get ignored. +status_message_generation: u32 = 0, + +delegate: ?Delegate = null, + +const vtable: matcha.Model.VTable = .{ + .init = vtableInit, + .update = vtableUpdate, + .view = vtableView, +}; + +fn vtableInit(_: *matcha.Model) ?Cmd { + return null; +} + +fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { + const self: *List = @fieldParentPtr("model", m); + return self.update(m.gpa, msg); +} + +fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { + const self: *List = @fieldParentPtr("model", m); + try self.view(w); + return .{}; +} + +pub fn deinit(self: *List, allocator: Allocator) void { + self.filtered.deinit(allocator); + self.filter_input.deinit(allocator); +} + +// ---- accessors ---- + +pub fn setItems(self: *List, items: []const Item) void { + self.items = items; + self.filtered.clearRetainingCapacity(); + // If filter active, caller should rerun setFilterText or filter cmd. + self.updatePagination(); + self.updateKeybindings(); +} + +pub fn setDelegate(self: *List, d: Delegate) void { + self.delegate = d; + self.updatePagination(); +} + +pub fn setSize(self: *List, w: u16, h: u16) void { + self.width = w; + self.height = h; + self.help.width = w; + // Reserve title prompt width for the filter input. + const prompt_w = blush.measure.cellWidth(self.filter_input.prompt); + const spinner_w: u16 = if (self.show_spinner) blush.measure.cellWidth(self.spinner.preset.frames[0]) else 0; + self.filter_input.setWidth(if (w > prompt_w + spinner_w) w - prompt_w - spinner_w else 0); + self.updatePagination(); + self.updateKeybindings(); +} + +pub fn setWidth(self: *List, w: u16) void { + self.setSize(w, self.height); +} + +pub fn setHeight(self: *List, h: u16) void { + self.setSize(self.width, h); +} + +pub fn visibleItems(self: *const List) usize { + return if (self.filter_state == .unfiltered) self.items.len else self.filtered.items.len; +} + +pub fn itemAt(self: *const List, idx: usize) ?Item { + if (self.filter_state == .unfiltered) { + if (idx >= self.items.len) return null; + return self.items[idx]; + } + if (idx >= self.filtered.items.len) return null; + return self.items[self.filtered.items[idx].index]; +} + +pub fn selectedItem(self: *const List) ?Item { + return self.itemAt(self.index()); +} + +pub fn index(self: *const List) usize { + return @as(usize, self.paginator.page) * @as(usize, self.paginator.per_page) + self.cursor; +} + +pub fn globalIndex(self: *const List) usize { + const i = self.index(); + if (self.filter_state == .unfiltered) return i; + if (i >= self.filtered.items.len) return i; + return self.filtered.items[i].index; +} + +pub fn matchesForItem(self: *const List, idx: usize) ?FilteredItem { + if (self.filter_state == .unfiltered) return null; + if (idx >= self.filtered.items.len) return null; + return self.filtered.items[idx]; +} + +/// Borrows a temporary encoding of the current filter text into `out`. +/// Returns the encoded slice, or empty on failure. +pub fn filterValueInto(self: *const List, out: []u8) []const u8 { + return self.filter_input.encodeValue(out) catch out[0..0]; +} + +// ---- cursor / paging ---- + +pub fn cursorUp(self: *List) void { + if (self.cursor > 0) { + self.cursor -= 1; + return; + } + if (self.paginator.onFirstPage()) { + if (self.infinite_scrolling) { + self.gotoEnd(); + } + return; + } + self.paginator.prevPage(); + self.cursor = self.maxCursorIndex(); +} + +pub fn cursorDown(self: *List) void { + const max_idx = self.maxCursorIndex(); + if (self.cursor < max_idx) { + self.cursor += 1; + return; + } + if (!self.paginator.onLastPage()) { + self.paginator.nextPage(); + self.cursor = 0; + return; + } + if (self.infinite_scrolling) self.gotoStart(); +} + +pub fn gotoStart(self: *List) void { + self.paginator.page = 0; + self.cursor = 0; +} + +pub fn gotoEnd(self: *List) void { + if (self.paginator.total_pages == 0) { + self.paginator.page = 0; + } else { + self.paginator.page = self.paginator.total_pages - 1; + } + self.cursor = self.maxCursorIndex(); +} + +fn maxCursorIndex(self: *const List) u16 { + const items = self.paginator.itemsOnPage(@intCast(@min(self.visibleItems(), std.math.maxInt(u16)))); + return if (items == 0) 0 else items - 1; +} + +// ---- filtering ---- + +pub fn resetFilter(self: *List, allocator: Allocator) void { + if (self.filter_state == .unfiltered) return; + self.filter_state = .unfiltered; + self.filter_input.reset(); + self.filtered.clearRetainingCapacity(); + _ = allocator; + self.updatePagination(); + self.updateKeybindings(); +} + +pub fn setFilterText(self: *List, allocator: Allocator, term: []const u8) !void { + self.filter_state = .filtering; + try self.filter_input.setValue(allocator, term); + try self.refilter(allocator); + self.filter_state = .filter_applied; + self.gotoStart(); + self.filter_input.cursorEnd(); + self.updatePagination(); + self.updateKeybindings(); +} + +/// Refresh `filtered` from the current filter input. +fn refilter(self: *List, allocator: Allocator) !void { + self.filtered.clearRetainingCapacity(); + + // Encode filter input to a UTF-8 buffer once. + var term_buf: [256]u8 = undefined; + const term = self.filter_input.encodeValue(&term_buf) catch term_buf[0..0]; + if (term.len == 0) { + for (self.items, 0..) |_, i| { + try self.filtered.append(allocator, .{ .index = i, .match_start = 0, .match_len = 0 }); + } + return; + } + for (self.items, 0..) |it, i| { + const fv = it.filterValue(); + if (substringMatch(fv, term)) |m| { + try self.filtered.append(allocator, .{ + .index = i, + .match_start = m.start, + .match_len = m.len, + }); + } + } +} + +const SubMatch = struct { start: usize, len: usize }; + +fn substringMatch(haystack: []const u8, needle: []const u8) ?SubMatch { + if (needle.len == 0) return SubMatch{ .start = 0, .len = 0 }; + // ASCII case-insensitive search. Find byte position, then translate to + // codepoint position so DefaultDelegate's underline lines up correctly. + var byte_idx: usize = 0; + while (byte_idx + needle.len <= haystack.len) : (byte_idx += 1) { + if (asciiEqlNoCase(haystack[byte_idx .. byte_idx + needle.len], needle)) { + const start_cp = codepointIndex(haystack, byte_idx); + const len_cp = codepointCount(needle); + return SubMatch{ .start = start_cp, .len = len_cp }; + } + } + return null; +} + +fn asciiEqlNoCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + const lx = if (x >= 'A' and x <= 'Z') x + 32 else x; + const ly = if (y >= 'A' and y <= 'Z') y + 32 else y; + if (lx != ly) return false; + } + return true; +} + +fn codepointIndex(text: []const u8, byte_at: usize) usize { + var cp: usize = 0; + var i: usize = 0; + while (i < byte_at and i < text.len) { + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + i += len; + cp += 1; + } + return cp; +} + +fn codepointCount(text: []const u8) usize { + var n: usize = 0; + var i: usize = 0; + while (i < text.len) { + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + i += len; + n += 1; + } + return n; +} + +// ---- pagination / keybindings ---- + +fn updatePagination(self: *List) void { + const cur = self.index(); + var avail = self.height; + if (self.show_title or (self.show_filter and self.filtering_enabled)) avail -|= 2; // title + pad + if (self.show_status_bar) avail -|= 2; + if (self.show_pagination) avail -|= 1; + if (self.show_help) avail -|= 2; + + const dh = if (self.delegate) |d| d.height() else 1; + const ds = if (self.delegate) |d| d.spacing() else 0; + const row = @max(dh +| ds, 1); + self.paginator.per_page = @max(1, avail / row); + + const total: u16 = @intCast(@min(self.visibleItems(), std.math.maxInt(u16))); + _ = self.paginator.setTotalPages(if (total == 0) 1 else total); + + // Restore cursor. + if (self.paginator.per_page > 0) { + self.paginator.page = @intCast(cur / self.paginator.per_page); + self.cursor = @intCast(cur % self.paginator.per_page); + } + if (self.paginator.total_pages > 0 and self.paginator.page >= self.paginator.total_pages) { + self.paginator.page = self.paginator.total_pages - 1; + } +} + +fn updateKeybindings(self: *List) void { + const has_items = self.items.len > 0; + const has_pages = self.paginator.total_pages > 1; + const filtering = self.filter_state == .filtering; + + self.key_map.cursor_up.disabled = filtering or !has_items; + self.key_map.cursor_down.disabled = filtering or !has_items; + self.key_map.prev_page.disabled = filtering or !has_pages; + self.key_map.next_page.disabled = filtering or !has_pages; + self.key_map.goto_start.disabled = filtering or !has_items; + self.key_map.goto_end.disabled = filtering or !has_items; + self.key_map.filter.disabled = filtering or !self.filtering_enabled or !has_items; + self.key_map.clear_filter.disabled = filtering or self.filter_state != .filter_applied; + self.key_map.cancel_while_filtering.disabled = !filtering; + self.key_map.accept_while_filtering.disabled = !filtering or self.filter_input.value.items.len == 0; + self.key_map.quit.disabled = self.disable_quit_keybindings or filtering; +} + +// ---- update ---- + +pub fn update(self: *List, allocator: Allocator, msg: Msg) ?Cmd { + // Force quit always wins. + if (msg == .key_press and self.key_map.force_quit.matches(msg.key_press)) return .quit; + + // Status message timeout (gated by generation). + if (msg == .custom and msg.custom.type_id == statusTimeoutTypeId()) { + const payload_ptr = msg.custom.payload orelse return null; + const gen: u32 = @intCast(@intFromPtr(payload_ptr)); + if (gen == self.status_message_generation) self.status_message = ""; + return null; + } + + if (self.filter_state == .filtering) return self.handleFiltering(allocator, msg); + return self.handleBrowsing(allocator, msg); +} + +fn handleBrowsing(self: *List, allocator: Allocator, msg: Msg) ?Cmd { + switch (msg) { + .key_press => |k| { + if (self.key_map.clear_filter.matches(k)) { + self.resetFilter(allocator); + return null; + } + if (self.key_map.quit.matches(k)) return .quit; + if (self.key_map.cursor_up.matches(k)) self.cursorUp(); + if (self.key_map.cursor_down.matches(k)) self.cursorDown(); + if (self.key_map.prev_page.matches(k)) self.paginator.prevPage(); + if (self.key_map.next_page.matches(k)) self.paginator.nextPage(); + if (self.key_map.goto_start.matches(k)) self.gotoStart(); + if (self.key_map.goto_end.matches(k)) self.gotoEnd(); + if (self.key_map.filter.matches(k)) { + self.status_message = ""; + self.gotoStart(); + self.filter_state = .filtering; + self.filter_input.cursorEnd(); + _ = self.filter_input.focus(allocator); + self.updateKeybindings(); + } + if (self.key_map.show_full_help.matches(k) or self.key_map.close_full_help.matches(k)) { + self.help.show_all = !self.help.show_all; + self.updatePagination(); + } + }, + else => {}, + } + + // Forward msg to delegate's optional update fn. + var cmd: ?Cmd = null; + if (self.delegate) |d| if (d.vtable.update) |fnp| { + cmd = fnp(d.ctx, allocator, msg, self); + }; + + // Clamp cursor. + const max_idx = self.maxCursorIndex(); + if (self.cursor > max_idx) self.cursor = max_idx; + return cmd; +} + +fn handleFiltering(self: *List, allocator: Allocator, msg: Msg) ?Cmd { + switch (msg) { + .key_press => |k| { + if (self.key_map.cancel_while_filtering.matches(k)) { + self.resetFilter(allocator); + return null; + } + if (self.key_map.accept_while_filtering.matches(k)) { + self.status_message = ""; + if (self.items.len == 0) return null; + if (self.visibleItems() == 0) { + self.resetFilter(allocator); + return null; + } + self.filter_input.blur(); + self.filter_state = .filter_applied; + self.updateKeybindings(); + if (self.filter_input.value.items.len == 0) self.resetFilter(allocator); + return null; + } + }, + else => {}, + } + + // Forward to filter input. If value changed, refilter. + const before_len = self.filter_input.value.items.len; + const sub_cmd = self.filter_input.update(allocator, msg); + const changed = self.filter_input.value.items.len != before_len or + (before_len > 0 and self.filter_input.value.items.len > 0); + // Re-filter unconditionally — cheap for v1; refine later if needed. + _ = changed; + self.refilter(allocator) catch {}; + self.updateKeybindings(); + self.updatePagination(); + return sub_cmd; +} + +/// Replace the status message with `msg` (borrowed) for the configured +/// lifetime. Returns a Cmd that clears the message on expiry. +pub fn newStatusMessage(self: *List, allocator: Allocator, msg: []const u8) ?Cmd { + self.status_message = msg; + self.status_message_generation +%= 1; + const gen_ptr: *anyopaque = @ptrFromInt(@as(usize, self.status_message_generation)); + return Cmd.tick(allocator, self.status_message_lifetime_ns, .{ + .custom = .{ .type_id = statusTimeoutTypeId(), .payload = gen_ptr }, + }) catch null; +} + +// ---- view ---- + +pub fn view(self: *List, w: *Writer) Writer.Error!void { + var avail: u16 = self.height; + if (self.show_title or (self.show_filter and self.filtering_enabled)) { + try self.viewTitle(w); + try w.writeByte('\n'); + avail -|= 1; + } + if (self.show_status_bar) { + try self.viewStatus(w); + try w.writeByte('\n'); + avail -|= 1; + } + + var pag_buf: [256]u8 = undefined; + var pag_w: Writer = .fixed(&pag_buf); + if (self.show_pagination) { + try self.viewPagination(&pag_w); + avail -|= 1; + } + + var help_buf: [1024]u8 = undefined; + var help_w: Writer = .fixed(&help_buf); + if (self.show_help) { + try self.viewHelp(&help_w); + avail -|= 2; + } + + try self.viewItems(w, avail); + + if (self.show_pagination) { + try w.writeByte('\n'); + try w.writeAll(pag_w.buffered()); + } + if (self.show_help) { + try w.writeByte('\n'); + try w.writeAll(help_w.buffered()); + } +} + +fn viewTitle(self: *List, w: *Writer) Writer.Error!void { + if (self.show_filter and self.filter_state == .filtering) { + try self.filter_input.view(w); + return; + } + if (self.show_title) { + try util.writeInline(w, self.styles.title, " "); + try util.writeInline(w, self.styles.title, self.title_text); + try util.writeInline(w, self.styles.title, " "); + if (self.status_message.len > 0) { + try w.writeAll(" "); + try w.writeAll(self.status_message); + } + } + if (self.show_spinner) { + try w.writeAll(" "); + try self.spinner.view(w); + } +} + +fn viewStatus(self: *List, w: *Writer) Writer.Error!void { + var buf: [256]u8 = undefined; + var sw: Writer = .fixed(&buf); + const visible = self.visibleItems(); + const total = self.items.len; + const item_name = if (visible == 1) self.item_name_singular else self.item_name_plural; + + if (self.filter_state == .filtering) { + if (visible == 0) { + try util.writeInline(&sw, self.styles.status_empty, "Nothing matched"); + } else { + try sw.print("{d} {s}", .{ visible, item_name }); + } + } else if (total == 0) { + try util.writeInline(&sw, self.styles.status_empty, "No "); + try util.writeInline(&sw, self.styles.status_empty, self.item_name_plural); + } else { + if (self.filter_state == .filter_applied) { + var ti_buf: [64]u8 = undefined; + const ti_text = self.filter_input.encodeValue(&ti_buf) catch ti_buf[0..0]; + try sw.print("\u{201C}{s}\u{201D} ", .{ti_text}); + } + try sw.print("{d} {s}", .{ visible, item_name }); + } + + const num_filtered = total -| visible; + if (num_filtered > 0) { + try util.writeInline(&sw, self.styles.divider_dot, " • "); + try sw.print("{d} filtered", .{num_filtered}); + } + + try util.writeInline(w, self.styles.status_bar, sw.buffered()); +} + +fn viewPagination(self: *List, w: *Writer) Writer.Error!void { + if (self.paginator.total_pages < 2) return; + try util.writeInline(w, self.styles.pagination, " "); + try self.paginator.view(w); +} + +fn viewHelp(self: *List, w: *Writer) Writer.Error!void { + try w.writeByte('\n'); + try util.writeInline(w, self.styles.help, " "); + if (self.help.show_all) { + const cursor = [_]key.Binding{ + self.key_map.cursor_up, + self.key_map.cursor_down, + self.key_map.next_page, + self.key_map.prev_page, + self.key_map.goto_start, + self.key_map.goto_end, + }; + const flt = [_]key.Binding{ + self.key_map.filter, + self.key_map.clear_filter, + self.key_map.accept_while_filtering, + self.key_map.cancel_while_filtering, + }; + const meta = [_]key.Binding{ + self.key_map.quit, + self.key_map.close_full_help, + }; + const groups = [_][]const key.Binding{ &cursor, &flt, &meta }; + try self.help.fullView(w, &groups); + } else { + const bindings = [_]key.Binding{ + self.key_map.cursor_up, + self.key_map.cursor_down, + self.key_map.filter, + self.key_map.clear_filter, + self.key_map.accept_while_filtering, + self.key_map.cancel_while_filtering, + self.key_map.quit, + self.key_map.show_full_help, + }; + try self.help.shortView(w, &bindings); + } +} + +fn viewItems(self: *List, w: *Writer, avail: u16) Writer.Error!void { + const total = self.visibleItems(); + if (total == 0) { + if (self.filter_state == .filtering) return; + try util.writeInline(w, self.styles.no_items, "No "); + try util.writeInline(w, self.styles.no_items, self.item_name_plural); + try util.writeInline(w, self.styles.no_items, "."); + return; + } + const d = self.delegate orelse return; + const start = self.paginator.page * self.paginator.per_page; + const end = @min(start + self.paginator.per_page, total); + const items_on_page = self.paginator.itemsOnPage(@intCast(@min(total, std.math.maxInt(u16)))); + + var i: usize = start; + while (i < end) : (i += 1) { + const it = self.itemAt(i) orelse continue; + try d.render(w, self, i, it); + if (i + 1 < end) { + var s: u16 = 0; + while (s < d.spacing() + 1) : (s += 1) try w.writeByte('\n'); + } + } + if (items_on_page < self.paginator.per_page) { + const missing = self.paginator.per_page - items_on_page; + const rows = missing * (d.height() + d.spacing()); + var k: u16 = 0; + while (k < rows) : (k += 1) try w.writeByte('\n'); + } + _ = avail; +} + +// ---- DefaultDelegate ---- + +pub const DefaultItemStyles = struct { + normal_title: ?blush.Style = null, + normal_desc: ?blush.Style = null, + selected_title: ?blush.Style = null, + selected_desc: ?blush.Style = null, + dimmed_title: ?blush.Style = null, + dimmed_desc: ?blush.Style = null, + filter_match: ?blush.Style = null, +}; + +pub const default_item_styles: DefaultItemStyles = .{ + .normal_title = .{ .foreground = .{ .rgb = .{ .r = 0xDD, .g = 0xDD, .b = 0xDD } } }, + .normal_desc = .{ .foreground = .{ .rgb = .{ .r = 0x77, .g = 0x77, .b = 0x77 } } }, + .selected_title = .{ .foreground = .{ .rgb = .{ .r = 0xEE, .g = 0x6F, .b = 0xF8 } }, .bold = true }, + .selected_desc = .{ .foreground = .{ .rgb = .{ .r = 0xAD, .g = 0x58, .b = 0xB4 } } }, + .dimmed_title = .{ .foreground = .{ .rgb = .{ .r = 0x77, .g = 0x77, .b = 0x77 } } }, + .dimmed_desc = .{ .foreground = .{ .rgb = .{ .r = 0x4D, .g = 0x4D, .b = 0x4D } } }, + .filter_match = .{ .underline = true }, +}; + +pub const DefaultDelegate = struct { + show_description: bool = true, + height_value: u16 = 2, + spacing_value: u16 = 1, + /// Optional left-edge mark for the selected row (a colored bar). + selected_mark: []const u8 = "▎ ", + normal_padding_left: u16 = 2, + styles: DefaultItemStyles = default_item_styles, + + var vt: Delegate.VTable = .{ + .render = ddRender, + .height = ddHeight, + .spacing = ddSpacing, + }; + + pub fn delegate(self: *DefaultDelegate) Delegate { + return .{ .ctx = self, .vtable = &vt }; + } + + fn ddHeight(ctx: *anyopaque) u16 { + const self: *DefaultDelegate = @ptrCast(@alignCast(ctx)); + return if (self.show_description) self.height_value else 1; + } + + fn ddSpacing(ctx: *anyopaque) u16 { + const self: *DefaultDelegate = @ptrCast(@alignCast(ctx)); + return self.spacing_value; + } + + fn ddRender( + ctx: *anyopaque, + w: *Writer, + list: *const List, + idx: usize, + item: Item, + ) Writer.Error!void { + const self: *DefaultDelegate = @ptrCast(@alignCast(ctx)); + const title_text = item.title() orelse return; + const desc_text = if (self.show_description) (item.description() orelse "") else ""; + + const is_selected = idx == list.index(); + const is_filtering = list.filter_state == .filtering; + const empty_filter = is_filtering and list.filter_input.value.items.len == 0; + const is_filtered_view = list.filter_state == .filtering or + list.filter_state == .filter_applied; + + const title_style: ?blush.Style = if (empty_filter) + self.styles.dimmed_title + else if (is_selected and !is_filtering) + self.styles.selected_title + else + self.styles.normal_title; + const desc_style: ?blush.Style = if (empty_filter) + self.styles.dimmed_desc + else if (is_selected and !is_filtering) + self.styles.selected_desc + else + self.styles.normal_desc; + + const pad_l: u16 = if (is_selected and !is_filtering and !empty_filter and self.selected_mark.len > 0) + 0 + else + self.normal_padding_left; + + // Title line. + if (is_selected and !is_filtering and !empty_filter and self.selected_mark.len > 0) { + try util.writeInline(w, self.styles.selected_title, self.selected_mark); + } else { + var i: u16 = 0; + while (i < pad_l) : (i += 1) try w.writeByte(' '); + } + const match_info: ?FilteredItem = if (is_filtered_view) list.matchesForItem(idx) else null; + try writeMaybeMatched(w, title_text, title_style, self.styles.filter_match, match_info); + + if (self.show_description) { + try w.writeByte('\n'); + if (is_selected and !is_filtering and !empty_filter and self.selected_mark.len > 0) { + try util.writeInline(w, self.styles.selected_desc, self.selected_mark); + } else { + var i: u16 = 0; + while (i < pad_l) : (i += 1) try w.writeByte(' '); + } + try util.writeInline(w, desc_style, desc_text); + } + } +}; + +fn writeMaybeMatched( + w: *Writer, + text: []const u8, + base: ?blush.Style, + match_style: ?blush.Style, + info: ?FilteredItem, +) Writer.Error!void { + if (info == null or match_style == null or info.?.match_len == 0) { + try util.writeInline(w, base, text); + return; + } + const m = info.?; + var iter = (std.unicode.Utf8View.init(text) catch return util.writeInline(w, base, text)).iterator(); + var cp_idx: usize = 0; + var buf: [4]u8 = undefined; + while (iter.nextCodepoint()) |cp| { + const in_match = cp_idx >= m.match_start and cp_idx < m.match_start + m.match_len; + const len = std.unicode.utf8Encode(cp, &buf) catch 1; + const style = if (in_match) blk: { + // Merge base + match underline so we don't lose fg color. + var merged: blush.Style = base orelse .{}; + const ms = match_style.?; + if (ms.bold) merged.bold = true; + if (ms.italic) merged.italic = true; + if (ms.underline) merged.underline = true; + if (ms.faint) merged.faint = true; + break :blk merged; + } else (base orelse blush.Style{}); + try util.writeInline(w, style, buf[0..len]); + cp_idx += 1; + } +} + +// ---- tests ---- + +const testing = std.testing; + +const TestItem = struct { + title_text: []const u8, + desc_text: []const u8 = "", + + var vt: Item.VTable = .{ + .filter_value = filterValue, + .title = titleFn, + .description = descFn, + }; + + fn filterValue(ctx: *anyopaque) []const u8 { + const self: *const TestItem = @ptrCast(@alignCast(ctx)); + return self.title_text; + } + fn titleFn(ctx: *anyopaque) []const u8 { + const self: *const TestItem = @ptrCast(@alignCast(ctx)); + return self.title_text; + } + fn descFn(ctx: *anyopaque) []const u8 { + const self: *const TestItem = @ptrCast(@alignCast(ctx)); + return self.desc_text; + } + fn item(self: *TestItem) Item { + return .{ .ctx = self, .vtable = &vt }; + } +}; + +test "substring filter matches case-insensitive" { + var a: TestItem = .{ .title_text = "Apple" }; + var b: TestItem = .{ .title_text = "Banana" }; + var c: TestItem = .{ .title_text = "appliance" }; + var items = [_]Item{ a.item(), b.item(), c.item() }; + + var list: List = .{}; + defer list.deinit(testing.allocator); + list.setItems(&items); + list.filter_state = .filtering; + try list.filter_input.setValue(testing.allocator, "AP"); + try list.refilter(testing.allocator); + try testing.expectEqual(@as(usize, 2), list.filtered.items.len); + try testing.expectEqual(@as(usize, 0), list.filtered.items[0].index); + try testing.expectEqual(@as(usize, 2), list.filtered.items[1].index); +} + +test "cursorDown wraps to next page" { + var a: TestItem = .{ .title_text = "1" }; + var b: TestItem = .{ .title_text = "2" }; + var c: TestItem = .{ .title_text = "3" }; + var items = [_]Item{ a.item(), b.item(), c.item() }; + + var list: List = .{}; + defer list.deinit(testing.allocator); + list.setItems(&items); + list.paginator.per_page = 2; + _ = list.paginator.setTotalPages(3); + + list.cursorDown(); + try testing.expectEqual(@as(u16, 1), list.cursor); + try testing.expectEqual(@as(u16, 0), list.paginator.page); + list.cursorDown(); + try testing.expectEqual(@as(u16, 0), list.cursor); + try testing.expectEqual(@as(u16, 1), list.paginator.page); +} diff --git a/leaves/Progress.zig b/leaves/Progress.zig index b8ba2e9..77695b4 100644 --- a/leaves/Progress.zig +++ b/leaves/Progress.zig @@ -26,64 +26,141 @@ const Color = blush.Style.Color; const Progress = @This(); -pub const default_full_char_half_block: []const u8 = "▌"; -pub const default_full_char_full_block: []const u8 = "█"; -pub const default_empty_char: []const u8 = "░"; -pub const default_width: u16 = 40; +const half_block: []const u8 = "▌"; +const full_block: []const u8 = "█"; /// Lipgloss "blueberry" — matches bubbletea's default solid fill color. -pub const default_full_color: Color = .{ .rgb = .{ .r = 0x75, .g = 0x71, .b = 0xF9 } }; /// Lipgloss "slate gray" — matches bubbletea's default empty color. -pub const default_empty_color: Color = .{ .rgb = .{ .r = 0x60, .g = 0x60, .b = 0x60 } }; +model: matcha.Model = .{ + .vtable = &.{ + .init = modelInit, + .update = modelUpdate, + .view = modelView, + }, +}, -model: matcha.Model = .{ .vtable = &vtable }, - -width: u16 = default_width, -full_char: []const u8 = default_full_char_half_block, -empty_char: []const u8 = default_empty_char, +width: u16 = 40, +full_char: []const u8 = half_block, +empty_char: []const u8 = "░", /// 0 colors: no fg styling. 1: solid fill. 2+: gradient. -full_colors: []const Color = &.{default_full_color}, -empty_color: ?Color = default_empty_color, +full_colors: []const Color = &.{.{ .rgb = .{ .r = 0x75, .g = 0x71, .b = 0xF9 } }}, +empty_color: ?Color = .{ .rgb = .{ .r = 0x60, .g = 0x60, .b = 0x60 } }, /// When true and `full_colors.len >= 2`, the gradient is scaled to the /// filled width, so the full color range is visible at every percent. scale_blend: bool = false, show_percentage: bool = true, percent_style: ?blush.Style = null, -/// Currently displayed percentage in [0, 1]. +/// Currently displayed (animated) percentage in [0, 1]. `setPercent` +/// updates `target_percent` and runs a spring to walk this toward it. percent: f64 = 0, -const vtable: matcha.Model.VTable = .{ - .init = vtableInit, - .update = vtableUpdate, - .view = vtableView, -}; +/// Animation target. Equal to `percent` when not animating. +target_percent: f64 = 0, +/// Spring velocity. Tracked by `Update` while animating. +velocity: f64 = 0, +/// Spring coefficients, recomputed when `freq`/`damping`/`fps` change. +spring: Spring = .{}, +spring_freq: f64 = 18.0, +spring_damping: f64 = 1.0, +fps: u32 = 60, +/// True while a chain of tick Cmds is in flight. +chain_active: bool = false, -fn vtableInit(_: *matcha.Model) ?Cmd { +fn modelInit(_: *matcha.Model) ?Cmd { return null; } -fn vtableUpdate(_: *matcha.Model, _: Msg) ?Cmd { - return null; +fn modelUpdate(m: *matcha.Model, msg: Msg) ?Cmd { + const self: *Progress = @fieldParentPtr("model", m); + return self.update(m.gpa, msg); } -fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { +fn modelView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *Progress = @fieldParentPtr("model", m); try self.view(w); return .{}; } -pub fn setPercent(self: *Progress, p: f64) void { - self.percent = std.math.clamp(p, 0, 1); +/// Set the displayed percent immediately, no animation. Use for the +/// "pure" ViewAs pattern where the caller owns the percent. +pub fn setPercentStatic(self: *Progress, p: f64) void { + const clamped = std.math.clamp(p, 0, 1); + self.percent = clamped; + self.target_percent = clamped; + self.velocity = 0; } -pub fn incrPercent(self: *Progress, delta: f64) void { - self.setPercent(self.percent + delta); +/// Update the target percent and return a Cmd that drives spring +/// animation toward it. The returned Cmd must be dispatched; subsequent +/// `tick` msgs go through `update`. Returns null when no allocator-backed +/// tick can be scheduled. +pub fn setPercent(self: *Progress, allocator: Allocator, p: f64) ?Cmd { + self.target_percent = std.math.clamp(p, 0, 1); + self.refreshSpring(); + if (self.chain_active) return null; // existing chain absorbs the new target + self.chain_active = true; + return self.scheduleTick(allocator); +} + +pub fn incrPercent(self: *Progress, allocator: Allocator, delta: f64) ?Cmd { + return self.setPercent(allocator, self.target_percent + delta); +} + +pub fn setSpringOptions(self: *Progress, freq: f64, damping: f64) void { + self.spring_freq = freq; + self.spring_damping = damping; + self.refreshSpring(); } pub fn setWidth(self: *Progress, cells: u16) void { self.width = cells; } +pub fn isAnimating(self: *const Progress) bool { + const dist = @abs(self.percent - self.target_percent); + return !(dist < 0.001 and @abs(self.velocity) < 0.01); +} + +pub fn update(self: *Progress, allocator: Allocator, msg: Msg) ?Cmd { + switch (msg) { + .custom => |c| { + if (c.type_id != self.tickTypeId()) return null; + if (!self.chain_active) return null; + if (!self.isAnimating()) { + self.chain_active = false; + // Snap to target so we don't leave a sub-pixel residue. + self.percent = self.target_percent; + self.velocity = 0; + return null; + } + const out = self.spring.update(self.percent, self.velocity, self.target_percent); + self.percent = out.pos; + self.velocity = out.vel; + return self.scheduleTick(allocator); + }, + else => return null, + } +} + +fn tickTypeId(self: *const Progress) usize { + return @intFromPtr(self); +} + +fn scheduleTick(self: *Progress, allocator: Allocator) ?Cmd { + const dt_ns = std.time.ns_per_s / @max(self.fps, 1); + return Cmd.tick(allocator, dt_ns, .{ + .custom = .{ .type_id = self.tickTypeId() }, + }) catch { + self.chain_active = false; + return null; + }; +} + +fn refreshSpring(self: *Progress) void { + const dt: f64 = 1.0 / @as(f64, @floatFromInt(@max(self.fps, 1))); + self.spring = Spring.init(dt, self.spring_freq, self.spring_damping); +} + pub fn view(self: *const Progress, w: *Writer) Writer.Error!void { try self.viewAs(w, self.percent); } @@ -119,7 +196,7 @@ fn writeFill(self: *const Progress, w: *Writer, total: u16, filled: u16) Writer. } fn writeGradientFill(self: *const Progress, w: *Writer, total: u16, filled: u16) Writer.Error!void { - const is_half_block = std.mem.eql(u8, self.full_char, default_full_char_half_block); + const is_half_block = std.mem.eql(u8, self.full_char, half_block); const multiplier: usize = if (is_half_block) 2 else 1; const span: usize = if (self.scale_blend) filled else total; const steps: usize = span * multiplier; @@ -148,14 +225,100 @@ fn writeRepeatedSolid(w: *Writer, color: ?Color, glyph: []const u8, count: u16) if (color != null) try w.writeAll("\x1b[0m"); } -test "setPercent clamps" { +/// Damped harmonic oscillator. Ported from charmbracelet/harmonica. +pub const Spring = struct { + pos_pos: f64 = 1, + pos_vel: f64 = 0, + vel_pos: f64 = 0, + vel_vel: f64 = 1, + + pub fn init(delta_time: f64, angular_freq: f64, damping_ratio: f64) Spring { + const ang = @max(0.0, angular_freq); + const damp = @max(0.0, damping_ratio); + const epsilon: f64 = std.math.floatEps(f64); + + var s: Spring = .{}; + if (ang < epsilon) { + // No frequency → identity (no movement). + return s; + } + + if (damp > 1.0 + epsilon) { + // Over-damped. + const za = -ang * damp; + const zb = ang * @sqrt(damp * damp - 1.0); + const z1 = za - zb; + const z2 = za + zb; + const e1 = @exp(z1 * delta_time); + const e2 = @exp(z2 * delta_time); + const inv_two_zb = 1.0 / (2.0 * zb); + const e1_two = e1 * inv_two_zb; + const e2_two = e2 * inv_two_zb; + const z1e1 = z1 * e1_two; + const z2e2 = z2 * e2_two; + s.pos_pos = e1_two * z2 - z2e2 + e2; + s.pos_vel = -e1_two + e2_two; + s.vel_pos = (z1e1 - z2e2 + e2) * z2; + s.vel_vel = -z1e1 + z2e2; + } else if (damp < 1.0 - epsilon) { + // Under-damped. + const omega_z = ang * damp; + const alpha = ang * @sqrt(1.0 - damp * damp); + const exp_t = @exp(-omega_z * delta_time); + const cos_t = @cos(alpha * delta_time); + const sin_t = @sin(alpha * delta_time); + const inv_a = 1.0 / alpha; + const exp_sin = exp_t * sin_t; + const exp_cos = exp_t * cos_t; + const exp_omega_sin_over_a = exp_t * omega_z * sin_t * inv_a; + s.pos_pos = exp_cos + exp_omega_sin_over_a; + s.pos_vel = exp_sin * inv_a; + s.vel_pos = -exp_sin * alpha - omega_z * exp_omega_sin_over_a; + s.vel_vel = exp_cos - exp_omega_sin_over_a; + } else { + // Critically damped. + const exp_t = @exp(-ang * delta_time); + const time_exp = delta_time * exp_t; + const time_exp_freq = time_exp * ang; + s.pos_pos = time_exp_freq + exp_t; + s.pos_vel = time_exp; + s.vel_pos = -ang * time_exp_freq; + s.vel_vel = -time_exp_freq + exp_t; + } + return s; + } + + pub fn update(s: Spring, pos: f64, vel: f64, target: f64) struct { pos: f64, vel: f64 } { + const old_pos = pos - target; + const old_vel = vel; + return .{ + .pos = old_pos * s.pos_pos + old_vel * s.pos_vel + target, + .vel = old_pos * s.vel_pos + old_vel * s.vel_vel, + }; + } +}; + +test "setPercentStatic clamps" { var p: Progress = .{}; - p.setPercent(1.5); + p.setPercentStatic(1.5); try std.testing.expectEqual(@as(f64, 1), p.percent); - p.setPercent(-0.5); + p.setPercentStatic(-0.5); try std.testing.expectEqual(@as(f64, 0), p.percent); } +test "spring critically damped converges" { + const s: Spring = .init(1.0 / 60.0, 6.0, 1.0); + var pos: f64 = 0; + var vel: f64 = 0; + var i: usize = 0; + while (i < 600) : (i += 1) { + const out = s.update(pos, vel, 1.0); + pos = out.pos; + vel = out.vel; + } + try std.testing.expect(@abs(pos - 1.0) < 0.01); +} + test "view 50% no color no percent" { var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); @@ -163,7 +326,7 @@ test "view 50% no color no percent" { .width = 10, .show_percentage = false, .percent = 0.5, - .full_char = default_full_char_full_block, + .full_char = full_block, .full_colors = &.{}, .empty_color = null, }; @@ -177,7 +340,7 @@ test "view 100% with percent label" { const p: Progress = .{ .width = 10, .percent = 1, - .full_char = default_full_char_full_block, + .full_char = full_block, .full_colors = &.{}, .empty_color = null, }; @@ -192,7 +355,7 @@ test "view 0% empty bar" { .width = 10, .show_percentage = false, .percent = 0, - .full_char = default_full_char_full_block, + .full_char = full_block, .full_colors = &.{}, .empty_color = null, }; @@ -211,7 +374,7 @@ test "gradient endpoints scale_blend" { .width = 4, .show_percentage = false, .percent = 1, - .full_char = default_full_char_full_block, + .full_char = full_block, .full_colors = &stops, .empty_color = null, .scale_blend = true, diff --git a/leaves/Table.zig b/leaves/Table.zig index 2959065..111bcb8 100644 --- a/leaves/Table.zig +++ b/leaves/Table.zig @@ -16,11 +16,13 @@ const matcha = @import("matcha"); const blush = @import("blush"); const key = @import("key.zig"); const util = @import("util.zig"); +const Help = @import("Help.zig"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Cmd = matcha.Cmd; const Msg = matcha.Msg; +const ArrayList = std.ArrayList; const Table = @This(); @@ -97,6 +99,15 @@ cell_right_pad: u16 = 1, y_offset: usize = 0, +/// Embedded help; render via `helpView(w)`. Caller can override styles +/// before calling. +help: Help = .{}, + +/// Backing buffer + slice tables when rows are populated by `fromValues`. +fv_content: ArrayList(u8) = .empty, +fv_cells: ArrayList([]const u8) = .empty, +fv_rows: ArrayList(Row) = .empty, + const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, @@ -118,6 +129,12 @@ fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { return .{}; } +pub fn deinit(self: *Table, allocator: Allocator) void { + self.fv_content.deinit(allocator); + self.fv_cells.deinit(allocator); + self.fv_rows.deinit(allocator); +} + pub fn focus(self: *Table) void { self.focused = true; } @@ -126,6 +143,62 @@ pub fn blur(self: *Table) void { self.focused = false; } +/// Populates rows from a plain string. Lines are split on `\n`; each line +/// is split into cells by `separator`. The table owns the parsed copy +/// for the lifetime of the Table (until next `fromValues` or `deinit`). +pub fn fromValues( + self: *Table, + allocator: Allocator, + text: []const u8, + separator: []const u8, +) !void { + self.fv_content.clearRetainingCapacity(); + self.fv_cells.clearRetainingCapacity(); + self.fv_rows.clearRetainingCapacity(); + try self.fv_content.appendSlice(allocator, text); + + // Tracks (cell_start, cell_count) per row. Resolved into Row slices + // after fv_cells is fully populated (so the slice base is stable). + var row_bounds: ArrayList(struct { start: usize, count: usize }) = .empty; + defer row_bounds.deinit(allocator); + + var line_start: usize = 0; + var i: usize = 0; + while (i <= self.fv_content.items.len) : (i += 1) { + const at_end = i == self.fv_content.items.len; + const is_nl = !at_end and self.fv_content.items[i] == '\n'; + if (!is_nl and !at_end) continue; + + const line = self.fv_content.items[line_start..i]; + const cell_start = self.fv_cells.items.len; + + var pos: usize = 0; + while (pos <= line.len) { + const found = if (separator.len == 0) + @as(?usize, null) + else + std.mem.indexOfPos(u8, line, pos, separator); + const cell_end = found orelse line.len; + try self.fv_cells.append(allocator, line[pos..cell_end]); + if (found == null) break; + pos = cell_end + separator.len; + } + try row_bounds.append(allocator, .{ + .start = cell_start, + .count = self.fv_cells.items.len - cell_start, + }); + + line_start = i + 1; + if (at_end) break; + } + + // fv_cells is now stable; resolve row slices. + for (row_bounds.items) |b| { + try self.fv_rows.append(allocator, self.fv_cells.items[b.start .. b.start + b.count]); + } + self.setRows(self.fv_rows.items); +} + pub fn setRows(self: *Table, rows: []const Row) void { self.rows = rows; if (rows.len == 0) { @@ -187,6 +260,31 @@ pub fn gotoBottom(self: *Table) void { if (self.rows.len > 0) self.setCursor(self.rows.len - 1); } +/// Renders the embedded Help with the table's KeyMap as bindings. +/// Bubbles mirror: ShortHelp = {Up, Down}; FullHelp = two columns: +/// nav (Up, Down, GotoTop, GotoBottom) and paging. +pub fn helpView(self: *const Table, w: *Writer) Writer.Error!void { + if (self.help.show_all) { + const nav = [_]key.Binding{ + self.key_map.line_up, + self.key_map.line_down, + self.key_map.goto_top, + self.key_map.goto_bottom, + }; + const paging = [_]key.Binding{ + self.key_map.page_up, + self.key_map.page_down, + self.key_map.half_page_up, + self.key_map.half_page_down, + }; + const groups = [_][]const key.Binding{ &nav, &paging }; + try self.help.fullView(w, &groups); + } else { + const bindings = [_]key.Binding{ self.key_map.line_up, self.key_map.line_down }; + try self.help.shortView(w, &bindings); + } +} + pub fn update(self: *Table, msg: Msg) ?Cmd { if (!self.focused) return null; switch (msg) { @@ -407,6 +505,22 @@ test "selectedRow returns nil for empty rows" { try testing.expect(t.selectedRow() == null); } +test "fromValues parses lines/cells" { + var t: Table = .{ .height = 5, .focused = true }; + defer t.deinit(testing.allocator); + const cols = [_]Column{ + .{ .title = "A", .width = 1 }, + .{ .title = "B", .width = 1 }, + }; + t.setColumns(&cols); + try t.fromValues(testing.allocator, "1,2\n3,4", ","); + try testing.expectEqual(@as(usize, 2), t.rows.len); + try testing.expectEqualStrings("1", t.rows[0][0]); + try testing.expectEqualStrings("2", t.rows[0][1]); + try testing.expectEqualStrings("3", t.rows[1][0]); + try testing.expectEqualStrings("4", t.rows[1][1]); +} + test "selected row gets selected style; others get cell style" { var t: Table = .{ .height = 3, diff --git a/leaves/TextInput.zig b/leaves/TextInput.zig index 60ea05c..0258b5b 100644 --- a/leaves/TextInput.zig +++ b/leaves/TextInput.zig @@ -383,7 +383,10 @@ pub fn update(self: *TextInput, allocator: Allocator, msg: Msg) ?Cmd { else => {}, } - if (input_changed) self.runValidate(); + if (input_changed) { + self.runValidate(); + self.refreshSuggestions(allocator); + } self.handleOverflow(); @@ -429,11 +432,35 @@ pub fn view(self: *TextInput, w: *Writer) Writer.Error!void { // Text after the cursor. try self.writeTransformed(w, visible[local_pos + 1 ..]); } else if (self.use_virtual_cursor) { - self.cursor.char = " "; - self.cursor.style = self.styles.cursor; - self.cursor.text_style = self.styles.cursor_text orelse self.styles.text; - try self.cursor.view(w); - } // else: real cursor — leave the trailing cell blank. + // At end of value: ghost cursor either holds the first char of an + // unaccepted suggestion (if any) or a space. + if (self.currentSuggestionTail()) |tail| { + var t_iter = (std.unicode.Utf8View.init(tail) catch unreachable).iterator(); + if (t_iter.nextCodepoint()) |cp| { + const len = std.unicode.utf8Encode(cp, &cur_buf) catch 1; + self.cursor.char = cur_buf[0..len]; + self.cursor.style = self.styles.cursor; + self.cursor.text_style = self.styles.suggestion; + try self.cursor.view(w); + try util.writeInline(w, self.styles.suggestion, tail[len..]); + } else { + self.cursor.char = " "; + self.cursor.style = self.styles.cursor; + self.cursor.text_style = self.styles.cursor_text orelse self.styles.text; + try self.cursor.view(w); + } + } else { + self.cursor.char = " "; + self.cursor.style = self.styles.cursor; + self.cursor.text_style = self.styles.cursor_text orelse self.styles.text; + try self.cursor.view(w); + } + } else { + // Real cursor mode: still render the ghost text inline. + if (self.currentSuggestionTail()) |tail| { + try util.writeInline(w, self.styles.suggestion, tail); + } + } // Width-padding (so a styled background extends to the configured width). if (self.width > 0 and self.styles.text != null) { @@ -740,6 +767,39 @@ test "validate sets err flag" { try testing.expect(!ti.err); } +test "suggestion match + accept" { + var ti: TextInput = .{ .show_suggestions = true }; + defer ti.deinit(testing.allocator); + ti.cursor.mode = .static; + _ = ti.focus(testing.allocator); + const sugs = [_][]const u8{ "apple", "apricot", "banana" }; + ti.setSuggestions(testing.allocator, &sugs); + try ti.setValue(testing.allocator, "ap"); + ti.refreshSuggestions(testing.allocator); + try testing.expectEqual(@as(usize, 2), ti.matched_suggestions.items.len); + try testing.expectEqualStrings("ple", ti.currentSuggestionTail().?); + ti.acceptSuggestion(testing.allocator); + var buf: [16]u8 = undefined; + const out = try ti.encodeValue(&buf); + try testing.expectEqualStrings("apple", out); +} + +test "suggestion cycle next" { + var ti: TextInput = .{ .show_suggestions = true }; + defer ti.deinit(testing.allocator); + ti.cursor.mode = .static; + _ = ti.focus(testing.allocator); + const sugs = [_][]const u8{ "alpha", "alpine", "alabaster" }; + ti.setSuggestions(testing.allocator, &sugs); + try ti.setValue(testing.allocator, "al"); + ti.refreshSuggestions(testing.allocator); + try testing.expectEqual(@as(usize, 3), ti.matched_suggestions.items.len); + try testing.expectEqualStrings("pha", ti.currentSuggestionTail().?); + const down: Msg = .{ .key_press = .{ .kind = .down } }; + _ = ti.update(testing.allocator, down); + try testing.expectEqualStrings("pine", ti.currentSuggestionTail().?); +} + test "echo password masks output" { var ti: TextInput = .{ .echo_mode = .password, .echo_char = '*' }; defer ti.deinit(testing.allocator); diff --git a/leaves/Textarea.zig b/leaves/Textarea.zig index 322227d..e838e66 100644 --- a/leaves/Textarea.zig +++ b/leaves/Textarea.zig @@ -43,6 +43,10 @@ pub const KeyMap = struct { input_begin: key.Binding = .{ .keys = &.{ key.alt('<') } }, input_end: key.Binding = .{ .keys = &.{ key.alt('>') } }, paste: key.Binding = .{ .keys = &.{key.ctrl('v')} }, + transpose_char_backward: key.Binding = .{ .keys = &.{key.ctrl('t')} }, + uppercase_word_forward: key.Binding = .{ .keys = &.{key.alt('u')} }, + lowercase_word_forward: key.Binding = .{ .keys = &.{key.alt('l')} }, + capitalize_word_forward: key.Binding = .{ .keys = &.{key.alt('c')} }, }; pub const Styles = struct { @@ -75,6 +79,18 @@ max_lines: usize = 10_000, width: u16 = 40, height: u16 = 6, +/// When true, `height` auto-resizes between `min_height` and `max_height` +/// to fit the line count after each edit. +dynamic_height: bool = false, +min_height: u16 = 1, +max_height: u16 = 99, +/// When true, lines wider than the content width wrap onto continuation +/// rows instead of being truncated. `y_offset` counts visual rows. +/// Vertical nav (`line_previous`/`line_next`) moves by visual rows too. +soft_wrap: bool = false, +/// Mouse wheel scrolls the viewport by this many rows per notch. Set to 0 +/// to disable mouse-wheel scroll. +mouse_wheel_delta: u16 = 3, styles: Styles = default_styles, key_map: KeyMap = .{}, @@ -153,15 +169,22 @@ pub fn setVirtualCursor(self: *Textarea, use_virtual: bool) void { /// assigning to `view.cursor`. pub fn cursorView(self: *const Textarea) ?matcha.View.Cursor { if (self.use_virtual_cursor or !self.focused) return null; - if (self.row < self.y_offset) return null; - const visible_row: u16 = @intCast(self.row - self.y_offset); - if (visible_row >= self.height) return null; + const cur_vrow = self.cursorVisualRow(); + if (cur_vrow < self.y_offset) return null; + const local_vrow = cur_vrow - self.y_offset; + if (local_vrow >= self.height) return null; + const prompt_w = blush.measure.cellWidth(self.prompt); const ln_w = self.lineNumberWidth(); - const x: u16 = prompt_w +| ln_w +| @as(u16, @intCast(self.col)); + const vcol: usize = if (self.soft_wrap) blk: { + const cw = self.contentCellsLeft(); + break :blk if (cw == 0) self.col else self.col % cw; + } else self.col; + + const x: u16 = prompt_w +| ln_w +| @as(u16, @intCast(vcol)); return .{ .x = x, - .y = visible_row, + .y = @intCast(local_vrow), .shape = self.cursor_shape, .blink = self.real_cursor_blink, }; @@ -281,6 +304,14 @@ pub fn update(self: *Textarea, allocator: Allocator, msg: Msg) ?Cmd { self.last_col_offset = self.col; } else if (self.key_map.insert_newline.matches(k)) { self.insertNewline(allocator) catch {}; + } else if (self.key_map.transpose_char_backward.matches(k)) { + self.transposeCharBackward(); + } else if (self.key_map.uppercase_word_forward.matches(k)) { + self.transformWordForward(.upper); + } else if (self.key_map.lowercase_word_forward.matches(k)) { + self.transformWordForward(.lower); + } else if (self.key_map.capitalize_word_forward.matches(k)) { + self.transformWordForward(.capitalize); } else if (self.key_map.paste.matches(k)) { return matcha.clipboard.pasteCmd(); } else if (k.kind == .rune and !k.mods.ctrl and !k.mods.alt) { @@ -294,9 +325,18 @@ pub fn update(self: *Textarea, allocator: Allocator, msg: Msg) ?Cmd { .paste => |text| { self.insertText(allocator, text) catch {}; }, + .mouse_wheel => |m| { + if (self.mouse_wheel_delta == 0) return null; + switch (m.button) { + .wheel_up => self.scrollViewUp(self.mouse_wheel_delta), + .wheel_down => self.scrollViewDown(self.mouse_wheel_delta), + else => {}, + } + }, else => {}, } + self.recalculateHeight(); self.ensureRowVisible(); var cmd = self.cursor_blink.update(allocator, msg); @@ -320,7 +360,6 @@ pub fn view(self: *Textarea, w: *Writer) Writer.Error!void { // Lazily ensure at least one (possibly empty) line so cursor rendering // has something to anchor on. if (self.lines.items.len == 0) { - // Render a single blank line plus end-of-buffer fillers. try self.writeLine(w, 0, &.{}); var i: u16 = 1; while (i < self.height) : (i += 1) { @@ -330,6 +369,14 @@ pub fn view(self: *Textarea, w: *Writer) Writer.Error!void { return; } + if (self.soft_wrap) { + try self.viewSoftWrap(w); + } else { + try self.viewHardCut(w); + } +} + +fn viewHardCut(self: *Textarea, w: *Writer) Writer.Error!void { const start = self.y_offset; const end = @min(start + self.height, self.lines.items.len); @@ -346,6 +393,95 @@ pub fn view(self: *Textarea, w: *Writer) Writer.Error!void { } } +fn viewSoftWrap(self: *Textarea, w: *Writer) Writer.Error!void { + const content_w = self.contentCellsLeft(); + if (content_w == 0) return; + + var skipped: usize = 0; + var rendered: u16 = 0; + var line_idx: usize = 0; + while (line_idx < self.lines.items.len and rendered < self.height) : (line_idx += 1) { + const line = self.lines.items[line_idx].items; + const rows = visualRowsForLine(line.len, content_w); + var chunk: usize = 0; + while (chunk < rows and rendered < self.height) : (chunk += 1) { + if (skipped < self.y_offset) { + skipped += 1; + continue; + } + if (rendered > 0) try w.writeByte('\n'); + try self.writeChunk(w, line_idx, line, chunk, content_w); + rendered += 1; + } + } + while (rendered < self.height) : (rendered += 1) { + if (rendered > 0) try w.writeByte('\n'); + try self.writeEndOfBuffer(w); + } +} + +fn writeChunk( + self: *Textarea, + w: *Writer, + line_idx: usize, + line: []const u21, + chunk_idx: usize, + content_w: u16, +) Writer.Error!void { + try util.writeInline(w, self.styles.prompt, self.prompt); + if (self.show_line_numbers) { + if (chunk_idx == 0) { + try self.writeLineNumber(w, line_idx); + } else { + // Continuation row: blank pad matching line-number width. + const ln_w = self.lineNumberWidth(); + var i: u16 = 0; + while (i < ln_w) : (i += 1) try w.writeByte(' '); + } + } + + const start_cp: usize = chunk_idx * content_w; + const end_cp: usize = @min(start_cp + content_w, line.len); + const is_cursor_line = line_idx == self.row and self.focused; + const cursor_col = if (is_cursor_line) self.col else line.len; + + var emitted: u16 = 0; + var cur_buf: [4]u8 = undefined; + var i: usize = start_cp; + while (i < end_cp) : (i += 1) { + if (emitted >= content_w) break; + if (i == cursor_col and self.use_virtual_cursor) { + const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; + self.cursor_blink.char = cur_buf[0..len]; + self.cursor_blink.style = self.styles.cursor; + self.cursor_blink.text_style = self.styles.cursor_text orelse self.styles.text; + try self.cursor_blink.view(w); + emitted += 1; + continue; + } + const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; + if (self.styles.text) |s| try openInlineStyle(w, s); + try w.writeAll(cur_buf[0..len]); + if (self.styles.text) |s| try closeInlineStyle(w, s); + emitted += 1; + } + + // Cursor at end of line on the last chunk. + if (is_cursor_line and cursor_col == line.len and end_cp == line.len and + emitted < content_w and self.use_virtual_cursor) + { + self.cursor_blink.char = " "; + self.cursor_blink.style = self.styles.cursor; + self.cursor_blink.text_style = self.styles.cursor_text orelse self.styles.text; + try self.cursor_blink.view(w); + } +} + +fn visualRowsForLine(len: usize, content_w: u16) usize { + if (content_w == 0 or len == 0) return 1; + return (len + content_w - 1) / content_w; +} + // ---- editing ---- fn insertCodepoint(self: *Textarea, allocator: Allocator, cp: u21) !void { @@ -548,15 +684,56 @@ fn charForward(self: *Textarea) void { } fn linePrevious(self: *Textarea) void { + if (self.soft_wrap) { + const cw = self.contentCellsLeft(); + if (cw > 0 and self.col >= cw) { + // Move up one visual row within the current logical line. + self.col -= cw; + return; + } + } if (self.row == 0) return; self.row -= 1; - self.col = @min(self.last_col_offset, self.lines.items[self.row].items.len); + const prev = self.lines.items[self.row].items.len; + if (self.soft_wrap) { + const cw = self.contentCellsLeft(); + if (cw > 0) { + const last_vrow = visualRowsForLine(prev, cw) - 1; + const intent_vcol = self.last_col_offset % cw; + const new_col = last_vrow * cw + intent_vcol; + self.col = @min(new_col, prev); + return; + } + } + self.col = @min(self.last_col_offset, prev); } fn lineNext(self: *Textarea) void { - if (self.lines.items.len == 0 or self.row + 1 >= self.lines.items.len) return; + if (self.lines.items.len == 0) return; + if (self.soft_wrap) { + const cw = self.contentCellsLeft(); + const line_len = self.lines.items[self.row].items.len; + if (cw > 0) { + const this_vrow = self.col / cw; + const last_vrow = visualRowsForLine(line_len, cw) - 1; + if (this_vrow < last_vrow) { + self.col = @min(self.col + cw, line_len); + return; + } + } + } + if (self.row + 1 >= self.lines.items.len) return; self.row += 1; - self.col = @min(self.last_col_offset, self.lines.items[self.row].items.len); + const next_len = self.lines.items[self.row].items.len; + if (self.soft_wrap) { + const cw = self.contentCellsLeft(); + if (cw > 0) { + const intent_vcol = self.last_col_offset % cw; + self.col = @min(intent_vcol, next_len); + return; + } + } + self.col = @min(self.last_col_offset, next_len); } fn lineStartAction(self: *Textarea) void { @@ -617,20 +794,89 @@ fn isWordSep(cp: u21) bool { return cp == ' ' or cp == '\t'; } +const WordTransform = enum { upper, lower, capitalize }; + +fn transposeCharBackward(self: *Textarea) void { + if (self.lines.items.len == 0) return; + const line = &self.lines.items[self.row]; + if (line.items.len < 2) return; + if (self.col == 0) return; + // If cursor is at end, swap last two and don't advance. + const at_end = self.col >= line.items.len; + const i = if (at_end) line.items.len - 1 else self.col; + if (i == 0) return; + const tmp = line.items[i - 1]; + line.items[i - 1] = line.items[i]; + line.items[i] = tmp; + if (!at_end) self.col += 1; + self.last_col_offset = self.col; +} + +fn transformWordForward(self: *Textarea, tform: WordTransform) void { + if (self.lines.items.len == 0) return; + var line = self.lines.items[self.row].items; + // Skip separators at cursor. + var i = self.col; + while (i < line.len and isWordSep(line[i])) : (i += 1) {} + // Apply transform across the word. + var first_in_word = true; + while (i < line.len and !isWordSep(line[i])) : (i += 1) { + const cp = line[i]; + const replaced: u21 = switch (tform) { + .upper => upperAscii(cp), + .lower => lowerAscii(cp), + .capitalize => if (first_in_word) upperAscii(cp) else lowerAscii(cp), + }; + line[i] = replaced; + first_in_word = false; + } + self.col = i; + self.last_col_offset = self.col; +} + +fn upperAscii(cp: u21) u21 { + return if (cp >= 'a' and cp <= 'z') cp - 32 else cp; +} + +fn lowerAscii(cp: u21) u21 { + return if (cp >= 'A' and cp <= 'Z') cp + 32 else cp; +} + // ---- limits / scroll ---- fn atCharLimit(self: *const Textarea) bool { return self.char_limit > 0 and self.length() >= self.char_limit; } +fn scrollViewUp(self: *Textarea, n: u16) void { + self.y_offset = if (self.y_offset > n) self.y_offset - n else 0; +} + +fn scrollViewDown(self: *Textarea, n: u16) void { + const total = self.lines.items.len; + const max_off: usize = if (total > self.height) total - self.height else 0; + const new_off = self.y_offset +| n; + self.y_offset = @min(new_off, max_off); +} + +fn recalculateHeight(self: *Textarea) void { + if (!self.dynamic_height) return; + const lines: u16 = @intCast(@min(self.lines.items.len, std.math.maxInt(u16))); + const lo = @max(self.min_height, 1); + var h = @max(lines, lo); + if (self.max_height > 0 and h > self.max_height) h = self.max_height; + self.height = h; +} + fn ensureRowVisible(self: *Textarea) void { if (self.height == 0) return; - if (self.row < self.y_offset) { - self.y_offset = self.row; - } else if (self.row >= self.y_offset + self.height) { - self.y_offset = self.row + 1 - self.height; + const cur = self.cursorVisualRow(); + if (cur < self.y_offset) { + self.y_offset = cur; + } else if (cur >= self.y_offset + self.height) { + self.y_offset = cur + 1 - self.height; } - const total = self.lines.items.len; + const total = self.totalVisualRows(); if (total > self.height) { const max_offset = total - self.height; if (self.y_offset > max_offset) self.y_offset = max_offset; @@ -639,6 +885,27 @@ fn ensureRowVisible(self: *Textarea) void { } } +fn cursorVisualRow(self: *const Textarea) usize { + if (!self.soft_wrap) return self.row; + const cw = self.contentCellsLeft(); + if (cw == 0) return self.row; + var v: usize = 0; + var i: usize = 0; + while (i < self.row) : (i += 1) { + v += visualRowsForLine(self.lines.items[i].items.len, cw); + } + return v + self.col / cw; +} + +fn totalVisualRows(self: *const Textarea) usize { + if (!self.soft_wrap) return self.lines.items.len; + const cw = self.contentCellsLeft(); + if (cw == 0) return self.lines.items.len; + var total: usize = 0; + for (self.lines.items) |line| total += visualRowsForLine(line.items.len, cw); + return total; +} + // ---- rendering ---- fn writeLine(self: *Textarea, w: *Writer, idx: usize, line: []const u21) Writer.Error!void { @@ -816,6 +1083,41 @@ test "char_limit blocks insertion" { try testing.expectEqual(@as(usize, 3), ta.length()); } +test "soft-wrap wraps long line into visual rows" { + var ta = newForTest(); + defer ta.deinit(testing.allocator); + ta.soft_wrap = true; + ta.show_line_numbers = false; + ta.prompt = ""; + ta.styles = .{}; + ta.width = 4; + ta.height = 3; + _ = ta.focus(testing.allocator); + try ta.setValue(testing.allocator, "abcdefghi"); + // Move cursor off the cursor cell so it doesn't appear in output. + ta.blur(); + var buf: [256]u8 = undefined; + var w: Writer = .fixed(&buf); + try ta.view(&w); + try testing.expectEqualStrings("abcd\nefgh\ni", w.buffered()); +} + +test "soft-wrap linePrevious moves visual row" { + var ta = newForTest(); + defer ta.deinit(testing.allocator); + ta.soft_wrap = true; + ta.show_line_numbers = false; + ta.prompt = ""; + ta.width = 4; + ta.height = 3; + _ = ta.focus(testing.allocator); + try ta.setValue(testing.allocator, "abcdefghi"); + // Cursor at end → row 0, col 9. Visual row 2 (vcol 1). + ta.linePrevious(); + // Now visual row 1: col should become 9 - 4 = 5. + try testing.expectEqual(@as(usize, 5), ta.col); +} + test "view renders prompt, line numbers, lines" { var ta = newForTest(); defer ta.deinit(testing.allocator); diff --git a/leaves/Viewport.zig b/leaves/Viewport.zig index 5cc0bad..52af113 100644 --- a/leaves/Viewport.zig +++ b/leaves/Viewport.zig @@ -44,6 +44,38 @@ pub const KeyMap = struct { .keys = &.{ key.special(.down), key.rune('j') }, .help = .{ .key = "↓/j", .desc = "down" }, }, + left: key.Binding = .{ + .keys = &.{ key.special(.left), key.rune('h') }, + .help = .{ .key = "←/h", .desc = "left" }, + }, + right: key.Binding = .{ + .keys = &.{ key.special(.right), key.rune('l') }, + .help = .{ .key = "→/l", .desc = "right" }, + }, +}; + +pub const GutterContext = struct { + /// Line index in the content (0-based). + index: usize, + /// Total content lines. + total: usize, + /// True when this row is a soft-wrap continuation of the prior row. + /// Always false in v1 viewport (no soft-wrap yet). + soft: bool = false, +}; + +/// Callback that returns the prefix string rendered to the left of each +/// visible line. The string's cell width must be the same on every call; +/// the viewport reserves that width up-front. +pub const GutterFn = *const fn (ctx: GutterContext) []const u8; + +/// Callback that returns an optional style applied to a given content line. +pub const LineStyleFn = *const fn (index: usize) ?blush.Style; + +pub const Highlight = struct { + line: usize, + col_start: u16, + col_end: u16, }; model: matcha.Model = .{ .vtable = &vtable }, @@ -54,6 +86,14 @@ key_map: KeyMap = .{}, /// When true, the rendered output always pads to `height` rows even if the /// content is shorter. fill_height: bool = false, +/// When true, lines wider than the content width wrap onto continuation +/// rows instead of horizontally scrolling. Disables `setXOffset` and +/// makes `y_offset` count visual rows. +soft_wrap: bool = false, +/// Optional gutter rendered to the left of every line. +gutter_fn: ?GutterFn = null, +/// Optional per-line style applied to the line content (after the gutter). +line_style_fn: ?LineStyleFn = null, /// Backing buffer for owned content. `lines` slices into this. content: ArrayList(u8) = .empty, @@ -61,6 +101,22 @@ content: ArrayList(u8) = .empty, /// or external memory (if loaded via `setContentLinesBorrow`). lines: ArrayList([]const u8) = .empty, y_offset: u16 = 0, +/// Horizontal scroll offset in cells. +x_offset: u16 = 0, +/// Columns scrolled per ScrollLeft/Right call. +horizontal_step: u16 = 6, +/// Cached width of the widest line; recomputed on setContent. +longest_line_width: u16 = 0, +/// Whether mouse-wheel events trigger scrolling. +mouse_wheel_enabled: bool = true, +/// Lines scrolled per wheel notch. +mouse_wheel_delta: u16 = 3, +/// Borrowed highlight ranges. Single-line spans only in v1; multi-line +/// matches need to be split into per-line entries by the caller. +highlights: []const Highlight = &.{}, +current_highlight: usize = 0, +highlight_style: ?blush.Style = null, +selected_highlight_style: ?blush.Style = null, const vtable: matcha.Model.VTable = .{ .init = vtableInit, @@ -120,6 +176,7 @@ pub fn setContent(self: *Viewport, allocator: Allocator, text: []const u8) !void // text ended with '\n' — final empty line is intentional? bubbles // treats a single empty line as no content, so do the same. } + self.refreshLongest(); self.clampOffset(); } @@ -129,13 +186,42 @@ pub fn setContentLinesBorrow(self: *Viewport, allocator: Allocator, src_lines: [ self.content.clearRetainingCapacity(); self.lines.clearRetainingCapacity(); try self.lines.appendSlice(allocator, src_lines); + self.refreshLongest(); self.clampOffset(); } +fn refreshLongest(self: *Viewport) void { + var max_w: u16 = 0; + for (self.lines.items) |line| { + const w = blush.measure.cellWidth(line); + if (w > max_w) max_w = w; + } + self.longest_line_width = max_w; +} + pub fn lineCount(self: *const Viewport) u16 { return @intCast(@min(self.lines.items.len, std.math.maxInt(u16))); } +/// Total visible rows the content occupies (= lineCount when soft_wrap is +/// off; sums per-line wrap counts otherwise). +pub fn totalVisualRows(self: *const Viewport) usize { + if (!self.soft_wrap) return self.lines.items.len; + const content_w = self.contentWidth(); + if (content_w == 0) return self.lines.items.len; + var total: usize = 0; + for (self.lines.items) |line| { + const w = blush.measure.cellWidth(line); + total += visualRowsForWidth(w, content_w); + } + return total; +} + +fn visualRowsForWidth(line_width: u16, max_w: u16) usize { + if (max_w == 0 or line_width == 0) return 1; + return (line_width + max_w - 1) / max_w; +} + pub fn yOffset(self: *const Viewport) u16 { return self.y_offset; } @@ -145,9 +231,10 @@ pub fn setYOffset(self: *Viewport, offset: u16) void { } pub fn maxYOffset(self: *const Viewport) u16 { - const total = self.lineCount(); - if (total <= self.height) return 0; - return total - self.height; + const total = self.totalVisualRows(); + const total_u16: u16 = @intCast(@min(total, std.math.maxInt(u16))); + if (total_u16 <= self.height) return 0; + return total_u16 - self.height; } pub fn atTop(self: *const Viewport) bool { @@ -202,6 +289,86 @@ pub fn gotoBottom(self: *Viewport) void { self.y_offset = self.maxYOffset(); } +pub fn setHighlights(self: *Viewport, list: []const Highlight) void { + self.highlights = list; + self.current_highlight = 0; + self.ensureHighlightVisible(); +} + +pub fn clearHighlights(self: *Viewport) void { + self.highlights = &.{}; + self.current_highlight = 0; +} + +pub fn highlightNext(self: *Viewport) void { + if (self.highlights.len == 0) return; + self.current_highlight = (self.current_highlight + 1) % self.highlights.len; + self.ensureHighlightVisible(); +} + +pub fn highlightPrevious(self: *Viewport) void { + if (self.highlights.len == 0) return; + self.current_highlight = (self.current_highlight + self.highlights.len - 1) % + self.highlights.len; + self.ensureHighlightVisible(); +} + +fn ensureHighlightVisible(self: *Viewport) void { + if (self.current_highlight >= self.highlights.len) return; + const h = self.highlights[self.current_highlight]; + const line: u16 = @intCast(@min(h.line, std.math.maxInt(u16))); + if (line < self.y_offset or line >= self.y_offset +| self.height) { + self.setYOffset(line); + } + if (!self.soft_wrap) { + // Bring the column into view. + const content_w = self.contentWidth(); + if (h.col_end > self.x_offset +| content_w) { + const target: u16 = if (h.col_start > self.horizontal_step) + h.col_start - self.horizontal_step + else + 0; + self.setXOffset(target); + } else if (h.col_start < self.x_offset) { + self.setXOffset(h.col_start); + } + } +} + +pub fn xOffset(self: *const Viewport) u16 { + return self.x_offset; +} + +pub fn setXOffset(self: *Viewport, x: u16) void { + if (self.soft_wrap) return; + self.x_offset = @min(x, self.maxXOffset()); +} + +pub fn maxXOffset(self: *const Viewport) u16 { + const content_w = self.contentWidth(); + if (self.longest_line_width <= content_w) return 0; + return self.longest_line_width - content_w; +} + +pub fn scrollLeft(self: *Viewport, n: u16) void { + self.x_offset = if (self.x_offset > n) self.x_offset - n else 0; +} + +pub fn scrollRight(self: *Viewport, n: u16) void { + self.setXOffset(self.x_offset +| n); +} + +pub fn horizontalScrollPercent(self: *const Viewport) f32 { + const max_x = self.maxXOffset(); + if (max_x == 0) return 1.0; + return @as(f32, @floatFromInt(self.x_offset)) / @as(f32, @floatFromInt(max_x)); +} + +fn contentWidth(self: *const Viewport) u16 { + const gutter_w = self.gutterWidth(); + return if (self.width > gutter_w) self.width - gutter_w else 0; +} + pub fn update(self: *Viewport, msg: Msg) ?Cmd { switch (msg) { .key_press => |k| { @@ -211,6 +378,24 @@ pub fn update(self: *Viewport, msg: Msg) ?Cmd { if (self.key_map.half_page_up.matches(k)) self.halfPageUp(); if (self.key_map.down.matches(k)) self.scrollDown(1); if (self.key_map.up.matches(k)) self.scrollUp(1); + if (self.key_map.left.matches(k)) self.scrollLeft(self.horizontal_step); + if (self.key_map.right.matches(k)) self.scrollRight(self.horizontal_step); + }, + .mouse_wheel => |m| { + if (!self.mouse_wheel_enabled) return null; + switch (m.button) { + .wheel_up => if (m.mods.shift) + self.scrollLeft(self.horizontal_step) + else + self.scrollUp(self.mouse_wheel_delta), + .wheel_down => if (m.mods.shift) + self.scrollRight(self.horizontal_step) + else + self.scrollDown(self.mouse_wheel_delta), + .wheel_left => self.scrollLeft(self.horizontal_step), + .wheel_right => self.scrollRight(self.horizontal_step), + else => {}, + } }, else => {}, } @@ -219,38 +404,151 @@ pub fn update(self: *Viewport, msg: Msg) ?Cmd { pub fn view(self: *const Viewport, w: *Writer) Writer.Error!void { if (self.height == 0 or self.width == 0) return; + if (self.soft_wrap) try self.viewSoftWrap(w) else try self.viewHardCut(w); +} +fn viewHardCut(self: *const Viewport, w: *Writer) Writer.Error!void { const total = self.lineCount(); const start: u16 = self.y_offset; const end: u16 = @min(start +| self.height, total); + const content_w = self.contentWidth(); var rendered: u16 = 0; var i = start; while (i < end) : (i += 1) { if (rendered > 0) try w.writeByte('\n'); - try writeTruncated(w, self.lines.items[i], self.width); + if (self.gutter_fn) |gfn| try w.writeAll(gfn(.{ + .index = i, + .total = self.lines.items.len, + })); + if (self.line_style_fn) |sfn| if (sfn(i)) |s| try openInlineStyle(w, s); + var ranges_buf: [16]StyledRange = undefined; + const ranges = self.gatherRanges(&ranges_buf, i); + try writeCut(w, self.lines.items[i], self.x_offset, self.x_offset +| content_w, ranges); + if (self.line_style_fn) |sfn| if (sfn(i)) |s| try closeInlineStyle(w, s); rendered += 1; } + if (!self.fill_height) return; + while (rendered < self.height) : (rendered += 1) { + if (rendered > 0) try w.writeByte('\n'); + if (self.gutter_fn) |gfn| try w.writeAll(gfn(.{ + .index = self.lines.items.len, + .total = self.lines.items.len, + })); + } +} + +fn viewSoftWrap(self: *const Viewport, w: *Writer) Writer.Error!void { + const content_w = self.contentWidth(); + if (content_w == 0) return; + const total = self.lines.items.len; + + // Walk logical lines, expanding each into chunks of `content_w` cells. + // Skip until we've scrolled past `y_offset` visual rows; then emit up + // to `height` rows. + var visual_skipped: usize = 0; + var rendered: u16 = 0; + var line_idx: usize = 0; + while (line_idx < total and rendered < self.height) : (line_idx += 1) { + const line = self.lines.items[line_idx]; + const line_w = blush.measure.cellWidth(line); + const rows = visualRowsForWidth(line_w, content_w); + + var chunk: usize = 0; + while (chunk < rows and rendered < self.height) : (chunk += 1) { + if (visual_skipped < self.y_offset) { + visual_skipped += 1; + continue; + } + if (rendered > 0) try w.writeByte('\n'); + if (self.gutter_fn) |gfn| try w.writeAll(gfn(.{ + .index = line_idx, + .total = total, + .soft = chunk > 0, + })); + if (self.line_style_fn) |sfn| if (sfn(line_idx)) |s| try openInlineStyle(w, s); + const col_start: u16 = @intCast(chunk * @as(usize, content_w)); + var ranges_buf: [16]StyledRange = undefined; + const ranges = self.gatherRanges(&ranges_buf, line_idx); + try writeCut(w, line, col_start, col_start +| content_w, ranges); + if (self.line_style_fn) |sfn| if (sfn(line_idx)) |s| try closeInlineStyle(w, s); + rendered += 1; + } + } if (!self.fill_height) return; while (rendered < self.height) : (rendered += 1) { if (rendered > 0) try w.writeByte('\n'); + if (self.gutter_fn) |gfn| try w.writeAll(gfn(.{ + .index = total, + .total = total, + })); + } +} + +fn gutterWidth(self: *const Viewport) u16 { + const gfn = self.gutter_fn orelse return 0; + return blush.measure.cellWidth(gfn(.{ .index = 0, .total = 0 })); +} + +fn gatherRanges(self: *const Viewport, buf: []StyledRange, line_idx: usize) []const StyledRange { + if (self.highlights.len == 0) return &.{}; + var n: usize = 0; + for (self.highlights, 0..) |h, idx| { + if (h.line != line_idx) continue; + if (n >= buf.len) break; + const style = if (idx == self.current_highlight) + self.selected_highlight_style orelse self.highlight_style + else + self.highlight_style; + if (style == null) continue; + buf[n] = .{ .col_start = h.col_start, .col_end = h.col_end, .style = style.? }; + n += 1; } + return buf[0..n]; +} + +fn openInlineStyle(w: *Writer, s: blush.Style) Writer.Error!void { + if (s.bold) try w.writeAll("\x1b[1m"); + if (s.faint) try w.writeAll("\x1b[2m"); + if (s.italic) try w.writeAll("\x1b[3m"); + if (s.underline) try w.writeAll("\x1b[4m"); + if (s.foreground) |c| try c.writeSgr(w, .fg); + if (s.background) |c| try c.writeSgr(w, .bg); +} + +fn closeInlineStyle(w: *Writer, s: blush.Style) Writer.Error!void { + if (s.bold or s.italic or s.underline or s.faint or + s.foreground != null or s.background != null) try w.writeAll("\x1b[0m"); } fn clampOffset(self: *Viewport) void { self.y_offset = @min(self.y_offset, self.maxYOffset()); } -/// Writes `line` to `w`, truncating after `max_cells` visible cells while -/// preserving CSI escape sequences (so SGR styling survives the cut). -/// Emits a trailing `\x1b[0m` if we passed through any escape sequence, -/// to keep style from bleeding into the next line. -fn writeTruncated(w: *Writer, line: []const u8, max_cells: u16) Writer.Error!void { - if (max_cells == 0) return; +const StyledRange = struct { + col_start: u16, + col_end: u16, + style: blush.Style, +}; + +/// Emits cells [col_start, col_end) of `line`, ANSI-aware. CSI escapes +/// always pass through (so SGR opened before col_start still affects the +/// visible portion). When `ranges` is non-empty, cells inside any range +/// are wrapped in that range's style. Trailing `\x1b[0m` if any escape +/// or range opened. +fn writeCut( + w: *Writer, + line: []const u8, + col_start: u16, + col_end: u16, + ranges: []const StyledRange, +) Writer.Error!void { + if (col_end <= col_start) return; var visible: u16 = 0; var i: usize = 0; var saw_escape = false; + var current_range: ?usize = null; while (i < line.len) { if (line[i] == 0x1b) { saw_escape = true; @@ -264,22 +562,32 @@ fn writeTruncated(w: *Writer, line: []const u8, max_cells: u16) Writer.Error!voi try w.writeAll(line[start..i]); continue; } - if (visible >= max_cells) break; - if (line[i] < 0x80) { - try w.writeByte(line[i]); - visible += 1; - i += 1; - } else { - const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; - const safe_len = @min(len, line.len - i); + const cell_len: usize = if (line[i] < 0x80) 1 else std.unicode.utf8ByteSequenceLength(line[i]) catch 1; + const safe_len = @min(cell_len, line.len - i); + if (visible >= col_end) break; + if (visible >= col_start) { + const r = findRange(ranges, visible); + if (r != current_range) { + if (current_range != null) try w.writeAll("\x1b[0m"); + if (r) |idx| try openInlineStyle(w, ranges[idx].style); + current_range = r; + } try w.writeAll(line[i .. i + safe_len]); - visible += 1; - i += safe_len; } + visible += 1; + i += safe_len; } + if (current_range != null) try w.writeAll("\x1b[0m"); if (saw_escape) try w.writeAll("\x1b[0m"); } +fn findRange(ranges: []const StyledRange, col: u16) ?usize { + for (ranges, 0..) |r, idx| { + if (col >= r.col_start and col < r.col_end) return idx; + } + return null; +} + // ---- tests ---- const testing = std.testing; @@ -326,6 +634,25 @@ test "view renders visible window" { try testing.expectEqualStrings("bb\nccc", w.buffered()); } +test "horizontal scroll shifts visible columns" { + var v: Viewport = .{ .width = 3, .height = 1 }; + defer v.deinit(testing.allocator); + try v.setContent(testing.allocator, "abcdefgh"); + v.setXOffset(2); + var buf: [64]u8 = undefined; + var w: Writer = .fixed(&buf); + try v.view(&w); + try testing.expectEqualStrings("cde", w.buffered()); +} + +test "scrollRight clamps to maxXOffset" { + var v: Viewport = .{ .width = 4, .height = 1 }; + defer v.deinit(testing.allocator); + try v.setContent(testing.allocator, "1234567890"); + v.scrollRight(100); + try testing.expectEqual(v.maxXOffset(), v.x_offset); +} + test "view truncates wide lines to width" { var v: Viewport = .{ .width = 3, .height = 1 }; defer v.deinit(testing.allocator); @@ -356,6 +683,74 @@ test "fill_height pads short content" { try testing.expectEqualStrings("x\n\n\n", w.buffered()); } +test "gutter_fn prepends per line and shrinks content width" { + const G = struct { + fn gut(_: GutterContext) []const u8 { + return "| "; + } + }; + var v: Viewport = .{ .width = 6, .height = 2, .gutter_fn = G.gut }; + defer v.deinit(testing.allocator); + try v.setContent(testing.allocator, "abcdef\nghi"); + var buf: [128]u8 = undefined; + var w: Writer = .fixed(&buf); + try v.view(&w); + // content_w = 6 - 2 = 4 → "abcd" / "ghi" with truncation/padding. + try testing.expectEqualStrings("| abcd\n| ghi", w.buffered()); +} + +test "line_style_fn wraps styled lines" { + const S = struct { + fn st(_: usize) ?blush.Style { + return .{ .bold = true }; + } + }; + var v: Viewport = .{ .width = 3, .height = 1, .line_style_fn = S.st }; + defer v.deinit(testing.allocator); + try v.setContent(testing.allocator, "abc"); + var buf: [128]u8 = undefined; + var w: Writer = .fixed(&buf); + try v.view(&w); + try testing.expectEqualStrings("\x1b[1mabc\x1b[0m", w.buffered()); +} + +test "highlights wrap matched cells" { + var v: Viewport = .{ + .width = 10, + .height = 1, + .highlight_style = .{ .bold = true }, + }; + defer v.deinit(testing.allocator); + try v.setContent(testing.allocator, "abcdefghij"); + const hi = [_]Highlight{.{ .line = 0, .col_start = 2, .col_end = 5 }}; + v.setHighlights(&hi); + var buf: [128]u8 = undefined; + var w: Writer = .fixed(&buf); + try v.view(&w); + try testing.expectEqualStrings("ab\x1b[1mcde\x1b[0mfghij", w.buffered()); +} + +test "soft-wrap expands long line into rows" { + var v: Viewport = .{ .width = 3, .height = 3, .soft_wrap = true }; + defer v.deinit(testing.allocator); + try v.setContent(testing.allocator, "abcdefgh"); + var buf: [64]u8 = undefined; + var w: Writer = .fixed(&buf); + try v.view(&w); + try testing.expectEqualStrings("abc\ndef\ngh", w.buffered()); +} + +test "soft-wrap y_offset counts visual rows" { + var v: Viewport = .{ .width = 3, .height = 2, .soft_wrap = true }; + defer v.deinit(testing.allocator); + try v.setContent(testing.allocator, "abcdefgh"); + v.setYOffset(1); // skip "abc" + var buf: [64]u8 = undefined; + var w: Writer = .fixed(&buf); + try v.view(&w); + try testing.expectEqualStrings("def\ngh", w.buffered()); +} + test "scrollPercent" { var v: Viewport = .{ .height = 2 }; defer v.deinit(testing.allocator); diff --git a/leaves/root.zig b/leaves/root.zig index d3fc9ef..2a98d01 100644 --- a/leaves/root.zig +++ b/leaves/root.zig @@ -13,6 +13,8 @@ pub const TextInput = @import("TextInput.zig"); pub const Viewport = @import("Viewport.zig"); pub const Table = @import("Table.zig"); pub const Textarea = @import("Textarea.zig"); +pub const List = @import("List.zig"); +pub const FilePicker = @import("FilePicker.zig"); test { std.testing.refAllDecls(@This()); diff --git a/matcha/Cmd.zig b/matcha/Cmd.zig index 8cd98fc..038eeca 100644 --- a/matcha/Cmd.zig +++ b/matcha/Cmd.zig @@ -5,15 +5,15 @@ const Allocator = std.mem.Allocator; const Cmd = @This(); -run_fn: *const fn (ctx: ?*anyopaque, allocator: Allocator, io: Io) ?Msg, ctx: ?*anyopaque = null, +run_fn: *const fn (allocator: Allocator, io: Io, ctx: ?*anyopaque) ?Msg, free_ctx: ?*const fn (allocator: Allocator, ctx: *anyopaque) void = null, pub fn run(self: Cmd, allocator: Allocator, io: Io) ?Msg { - const result = self.run_fn(self.ctx, allocator, io); - if (self.free_ctx) |free| { - if (self.ctx) |c| free(allocator, c); - } + const result = self.run_fn(allocator, io, self.ctx); + if (self.free_ctx) |free| + if (self.ctx) |c| + free(allocator, c); return result; } @@ -28,7 +28,7 @@ pub fn pure(comptime msg: Msg) Cmd { pub const quit: Cmd = .{ .run_fn = quitFn }; -fn quitFn(_: ?*anyopaque, _: Allocator, _: Io) ?Msg { +fn quitFn(_: Allocator, _: Io, _: ?*anyopaque) ?Msg { return .quit; } @@ -53,7 +53,7 @@ pub fn tick(allocator: Allocator, duration_ns: u64, msg: Msg) Allocator.Error!Cm }; } -fn tickRun(ctx: ?*anyopaque, _: Allocator, io: Io) ?Msg { +fn tickRun(_: Allocator, io: Io, ctx: ?*anyopaque) ?Msg { const tc: *TickCtx = @ptrCast(@alignCast(ctx.?)); Io.sleep(io, .fromNanoseconds(@intCast(tc.duration_ns)), .awake) catch return null; return tc.msg; @@ -63,3 +63,33 @@ fn tickFree(allocator: Allocator, ctx: *anyopaque) void { const tc: *TickCtx = @ptrCast(@alignCast(ctx)); allocator.destroy(tc); } + +const BatchCtx = struct { + cmds: []Cmd, +}; + +/// Returns a Cmd that, when run, dispatches every Cmd in `cmds` through +/// the program's cmd queue. The slice is duped with `allocator`; the +/// Program loop frees it after it pushes the children. +pub fn batch(allocator: Allocator, cmds: []const Cmd) Allocator.Error!Cmd { + const dup = try allocator.dupe(Cmd, cmds); + const ctx = try allocator.create(BatchCtx); + ctx.* = .{ .cmds = dup }; + return .{ + .run_fn = batchRun, + .ctx = ctx, + .free_ctx = batchFreeCtx, + }; +} + +fn batchRun(_: Allocator, _: Io, ctx: ?*anyopaque) ?Msg { + const bc: *BatchCtx = @ptrCast(@alignCast(ctx.?)); + return .{ .batch = bc.cmds }; +} + +fn batchFreeCtx(allocator: Allocator, ctx: *anyopaque) void { + // Only free the struct; the cmds slice is owned by the produced Msg + // and is freed by `Program.run` after the loop pushes each child Cmd. + const bc: *BatchCtx = @ptrCast(@alignCast(ctx)); + allocator.destroy(bc); +} diff --git a/matcha/InputBuffer.zig b/matcha/InputBuffer.zig new file mode 100644 index 0000000..0fd6e9c --- /dev/null +++ b/matcha/InputBuffer.zig @@ -0,0 +1,187 @@ +//! Cross-platform input byte buffer + decoder state. Backends (posix Tty, +//! windows Tty) `append` raw VT bytes and call `next` to pop one Msg at a +//! time. Bracketed-paste accumulation lives here so both backends share it. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArrayList = std.ArrayList; + +const input = @import("input.zig"); +const Msg = @import("msg.zig").Msg; + +const InputBuffer = @This(); + +/// Capacity of the inline pending buffer. Larger than any single escape +/// sequence we expect; overflow simply drops the oldest bytes. +pub const PENDING_CAP: usize = 512; + +gpa: Allocator, +pending: [PENDING_CAP]u8 = undefined, +pending_len: usize = 0, + +/// Bracketed-paste accumulation. While `in_paste`, incoming bytes are +/// appended to `paste_buf` instead of being parsed. On `\x1b[201~`, the +/// buffer is dup'd and emitted as a `paste` Msg. +in_paste: bool = false, +paste_buf: ArrayList(u8) = .empty, + +/// One-slot queue for emitting `paste` + `paste_end` after `paste_start`. +/// `next` returns at most one Msg, so the trailing event waits here until +/// the next call. +queued_msg: ?Msg = null, + +pub fn init(gpa: Allocator) InputBuffer { + return .{ .gpa = gpa }; +} + +pub fn deinit(self: *InputBuffer) void { + self.paste_buf.deinit(self.gpa); +} + +/// Append raw bytes to `pending`. On overflow the oldest bytes are dropped. +pub fn append(self: *InputBuffer, bytes: []const u8) void { + if (bytes.len == 0) return; + if (self.pending_len + bytes.len > self.pending.len) { + const drop = self.pending_len + bytes.len - self.pending.len; + if (drop >= self.pending_len) { + self.pending_len = 0; + } else { + std.mem.copyForwards( + u8, + self.pending[0 .. self.pending_len - drop], + self.pending[drop..self.pending_len], + ); + self.pending_len -= drop; + } + } + const n = @min(bytes.len, self.pending.len); + @memcpy(self.pending[self.pending_len..][0..n], bytes[bytes.len - n ..]); + self.pending_len += n; +} + +/// Try to decode one Msg from buffered bytes. Returns null when nothing is +/// ready (either the buffer is empty or contains only an incomplete prefix). +pub fn next(self: *InputBuffer) !?Msg { + if (self.queued_msg) |m| { + self.queued_msg = null; + return m; + } + + if (self.in_paste) return try self.consumePaste(); + + var off: usize = 0; + while (off < self.pending_len) { + const r = input.parse(self.pending[off..self.pending_len]) catch |err| + switch (err) { + error.Incomplete => break, + error.Invalid => { + off += 1; + continue; + }, + }; + off += r.consumed; + self.consume(off); + if (r.msg == .paste_start) { + self.in_paste = true; + self.paste_buf.clearRetainingCapacity(); + } + return r.msg; + } + if (off > 0) self.consume(off); + return null; +} + +/// While `in_paste`, scan pending for the end marker. Append bytes up to it +/// into `paste_buf`; when found, dup the buffer as the `paste` payload and +/// queue `paste_end` for the next call. +fn consumePaste(self: *InputBuffer) !?Msg { + const end_marker = "\x1b[201~"; + if (std.mem.indexOf(u8, self.pending[0..self.pending_len], end_marker)) |idx| { + try self.paste_buf.appendSlice(self.gpa, self.pending[0..idx]); + self.consume(idx + end_marker.len); + const content = try self.gpa.dupe(u8, self.paste_buf.items); + self.paste_buf.clearRetainingCapacity(); + self.in_paste = false; + self.queued_msg = .paste_end; + return .{ .paste = content }; + } + // No end marker yet — drain bytes that can't be part of the marker + // prefix into paste_buf, keep the trailing suspicious bytes in pending + // so the next read can complete the match. + const keep = @min(self.pending_len, end_marker.len - 1); + const drain_to = self.pending_len - keep; + if (drain_to > 0) { + try self.paste_buf.appendSlice(self.gpa, self.pending[0..drain_to]); + self.consume(drain_to); + } + return null; +} + +fn consume(self: *InputBuffer, count: usize) void { + if (count == 0) return; + std.mem.copyForwards( + u8, + self.pending[0 .. self.pending_len - count], + self.pending[count..self.pending_len], + ); + self.pending_len -= count; +} + +test "decodes single key" { + var buf: InputBuffer = .init(std.testing.allocator); + defer buf.deinit(); + buf.append("a"); + const msg = (try buf.next()).?; + try std.testing.expectEqual(Msg.Key.Kind.rune, msg.key_press.kind); + try std.testing.expectEqual(@as(?Msg, null), try buf.next()); +} + +test "splits across appends" { + var buf: InputBuffer = .init(std.testing.allocator); + defer buf.deinit(); + buf.append("\x1b["); + try std.testing.expectEqual(@as(?Msg, null), try buf.next()); + buf.append("A"); + const msg = (try buf.next()).?; + try std.testing.expectEqual(Msg.Key.Kind.up, msg.key_press.kind); +} + +test "paste delivers start, content, end" { + var buf: InputBuffer = .init(std.testing.allocator); + defer buf.deinit(); + buf.append("\x1b[200~hello world\x1b[201~"); + try std.testing.expectEqual(Msg.paste_start, (try buf.next()).?); + const paste_msg = (try buf.next()).?; + try std.testing.expectEqualStrings("hello world", paste_msg.paste); + std.testing.allocator.free(paste_msg.paste); + try std.testing.expectEqual(Msg.paste_end, (try buf.next()).?); +} + +test "paste across multiple appends" { + var buf: InputBuffer = .init(std.testing.allocator); + defer buf.deinit(); + buf.append("\x1b[200~"); + try std.testing.expectEqual(Msg.paste_start, (try buf.next()).?); + buf.append("part one "); + try std.testing.expectEqual(@as(?Msg, null), try buf.next()); + buf.append("part two\x1b[201~"); + const paste_msg = (try buf.next()).?; + try std.testing.expectEqualStrings("part one part two", paste_msg.paste); + std.testing.allocator.free(paste_msg.paste); + try std.testing.expectEqual(Msg.paste_end, (try buf.next()).?); +} + +test "paste end marker split across appends" { + var buf: InputBuffer = .init(std.testing.allocator); + defer buf.deinit(); + buf.append("\x1b[200~data"); + try std.testing.expectEqual(Msg.paste_start, (try buf.next()).?); + try std.testing.expectEqual(@as(?Msg, null), try buf.next()); + buf.append("\x1b["); + try std.testing.expectEqual(@as(?Msg, null), try buf.next()); + buf.append("201~"); + const paste_msg = (try buf.next()).?; + try std.testing.expectEqualStrings("data", paste_msg.paste); + std.testing.allocator.free(paste_msg.paste); + try std.testing.expectEqual(Msg.paste_end, (try buf.next()).?); +} diff --git a/matcha/Model.zig b/matcha/Model.zig index 7d97e5c..2c6d6c2 100644 --- a/matcha/Model.zig +++ b/matcha/Model.zig @@ -11,11 +11,20 @@ const Model = @This(); vtable: *const VTable, /// Long-lived program allocator. Set by Program before any callback runs. /// Use for state that lives across updates (ArrayLists, HashMaps, etc.). +/// Equivalent to `process.gpa` — published flat for ergonomics. gpa: Allocator = undefined, /// Per-callback scratch arena. Reset by Program before every callback /// (init/update/view) — allocations are valid only until the callback /// returns. Never store an arena-backed pointer on the model. arena: Allocator = undefined, +/// `std.Io` handle, useful for blocking file/process work driven from +/// `init`/`update`. Set by Program before any callback runs. +/// Equivalent to `process.io` — published flat for ergonomics. +io: std.Io = undefined, +/// Full process bundle (env vars, argv, preopens, process-permanent arena). +/// Set by Program before any callback runs. Use `process.environ_map.get(...)` +/// for env lookups, `process.minimal.args` for argv, etc. +process: std.process.Init = undefined, pub const VTable = struct { /// Called once at program start. May return an initial Cmd. diff --git a/matcha/Program.zig b/matcha/Program.zig index a5cfe9a..a83fa5d 100644 --- a/matcha/Program.zig +++ b/matcha/Program.zig @@ -1,6 +1,5 @@ const std = @import("std"); const Io = std.Io; -const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const Writer = Io.Writer; const matcha = @import("root.zig"); @@ -25,13 +24,15 @@ pub const Options = struct { cmd_queue_capacity: usize = 64, }; -allocator: Allocator, -io: Io, +/// Process-wide bundle: gpa, io, permanent arena, environ, args, preopens. +/// Forwarded to `Model.process` so callbacks can read env vars / argv / etc. +process: std.process.Init, model: *Model, options: Options, /// Per-callback scratch arena. Reset before every model callback; the /// resolved Allocator is published to `model.arena` for callbacks to use. +/// Distinct from `process.arena`, which is process-permanent. arena: ArenaAllocator, msgs: Queue(Msg), @@ -55,20 +56,18 @@ cmd_thread: ?Thread = null, render_thread: ?Thread = null, pub fn init( - allocator: Allocator, - io: Io, + process: std.process.Init, model: *Model, options: Options, ) !Program { return .{ - .allocator = allocator, - .io = io, + .process = process, .model = model, .options = options, - .arena = .init(allocator), - .msgs = try .init(allocator, io, options.msg_queue_capacity), - .cmds = try .init(allocator, io, options.cmd_queue_capacity), - .view_buf = .init(allocator), + .arena = .init(process.gpa), + .msgs = try .init(process.gpa, process.io, options.msg_queue_capacity), + .cmds = try .init(process.gpa, process.io, options.cmd_queue_capacity), + .view_buf = .init(process.gpa), }; } @@ -81,13 +80,15 @@ pub fn deinit(self: *Program) void { } pub fn run(self: *Program) !void { - self.model.gpa = self.allocator; + self.model.process = self.process; + self.model.gpa = self.process.gpa; self.model.arena = self.arena.allocator(); + self.model.io = self.process.io; - self.tty = try Tty.init(self.io, &self.stdout_buf); + self.tty = try Tty.init(self.process.gpa, self.process.io, &self.stdout_buf); defer self.tty.deinit(); - var renderer: Renderer = .init(self.allocator, self.tty.writer()); + var renderer: Renderer = .init(self.process.gpa, self.tty.writer()); defer renderer.deinit(); defer self.teardown(&renderer); @@ -112,6 +113,12 @@ pub fn run(self: *Program) !void { while (self.msgs.pop()) |msg| { switch (msg) { .quit, .interrupt => break, + // Batch is internal: fan out the child cmds and skip the model. + .batch => |cmds| { + for (cmds) |child| self.cmds.push(child) catch break; + self.process.gpa.free(cmds); + continue; + }, else => {}, } _ = self.arena.reset(.retain_capacity); @@ -120,7 +127,7 @@ pub fn run(self: *Program) !void { } // Free heap-allocated msg payloads owned by the queue (see Msg). switch (msg) { - .paste => |t| if (t.len > 0) self.allocator.free(t), + .paste => |t| if (t.len > 0) self.process.gpa.free(t), else => {}, } try self.renderFrame(); @@ -132,9 +139,9 @@ fn teardown(self: *Program, renderer: *Renderer) void { self.tty.wake(); self.cmds.close(); - self.view_mutex.lockUncancelable(self.io); - self.view_cond.broadcast(self.io); - self.view_mutex.unlock(self.io); + self.view_mutex.lockUncancelable(self.process.io); + self.view_cond.broadcast(self.process.io); + self.view_mutex.unlock(self.process.io); self.msgs.close(); if (self.input_thread) |t| t.join(); @@ -147,13 +154,13 @@ fn teardown(self: *Program, renderer: *Renderer) void { } fn renderFrame(self: *Program) !void { - self.view_mutex.lockUncancelable(self.io); - defer self.view_mutex.unlock(self.io); + self.view_mutex.lockUncancelable(self.process.io); + defer self.view_mutex.unlock(self.process.io); self.view_buf.writer.end = 0; _ = self.arena.reset(.retain_capacity); self.view_meta = try self.model.view(&self.view_buf.writer); self.view_dirty = true; - self.view_cond.signal(self.io); + self.view_cond.signal(self.process.io); } fn inputLoop(self: *Program) void { @@ -166,37 +173,37 @@ fn inputLoop(self: *Program) void { fn cmdLoop(self: *Program) void { while (self.cmds.pop()) |cmd| - if (cmd.run(self.allocator, self.io)) |msg| + if (cmd.run(self.process.gpa, self.process.io)) |msg| self.msgs.push(msg) catch return; } fn renderLoop(self: *Program, renderer: *Renderer) void { while (!self.shutdown.load(.acquire)) { - self.view_mutex.lockUncancelable(self.io); + self.view_mutex.lockUncancelable(self.process.io); while (!self.view_dirty and !self.shutdown.load(.acquire)) { - self.view_cond.waitUncancelable(self.io, &self.view_mutex); + self.view_cond.waitUncancelable(self.process.io, &self.view_mutex); } if (self.shutdown.load(.acquire)) { - self.view_mutex.unlock(self.io); + self.view_mutex.unlock(self.process.io); break; } - const content = self.allocator.dupe(u8, self.view_buf.writer.buffered()) catch { - self.view_mutex.unlock(self.io); + const content = self.process.gpa.dupe(u8, self.view_buf.writer.buffered()) catch { + self.view_mutex.unlock(self.process.io); continue; }; var view = self.view_meta; const title_dup: ?[]u8 = if (view.window_title) |t| - self.allocator.dupe(u8, t) catch null + self.process.gpa.dupe(u8, t) catch null else null; if (title_dup) |t| view.window_title = t; self.view_dirty = false; - self.view_mutex.unlock(self.io); - defer self.allocator.free(content); - defer if (title_dup) |t| self.allocator.free(t); + self.view_mutex.unlock(self.process.io); + defer self.process.gpa.free(content); + defer if (title_dup) |t| self.process.gpa.free(t); renderer.render(content, view) catch break; - Io.sleep(self.io, .fromNanoseconds(@intCast(self.options.frame_interval_ns)), .awake) catch break; + Io.sleep(self.process.io, .fromNanoseconds(@intCast(self.options.frame_interval_ns)), .awake) catch break; } } diff --git a/matcha/Renderer.zig b/matcha/Renderer.zig index 6e486ef..13ff559 100644 --- a/matcha/Renderer.zig +++ b/matcha/Renderer.zig @@ -27,6 +27,11 @@ pub fn deinit(self: *Renderer) void { pub fn shutdown(self: *Renderer) !void { if (self.prev_view.cursor == null) try self.writer.writeAll("\x1b[?25h"); + try self.disableMouse(self.prev_view.mouse); + if (self.prev_view.bracketed_paste) + try self.writer.writeAll("\x1b[?2004l"); + if (self.prev_view.focus_reporting) + try self.writer.writeAll("\x1b[?1004l"); // Alt-screen apps exit the alt buffer (which discards the rendered frame // anyway). Inline apps leave the final frame on screen — bubbletea // convention is that the model's last `View()` is the post-quit message @@ -36,6 +41,24 @@ pub fn shutdown(self: *Renderer) !void { try self.writer.flush(); } +fn mouseSetCode(mode: View.Mouse) ?[]const u8 { + return switch (mode) { + .off => null, + .normal => "\x1b[?1000h\x1b[?1006h", + .button_motion => "\x1b[?1002h\x1b[?1006h", + .all_motion => "\x1b[?1003h\x1b[?1006h", + }; +} + +fn disableMouse(self: *Renderer, mode: View.Mouse) !void { + switch (mode) { + .off => {}, + .normal => try self.writer.writeAll("\x1b[?1000l\x1b[?1006l"), + .button_motion => try self.writer.writeAll("\x1b[?1002l\x1b[?1006l"), + .all_motion => try self.writer.writeAll("\x1b[?1003l\x1b[?1006l"), + } +} + fn eraseInline(self: *Renderer) !void { if (self.prev_lines == 0) return; @@ -68,6 +91,26 @@ pub fn render(self: *Renderer, frame: []const u8, view: View) !void { self.prev_lines = 0; } + if (view.mouse != self.prev_view.mouse) { + try self.disableMouse(self.prev_view.mouse); + if (mouseSetCode(view.mouse)) |seq| + try self.writer.writeAll(seq); + } + + if (view.bracketed_paste != self.prev_view.bracketed_paste) { + try self.writer.writeAll(if (view.bracketed_paste) + "\x1b[?2004h" + else + "\x1b[?2004l"); + } + + if (view.focus_reporting != self.prev_view.focus_reporting) { + try self.writer.writeAll(if (view.focus_reporting) + "\x1b[?1004h" + else + "\x1b[?1004l"); + } + try self.writer.writeAll("\x1b[?25l"); if (self.first_frame or alt_changed) diff --git a/matcha/View.zig b/matcha/View.zig index 1f98938..a7144fe 100644 --- a/matcha/View.zig +++ b/matcha/View.zig @@ -1,6 +1,19 @@ cursor: ?Cursor = null, alt_screen: bool = false, window_title: ?[]const u8 = null, +mouse: Mouse = .off, +bracketed_paste: bool = false, +focus_reporting: bool = false, + +pub const Mouse = enum(u8) { + off, + /// Button press/release only (DECSET 1000). + normal, + /// Button events plus motion while a button is held (DECSET 1002). + button_motion, + /// Button events plus all motion, even with no buttons down (DECSET 1003). + all_motion, +}; pub const Cursor = struct { x: u16 = 0, diff --git a/matcha/clipboard.zig b/matcha/clipboard.zig index aad8a1f..54bd502 100644 --- a/matcha/clipboard.zig +++ b/matcha/clipboard.zig @@ -5,12 +5,13 @@ //! at run time; `Program.run` frees it after the model dispatches the msg //! (see the lifecycle note on `Msg.paste`). const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + const builtin = @import("builtin"); const Cmd = @import("Cmd.zig"); const Msg = @import("msg.zig").Msg; -const Allocator = std.mem.Allocator; - /// Returns a Cmd that, when run, reads the system clipboard and emits a /// `paste` Msg. On any failure (tool missing, non-zero exit, empty buffer) /// the Cmd produces no Msg. @@ -18,7 +19,7 @@ pub fn pasteCmd() Cmd { return .{ .run_fn = pasteRun }; } -fn pasteRun(_: ?*anyopaque, allocator: Allocator, io: std.Io) ?Msg { +fn pasteRun(allocator: Allocator, io: Io, _: ?*anyopaque) ?Msg { const text = readClipboard(allocator, io) catch return null; if (text.len == 0) { allocator.free(text); @@ -44,7 +45,7 @@ fn pickTools() ToolList { }; } -fn readClipboard(allocator: Allocator, io: std.Io) ![]u8 { +fn readClipboard(allocator: Allocator, io: Io) ![]u8 { for (pickTools()) |argv| { const result = std.process.run(allocator, io, .{ .argv = argv, diff --git a/matcha/input.zig b/matcha/input.zig new file mode 100644 index 0000000..67035cb --- /dev/null +++ b/matcha/input.zig @@ -0,0 +1,391 @@ +const std = @import("std"); +const key = @import("key.zig"); +const Msg = @import("msg.zig").Msg; + +pub const ParseError = error{ Incomplete, Invalid }; + +pub const Parsed = struct { + msg: Msg, + consumed: usize, +}; + +/// Parse one terminal input event. Handles SGR mouse, focus/blur, bracketed- +/// paste markers, then falls back to plain key parsing. +/// +/// `paste_start` and `paste_end` are emitted as markers only — paste payload +/// accumulation between them is the caller's responsibility (e.g. the Tty's +/// raw byte buffer). +pub fn parse(buf: []const u8) ParseError!Parsed { + if (buf.len == 0) return error.Incomplete; + + if (buf[0] == 0x1b and buf.len >= 2 and buf[1] == '[') { + if (buf.len < 3) return error.Incomplete; + switch (buf[2]) { + '<' => return parseMouseSgr(buf), + 'I' => return .{ .msg = .focus, .consumed = 3 }, + 'O' => return .{ .msg = .blur, .consumed = 3 }, + else => {}, + } + if (std.mem.startsWith(u8, buf, "\x1b[200~")) + return .{ .msg = .paste_start, .consumed = 6 }; + if (std.mem.startsWith(u8, buf, "\x1b[201~")) + return .{ .msg = .paste_end, .consumed = 6 }; + + // Other CSI finals we own: '_' (win32-input-mode key event) and 't' + // (window-op, used for resize). Locate the final byte first; if not + // found, key.parse below will detect the incomplete CSI. + if (findCsiFinal(buf)) |idx| { + switch (buf[idx]) { + '_' => return parseWin32Input(buf, idx), + 't' => return parseWindowOp(buf, idx), + else => {}, + } + } + } + + const r = try key.parse(buf); + const msg: Msg = blk: { + if (r.msg.kind == .rune and r.msg.mods.ctrl) { + if (r.msg.rune == 'c') break :blk .interrupt; + if (r.msg.rune == 'd') break :blk .quit; + } + break :blk .{ .key_press = r.msg }; + }; + return .{ .msg = msg, .consumed = r.consumed }; +} + +/// ESC [ < Cb ; Cx ; Cy (M|m) +/// SGR-encoded mouse event, terminated by 'M' (press) or 'm' (release). +fn parseMouseSgr(buf: []const u8) ParseError!Parsed { + var i: usize = 3; + while (i < buf.len) : (i += 1) { + const c = buf[i]; + if (c == 'M' or c == 'm') break; + if (c != ';' and (c < '0' or c > '9')) return error.Invalid; + } else return error.Incomplete; + + const final = buf[i]; + const params = buf[3..i]; + + var it = std.mem.splitScalar(u8, params, ';'); + const cb_str = it.next() orelse return error.Invalid; + const cx_str = it.next() orelse return error.Invalid; + const cy_str = it.next() orelse return error.Invalid; + if (it.next() != null) return error.Invalid; + + const cb = std.fmt.parseInt(u32, cb_str, 10) catch return error.Invalid; + const cx = std.fmt.parseInt(u32, cx_str, 10) catch return error.Invalid; + const cy = std.fmt.parseInt(u32, cy_str, 10) catch return error.Invalid; + + if (cx == 0 or cy == 0) return error.Invalid; + + const bit_shift: u32 = 0x04; + const bit_alt: u32 = 0x08; + const bit_ctrl: u32 = 0x10; + const bit_motion: u32 = 0x20; + const bit_wheel: u32 = 0x40; + const bit_add: u32 = 0x80; + const bits_mask: u32 = 0x03; + + const mods: Msg.Key.Mods = .{ + .shift = (cb & bit_shift) != 0, + .alt = (cb & bit_alt) != 0, + .ctrl = (cb & bit_ctrl) != 0, + }; + + var is_wheel = false; + const button: Msg.Mouse.Button = blk: { + if (cb & bit_add != 0) { + // Buttons 8-11 → backward, forward, extra_10, extra_11. + break :blk @enumFromInt(@intFromEnum(Msg.Mouse.Button.backward) + (cb & bits_mask)); + } + if (cb & bit_wheel != 0) { + is_wheel = true; + break :blk @enumFromInt(@intFromEnum(Msg.Mouse.Button.wheel_up) + (cb & bits_mask)); + } + break :blk @enumFromInt(@intFromEnum(Msg.Mouse.Button.left) + (cb & bits_mask)); + }; + + const is_motion = (cb & bit_motion) != 0 and !is_wheel; + const release = final == 'm'; + + const mouse: Msg.Mouse = .{ + .x = @intCast(cx - 1), + .y = @intCast(cy - 1), + .button = button, + .mods = mods, + }; + + const msg: Msg = if (is_wheel) + .{ .mouse_wheel = mouse } + else if (is_motion) + .{ .mouse_motion = mouse } + else if (release) + .{ .mouse_release = mouse } + else + .{ .mouse_click = mouse }; + + return .{ .msg = msg, .consumed = i + 1 }; +} + +/// Locate the CSI final byte (range 0x40..=0x7e) in a `\x1b[...` sequence. +/// Returns the index of the final byte, or null if not present in `buf`. +/// `buf` must start with `\x1b[`. +fn findCsiFinal(buf: []const u8) ?usize { + var i: usize = 2; + while (i < buf.len) : (i += 1) { + const c = buf[i]; + if (c >= 0x40 and c <= 0x7e) return i; + } + return null; +} + +/// ESC [ VK ; SC ; CH ; KD ; CKS ; RC _ +/// +/// Win32-input-mode key event. The fields mirror Win32 KEY_EVENT_RECORD: +/// VK = virtual key code (0 means pure Unicode) +/// SC = virtual scan code (unused; informational) +/// CH = Unicode code point (post-UTF-16 decode; 0 when VK alone) +/// KD = 1 for key down, 0 for key up +/// CKS = control key state bitmap (alt/ctrl/shift/etc) +/// RC = repeat count (we emit a single event regardless) +fn parseWin32Input(buf: []const u8, final_idx: usize) ParseError!Parsed { + const params = buf[2..final_idx]; + var it = std.mem.splitScalar(u8, params, ';'); + const vk = parseDecParam(&it) orelse return error.Invalid; + _ = parseDecParam(&it) orelse return error.Invalid; // scan code, unused + const ch = parseDecParam(&it) orelse return error.Invalid; + const kd = parseDecParam(&it) orelse return error.Invalid; + const cks = parseDecParam(&it) orelse return error.Invalid; + _ = parseDecParam(&it); // repeat count, optional + if (it.next() != null) return error.Invalid; + + const mods = win32Mods(cks); + const kind = vkToKind(@truncate(vk)); + + var k: Msg.Key = .{ .kind = kind, .mods = mods }; + if (kind == .rune) { + if (ch == 0) return error.Invalid; + k.rune = @truncate(ch); + // Ctrl+letter on Win32 sends the letter VK (e.g. 'C'=0x43) with + // CH=3 (the control byte) — promote to the letter rune so callers + // see `ctrl+c` rather than U+0003. + if (mods.ctrl and ch < 0x20 and vk >= 'A' and vk <= 'Z') { + k.rune = @truncate(vk + ('a' - 'A')); + } + } + + const press = kd != 0; + if (press and mods.ctrl and k.kind == .rune) { + if (k.rune == 'c') return .{ .msg = .interrupt, .consumed = final_idx + 1 }; + if (k.rune == 'd') return .{ .msg = .quit, .consumed = final_idx + 1 }; + } + return .{ + .msg = if (press) .{ .key_press = k } else .{ .key_release = k }, + .consumed = final_idx + 1, + }; +} + +/// ESC [ Ps ; Ps ; Ps t +/// +/// Window manipulation. We emit `window_size` for the `8 ; height ; width t` +/// variant (xterm's "report text area size" / charmbracelet's serialized +/// WINDOW_BUFFER_SIZE_EVENT). Other ops are treated as Invalid so the +/// surrounding decoder skips them. +fn parseWindowOp(buf: []const u8, final_idx: usize) ParseError!Parsed { + const params = buf[2..final_idx]; + var it = std.mem.splitScalar(u8, params, ';'); + const op = parseDecParam(&it) orelse return error.Invalid; + if (op != 8) return error.Invalid; + const h = parseDecParam(&it) orelse return error.Invalid; + const w = parseDecParam(&it) orelse return error.Invalid; + if (it.next() != null) return error.Invalid; + return .{ + .msg = .{ .window_size = .{ .width = @truncate(w), .height = @truncate(h) } }, + .consumed = final_idx + 1, + }; +} + +fn parseDecParam(it: *std.mem.SplitIterator(u8, .scalar)) ?u32 { + const s = it.next() orelse return null; + if (s.len == 0) return 0; + return std.fmt.parseInt(u32, s, 10) catch null; +} + +/// Win32 control-key state bitmap, matching the constants in +/// platform/windows.zig. +fn win32Mods(cks: u32) Msg.Key.Mods { + const left_alt: u32 = 0x0002; + const right_alt: u32 = 0x0001; + const left_ctrl: u32 = 0x0008; + const right_ctrl: u32 = 0x0004; + const shift: u32 = 0x0010; + const num_lock: u32 = 0x0020; + const caps_lock: u32 = 0x0080; + return .{ + .alt = (cks & (left_alt | right_alt)) != 0, + .ctrl = (cks & (left_ctrl | right_ctrl)) != 0, + .shift = (cks & shift) != 0, + .num_lock = (cks & num_lock) != 0, + .caps_lock = (cks & caps_lock) != 0, + }; +} + +fn vkToKind(vk: u16) Msg.Key.Kind { + return switch (vk) { + 0x08 => .backspace, + 0x09 => .tab, + 0x0d => .enter, + 0x1b => .escape, + 0x20 => .space, + 0x21 => .page_up, + 0x22 => .page_down, + 0x23 => .end, + 0x24 => .home, + 0x25 => .left, + 0x26 => .up, + 0x27 => .right, + 0x28 => .down, + 0x2d => .insert, + 0x2e => .delete, + 0x70...0x83 => |v| @enumFromInt(@intFromEnum(Msg.Key.Kind.f1) + (v - 0x70)), + else => .rune, + }; +} + +test "mouse left click" { + const p = try parse("\x1b[<0;10;5M"); + try std.testing.expectEqual(@as(usize, 10), p.consumed); + try std.testing.expectEqual(Msg.Mouse.Button.left, p.msg.mouse_click.button); + try std.testing.expectEqual(@as(u16, 9), p.msg.mouse_click.x); + try std.testing.expectEqual(@as(u16, 4), p.msg.mouse_click.y); +} + +test "mouse right release" { + const p = try parse("\x1b[<2;1;1m"); + try std.testing.expectEqual(Msg.Mouse.Button.right, p.msg.mouse_release.button); +} + +test "mouse wheel up" { + const p = try parse("\x1b[<64;3;4M"); + try std.testing.expectEqual(Msg.Mouse.Button.wheel_up, p.msg.mouse_wheel.button); + try std.testing.expectEqual(@as(u16, 2), p.msg.mouse_wheel.x); + try std.testing.expectEqual(@as(u16, 3), p.msg.mouse_wheel.y); +} + +test "mouse motion with button" { + // 0 (left) + 32 (motion) = 32, button held + moving + const p = try parse("\x1b[<32;5;5M"); + try std.testing.expectEqual(Msg.Mouse.Button.left, p.msg.mouse_motion.button); +} + +test "mouse modifiers ctrl+shift" { + // 0 (left) + 4 (shift) + 16 (ctrl) = 20 + const p = try parse("\x1b[<20;1;1M"); + try std.testing.expect(p.msg.mouse_click.mods.ctrl); + try std.testing.expect(p.msg.mouse_click.mods.shift); +} + +test "mouse extended button forward" { + // 128 + 1 = 129 → forward + const p = try parse("\x1b[<129;1;1M"); + try std.testing.expectEqual(Msg.Mouse.Button.forward, p.msg.mouse_click.button); +} + +test "mouse incomplete" { + try std.testing.expectError(error.Incomplete, parse("\x1b[<0;1;1")); +} + +test "focus event" { + const p = try parse("\x1b[I"); + try std.testing.expectEqual(@as(usize, 3), p.consumed); + try std.testing.expectEqual(Msg.focus, p.msg); +} + +test "blur event" { + const p = try parse("\x1b[O"); + try std.testing.expectEqual(Msg.blur, p.msg); +} + +test "paste start marker" { + const p = try parse("\x1b[200~"); + try std.testing.expectEqual(@as(usize, 6), p.consumed); + try std.testing.expectEqual(Msg.paste_start, p.msg); +} + +test "paste end marker" { + const p = try parse("\x1b[201~"); + try std.testing.expectEqual(Msg.paste_end, p.msg); +} + +test "falls back to key parser" { + const p = try parse("a"); + try std.testing.expectEqual(Msg.Key.Kind.rune, p.msg.key_press.kind); + try std.testing.expectEqual(@as(u21, 'a'), p.msg.key_press.rune); +} + +test "ctrl+c translates to interrupt" { + const p = try parse(&.{0x03}); + try std.testing.expectEqual(Msg.interrupt, p.msg); +} + +test "ctrl+d translates to quit" { + const p = try parse(&.{0x04}); + try std.testing.expectEqual(Msg.quit, p.msg); +} + +test "arrow key still routes to key parser" { + const p = try parse("\x1b[A"); + try std.testing.expectEqual(Msg.Key.Kind.up, p.msg.key_press.kind); +} + +test "window op: resize" { + // CSI 8 ; 24 ; 80 t → 80x24 terminal + const p = try parse("\x1b[8;24;80t"); + try std.testing.expectEqual(@as(u16, 80), p.msg.window_size.width); + try std.testing.expectEqual(@as(u16, 24), p.msg.window_size.height); +} + +test "window op: non-resize is invalid" { + try std.testing.expectError(error.Invalid, parse("\x1b[5t")); // raise window +} + +test "win32 input: letter key down" { + // VK 'A' (0x41=65), SC 30, CH 'a' (0x61=97), KD 1, CKS 0, RC 1 + const p = try parse("\x1b[65;30;97;1;0;1_"); + try std.testing.expectEqual(Msg.Key.Kind.rune, p.msg.key_press.kind); + try std.testing.expectEqual(@as(u21, 'a'), p.msg.key_press.rune); +} + +test "win32 input: enter key" { + // VK_RETURN=0x0d=13, CH 13, KD 1 + const p = try parse("\x1b[13;28;13;1;0;1_"); + try std.testing.expectEqual(Msg.Key.Kind.enter, p.msg.key_press.kind); +} + +test "win32 input: F5 with ctrl" { + // VK_F5=0x74=116, CH 0, KD 1, CKS LEFT_CTRL_PRESSED=0x08 + const p = try parse("\x1b[116;63;0;1;8;1_"); + try std.testing.expectEqual(Msg.Key.Kind.f5, p.msg.key_press.kind); + try std.testing.expect(p.msg.key_press.mods.ctrl); +} + +test "win32 input: key release" { + // VK 'A', CH 'a', KD 0 → key_release + const p = try parse("\x1b[65;30;97;0;0;1_"); + try std.testing.expectEqual(Msg.Key.Kind.rune, p.msg.key_release.kind); + try std.testing.expectEqual(@as(u21, 'a'), p.msg.key_release.rune); +} + +test "win32 input: ctrl+c → interrupt" { + // VK 'C'=0x43=67, CH 3 (ctrl byte), KD 1, CKS LEFT_CTRL_PRESSED + const p = try parse("\x1b[67;46;3;1;8;1_"); + try std.testing.expectEqual(Msg.interrupt, p.msg); +} + +test "win32 input: alt+shift+rune" { + // VK 'X'=88, CH 'X'=88, KD 1, CKS LEFT_ALT|SHIFT = 0x12 + const p = try parse("\x1b[88;45;88;1;18;1_"); + try std.testing.expect(p.msg.key_press.mods.alt); + try std.testing.expect(p.msg.key_press.mods.shift); +} diff --git a/matcha/platform/posix.zig b/matcha/platform/posix.zig index d103b49..f84f955 100644 --- a/matcha/platform/posix.zig +++ b/matcha/platform/posix.zig @@ -2,8 +2,9 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const Io = std.Io; +const Allocator = std.mem.Allocator; -const key = @import("../key.zig"); +const InputBuffer = @import("../InputBuffer.zig"); const Msg = @import("../msg.zig").Msg; /// On Linux, use raw syscalls (no libc needed). Everywhere else, fall back @@ -38,8 +39,7 @@ pub const Tty = struct { woken: std.atomic.Value(bool) = .init(false), - pending: [512]u8 = undefined, - pending_len: usize = 0, + input_buf: InputBuffer, stdout_writer: Io.File.Writer, @@ -48,7 +48,7 @@ pub const Tty = struct { PipeFailed, } || posix.TermiosGetError || posix.TermiosSetError; - pub fn init(io: Io, stdout_buf: []u8) InitError!Tty { + pub fn init(gpa: Allocator, io: Io, stdout_buf: []u8) InitError!Tty { const stdin_fd = posix.STDIN_FILENO; const stdout_fd = posix.STDOUT_FILENO; @@ -90,6 +90,7 @@ pub const Tty = struct { .saved_termios = saved, .wake_pipe = wake_fds, .sigwinch_pipe = winch_fds, + .input_buf = .init(gpa), .stdout_writer = Io.File.stdout().writer(io, stdout_buf), }; } @@ -105,6 +106,7 @@ pub const Tty = struct { _ = sys.close(self.wake_pipe[1]); _ = sys.close(self.sigwinch_pipe[0]); _ = sys.close(self.sigwinch_pipe[1]); + self.input_buf.deinit(); } pub fn writer(self: *Tty) *Io.Writer { @@ -133,7 +135,7 @@ pub const Tty = struct { if (self.woken.load(.acquire)) return null; // Try to extract a complete key from already-buffered bytes. - if (try self.parsePending()) |msg| return msg; + if (try self.input_buf.next()) |msg| return msg; var pollfds = [_]posix.pollfd{ .{ .fd = self.stdin_fd, .events = posix.POLL.IN, .revents = 0 }, @@ -157,62 +159,12 @@ pub const Tty = struct { const n = posix.read(self.stdin_fd, &read_buf) catch |err| return err; if (n == 0) return null; // EOF on stdin - // Append to pending; on overflow drop oldest bytes. - if (self.pending_len + n > self.pending.len) { - const drop = self.pending_len + n - self.pending.len; - std.mem.copyForwards( - u8, - self.pending[0 .. self.pending_len - drop], - self.pending[drop..self.pending_len], - ); - self.pending_len -= drop; - } - @memcpy(self.pending[self.pending_len..][0..n], read_buf[0..n]); - self.pending_len += n; - - if (try self.parsePending()) |msg| return msg; + self.input_buf.append(read_buf[0..n]); + if (try self.input_buf.next()) |msg| return msg; // Otherwise loop and poll for more bytes. } } - fn parsePending(self: *Tty) !?Msg { - var off: usize = 0; - while (off < self.pending_len) { - const r = key.parse(self.pending[off..self.pending_len]) catch |err| - switch (err) { - error.Incomplete => break, - error.Invalid => { - off += 1; - continue; - }, - }; - // Translate ctrl+c → interrupt, ctrl+d → quit before handing - // the key to the model. - const msg: Msg = blk: { - if (r.msg.kind == .rune and r.msg.mods.ctrl) { - if (r.msg.rune == 'c') break :blk .interrupt; - if (r.msg.rune == 'd') break :blk .quit; - } - break :blk .{ .key_press = r.msg }; - }; - off += r.consumed; - self.consume(off); - return msg; - } - if (off > 0) self.consume(off); - return null; - } - - fn consume(self: *Tty, count: usize) void { - if (count == 0) return; - std.mem.copyForwards( - u8, - self.pending[0 .. self.pending_len - count], - self.pending[count..self.pending_len], - ); - self.pending_len -= count; - } - fn setRawMode(fd: posix.fd_t, current: posix.termios) posix.TermiosSetError!void { var raw = current; // Input flags: disable break processing, CR/NL conversion, parity diff --git a/matcha/platform/windows.zig b/matcha/platform/windows.zig index 8c75edb..1fb6545 100644 --- a/matcha/platform/windows.zig +++ b/matcha/platform/windows.zig @@ -1,16 +1,18 @@ //! Windows Tty backend. //! -//! Uses ReadConsoleInputW to receive INPUT_RECORDs (key/mouse/window) -//! directly — no byte-stream parsing on Windows. Output goes through -//! WriteFile against the stdout HANDLE with ENABLE_VIRTUAL_TERMINAL_PROCESSING -//! so the renderer's ANSI escape sequences work as-is. Cancellation uses -//! a CreateEventW handle multiplexed with stdin via WaitForMultipleObjects. +//! Reads INPUT_RECORDs via ReadConsoleInputW and **serializes them into VT +//! byte sequences** that flow through the same `input.parse` decoder POSIX +//! uses. This keeps the parser single-sourced: mouse, focus, window-size, +//! and keys all share the cross-platform decode path. Cancellation uses a +//! CreateEventW handle multiplexed with stdin via WaitForMultipleObjects. const std = @import("std"); const builtin = @import("builtin"); const windows = std.os.windows; const Io = std.Io; +const Allocator = std.mem.Allocator; +const InputBuffer = @import("../InputBuffer.zig"); const Msg = @import("../msg.zig").Msg; // ─── Win32 constants ─────────────────────────────────────────────────────── @@ -26,7 +28,7 @@ const ENABLE_QUICK_EDIT_MODE: DWORD = 0x0040; const ENABLE_EXTENDED_FLAGS: DWORD = 0x0080; const ENABLE_VIRTUAL_TERMINAL_INPUT: DWORD = 0x0200; -// Output mode flags. (windows.zig already declares two of these.) +// Output mode flags. const ENABLE_PROCESSED_OUTPUT: DWORD = 0x0001; const ENABLE_WRAP_AT_EOL_OUTPUT: DWORD = 0x0002; const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING; @@ -46,47 +48,22 @@ const WINDOW_BUFFER_SIZE_EVENT: WORD = 0x0004; const MENU_EVENT: WORD = 0x0008; const FOCUS_EVENT: WORD = 0x0010; -// Control key state bits. -const RIGHT_ALT_PRESSED: DWORD = 0x0001; -const LEFT_ALT_PRESSED: DWORD = 0x0002; -const RIGHT_CTRL_PRESSED: DWORD = 0x0004; -const LEFT_CTRL_PRESSED: DWORD = 0x0008; -const SHIFT_PRESSED: DWORD = 0x0010; -const NUMLOCK_ON: DWORD = 0x0020; -const SCROLLLOCK_ON: DWORD = 0x0040; -const CAPSLOCK_ON: DWORD = 0x0080; -const ENHANCED_KEY: DWORD = 0x0100; - -// Mouse event flags / button state. +// Mouse event flags. const MOUSE_MOVED: DWORD = 0x0001; const DOUBLE_CLICK: DWORD = 0x0002; const MOUSE_WHEELED: DWORD = 0x0004; const MOUSE_HWHEELED: DWORD = 0x0008; +// Mouse button state bits. const FROM_LEFT_1ST_BUTTON_PRESSED: DWORD = 0x0001; const RIGHTMOST_BUTTON_PRESSED: DWORD = 0x0002; const FROM_LEFT_2ND_BUTTON_PRESSED: DWORD = 0x0004; const FROM_LEFT_3RD_BUTTON_PRESSED: DWORD = 0x0008; const FROM_LEFT_4TH_BUTTON_PRESSED: DWORD = 0x0010; -// Virtual-key codes (subset). -const VK_BACK: WORD = 0x08; -const VK_TAB: WORD = 0x09; -const VK_RETURN: WORD = 0x0D; -const VK_ESCAPE: WORD = 0x1B; -const VK_SPACE: WORD = 0x20; -const VK_PRIOR: WORD = 0x21; // page up -const VK_NEXT: WORD = 0x22; // page down -const VK_END: WORD = 0x23; -const VK_HOME: WORD = 0x24; -const VK_LEFT: WORD = 0x25; -const VK_UP: WORD = 0x26; -const VK_RIGHT: WORD = 0x27; -const VK_DOWN: WORD = 0x28; -const VK_INSERT: WORD = 0x2D; -const VK_DELETE: WORD = 0x2E; -const VK_F1: WORD = 0x70; -const VK_F20: WORD = 0x83; +// Control key state bits (also referenced by win32-input-mode encoding in +// input.zig — keep these constants in sync if Win32 ever changes them). +const LEFT_CTRL_PRESSED: DWORD = 0x0008; // ─── Win32 types ─────────────────────────────────────────────────────────── @@ -212,8 +189,14 @@ pub const Tty = struct { woken: std.atomic.Value(bool) = .init(false), - /// Last button-state bitmap, used to derive press vs release events. + input_buf: InputBuffer, + + /// Previous mouse button bitmap; diffed against new state to derive + /// per-button press/release events for SGR emission. last_mouse_buttons: DWORD = 0, + /// UTF-16 high surrogate carried over from the previous KEY_EVENT. + /// Non-null means the next event supplies the matching low half. + utf16_high: ?WCHAR = null, stdout_writer: Io.File.Writer, @@ -224,7 +207,7 @@ pub const Tty = struct { Unexpected, }; - pub fn init(io: Io, stdout_buf: []u8) InitError!Tty { + pub fn init(gpa: Allocator, io: Io, stdout_buf: []u8) InitError!Tty { const stdin_file = Io.File.stdin(); const stdout_file = Io.File.stdout(); const stdin = stdin_file.handle; @@ -238,8 +221,11 @@ pub const Tty = struct { const saved_cp = GetConsoleOutputCP(); - // Configure input: raw, with window-resize and mouse events. Drop - // ENABLE_QUICK_EDIT_MODE which otherwise eats mouse selection. + // Raw input with window-resize, mouse, focus, and VT input events. + // Drop QUICK_EDIT (eats mouse selection). VT input lets the terminal + // deliver bracketed-paste markers, function keys, and other CSI + // sequences as byte streams via KEY_EVENT.UnicodeChar; we accumulate + // them in InputBuffer and let the shared decoder handle the rest. const new_in: DWORD = (saved_in & ~ENABLE_LINE_INPUT & ~ENABLE_ECHO_INPUT & @@ -247,12 +233,13 @@ pub const Tty = struct { ~ENABLE_QUICK_EDIT_MODE) | ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT | - ENABLE_EXTENDED_FLAGS; + ENABLE_EXTENDED_FLAGS | + ENABLE_VIRTUAL_TERMINAL_INPUT; if (SetConsoleMode(stdin, new_in) == .FALSE) return error.ConsoleModeFailed; errdefer _ = SetConsoleMode(stdin, saved_in); - // Configure output: handle ANSI escapes (VT processing), don't auto- - // wrap at the last column, allow LVB grid (underline/reverse). + // Output: ANSI escape handling, no auto-wrap at last column, allow + // LVB grid (underline/reverse). const new_out: DWORD = saved_out | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING | @@ -261,11 +248,9 @@ pub const Tty = struct { if (SetConsoleMode(stdout_h, new_out) == .FALSE) return error.ConsoleModeFailed; errdefer _ = SetConsoleMode(stdout_h, saved_out); - // UTF-8 output so our byte stream renders correctly. _ = SetConsoleOutputCP(CP_UTF8); errdefer _ = SetConsoleOutputCP(saved_cp); - // Auto-reset event used to cancel WaitForMultipleObjects in nextMsg. const wake_handle = CreateEventW(null, .FALSE, .FALSE, null) orelse return error.EventCreationFailed; return .{ @@ -276,6 +261,7 @@ pub const Tty = struct { .saved_input_mode = saved_in, .saved_output_mode = saved_out, .saved_output_cp = saved_cp, + .input_buf = .init(gpa), .stdout_writer = Io.File.stdout().writer(io, stdout_buf), }; } @@ -285,6 +271,7 @@ pub const Tty = struct { _ = SetConsoleMode(self.stdout, self.saved_output_mode); _ = SetConsoleOutputCP(self.saved_output_cp); windows.CloseHandle(self.wake_event); + self.input_buf.deinit(); } pub fn writer(self: *Tty) *Io.Writer { @@ -309,156 +296,143 @@ pub const Tty = struct { pub fn nextMsg(self: *Tty) !?Msg { while (true) { if (self.woken.load(.acquire)) return null; + if (try self.input_buf.next()) |msg| return msg; const handles = [_]HANDLE{ self.stdin, self.wake_event }; const wait = WaitForMultipleObjects(2, &handles, .FALSE, INFINITE); if (wait == WAIT_FAILED) return windows.unexpectedError(windows.GetLastError()); - if (wait == WAIT_OBJECT_0 + 1) return null; // wake_event - if (wait != WAIT_OBJECT_0) continue; // spurious + if (wait == WAIT_OBJECT_0 + 1) return null; + if (wait != WAIT_OBJECT_0) continue; - var record: INPUT_RECORD = undefined; + var records: [16]INPUT_RECORD = undefined; var read: DWORD = 0; - if (ReadConsoleInputW(self.stdin, @ptrCast(&record), 1, &read) == .FALSE) { + if (ReadConsoleInputW(self.stdin, &records, records.len, &read) == .FALSE) { return windows.unexpectedError(windows.GetLastError()); } - if (read == 0) continue; - - switch (record.EventType) { - KEY_EVENT => { - const ke = record.Event.KeyEvent; - // Skip key-up events for now; only press → key_press. - if (ke.bKeyDown == .FALSE) continue; - if (translateKey(ke)) |msg| return msg; - }, - MOUSE_EVENT => { - const me = record.Event.MouseEvent; - if (self.translateMouse(me)) |msg| { - self.last_mouse_buttons = me.dwButtonState; - return msg; - } - self.last_mouse_buttons = me.dwButtonState; - }, - WINDOW_BUFFER_SIZE_EVENT => { - const sz = record.Event.WindowBufferSizeEvent.dwSize; - return .{ .window_size = .{ - .width = @intCast(sz.X), - .height = @intCast(sz.Y), - } }; - }, - FOCUS_EVENT => { - const fe = record.Event.FocusEvent; - return if (fe.bSetFocus == .FALSE) Msg.blur else Msg.focus; - }, - MENU_EVENT => {}, // ignored - else => {}, - } + for (records[0..read]) |rec| try self.serialize(rec); + if (try self.input_buf.next()) |msg| return msg; } } - fn translateMouse(self: *Tty, me: MOUSE_EVENT_RECORD) ?Msg { - const mods = mouseModsFromState(me.dwControlKeyState); - const x: u16 = @intCast(@max(me.dwMousePosition.X, 0)); - const y: u16 = @intCast(@max(me.dwMousePosition.Y, 0)); - - if (me.dwEventFlags & MOUSE_WHEELED != 0) { - // High word of dwButtonState is the wheel delta; positive=up. - const delta: i16 = @bitCast(@as(u16, @truncate(me.dwButtonState >> 16))); - const button: Msg.Mouse.Button = if (delta > 0) .wheel_up else .wheel_down; - return .{ .mouse_wheel = .{ .x = x, .y = y, .button = button, .mods = mods } }; + /// Translate one Win32 INPUT_RECORD into the equivalent VT byte sequence + /// and feed it to `input_buf`. Mirrors charmbracelet/ultraviolet's + /// serializeWin32InputRecords. + fn serialize(self: *Tty, rec: INPUT_RECORD) !void { + switch (rec.EventType) { + KEY_EVENT => try self.serializeKey(rec.Event.KeyEvent), + MOUSE_EVENT => try self.serializeMouse(rec.Event.MouseEvent), + WINDOW_BUFFER_SIZE_EVENT => { + const sz = rec.Event.WindowBufferSizeEvent.dwSize; + var scratch: [32]u8 = undefined; + const s = std.fmt.bufPrint(&scratch, "\x1b[8;{d};{d}t", .{ + @as(u32, @intCast(@max(sz.Y, 0))), + @as(u32, @intCast(@max(sz.X, 0))), + }) catch return; + self.input_buf.append(s); + }, + FOCUS_EVENT => self.input_buf.append( + if (rec.Event.FocusEvent.bSetFocus == .FALSE) "\x1b[O" else "\x1b[I", + ), + else => {}, // MENU_EVENT and others: ignore } - if (me.dwEventFlags & MOUSE_HWHEELED != 0) { - const delta: i16 = @bitCast(@as(u16, @truncate(me.dwButtonState >> 16))); - const button: Msg.Mouse.Button = if (delta > 0) .wheel_right else .wheel_left; - return .{ .mouse_wheel = .{ .x = x, .y = y, .button = button, .mods = mods } }; - } - if (me.dwEventFlags & MOUSE_MOVED != 0) { - return .{ .mouse_motion = .{ .x = x, .y = y, .button = primaryButton(me.dwButtonState), .mods = mods } }; - } - - // Diff against last button state to figure out press vs release. - const pressed = me.dwButtonState & ~self.last_mouse_buttons; - const released = self.last_mouse_buttons & ~me.dwButtonState; - if (pressed != 0) { - return .{ .mouse_click = .{ .x = x, .y = y, .button = buttonFromBit(pressed), .mods = mods } }; - } - if (released != 0) { - return .{ .mouse_release = .{ .x = x, .y = y, .button = buttonFromBit(released), .mods = mods } }; - } - return null; } -}; - -// ─── Helpers ─────────────────────────────────────────────────────────────── - -fn translateKey(ke: KEY_EVENT_RECORD) ?Msg { - const mods = keyModsFromState(ke.dwControlKeyState); - // Ctrl+c → interrupt, Ctrl+d → quit (matching POSIX behavior). - if (mods.ctrl) { - switch (ke.wVirtualKeyCode) { - 'C' => return .interrupt, - 'D' => return .quit, - else => {}, + fn serializeKey(self: *Tty, ke: KEY_EVENT_RECORD) !void { + // Only emit on key down. POSIX terminals don't surface release events + // by default; matching that keeps Msg semantics uniform across + // platforms. (Kitty keyboard protocol can re-enable release later.) + if (ke.bKeyDown == .FALSE) return; + + const wc = ke.uChar.UnicodeChar; + if (wc == 0) return; // pure VK with no char (terminal already sent the VT bytes) + + // Combine UTF-16 surrogate pairs from sequential events. + var codepoint: u21 = wc; + if (self.utf16_high) |hi| { + self.utf16_high = null; + if (wc >= 0xDC00 and wc <= 0xDFFF) { + codepoint = 0x10000 + + (@as(u21, hi - 0xD800) << 10) + + (@as(u21, wc) - 0xDC00); + } else { + codepoint = wc; // orphaned high — drop and use current char + } + } else if (wc >= 0xD800 and wc <= 0xDBFF) { + self.utf16_high = wc; + return; + } else if (wc >= 0xDC00 and wc <= 0xDFFF) { + return; // orphaned low half } + + // Emit as UTF-8. The terminal's VT sequences (paste markers, arrow + // keys, etc.) arrive byte-by-byte here; writing them through forms + // the same stream POSIX would see. + var scratch: [4]u8 = undefined; + const n = std.unicode.utf8Encode(codepoint, &scratch) catch return; + self.input_buf.append(scratch[0..n]); } - const kind: ?Msg.Key.Kind = switch (ke.wVirtualKeyCode) { - VK_RETURN => .enter, - VK_TAB => .tab, - VK_BACK => .backspace, - VK_ESCAPE => .escape, - VK_SPACE => .space, - VK_UP => .up, - VK_DOWN => .down, - VK_LEFT => .left, - VK_RIGHT => .right, - VK_HOME => .home, - VK_END => .end, - VK_PRIOR => .page_up, - VK_NEXT => .page_down, - VK_INSERT => .insert, - VK_DELETE => .delete, - VK_F1...VK_F20 => |vk| @enumFromInt(@intFromEnum(Msg.Key.Kind.f1) + (vk - VK_F1)), - else => null, - }; + fn serializeMouse(self: *Tty, me: MOUSE_EVENT_RECORD) !void { + const x: u32 = @intCast(@max(me.dwMousePosition.X, 0) + 1); + const y: u32 = @intCast(@max(me.dwMousePosition.Y, 0) + 1); - if (kind) |k| return .{ .key_press = .{ .kind = k, .mods = mods } }; + // SGR mouse encoding bits: shift=0x4, alt=0x8, ctrl=0x10, + // motion=0x20, wheel=0x40, additional buttons (8+)=0x80. + const LEFT_ALT_PRESSED: DWORD = 0x0002; + const RIGHT_ALT_PRESSED: DWORD = 0x0001; + const RIGHT_CTRL_PRESSED: DWORD = 0x0004; + const SHIFT_PRESSED: DWORD = 0x0010; + var sgr_mods: u32 = 0; + if (me.dwControlKeyState & SHIFT_PRESSED != 0) sgr_mods |= 0x04; + if (me.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED) != 0) sgr_mods |= 0x08; + if (me.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED) != 0) sgr_mods |= 0x10; - // Printable: take the UnicodeChar field. UnicodeChar is WCHAR; for - // BMP code points it's the codepoint directly. Surrogate pairs would - // need follow-up event handling — skipped for v0. - const wc = ke.uChar.UnicodeChar; - if (wc != 0) { - // High surrogate: ignore for now (v0 doesn't handle non-BMP). - if (wc >= 0xD800 and wc <= 0xDFFF) return null; - return .{ .key_press = .{ .kind = .rune, .rune = wc, .mods = mods } }; - } - return null; -} + var cb: u32 = 0; + var final: u8 = 'M'; -fn keyModsFromState(state: DWORD) Msg.Key.Mods { - return .{ - .ctrl = (state & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) != 0, - .alt = (state & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED)) != 0, - .shift = (state & SHIFT_PRESSED) != 0, - .caps_lock = (state & CAPSLOCK_ON) != 0, - .num_lock = (state & NUMLOCK_ON) != 0, - }; -} - -fn mouseModsFromState(state: DWORD) Msg.Key.Mods { - return keyModsFromState(state); -} + if (me.dwEventFlags & MOUSE_WHEELED != 0) { + const delta: i16 = @bitCast(@as(u16, @truncate(me.dwButtonState >> 16))); + cb = 0x40 | (if (delta > 0) @as(u32, 0) else @as(u32, 1)); // wheel_up=0, wheel_down=1 + cb |= sgr_mods; + } else if (me.dwEventFlags & MOUSE_HWHEELED != 0) { + const delta: i16 = @bitCast(@as(u16, @truncate(me.dwButtonState >> 16))); + // wheel_left=2, wheel_right=3 (relative to wheel_up base) + cb = 0x40 | (if (delta > 0) @as(u32, 3) else @as(u32, 2)); + cb |= sgr_mods; + } else if (me.dwEventFlags & MOUSE_MOVED != 0) { + const btn = primaryButton(me.dwButtonState); + cb = btn | 0x20 | sgr_mods; + } else { + // Press / release: diff with last button state. + const pressed = me.dwButtonState & ~self.last_mouse_buttons; + const released = self.last_mouse_buttons & ~me.dwButtonState; + if (pressed != 0) { + cb = primaryButton(pressed) | sgr_mods; + } else if (released != 0) { + cb = primaryButton(released) | sgr_mods; + final = 'm'; + } else { + self.last_mouse_buttons = me.dwButtonState; + return; // no diff, no event + } + } + self.last_mouse_buttons = me.dwButtonState; -fn primaryButton(state: DWORD) Msg.Mouse.Button { - if (state & FROM_LEFT_1ST_BUTTON_PRESSED != 0) return .left; - if (state & RIGHTMOST_BUTTON_PRESSED != 0) return .right; - if (state & FROM_LEFT_2ND_BUTTON_PRESSED != 0) return .middle; - if (state & FROM_LEFT_3RD_BUTTON_PRESSED != 0) return .backward; - if (state & FROM_LEFT_4TH_BUTTON_PRESSED != 0) return .forward; - return .none; -} + var scratch: [32]u8 = undefined; + const s = std.fmt.bufPrint(&scratch, "\x1b[<{d};{d};{d}{c}", .{ + cb, x, y, final, + }) catch return; + self.input_buf.append(s); + } +}; -fn buttonFromBit(bits: DWORD) Msg.Mouse.Button { - return primaryButton(bits); +/// Map a Win32 button-state bit to the SGR `Cb` low bits. Returns just the +/// button portion; caller ORs in mods/motion/wheel/add bits. +fn primaryButton(state: DWORD) u32 { + if (state & FROM_LEFT_1ST_BUTTON_PRESSED != 0) return 0; // left + if (state & FROM_LEFT_2ND_BUTTON_PRESSED != 0) return 1; // middle + if (state & RIGHTMOST_BUTTON_PRESSED != 0) return 2; // right + if (state & FROM_LEFT_3RD_BUTTON_PRESSED != 0) return 0x80; // backward (add bit only) + if (state & FROM_LEFT_4TH_BUTTON_PRESSED != 0) return 0x80 | 1; // forward + return 3; // no button → "none" (release marker in X10, ignored in SGR) } diff --git a/matcha/root.zig b/matcha/root.zig index cb58789..f8ab4c1 100644 --- a/matcha/root.zig +++ b/matcha/root.zig @@ -7,6 +7,8 @@ pub const Model = @import("Model.zig"); pub const Program = @import("Program.zig"); pub const Renderer = @import("Renderer.zig"); pub const key = @import("key.zig"); +pub const input = @import("input.zig"); +pub const InputBuffer = @import("InputBuffer.zig"); pub const clipboard = @import("clipboard.zig"); pub const Queue = @import("queue.zig").Queue; pub const Tty = switch (@import("builtin").os.tag) {