Merge remote-tracking branch 'origin/main' into shaping-positions

This commit is contained in:
Jacob Sandlund
2025-12-12 08:54:34 -05:00
27 changed files with 747 additions and 242 deletions

View File

@@ -5183,7 +5183,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.prompt_surface_title => return try self.rt_app.performAction(
.{ .surface = self },
.prompt_title,
{},
.surface,
),
.prompt_tab_title => return try self.rt_app.performAction(
.{ .surface = self },
.prompt_title,
.tab,
),
.clear_screen => {

View File

@@ -189,8 +189,9 @@ pub const Action = union(Key) {
set_title: SetTitle,
/// Set the title of the target to a prompted value. It is up to
/// the apprt to prompt.
prompt_title,
/// the apprt to prompt. The value specifies whether to prompt for the
/// surface title or the tab title.
prompt_title: PromptTitle,
/// The current working directory has changed for the target terminal.
pwd: Pwd,
@@ -536,6 +537,12 @@ pub const MouseVisibility = enum(c_int) {
hidden,
};
/// Whether to prompt for the surface title or tab title.
pub const PromptTitle = enum(c_int) {
surface,
tab,
};
pub const MouseOverLink = struct {
url: [:0]const u8,

View File

@@ -693,7 +693,7 @@ pub const Application = extern struct {
.progress_report => return Action.progressReport(target, value),
.prompt_title => return Action.promptTitle(target),
.prompt_title => return Action.promptTitle(target, value),
.quit => self.quit(),
@@ -2250,12 +2250,18 @@ const Action = struct {
};
}
pub fn promptTitle(target: apprt.Target) bool {
switch (target) {
.app => return false,
.surface => |v| {
v.rt_surface.surface.promptTitle();
return true;
pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool {
switch (value) {
.surface => switch (target) {
.app => return false,
.surface => |v| {
v.rt_surface.surface.promptTitle();
return true;
},
},
.tab => {
// GTK does not yet support tab title prompting
return false;
},
}
}

View File

@@ -519,6 +519,11 @@ pub const Action = union(enum) {
/// version can be found by running `ghostty +version`.
prompt_surface_title,
/// Change the title of the current tab/window via a pop-up prompt. The
/// title set via this prompt overrides any title set by the terminal
/// and persists across focus changes within the tab.
prompt_tab_title,
/// Create a new split in the specified direction.
///
/// Valid arguments:
@@ -1191,6 +1196,7 @@ pub const Action = union(enum) {
.reset_font_size,
.set_font_size,
.prompt_surface_title,
.prompt_tab_title,
.clear_screen,
.select_all,
.scroll_to_top,

View File

@@ -413,10 +413,16 @@ fn actionCommands(action: Action.Key) []const Command {
.prompt_surface_title => comptime &.{.{
.action = .prompt_surface_title,
.title = "Change Title...",
.title = "Change Terminal Title...",
.description = "Prompt for a new title for the current terminal.",
}},
.prompt_tab_title => comptime &.{.{
.action = .prompt_tab_title,
.title = "Change Tab Title...",
.description = "Prompt for a new title for the current tab.",
}},
.new_split => comptime &.{
.{
.action = .{ .new_split = .left },

View File

@@ -1220,7 +1220,7 @@ const ReflowCursor = struct {
// with graphemes then we increase capacity.
if (self.page.graphemeCount() >= self.page.graphemeCapacity()) {
try self.adjustCapacity(list, .{
.hyperlink_bytes = cap.grapheme_bytes * 2,
.grapheme_bytes = cap.grapheme_bytes * 2,
});
}
@@ -10758,3 +10758,86 @@ test "PageList clears history" {
.x = 0,
}, s.getTopLeft(.active));
}
test "PageList resize reflow grapheme map capacity exceeded" {
// This test verifies that when reflowing content with many graphemes,
// the grapheme map capacity is correctly increased when needed.
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 10, 0);
defer s.deinit();
try testing.expectEqual(@as(usize, 1), s.totalPages());
// Get the grapheme capacity from the page. We need more than this many
// graphemes in a single destination page to trigger capacity increase
// during reflow. Since each source page can only hold this many graphemes,
// we create two source pages with graphemes that will merge into one
// destination page.
const grapheme_capacity = s.pages.first.?.data.graphemeCapacity();
// Use slightly more than half the capacity per page, so combined they
// exceed the capacity of a single destination page.
const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4;
// Grow to the capacity of the first page and add more rows
// so that we have two pages total.
{
const page = &s.pages.first.?.data;
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
}
page.pauseIntegrityChecks(false);
try testing.expectEqual(@as(usize, 1), s.totalPages());
try s.growRows(graphemes_per_page);
try testing.expectEqual(@as(usize, 2), s.totalPages());
// We now have two pages.
try testing.expect(s.pages.first.? != s.pages.last.?);
try testing.expectEqual(s.pages.last.?, s.pages.first.?.next);
}
// Add graphemes to both pages. We add graphemes to rows at the END of the
// first page, and graphemes to rows at the START of the second page.
// When reflowing to 2 columns, these rows will wrap and stay together
// on the same destination page, requiring capacity increase.
// Add graphemes to the end of the first page (last rows)
{
const page = &s.pages.first.?.data;
const start_row = page.size.rows - graphemes_per_page;
for (0..graphemes_per_page) |i| {
const y = start_row + i;
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'A' },
};
try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0301)));
}
}
// Add graphemes to the beginning of the second page
{
const page = &s.pages.last.?.data;
const count = @min(graphemes_per_page, page.size.rows);
for (0..count) |y| {
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'B' },
};
try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0302)));
}
}
// Resize to fewer columns to trigger reflow.
// The graphemes from both pages will be copied to destination pages.
// They will all end up in a contiguous region of the destination.
// If the bug exists (hyperlink_bytes increased instead of grapheme_bytes),
// this will fail with GraphemeMapOutOfMemory when we exceed capacity.
try s.resize(.{ .cols = 2, .reflow = true });
// Verify the resize succeeded
try testing.expectEqual(@as(usize, 2), s.cols);
}

View File

@@ -855,13 +855,17 @@ fn HashMapUnmanaged(
pub fn layoutForCapacity(new_capacity: Size) Layout {
assert(new_capacity == 0 or std.math.isPowerOfTwo(new_capacity));
// Cast to usize to prevent overflow in size calculations.
// See: https://github.com/ziglang/zig/pull/19048
const cap: usize = new_capacity;
// Pack our metadata, keys, and values.
const meta_start = @sizeOf(Header);
const meta_end = @sizeOf(Header) + new_capacity * @sizeOf(Metadata);
const meta_end = @sizeOf(Header) + cap * @sizeOf(Metadata);
const keys_start = std.mem.alignForward(usize, meta_end, key_align);
const keys_end = keys_start + new_capacity * @sizeOf(K);
const keys_end = keys_start + cap * @sizeOf(K);
const vals_start = std.mem.alignForward(usize, keys_end, val_align);
const vals_end = vals_start + new_capacity * @sizeOf(V);
const vals_end = vals_start + cap * @sizeOf(V);
// Our total memory size required is the end of our values
// aligned to the base required alignment.
@@ -1511,3 +1515,26 @@ test "OffsetHashMap remake map" {
try expectEqual(5, map.get(5).?);
}
}
test "layoutForCapacity no overflow for large capacity" {
// Test that layoutForCapacity correctly handles large capacities without overflow.
// Prior to the fix, new_capacity (u32) was multiplied before widening to usize,
// causing overflow when new_capacity * @sizeOf(K) exceeded 2^32.
// See: https://github.com/ghostty-org/ghostty/issues/9862
const Map = AutoHashMapUnmanaged(u64, u64);
// Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8
// 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32
const large_cap: Map.Size = 1 << 30;
const layout = Map.layoutForCapacity(large_cap);
// With the fix, total_size should be at least cap * (sizeof(K) + sizeof(V))
// = 2^30 * 16 = 2^34 bytes = 16 GiB
// Without the fix, this would wrap and produce a much smaller value.
const min_expected: usize = @as(usize, large_cap) * (@sizeOf(u64) + @sizeOf(u64));
try expect(layout.total_size >= min_expected);
// Also verify the individual offsets don't wrap
try expect(layout.keys_start > 0);
try expect(layout.vals_start > layout.keys_start);
}

View File

@@ -259,7 +259,7 @@ fn setupBash(
resource_dir: []const u8,
env: *EnvMap,
) !?config.Command {
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 3);
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2);
defer args.deinit(alloc);
// Iterator that yields each argument in the original command line.
@@ -273,11 +273,6 @@ fn setupBash(
} else return null;
try args.append(alloc, "--posix");
// On macOS, we request a login shell to match that platform's norms.
if (comptime builtin.target.os.tag.isDarwin()) {
try args.append(alloc, "--login");
}
// Stores the list of intercepted command line flags that will be passed
// to our shell integration script: --norc --noprofile
// We always include at least "1" so the script can differentiate between
@@ -357,9 +352,8 @@ fn setupBash(
);
try env.put("ENV", integ_dir);
// Since we built up a command line, we don't need to wrap it in
// ANOTHER shell anymore and can do a direct command.
return .{ .direct = try args.toOwnedSlice(alloc) };
// Join the accumulated arguments to form the final command string.
return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) };
}
test "bash" {
@@ -373,12 +367,7 @@ test "bash" {
const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?);
try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?);
}
@@ -421,12 +410,7 @@ test "bash: inject flags" {
const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?);
}
@@ -437,12 +421,7 @@ test "bash: inject flags" {
const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?);
}
}
@@ -459,24 +438,14 @@ test "bash: rcfile" {
// bash --rcfile
{
const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
// bash --init-file
{
const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
}
@@ -538,35 +507,13 @@ test "bash: additional arguments" {
// "-" argument separator
{
const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env);
try testing.expect(command.?.direct.len >= 6);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2;
try testing.expectEqualStrings("-", command.?.direct[offset + 0]);
try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]);
try testing.expectEqualStrings("file1", command.?.direct[offset + 2]);
try testing.expectEqualStrings("file2", command.?.direct[offset + 3]);
try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell);
}
// "--" argument separator
{
const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env);
try testing.expect(command.?.direct.len >= 6);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2;
try testing.expectEqualStrings("--", command.?.direct[offset + 0]);
try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]);
try testing.expectEqualStrings("file1", command.?.direct[offset + 2]);
try testing.expectEqualStrings("file2", command.?.direct[offset + 3]);
try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell);
}
}