From 76cc8260bb06d8ebc6df43d645c9b27a01ff156a Mon Sep 17 00:00:00 2001 From: UnsaltedScholar Date: Tue, 31 Mar 2026 15:07:36 -0400 Subject: [PATCH] Add quote textobject actions --- src/keybind/builtin/vim.json | 16 +++ src/tui/editor.zig | 120 +++++++++++++++++++++ src/tui/mode/vim.zig | 202 ++++++++++++++++++++++++++++++----- 3 files changed, 314 insertions(+), 24 deletions(-) diff --git a/src/keybind/builtin/vim.json b/src/keybind/builtin/vim.json index 07d82099..8b3d9106 100644 --- a/src/keybind/builtin/vim.json +++ b/src/keybind/builtin/vim.json @@ -88,6 +88,8 @@ ["di]", "cut_inside_square_brackets"], ["di{", "cut_inside_braces"], ["di}", "cut_inside_braces"], + ["di'", "cut_inside_single_quotes"], + ["di\"", "cut_inside_double_quotes"], ["daw", "cut_around_word"], ["da(", "cut_around_parentheses"], @@ -96,6 +98,8 @@ ["da]", "cut_around_square_brackets"], ["da{", "cut_around_braces"], ["da}", "cut_around_braces"], + ["da'", "cut_around_single_quotes"], + ["da\"", "cut_around_double_quotes"], ["cc", ["enter_mode", "insert"], ["cut_internal_vim"]], ["C", ["enter_mode", "insert"], ["cut_to_end_vim"]], @@ -110,6 +114,8 @@ ["ci]", ["enter_mode", "insert"], ["cut_inside_square_brackets"]], ["ci{", ["enter_mode", "insert"], ["cut_inside_braces"]], ["ci}", ["enter_mode", "insert"], ["cut_inside_braces"]], + ["ci'", ["enter_mode", "insert"], ["cut_inside_single_quotes"]], + ["ci\"", ["enter_mode", "insert"], ["cut_inside_double_quotes"]], ["caw", ["enter_mode", "insert"], ["cut_around_word"]], ["ca(", ["enter_mode", "insert"], ["cut_around_parentheses"]], @@ -118,6 +124,8 @@ ["ca]", ["enter_mode", "insert"], ["cut_around_square_brackets"]], ["ca{", ["enter_mode", "insert"], ["cut_around_braces"]], ["ca}", ["enter_mode", "insert"], ["cut_around_braces"]], + ["ca'", ["enter_mode", "insert"], ["cut_around_single_quotes"]], + ["ca\"", ["enter_mode", "insert"], ["cut_around_double_quotes"]], ["yy", ["copy_line_internal_vim"], ["cancel"]], @@ -128,6 +136,8 @@ ["yi]", ["copy_inside_square_brackets"], ["cancel"]], ["yi{", ["copy_inside_braces"], ["cancel"]], ["yi}", ["copy_inside_braces"], ["cancel"]], + ["yi'", ["copy_inside_single_quotes"], ["cancel"]], + ["yi\"", ["copy_inside_double_quotes"], ["cancel"]], ["yaw", ["copy_around_word"], ["cancel"]], ["ya(", ["copy_around_parentheses"], ["cancel"]], @@ -136,6 +146,8 @@ ["ya]", ["copy_around_square_brackets"], ["cancel"]], ["ya{", ["copy_around_braces"], ["cancel"]], ["ya}", ["copy_around_braces"], ["cancel"]], + ["ya'", ["copy_around_single_quotes"], ["cancel"]], + ["ya\"", ["copy_around_double_quotes"], ["cancel"]], ["", "move_scroll_half_page_up_vim"], ["", "move_scroll_half_page_down_vim"], @@ -214,6 +226,8 @@ ["i]", "select_inside_square_brackets"], ["i{", "select_inside_braces"], ["i}", "select_inside_braces"], + ["i'", "select_inside_single_quotes"], + ["i\"", "select_inside_double_quotes"], ["aw", "select_around_word"], ["a(", "select_around_parentheses"], @@ -222,6 +236,8 @@ ["a]", "select_around_square_brackets"], ["a{", "select_around_braces"], ["a}", "select_around_braces"], + ["a'", "select_around_single_quotes"], + ["a\"", "select_around_double_quotes"], ["^", "smart_move_begin"], ["$", "select_end"], diff --git a/src/tui/editor.zig b/src/tui/editor.zig index b0d75484..6ea84122 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -3974,6 +3974,126 @@ pub const Editor = struct { } pub const goto_bracket_meta: Meta = .{ .description = "Goto matching bracket" }; + const QuoteRole = enum { opening, closing }; + + fn row_start_cursor(root: Buffer.Root, cursor: Cursor, metrics: Buffer.Metrics) Cursor { + var c = cursor; + + while (true) { + var prev = c; + prev.move_left(root, metrics) catch break; + if (prev.row != c.row) break; + c = prev; + } + + return c; + } + + fn quote_is_escaped(root: Buffer.Root, quote_cursor: Cursor, metrics: Buffer.Metrics) bool { + var cursor = quote_cursor; + var backslashes: usize = 0; + + while (true) { + var prev = cursor; + prev.move_left(root, metrics) catch break; + if (prev.row != cursor.row) break; + + const egc, _, _ = root.egc_at(prev.row, prev.col, metrics) catch break; + if (!std.mem.eql(u8, egc, "\\")) break; + + backslashes += 1; + cursor = prev; + } + + return (backslashes % 2) == 1; + } + + fn find_unescaped_quote( + root: Buffer.Root, + start: Cursor, + metrics: Buffer.Metrics, + direction: enum { left, right }, + quote: []const u8, + ) error{Stop}!Cursor { + var cursor = start; + var i: usize = 0; + + while (i < bracket_search_radius) : (i += 1) { + switch (direction) { + .left => cursor.move_left(root, metrics) catch return error.Stop, + .right => cursor.move_right(root, metrics) catch return error.Stop, + } + + const egc, _, _ = root.egc_at(cursor.row, cursor.col, metrics) catch { + return error.Stop; + }; + + if (!std.mem.eql(u8, egc, quote)) continue; + if (quote_is_escaped(root, cursor, metrics)) continue; + + return cursor; + } + + return error.Stop; + } + + fn quote_role_on_row( + root: Buffer.Root, + quote_cursor: Cursor, + metrics: Buffer.Metrics, + quote: []const u8, + ) error{Stop}!QuoteRole { + var cursor = row_start_cursor(root, .{ .row = quote_cursor.row, .col = 0 }, metrics); + var opening = true; + + while (cursor.row == quote_cursor.row and cursor.col <= quote_cursor.col) { + const egc, _, _ = root.egc_at(cursor.row, cursor.col, metrics) catch { + return error.Stop; + }; + + if (std.mem.eql(u8, egc, quote) and !quote_is_escaped(root, cursor, metrics)) { + if (cursor.col == quote_cursor.col) { + return if (opening) .opening else .closing; + } + opening = !opening; + } + + cursor.move_right(root, metrics) catch break; + } + + return error.Stop; + } + + pub fn find_quote_pair( + root: Buffer.Root, + original_cursor: Cursor, + metrics: Buffer.Metrics, + quote: []const u8, + ) error{Stop}!struct { struct { usize, usize }, struct { usize, usize } } { + + // Find nearest quote (prefer rightward) + const anchor = + find_unescaped_quote(root, original_cursor, metrics, .right, quote) catch find_unescaped_quote(root, original_cursor, metrics, .left, quote) catch return error.Stop; + + const role = try quote_role_on_row(root, anchor, metrics, quote); + + const other = switch (role) { + .opening => try find_unescaped_quote(root, anchor, metrics, .right, quote), + .closing => try find_unescaped_quote(root, anchor, metrics, .left, quote), + }; + + return switch (role) { + .opening => .{ + .{ anchor.row, anchor.col }, + .{ other.row, other.col }, + }, + .closing => .{ + .{ other.row, other.col }, + .{ anchor.row, anchor.col }, + }, + }; + } + pub fn move_or_select_to_char_right(self: *Self, ctx: Context) Result { const selected = if (self.get_primary().selection) |_| true else false; if (selected) try self.select_to_char_right(ctx) else try self.move_to_char_right(ctx); diff --git a/src/tui/mode/vim.zig b/src/tui/mode/vim.zig index 043ed35a..823cc812 100644 --- a/src/tui/mode/vim.zig +++ b/src/tui/mode/vim.zig @@ -220,6 +220,42 @@ const cmds_ = struct { } pub const select_around_braces_meta: Meta = .{ .description = "Select around {}" }; + pub fn select_inside_single_quotes(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_single_quotes_textobject, ed.metrics); + } + pub const select_inside_single_quotes_meta: Meta = .{ .description = "Select inside ''" }; + + pub fn select_around_single_quotes(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_single_quotes_textobject, ed.metrics); + } + pub const select_around_single_quotes_meta: Meta = .{ .description = "Select around ''" }; + + pub fn select_inside_double_quotes(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_double_quotes_textobject, ed.metrics); + } + pub const select_inside_double_quotes_meta: Meta = .{ .description = "Select inside \"\"" }; + + pub fn select_around_double_quotes(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_double_quotes_textobject, ed.metrics); + } + pub const select_around_double_quotes_meta: Meta = .{ .description = "Select around \"\"" }; + pub fn cut_inside_word(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; @@ -300,6 +336,46 @@ const cmds_ = struct { } pub const cut_around_braces_meta: Meta = .{ .description = "Cut around {}" }; + pub fn cut_inside_single_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_single_quotes_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_inside_single_quotes_meta: Meta = .{ .description = "Cut inside ''" }; + + pub fn cut_around_single_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_single_quotes_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_around_single_quotes_meta: Meta = .{ .description = "Cut around ''" }; + + pub fn cut_inside_double_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_double_quotes_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_inside_double_quotes_meta: Meta = .{ .description = "Cut inside \"\"" }; + + pub fn cut_around_double_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_double_quotes_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_around_double_quotes_meta: Meta = .{ .description = "Cut around \"\"" }; + pub fn copy_inside_word(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; @@ -379,6 +455,46 @@ const cmds_ = struct { try ed.copy_internal_vim(ctx); } pub const copy_around_braces_meta: Meta = .{ .description = "Copy around {}" }; + + pub fn copy_inside_single_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_single_quotes_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_inside_single_quotes_meta: Meta = .{ .description = "Copy inside ''" }; + + pub fn copy_around_single_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_single_quotes_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_around_single_quotes_meta: Meta = .{ .description = "Copy around ''" }; + + pub fn copy_inside_double_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_double_quotes_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_inside_double_quotes_meta: Meta = .{ .description = "Copy inside \"\"" }; + + pub fn copy_around_double_quotes(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_double_quotes_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_around_double_quotes_meta: Meta = .{ .description = "Copy around \"\"" }; }; fn is_tab_or_space(c: []const u8) bool { @@ -444,56 +560,94 @@ fn select_word_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Me } fn select_inside_parentheses_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { - return try select_bracket_textobject(root, cursel, metrics, "(", ")", .inside); + return try select_scope_textobject(root, cursel, metrics, "(", ")", .inside); } fn select_around_parentheses_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { - return try select_bracket_textobject(root, cursel, metrics, "(", ")", .around); + return try select_scope_textobject(root, cursel, metrics, "(", ")", .around); } fn select_inside_square_brackets_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { - return try select_bracket_textobject(root, cursel, metrics, "[", "]", .inside); + return try select_scope_textobject(root, cursel, metrics, "[", "]", .inside); } fn select_around_square_brackets_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { - return try select_bracket_textobject(root, cursel, metrics, "[", "]", .around); + return try select_scope_textobject(root, cursel, metrics, "[", "]", .around); } fn select_inside_braces_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { - return try select_bracket_textobject(root, cursel, metrics, "{", "}", .inside); + return try select_scope_textobject(root, cursel, metrics, "{", "}", .inside); } fn select_around_braces_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { - return try select_bracket_textobject(root, cursel, metrics, "{", "}", .around); + return try select_scope_textobject(root, cursel, metrics, "{", "}", .around); } -fn select_bracket_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics, opening_char: []const u8, closing_char: []const u8, scope: enum { inside, around }) !void { +fn select_inside_single_quotes_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_scope_textobject(root, cursel, metrics, "'", "'", .inside); +} + +fn select_around_single_quotes_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_scope_textobject(root, cursel, metrics, "'", "'", .around); +} + +fn select_inside_double_quotes_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_scope_textobject(root, cursel, metrics, "\"", "\"", .inside); +} + +fn select_around_double_quotes_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_scope_textobject(root, cursel, metrics, "\"", "\"", .around); +} + +fn select_scope_textobject( + root: Buffer.Root, + cursel: *CurSel, + metrics: Buffer.Metrics, + opening_char: []const u8, + closing_char: []const u8, + scope: enum { inside, around }, +) !void { const current = cursel.cursor; var prev = cursel.cursor; var next = cursel.cursor; - const bracket_egc, _, _ = root.egc_at(current.row, current.col, metrics) catch { - return error.Stop; - }; - if (std.mem.eql(u8, bracket_egc, opening_char)) { - const closing_row, const closing_col = try Editor.match_bracket(root, current, metrics); - - prev = current; - next.row = closing_row; - next.col = closing_col; - } else if (std.mem.eql(u8, bracket_egc, closing_char)) { - const opening_row, const opening_col = try Editor.match_bracket(root, current, metrics); - - prev.row = opening_row; - prev.col = opening_col; - next = current; - } else { - const opening_pos, const closing_pos = find_bracket_pair(root, cursel, metrics, .left, opening_char) catch try find_bracket_pair(root, cursel, metrics, .right, opening_char); + if (std.mem.eql(u8, opening_char, closing_char)) { + const opening_pos, const closing_pos = + try Editor.find_quote_pair(root, current, metrics, opening_char); prev.row = opening_pos[0]; prev.col = opening_pos[1]; next.row = closing_pos[0]; next.col = closing_pos[1]; + } else { + const bracket_egc, _, _ = root.egc_at(current.row, current.col, metrics) catch { + return error.Stop; + }; + + if (std.mem.eql(u8, bracket_egc, opening_char)) { + const closing_row, const closing_col = + try Editor.match_bracket(root, current, metrics); + + prev = current; + next.row = closing_row; + next.col = closing_col; + } else if (std.mem.eql(u8, bracket_egc, closing_char)) { + const opening_row, const opening_col = + try Editor.match_bracket(root, current, metrics); + + prev.row = opening_row; + prev.col = opening_col; + next = current; + } else { + const pair = find_bracket_pair(root, cursel, metrics, .left, opening_char) catch blk: { + break :blk try find_bracket_pair(root, cursel, metrics, .right, opening_char); + }; + + prev.row = pair[0][0]; + prev.col = pair[0][1]; + next.row = pair[1][0]; + next.col = pair[1][1]; + } } prev.move_right(root, metrics) catch {};