mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-14 07:33:58 +00:00
Merge remote-tracking branch 'upstream/main' into harfbuzz-positions
This commit is contained in:
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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
8
.shellcheckrc
Normal 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
|
||||
22
HACKING.md
22
HACKING.md
@@ -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
|
||||
|
||||
@@ -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
6
build.zig.zon.json
generated
@@ -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
6
build.zig.zon.nix
generated
@@ -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
2
build.zig.zon.txt
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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 => {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user