Compare commits

...

8 commits

Author SHA1 Message Date
2461717f11
feat: add support for byte offsets in file links to navigate command 2025-09-17 22:47:50 +02:00
7228a604b0
feat: add byte offset support to vim style '+' cli arguments
This adds support for using `+b{offset}` on the command line.
2025-09-17 22:46:35 +02:00
219b8cd00a
feat: support byte offsets in file links
This adds support for a 'b' prefix to the first file link argument
to denote a byte offset.

`{path/to/file.ext}:b{offset}`
2025-09-17 22:42:25 +02:00
7c5a22c959
feat: add goto_offset keybind "b" in goto mini mode
This effectively makes `ctrl+g b` the goto_offset keybinding.
2025-09-17 22:18:45 +02:00
30a457158c
feat: add goto_offset mini mode and command 2025-09-17 22:18:20 +02:00
18cd62ba7e
feat: add editor goto_byte_offset command 2025-09-17 22:17:48 +02:00
935b178d89
feat: add Buffer.Node.byte_offset_to_line_and_col and testcase 2025-09-17 22:17:00 +02:00
1658c9e3b4
refactor: add crlf mode testcase for Buffer.Node.get_byte_pos 2025-09-17 22:16:07 +02:00
9 changed files with 222 additions and 15 deletions

View file

@ -794,6 +794,35 @@ const Node = union(enum) {
return if (found) ctx.result else error.NotFound; return if (found) ctx.result else error.NotFound;
} }
pub fn byte_offset_to_line_and_col(self: *const Node, pos: usize, metrics: Metrics, eol_mode: EolMode) Cursor {
const ctx_ = struct {
pos: usize,
line: usize = 0,
col: usize = 0,
eol_mode: EolMode,
fn walker(ctx_: *anyopaque, egc: []const u8, wcwidth: usize, _: Metrics) Walker {
const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_)));
if (egc[0] == '\n') {
ctx.pos -= switch (ctx.eol_mode) {
.lf => 1,
.crlf => @min(2, ctx.pos),
};
if (ctx.pos == 0) return Walker.stop;
ctx.line += 1;
ctx.col = 0;
} else {
ctx.pos -= @min(egc.len, ctx.pos);
if (ctx.pos == 0) return Walker.stop;
ctx.col += wcwidth;
}
return Walker.keep_walking;
}
};
var ctx: ctx_ = .{ .pos = pos + 1, .eol_mode = eol_mode };
self.walk_egc_forward(0, ctx_.walker, &ctx, metrics) catch {};
return .{ .row = ctx.line, .col = ctx.col };
}
pub fn insert_chars( pub fn insert_chars(
self_: *const Node, self_: *const Node,
line_: usize, line_: usize,

View file

@ -13,6 +13,7 @@ pub const FileDest = struct {
column: ?usize = null, column: ?usize = null,
end_column: ?usize = null, end_column: ?usize = null,
exists: bool = false, exists: bool = false,
offset: ?usize = null,
}; };
pub const DirDest = struct { pub const DirDest = struct {
@ -37,11 +38,17 @@ pub fn parse(link: []const u8) error{InvalidFileLink}!Dest {
.{ .file = .{ .path = it.first() } }; .{ .file = .{ .path = it.first() } };
switch (dest) { switch (dest) {
.file => |*file| { .file => |*file| {
if (it.next()) |line_| if (it.next()) |line_| if (line_.len > 0 and line_[0] == 'b') {
file.offset = std.fmt.parseInt(usize, line_[1..], 10) catch blk: {
file.path = link;
break :blk null;
};
} else {
file.line = std.fmt.parseInt(usize, line_, 10) catch blk: { file.line = std.fmt.parseInt(usize, line_, 10) catch blk: {
file.path = link; file.path = link;
break :blk null; break :blk null;
}; };
};
if (file.line) |_| if (it.next()) |col_| { if (file.line) |_| if (it.next()) |col_| {
file.column = std.fmt.parseInt(usize, col_, 10) catch null; file.column = std.fmt.parseInt(usize, col_, 10) catch null;
}; };
@ -88,6 +95,9 @@ pub fn parse_bracket_link(link: []const u8) error{InvalidFileLink}!Dest {
pub fn navigate(to: tp.pid_ref, link: *const Dest) anyerror!void { pub fn navigate(to: tp.pid_ref, link: *const Dest) anyerror!void {
switch (link.*) { switch (link.*) {
.file => |file| { .file => |file| {
if (file.offset) |offset| {
return to.send(.{ "cmd", "navigate", .{ .file = file.path, .offset = offset } });
}
if (file.line) |l| { if (file.line) |l| {
if (file.column) |col| { if (file.column) |col| {
try to.send(.{ "cmd", "navigate", .{ .file = file.path, .line = l, .column = col } }); try to.send(.{ "cmd", "navigate", .{ .file = file.path, .line = l, .column = col } });

View file

@ -341,6 +341,7 @@
}, },
"mini/numeric": { "mini/numeric": {
"press": [ "press": [
["b", "goto_offset"],
["ctrl+q", "quit"], ["ctrl+q", "quit"],
["ctrl+v", "system_paste"], ["ctrl+v", "system_paste"],
["ctrl+u", "mini_mode_reset"], ["ctrl+u", "mini_mode_reset"],

View file

@ -245,10 +245,23 @@ pub fn main() anyerror!void {
defer links.deinit(); defer links.deinit();
var prev: ?*file_link.Dest = null; var prev: ?*file_link.Dest = null;
var line_next: ?usize = null; var line_next: ?usize = null;
var offset_next: ?usize = null;
for (positional_args.items) |arg| { for (positional_args.items) |arg| {
if (arg.len == 0) continue; if (arg.len == 0) continue;
if (!args.literal and arg[0] == '+') { if (!args.literal and arg[0] == '+') {
if (arg.len > 2 and arg[1] == 'b') {
const offset = try std.fmt.parseInt(usize, arg[2..], 10);
if (prev) |p| switch (p.*) {
.file => |*file| {
file.offset = offset;
continue;
},
else => {},
};
offset_next = offset;
line_next = null;
} else {
const line = try std.fmt.parseInt(usize, arg[1..], 10); const line = try std.fmt.parseInt(usize, arg[1..], 10);
if (prev) |p| switch (p.*) { if (prev) |p| switch (p.*) {
.file => |*file| { .file => |*file| {
@ -258,6 +271,8 @@ pub fn main() anyerror!void {
else => {}, else => {},
}; };
line_next = line; line_next = line;
offset_next = null;
}
continue; continue;
} }
@ -274,6 +289,15 @@ pub fn main() anyerror!void {
else => {}, else => {},
} }
} }
if (offset_next) |offset| {
switch (curr.*) {
.file => |*file| {
file.offset = offset;
offset_next = null;
},
else => {},
}
}
} }
var have_project = false; var have_project = false;

View file

@ -5477,6 +5477,28 @@ pub const Editor = struct {
} }
pub const goto_line_and_column_meta: Meta = .{ .arguments = &.{ .integer, .integer } }; pub const goto_line_and_column_meta: Meta = .{ .arguments = &.{ .integer, .integer } };
pub fn goto_byte_offset(self: *Self, ctx: Context) Result {
try self.send_editor_jump_source();
var offset: usize = 0;
if (try ctx.args.match(.{
tp.extract(&offset),
})) {
// self.logger.print("goto: byte offset:{d}", .{ offset });
} else return error.InvalidGotoByteOffsetArgument;
self.cancel_all_selections();
const root = self.buf_root() catch return;
const eol_mode = self.buf_eol_mode() catch return;
const primary = self.get_primary();
primary.cursor = root.byte_offset_to_line_and_col(offset, self.metrics, eol_mode);
if (self.view.is_visible(&primary.cursor))
self.clamp()
else
try self.scroll_view_center(.{});
try self.send_editor_jump_destination();
self.need_render();
}
pub const goto_byte_offset_meta: Meta = .{ .arguments = &.{.integer} };
pub fn goto_definition(self: *Self, _: Context) Result { pub fn goto_definition(self: *Self, _: Context) Result {
const file_path = self.file_path orelse return; const file_path = self.file_path orelse return;
const primary = self.get_primary(); const primary = self.get_primary();

View file

@ -150,10 +150,10 @@ pub fn receive(self: *Self, from_: tp.pid_ref, m: tp.message) error{Exit}!bool {
}); });
return true; return true;
} else if (try m.match(.{ "navigate_complete", tp.extract(&same_file), tp.extract(&path), tp.extract(&goto_args), tp.extract(&line), tp.extract(&column) })) { } else if (try m.match(.{ "navigate_complete", tp.extract(&same_file), tp.extract(&path), tp.extract(&goto_args), tp.extract(&line), tp.extract(&column) })) {
cmds.navigate_complete(self, same_file, path, goto_args, line, column) catch |e| return tp.exit_error(e, @errorReturnTrace()); cmds.navigate_complete(self, same_file, path, goto_args, line, column, null) catch |e| return tp.exit_error(e, @errorReturnTrace());
return true; return true;
} else if (try m.match(.{ "navigate_complete", tp.extract(&same_file), tp.extract(&path), tp.extract(&goto_args), tp.null_, tp.null_ })) { } else if (try m.match(.{ "navigate_complete", tp.extract(&same_file), tp.extract(&path), tp.extract(&goto_args), tp.null_, tp.null_ })) {
cmds.navigate_complete(self, same_file, path, goto_args, null, null) catch |e| return tp.exit_error(e, @errorReturnTrace()); cmds.navigate_complete(self, same_file, path, goto_args, null, null, null) catch |e| return tp.exit_error(e, @errorReturnTrace());
return true; return true;
} }
return if (try self.floating_views.send(from_, m)) true else self.widgets.send(from_, m); return if (try self.floating_views.send(from_, m)) true else self.widgets.send(from_, m);
@ -349,6 +349,7 @@ const cmds = struct {
var file_name: []const u8 = undefined; var file_name: []const u8 = undefined;
var line: ?i64 = null; var line: ?i64 = null;
var column: ?i64 = null; var column: ?i64 = null;
var offset: ?i64 = null;
var goto_args: []const u8 = &.{}; var goto_args: []const u8 = &.{};
var iter = ctx.args.buf; var iter = ctx.args.buf;
@ -370,6 +371,9 @@ const cmds = struct {
} else if (std.mem.eql(u8, field_name, "goto")) { } else if (std.mem.eql(u8, field_name, "goto")) {
if (!try cbor.matchValue(&iter, cbor.extract_cbor(&goto_args))) if (!try cbor.matchValue(&iter, cbor.extract_cbor(&goto_args)))
return error.InvalidNavigateGotoArgument; return error.InvalidNavigateGotoArgument;
} else if (std.mem.eql(u8, field_name, "offset")) {
if (!try cbor.matchValue(&iter, cbor.extract(&offset)))
return error.InvalidNavigateOffsetArgument;
} else { } else {
try cbor.skipValue(&iter); try cbor.skipValue(&iter);
} }
@ -392,7 +396,8 @@ const cmds = struct {
if (tui.config().restore_last_cursor_position and if (tui.config().restore_last_cursor_position and
!same_file and !same_file and
!have_editor_metadata and !have_editor_metadata and
line == null) line == null and
offset == null)
{ {
const ctx_: struct { const ctx_: struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
@ -424,11 +429,11 @@ const cmds = struct {
return; return;
} }
return cmds.navigate_complete(self, same_file, f, goto_args, line, column); return cmds.navigate_complete(self, same_file, f, goto_args, line, column, offset);
} }
pub const navigate_meta: Meta = .{ .arguments = &.{.object} }; pub const navigate_meta: Meta = .{ .arguments = &.{.object} };
fn navigate_complete(self: *Self, same_file: bool, f: []const u8, goto_args: []const u8, line: ?i64, column: ?i64) Result { fn navigate_complete(self: *Self, same_file: bool, f: []const u8, goto_args: []const u8, line: ?i64, column: ?i64, offset: ?i64) Result {
if (!same_file) { if (!same_file) {
if (self.get_active_editor()) |editor| { if (self.get_active_editor()) |editor| {
editor.send_editor_jump_source() catch {}; editor.send_editor_jump_source() catch {};
@ -444,6 +449,10 @@ const cmds = struct {
try command.executeName("scroll_view_center", .{}); try command.executeName("scroll_view_center", .{});
if (column) |col| if (column) |col|
try command.executeName("goto_column", command.fmt(.{col})); try command.executeName("goto_column", command.fmt(.{col}));
} else if (offset) |o| {
try command.executeName("goto_byte_offset", command.fmt(.{o}));
if (!same_file)
try command.executeName("scroll_view_center", .{});
} }
tui.need_render(); tui.need_render();
} }

View file

@ -0,0 +1,58 @@
const fmt = @import("std").fmt;
const command = @import("command");
const tui = @import("../../tui.zig");
const Cursor = @import("../../editor.zig").Cursor;
pub const Type = @import("numeric_input.zig").Create(@This());
pub const create = Type.create;
pub const ValueType = struct {
cursor: Cursor = .{},
offset: usize = 0,
};
pub fn name(_: *Type) []const u8 {
return "goto byte";
}
pub fn start(_: *Type) ValueType {
const editor = tui.get_active_editor() orelse return .{};
return .{ .cursor = editor.get_primary().cursor };
}
pub fn process_digit(self: *Type, digit: u8) void {
switch (digit) {
0...9 => {
if (self.input) |*input| {
input.offset = input.offset * 10 + digit;
} else {
self.input = .{ .offset = digit };
}
},
else => unreachable,
}
}
pub fn delete(self: *Type, input: *ValueType) void {
const newval = if (input.offset < 10) 0 else input.offset / 10;
if (newval == 0) self.input = null else input.offset = newval;
}
pub fn format_value(_: *Type, input_: ?ValueType, buf: []u8) []const u8 {
return if (input_) |input|
fmt.bufPrint(buf, "{d}", .{input.offset}) catch ""
else
"";
}
pub const preview = goto;
pub const apply = goto;
pub const cancel = goto;
fn goto(self: *Type, _: command.Context) void {
if (self.input) |input|
command.executeName("goto_byte_offset", command.fmt(.{input.offset})) catch {}
else
command.executeName("goto_line_and_column", command.fmt(.{ self.start.cursor.row, self.start.cursor.col })) catch {};
}

View file

@ -1038,6 +1038,11 @@ const cmds = struct {
} }
pub const goto_meta: Meta = .{ .description = "Goto line" }; pub const goto_meta: Meta = .{ .description = "Goto line" };
pub fn goto_offset(self: *Self, ctx: Ctx) Result {
return enter_mini_mode(self, @import("mode/mini/goto_offset.zig"), ctx);
}
pub const goto_offset_meta: Meta = .{ .description = "Goto byte offset" };
pub fn move_to_char(self: *Self, ctx: Ctx) Result { pub fn move_to_char(self: *Self, ctx: Ctx) Result {
return enter_mini_mode(self, @import("mode/mini/move_to_char.zig"), ctx); return enter_mini_mode(self, @import("mode/mini/move_to_char.zig"), ctx);
} }

View file

@ -190,6 +190,14 @@ test "get_byte_pos" {
try std.testing.expectEqual(33, try buffer.root.get_byte_pos(.{ .row = 4, .col = 0 }, metrics(), eol_mode)); try std.testing.expectEqual(33, try buffer.root.get_byte_pos(.{ .row = 4, .col = 0 }, metrics(), eol_mode));
try std.testing.expectEqual(66, try buffer.root.get_byte_pos(.{ .row = 8, .col = 0 }, metrics(), eol_mode)); try std.testing.expectEqual(66, try buffer.root.get_byte_pos(.{ .row = 8, .col = 0 }, metrics(), eol_mode));
try std.testing.expectEqual(97, try buffer.root.get_byte_pos(.{ .row = 11, .col = 2 }, metrics(), eol_mode)); try std.testing.expectEqual(97, try buffer.root.get_byte_pos(.{ .row = 11, .col = 2 }, metrics(), eol_mode));
eol_mode = .crlf;
try std.testing.expectEqual(0, try buffer.root.get_byte_pos(.{ .row = 0, .col = 0 }, metrics(), eol_mode));
try std.testing.expectEqual(10, try buffer.root.get_byte_pos(.{ .row = 1, .col = 0 }, metrics(), eol_mode));
try std.testing.expectEqual(12, try buffer.root.get_byte_pos(.{ .row = 1, .col = 2 }, metrics(), eol_mode));
try std.testing.expectEqual(37, try buffer.root.get_byte_pos(.{ .row = 4, .col = 0 }, metrics(), eol_mode));
try std.testing.expectEqual(74, try buffer.root.get_byte_pos(.{ .row = 8, .col = 0 }, metrics(), eol_mode));
try std.testing.expectEqual(108, try buffer.root.get_byte_pos(.{ .row = 11, .col = 2 }, metrics(), eol_mode));
} }
test "delete_bytes" { test "delete_bytes" {
@ -406,3 +414,44 @@ test "get_from_pos" {
const result3 = buffer.root.get_from_pos(.{ .row = 1, .col = 5 }, &result_buf, metrics()); const result3 = buffer.root.get_from_pos(.{ .row = 1, .col = 5 }, &result_buf, metrics());
try std.testing.expectEqualDeep(result3[0 .. line1.len - 4], line1[4..]); try std.testing.expectEqualDeep(result3[0 .. line1.len - 4], line1[4..]);
} }
test "byte_offset_to_line_and_col" {
const doc: []const u8 =
\\All your
\\ropes
\\are belong to
\\us!
\\All your
\\ropes
\\are belong to
\\us!
\\All your
\\ropes
\\are belong to
\\us!
;
var eol_mode: Buffer.EolMode = .lf;
var sanitized: bool = false;
const buffer = try Buffer.create(a);
defer buffer.deinit();
buffer.update(try buffer.load_from_string(doc, &eol_mode, &sanitized));
try std.testing.expectEqual(Buffer.Cursor{ .row = 0, .col = 0 }, buffer.root.byte_offset_to_line_and_col(0, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 0, .col = 8 }, buffer.root.byte_offset_to_line_and_col(8, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 1, .col = 0 }, buffer.root.byte_offset_to_line_and_col(9, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 1, .col = 2 }, buffer.root.byte_offset_to_line_and_col(11, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 4, .col = 0 }, buffer.root.byte_offset_to_line_and_col(33, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 8, .col = 0 }, buffer.root.byte_offset_to_line_and_col(66, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 11, .col = 2 }, buffer.root.byte_offset_to_line_and_col(97, metrics(), eol_mode));
eol_mode = .crlf;
try std.testing.expectEqual(Buffer.Cursor{ .row = 0, .col = 0 }, buffer.root.byte_offset_to_line_and_col(0, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 0, .col = 8 }, buffer.root.byte_offset_to_line_and_col(8, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 0, .col = 8 }, buffer.root.byte_offset_to_line_and_col(9, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 1, .col = 0 }, buffer.root.byte_offset_to_line_and_col(10, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 1, .col = 2 }, buffer.root.byte_offset_to_line_and_col(12, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 4, .col = 0 }, buffer.root.byte_offset_to_line_and_col(37, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 8, .col = 0 }, buffer.root.byte_offset_to_line_and_col(74, metrics(), eol_mode));
try std.testing.expectEqual(Buffer.Cursor{ .row = 11, .col = 2 }, buffer.root.byte_offset_to_line_and_col(108, metrics(), eol_mode));
}