diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/cli/boo.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/cli/boo.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/cli/boo.zig | 236 |
1 files changed, 236 insertions, 0 deletions
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); +} |
