Add Space leader command rail

This commit is contained in:
slhx agent
2026-06-21 02:16:23 +02:00
parent db3028ce0e
commit cfbacb3cd2
3 changed files with 392 additions and 5 deletions
+265
View File
@@ -0,0 +1,265 @@
const std = @import("std");
const input = @import("input.zig");
const session_mod = @import("session.zig");
// Space leader command grammar, separate from keyboard layout facts.
// req: input/001, input/002, input/004, testing/001, testing/002, testing/003, testing/004
test {
_ = Leader;
}
pub const Feature = enum {
search,
panel_close,
};
pub const Action = union(enum) {
none,
save,
quit,
open: []u8,
not_built: Feature,
pub fn deinit(self: Action, allocator: std.mem.Allocator) void {
switch (self) {
.open => |path| allocator.free(path),
else => {},
}
}
};
const Mode = enum {
idle,
rail,
open_prompt,
};
pub const Leader = struct {
allocator: std.mem.Allocator,
mode: Mode = .idle,
open_prompt: std.ArrayList(u8) = .empty,
message: ?[]const u8 = null,
pub fn init(allocator: std.mem.Allocator) Leader {
return .{ .allocator = allocator };
}
pub fn deinit(self: *Leader) void {
self.open_prompt.deinit(self.allocator);
self.* = undefined;
}
pub fn handleEvent(self: *Leader, event: input.Event) !Action {
switch (self.mode) {
.idle => return self.handleIdle(event),
.rail => return self.handleRail(event),
.open_prompt => return self.handleOpenPrompt(event),
}
}
pub fn status(self: *const Leader) []const u8 {
if (self.message) |message| return message;
return switch (self.mode) {
.idle => "",
.rail => "leader: s save q quit o open / search x close",
.open_prompt => "open: type path, Enter opens, Esc cancels",
};
}
pub fn isActive(self: *const Leader) bool {
return self.mode != .idle or self.message != null;
}
pub fn promptText(self: *const Leader) []const u8 {
return self.open_prompt.items;
}
fn handleIdle(self: *Leader, event: input.Event) Action {
self.message = null;
switch (event) {
.key => |key| if (key == .space) {
self.mode = .rail;
return .none;
},
else => {},
}
return .none;
}
fn handleRail(self: *Leader, event: input.Event) !Action {
self.message = null;
switch (event) {
.text => |text| {
if (std.mem.eql(u8, text, "s")) {
self.mode = .idle;
return .save;
}
if (std.mem.eql(u8, text, "q")) {
self.mode = .idle;
return .quit;
}
if (std.mem.eql(u8, text, "o")) {
self.open_prompt.clearRetainingCapacity();
self.mode = .open_prompt;
return .none;
}
if (std.mem.eql(u8, text, "/")) {
self.mode = .idle;
self.message = "search is not built in this profile yet";
return .{ .not_built = .search };
}
if (std.mem.eql(u8, text, "x")) {
self.mode = .idle;
self.message = "panel close is not built in this profile yet";
return .{ .not_built = .panel_close };
}
self.mode = .idle;
self.message = "unknown leader key";
return .none;
},
.key => |key| switch (key) {
.escape, .backspace => {
self.mode = .idle;
self.message = "leader cancelled";
return .none;
},
.space => return .none,
else => {
self.mode = .idle;
self.message = "unknown leader key";
return .none;
},
},
.unknown => {
self.mode = .idle;
self.message = "unknown leader key";
return .none;
},
}
}
fn handleOpenPrompt(self: *Leader, event: input.Event) !Action {
self.message = null;
switch (event) {
.text => |text| {
try self.open_prompt.appendSlice(self.allocator, text);
return .none;
},
.key => |key| switch (key) {
.enter => {
if (self.open_prompt.items.len == 0) {
self.mode = .idle;
self.message = "open cancelled: empty path";
return .none;
}
const path = try self.allocator.dupe(u8, self.open_prompt.items);
self.open_prompt.clearRetainingCapacity();
self.mode = .idle;
return .{ .open = path };
},
.backspace => {
const previous = session_mod.previousBoundary(self.open_prompt.items, self.open_prompt.items.len);
self.open_prompt.shrinkRetainingCapacity(previous);
return .none;
},
.escape => {
self.open_prompt.clearRetainingCapacity();
self.mode = .idle;
self.message = "open cancelled";
return .none;
},
else => return .none,
},
.unknown => {
self.message = "open ignored unknown input";
return .none;
},
}
}
};
fn expectActionTag(expected: std.meta.Tag(Action), action: Action) !void {
try std.testing.expectEqual(expected, std.meta.activeTag(action));
}
test "regular: space opens a visible leader rail and save dispatches" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try std.testing.expectEqualStrings("leader: s save q quit o open / search x close", leader.status());
try expectActionTag(.save, try leader.handleEvent(input.normalize("s")));
try std.testing.expect(!leader.isActive());
}
test "regular: leader quit dispatches without Esc Ctrl Alt or function keys" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try expectActionTag(.quit, try leader.handleEvent(input.normalize("q")));
}
test "regular: leader open prompt collects UTF-8 path and backspace respects codepoints" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("o")));
try std.testing.expectEqualStrings("open: type path, Enter opens, Esc cancels", leader.status());
try expectActionTag(.none, try leader.handleEvent(input.normalize("café")));
try std.testing.expectEqualStrings("café", leader.promptText());
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x7f")));
try std.testing.expectEqualStrings("caf", leader.promptText());
try expectActionTag(.none, try leader.handleEvent(input.normalize(".zig")));
const action = try leader.handleEvent(input.normalize("\r"));
defer action.deinit(std.testing.allocator);
switch (action) {
.open => |path| try std.testing.expectEqualStrings("caf.zig", path),
else => return error.ExpectedOpenAction,
}
}
test "regular: not-yet-built leader entries are explicit recoverable actions" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
const action = try leader.handleEvent(input.normalize("/"));
switch (action) {
.not_built => |feature| try std.testing.expectEqual(Feature.search, feature),
else => return error.ExpectedNotBuiltAction,
}
try std.testing.expectEqualStrings("search is not built in this profile yet", leader.status());
}
test "adversarial: unknown leader key does not dispatch and recovers to idle" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("?")));
try std.testing.expectEqualStrings("unknown leader key", leader.status());
try expectActionTag(.none, try leader.handleEvent(input.normalize("a")));
}
test "adversarial: empty open and escape cancel without dispatching" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("o")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("\r")));
try std.testing.expectEqualStrings("open cancelled: empty path", leader.status());
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("o")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("x")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b")));
try std.testing.expectEqualStrings("open cancelled", leader.status());
}