From 37bbb17da628f9c4e47e3c6b2590fcf3cc6201bb Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 12 Apr 2026 22:22:53 +0200 Subject: [PATCH 01/15] feat: add palette_placement config option --- src/config.zig | 9 +++++++++ src/tui/mode/overlay/open_recent.zig | 30 ++++++++++++++++++++++++++-- src/tui/mode/overlay/palette.zig | 24 +++++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/config.zig b/src/config.zig index a6a62967..26a8ce73 100644 --- a/src/config.zig +++ b/src/config.zig @@ -85,6 +85,8 @@ hint_window_style: WidgetStyle = .thick_boxed, info_box_style: WidgetStyle = .bar_left_spacious, info_box_width_limit: usize = 80, +palette_placement: PalettePlacement = .top_center, + centered_view: bool = false, centered_view_width: usize = 145, centered_view_min_screen_width: usize = 145, @@ -248,3 +250,10 @@ pub const AgeFormat = enum { short, long, }; + +pub const PalettePlacement = enum { + top_center, + top_left, + top_right, + center, +}; diff --git a/src/tui/mode/overlay/open_recent.zig b/src/tui/mode/overlay/open_recent.zig index c419917e..df1ad82c 100644 --- a/src/tui/mode/overlay/open_recent.zig +++ b/src/tui/mode/overlay/open_recent.zig @@ -187,10 +187,36 @@ fn prepare_resize_menu(self: *Self, _: *MenuType, _: Widget.Box) Widget.Box { } fn prepare_resize(self: *Self) Widget.Box { + const screen = tui.screen(); + const padding = tui.get_widget_style(widget_type).padding; const w = self.menu_width(); - const x = self.menu_pos_x(); const h = self.menu.menu.widgets.items.len; - return .{ .y = 0, .x = x, .w = w, .h = h }; + return switch (tui.config().palette_placement) { + .top_center => .{ + .y = 0, + .x = self.menu_pos_x(), + .w = w, + .h = h, + }, + .top_left => .{ + .y = 0, + .x = 0, + .w = w, + .h = h, + }, + .top_right => .{ + .y = 0, + .x = if (screen.w > (w - padding.right)) (screen.w - w - padding.right) else 0, + .w = w, + .h = h, + }, + .center => .{ + .y = if (screen.h > h) (screen.h - h) / 2 else 0, + .x = self.menu_pos_x(), + .w = w, + .h = h, + }, + }; } fn do_resize(self: *Self) void { diff --git a/src/tui/mode/overlay/palette.zig b/src/tui/mode/overlay/palette.zig index 74764a5d..57ad8b5c 100644 --- a/src/tui/mode/overlay/palette.zig +++ b/src/tui/mode/overlay/palette.zig @@ -26,7 +26,17 @@ pub const Placement = enum { top_center, top_left, top_right, + center, primary_cursor, + + fn from_config(conf: @import("config").PalettePlacement) Placement { + return switch (conf) { + .top_center => .top_center, + .top_left => .top_left, + .top_right => .top_right, + .center => .center, + }; + } }; pub const ActivateMode = enum { @@ -110,7 +120,10 @@ pub fn Create(options: type) type { .mode = try keybind.mode("overlay/palette", allocator, .{ .insert_command = "overlay_insert_bytes", }), - .placement = if (@hasDecl(options, "placement")) options.placement else .top_center, + .placement = if (@hasDecl(options, "placement")) + options.placement + else + Placement.from_config(tui.config().palette_placement), }; try self.commands.init(self); self.mode.event_handler = EventHandler.to_owned(self); @@ -204,6 +217,7 @@ pub fn Create(options: type) type { .top_center => self.prepare_resize_top_center(screen, w), .top_left => self.prepare_resize_top_left(screen, w), .top_right => self.prepare_resize_top_right(screen, w, padding), + .center => self.prepare_resize_center(screen, w), .primary_cursor => self.prepare_resize_primary_cursor(screen, w, padding), }; } @@ -248,6 +262,14 @@ pub fn Create(options: type) type { return self.prepare_resize_at_y_x(screen, w, cursor.row + 1 + padding.top, cursor.col); } + fn prepare_resize_center(self: *Self, screen: Widget.Box, w: usize) Widget.Box { + const x = if (screen.w > w) (screen.w - w) / 2 else 0; + const h = @min(self.items + self.menu.header_count, self.view_rows + self.menu.header_count); + const y = if (screen.h > h) (screen.h - h) / 2 else 0; + self.view_rows = get_view_rows(screen) -| y; + return .{ .y = y, .x = x, .w = w, .h = h }; + } + fn after_resize_menu(self: *Self, _: *Menu.State(*Self), _: Widget.Box) void { return self.after_resize(); } From 763935912fb3ab2b1a3d876c0f361077af4ee8ae Mon Sep 17 00:00:00 2001 From: Ryan Mehri Date: Sun, 12 Apr 2026 16:48:51 -0400 Subject: [PATCH 02/15] fix: incorrect clamping on highlight ranges --- src/tui/editor.zig | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 74a99168..b0d75484 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -1673,23 +1673,15 @@ pub const Editor = struct { const style_ = style_cache_lookup(ctx.theme, ctx.cache, scope, id); const style = if (style_) |sty| sty.style else return; - if (sel.end.row < ctx.self.view.row) return; - if (sel.begin.row > ctx.self.view.row + ctx.self.view.rows) return; - if (sel.begin.row < ctx.self.view.row) sel.begin.row = ctx.self.view.row; - if (sel.end.row > ctx.self.view.row + ctx.self.view.rows) sel.end.row = ctx.self.view.row + ctx.self.view.rows; - - if (sel.end.col < ctx.self.view.col) return; - if (sel.begin.col > ctx.self.view.col + ctx.self.view.cols) return; - if (sel.begin.col < ctx.self.view.col) sel.begin.col = ctx.self.view.col; - if (sel.end.col > ctx.self.view.col + ctx.self.view.cols) sel.end.col = ctx.self.view.col + ctx.self.view.cols; + ctx.clamp_to_view(&sel.begin); + ctx.clamp_to_view(&sel.end); for (sel.begin.row..sel.end.row + 1) |row| { - const begin_col = if (row == sel.begin.row) sel.begin.col else 0; + const begin_col = if (row == sel.begin.row) sel.begin.col else ctx.self.view.col; const end_col = if (row == sel.end.row) sel.end.col else ctx.self.view.col + ctx.self.view.cols; - const y = @max(ctx.self.view.row, row) - ctx.self.view.row; - const x = @max(ctx.self.view.col, begin_col) - ctx.self.view.col; - const end_x = @max(ctx.self.view.col, end_col) - ctx.self.view.col; - if (x >= end_x) return; + const y = row - ctx.self.view.row; + const x = begin_col - ctx.self.view.col; + const end_x = end_col - ctx.self.view.col; for (x..end_x) |x_| try ctx.render_cell(y, x_, style); } @@ -1701,6 +1693,19 @@ pub const Editor = struct { cell.set_style(style); _ = ctx.self.plane.putc(&cell) catch {}; } + fn clamp_to_view(ctx: *const @This(), cursor: *Cursor) void { + const row_off: u32 = @intCast(ctx.self.view.row); + const col_off: u32 = @intCast(ctx.self.view.col); + if (cursor.row < row_off) { + cursor.row = row_off; + cursor.col = col_off; + } + if (cursor.row >= row_off + ctx.self.view.rows) { + cursor.row = row_off + ctx.self.view.rows; + cursor.col = col_off + ctx.self.view.cols; + } + cursor.col = std.math.clamp(cursor.col, col_off, col_off + ctx.self.view.cols); + } }; var ctx: Ctx = .{ .self = self, From 76cc8260bb06d8ebc6df43d645c9b27a01ff156a Mon Sep 17 00:00:00 2001 From: UnsaltedScholar Date: Tue, 31 Mar 2026 15:07:36 -0400 Subject: [PATCH 03/15] 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 {}; From afeca37f1059d3e6f236b60cc942b89fbddf7928 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Apr 2026 19:02:53 +0200 Subject: [PATCH 04/15] fix: quote textobject selects wrong pair when cursor is on closing quote --- src/tui/editor.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 6ea84122..7748fe53 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -4071,9 +4071,15 @@ pub const Editor = struct { 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; + // If the cursor is already on a quote, use it directly as the anchor. + // Otherwise find the nearest quote, preferring rightward. + const cursor_egc, _, _ = root.egc_at(original_cursor.row, original_cursor.col, metrics) catch return error.Stop; + const anchor = if (std.mem.eql(u8, cursor_egc, quote)) + original_cursor + else + 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); From 157e1ba7d3f517f9b81f4e69f4707afb0286c1a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20T=C3=A1mara?= Date: Wed, 18 Mar 2026 13:07:27 -0500 Subject: [PATCH 05/15] feat: [hx] surround add --- src/tui/mode/helix.zig | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/tui/mode/helix.zig b/src/tui/mode/helix.zig index 2721df22..155199b7 100644 --- a/src/tui/mode/helix.zig +++ b/src/tui/mode/helix.zig @@ -430,6 +430,17 @@ const cmds_ = struct { } pub const select_textobject_inner_meta: Meta = .{ .description = "select inside object helix" }; + pub fn surround_add(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + var root = ed.buf_root() catch return; + root = try ed.with_cursels_mut_once_arg(root, surround_cursel_add, ed.allocator, ctx); + try ed.update_buf(root); + ed.clamp(); + ed.need_render(); + } + pub const surround_add_meta: Meta = .{ .description = "surround add" }; + pub fn select_textobject_around(_: *void, ctx: Ctx) Result { var action: []const u8 = ""; @@ -865,6 +876,48 @@ fn replace_cursel_with_character(ed: *Editor, root: Buffer.Root, cursel: *CurSel return insert_replace_selection(ed, root, cursel, replacement, allocator) catch return error.Stop; } +fn find_open_close_pair(bracket: []const u8) struct { left: []const u8, right: []const u8 } { + for (Buffer.unicode.open_close_pairs) |pair| { + if (std.mem.eql(u8, bracket, pair[0]) or std.mem.eql(u8, bracket, pair[1])) { + return .{ .left = pair[0], .right = pair[1] }; + } + } + return .{ .left = bracket, .right = bracket }; +} + +fn surround_cursel_add(ed: *Editor, root: Buffer.Root, cursel: *CurSel, allocator: Allocator, ctx: command.Context) error{Stop}!Buffer.Root { + var encloser: []const u8 = undefined; + if (!(ctx.args.match(.{tp.extract(&encloser)}) catch return error.Stop)) + return error.Stop; + + const enclose_pair = find_open_close_pair(encloser); + var root_: Buffer.Root = root; + cursel.check_selection(root, ed.metrics); + + const sel = cursel.enable_selection(root_, ed.metrics); + var begin = sel.begin; + var end = sel.end; + if (sel.is_reversed()) { + end = sel.begin; + begin = sel.end; + } + if (begin.row == end.row and end.col == begin.col) { + end.move_right(root_, ed.metrics) catch {}; + } + _, _, root_ = root_.insert_chars(end.row, end.col, enclose_pair.right, allocator, ed.metrics) catch return error.Stop; + _, _, root_ = root_.insert_chars(begin.row, begin.col, enclose_pair.left, allocator, ed.metrics) catch return error.Stop; + + try end.move_right(root_, ed.metrics); + try end.move_right(root_, ed.metrics); + cursel.selection = Selection{ .begin = begin, .end = end }; + ed.nudge_insert(.{ .begin = begin, .end = end }, cursel, encloser.len * 2); + + if (end.right_of(begin)) { + try cursel.*.cursor.move_right(root_, ed.metrics); + } + return root_; +} + fn move_noop(_: Buffer.Root, _: *Cursor, _: Buffer.Metrics) error{Stop}!void {} fn move_cursor_word_right_end_helix(root: Buffer.Root, cursor: *Cursor, metrics: Buffer.Metrics) error{Stop}!void { From 34d99a17d501efc96034c8567828fd4a46a49aa0 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Apr 2026 19:28:43 +0200 Subject: [PATCH 06/15] fix: use buf_for_update consistently in helix mode --- src/tui/mode/helix.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tui/mode/helix.zig b/src/tui/mode/helix.zig index 155199b7..2f9736ec 100644 --- a/src/tui/mode/helix.zig +++ b/src/tui/mode/helix.zig @@ -433,8 +433,8 @@ const cmds_ = struct { pub fn surround_add(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; - var root = ed.buf_root() catch return; - root = try ed.with_cursels_mut_once_arg(root, surround_cursel_add, ed.allocator, ctx); + const b = try ed.buf_for_update(); + const root = try ed.with_cursels_mut_once_arg(b.root, surround_cursel_add, ed.allocator, ctx); try ed.update_buf(root); ed.clamp(); ed.need_render(); @@ -492,8 +492,8 @@ const cmds_ = struct { pub fn replace_with_character_helix(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; - var root = ed.buf_root() catch return; - root = try ed.with_cursels_mut_once_arg(root, replace_cursel_with_character, ed.allocator, ctx); + const b = try ed.buf_for_update(); + const root = try ed.with_cursels_mut_once_arg(b.root, replace_cursel_with_character, ed.allocator, ctx); try ed.update_buf(root); ed.clamp(); ed.need_render(); From 16377e37005624cd3186f57a7c78272300612886 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Apr 2026 19:32:01 +0200 Subject: [PATCH 07/15] fix: surround add overshoots end cursor on multi-row selections --- src/tui/mode/helix.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tui/mode/helix.zig b/src/tui/mode/helix.zig index 2f9736ec..0a5bd4e5 100644 --- a/src/tui/mode/helix.zig +++ b/src/tui/mode/helix.zig @@ -907,8 +907,10 @@ fn surround_cursel_add(ed: *Editor, root: Buffer.Root, cursel: *CurSel, allocato _, _, root_ = root_.insert_chars(end.row, end.col, enclose_pair.right, allocator, ed.metrics) catch return error.Stop; _, _, root_ = root_.insert_chars(begin.row, begin.col, enclose_pair.left, allocator, ed.metrics) catch return error.Stop; - try end.move_right(root_, ed.metrics); - try end.move_right(root_, ed.metrics); + if (begin.row == end.row) { + try end.move_right(root_, ed.metrics); // for left-bracket column shift on same row + } + try end.move_right(root_, ed.metrics); // skip past right bracket cursel.selection = Selection{ .begin = begin, .end = end }; ed.nudge_insert(.{ .begin = begin, .end = end }, cursel, encloser.len * 2); From 5871583cba93bd1f8b87c9d7dcd5e672ca5c67ff Mon Sep 17 00:00:00 2001 From: Ingo Lohmar Date: Sun, 5 Apr 2026 22:16:22 +0200 Subject: [PATCH 08/15] [BREAKING] drop misleading `move_` from `scroll_{left,right}` This might break existing custom keybindings, so we mark it BREAKING for the release notes. --- src/keybind/builtin/flow.json | 4 ++-- src/tui/editor.zig | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 9ce0934d..4425d51d 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -180,8 +180,8 @@ ["alt+[", "select_prev_sibling", true], ["alt+]", "select_next_sibling", true], ["alt+a", "select_all_siblings"], - ["alt+shift+home", "move_scroll_left"], - ["alt+shift+end", "move_scroll_right"], + ["alt+shift+home", "scroll_left"], + ["alt+shift+end", "scroll_right"], ["alt+shift+up", "add_cursor_up"], ["alt+shift+down", "add_cursor_down"], ["alt+shift+f12", "goto_type_definition"], diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 7748fe53..a1d2a9ff 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -4558,15 +4558,15 @@ pub const Editor = struct { } pub const move_scroll_down_meta: Meta = .{ .description = "Move and scroll down", .arguments = &.{.integer} }; - pub fn move_scroll_left(self: *Self, _: Context) Result { + pub fn scroll_left(self: *Self, _: Context) Result { self.view.move_left(tui.config().scroll_step_horizontal) catch {}; } - pub const move_scroll_left_meta: Meta = .{ .description = "Scroll left" }; + pub const scroll_left_meta: Meta = .{ .description = "Scroll left" }; - pub fn move_scroll_right(self: *Self, _: Context) Result { + pub fn scroll_right(self: *Self, _: Context) Result { self.view.move_right(tui.config().scroll_step_horizontal) catch {}; } - pub const move_scroll_right_meta: Meta = .{ .description = "Scroll right" }; + pub const scroll_right_meta: Meta = .{ .description = "Scroll right" }; pub fn mouse_scroll_left(self: *Self) void { const scroll_step_horizontal = tui.config().scroll_step_horizontal; From 00abd90cd506ce24db58a8af4f5ef7b47dfed427 Mon Sep 17 00:00:00 2001 From: Ingo Lohmar Date: Sun, 5 Apr 2026 22:21:47 +0200 Subject: [PATCH 09/15] add `scroll_{up,down}` commands --- src/tui/editor.zig | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index a1d2a9ff..7ec0366d 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -3007,14 +3007,14 @@ pub const Editor = struct { self.update_animation_step(dest); } - fn scroll_up(self: *Self) void { + fn scroll_up_internal(self: *Self) void { const scroll_step_vertical = tui.config().scroll_step_vertical; var dest_row = self.scroll_dest; dest_row = if (dest_row > scroll_step_vertical) dest_row - scroll_step_vertical else 0; self.update_scroll_dest_abs(dest_row); } - fn scroll_down(self: *Self) void { + fn scroll_down_internal(self: *Self) void { const scroll_step_vertical = tui.config().scroll_step_vertical; var dest_row = self.scroll_dest; dest_row += scroll_step_vertical; @@ -3040,7 +3040,7 @@ pub const Editor = struct { else if (tui.fast_scroll()) self.scroll_pageup() else - self.scroll_up(); + self.scroll_up_internal(); } pub fn mouse_scroll_down(self: *Self) void { @@ -3049,7 +3049,7 @@ pub const Editor = struct { else if (tui.fast_scroll()) self.scroll_pagedown() else - self.scroll_down(); + self.scroll_down_internal(); } pub fn scroll_to(self: *Self, row: usize) void { @@ -4542,6 +4542,16 @@ pub const Editor = struct { } pub const unindent_meta: Meta = .{ .description = "Unindent current line", .arguments = &.{.integer} }; + pub fn scroll_up(self: *Self, _: Context) Result { + self.scroll_up_internal(); + } + pub const scroll_up_meta: Meta = .{ .description = "Scroll up" }; + + pub fn scroll_down(self: *Self, _: Context) Result { + self.scroll_down_internal(); + } + pub const scroll_down_meta: Meta = .{ .description = "Scroll down" }; + pub fn move_scroll_up(self: *Self, ctx: Context) Result { const root = try self.buf_root(); self.with_cursors_const_repeat(root, move_cursor_up, ctx) catch {}; From 5d7323bfe71a35874e795c0041de3f06078e5f80 Mon Sep 17 00:00:00 2001 From: Ingo Lohmar Date: Sun, 5 Apr 2026 23:00:40 +0200 Subject: [PATCH 10/15] args for scroll_{up,down,left,right}; scroll_step_vertical only for mouse --- src/tui/editor.zig | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 7ec0366d..67441531 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -3007,17 +3007,15 @@ pub const Editor = struct { self.update_animation_step(dest); } - fn scroll_up_internal(self: *Self) void { - const scroll_step_vertical = tui.config().scroll_step_vertical; + fn scroll_up_internal(self: *Self, count: usize) void { var dest_row = self.scroll_dest; - dest_row = if (dest_row > scroll_step_vertical) dest_row - scroll_step_vertical else 0; + dest_row -|= count; self.update_scroll_dest_abs(dest_row); } - fn scroll_down_internal(self: *Self) void { - const scroll_step_vertical = tui.config().scroll_step_vertical; + fn scroll_down_internal(self: *Self, count: usize) void { var dest_row = self.scroll_dest; - dest_row += scroll_step_vertical; + dest_row += count; self.update_scroll_dest_abs(dest_row); } @@ -3040,7 +3038,7 @@ pub const Editor = struct { else if (tui.fast_scroll()) self.scroll_pageup() else - self.scroll_up_internal(); + self.scroll_up_internal(tui.config().scroll_step_vertical); } pub fn mouse_scroll_down(self: *Self) void { @@ -3049,7 +3047,7 @@ pub const Editor = struct { else if (tui.fast_scroll()) self.scroll_pagedown() else - self.scroll_down_internal(); + self.scroll_down_internal(tui.config().scroll_step_vertical); } pub fn scroll_to(self: *Self, row: usize) void { @@ -4542,15 +4540,19 @@ pub const Editor = struct { } pub const unindent_meta: Meta = .{ .description = "Unindent current line", .arguments = &.{.integer} }; - pub fn scroll_up(self: *Self, _: Context) Result { - self.scroll_up_internal(); + pub fn scroll_up(self: *Self, ctx: Context) Result { + var count: usize = 1; + _ = ctx.args.match(.{tp.extract(&count)}) catch false; + self.scroll_up_internal(count); } - pub const scroll_up_meta: Meta = .{ .description = "Scroll up" }; + pub const scroll_up_meta: Meta = .{ .description = "Scroll up", .arguments = &.{.integer} }; - pub fn scroll_down(self: *Self, _: Context) Result { - self.scroll_down_internal(); + pub fn scroll_down(self: *Self, ctx: Context) Result { + var count: usize = 1; + _ = ctx.args.match(.{tp.extract(&count)}) catch false; + self.scroll_down_internal(count); } - pub const scroll_down_meta: Meta = .{ .description = "Scroll down" }; + pub const scroll_down_meta: Meta = .{ .description = "Scroll down", .arguments = &.{.integer} }; pub fn move_scroll_up(self: *Self, ctx: Context) Result { const root = try self.buf_root(); @@ -4568,15 +4570,19 @@ pub const Editor = struct { } pub const move_scroll_down_meta: Meta = .{ .description = "Move and scroll down", .arguments = &.{.integer} }; - pub fn scroll_left(self: *Self, _: Context) Result { - self.view.move_left(tui.config().scroll_step_horizontal) catch {}; + pub fn scroll_left(self: *Self, ctx: Context) Result { + var count: usize = 1; + _ = ctx.args.match(.{tp.extract(&count)}) catch false; + self.view.move_left(count) catch {}; } - pub const scroll_left_meta: Meta = .{ .description = "Scroll left" }; + pub const scroll_left_meta: Meta = .{ .description = "Scroll left", .arguments = &.{.integer} }; - pub fn scroll_right(self: *Self, _: Context) Result { - self.view.move_right(tui.config().scroll_step_horizontal) catch {}; + pub fn scroll_right(self: *Self, ctx: Context) Result { + var count: usize = 1; + _ = ctx.args.match(.{tp.extract(&count)}) catch false; + self.view.move_right(count) catch {}; } - pub const scroll_right_meta: Meta = .{ .description = "Scroll right" }; + pub const scroll_right_meta: Meta = .{ .description = "Scroll right", .arguments = &.{.integer} }; pub fn mouse_scroll_left(self: *Self) void { const scroll_step_horizontal = tui.config().scroll_step_horizontal; From d7c02f070035b5a60bc115bb6641eeb6beb17818 Mon Sep 17 00:00:00 2001 From: Danylo Kondratiev Date: Thu, 22 Jan 2026 23:33:09 +0200 Subject: [PATCH 11/15] refactor: add helper function get_selection_or_select_word --- src/tui/editor.zig | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 67441531..620f2390 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -7257,15 +7257,21 @@ pub const Editor = struct { .arguments = &.{.integer}, }; + fn get_selection_or_select_word(self: *Self, root: Buffer.Root, cursel: *CurSel) error{Stop}!*Selection { + if (cursel.selection) |*sel| { + return sel; + } else { + var sel = cursel.enable_selection(root, self.metrics); + try move_cursor_word_begin(root, &sel.begin, self.metrics); + try move_cursor_word_end(root, &sel.end, self.metrics); + return sel; + } + } + fn to_upper_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { var root = root_; const saved = cursel.*; - const sel = if (cursel.selection) |*sel| sel else ret: { - var sel = cursel.enable_selection(root, self.metrics); - move_cursor_word_begin(root, &sel.begin, self.metrics) catch return error.Stop; - move_cursor_word_end(root, &sel.end, self.metrics) catch return error.Stop; - break :ret sel; - }; + const sel = try self.get_selection_or_select_word(root, cursel); var sfa = std.heap.stackFallback(4096, self.allocator); const sfa_allocator = sfa.get(); const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; @@ -7289,12 +7295,7 @@ pub const Editor = struct { fn to_lower_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, buffer_allocator: Allocator) error{Stop}!Buffer.Root { var root = root_; const saved = cursel.*; - const sel = if (cursel.selection) |*sel| sel else ret: { - var sel = cursel.enable_selection(root, self.metrics); - move_cursor_word_begin(root, &sel.begin, self.metrics) catch return error.Stop; - move_cursor_word_end(root, &sel.end, self.metrics) catch return error.Stop; - break :ret sel; - }; + const sel = try self.get_selection_or_select_word(root, cursel); var sfa = std.heap.stackFallback(4096, self.allocator); const sfa_allocator = sfa.get(); const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; From b2cb003d8235bb4e00f58b9ec93eb8432c0f60f7 Mon Sep 17 00:00:00 2001 From: Danylo Kondratiev Date: Thu, 22 Jan 2026 23:33:09 +0200 Subject: [PATCH 12/15] feat: add toggle_case --- src/buffer/unicode.zig | 18 ++++++++++++++++++ src/tui/editor.zig | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/buffer/unicode.zig b/src/buffer/unicode.zig index eca8f5e8..fd13e91d 100644 --- a/src/buffer/unicode.zig +++ b/src/buffer/unicode.zig @@ -201,6 +201,24 @@ pub fn switch_case(allocator: std.mem.Allocator, text: []const u8) TransformErro to_upper(allocator, text); } +pub fn toggle_case(allocator: std.mem.Allocator, text: []const u8) TransformError![]u8 { + var result: std.Io.Writer.Allocating = .init(allocator); + defer result.deinit(); + const writer = &result.writer; + const view: Utf8View = .initUnchecked(text); + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + const cp_ = if (uucode.get(.is_lowercase, cp)) + uucode.get(.simple_uppercase_mapping, cp) orelse cp + else + uucode.get(.simple_lowercase_mapping, cp) orelse cp; + var utf8_buf: [6]u8 = undefined; + const size = try utf8Encode(cp_, &utf8_buf); + try writer.writeAll(utf8_buf[0..size]); + } + return result.toOwnedSlice(); +} + pub fn is_lowercase(text: []const u8) bool { return utf8_predicate_all(.is_lowercase, text); } diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 620f2390..a4456dad 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -7347,6 +7347,30 @@ pub const Editor = struct { } pub const switch_case_meta: Meta = .{ .description = "Switch the case of selection or character at cursor" }; + fn toggle_case_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { + var root = root_; + const saved = cursel.*; + const sel = try self.get_selection_or_select_word(root, cursel); + var sfa = std.heap.stackFallback(4096, self.allocator); + const sfa_allocator = sfa.get(); + const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; + defer sfa_allocator.free(cut_text); + const transformed = Buffer.unicode.toggle_case(sfa_allocator, cut_text) catch return error.Stop; + defer sfa_allocator.free(transformed); + root = try self.delete_selection(root, cursel, allocator); + root = self.insert(root, cursel, transformed, allocator) catch return error.Stop; + cursel.* = saved; + return root; + } + + pub fn toggle_case(self: *Self, _: Context) Result { + const b = try self.buf_for_update(); + const root = try self.with_cursels_mut_once(b.root, toggle_case_cursel, b.allocator); + try self.update_buf(root); + self.clamp(); + } + pub const toggle_case_meta: Meta = .{ .description = "Toggle the case of each character in selection or character at cursor" }; + pub fn forced_mark_clean(self: *Self, _: Context) Result { if (self.buffer) |b| { b.mark_clean(); From ce61c1765a74b2dde680ab01c45c0f16564c8ca4 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Apr 2026 22:09:08 +0200 Subject: [PATCH 13/15] refactor: use changes_when_lowercased consistently in toggle_case switch_case already uses changes_when_lowercased to detect uppercase characters. toggle_case was using is_lowercase with inverted branch order, which is functionally equivalent but inconsistent. --- src/buffer/unicode.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/buffer/unicode.zig b/src/buffer/unicode.zig index fd13e91d..fc78be54 100644 --- a/src/buffer/unicode.zig +++ b/src/buffer/unicode.zig @@ -208,10 +208,10 @@ pub fn toggle_case(allocator: std.mem.Allocator, text: []const u8) TransformErro const view: Utf8View = .initUnchecked(text); var it = view.iterator(); while (it.nextCodepoint()) |cp| { - const cp_ = if (uucode.get(.is_lowercase, cp)) - uucode.get(.simple_uppercase_mapping, cp) orelse cp + const cp_ = if (uucode.get(.changes_when_lowercased, cp)) + uucode.get(.simple_lowercase_mapping, cp) orelse cp else - uucode.get(.simple_lowercase_mapping, cp) orelse cp; + uucode.get(.simple_uppercase_mapping, cp) orelse cp; var utf8_buf: [6]u8 = undefined; const size = try utf8Encode(cp_, &utf8_buf); try writer.writeAll(utf8_buf[0..size]); From c7e6906efdddb0be5d364ddbd628abf6c0976102 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Apr 2026 22:20:35 +0200 Subject: [PATCH 14/15] feat: add toggle_case keybind to flow mode --- src/keybind/builtin/flow.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index 4425d51d..db80dde4 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -150,6 +150,7 @@ ["alt+l", "to_lower"], ["alt+q", "reflow"], ["alt+c", "switch_case"], + ["ctrl+k c", "toggle_case"], ["ctrl+_", "underline"], ["ctrl+=", "underline_with_char", "=", "solid"], ["ctrl+plus", "underline_with_char", "="], From 2ae8d3048d06c02df5e3b8053ccb0b598eec8899 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Mon, 13 Apr 2026 22:40:08 +0200 Subject: [PATCH 15/15] refactor: reduce code duplication in case transform functions --- src/tui/editor.zig | 48 ++++++++++++++++------------------------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/src/tui/editor.zig b/src/tui/editor.zig index a4456dad..347c13a5 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -7268,7 +7268,13 @@ pub const Editor = struct { } } - fn to_upper_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { + fn transform_cursel( + comptime transform: fn (std.mem.Allocator, []const u8) Buffer.unicode.TransformError![]u8, + self: *Self, + root_: Buffer.Root, + cursel: *CurSel, + allocator: Allocator, + ) error{Stop}!Buffer.Root { var root = root_; const saved = cursel.*; const sel = try self.get_selection_or_select_word(root, cursel); @@ -7276,14 +7282,18 @@ pub const Editor = struct { const sfa_allocator = sfa.get(); const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; defer sfa_allocator.free(cut_text); - const ucased = Buffer.unicode.to_upper(sfa_allocator, cut_text) catch return error.Stop; - defer sfa_allocator.free(ucased); + const transformed = transform(sfa_allocator, cut_text) catch return error.Stop; + defer sfa_allocator.free(transformed); root = try self.delete_selection(root, cursel, allocator); - root = self.insert(root, cursel, ucased, allocator) catch return error.Stop; + root = self.insert(root, cursel, transformed, allocator) catch return error.Stop; cursel.* = saved; return root; } + fn to_upper_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { + return transform_cursel(Buffer.unicode.to_upper, self, root_, cursel, allocator); + } + pub fn to_upper(self: *Self, _: Context) Result { const b = try self.buf_for_update(); const root = try self.with_cursels_mut_once(b.root, to_upper_cursel, b.allocator); @@ -7292,20 +7302,8 @@ pub const Editor = struct { } pub const to_upper_meta: Meta = .{ .description = "Convert selection or word to upper case" }; - fn to_lower_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, buffer_allocator: Allocator) error{Stop}!Buffer.Root { - var root = root_; - const saved = cursel.*; - const sel = try self.get_selection_or_select_word(root, cursel); - var sfa = std.heap.stackFallback(4096, self.allocator); - const sfa_allocator = sfa.get(); - const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; - defer sfa_allocator.free(cut_text); - const ucased = Buffer.unicode.to_lower(sfa_allocator, cut_text) catch return error.Stop; - defer sfa_allocator.free(ucased); - root = try self.delete_selection(root, cursel, buffer_allocator); - root = self.insert(root, cursel, ucased, buffer_allocator) catch return error.Stop; - cursel.* = saved; - return root; + fn to_lower_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { + return transform_cursel(Buffer.unicode.to_lower, self, root_, cursel, allocator); } pub fn to_lower(self: *Self, _: Context) Result { @@ -7348,19 +7346,7 @@ pub const Editor = struct { pub const switch_case_meta: Meta = .{ .description = "Switch the case of selection or character at cursor" }; fn toggle_case_cursel(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { - var root = root_; - const saved = cursel.*; - const sel = try self.get_selection_or_select_word(root, cursel); - var sfa = std.heap.stackFallback(4096, self.allocator); - const sfa_allocator = sfa.get(); - const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; - defer sfa_allocator.free(cut_text); - const transformed = Buffer.unicode.toggle_case(sfa_allocator, cut_text) catch return error.Stop; - defer sfa_allocator.free(transformed); - root = try self.delete_selection(root, cursel, allocator); - root = self.insert(root, cursel, transformed, allocator) catch return error.Stop; - cursel.* = saved; - return root; + return transform_cursel(Buffer.unicode.toggle_case, self, root_, cursel, allocator); } pub fn toggle_case(self: *Self, _: Context) Result {