diff options
Diffstat (limited to 'vendor/libghostty-vt/src/cli')
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; +} |
