16 Commits

Author SHA1 Message Date
Mitchell Hashimoto
8d11c08db3 feat: add selection-clear-on-copy configuration option (#8462)
Addresses issue: Add selection-clear-on-copy configuration #8407

Added configuration option `selection-clear-on-copy` that matches with
the `selection-clear-on-typing` option.
And `copy-on-select` is ignored when `selection-clear-on-copy` is true
regardless of whether `copy-on-select` is set to true or clipboard.
Also `.copy_to_clipboard` binding action was refactored to use
`copySelectionToClipboards` for consistent behavior.

> Consulted with Copilot (Claude Sonnet 4) to understand the control
flow of copy operations and help write the docs. Solution was authored
and implemented by me.
2025-09-02 14:55:46 -07:00
Toufiq Shishir
90c0fc2590 feat: add selection-clear-on-copy configuration 2025-09-02 14:48:47 -07:00
Mitchell Hashimoto
e909e28876 Compare fields directly instead of PackedStyle (#8489)
This is a small, but I think worthwhile micro optimization in style.zig,
I uncovered while investigating wider ranging optimizations in the
rendering section.

For me it results in ~4-5% increase in fps for DOOM-fire-zig benchmark,
which maximally stresses this code path.

Comparing the fields directly is actually faster than PackedStyle.

I wrote the code in style.zig, claude 4 wrote the benchmark, all PR
responses will be generated by jcm-slow-1


Style.eql Benchmark Comparison
==============================

Test: Small (1K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 49937
  Duration: 500.01 ms
  Throughput: 99872402 comparisons/sec
Old implementation:
  Iterations: 8508
  Duration: 500.06 ms
  Throughput: 17014026 comparisons/sec
Performance improvement:
  Speedup: 5.87x
  Improvement: +487.0%

Test: Medium (10K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 4435
  Duration: 500.09 ms
  Throughput: 88684746 comparisons/sec
Old implementation:
  Iterations: 850
  Duration: 500.50 ms
  Throughput: 16983017 comparisons/sec
Performance improvement:
  Speedup: 5.22x
  Improvement: +422.2%

Test: Large (50K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 861
  Duration: 500.41 ms
  Throughput: 86030144 comparisons/sec
Old implementation:
  Iterations: 171
  Duration: 501.70 ms
  Throughput: 17041989 comparisons/sec
Performance improvement:
  Speedup: 5.05x
  Improvement: +404.8%

Test: Mostly equal (10K pairs, 90% equal)
--------------------------------------------------
New implementation:
  Iterations: 4608
  Duration: 500.03 ms
  Throughput: 92154471 comparisons/sec
Old implementation:
  Iterations: 854
  Duration: 500.45 ms
  Throughput: 17064744 comparisons/sec
Performance improvement:
  Speedup: 5.40x
  Improvement: +440.0%

Test: Mostly different (10K pairs, 10% equal)
--------------------------------------------------
New implementation:
  Iterations: 4065
  Duration: 500.03 ms
  Throughput: 81294960 comparisons/sec
Old implementation:
  Iterations: 848
  Duration: 500.21 ms
  Throughput: 16952948 comparisons/sec
Performance improvement:
  Speedup: 4.80x
  Improvement: +379.5%

Test: Same flags (10K pairs, 50% equal)
--------------------------------------------------
New implementation:
  Iterations: 2799
  Duration: 500.00 ms
  Throughput: 55979776 comparisons/sec
Old implementation:
  Iterations: 859
  Duration: 500.13 ms
  Throughput: 17175672 comparisons/sec
Performance improvement:
  Speedup: 3.26x
  Improvement: +225.9%
2025-09-02 14:37:30 -07:00
Jesse Miller
cf0390bab5 Use comptime for eql() to ensure Style struct coverage. 2025-09-02 15:14:42 -06:00
Jesse Miller
4614e5fdad Zig 0.14+ can directly compare packed structs. 2025-09-02 14:58:21 -06:00
Mitchell Hashimoto
ce94bb9f6a macOS: firstRect should return full rect width/height (#8492)
Fixes #2473

This commit changes `ghostty_surface_ime_point` to return a full rect
with the width/height calculated for the preedit.

The `firstRect` function, which calls `ghostty_surface_ime_point` was
previously setting the width/height to zero. macOS didn't like this. We
then changed it to just hardcode it to width/height of one cell. This
worked but made it so the IME cursor didn't follow the preedit.

The result is shown in the video below. Notice the dictation icon
follows the text properly:



https://github.com/user-attachments/assets/81be8c63-9f0a-49b7-ac30-2db930beb238
2025-09-02 13:28:08 -07:00
Jesse Miller
ac104a3dfc zig fmt 2025-09-02 14:14:06 -06:00
Mitchell Hashimoto
16e47e7586 fix(font): detect and reject improper advance for icwidth (#8491)
Fixes #8481

Explained in code comments, basically the NF patcher can produce fonts
that have CJK characters with 1-cell advances, which screws up fallback
font scaling; fixed by not counting the ic width metric if the width of
the glyph is greater than the advance width.

> [!NOTE]
> As follow-on work to this it may be worth setting limits for scaling,
so you can't have one font scaled like twice as large as the primary
font, since that's almost always going to indicate something is very
wrong.
2025-09-02 13:09:07 -07:00
Mitchell Hashimoto
e8217aa007 macOS: firstRect should return full rect width/height
Fixes #2473

This commit changes `ghostty_surface_ime_point` to return a full rect
with the width/height calculated for the preedit.

The `firstRect` function, which calls `ghostty_surface_ime_point` was
previously setting the width/height to zero. macOS didn't like this. We
then changed it to just hardcode it to width/height of one cell. This
worked but made it so the IME cursor didn't follow the preedit.
2025-09-02 13:08:46 -07:00
Qwerasd
9aa1698e5a font: log warning when rejecting ic_width 2025-09-02 13:47:59 -06:00
Mitchell Hashimoto
3664ee9f87 macOS: Notify macOS of cell width/height for firstRect (#8490)
Related to #2473

This fixes an issue where the dictation icon didn't show the language
picker.
2025-09-02 12:38:50 -07:00
Qwerasd
a72995590b fix(font): detect and reject improper advance for icwidth 2025-09-02 13:33:33 -06:00
Mitchell Hashimoto
2bf0d3f4c7 macOS: Notify macOS of cell width/height for firstRect
Related to #2473

This fixes an issue where the dictation icon didn't show the language 
picker.
2025-09-02 12:26:52 -07:00
Mitchell Hashimoto
4af290d5f0 fix(renderer): kitty images should all be processed (#8488)
When processing kitty images in a loop in a few places we were returning
under certain conditions where we should instead have just continued the
loop. This caused serious problems for kitty images, especially for apps
that used multiple images on screen at once.

... I have no clue how I originally wrote this code and didn't see such
a trivial mistake, I think I was sleep deprived or something.

Should fix #8471
2025-09-02 12:14:11 -07:00
Qwerasd
ef7857f9be fix(renderer): kitty images should all be processed
When processing kitty images in a loop in a few places we were returning
under certain conditions where we should instead have just continued the
loop. This caused serious problems for kitty images, especially for apps
that used multiple images on screen at once.

... I have no clue how I originally wrote this code and didn't see such
a trivial mistake, I think I was sleep deprived or something.
2025-09-02 12:42:34 -06:00
Jesse Miller
7dcf2c9b62 Compare fields directly instead of PackedStyle
Comparing the fields directly is actually faster than PackedStyle
2025-09-02 12:05:30 -06:00
10 changed files with 159 additions and 20 deletions

View File

@@ -964,7 +964,7 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t,
double,
ghostty_input_scroll_mods_t);
void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
void ghostty_surface_ime_point(ghostty_surface_t, double*, double*);
void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);
void ghostty_surface_request_close(ghostty_surface_t);
void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);
void ghostty_surface_split_focus(ghostty_surface_t,

View File

@@ -1683,8 +1683,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
}
// Ghostty will tell us where it thinks an IME keyboard should render.
var x: Double = 0;
var y: Double = 0;
var x: Double = 0
var y: Double = 0
var width: Double = cellSize.width
var height: Double = cellSize.height
// QuickLook never gives us a matching range to our selection so if we detect
// this then we return the top-left selection point rather than the cursor point.
@@ -1702,15 +1704,19 @@ extension Ghostty.SurfaceView: NSTextInputClient {
// Free our text
ghostty_surface_free_text(surface, &text)
} else {
ghostty_surface_ime_point(surface, &x, &y)
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
}
} else {
ghostty_surface_ime_point(surface, &x, &y)
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
}
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
// bottom-left since that is what UIKit expects
let viewRect = NSMakeRect(x, frame.size.height - y, 0, 0)
let viewRect = NSMakeRect(
x,
frame.size.height - y,
max(width, cellSize.width),
max(height, cellSize.height))
// Convert the point to the window coordinates
let winRect = self.convert(viewRect, to: nil)

View File

@@ -258,6 +258,7 @@ const DerivedConfig = struct {
mouse_shift_capture: configpkg.MouseShiftCapture,
macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
macos_option_as_alt: ?configpkg.OptionAsAlt,
selection_clear_on_copy: bool,
selection_clear_on_typing: bool,
vt_kam_allowed: bool,
wait_after_command: bool,
@@ -327,6 +328,7 @@ const DerivedConfig = struct {
.mouse_shift_capture = config.@"mouse-shift-capture",
.macos_non_native_fullscreen = config.@"macos-non-native-fullscreen",
.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",
.vt_kam_allowed = config.@"vt-kam-allowed",
.wait_after_command = config.@"wait-after-command",
@@ -1730,6 +1732,7 @@ pub fn pwd(
pub fn imePoint(self: *const Surface) apprt.IMEPos {
self.renderer_state.mutex.lock();
const cursor = self.renderer_state.terminal.screen.cursor;
const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0;
self.renderer_state.mutex.unlock();
// TODO: need to handle when scrolling and the cursor is not
@@ -1764,7 +1767,38 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos {
break :y y;
};
return .{ .x = x, .y = y };
// Our height for now is always just the cell height because our preedit
// rendering only renders in a single line.
const height: f64 = height: {
var height: f64 = @floatFromInt(self.size.cell.height);
height /= content_scale.y;
break :height height;
};
const width: f64 = width: {
var width: f64 = @floatFromInt(preedit_width * self.size.cell.width);
// Our max width is the remaining screen width after the cursor.
// We don't have to deal with wrapping because the preedit doesn't
// wrap right now.
const screen_width: f64 = @floatFromInt(self.size.terminal().width);
const x_offset: f64 = @floatFromInt((cursor.x + 1) * self.size.cell.width);
const max = screen_width - x_offset;
width = @min(width, max);
// Note: we don't apply content scale here because it looks like
// for some reason in macOS its already scaled. I'm not sure why
// that is so I'm going to just leave this comment here so its known
// that I left this out on purpose pending more investigation.
break :width width;
};
return .{
.x = x,
.y = y,
.width = width,
.height = height,
};
}
fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void {
@@ -4512,6 +4546,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
return true;
};
// Clear the selection if configured to do so.
if (self.config.selection_clear_on_copy) {
if (self.setSelection(null)) {
self.queueRender() catch |err| {
log.warn("failed to queue render after clear selection err={}", .{err});
};
} else |err| {
log.warn("failed to clear selection after copy err={}", .{err});
}
}
return true;
}

View File

@@ -1822,10 +1822,18 @@ pub const CAPI = struct {
surface.mousePressureCallback(stage, pressure);
}
export fn ghostty_surface_ime_point(surface: *Surface, x: *f64, y: *f64) void {
export fn ghostty_surface_ime_point(
surface: *Surface,
x: *f64,
y: *f64,
width: *f64,
height: *f64,
) void {
const pos = surface.core_surface.imePoint();
x.* = pos.x;
y.* = pos.y;
width.* = pos.width;
height.* = pos.height;
}
/// Request that the surface become closed. This will go through the

View File

@@ -24,6 +24,8 @@ pub const CursorPos = struct {
pub const IMEPos = struct {
x: f64,
y: f64,
width: f64,
height: f64,
};
/// The clipboard type.

View File

@@ -654,6 +654,18 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// Available since: 1.2.0
@"selection-clear-on-typing": bool = true,
/// Whether to clear selected text after copying. This defaults to `false`.
///
/// When set to `true`, the selection will be automatically cleared after
/// any copy operation that invokes the `copy_to_clipboard` keyboard binding.
/// Importantly, this will not clear the selection if the copy operation
/// was invoked via `copy-on-select`.
///
/// When set to `false`, the selection remains visible after copying, allowing
/// to see what was copied and potentially perform additional operations
/// on the same selection.
@"selection-clear-on-copy": bool = false,
/// 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

@@ -806,14 +806,41 @@ pub const Face = struct {
const ic_width: ?f64 = ic_width: {
const glyph = self.glyphIndex('水') orelse break :ic_width null;
var advances: [1]macos.graphics.Size = undefined;
_ = ct_font.getAdvancesForGlyphs(
const advance = ct_font.getAdvancesForGlyphs(
.horizontal,
&.{@intCast(glyph)},
&advances,
null,
);
break :ic_width advances[0].width;
const bounds = ct_font.getBoundingRectsForGlyphs(
.horizontal,
&.{@intCast(glyph)},
null,
);
// If the advance of the glyph is less than the width of the actual
// glyph then we just treat it as invalid since it's probably wrong
// and using it for size normalization will instead make the font
// way too big.
//
// This can sometimes happen if there's a CJK font that has been
// patched with the nerd fonts patcher and it butchers the advance
// values so the advance ends up half the width of the actual glyph.
if (bounds.size.width > advance) {
var buf: [1024]u8 = undefined;
const font_name = self.name(&buf) catch "<Error getting font name>";
log.warn(
"(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.",
.{
font_name,
bounds.size.width,
advance,
},
);
break :ic_width null;
}
break :ic_width advance;
};
return .{

View File

@@ -1007,7 +1007,31 @@ pub const Face = struct {
.no_svg = true,
}) catch break :ic_width null;
break :ic_width f26dot6ToF64(face.handle.*.glyph.*.advance.x);
const ft_glyph = face.handle.*.glyph;
// If the advance of the glyph is less than the width of the actual
// glyph then we just treat it as invalid since it's probably wrong
// and using it for size normalization will instead make the font
// way too big.
//
// This can sometimes happen if there's a CJK font that has been
// patched with the nerd fonts patcher and it butchers the advance
// values so the advance ends up half the width of the actual glyph.
if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) {
var buf: [1024]u8 = undefined;
const font_name = self.name(&buf) catch "<Error getting font name>";
log.warn(
"(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.",
.{
font_name,
f26dot6ToF64(ft_glyph.*.metrics.width),
f26dot6ToF64(ft_glyph.*.advance.x),
},
);
break :ic_width null;
}
break :ic_width f26dot6ToF64(ft_glyph.*.advance.x);
};
return .{

View File

@@ -1551,15 +1551,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Look up the image
const image = self.images.get(p.image_id) orelse {
log.warn("image not found for placement image_id={}", .{p.image_id});
return;
continue;
};
// Get the texture
const texture = switch (image.image) {
.ready => |t| t,
.ready,
.unload_ready,
=> |t| t,
else => {
log.warn("image not ready for placement image_id={}", .{p.image_id});
return;
continue;
},
};
@@ -1909,7 +1911,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
if (img.isUnloading()) {
img.deinit(self.alloc);
self.images.removeByPtr(kv.key_ptr);
return;
continue;
}
if (img.isPending()) try img.upload(self.alloc, &self.api);
}

View File

@@ -84,10 +84,23 @@ pub const Style = struct {
}
/// True if the style is equal to another style.
/// For performance do direct comparisons first.
pub fn eql(self: Style, other: Style) bool {
// We convert the styles to packed structs and compare as integers
// because this is much faster than comparing each field separately.
return PackedStyle.fromStyle(self) == PackedStyle.fromStyle(other);
inline for (comptime std.meta.fields(Style)) |field| {
if (comptime std.meta.hasUniqueRepresentation(field.type)) {
if (@field(self, field.name) != @field(other, field.name)) {
return false;
}
}
}
inline for (comptime std.meta.fields(Style)) |field| {
if (comptime !std.meta.hasUniqueRepresentation(field.type)) {
if (!std.meta.eql(@field(self, field.name), @field(other, field.name))) {
return false;
}
}
}
return true;
}
/// Returns the bg color for a cell with this style given the cell