diff --git a/src/Surface.zig b/src/Surface.zig index 1f50cb681..65cb40091 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -316,6 +316,7 @@ const DerivedConfig = struct { macos_option_as_alt: ?input.OptionAsAlt, selection_clear_on_copy: bool, selection_clear_on_typing: bool, + selection_word_chars: []const u8, vt_kam_allowed: bool, wait_after_command: bool, window_padding_top: u32, @@ -392,6 +393,7 @@ const DerivedConfig = struct { .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_copy = config.@"selection-clear-on-copy", .selection_clear_on_typing = config.@"selection-clear-on-typing", + .selection_word_chars = config.@"selection-word-chars", .vt_kam_allowed = config.@"vt-kam-allowed", .wait_after_command = config.@"wait-after-command", .window_padding_top = config.@"window-padding-y".top_left, @@ -4180,7 +4182,7 @@ pub fn mouseButtonCallback( // Ignore any errors, likely regex errors. } - break :sel self.io.terminal.screens.active.selectWord(pin.*); + break :sel self.io.terminal.screens.active.selectWord(pin.*, self.config.selection_word_chars); }; if (sel_) |sel| { try self.io.terminal.screens.active.select(sel); @@ -4262,7 +4264,7 @@ pub fn mouseButtonCallback( if (try self.linkAtPos(pos)) |link| { try self.setSelection(link.selection); } else { - const sel = screen.selectWord(pin) orelse break :sel; + const sel = screen.selectWord(pin, self.config.selection_word_chars) orelse break :sel; try self.setSelection(sel); } try self.queueRender(); @@ -4583,7 +4585,10 @@ pub fn mousePressureCallback( // This should always be set in this state but we don't want // to handle state inconsistency here. const pin = self.mouse.left_click_pin orelse break :select; - const sel = self.io.terminal.screens.active.selectWord(pin.*) orelse break :select; + const sel = self.io.terminal.screens.active.selectWord( + pin.*, + self.config.selection_word_chars, + ) orelse break :select; try self.io.terminal.screens.active.select(sel); try self.queueRender(); } @@ -4806,7 +4811,11 @@ fn dragLeftClickDouble( const click_pin = self.mouse.left_click_pin.?.*; // Get the word closest to our starting click. - const word_start = screen.selectWordBetween(click_pin, drag_pin) orelse { + const word_start = screen.selectWordBetween( + click_pin, + drag_pin, + self.config.selection_word_chars, + ) orelse { try self.setSelection(null); return; }; @@ -4815,6 +4824,7 @@ fn dragLeftClickDouble( const word_current = screen.selectWordBetween( drag_pin, click_pin, + self.config.selection_word_chars, ) orelse { try self.setSelection(null); return; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 364a1bec1..b4ad7f885 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -2165,7 +2165,10 @@ pub const CAPI = struct { if (comptime std.debug.runtime_safety) unreachable; return false; }; - break :sel surface.io.terminal.screens.active.selectWord(pin) orelse return false; + break :sel surface.io.terminal.screens.active.selectWord( + pin, + surface.config.selection_word_chars, + ) orelse return false; }; // Read the selection diff --git a/src/config/Config.zig b/src/config/Config.zig index 7689899de..6d941d493 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -712,6 +712,31 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// on the same selection. @"selection-clear-on-copy": bool = false, +/// Characters that mark word boundaries during text selection operations such +/// as double-clicking. When selecting a word, the selection will stop at any +/// of these characters. +/// +/// This is similar to the `WORDCHARS` environment variable in zsh, except this +/// specifies the boundary characters rather than the word characters. The +/// default includes common delimiters and punctuation that typically separate +/// words in code and prose. +/// +/// Each character in this string becomes a word boundary. Multi-byte UTF-8 +/// characters are supported. +/// +/// The null character (U+0000) is always treated as a boundary and does not +/// need to be included in this configuration. +/// +/// Default: ` \t'"│`|:;,()[]{}<>$` +/// +/// To add or remove specific characters, you can set this to a custom value. +/// For example, to treat semicolons as part of words: +/// +/// selection-word-chars = " \t'\"│`|:,()[]{}<>$" +/// +/// Available since: 1.2.0 +@"selection-word-chars": []const u8 = " \t'\"│`|:;,()[]{}<>$", + /// The minimum contrast ratio between the foreground and background colors. /// The contrast ratio is a value between 1 and 21. A value of 1 allows for no /// contrast (e.g. black on black). This value is the contrast ratio as defined diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index d36cdac2a..3f7a48a65 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2617,11 +2617,15 @@ pub fn selectAll(self: *Screen) ?Selection { /// end_pt (inclusive). Because it selects "nearest" to start point, start /// point can be before or after end point. /// +/// The boundary_chars parameter should be a UTF-8 string of characters that +/// mark word boundaries, passed through to selectWord. +/// /// TODO: test this pub fn selectWordBetween( self: *Screen, start: Pin, end: Pin, + boundary_chars: []const u8, ) ?Selection { const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up; var it = start.cellIterator(dir, end); @@ -2633,7 +2637,7 @@ pub fn selectWordBetween( } // If we found a word, then return it - if (self.selectWord(pin)) |sel| return sel; + if (self.selectWord(pin, boundary_chars)) |sel| return sel; } return null; @@ -2645,32 +2649,37 @@ pub fn selectWordBetween( /// /// This will return null if a selection is impossible. The only scenario /// this happens is if the point pt is outside of the written screen space. -pub fn selectWord(self: *Screen, pin: Pin) ?Selection { +/// +/// The boundary_chars parameter should be a UTF-8 string of characters that +/// mark word boundaries. The null character (U+0000) is always included as +/// a boundary automatically. +pub fn selectWord( + self: *Screen, + pin: Pin, + boundary_chars: []const u8, +) ?Selection { _ = self; - // Boundary characters for selection purposes - const boundary = &[_]u32{ - 0, - ' ', - '\t', - '\'', - '"', - '│', - '`', - '|', - ':', - ';', - ',', - '(', - ')', - '[', - ']', - '{', - '}', - '<', - '>', - '$', - }; + // Parse boundary characters from UTF-8 string to u32 codepoints. + // We allocate a fixed-size array on the stack (64 boundary chars should be plenty). + var boundary_buf: [64]u32 = undefined; + var boundary_len: usize = 1; + boundary_buf[0] = 0; // Always include null character as a boundary + + // Parse the UTF-8 boundary string into codepoints + if (std.unicode.Utf8View.init(boundary_chars)) |utf8_view| { + var utf8_it = utf8_view.iterator(); + while (utf8_it.nextCodepoint()) |codepoint| { + if (boundary_len >= boundary_buf.len) break; // Safety limit + boundary_buf[boundary_len] = codepoint; + boundary_len += 1; + } + } else |_| { + // If invalid UTF-8, use just the null boundary + // This is a graceful fallback that still allows selection to work + } + + const boundary = boundary_buf[0..boundary_len]; // If our cell is empty we can't select a word, because we can't select // areas where the screen is not yet written. @@ -7699,6 +7708,9 @@ test "Screen: selectWord" { defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); + // Default boundary characters for word selection + const boundary_chars = " \t'\"│`|:;,()[]{}<>$"; + // Outside of active area // try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); // try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); @@ -7708,7 +7720,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7725,7 +7737,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7742,7 +7754,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7759,7 +7771,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, @@ -7776,7 +7788,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7793,7 +7805,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7825,7 +7837,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7842,7 +7854,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7859,7 +7871,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7885,7 +7897,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7902,7 +7914,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7919,7 +7931,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7966,7 +7978,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -7983,7 +7995,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 4, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -8000,7 +8012,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -8019,7 +8031,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8065,7 +8077,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8081,7 +8093,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8097,7 +8109,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, .y = 10, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8168,7 +8180,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 6, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8185,7 +8197,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 3, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8229,7 +8241,7 @@ test "Screen: selectPrompt prompt at start" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8273,7 +8285,7 @@ test "Screen: selectPrompt prompt at end" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0,