mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 03:25:50 +00:00
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:
committed by
Mitchell Hashimoto
parent
6730afe312
commit
811e3594eb
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user