feat: add configurable word boundary characters for text selection

Add new `selection-word-chars` config option to customize which characters
mark word boundaries during text selection operations (double-click, word
selection, etc.). Similar to zsh's WORDCHARS environment variable, but
specifies boundary characters rather than word characters.

Default boundaries: ` \t'"│`|:;,()[]{}<>$`

Users can now customize word selection behavior, such as treating
semicolons as part of words or excluding periods from boundaries:

    selection-word-chars = " \t'\"│`|:,()[]{}<>$"

Changes:
- Add selection-word-chars config field with comprehensive documentation
- Modify selectWord() and selectWordBetween() to accept boundary_chars parameter
- Parse UTF-8 boundary string to u32 codepoints at runtime
- Update all call sites in Surface.zig and embedded.zig
- Update all test cases to pass boundary characters
This commit is contained in:
mauroporras
2025-10-24 15:37:21 -05:00
committed by Mitchell Hashimoto
parent 6730afe312
commit 811e3594eb
4 changed files with 103 additions and 53 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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,