diff --git a/src/Surface.zig b/src/Surface.zig index 99c740c89..f44d7bf89 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5565,7 +5565,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .end => .end, .beginning_of_line => .beginning_of_line, .end_of_line => .end_of_line, - }); + .word_left => .word_left, + .word_right => .word_right, + }, self.config.selection_word_chars); // If the selection endpoint is outside of the current viewpoint, // scroll it in to view. Note we always specifically use sel.end diff --git a/src/config/Config.zig b/src/config/Config.zig index 66b8c6057..ded213ccb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6594,6 +6594,18 @@ pub const Keybinds = struct { .{ .adjust_selection = .end }, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .shift = true, .alt = true } }, + .{ .adjust_selection = .word_left }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .shift = true, .alt = true } }, + .{ .adjust_selection = .word_right }, + .{ .performable = true }, + ); // Tabs common to all platforms try self.set.put( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d60f2933b..53dec1efb 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -505,6 +505,11 @@ pub const Action = union(enum) { /// Adjust the selection to the beginning or the end of the line /// respectively. /// + /// - `word_left`, `word_right` + /// + /// Adjust the selection one word to the left or right respectively, + /// using the configured `selection-word-chars` for word boundaries. + /// adjust_selection: AdjustSelection, /// Jump the viewport forward or back by the given number of prompts. @@ -1028,6 +1033,8 @@ pub const Action = union(enum) { end, beginning_of_line, end_of_line, + word_left, + word_right, }; pub const SplitDirection = enum { diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 5258210cf..54c32b0ec 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -414,6 +414,7 @@ pub fn adjust( self: *Selection, s: *const Screen, adjustment: Adjustment, + boundary_codepoints: ?[]const u21, ) void { // Note that we always adjust "end" because end always represents // the last point of the selection by mouse, not necessarily the @@ -424,7 +425,7 @@ pub fn adjust( .up => if (end_pin.up(1)) |new_end| { end_pin.* = new_end; } else { - self.adjust(s, .beginning_of_line); + self.adjust(s, .beginning_of_line, null); }, .down => { @@ -439,7 +440,7 @@ pub fn adjust( } } else { // If we're at the bottom, just go to the end of the line - self.adjust(s, .end_of_line); + self.adjust(s, .end_of_line, null); } }, @@ -472,13 +473,13 @@ pub fn adjust( .page_up => if (end_pin.up(s.pages.rows)) |new_end| { end_pin.* = new_end; } else { - self.adjust(s, .home); + self.adjust(s, .home, null); }, .page_down => if (end_pin.down(s.pages.rows)) |new_end| { end_pin.* = new_end; } else { - self.adjust(s, .end); + self.adjust(s, .end, null); }, .home => end_pin.* = s.pages.pin(.{ .screen = .{ @@ -506,6 +507,118 @@ pub fn adjust( .beginning_of_line => end_pin.x = 0, .end_of_line => end_pin.x = end_pin.node.data.size.cols - 1, + + .word_left => { + const codepoints = boundary_codepoints orelse return; + var it = end_pin.cellIterator(.left_up, null); + _ = it.next(); // skip current cell + + // Phase 1: skip boundary characters (whitespace/punctuation) going left. + // Empty cells and hard line breaks (non-wrapped rows) are treated + // as hard boundaries, consistent with selectWord in Screen.zig. + var last_non_boundary: ?Pin = null; + while (it.next()) |next| { + const rac = next.rowAndCell(); + const cell = rac.cell; + + // Stop at hard line breaks (landing on last col of a non-wrapped row) + if (next.x == next.node.data.size.cols - 1 and !rac.row.wrap) break; + + // Empty cells are hard boundaries + if (!cell.hasText()) break; + + const is_boundary = std.mem.indexOfAny( + u21, + codepoints, + &[_]u21{cell.content.codepoint}, + ) != null; + if (!is_boundary) { + last_non_boundary = next; + break; + } + } + + // Phase 2: skip non-boundary characters (word body) going left + // to find the start of the word. + if (last_non_boundary != null) { + while (it.next()) |next| { + const rac = next.rowAndCell(); + const cell = rac.cell; + + // Stop at hard line breaks + if (next.x == next.node.data.size.cols - 1 and !rac.row.wrap) break; + + if (!cell.hasText()) break; + const is_boundary = std.mem.indexOfAny( + u21, + codepoints, + &[_]u21{cell.content.codepoint}, + ) != null; + if (is_boundary) break; + last_non_boundary = next; + } + } + + if (last_non_boundary) |pos| { + end_pin.* = pos; + } + }, + + .word_right => { + const codepoints = boundary_codepoints orelse return; + var it = end_pin.cellIterator(.right_down, null); + _ = it.next(); // skip current cell + + // Phase 1: skip boundary characters (whitespace/punctuation) going right. + // Empty cells and hard line breaks (non-wrapped rows) are treated + // as hard boundaries, consistent with selectWord in Screen.zig. + var last_non_boundary: ?Pin = null; + while (it.next()) |next| { + const rac = next.rowAndCell(); + const cell = rac.cell; + + // Stop at hard line breaks + if (next.x == next.node.data.size.cols - 1 and !rac.row.wrap) break; + + // Empty cells are hard boundaries + if (!cell.hasText()) break; + + const is_boundary = std.mem.indexOfAny( + u21, + codepoints, + &[_]u21{cell.content.codepoint}, + ) != null; + if (!is_boundary) { + last_non_boundary = next; + break; + } + } + + // Phase 2: skip non-boundary characters (word body) going right + // to find the end of the word. + if (last_non_boundary != null) { + while (it.next()) |next| { + const rac = next.rowAndCell(); + const cell = rac.cell; + + // Stop at hard line breaks + if (next.x == next.node.data.size.cols - 1 and !rac.row.wrap) break; + + if (!cell.hasText()) break; + const is_boundary = std.mem.indexOfAny( + u21, + codepoints, + &[_]u21{cell.content.codepoint}, + ) != null; + if (is_boundary) break; + last_non_boundary = next; + } + } + + if (last_non_boundary) |pos| { + end_pin.* = pos; + } + }, } } @@ -523,7 +636,7 @@ test "Selection: adjust right" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .right); + sel.adjust(&s, .right, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 5, @@ -543,7 +656,7 @@ test "Selection: adjust right" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .right); + sel.adjust(&s, .right, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, @@ -563,7 +676,7 @@ test "Selection: adjust right" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .right); + sel.adjust(&s, .right, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 5, @@ -590,7 +703,7 @@ test "Selection: adjust left" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .left); + sel.adjust(&s, .left, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -611,7 +724,7 @@ test "Selection: adjust left" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .left); + sel.adjust(&s, .left, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -639,7 +752,7 @@ test "Selection: adjust left skips blanks" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .left); + sel.adjust(&s, .left, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -660,7 +773,7 @@ test "Selection: adjust left skips blanks" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .left); + sel.adjust(&s, .left, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -688,7 +801,7 @@ test "Selection: adjust up" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .up); + sel.adjust(&s, .up, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 5, @@ -708,7 +821,7 @@ test "Selection: adjust up" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .up); + sel.adjust(&s, .up, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 5, @@ -735,7 +848,7 @@ test "Selection: adjust down" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .down); + sel.adjust(&s, .down, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 5, @@ -755,7 +868,7 @@ test "Selection: adjust down" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .down); + sel.adjust(&s, .down, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, @@ -782,7 +895,7 @@ test "Selection: adjust down with not full screen" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .down); + sel.adjust(&s, .down, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -810,7 +923,7 @@ test "Selection: adjust home" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .home); + sel.adjust(&s, .home, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -838,7 +951,7 @@ test "Selection: adjust end with not full screen" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .end); + sel.adjust(&s, .end, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -866,7 +979,7 @@ test "Selection: adjust beginning of line" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .beginning_of_line); + sel.adjust(&s, .beginning_of_line, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -887,7 +1000,7 @@ test "Selection: adjust beginning of line" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .beginning_of_line); + sel.adjust(&s, .beginning_of_line, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -908,7 +1021,7 @@ test "Selection: adjust beginning of line" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .beginning_of_line); + sel.adjust(&s, .beginning_of_line, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -936,7 +1049,7 @@ test "Selection: adjust end of line" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .end_of_line); + sel.adjust(&s, .end_of_line, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -956,7 +1069,7 @@ test "Selection: adjust end of line" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .end_of_line); + sel.adjust(&s, .end_of_line, null); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -976,7 +1089,7 @@ test "Selection: adjust end of line" { false, ); defer sel.deinit(&s); - sel.adjust(&s, .end_of_line); + sel.adjust(&s, .end_of_line, null); // Start line try testing.expectEqual(point.Point{ .screen = .{ @@ -990,6 +1103,187 @@ test "Selection: adjust end of line" { } } +test "Selection: adjust word_right" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello world foo bar"); + + const boundary = &[_]u21{ ' ', '\t' }; + + // word_right from middle of "hello" -> end of "hello" + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_right, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // word_right from end of "hello" -> end of "world" + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_right, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // word_right from last word "bar" -> end of "bar" + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 16, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_right, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 18, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } +} + +test "Selection: adjust word_right with multiple spaces" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello world"); + + const boundary = &[_]u21{ ' ', '\t' }; + + // word_right from middle of "hello" -> end of "hello" + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_right, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } +} + +test "Selection: adjust word_left" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello world foo bar"); + + const boundary = &[_]u21{ ' ', '\t' }; + + // word_left from middle of "world" -> start of "world" + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 8, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_left, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // word_left from start of "world" -> start of "hello" + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 10, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_left, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // word_left from start of "hello" -> stays put (no previous word) + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_left, boundary); + + // Should stay at x=0 since there's no previous word + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } +} + +test "Selection: adjust word_left with multiple spaces" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello world"); + + const boundary = &[_]u21{ ' ', '\t' }; + + // word_left from middle of "world" -> start of "world" (skips within word) + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 10, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_left, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 8, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // word_left from start of "world" -> start of "hello" (skips multiple spaces) + { + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 8, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .word_left, boundary); + + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } +} + test "Selection: order, standard" { const testing = std.testing; const alloc = testing.allocator;