Merge remote-tracking branch 'upstream/main' into harfbuzz-positions

This commit is contained in:
Jacob Sandlund
2026-01-20 09:16:40 -05:00
24 changed files with 3648 additions and 716 deletions

View File

@@ -978,8 +978,6 @@ jobs:
--check-sourced \
--color=always \
--severity=warning \
--shell=bash \
--external-sources \
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
translations:

8
.shellcheckrc Normal file
View File

@@ -0,0 +1,8 @@
# ShellCheck <https://www.shellcheck.net/>
# https://github.com/koalaman/shellcheck/wiki/Directive#shellcheckrc-file
# Allow opening any 'source'd file, even if not specified as input
external-sources=true
# Assume bash by default
shell=bash

View File

@@ -164,6 +164,28 @@ alejandra .
Make sure your Alejandra version matches the version of Alejandra in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix).
### ShellCheck
Bash scripts are checked with [ShellCheck](https://www.shellcheck.net/) in CI.
Nix users can use the following command to run ShellCheck over all of our scripts:
```
nix develop -c shellcheck \
--check-sourced \
--severity=warning \
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
```
Non-Nix users can [install ShellCheck](https://github.com/koalaman/shellcheck#user-content-installing) and then run:
```
shellcheck \
--check-sourced \
--severity=warning \
$(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort)
```
### Updating the Zig Cache Fixed-Output Derivation Hash
The Nix package depends on a [fixed-output

View File

@@ -116,8 +116,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz",
.hash = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
.hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
.lazy = true,
},
},

6
build.zig.zon.json generated
View File

@@ -54,10 +54,10 @@
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
},
"N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV": {
"N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz",
"hash": "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
"hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",

6
build.zig.zon.nix generated
View File

@@ -171,11 +171,11 @@ in
};
}
{
name = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV";
name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz";
hash = "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz";
hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=";
};
}
{

2
build.zig.zon.txt generated
View File

@@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz
https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz

View File

@@ -67,9 +67,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz",
"dest": "vendor/p/N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV",
"sha256": "6d6290c51820cff64bafac3466dfe14e79c001bd77550595ef449c0ac3452199"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz",
"dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7",
"sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5"
},
{
"type": "archive",

View File

@@ -66,6 +66,14 @@ typedef enum {
GHOSTTY_MOUSE_LEFT,
GHOSTTY_MOUSE_RIGHT,
GHOSTTY_MOUSE_MIDDLE,
GHOSTTY_MOUSE_FOUR,
GHOSTTY_MOUSE_FIVE,
GHOSTTY_MOUSE_SIX,
GHOSTTY_MOUSE_SEVEN,
GHOSTTY_MOUSE_EIGHT,
GHOSTTY_MOUSE_NINE,
GHOSTTY_MOUSE_TEN,
GHOSTTY_MOUSE_ELEVEN,
} ghostty_input_mouse_button_e;
typedef enum {

View File

@@ -370,6 +370,14 @@ extension Ghostty.Input {
case left
case right
case middle
case four
case five
case six
case seven
case eight
case nine
case ten
case eleven
var cMouseButton: ghostty_input_mouse_button_e {
switch self {
@@ -377,6 +385,33 @@ extension Ghostty.Input {
case .left: GHOSTTY_MOUSE_LEFT
case .right: GHOSTTY_MOUSE_RIGHT
case .middle: GHOSTTY_MOUSE_MIDDLE
case .four: GHOSTTY_MOUSE_FOUR
case .five: GHOSTTY_MOUSE_FIVE
case .six: GHOSTTY_MOUSE_SIX
case .seven: GHOSTTY_MOUSE_SEVEN
case .eight: GHOSTTY_MOUSE_EIGHT
case .nine: GHOSTTY_MOUSE_NINE
case .ten: GHOSTTY_MOUSE_TEN
case .eleven: GHOSTTY_MOUSE_ELEVEN
}
}
/// Initialize from NSEvent.buttonNumber
/// NSEvent buttonNumber: 0=left, 1=right, 2=middle, 3=back (button 8), 4=forward (button 9), etc.
init(fromNSEventButtonNumber buttonNumber: Int) {
switch buttonNumber {
case 0: self = .left
case 1: self = .right
case 2: self = .middle
case 3: self = .eight // Back button
case 4: self = .nine // Forward button
case 5: self = .six
case 6: self = .seven
case 7: self = .four
case 8: self = .five
case 9: self = .ten
case 10: self = .eleven
default: self = .unknown
}
}
}

View File

@@ -860,16 +860,16 @@ extension Ghostty {
override func otherMouseDown(with event: NSEvent) {
guard let surface = self.surface else { return }
guard event.buttonNumber == 2 else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods)
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, button.cMouseButton, mods)
}
override func otherMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return }
guard event.buttonNumber == 2 else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods)
let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.cMouseButton, mods)
}

View File

@@ -9,7 +9,6 @@ const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const internal_os = @import("../../os/main.zig");
const xdg = internal_os.xdg;
const TempDir = internal_os.TempDir;
const Entry = @import("Entry.zig");
// 512KB - sufficient for approximately 10k entries
@@ -125,7 +124,7 @@ pub fn add(
break :update .updated;
};
try self.writeCacheFile(alloc, entries, null);
try self.writeCacheFile(entries, null);
return result;
}
@@ -166,7 +165,7 @@ pub fn remove(
alloc.free(kv.value.terminfo_version);
}
try self.writeCacheFile(alloc, entries, null);
try self.writeCacheFile(entries, null);
}
/// Check if a hostname exists in the cache.
@@ -209,32 +208,30 @@ fn fixupPermissions(file: std.fs.File) (std.fs.File.StatError || std.fs.File.Chm
fn writeCacheFile(
self: DiskCache,
alloc: Allocator,
entries: std.StringHashMap(Entry),
expire_days: ?u32,
) !void {
var td: TempDir = try .init();
defer td.deinit();
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
const cache_basename = std.fs.path.basename(self.path);
const tmp_file = try td.dir.createFile("ssh-cache", .{ .mode = 0o600 });
defer tmp_file.close();
const tmp_path = try td.dir.realpathAlloc(alloc, "ssh-cache");
defer alloc.free(tmp_path);
var dir = try std.fs.cwd().openDir(cache_dir, .{});
defer dir.close();
var buf: [1024]u8 = undefined;
var writer = tmp_file.writer(&buf);
var atomic_file = try dir.atomicFile(cache_basename, .{
.mode = 0o600,
.write_buffer = &buf,
});
defer atomic_file.deinit();
var iter = entries.iterator();
while (iter.next()) |kv| {
// Only write non-expired entries
if (kv.value_ptr.isExpired(expire_days)) continue;
try kv.value_ptr.format(&writer.interface);
try kv.value_ptr.format(&atomic_file.file_writer.interface);
}
// Don't forget to flush!!
try writer.interface.flush();
// Atomic replace
try std.fs.renameAbsolute(tmp_path, self.path);
try atomic_file.finish();
}
/// List all entries in the cache.
@@ -382,16 +379,16 @@ test "disk cache clear" {
const alloc = testing.allocator;
// Create our path
var td: TempDir = try .init();
defer td.deinit();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var buf: [4096]u8 = undefined;
{
var file = try td.dir.createFile("cache", .{});
var file = try tmp.dir.createFile("cache", .{});
defer file.close();
var file_writer = file.writer(&buf);
try file_writer.interface.writeAll("HELLO!");
}
const path = try td.dir.realpathAlloc(alloc, "cache");
const path = try tmp.dir.realpathAlloc(alloc, "cache");
defer alloc.free(path);
// Setup our cache
@@ -401,7 +398,7 @@ test "disk cache clear" {
// Verify the file is gone
try testing.expectError(
error.FileNotFound,
td.dir.openFile("cache", .{}),
tmp.dir.openFile("cache", .{}),
);
}
@@ -410,18 +407,18 @@ test "disk cache operations" {
const alloc = testing.allocator;
// Create our path
var td: TempDir = try .init();
defer td.deinit();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var buf: [4096]u8 = undefined;
{
var file = try td.dir.createFile("cache", .{});
var file = try tmp.dir.createFile("cache", .{});
defer file.close();
var file_writer = file.writer(&buf);
const writer = &file_writer.interface;
try writer.writeAll("HELLO!");
try writer.flush();
}
const path = try td.dir.realpathAlloc(alloc, "cache");
const path = try tmp.dir.realpathAlloc(alloc, "cache");
defer alloc.free(path);
// Setup our cache
@@ -453,6 +450,32 @@ test "disk cache operations" {
);
}
test "disk cache cleans up temp files" {
const testing = std.testing;
const alloc = testing.allocator;
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
defer alloc.free(tmp_path);
const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
defer alloc.free(cache_path);
const cache: DiskCache = .{ .path = cache_path };
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com"));
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org"));
// Verify only the cache file exists and no temp files left behind
var count: usize = 0;
var iter = tmp.dir.iterate();
while (try iter.next()) |entry| {
count += 1;
try testing.expectEqualStrings("cache", entry.name);
}
try testing.expectEqual(1, count);
}
test isValidHost {
const testing = std.testing;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -996,7 +996,7 @@ pub fn saveCursor(self: *Terminal) void {
///
/// The primary and alternate screen have distinct save state.
/// If no save was done before values are reset to their initial values.
pub fn restoreCursor(self: *Terminal) !void {
pub fn restoreCursor(self: *Terminal) void {
const saved: Screen.SavedCursor = self.screens.active.saved_cursor orelse .{
.x = 0,
.y = 0,
@@ -1008,10 +1008,17 @@ pub fn restoreCursor(self: *Terminal) !void {
};
// Set the style first because it can fail
const old_style = self.screens.active.cursor.style;
self.screens.active.cursor.style = saved.style;
errdefer self.screens.active.cursor.style = old_style;
try self.screens.active.manualStyleUpdate();
self.screens.active.manualStyleUpdate() catch |err| {
// Regardless of the error here, we revert back to an unstyled
// cursor. It is more important that the restore succeeds in
// other attributes because terminals have no way to communicate
// failure back.
log.warn("restoreCursor error updating style err={}", .{err});
const screen: *Screen = self.screens.active;
screen.cursor.style = .{};
self.screens.active.manualStyleUpdate() catch unreachable;
};
self.screens.active.charset = saved.charset;
self.modes.set(.origin, saved.origin);
@@ -1634,54 +1641,48 @@ pub fn insertLines(self: *Terminal, count: usize) void {
self.scrolling_region.left,
self.scrolling_region.right + 1,
) catch |err| {
const cap = dst_p.node.data.capacity;
// Adjust our page capacity to make
// room for we didn't have space for
_ = self.screens.active.adjustCapacity(
_ = self.screens.active.increaseCapacity(
dst_p.node,
switch (err) {
// Rehash the sets
error.StyleSetNeedsRehash,
error.HyperlinkSetNeedsRehash,
=> .{},
=> null,
// Increase style memory
error.StyleSetOutOfMemory,
=> .{ .styles = cap.styles * 2 },
=> .styles,
// Increase string memory
error.StringAllocOutOfMemory,
=> .{ .string_bytes = cap.string_bytes * 2 },
=> .string_bytes,
// Increase hyperlink memory
error.HyperlinkSetOutOfMemory,
error.HyperlinkMapOutOfMemory,
=> .{ .hyperlink_bytes = cap.hyperlink_bytes * 2 },
=> .hyperlink_bytes,
// Increase grapheme memory
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> .{ .grapheme_bytes = cap.grapheme_bytes * 2 },
=> .grapheme_bytes,
},
) catch |e| switch (e) {
// This shouldn't be possible because above we're only
// adjusting capacity _upwards_. So it should have all
// the existing capacity it had to fit the adjusted
// data. Panic since we don't expect this.
error.StyleSetOutOfMemory,
error.StyleSetNeedsRehash,
error.StringAllocOutOfMemory,
error.HyperlinkSetOutOfMemory,
error.HyperlinkSetNeedsRehash,
error.HyperlinkMapOutOfMemory,
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> @panic("adjustCapacity resulted in capacity errors"),
// The system allocator is OOM. We can't currently do
// anything graceful here. We panic.
// System OOM. We have no way to recover from this
// currently. We should probably change insertLines
// to raise an error here.
error.OutOfMemory,
=> @panic("adjustCapacity system allocator OOM"),
=> @panic("increaseCapacity system allocator OOM"),
// The page can't accommodate the managed memory required
// for this operation. We previously just corrupted
// memory here so a crash is better. The right long
// term solution is to allocate a new page here
// move this row to the new page, and start over.
error.OutOfSpace,
=> @panic("increaseCapacity OutOfSpace"),
};
// Continue the loop to try handling this row again.
@@ -1834,49 +1835,41 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
self.scrolling_region.left,
self.scrolling_region.right + 1,
) catch |err| {
const cap = dst_p.node.data.capacity;
// Adjust our page capacity to make
// room for we didn't have space for
_ = self.screens.active.adjustCapacity(
_ = self.screens.active.increaseCapacity(
dst_p.node,
switch (err) {
// Rehash the sets
error.StyleSetNeedsRehash,
error.HyperlinkSetNeedsRehash,
=> .{},
=> null,
// Increase style memory
error.StyleSetOutOfMemory,
=> .{ .styles = cap.styles * 2 },
=> .styles,
// Increase string memory
error.StringAllocOutOfMemory,
=> .{ .string_bytes = cap.string_bytes * 2 },
=> .string_bytes,
// Increase hyperlink memory
error.HyperlinkSetOutOfMemory,
error.HyperlinkMapOutOfMemory,
=> .{ .hyperlink_bytes = cap.hyperlink_bytes * 2 },
=> .hyperlink_bytes,
// Increase grapheme memory
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> .{ .grapheme_bytes = cap.grapheme_bytes * 2 },
=> .grapheme_bytes,
},
) catch |e| switch (e) {
// See insertLines which has the same error capture.
error.StyleSetOutOfMemory,
error.StyleSetNeedsRehash,
error.StringAllocOutOfMemory,
error.HyperlinkSetOutOfMemory,
error.HyperlinkSetNeedsRehash,
error.HyperlinkMapOutOfMemory,
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> @panic("adjustCapacity resulted in capacity errors"),
// See insertLines
error.OutOfMemory,
=> @panic("adjustCapacity system allocator OOM"),
=> @panic("increaseCapacity system allocator OOM"),
error.OutOfSpace,
=> @panic("increaseCapacity OutOfSpace"),
};
// Continue the loop to try handling this row again.
@@ -2761,12 +2754,7 @@ pub fn switchScreenMode(
}
} else {
assert(self.screens.active_key == .primary);
self.restoreCursor() catch |err| {
log.warn(
"restore cursor on switch screen failed to={} err={}",
.{ to, err },
);
};
self.restoreCursor();
},
}
}
@@ -4821,7 +4809,7 @@ test "Terminal: horizontal tab back with cursor before left margin" {
t.saveCursor();
t.modes.set(.enable_left_and_right_margin, true);
t.setLeftAndRightMargin(5, 0);
try t.restoreCursor();
t.restoreCursor();
try t.horizontalTabBack();
try t.print('X');
@@ -9887,7 +9875,7 @@ test "Terminal: saveCursor" {
t.screens.active.charset.gr = .G0;
try t.setAttribute(.{ .unset = {} });
t.modes.set(.origin, false);
try t.restoreCursor();
t.restoreCursor();
try testing.expect(t.screens.active.cursor.style.flags.bold);
try testing.expect(t.screens.active.charset.gr == .G3);
try testing.expect(t.modes.get(.origin));
@@ -9903,7 +9891,7 @@ test "Terminal: saveCursor position" {
t.saveCursor();
t.setCursorPos(1, 1);
try t.print('B');
try t.restoreCursor();
t.restoreCursor();
try t.print('X');
{
@@ -9923,7 +9911,7 @@ test "Terminal: saveCursor pending wrap state" {
t.saveCursor();
t.setCursorPos(1, 1);
try t.print('B');
try t.restoreCursor();
t.restoreCursor();
try t.print('X');
{
@@ -9943,7 +9931,7 @@ test "Terminal: saveCursor origin mode" {
t.modes.set(.enable_left_and_right_margin, true);
t.setLeftAndRightMargin(3, 5);
t.setTopAndBottomMargin(2, 4);
try t.restoreCursor();
t.restoreCursor();
try t.print('X');
{
@@ -9961,7 +9949,7 @@ test "Terminal: saveCursor resize" {
t.setCursorPos(1, 10);
t.saveCursor();
try t.resize(alloc, 5, 5);
try t.restoreCursor();
t.restoreCursor();
try t.print('X');
{
@@ -9982,7 +9970,7 @@ test "Terminal: saveCursor protected pen" {
t.saveCursor();
t.setProtectedMode(.off);
try testing.expect(!t.screens.active.cursor.protected);
try t.restoreCursor();
t.restoreCursor();
try testing.expect(t.screens.active.cursor.protected);
}
@@ -9995,10 +9983,67 @@ test "Terminal: saveCursor doesn't modify hyperlink state" {
const id = t.screens.active.cursor.hyperlink_id;
t.saveCursor();
try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id);
try t.restoreCursor();
t.restoreCursor();
try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id);
}
test "Terminal: restoreCursor uses default style on OutOfSpace" {
// Tests that restoreCursor falls back to default style when
// manualStyleUpdate fails with OutOfSpace (can't split a 1-row page
// and styles are at max capacity).
const alloc = testing.allocator;
// Use a single row so the page can't be split
var t = try init(alloc, .{ .cols = 10, .rows = 1 });
defer t.deinit(alloc);
// Set a style and save the cursor
try t.setAttribute(.{ .bold = {} });
t.saveCursor();
// Clear the style
try t.setAttribute(.{ .unset = {} });
try testing.expect(!t.screens.active.cursor.style.flags.bold);
// Fill the style map to max capacity
const max_styles = std.math.maxInt(size.CellCountInt);
while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) {
_ = t.screens.active.increaseCapacity(
t.screens.active.cursor.page_pin.node,
.styles,
) catch break;
}
const page = &t.screens.active.cursor.page_pin.node.data;
try testing.expectEqual(max_styles, page.capacity.styles);
// Fill all style slots using the StyleSet's layout capacity which accounts
// for the load factor. The capacity in the layout is the actual max number
// of items that can be stored.
{
page.pauseIntegrityChecks(true);
defer page.pauseIntegrityChecks(false);
defer page.assertIntegrity();
const max_items = page.styles.layout.cap;
var n: usize = 1;
while (n < max_items) : (n += 1) {
_ = page.styles.add(
page.memory,
.{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } },
) catch break;
}
}
// Restore cursor - should fall back to default style since page
// can't be split (1 row) and styles are at max capacity
t.restoreCursor();
// The style should be reset to default because OutOfSpace occurred
try testing.expect(!t.screens.active.cursor.style.flags.bold);
try testing.expectEqual(style.default_id, t.screens.active.cursor.style_id);
}
test "Terminal: setProtectedMode" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 3, .rows = 3 });
@@ -11390,7 +11435,7 @@ test "Terminal: resize with reflow and saved cursor" {
t.saveCursor();
try t.resize(alloc, 5, 3);
try t.restoreCursor();
t.restoreCursor();
{
const str = try t.plainString(testing.allocator);
@@ -11431,7 +11476,7 @@ test "Terminal: resize with reflow and saved cursor pending wrap" {
t.saveCursor();
try t.resize(alloc, 5, 3);
try t.restoreCursor();
t.restoreCursor();
{
const str = try t.plainString(testing.allocator);

View File

@@ -63,6 +63,14 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type {
};
}
/// Returns the number of bytes required to allocate n elements of
/// type T. This accounts for the chunk size alignment used by the
/// bitmap allocator.
pub fn bytesRequired(comptime T: type, n: usize) usize {
const byte_count = @sizeOf(T) * n;
return alignForward(usize, byte_count, chunk_size);
}
/// Allocate n elements of type T. This will return error.OutOfMemory
/// if there isn't enough space in the backing buffer.
///
@@ -955,3 +963,45 @@ test "BitmapAllocator alloc and free two 1.5 bitmaps offset 0.75" {
bm.bitmap.ptr(buf)[0..4],
);
}
test "BitmapAllocator bytesRequired" {
const testing = std.testing;
// Chunk size of 16 bytes (like grapheme_chunk in page.zig)
{
const Alloc = BitmapAllocator(16);
// Single byte rounds up to chunk size
try testing.expectEqual(16, Alloc.bytesRequired(u8, 1));
try testing.expectEqual(16, Alloc.bytesRequired(u8, 16));
try testing.expectEqual(32, Alloc.bytesRequired(u8, 17));
// u21 (4 bytes each)
try testing.expectEqual(16, Alloc.bytesRequired(u21, 1)); // 4 bytes -> 16
try testing.expectEqual(16, Alloc.bytesRequired(u21, 4)); // 16 bytes -> 16
try testing.expectEqual(32, Alloc.bytesRequired(u21, 5)); // 20 bytes -> 32
try testing.expectEqual(32, Alloc.bytesRequired(u21, 6)); // 24 bytes -> 32
}
// Chunk size of 4 bytes
{
const Alloc = BitmapAllocator(4);
try testing.expectEqual(4, Alloc.bytesRequired(u8, 1));
try testing.expectEqual(4, Alloc.bytesRequired(u8, 4));
try testing.expectEqual(8, Alloc.bytesRequired(u8, 5));
// u32 (4 bytes each) - exactly one chunk per element
try testing.expectEqual(4, Alloc.bytesRequired(u32, 1));
try testing.expectEqual(8, Alloc.bytesRequired(u32, 2));
}
// Chunk size of 32 bytes (like string_chunk in page.zig)
{
const Alloc = BitmapAllocator(32);
try testing.expectEqual(32, Alloc.bytesRequired(u8, 1));
try testing.expectEqual(32, Alloc.bytesRequired(u8, 32));
try testing.expectEqual(64, Alloc.bytesRequired(u8, 33));
}
}

View File

@@ -13,8 +13,9 @@ const autoHash = std.hash.autoHash;
const autoHashStrat = std.hash.autoHashStrat;
/// The unique identifier for a hyperlink. This is at most the number of cells
/// that can fit in a single terminal page.
pub const Id = size.CellCountInt;
/// that can fit in a single terminal page, since each cell can only contain
/// at most one hyperlink.
pub const Id = size.HyperlinkCountInt;
// The mapping of cell to hyperlink. We use an offset hash map to save space
// since its very unlikely a cell is a hyperlink, so its a waste to store

View File

@@ -33,64 +33,69 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
return null;
};
while (it.next()) |kv| {
if (std.mem.eql(u8, kv.key, "aid")) {
const key = kv.key orelse continue;
if (std.mem.eql(u8, key, "aid")) {
parser.command.prompt_start.aid = kv.value;
} else if (std.mem.eql(u8, kv.key, "redraw")) redraw: {
} else if (std.mem.eql(u8, key, "redraw")) redraw: {
// https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
// Kitty supports a "redraw" option for prompt_start. I can't find
// this documented anywhere but can see in the code that this is used
// by shell environments to tell the terminal that the shell will NOT
// redraw the prompt so we should attempt to resize it.
parser.command.prompt_start.redraw = (value: {
if (kv.value.len != 1) break :value null;
switch (kv.value[0]) {
const value = kv.value orelse break :value null;
if (value.len != 1) break :value null;
switch (value[0]) {
'0' => break :value false,
'1' => break :value true,
else => break :value null,
}
}) orelse {
log.info("OSC 133 A: invalid redraw value: {s}", .{kv.value});
log.info("OSC 133 A: invalid redraw value: {?s}", .{kv.value});
break :redraw;
};
} else if (std.mem.eql(u8, kv.key, "special_key")) redraw: {
} else if (std.mem.eql(u8, key, "special_key")) redraw: {
// https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
parser.command.prompt_start.special_key = (value: {
if (kv.value.len != 1) break :value null;
switch (kv.value[0]) {
const value = kv.value orelse break :value null;
if (value.len != 1) break :value null;
switch (value[0]) {
'0' => break :value false,
'1' => break :value true,
else => break :value null,
}
}) orelse {
log.info("OSC 133 A invalid special_key value: {s}", .{kv.value});
log.info("OSC 133 A invalid special_key value: {?s}", .{kv.value});
break :redraw;
};
} else if (std.mem.eql(u8, kv.key, "click_events")) redraw: {
} else if (std.mem.eql(u8, key, "click_events")) redraw: {
// https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
parser.command.prompt_start.click_events = (value: {
if (kv.value.len != 1) break :value null;
switch (kv.value[0]) {
const value = kv.value orelse break :value null;
if (value.len != 1) break :value null;
switch (value[0]) {
'0' => break :value false,
'1' => break :value true,
else => break :value null,
}
}) orelse {
log.info("OSC 133 A invalid click_events value: {s}", .{kv.value});
log.info("OSC 133 A invalid click_events value: {?s}", .{kv.value});
break :redraw;
};
} else if (std.mem.eql(u8, kv.key, "k")) k: {
} else if (std.mem.eql(u8, key, "k")) k: {
// The "k" marks the kind of prompt, or "primary" if we don't know.
// This can be used to distinguish between the first (initial) prompt,
// a continuation, etc.
if (kv.value.len != 1) break :k;
parser.command.prompt_start.kind = switch (kv.value[0]) {
const value = kv.value orelse break :k;
if (value.len != 1) break :k;
parser.command.prompt_start.kind = switch (value[0]) {
'c' => .continuation,
's' => .secondary,
'r' => .right,
'i' => .primary,
else => .primary,
};
} else log.info("OSC 133 A: unknown semantic prompt option: {s}", .{kv.key});
} else log.info("OSC 133 A: unknown semantic prompt option: {?s}", .{kv.key});
}
},
'B' => prompt_end: {
@@ -105,7 +110,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
return null;
};
while (it.next()) |kv| {
log.info("OSC 133 B: unknown semantic prompt option: {s}", .{kv.key});
log.info("OSC 133 B: unknown semantic prompt option: {?s}", .{kv.key});
}
},
'C' => end_of_input: {
@@ -122,12 +127,13 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
return null;
};
while (it.next()) |kv| {
if (std.mem.eql(u8, kv.key, "cmdline")) {
parser.command.end_of_input.cmdline = string_encoding.printfQDecode(kv.value) catch null;
} else if (std.mem.eql(u8, kv.key, "cmdline_url")) {
parser.command.end_of_input.cmdline = string_encoding.urlPercentDecode(kv.value) catch null;
const key = kv.key orelse continue;
if (std.mem.eql(u8, key, "cmdline")) {
parser.command.end_of_input.cmdline = if (kv.value) |value| string_encoding.printfQDecode(value) catch null else null;
} else if (std.mem.eql(u8, key, "cmdline_url")) {
parser.command.end_of_input.cmdline = if (kv.value) |value| string_encoding.urlPercentDecode(value) catch null else null;
} else {
log.info("OSC 133 C: unknown semantic prompt option: {s}", .{kv.key});
log.info("OSC 133 C: unknown semantic prompt option: {s}", .{key});
}
}
},
@@ -159,8 +165,8 @@ const SemanticPromptKVIterator = struct {
string: []u8,
pub const SemanticPromptKV = struct {
key: [:0]u8,
value: [:0]u8,
key: ?[:0]u8,
value: ?[:0]u8,
};
pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!SemanticPromptKVIterator {
@@ -186,8 +192,24 @@ const SemanticPromptKVIterator = struct {
break :kv kv;
};
// If we have an empty item, we return a null key and value.
//
// This allows for trailing semicolons, but also lets us parse
// (or rather, ignore) empty fields; for example `a=b;;e=f`.
if (kv.len < 1) return .{
.key = null,
.value = null,
};
const key = key: {
const index = std.mem.indexOfScalar(u8, kv, '=') orelse break :key kv;
const index = std.mem.indexOfScalar(u8, kv, '=') orelse {
// If there is no '=' return entire `kv` string as the key and
// a null value.
return .{
.key = kv,
.value = null,
};
};
kv[index] = 0;
const key = kv[0..index :0];
break :key key;
@@ -348,6 +370,18 @@ test "OSC 133: prompt_start with special_key empty" {
try testing.expect(cmd.prompt_start.special_key == false);
}
test "OSC 133: prompt_start with trailing ;" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "133;A;";
for (input) |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .prompt_start);
}
test "OSC 133: prompt_start with click_events true" {
const testing = std.testing;
@@ -387,6 +421,36 @@ test "OSC 133: prompt_start with click_events empty" {
try testing.expect(cmd.prompt_start.click_events == false);
}
test "OSC 133: prompt_start with click_events bare key" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "133;A;click_events";
for (input) |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .prompt_start);
try testing.expect(cmd.prompt_start.click_events == false);
}
test "OSC 133: prompt_start with invalid bare key" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "133;A;barekey";
for (input) |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .prompt_start);
try testing.expect(cmd.prompt_start.aid == null);
try testing.expectEqual(.primary, cmd.prompt_start.kind);
try testing.expect(cmd.prompt_start.redraw == true);
try testing.expect(cmd.prompt_start.special_key == false);
try testing.expect(cmd.prompt_start.click_events == false);
}
test "OSC 133: end_of_command no exit code" {
const testing = std.testing;
@@ -692,3 +756,16 @@ test "OSC 133: end_of_input with cmdline_url 9" {
try testing.expect(cmd == .end_of_input);
try testing.expect(cmd.end_of_input.cmdline == null);
}
test "OSC 133: end_of_input with bare key" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "133;C;cmdline_url";
for (input) |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .end_of_input);
try testing.expect(cmd.end_of_input.cmdline == null);
}

View File

@@ -633,6 +633,114 @@ pub const Page = struct {
HyperlinkError ||
GraphemeError;
/// Compute the exact capacity required to store a range of rows from
/// this page.
///
/// The returned capacity will have the same number of columns as this
/// page and the number of rows equal to the range given. The returned
/// capacity is by definition strictly less than or equal to this
/// page's capacity, so the layout is guaranteed to succeed.
///
/// Preconditions:
/// - Range must be at least 1 row
/// - Start and end must be valid for this page
pub fn exactRowCapacity(
self: *const Page,
y_start: usize,
y_end: usize,
) Capacity {
assert(y_start < y_end);
assert(y_end <= self.size.rows);
// Track unique IDs using a bitset. Both style IDs and hyperlink IDs
// are CellCountInt (u16), so we reuse this set for both to save
// stack memory (~8KB instead of ~16KB).
const CellCountSet = std.StaticBitSet(std.math.maxInt(size.CellCountInt) + 1);
comptime assert(size.StyleCountInt == size.CellCountInt);
comptime assert(size.HyperlinkCountInt == size.CellCountInt);
// Accumulators
var id_set: CellCountSet = .initEmpty();
var grapheme_bytes: usize = 0;
var string_bytes: usize = 0;
// First pass: count styles and grapheme bytes
const rows = self.rows.ptr(self.memory)[y_start..y_end];
for (rows) |*row| {
const cells = row.cells.ptr(self.memory)[0..self.size.cols];
for (cells) |*cell| {
if (cell.style_id != stylepkg.default_id) {
id_set.set(cell.style_id);
}
if (cell.hasGrapheme()) {
if (self.lookupGrapheme(cell)) |cps| {
grapheme_bytes += GraphemeAlloc.bytesRequired(u21, cps.len);
}
}
}
}
const styles_cap = StyleSet.capacityForCount(id_set.count());
// Second pass: count hyperlinks and string bytes
// We count both unique hyperlinks (for hyperlink_set) and total
// hyperlink cells (for hyperlink_map capacity).
id_set = .initEmpty();
var hyperlink_cells: usize = 0;
for (rows) |*row| {
const cells = row.cells.ptr(self.memory)[0..self.size.cols];
for (cells) |*cell| {
if (cell.hyperlink) {
hyperlink_cells += 1;
if (self.lookupHyperlink(cell)) |id| {
// Only count each unique hyperlink once for set sizing
if (!id_set.isSet(id)) {
id_set.set(id);
// Get the hyperlink entry to compute string bytes
const entry = self.hyperlink_set.get(self.memory, id);
string_bytes += StringAlloc.bytesRequired(u8, entry.uri.len);
switch (entry.id) {
.implicit => {},
.explicit => |slice| {
string_bytes += StringAlloc.bytesRequired(u8, slice.len);
},
}
}
}
}
}
}
// The hyperlink_map capacity in layout() is computed as:
// hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2)
// We need enough hyperlink_bytes so that when layout() computes
// the map capacity, it can accommodate all hyperlink cells. This
// is unit tested.
const hyperlink_cap = cap: {
const hyperlink_count = id_set.count();
const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count);
const hyperlink_map_min = std.math.divCeil(
usize,
hyperlink_cells,
hyperlink_cell_multiplier,
) catch 0;
break :cap @max(hyperlink_set_cap, hyperlink_map_min);
};
// All the intCasts below are safe because we should have a
// capacity strictly less than or equal to this page's capacity.
return .{
.cols = self.size.cols,
.rows = @intCast(y_end - y_start),
.styles = @intCast(styles_cap),
.grapheme_bytes = @intCast(grapheme_bytes),
.hyperlink_bytes = @intCast(hyperlink_cap * @sizeOf(hyperlink.Set.Item)),
.string_bytes = @intCast(string_bytes),
};
}
/// Clone the contents of another page into this page. The capacities
/// can be different, but the size of the other page must fit into
/// this page.
@@ -1569,7 +1677,13 @@ pub const Page = struct {
const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align.toByteUnits());
const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size;
const grapheme_count = @divFloor(cap.grapheme_bytes, grapheme_chunk);
const grapheme_count: usize = count: {
if (cap.grapheme_bytes == 0) break :count 0;
// Use divCeil to match GraphemeAlloc.layout() which uses alignForward,
// ensuring grapheme_map has capacity when grapheme_alloc has chunks.
const base = std.math.divCeil(usize, cap.grapheme_bytes, grapheme_chunk) catch unreachable;
break :count std.math.ceilPowerOfTwo(usize, base) catch unreachable;
};
const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count));
const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align.toByteUnits());
const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size;
@@ -1639,25 +1753,33 @@ pub const Size = struct {
};
/// Capacity of this page.
///
/// This capacity can be maxed out (every field max) and still fit
/// within a 64-bit memory space. If you need more than this, you will
/// need to split data across separate pages.
///
/// For 32-bit systems, it is possible to overflow the addressable
/// space and this is something we still need to address in the future
/// likely by limiting the maximum capacity on 32-bit systems further.
pub const Capacity = struct {
/// Number of columns and rows we can know about.
cols: size.CellCountInt,
rows: size.CellCountInt,
/// Number of unique styles that can be used on this page.
styles: usize = 16,
styles: size.StyleCountInt = 16,
/// Number of bytes to allocate for hyperlink data. Note that the
/// amount of data used for hyperlinks in total is more than this because
/// hyperlinks use string data as well as a small amount of lookup metadata.
/// This number is a rough approximation.
hyperlink_bytes: usize = hyperlink_bytes_default,
hyperlink_bytes: size.HyperlinkCountInt = hyperlink_bytes_default,
/// Number of bytes to allocate for grapheme data.
grapheme_bytes: usize = grapheme_bytes_default,
grapheme_bytes: size.GraphemeBytesInt = grapheme_bytes_default,
/// Number of bytes to allocate for strings.
string_bytes: usize = string_bytes_default,
string_bytes: size.StringBytesInt = string_bytes_default,
pub const Adjustment = struct {
cols: ?size.CellCountInt = null,
@@ -2025,6 +2147,21 @@ pub const Cell = packed struct(u64) {
// //const pages = total_size / std.heap.page_size_min;
// }
test "Page.layout can take a maxed capacity" {
// Our intention is for a maxed-out capacity to always fit
// within a page layout without triggering runtime safety on any
// overflow. This simplifies some of our handling downstream of the
// call (relevant to: https://github.com/ghostty-org/ghostty/issues/10258)
var cap: Capacity = undefined;
inline for (@typeInfo(Capacity).@"struct".fields) |field| {
@field(cap, field.name) = std.math.maxInt(field.type);
}
// Note that a max capacity will exceed our max_page_size so we
// can't init a page with it, but it should layout.
_ = Page.layout(cap);
}
test "Cell is zero by default" {
const cell = Cell.init(0);
const cell_int: u64 = @bitCast(cell);
@@ -3191,3 +3328,512 @@ test "Page verifyIntegrity zero cols" {
page.verifyIntegrity(testing.allocator),
);
}
test "Page exactRowCapacity empty rows" {
var page = try Page.init(.{
.cols = 10,
.rows = 10,
.styles = 8,
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
.string_bytes = 512,
});
defer page.deinit();
// Empty page: all capacity fields should be 0 (except cols/rows)
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(10, cap.cols);
try testing.expectEqual(5, cap.rows);
try testing.expectEqual(0, cap.styles);
try testing.expectEqual(0, cap.grapheme_bytes);
try testing.expectEqual(0, cap.hyperlink_bytes);
try testing.expectEqual(0, cap.string_bytes);
}
test "Page exactRowCapacity styles" {
var page = try Page.init(.{
.cols = 10,
.rows = 10,
.styles = 8,
});
defer page.deinit();
// No styles: capacity should be 0
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(0, cap.styles);
}
// Add one style to a cell
const style1_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } });
{
const rac = page.getRowAndCell(0, 0);
rac.row.styled = true;
rac.cell.style_id = style1_id;
}
// One unique style - capacity accounts for load factor
const cap_one_style = page.exactRowCapacity(0, 5);
{
try testing.expectEqual(StyleSet.capacityForCount(1), cap_one_style.styles);
}
// Add same style to another cell (duplicate) - capacity unchanged
{
const rac = page.getRowAndCell(1, 0);
rac.cell.style_id = style1_id;
}
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(cap_one_style.styles, cap.styles);
}
// Add a different style
const style2_id = try page.styles.add(page.memory, .{ .flags = .{ .italic = true } });
{
const rac = page.getRowAndCell(2, 0);
rac.cell.style_id = style2_id;
}
// Two unique styles - capacity accounts for load factor
const cap_two_styles = page.exactRowCapacity(0, 5);
{
try testing.expectEqual(StyleSet.capacityForCount(2), cap_two_styles.styles);
try testing.expect(cap_two_styles.styles > cap_one_style.styles);
}
// Style outside the row range should not be counted
{
const rac = page.getRowAndCell(0, 7);
rac.row.styled = true;
rac.cell.style_id = try page.styles.add(page.memory, .{ .flags = .{ .underline = .single } });
}
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(cap_two_styles.styles, cap.styles);
}
// Full range includes the new style
{
const cap = page.exactRowCapacity(0, 10);
try testing.expectEqual(StyleSet.capacityForCount(3), cap.styles);
}
// Verify clone works with exact capacity and produces same result
{
const cap = page.exactRowCapacity(0, 5);
var cloned = try Page.init(cap);
defer cloned.deinit();
for (0..5) |y| {
const src_row = &page.rows.ptr(page.memory)[y];
const dst_row = &cloned.rows.ptr(cloned.memory)[y];
try cloned.cloneRowFrom(&page, dst_row, src_row);
}
const cloned_cap = cloned.exactRowCapacity(0, 5);
try testing.expectEqual(cap, cloned_cap);
}
}
test "Page exactRowCapacity single style clone" {
// Regression test: verify a single style can be cloned with exact capacity.
// This tests that capacityForCount properly accounts for ID 0 being reserved.
var page = try Page.init(.{
.cols = 10,
.rows = 2,
.styles = 8,
});
defer page.deinit();
// Add exactly one style to row 0
const style_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } });
{
const rac = page.getRowAndCell(0, 0);
rac.row.styled = true;
rac.cell.style_id = style_id;
}
// exactRowCapacity for just row 0 should give capacity for 1 style
const cap = page.exactRowCapacity(0, 1);
try testing.expectEqual(StyleSet.capacityForCount(1), cap.styles);
// Create a new page with exact capacity and clone
var cloned = try Page.init(cap);
defer cloned.deinit();
const src_row = &page.rows.ptr(page.memory)[0];
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
// This must not fail with StyleSetOutOfMemory
try cloned.cloneRowFrom(&page, dst_row, src_row);
// Verify the style was cloned correctly
const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0];
try testing.expect(cloned_cell.style_id != stylepkg.default_id);
}
test "Page exactRowCapacity styles max single row" {
var page = try Page.init(.{
.cols = std.math.maxInt(size.CellCountInt),
.rows = 1,
.styles = std.math.maxInt(size.StyleCountInt),
});
defer page.deinit();
// Style our first row
const row = &page.rows.ptr(page.memory)[0];
row.styled = true;
// Fill cells with styles until we get OOM, but limit to a reasonable count
// to avoid overflow when computing capacityForCount near maxInt
const cells = row.cells.ptr(page.memory)[0..page.size.cols];
var count: usize = 0;
const max_count: usize = 1000; // Limit to avoid overflow in capacity calculation
for (cells, 0..) |*cell, i| {
if (count >= max_count) break;
const style_id = page.styles.add(page.memory, .{
.fg_color = .{ .rgb = .{
.r = @intCast(i & 0xFF),
.g = @intCast((i >> 8) & 0xFF),
.b = 0,
} },
}) catch break;
cell.style_id = style_id;
count += 1;
}
// Verify we added a meaningful number of styles
try testing.expect(count > 0);
// Capacity should be at least count (adjusted for load factor)
const cap = page.exactRowCapacity(0, 1);
try testing.expectEqual(StyleSet.capacityForCount(count), cap.styles);
}
test "Page exactRowCapacity grapheme_bytes" {
var page = try Page.init(.{
.cols = 10,
.rows = 10,
.styles = 8,
});
defer page.deinit();
// No graphemes: capacity should be 0
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(0, cap.grapheme_bytes);
}
// Add one grapheme (1 codepoint) to a cell - rounds up to grapheme_chunk
{
const rac = page.getRowAndCell(0, 0);
rac.cell.* = .init('a');
try page.appendGrapheme(rac.row, rac.cell, 0x0301); // combining acute accent
}
{
const cap = page.exactRowCapacity(0, 5);
// 1 codepoint = 4 bytes, rounds up to grapheme_chunk (16)
try testing.expectEqual(grapheme_chunk, cap.grapheme_bytes);
}
// Add another grapheme to a different cell - should sum
{
const rac = page.getRowAndCell(1, 0);
rac.cell.* = .init('e');
try page.appendGrapheme(rac.row, rac.cell, 0x0300); // combining grave accent
}
{
const cap = page.exactRowCapacity(0, 5);
// 2 graphemes, each 1 codepoint = 2 * grapheme_chunk
try testing.expectEqual(grapheme_chunk * 2, cap.grapheme_bytes);
}
// Add a larger grapheme (multiple codepoints) that fits in one chunk
{
const rac = page.getRowAndCell(2, 0);
rac.cell.* = .init('o');
try page.appendGrapheme(rac.row, rac.cell, 0x0301);
try page.appendGrapheme(rac.row, rac.cell, 0x0302);
try page.appendGrapheme(rac.row, rac.cell, 0x0303);
}
{
const cap = page.exactRowCapacity(0, 5);
// First two cells: 2 * grapheme_chunk
// Third cell: 3 codepoints = 12 bytes, rounds up to grapheme_chunk
try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes);
}
// Grapheme outside the row range should not be counted
{
const rac = page.getRowAndCell(0, 7);
rac.cell.* = .init('x');
try page.appendGrapheme(rac.row, rac.cell, 0x0304);
}
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes);
}
// Full range includes the new grapheme
{
const cap = page.exactRowCapacity(0, 10);
try testing.expectEqual(grapheme_chunk * 4, cap.grapheme_bytes);
}
// Verify clone works with exact capacity and produces same result
{
const cap = page.exactRowCapacity(0, 5);
var cloned = try Page.init(cap);
defer cloned.deinit();
for (0..5) |y| {
const src_row = &page.rows.ptr(page.memory)[y];
const dst_row = &cloned.rows.ptr(cloned.memory)[y];
try cloned.cloneRowFrom(&page, dst_row, src_row);
}
const cloned_cap = cloned.exactRowCapacity(0, 5);
try testing.expectEqual(cap, cloned_cap);
}
}
test "Page exactRowCapacity grapheme_bytes larger than chunk" {
var page = try Page.init(.{
.cols = 10,
.rows = 10,
.styles = 8,
});
defer page.deinit();
// Add a grapheme larger than one chunk (grapheme_chunk_len = 4 codepoints)
const rac = page.getRowAndCell(0, 0);
rac.cell.* = .init('a');
// Add 6 codepoints - requires 2 chunks (6 * 4 = 24 bytes, rounds up to 32)
for (0..6) |i| {
try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0300 + i));
}
const cap = page.exactRowCapacity(0, 1);
// 6 codepoints = 24 bytes, alignForward(24, 16) = 32
try testing.expectEqual(32, cap.grapheme_bytes);
// Verify clone works with exact capacity and produces same result
var cloned = try Page.init(cap);
defer cloned.deinit();
const src_row = &page.rows.ptr(page.memory)[0];
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
try cloned.cloneRowFrom(&page, dst_row, src_row);
const cloned_cap = cloned.exactRowCapacity(0, 1);
try testing.expectEqual(cap, cloned_cap);
}
test "Page exactRowCapacity hyperlinks" {
var page = try Page.init(.{
.cols = 10,
.rows = 10,
.styles = 8,
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
.string_bytes = 512,
});
defer page.deinit();
// No hyperlinks: capacity should be 0
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(0, cap.hyperlink_bytes);
try testing.expectEqual(0, cap.string_bytes);
}
// Add one hyperlink with implicit ID
const uri1 = "https://example.com";
const id1 = blk: {
const rac = page.getRowAndCell(0, 0);
// Create and add hyperlink entry
const id = try page.insertHyperlink(.{
.id = .{ .implicit = 1 },
.uri = uri1,
});
try page.setHyperlink(rac.row, rac.cell, id);
break :blk id;
};
// 1 hyperlink - capacity accounts for load factor
const cap_one_link = page.exactRowCapacity(0, 5);
{
try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap_one_link.hyperlink_bytes);
// URI "https://example.com" = 19 bytes, rounds up to string_chunk (32)
try testing.expectEqual(string_chunk, cap_one_link.string_bytes);
}
// Add same hyperlink to another cell (duplicate ID) - capacity unchanged
{
const rac = page.getRowAndCell(1, 0);
// Use the same hyperlink ID for another cell
page.hyperlink_set.use(page.memory, id1);
try page.setHyperlink(rac.row, rac.cell, id1);
}
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(cap_one_link.hyperlink_bytes, cap.hyperlink_bytes);
try testing.expectEqual(cap_one_link.string_bytes, cap.string_bytes);
}
// Add a different hyperlink with explicit ID
const uri2 = "https://other.example.org/path";
const explicit_id = "my-link-id";
{
const rac = page.getRowAndCell(2, 0);
const id = try page.insertHyperlink(.{
.id = .{ .explicit = explicit_id },
.uri = uri2,
});
try page.setHyperlink(rac.row, rac.cell, id);
}
// 2 hyperlinks - capacity accounts for load factor
const cap_two_links = page.exactRowCapacity(0, 5);
{
try testing.expectEqual(hyperlink.Set.capacityForCount(2) * @sizeOf(hyperlink.Set.Item), cap_two_links.hyperlink_bytes);
// First URI: 19 bytes -> 32, Second URI: 30 bytes -> 32, Explicit ID: 10 bytes -> 32
try testing.expectEqual(string_chunk * 3, cap_two_links.string_bytes);
}
// Hyperlink outside the row range should not be counted
{
const rac = page.getRowAndCell(0, 7); // row 7 is outside range [0, 5)
const id = try page.insertHyperlink(.{
.id = .{ .implicit = 99 },
.uri = "https://outside.example.com",
});
try page.setHyperlink(rac.row, rac.cell, id);
}
{
const cap = page.exactRowCapacity(0, 5);
try testing.expectEqual(cap_two_links.hyperlink_bytes, cap.hyperlink_bytes);
try testing.expectEqual(cap_two_links.string_bytes, cap.string_bytes);
}
// Full range includes the new hyperlink
{
const cap = page.exactRowCapacity(0, 10);
try testing.expectEqual(hyperlink.Set.capacityForCount(3) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes);
// Third URI: 27 bytes -> 32
try testing.expectEqual(string_chunk * 4, cap.string_bytes);
}
// Verify clone works with exact capacity and produces same result
{
const cap = page.exactRowCapacity(0, 5);
var cloned = try Page.init(cap);
defer cloned.deinit();
for (0..5) |y| {
const src_row = &page.rows.ptr(page.memory)[y];
const dst_row = &cloned.rows.ptr(cloned.memory)[y];
try cloned.cloneRowFrom(&page, dst_row, src_row);
}
const cloned_cap = cloned.exactRowCapacity(0, 5);
try testing.expectEqual(cap, cloned_cap);
}
}
test "Page exactRowCapacity single hyperlink clone" {
// Regression test: verify a single hyperlink can be cloned with exact capacity.
// This tests that capacityForCount properly accounts for ID 0 being reserved.
var page = try Page.init(.{
.cols = 10,
.rows = 2,
.styles = 8,
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
.string_bytes = 512,
});
defer page.deinit();
// Add exactly one hyperlink to row 0
const uri = "https://example.com";
const id = blk: {
const rac = page.getRowAndCell(0, 0);
const link_id = try page.insertHyperlink(.{
.id = .{ .implicit = 1 },
.uri = uri,
});
try page.setHyperlink(rac.row, rac.cell, link_id);
break :blk link_id;
};
_ = id;
// exactRowCapacity for just row 0 should give capacity for 1 hyperlink
const cap = page.exactRowCapacity(0, 1);
try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes);
// Create a new page with exact capacity and clone
var cloned = try Page.init(cap);
defer cloned.deinit();
const src_row = &page.rows.ptr(page.memory)[0];
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
// This must not fail with HyperlinkSetOutOfMemory
try cloned.cloneRowFrom(&page, dst_row, src_row);
// Verify the hyperlink was cloned correctly
const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0];
try testing.expect(cloned_cell.hyperlink);
}
test "Page exactRowCapacity hyperlink map capacity for many cells" {
// A single hyperlink spanning many cells requires hyperlink_map capacity
// based on cell count, not unique hyperlink count.
const cols = 50;
var page = try Page.init(.{
.cols = cols,
.rows = 2,
.styles = 8,
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
.string_bytes = 512,
});
defer page.deinit();
// Add one hyperlink spanning all 50 columns in row 0
const uri = "https://example.com";
const id = blk: {
const rac = page.getRowAndCell(0, 0);
const link_id = try page.insertHyperlink(.{
.id = .{ .implicit = 1 },
.uri = uri,
});
try page.setHyperlink(rac.row, rac.cell, link_id);
break :blk link_id;
};
// Apply same hyperlink to remaining cells in row 0
for (1..cols) |x| {
const rac = page.getRowAndCell(@intCast(x), 0);
page.hyperlink_set.use(page.memory, id);
try page.setHyperlink(rac.row, rac.cell, id);
}
// exactRowCapacity must account for 50 hyperlink cells, not just 1 unique hyperlink
const cap = page.exactRowCapacity(0, 1);
// The hyperlink_bytes must be large enough that layout() computes sufficient
// hyperlink_map capacity. With hyperlink_cell_multiplier=16, we need at least
// ceil(50/16) = 4 hyperlink entries worth of bytes for the map.
const min_for_map = std.math.divCeil(usize, cols, hyperlink_cell_multiplier) catch 0;
const min_hyperlink_bytes = min_for_map * @sizeOf(hyperlink.Set.Item);
try testing.expect(cap.hyperlink_bytes >= min_hyperlink_bytes);
// Create a new page with exact capacity and clone - must not fail
var cloned = try Page.init(cap);
defer cloned.deinit();
const src_row = &page.rows.ptr(page.memory)[0];
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
// This must not fail with HyperlinkMapOutOfMemory
try cloned.cloneRowFrom(&page, dst_row, src_row);
// Verify all hyperlinks were cloned correctly
for (0..cols) |x| {
const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[x];
try testing.expect(cloned_cell.hyperlink);
}
}

View File

@@ -64,6 +64,20 @@ pub fn RefCountedSet(
@alignOf(Id),
));
/// This is the max load until the set returns OutOfMemory and
/// requires more capacity.
///
/// Experimentally, this load factor works quite well.
pub const load_factor = 0.8125;
/// Returns the minimum capacity needed to store `n` items,
/// accounting for the load factor and the reserved ID 0.
pub fn capacityForCount(n: usize) usize {
if (n == 0) return 0;
// +1 because ID 0 is reserved, so we need at least n+1 slots.
return @intFromFloat(@ceil(@as(f64, @floatFromInt(n + 1)) / load_factor));
}
/// Set item
pub const Item = struct {
/// The value this item represents.
@@ -154,9 +168,6 @@ pub fn RefCountedSet(
/// The returned layout `cap` property will be 1 more than the number
/// of items that the set can actually store, since ID 0 is reserved.
pub fn init(cap: usize) Layout {
// Experimentally, this load factor works quite well.
const load_factor = 0.8125;
assert(cap <= @as(usize, @intCast(std.math.maxInt(Id))) + 1);
// Zero-cap set is valid, return special case

View File

@@ -11,9 +11,32 @@ pub const max_page_size = std.math.maxInt(u32);
/// derived from the maximum terminal page size.
pub const OffsetInt = std.math.IntFittingRange(0, max_page_size - 1);
/// The int type that can contain the maximum number of cells in a page.
pub const CellCountInt = u16; // TODO: derive
/// Int types for maximum values of things. A lot of these sizes are
/// based on "X is enough for any reasonable use case" principles.
// The goal is that a user can have the maxInt amount of all of these
// present at one time and be able to address them in a single Page.zig.
// Total number of cells that are possible in each dimension (row/col).
// Based on 2^16 being enough for any reasonable terminal size and allowing
// IDs to remain 16-bit.
pub const CellCountInt = u16;
// Total number of styles and hyperlinks that are possible in a page.
// We match CellCountInt here because each cell in a single row can have at
// most one style, making it simple to split a page by splitting rows.
//
// Note due to the way RefCountedSet works, we are short one value, but
// this is a theoretical limit we accept. A page with a single row max
// columns wide would be one short of having every cell have a unique style.
pub const StyleCountInt = CellCountInt;
pub const HyperlinkCountInt = CellCountInt;
// Total number of bytes that can be taken up by grapheme data and string
// data. Both of these technically unlimited with malicious input, but
// we choose a reasonable limit of 2^32 (4GB) per.
pub const GraphemeBytesInt = u32;
pub const StringBytesInt = u32;
/// The offset from the base address of the page to the start of some data.
/// This is typed for ease of use.
///

View File

@@ -125,7 +125,7 @@ pub const Handler = struct {
}
},
.save_cursor => self.terminal.saveCursor(),
.restore_cursor => try self.terminal.restoreCursor(),
.restore_cursor => self.terminal.restoreCursor(),
.invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking),
.configure_charset => self.terminal.configureCharset(value.slot, value.charset),
.set_attribute => switch (value) {
@@ -240,7 +240,7 @@ pub const Handler = struct {
.save_cursor => if (enabled) {
self.terminal.saveCursor();
} else {
try self.terminal.restoreCursor();
self.terminal.restoreCursor();
},
.enable_mode_3 => {},

View File

@@ -11,7 +11,7 @@ const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet;
/// The unique identifier for a style. This is at most the number of cells
/// that can fit into a terminal page.
pub const Id = size.CellCountInt;
pub const Id = size.StyleCountInt;
/// The Id to use for default styling.
pub const default_id: Id = 0;

View File

@@ -398,11 +398,16 @@ pub const StreamHandler = struct {
break :tmux;
},
.exit => if (self.tmux_viewer) |viewer| {
// Free our viewer state
viewer.deinit();
self.alloc.destroy(viewer);
self.tmux_viewer = null;
.exit => {
// Free our viewer state if we have one
if (self.tmux_viewer) |viewer| {
viewer.deinit();
self.alloc.destroy(viewer);
self.tmux_viewer = null;
}
// And always break since we assert below
// that we're not handling an exit command.
break :tmux;
},
@@ -716,7 +721,7 @@ pub const StreamHandler = struct {
if (enabled) {
self.terminal.saveCursor();
} else {
try self.terminal.restoreCursor();
self.terminal.restoreCursor();
}
},
@@ -928,7 +933,7 @@ pub const StreamHandler = struct {
}
pub inline fn restoreCursor(self: *StreamHandler) !void {
try self.terminal.restoreCursor();
self.terminal.restoreCursor();
}
pub fn enquiry(self: *StreamHandler) !void {