aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/cli
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/libghostty-vt/src/cli')
-rw-r--r--vendor/libghostty-vt/src/cli/CommaSplitter.zig483
-rw-r--r--vendor/libghostty-vt/src/cli/Pager.zig93
-rw-r--r--vendor/libghostty-vt/src/cli/README.md13
-rw-r--r--vendor/libghostty-vt/src/cli/action.zig277
-rw-r--r--vendor/libghostty-vt/src/cli/args.zig1627
-rw-r--r--vendor/libghostty-vt/src/cli/boo.zig236
-rw-r--r--vendor/libghostty-vt/src/cli/crash_report.zig93
-rw-r--r--vendor/libghostty-vt/src/cli/diagnostics.zig197
-rw-r--r--vendor/libghostty-vt/src/cli/edit_config.zig180
-rw-r--r--vendor/libghostty-vt/src/cli/explain_config.zig151
-rw-r--r--vendor/libghostty-vt/src/cli/ghostty.zig308
-rw-r--r--vendor/libghostty-vt/src/cli/help.zig78
-rw-r--r--vendor/libghostty-vt/src/cli/list_actions.zig52
-rw-r--r--vendor/libghostty-vt/src/cli/list_colors.zig209
-rw-r--r--vendor/libghostty-vt/src/cli/list_fonts.zig165
-rw-r--r--vendor/libghostty-vt/src/cli/list_keybinds.zig435
-rw-r--r--vendor/libghostty-vt/src/cli/list_themes.zig1741
-rw-r--r--vendor/libghostty-vt/src/cli/lorem_ipsum.txt45
-rw-r--r--vendor/libghostty-vt/src/cli/new_window.zig231
-rw-r--r--vendor/libghostty-vt/src/cli/show_config.zig92
-rw-r--r--vendor/libghostty-vt/src/cli/show_face.zig239
-rw-r--r--vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig579
-rw-r--r--vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig156
-rw-r--r--vendor/libghostty-vt/src/cli/ssh_cache.zig228
-rw-r--r--vendor/libghostty-vt/src/cli/tui.zig6
-rw-r--r--vendor/libghostty-vt/src/cli/validate_config.zig78
-rw-r--r--vendor/libghostty-vt/src/cli/version.zig76
27 files changed, 8068 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/cli/CommaSplitter.zig b/vendor/libghostty-vt/src/cli/CommaSplitter.zig
new file mode 100644
index 00000000..d5093992
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/CommaSplitter.zig
@@ -0,0 +1,483 @@
+//! Iterator to split a string into fields by commas, taking into account
+//! quotes and escapes.
+//!
+//! Supports the same escapes as in Zig literal strings.
+//!
+//! Quotes must begin and end with a double quote (`"`). It is an error to not
+//! end a quote that was begun. To include a double quote inside a quote (or to
+//! not have a double quote start a quoted section) escape it with a backslash.
+//!
+//! Single quotes (`'`) are not special, they do not begin a quoted block.
+//!
+//! Zig multiline string literals are NOT supported.
+//!
+//! Quotes and escapes are not stripped or decoded, that must be handled as a
+//! separate step!
+//!
+//! On Windows, backslash is only treated as an escape character inside quoted
+//! strings. Outside quotes, backslash is a literal character (path separator).
+const CommaSplitter = @This();
+
+const builtin = @import("builtin");
+
+/// Whether backslash acts as an escape character outside quoted strings.
+/// On Windows, backslash is the path separator so it is always literal
+/// outside quotes.
+const escape_outside_quotes = builtin.os.tag != .windows;
+
+pub const Error = error{
+ UnclosedQuote,
+ UnfinishedEscape,
+ IllegalEscape,
+};
+
+/// the string that we are splitting
+str: []const u8,
+/// how much of the string has been consumed so far
+index: usize,
+
+/// initialize a splitter with the given string
+pub fn init(str: []const u8) CommaSplitter {
+ return .{
+ .str = str,
+ .index = 0,
+ };
+}
+
+/// return the next field, null if no more fields
+pub fn next(self: *CommaSplitter) Error!?[]const u8 {
+ if (self.index >= self.str.len) return null;
+
+ // where the current field starts
+ const start = self.index;
+ // state of state machine
+ const State = enum {
+ normal,
+ quoted,
+ escape,
+ hexescape,
+ unicodeescape,
+ };
+ // keep track of the state to return to when done processing an escape
+ // sequence.
+ var last: State = .normal;
+ // used to count number of digits seen in a hex escape
+ var hexescape_digits: usize = 0;
+ // sub-state of parsing hex escapes
+ var unicodeescape_state: enum {
+ start,
+ digits,
+ } = .start;
+ // number of digits in a unicode escape seen so far
+ var unicodeescape_digits: usize = 0;
+ // accumulator for value of unicode escape
+ var unicodeescape_value: usize = 0;
+
+ loop: switch (State.normal) {
+ .normal => {
+ if (self.index >= self.str.len) return self.str[start..];
+ switch (self.str[self.index]) {
+ ',' => {
+ self.index += 1;
+ return self.str[start .. self.index - 1];
+ },
+ '"' => {
+ self.index += 1;
+ continue :loop .quoted;
+ },
+ '\\' => {
+ self.index += 1;
+ if (comptime escape_outside_quotes) {
+ last = .normal;
+ continue :loop .escape;
+ }
+ continue :loop .normal;
+ },
+ else => {
+ self.index += 1;
+ continue :loop .normal;
+ },
+ }
+ },
+ .quoted => {
+ if (self.index >= self.str.len) return error.UnclosedQuote;
+ switch (self.str[self.index]) {
+ '"' => {
+ self.index += 1;
+ continue :loop .normal;
+ },
+ '\\' => {
+ self.index += 1;
+ last = .quoted;
+ continue :loop .escape;
+ },
+ else => {
+ self.index += 1;
+ continue :loop .quoted;
+ },
+ }
+ },
+ .escape => {
+ if (self.index >= self.str.len) return error.UnfinishedEscape;
+ switch (self.str[self.index]) {
+ 'n', 'r', 't', '\\', '\'', '"' => {
+ self.index += 1;
+ continue :loop last;
+ },
+ 'x' => {
+ self.index += 1;
+ hexescape_digits = 0;
+ continue :loop .hexescape;
+ },
+ 'u' => {
+ self.index += 1;
+ unicodeescape_state = .start;
+ unicodeescape_digits = 0;
+ unicodeescape_value = 0;
+ continue :loop .unicodeescape;
+ },
+ else => return error.IllegalEscape,
+ }
+ },
+ .hexescape => {
+ if (self.index >= self.str.len) return error.UnfinishedEscape;
+ switch (self.str[self.index]) {
+ '0'...'9', 'a'...'f', 'A'...'F' => {
+ self.index += 1;
+ hexescape_digits += 1;
+ if (hexescape_digits == 2) continue :loop last;
+ continue :loop .hexescape;
+ },
+ else => return error.IllegalEscape,
+ }
+ },
+ .unicodeescape => {
+ if (self.index >= self.str.len) return error.UnfinishedEscape;
+ switch (unicodeescape_state) {
+ .start => {
+ switch (self.str[self.index]) {
+ '{' => {
+ self.index += 1;
+ unicodeescape_value = 0;
+ unicodeescape_state = .digits;
+ continue :loop .unicodeescape;
+ },
+ else => return error.IllegalEscape,
+ }
+ },
+ .digits => {
+ switch (self.str[self.index]) {
+ '}' => {
+ self.index += 1;
+ if (unicodeescape_digits == 0) return error.IllegalEscape;
+ continue :loop last;
+ },
+ '0'...'9' => |d| {
+ self.index += 1;
+ unicodeescape_digits += 1;
+ unicodeescape_value <<= 4;
+ unicodeescape_value += d - '0';
+ },
+ 'a'...'f' => |d| {
+ self.index += 1;
+ unicodeescape_digits += 1;
+ unicodeescape_value <<= 4;
+ unicodeescape_value += d - 'a';
+ },
+ 'A'...'F' => |d| {
+ self.index += 1;
+ unicodeescape_digits += 1;
+ unicodeescape_value <<= 4;
+ unicodeescape_value += d - 'A';
+ },
+ else => return error.IllegalEscape,
+ }
+ if (unicodeescape_value > 0x10ffff) return error.IllegalEscape;
+ continue :loop .unicodeescape;
+ },
+ }
+ },
+ }
+}
+
+/// Return any remaining string data, whether it has a comma or not.
+pub fn rest(self: *CommaSplitter) ?[]const u8 {
+ if (self.index >= self.str.len) return null;
+ defer self.index = self.str.len;
+ return self.str[self.index..];
+}
+
+test "splitter 1" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("a,b,c");
+ try testing.expectEqualStrings("a", (try s.next()).?);
+ try testing.expectEqualStrings("b", (try s.next()).?);
+ try testing.expectEqualStrings("c", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 2" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("");
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 3" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("a");
+ try testing.expectEqualStrings("a", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 4" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\x5a");
+ try testing.expectEqualStrings("\\x5a", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 5" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("'a',b");
+ try testing.expectEqualStrings("'a'", (try s.next()).?);
+ try testing.expectEqualStrings("b", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 6" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("'a,b',c");
+ try testing.expectEqualStrings("'a", (try s.next()).?);
+ try testing.expectEqualStrings("b'", (try s.next()).?);
+ try testing.expectEqualStrings("c", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 7" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\"a,b\",c");
+ try testing.expectEqualStrings("\"a,b\"", (try s.next()).?);
+ try testing.expectEqualStrings("c", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 8" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init(" a , b ");
+ try testing.expectEqualStrings(" a ", (try s.next()).?);
+ try testing.expectEqualStrings(" b ", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 9" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\x");
+ try testing.expectError(error.UnfinishedEscape, s.next());
+}
+
+test "splitter 10" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\x5");
+ try testing.expectError(error.UnfinishedEscape, s.next());
+}
+
+test "splitter 11" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\u");
+ try testing.expectError(error.UnfinishedEscape, s.next());
+}
+
+test "splitter 12" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\u{");
+ try testing.expectError(error.UnfinishedEscape, s.next());
+}
+
+test "splitter 13" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\u{}");
+ try testing.expectError(error.IllegalEscape, s.next());
+}
+
+test "splitter 14" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\u{h1}");
+ try testing.expectError(error.IllegalEscape, s.next());
+}
+
+test "splitter 15" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\u{10ffff}");
+ try testing.expectEqualStrings("\\u{10ffff}", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 16" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\u{110000}");
+ try testing.expectError(error.IllegalEscape, s.next());
+}
+
+test "splitter 17" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\d");
+ try testing.expectError(error.IllegalEscape, s.next());
+}
+
+test "splitter 18" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\\n\\r\\t\\\"\\'\\\\");
+ try testing.expectEqualStrings("\\n\\r\\t\\\"\\'\\\\", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 19" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\"abc'def'ghi\"");
+ try testing.expectEqualStrings("\"abc'def'ghi\"", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 20" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("\",\",abc");
+ try testing.expectEqualStrings("\",\"", (try s.next()).?);
+ try testing.expectEqualStrings("abc", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 21" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("'a','b', 'c'");
+ try testing.expectEqualStrings("'a'", (try s.next()).?);
+ try testing.expectEqualStrings("'b'", (try s.next()).?);
+ try testing.expectEqualStrings(" 'c'", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 22" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("abc\"def");
+ try testing.expectError(error.UnclosedQuote, s.next());
+}
+
+test "splitter 23" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("title:\"Focus Split: Up\",description:\"Focus the split above, if it exists.\",action:goto_split:up");
+ try testing.expectEqualStrings("title:\"Focus Split: Up\"", (try s.next()).?);
+ try testing.expectEqualStrings("description:\"Focus the split above, if it exists.\"", (try s.next()).?);
+ try testing.expectEqualStrings("action:goto_split:up", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 24" {
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("a,b,c,def");
+ try testing.expectEqualStrings("a", (try s.next()).?);
+ try testing.expectEqualStrings("b", (try s.next()).?);
+ try testing.expectEqualStrings("c,def", s.rest().?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter 25" {
+ if (comptime !escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("a,\\u{10,df}");
+ try testing.expectEqualStrings("a", (try s.next()).?);
+ try testing.expectError(error.IllegalEscape, s.next());
+}
+
+// Windows-specific tests: backslash is literal outside quotes.
+
+test "splitter: windows paths" {
+ if (comptime escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ var s: CommaSplitter = .init("light:C:\\Users\\foo\\theme,dark:C:\\Users\\bar\\theme");
+ try testing.expectEqualStrings("light:C:\\Users\\foo\\theme", (try s.next()).?);
+ try testing.expectEqualStrings("dark:C:\\Users\\bar\\theme", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter: backslash literal outside quotes on windows" {
+ if (comptime escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ // Backslash followed by characters that would be escapes on Unix
+ // are treated as literal on Windows outside quotes.
+ var s: CommaSplitter = .init("\\n\\r\\t");
+ try testing.expectEqualStrings("\\n\\r\\t", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
+
+test "splitter: backslash still escapes inside quotes on windows" {
+ if (comptime escape_outside_quotes) return error.SkipZigTest;
+ const std = @import("std");
+ const testing = std.testing;
+
+ // Inside quotes, backslash escapes work on all platforms.
+ var s: CommaSplitter = .init("\"hello\\nworld\"");
+ try testing.expectEqualStrings("\"hello\\nworld\"", (try s.next()).?);
+ try testing.expect(null == try s.next());
+}
diff --git a/vendor/libghostty-vt/src/cli/Pager.zig b/vendor/libghostty-vt/src/cli/Pager.zig
new file mode 100644
index 00000000..247c998e
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/Pager.zig
@@ -0,0 +1,93 @@
+//! A pager wraps output to an external pager program (like `less`) when
+//! stdout is a TTY. The pager command is resolved as:
+//!
+//! `$GHOSTTY_PAGER` > `$PAGER` > `less`
+//!
+//! Setting either env var to an empty string disables paging.
+//! If stdout is not a TTY, writes go directly to stdout.
+const Pager = @This();
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const internal_os = @import("../os/main.zig");
+
+/// The pager child process, if one was spawned.
+child: ?std.process.Child = null,
+
+/// The buffered file writer used for both the pager pipe and direct
+/// stdout paths.
+file_writer: std.fs.File.Writer = undefined,
+
+/// Initialize the pager. If stdout is a TTY, this spawns the pager
+/// process. Otherwise, output goes directly to stdout.
+pub fn init(alloc: Allocator) Pager {
+ return .{ .child = initPager(alloc) };
+}
+
+/// Writes to the pager process if available; otherwise, stdout.
+pub fn writer(self: *Pager, buffer: []u8) *std.Io.Writer {
+ if (self.child) |child| {
+ self.file_writer = child.stdin.?.writer(buffer);
+ } else {
+ self.file_writer = std.fs.File.stdout().writer(buffer);
+ }
+ return &self.file_writer.interface;
+}
+
+/// Deinitialize the pager. Waits for the spawned process to exit.
+pub fn deinit(self: *Pager) void {
+ if (self.child) |*child| {
+ // Flush any remaining buffered data, close the pipe so the
+ // pager sees EOF, then wait for it to exit.
+ self.file_writer.interface.flush() catch {};
+ if (child.stdin) |stdin| {
+ stdin.close();
+ child.stdin = null;
+ }
+ _ = child.wait() catch {};
+ }
+
+ self.* = undefined;
+}
+
+fn initPager(alloc: Allocator) ?std.process.Child {
+ const stdout_file: std.fs.File = .stdout();
+ if (!stdout_file.isTty()) return null;
+
+ // Resolve the pager command: $GHOSTTY_PAGER > $PAGER > `less`.
+ // An empty value for either env var disables paging.
+ const ghostty_var = internal_os.getenv(alloc, "GHOSTTY_PAGER") catch null;
+ defer if (ghostty_var) |v| v.deinit(alloc);
+ const pager_var = internal_os.getenv(alloc, "PAGER") catch null;
+ defer if (pager_var) |v| v.deinit(alloc);
+
+ const cmd: ?[]const u8 = cmd: {
+ if (ghostty_var) |v| break :cmd if (v.value.len > 0) v.value else null;
+ if (pager_var) |v| break :cmd if (v.value.len > 0) v.value else null;
+ break :cmd "less";
+ };
+
+ if (cmd == null) return null;
+
+ var child: std.process.Child = .init(&.{cmd.?}, alloc);
+ child.stdin_behavior = .Pipe;
+ child.stdout_behavior = .Inherit;
+ child.stderr_behavior = .Inherit;
+
+ child.spawn() catch return null;
+ return child;
+}
+
+test "pager: non-tty" {
+ var pager: Pager = .init(std.testing.allocator);
+ defer pager.deinit();
+ try std.testing.expect(pager.child == null);
+}
+
+test "pager: default writer" {
+ var pager: Pager = .{};
+ defer pager.deinit();
+ try std.testing.expect(pager.child == null);
+ var buf: [4096]u8 = undefined;
+ const w = pager.writer(&buf);
+ try w.writeAll("hello");
+}
diff --git a/vendor/libghostty-vt/src/cli/README.md b/vendor/libghostty-vt/src/cli/README.md
new file mode 100644
index 00000000..7a1d9940
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/README.md
@@ -0,0 +1,13 @@
+# Subcommand Actions
+
+This is the cli specific code. It contains cli actions and tui definitions and
+argument parsing.
+
+This README is meant as developer documentation and not as user documentation.
+For user documentation, see the main README or [ghostty.org](https://ghostty.org/docs).
+
+## Updating documentation
+
+Each cli action is defined in it's own file. Documentation for each action is defined
+in the doc comment associated with the `run` function. For example the `run` function
+in `list_keybinds.zig` contains the help text for `ghostty +list-keybinds`.
diff --git a/vendor/libghostty-vt/src/cli/action.zig b/vendor/libghostty-vt/src/cli/action.zig
new file mode 100644
index 00000000..41173a9f
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/action.zig
@@ -0,0 +1,277 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+pub const DetectError = error{
+ /// Multiple actions were detected. You can specify at most one
+ /// action on the CLI otherwise the behavior desired is ambiguous.
+ MultipleActions,
+
+ /// An unknown action was specified.
+ InvalidAction,
+};
+
+/// Detect the action from CLI args.
+pub fn detectArgs(comptime E: type, alloc: Allocator) !?E {
+ var iter = try std.process.argsWithAllocator(alloc);
+ defer iter.deinit();
+ return try detectIter(E, &iter);
+}
+
+/// Detect the action from any iterator. Each iterator value should yield
+/// a CLI argument such as "--foo".
+///
+/// The comptime type E must be an enum with the available actions.
+/// If the type E has a decl `detectSpecialCase`, then it will be called
+/// for each argument to allow handling of special cases. The function
+/// signature for `detectSpecialCase` should be:
+///
+/// fn detectSpecialCase(arg: []const u8) ?SpecialCase(E)
+///
+pub fn detectIter(
+ comptime E: type,
+ iter: anytype,
+) DetectError!?E {
+ var fallback: ?E = null;
+ var pending: ?E = null;
+ while (iter.next()) |arg| {
+ // Allow handling of special cases.
+ if (@hasDecl(E, "detectSpecialCase")) special: {
+ const special = E.detectSpecialCase(arg) orelse break :special;
+ switch (special) {
+ .action => |a| return a,
+ .fallback => |a| fallback = a,
+ .abort_if_no_action => if (pending == null) return null,
+ }
+ }
+
+ // Commands must start with "+"
+ if (arg.len == 0 or arg[0] != '+') continue;
+ if (pending != null) return DetectError.MultipleActions;
+ pending = std.meta.stringToEnum(E, arg[1..]) orelse
+ return DetectError.InvalidAction;
+ }
+
+ // If we have an action, we always return that action, even if we've
+ // seen "--help" or "-h" because the action may have its own help text.
+ if (pending != null) return pending;
+
+ // If we have no action but we have a fallback, then we return that.
+ if (fallback) |a| return a;
+
+ return null;
+}
+
+/// The action enum E can implement the decl `detectSpecialCase` to
+/// return this enum in order to perform various special case actions.
+pub fn SpecialCase(comptime E: type) type {
+ return union(enum) {
+ /// Immediately return this action.
+ action: E,
+
+ /// Return this action if no other action is found.
+ fallback: E,
+
+ /// If there is no pending action (we haven't seen an action yet)
+ /// then we should return no action. This is kind of weird but is
+ /// a special case to allow "-e" in Ghostty.
+ abort_if_no_action,
+ };
+}
+
+test "detect direct match" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const Enum = enum { foo, bar, baz };
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+foo",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.foo, result.?);
+}
+
+test "detect invalid match" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const Enum = enum { foo, bar, baz };
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+invalid",
+ );
+ defer iter.deinit();
+ try testing.expectError(
+ DetectError.InvalidAction,
+ detectIter(Enum, &iter),
+ );
+}
+
+test "detect multiple actions" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const Enum = enum { foo, bar, baz };
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+foo +bar",
+ );
+ defer iter.deinit();
+ try testing.expectError(
+ DetectError.MultipleActions,
+ detectIter(Enum, &iter),
+ );
+}
+
+test "detect no match" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const Enum = enum { foo, bar, baz };
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--some-flag",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expect(result == null);
+}
+
+test "detect special case action" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const Enum = enum {
+ foo,
+ bar,
+
+ fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) {
+ return if (std.mem.eql(u8, arg, "--special"))
+ .{ .action = .foo }
+ else
+ null;
+ }
+ };
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--special +bar",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.foo, result.?);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+bar --special",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.foo, result.?);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+bar",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.bar, result.?);
+ }
+}
+
+test "detect special case fallback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const Enum = enum {
+ foo,
+ bar,
+
+ fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) {
+ return if (std.mem.eql(u8, arg, "--special"))
+ .{ .fallback = .foo }
+ else
+ null;
+ }
+ };
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--special",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.foo, result.?);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+bar --special",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.bar, result.?);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--special +bar",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.bar, result.?);
+ }
+}
+
+test "detect special case abort_if_no_action" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const Enum = enum {
+ foo,
+ bar,
+
+ fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) {
+ return if (std.mem.eql(u8, arg, "-e"))
+ .abort_if_no_action
+ else
+ null;
+ }
+ };
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "-e",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expect(result == null);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+foo -e",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expectEqual(Enum.foo, result.?);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "-e +bar",
+ );
+ defer iter.deinit();
+ const result = try detectIter(Enum, &iter);
+ try testing.expect(result == null);
+ }
+}
diff --git a/vendor/libghostty-vt/src/cli/args.zig b/vendor/libghostty-vt/src/cli/args.zig
new file mode 100644
index 00000000..bd5060d6
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/args.zig
@@ -0,0 +1,1627 @@
+const std = @import("std");
+const mem = std.mem;
+const assert = @import("../quirks.zig").inlineAssert;
+const Allocator = mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const diags = @import("diagnostics.zig");
+const internal_os = @import("../os/main.zig");
+const Diagnostic = diags.Diagnostic;
+const DiagnosticList = diags.DiagnosticList;
+const CommaSplitter = @import("CommaSplitter.zig");
+
+const log = std.log.scoped(.cli);
+
+// TODO:
+// - Only `--long=value` format is accepted. Do we want to allow
+// `--long value`? Not currently allowed.
+
+// For trimming
+pub const whitespace = " \t";
+
+/// The base errors for arg parsing. Additional errors can be returned due
+/// to type-specific parsing but these are always possible.
+pub const Error = error{
+ ValueRequired,
+ InvalidField,
+ InvalidValue,
+};
+
+/// Parse the command line arguments from iter into dst.
+///
+/// dst must be a struct. The fields and their types will be used to determine
+/// the valid CLI flags. See the tests in this file as an example. For field
+/// types that are structs, the struct can implement the `parseCLI` function
+/// to do custom parsing.
+///
+/// If the destination type has a field "_arena" of type `?ArenaAllocator`,
+/// an arena allocator will be created (or reused if set already) for any
+/// allocations. Allocations are necessary for certain types, like `[]const u8`.
+///
+/// If the destination type has a field "_diagnostics", it must be of type
+/// "DiagnosticList" and any diagnostic messages will be added to that list.
+/// When diagnostics are present, only allocation errors will be returned.
+///
+/// If the destination type has a decl "compatibility", it must be of type
+/// std.StaticStringMap(CompatibilityHandler(T)), and it will be used to
+/// handle backwards compatibility for fields with the given name. The
+/// field name doesn't need to exist (so you can setup compatibility for
+/// removed fields). The value is a function that will be called when
+/// all other parsing fails for that field. If a field changes such that
+/// the old values would NOT error, then the caller should handle that
+/// downstream after parsing is done, not through this method.
+///
+/// Note: If the arena is already non-null, then it will be used. In this
+/// case, in the case of an error some memory might be leaked into the arena.
+pub fn parse(
+ comptime T: type,
+ alloc: Allocator,
+ dst: *T,
+ iter: anytype,
+) !void {
+ const info = @typeInfo(T);
+ assert(info == .@"struct");
+
+ // Make an arena for all our allocations if we support it. Otherwise,
+ // use an allocator that always fails. If the arena is already set on
+ // the config, then we reuse that. See memory note in parse docs.
+ const arena_available = @hasField(T, "_arena");
+ var arena_owned: bool = false;
+ const arena_alloc = if (arena_available) arena: {
+ // If the arena is unset, we create it. We mark that we own it
+ // only so that we can clean it up on error.
+ if (dst._arena == null) {
+ dst._arena = .init(alloc);
+ arena_owned = true;
+ }
+
+ break :arena dst._arena.?.allocator();
+ } else fail: {
+ // Note: this is... not safe...
+ var fail = std.testing.FailingAllocator.init(alloc, .{});
+ break :fail fail.allocator();
+ };
+ errdefer if (arena_available and arena_owned) {
+ dst._arena.?.deinit();
+ dst._arena = null;
+ };
+
+ while (iter.next()) |arg| {
+ // Do manual parsing if we have a hook for it.
+ if (@hasDecl(T, "parseManuallyHook")) {
+ if (!try dst.parseManuallyHook(
+ arena_alloc,
+ arg,
+ iter,
+ )) return;
+ }
+
+ // If the destination supports help then we check for it, call
+ // the help function and return.
+ if (@hasDecl(T, "help")) {
+ if (mem.eql(u8, arg, "--help") or
+ mem.eql(u8, arg, "-h"))
+ {
+ try dst.help();
+ return;
+ }
+ }
+
+ // If this doesn't start with "--" then it isn't a config
+ // flag. We don't support positional arguments or configuration
+ // values set with spaces so this is an error.
+ if (!mem.startsWith(u8, arg, "--")) {
+ if (comptime !canTrackDiags(T)) return Error.InvalidField;
+
+ // Add our diagnostic
+ try dst._diagnostics.append(arena_alloc, .{
+ .key = try arena_alloc.dupeZ(u8, arg),
+ .message = "invalid field",
+ .location = try diags.Location.fromIter(iter, arena_alloc),
+ });
+
+ continue;
+ }
+
+ var key: []const u8 = arg[2..];
+ const value: ?[]const u8 = value: {
+ // If the arg has "=" then the value is after the "=".
+ if (mem.indexOf(u8, key, "=")) |idx| {
+ defer key = key[0..idx];
+ break :value key[idx + 1 ..];
+ }
+
+ break :value null;
+ };
+
+ parseIntoField(T, arena_alloc, dst, key, value) catch |err| err: {
+ // If we get an error parsing a field, then we try to fall
+ // back to compatibility handlers if able.
+ if (@hasDecl(T, "compatibility")) {
+ // If we have a compatibility handler for this key, then
+ // we call it and see if it handles the error.
+ if (T.compatibility.get(key)) |handler| {
+ if (handler(dst, arena_alloc, key, value)) {
+ log.info(
+ "compatibility handler for {s} handled error, you may be using a deprecated field: {}",
+ .{ key, err },
+ );
+ break :err;
+ }
+ }
+ }
+
+ if (comptime !canTrackDiags(T)) return err;
+
+ // The error set is dependent on comptime T, so we always add
+ // an extra error so we can have the "else" below.
+ const ErrSet = @TypeOf(err) || error{ Unknown, OutOfMemory } || Error;
+ const message: [:0]const u8 = switch (@as(ErrSet, @errorCast(err))) {
+ // OOM is not recoverable since we need to allocate to
+ // track more error messages.
+ error.OutOfMemory => return err,
+ error.InvalidField => "unknown field",
+ error.ValueRequired => formatValueRequired(T, arena_alloc, key) catch "value required",
+ error.InvalidValue => formatInvalidValue(T, arena_alloc, key, value) catch "invalid value",
+ else => try std.fmt.allocPrintSentinel(
+ arena_alloc,
+ "unknown error {}",
+ .{err},
+ 0,
+ ),
+ };
+
+ // Add our diagnostic
+ try dst._diagnostics.append(arena_alloc, .{
+ .key = try arena_alloc.dupeZ(u8, key),
+ .message = message,
+ .location = try diags.Location.fromIter(iter, arena_alloc),
+ });
+ };
+ }
+}
+
+/// The function type for a compatibility handler. The compatibility
+/// handler is documented in the `parse` function documentation.
+///
+/// The function type should return bool if the compatibility was
+/// handled, and false otherwise. If false is returned then the
+/// naturally occurring error will continue to be processed as if
+/// this compatibility handler was not present.
+///
+/// Compatibility handlers aren't allowed to return errors because
+/// they're generally only called in error cases, so we already have
+/// an error message to show users. If there is an error in handling
+/// the compatibility, then the handler should return false.
+pub fn CompatibilityHandler(comptime T: type) type {
+ return *const fn (
+ dst: *T,
+ alloc: Allocator,
+ key: []const u8,
+ value: ?[]const u8,
+ ) bool;
+}
+
+/// Convenience function to create a compatibility handler that
+/// renames a field from `from` to `to`.
+pub fn compatibilityRenamed(
+ comptime T: type,
+ comptime to: []const u8,
+) CompatibilityHandler(T) {
+ comptime assert(@hasField(T, to));
+
+ return (struct {
+ fn compat(
+ dst: *T,
+ alloc: Allocator,
+ key: []const u8,
+ value: ?[]const u8,
+ ) bool {
+ _ = key;
+
+ parseIntoField(T, alloc, dst, to, value) catch |err| {
+ log.warn("error parsing renamed field {s}: {}", .{
+ to,
+ err,
+ });
+
+ return false;
+ };
+
+ return true;
+ }
+ }).compat;
+}
+
+fn formatValueRequired(
+ comptime T: type,
+ arena_alloc: std.mem.Allocator,
+ key: []const u8,
+) std.Io.Writer.Error![:0]const u8 {
+ var stream: std.Io.Writer.Allocating = .init(arena_alloc);
+ const writer = &stream.writer;
+
+ try writer.print("value required", .{});
+ try formatValues(T, key, writer);
+ try writer.writeByte(0);
+
+ const written = stream.written();
+ return written[0 .. written.len - 1 :0];
+}
+
+fn formatInvalidValue(
+ comptime T: type,
+ arena_alloc: std.mem.Allocator,
+ key: []const u8,
+ value: ?[]const u8,
+) std.Io.Writer.Error![:0]const u8 {
+ var stream: std.Io.Writer.Allocating = .init(arena_alloc);
+ const writer = &stream.writer;
+
+ try writer.print("invalid value \"{?s}\"", .{value});
+ try formatValues(T, key, writer);
+ try writer.writeByte(0);
+
+ const written = stream.written();
+ return written[0 .. written.len - 1 :0];
+}
+
+fn formatValues(
+ comptime T: type,
+ key: []const u8,
+ writer: *std.Io.Writer,
+) std.Io.Writer.Error!void {
+ @setEvalBranchQuota(2000);
+ const typeinfo = @typeInfo(T);
+ inline for (typeinfo.@"struct".fields) |f| {
+ if (std.mem.eql(u8, key, f.name)) {
+ switch (@typeInfo(f.type)) {
+ .@"enum" => |e| {
+ try writer.print(", valid values are: ", .{});
+ inline for (e.fields, 0..) |field, i| {
+ if (i != 0) try writer.print(", ", .{});
+ try writer.print("{s}", .{field.name});
+ }
+ },
+ else => {},
+ }
+ break;
+ }
+ }
+}
+
+/// Returns true if this type can track diagnostics.
+fn canTrackDiags(comptime T: type) bool {
+ return @hasField(T, "_diagnostics");
+}
+
+/// Parse a single key/value pair into the destination type T.
+///
+/// This may result in allocations. The allocations can only be freed by freeing
+/// all the memory associated with alloc. It is expected that alloc points to
+/// an arena.
+pub fn parseIntoField(
+ comptime T: type,
+ alloc: Allocator,
+ dst: *T,
+ key: []const u8,
+ value: ?[]const u8,
+) !void {
+ const info = @typeInfo(T);
+ assert(info == .@"struct");
+
+ inline for (info.@"struct".fields) |field| {
+ if (field.name[0] != '_' and mem.eql(u8, field.name, key)) {
+ // For optional fields, we just treat it as the child type.
+ // This lets optional fields default to null but get set by
+ // the CLI.
+ const Field = switch (@typeInfo(field.type)) {
+ .optional => |opt| opt.child,
+ else => field.type,
+ };
+ const fieldInfo = @typeInfo(Field);
+ const canHaveDecls = fieldInfo == .@"struct" or
+ fieldInfo == .@"union" or
+ fieldInfo == .@"enum";
+
+ // If the value is empty string (set but empty string),
+ // then we reset the value to the default.
+ if (value) |v| default: {
+ if (v.len != 0) break :default;
+ // Set default value if possible.
+ if (canHaveDecls and @hasDecl(Field, "init")) {
+ try @field(dst, field.name).init(alloc);
+ return;
+ }
+ const raw = field.default_value_ptr orelse break :default;
+ const ptr: *const field.type = @ptrCast(@alignCast(raw));
+ @field(dst, field.name) = ptr.*;
+ return;
+ }
+
+ // If we are a type that can have decls and have a parseCLI decl,
+ // we call that and use that to set the value.
+ if (canHaveDecls) {
+ if (@hasDecl(Field, "parseCLI")) {
+ const fnInfo = @typeInfo(@TypeOf(Field.parseCLI)).@"fn";
+ switch (fnInfo.params.len) {
+ // 1 arg = (input) => output
+ 1 => @field(dst, field.name) = try Field.parseCLI(value),
+
+ // 2 arg = (self, input) => void
+ 2 => switch (@typeInfo(field.type)) {
+ .@"struct",
+ .@"union",
+ .@"enum",
+ => try @field(dst, field.name).parseCLI(value),
+
+ // If the field is optional and set, then we use
+ // the pointer value directly into it. If its not
+ // set we need to create a new instance.
+ .optional => if (@field(dst, field.name)) |*v| {
+ try v.parseCLI(value);
+ } else {
+ // Note: you cannot do @field(dst, name) = undefined
+ // because this causes the value to be "null"
+ // in ReleaseFast modes.
+ var tmp: Field = undefined;
+ try tmp.parseCLI(value);
+ @field(dst, field.name) = tmp;
+ },
+
+ else => @compileError("unexpected field type"),
+ },
+
+ // 3 arg = (self, alloc, input) => void
+ 3 => switch (@typeInfo(field.type)) {
+ .@"struct",
+ .@"union",
+ .@"enum",
+ => try @field(dst, field.name).parseCLI(alloc, value),
+
+ .optional => if (@field(dst, field.name)) |*v| {
+ try v.parseCLI(alloc, value);
+ } else {
+ var tmp: Field = undefined;
+ try tmp.parseCLI(alloc, value);
+ @field(dst, field.name) = tmp;
+ },
+
+ else => @compileError("unexpected field type"),
+ },
+
+ else => @compileError("parseCLI invalid argument count"),
+ }
+
+ return;
+ }
+ }
+
+ // No parseCLI, magic the value based on the type
+ @field(dst, field.name) = switch (Field) {
+ []const u8 => value: {
+ const slice = value orelse return error.ValueRequired;
+ const buf = try alloc.alloc(u8, slice.len);
+ @memcpy(buf, slice);
+ break :value buf;
+ },
+
+ [:0]const u8 => value: {
+ const slice = value orelse return error.ValueRequired;
+ const buf = try alloc.allocSentinel(u8, slice.len, 0);
+ @memcpy(buf, slice);
+ buf[slice.len] = 0;
+ break :value buf;
+ },
+
+ bool => try parseBool(value orelse "t"),
+
+ inline u8,
+ u16,
+ u21,
+ u32,
+ u64,
+ usize,
+ i8,
+ i16,
+ i32,
+ i64,
+ isize,
+ => |Int| std.fmt.parseInt(
+ Int,
+ value orelse return error.ValueRequired,
+ 0,
+ ) catch return error.InvalidValue,
+
+ f32,
+ f64,
+ => |Float| std.fmt.parseFloat(
+ Float,
+ value orelse return error.ValueRequired,
+ ) catch return error.InvalidValue,
+
+ else => switch (fieldInfo) {
+ .@"enum" => std.meta.stringToEnum(
+ Field,
+ value orelse return error.ValueRequired,
+ ) orelse return error.InvalidValue,
+
+ .@"struct" => try parseStruct(
+ Field,
+ alloc,
+ value orelse return error.ValueRequired,
+ ),
+
+ .@"union" => try parseTaggedUnion(
+ Field,
+ alloc,
+ value orelse return error.ValueRequired,
+ ),
+
+ else => @compileError("unsupported field type"),
+ },
+ };
+
+ return;
+ }
+ }
+
+ return error.InvalidField;
+}
+
+pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
+ const info = @typeInfo(T).@"union";
+ assert(@typeInfo(info.tag_type.?) == .@"enum");
+
+ // Get the union tag that is being set. We support values with no colon
+ // if the value is void so its not an error to have no colon.
+ const colon_idx = mem.indexOf(u8, v, ":") orelse v.len;
+ const tag_str = std.mem.trim(u8, v[0..colon_idx], whitespace);
+ const value = if (colon_idx < v.len) v[colon_idx + 1 ..] else "";
+
+ // Find the field in the union that matches the tag.
+ inline for (info.fields) |field| {
+ if (mem.eql(u8, field.name, tag_str)) {
+ // Special case void types where we don't need a value.
+ if (field.type == void) {
+ if (value.len > 0) return error.InvalidValue;
+ return @unionInit(T, field.name, {});
+ }
+
+ // We need to create a struct that looks like this union field.
+ // This lets us use parseIntoField as if its a dedicated struct.
+ const Target = @Type(.{ .@"struct" = .{
+ .layout = .auto,
+ .fields = &.{.{
+ .name = field.name,
+ .type = field.type,
+ .default_value_ptr = null,
+ .is_comptime = false,
+ .alignment = @alignOf(field.type),
+ }},
+ .decls = &.{},
+ .is_tuple = false,
+ } });
+
+ // Parse the value into the struct
+ var t: Target = undefined;
+ try parseIntoField(Target, alloc, &t, field.name, value);
+
+ // Build our union
+ return @unionInit(T, field.name, @field(t, field.name));
+ }
+ }
+
+ return error.InvalidValue;
+}
+
+fn parseStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
+ return switch (@typeInfo(T).@"struct".layout) {
+ .auto => parseAutoStruct(T, alloc, v, null),
+ .@"packed" => parsePackedStruct(T, v),
+ else => @compileError("unsupported struct layout"),
+ };
+}
+
+pub fn parseAutoStruct(
+ comptime T: type,
+ alloc: Allocator,
+ v: []const u8,
+ default_: ?T,
+) !T {
+ const info = @typeInfo(T).@"struct";
+ comptime assert(info.layout == .auto);
+
+ // We start our result as undefined so we don't get an error for required
+ // fields. We track required fields below and we validate that we set them
+ // all at the bottom of this function (in addition to setting defaults for
+ // optionals).
+ var result: T = undefined;
+
+ // Keep track of which fields were set so we can error if a required
+ // field was not set.
+ const FieldSet = std.StaticBitSet(info.fields.len);
+ var fields_set: FieldSet = .initEmpty();
+
+ // We split each value by "," allowing for quoting and escaping.
+ var iter: CommaSplitter = .init(v);
+ loop: while (try iter.next()) |entry| {
+ // Find the key/value, trimming whitespace. The value may be quoted
+ // which we strip the quotes from.
+ const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue;
+ const key = std.mem.trim(u8, entry[0..idx], whitespace);
+
+ // used if we need to decode a double-quoted string.
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ defer buf.deinit();
+
+ const value = value: {
+ const value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
+
+ // Detect a quoted string.
+ if (value.len >= 2 and
+ value[0] == '"' and
+ value[value.len - 1] == '"')
+ {
+ // Decode a double-quoted string as a Zig string literal.
+ const parsed = try std.zig.string_literal.parseWrite(&buf.writer, value);
+ if (parsed == .failure) return error.InvalidValue;
+ break :value buf.written();
+ }
+
+ break :value value;
+ };
+
+ inline for (info.fields, 0..) |field, i| {
+ if (std.mem.eql(u8, field.name, key)) {
+ try parseIntoField(T, alloc, &result, key, value);
+ fields_set.set(i);
+ continue :loop;
+ }
+ }
+
+ // No field matched
+ return error.InvalidValue;
+ }
+
+ // Ensure all required fields are set
+ inline for (info.fields, 0..) |field, i| {
+ if (!fields_set.isSet(i)) {
+ @field(result, field.name) = default: {
+ // If we're given a default value then we inherit those.
+ // Otherwise we use the default values as specified by the
+ // struct.
+ if (default_) |default| {
+ break :default @field(default, field.name);
+ } else {
+ const default_ptr = field.default_value_ptr orelse return error.InvalidValue;
+ const typed_ptr: *const field.type = @ptrCast(@alignCast(default_ptr));
+ break :default typed_ptr.*;
+ }
+ };
+ }
+ }
+
+ return result;
+}
+
+pub fn parsePackedStruct(comptime T: type, v: []const u8) !T {
+ const info = @typeInfo(T).@"struct";
+ comptime assert(info.layout == .@"packed");
+
+ var result: T = .{};
+
+ // Allow standalone boolean values like "true" and "false" to
+ // turn on or off all of the struct's fields.
+ bools: {
+ const b = parseBool(v) catch break :bools;
+ inline for (info.fields) |field| {
+ assert(field.type == bool);
+ @field(result, field.name) = b;
+ }
+ return result;
+ }
+
+ // We split each value by ","
+ var iter = std.mem.splitSequence(u8, v, ",");
+ loop: while (iter.next()) |part_raw| {
+ // Determine the field we're looking for and the value. If the
+ // field is prefixed with "no-" then we set the value to false.
+ const part, const value = part: {
+ const negation_prefix = "no-";
+ const trimmed = std.mem.trim(u8, part_raw, whitespace);
+ if (std.mem.startsWith(u8, trimmed, negation_prefix)) {
+ break :part .{ trimmed[negation_prefix.len..], false };
+ } else {
+ break :part .{ trimmed, true };
+ }
+ };
+
+ inline for (info.fields) |field| {
+ assert(field.type == bool);
+ if (std.mem.eql(u8, field.name, part)) {
+ @field(result, field.name) = value;
+ continue :loop;
+ }
+ }
+
+ // No field matched
+ return error.InvalidValue;
+ }
+
+ return result;
+}
+
+pub fn parseBool(v: []const u8) !bool {
+ const t = &[_][]const u8{ "1", "t", "T", "true" };
+ const f = &[_][]const u8{ "0", "f", "F", "false" };
+
+ inline for (t) |str| {
+ if (mem.eql(u8, v, str)) return true;
+ }
+ inline for (f) |str| {
+ if (mem.eql(u8, v, str)) return false;
+ }
+
+ return error.InvalidValue;
+}
+
+test "parse: simple" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: []const u8 = "",
+ b: bool = false,
+ @"b-f": bool = true,
+
+ _arena: ?ArenaAllocator = null,
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--a=42 --b --b-f=false",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expectEqualStrings("42", data.a);
+ try testing.expect(data.b);
+ try testing.expect(!data.@"b-f");
+
+ // Reparsing works
+ var iter2 = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--a=84",
+ );
+ defer iter2.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter2);
+ try testing.expect(data._arena != null);
+ try testing.expectEqualStrings("84", data.a);
+ try testing.expect(data.b);
+ try testing.expect(!data.@"b-f");
+}
+
+test "parse: quoted value" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: u8 = 0,
+ b: []const u8 = "",
+ _arena: ?ArenaAllocator = null,
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--a=\"42\" --b=\"hello!\"",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expectEqual(@as(u8, 42), data.a);
+ try testing.expectEqualStrings("hello!", data.b);
+}
+
+test "parse: empty value resets to default" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: u8 = 42,
+ b: bool = false,
+ _arena: ?ArenaAllocator = null,
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--a= --b=",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expectEqual(@as(u8, 42), data.a);
+ try testing.expect(!data.b);
+}
+
+test "parse: positional arguments are invalid" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: u8 = 42,
+ _arena: ?ArenaAllocator = null,
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--a=84 what",
+ );
+ defer iter.deinit();
+ try testing.expectError(
+ error.InvalidField,
+ parse(@TypeOf(data), testing.allocator, &data, &iter),
+ );
+ try testing.expectEqual(@as(u8, 84), data.a);
+}
+
+test "parse: diagnostic tracking" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: []const u8 = "",
+ b: enum { one } = .one,
+
+ _arena: ?ArenaAllocator = null,
+ _diagnostics: DiagnosticList = .{},
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--what --a=42",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expectEqualStrings("42", data.a);
+ try testing.expect(data._diagnostics.items().len == 1);
+ {
+ const diag = data._diagnostics.items()[0];
+ try testing.expectEqual(diags.Location.none, diag.location);
+ try testing.expectEqualStrings("what", diag.key);
+ try testing.expectEqualStrings("unknown field", diag.message);
+ }
+}
+
+test "parse: diagnostic location" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: []const u8 = "",
+ b: enum { one, two } = .one,
+
+ _arena: ?ArenaAllocator = null,
+ _diagnostics: DiagnosticList = .{},
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var r: std.Io.Reader = .fixed(
+ \\a=42
+ \\what
+ \\b=two
+ );
+
+ var iter: LineIterator = .{ .r = &r, .filepath = "test" };
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expectEqualStrings("42", data.a);
+ try testing.expect(data.b == .two);
+ try testing.expect(data._diagnostics.items().len == 1);
+ {
+ const diag = data._diagnostics.items()[0];
+ try testing.expectEqualStrings("what", diag.key);
+ try testing.expectEqualStrings("unknown field", diag.message);
+ try testing.expectEqualStrings("test", diag.location.file.path);
+ try testing.expectEqual(2, diag.location.file.line);
+ }
+}
+
+test "parse: compatibility handler" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: bool = false,
+ _arena: ?ArenaAllocator = null,
+
+ pub const compatibility: std.StaticStringMap(
+ CompatibilityHandler(@This()),
+ ) = .initComptime(&.{
+ .{ "a", compat },
+ });
+
+ fn compat(
+ self: *@This(),
+ alloc: Allocator,
+ key: []const u8,
+ value: ?[]const u8,
+ ) bool {
+ _ = alloc;
+ if (std.mem.eql(u8, key, "a")) {
+ if (value) |v| {
+ if (mem.eql(u8, v, "yuh")) {
+ self.a = true;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--a=yuh",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expect(data.a);
+}
+
+test "parse: compatibility renamed" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: bool = false,
+ b: bool = false,
+ _arena: ?ArenaAllocator = null,
+
+ pub const compatibility: std.StaticStringMap(
+ CompatibilityHandler(@This()),
+ ) = .initComptime(&.{
+ .{ "old", compatibilityRenamed(@This(), "a") },
+ });
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--old=true --b=true",
+ );
+ defer iter.deinit();
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expect(data.a);
+ try testing.expect(data.b);
+}
+
+test "parseIntoField: ignore underscore-prefixed fields" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ _a: []const u8 = "12",
+ } = .{};
+
+ try testing.expectError(
+ error.InvalidField,
+ parseIntoField(@TypeOf(data), alloc, &data, "_a", "42"),
+ );
+ try testing.expectEqualStrings("12", data._a);
+}
+
+test "parseIntoField: struct with init func" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: struct {
+ const Self = @This();
+
+ v: []const u8,
+
+ pub fn init(self: *Self, _alloc: Allocator) !void {
+ _ = _alloc;
+ self.* = .{ .v = "HELLO!" };
+ }
+ },
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "");
+ try testing.expectEqual(@as([]const u8, "HELLO!"), data.a.v);
+}
+
+test "parseIntoField: string" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: []const u8,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "42");
+ try testing.expectEqualStrings("42", data.a);
+}
+
+test "parseIntoField: sentinel string" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: [:0]const u8,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "42");
+ try testing.expectEqualStrings("42", data.a);
+ try testing.expectEqual(@as(u8, 0), data.a[data.a.len]);
+}
+
+test "parseIntoField: bool" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: bool,
+ } = undefined;
+
+ // True
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "1");
+ try testing.expectEqual(true, data.a);
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "t");
+ try testing.expectEqual(true, data.a);
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "T");
+ try testing.expectEqual(true, data.a);
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "true");
+ try testing.expectEqual(true, data.a);
+
+ // False
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "0");
+ try testing.expectEqual(false, data.a);
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "f");
+ try testing.expectEqual(false, data.a);
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "F");
+ try testing.expectEqual(false, data.a);
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "false");
+ try testing.expectEqual(false, data.a);
+}
+
+test "parseIntoField: unsigned numbers" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ u8: u8,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "u8", "1");
+ try testing.expectEqual(@as(u8, 1), data.u8);
+}
+
+test "parseIntoField: floats" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ f64: f64,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "f64", "1");
+ try testing.expectEqual(@as(f64, 1.0), data.f64);
+}
+
+test "parseIntoField: enums" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const Enum = enum { one, two, three };
+ var data: struct {
+ v: Enum,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "v", "two");
+ try testing.expectEqual(Enum.two, data.v);
+}
+
+test "parseIntoField: packed struct" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const Field = packed struct {
+ a: bool = false,
+ b: bool = true,
+ };
+ var data: struct {
+ v: Field,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "v", "b");
+ try testing.expect(!data.v.a);
+ try testing.expect(data.v.b);
+}
+
+test "parseIntoField: packed struct negation" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const Field = packed struct {
+ a: bool = false,
+ b: bool = true,
+ };
+ var data: struct {
+ v: Field,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "v", "a,no-b");
+ try testing.expect(data.v.a);
+ try testing.expect(!data.v.b);
+}
+
+test "parseIntoField: packed struct true/false" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const Field = packed struct {
+ a: bool = false,
+ b: bool = true,
+ };
+ var data: struct {
+ v: Field,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "v", "true");
+ try testing.expect(data.v.a);
+ try testing.expect(data.v.b);
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "v", "false");
+ try testing.expect(!data.v.a);
+ try testing.expect(!data.v.b);
+
+ try testing.expectError(
+ error.InvalidValue,
+ parseIntoField(@TypeOf(data), alloc, &data, "v", "true,a"),
+ );
+}
+
+test "parseIntoField: packed struct whitespace" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ const Field = packed struct {
+ a: bool = false,
+ b: bool = true,
+ };
+ var data: struct {
+ v: Field,
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "v", " a, no-b ");
+ try testing.expect(data.v.a);
+ try testing.expect(!data.v.b);
+}
+
+test "parseIntoField: optional field" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: ?bool = null,
+ } = .{};
+
+ // True
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "1");
+ try testing.expectEqual(true, data.a.?);
+
+ // Unset
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "");
+ try testing.expect(data.a == null);
+}
+
+test "parseIntoField: struct with parse func" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: struct {
+ const Self = @This();
+
+ v: []const u8,
+
+ pub fn parseCLI(value: ?[]const u8) !Self {
+ _ = value;
+ return Self{ .v = "HELLO!" };
+ }
+ },
+ } = undefined;
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "42");
+ try testing.expectEqual(@as([]const u8, "HELLO!"), data.a.v);
+}
+
+test "parseIntoField: optional struct with parse func" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ a: ?struct {
+ const Self = @This();
+
+ v: []const u8,
+
+ pub fn parseCLI(self: *Self, _: Allocator, value: ?[]const u8) !void {
+ _ = value;
+ self.* = .{ .v = "HELLO!" };
+ }
+ } = null,
+ } = .{};
+
+ try parseIntoField(@TypeOf(data), alloc, &data, "a", "42");
+ try testing.expectEqual(@as([]const u8, "HELLO!"), data.a.?.v);
+}
+
+test "parseIntoField: struct with basic fields" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ value: struct {
+ a: []const u8,
+ b: u32,
+ c: u8 = 12,
+ } = undefined,
+ } = .{};
+
+ // Set required fields
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello,b:42");
+ try testing.expectEqualStrings("hello", data.value.a);
+ try testing.expectEqual(42, data.value.b);
+ try testing.expectEqual(12, data.value.c);
+
+ // Set all fields
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "a:world,b:84,c:24");
+ try testing.expectEqualStrings("world", data.value.a);
+ try testing.expectEqual(84, data.value.b);
+ try testing.expectEqual(24, data.value.c);
+
+ // Missing require dfield
+ try testing.expectError(
+ error.InvalidValue,
+ parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello"),
+ );
+}
+
+test "parseIntoField: tagged union" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ value: union(enum) {
+ a: u8,
+ b: u8,
+ c: void,
+ d: []const u8,
+ e: [:0]const u8,
+ } = undefined,
+ } = .{};
+
+ // Set one field
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "a:1");
+ try testing.expectEqual(1, data.value.a);
+
+ // Set another
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "b:2");
+ try testing.expectEqual(2, data.value.b);
+
+ // Set void field
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "c");
+ try testing.expectEqual({}, data.value.c);
+
+ // Set string field
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "d:hello");
+ try testing.expectEqualStrings("hello", data.value.d);
+
+ // Set sentinel string field
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "e:hello");
+ try testing.expectEqualStrings("hello", data.value.e);
+}
+
+test "parseIntoField: tagged union unknown filed" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ value: union(enum) {
+ a: u8,
+ b: u8,
+ } = undefined,
+ } = .{};
+
+ try testing.expectError(
+ error.InvalidValue,
+ parseIntoField(@TypeOf(data), alloc, &data, "value", "c:1"),
+ );
+}
+
+test "parseIntoField: tagged union invalid field value" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ value: union(enum) {
+ a: u8,
+ b: u8,
+ } = undefined,
+ } = .{};
+
+ try testing.expectError(
+ error.InvalidValue,
+ parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello"),
+ );
+}
+
+test "parseIntoField: tagged union missing tag" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var data: struct {
+ value: union(enum) {
+ a: u8,
+ b: u8,
+ } = undefined,
+ } = .{};
+
+ try testing.expectError(
+ error.InvalidValue,
+ parseIntoField(@TypeOf(data), alloc, &data, "value", "a"),
+ );
+ try testing.expectError(
+ error.InvalidValue,
+ parseIntoField(@TypeOf(data), alloc, &data, "value", ":a"),
+ );
+}
+
+/// An iterator that considers its location to be CLI args. It
+/// iterates through an underlying iterator and increments a counter
+/// to track the current CLI arg index.
+///
+/// This also ignores any argument that starts with `+`. It assumes that
+/// actions were parsed out before this iterator was created.
+pub fn ArgsIterator(comptime Iterator: type) type {
+ return struct {
+ const Self = @This();
+
+ /// The underlying args iterator.
+ iterator: Iterator,
+
+ /// Our current index into the iterator. This is 1-indexed.
+ /// The 0 value is used to indicate that we haven't read any
+ /// values yet.
+ index: usize = 0,
+
+ pub fn deinit(self: *Self) void {
+ if (@hasDecl(Iterator, "deinit")) {
+ self.iterator.deinit();
+ }
+ }
+
+ pub fn next(self: *Self) ?[]const u8 {
+ const value = self.iterator.next() orelse return null;
+ self.index += 1;
+
+ // We ignore any argument that starts with "+". This is used
+ // to indicate actions and are expected to be parsed out before
+ // this iterator is created.
+ if (value.len > 0 and value[0] == '+') return self.next();
+
+ return value;
+ }
+
+ /// Returns a location for a diagnostic message.
+ pub fn location(self: *const Self, _: Allocator) error{}!?diags.Location {
+ return .{ .cli = self.index };
+ }
+ };
+}
+
+/// Create an args iterator for the process args. This will skip argv0.
+pub fn argsIterator(alloc_gpa: Allocator) internal_os.args.ArgIterator.InitError!ArgsIterator(internal_os.args.ArgIterator) {
+ var iter = try internal_os.args.iterator(alloc_gpa);
+ errdefer iter.deinit();
+ _ = iter.next(); // skip argv0
+ return .{ .iterator = iter };
+}
+
+test "ArgsIterator" {
+ const testing = std.testing;
+
+ const child = try std.process.ArgIteratorGeneral(.{}).init(
+ testing.allocator,
+ "--what +list-things --a=42",
+ );
+ const Iter = ArgsIterator(@TypeOf(child));
+ var iter: Iter = .{ .iterator = child };
+ defer iter.deinit();
+
+ try testing.expectEqualStrings("--what", iter.next().?);
+ try testing.expectEqualStrings("--a=42", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
+
+/// Returns an iterator (implements "next") that reads CLI args by line.
+/// Each CLI arg is expected to be a single line. This is used to implement
+/// configuration files.
+pub const LineIterator = struct {
+ const Self = @This();
+
+ /// The maximum size a single line can be. We don't expect any
+ /// CLI arg to exceed this size. Can't wait to git blame this in
+ /// like 4 years and be wrong about this.
+ pub const MAX_LINE_SIZE = 4096;
+
+ /// Our stateful reader.
+ r: *std.Io.Reader,
+
+ /// Filepath that is used for diagnostics. This is only used for
+ /// diagnostic messages so it can be formatted however you want.
+ /// It is prefixed to the messages followed by the line number.
+ filepath: []const u8 = "",
+
+ /// The current line that we're on. This is 1-indexed because
+ /// lines are generally 1-indexed in the real world. The value
+ /// can be zero if we haven't read any lines yet.
+ line: usize = 0,
+
+ /// This is the buffer where we store the current entry that
+ /// is formatted to be compatible with the parse function.
+ entry: [MAX_LINE_SIZE]u8 = [_]u8{ '-', '-' } ++ ([_]u8{0} ** (MAX_LINE_SIZE - 2)),
+
+ pub fn init(reader: *std.Io.Reader) Self {
+ return .{ .r = reader };
+ }
+
+ pub fn next(self: *Self) ?[]const u8 {
+ // First prime the reader.
+ // File readers at least are initialized with a size of 0,
+ // and this will actually prompt the reader to get the actual
+ // size of the file, which will be used in the EOF check below.
+ //
+ // This will also optimize reads down the line as we're
+ // more likely to beworking with buffered data.
+ //
+ // fillMore asserts that the buffer has available capacity,
+ // so skip this if it's full.
+ if (self.r.bufferedLen() < self.r.buffer.len) {
+ self.r.fillMore() catch {};
+ }
+
+ var writer: std.Io.Writer = .fixed(self.entry[2..]);
+
+ var entry = while (self.r.seek != self.r.end) {
+ // Reset write head
+ writer.end = 0;
+
+ _ = self.r.streamDelimiterEnding(&writer, '\n') catch |e| {
+ log.warn("cannot read from \"{s}\": {}", .{ self.filepath, e });
+ return null;
+ };
+ _ = self.r.discardDelimiterInclusive('\n') catch {};
+
+ var entry = writer.buffered();
+ self.line += 1;
+
+ // Trim any whitespace (including CR) around it
+ const trim = std.mem.trim(u8, entry, whitespace ++ "\r");
+ if (trim.len != entry.len) {
+ std.mem.copyForwards(u8, entry, trim);
+ entry = entry[0..trim.len];
+ }
+
+ // Ignore blank lines and comments
+ if (entry.len == 0 or entry[0] == '#') continue;
+ break entry;
+ } else return null;
+
+ if (mem.indexOf(u8, entry, "=")) |idx| {
+ const key = std.mem.trim(u8, entry[0..idx], whitespace);
+ const value = value: {
+ var value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
+
+ // Detect a quoted string.
+ if (value.len >= 2 and
+ value[0] == '"' and
+ value[value.len - 1] == '"')
+ {
+ // Trim quotes since our CLI args processor expects
+ // quotes to already be gone.
+ value = value[1 .. value.len - 1];
+ }
+
+ break :value value;
+ };
+
+ const len = key.len + value.len + 1;
+ if (entry.len != len) {
+ std.mem.copyForwards(u8, entry, key);
+ entry[key.len] = '=';
+ std.mem.copyForwards(u8, entry[key.len + 1 ..], value);
+ entry = entry[0..len];
+ }
+ }
+
+ // We need to reslice so that we include our '--' at the beginning
+ // of our buffer so that we can trick the CLI parser to treat it
+ // as CLI args.
+ return self.entry[0 .. entry.len + 2];
+ }
+
+ /// Returns a location for a diagnostic message.
+ pub fn location(
+ self: *const Self,
+ alloc: Allocator,
+ ) Allocator.Error!?diags.Location {
+ // If we have no filepath then we have no location.
+ if (self.filepath.len == 0) return null;
+
+ return .{ .file = .{
+ .path = try alloc.dupe(u8, self.filepath),
+ .line = self.line,
+ } };
+ }
+};
+
+/// An iterator valid for arg parsing from a slice.
+pub const SliceIterator = struct {
+ const Self = @This();
+
+ slice: []const []const u8,
+ idx: usize = 0,
+
+ pub fn next(self: *Self) ?[]const u8 {
+ if (self.idx >= self.slice.len) return null;
+ defer self.idx += 1;
+ return self.slice[self.idx];
+ }
+};
+
+/// Construct a SliceIterator from a slice.
+pub fn sliceIterator(slice: []const []const u8) SliceIterator {
+ return .{ .slice = slice };
+}
+
+test "LineIterator" {
+ const testing = std.testing;
+ var reader: std.Io.Reader = .fixed(
+ \\A
+ \\B=42
+ \\C
+ \\
+ \\# A comment
+ \\D
+ \\
+ \\ # An indented comment
+ \\ E
+ \\
+ \\# A quoted string with whitespace
+ \\F= "value "
+ );
+
+ var iter: LineIterator = .init(&reader);
+ try testing.expectEqualStrings("--A", iter.next().?);
+ try testing.expectEqualStrings("--B=42", iter.next().?);
+ try testing.expectEqualStrings("--C", iter.next().?);
+ try testing.expectEqualStrings("--D", iter.next().?);
+ try testing.expectEqualStrings("--E", iter.next().?);
+ try testing.expectEqualStrings("--F=value ", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
+
+test "LineIterator end in newline" {
+ const testing = std.testing;
+ var reader: std.Io.Reader = .fixed("A\n\n");
+
+ var iter: LineIterator = .init(&reader);
+ try testing.expectEqualStrings("--A", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
+
+test "LineIterator spaces around '='" {
+ const testing = std.testing;
+ var reader: std.Io.Reader = .fixed("A = B\n\n");
+
+ var iter: LineIterator = .init(&reader);
+ try testing.expectEqualStrings("--A=B", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
+
+test "LineIterator no value" {
+ const testing = std.testing;
+ var reader: std.Io.Reader = .fixed("A = \n\n");
+
+ var iter: LineIterator = .init(&reader);
+ try testing.expectEqualStrings("--A=", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
+
+test "LineIterator with CRLF line endings" {
+ const testing = std.testing;
+ var reader: std.Io.Reader = .fixed("A\r\nB = C\r\n");
+
+ var iter: LineIterator = .init(&reader);
+ try testing.expectEqualStrings("--A", iter.next().?);
+ try testing.expectEqualStrings("--B=C", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
+
+test "LineIterator with buffered reader" {
+ const testing = std.testing;
+ var f: std.Io.Reader = .fixed("A\nB = C\n");
+ var buf: [2]u8 = undefined;
+ var r = f.limited(.unlimited, &buf);
+ const reader = &r.interface;
+
+ var iter: LineIterator = .init(reader);
+ try testing.expectEqualStrings("--A", iter.next().?);
+ try testing.expectEqualStrings("--B=C", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
+
+test "LineIterator with buffered and primed reader" {
+ const testing = std.testing;
+ var f: std.Io.Reader = .fixed("A\nB = C\n");
+ var buf: [2]u8 = undefined;
+ var r = f.limited(.unlimited, &buf);
+ const reader = &r.interface;
+
+ try reader.fill(buf.len);
+
+ var iter: LineIterator = .init(reader);
+ try testing.expectEqualStrings("--A", iter.next().?);
+ try testing.expectEqualStrings("--B=C", iter.next().?);
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+ try testing.expectEqual(@as(?[]const u8, null), iter.next());
+}
diff --git a/vendor/libghostty-vt/src/cli/boo.zig b/vendor/libghostty-vt/src/cli/boo.zig
new file mode 100644
index 00000000..2834eadb
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/boo.zig
@@ -0,0 +1,236 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+const Allocator = std.mem.Allocator;
+const vaxis = @import("vaxis");
+
+const framedata = @import("framedata").compressed;
+
+const vxfw = vaxis.vxfw;
+
+pub const Options = struct {
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+const Boo = struct {
+ frame: u8,
+ framerate: u32, // 30 fps
+ // We know the size of this at compile time, but we heap allocate the slice to prevent the
+ // binary from increasing too much in size
+ buffer: [frame_width * frame_height]vaxis.Cell = undefined,
+
+ ghostty_style: vaxis.Style,
+ outline_style: vaxis.Style,
+
+ // Width of a single frame
+ const frame_width = 100;
+ // Height of a single frame
+ const frame_height = 41;
+
+ fn widget(self: *Boo) vxfw.Widget {
+ return .{
+ .userdata = self,
+ .eventHandler = Boo.typeErasedEventHandler,
+ .drawFn = Boo.typeErasedDrawFn,
+ };
+ }
+
+ fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
+ const self: *Boo = @ptrCast(@alignCast(ptr));
+ switch (event) {
+ .init,
+ .tick,
+ => {
+ self.updateFrame();
+ ctx.redraw = true;
+ return ctx.tick(self.framerate, self.widget());
+ },
+ .key_press => |key| {
+ if (key.matches('c', .{ .ctrl = true }) or
+ key.matches(vaxis.Key.escape, .{}))
+ {
+ ctx.quit = true;
+ return;
+ }
+ },
+ else => {},
+ }
+ }
+
+ fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
+ const self: *Boo = @ptrCast(@alignCast(ptr));
+ const max = ctx.max.size();
+
+ // Warn for screen size
+ if (max.width < frame_width or max.height < frame_height) {
+ const text: vxfw.Text = .{ .text = "Screen must be at least 100w x 41h" };
+ const center: vxfw.Center = .{ .child = text.widget() };
+ return center.draw(ctx);
+ }
+
+ // Calculate x and y offsets to center the animation frame
+ const offset_y = (max.height - frame_height) / 2;
+ const offset_x = (max.width - frame_width) / 2;
+
+ // Create the animation surface
+ const child: vxfw.Surface = .{
+ .size = .{ .width = @intCast(frame_width), .height = @intCast(frame_height) },
+ .widget = self.widget(),
+ .buffer = &self.buffer,
+ .children = &.{},
+ };
+
+ // Allocate a slice of child surfaces
+ var children = try ctx.arena.alloc(vxfw.SubSurface, 1);
+ children[0] = .{
+ .origin = .{ .row = @intCast(offset_y), .col = @intCast(offset_x) },
+ .surface = child,
+ };
+
+ return .{
+ .size = max,
+ .widget = self.widget(),
+ .buffer = &.{},
+ .children = children,
+ };
+ }
+
+ /// Updates our internal buffer with the current frame, then advances the frame index
+ fn updateFrame(self: *Boo) void {
+ const frame = frames[self.frame];
+ // A frame is characters with html spans. When we encounter a span, we use the outline style
+ // until the span ends. That is, when we find a '<', we parse until '>'. Then we use the
+ // outline styule until the next '<', and skip until the next '>'
+
+ const State = enum {
+ normal,
+ span,
+ in_tag,
+ in_closing_tag,
+ };
+
+ var cell_idx: usize = 0;
+
+ var line_iter = std.mem.splitScalar(u8, frame, '\n');
+ while (line_iter.next()) |line| {
+ var state: State = .normal;
+ var style = self.ghostty_style;
+ var cp_iter: std.unicode.Utf8Iterator = .{ .bytes = line, .i = 0 };
+ while (cp_iter.nextCodepointSlice()) |char| {
+ switch (state) {
+ .normal => if (std.mem.eql(u8, "<", char)) {
+ state = .in_tag;
+ // We will be entering a span
+ style = self.outline_style;
+ continue;
+ },
+ .span => if (std.mem.eql(u8, "<", char)) {
+ state = .in_tag;
+ style = self.ghostty_style;
+ continue;
+ },
+ .in_tag => {
+ // If we encounter a '/', we are a closing tag
+ // If we parse all the way to a '>' we are an opening tag: we are now in a span
+ if (std.mem.eql(u8, "/", char))
+ state = .in_closing_tag
+ else if (std.mem.eql(u8, ">", char))
+ state = .span;
+ continue;
+ },
+ .in_closing_tag => {
+ // If we are closing a tag, we will enter the normal state
+ if (std.mem.eql(u8, ">", char)) state = .normal;
+ continue;
+ },
+ }
+ self.buffer[cell_idx] = .{
+ .char = .{
+ .grapheme = char,
+ .width = 1,
+ },
+ .style = style,
+ };
+ cell_idx += 1;
+ }
+ }
+ std.debug.assert(cell_idx == self.buffer.len);
+
+ // Lastly, update the frame index
+ self.frame += 1;
+ if (self.frame == frames.len) self.frame = 0;
+ }
+};
+
+/// The `boo` command is used to display the animation from the Ghostty website in the terminal
+pub fn run(gpa: Allocator) !u8 {
+ // Disable on non-desktop systems.
+ switch (builtin.os.tag) {
+ .windows, .macos, .linux, .freebsd => {},
+ else => return 1,
+ }
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(gpa);
+ defer iter.deinit();
+ try args.parse(Options, gpa, &opts, &iter);
+ }
+
+ try decompressFrames(gpa);
+ defer {
+ gpa.free(frames);
+ gpa.free(decompressed_data);
+ }
+
+ var app = try vxfw.App.init(gpa);
+ defer app.deinit();
+
+ var boo: Boo = undefined;
+ boo.frame = 0;
+ boo.framerate = 1000 / 30;
+ boo.ghostty_style = .{};
+ boo.outline_style = .{ .fg = .{ .index = 4 } };
+ @memset(&boo.buffer, .{});
+
+ try app.run(boo.widget(), .{});
+
+ return 0;
+}
+
+/// We store a global ref to the decompressed data. All of our frames reference into this data
+var decompressed_data: []const u8 = undefined;
+
+/// Heap allocated list of frames. The underlying frame data references decompressed_data
+var frames: []const []const u8 = undefined;
+
+/// Decompress the frames into a slice of individual frames
+fn decompressFrames(gpa: Allocator) !void {
+ var src: std.Io.Reader = .fixed(framedata);
+
+ // var buf: [std.compress.flate.max_window_len]u8 = undefined;
+ var decompress: std.compress.flate.Decompress = .init(&src, .raw, &.{});
+
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ _ = try decompress.reader.streamRemaining(&out.writer);
+ decompressed_data = try out.toOwnedSlice();
+
+ var frame_list: std.ArrayList([]const u8) = try .initCapacity(gpa, 235);
+
+ var frame_iter = std.mem.splitScalar(u8, decompressed_data, '\x01');
+ while (frame_iter.next()) |frame| {
+ try frame_list.append(gpa, frame);
+ }
+ frames = try frame_list.toOwnedSlice(gpa);
+}
diff --git a/vendor/libghostty-vt/src/cli/crash_report.zig b/vendor/libghostty-vt/src/cli/crash_report.zig
new file mode 100644
index 00000000..f0940fda
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/crash_report.zig
@@ -0,0 +1,93 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+const Config = @import("../config.zig").Config;
+const crash = @import("../crash/main.zig");
+
+pub const Options = struct {
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `crash-report` command is used to inspect and send crash reports.
+///
+/// When executed without any arguments, this will list existing crash reports.
+///
+/// This command currently only supports listing crash reports. Viewing
+/// and sending crash reports is unimplemented and will be added in the future.
+pub fn run(alloc_gpa: Allocator) !u8 {
+ // Use an arena for the whole command to avoid manual memory management.
+ var arena = std.heap.ArenaAllocator.init(alloc_gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc_gpa);
+ defer iter.deinit();
+ try args.parse(Options, alloc_gpa, &opts, &iter);
+ }
+
+ var buffer: [1024]u8 = undefined;
+ var stdout_file: std.fs.File = .stdout();
+ var stdout_writer = stdout_file.writer(&buffer);
+ const stdout = &stdout_writer.interface;
+
+ const result = runInner(alloc, &stdout_file, stdout);
+ stdout.flush() catch {};
+ return result;
+}
+
+fn runInner(
+ alloc: Allocator,
+ stdout_file: *std.fs.File,
+ stdout: *std.Io.Writer,
+) !u8 {
+ const crash_dir = try crash.defaultDir(alloc);
+ var reports: std.ArrayList(crash.Report) = .empty;
+ errdefer reports.deinit(alloc);
+
+ var it = try crash_dir.iterator();
+ while (try it.next()) |report| try reports.append(alloc, .{
+ .name = try alloc.dupe(u8, report.name),
+ .mtime = report.mtime,
+ });
+
+ // If we have no reports, then we're done. If we have a tty then we
+ // print a message, otherwise we do nothing.
+ if (reports.items.len == 0) {
+ if (std.posix.isatty(stdout_file.handle)) {
+ try stdout.writeAll("No crash reports! 👻\n");
+ }
+ return 0;
+ }
+
+ std.mem.sort(crash.Report, reports.items, {}, lt);
+
+ for (reports.items) |report| {
+ var buf: [128]u8 = undefined;
+ const now = std.time.nanoTimestamp();
+ const diff = now - report.mtime;
+ const since = if (diff <= 0) "now" else s: {
+ const d = Config.Duration{ .duration = @intCast(diff) };
+ break :s try std.fmt.bufPrint(&buf, "{f} ago", .{d.round(std.time.ns_per_s)});
+ };
+ try stdout.print("{s} ({s})\n", .{ report.name, since });
+ }
+
+ return 0;
+}
+
+fn lt(_: void, lhs: crash.Report, rhs: crash.Report) bool {
+ return lhs.mtime > rhs.mtime;
+}
diff --git a/vendor/libghostty-vt/src/cli/diagnostics.zig b/vendor/libghostty-vt/src/cli/diagnostics.zig
new file mode 100644
index 00000000..7f4dcc45
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/diagnostics.zig
@@ -0,0 +1,197 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = @import("../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const build_config = @import("../build_config.zig");
+
+/// A diagnostic message from parsing. This is used to provide additional
+/// human-friendly warnings and errors about the parsed data.
+///
+/// All of the memory for the diagnostic is allocated from the arena
+/// associated with the config structure. If an arena isn't available
+/// then diagnostics are not supported.
+pub const Diagnostic = struct {
+ location: Location = .none,
+ key: [:0]const u8 = "",
+ message: [:0]const u8,
+
+ /// Write the full user-friendly diagnostic message to the writer.
+ pub fn format(self: *const Diagnostic, writer: *std.Io.Writer) !void {
+ switch (self.location) {
+ .none => {},
+ .cli => |index| try writer.print("cli:{}:", .{index}),
+ .file => |file| try writer.print(
+ "{s}:{}:",
+ .{ file.path, file.line },
+ ),
+ }
+
+ if (self.key.len > 0) {
+ try writer.print("{s}: ", .{self.key});
+ } else if (self.location != .none) {
+ try writer.print(" ", .{});
+ }
+
+ try writer.print("{s}", .{self.message});
+ }
+
+ pub fn clone(self: *const Diagnostic, alloc: Allocator) Allocator.Error!Diagnostic {
+ return .{
+ .location = try self.location.clone(alloc),
+ .key = try alloc.dupeZ(u8, self.key),
+ .message = try alloc.dupeZ(u8, self.message),
+ };
+ }
+};
+
+/// The possible locations for a diagnostic message. This is used
+/// to provide context for the message.
+pub const Location = union(enum) {
+ none,
+ cli: usize,
+ file: struct {
+ path: []const u8,
+ line: usize,
+ },
+
+ pub const Key = @typeInfo(Location).@"union".tag_type.?;
+
+ pub fn fromIter(iter: anytype, alloc: Allocator) Allocator.Error!Location {
+ const Iter = t: {
+ const T = @TypeOf(iter);
+ break :t switch (@typeInfo(T)) {
+ .pointer => |v| v.child,
+ .@"struct" => T,
+ else => return .none,
+ };
+ };
+
+ if (!@hasDecl(Iter, "location")) return .none;
+ return (try iter.location(alloc)) orelse .none;
+ }
+
+ pub fn clone(self: *const Location, alloc: Allocator) Allocator.Error!Location {
+ return switch (self.*) {
+ .none,
+ .cli,
+ => self.*,
+
+ .file => |v| .{ .file = .{
+ .path = try alloc.dupe(u8, v.path),
+ .line = v.line,
+ } },
+ };
+ }
+};
+
+/// A list of diagnostics. The "_diagnostics" field must be this type
+/// for diagnostics to be supported. If this field is an incorrect type
+/// a compile-time error will be raised.
+///
+/// This is implemented as a simple wrapper around an array list
+/// so that we can inject some logic around adding diagnostics
+/// and potentially in the future structure them differently.
+pub const DiagnosticList = struct {
+ /// The list of diagnostics.
+ list: std.ArrayListUnmanaged(Diagnostic) = .{},
+
+ /// Precomputed data for diagnostics. This is used specifically
+ /// when we build libghostty so that we can precompute the messages
+ /// and return them via the C API without allocating memory at
+ /// call time.
+ precompute: Precompute = precompute_init,
+
+ const precompute_enabled = switch (build_config.artifact) {
+ // We enable precompute for tests so that the logic is
+ // semantically analyzed and run.
+ .exe, .wasm_module => builtin.is_test,
+
+ // We specifically want precompute for libghostty.
+ .lib => true,
+ };
+
+ const Precompute = if (precompute_enabled) struct {
+ messages: std.ArrayListUnmanaged([:0]const u8) = .{},
+
+ pub fn clone(
+ self: *const Precompute,
+ alloc: Allocator,
+ ) Allocator.Error!Precompute {
+ var result: Precompute = .{};
+ try result.messages.ensureTotalCapacity(alloc, self.messages.items.len);
+ for (self.messages.items) |msg| {
+ result.messages.appendAssumeCapacity(
+ try alloc.dupeZ(u8, msg),
+ );
+ }
+ return result;
+ }
+ } else void;
+
+ const precompute_init: Precompute = if (precompute_enabled) .{} else {};
+
+ pub fn clone(
+ self: *const DiagnosticList,
+ alloc: Allocator,
+ ) Allocator.Error!DiagnosticList {
+ var result: DiagnosticList = .{};
+
+ try result.list.ensureTotalCapacity(alloc, self.list.items.len);
+ for (self.list.items) |*diag| result.list.appendAssumeCapacity(
+ try diag.clone(alloc),
+ );
+
+ if (comptime precompute_enabled) {
+ result.precompute = try self.precompute.clone(alloc);
+ }
+
+ return result;
+ }
+
+ pub fn append(
+ self: *DiagnosticList,
+ alloc: Allocator,
+ diag: Diagnostic,
+ ) Allocator.Error!void {
+ try self.list.append(alloc, diag);
+ errdefer _ = self.list.pop();
+
+ if (comptime precompute_enabled) {
+ var stream: std.Io.Writer.Allocating = .init(alloc);
+ defer stream.deinit();
+ diag.format(&stream.writer) catch |err| switch (err) {
+ // WriteFailed in this instance can only mean an OOM
+ error.WriteFailed => return error.OutOfMemory,
+ };
+
+ const owned: [:0]const u8 = try stream.toOwnedSliceSentinel(0);
+ errdefer alloc.free(owned);
+
+ try self.precompute.messages.append(alloc, owned);
+ errdefer _ = self.precompute.messages.pop();
+
+ assert(self.precompute.messages.items.len == self.list.items.len);
+ }
+ }
+
+ pub fn empty(self: *const DiagnosticList) bool {
+ return self.list.items.len == 0;
+ }
+
+ pub fn items(self: *const DiagnosticList) []const Diagnostic {
+ return self.list.items;
+ }
+
+ /// Returns true if there are any diagnostics for the given
+ /// location type.
+ pub fn containsLocation(
+ self: *const DiagnosticList,
+ location: Location.Key,
+ ) bool {
+ for (self.list.items) |diag| {
+ if (diag.location == location) return true;
+ }
+
+ return false;
+ }
+};
diff --git a/vendor/libghostty-vt/src/cli/edit_config.zig b/vendor/libghostty-vt/src/cli/edit_config.zig
new file mode 100644
index 00000000..c08651a0
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/edit_config.zig
@@ -0,0 +1,180 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = @import("../quirks.zig").inlineAssert;
+const args = @import("args.zig");
+const Allocator = std.mem.Allocator;
+const Action = @import("ghostty.zig").Action;
+const configpkg = @import("../config.zig");
+const internal_os = @import("../os/main.zig");
+const Config = configpkg.Config;
+
+pub const Options = struct {
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `edit-config` command opens the Ghostty configuration file in the
+/// editor specified by the `$VISUAL` or `$EDITOR` environment variables.
+///
+/// IMPORTANT: This command will not reload the configuration after
+/// editing. You will need to manually reload the configuration using the
+/// application menu, configured keybind, or by restarting Ghostty. We
+/// plan to auto-reload in the future, but Ghostty isn't capable of
+/// this yet.
+///
+/// The filepath opened is the default user-specific configuration
+/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`.
+/// On macOS, this may also be located at
+/// `~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.
+/// On macOS, whichever path exists and is non-empty will be prioritized,
+/// prioritizing the Application Support directory if neither are
+/// non-empty.
+///
+/// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
+/// if both are set. If neither are set, it will print an error
+/// and exit.
+pub fn run(alloc: Allocator) !u8 {
+ // Implementation note (by @mitchellh): I do proper memory cleanup
+ // throughout this command, even though we plan on doing `exec`.
+ // I do this out of good hygiene in case we ever change this to
+ // not using `exec` anymore and because this command isn't performance
+ // critical where setting up the defer cleanup is a problem.
+
+ var buffer: [1024]u8 = undefined;
+ var stderr_writer = std.fs.File.stderr().writer(&buffer);
+ const stderr = &stderr_writer.interface;
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ const result = runInner(alloc, stderr);
+ // Flushing *shouldn't* fail but...
+ stderr.flush() catch {};
+ return result;
+}
+
+fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 {
+ // We load the configuration once because that will write our
+ // default configuration files to disk. We don't use the config.
+ var config = try Config.load(alloc);
+ defer config.deinit();
+
+ // Find the preferred path.
+ const path = try configpkg.preferredDefaultFilePath(alloc);
+ defer alloc.free(path);
+
+ // We don't currently support Windows because we use the exec syscall.
+ if (comptime builtin.os.tag == .windows) {
+ try stderr.print(
+ \\The `ghostty +edit-config` command is not supported on Windows.
+ \\Please edit the configuration file manually at the following path:
+ \\
+ \\{s}
+ \\
+ ,
+ .{path},
+ );
+ return 1;
+ }
+
+ // Get our editor
+ const get_env_: ?internal_os.GetEnvResult = env: {
+ // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
+ if (try internal_os.getenv(alloc, "VISUAL")) |v| {
+ if (v.value.len > 0) break :env v;
+ v.deinit(alloc);
+ }
+
+ if (try internal_os.getenv(alloc, "EDITOR")) |v| {
+ if (v.value.len > 0) break :env v;
+ v.deinit(alloc);
+ }
+
+ break :env null;
+ };
+ defer if (get_env_) |v| v.deinit(alloc);
+ const editor: []const u8 = if (get_env_) |v| v.value else "";
+
+ // If we don't have `$EDITOR` set then we can't do anything
+ // but we can still print a helpful message.
+ if (editor.len == 0) {
+ try stderr.print(
+ \\The $EDITOR or $VISUAL environment variable is not set or is empty.
+ \\This environment variable is required to edit the Ghostty configuration
+ \\via this CLI command.
+ \\
+ \\Please set the environment variable to your preferred terminal
+ \\text editor and try again.
+ \\
+ \\If you prefer to edit the configuration file another way,
+ \\you can find the configuration file at the following path:
+ \\
+ \\
+ ,
+ .{},
+ );
+
+ // Output the path using the OSC8 sequence so that it is linked.
+ try stderr.print(
+ "\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n",
+ .{ path, path },
+ );
+
+ return 1;
+ }
+
+ const command = command: {
+ var buffer: std.io.Writer.Allocating = .init(alloc);
+ defer buffer.deinit();
+ const writer = &buffer.writer;
+ try writer.writeAll(editor);
+ try writer.writeByte(' ');
+ {
+ var sh: internal_os.ShellEscapeWriter = .init(writer);
+ try sh.writer.writeAll(path);
+ try sh.writer.flush();
+ }
+ try writer.flush();
+ break :command try buffer.toOwnedSliceSentinel(0);
+ };
+ defer alloc.free(command);
+
+ // We require libc because we want to use std.c.environ for envp
+ // and not have to build that ourselves. We can remove this
+ // limitation later but Ghostty already heavily requires libc
+ // so this is not a big deal.
+ comptime assert(builtin.link_libc);
+
+ const err = std.posix.execvpeZ(
+ "/bin/sh",
+ &.{ "/bin/sh", "-c", command },
+ std.c.environ,
+ );
+
+ // If we reached this point then exec failed.
+ try stderr.print(
+ \\Failed to execute the editor. Error code={}.
+ \\
+ \\This is usually due to the executable path not existing, invalid
+ \\permissions, or the shell environment not being set up
+ \\correctly.
+ \\
+ \\Editor: {s}
+ \\Path: {s}
+ \\
+ , .{ err, editor, path });
+ return 1;
+}
diff --git a/vendor/libghostty-vt/src/cli/explain_config.zig b/vendor/libghostty-vt/src/cli/explain_config.zig
new file mode 100644
index 00000000..4f034afe
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/explain_config.zig
@@ -0,0 +1,151 @@
+const std = @import("std");
+const args = @import("args.zig");
+const Allocator = std.mem.Allocator;
+const Action = @import("ghostty.zig").Action;
+const help_strings = @import("help_strings");
+const Config = @import("../config/Config.zig");
+const ConfigKey = @import("../config/key.zig").Key;
+const KeybindAction = @import("../input/Binding.zig").Action;
+const Pager = @import("Pager.zig");
+
+pub const Options = struct {
+ /// The config option to explain. For example:
+ ///
+ /// ghostty +explain-config --option=font-size
+ option: ?[]const u8 = null,
+
+ /// The keybind action to explain. For example:
+ ///
+ /// ghostty +explain-config --keybind=copy_to_clipboard
+ keybind: ?[]const u8 = null,
+
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `explain-config` command prints the documentation for a single
+/// Ghostty configuration option or keybind action.
+///
+/// Examples:
+///
+/// ghostty +explain-config font-size
+/// ghostty +explain-config copy_to_clipboard
+/// ghostty +explain-config --option=font-size
+/// ghostty +explain-config --keybind=copy_to_clipboard
+///
+/// Flags:
+///
+/// * `--option`: The name of the configuration option to explain.
+/// * `--keybind`: The name of the keybind action to explain.
+/// * `--no-pager`: Disable automatic paging of output.
+pub fn run(alloc: Allocator) !u8 {
+ var option_name: ?[]const u8 = null;
+ var keybind_name: ?[]const u8 = null;
+ var positional: ?[]const u8 = null;
+ var no_pager: bool = false;
+
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ defer if (option_name) |s| alloc.free(s);
+ defer if (keybind_name) |s| alloc.free(s);
+ defer if (positional) |s| alloc.free(s);
+
+ while (iter.next()) |arg| {
+ if (std.mem.startsWith(u8, arg, "--option=")) {
+ option_name = try alloc.dupe(u8, arg["--option=".len..]);
+ } else if (std.mem.startsWith(u8, arg, "--keybind=")) {
+ keybind_name = try alloc.dupe(u8, arg["--keybind=".len..]);
+ } else if (std.mem.eql(u8, arg, "--no-pager")) {
+ no_pager = true;
+ } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
+ return Action.help_error;
+ } else if (!std.mem.startsWith(u8, arg, "-")) {
+ positional = try alloc.dupe(u8, arg);
+ }
+ }
+
+ // Resolve what to look up. Explicit flags go directly to their
+ // respective lookup. A bare positional argument tries config
+ // options first, then keybind actions as a fallback.
+ const name = keybind_name orelse option_name orelse positional orelse {
+ var stderr: std.fs.File = .stderr();
+ var buffer: [4096]u8 = undefined;
+ var stderr_writer = stderr.writer(&buffer);
+ try stderr_writer.interface.writeAll("Usage: ghostty +explain-config <option>\n");
+ try stderr_writer.interface.writeAll(" ghostty +explain-config --option=<option>\n");
+ try stderr_writer.interface.writeAll(" ghostty +explain-config --keybind=<action>\n");
+ try stderr_writer.end();
+ return 1;
+ };
+
+ const text = if (keybind_name != null)
+ explainKeybind(name)
+ else if (option_name != null)
+ explainOption(name)
+ else
+ explainOption(name) orelse explainKeybind(name);
+
+ var pager: Pager = if (!no_pager) .init(alloc) else .{};
+ defer pager.deinit();
+ var buffer: [4096]u8 = undefined;
+ const writer = pager.writer(&buffer);
+
+ if (text) |t| {
+ try writer.writeAll(t);
+ try writer.writeAll("\n");
+ } else {
+ try writer.writeAll("Unknown: '");
+ try writer.writeAll(name);
+ try writer.writeAll("'.\n");
+ try writer.flush();
+ return 1;
+ }
+
+ try writer.flush();
+ return 0;
+}
+
+fn explainOption(name: []const u8) ?[]const u8 {
+ const key = std.meta.stringToEnum(ConfigKey, name) orelse return null;
+ return switch (key) {
+ inline else => |tag| {
+ const field_name = @tagName(tag);
+ return if (@hasDecl(help_strings.Config, field_name))
+ @field(help_strings.Config, field_name)
+ else
+ null;
+ },
+ };
+}
+
+fn explainKeybind(name: []const u8) ?[]const u8 {
+ const tag = std.meta.stringToEnum(std.meta.Tag(KeybindAction), name) orelse return null;
+ return switch (tag) {
+ inline else => |t| {
+ const field_name = @tagName(t);
+ return if (@hasDecl(help_strings.KeybindAction, field_name))
+ @field(help_strings.KeybindAction, field_name)
+ else
+ null;
+ },
+ };
+}
+
+test "explain" {
+ // Config options
+ try std.testing.expect(explainOption("font-size") != null);
+ try std.testing.expect(explainOption("copy_to_clipboard") == null);
+ try std.testing.expect(explainOption("unknown-option") == null);
+
+ // Keybind actions
+ try std.testing.expect(explainKeybind("copy_to_clipboard") != null);
+ try std.testing.expect(explainKeybind("font-size") == null);
+ try std.testing.expect(explainKeybind("unknown_keybind") == null);
+}
diff --git a/vendor/libghostty-vt/src/cli/ghostty.zig b/vendor/libghostty-vt/src/cli/ghostty.zig
new file mode 100644
index 00000000..3acb9004
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/ghostty.zig
@@ -0,0 +1,308 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const help_strings = @import("help_strings");
+const actionpkg = @import("action.zig");
+const SpecialCase = actionpkg.SpecialCase;
+
+const list_fonts = @import("list_fonts.zig");
+const help = @import("help.zig");
+const version = @import("version.zig");
+const list_keybinds = @import("list_keybinds.zig");
+const list_themes = @import("list_themes.zig");
+const list_colors = @import("list_colors.zig");
+const list_actions = @import("list_actions.zig");
+const ssh_cache = @import("ssh_cache.zig");
+const edit_config = @import("edit_config.zig");
+const show_config = @import("show_config.zig");
+const explain_config = @import("explain_config.zig");
+const validate_config = @import("validate_config.zig");
+const crash_report = @import("crash_report.zig");
+const show_face = @import("show_face.zig");
+const boo = @import("boo.zig");
+const new_window = @import("new_window.zig");
+
+/// Special commands that can be invoked via CLI flags. These are all
+/// invoked by using `+<action>` as a CLI flag. The only exception is
+/// "version" which can be invoked additionally with `--version`.
+pub const Action = enum {
+ /// Output the version and exit
+ version,
+
+ /// Output help information for the CLI or configuration
+ help,
+
+ /// List available fonts
+ @"list-fonts",
+
+ /// List available keybinds
+ @"list-keybinds",
+
+ /// List available themes
+ @"list-themes",
+
+ /// List named RGB colors
+ @"list-colors",
+
+ /// List keybind actions
+ @"list-actions",
+
+ /// Manage SSH terminfo cache for automatic remote host setup
+ @"ssh-cache",
+
+ /// Edit the config file in the configured terminal editor.
+ @"edit-config",
+
+ /// Dump the config to stdout
+ @"show-config",
+
+ /// Explain a single config option
+ @"explain-config",
+
+ // Validate passed config file
+ @"validate-config",
+
+ // Show which font face Ghostty loads a codepoint from.
+ @"show-face",
+
+ // List, (eventually) view, and (eventually) send crash reports.
+ @"crash-report",
+
+ // Boo!
+ boo,
+
+ // Use IPC to tell the running Ghostty to open a new window.
+ @"new-window",
+
+ pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) {
+ // If we see a "-e" and we haven't seen a command yet, then
+ // we are done looking for commands. This special case enables
+ // `ghostty -e ghostty +command`. If we've seen a command we
+ // still want to keep looking because
+ // `ghostty +command -e +command` is invalid.
+ if (std.mem.eql(u8, arg, "-e")) return .abort_if_no_action;
+
+ // Special case, --version always outputs the version no
+ // matter what, no matter what other args exist.
+ if (std.mem.eql(u8, arg, "--version")) {
+ return .{ .action = .version };
+ }
+
+ // --help matches "help" but if a subcommand is specified
+ // then we match the subcommand.
+ if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
+ return .{ .fallback = .help };
+ }
+
+ return null;
+ }
+
+ /// This should be returned by actions that want to print the help text.
+ pub const help_error = error.ActionHelpRequested;
+
+ /// Run the action. This returns the exit code to exit with.
+ pub fn run(self: Action, alloc: Allocator) !u8 {
+ return self.runMain(alloc) catch |err| switch (err) {
+ // If help is requested, then we use some comptime trickery
+ // to find this action in the help strings and output that.
+ help_error => err: {
+ inline for (@typeInfo(Action).@"enum".fields) |field| {
+ // Future note: for now we just output the help text directly
+ // to stdout. In the future we can style this much prettier
+ // for all commands by just changing this one place.
+
+ if (std.mem.eql(u8, field.name, @tagName(self))) {
+ var buffer: [1024]u8 = undefined;
+ var stdout_writer = std.fs.File.stdout().writer(&buffer);
+ const stdout = &stdout_writer.interface;
+ const text = @field(help_strings.Action, field.name) ++ "\n";
+ stdout.writeAll(text) catch |write_err| {
+ std.log.warn("failed to write help text: {}\n", .{write_err});
+ break :err 1;
+ };
+ stdout.flush() catch |flush_err| {
+ std.log.warn("failed to flush help text: {}\n", .{flush_err});
+ break :err 1;
+ };
+
+ break :err 0;
+ }
+ }
+
+ break :err err;
+ },
+ else => err,
+ };
+ }
+
+ fn runMain(self: Action, alloc: Allocator) !u8 {
+ return switch (self) {
+ .version => try version.run(alloc),
+ .help => try help.run(alloc),
+ .@"list-fonts" => try list_fonts.run(alloc),
+ .@"list-keybinds" => try list_keybinds.run(alloc),
+ .@"list-themes" => try list_themes.run(alloc),
+ .@"list-colors" => try list_colors.run(alloc),
+ .@"list-actions" => try list_actions.run(alloc),
+ .@"ssh-cache" => try ssh_cache.run(alloc),
+ .@"edit-config" => try edit_config.run(alloc),
+ .@"show-config" => try show_config.run(alloc),
+ .@"explain-config" => try explain_config.run(alloc),
+ .@"validate-config" => try validate_config.run(alloc),
+ .@"crash-report" => try crash_report.run(alloc),
+ .@"show-face" => try show_face.run(alloc),
+ .boo => try boo.run(alloc),
+ .@"new-window" => try new_window.run(alloc),
+ };
+ }
+
+ /// Returns the filename associated with an action. This is a relative
+ /// path from the root src/ directory.
+ pub fn file(comptime self: Action) []const u8 {
+ comptime {
+ const filename = filename: {
+ const tag = @tagName(self);
+ var filename: [tag.len]u8 = undefined;
+ _ = std.mem.replace(u8, tag, "-", "_", &filename);
+ break :filename &filename;
+ };
+
+ return "cli/" ++ filename ++ ".zig";
+ }
+ }
+
+ /// Returns the options of action. Supports generating shell completions
+ /// without duplicating the mapping from Action to relevant Option
+ /// @import(..) declaration.
+ pub fn options(comptime self: Action) type {
+ comptime {
+ return switch (self) {
+ .version => version.Options,
+ .help => help.Options,
+ .@"list-fonts" => list_fonts.Options,
+ .@"list-keybinds" => list_keybinds.Options,
+ .@"list-themes" => list_themes.Options,
+ .@"list-colors" => list_colors.Options,
+ .@"list-actions" => list_actions.Options,
+ .@"ssh-cache" => ssh_cache.Options,
+ .@"edit-config" => edit_config.Options,
+ .@"show-config" => show_config.Options,
+ .@"explain-config" => explain_config.Options,
+ .@"validate-config" => validate_config.Options,
+ .@"crash-report" => crash_report.Options,
+ .@"show-face" => show_face.Options,
+ .boo => boo.Options,
+ .@"new-window" => new_window.Options,
+ };
+ }
+ }
+};
+
+test "parse action none" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--a=42 --b --b-f=false",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action == null);
+}
+
+test "parse action version" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--a=42 --b --b-f=false --version",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action.? == .version);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--version --a=42 --b --b-f=false",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action.? == .version);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--c=84 --d --version --a=42 --b --b-f=false",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action.? == .version);
+ }
+}
+
+test "parse action plus" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--a=42 --b --b-f=false +version",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action.? == .version);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+version --a=42 --b --b-f=false",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action.? == .version);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--c=84 --d +version --a=42 --b --b-f=false",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action.? == .version);
+ }
+}
+
+test "parse action plus ignores -e" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "--a=42 -e +version",
+ );
+ defer iter.deinit();
+ const action = try actionpkg.detectIter(Action, &iter);
+ try testing.expect(action == null);
+ }
+
+ {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(
+ alloc,
+ "+list-fonts --a=42 -e +version",
+ );
+ defer iter.deinit();
+ try testing.expectError(
+ actionpkg.DetectError.MultipleActions,
+ actionpkg.detectIter(Action, &iter),
+ );
+ }
+}
diff --git a/vendor/libghostty-vt/src/cli/help.zig b/vendor/libghostty-vt/src/cli/help.zig
new file mode 100644
index 00000000..a2b4dde8
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/help.zig
@@ -0,0 +1,78 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+
+// Note that this options struct doesn't implement the `help` decl like other
+// actions. That is because the help command is special and wants to handle its
+// own logic around help detection.
+pub const Options = struct {
+ /// This must be registered so that it isn't an error to pass `--help`
+ help: bool = false,
+
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+};
+
+/// The `help` command shows general help about Ghostty. Recognized as either
+/// `-h, `--help`, or like other actions `+help`.
+///
+/// You can also specify `--help` or `-h` along with any action such as
+/// `+list-themes` to see help for a specific action.
+pub fn run(alloc: Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ var buffer: [2048]u8 = undefined;
+ var stdout_writer = std.fs.File.stdout().writer(&buffer);
+ const stdout = &stdout_writer.interface;
+ try stdout.writeAll(
+ \\Usage: ghostty [+action] [options]
+ \\
+ \\Run the Ghostty terminal emulator or a specific helper action.
+ \\
+ \\If no `+action` is specified, run the Ghostty terminal emulator.
+ \\All configuration keys are available as command line options.
+ \\To specify a configuration key, use the `--<key>=<value>` syntax
+ \\where key and value are the same format you'd put into a configuration
+ \\file. For example, `--font-size=12` or `--font-family="Fira Code"`.
+ \\
+ \\To see a list of all available configuration options, please see
+ \\the `src/config/Config.zig` file. A future update will allow seeing
+ \\the list of configuration options from the command line.
+ \\
+ \\A special command line argument `-e <command>` can be used to run
+ \\the specific command inside the terminal emulator. For example,
+ \\`ghostty -e top` will run the `top` command inside the terminal.
+ \\
+ \\On macOS, launching the terminal emulator from the CLI is not
+ \\supported and only actions are supported. Use `open -na Ghostty.app`
+ \\instead, or `open -na ghostty.app --args --foo=bar --baz=quz` to pass
+ \\arguments.
+ \\
+ \\Available actions:
+ \\
+ \\
+ );
+
+ inline for (@typeInfo(Action).@"enum".fields) |field| {
+ try stdout.print(" +{s}\n", .{field.name});
+ }
+
+ try stdout.writeAll(
+ \\
+ \\Specify `+<action> --help` to see the help for a specific action,
+ \\where `<action>` is one of actions listed above.
+ \\
+ );
+ try stdout.flush();
+
+ return 0;
+}
diff --git a/vendor/libghostty-vt/src/cli/list_actions.zig b/vendor/libghostty-vt/src/cli/list_actions.zig
new file mode 100644
index 00000000..aaef1195
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/list_actions.zig
@@ -0,0 +1,52 @@
+const std = @import("std");
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+const Allocator = std.mem.Allocator;
+const helpgen_actions = @import("../input/helpgen_actions.zig");
+
+pub const Options = struct {
+ /// If `true`, print out documentation about the action associated with the
+ /// keybinds.
+ docs: bool = false,
+
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `list-actions` command is used to list all the available keybind
+/// actions for Ghostty. These are distinct from the CLI Actions which can
+/// be listed via `+help`
+///
+/// Flags:
+///
+/// * `--docs`: will print out the documentation for each action.
+pub fn run(alloc: Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ var stdout: std.fs.File = .stdout();
+ var buffer: [4096]u8 = undefined;
+ var stdout_writer = stdout.writer(&buffer);
+ try helpgen_actions.generate(
+ &stdout_writer.interface,
+ .plaintext,
+ opts.docs,
+ std.heap.page_allocator,
+ );
+ try stdout_writer.interface.flush();
+
+ return 0;
+}
diff --git a/vendor/libghostty-vt/src/cli/list_colors.zig b/vendor/libghostty-vt/src/cli/list_colors.zig
new file mode 100644
index 00000000..50c12a69
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/list_colors.zig
@@ -0,0 +1,209 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const Allocator = std.mem.Allocator;
+const Action = @import("ghostty.zig").Action;
+const args = @import("args.zig");
+const x11_color = @import("../terminal/main.zig").x11_color;
+const vaxis = @import("vaxis");
+const tui = @import("tui.zig");
+
+pub const Options = struct {
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// If `true`, print without formatting even if printing to a tty
+ plain: bool = false,
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `list-colors` command is used to list all the named RGB colors in
+/// Ghostty.
+///
+/// Flags:
+///
+/// * `--plain`: will disable formatting and make the output more
+/// friendly for Unix tooling. This is default when not printing to a tty.
+pub fn run(alloc: Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ var keys: std.ArrayList([]const u8) = .empty;
+ defer keys.deinit(alloc);
+ for (x11_color.map.keys()) |key| try keys.append(alloc, key);
+
+ std.mem.sortUnstable([]const u8, keys.items, {}, struct {
+ fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool {
+ return std.ascii.orderIgnoreCase(lhs, rhs) == .lt;
+ }
+ }.lessThan);
+
+ // Despite being under the posix namespace, this also works on Windows as of zig 0.13.0
+ var stdout: std.fs.File = .stdout();
+ if (tui.can_pretty_print and !opts.plain and std.posix.isatty(stdout.handle)) {
+ var arena = std.heap.ArenaAllocator.init(alloc);
+ defer arena.deinit();
+ return prettyPrint(arena.allocator(), keys.items);
+ } else {
+ var buffer: [4096]u8 = undefined;
+ var stdout_writer = stdout.writer(&buffer);
+ const writer = &stdout_writer.interface;
+ for (keys.items) |name| {
+ const rgb = x11_color.map.get(name).?;
+ try writer.print("{s} = #{x:0>2}{x:0>2}{x:0>2}\n", .{
+ name,
+ rgb.r,
+ rgb.g,
+ rgb.b,
+ });
+ }
+ }
+
+ return 0;
+}
+
+fn prettyPrint(alloc: Allocator, keys: [][]const u8) !u8 {
+ // Set up vaxis
+ var buf: [1024]u8 = undefined;
+ var tty = try vaxis.Tty.init(&buf);
+ defer tty.deinit();
+ var vx = try vaxis.init(alloc, .{});
+ defer vx.deinit(alloc, tty.writer());
+
+ // We know we are ghostty, so let's enable mode 2027. Vaxis normally does this but you need an
+ // event loop to auto-enable it.
+ vx.caps.unicode = .unicode;
+ try tty.writer().writeAll(vaxis.ctlseqs.unicode_set);
+ defer tty.writer().writeAll(vaxis.ctlseqs.unicode_reset) catch {};
+
+ const winsize: vaxis.Winsize = switch (builtin.os.tag) {
+ // We use some default, it doesn't really matter for what
+ // we're doing because we don't do any wrapping.
+ .windows => .{
+ .rows = 24,
+ .cols = 120,
+ .x_pixel = 1024,
+ .y_pixel = 768,
+ },
+
+ else => try vaxis.Tty.getWinsize(tty.fd),
+ };
+ try vx.resize(alloc, tty.writer(), winsize);
+
+ const win = vx.window();
+
+ var max_name_len: usize = 0;
+ for (keys) |name| {
+ if (name.len > max_name_len) max_name_len = name.len;
+ }
+
+ // max name length plus " = #RRGGBB XX" plus " " gutter between columns
+ const column_size = max_name_len + 15;
+ // add two to take into account lack of gutter after last column
+ const columns: usize = @divFloor(win.width + 2, column_size);
+
+ var i: usize = 0;
+ const step = @divFloor(keys.len, columns) + 1;
+ while (i < step) : (i += 1) {
+ win.clear();
+
+ var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false };
+
+ for (0..columns) |j| {
+ const k = i + (step * j);
+ if (k >= keys.len) continue;
+
+ const name = keys[k];
+ const rgb = x11_color.map.get(name).?;
+
+ const style1: vaxis.Style = .{
+ .fg = .{
+ .rgb = .{ rgb.r, rgb.g, rgb.b },
+ },
+ };
+ const style2: vaxis.Style = .{
+ .fg = .{
+ .rgb = .{ rgb.r, rgb.g, rgb.b },
+ },
+ .bg = .{
+ .rgb = .{ rgb.r, rgb.g, rgb.b },
+ },
+ };
+
+ // name of the color
+ result = win.printSegment(
+ .{ .text = name },
+ .{ .col_offset = result.col },
+ );
+ // push the color data to the end of the column
+ for (0..max_name_len - name.len) |_| {
+ result = win.printSegment(
+ .{ .text = " " },
+ .{ .col_offset = result.col },
+ );
+ }
+ result = win.printSegment(
+ .{ .text = " = " },
+ .{ .col_offset = result.col },
+ );
+ // rgb triple
+ result = win.printSegment(.{
+ .text = try std.fmt.allocPrint(
+ alloc,
+ "#{x:0>2}{x:0>2}{x:0>2}",
+ .{
+ rgb.r, rgb.g, rgb.b,
+ },
+ ),
+ .style = style1,
+ }, .{ .col_offset = result.col });
+ result = win.printSegment(
+ .{ .text = " " },
+ .{ .col_offset = result.col },
+ );
+ // colored block
+ result = win.printSegment(
+ .{
+ .text = " ",
+ .style = style2,
+ },
+ .{ .col_offset = result.col },
+ );
+ // add the gutter if needed
+ if (j + 1 < columns) {
+ result = win.printSegment(
+ .{
+ .text = " ",
+ },
+ .{ .col_offset = result.col },
+ );
+ }
+ }
+
+ // clear the rest of the line
+ while (result.col != 0) {
+ result = win.printSegment(
+ .{
+ .text = " ",
+ },
+ .{ .col_offset = result.col },
+ );
+ }
+
+ // output the data
+ try vx.prettyPrint(tty.writer());
+ }
+
+ return 0;
+}
diff --git a/vendor/libghostty-vt/src/cli/list_fonts.zig b/vendor/libghostty-vt/src/cli/list_fonts.zig
new file mode 100644
index 00000000..396c4e8a
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/list_fonts.zig
@@ -0,0 +1,165 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const Action = @import("ghostty.zig").Action;
+const args = @import("args.zig");
+const font = @import("../font/main.zig");
+
+const log = std.log.scoped(.list_fonts);
+
+pub const Options = struct {
+ /// This is set by the CLI parser for deinit.
+ _arena: ?ArenaAllocator = null,
+
+ /// The font family to search for. If this is set, then only fonts
+ /// matching this family will be listed.
+ family: ?[:0]const u8 = null,
+
+ /// The style name to search for.
+ style: ?[:0]const u8 = null,
+
+ /// Font styles to search for. If this is set, then only fonts that
+ /// match the given styles will be listed.
+ bold: bool = false,
+ italic: bool = false,
+
+ pub fn deinit(self: *Options) void {
+ if (self._arena) |arena| arena.deinit();
+ self.* = undefined;
+ }
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `list-fonts` command is used to list all the available fonts for
+/// Ghostty. This uses the exact same font discovery mechanism Ghostty uses to
+/// find fonts to use.
+///
+/// When executed with no arguments, this will list all available fonts, sorted
+/// by family name, then font name. If a family name is given with `--family`,
+/// the sorting will be disabled and the results instead will be shown in the
+/// same priority order Ghostty would use to pick a font.
+///
+/// Flags:
+///
+/// * `--bold`: Filter results to specific bold styles. It is not guaranteed
+/// that only those styles are returned. They are only prioritized.
+///
+/// * `--italic`: Filter results to specific italic styles. It is not guaranteed
+/// that only those styles are returned. They are only prioritized.
+///
+/// * `--style`: Filter results based on the style string advertised by a font.
+/// It is not guaranteed that only those styles are returned. They are only
+/// prioritized.
+///
+/// * `--family`: Filter results to a specific font family. The family handling
+/// is identical to the `font-family` set of Ghostty configuration values, so
+/// this can be used to debug why your desired font may not be loading.
+pub fn run(alloc: Allocator) !u8 {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ return try runArgs(alloc, &iter);
+}
+
+fn runArgs(alloc_gpa: Allocator, argsIter: anytype) !u8 {
+ var config: Options = .{};
+ defer config.deinit();
+ try args.parse(Options, alloc_gpa, &config, argsIter);
+
+ // Use an arena for all our memory allocs
+ var arena = ArenaAllocator.init(alloc_gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Its possible to build Ghostty without font discovery!
+ if (comptime font.Discover == void) {
+ var buffer: [1024]u8 = undefined;
+ var stderr_writer = std.fs.File.stderr().writer(&buffer);
+ const stderr = &stderr_writer.interface;
+ try stderr.print(
+ \\Ghostty was built without a font discovery mechanism. This is a compile-time
+ \\option. Please review how Ghostty was built from source, contact the
+ \\maintainer to enable a font discovery mechanism, and try again.
+ ,
+ .{},
+ );
+ try stderr.flush();
+ return 1;
+ }
+
+ var buffer: [2048]u8 = undefined;
+ var stdout_writer = std.fs.File.stdout().writer(&buffer);
+ const stdout = &stdout_writer.interface;
+
+ // We'll be putting our fonts into a list categorized by family
+ // so it is easier to read the output.
+ var families: std.ArrayList([]const u8) = .empty;
+ var map: std.StringHashMap(std.ArrayListUnmanaged([]const u8)) = .init(alloc);
+
+ // Look up all available fonts
+ var disco = font.Discover.init();
+ defer disco.deinit();
+ var disco_it = try disco.discover(alloc, .{
+ .family = config.family,
+ .style = config.style,
+ .bold = config.bold,
+ .italic = config.italic,
+ .monospace = config.family == null,
+ });
+ defer disco_it.deinit();
+ while (try disco_it.next()) |face| {
+ var buf: [1024]u8 = undefined;
+
+ const family_buf = face.familyName(&buf) catch |err| {
+ log.err("failed to get font family name: {}", .{err});
+ continue;
+ };
+ const family = try alloc.dupe(u8, family_buf);
+
+ const full_name_buf = face.name(&buf) catch |err| {
+ log.err("failed to get font name: {}", .{err});
+ continue;
+ };
+ const full_name = try alloc.dupe(u8, full_name_buf);
+
+ const gop = try map.getOrPut(family);
+ if (!gop.found_existing) {
+ try families.append(alloc, family);
+ gop.value_ptr.* = .{};
+ }
+ try gop.value_ptr.append(alloc, full_name);
+ }
+
+ // Sort our keys.
+ if (config.family == null) {
+ std.mem.sortUnstable([]const u8, families.items, {}, struct {
+ fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool {
+ return std.mem.order(u8, lhs, rhs) == .lt;
+ }
+ }.lessThan);
+ }
+
+ // Output each
+ for (families.items) |family| {
+ const list = map.get(family) orelse continue;
+ if (list.items.len == 0) continue;
+ if (config.family == null) {
+ std.mem.sortUnstable([]const u8, list.items, {}, struct {
+ fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool {
+ return std.mem.order(u8, lhs, rhs) == .lt;
+ }
+ }.lessThan);
+ }
+
+ try stdout.print("{s}\n", .{family});
+ for (list.items) |item| try stdout.print(" {s}\n", .{item});
+ try stdout.print("\n", .{});
+ }
+
+ try stdout.flush();
+ return 0;
+}
diff --git a/vendor/libghostty-vt/src/cli/list_keybinds.zig b/vendor/libghostty-vt/src/cli/list_keybinds.zig
new file mode 100644
index 00000000..61050d0c
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/list_keybinds.zig
@@ -0,0 +1,435 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+const Arena = std.heap.ArenaAllocator;
+const Allocator = std.mem.Allocator;
+const configpkg = @import("../config.zig");
+const Config = configpkg.Config;
+const vaxis = @import("vaxis");
+const input = @import("../input.zig");
+const tui = @import("tui.zig");
+const Binding = input.Binding;
+
+pub const Options = struct {
+ /// If `true`, print out the default keybinds instead of the ones configured
+ /// in the config file.
+ default: bool = false,
+
+ /// If `true`, print out documentation about the action associated with the
+ /// keybinds.
+ docs: bool = false,
+
+ /// If `true`, print without formatting even if printing to a tty
+ plain: bool = false,
+
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `list-keybinds` command is used to list all the available keybinds for
+/// Ghostty.
+///
+/// When executed without any arguments this will list the current keybinds
+/// loaded by the config file. If no config file is found or there aren't any
+/// changes to the keybinds it will print out the default ones configured for
+/// Ghostty
+///
+/// Flags:
+///
+/// * `--default`: will print out all the default keybinds
+///
+/// * `--docs`: currently does nothing, intended to print out documentation
+/// about the action associated with the keybinds
+///
+/// * `--plain`: will disable formatting and make the output more
+/// friendly for Unix tooling. This is default when not printing to a tty.
+pub fn run(alloc: Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ var config = if (opts.default) try Config.default(alloc) else try Config.load(alloc);
+ defer config.deinit();
+
+ var buffer: [1024]u8 = undefined;
+ const stdout: std.fs.File = .stdout();
+ var stdout_writer = stdout.writer(&buffer);
+ const writer = &stdout_writer.interface;
+
+ if (tui.can_pretty_print and !opts.plain and stdout.isTty()) {
+ var arena = std.heap.ArenaAllocator.init(alloc);
+ defer arena.deinit();
+ return prettyPrint(arena.allocator(), config.keybind);
+ } else {
+ try config.keybind.formatEntryDocs(
+ configpkg.entryFormatter("keybind", writer),
+ opts.docs,
+ );
+ }
+
+ // Don't forget to flush!
+ try writer.flush();
+ return 0;
+}
+
+const TriggerNode = struct {
+ data: Binding.Trigger,
+ node: std.SinglyLinkedList.Node = .{},
+
+ pub fn get(node: *std.SinglyLinkedList.Node) *TriggerNode {
+ return @fieldParentPtr("node", node);
+ }
+};
+
+const ChordBinding = struct {
+ table_name: ?[]const u8 = null,
+ triggers: std.SinglyLinkedList,
+ actions: []const Binding.Action,
+
+ // Order keybinds based on various properties
+ // 1. Default bindings before table bindings (tables grouped at end)
+ // 2. Longest chord sequence
+ // 3. Most active modifiers
+ // 4. Alphabetically by active modifiers
+ // 5. Trigger key order
+ // 6. Within tables, sort by table name
+ // These properties propagate through chorded keypresses
+ //
+ // Adapted from Binding.lessThan
+ pub fn lessThan(_: void, lhs: ChordBinding, rhs: ChordBinding) bool {
+ const lhs_has_table = lhs.table_name != null;
+ const rhs_has_table = rhs.table_name != null;
+
+ if (lhs_has_table != rhs_has_table) {
+ return !lhs_has_table;
+ }
+
+ if (lhs_has_table) {
+ const table_cmp = std.mem.order(u8, lhs.table_name.?, rhs.table_name.?);
+ if (table_cmp != .eq) {
+ return table_cmp == .lt;
+ }
+ }
+
+ const lhs_len = lhs.triggers.len();
+ const rhs_len = rhs.triggers.len();
+
+ std.debug.assert(lhs_len != 0);
+ std.debug.assert(rhs_len != 0);
+
+ if (lhs_len != rhs_len) {
+ return lhs_len > rhs_len;
+ }
+
+ const lhs_count: usize = blk: {
+ var count: usize = 0;
+ var maybe_trigger = lhs.triggers.first;
+ while (maybe_trigger) |node| : (maybe_trigger = node.next) {
+ const trigger: *TriggerNode = .get(node);
+ if (trigger.data.mods.super) count += 1;
+ if (trigger.data.mods.ctrl) count += 1;
+ if (trigger.data.mods.shift) count += 1;
+ if (trigger.data.mods.alt) count += 1;
+ }
+ break :blk count;
+ };
+ const rhs_count: usize = blk: {
+ var count: usize = 0;
+ var maybe_trigger = rhs.triggers.first;
+ while (maybe_trigger) |node| : (maybe_trigger = node.next) {
+ const trigger: *TriggerNode = .get(node);
+ if (trigger.data.mods.super) count += 1;
+ if (trigger.data.mods.ctrl) count += 1;
+ if (trigger.data.mods.shift) count += 1;
+ if (trigger.data.mods.alt) count += 1;
+ }
+
+ break :blk count;
+ };
+
+ if (lhs_count != rhs_count)
+ return lhs_count > rhs_count;
+
+ {
+ var l_trigger = lhs.triggers.first;
+ var r_trigger = rhs.triggers.first;
+ while (l_trigger != null and r_trigger != null) {
+ const l_int = TriggerNode.get(l_trigger.?).data.mods.int();
+ const r_int = TriggerNode.get(r_trigger.?).data.mods.int();
+
+ if (l_int != r_int) {
+ return l_int > r_int;
+ }
+
+ l_trigger = l_trigger.?.next;
+ r_trigger = r_trigger.?.next;
+ }
+ }
+
+ var l_trigger = lhs.triggers.first;
+ var r_trigger = rhs.triggers.first;
+
+ while (l_trigger != null and r_trigger != null) {
+ // We want catch_all to sort last.
+ const lhs_key: c_int = blk: {
+ switch (TriggerNode.get(l_trigger.?).data.key) {
+ .physical => |key| break :blk @intFromEnum(key),
+ .unicode => |key| break :blk @intCast(key),
+ .catch_all => break :blk std.math.maxInt(c_int),
+ }
+ };
+ const rhs_key: c_int = blk: {
+ switch (TriggerNode.get(r_trigger.?).data.key) {
+ .physical => |key| break :blk @intFromEnum(key),
+ .unicode => |key| break :blk @intCast(key),
+ .catch_all => break :blk std.math.maxInt(c_int),
+ }
+ };
+
+ l_trigger = l_trigger.?.next;
+ r_trigger = r_trigger.?.next;
+
+ if (l_trigger == null or r_trigger == null) {
+ return lhs_key < rhs_key;
+ }
+
+ if (lhs_key != rhs_key) {
+ return lhs_key < rhs_key;
+ }
+ }
+
+ // The previous loop will always return something on its final iteration so we cannot
+ // reach this point
+ unreachable;
+ }
+};
+
+fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
+ // Set up vaxis
+ var buf: [1024]u8 = undefined;
+ var tty = try vaxis.Tty.init(&buf);
+ defer tty.deinit();
+ var vx = try vaxis.init(alloc, .{});
+ const writer = tty.writer();
+ defer vx.deinit(alloc, writer);
+
+ // We know we are ghostty, so let's enable mode 2027. Vaxis normally does this but you need an
+ // event loop to auto-enable it.
+ vx.caps.unicode = .unicode;
+ try writer.writeAll(vaxis.ctlseqs.unicode_set);
+ defer writer.writeAll(vaxis.ctlseqs.unicode_reset) catch {};
+
+ const winsize: vaxis.Winsize = switch (builtin.os.tag) {
+ // We use some default, it doesn't really matter for what
+ // we're doing because we don't do any wrapping.
+ .windows => .{
+ .rows = 24,
+ .cols = 120,
+ .x_pixel = 1024,
+ .y_pixel = 768,
+ },
+
+ else => try vaxis.Tty.getWinsize(tty.fd),
+ };
+ try vx.resize(alloc, writer, winsize);
+
+ const win = vx.window();
+
+ // Collect default bindings, recursively flattening chords
+ var iter = keybinds.set.bindings.iterator();
+ const default_bindings, var widest_chord = try iterateBindings(alloc, &iter, &win);
+
+ var bindings_list: std.ArrayList(ChordBinding) = .empty;
+ try bindings_list.appendSlice(alloc, default_bindings);
+
+ // Collect key table bindings
+ var widest_table_prefix: u16 = 0;
+ var table_iter = keybinds.tables.iterator();
+ while (table_iter.next()) |table_entry| {
+ const table_name = table_entry.key_ptr.*;
+ var binding_iter = table_entry.value_ptr.bindings.iterator();
+ const table_bindings, const table_width = try iterateBindings(alloc, &binding_iter, &win);
+ for (table_bindings) |*b| {
+ b.table_name = table_name;
+ }
+
+ try bindings_list.appendSlice(alloc, table_bindings);
+ widest_chord = @max(widest_chord, table_width);
+ widest_table_prefix = @max(widest_table_prefix, @as(u16, @intCast(win.gwidth(table_name) + win.gwidth("/"))));
+ }
+
+ const bindings = bindings_list.items;
+ std.mem.sort(ChordBinding, bindings, {}, ChordBinding.lessThan);
+
+ // Set up styles for each modifier
+ const super_style: vaxis.Style = .{ .fg = .{ .index = 1 } };
+ const ctrl_style: vaxis.Style = .{ .fg = .{ .index = 2 } };
+ const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } };
+ const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } };
+ const table_style: vaxis.Style = .{ .fg = .{ .index = 8 } };
+
+ // Print the list
+ for (bindings) |bind| {
+ win.clear();
+
+ var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false };
+
+ if (bind.table_name) |name| {
+ result = win.printSegment(
+ .{ .text = name, .style = table_style },
+ .{ .col_offset = result.col },
+ );
+ result = win.printSegment(.{ .text = "/", .style = table_style }, .{ .col_offset = result.col });
+ }
+
+ var maybe_trigger = bind.triggers.first;
+ while (maybe_trigger) |node| : (maybe_trigger = node.next) {
+ const trigger: *TriggerNode = .get(node);
+
+ if (trigger.data.mods.super) {
+ result = win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ if (trigger.data.mods.ctrl) {
+ result = win.printSegment(.{ .text = "ctrl ", .style = ctrl_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ if (trigger.data.mods.alt) {
+ result = win.printSegment(.{ .text = "alt ", .style = alt_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ if (trigger.data.mods.shift) {
+ result = win.printSegment(.{ .text = "shift", .style = shift_style }, .{ .col_offset = result.col });
+ result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
+ }
+ const key = switch (trigger.data.key) {
+ .physical => |k| try std.fmt.allocPrint(alloc, "{t}", .{k}),
+ .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}),
+ .catch_all => "catch_all",
+ };
+ result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
+
+ // Print a separator between chorded keys
+ if (trigger.node.next != null) {
+ result = win.printSegment(.{ .text = " > ", .style = .{ .bold = true, .fg = .{ .index = 6 } } }, .{ .col_offset = result.col });
+ }
+ }
+
+ var action_col: u16 = widest_table_prefix + widest_chord + 3;
+ for (bind.actions, 0..) |act, i| {
+ if (i > 0) {
+ const chain_result = win.printSegment(
+ .{ .text = ", ", .style = .{ .dim = true } },
+ .{ .col_offset = action_col },
+ );
+ action_col = chain_result.col;
+ }
+
+ const action = try std.fmt.allocPrint(alloc, "{f}", .{act});
+ // If our action has an argument, we print the argument in a different color
+ if (std.mem.indexOfScalar(u8, action, ':')) |idx| {
+ const print_result = win.print(&.{
+ .{ .text = action[0..idx] },
+ .{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
+ .{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
+ }, .{ .col_offset = action_col });
+ action_col = print_result.col;
+ } else {
+ const print_result = win.printSegment(
+ .{ .text = action },
+ .{ .col_offset = action_col },
+ );
+ action_col = print_result.col;
+ }
+ }
+ try vx.prettyPrint(writer);
+ }
+ try writer.flush();
+ return 0;
+}
+
+fn iterateBindings(
+ alloc: Allocator,
+ iter: anytype,
+ win: *const vaxis.Window,
+) !struct { []ChordBinding, u16 } {
+ var widest_chord: u16 = 0;
+ var bindings: std.ArrayList(ChordBinding) = .empty;
+ while (iter.next()) |bind| {
+ const width = blk: {
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ const t = bind.key_ptr.*;
+
+ if (t.mods.super) try buf.writer.print("super + ", .{});
+ if (t.mods.ctrl) try buf.writer.print("ctrl + ", .{});
+ if (t.mods.alt) try buf.writer.print("alt + ", .{});
+ if (t.mods.shift) try buf.writer.print("shift + ", .{});
+
+ switch (t.key) {
+ .physical => |k| try buf.writer.print("{t}", .{k}),
+ .unicode => |c| try buf.writer.print("{u}", .{c}),
+ .catch_all => try buf.writer.print("catch_all", .{}),
+ }
+
+ break :blk win.gwidth(buf.written());
+ };
+
+ switch (bind.value_ptr.*) {
+ .leader => |leader| {
+ // Recursively iterate on the set of bindings for this leader key
+ var n_iter = leader.bindings.iterator();
+ const sub_bindings, const max_width = try iterateBindings(alloc, &n_iter, win);
+
+ // Prepend the current keybind onto the list of sub-binds
+ for (sub_bindings) |*nb| {
+ const prepend_node = try alloc.create(TriggerNode);
+ prepend_node.* = .{ .data = bind.key_ptr.* };
+ nb.triggers.prepend(&prepend_node.node);
+ }
+
+ // Add the longest sub-bind width to the current bind width along with a padding
+ // of 5 for the ' > ' spacer
+ widest_chord = @max(widest_chord, width + max_width + 5);
+ try bindings.appendSlice(alloc, sub_bindings);
+ },
+ .leaf => |leaf| {
+ const node = try alloc.create(TriggerNode);
+ node.* = .{ .data = bind.key_ptr.* };
+
+ const actions = try alloc.alloc(Binding.Action, 1);
+ actions[0] = leaf.action;
+
+ widest_chord = @max(widest_chord, width);
+ try bindings.append(alloc, .{
+ .triggers = .{ .first = &node.node },
+ .actions = actions,
+ });
+ },
+ .leaf_chained => |leaf| {
+ const node = try alloc.create(TriggerNode);
+ node.* = .{ .data = bind.key_ptr.* };
+
+ widest_chord = @max(widest_chord, width);
+ try bindings.append(alloc, .{
+ .triggers = .{ .first = &node.node },
+ .actions = leaf.actions.items,
+ });
+ },
+ }
+ }
+
+ return .{ try bindings.toOwnedSlice(alloc), widest_chord };
+}
diff --git a/vendor/libghostty-vt/src/cli/list_themes.zig b/vendor/libghostty-vt/src/cli/list_themes.zig
new file mode 100644
index 00000000..42aff9d5
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/list_themes.zig
@@ -0,0 +1,1741 @@
+const std = @import("std");
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+const Config = @import("../config/Config.zig");
+const configpkg = @import("../config.zig");
+const themepkg = @import("../config/theme.zig");
+const tui = @import("tui.zig");
+const global_state = &@import("../global.zig").state;
+
+const vaxis = @import("vaxis");
+const zf = @import("zf");
+
+// When the number of filtered themes is less than or equal to this threshold,
+// the window position will be reset to 0 to show all results from the top.
+// This ensures better visibility for small result sets while maintaining
+// scroll position for larger lists.
+const SMALL_LIST_THRESHOLD = 10;
+
+const ColorScheme = enum { all, dark, light };
+
+pub const Options = struct {
+ /// If true, print the full path to the theme.
+ path: bool = false,
+
+ /// If true, force a plain list of themes.
+ plain: bool = false,
+
+ /// Specifies the color scheme of the themes to include in the list.
+ color: ColorScheme = .all,
+
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+const ThemeListElement = struct {
+ location: themepkg.Location,
+ path: []const u8,
+ theme: []const u8,
+ rank: ?f64 = null,
+
+ fn lessThan(_: void, lhs: @This(), rhs: @This()) bool {
+ // TODO: use Unicode-aware comparison
+ return std.ascii.orderIgnoreCase(lhs.theme, rhs.theme) == .lt;
+ }
+
+ pub fn toUri(self: *const ThemeListElement, alloc: std.mem.Allocator) ![]const u8 {
+ const uri = std.Uri{
+ .scheme = "file",
+ .host = .{ .raw = "" },
+ .path = .{ .raw = self.path },
+ };
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ errdefer buf.deinit();
+ try uri.writeToStream(
+ &buf.writer,
+ .{ .scheme = true, .authority = true, .path = true },
+ );
+ return buf.toOwnedSlice();
+ }
+};
+
+/// The `list-themes` command is used to preview or list all the available
+/// themes for Ghostty.
+///
+/// If this command is run from a TTY, a TUI preview of the themes will be
+/// shown. While in the preview, `F1` will bring up a help screen and `ESC` will
+/// exit the preview. Other keys that can be used to navigate the preview are
+/// listed in the help screen.
+///
+/// If this command is not run from a TTY, or the output is piped to another
+/// command, a plain list of theme names will be printed to the screen. A plain
+/// list can be forced using the `--plain` CLI flag.
+///
+/// Two different directories will be searched for themes.
+///
+/// The first directory is the `themes` subdirectory of your Ghostty
+/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or
+/// `~/.config/ghostty/themes`.
+///
+/// The second directory is the `themes` subdirectory of the Ghostty resources
+/// directory. Ghostty ships with a multitude of themes that will be installed
+/// into this directory. On macOS, this directory is the
+/// `Ghostty.app/Contents/Resources/ghostty/themes`. On Linux, this directory
+/// is the `share/ghostty/themes` (wherever you installed the Ghostty "share"
+/// directory). If you're running Ghostty from the source, this is the
+/// `zig-out/share/ghostty/themes` directory.
+///
+/// You can also set the `GHOSTTY_RESOURCES_DIR` environment variable to point
+/// to the resources directory.
+///
+/// Flags:
+///
+/// * `--path`: Show the full path to the theme.
+///
+/// * `--plain`: Force a plain listing of themes.
+///
+/// * `--color`: Specify the color scheme of the themes included in the list.
+/// This can be `dark`, `light`, or `all`. The default is `all`.
+pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(gpa_alloc);
+ defer iter.deinit();
+ try args.parse(Options, gpa_alloc, &opts, &iter);
+ }
+
+ var arena = std.heap.ArenaAllocator.init(gpa_alloc);
+ const alloc = arena.allocator();
+
+ var stdout_buf: [4096]u8 = undefined;
+ var stdout_file: std.fs.File = .stdout();
+ var stdout_writer = stdout_file.writer(&stdout_buf);
+ const stdout = &stdout_writer.interface;
+
+ var stderr_buf: [4096]u8 = undefined;
+ var stderr_writer = std.fs.File.stderr().writer(&stderr_buf);
+ const stderr = &stderr_writer.interface;
+
+ const resources_dir = global_state.resources_dir.app();
+ if (resources_dir == null)
+ try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++
+ "that Ghostty is installed correctly.\n", .{});
+
+ var count: usize = 0;
+
+ var themes: std.ArrayList(ThemeListElement) = .empty;
+
+ var it: themepkg.LocationIterator = .{ .arena_alloc = arena.allocator() };
+
+ while (try it.next()) |loc| {
+ var dir = std.fs.cwd().openDir(loc.dir, .{ .iterate = true }) catch |err| switch (err) {
+ error.FileNotFound => continue,
+ else => {
+ std.debug.print("error trying to open {s}: {}\n", .{ loc.dir, err });
+ continue;
+ },
+ };
+ defer dir.close();
+
+ var walker = dir.iterate();
+
+ while (try walker.next()) |entry| {
+ switch (entry.kind) {
+ .file, .sym_link => {
+ if (std.mem.eql(u8, entry.name, ".DS_Store"))
+ continue;
+ count += 1;
+
+ const path = try std.fs.path.join(alloc, &.{ loc.dir, entry.name });
+ try themes.append(alloc, .{
+ .path = path,
+ .location = loc.location,
+ .theme = try alloc.dupe(u8, entry.name),
+ });
+ },
+ else => {},
+ }
+ }
+ }
+
+ if (count == 0) {
+ try stderr.print("No themes found, check to make sure that the themes were installed correctly.", .{});
+ return 1;
+ }
+
+ std.mem.sortUnstable(ThemeListElement, themes.items, {}, ThemeListElement.lessThan);
+
+ if (tui.can_pretty_print and !opts.plain and stdout_file.isTty()) {
+ try preview(gpa_alloc, themes.items, opts.color);
+ return 0;
+ }
+
+ var theme_config = try Config.default(gpa_alloc);
+ defer theme_config.deinit();
+ for (themes.items) |theme| {
+ try theme_config.loadFile(theme_config._arena.?.allocator(), theme.path);
+ if (!shouldIncludeTheme(opts.color, theme_config)) {
+ continue;
+ }
+ if (opts.path)
+ try stdout.print("{s} ({t}) {s}\n", .{ theme.theme, theme.location, theme.path })
+ else
+ try stdout.print("{s} ({t})\n", .{ theme.theme, theme.location });
+ }
+
+ // Don't forget to flush!
+ try stdout.flush();
+ return 0;
+}
+
+fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 {
+ const main_cfg_path = try configpkg.preferredDefaultFilePath(alloc);
+ defer alloc.free(main_cfg_path);
+
+ const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName;
+ return try std.fs.path.join(alloc, &.{ base_dir, "auto", "theme.ghostty" });
+}
+
+fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void {
+ const auto_path = try resolveAutoThemePath(alloc);
+ defer alloc.free(auto_path);
+
+ if (std.fs.path.dirname(auto_path)) |dir| {
+ try std.fs.cwd().makePath(dir);
+ }
+
+ var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true });
+ defer f.close();
+
+ var buf: [128]u8 = undefined;
+ var w = f.writer(&buf);
+ try w.interface.print("theme = {s}\n", .{theme_name});
+ try w.interface.flush();
+}
+
+const Event = union(enum) {
+ key_press: vaxis.Key,
+ mouse: vaxis.Mouse,
+ color_scheme: vaxis.Color.Scheme,
+ winsize: vaxis.Winsize,
+};
+
+const Preview = struct {
+ allocator: std.mem.Allocator,
+ should_quit: bool,
+ tty: vaxis.Tty,
+ vx: vaxis.Vaxis,
+ mouse: ?vaxis.Mouse,
+ themes: []ThemeListElement,
+ filtered: std.ArrayList(usize),
+ current: usize,
+ window: usize,
+ hex: bool,
+ mode: enum {
+ normal,
+ help,
+ search,
+ save,
+ },
+ color_scheme: vaxis.Color.Scheme,
+ text_input: vaxis.widgets.TextInput,
+ theme_filter: ColorScheme,
+
+ pub fn init(
+ allocator: std.mem.Allocator,
+ themes: []ThemeListElement,
+ theme_filter: ColorScheme,
+ buf: []u8,
+ ) !*Preview {
+ const self = try allocator.create(Preview);
+
+ self.* = .{
+ .allocator = allocator,
+ .should_quit = false,
+ .tty = try .init(buf),
+ .vx = try vaxis.init(allocator, .{}),
+ .mouse = null,
+ .themes = themes,
+ .filtered = try .initCapacity(allocator, themes.len),
+ .current = 0,
+ .window = 0,
+ .hex = false,
+ .mode = .normal,
+ .color_scheme = .light,
+ .text_input = .init(allocator),
+ .theme_filter = theme_filter,
+ };
+
+ try self.updateFiltered();
+
+ return self;
+ }
+
+ pub fn deinit(self: *Preview) void {
+ const allocator = self.allocator;
+ self.filtered.deinit(allocator);
+ self.text_input.deinit();
+ self.vx.deinit(allocator, self.tty.writer());
+ self.tty.deinit();
+ allocator.destroy(self);
+ }
+
+ pub fn run(self: *Preview) !void {
+ var loop: vaxis.Loop(Event) = .{
+ .tty = &self.tty,
+ .vaxis = &self.vx,
+ };
+ try loop.init();
+ try loop.start();
+
+ const writer = self.tty.writer();
+
+ try self.vx.enterAltScreen(writer);
+ try self.vx.setTitle(writer, "👻 Ghostty Theme Preview 👻");
+ try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s);
+ try self.vx.setMouseMode(writer, true);
+ if (self.vx.caps.color_scheme_updates)
+ try self.vx.subscribeToColorSchemeUpdates(writer);
+
+ while (!self.should_quit) {
+ var arena = std.heap.ArenaAllocator.init(self.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ loop.pollEvent();
+ while (loop.tryEvent()) |event| {
+ try self.update(event, alloc);
+ }
+ try self.draw(alloc);
+
+ try self.vx.render(writer);
+ try writer.flush();
+ }
+ }
+
+ fn updateFiltered(self: *Preview) !void {
+ const relative = self.current -| self.window;
+ var selected: []const u8 = undefined;
+ if (self.filtered.items.len > 0) {
+ selected = self.themes[self.filtered.items[self.current]].theme;
+ }
+
+ const hash_algorithm = std.hash.Wyhash;
+
+ const old_digest = d: {
+ var hash = hash_algorithm.init(0);
+ for (self.filtered.items) |item|
+ hash.update(std.mem.asBytes(&item));
+ break :d hash.final();
+ };
+
+ self.filtered.clearRetainingCapacity();
+
+ var theme_config = try Config.default(self.allocator);
+ defer theme_config.deinit();
+ if (self.text_input.buf.realLength() > 0) {
+ const first_half = self.text_input.buf.firstHalf();
+ const second_half = self.text_input.buf.secondHalf();
+
+ const buffer = try self.allocator.alloc(u8, first_half.len + second_half.len);
+ defer self.allocator.free(buffer);
+
+ @memcpy(buffer[0..first_half.len], first_half);
+ @memcpy(buffer[first_half.len..], second_half);
+
+ const string = try std.ascii.allocLowerString(self.allocator, buffer);
+ defer self.allocator.free(string);
+
+ var tokens: std.ArrayList([]const u8) = .empty;
+ defer tokens.deinit(self.allocator);
+
+ var it = std.mem.tokenizeScalar(u8, string, ' ');
+ while (it.next()) |token| try tokens.append(self.allocator, token);
+
+ for (self.themes, 0..) |*theme, i| {
+ try theme_config.loadFile(theme_config._arena.?.allocator(), theme.path);
+ if (!shouldIncludeTheme(self.theme_filter, theme_config)) continue;
+
+ theme.rank = zf.rank(theme.theme, tokens.items, .{
+ .to_lower = true,
+ .plain = true,
+ });
+ if (theme.rank != null) try self.filtered.append(self.allocator, i);
+ }
+ } else {
+ for (self.themes, 0..) |*theme, i| {
+ try theme_config.loadFile(theme_config._arena.?.allocator(), theme.path);
+ if (shouldIncludeTheme(self.theme_filter, theme_config)) {
+ try self.filtered.append(self.allocator, i);
+ theme.rank = null;
+ }
+ }
+ }
+
+ const new_digest = d: {
+ var hash = hash_algorithm.init(0);
+ for (self.filtered.items) |item|
+ hash.update(std.mem.asBytes(&item));
+ break :d hash.final();
+ };
+
+ if (old_digest == new_digest) return;
+
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ self.window = 0;
+ return;
+ }
+
+ self.current, self.window = current: {
+ if (selected.len == 0) break :current .{ 0, 0 };
+
+ for (self.filtered.items, 0..) |index, i| {
+ if (std.mem.eql(u8, self.themes[index].theme, selected)) {
+ // Keep the relative position but ensure all search results are visible
+ const new_window = i -| relative;
+ // If the new window would hide some results at the top, adjust it
+ break :current .{ i, if (self.filtered.items.len <= SMALL_LIST_THRESHOLD) 0 else new_window };
+ }
+ }
+ break :current .{ 0, 0 };
+ };
+ }
+
+ fn up(self: *Preview, count: usize) void {
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ return;
+ }
+ self.current -|= count;
+ }
+
+ fn down(self: *Preview, count: usize) void {
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ return;
+ }
+ self.current += count;
+ if (self.current >= self.filtered.items.len)
+ self.current = self.filtered.items.len - 1;
+ }
+
+ pub fn update(self: *Preview, event: Event, alloc: std.mem.Allocator) !void {
+ switch (event) {
+ .key_press => |key| {
+ if (key.matches('c', .{ .ctrl = true }))
+ self.should_quit = true;
+ switch (self.mode) {
+ .normal => {
+ if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{}))
+ self.should_quit = true;
+ if (key.matchesAny(&.{ '?', vaxis.Key.f1 }, .{}))
+ self.mode = .help;
+ if (key.matches('h', .{ .ctrl = true }))
+ self.mode = .help;
+ if (key.matches('/', .{}))
+ self.mode = .search;
+ if (key.matchesAny(&.{ vaxis.Key.enter, vaxis.Key.kp_enter }, .{}))
+ self.mode = .save;
+ if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) {
+ self.text_input.buf.clearRetainingCapacity();
+ try self.updateFiltered();
+ }
+ if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{}))
+ self.current = 0;
+ if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{}))
+ self.current = self.filtered.items.len - 1;
+ if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{}))
+ self.down(1);
+ if (key.matchesAny(&.{ vaxis.Key.page_down, vaxis.Key.kp_down }, .{}))
+ self.down(20);
+ if (key.matchesAny(&.{ 'k', '-', vaxis.Key.up, vaxis.Key.kp_up, vaxis.Key.kp_subtract }, .{}))
+ self.up(1);
+ if (key.matchesAny(&.{ vaxis.Key.page_up, vaxis.Key.kp_page_up }, .{}))
+ self.up(20);
+ if (key.matchesAny(&.{ 'h', 'x' }, .{}))
+ self.hex = true;
+ if (key.matches('d', .{}))
+ self.hex = false;
+ if (key.matches('c', .{}))
+ try self.vx.copyToSystemClipboard(
+ self.tty.writer(),
+ self.themes[self.filtered.items[self.current]].theme,
+ alloc,
+ )
+ else if (key.matches('c', .{ .shift = true }))
+ try self.vx.copyToSystemClipboard(
+ self.tty.writer(),
+ self.themes[self.filtered.items[self.current]].path,
+ alloc,
+ );
+ if (key.matches('f', .{})) {
+ switch (self.theme_filter) {
+ .all => self.theme_filter = .dark,
+ .dark => self.theme_filter = .light,
+ .light => self.theme_filter = .all,
+ }
+ try self.updateFiltered();
+ }
+ },
+ .help => {
+ if (key.matches('q', .{}))
+ self.should_quit = true;
+ if (key.matchesAny(&.{ '?', vaxis.Key.escape, vaxis.Key.f1 }, .{}))
+ self.mode = .normal;
+ if (key.matches('h', .{ .ctrl = true }))
+ self.mode = .normal;
+ },
+ .search => search: {
+ if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter }, .{})) {
+ self.mode = .normal;
+ break :search;
+ }
+ if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) {
+ self.text_input.clearRetainingCapacity();
+ try self.updateFiltered();
+ break :search;
+ }
+ try self.text_input.update(.{ .key_press = key });
+ try self.updateFiltered();
+ },
+ .save => {
+ if (key.matches('q', .{}))
+ self.should_quit = true;
+ if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{}))
+ self.mode = .normal;
+ if (key.matches('w', .{})) {
+ self.saveSelectedTheme();
+ }
+ },
+ }
+ },
+ .color_scheme => |color_scheme| self.color_scheme = color_scheme,
+ .mouse => |mouse| self.mouse = mouse,
+ .winsize => |ws| try self.vx.resize(self.allocator, self.tty.writer(), ws),
+ }
+ }
+
+ pub fn ui_fg(self: *Preview) vaxis.Color {
+ return switch (self.color_scheme) {
+ .light => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } },
+ .dark => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } },
+ };
+ }
+
+ pub fn ui_bg(self: *Preview) vaxis.Color {
+ return switch (self.color_scheme) {
+ .light => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } },
+ .dark => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } },
+ };
+ }
+
+ pub fn ui_standard(self: *Preview) vaxis.Style {
+ return .{
+ .fg = self.ui_fg(),
+ .bg = self.ui_bg(),
+ };
+ }
+
+ pub fn ui_hover_bg(self: *Preview) vaxis.Color {
+ return switch (self.color_scheme) {
+ .light => .{ .rgb = [_]u8{ 0xbb, 0xbb, 0xbb } },
+ .dark => .{ .rgb = [_]u8{ 0x22, 0x22, 0x22 } },
+ };
+ }
+
+ pub fn ui_highlighted(self: *Preview) vaxis.Style {
+ return .{
+ .fg = self.ui_fg(),
+ .bg = self.ui_hover_bg(),
+ };
+ }
+
+ pub fn ui_selected_fg(self: *Preview) vaxis.Color {
+ return switch (self.color_scheme) {
+ .light => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } },
+ .dark => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } },
+ };
+ }
+
+ pub fn ui_selected_bg(self: *Preview) vaxis.Color {
+ return switch (self.color_scheme) {
+ .light => .{ .rgb = [_]u8{ 0xaa, 0xaa, 0xaa } },
+ .dark => .{ .rgb = [_]u8{ 0x33, 0x33, 0x33 } },
+ };
+ }
+
+ pub fn ui_selected(self: *Preview) vaxis.Style {
+ return .{
+ .fg = self.ui_selected_fg(),
+ .bg = self.ui_selected_bg(),
+ };
+ }
+
+ pub fn ui_err_fg(self: *Preview) vaxis.Color {
+ return switch (self.color_scheme) {
+ .light => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } },
+ .dark => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } },
+ };
+ }
+
+ pub fn ui_err(self: *Preview) vaxis.Style {
+ return .{
+ .fg = self.ui_err_fg(),
+ .bg = self.ui_bg(),
+ };
+ }
+
+ pub fn draw(self: *Preview, alloc: std.mem.Allocator) !void {
+ const win = self.vx.window();
+ win.clear();
+
+ self.vx.setMouseShape(.default);
+
+ const theme_list = win.child(.{
+ .x_off = 0,
+ .y_off = 0,
+ .width = 32,
+ .height = win.height,
+ });
+
+ var highlight: ?usize = null;
+
+ if (self.mouse) |mouse| {
+ self.mouse = null;
+ if (self.mode == .normal) {
+ if (mouse.button == .wheel_up) {
+ self.up(1);
+ }
+ if (mouse.button == .wheel_down) {
+ self.down(1);
+ }
+ if (theme_list.hasMouse(mouse)) |_| {
+ if (mouse.button == .left and mouse.type == .release) {
+ const selection = self.window + mouse.row;
+ if (selection < self.filtered.items.len) {
+ self.current = selection;
+ }
+ }
+ highlight = mouse.row;
+ }
+ }
+ }
+
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ self.window = 0;
+ } else {
+ const start = self.window;
+ const end = self.window + theme_list.height - 1;
+ if (self.current > end)
+ self.window = self.current - theme_list.height + 1;
+ if (self.current < start)
+ self.window = self.current;
+ if (self.window >= self.filtered.items.len)
+ self.window = self.filtered.items.len - 1;
+ }
+
+ theme_list.fill(.{ .style = self.ui_standard() });
+
+ for (0..theme_list.height) |row_capture| {
+ const row: u16 = @intCast(row_capture);
+ const index = self.window + row;
+ if (index >= self.filtered.items.len) break;
+
+ const theme = self.themes[self.filtered.items[index]];
+
+ const style: enum { normal, highlighted, selected } = style: {
+ if (index == self.current) break :style .selected;
+ if (highlight) |h| if (h == row) break :style .highlighted;
+ break :style .normal;
+ };
+
+ if (style == .selected) {
+ _ = theme_list.printSegment(
+ .{
+ .text = "❯ ",
+ .style = self.ui_selected(),
+ },
+ .{
+ .row_offset = row,
+ .col_offset = 0,
+ },
+ );
+ }
+ _ = theme_list.printSegment(
+ .{
+ .text = theme.theme,
+ .style = switch (style) {
+ .normal => self.ui_standard(),
+ .highlighted => self.ui_highlighted(),
+ .selected => self.ui_selected(),
+ },
+ .link = .{
+ .uri = try theme.toUri(alloc),
+ },
+ },
+ .{
+ .row_offset = row,
+ .col_offset = 2,
+ },
+ );
+ if (style == .selected) {
+ if (theme.theme.len < theme_list.width - 4) {
+ for (2 + theme.theme.len..theme_list.width - 2) |i_capture| {
+ const i: u16 = @intCast(i_capture);
+ _ = theme_list.printSegment(
+ .{
+ .text = " ",
+ .style = self.ui_selected(),
+ },
+ .{
+ .row_offset = row,
+ .col_offset = i,
+ },
+ );
+ }
+ }
+ _ = theme_list.printSegment(
+ .{
+ .text = " ❮",
+ .style = self.ui_selected(),
+ },
+ .{
+ .row_offset = row,
+ .col_offset = theme_list.width - 2,
+ },
+ );
+ }
+ }
+
+ try self.drawPreview(alloc, win, theme_list.x_off + theme_list.width);
+
+ switch (self.mode) {
+ .normal => {
+ win.hideCursor();
+ },
+ .help => {
+ win.hideCursor();
+ const width = 60;
+ const height = 22;
+ const child = win.child(
+ .{
+ .x_off = win.width / 2 -| width / 2,
+ .y_off = win.height / 2 -| height / 2,
+ .width = width,
+ .height = height,
+ .border = .{
+ .where = .all,
+ .style = self.ui_standard(),
+ },
+ },
+ );
+
+ child.fill(.{ .style = self.ui_standard() });
+
+ const key_help = [_]struct { keys: []const u8, help: []const u8 }{
+ .{ .keys = "^C, q, ESC", .help = "Quit." },
+ .{ .keys = "F1, ?, ^H", .help = "Toggle help window." },
+ .{ .keys = "f", .help = "Cycle through theme filters." },
+ .{ .keys = "k, ↑", .help = "Move up 1 theme." },
+ .{ .keys = "ScrollUp", .help = "Move up 1 theme." },
+ .{ .keys = "PgUp", .help = "Move up 20 themes." },
+ .{ .keys = "j, ↓", .help = "Move down 1 theme." },
+ .{ .keys = "ScrollDown", .help = "Move down 1 theme." },
+ .{ .keys = "PgDown", .help = "Move down 20 themes." },
+ .{ .keys = "h, x", .help = "Show palette numbers in hexadecimal." },
+ .{ .keys = "d", .help = "Show palette numbers in decimal." },
+ .{ .keys = "c", .help = "Copy theme name to the clipboard." },
+ .{ .keys = "C", .help = "Copy theme path to the clipboard." },
+ .{ .keys = "Home", .help = "Go to the start of the list." },
+ .{ .keys = "End", .help = "Go to the end of the list." },
+ .{ .keys = "/", .help = "Start search." },
+ .{ .keys = "^X, ^/", .help = "Clear search." },
+ .{ .keys = "⏎", .help = "Save theme or close search window." },
+ .{ .keys = "w", .help = "Write theme to auto config file." },
+ };
+
+ for (key_help, 0..) |help, captured_i| {
+ const i: u16 = @intCast(captured_i);
+ _ = child.printSegment(
+ .{
+ .text = help.keys,
+ .style = self.ui_standard(),
+ },
+ .{
+ .row_offset = i + 1,
+ .col_offset = 2,
+ },
+ );
+ _ = child.printSegment(
+ .{
+ .text = "—",
+ .style = self.ui_standard(),
+ },
+ .{
+ .row_offset = i + 1,
+ .col_offset = 15,
+ },
+ );
+ _ = child.printSegment(
+ .{
+ .text = help.help,
+ .style = self.ui_standard(),
+ },
+ .{
+ .row_offset = i + 1,
+ .col_offset = 17,
+ },
+ );
+ }
+ },
+ .search => {
+ const child = win.child(.{
+ .x_off = 20,
+ .y_off = win.height - 5,
+ .width = win.width - 40,
+ .height = 3,
+ .border = .{
+ .where = .all,
+ .style = self.ui_standard(),
+ },
+ });
+ child.fill(.{ .style = self.ui_standard() });
+ self.text_input.drawWithStyle(child, self.ui_standard());
+ },
+ .save => {
+ const theme = self.themes[self.filtered.items[self.current]];
+
+ const width = 92;
+ const height = 17;
+ const child = win.child(
+ .{
+ .x_off = win.width / 2 -| width / 2,
+ .y_off = win.height / 2 -| height / 2,
+ .width = width,
+ .height = height,
+ .border = .{
+ .where = .all,
+ .style = self.ui_standard(),
+ },
+ },
+ );
+
+ child.fill(.{ .style = self.ui_standard() });
+
+ const save_instructions = [_][]const u8{
+ "To apply this theme, add the following line to your Ghostty configuration:",
+ "",
+ try std.fmt.allocPrint(alloc, "theme = {s}", .{theme.theme}),
+ "",
+ "Save the configuration file and then reload it to apply the new theme.",
+ "",
+ "Or press 'w' to write an auto theme file to your system's preferred default config path.",
+ "Then add the following line to your Ghostty configuration and reload:",
+ "",
+ "config-file = ?auto/theme.ghostty",
+ "",
+ "For more details on configuration and themes, visit the Ghostty documentation:",
+ "",
+ "https://ghostty.org/docs/config/reference",
+ };
+
+ for (save_instructions, 0..) |instruction, captured_i| {
+ const i: u16 = @intCast(captured_i);
+ _ = child.printSegment(
+ .{
+ .text = instruction,
+ .style = self.ui_standard(),
+ },
+ .{
+ .row_offset = i + 1,
+ .col_offset = 2,
+ },
+ );
+ }
+ },
+ }
+ }
+
+ pub fn drawPreview(self: *Preview, alloc: std.mem.Allocator, win: vaxis.Window, x_off_unconverted: i17) !void {
+ const x_off: u16 = @intCast(x_off_unconverted);
+ const width: u16 = win.width - x_off;
+
+ if (self.filtered.items.len > 0) {
+ const theme = self.themes[self.filtered.items[self.current]];
+
+ var config = try Config.default(alloc);
+ defer config.deinit();
+
+ config.loadFile(config._arena.?.allocator(), theme.path) catch |err| {
+ const theme_path_len: u16 = @intCast(theme.path.len);
+
+ const child = win.child(
+ .{
+ .x_off = x_off,
+ .y_off = 0,
+ .width = width,
+ .height = win.height,
+ },
+ );
+ child.fill(.{ .style = self.ui_standard() });
+ const middle = child.height / 2;
+ {
+ const text = try std.fmt.allocPrint(alloc, "Unable to open {s} from:", .{theme.theme});
+ const text_len: u16 = @intCast(text.len);
+ _ = child.printSegment(
+ .{
+ .text = text,
+ .style = self.ui_err(),
+ },
+ .{
+ .row_offset = middle -| 1,
+ .col_offset = child.width / 2 -| text_len / 2,
+ },
+ );
+ }
+ {
+ _ = child.printSegment(
+ .{
+ .text = theme.path,
+ .style = self.ui_err(),
+ .link = .{
+ .uri = try theme.toUri(alloc),
+ },
+ },
+ .{
+ .row_offset = middle,
+ .col_offset = child.width / 2 -| theme_path_len / 2,
+ },
+ );
+ }
+ {
+ const text = try std.fmt.allocPrint(alloc, "{}", .{err});
+ const text_len: u16 = @intCast(text.len);
+ _ = child.printSegment(
+ .{
+ .text = text,
+ .style = self.ui_err(),
+ },
+ .{
+ .row_offset = middle + 1,
+ .col_offset = child.width / 2 -| text_len / 2,
+ },
+ );
+ }
+ return;
+ };
+
+ var next_start: u16 = 0;
+
+ const fg: vaxis.Color = .{
+ .rgb = [_]u8{
+ config.foreground.r,
+ config.foreground.g,
+ config.foreground.b,
+ },
+ };
+ const bg: vaxis.Color = .{
+ .rgb = [_]u8{
+ config.background.r,
+ config.background.g,
+ config.background.b,
+ },
+ };
+ const cursor_fg: vaxis.Color = if (config.@"cursor-text") |cursor_text| .{
+ .rgb = [_]u8{
+ cursor_text.color.r,
+ cursor_text.color.g,
+ cursor_text.color.b,
+ },
+ } else bg;
+ const cursor_bg: vaxis.Color = if (config.@"cursor-color") |cursor_bg| .{
+ .rgb = [_]u8{
+ cursor_bg.color.r,
+ cursor_bg.color.g,
+ cursor_bg.color.b,
+ },
+ } else fg;
+ const selection_fg: vaxis.Color = if (config.@"selection-foreground") |selection_fg| .{
+ .rgb = [_]u8{
+ selection_fg.color.r,
+ selection_fg.color.g,
+ selection_fg.color.b,
+ },
+ } else bg;
+ const selection_bg: vaxis.Color = if (config.@"selection-background") |selection_bg| .{
+ .rgb = [_]u8{
+ selection_bg.color.r,
+ selection_bg.color.g,
+ selection_bg.color.b,
+ },
+ } else fg;
+ const cursor: vaxis.Style = .{
+ .fg = cursor_fg,
+ .bg = cursor_bg,
+ };
+ const standard_selection: vaxis.Style = .{
+ .fg = selection_fg,
+ .bg = selection_bg,
+ };
+ const standard: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ };
+ const standard_bold: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .bold = true,
+ };
+ const standard_italic: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .italic = true,
+ };
+ const standard_bold_italic: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .bold = true,
+ .italic = true,
+ };
+ const standard_underline: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .ul_style = .single,
+ };
+ const standard_double_underline: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .ul_style = .double,
+ };
+ const standard_dashed_underline: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .ul_style = .dashed,
+ };
+ const standard_curly_underline: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .ul_style = .curly,
+ };
+ const standard_dotted_underline: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .ul_style = .dotted,
+ };
+
+ {
+ const theme_len: u16 = @intCast(theme.theme.len);
+ const theme_path_len: u16 = @intCast(theme.path.len);
+ const child = win.child(
+ .{
+ .x_off = x_off,
+ .y_off = next_start,
+ .width = width,
+ .height = 4,
+ },
+ );
+ child.fill(.{ .style = standard });
+ _ = child.printSegment(
+ .{
+ .text = theme.theme,
+ .style = standard_bold_italic,
+ .link = .{
+ .uri = try theme.toUri(alloc),
+ },
+ },
+ .{
+ .row_offset = 1,
+ .col_offset = child.width / 2 -| theme_len / 2,
+ },
+ );
+ _ = child.printSegment(
+ .{
+ .text = theme.path,
+ .style = standard,
+ .link = .{
+ .uri = try theme.toUri(alloc),
+ },
+ },
+ .{
+ .row_offset = 2,
+ .col_offset = child.width / 2 -| theme_path_len / 2,
+ .wrap = .none,
+ },
+ );
+ next_start += child.height;
+ }
+
+ if (config._diagnostics.items().len > 0) {
+ const diagnostic_items_len: u16 = @intCast(config._diagnostics.items().len);
+ const child = win.child(
+ .{
+ .x_off = x_off,
+ .y_off = next_start,
+ .width = width,
+ .height = if (config._diagnostics.items().len == 0) 0 else 2 + diagnostic_items_len,
+ },
+ );
+ {
+ const text = "Problems were encountered trying to load the theme:";
+ const text_len: u16 = @intCast(text.len);
+ _ = child.printSegment(
+ .{
+ .text = text,
+ .style = self.ui_err(),
+ },
+ .{
+ .row_offset = 0,
+ .col_offset = child.width / 2 -| (text_len / 2),
+ },
+ );
+ }
+
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ defer buf.deinit();
+ for (config._diagnostics.items(), 0..) |diag, captured_i| {
+ const i: u16 = @intCast(captured_i);
+ try diag.format(&buf.writer);
+ _ = child.printSegment(
+ .{
+ .text = buf.written(),
+ .style = self.ui_err(),
+ },
+ .{
+ .row_offset = 2 + i,
+ .col_offset = 2,
+ },
+ );
+ buf.clearRetainingCapacity();
+ }
+ next_start += child.height;
+ }
+ {
+ const child = win.child(.{
+ .x_off = x_off,
+ .y_off = next_start,
+ .width = width,
+ .height = 6,
+ });
+
+ child.fill(.{ .style = standard });
+
+ for (0..16) |captured_i| {
+ const i: u16 = @intCast(captured_i);
+ const r = i / 8;
+ const c = i % 8;
+ const text = if (self.hex)
+ try std.fmt.allocPrint(alloc, " {x:0>2}", .{i})
+ else
+ try std.fmt.allocPrint(alloc, "{d:3}", .{i});
+ _ = child.printSegment(
+ .{
+ .text = text,
+ .style = standard,
+ },
+ .{
+ .row_offset = 3 * r,
+ .col_offset = c * 8,
+ },
+ );
+ _ = child.printSegment(
+ .{
+ .text = "████",
+ .style = .{
+ .fg = color(config, i),
+ .bg = bg,
+ },
+ },
+ .{
+ .row_offset = 3 * r,
+ .col_offset = 4 + c * 8,
+ },
+ );
+ _ = child.printSegment(
+ .{
+ .text = "████",
+ .style = .{
+ .fg = color(config, i),
+ .bg = bg,
+ },
+ },
+ .{
+ .row_offset = 3 * r + 1,
+ .col_offset = 4 + c * 8,
+ },
+ );
+ }
+ next_start += child.height;
+ }
+ {
+ const child = win.child(
+ .{
+ .x_off = x_off,
+ .y_off = next_start,
+ .width = width,
+ .height = 24,
+ },
+ );
+ const bold: vaxis.Style = .{
+ .fg = fg,
+ .bg = bg,
+ .bold = true,
+ };
+ const color1: vaxis.Style = .{
+ .fg = color(config, 1),
+ .bg = bg,
+ };
+ const color2: vaxis.Style = .{
+ .fg = color(config, 2),
+ .bg = bg,
+ };
+ const color3: vaxis.Style = .{
+ .fg = color(config, 3),
+ .bg = bg,
+ };
+ const color4: vaxis.Style = .{
+ .fg = color(config, 4),
+ .bg = bg,
+ };
+ const color5: vaxis.Style = .{
+ .fg = color(config, 5),
+ .bg = bg,
+ };
+ const color6: vaxis.Style = .{
+ .fg = color(config, 6),
+ .bg = bg,
+ };
+ const color6ul: vaxis.Style = .{
+ .fg = color(config, 6),
+ .bg = bg,
+ .ul_style = .single,
+ };
+ const color10: vaxis.Style = .{
+ .fg = color(config, 10),
+ .bg = bg,
+ };
+ const color12: vaxis.Style = .{
+ .fg = color(config, 12),
+ .bg = bg,
+ };
+ const color238: vaxis.Style = .{
+ .fg = color(config, 238),
+ .bg = bg,
+ };
+ child.fill(.{ .style = standard });
+ _ = child.print(
+ &.{
+ .{ .text = "→", .style = color2 },
+ .{ .text = " ", .style = standard },
+ .{ .text = "bat", .style = color4 },
+ .{ .text = " ", .style = standard },
+ .{ .text = "ziggzagg.zig", .style = color6ul },
+ },
+ .{
+ .row_offset = 0,
+ .col_offset = 2,
+ },
+ );
+ {
+ _ = child.print(
+ &.{
+ .{
+ .text = "───────┬",
+ .style = color238,
+ },
+ },
+ .{
+ .row_offset = 1,
+ .col_offset = 2,
+ },
+ );
+ if (child.width > 10) {
+ for (10..child.width) |captured_col| {
+ const col: u16 = @intCast(captured_col);
+ _ = child.print(
+ &.{
+ .{
+ .text = "─",
+ .style = color238,
+ },
+ },
+ .{
+ .row_offset = 1,
+ .col_offset = col,
+ },
+ );
+ }
+ }
+ }
+ _ = child.print(
+ &.{
+ .{
+ .text = " │ ",
+ .style = color238,
+ },
+
+ .{
+ .text = "File: ",
+ .style = standard,
+ },
+
+ .{
+ .text = "ziggzagg.zig",
+ .style = bold,
+ },
+ },
+ .{
+ .row_offset = 2,
+ .col_offset = 2,
+ },
+ );
+ {
+ _ = child.print(
+ &.{
+ .{
+ .text = "───────┼",
+ .style = color238,
+ },
+ },
+ .{
+ .row_offset = 3,
+ .col_offset = 2,
+ },
+ );
+ if (child.width > 10) {
+ for (10..child.width) |captured_col| {
+ const col: u16 = @intCast(captured_col);
+ _ = child.print(
+ &.{
+ .{
+ .text = "─",
+ .style = color238,
+ },
+ },
+ .{
+ .row_offset = 3,
+ .col_offset = col,
+ },
+ );
+ }
+ }
+ }
+ _ = child.print(
+ &.{
+ .{ .text = " 1 │ ", .style = color238 },
+ .{ .text = "const", .style = color5 },
+ .{ .text = " std ", .style = standard },
+ .{ .text = "= @import", .style = color5 },
+ .{ .text = "(", .style = standard },
+ .{ .text = "\"std\"", .style = color10 },
+ .{ .text = ");", .style = standard },
+ },
+ .{
+ .row_offset = 4,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 2 │", .style = color238 },
+ },
+ .{
+ .row_offset = 5,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 3 │ ", .style = color238 },
+ .{ .text = "pub ", .style = color5 },
+ .{ .text = "fn ", .style = color12 },
+ .{ .text = "main", .style = color2 },
+ .{ .text = "() ", .style = standard },
+ .{ .text = "!", .style = color5 },
+ .{ .text = "void", .style = color12 },
+ .{ .text = " {", .style = standard },
+ },
+ .{
+ .row_offset = 6,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 4 │ ", .style = color238 },
+ .{ .text = "const ", .style = color5 },
+ .{ .text = "stdout ", .style = standard },
+ .{ .text = "=", .style = color5 },
+ .{ .text = " std.Io.getStdOut().writer();", .style = standard },
+ },
+ .{
+ .row_offset = 7,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 5 │ ", .style = color238 },
+ .{ .text = "var ", .style = color5 },
+ .{ .text = "i:", .style = standard },
+ .{ .text = " usize", .style = color12 },
+ .{ .text = " =", .style = color5 },
+ .{ .text = " 1", .style = color4 },
+ .{ .text = ";", .style = standard },
+ },
+ .{
+ .row_offset = 8,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 6 │ ", .style = color238 },
+ .{ .text = "while ", .style = color5 },
+ .{ .text = "(i ", .style = standard },
+ .{ .text = "<= ", .style = color5 },
+ .{ .text = "16", .style = color4 },
+ .{ .text = ") : (i ", .style = standard },
+ .{ .text = "+= ", .style = color5 },
+ .{ .text = "1", .style = color4 },
+ .{ .text = ") {", .style = standard },
+ },
+ .{
+ .row_offset = 9,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 7 │ ", .style = color238 },
+ .{ .text = "if ", .style = color5 },
+ .{ .text = "(i ", .style = standard },
+ .{ .text = "% ", .style = color5 },
+ .{ .text = "15 ", .style = color4 },
+ .{ .text = "== ", .style = color5 },
+ .{ .text = "0", .style = color4 },
+ .{ .text = ") {", .style = standard },
+ },
+ .{
+ .row_offset = 10,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 8 │ ", .style = color238 },
+ .{ .text = "try ", .style = color5 },
+ .{ .text = "stdout.writeAll(", .style = standard },
+ .{ .text = "\"ZiggZagg", .style = color10 },
+ .{ .text = "\\n", .style = color12 },
+ .{ .text = "\"", .style = color10 },
+ .{ .text = ");", .style = standard },
+ },
+ .{
+ .row_offset = 11,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 9 │ ", .style = color238 },
+ .{ .text = "} ", .style = standard },
+ .{ .text = "else if ", .style = color5 },
+ .{ .text = "(i ", .style = standard },
+ .{ .text = "% ", .style = color5 },
+ .{ .text = "3 ", .style = color4 },
+ .{ .text = "== ", .style = color5 },
+ .{ .text = "0", .style = color4 },
+ .{ .text = ") {", .style = standard },
+ },
+ .{
+ .row_offset = 12,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 10 │ ", .style = color238 },
+ .{ .text = "try ", .style = color5 },
+ .{ .text = "stdout.writeAll(", .style = standard },
+ .{ .text = "\"Zigg", .style = color10 },
+ .{ .text = "\\n", .style = color12 },
+ .{ .text = "\"", .style = color10 },
+ .{ .text = ");", .style = standard },
+ },
+ .{
+ .row_offset = 13,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 11 │ ", .style = color238 },
+ .{ .text = "} ", .style = standard },
+ .{ .text = "else if ", .style = color5 },
+ .{ .text = "(i ", .style = standard },
+ .{ .text = "% ", .style = color5 },
+ .{ .text = "5 ", .style = color4 },
+ .{ .text = "== ", .style = color5 },
+ .{ .text = "0", .style = color4 },
+ .{ .text = ") {", .style = standard },
+ },
+ .{
+ .row_offset = 14,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 12 │ ", .style = color238 },
+ .{ .text = "try ", .style = color5 },
+ .{ .text = "stdout.writeAll(", .style = standard },
+ .{ .text = "\"Zagg", .style = color10 },
+ .{ .text = "\\n", .style = color12 },
+ .{ .text = "\"", .style = color10 },
+ .{ .text = ");", .style = standard },
+ },
+ .{
+ .row_offset = 15,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 13 │ ", .style = color238 },
+ .{ .text = "} ", .style = standard },
+ .{ .text = "else ", .style = color5 },
+ .{ .text = "{", .style = standard },
+ },
+ .{
+ .row_offset = 16,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 14 │ ", .style = color238 },
+ .{ .text = "try ", .style = color5 },
+ .{ .text = "stdout.print(\"{d}\\n\", .{i})", .style = standard_selection },
+ .{ .text = ";", .style = cursor },
+ },
+ .{
+ .row_offset = 17,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 15 │ ", .style = color238 },
+ .{ .text = "}", .style = standard },
+ },
+ .{
+ .row_offset = 18,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 16 │ ", .style = color238 },
+ .{ .text = "}", .style = standard },
+ },
+ .{
+ .row_offset = 19,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = " 17 │ ", .style = color238 },
+ .{ .text = "}", .style = standard },
+ },
+ .{
+ .row_offset = 20,
+ .col_offset = 2,
+ },
+ );
+ {
+ _ = child.print(
+ &.{
+ .{
+ .text = "───────┴",
+ .style = color238,
+ },
+ },
+ .{
+ .row_offset = 21,
+ .col_offset = 2,
+ },
+ );
+ if (child.width > 10) {
+ for (10..child.width) |captured_col| {
+ const col: u16 = @intCast(captured_col);
+ _ = child.print(
+ &.{
+ .{
+ .text = "─",
+ .style = color238,
+ },
+ },
+ .{
+ .row_offset = 21,
+ .col_offset = col,
+ },
+ );
+ }
+ }
+ }
+ _ = child.print(
+ &.{
+ .{ .text = "ghostty ", .style = color6 },
+ .{ .text = "on ", .style = standard },
+ .{ .text = " main ", .style = color4 },
+ .{ .text = "[+] ", .style = color1 },
+ .{ .text = "via ", .style = standard },
+ .{ .text = " v0.13.0 ", .style = color3 },
+ .{ .text = "via ", .style = standard },
+ .{ .text = " impure (ghostty-env)", .style = color4 },
+ },
+ .{
+ .row_offset = 22,
+ .col_offset = 2,
+ },
+ );
+ _ = child.print(
+ &.{
+ .{ .text = "✦ ", .style = color4 },
+ .{ .text = "at ", .style = standard },
+ .{ .text = "10:36:15 ", .style = color3 },
+ .{ .text = "→", .style = color2 },
+ },
+ .{
+ .row_offset = 23,
+ .col_offset = 2,
+ },
+ );
+ next_start += child.height;
+ }
+ if (next_start < win.height) {
+ const child = win.child(
+ .{
+ .x_off = x_off,
+ .y_off = next_start,
+ .width = width,
+ .height = win.height - next_start,
+ },
+ );
+ child.fill(.{ .style = standard });
+ var it = std.mem.splitAny(u8, lorem_ipsum, " \n");
+ var row: u16 = 1;
+ var col: u16 = 2;
+ while (row < child.height) {
+ const word = it.next() orelse line: {
+ it.reset();
+ break :line it.next() orelse unreachable;
+ };
+ const word_len: u16 = @intCast(word.len);
+ if (col + word.len > child.width) {
+ row += 1;
+ col = 2;
+ }
+ const style: vaxis.Style = style: {
+ if (std.mem.eql(u8, "ipsum", word)) break :style .{ .fg = color(config, 2), .bg = bg };
+ if (std.mem.eql(u8, "consectetur", word)) break :style standard_bold;
+ if (std.mem.eql(u8, "reprehenderit", word)) break :style standard_italic;
+ if (std.mem.eql(u8, "Praesent", word)) break :style standard_bold_italic;
+ if (std.mem.eql(u8, "auctor", word)) break :style standard_underline;
+ if (std.mem.eql(u8, "dui", word)) break :style standard_double_underline;
+ if (std.mem.eql(u8, "erat", word)) break :style standard_dashed_underline;
+ if (std.mem.eql(u8, "enim", word)) break :style standard_dotted_underline;
+ if (std.mem.eql(u8, "odio", word)) break :style standard_curly_underline;
+ break :style standard;
+ };
+ _ = child.printSegment(
+ .{
+ .text = word,
+ .style = style,
+ },
+ .{
+ .row_offset = row,
+ .col_offset = col,
+ },
+ );
+ col += word_len + 1;
+ }
+ }
+ } else {
+ const child = win.child(.{
+ .x_off = 0,
+ .y_off = 0,
+ .width = win.width,
+ .height = win.height,
+ });
+ child.fill(.{
+ .style = self.ui_standard(),
+ });
+
+ _ = child.printSegment(.{
+ .text = "No theme found!",
+ .style = self.ui_standard(),
+ }, .{
+ .row_offset = win.height / 2 - 1,
+ .col_offset = win.width / 2 - 7,
+ });
+ }
+ }
+
+ fn saveSelectedTheme(self: *Preview) void {
+ if (self.filtered.items.len == 0)
+ return;
+
+ const idx = self.filtered.items[self.current];
+ const theme = self.themes[idx];
+
+ writeAutoThemeFile(self.allocator, theme.theme) catch {
+ return;
+ };
+ }
+};
+
+fn color(config: Config, palette: usize) vaxis.Color {
+ return .{
+ .rgb = [_]u8{
+ config.palette.value[palette].r,
+ config.palette.value[palette].g,
+ config.palette.value[palette].b,
+ },
+ };
+}
+
+const lorem_ipsum = @embedFile("lorem_ipsum.txt");
+
+fn preview(allocator: std.mem.Allocator, themes: []ThemeListElement, theme_filter: ColorScheme) !void {
+ var buf: [4096]u8 = undefined;
+ var app = try Preview.init(
+ allocator,
+ themes,
+ theme_filter,
+ &buf,
+ );
+ defer app.deinit();
+ try app.run();
+}
+
+fn shouldIncludeTheme(theme_filter: ColorScheme, theme_config: Config) bool {
+ const rf = @as(f32, @floatFromInt(theme_config.background.r)) / 255.0;
+ const gf = @as(f32, @floatFromInt(theme_config.background.g)) / 255.0;
+ const bf = @as(f32, @floatFromInt(theme_config.background.b)) / 255.0;
+ const luminance = 0.2126 * rf + 0.7152 * gf + 0.0722 * bf;
+ const is_dark = luminance < 0.5;
+ return (theme_filter == .all) or (theme_filter == .dark and is_dark) or (theme_filter == .light and !is_dark);
+}
diff --git a/vendor/libghostty-vt/src/cli/lorem_ipsum.txt b/vendor/libghostty-vt/src/cli/lorem_ipsum.txt
new file mode 100644
index 00000000..13b9a52c
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/lorem_ipsum.txt
@@ -0,0 +1,45 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras hendrerit aliquet
+turpis non dictum. Mauris pulvinar nisl sit amet dui cursus tempus. Pellentesque
+ut dui justo. Etiam quis magna sagittis nisi pretium consequat vitae ut nisl.
+Sed at metus id odio pulvinar sodales. Vestibulum sollicitudin, sem id tristique
+vestibulum, neque ante dictum tortor, in convallis mi enim ac lorem. Suspendisse
+orci ex, ullamcorper sed leo vitae, mattis egestas nisl. Morbi id est vel
+ipsum mollis convallis vel at mauris. Duis vehicula facilisis placerat. Aliquam
+venenatis auctor ipsum vel elementum. Proin ac tincidunt lacus. Sed facilisis
+tellus ullamcorper bibendum lobortis. Pellentesque porta, lacus quis efficitur
+pulvinar, sem mi varius ante, sed finibus diam ante et risus.
+
+Morbi ut sollicitudin justo. Nulla mattis mi ac mauris tincidunt tempor. Morbi
+vel gravida erat. Ut eu risus quis nisi facilisis aliquet varius id orci.
+Pellentesque tortor diam, porttitor nec urna nec, convallis consectetur dui.
+Vestibulum et hendrerit ipsum. Morbi pharetra dictum turpis in elementum. Ut
+nec volutpat nunc, at venenatis leo. Morbi eget nulla luctus, tincidunt dui vel,
+cursus urna. Maecenas ac pellentesque nisi. Quisque ut lorem porta, eleifend
+metus id, pellentesque tellus.
+
+Vivamus gravida convallis felis, at hendrerit dolor. Vestibulum tincidunt id
+augue quis hendrerit. Praesent venenatis elit quis posuere gravida. Praesent
+at massa a purus maximus tempus. Proin dui leo, feugiat et erat ac, tincidunt
+aliquam risus. Aenean rutrum hendrerit turpis, sit amet consectetur justo porta
+non. Sed auctor justo elit, sed mollis odio ullamcorper nec. Pellentesque ac
+hendrerit tortor. Praesent quis viverra dui, sit amet imperdiet magna.
+
+Mauris iaculis maximus felis, aliquet vehicula neque sagittis nec. Duis
+convallis purus enim, vel scelerisque purus dignissim eu. Donec congue sapien
+a neque rhoncus, sit amet accumsan libero tincidunt. Proin vitae placerat urna.
+Donec dolor sapien, fringilla sed semper sit amet, sollicitudin sit amet orci.
+Mauris maximus convallis vehicula. Aliquam urna ipsum, fermentum ac iaculis vel,
+blandit eget lorem. Sed enim ante, sodales a diam in, convallis interdum quam.
+Duis non urna risus. Proin ac neque at risus ullamcorper mattis eu vel nunc.
+Proin et ipsum euismod, ullamcorper justo et, imperdiet est. Curabitur quis
+arcu faucibus, bibendum nisl nec, hendrerit sapien. Curabitur vitae ante risus.
+Praesent eget sagittis tortor.
+
+Mauris aliquam nec nibh eu congue. Nullam congue auctor vestibulum. Donec
+posuere sapien nec massa efficitur tincidunt. Vestibulum ante ipsum primis in
+faucibus orci luctus et ultrices posuere cubilia curae; Proin molestie, nisl
+in tincidunt condimentum, ante metus fermentum felis, ac molestie lacus dui vel
+dolor. Donec ornare laoreet posuere. Etiam id tincidunt ante. Maecenas semper
+diam ac tortor facilisis egestas. Nam eu bibendum nisl. Integer tempor nisl nec
+ex consectetur, quis lobortis enim finibus. Sed ac erat posuere, fermentum metus
+sed, suscipit nisl.
diff --git a/vendor/libghostty-vt/src/cli/new_window.zig b/vendor/libghostty-vt/src/cli/new_window.zig
new file mode 100644
index 00000000..a89c4ffa
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/new_window.zig
@@ -0,0 +1,231 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const Action = @import("../cli.zig").ghostty.Action;
+const apprt = @import("../apprt.zig");
+const args = @import("args.zig");
+const diagnostics = @import("diagnostics.zig");
+const lib = @import("../lib/main.zig");
+const homedir = @import("../os/homedir.zig");
+
+pub const Options = struct {
+ /// This is set by the CLI parser for deinit.
+ _arena: ?ArenaAllocator = null,
+
+ /// If set, open up a new window in a custom instance of Ghostty.
+ class: ?[:0]const u8 = null,
+
+ /// Did the user specify a `--working-directory` argument on the command line?
+ _working_directory_seen: bool = false,
+
+ /// All of the arguments after `+new-window`. They will be sent to Ghosttty
+ /// for processing.
+ _arguments: std.ArrayList([:0]const u8) = .empty,
+
+ /// Enable arg parsing diagnostics so that we don't get an error if
+ /// there is a "normal" config setting on the cli.
+ _diagnostics: diagnostics.DiagnosticList = .{},
+
+ /// Manual parse hook, collect all of the arguments after `+new-window`.
+ pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) (error{InvalidValue} || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!bool {
+ var e_seen: bool = std.mem.eql(u8, arg, "-e");
+
+ // Include the argument that triggered the manual parse hook.
+ if (try self.checkArg(alloc, arg)) |a| try self._arguments.append(alloc, a);
+
+ // Gather up the rest of the arguments to use as the command.
+ while (iter.next()) |param| {
+ if (e_seen) {
+ try self._arguments.append(alloc, try alloc.dupeZ(u8, param));
+ continue;
+ }
+ if (std.mem.eql(u8, param, "-e")) {
+ e_seen = true;
+ try self._arguments.append(alloc, try alloc.dupeZ(u8, param));
+ continue;
+ }
+ if (try self.checkArg(alloc, param)) |a| try self._arguments.append(alloc, a);
+ }
+
+ return false;
+ }
+
+ fn checkArg(self: *Options, alloc: Allocator, arg: []const u8) (error{InvalidValue} || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!?[:0]const u8 {
+ if (lib.cutPrefix(u8, arg, "--class=")) |rest| {
+ self.class = try alloc.dupeZ(u8, std.mem.trim(u8, rest, &std.ascii.whitespace));
+ return null;
+ }
+
+ if (lib.cutPrefix(u8, arg, "--working-directory=")) |rest| {
+ const stripped = std.mem.trim(u8, rest, &std.ascii.whitespace);
+ if (std.mem.eql(u8, stripped, "home")) return try alloc.dupeZ(u8, arg);
+ if (std.mem.eql(u8, stripped, "inherit")) return try alloc.dupeZ(u8, arg);
+ const cwd: std.fs.Dir = std.fs.cwd();
+ var expandhome_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const expanded = try homedir.expandHome(stripped, &expandhome_buf);
+ var realpath_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const realpath = try cwd.realpath(expanded, &realpath_buf);
+ self._working_directory_seen = true;
+ return try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{realpath}, 0);
+ }
+
+ return try alloc.dupeZ(u8, arg);
+ }
+
+ pub fn deinit(self: *Options) void {
+ if (self._arena) |arena| arena.deinit();
+ self.* = undefined;
+ }
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `new-window` will use native platform IPC to open up a new window in a
+/// running instance of Ghostty.
+///
+/// If the `--class` flag is not set, the `new-window` command will try and
+/// connect to a running instance of Ghostty based on what optimizations the
+/// Ghostty CLI was compiled with. Otherwise the `new-window` command will try
+/// and contact a running Ghostty instance that was configured with the same
+/// `class` as was given on the command line.
+///
+/// All of the arguments after the `+new-window` argument (except for the
+/// `--class` flag) will be sent to the remote Ghostty instance and will be
+/// parsed as command line flags. These flags will override certain settings
+/// when creating the first surface in the new window. Currently, only
+/// `--working-directory`, `--command`, and `--title` are supported. `-e` will
+/// also work as an alias for `--command`, except that if `-e` is found on the
+/// command line all following arguments will become part of the command and no
+/// more arguments will be parsed for configuration settings.
+///
+/// If `--working-directory` is found on the command line and is a relative
+/// path (i.e. doesn't start with `/`) it will be resolved to an absolute path
+/// relative to the current working directory that the `ghostty +new-window`
+/// command is run from. `~/` prefixes will also be expanded to the user's home
+/// directory.
+///
+/// If `--working-directory` is _not_ found on the command line, the working
+/// directory that `ghostty +new-window` is run from will be passed to Ghostty.
+///
+/// GTK uses an application ID to identify instances of applications. If Ghostty
+/// is compiled with release optimizations, the default application ID will be
+/// `com.mitchellh.ghostty`. If Ghostty is compiled with debug optimizations,
+/// the default application ID will be `com.mitchellh.ghostty-debug`. The
+/// `class` configuration entry can be used to set up a custom application
+/// ID. The class name must follow the requirements defined [in the GTK
+/// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html)
+/// or it will be ignored and Ghostty will use the default as defined above.
+///
+/// On GTK, D-Bus activation must be properly configured. Ghostty does not need
+/// to be running for this to open a new window, making it suitable for binding
+/// to keys in your window manager (if other methods for configuring global
+/// shortcuts are unavailable). D-Bus will handle launching a new instance
+/// of Ghostty if it is not already running. See the Ghostty website for
+/// information on properly configuring D-Bus activation.
+///
+/// Only supported on GTK.
+///
+/// Flags:
+///
+/// * `--class=<class>`: If set, open up a new window in a custom instance of
+/// Ghostty. The class must be a valid GTK application ID.
+///
+/// * `--command`: The command to be executed in the first surface of the new window.
+///
+/// * `--working-directory=<directory>`: The working directory to pass to Ghostty.
+///
+/// * `--title`: A title that will override the title of the first surface in
+/// the new window. The title override may be edited or removed later.
+///
+/// * `-e`: Any arguments after this will be interpreted as a command to
+/// execute inside the first surface of the new window instead of the
+/// default command.
+///
+/// Available since: 1.2.0
+pub fn run(alloc: Allocator) !u8 {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+
+ var buffer: [1024]u8 = undefined;
+ var stderr_writer = std.fs.File.stderr().writer(&buffer);
+ const stderr = &stderr_writer.interface;
+
+ const result = runArgs(alloc, &iter, stderr);
+ stderr.flush() catch {};
+ return result;
+}
+
+fn runArgs(
+ alloc_gpa: Allocator,
+ argsIter: anytype,
+ stderr: *std.Io.Writer,
+) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ args.parse(Options, alloc_gpa, &opts, argsIter) catch |err| switch (err) {
+ error.ActionHelpRequested => return err,
+ else => {
+ try stderr.print("Error parsing args: {}\n", .{err});
+ return 1;
+ },
+ };
+
+ // Print out any diagnostics, unless it's likely that the diagnostic was
+ // generated trying to parse a "normal" configuration setting. Exit with an
+ // error code if any diagnostics were printed.
+ if (!opts._diagnostics.empty()) {
+ var exit: bool = false;
+ outer: for (opts._diagnostics.items()) |diagnostic| {
+ if (diagnostic.location != .cli) continue :outer;
+ inner: inline for (@typeInfo(Options).@"struct".fields) |field| {
+ if (field.name[0] == '_') continue :inner;
+ if (std.mem.eql(u8, field.name, diagnostic.key)) {
+ try stderr.print("config error: {f}\n", .{diagnostic});
+ exit = true;
+ }
+ }
+ }
+ if (exit) return 1;
+ }
+
+ if (!opts._working_directory_seen) {
+ const alloc = opts._arena.?.allocator();
+ const cwd: std.fs.Dir = std.fs.cwd();
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const wd = try cwd.realpath(".", &buf);
+ // This should be inserted at the beginning of the list, just in case `-e` was used.
+ try opts._arguments.insert(alloc, 0, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0));
+ }
+
+ var arena = ArenaAllocator.init(alloc_gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ if (apprt.App.performIpc(
+ alloc,
+ if (opts.class) |class| .{ .class = class } else .detect,
+ .new_window,
+ .{
+ .arguments = if (opts._arguments.items.len == 0) null else opts._arguments.items,
+ },
+ ) catch |err| switch (err) {
+ error.IPCFailed => {
+ // The apprt should have printed a more specific error message
+ // already.
+ return 1;
+ },
+ else => {
+ try stderr.print("Sending the IPC failed: {}", .{err});
+ return 1;
+ },
+ }) return 0;
+
+ // If we get here, the platform is not supported.
+ try stderr.print("+new-window is not supported on this platform.\n", .{});
+ return 1;
+}
diff --git a/vendor/libghostty-vt/src/cli/show_config.zig b/vendor/libghostty-vt/src/cli/show_config.zig
new file mode 100644
index 00000000..cd0ba89a
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/show_config.zig
@@ -0,0 +1,92 @@
+const std = @import("std");
+const args = @import("args.zig");
+const Allocator = std.mem.Allocator;
+const Action = @import("ghostty.zig").Action;
+const configpkg = @import("../config.zig");
+const Config = configpkg.Config;
+const Pager = @import("Pager.zig");
+
+pub const Options = struct {
+ /// If true, do not load the user configuration, only load the defaults.
+ default: bool = false,
+
+ /// Only show the options that have been changed from the default.
+ /// This has no effect if `--default` is specified.
+ @"changes-only": bool = true,
+
+ /// If true print the documentation above each option as a comment,
+ /// if available.
+ docs: bool = false,
+
+ /// Disable automatic paging of output.
+ @"no-pager": bool = false,
+
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `show-config` command shows the current configuration in a valid Ghostty
+/// configuration file format.
+///
+/// When executed without any arguments this will output the current
+/// configuration that is different from the default configuration. If you're
+/// using the default configuration this will output nothing.
+///
+/// If you are a new user and want to see all available options with
+/// documentation, run `ghostty +show-config --default --docs`.
+///
+/// The output is not in any specific order, but the order should be consistent
+/// between runs. The output is not guaranteed to be exactly match the input
+/// configuration files, but it will result in the same behavior. Comments,
+/// whitespace, and other formatting is not preserved from user configuration
+/// files.
+///
+/// Flags:
+///
+/// * `--default`: Show the default configuration instead of loading
+/// the user configuration.
+///
+/// * `--changes-only`: Only show the options that have been changed
+/// from the default. This has no effect if `--default` is specified.
+///
+/// * `--docs`: Print the documentation above each option as a comment,
+/// This is very noisy but is very useful to learn about available
+/// options, especially paired with `--default`.
+///
+/// * `--no-pager`: Disable automatic paging of output.
+pub fn run(alloc: Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ var config = if (opts.default) try Config.default(alloc) else try Config.load(alloc);
+ defer config.deinit();
+
+ const configfmt: configpkg.FileFormatter = .{
+ .alloc = alloc,
+ .config = &config,
+ .changed = !opts.default and opts.@"changes-only",
+ .docs = opts.docs,
+ };
+
+ var pager: Pager = if (!opts.@"no-pager") .init(alloc) else .{};
+ defer pager.deinit();
+ var buffer: [4096]u8 = undefined;
+ const writer = pager.writer(&buffer);
+
+ try configfmt.format(writer);
+ try writer.flush();
+ return 0;
+}
diff --git a/vendor/libghostty-vt/src/cli/show_face.zig b/vendor/libghostty-vt/src/cli/show_face.zig
new file mode 100644
index 00000000..9dee777b
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/show_face.zig
@@ -0,0 +1,239 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const Action = @import("ghostty.zig").Action;
+const args = @import("args.zig");
+const diagnostics = @import("diagnostics.zig");
+const font = @import("../font/main.zig");
+const configpkg = @import("../config.zig");
+const Config = configpkg.Config;
+
+pub const Options = struct {
+ /// This is set by the CLI parser for deinit.
+ _arena: ?ArenaAllocator = null,
+
+ /// The codepoint to search for.
+ cp: ?u21 = null,
+
+ /// Search for all of the codepoints in the string.
+ string: ?[]const u8 = null,
+
+ /// Font style to search for.
+ style: font.Style = .regular,
+
+ /// If specified, force text or emoji presentation.
+ presentation: ?font.Presentation = null,
+
+ // Enable arg parsing diagnostics so that we don't get an error if
+ // there is a "normal" config setting on the cli.
+ _diagnostics: diagnostics.DiagnosticList = .{},
+
+ pub fn deinit(self: *Options) void {
+ if (self._arena) |arena| arena.deinit();
+ self.* = undefined;
+ }
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `show-face` command shows what font face Ghostty will use to render a
+/// specific codepoint. Note that this command does not take into consideration
+/// grapheme clustering or any other Unicode features that might modify the
+/// presentation of a codepoint, so this may show a different font face than
+/// Ghostty uses to render a codepoint in a terminal session.
+///
+/// Flags:
+///
+/// * `--cp`: Find the face for a single codepoint. The codepoint may be specified
+/// in decimal (`--cp=65`), hexadecimal (`--cp=0x41`), octal (`--cp=0o101`), or
+/// binary (`--cp=0b1000001`).
+///
+/// * `--string`: Find the face for all of the codepoints in a string. The
+/// string must be a valid UTF-8 sequence.
+///
+/// * `--style`: Search for a specific style. Valid options are `regular`, `bold`,
+/// `italic`, and `bold_italic`.
+///
+/// * `--presentation`: If set, force searching for a specific presentation
+/// style. Valid options are `text` and `emoji`. If unset, the presentation
+/// style of a codepoint will be inferred from the Unicode standard.
+pub fn run(alloc: Allocator) !u8 {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+
+ var stdout_buffer: [1024]u8 = undefined;
+ var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
+ const stdout = &stdout_writer.interface;
+
+ var stderr_buffer: [1024]u8 = undefined;
+ var stderr_writer = std.fs.File.stdout().writer(&stderr_buffer);
+ const stderr = &stderr_writer.interface;
+
+ const result = runArgs(
+ alloc,
+ &iter,
+ stdout,
+ stderr,
+ );
+ stdout.flush() catch {};
+ stderr.flush() catch {};
+ return result;
+}
+
+fn runArgs(
+ alloc_gpa: Allocator,
+ argsIter: anytype,
+ stdout: *std.Io.Writer,
+ stderr: *std.Io.Writer,
+) !u8 {
+ // Its possible to build Ghostty without font discovery!
+ if (comptime font.Discover == void) {
+ try stderr.print(
+ \\Ghostty was built without a font discovery mechanism. This is a compile-time
+ \\option. Please review how Ghostty was built from source, contact the
+ \\maintainer to enable a font discovery mechanism, and try again.
+ ,
+ .{},
+ );
+ return 1;
+ }
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ args.parse(Options, alloc_gpa, &opts, argsIter) catch |err| switch (err) {
+ error.ActionHelpRequested => return err,
+ else => {
+ try stderr.print("Error parsing args: {}\n", .{err});
+ return 1;
+ },
+ };
+
+ // Print out any diagnostics, unless it's likely that the diagnostic was
+ // generated trying to parse a "normal" configuration setting. Exit with an
+ // error code if any diagnostics were printed.
+ if (!opts._diagnostics.empty()) {
+ var exit: bool = false;
+ outer: for (opts._diagnostics.items()) |diagnostic| {
+ if (diagnostic.location != .cli) continue :outer;
+ inner: inline for (@typeInfo(Options).@"struct".fields) |field| {
+ if (field.name[0] == '_') continue :inner;
+ if (std.mem.eql(u8, field.name, diagnostic.key)) {
+ try stderr.print("config error: {f}\n", .{diagnostic});
+ exit = true;
+ }
+ }
+ }
+ if (exit) return 1;
+ }
+
+ var arena = ArenaAllocator.init(alloc_gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ if (opts.cp == null and opts.string == null) {
+ try stderr.print("You must specify a codepoint with --cp or a string with --string\n", .{});
+ return 1;
+ }
+
+ var config = Config.load(alloc) catch |err| {
+ try stderr.print("Unable to load config: {}", .{err});
+ return 1;
+ };
+ defer config.deinit();
+
+ // Print out any diagnostics generated from parsing the config, unless
+ // the diagnostic might have been generated because it's actually an
+ // action-specific argument.
+ if (!config._diagnostics.empty()) {
+ outer: for (config._diagnostics.items()) |diagnostic| {
+ inner: inline for (@typeInfo(Options).@"struct".fields) |field| {
+ if (field.name[0] == '_') continue :inner;
+ if (std.mem.eql(u8, field.name, diagnostic.key) and (diagnostic.location == .none or diagnostic.location == .cli)) continue :outer;
+ }
+ try stderr.print("config error: {f}\n", .{diagnostic});
+ }
+ }
+
+ var font_grid_set = font.SharedGridSet.init(alloc) catch |err| {
+ try stderr.print("Unable to initialize font grid set: {}", .{err});
+ return 1;
+ };
+ errdefer font_grid_set.deinit();
+
+ const font_size: font.face.DesiredSize = .{
+ .points = config.@"font-size",
+ .xdpi = 96,
+ .ydpi = 96,
+ };
+
+ var font_config = font.SharedGridSet.DerivedConfig.init(alloc, config) catch |err| {
+ try stderr.print("Unable to initialize font config: {}", .{err});
+ return 1;
+ };
+
+ const font_grid_key, const font_grid = font_grid_set.ref(
+ &font_config,
+ font_size,
+ ) catch |err| {
+ try stderr.print("Unable to get font grid: {}", .{err});
+ return 1;
+ };
+ defer font_grid_set.deref(font_grid_key);
+
+ if (opts.cp) |cp| {
+ if (try lookup(alloc, stdout, stderr, font_grid, opts.style, opts.presentation, cp)) |rc| return rc;
+ }
+ if (opts.string) |string| {
+ const view = std.unicode.Utf8View.init(string) catch |err| {
+ try stderr.print("Unable to parse string as unicode: {}", .{err});
+ return 1;
+ };
+ var it = view.iterator();
+ while (it.nextCodepoint()) |cp| {
+ if (try lookup(alloc, stdout, stderr, font_grid, opts.style, opts.presentation, cp)) |rc| return rc;
+ }
+ }
+
+ return 0;
+}
+
+fn lookup(
+ alloc: std.mem.Allocator,
+ stdout: *std.Io.Writer,
+ stderr: *std.Io.Writer,
+ font_grid: *font.SharedGrid,
+ style: font.Style,
+ presentation: ?font.Presentation,
+ cp: u21,
+) !?u8 {
+ const idx = font_grid.resolver.getIndex(alloc, cp, style, presentation) orelse {
+ try stdout.print("U+{0X:0>2} « {0u} » not found.\n", .{cp});
+ return null;
+ };
+
+ const face = font_grid.resolver.collection.getFace(idx) catch |err| switch (err) {
+ error.SpecialHasNoFace => {
+ try stdout.print("U+{0X:0>2} « {0u} » is handled by Ghostty's internal sprites.\n", .{cp});
+ return null;
+ },
+ else => {
+ try stderr.print("Unable to get face: {}", .{err});
+ return 1;
+ },
+ };
+
+ var buf: [1024]u8 = undefined;
+ const name = face.name(&buf) catch |err| {
+ try stderr.print("Unable to get name of face: {}", .{err});
+ return 1;
+ };
+
+ try stdout.print("U+{0X:0>2} « {0u} » found in face “{1s}”.\n", .{ cp, name });
+
+ return null;
+}
diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig
new file mode 100644
index 00000000..6fa74b43
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig
@@ -0,0 +1,579 @@
+/// An SSH terminfo entry cache that stores its cache data on
+/// disk. The cache only stores metadata (hostname, terminfo value,
+/// etc.) and does not store any sensitive data.
+const DiskCache = @This();
+
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = @import("../../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const internal_os = @import("../../os/main.zig");
+const xdg = internal_os.xdg;
+const Entry = @import("Entry.zig");
+
+// 512KB - sufficient for approximately 10k entries
+const MAX_CACHE_SIZE = 512 * 1024;
+
+/// Path to a file where the cache is stored.
+path: []const u8,
+
+pub const DefaultPathError = Allocator.Error || error{
+ /// The general error that is returned for any filesystem error
+ /// that may have resulted in the XDG lookup failing.
+ XdgLookupFailed,
+};
+
+pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
+
+/// Returns the default path for the cache for a given program.
+///
+/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
+///
+/// The returned value is allocated and must be freed by the caller.
+pub fn defaultPath(
+ alloc: Allocator,
+ program: []const u8,
+) DefaultPathError![]const u8 {
+ const state_dir: []const u8 = xdg.state(
+ alloc,
+ .{ .subdir = program },
+ ) catch |err| return switch (err) {
+ error.OutOfMemory => error.OutOfMemory,
+ else => error.XdgLookupFailed,
+ };
+ defer alloc.free(state_dir);
+ return try std.fs.path.join(alloc, &.{ state_dir, "ssh_cache" });
+}
+
+/// Clear all cache data stored in the disk cache.
+/// This removes the cache file from disk, effectively clearing all cached
+/// SSH terminfo entries.
+pub fn clear(self: DiskCache) !void {
+ std.fs.cwd().deleteFile(self.path) catch |err| switch (err) {
+ error.FileNotFound => {},
+ else => return err,
+ };
+}
+
+pub const AddResult = enum { added, updated };
+
+pub const AddError = std.fs.Dir.MakeError ||
+ std.fs.Dir.StatFileError ||
+ std.fs.File.OpenError ||
+ std.fs.File.ChmodError ||
+ std.io.Reader.LimitedAllocError ||
+ FixupPermissionsError ||
+ ReadEntriesError ||
+ WriteCacheFileError ||
+ Error;
+
+/// Add or update a hostname entry in the cache.
+/// Returns AddResult.added for new entries or AddResult.updated for existing ones.
+/// The cache file is created if it doesn't exist with secure permissions (0600).
+pub fn add(
+ self: DiskCache,
+ alloc: Allocator,
+ hostname: []const u8,
+) AddError!AddResult {
+ if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
+
+ // Create cache directory if needed
+ if (std.fs.path.dirname(self.path)) |dir| {
+ std.fs.cwd().makePath(dir) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return err,
+ };
+ }
+
+ // Open or create cache file with secure permissions
+ const file = std.fs.createFileAbsolute(self.path, .{
+ .read = true,
+ .truncate = false,
+ .mode = 0o600,
+ }) catch |err| switch (err) {
+ error.PathAlreadyExists => blk: {
+ const existing_file = try std.fs.openFileAbsolute(
+ self.path,
+ .{ .mode = .read_write },
+ );
+ errdefer existing_file.close();
+ try fixupPermissions(existing_file);
+ break :blk existing_file;
+ },
+ else => return err,
+ };
+ defer file.close();
+
+ // Lock
+ // Causes a compile failure in the Zig std library on Windows, see:
+ // https://github.com/ziglang/zig/issues/18430
+ if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
+ defer if (comptime builtin.os.tag != .windows) file.unlock();
+
+ var entries = try readEntries(alloc, file);
+ defer deinitEntries(alloc, &entries);
+
+ // Add or update entry
+ const gop = try entries.getOrPut(hostname);
+ const result: AddResult = if (!gop.found_existing) add: {
+ const hostname_copy = try alloc.dupe(u8, hostname);
+ errdefer alloc.free(hostname_copy);
+ const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty");
+ errdefer alloc.free(terminfo_copy);
+
+ gop.key_ptr.* = hostname_copy;
+ gop.value_ptr.* = .{
+ .hostname = gop.key_ptr.*,
+ .timestamp = std.time.timestamp(),
+ .terminfo_version = terminfo_copy,
+ };
+ break :add .added;
+ } else update: {
+ // Update timestamp for existing entry
+ gop.value_ptr.timestamp = std.time.timestamp();
+ break :update .updated;
+ };
+
+ try self.writeCacheFile(entries, null);
+ return result;
+}
+
+pub const RemoveError = std.fs.File.OpenError ||
+ FixupPermissionsError ||
+ ReadEntriesError ||
+ WriteCacheFileError ||
+ Error;
+
+/// Remove a hostname entry from the cache.
+/// No error is returned if the hostname doesn't exist or the cache file is missing.
+pub fn remove(
+ self: DiskCache,
+ alloc: Allocator,
+ hostname: []const u8,
+) RemoveError!void {
+ if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
+
+ // Open our file
+ const file = std.fs.openFileAbsolute(
+ self.path,
+ .{ .mode = .read_write },
+ ) catch |err| switch (err) {
+ error.FileNotFound => return,
+ else => return err,
+ };
+ defer file.close();
+ try fixupPermissions(file);
+
+ // Lock
+ // Causes a compile failure in the Zig std library on Windows, see:
+ // https://github.com/ziglang/zig/issues/18430
+ if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
+ defer if (comptime builtin.os.tag != .windows) file.unlock();
+
+ // Read existing entries
+ var entries = try readEntries(alloc, file);
+ defer deinitEntries(alloc, &entries);
+
+ // Remove the entry if it exists and ensure we free the memory
+ if (entries.fetchRemove(hostname)) |kv| {
+ assert(kv.key.ptr == kv.value.hostname.ptr);
+ alloc.free(kv.value.hostname);
+ alloc.free(kv.value.terminfo_version);
+ }
+
+ try self.writeCacheFile(entries, null);
+}
+
+pub const ContainsError = std.fs.File.OpenError ||
+ ReadEntriesError ||
+ error{HostnameIsInvalid};
+
+/// Check if a hostname exists in the cache.
+/// Returns false if the cache file doesn't exist.
+pub fn contains(
+ self: DiskCache,
+ alloc: Allocator,
+ hostname: []const u8,
+) ContainsError!bool {
+ if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
+
+ // Open our file
+ const file = std.fs.openFileAbsolute(
+ self.path,
+ .{},
+ ) catch |err| switch (err) {
+ error.FileNotFound => return false,
+ else => return err,
+ };
+ defer file.close();
+
+ // Read existing entries
+ var entries = try readEntries(alloc, file);
+ defer deinitEntries(alloc, &entries);
+
+ return entries.contains(hostname);
+}
+
+pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError);
+
+fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
+ // Windows does not support chmod
+ if (comptime builtin.os.tag == .windows) return;
+
+ // Ensure file has correct permissions (readable/writable by
+ // owner only)
+ const stat = try file.stat();
+ if (stat.mode & 0o777 != 0o600) {
+ try file.chmod(0o600);
+ }
+}
+
+pub const WriteCacheFileError = std.fs.Dir.OpenError ||
+ std.fs.AtomicFile.InitError ||
+ std.fs.AtomicFile.FlushError ||
+ std.fs.AtomicFile.FinishError ||
+ Entry.FormatError ||
+ error{InvalidCachePath};
+
+fn writeCacheFile(
+ self: DiskCache,
+ entries: std.StringHashMap(Entry),
+ expire_days: ?u32,
+) WriteCacheFileError!void {
+ const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
+ const cache_basename = std.fs.path.basename(self.path);
+
+ var dir = try std.fs.cwd().openDir(cache_dir, .{});
+ defer dir.close();
+
+ var buf: [1024]u8 = undefined;
+ 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(&atomic_file.file_writer.interface);
+ }
+
+ try atomic_file.finish();
+}
+
+/// List all entries in the cache.
+/// The returned HashMap must be freed using `deinitEntries`.
+/// Returns an empty map if the cache file doesn't exist.
+pub fn list(
+ self: DiskCache,
+ alloc: Allocator,
+) !std.StringHashMap(Entry) {
+ // Open our file
+ const file = std.fs.openFileAbsolute(
+ self.path,
+ .{},
+ ) catch |err| switch (err) {
+ error.FileNotFound => return .init(alloc),
+ else => return err,
+ };
+ defer file.close();
+ return readEntries(alloc, file);
+}
+
+/// Free memory allocated by the `list` function.
+/// This must be called to properly deallocate all entry data.
+pub fn deinitEntries(
+ alloc: Allocator,
+ entries: *std.StringHashMap(Entry),
+) void {
+ // All our entries we dupe the memory owned by the hostname and the
+ // terminfo, and we always match the hostname key and value.
+ var it = entries.iterator();
+ while (it.next()) |entry| {
+ assert(entry.key_ptr.*.ptr == entry.value_ptr.hostname.ptr);
+ alloc.free(entry.value_ptr.hostname);
+ alloc.free(entry.value_ptr.terminfo_version);
+ }
+ entries.deinit();
+}
+
+pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError;
+
+fn readEntries(
+ alloc: Allocator,
+ file: std.fs.File,
+) ReadEntriesError!std.StringHashMap(Entry) {
+ var reader = file.reader(&.{});
+ const content = try reader.interface.allocRemaining(
+ alloc,
+ .limited(MAX_CACHE_SIZE),
+ );
+ defer alloc.free(content);
+
+ var entries = std.StringHashMap(Entry).init(alloc);
+ var lines = std.mem.tokenizeScalar(u8, content, '\n');
+ while (lines.next()) |line| {
+ const trimmed = std.mem.trim(u8, line, " \t\r");
+ const entry = Entry.parse(trimmed) orelse continue;
+
+ // Always allocate hostname first to avoid key pointer confusion
+ const hostname = try alloc.dupe(u8, entry.hostname);
+ errdefer alloc.free(hostname);
+
+ const gop = try entries.getOrPut(hostname);
+ if (!gop.found_existing) {
+ const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
+ gop.value_ptr.* = .{
+ .hostname = hostname,
+ .timestamp = entry.timestamp,
+ .terminfo_version = terminfo_copy,
+ };
+ } else {
+ // Don't need the copy since entry already exists
+ alloc.free(hostname);
+
+ // Handle duplicate entries - keep newer timestamp
+ if (entry.timestamp > gop.value_ptr.timestamp) {
+ gop.value_ptr.timestamp = entry.timestamp;
+ if (!std.mem.eql(
+ u8,
+ gop.value_ptr.terminfo_version,
+ entry.terminfo_version,
+ )) {
+ alloc.free(gop.value_ptr.terminfo_version);
+ const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
+ gop.value_ptr.terminfo_version = terminfo_copy;
+ }
+ }
+ }
+ }
+
+ return entries;
+}
+
+// Supports both standalone hostnames and user@hostname format
+fn isValidCacheKey(key: []const u8) bool {
+ if (key.len == 0) return false;
+
+ // Check for user@hostname format
+ if (std.mem.indexOfScalar(u8, key, '@')) |at_pos| {
+ const user = key[0..at_pos];
+ const hostname = key[at_pos + 1 ..];
+ return isValidUser(user) and isValidHost(hostname);
+ }
+
+ return isValidHost(key);
+}
+
+// Checks if a host is a valid hostname or IP address
+fn isValidHost(host: []const u8) bool {
+ // First check for valid hostnames because this is assumed to be the more
+ // likely ssh host format.
+ if (internal_os.hostname.isValid(host)) {
+ return true;
+ }
+
+ // We also accept valid IP addresses. In practice, IPv4 addresses are also
+ // considered valid hostnames due to their overlapping syntax, so we can
+ // simplify this check to be IPv6-specific.
+ if (std.net.Address.parseIp6(host, 0)) |_| {
+ return true;
+ } else |_| {
+ return false;
+ }
+}
+
+fn isValidUser(user: []const u8) bool {
+ if (user.len == 0 or user.len > 64) return false;
+ for (user) |c| {
+ switch (c) {
+ 'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {},
+ else => return false,
+ }
+ }
+ return true;
+}
+
+test "disk cache default path" {
+ const testing = std.testing;
+ const alloc = std.testing.allocator;
+
+ const path = try DiskCache.defaultPath(alloc, "ghostty");
+ defer alloc.free(path);
+ try testing.expect(path.len > 0);
+}
+
+test "disk cache clear" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Create our path
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ {
+ 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 tmp.dir.realpathAlloc(alloc, "cache");
+ defer alloc.free(path);
+
+ // Setup our cache
+ const cache: DiskCache = .{ .path = path };
+ try cache.clear();
+
+ // Verify the file is gone
+ try testing.expectError(
+ error.FileNotFound,
+ tmp.dir.openFile("cache", .{}),
+ );
+}
+
+test "disk cache operations" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Create our path
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var buf: [4096]u8 = undefined;
+ {
+ 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 tmp.dir.realpathAlloc(alloc, "cache");
+ defer alloc.free(path);
+
+ // Setup our cache
+ const cache: DiskCache = .{ .path = path };
+ try testing.expectEqual(
+ AddResult.added,
+ try cache.add(alloc, "example.com"),
+ );
+ try testing.expectEqual(
+ AddResult.updated,
+ try cache.add(alloc, "example.com"),
+ );
+ try testing.expect(
+ try cache.contains(alloc, "example.com"),
+ );
+
+ // List
+ var entries = try cache.list(alloc);
+ deinitEntries(alloc, &entries);
+
+ // Remove
+ try cache.remove(alloc, "example.com");
+ try testing.expect(
+ !(try cache.contains(alloc, "example.com")),
+ );
+ try testing.expectEqual(
+ AddResult.added,
+ try cache.add(alloc, "example.com"),
+ );
+}
+
+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;
+
+ // Valid hostnames
+ try testing.expect(isValidHost("localhost"));
+ try testing.expect(isValidHost("example.com"));
+ try testing.expect(isValidHost("sub.example.com"));
+
+ // IPv4 addresses
+ try testing.expect(isValidHost("127.0.0.1"));
+ try testing.expect(isValidHost("192.168.1.1"));
+
+ // IPv6 addresses
+ try testing.expect(isValidHost("::1"));
+ try testing.expect(isValidHost("2001:db8::1"));
+ try testing.expect(isValidHost("2001:db8:0:1:1:1:1:1"));
+ try testing.expect(!isValidHost("fe80::1%eth0")); // scopes not supported
+
+ // Invalid hosts
+ try testing.expect(!isValidHost(""));
+ try testing.expect(!isValidHost("host\nname"));
+ try testing.expect(!isValidHost(".example.com"));
+ try testing.expect(!isValidHost("host..domain"));
+ try testing.expect(!isValidHost("-hostname"));
+ try testing.expect(!isValidHost("hostname-"));
+ try testing.expect(!isValidHost("host name"));
+ try testing.expect(!isValidHost("host_name"));
+ try testing.expect(!isValidHost("host@domain"));
+ try testing.expect(!isValidHost("host:port"));
+}
+
+test isValidUser {
+ const testing = std.testing;
+
+ // Valid
+ try testing.expect(isValidUser("user"));
+ try testing.expect(isValidUser("user-user"));
+ try testing.expect(isValidUser("user_name"));
+ try testing.expect(isValidUser("user.name"));
+ try testing.expect(isValidUser("user123"));
+
+ // Invalid
+ try testing.expect(!isValidUser(""));
+ try testing.expect(!isValidUser("user name"));
+ try testing.expect(!isValidUser("user@example"));
+ try testing.expect(!isValidUser("user:group"));
+ try testing.expect(!isValidUser("user\nname"));
+ try testing.expect(!isValidUser("a" ** 65)); // too long
+}
+
+test isValidCacheKey {
+ const testing = std.testing;
+
+ // Valid
+ try testing.expect(isValidCacheKey("example.com"));
+ try testing.expect(isValidCacheKey("sub.example.com"));
+ try testing.expect(isValidCacheKey("192.168.1.1"));
+ try testing.expect(isValidCacheKey("::1"));
+ try testing.expect(isValidCacheKey("user@example.com"));
+ try testing.expect(isValidCacheKey("user@192.168.1.1"));
+ try testing.expect(isValidCacheKey("user@::1"));
+
+ // Invalid
+ try testing.expect(!isValidCacheKey(""));
+ try testing.expect(!isValidCacheKey(".example.com"));
+ try testing.expect(!isValidCacheKey("@example.com"));
+ try testing.expect(!isValidCacheKey("user@"));
+ try testing.expect(!isValidCacheKey("user@@example"));
+ try testing.expect(!isValidCacheKey("user@.example.com"));
+}
diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig b/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig
new file mode 100644
index 00000000..b586161f
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig
@@ -0,0 +1,156 @@
+/// A single entry within our SSH entry cache. Our SSH entry cache
+/// stores which hosts we've sent our terminfo to so that we don't have
+/// to send it again. It doesn't store any sensitive information.
+const Entry = @This();
+
+const std = @import("std");
+
+hostname: []const u8,
+timestamp: i64,
+terminfo_version: []const u8,
+
+pub fn parse(line: []const u8) ?Entry {
+ const trimmed = std.mem.trim(u8, line, " \t\r\n");
+ if (trimmed.len == 0) return null;
+
+ // Parse format: hostname|timestamp|terminfo_version
+ var iter = std.mem.tokenizeScalar(u8, trimmed, '|');
+ const hostname = iter.next() orelse return null;
+ const timestamp_str = iter.next() orelse return null;
+ const terminfo_version = iter.next() orelse "xterm-ghostty";
+ const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| {
+ std.log.warn(
+ "Invalid timestamp in cache entry: {s} err={}",
+ .{ timestamp_str, err },
+ );
+ return null;
+ };
+
+ return .{
+ .hostname = hostname,
+ .timestamp = timestamp,
+ .terminfo_version = terminfo_version,
+ };
+}
+
+pub const FormatError = std.Io.Writer.Error;
+
+pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void {
+ try writer.print(
+ "{s}|{d}|{s}\n",
+ .{ self.hostname, self.timestamp, self.terminfo_version },
+ );
+}
+
+pub fn isExpired(self: Entry, expire_days_: ?u32) bool {
+ const expire_days = expire_days_ orelse return false;
+ const now = std.time.timestamp();
+ const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day);
+ return age_days > expire_days;
+}
+
+test "cache entry expiration" {
+ const testing = std.testing;
+ const now = std.time.timestamp();
+
+ const fresh_entry: Entry = .{
+ .hostname = "test.com",
+ .timestamp = now - std.time.s_per_day, // 1 day old
+ .terminfo_version = "xterm-ghostty",
+ };
+ try testing.expect(!fresh_entry.isExpired(90));
+
+ const old_entry: Entry = .{
+ .hostname = "old.com",
+ .timestamp = now - (std.time.s_per_day * 100), // 100 days old
+ .terminfo_version = "xterm-ghostty",
+ };
+ try testing.expect(old_entry.isExpired(90));
+
+ // Test never-expire case
+ try testing.expect(!old_entry.isExpired(null));
+}
+
+test "cache entry expiration exact boundary" {
+ const testing = std.testing;
+ const now = std.time.timestamp();
+
+ // Exactly at expiration boundary
+ const boundary_entry: Entry = .{
+ .hostname = "example.com",
+ .timestamp = now - (std.time.s_per_day * 30),
+ .terminfo_version = "xterm-ghostty",
+ };
+ try testing.expect(!boundary_entry.isExpired(30));
+ try testing.expect(boundary_entry.isExpired(29));
+}
+
+test "cache entry expiration large timestamp" {
+ const testing = std.testing;
+ const now = std.time.timestamp();
+
+ const boundary_entry: Entry = .{
+ .hostname = "example.com",
+ .timestamp = now + (std.time.s_per_day * 30),
+ .terminfo_version = "xterm-ghostty",
+ };
+ try testing.expect(!boundary_entry.isExpired(30));
+}
+
+test "cache entry parsing valid formats" {
+ const testing = std.testing;
+
+ const entry = Entry.parse("example.com|1640995200|xterm-ghostty").?;
+ try testing.expectEqualStrings("example.com", entry.hostname);
+ try testing.expectEqual(@as(i64, 1640995200), entry.timestamp);
+ try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version);
+
+ // Test default terminfo version
+ const entry_no_version = Entry.parse("test.com|1640995200").?;
+ try testing.expectEqualStrings(
+ "xterm-ghostty",
+ entry_no_version.terminfo_version,
+ );
+
+ // Test complex hostnames
+ const complex_entry = Entry.parse("user@server.example.com|1640995200|xterm-ghostty").?;
+ try testing.expectEqualStrings(
+ "user@server.example.com",
+ complex_entry.hostname,
+ );
+}
+
+test "cache entry parsing invalid formats" {
+ const testing = std.testing;
+
+ try testing.expect(Entry.parse("") == null);
+
+ // Invalid format (no pipe)
+ try testing.expect(Entry.parse("v1") == null);
+
+ // Missing timestamp
+ try testing.expect(Entry.parse("example.com") == null);
+
+ // Invalid timestamp
+ try testing.expect(Entry.parse("example.com|invalid") == null);
+
+ // Empty terminfo should default
+ try testing.expect(Entry.parse("example.com|1640995200|") != null);
+}
+
+test "cache entry parsing malformed data resilience" {
+ const testing = std.testing;
+
+ // Extra pipes should not break parsing
+ try testing.expect(Entry.parse("host|123|term|extra") != null);
+
+ // Whitespace handling
+ try testing.expect(Entry.parse(" host|123|term ") != null);
+ try testing.expect(Entry.parse("\n") == null);
+ try testing.expect(Entry.parse(" \t \n") == null);
+
+ // Extremely large timestamp
+ try testing.expect(
+ Entry.parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty") == null,
+ );
+}
diff --git a/vendor/libghostty-vt/src/cli/ssh_cache.zig b/vendor/libghostty-vt/src/cli/ssh_cache.zig
new file mode 100644
index 00000000..d3ee658a
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/ssh_cache.zig
@@ -0,0 +1,228 @@
+const std = @import("std");
+const fs = std.fs;
+const Allocator = std.mem.Allocator;
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+pub const Entry = @import("ssh-cache/Entry.zig");
+pub const DiskCache = @import("ssh-cache/DiskCache.zig");
+
+pub const Options = struct {
+ clear: bool = false,
+ add: ?[]const u8 = null,
+ remove: ?[]const u8 = null,
+ host: ?[]const u8 = null,
+ @"expire-days": ?u32 = null,
+
+ pub fn deinit(self: *Options) void {
+ _ = self;
+ }
+
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// Manage the SSH terminfo cache for automatic remote host setup.
+///
+/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
+/// Ghostty automatically installs its terminfo on remote hosts. This command
+/// manages the cache of successful installations to avoid redundant uploads.
+///
+/// The cache stores hostnames (or user@hostname combinations) along with timestamps.
+/// Entries older than the expiration period are automatically removed during cache
+/// operations. By default, entries never expire.
+///
+/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
+/// If multiple are specified, one of the actions will be executed but
+/// it isn't guaranteed which one. This is entirely unsafe so you should split
+/// multiple actions into separate commands.
+///
+/// Examples:
+/// ghostty +ssh-cache # List all cached hosts
+/// ghostty +ssh-cache --host=example.com # Check if host is cached
+/// ghostty +ssh-cache --add=example.com # Manually add host to cache
+/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
+/// ghostty +ssh-cache --remove=example.com # Remove host from cache
+/// ghostty +ssh-cache --clear # Clear entire cache
+/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
+pub fn run(alloc_gpa: Allocator) !u8 {
+ var arena = std.heap.ArenaAllocator.init(alloc_gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc_gpa);
+ defer iter.deinit();
+ try args.parse(Options, alloc_gpa, &opts, &iter);
+ }
+
+ var stdout_buffer: [1024]u8 = undefined;
+ var stdout_file: std.fs.File = .stdout();
+ var stdout_writer = stdout_file.writer(&stdout_buffer);
+ const stdout = &stdout_writer.interface;
+
+ var stderr_buffer: [1024]u8 = undefined;
+ var stderr_file: std.fs.File = .stderr();
+ var stderr_writer = stderr_file.writer(&stderr_buffer);
+ const stderr = &stderr_writer.interface;
+
+ const result = runInner(alloc, opts, stdout, stderr);
+
+ // Flushing *shouldn't* fail but...
+ stdout.flush() catch {};
+ stderr.flush() catch {};
+ return result;
+}
+
+pub fn runInner(
+ alloc: Allocator,
+ opts: Options,
+ stdout: *std.Io.Writer,
+ stderr: *std.Io.Writer,
+) !u8 {
+ // Setup our disk cache to the standard location
+ const cache_path = try DiskCache.defaultPath(alloc, "ghostty");
+ const cache: DiskCache = .{ .path = cache_path };
+
+ if (opts.clear) {
+ try cache.clear();
+ try stdout.print("Cache cleared.\n", .{});
+ return 0;
+ }
+
+ if (opts.add) |host| {
+ const result = cache.add(alloc, host) catch |err| switch (err) {
+ DiskCache.Error.HostnameIsInvalid => {
+ try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
+ try stderr.print("Expected format: hostname or user@hostname\n", .{});
+ return 1;
+ },
+ DiskCache.Error.CacheIsLocked => {
+ try stderr.print("Error: Cache is busy, try again\n", .{});
+ return 1;
+ },
+ else => {
+ try stderr.print(
+ "Error: Unable to add '{s}' to cache. Error: {}\n",
+ .{ host, err },
+ );
+ return 1;
+ },
+ };
+
+ switch (result) {
+ .added => try stdout.print("Added '{s}' to cache.\n", .{host}),
+ .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}),
+ }
+ return 0;
+ }
+
+ if (opts.remove) |host| {
+ cache.remove(alloc, host) catch |err| switch (err) {
+ DiskCache.Error.HostnameIsInvalid => {
+ try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
+ try stderr.print("Expected format: hostname or user@hostname\n", .{});
+ return 1;
+ },
+ DiskCache.Error.CacheIsLocked => {
+ try stderr.print("Error: Cache is busy, try again\n", .{});
+ return 1;
+ },
+ else => {
+ try stderr.print(
+ "Error: Unable to remove '{s}' from cache. Error: {}\n",
+ .{ host, err },
+ );
+ return 1;
+ },
+ };
+ try stdout.print("Removed '{s}' from cache.\n", .{host});
+ return 0;
+ }
+
+ if (opts.host) |host| {
+ const cached = cache.contains(alloc, host) catch |err| switch (err) {
+ error.HostnameIsInvalid => {
+ try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
+ try stderr.print("Expected format: hostname or user@hostname\n", .{});
+ return 1;
+ },
+ else => {
+ try stderr.print(
+ "Error: Unable to check host '{s}' in cache. Error: {}\n",
+ .{ host, err },
+ );
+ return 1;
+ },
+ };
+
+ if (cached) {
+ try stdout.print(
+ "'{s}' has Ghostty terminfo installed.\n",
+ .{host},
+ );
+ return 0;
+ } else {
+ try stdout.print(
+ "'{s}' does not have Ghostty terminfo installed.\n",
+ .{host},
+ );
+ return 1;
+ }
+ }
+
+ // Default action: list all hosts
+ var entries = try cache.list(alloc);
+ defer DiskCache.deinitEntries(alloc, &entries);
+ try listEntries(alloc, &entries, stdout);
+ return 0;
+}
+
+fn listEntries(
+ alloc: Allocator,
+ entries: *const std.StringHashMap(Entry),
+ writer: *std.Io.Writer,
+) !void {
+ if (entries.count() == 0) {
+ try writer.print("No hosts in cache.\n", .{});
+ return;
+ }
+
+ // Sort entries by hostname for consistent output
+ var items: std.ArrayList(Entry) = .empty;
+ defer items.deinit(alloc);
+
+ var iter = entries.iterator();
+ while (iter.next()) |kv| {
+ try items.append(alloc, kv.value_ptr.*);
+ }
+
+ std.mem.sort(Entry, items.items, {}, struct {
+ fn lessThan(_: void, a: Entry, b: Entry) bool {
+ return std.mem.lessThan(u8, a.hostname, b.hostname);
+ }
+ }.lessThan);
+
+ try writer.print("Cached hosts ({d}):\n", .{items.items.len});
+ const now = std.time.timestamp();
+
+ for (items.items) |entry| {
+ const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
+ if (age_days == 0) {
+ try writer.print(" {s} (today)\n", .{entry.hostname});
+ } else if (age_days == 1) {
+ try writer.print(" {s} (yesterday)\n", .{entry.hostname});
+ } else {
+ try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
+ }
+ }
+}
+
+test {
+ _ = DiskCache;
+ _ = Entry;
+}
diff --git a/vendor/libghostty-vt/src/cli/tui.zig b/vendor/libghostty-vt/src/cli/tui.zig
new file mode 100644
index 00000000..28d593ed
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/tui.zig
@@ -0,0 +1,6 @@
+const builtin = @import("builtin");
+
+pub const can_pretty_print = switch (builtin.os.tag) {
+ .ios, .tvos, .watchos => false,
+ else => true,
+};
diff --git a/vendor/libghostty-vt/src/cli/validate_config.zig b/vendor/libghostty-vt/src/cli/validate_config.zig
new file mode 100644
index 00000000..5586cf29
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/validate_config.zig
@@ -0,0 +1,78 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const args = @import("args.zig");
+const Action = @import("ghostty.zig").Action;
+const Config = @import("../config.zig").Config;
+
+pub const Options = struct {
+ /// The path of the config file to validate. If this isn't specified,
+ /// then the default config file paths will be validated.
+ @"config-file": ?[:0]const u8 = null,
+
+ pub fn deinit(self: Options) void {
+ _ = self;
+ }
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `validate-config` command is used to validate a Ghostty config file.
+///
+/// When executed without any arguments, this will load the config from the default
+/// location.
+///
+/// Flags:
+///
+/// * `--config-file`: can be passed to validate a specific target config file in
+/// a non-default location
+pub fn run(alloc: std.mem.Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc);
+ defer iter.deinit();
+ try args.parse(Options, alloc, &opts, &iter);
+ }
+
+ var buffer: [1024]u8 = undefined;
+ var stdout_writer = std.fs.File.stdout().writer(&buffer);
+ const stdout = &stdout_writer.interface;
+ const result = runInner(alloc, opts, stdout);
+ try stdout_writer.end();
+ return result;
+}
+
+fn runInner(
+ alloc: std.mem.Allocator,
+ opts: Options,
+ stdout: *std.Io.Writer,
+) !u8 {
+ var cfg = try Config.default(alloc);
+ defer cfg.deinit();
+
+ // If a config path is passed, validate it, otherwise validate default configs
+ if (opts.@"config-file") |config_path| {
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const abs_path = try std.fs.cwd().realpath(config_path, &buf);
+ try cfg.loadFile(alloc, abs_path);
+ try cfg.loadRecursiveFiles(alloc);
+ } else {
+ cfg = try Config.load(alloc);
+ }
+
+ try cfg.finalize();
+
+ if (cfg._diagnostics.items().len > 0) {
+ for (cfg._diagnostics.items()) |diag| {
+ try stdout.print("{f}\n", .{diag});
+ }
+ return 1;
+ }
+
+ return 0;
+}
diff --git a/vendor/libghostty-vt/src/cli/version.zig b/vendor/libghostty-vt/src/cli/version.zig
new file mode 100644
index 00000000..cf8e66fa
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/version.zig
@@ -0,0 +1,76 @@
+const std = @import("std");
+const build_options = @import("build_options");
+const Allocator = std.mem.Allocator;
+const builtin = @import("builtin");
+const build_config = @import("../build_config.zig");
+const internal_os = @import("../os/main.zig");
+const xev = @import("../global.zig").xev;
+const renderer = @import("../renderer.zig");
+
+const gtk_version = @import("../apprt/gtk/gtk_version.zig");
+const adw_version = @import("../apprt/gtk/adw_version.zig");
+
+pub const Options = struct {};
+
+/// The `version` command is used to display information about Ghostty. Recognized as
+/// either `+version` or `--version`.
+pub fn run(alloc: Allocator) !u8 {
+ var buffer: [1024]u8 = undefined;
+ const stdout_file: std.fs.File = .stdout();
+ var stdout_writer = stdout_file.writer(&buffer);
+
+ const stdout = &stdout_writer.interface;
+ const tty = stdout_file.isTty();
+
+ if (tty) if (build_config.version.build) |commit_hash| {
+ try stdout.print(
+ "\x1b]8;;https://github.com/ghostty-org/ghostty/commit/{s}\x1b\\",
+ .{commit_hash},
+ );
+ };
+ try stdout.print("Ghostty {s}\n\n", .{build_config.version_string});
+ if (tty) try stdout.print("\x1b]8;;\x1b\\", .{});
+
+ try stdout.print("Version\n", .{});
+ try stdout.print(" - version: {s}\n", .{build_config.version_string});
+ try stdout.print(" - channel: {t}\n", .{build_config.release_channel});
+
+ try stdout.print("Build Config\n", .{});
+ try stdout.print(" - Zig version : {s}\n", .{builtin.zig_version_string});
+ try stdout.print(" - build mode : {}\n", .{builtin.mode});
+ try stdout.print(" - app runtime : {}\n", .{build_config.app_runtime});
+ try stdout.print(" - font engine : {}\n", .{build_config.font_backend});
+ try stdout.print(" - renderer : {}\n", .{renderer.Renderer});
+ try stdout.print(" - libxev : {t}\n", .{xev.backend});
+ if (comptime build_config.app_runtime == .gtk) {
+ if (comptime builtin.os.tag == .linux) {
+ const kernel_info = internal_os.getKernelInfo(alloc);
+ defer if (kernel_info) |k| alloc.free(k);
+ try stdout.print(" - kernel version: {s}\n", .{kernel_info orelse "Kernel information unavailable"});
+ }
+ try stdout.print(" - desktop env : {t}\n", .{internal_os.desktopEnvironment()});
+ try stdout.print(" - GTK version :\n", .{});
+ try stdout.print(" build : {f}\n", .{gtk_version.comptime_version});
+ try stdout.print(" runtime : {f}\n", .{gtk_version.getRuntimeVersion()});
+ try stdout.print(" - libadwaita : enabled\n", .{});
+ try stdout.print(" build : {f}\n", .{adw_version.comptime_version});
+ try stdout.print(" runtime : {f}\n", .{adw_version.getRuntimeVersion()});
+ if (comptime build_options.x11) {
+ try stdout.print(" - libX11 : enabled\n", .{});
+ } else {
+ try stdout.print(" - libX11 : disabled\n", .{});
+ }
+
+ // We say `libwayland` since it is possible to build Ghostty without
+ // Wayland integration but with Wayland-enabled GTK
+ if (comptime build_options.wayland) {
+ try stdout.print(" - libwayland : enabled\n", .{});
+ } else {
+ try stdout.print(" - libwayland : disabled\n", .{});
+ }
+ }
+
+ // Don't forget to flush!
+ try stdout.flush();
+ return 0;
+}