Add Space leader command rail
This commit is contained in:
+265
@@ -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());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const std = @import("std");
|
||||
const input = @import("input.zig");
|
||||
const layout = @import("layout.zig");
|
||||
const leader = @import("leader.zig");
|
||||
const protocol = @import("protocol.zig");
|
||||
const replay = @import("replay.zig");
|
||||
const session = @import("session.zig");
|
||||
@@ -120,6 +121,7 @@ fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.It
|
||||
test {
|
||||
_ = input;
|
||||
_ = layout;
|
||||
_ = leader;
|
||||
_ = protocol;
|
||||
_ = replay;
|
||||
_ = session;
|
||||
|
||||
+121
-1
@@ -1,4 +1,6 @@
|
||||
const std = @import("std");
|
||||
const input = @import("input.zig");
|
||||
const leader_mod = @import("leader.zig");
|
||||
const protocol = @import("protocol.zig");
|
||||
const replay = @import("replay.zig");
|
||||
const session_mod = @import("session.zig");
|
||||
@@ -59,6 +61,7 @@ pub const TraceResult = struct {
|
||||
pub const Client = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
session: session_mod.Session,
|
||||
leader: leader_mod.Leader,
|
||||
viewport: Viewport,
|
||||
saved_bytes: ?[]u8 = null,
|
||||
quit: bool = false,
|
||||
@@ -68,12 +71,14 @@ pub const Client = struct {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.session = session_mod.Session.init(allocator),
|
||||
.leader = leader_mod.Leader.init(allocator),
|
||||
.viewport = viewport,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Client) void {
|
||||
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
|
||||
self.leader.deinit();
|
||||
self.session.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
@@ -85,6 +90,8 @@ pub const Client = struct {
|
||||
}
|
||||
if (self.quit) return Error.ClientQuit;
|
||||
|
||||
if (std.mem.startsWith(u8, line, "key ")) return self.handleTraceKey(line[4..]);
|
||||
if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]);
|
||||
if (std.mem.startsWith(u8, line, "open ")) return self.applyProtocol(line);
|
||||
if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line);
|
||||
if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left");
|
||||
@@ -125,7 +132,15 @@ pub const Client = struct {
|
||||
try out.append(allocator, '\n');
|
||||
}
|
||||
|
||||
const status = try std.fmt.allocPrint(
|
||||
const leader_status = self.leader.status();
|
||||
const status = if (leader_status.len != 0)
|
||||
try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{s}{s}{s}",
|
||||
.{ leader_status, if (self.leader.promptText().len > 0) ": " else "", self.leader.promptText() },
|
||||
)
|
||||
else
|
||||
try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"mim row={d} col={d} bytes={d}{s}",
|
||||
.{ cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
|
||||
@@ -135,10 +150,40 @@ pub const Client = struct {
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
pub fn handleInput(self: *Client, raw: []const u8) !void {
|
||||
if (self.quit) return Error.ClientQuit;
|
||||
const action = try self.leader.handleEvent(input.normalize(raw));
|
||||
defer action.deinit(self.allocator);
|
||||
try self.applyLeaderAction(action);
|
||||
}
|
||||
|
||||
pub fn saved(self: *const Client) ![]const u8 {
|
||||
return self.saved_bytes orelse Error.NothingSaved;
|
||||
}
|
||||
|
||||
fn handleTraceKey(self: *Client, key_name: []const u8) !void {
|
||||
if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" ");
|
||||
if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r");
|
||||
if (std.mem.eql(u8, key_name, "backspace")) return self.handleInput("\x7f");
|
||||
if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b");
|
||||
if (key_name.len == 1) return self.handleInput(key_name);
|
||||
return Error.UnknownTraceEvent;
|
||||
}
|
||||
|
||||
fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void {
|
||||
switch (action) {
|
||||
.none => {},
|
||||
.save => try self.save(),
|
||||
.quit => self.quit = true,
|
||||
.open => |path| {
|
||||
const line = try std.fmt.allocPrint(self.allocator, "open {s}", .{path});
|
||||
defer self.allocator.free(line);
|
||||
try self.applyProtocol(line);
|
||||
},
|
||||
.not_built => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn applyProtocolCommand(self: *Client, line: []const u8) !void {
|
||||
const command = try std.fmt.allocPrint(self.allocator, "command {s}", .{line});
|
||||
defer self.allocator.free(command);
|
||||
@@ -294,3 +339,78 @@ test "adversarial: quit prevents further edits" {
|
||||
|
||||
try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x"));
|
||||
}
|
||||
|
||||
test "regular: leader rail is visible in terminal render and dispatches save" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open abc");
|
||||
try client.handleTraceLine("key space");
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "leader: s save") != null);
|
||||
|
||||
try client.handleTraceLine("key s");
|
||||
try std.testing.expectEqualStrings("abc", try client.saved());
|
||||
}
|
||||
|
||||
test "regular: leader rail dispatches quit" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 4 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open abc");
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key q");
|
||||
try std.testing.expect(client.quit);
|
||||
try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x"));
|
||||
}
|
||||
|
||||
test "regular: leader open prompt opens typed UTF-8 payload" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open old");
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key o");
|
||||
try client.handleTraceLine("type café.zig");
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "café.zig") != null);
|
||||
|
||||
try client.handleTraceLine("key enter");
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key s");
|
||||
try std.testing.expectEqualStrings("café.zig", try client.saved());
|
||||
}
|
||||
|
||||
test "regular: leader search and panel-close entries recover as not-built messages" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
||||
defer client.deinit();
|
||||
try client.handleTraceLine("open abc");
|
||||
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key /");
|
||||
const search_frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(search_frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, search_frame, "search is not built") != null);
|
||||
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key x");
|
||||
const close_frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(close_frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, close_frame, "panel close is not built") != null);
|
||||
}
|
||||
|
||||
test "adversarial: unknown leader input recovers without saving or quitting" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 4 });
|
||||
defer client.deinit();
|
||||
try client.handleTraceLine("open abc");
|
||||
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key ?");
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "unknown leader key") != null);
|
||||
try std.testing.expect(!client.quit);
|
||||
try std.testing.expectError(Error.NothingSaved, client.saved());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user