Add coding symbol rail
This commit is contained in:
+95
-2
@@ -1,6 +1,7 @@
|
||||
const std = @import("std");
|
||||
const input = @import("input.zig");
|
||||
const session_mod = @import("session.zig");
|
||||
const symbol_mod = @import("symbol.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
|
||||
@@ -19,6 +20,7 @@ pub const Action = union(enum) {
|
||||
save,
|
||||
quit,
|
||||
open: []u8,
|
||||
symbol: symbol_mod.Symbol,
|
||||
not_built: Feature,
|
||||
|
||||
pub fn deinit(self: Action, allocator: std.mem.Allocator) void {
|
||||
@@ -32,6 +34,7 @@ pub const Action = union(enum) {
|
||||
const Mode = enum {
|
||||
idle,
|
||||
rail,
|
||||
symbol_rail,
|
||||
open_prompt,
|
||||
};
|
||||
|
||||
@@ -54,6 +57,7 @@ pub const Leader = struct {
|
||||
switch (self.mode) {
|
||||
.idle => return self.handleIdle(event),
|
||||
.rail => return self.handleRail(event),
|
||||
.symbol_rail => return self.handleSymbolRail(event),
|
||||
.open_prompt => return self.handleOpenPrompt(event),
|
||||
}
|
||||
}
|
||||
@@ -62,7 +66,8 @@ pub const Leader = struct {
|
||||
if (self.message) |message| return message;
|
||||
return switch (self.mode) {
|
||||
.idle => "",
|
||||
.rail => "leader: s save q quit o open / search x close",
|
||||
.rail => "leader: s save q quit o open p symbols / search x close",
|
||||
.symbol_rail => symbol_mod.rail_status,
|
||||
.open_prompt => "open: type path, Enter opens, Esc cancels",
|
||||
};
|
||||
}
|
||||
@@ -104,6 +109,10 @@ pub const Leader = struct {
|
||||
self.mode = .open_prompt;
|
||||
return .none;
|
||||
}
|
||||
if (std.mem.eql(u8, text, "p")) {
|
||||
self.mode = .symbol_rail;
|
||||
return .none;
|
||||
}
|
||||
if (std.mem.eql(u8, text, "/")) {
|
||||
self.mode = .idle;
|
||||
self.message = "search is not built in this profile yet";
|
||||
@@ -139,6 +148,38 @@ pub const Leader = struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn handleSymbolRail(self: *Leader, event: input.Event) Action {
|
||||
self.message = null;
|
||||
switch (event) {
|
||||
.text => |text| {
|
||||
if (symbol_mod.lookup(text)) |entry| {
|
||||
self.mode = .idle;
|
||||
return .{ .symbol = entry.symbol };
|
||||
}
|
||||
self.mode = .idle;
|
||||
self.message = "unknown symbol key";
|
||||
return .none;
|
||||
},
|
||||
.key => |key| switch (key) {
|
||||
.escape, .backspace => {
|
||||
self.mode = .idle;
|
||||
self.message = "symbol rail cancelled";
|
||||
return .none;
|
||||
},
|
||||
else => {
|
||||
self.mode = .idle;
|
||||
self.message = "unknown symbol key";
|
||||
return .none;
|
||||
},
|
||||
},
|
||||
.unknown => {
|
||||
self.mode = .idle;
|
||||
self.message = "unknown symbol key";
|
||||
return .none;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handleOpenPrompt(self: *Leader, event: input.Event) !Action {
|
||||
self.message = null;
|
||||
switch (event) {
|
||||
@@ -188,7 +229,7 @@ test "regular: space opens a visible leader rail and save dispatches" {
|
||||
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 std.testing.expectEqualStrings("leader: s save q quit o open p symbols / search x close", leader.status());
|
||||
|
||||
try expectActionTag(.save, try leader.handleEvent(input.normalize("s")));
|
||||
try std.testing.expect(!leader.isActive());
|
||||
@@ -263,3 +304,55 @@ test "adversarial: empty open and escape cancel without dispatching" {
|
||||
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b")));
|
||||
try std.testing.expectEqualStrings("open cancelled", leader.status());
|
||||
}
|
||||
|
||||
test "regular: leader symbol rail dispatches coding symbols by letters" {
|
||||
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("p")));
|
||||
try std.testing.expectEqualStrings(symbol_mod.rail_status, leader.status());
|
||||
|
||||
const action = try leader.handleEvent(input.normalize("c"));
|
||||
switch (action) {
|
||||
.symbol => |symbol| try std.testing.expectEqual(symbol_mod.Symbol.braces, symbol),
|
||||
else => return error.ExpectedSymbolAction,
|
||||
}
|
||||
}
|
||||
|
||||
test "regular: leader symbol rail uses letters for hard-to-reach punctuation" {
|
||||
var leader = Leader.init(std.testing.allocator);
|
||||
defer leader.deinit();
|
||||
|
||||
inline for (.{
|
||||
.{ "s", symbol_mod.Symbol.slash },
|
||||
.{ "v", symbol_mod.Symbol.pipe },
|
||||
.{ "q", symbol_mod.Symbol.double_quote },
|
||||
.{ "e", symbol_mod.Symbol.single_quote },
|
||||
.{ "t", symbol_mod.Symbol.backtick },
|
||||
.{ "u", symbol_mod.Symbol.underscore },
|
||||
}) |case| {
|
||||
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||
try expectActionTag(.none, try leader.handleEvent(input.normalize("p")));
|
||||
const action = try leader.handleEvent(input.normalize(case[0]));
|
||||
switch (action) {
|
||||
.symbol => |actual| try std.testing.expectEqual(case[1], actual),
|
||||
else => return error.ExpectedSymbolAction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "adversarial: unknown and cancelled symbol rail inputs do not dispatch" {
|
||||
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("p")));
|
||||
try expectActionTag(.none, try leader.handleEvent(input.normalize("?")));
|
||||
try std.testing.expectEqualStrings("unknown symbol key", leader.status());
|
||||
|
||||
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||
try expectActionTag(.none, try leader.handleEvent(input.normalize("p")));
|
||||
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b")));
|
||||
try std.testing.expectEqualStrings("symbol rail cancelled", leader.status());
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const protocol = @import("protocol.zig");
|
||||
const replay = @import("replay.zig");
|
||||
const session = @import("session.zig");
|
||||
const socket = @import("socket.zig");
|
||||
const symbol = @import("symbol.zig");
|
||||
const tui = @import("tui.zig");
|
||||
|
||||
pub const version = "0.1.0-dev";
|
||||
@@ -126,6 +127,7 @@ test {
|
||||
_ = replay;
|
||||
_ = session;
|
||||
_ = socket;
|
||||
_ = symbol;
|
||||
_ = tui;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,23 @@ fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session,
|
||||
if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right);
|
||||
if (std.mem.eql(u8, command, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward);
|
||||
if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] });
|
||||
if (std.mem.startsWith(u8, command, "pair ")) {
|
||||
const pair = pairByName(command[5..]) orelse return allocator.dupe(u8, "err unknown pair\n");
|
||||
return dispatchAndRespond(allocator, session, .{ .insert_pair = pair });
|
||||
}
|
||||
return allocator.dupe(u8, "err unknown command\n");
|
||||
}
|
||||
|
||||
fn pairByName(name: []const u8) ?session_mod.Pair {
|
||||
if (std.mem.eql(u8, name, "parens")) return .{ .open = "(", .close = ")" };
|
||||
if (std.mem.eql(u8, name, "brackets")) return .{ .open = "[", .close = "]" };
|
||||
if (std.mem.eql(u8, name, "braces")) return .{ .open = "{", .close = "}" };
|
||||
if (std.mem.eql(u8, name, "double_quote")) return .{ .open = "\"", .close = "\"" };
|
||||
if (std.mem.eql(u8, name, "single_quote")) return .{ .open = "'", .close = "'" };
|
||||
if (std.mem.eql(u8, name, "backtick")) return .{ .open = "`", .close = "`" };
|
||||
return null;
|
||||
}
|
||||
|
||||
fn dispatchAndRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: session_mod.Command) ![]u8 {
|
||||
session.dispatch(command) catch |err| switch (err) {
|
||||
error.InvalidUtf8Insertion => return allocator.dupe(u8, "err invalid utf8\n"),
|
||||
@@ -117,3 +131,35 @@ test "adversarial: protocol reports no buffer and invalid utf8 without corruptin
|
||||
const after = try session.snapshot();
|
||||
try std.testing.expectEqualStrings(before.bytes, after.bytes);
|
||||
}
|
||||
|
||||
test "regular: protocol pair commands insert delimiters with cursor between them" {
|
||||
var session = session_mod.Session.init(std.testing.allocator);
|
||||
defer session.deinit();
|
||||
|
||||
var response = try handleLine(std.testing.allocator, &session, "open call\n");
|
||||
std.testing.allocator.free(response);
|
||||
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
|
||||
std.testing.allocator.free(response);
|
||||
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
|
||||
std.testing.allocator.free(response);
|
||||
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
|
||||
std.testing.allocator.free(response);
|
||||
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
|
||||
std.testing.allocator.free(response);
|
||||
response = try handleLine(std.testing.allocator, &session, "command pair parens\n");
|
||||
defer std.testing.allocator.free(response);
|
||||
|
||||
try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=5 bytes_len=6\n", response);
|
||||
try std.testing.expectEqualStrings("call()", (try session.snapshot()).bytes);
|
||||
}
|
||||
|
||||
test "adversarial: protocol rejects unknown pair names" {
|
||||
var session = session_mod.Session.init(std.testing.allocator);
|
||||
defer session.deinit();
|
||||
try session.openFixture("abc");
|
||||
|
||||
const response = try handleLine(std.testing.allocator, &session, "command pair snippet_everything\n");
|
||||
defer std.testing.allocator.free(response);
|
||||
try std.testing.expectEqualStrings("err unknown pair\n", response);
|
||||
try std.testing.expectEqualStrings("abc", (try session.snapshot()).bytes);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,16 @@ pub const Selection = struct {
|
||||
cursor: usize,
|
||||
};
|
||||
|
||||
pub const Pair = struct {
|
||||
open: []const u8,
|
||||
close: []const u8,
|
||||
};
|
||||
|
||||
pub const Command = union(enum) {
|
||||
move_left,
|
||||
move_right,
|
||||
insert: []const u8,
|
||||
insert_pair: Pair,
|
||||
delete_backward,
|
||||
};
|
||||
|
||||
@@ -62,6 +68,7 @@ pub const Buffer = struct {
|
||||
.move_left => self.moveLeft(),
|
||||
.move_right => self.moveRight(),
|
||||
.insert => |text| try self.insert(text),
|
||||
.insert_pair => |pair| try self.insertPair(pair),
|
||||
.delete_backward => self.deleteBackward(),
|
||||
}
|
||||
}
|
||||
@@ -84,6 +91,16 @@ pub const Buffer = struct {
|
||||
self.selection = null;
|
||||
}
|
||||
|
||||
pub fn insertPair(self: *Buffer, pair: Pair) !void {
|
||||
if (!std.unicode.utf8ValidateSlice(pair.open) or !std.unicode.utf8ValidateSlice(pair.close)) return error.InvalidUtf8Insertion;
|
||||
const combined = try std.mem.concat(self.allocator, u8, &.{ pair.open, pair.close });
|
||||
defer self.allocator.free(combined);
|
||||
try self.bytes.insertSlice(self.allocator, self.cursor.byte, combined);
|
||||
self.cursor.byte += pair.open.len;
|
||||
self.refreshCell();
|
||||
self.selection = null;
|
||||
}
|
||||
|
||||
pub fn deleteBackward(self: *Buffer) void {
|
||||
if (self.cursor.byte == 0) return;
|
||||
const start = previousBoundary(self.bytes.items, self.cursor.byte);
|
||||
@@ -265,3 +282,29 @@ test "adversarial: dispatch requires an open buffer" {
|
||||
try std.testing.expectError(error.NoBufferOpen, session.dispatch(.move_right));
|
||||
try std.testing.expectError(error.NoBufferOpen, session.snapshot());
|
||||
}
|
||||
|
||||
test "regular: pair insertion places cursor between delimiters" {
|
||||
var buffer = try Buffer.openFromBytes(std.testing.allocator, "call");
|
||||
defer buffer.deinit();
|
||||
|
||||
buffer.moveRight();
|
||||
buffer.moveRight();
|
||||
buffer.moveRight();
|
||||
buffer.moveRight();
|
||||
try buffer.insertPair(.{ .open = "(", .close = ")" });
|
||||
|
||||
const snap = buffer.snapshot();
|
||||
try std.testing.expectEqualStrings("call()", snap.bytes);
|
||||
try std.testing.expectEqual(@as(usize, 5), snap.cursor_byte);
|
||||
try std.testing.expectEqual(@as(usize, 5), snap.cursor_cell);
|
||||
}
|
||||
|
||||
test "adversarial: invalid UTF-8 pair insertion is rejected and existing bytes are preserved" {
|
||||
var buffer = try Buffer.openFromBytes(std.testing.allocator, "safe");
|
||||
defer buffer.deinit();
|
||||
|
||||
const bad = [_]u8{ 0xc3, 0x28 };
|
||||
try std.testing.expectError(error.InvalidUtf8Insertion, buffer.insertPair(.{ .open = &bad, .close = ")" }));
|
||||
try std.testing.expectEqualStrings("safe", buffer.snapshot().bytes);
|
||||
try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
const std = @import("std");
|
||||
|
||||
// Source-patched coding symbol rail table.
|
||||
// req: input/003, governance/001, testing/001, testing/002, testing/003, testing/004
|
||||
|
||||
test {
|
||||
_ = lookup;
|
||||
}
|
||||
|
||||
pub const Symbol = enum {
|
||||
parens,
|
||||
brackets,
|
||||
braces,
|
||||
slash,
|
||||
pipe,
|
||||
double_quote,
|
||||
single_quote,
|
||||
backtick,
|
||||
underscore,
|
||||
};
|
||||
|
||||
pub const Entry = struct {
|
||||
key: []const u8,
|
||||
symbol: Symbol,
|
||||
label: []const u8,
|
||||
protocol_command: []const u8,
|
||||
};
|
||||
|
||||
pub const entries = [_]Entry{
|
||||
.{ .key = "p", .symbol = .parens, .label = "()", .protocol_command = "command pair parens" },
|
||||
.{ .key = "b", .symbol = .brackets, .label = "[]", .protocol_command = "command pair brackets" },
|
||||
.{ .key = "c", .symbol = .braces, .label = "{}", .protocol_command = "command pair braces" },
|
||||
.{ .key = "s", .symbol = .slash, .label = "/", .protocol_command = "command insert /" },
|
||||
.{ .key = "v", .symbol = .pipe, .label = "|", .protocol_command = "command insert |" },
|
||||
.{ .key = "q", .symbol = .double_quote, .label = "\"\"", .protocol_command = "command pair double_quote" },
|
||||
.{ .key = "e", .symbol = .single_quote, .label = "''", .protocol_command = "command pair single_quote" },
|
||||
.{ .key = "t", .symbol = .backtick, .label = "``", .protocol_command = "command pair backtick" },
|
||||
.{ .key = "u", .symbol = .underscore, .label = "_", .protocol_command = "command insert _" },
|
||||
};
|
||||
|
||||
pub const rail_status = "symbols: p () b [] c {} s / v | q \"\" e '' t `` u _";
|
||||
|
||||
pub fn lookup(key: []const u8) ?Entry {
|
||||
for (entries) |entry| {
|
||||
if (std.mem.eql(u8, key, entry.key)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn protocolCommand(symbol: Symbol) []const u8 {
|
||||
for (entries) |entry| {
|
||||
if (entry.symbol == symbol) return entry.protocol_command;
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
|
||||
test "regular: symbol rail maps mobile-friendly letters to coding punctuation" {
|
||||
try std.testing.expectEqual(Symbol.parens, lookup("p").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.brackets, lookup("b").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.braces, lookup("c").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.slash, lookup("s").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.pipe, lookup("v").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.double_quote, lookup("q").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.single_quote, lookup("e").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.backtick, lookup("t").?.symbol);
|
||||
try std.testing.expectEqual(Symbol.underscore, lookup("u").?.symbol);
|
||||
}
|
||||
|
||||
test "regular: symbol rail protocol commands stay tiny and explicit" {
|
||||
try std.testing.expectEqualStrings("command pair parens", protocolCommand(.parens));
|
||||
try std.testing.expectEqualStrings("command pair brackets", protocolCommand(.brackets));
|
||||
try std.testing.expectEqualStrings("command pair braces", protocolCommand(.braces));
|
||||
try std.testing.expectEqualStrings("command insert /", protocolCommand(.slash));
|
||||
try std.testing.expectEqualStrings("command insert |", protocolCommand(.pipe));
|
||||
try std.testing.expectEqualStrings("command pair double_quote", protocolCommand(.double_quote));
|
||||
try std.testing.expectEqualStrings("command pair single_quote", protocolCommand(.single_quote));
|
||||
try std.testing.expectEqualStrings("command pair backtick", protocolCommand(.backtick));
|
||||
try std.testing.expectEqualStrings("command insert _", protocolCommand(.underscore));
|
||||
}
|
||||
|
||||
test "adversarial: unknown symbol key does not map to a symbol" {
|
||||
try std.testing.expectEqual(@as(?Entry, null), lookup("x"));
|
||||
try std.testing.expectEqual(@as(?Entry, null), lookup("/"));
|
||||
try std.testing.expectEqual(@as(?Entry, null), lookup(""));
|
||||
}
|
||||
+53
@@ -4,6 +4,7 @@ const leader_mod = @import("leader.zig");
|
||||
const protocol = @import("protocol.zig");
|
||||
const replay = @import("replay.zig");
|
||||
const session_mod = @import("session.zig");
|
||||
const symbol_mod = @import("symbol.zig");
|
||||
|
||||
// First terminal thin client surface, scriptable for E2E-style tests.
|
||||
// req: session/001, session/003, ui/001, coding/001, testing/001, testing/002
|
||||
@@ -180,6 +181,7 @@ pub const Client = struct {
|
||||
defer self.allocator.free(line);
|
||||
try self.applyProtocol(line);
|
||||
},
|
||||
.symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)),
|
||||
.not_built => {},
|
||||
}
|
||||
}
|
||||
@@ -414,3 +416,54 @@ test "adversarial: unknown leader input recovers without saving or quitting" {
|
||||
try std.testing.expect(!client.quit);
|
||||
try std.testing.expectError(Error.NothingSaved, client.saved());
|
||||
}
|
||||
|
||||
fn expectSymbolTrace(symbol_key: []const u8, expected_bytes: []const u8, expected_cursor_byte: usize) !void {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open base");
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key p");
|
||||
const rail = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(rail);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rail, "symbols:") != null);
|
||||
|
||||
const line = try std.fmt.allocPrint(std.testing.allocator, "key {s}", .{symbol_key});
|
||||
defer std.testing.allocator.free(line);
|
||||
try client.handleTraceLine(line);
|
||||
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings(expected_bytes, snap.bytes);
|
||||
try std.testing.expectEqual(expected_cursor_byte, snap.cursor_byte);
|
||||
}
|
||||
|
||||
test "regular: symbol rail inserts paired braces brackets parens and quotes with cursor inside" {
|
||||
try expectSymbolTrace("p", "()base", 1);
|
||||
try expectSymbolTrace("b", "[]base", 1);
|
||||
try expectSymbolTrace("c", "{}base", 1);
|
||||
try expectSymbolTrace("q", "\"\"base", 1);
|
||||
try expectSymbolTrace("e", "''base", 1);
|
||||
try expectSymbolTrace("t", "``base", 1);
|
||||
}
|
||||
|
||||
test "regular: symbol rail inserts slash pipe and underscore as single symbols" {
|
||||
try expectSymbolTrace("s", "/base", 1);
|
||||
try expectSymbolTrace("v", "|base", 1);
|
||||
try expectSymbolTrace("u", "_base", 1);
|
||||
}
|
||||
|
||||
test "adversarial: unknown symbol key does not mutate the buffer" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open base");
|
||||
try client.handleTraceLine("key space");
|
||||
try client.handleTraceLine("key p");
|
||||
try client.handleTraceLine("key ?");
|
||||
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings("base", snap.bytes);
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "unknown symbol key") != null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user