Add composable object selections

This commit is contained in:
slhx agent
2026-06-21 12:41:43 +02:00
parent a08ee02820
commit 30af1e97bc
2 changed files with 461 additions and 2 deletions
+38
View File
@@ -370,6 +370,44 @@ pub const Session = struct {
.active_panel_title = panel_state.active_title,
};
}
pub fn moveToByte(self: *Session, byte: usize) !void {
if (self.buffer) |*buffer| {
if (byte > buffer.bytes.items.len) return error.SelectionOutOfBounds;
buffer.cursor.byte = byte;
buffer.refreshCell();
return;
}
return error.NoActiveBuffer;
}
pub fn selectRange(self: *Session, start: usize, end: usize) !void {
if (self.buffer) |*buffer| {
if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds;
buffer.selection = .{ .anchor = start, .cursor = end };
buffer.cursor.byte = end;
buffer.refreshCell();
return;
}
return error.NoActiveBuffer;
}
pub fn clearSelection(self: *Session) void {
if (self.buffer) |*buffer| buffer.selection = null;
}
pub fn replaceRange(self: *Session, start: usize, end: usize, bytes: []const u8) !void {
if (!std.unicode.utf8ValidateSlice(bytes)) return error.InvalidUtf8Insertion;
if (self.buffer) |*buffer| {
if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds;
try buffer.bytes.replaceRange(buffer.allocator, start, end - start, bytes);
buffer.cursor.byte = start + bytes.len;
buffer.refreshCell();
buffer.selection = null;
return;
}
return error.NoActiveBuffer;
}
};
fn isWordSeparator(byte: u8) bool {
+423 -2
View File
@@ -45,6 +45,16 @@ const EditSnapshot = struct {
cursor_byte: usize,
};
const ObjectRange = struct {
start: usize,
end: usize,
};
const PairRange = struct {
open: usize,
close: usize,
};
pub const Error = error{
ViewportTooSmall,
InvalidResize,
@@ -845,6 +855,13 @@ pub const Client = struct {
}
if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match);
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
if (text.len == 1) {
if (self.objectRangeForKey(text[0])) |range| {
try self.session.selectRange(range.start, range.end);
self.message = "selected object";
return;
} else |_| {}
}
self.unknownPrefixOrInput("select");
},
.key => |key| switch (key) {
@@ -887,7 +904,9 @@ pub const Client = struct {
switch (active) {
.none => {},
.insert_space => try self.applyInsertSpaceRail(event),
.match => self.applyKnownRailOrMessage(event, "match rail ready"),
.match => self.applyMatchRail(event) catch |err| {
self.message = @errorName(err);
},
.go => self.applyKnownRailOrMessage(event, "go rail ready"),
.repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"),
.delete => try self.applyDeleteRail(event),
@@ -954,6 +973,33 @@ pub const Client = struct {
self.message = null;
}
fn applyMatchRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
if (std.mem.eql(u8, text, "m")) return self.jumpToMatch(null);
if (std.mem.eql(u8, text, "s")) {
const range = try self.matchRange(null, false);
try self.session.selectRange(range.start, range.end);
self.message = "selected inside pair";
return;
}
if (std.mem.eql(u8, text, "a")) {
const range = try self.matchRange(null, true);
try self.session.selectRange(range.start, range.end);
self.message = "selected around pair";
return;
}
if (text.len == 1 and isPairSelector(text[0])) {
if (self.effectiveMode() == .select) {
const range = try self.matchRange(text[0], true);
try self.session.selectRange(range.start, range.end);
} else {
try self.jumpToMatch(text[0]);
}
return;
}
self.unknownPrefixOrInput("normal");
}
fn applyDeleteRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
if (std.mem.eql(u8, text, "d")) {
@@ -963,6 +1009,9 @@ pub const Client = struct {
}
if (std.mem.eql(u8, text, "h")) return self.applyMutatingProtocol("command delete_backward");
if (std.mem.eql(u8, text, "l")) return self.applyMutatingProtocol("command delete_forward");
if (text.len == 1) {
if (self.objectRangeForKey(text[0])) |range| return self.deleteRange(range) else |_| {}
}
self.unknownPrefixOrInput("normal");
}
@@ -987,6 +1036,12 @@ pub const Client = struct {
self.message = "insert";
return;
}
if (text.len == 1) {
if (self.objectRangeForKey(text[0])) |range| {
try self.changeRange(range);
return;
} else |_| {}
}
self.unknownPrefixOrInput("normal");
}
@@ -997,6 +1052,13 @@ pub const Client = struct {
self.message = "yanked line";
return;
}
if (text.len == 1) {
if (self.objectRangeForKey(text[0])) |range| {
try self.yankRange(range);
self.message = "yanked object";
return;
} else |_| {}
}
self.unknownPrefixOrInput("normal");
}
@@ -1018,6 +1080,36 @@ pub const Client = struct {
};
}
fn deleteRange(self: *Client, range: ObjectRange) !void {
try self.yankRange(range);
try self.recordUndo();
self.clearRedo();
self.session.replaceRange(range.start, range.end, "") catch |err| {
self.dropLastUndoSnapshot();
return err;
};
}
fn changeRange(self: *Client, range: ObjectRange) !void {
try self.yankRange(range);
try self.recordUndo();
self.clearRedo();
self.session.replaceRange(range.start, range.end, "") catch |err| {
self.dropLastUndoSnapshot();
return err;
};
self.mode = .insert;
self.message = "insert";
}
fn yankRange(self: *Client, range: ObjectRange) !void {
const snap = try self.session.snapshot();
if (range.start > range.end or range.end > snap.bytes.len) return error.SelectionOutOfBounds;
const copy = try self.allocator.dupe(u8, snap.bytes[range.start..range.end]);
if (self.yank_bytes) |old| self.allocator.free(old);
self.yank_bytes = copy;
}
fn pasteRegister(self: *Client) !void {
const bytes = self.yank_bytes orelse {
self.message = "nothing yanked";
@@ -1072,6 +1164,102 @@ pub const Client = struct {
return .{ .bytes = try self.allocator.dupe(u8, snap.bytes), .cursor_byte = snap.cursor_byte };
}
fn objectRangeForKey(self: *Client, key: u8) !ObjectRange {
return switch (key) {
'w' => try self.wordRange(),
'l' => try self.lineRange(true),
'i' => try self.indentRange(),
'p' => try self.parameterRange(),
'f' => try self.enclosingFormRange(),
'd' => try self.lineRange(false),
else => error.UnsupportedObject,
};
}
fn wordRange(self: *Client) !ObjectRange {
const snap = try self.session.snapshot();
var start = snap.cursor_byte;
while (start > 0 and !isObjectSeparator(snap.bytes[previousBoundaryLocal(snap.bytes, start)])) start = previousBoundaryLocal(snap.bytes, start);
var end = snap.cursor_byte;
while (end < snap.bytes.len and !isObjectSeparator(snap.bytes[end])) end = nextBoundaryLocal(snap.bytes, end);
return .{ .start = start, .end = end };
}
fn lineRange(self: *Client, include_newline: bool) !ObjectRange {
const snap = try self.session.snapshot();
var start = snap.cursor_byte;
while (start > 0 and snap.bytes[start - 1] != '\n') start -= 1;
var end = snap.cursor_byte;
while (end < snap.bytes.len and snap.bytes[end] != '\n') end += 1;
if (include_newline and end < snap.bytes.len) end += 1;
return .{ .start = start, .end = end };
}
fn indentRange(self: *Client) !ObjectRange {
const snap = try self.session.snapshot();
const current = try self.lineRange(false);
const indent = lineIndent(snap.bytes[current.start..current.end]);
var start = current.start;
while (start > 0) {
const prev_end = start - 1;
var prev_start = prev_end;
while (prev_start > 0 and snap.bytes[prev_start - 1] != '\n') prev_start -= 1;
if (lineContentLen(snap.bytes[prev_start..prev_end]) != 0 and lineIndent(snap.bytes[prev_start..prev_end]) < indent) break;
start = prev_start;
}
var end = current.end;
while (end < snap.bytes.len) {
const next_start = if (end < snap.bytes.len and snap.bytes[end] == '\n') end + 1 else end;
if (next_start >= snap.bytes.len) break;
var next_end = next_start;
while (next_end < snap.bytes.len and snap.bytes[next_end] != '\n') next_end += 1;
if (lineContentLen(snap.bytes[next_start..next_end]) != 0 and lineIndent(snap.bytes[next_start..next_end]) < indent) break;
end = next_end;
}
return .{ .start = start, .end = end };
}
fn parameterRange(self: *Client) !ObjectRange {
const around = try self.matchRange('(', false);
const snap = try self.session.snapshot();
var start = around.start;
var end = around.end;
var at = around.start;
while (at < around.end) : (at += 1) {
if (snap.bytes[at] == ',' and at < snap.cursor_byte) start = at + 1;
if (snap.bytes[at] == ',' and at >= snap.cursor_byte) {
end = at;
break;
}
}
return trimRange(snap.bytes, .{ .start = start, .end = end });
}
fn enclosingFormRange(self: *Client) !ObjectRange {
return self.matchRange('{', true) catch self.matchRange('(', true);
}
fn jumpToMatch(self: *Client, selector: ?u8) !void {
const pair = try self.matchPair(selector);
const snap = try self.session.snapshot();
const target = if (snap.cursor_byte <= pair.open) pair.close else pair.open;
try self.session.moveToByte(target);
self.message = "matched pair";
}
fn matchRange(self: *Client, selector: ?u8, around: bool) !ObjectRange {
const pair = try self.matchPair(selector);
return if (around) .{ .start = pair.open, .end = pair.close + 1 } else .{ .start = pair.open + 1, .end = pair.close };
}
fn matchPair(self: *Client, selector: ?u8) !PairRange {
const snap = try self.session.snapshot();
if (snap.bytes.len == 0) return error.NoMatch;
if (selector) |sel| return findPairForSelector(snap.bytes, snap.cursor_byte, sel) orelse error.NoMatch;
if (findPairAtOrNear(snap.bytes, snap.cursor_byte)) |pair| return pair;
return error.NoMatch;
}
fn insertText(self: *Client, text: []const u8) !void {
const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text});
defer self.allocator.free(line);
@@ -2604,7 +2792,7 @@ test "regular: modal rails expose match go repeat and unknown recovery" {
try std.testing.expectEqualStrings("none", client.pendingRailName());
const unknown_frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(unknown_frame);
try std.testing.expect(std.mem.indexOf(u8, unknown_frame, "unknown prefix key") != null);
try std.testing.expect(std.mem.indexOf(u8, unknown_frame, "unknown normal key") != null);
try client.handleInput("g");
try std.testing.expectEqualStrings("go", client.pendingRailName());
@@ -2749,3 +2937,236 @@ test "adversarial: replace rejects invalid utf8 and undo preserves content" {
defer std.testing.allocator.free(after_undo);
try std.testing.expectEqualStrings("safe", after_undo);
}
fn isObjectSeparator(byte: u8) bool {
return std.ascii.isWhitespace(byte) or std.mem.indexOfScalar(u8, "(){}[]<>.,;:+-*/=\"'`", byte) != null;
}
fn previousBoundaryLocal(bytes: []const u8, cursor: usize) usize {
return session_mod.boundaryAtOrBefore(bytes, if (cursor == 0) 0 else cursor - 1);
}
fn nextBoundaryLocal(bytes: []const u8, cursor: usize) usize {
var at = cursor + 1;
while (at < bytes.len and (bytes[at] & 0b1100_0000) == 0b1000_0000) at += 1;
return @min(at, bytes.len);
}
fn lineIndent(bytes: []const u8) usize {
var count: usize = 0;
while (count < bytes.len and (bytes[count] == ' ' or bytes[count] == '\t')) count += 1;
return count;
}
fn lineContentLen(bytes: []const u8) usize {
for (bytes) |byte| if (!std.ascii.isWhitespace(byte)) return bytes.len;
return 0;
}
fn trimRange(bytes: []const u8, range: ObjectRange) ObjectRange {
var start = range.start;
var end = range.end;
while (start < end and std.ascii.isWhitespace(bytes[start])) start += 1;
while (end > start and std.ascii.isWhitespace(bytes[end - 1])) end -= 1;
return .{ .start = start, .end = end };
}
fn isPairSelector(byte: u8) bool {
return std.mem.indexOfScalar(u8, "({[\"'`", byte) != null;
}
fn pairChars(selector: u8) struct { open: u8, close: u8, quote: bool } {
return switch (selector) {
'(' => .{ .open = '(', .close = ')', .quote = false },
'{' => .{ .open = '{', .close = '}', .quote = false },
'[' => .{ .open = '[', .close = ']', .quote = false },
'\"' => .{ .open = '\"', .close = '\"', .quote = true },
'\'' => .{ .open = '\'', .close = '\'', .quote = true },
'`' => .{ .open = '`', .close = '`', .quote = true },
else => .{ .open = '(', .close = ')', .quote = false },
};
}
fn findPairForSelector(bytes: []const u8, cursor: usize, selector: u8) ?PairRange {
const chars = pairChars(selector);
if (chars.quote) return findQuotePair(bytes, cursor, chars.open);
var depth: usize = 0;
var open: ?usize = null;
var i: usize = @min(cursor, bytes.len);
while (i > 0) {
i -= 1;
if (bytes[i] == chars.close) depth += 1 else if (bytes[i] == chars.open) {
if (depth == 0) {
open = i;
break;
}
depth -= 1;
}
}
const found_open = open orelse return null;
depth = 0;
i = found_open;
while (i < bytes.len) : (i += 1) {
if (bytes[i] == chars.open) depth += 1 else if (bytes[i] == chars.close) {
depth -= 1;
if (depth == 0) return .{ .open = found_open, .close = i };
}
}
return null;
}
fn findQuotePair(bytes: []const u8, cursor: usize, quote: u8) ?PairRange {
var open: ?usize = null;
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
if (bytes[i] != quote or (i > 0 and bytes[i - 1] == '\\')) continue;
if (open == null) open = i else {
const start = open.?;
if (cursor >= start and cursor <= i) return .{ .open = start, .close = i };
open = null;
}
}
return null;
}
fn findPairAtOrNear(bytes: []const u8, cursor: usize) ?PairRange {
const selectors = "({[\"'`";
if (cursor < bytes.len and isPairSelector(bytes[cursor])) {
if (findPairForSelector(bytes, cursor + 1, bytes[cursor])) |pair| return pair;
}
if (cursor > 0 and isPairSelector(bytes[cursor - 1])) {
if (findPairForSelector(bytes, cursor, bytes[cursor - 1])) |pair| return pair;
}
for (selectors) |selector| {
if (findPairForSelector(bytes, cursor, selector)) |pair| return pair;
}
return null;
}
test "regular: match rail jumps and selects delimiter pairs" {
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 10 });
defer client.deinit();
try client.handleTraceLine("open fn main() { call(one, two); }");
try client.handleInput("w");
try client.handleInput("w");
try client.handleInput("w");
try client.handleInput("m");
try client.handleInput("(");
var snap = try client.session.snapshot();
try std.testing.expect(snap.cursor_byte > 7);
try client.handleInput("m");
try client.handleInput("s");
snap = try client.session.snapshot();
try std.testing.expect(snap.selection != null);
try std.testing.expectEqualStrings("one, two", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
try client.handleInput("m");
try client.handleInput("a");
snap = try client.session.snapshot();
try std.testing.expectEqualStrings("(one, two)", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
}
test "regular: select mode object grammar selects word line indent parameter and form" {
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 });
defer client.deinit();
try client.handleTraceLine("open fn main() {\n alpha(beta, gamma);\n next();\n}\n");
try client.handleInput("j");
try client.handleInput("w");
try client.handleInput("s");
try client.handleInput("w");
var snap = try client.session.snapshot();
try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
try client.handleInput("n");
try client.handleInput("s");
try client.handleInput("l");
snap = try client.session.snapshot();
try std.testing.expectEqualStrings(" alpha(beta, gamma);\n", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
try client.handleInput("n");
try client.handleInput("s");
try client.handleInput("i");
snap = try client.session.snapshot();
try std.testing.expectEqualStrings(" alpha(beta, gamma);\n next();", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
var param_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 });
defer param_client.deinit();
try param_client.handleTraceLine("open call(beta, gamma)");
try param_client.handleTraceLine("right");
try param_client.handleTraceLine("right");
try param_client.handleTraceLine("right");
try param_client.handleTraceLine("right");
try param_client.handleTraceLine("right");
try param_client.handleTraceLine("right");
try param_client.handleInput("s");
try param_client.handleInput("p");
var param_snap = try param_client.session.snapshot();
try std.testing.expectEqualStrings("beta", param_snap.bytes[param_snap.selection.?.anchor..param_snap.selection.?.cursor]);
var form_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 });
defer form_client.deinit();
try form_client.handleTraceLine("open { alpha(); }");
try form_client.handleTraceLine("right");
try form_client.handleTraceLine("right");
try form_client.handleInput("s");
try form_client.handleInput("f");
var form_snap = try form_client.session.snapshot();
try std.testing.expect(std.mem.startsWith(u8, form_snap.bytes[form_snap.selection.?.anchor..form_snap.selection.?.cursor], "{"));
}
test "regular: delete change yank compose with shared object ranges" {
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 });
defer client.deinit();
try client.handleTraceLine("open alpha beta\n one\n two\nend");
try client.handleInput("d");
try client.handleInput("w");
const after_delete_word = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_delete_word);
try std.testing.expectEqualStrings(" beta\n one\n two\nend", after_delete_word);
try client.handleInput("p");
const pasted = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(pasted);
try std.testing.expectEqualStrings("alpha beta\n one\n two\nend", pasted);
try client.handleInput("j");
try client.handleInput("c");
try client.handleInput("i");
try client.handleInput("X");
try client.handleInput(" ");
try client.handleInput("n");
const changed = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(changed);
try std.testing.expectEqualStrings("alpha beta\nX\nend", changed);
}
test "adversarial: unmatched delimiter reports without corrupting buffer" {
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open call(one");
try client.handleInput("m");
try client.handleInput("(");
const bytes = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(bytes);
try std.testing.expectEqualStrings("call(one", 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 normal key") != null or std.mem.indexOf(u8, frame, "NoMatch") != null);
}
test "regular: quote matching ignores escaped quote" {
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open say(\"a\\\"b\")");
try client.handleTraceLine("right");
try client.handleTraceLine("right");
try client.handleTraceLine("right");
try client.handleTraceLine("right");
try client.handleTraceLine("right");
try client.handleInput("m");
try client.handleInput("\"");
const snap = try client.session.snapshot();
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
}